diff --git a/README.md b/README.md
index f6b0848..ac9d511 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ apps/
packages/
cli logicsrc OpenSpec CLI
openontology OpenOntology reference engine (entities, claims, queries, change sets)
+ openprd OpenPRD reference implementation (numbered PRDs, lifecycle, task bridge)
logicsrc-mcp @profullstack/logicsrc-mcp standards MCP server
sdk SDK contract types and helpers
tui terminal UI
@@ -52,6 +53,24 @@ npm --workspace @profullstack/logicsrc-mcp run build
node packages/logicsrc-mcp/dist/index.js
```
+## OpenPRD
+
+[OpenPRD](docs/openprd.md) is a lightweight standard for product requirements documents: a repo
+keeps a numbered, committed collection under `prd/`, one Markdown file each, with front-matter, a
+fixed set of eight sections, and a lifecycle. `@logicsrc/openprd` implements it.
+
+```bash
+npm --workspace @logicsrc/cli run dev -- prd new "Expand the parked-domain service"
+npm --workspace @logicsrc/cli run dev -- prd validate ./prd --strict
+npm --workspace @logicsrc/cli run dev -- prd status 0001 Review
+npm --workspace @logicsrc/cli run dev -- prd tasks 0001 --priority P0
+```
+
+Conformance failures (filename, front-matter, id match, the eight sections in order) are errors;
+lint findings are warnings that `--strict` promotes. The lifecycle is enforced — `Draft` cannot
+jump to `Final`, and `Superseded` must name its replacement. `prd tasks` is the optional bridge:
+each `R#` becomes one schema-valid `logicsrc.task`.
+
## OpenOntology
[LogicSRC OpenOntology](docs/openontology.md) is an open contract for durable, source-backed domain
diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts
index 3b41e0e..60e4bda 100644
--- a/apps/logicsrc-web/src/app/sitemap.ts
+++ b/apps/logicsrc-web/src/app/sitemap.ts
@@ -17,6 +17,7 @@ const STATIC_ROUTES: Array<{
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
{ path: "/docs", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openontology", changeFrequency: "weekly", priority: 0.9 },
+ { path: "/docs/openprd", changeFrequency: "weekly", priority: 0.8 },
{ path: "/openspec", changeFrequency: "weekly", priority: 0.8 },
{ path: "/agent-swarm", changeFrequency: "weekly", priority: 0.8 },
{ path: "/agentbyte", changeFrequency: "weekly", priority: 0.8 },
diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx
index ff97ffe..0885822 100644
--- a/apps/logicsrc-web/src/components/site-shell.tsx
+++ b/apps/logicsrc-web/src/components/site-shell.tsx
@@ -9,6 +9,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
{ href: "/agentbyte", label: "AgentByte" },
{ href: "/credential-sharing", label: "Credentials" },
{ href: "/openontology", label: "OpenOntology" },
+ { href: "/docs/openprd", label: "OpenPRD" },
{ href: "/#cli", label: "CLI" },
{ href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" },
diff --git a/apps/logicsrc-web/src/lib/docs.ts b/apps/logicsrc-web/src/lib/docs.ts
index 4e906ef..924958e 100644
--- a/apps/logicsrc-web/src/lib/docs.ts
+++ b/apps/logicsrc-web/src/lib/docs.ts
@@ -8,6 +8,7 @@ const DOCS_DIR = resolve(process.cwd(), "../../docs");
// Curated, public-facing reference docs. Internal notes (roadmap, positioning,
// arcade) are intentionally excluded.
export const DOC_SLUGS = [
+ "openprd",
"openontology",
"openontology-governance",
"openontology-interoperability",
diff --git a/apps/logicsrc-web/src/lib/page-markup.ts b/apps/logicsrc-web/src/lib/page-markup.ts
index 1c7c49f..d148c8b 100644
--- a/apps/logicsrc-web/src/lib/page-markup.ts
+++ b/apps/logicsrc-web/src/lib/page-markup.ts
@@ -126,6 +126,7 @@ export function renderPageMarkup(): string {
AgentByte
Credentials
OpenOntology
+ OpenPRD
CLI
Docs
Blog
diff --git a/docs/openprd.md b/docs/openprd.md
index 2f114be..bd2e99c 100644
--- a/docs/openprd.md
+++ b/docs/openprd.md
@@ -4,7 +4,7 @@ OpenPRD is a lightweight, open standard for **product requirements documents** a
It borrows the shape of a BIP/EIP/DIP process: a repo keeps a **numbered, committed collection** of PRDs under `prd/`, each one a single Markdown file with a fixed set of sections and a lifecycle. Where [OpenSpec](./openspec-comparison.md) models a *change* as a multi-file bundle, OpenPRD models a *product decision* as **one numbered file** you can read a year from now to recover the *why*.
-Tools such as the moshcode CLI consume this standard to publish PRDs into whatever repo you're working in.
+Tools such as the moshcode CLI consume this standard to publish PRDs into whatever repo you're working in. LogicSRC ships its own reference implementation — see [Implementation](#implementation).
## When to write one
@@ -87,6 +87,54 @@ See [`0000-template.md`](./openprd/0000-template.md) for the copy-paste template
OpenPRD is intentionally decoupled from the rest of LogicSRC: a PRD is just a file and needs no service to exist. When coordination is wanted, a PRD's `Requirements` map cleanly onto LogicSRC `task` documents (each `R#` → one task), and `owner`/`repo` reuse LogicSRC identity and repo conventions. That bridge is optional and lives in tooling, not in this standard.
+## Implementation
+
+`@logicsrc/openprd` is the reference implementation, exposed through the LogicSRC CLI. A PRD is
+still just a file: nothing below is required for a document to conform.
+
+```bash
+logicsrc prd init # create prd/ with the template and an index
+logicsrc prd new "Expand the service" # next free number, filled front-matter, eight stub sections
+logicsrc prd list # id, title, status, tags, requirement count
+logicsrc prd show 0001 # front-matter, sections, and parsed requirements
+logicsrc prd validate --strict # conformance + lint, exit 1 on error
+logicsrc prd index --write # regenerate prd/README.md from what is on disk
+logicsrc prd status 0001 Review # lifecycle move, refusing illegal transitions
+logicsrc prd tasks 0001 # the optional LogicSRC task bridge
+```
+
+Validation separates the four conformance rules below from lint. Conformance failures are errors;
+everything else — an empty section, a requirement with no priority tag, numbering that skips, a
+stale index, a one-sided supersession link — is a warning or a note, and `--strict` promotes them.
+Findings carry stable codes (`OP-C-SECTION-ORDER`, `OP-L-REQ-DUPLICATE`, …), the file, the line,
+and a remediation hint. Exit codes: `0` ok, `1` invalid, `2` usage, `3` not found.
+
+The lifecycle is enforced rather than advisory: `Draft` cannot jump to `Final`, terminal statuses
+do not resume, and moving to `Superseded` requires naming the PRD that replaces it.
+
+Requirement numbering, id uniqueness, and "no gaps" are checked across the whole collection, not
+just per file — a repo with `0001` and `0003` and no `0002` fails.
+
+### Task bridge
+
+The optional mapping described above lives in tooling:
+
+```bash
+logicsrc prd tasks 0001 --priority P0 --format ndjson
+```
+
+Each `R#` becomes one `logicsrc.task` document, validated against `logicsrc-task.schema.json`
+before it is emitted. The board defaults to `/prd/`, `repo` carries over to `github_repo`, and
+the creator DID is derived from the first author (`anthony@profullstack.com` →
+`anthony.profullstack`) unless `--creator` says otherwise.
+
+### Conformance bundle
+
+`packages/schemas/fixtures/openprd/` holds fixtures a third-party implementation can run:
+`conformance.json` lists documents that must validate and documents that must fail, each with the
+error code it must produce. Because several rules depend on the filename, each fixture records the
+name it must be validated as.
+
## Conformance
A document conforms to OpenPRD `0.2` when:
diff --git a/package-lock.json b/package-lock.json
index b1cfd99..9be9042 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1908,6 +1908,10 @@
"resolved": "packages/openontology",
"link": true
},
+ "node_modules/@logicsrc/openprd": {
+ "resolved": "packages/openprd",
+ "link": true
+ },
"node_modules/@logicsrc/plugin-agentgit": {
"resolved": "plugins/agentgit",
"link": true
@@ -7561,6 +7565,7 @@
"dependencies": {
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/openontology": "file:../openontology",
+ "@logicsrc/openprd": "file:../openprd",
"@logicsrc/plugin-coinpay": "file:../../plugins/coinpay",
"@logicsrc/plugin-core": "file:../plugin-core",
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
@@ -7607,6 +7612,18 @@
"vitest": "^4.0.8"
}
},
+ "packages/openprd": {
+ "name": "@logicsrc/openprd",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@logicsrc/validators": "file:../validators",
+ "yaml": "^2.8.1"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.8"
+ }
+ },
"packages/plugin-core": {
"name": "@logicsrc/plugin-core",
"version": "0.1.0",
diff --git a/package.json b/package.json
index 9ce2cdd..b1ae596 100644
--- a/package.json
+++ b/package.json
@@ -12,14 +12,14 @@
"apps/*"
],
"scripts": {
- "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
+ "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
"start": "npm --workspace @logicsrc/web run start",
"test": "npm run test --workspaces --if-present",
"check": "npm run build && npm run test",
"schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures",
"test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract",
"test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e",
- "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build"
+ "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build"
},
"devDependencies": {
"@types/node": "^24.10.1",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 6c89b32..c9e5d96 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/openontology": "file:../openontology",
+ "@logicsrc/openprd": "file:../openprd",
"@logicsrc/plugin-coinpay": "file:../../plugins/coinpay",
"@logicsrc/plugin-core": "file:../plugin-core",
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 423a3ab..87c9f90 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -28,6 +28,7 @@ import { print, type OutputFormat } from "./format.js";
import { parsePositiveInteger } from "./numeric-options.js";
import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js";
import { registerOntologyCommands } from "./ontology.js";
+import { registerPrdCommands } from "./prd.js";
import { defaultPluginRegistry } from "./registry.js";
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
@@ -831,6 +832,7 @@ async function runYoloArcade(game: string, repo?: string) {
}
registerOntologyCommands(program);
+registerPrdCommands(program);
program.parseAsync(process.argv).catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
diff --git a/packages/cli/src/prd.ts b/packages/cli/src/prd.ts
new file mode 100644
index 0000000..c87fded
--- /dev/null
+++ b/packages/cli/src/prd.ts
@@ -0,0 +1,361 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { Command } from "commander";
+import { stringify as toYaml } from "yaml";
+import {
+ checkTransition,
+ createPrd,
+ findPrd,
+ initPrdCollection,
+ loadPrdCollection,
+ nextPrdNumber,
+ nextStatuses,
+ prdToTasks,
+ renderDocument,
+ renderIndex,
+ renderReport,
+ reportFor,
+ rewriteFrontMatter,
+ summarize,
+ validatePrdCollection,
+ validateTasks,
+ writeIndex,
+ type PrdDocument,
+ type PrdStatus,
+ type Priority,
+ type ReportFormat
+} from "@logicsrc/openprd";
+
+/** Stable exit codes for CI: 0 ok · 1 invalid · 2 usage · 3 not found. */
+export const PRD_EXIT = { ok: 0, invalid: 1, usage: 2, notFound: 3 } as const;
+
+type Format = "table" | "json" | "yaml" | "markdown" | "ndjson";
+
+const DEFAULT_DIR = "./prd";
+
+function fail(message: string, code: number): never {
+ console.error(message);
+ process.exit(code);
+}
+
+function emit(data: unknown, format: Format): void {
+ switch (format) {
+ case "json":
+ console.log(JSON.stringify(data, null, 2));
+ return;
+ case "yaml":
+ console.log(toYaml(data).trimEnd());
+ return;
+ case "ndjson":
+ for (const row of Array.isArray(data) ? data : [data]) console.log(JSON.stringify(row));
+ return;
+ case "markdown": {
+ const rows = (Array.isArray(data) ? data : [data]) as Array>;
+ if (rows.length === 0) {
+ console.log("_No rows._");
+ return;
+ }
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
+ console.log(`| ${columns.join(" | ")} |`);
+ console.log(`| ${columns.map(() => "---").join(" | ")} |`);
+ for (const row of rows) {
+ console.log(`| ${columns.map((c) => String(row[c] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`);
+ }
+ return;
+ }
+ default: {
+ const rows = Array.isArray(data) ? data : [data];
+ if (rows.length === 0) {
+ console.log("(no PRDs)");
+ return;
+ }
+ console.table(rows);
+ }
+ }
+}
+
+function open(dir: string) {
+ try {
+ return loadPrdCollection(resolve(dir));
+ } catch (error) {
+ return fail((error as Error).message, PRD_EXIT.usage);
+ }
+}
+
+function mustFind(dir: string, ref: string): { doc: PrdDocument; dir: string } {
+ const collection = open(dir);
+ const doc = findPrd(collection, ref);
+ if (!doc) {
+ fail(
+ `No PRD matching "${ref}" in ${collection.dir}. Known ids: ${
+ collection.documents.map((d) => d.frontMatter.id ?? d.filePrefix).join(", ") || "(none)"
+ }`,
+ PRD_EXIT.notFound
+ );
+ }
+ return { doc, dir: collection.dir };
+}
+
+function splitList(value: string | undefined): string[] | undefined {
+ const items = value
+ ?.split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ return items && items.length > 0 ? items : undefined;
+}
+
+export function registerPrdCommands(program: Command): void {
+ const prd = program
+ .command("prd")
+ .description("OpenPRD: numbered product requirements documents with a fixed shape and lifecycle.");
+
+ prd
+ .command("init")
+ .argument("[dir]", "collection directory", DEFAULT_DIR)
+ .option("--title ", "heading for the generated index")
+ .description("Create a prd/ collection with the OpenPRD template and an index.")
+ .action((dir: string, options) => {
+ const result = initPrdCollection(resolve(dir), { title: options.title });
+ for (const file of result.created) console.log(`Created ${result.dir}/${file}`);
+ for (const file of result.skipped) console.log(`Kept existing ${result.dir}/${file}`);
+ console.log(`\nNext: logicsrc prd new "Short imperative title"`);
+ });
+
+ prd
+ .command("new")
+ .argument("", "imperative title — start with a verb")
+ .option("--dir ", "collection directory", DEFAULT_DIR)
+ .option("--author ", "comma-separated authors")
+ .option("--status ", "initial status", "Draft")
+ .option("--repo ", "target repository")
+ .option("--tags ", "comma-separated tags")
+ .option("--owner ", "accountable owner")
+ .option("--discussion ", "URL of the discussion thread")
+ .option("--supersedes ", "four-digit id this PRD replaces")
+ .description("Create the next numbered PRD from the template.")
+ .action((title: string, options) => {
+ try {
+ const result = createPrd(resolve(options.dir), {
+ title,
+ authors: splitList(options.author),
+ status: options.status as PrdStatus,
+ repo: options.repo,
+ tags: splitList(options.tags),
+ owner: options.owner,
+ discussion: options.discussion,
+ supersedes: options.supersedes
+ });
+ writeIndex(resolve(options.dir));
+ console.log(`Created ${result.path}`);
+ console.log(`Assigned id ${result.id}. Index updated.`);
+ console.log(`\nNext: fill in the eight sections, then logicsrc prd validate ${options.dir}`);
+ } catch (error) {
+ fail((error as Error).message, PRD_EXIT.usage);
+ }
+ });
+
+ prd
+ .command("list")
+ .option("--dir ", "collection directory", DEFAULT_DIR)
+ .option("--status ", "comma-separated statuses to include")
+ .option("--tag ", "only PRDs carrying this tag")
+ .option("--format ", "table, json, yaml, markdown, or ndjson", "table")
+ .description("List the PRDs in a collection.")
+ .action((options) => {
+ const collection = open(options.dir);
+ const statuses = splitList(options.status);
+ const rows = collection.documents
+ .map(summarize)
+ .filter((row) => !statuses || statuses.includes(row.status))
+ .filter((row) => !options.tag || row.tags.split(", ").includes(options.tag));
+ emit(rows, options.format as Format);
+ });
+
+ prd
+ .command("show")
+ .argument("[", "id, number, slug, or filename")
+ .option("--dir ", "collection directory", DEFAULT_DIR)
+ .option("--format ", "text, json, yaml, or markdown", "text")
+ .description("Show one PRD: front-matter, sections, and requirements.")
+ .action((ref: string, options) => {
+ const { doc } = mustFind(options.dir, ref);
+ console.log(renderDocument(doc, options.format as "text" | "json" | "yaml" | "markdown"));
+ });
+
+ prd
+ .command("validate")
+ .argument("[dir]", "collection directory", DEFAULT_DIR)
+ .option("--strict", "treat lint warnings as errors")
+ .option("--format ", "text, json, yaml, or markdown", "text")
+ .option("--id ][", "validate a single PRD instead of the collection")
+ .description("Check conformance: filename, front-matter, id match, and the eight sections.")
+ .action((dir: string, options) => {
+ if (options.id) {
+ const { doc } = mustFind(dir, options.id);
+ const report = reportFor(doc, { strict: options.strict === true });
+ console.log(renderReport(report, options.format as ReportFormat));
+ if (!report.ok) process.exit(PRD_EXIT.invalid);
+ return;
+ }
+
+ const collection = open(dir);
+ const report = validatePrdCollection(collection, {
+ strict: options.strict === true,
+ expectedIndex: renderIndex(collection)
+ });
+ console.log(renderReport(report, options.format as ReportFormat));
+ if (!report.ok) process.exit(PRD_EXIT.invalid);
+ });
+
+ prd
+ .command("lint")
+ .argument("[dir]", "collection directory", DEFAULT_DIR)
+ .option("--format ", "text, json, yaml, or markdown", "text")
+ .description("Report warnings and suggestions without failing on them.")
+ .action((dir: string, options) => {
+ const collection = open(dir);
+ const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
+ const advisory = {
+ ...report,
+ ok: true,
+ findings: report.findings.filter((finding) => finding.severity !== "error")
+ };
+ console.log(renderReport(advisory, options.format as ReportFormat));
+ });
+
+ prd
+ .command("index")
+ .argument("[dir]", "collection directory", DEFAULT_DIR)
+ .option("--write", "write prd/README.md instead of printing it")
+ .option("--title ", "heading for the index")
+ .description("Generate the collection index from the PRDs on disk.")
+ .action((dir: string, options) => {
+ if (!options.write) {
+ console.log(renderIndex(open(dir), { title: options.title }));
+ return;
+ }
+ const result = writeIndex(resolve(dir), { title: options.title });
+ console.log(result.changed ? `Wrote ${result.path}` : `${result.path} already up to date`);
+ });
+
+ prd
+ .command("status")
+ .argument("][", "id, number, slug, or filename")
+ .argument("[status]", "new status; omit to list the allowed next steps")
+ .option("--dir ", "collection directory", DEFAULT_DIR)
+ .option("--superseded-by ", "required when moving to Superseded")
+ .option("--dry-run", "show what would change without writing")
+ .description("Move a PRD through the lifecycle, enforcing the allowed transitions.")
+ .action((ref: string, status: string | undefined, options) => {
+ const { doc } = mustFind(options.dir, ref);
+ const from = doc.frontMatter.status;
+
+ if (!status) {
+ const allowed = nextStatuses(from);
+ console.log(`${doc.file} is ${from}.`);
+ console.log(allowed.length ? `Allowed next: ${allowed.join(", ")}` : "This status is terminal.");
+ return;
+ }
+
+ const check = checkTransition(from, status as PrdStatus, {
+ supersededBy: options.supersededBy ?? doc.frontMatter["superseded-by"]
+ });
+ if (!check.ok) {
+ fail(`Cannot move ${doc.file} from ${from} to ${status}: ${check.reason}`, PRD_EXIT.usage);
+ }
+
+ const today = new Date().toISOString().slice(0, 10);
+ const updates: Record = { status, updated: today };
+ if (options.supersededBy) updates["superseded-by"] = options.supersededBy;
+
+ if (options.dryRun) {
+ console.log(`${doc.file}: ${from} → ${status} (updated: ${today})`);
+ if (options.supersededBy) console.log(` superseded-by: ${options.supersededBy}`);
+ console.log("(dry run; nothing written)");
+ return;
+ }
+
+ const source = readFileSync(doc.path, "utf8");
+ writeFileSync(doc.path, rewriteFrontMatter(source, updates), "utf8");
+ writeIndex(resolve(options.dir));
+ console.log(`${doc.file}: ${from} → ${status}`);
+ });
+
+ prd
+ .command("next")
+ .argument("[dir]", "collection directory", DEFAULT_DIR)
+ .description("Print the next free four-digit id.")
+ .action((dir: string) => {
+ console.log(nextPrdNumber(open(dir)));
+ });
+
+ prd
+ .command("tasks")
+ .argument("][", "id, number, slug, or filename")
+ .option("--dir ", "collection directory", DEFAULT_DIR)
+ .option("--creator ", "LogicSRC DID for the created tasks")
+ .option("--board ", "board path, e.g. /prd/0001")
+ .option("--priority ]", "only these priorities, e.g. P0,P1")
+ .option("--format ", "json, ndjson, yaml, or table", "json")
+ .description("Map each requirement onto a LogicSRC task document (the optional bridge).")
+ .action((ref: string, options) => {
+ const { doc } = mustFind(options.dir, ref);
+ const { tasks, skipped } = prdToTasks(doc, {
+ creator: options.creator,
+ board: options.board,
+ priorities: splitList(options.priority) as Priority[] | undefined
+ });
+
+ const problems = validateTasks(tasks);
+ if (problems.length > 0) {
+ for (const problem of problems) {
+ console.error(`task ${problem.index} is not a valid logicsrc.task: ${problem.errors.join("; ")}`);
+ }
+ process.exit(PRD_EXIT.invalid);
+ }
+
+ if (options.format === "table") {
+ emit(
+ tasks.map((task) => ({ title: task.title, board: task.board, creator: task.creator_did })),
+ "table"
+ );
+ } else {
+ emit(tasks, options.format as Format);
+ }
+
+ for (const entry of skipped) {
+ console.error(`skipped ${entry.requirement}: ${entry.reason}`);
+ }
+ });
+
+ prd
+ .command("export")
+ .argument("[dir]", "collection directory", DEFAULT_DIR)
+ .option("--format ", "json, ndjson, yaml, or markdown", "json")
+ .option("--out ", "write to a file instead of stdout")
+ .description("Export the parsed collection for other tools.")
+ .action((dir: string, options) => {
+ const collection = open(dir);
+ const payload = collection.documents.map((doc) => ({
+ id: doc.frontMatter.id ?? doc.filePrefix,
+ file: doc.file,
+ frontMatter: doc.frontMatter,
+ sections: doc.sections.map((section) => ({ name: section.name, empty: section.empty })),
+ requirements: doc.requirements
+ }));
+
+ if (options.out) {
+ const text =
+ options.format === "ndjson"
+ ? `${payload.map((row) => JSON.stringify(row)).join("\n")}\n`
+ : options.format === "yaml"
+ ? `${toYaml(payload)}`
+ : `${JSON.stringify(payload, null, 2)}\n`;
+ writeFileSync(resolve(options.out), text, "utf8");
+ console.log(`Wrote ${resolve(options.out)}`);
+ return;
+ }
+
+ emit(payload, options.format as Format);
+ });
+}
diff --git a/packages/openprd/package.json b/packages/openprd/package.json
new file mode 100644
index 0000000..ae6a6be
--- /dev/null
+++ b/packages/openprd/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@logicsrc/openprd",
+ "version": "0.1.0",
+ "description": "Reference implementation of the OpenPRD standard: numbered product requirements documents with front-matter, fixed sections, a lifecycle, and a LogicSRC task bridge.",
+ "license": "MIT",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": "./dist/index.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/profullstack/logicsrc.git",
+ "directory": "packages/openprd"
+ },
+ "homepage": "https://logicsrc.com/docs/openprd",
+ "keywords": ["logicsrc", "openprd", "prd", "product-requirements", "standards", "cli"],
+ "publishConfig": { "access": "public" },
+ "files": ["dist"],
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "test": "vitest run src"
+ },
+ "dependencies": {
+ "@logicsrc/validators": "file:../validators",
+ "yaml": "^2.8.1"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.8"
+ }
+}
diff --git a/packages/openprd/src/collection.ts b/packages/openprd/src/collection.ts
new file mode 100644
index 0000000..f9fd562
--- /dev/null
+++ b/packages/openprd/src/collection.ts
@@ -0,0 +1,136 @@
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { formatId, parsePrd } from "./parse.js";
+import type { PrdCollection, PrdDocument, PrdStatus } from "./types.js";
+
+const TEMPLATE_FILE = "0000-template.md";
+const INDEX_FILE = "README.md";
+
+export class PrdCollectionError extends Error {
+ readonly code = "OP-L-COLLECTION";
+ constructor(message: string) {
+ super(message);
+ this.name = "PrdCollectionError";
+ }
+}
+
+/** Load every `NNNN-*.md` in a `prd/` directory, plus the template and index. */
+export function loadPrdCollection(dir: string): PrdCollection {
+ const base = resolve(dir);
+ if (!existsSync(base)) {
+ throw new PrdCollectionError(`No PRD collection at ${base} — run \`logicsrc prd init\` first`);
+ }
+
+ const files = readdirSync(base)
+ .filter((file) => file.endsWith(".md") && file !== INDEX_FILE)
+ .sort();
+
+ const documents: PrdDocument[] = [];
+ const unparsed: PrdCollection["unparsed"] = [];
+ let template: PrdDocument | null = null;
+
+ for (const file of files) {
+ const path = join(base, file);
+ try {
+ const doc = parsePrd(readFileSync(path, "utf8"), path);
+ if (file === TEMPLATE_FILE) template = doc;
+ else documents.push(doc);
+ } catch (error) {
+ unparsed.push({ file, reason: (error as Error).message });
+ }
+ }
+
+ documents.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
+
+ const indexPath = join(base, INDEX_FILE);
+ return {
+ dir: base,
+ template,
+ documents,
+ unparsed,
+ indexRaw: existsSync(indexPath) ? readFileSync(indexPath, "utf8") : null
+ };
+}
+
+/** The next free number: highest existing + 1, never reserved in advance. */
+export function nextPrdNumber(collection: PrdCollection): string {
+ const highest = collection.documents.reduce((max, doc) => {
+ const n = Number.parseInt(doc.filePrefix ?? "", 10);
+ return Number.isInteger(n) ? Math.max(max, n) : max;
+ }, 0);
+ return formatId(highest + 1);
+}
+
+export function findPrd(collection: PrdCollection, ref: string): PrdDocument | undefined {
+ const normalized = /^\d+$/.test(ref) ? formatId(Number.parseInt(ref, 10)) : ref;
+ return collection.documents.find(
+ (doc) =>
+ doc.frontMatter.id === normalized ||
+ doc.filePrefix === normalized ||
+ doc.file === ref ||
+ doc.slug === ref
+ );
+}
+
+export interface PrdSummary {
+ id: string;
+ title: string;
+ status: PrdStatus | string;
+ file: string;
+ authors: string;
+ tags: string;
+ requirements: number;
+ updated: string;
+}
+
+export function summarize(doc: PrdDocument): PrdSummary {
+ const fm = doc.frontMatter;
+ return {
+ id: fm.id ?? doc.filePrefix ?? "????",
+ title: fm.title ?? "(untitled)",
+ status: fm.status ?? "(none)",
+ file: doc.file,
+ authors: (fm.authors ?? []).join(", "),
+ tags: (fm.tags ?? []).join(", "),
+ requirements: doc.requirements.length,
+ updated: fm.updated ?? fm.created ?? ""
+ };
+}
+
+/**
+ * Render the `prd/README.md` index the standard calls for. Deterministic, so
+ * `prd index` is idempotent and CI can diff it.
+ */
+export function renderIndex(collection: PrdCollection, options: { title?: string } = {}): string {
+ const rows = collection.documents.map(summarize);
+ const lines = [
+ `# ${options.title ?? "LogicSRC PRDs"}`,
+ "",
+ "Numbered [OpenPRD](../docs/openprd.md) product requirements documents for this repo. One file",
+ "per PRD at `prd/-.md`, four-digit ids, no gaps. `0000-template.md` is the copy-paste",
+ "starting point.",
+ "",
+ "Status lives in each file's front-matter and is the source of truth:",
+ "`Draft → Review → Accepted → Final`, or `Rejected` / `Withdrawn` / `Superseded`.",
+ "",
+ "",
+ "",
+ "| ID | Title | Status | Tags |",
+ "| --- | --- | --- | --- |"
+ ];
+
+ if (rows.length === 0) {
+ lines.push("| — | _No PRDs yet. Run `logicsrc prd new \"Title\"`._ | — | — |");
+ }
+
+ for (const row of rows) {
+ const escape = (value: string) => value.replace(/\|/g, "\\|");
+ lines.push(
+ `| [${row.id}](./${row.file}) | ${escape(row.title)} | ${row.status} | ${escape(row.tags)} |`
+ );
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+export { TEMPLATE_FILE, INDEX_FILE };
diff --git a/packages/openprd/src/index.ts b/packages/openprd/src/index.ts
new file mode 100644
index 0000000..ba59d79
--- /dev/null
+++ b/packages/openprd/src/index.ts
@@ -0,0 +1,67 @@
+/**
+ * @logicsrc/openprd — reference implementation of the OpenPRD standard.
+ *
+ * The standard is docs/openprd.md plus `openprd-prd.schema.json`; this package
+ * implements it. A PRD is just a Markdown file with front-matter and eight
+ * sections — it needs no service to exist, and none of this code to be valid.
+ */
+
+export { OPENPRD_VERSION, SECTIONS, STATUSES } from "./types.js";
+export type * from "./types.js";
+
+export {
+ formatId,
+ parsePrd,
+ rewriteFrontMatter,
+ slugify,
+ PrdParseError
+} from "./parse.js";
+
+export {
+ canTransition,
+ checkTransition,
+ isActive,
+ nextStatuses,
+ TRANSITIONS,
+ type TransitionCheck
+} from "./lifecycle.js";
+
+export {
+ reportFor,
+ validatePrdCollection,
+ validatePrdDocument,
+ type ValidateOptions
+} from "./validate.js";
+
+export {
+ findPrd,
+ loadPrdCollection,
+ nextPrdNumber,
+ renderIndex,
+ summarize,
+ INDEX_FILE,
+ TEMPLATE_FILE,
+ PrdCollectionError,
+ type PrdSummary
+} from "./collection.js";
+
+export {
+ createPrd,
+ initPrdCollection,
+ writeIndex,
+ TEMPLATE,
+ type CreateOptions,
+ type CreateResult,
+ type InitResult
+} from "./scaffold.js";
+
+export {
+ deriveCreatorDid,
+ prdToTasks,
+ validateTasks,
+ type TaskDocument,
+ type ToTasksOptions,
+ type ToTasksResult
+} from "./tasks.js";
+
+export { renderDocument, renderReport, type ReportFormat } from "./render.js";
diff --git a/packages/openprd/src/lifecycle.ts b/packages/openprd/src/lifecycle.ts
new file mode 100644
index 0000000..649bf2f
--- /dev/null
+++ b/packages/openprd/src/lifecycle.ts
@@ -0,0 +1,71 @@
+import type { PrdStatus } from "./types.js";
+
+/**
+ * The lifecycle from docs/openprd.md:
+ *
+ * Draft → Review → Accepted → Final
+ * ↘ Rejected
+ * ↘ Withdrawn
+ * ↘ Superseded by NNNN
+ *
+ * Rejected, Withdrawn, and Superseded are terminal — the standard keeps them
+ * on disk because the *why* is part of the record, not because they resume.
+ * A Final PRD can still be superseded by a follow-up.
+ */
+export const TRANSITIONS: Record = {
+ Draft: ["Review", "Withdrawn"],
+ Review: ["Accepted", "Rejected", "Withdrawn", "Draft"],
+ Accepted: ["Final", "Superseded", "Withdrawn"],
+ Final: ["Superseded"],
+ Rejected: [],
+ Withdrawn: [],
+ Superseded: []
+};
+
+export function nextStatuses(from: PrdStatus): PrdStatus[] {
+ return TRANSITIONS[from] ?? [];
+}
+
+export function canTransition(from: PrdStatus, to: PrdStatus): boolean {
+ return nextStatuses(from).includes(to);
+}
+
+export interface TransitionCheck {
+ ok: boolean;
+ reason?: string;
+ /** Front-matter keys the transition requires alongside `status`. */
+ requires: string[];
+}
+
+export function checkTransition(
+ from: PrdStatus,
+ to: PrdStatus,
+ options: { supersededBy?: string | null } = {}
+): TransitionCheck {
+ if (from === to) {
+ return { ok: false, reason: `PRD is already ${to}`, requires: [] };
+ }
+ if (!canTransition(from, to)) {
+ const allowed = nextStatuses(from);
+ return {
+ ok: false,
+ reason: allowed.length
+ ? `${from} may only move to ${allowed.join(", ")}`
+ : `${from} is terminal; open a follow-up PRD instead`,
+ requires: []
+ };
+ }
+ if (to === "Superseded" && !options.supersededBy) {
+ return {
+ ok: false,
+ reason: "Superseded requires the id of the PRD that replaces this one",
+ requires: ["superseded-by"]
+ };
+ }
+ return { ok: true, requires: to === "Superseded" ? ["superseded-by"] : [] };
+}
+
+/** Statuses whose PRDs are still open work rather than historical record. */
+export function isActive(status: PrdStatus): boolean {
+ return status === "Draft" || status === "Review" || status === "Accepted";
+}
diff --git a/packages/openprd/src/parse.test.ts b/packages/openprd/src/parse.test.ts
new file mode 100644
index 0000000..0cb7b08
--- /dev/null
+++ b/packages/openprd/src/parse.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from "vitest";
+import { formatId, parsePrd, PrdParseError, rewriteFrontMatter, slugify } from "./parse.js";
+import { SECTIONS } from "./types.js";
+
+const MINIMAL = `---
+openprd: "0.2"
+id: "0007"
+title: Do the thing
+status: Draft
+authors:
+ - a@example.com
+---
+
+# Do the thing
+
+## Problem
+
+Something hurts.
+
+## Goals
+
+Make it stop.
+
+## Non-Goals
+
+_None._
+
+## Users
+
+Everyone.
+
+## Requirements
+
+- R1 [P0] First capability.
+- R2 [P1] Second capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+It stops hurting.
+
+## Risks & Open Questions
+
+- Might not stop.
+`;
+
+describe("parsePrd", () => {
+ const doc = parsePrd(MINIMAL, "/repo/prd/0007-do-the-thing.md");
+
+ it("splits front-matter from body and parses the YAML", () => {
+ expect(doc.frontMatter.id).toBe("0007");
+ expect(doc.frontMatter.title).toBe("Do the thing");
+ expect(doc.frontMatter.authors).toEqual(["a@example.com"]);
+ expect(doc.body.startsWith("\n# Do the thing")).toBe(true);
+ });
+
+ it("derives the id prefix and slug from the filename", () => {
+ expect(doc.filePrefix).toBe("0007");
+ expect(doc.slug).toBe("do-the-thing");
+ expect(doc.file).toBe("0007-do-the-thing.md");
+ });
+
+ it("finds all eight sections in order", () => {
+ expect(doc.sections.map((s) => s.name)).toEqual([...SECTIONS]);
+ });
+
+ it("captures the H1 heading separately from the sections", () => {
+ expect(doc.heading).toBe("Do the thing");
+ });
+
+ it("parses requirements with ids, priorities, and line numbers", () => {
+ expect(doc.requirements).toHaveLength(2);
+ expect(doc.requirements[0]).toMatchObject({ id: "R1", number: 1, priority: "P0", text: "First capability." });
+ expect(doc.requirements[1]?.priority).toBe("P1");
+ expect(doc.requirements[0]?.line).toBeGreaterThan(1);
+ });
+
+ it("rejects a file with no front-matter", () => {
+ expect(() => parsePrd("# Just markdown\n", "x.md")).toThrow(PrdParseError);
+ });
+
+ it("rejects front-matter that is not a mapping", () => {
+ expect(() => parsePrd("---\n- a\n- b\n---\n\n## Problem\n", "x.md")).toThrow(/mapping/);
+ });
+
+ it("reports invalid YAML rather than silently continuing", () => {
+ expect(() => parsePrd('---\ntitle: "unterminated\n---\n\nbody\n', "x.md")).toThrow(/not valid YAML/);
+ });
+
+ it("treats ### as content, not as a section boundary", () => {
+ const withSub = MINIMAL.replace(
+ "## Requirements\n",
+ "## Requirements\n\n### Product identity\n\nSome prose.\n"
+ );
+ const parsed = parsePrd(withSub, "0007-do-the-thing.md");
+ expect(parsed.sections.map((s) => s.name)).toEqual([...SECTIONS]);
+ expect(parsed.sections.find((s) => s.name === "Requirements")?.content).toContain("### Product identity");
+ });
+
+ it("ignores headings and requirement-shaped lines inside code fences", () => {
+ const withFence = MINIMAL.replace(
+ "## UX Notes\n",
+ "## UX Notes\n\n```txt\n## Not A Section\n- R9 [P0] not a real requirement\n```\n"
+ );
+ const parsed = parsePrd(withFence, "0007-do-the-thing.md");
+ expect(parsed.sections.map((s) => s.name)).toEqual([...SECTIONS]);
+ expect(parsed.requirements.map((r) => r.id)).toEqual(["R1", "R2"]);
+ });
+
+ it("accepts the bold requirement style real PRDs use", () => {
+ const bold = MINIMAL.replace("- R1 [P0] First capability.", "- **R1 [P0]** First capability.");
+ const parsed = parsePrd(bold, "0007-do-the-thing.md");
+ expect(parsed.requirements[0]).toMatchObject({ id: "R1", priority: "P0", text: "First capability." });
+ });
+
+ it("still records a requirement that is missing its priority tag", () => {
+ const untagged = MINIMAL.replace("- R2 [P1] Second capability.", "- R2 Second capability.");
+ const parsed = parsePrd(untagged, "0007-do-the-thing.md");
+ expect(parsed.requirements[1]).toMatchObject({ id: "R2", priority: null });
+ });
+
+ it("marks an empty section as empty", () => {
+ const emptied = MINIMAL.replace("## UX Notes\n\n_None._\n", "## UX Notes\n\n");
+ const parsed = parsePrd(emptied, "0007-do-the-thing.md");
+ expect(parsed.sections.find((s) => s.name === "UX Notes")?.empty).toBe(true);
+ expect(parsed.sections.find((s) => s.name === "Problem")?.empty).toBe(false);
+ });
+
+ it("flags a malformed filename by leaving the prefix null", () => {
+ const parsed = parsePrd(MINIMAL, "notes.md");
+ expect(parsed.filePrefix).toBeNull();
+ expect(parsed.slug).toBeNull();
+ });
+});
+
+describe("slugify and formatId", () => {
+ it("kebab-cases a title", () => {
+ expect(slugify("Add the LogicSRC OpenOntology specification")).toBe(
+ "add-the-logicsrc-openontology-specification"
+ );
+ });
+
+ it("strips punctuation, accents, and repeated separators", () => {
+ expect(slugify("Ship “Café” — v2.0!")).toBe("ship-cafe-v2-0");
+ });
+
+ it("never leaves a trailing hyphen after truncation", () => {
+ const slug = slugify("a".repeat(80));
+ expect(slug.endsWith("-")).toBe(false);
+ expect(slug.length).toBeLessThanOrEqual(72);
+ });
+
+ it("zero-pads to four digits", () => {
+ expect(formatId(1)).toBe("0001");
+ expect(formatId(42)).toBe("0042");
+ });
+});
+
+describe("rewriteFrontMatter", () => {
+ it("updates a key in place and leaves the body byte-identical", () => {
+ const updated = rewriteFrontMatter(MINIMAL, { status: "Review" });
+ expect(updated).toContain("status: Review");
+ expect(updated.split("---\n")[2]).toBe(MINIMAL.split("---\n")[2]);
+ });
+
+ it("appends a key that was not present", () => {
+ const updated = rewriteFrontMatter(MINIMAL, { updated: "2026-07-28" });
+ expect(updated).toContain("updated: 2026-07-28");
+ });
+
+ it("blanks a key when given null, keeping the line", () => {
+ const withRepo = rewriteFrontMatter(MINIMAL, { repo: "owner/name" });
+ const cleared = rewriteFrontMatter(withRepo, { repo: null });
+ expect(cleared).toContain("repo:");
+ expect(cleared).not.toContain("owner/name");
+ });
+
+ it("leaves other keys untouched", () => {
+ const updated = rewriteFrontMatter(MINIMAL, { status: "Accepted" });
+ const doc = parsePrd(updated, "0007-do-the-thing.md");
+ expect(doc.frontMatter.title).toBe("Do the thing");
+ expect(doc.frontMatter.authors).toEqual(["a@example.com"]);
+ expect(doc.frontMatter.status).toBe("Accepted");
+ });
+});
diff --git a/packages/openprd/src/parse.ts b/packages/openprd/src/parse.ts
new file mode 100644
index 0000000..91037d1
--- /dev/null
+++ b/packages/openprd/src/parse.ts
@@ -0,0 +1,197 @@
+import { basename } from "node:path";
+import { parse as parseYaml } from "yaml";
+import type { PrdDocument, PrdFrontMatter, Requirement, Section } from "./types.js";
+
+export class PrdParseError extends Error {
+ readonly code = "OP-P-PARSE";
+ constructor(message: string, readonly file?: string) {
+ super(message);
+ this.name = "PrdParseError";
+ }
+}
+
+const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
+
+/** `0001-add-the-thing.md` → prefix `0001`, slug `add-the-thing`. */
+const FILE_NAME = /^(\d{4})-(.+)\.md$/;
+
+/**
+ * A requirement line. The standard writes them as `R1 [P0] …`; real PRDs also
+ * bold the marker (`**R1 [P0]**`) and bullet it. All three parse the same.
+ */
+const REQUIREMENT = /^\s*(?:[-*+]\s+)?\*{0,2}R(\d+)\*{0,2}\s*\*{0,2}\[(P[012])\]\*{0,2}\s*(.*)$/;
+
+/** A requirement marker with no priority tag — caught as a lint finding. */
+const REQUIREMENT_NO_PRIORITY = /^\s*(?:[-*+]\s+)?\*{0,2}R(\d+)\*{0,2}[.:)\s]+(?!\[P[012]\])(.*)$/;
+
+export function parsePrd(source: string, path: string): PrdDocument {
+ const file = basename(path);
+ const match = FRONT_MATTER.exec(source);
+ if (!match) {
+ throw new PrdParseError(
+ `${file} has no YAML front-matter block (expected the file to open with '---')`,
+ file
+ );
+ }
+
+ const [, frontMatterRaw, body] = match as unknown as [string, string, string];
+
+ let frontMatter: PrdFrontMatter;
+ try {
+ frontMatter = (parseYaml(frontMatterRaw) ?? {}) as PrdFrontMatter;
+ } catch (error) {
+ throw new PrdParseError(`${file} front-matter is not valid YAML — ${(error as Error).message}`, file);
+ }
+ if (typeof frontMatter !== "object" || Array.isArray(frontMatter)) {
+ throw new PrdParseError(`${file} front-matter must be a YAML mapping`, file);
+ }
+
+ const nameMatch = FILE_NAME.exec(file);
+ // Line 1 is `---`; the body starts after the closing delimiter.
+ const bodyStartLine = frontMatterRaw.split("\n").length + 3;
+
+ return {
+ path,
+ file,
+ filePrefix: nameMatch?.[1] ?? null,
+ slug: nameMatch?.[2] ?? null,
+ frontMatter,
+ frontMatterRaw,
+ body,
+ heading: findHeading(body),
+ sections: findSections(body, bodyStartLine),
+ requirements: findRequirements(body, bodyStartLine)
+ };
+}
+
+function findHeading(body: string): string | null {
+ for (const line of body.split("\n")) {
+ if (line.startsWith("# ")) return line.slice(2).trim();
+ if (line.startsWith("## ")) return null; // a section started first
+ }
+ return null;
+}
+
+/**
+ * Sections are `##` headings only. `###` and deeper are content, so a PRD can
+ * organize a long Requirements section without inventing new sections.
+ */
+function findSections(body: string, offset: number): Section[] {
+ const lines = body.split("\n");
+ const sections: Section[] = [];
+ let fenced = false;
+
+ lines.forEach((line, index) => {
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
+ if (fenced) return;
+
+ const heading = /^##\s+(.+?)\s*$/.exec(line);
+ if (!heading || line.startsWith("###")) return;
+
+ sections.push({
+ name: (heading[1] as string).trim(),
+ line: offset + index,
+ content: "",
+ empty: true
+ });
+ });
+
+ // Fill each section's content from its heading to the next one.
+ const headingIndexes = sections.map((section) => section.line - offset);
+ sections.forEach((section, i) => {
+ const from = (headingIndexes[i] as number) + 1;
+ const to = i + 1 < headingIndexes.length ? (headingIndexes[i + 1] as number) : lines.length;
+ const content = lines.slice(from, to).join("\n").trim();
+ section.content = content;
+ section.empty = content.length === 0;
+ });
+
+ return sections;
+}
+
+function findRequirements(body: string, offset: number): Requirement[] {
+ const requirements: Requirement[] = [];
+ let fenced = false;
+
+ body.split("\n").forEach((line, index) => {
+ if (/^\s*(```|~~~)/.test(line)) fenced = !fenced;
+ if (fenced) return;
+
+ const match = REQUIREMENT.exec(line);
+ if (match) {
+ requirements.push({
+ id: `R${match[1]}`,
+ number: Number.parseInt(match[1] as string, 10),
+ priority: match[2] as Requirement["priority"],
+ text: (match[3] as string).trim(),
+ line: offset + index
+ });
+ return;
+ }
+
+ const untagged = REQUIREMENT_NO_PRIORITY.exec(line);
+ if (untagged) {
+ requirements.push({
+ id: `R${untagged[1]}`,
+ number: Number.parseInt(untagged[1] as string, 10),
+ priority: null,
+ text: (untagged[2] as string).trim(),
+ line: offset + index
+ });
+ }
+ });
+
+ return requirements;
+}
+
+/** Kebab-case slug from a title, matching the filename convention. */
+export function slugify(title: string): string {
+ return title
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 72)
+ .replace(/-+$/g, "");
+}
+
+/** Four-digit, zero-padded id. */
+export function formatId(n: number): string {
+ return String(n).padStart(4, "0");
+}
+
+/**
+ * Rewrite a document's front-matter in place, preserving the body byte for
+ * byte. Only the keys given are touched; everything else keeps its position,
+ * comments, and formatting.
+ */
+export function rewriteFrontMatter(
+ source: string,
+ updates: Record
+): string {
+ const match = FRONT_MATTER.exec(source);
+ if (!match) throw new PrdParseError("Cannot rewrite front-matter: no block found");
+
+ const [, raw, body] = match as unknown as [string, string, string];
+ const lines = raw.split("\n");
+ const applied = new Set();
+
+ const rendered = lines.map((line) => {
+ const keyMatch = /^([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(line);
+ if (!keyMatch) return line;
+ const key = keyMatch[1] as string;
+ if (!(key in updates)) return line;
+ applied.add(key);
+ const value = updates[key];
+ return value === null || value === "" ? `${key}:` : `${key}: ${value}`;
+ });
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (applied.has(key)) continue;
+ if (value === null || value === "") continue;
+ rendered.push(`${key}: ${value}`);
+ }
+
+ return `---\n${rendered.join("\n")}\n---\n${body}`;
+}
diff --git a/packages/openprd/src/render.ts b/packages/openprd/src/render.ts
new file mode 100644
index 0000000..c5db250
--- /dev/null
+++ b/packages/openprd/src/render.ts
@@ -0,0 +1,89 @@
+import { stringify as toYaml } from "yaml";
+import type { PrdDocument, ValidationReport } from "./types.js";
+
+export type ReportFormat = "text" | "json" | "yaml" | "markdown";
+
+export function renderReport(report: ValidationReport, format: ReportFormat = "text"): string {
+ if (format === "json") return JSON.stringify(report, null, 2);
+ if (format === "yaml") return toYaml(report).trimEnd();
+
+ if (format === "markdown") {
+ const lines = [
+ `# OpenPRD validation ${report.ok ? "passed" : "failed"}`,
+ "",
+ `- documents: ${report.checked.documents}`,
+ `- requirements: ${report.checked.requirements}`,
+ `- errors: ${report.counts.error}`,
+ `- warnings: ${report.counts.warning}`,
+ `- info: ${report.counts.info}`,
+ ""
+ ];
+ if (report.findings.length > 0) {
+ lines.push("| severity | code | file | line | message |", "| --- | --- | --- | --- | --- |");
+ for (const f of report.findings) {
+ lines.push(
+ `| ${f.severity} | ${f.code} | ${f.file ?? ""} | ${f.line ?? ""} | ${f.message.replace(/\|/g, "\\|")} |`
+ );
+ }
+ }
+ return lines.join("\n");
+ }
+
+ const lines: string[] = [];
+ lines.push(` ✓ ${report.checked.documents} PRD${report.checked.documents === 1 ? "" : "s"}`);
+ lines.push(` ✓ ${report.checked.requirements} requirements`);
+
+ for (const f of report.findings) {
+ const mark = f.severity === "error" ? "✗" : f.severity === "warning" ? "!" : "·";
+ const where = [f.file, f.line ? `line ${f.line}` : null].filter(Boolean).join(":");
+ lines.push(` ${mark} [${f.severity}] ${f.code} ${where ? `${where} — ` : ""}${f.message}`);
+ if (f.hint) lines.push(` hint: ${f.hint}`);
+ }
+
+ lines.push(
+ report.ok
+ ? "OpenPRD collection is valid."
+ : `OpenPRD collection is INVALID (${report.counts.error} error(s), ${report.counts.warning} warning(s)).`
+ );
+ return lines.join("\n");
+}
+
+/** Human-readable single-document view for `logicsrc prd show`. */
+export function renderDocument(doc: PrdDocument, format: "text" | "json" | "yaml" | "markdown" = "text"): string {
+ const fm = doc.frontMatter;
+
+ if (format === "json") return JSON.stringify(doc, null, 2);
+ if (format === "yaml") return toYaml(doc).trimEnd();
+ if (format === "markdown") return `---\n${doc.frontMatterRaw}\n---\n${doc.body}`;
+
+ const lines = [
+ `${fm.id ?? doc.filePrefix} ${fm.title ?? "(untitled)"}`,
+ `status: ${fm.status}${fm["superseded-by"] ? ` (superseded by ${fm["superseded-by"]})` : ""}`,
+ `file: ${doc.file}`,
+ `authors: ${(fm.authors ?? []).join(", ") || "(none)"}`,
+ ...(fm.repo ? [`repo: ${fm.repo}`] : []),
+ ...(fm.tags?.length ? [`tags: ${fm.tags.join(", ")}`] : []),
+ ...(fm.created || fm.updated ? [`dates: created ${fm.created ?? "?"}, updated ${fm.updated ?? "?"}`] : []),
+ "",
+ "sections:"
+ ];
+
+ for (const section of doc.sections) {
+ lines.push(` ${section.empty ? "·" : "✓"} ${section.name}${section.empty ? " (empty)" : ""}`);
+ }
+
+ if (doc.requirements.length > 0) {
+ lines.push("", `requirements (${doc.requirements.length}):`);
+ for (const requirement of doc.requirements) {
+ const priority = requirement.priority ?? "--";
+ lines.push(` ${requirement.id.padEnd(5)} [${priority}] ${truncate(requirement.text, 90)}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+function truncate(value: string, max: number): string {
+ const plain = value.replace(/\*\*(.*?)\*\*/g, "$1").replace(/`([^`]*)`/g, "$1");
+ return plain.length <= max ? plain : `${plain.slice(0, max - 1)}…`;
+}
diff --git a/packages/openprd/src/scaffold.test.ts b/packages/openprd/src/scaffold.test.ts
new file mode 100644
index 0000000..8f14dec
--- /dev/null
+++ b/packages/openprd/src/scaffold.test.ts
@@ -0,0 +1,294 @@
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterAll, describe, expect, it } from "vitest";
+import { loadPrdCollection, nextPrdNumber, renderIndex } from "./collection.js";
+import { parsePrd } from "./parse.js";
+import { createPrd, initPrdCollection, TEMPLATE, writeIndex } from "./scaffold.js";
+import { deriveCreatorDid, prdToTasks, validateTasks } from "./tasks.js";
+import { validatePrdCollection, validatePrdDocument } from "./validate.js";
+import { SECTIONS } from "./types.js";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(HERE, "../../..");
+
+const dirs: string[] = [];
+afterAll(() => {
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
+});
+
+function scratch(): string {
+ const dir = mkdtempSync(join(tmpdir(), "openprd-scaffold-"));
+ dirs.push(dir);
+ return dir;
+}
+
+describe("init", () => {
+ it("creates a template and an index", () => {
+ const dir = scratch();
+ const result = initPrdCollection(dir);
+ expect(result.created.sort()).toEqual(["0000-template.md", "README.md"]);
+ expect(existsSync(join(dir, "0000-template.md"))).toBe(true);
+ });
+
+ it("is idempotent — a second run keeps what is already there", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ const second = initPrdCollection(dir);
+ expect(second.created).toEqual([]);
+ expect(second.skipped.sort()).toEqual(["0000-template.md", "README.md"]);
+ });
+
+ it("ships a template that itself conforms to the standard", () => {
+ const doc = parsePrd(TEMPLATE, "0000-template.md");
+ expect(doc.sections.map((s) => s.name)).toEqual([...SECTIONS]);
+ const errors = validatePrdDocument(doc).filter((f) => f.severity === "error");
+ expect(errors).toEqual([]);
+ });
+});
+
+describe("new", () => {
+ it("assigns the next free number and writes a conforming PRD", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+
+ const first = createPrd(dir, { title: "Do the thing", authors: ["a@example.com"], today: "2026-07-26" });
+ expect(first.id).toBe("0001");
+ expect(first.file).toBe("0001-do-the-thing.md");
+
+ const doc = parsePrd(readFileSync(first.path, "utf8"), first.path);
+ expect(doc.sections.map((s) => s.name)).toEqual([...SECTIONS]);
+ expect(validatePrdDocument(doc).filter((f) => f.severity === "error")).toEqual([]);
+ });
+
+ it("numbers from what is on disk rather than reserving in advance", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "One", today: "2026-07-26" });
+ createPrd(dir, { title: "Two", today: "2026-07-26" });
+ expect(nextPrdNumber(loadPrdCollection(dir))).toBe("0003");
+ const third = createPrd(dir, { title: "Three", today: "2026-07-26" });
+ expect(third.id).toBe("0003");
+ });
+
+ it("carries front-matter through from the options", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ const created = createPrd(dir, {
+ title: "Expand the parked-domain service",
+ authors: ["anthony@profullstack.com"],
+ repo: "profullstack/logicsrc",
+ tags: ["growth", "dns"],
+ today: "2026-07-26"
+ });
+ const doc = parsePrd(readFileSync(created.path, "utf8"), created.path);
+ expect(doc.frontMatter).toMatchObject({
+ id: "0001",
+ title: "Expand the parked-domain service",
+ status: "Draft",
+ repo: "profullstack/logicsrc",
+ created: "2026-07-26",
+ updated: "2026-07-26"
+ });
+ expect(doc.frontMatter.tags).toEqual(["growth", "dns"]);
+ });
+
+ it("quotes a title containing YAML-significant characters", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ const created = createPrd(dir, { title: "Fix: the thing [again]", today: "2026-07-26" });
+ const doc = parsePrd(readFileSync(created.path, "utf8"), created.path);
+ expect(doc.frontMatter.title).toBe("Fix: the thing [again]");
+ });
+
+ it("refuses to overwrite an existing file", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "One", today: "2026-07-26" });
+ expect(() => createPrd(dir, { title: "One", id: "0001", today: "2026-07-26" })).toThrow(/already exists/);
+ });
+
+ it("rejects a title that yields no slug", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ expect(() => createPrd(dir, { title: "!!!", today: "2026-07-26" })).toThrow(/slug/);
+ });
+});
+
+describe("index", () => {
+ it("is deterministic and idempotent", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "One", today: "2026-07-26" });
+
+ const first = writeIndex(dir);
+ expect(first.changed).toBe(true);
+ expect(writeIndex(dir).changed).toBe(false);
+ expect(renderIndex(loadPrdCollection(dir))).toBe(renderIndex(loadPrdCollection(dir)));
+ });
+
+ it("lists every PRD with its status and links to the file", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "One", tags: ["growth"], today: "2026-07-26" });
+ createPrd(dir, { title: "Two", status: "Review", today: "2026-07-26" });
+ writeIndex(dir);
+
+ const index = readFileSync(join(dir, "README.md"), "utf8");
+ expect(index).toContain("[0001](./0001-one.md)");
+ expect(index).toContain("[0002](./0002-two.md)");
+ expect(index).toContain("Review");
+ expect(index).toContain("growth");
+ });
+
+ it("renders a placeholder row for an empty collection", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ expect(renderIndex(loadPrdCollection(dir))).toContain("No PRDs yet");
+ });
+});
+
+describe("task bridge", () => {
+ const dir = (() => {
+ const d = scratch();
+ initPrdCollection(d);
+ createPrd(d, {
+ title: "Expand the parked-domain service",
+ authors: ["anthony@profullstack.com"],
+ repo: "profullstack/logicsrc",
+ today: "2026-07-26"
+ });
+ return d;
+ })();
+
+ const doc = () => loadPrdCollection(dir).documents[0]!;
+
+ it("derives a LogicSRC DID from an author email", () => {
+ expect(deriveCreatorDid("anthony@profullstack.com")).toBe("anthony.profullstack");
+ expect(deriveCreatorDid("already.did")).toBe("already.did");
+ expect(deriveCreatorDid(undefined)).toBe("openprd.local");
+ });
+
+ it("emits one schema-valid task per requirement", () => {
+ const { tasks } = prdToTasks(doc());
+ expect(tasks).toHaveLength(doc().requirements.length);
+ expect(validateTasks(tasks)).toEqual([]);
+ expect(tasks[0]).toMatchObject({
+ type: "logicsrc.task",
+ board: "/prd/0001",
+ creator_did: "anthony.profullstack",
+ github_repo: "profullstack/logicsrc",
+ status: "draft"
+ });
+ });
+
+ it("keeps titles inside the schema's 160-character limit", () => {
+ const long = "x".repeat(400);
+ const parsed = parsePrd(
+ `---\nopenprd: "0.2"\nid: "0001"\ntitle: Long\nstatus: Draft\n---\n\n## Requirements\n\n- R1 [P0] ${long}\n`,
+ "0001-long.md"
+ );
+ const { tasks } = prdToTasks(parsed);
+ expect(tasks[0]!.title.length).toBeLessThanOrEqual(160);
+ expect(validateTasks(tasks)).toEqual([]);
+ });
+
+ it("filters by priority and reports what it skipped", () => {
+ const parsed = parsePrd(
+ `---\nopenprd: "0.2"\nid: "0001"\ntitle: Mixed\nstatus: Draft\n---\n\n## Requirements\n\n- R1 [P0] Must.\n- R2 [P2] Maybe.\n`,
+ "0001-mixed.md"
+ );
+ const { tasks, skipped } = prdToTasks(parsed, { priorities: ["P0"] });
+ expect(tasks).toHaveLength(1);
+ expect(skipped[0]).toMatchObject({ requirement: "R2" });
+ });
+
+ it("records where each task came from", () => {
+ const { tasks } = prdToTasks(doc());
+ expect(tasks[0]!.description).toMatch(/From OpenPRD 0001 .* line \d+/);
+ });
+});
+
+/**
+ * Dogfood: this repo's own collection and standard document must satisfy the
+ * implementation. If the standard changes, these fail first.
+ */
+describe("this repository", () => {
+ const prdDir = join(REPO, "prd");
+ const hasCollection = existsSync(join(prdDir, "0001-add-logicsrc-openontology-spec.md"));
+ const maybe = hasCollection ? it : it.skip;
+
+ maybe("has a conforming prd/ collection with a current index", () => {
+ const collection = loadPrdCollection(prdDir);
+ const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
+ const problems = report.findings.filter((f) => f.severity === "error" || f.severity === "warning");
+ expect(problems).toEqual([]);
+ expect(report.ok).toBe(true);
+ });
+
+ maybe("keeps the embedded template identical to docs/openprd/0000-template.md", () => {
+ const onDisk = readFileSync(join(REPO, "docs/openprd/0000-template.md"), "utf8");
+ expect(TEMPLATE).toBe(onDisk);
+ });
+
+ maybe("keeps prd/0000-template.md identical to the embedded template", () => {
+ expect(readFileSync(join(prdDir, "0000-template.md"), "utf8")).toBe(TEMPLATE);
+ });
+
+ maybe("maps every requirement in PRD 0001 onto a valid task", () => {
+ const collection = loadPrdCollection(prdDir);
+ const doc = collection.documents.find((d) => d.frontMatter.id === "0001");
+ const { tasks } = prdToTasks(doc!);
+ expect(tasks.length).toBe(doc!.requirements.length);
+ expect(validateTasks(tasks)).toEqual([]);
+ });
+});
+
+describe("conformance fixtures", () => {
+ const fixtures = join(REPO, "packages/schemas/fixtures/openprd");
+ const hasFixtures = existsSync(join(fixtures, "conformance.json"));
+ const maybe = hasFixtures ? it : it.skip;
+
+ maybe("validates every valid fixture and rejects every invalid one", () => {
+ const manifest = JSON.parse(readFileSync(join(fixtures, "conformance.json"), "utf8")) as {
+ valid: Array<{ fixture: string; file: string }>;
+ invalid: Array<{ fixture: string; file: string; code: string; reason: string }>;
+ };
+
+ for (const entry of manifest.valid) {
+ const doc = parsePrd(readFileSync(join(fixtures, entry.fixture), "utf8"), entry.file);
+ const errors = validatePrdDocument(doc).filter((f) => f.severity === "error");
+ expect(errors, `${entry.fixture} should conform`).toEqual([]);
+ }
+
+ for (const entry of manifest.invalid) {
+ let codes: string[] = [];
+ try {
+ const doc = parsePrd(readFileSync(join(fixtures, entry.fixture), "utf8"), entry.file);
+ codes = validatePrdDocument(doc)
+ .filter((f) => f.severity === "error")
+ .map((f) => f.code);
+ } catch (error) {
+ codes = [(error as { code?: string }).code ?? "OP-P-PARSE"];
+ }
+ expect(codes, `${entry.fixture} should fail with ${entry.code}`).toContain(entry.code);
+ }
+ });
+});
+
+/** Keeps the scratch helper honest: a collection we build must round-trip. */
+describe("round trip", () => {
+ it("survives init → new → index → load → validate", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "Round trip", authors: ["a@example.com"], today: "2026-07-26" });
+ writeFileSync(join(dir, "notes.txt"), "ignored by the loader", "utf8");
+ writeIndex(dir);
+
+ const collection = loadPrdCollection(dir);
+ expect(collection.documents).toHaveLength(1);
+ expect(collection.template).not.toBeNull();
+ expect(validatePrdCollection(collection, { expectedIndex: renderIndex(collection) }).ok).toBe(true);
+ });
+});
diff --git a/packages/openprd/src/scaffold.ts b/packages/openprd/src/scaffold.ts
new file mode 100644
index 0000000..01cc23d
--- /dev/null
+++ b/packages/openprd/src/scaffold.ts
@@ -0,0 +1,209 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { loadPrdCollection, nextPrdNumber, renderIndex, INDEX_FILE, TEMPLATE_FILE } from "./collection.js";
+import { formatId, slugify } from "./parse.js";
+import { OPENPRD_VERSION, SECTIONS, type PrdStatus } from "./types.js";
+
+/**
+ * The canonical OpenPRD template. It lives in code so `prd init` works in any
+ * repo, with or without a checkout of the standard; `template.test.ts` asserts
+ * it stays identical to docs/openprd/0000-template.md.
+ */
+export const TEMPLATE = `---
+openprd: "${OPENPRD_VERSION}"
+id: "0000"
+title: "Short imperative title — start with a verb if possible"
+status: Draft
+authors:
+ - you@example.com
+created: 2026-01-01
+updated: 2026-01-01
+repo:
+discussion:
+implementation:
+tags:
+supersedes:
+superseded-by:
+---
+
+## Problem
+
+The user/business problem, and why it matters now. Cite the ask, the incident,
+or the constraint — not aesthetics.
+
+## Goals
+
+What success looks like, as outcomes (not features).
+
+## Non-Goals
+
+Explicitly out of scope, to bound the work.
+
+## Users
+
+Who this is for; personas or segments.
+
+## Requirements
+
+- R1 [P0] First required capability.
+- R2 [P1] Next capability.
+
+## UX Notes
+
+Flows, states, and constraints that shape the experience.
+
+## Success Metrics
+
+How the goals will be measured.
+
+## Risks & Open Questions
+
+- Known risk or decision still owed.
+`;
+
+export interface InitResult {
+ dir: string;
+ created: string[];
+ skipped: string[];
+}
+
+/** Create a `prd/` collection: the template plus a generated index. */
+export function initPrdCollection(dir: string, options: { title?: string } = {}): InitResult {
+ const base = resolve(dir);
+ mkdirSync(base, { recursive: true });
+
+ const created: string[] = [];
+ const skipped: string[] = [];
+
+ const templatePath = join(base, TEMPLATE_FILE);
+ if (existsSync(templatePath)) {
+ skipped.push(TEMPLATE_FILE);
+ } else {
+ writeFileSync(templatePath, TEMPLATE, "utf8");
+ created.push(TEMPLATE_FILE);
+ }
+
+ const indexPath = join(base, INDEX_FILE);
+ const index = renderIndex(loadPrdCollection(base), options);
+ if (existsSync(indexPath)) {
+ skipped.push(INDEX_FILE);
+ } else {
+ writeFileSync(indexPath, index, "utf8");
+ created.push(INDEX_FILE);
+ }
+
+ return { dir: base, created, skipped };
+}
+
+export interface CreateOptions {
+ title: string;
+ authors?: string[];
+ status?: PrdStatus;
+ repo?: string;
+ tags?: string[];
+ discussion?: string;
+ implementation?: string;
+ owner?: string;
+ supersedes?: string;
+ /** Pinned in tests so generated files are byte-identical across runs. */
+ today?: string;
+ /** Override the assigned number. Defaults to the next free one. */
+ id?: string;
+}
+
+export interface CreateResult {
+ id: string;
+ slug: string;
+ file: string;
+ path: string;
+}
+
+/**
+ * Write the next numbered PRD. The number is assigned at creation from what is
+ * on disk — never reserved in advance, per the standard.
+ */
+export function createPrd(dir: string, options: CreateOptions): CreateResult {
+ const base = resolve(dir);
+ if (!existsSync(base)) mkdirSync(base, { recursive: true });
+
+ const collection = loadPrdCollection(base);
+ const id = options.id ? formatId(Number.parseInt(options.id, 10)) : nextPrdNumber(collection);
+ const slug = slugify(options.title);
+ if (!slug) throw new Error(`Cannot derive a slug from title ${JSON.stringify(options.title)}`);
+
+ const file = `${id}-${slug}.md`;
+ const path = join(base, file);
+ if (existsSync(path)) throw new Error(`${file} already exists`);
+
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
+ const authors = options.authors?.length ? options.authors : ["you@example.com"];
+
+ const frontMatter = [
+ "---",
+ `openprd: "${OPENPRD_VERSION}"`,
+ `id: "${id}"`,
+ `title: ${yamlScalar(options.title)}`,
+ `status: ${options.status ?? "Draft"}`,
+ "authors:",
+ ...authors.map((author) => ` - ${author}`),
+ ...(options.owner ? [`owner: ${options.owner}`] : []),
+ `repo: ${options.repo ?? ""}`.trimEnd(),
+ `created: ${today}`,
+ `updated: ${today}`,
+ `discussion: ${options.discussion ?? ""}`.trimEnd(),
+ `implementation: ${options.implementation ?? ""}`.trimEnd(),
+ options.tags?.length ? `tags:\n${options.tags.map((tag) => ` - ${tag}`).join("\n")}` : "tags:",
+ `supersedes: ${options.supersedes ?? ""}`.trimEnd(),
+ "superseded-by:",
+ "---",
+ ""
+ ].join("\n");
+
+ const body = [
+ `# ${options.title}`,
+ "",
+ ...SECTIONS.flatMap((section) => [`## ${section}`, "", placeholder(section), ""])
+ ].join("\n");
+
+ writeFileSync(path, `${frontMatter}${body}`, "utf8");
+ return { id, slug, file, path };
+}
+
+function placeholder(section: string): string {
+ switch (section) {
+ case "Problem":
+ return "_TODO: the user/business problem, and why it matters now._";
+ case "Goals":
+ return "_TODO: what success looks like, as outcomes._";
+ case "Non-Goals":
+ return "_TODO: explicitly out of scope._";
+ case "Users":
+ return "_TODO: who this is for._";
+ case "Requirements":
+ return "- R1 [P0] _TODO: first required capability._";
+ case "UX Notes":
+ return "_TODO: flows, states, and constraints._";
+ case "Success Metrics":
+ return "_TODO: how the goals will be measured._";
+ default:
+ return "- _TODO: known risk or decision still owed._";
+ }
+}
+
+function yamlScalar(value: string): string {
+ return /[:#{}[\],&*?|<>=!%@`"']/.test(value) || /^\s|\s$/.test(value)
+ ? JSON.stringify(value)
+ : value;
+}
+
+/** Rewrite `prd/README.md` from what is on disk. Returns true when it changed. */
+export function writeIndex(dir: string, options: { title?: string } = {}): { changed: boolean; path: string } {
+ const base = resolve(dir);
+ const collection = loadPrdCollection(base);
+ const index = renderIndex(collection, options);
+ const path = join(base, INDEX_FILE);
+ const before = existsSync(path) ? readFileSync(path, "utf8") : null;
+ if (before === index) return { changed: false, path };
+ writeFileSync(path, index, "utf8");
+ return { changed: true, path };
+}
diff --git a/packages/openprd/src/tasks.ts b/packages/openprd/src/tasks.ts
new file mode 100644
index 0000000..501ba0b
--- /dev/null
+++ b/packages/openprd/src/tasks.ts
@@ -0,0 +1,156 @@
+import { validate as validateSchema } from "@logicsrc/validators";
+import type { PrdDocument, Priority, Requirement } from "./types.js";
+
+/**
+ * The optional LogicSRC bridge described in docs/openprd.md:
+ *
+ * "a PRD's Requirements map cleanly onto LogicSRC task documents
+ * (each R# → one task), and owner/repo reuse LogicSRC identity and repo
+ * conventions. That bridge is optional and lives in tooling."
+ *
+ * So it lives here, in tooling — the standard itself stays a file format with
+ * no service behind it.
+ */
+
+export interface TaskDocument {
+ type: "logicsrc.task";
+ version: string;
+ title: string;
+ description: string;
+ board: string;
+ creator_did: string;
+ status: string;
+ skills?: string[];
+ github_repo?: string;
+ external_links?: string[];
+ logicsrc_version?: string;
+}
+
+export interface ToTasksOptions {
+ /** LogicSRC DID. Derived from the first author when omitted. */
+ creator?: string;
+ /** Board path. Defaults to `/prd/`. */
+ board?: string;
+ status?: string;
+ /** Only convert requirements at these priorities. */
+ priorities?: Priority[];
+}
+
+export interface ToTasksResult {
+ tasks: TaskDocument[];
+ skipped: Array<{ requirement: string; reason: string }>;
+}
+
+/**
+ * LogicSRC DIDs look like `name.namespace`. An author email maps onto that
+ * shape predictably: `anthony@profullstack.com` → `anthony.profullstack`.
+ */
+export function deriveCreatorDid(author: string | undefined): string {
+ if (!author) return "openprd.local";
+
+ const trimmed = author.trim();
+ if (/^[a-z0-9][a-z0-9._-]*\.[a-z0-9][a-z0-9._-]*$/.test(trimmed) && !trimmed.includes("@")) {
+ return trimmed;
+ }
+
+ const at = trimmed.indexOf("@");
+ if (at > 0) {
+ const local = sanitize(trimmed.slice(0, at));
+ const domain = trimmed.slice(at + 1);
+ const org = sanitize(domain.split(".")[0] ?? "local");
+ if (local && org) return `${local}.${org}`;
+ }
+
+ const fallback = sanitize(trimmed);
+ return fallback ? `${fallback}.local` : "openprd.local";
+}
+
+function sanitize(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]/g, "-")
+ .replace(/^[^a-z0-9]+/, "")
+ .replace(/[^a-z0-9]+$/, "");
+}
+
+export function prdToTasks(doc: PrdDocument, options: ToTasksOptions = {}): ToTasksResult {
+ const fm = doc.frontMatter;
+ const id = fm.id ?? doc.filePrefix ?? "0000";
+ const creator = options.creator ?? deriveCreatorDid(fm.owner ?? fm.authors?.[0]);
+ const board = options.board ?? `/prd/${id}`;
+
+ const tasks: TaskDocument[] = [];
+ const skipped: ToTasksResult["skipped"] = [];
+
+ for (const requirement of doc.requirements) {
+ if (options.priorities && (!requirement.priority || !options.priorities.includes(requirement.priority))) {
+ skipped.push({
+ requirement: requirement.id,
+ reason: `priority ${requirement.priority ?? "none"} not in the requested set`
+ });
+ continue;
+ }
+ if (!requirement.text) {
+ skipped.push({ requirement: requirement.id, reason: "requirement has no text" });
+ continue;
+ }
+
+ tasks.push(toTask(doc, requirement, { creator, board, status: options.status ?? "draft", id }));
+ }
+
+ return { tasks, skipped };
+}
+
+function toTask(
+ doc: PrdDocument,
+ requirement: Requirement,
+ ctx: { creator: string; board: string; status: string; id: string }
+): TaskDocument {
+ const fm = doc.frontMatter;
+ const plain = stripMarkdown(requirement.text);
+ const prefix = `${ctx.id} ${requirement.id}`;
+ const title = truncate(`${prefix}: ${plain}`, 160);
+
+ const task: TaskDocument = {
+ type: "logicsrc.task",
+ version: "0.1",
+ title,
+ description: `${plain}\n\nFrom OpenPRD ${ctx.id} "${fm.title}" (${doc.file}, line ${requirement.line}).`,
+ board: ctx.board,
+ creator_did: ctx.creator,
+ status: ctx.status
+ };
+
+ if (requirement.priority) task.skills = [requirement.priority.toLowerCase()];
+ if (fm.repo) task.github_repo = fm.repo;
+ const links = [fm.discussion, fm.implementation].filter((link): link is string => Boolean(link));
+ if (links.length) task.external_links = links;
+
+ return task;
+}
+
+function stripMarkdown(text: string): string {
+ return text
+ .replace(/\*\*(.*?)\*\*/g, "$1")
+ .replace(/`([^`]*)`/g, "$1")
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
+ .trim();
+}
+
+function truncate(value: string, max: number): string {
+ return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}…`;
+}
+
+/** Validate emitted tasks against the LogicSRC task schema. */
+export function validateTasks(tasks: TaskDocument[]): Array<{ index: number; errors: string[] }> {
+ const problems: Array<{ index: number; errors: string[] }> = [];
+ tasks.forEach((task, index) => {
+ const result = validateSchema("task", task);
+ if (result.ok) return;
+ problems.push({
+ index,
+ errors: result.errors.map((error) => `${error.instancePath || "/"} ${error.message ?? "invalid"}`)
+ });
+ });
+ return problems;
+}
diff --git a/packages/openprd/src/types.ts b/packages/openprd/src/types.ts
new file mode 100644
index 0000000..f51ae4a
--- /dev/null
+++ b/packages/openprd/src/types.ts
@@ -0,0 +1,124 @@
+/**
+ * TypeScript surface for the OpenPRD standard (docs/openprd.md).
+ *
+ * The normative contracts are the standard document plus
+ * `openprd-prd.schema.json` (front-matter). These types describe the parsed
+ * document that tooling exchanges — the CLI, SDK, and any MCP surface all
+ * speak this shape.
+ */
+
+export const OPENPRD_VERSION = "0.2";
+
+/** The eight `##` sections, in the order the standard requires. */
+export const SECTIONS = [
+ "Problem",
+ "Goals",
+ "Non-Goals",
+ "Users",
+ "Requirements",
+ "UX Notes",
+ "Success Metrics",
+ "Risks & Open Questions"
+] as const;
+
+export type SectionName = (typeof SECTIONS)[number];
+
+export const STATUSES = [
+ "Draft",
+ "Review",
+ "Accepted",
+ "Final",
+ "Rejected",
+ "Withdrawn",
+ "Superseded"
+] as const;
+
+export type PrdStatus = (typeof STATUSES)[number];
+
+export type Priority = "P0" | "P1" | "P2";
+
+/** The YAML front-matter block, validated by openprd-prd.schema.json. */
+export interface PrdFrontMatter {
+ openprd: string;
+ id: string;
+ title: string;
+ status: PrdStatus;
+ authors?: string[] | null;
+ owner?: string | null;
+ repo?: string | null;
+ created?: string | null;
+ updated?: string | null;
+ discussion?: string | null;
+ implementation?: string | null;
+ tags?: string[] | null;
+ supersedes?: string | null;
+ "superseded-by"?: string | null;
+}
+
+export interface Section {
+ name: string;
+ /** 1-based line of the `## ` heading. */
+ line: number;
+ content: string;
+ empty: boolean;
+}
+
+export interface Requirement {
+ /** `R1`, `R2`, … as written. */
+ id: string;
+ number: number;
+ priority: Priority | null;
+ text: string;
+ line: number;
+}
+
+export interface PrdDocument {
+ /** Path as given (absolute or relative). */
+ path: string;
+ /** Basename, e.g. `0001-add-the-thing.md`. */
+ file: string;
+ /** Four-digit prefix parsed from the filename, or null when malformed. */
+ filePrefix: string | null;
+ slug: string | null;
+ frontMatter: PrdFrontMatter;
+ /** Raw front-matter text, for round-trip-safe rewrites. */
+ frontMatterRaw: string;
+ body: string;
+ /** H1 heading immediately after the front-matter, when present. */
+ heading: string | null;
+ sections: Section[];
+ requirements: Requirement[];
+}
+
+export type Severity = "error" | "warning" | "info";
+
+export interface Finding {
+ code: string;
+ severity: Severity;
+ message: string;
+ file?: string;
+ line?: number;
+ hint?: string;
+}
+
+export interface ValidationReport {
+ ok: boolean;
+ findings: Finding[];
+ counts: Record;
+ checked: {
+ documents: number;
+ sections: number;
+ requirements: number;
+ };
+}
+
+export interface PrdCollection {
+ dir: string;
+ /** `0000-template.md`, when present. */
+ template: PrdDocument | null;
+ documents: PrdDocument[];
+ /** Files that could not be parsed at all, with the reason. */
+ unparsed: Array<{ file: string; reason: string }>;
+ /** Existing `README.md` index contents, when present. */
+ indexRaw: string | null;
+}
diff --git a/packages/openprd/src/validate.test.ts b/packages/openprd/src/validate.test.ts
new file mode 100644
index 0000000..1af6aa2
--- /dev/null
+++ b/packages/openprd/src/validate.test.ts
@@ -0,0 +1,315 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, describe, expect, it } from "vitest";
+import { loadPrdCollection, renderIndex } from "./collection.js";
+import { canTransition, checkTransition, nextStatuses } from "./lifecycle.js";
+import { parsePrd } from "./parse.js";
+import { createPrd, initPrdCollection, writeIndex } from "./scaffold.js";
+import { reportFor, validatePrdCollection, validatePrdDocument } from "./validate.js";
+import type { PrdStatus } from "./types.js";
+
+const dirs: string[] = [];
+afterAll(() => {
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
+});
+
+function scratch(): string {
+ const dir = mkdtempSync(join(tmpdir(), "openprd-"));
+ dirs.push(dir);
+ return dir;
+}
+
+/** A conforming PRD, which each test then breaks in exactly one way. */
+function conforming(overrides: { frontMatter?: string; body?: string } = {}): string {
+ const frontMatter =
+ overrides.frontMatter ??
+ `openprd: "0.2"
+id: "0001"
+title: Do the thing
+status: Draft
+authors:
+ - a@example.com
+created: 2026-07-01
+updated: 2026-07-02`;
+
+ const body =
+ overrides.body ??
+ `## Problem
+
+Something hurts.
+
+## Goals
+
+Make it stop.
+
+## Non-Goals
+
+_None._
+
+## Users
+
+Everyone.
+
+## Requirements
+
+- R1 [P0] First capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+It stops hurting.
+
+## Risks & Open Questions
+
+- Might not stop.`;
+
+ return `---\n${frontMatter}\n---\n\n${body}\n`;
+}
+
+const codes = (source: string, file = "0001-do-the-thing.md", options = {}) =>
+ validatePrdDocument(parsePrd(source, file), options).map((finding) => finding.code);
+
+describe("document conformance", () => {
+ it("accepts a conforming PRD with no errors", () => {
+ const report = reportFor(parsePrd(conforming(), "0001-do-the-thing.md"));
+ expect(report.findings.filter((f) => f.severity === "error")).toEqual([]);
+ expect(report.ok).toBe(true);
+ });
+
+ it("rejects a filename without a four-digit id", () => {
+ expect(codes(conforming(), "do-the-thing.md")).toContain("OP-C-FILENAME");
+ });
+
+ it("rejects a non-kebab-case slug", () => {
+ expect(codes(conforming(), "0001-Do_The_Thing.md")).toContain("OP-C-SLUG-FORM");
+ });
+
+ it("rejects front-matter that fails the schema", () => {
+ const missingStatus = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing`
+ });
+ expect(codes(missingStatus)).toContain("OP-C-FRONTMATTER");
+ });
+
+ it("rejects an unknown status value", () => {
+ const bad = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Shipped`
+ });
+ expect(codes(bad)).toContain("OP-C-FRONTMATTER");
+ });
+
+ it("rejects an id that does not match the filename prefix", () => {
+ const mismatch = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0009"\ntitle: Do the thing\nstatus: Draft`
+ });
+ expect(codes(mismatch)).toContain("OP-C-ID-MISMATCH");
+ });
+
+ it("rejects a missing section", () => {
+ const withoutUsers = conforming().replace("## Users\n\nEveryone.\n\n", "");
+ const found = codes(withoutUsers);
+ expect(found).toContain("OP-C-SECTION-MISSING");
+ });
+
+ it("rejects sections that are out of order", () => {
+ const swapped = conforming()
+ .replace("## Problem\n\nSomething hurts.", "## Goals\n\nMake it stop.")
+ .replace("## Goals\n\nMake it stop.\n\n## Non-Goals", "## Problem\n\nSomething hurts.\n\n## Non-Goals");
+ expect(codes(swapped)).toContain("OP-C-SECTION-ORDER");
+ });
+
+ it("treats a non-standard section as info, not an error", () => {
+ const extra = conforming().replace("## UX Notes", "## Appendix\n\nExtra.\n\n## UX Notes");
+ const findings = validatePrdDocument(parsePrd(extra, "0001-do-the-thing.md"));
+ const extraFinding = findings.find((f) => f.code === "OP-L-EXTRA-SECTION");
+ expect(extraFinding?.severity).toBe("info");
+ expect(findings.filter((f) => f.severity === "error")).toEqual([]);
+ });
+
+ it("accepts a section whose body is just _None._", () => {
+ expect(codes(conforming())).not.toContain("OP-L-EMPTY-SECTION");
+ });
+});
+
+describe("document lint", () => {
+ it("warns about an empty section and escalates it under --strict", () => {
+ const empty = conforming().replace("## UX Notes\n\n_None._", "## UX Notes\n");
+ const lenient = validatePrdDocument(parsePrd(empty, "0001-do-the-thing.md"));
+ const strict = validatePrdDocument(parsePrd(empty, "0001-do-the-thing.md"), { strict: true });
+ expect(lenient.find((f) => f.code === "OP-L-EMPTY-SECTION")?.severity).toBe("warning");
+ expect(strict.find((f) => f.code === "OP-L-EMPTY-SECTION")?.severity).toBe("error");
+ });
+
+ it("warns when a requirement has no priority tag", () => {
+ const untagged = conforming().replace("- R1 [P0] First capability.", "- R1 First capability.");
+ expect(codes(untagged)).toContain("OP-L-REQ-PRIORITY");
+ });
+
+ it("errors on duplicate requirement ids", () => {
+ const duplicated = conforming().replace(
+ "- R1 [P0] First capability.",
+ "- R1 [P0] First capability.\n- R1 [P1] Same number again."
+ );
+ const findings = validatePrdDocument(parsePrd(duplicated, "0001-do-the-thing.md"));
+ expect(findings.find((f) => f.code === "OP-L-REQ-DUPLICATE")?.severity).toBe("error");
+ });
+
+ it("warns when requirement numbering skips", () => {
+ const gap = conforming().replace(
+ "- R1 [P0] First capability.",
+ "- R1 [P0] First capability.\n- R5 [P1] Jumped."
+ );
+ expect(codes(gap)).toContain("OP-L-REQ-NUMBERING");
+ });
+
+ it("warns when a PRD lists no authors", () => {
+ const noAuthors = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Draft`
+ });
+ expect(codes(noAuthors)).toContain("OP-L-NO-AUTHOR");
+ });
+
+ it("errors when updated is before created", () => {
+ const backwards = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Draft\nauthors:\n - a@example.com\ncreated: 2026-07-10\nupdated: 2026-07-01`
+ });
+ const findings = validatePrdDocument(parsePrd(backwards, "0001-do-the-thing.md"));
+ expect(findings.find((f) => f.code === "OP-L-DATE-ORDER")?.severity).toBe("error");
+ });
+
+ it("errors when status is Superseded with no replacement named", () => {
+ const superseded = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Superseded\nauthors:\n - a@example.com`
+ });
+ expect(codes(superseded)).toContain("OP-L-SUPERSEDED-BY");
+ });
+
+ it("errors when a PRD supersedes itself", () => {
+ const selfRef = conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Do the thing\nstatus: Draft\nauthors:\n - a@example.com\nsupersedes: "0001"`
+ });
+ expect(codes(selfRef)).toContain("OP-L-SELF-REFERENCE");
+ });
+
+ it("notes when the slug does not summarize the title", () => {
+ expect(codes(conforming(), "0001-something-else-entirely.md")).toContain("OP-L-SLUG-DRIFT");
+ });
+});
+
+describe("collection rules", () => {
+ function collectionWith(files: Record): ReturnType {
+ const dir = scratch();
+ initPrdCollection(dir);
+ for (const [name, contents] of Object.entries(files)) {
+ writeFileSync(join(dir, name), contents, "utf8");
+ }
+ return loadPrdCollection(dir);
+ }
+
+ it("accepts a freshly initialized collection", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "Do the thing", authors: ["a@example.com"], today: "2026-07-26" });
+ writeIndex(dir);
+ const collection = loadPrdCollection(dir);
+ const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
+ expect(report.findings.filter((f) => f.severity === "error")).toEqual([]);
+ expect(report.ok).toBe(true);
+ });
+
+ it("errors on a numbering gap", () => {
+ const collection = collectionWith({
+ "0001-one.md": conforming(),
+ "0003-three.md": conforming({
+ frontMatter: `openprd: "0.2"\nid: "0003"\ntitle: Three\nstatus: Draft\nauthors:\n - a@example.com`
+ })
+ });
+ const report = validatePrdCollection(collection);
+ expect(report.findings.map((f) => f.code)).toContain("OP-C-NUMBERING-GAP");
+ expect(report.ok).toBe(false);
+ });
+
+ it("errors on a duplicate id across two files", () => {
+ const collection = collectionWith({
+ "0001-one.md": conforming(),
+ "0002-two.md": conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: Two\nstatus: Draft\nauthors:\n - a@example.com`
+ })
+ });
+ expect(validatePrdCollection(collection).findings.map((f) => f.code)).toContain("OP-C-DUPLICATE-ID");
+ });
+
+ it("errors when a cross-reference points outside the collection", () => {
+ const collection = collectionWith({
+ "0001-one.md": conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: One\nstatus: Draft\nauthors:\n - a@example.com\nsupersedes: "0099"`
+ })
+ });
+ expect(validatePrdCollection(collection).findings.map((f) => f.code)).toContain("OP-C-UNKNOWN-REFERENCE");
+ });
+
+ it("warns when supersession is recorded on only one side", () => {
+ const collection = collectionWith({
+ "0001-one.md": conforming({
+ frontMatter: `openprd: "0.2"\nid: "0001"\ntitle: One\nstatus: Superseded\nauthors:\n - a@example.com\nsuperseded-by: "0002"`
+ }),
+ "0002-two.md": conforming({
+ frontMatter: `openprd: "0.2"\nid: "0002"\ntitle: Two\nstatus: Draft\nauthors:\n - a@example.com`
+ })
+ });
+ expect(validatePrdCollection(collection).findings.map((f) => f.code)).toContain("OP-L-ONE-SIDED-REFERENCE");
+ });
+
+ it("reports an unparseable file instead of skipping it", () => {
+ const collection = collectionWith({ "0001-broken.md": "# no front matter\n" });
+ const report = validatePrdCollection(collection);
+ expect(report.findings.map((f) => f.code)).toContain("OP-P-PARSE");
+ expect(report.ok).toBe(false);
+ });
+
+ it("warns when the index is stale", () => {
+ const dir = scratch();
+ initPrdCollection(dir);
+ createPrd(dir, { title: "Unindexed", authors: ["a@example.com"], today: "2026-07-26" });
+ const collection = loadPrdCollection(dir);
+ const report = validatePrdCollection(collection, { expectedIndex: renderIndex(collection) });
+ expect(report.findings.map((f) => f.code)).toContain("OP-L-INDEX-STALE");
+ });
+});
+
+describe("lifecycle", () => {
+ it("follows the transitions in the standard", () => {
+ expect(nextStatuses("Draft")).toEqual(["Review", "Withdrawn"]);
+ expect(canTransition("Review", "Accepted")).toBe(true);
+ expect(canTransition("Accepted", "Final")).toBe(true);
+ expect(canTransition("Final", "Superseded")).toBe(true);
+ });
+
+ it("refuses to skip stages", () => {
+ expect(canTransition("Draft", "Final")).toBe(false);
+ expect(canTransition("Draft", "Accepted")).toBe(false);
+ const check = checkTransition("Draft", "Final");
+ expect(check.ok).toBe(false);
+ expect(check.reason).toMatch(/Review, Withdrawn/);
+ });
+
+ it("treats Rejected, Withdrawn, and Superseded as terminal", () => {
+ for (const status of ["Rejected", "Withdrawn", "Superseded"] as PrdStatus[]) {
+ expect(nextStatuses(status)).toEqual([]);
+ expect(checkTransition(status, "Draft").reason).toMatch(/terminal/);
+ }
+ });
+
+ it("requires a replacement id to mark a PRD Superseded", () => {
+ expect(checkTransition("Accepted", "Superseded").ok).toBe(false);
+ expect(checkTransition("Accepted", "Superseded", { supersededBy: "0002" }).ok).toBe(true);
+ });
+
+ it("refuses a no-op transition", () => {
+ expect(checkTransition("Draft", "Draft").reason).toMatch(/already/);
+ });
+});
diff --git a/packages/openprd/src/validate.ts b/packages/openprd/src/validate.ts
new file mode 100644
index 0000000..bbed7fc
--- /dev/null
+++ b/packages/openprd/src/validate.ts
@@ -0,0 +1,409 @@
+import { validate as validateSchema } from "@logicsrc/validators";
+import { slugify } from "./parse.js";
+import { SECTIONS, type Finding, type PrdCollection, type PrdDocument, type Severity, type ValidationReport } from "./types.js";
+
+export interface ValidateOptions {
+ /** Promote lint warnings to errors, for CI that wants a clean collection. */
+ strict?: boolean;
+ /** The version the collection targets. Mismatches are reported. */
+ expectedVersion?: string;
+}
+
+const TEMPLATE_ID = "0000";
+
+/**
+ * Conformance, straight from docs/openprd.md:
+ *
+ * - lives at prd/-.md with a four-digit
+ * - front-matter validates against openprd-prd.schema.json
+ * - id equals the filename's numeric prefix
+ * - all eight body sections are present in order
+ *
+ * Everything beyond those four is lint: useful, but never the difference
+ * between conforming and not.
+ */
+export function validatePrdDocument(doc: PrdDocument, options: ValidateOptions = {}): Finding[] {
+ const findings: Finding[] = [];
+ const lint: Severity = options.strict ? "error" : "warning";
+ const add = (finding: Finding) => findings.push({ file: doc.file, ...finding });
+
+ const isTemplate = doc.filePrefix === TEMPLATE_ID;
+
+ /* ── 1. Filename ─────────────────────────────────────────────────────── */
+
+ if (!doc.filePrefix) {
+ add({
+ code: "OP-C-FILENAME",
+ severity: "error",
+ message: `${doc.file} is not named -.md with a four-digit id`,
+ hint: "Rename to prd/0001-short-kebab-title.md"
+ });
+ } else if (doc.slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(doc.slug)) {
+ add({
+ code: "OP-C-SLUG-FORM",
+ severity: "error",
+ message: `${doc.file} slug "${doc.slug}" is not kebab-case`,
+ hint: "Lowercase letters, digits, and single hyphens only"
+ });
+ }
+
+ /* ── 2. Front-matter schema ──────────────────────────────────────────── */
+
+ const result = validateSchema("openprd-prd", doc.frontMatter);
+ if (!result.ok) {
+ for (const error of result.errors) {
+ add({
+ code: "OP-C-FRONTMATTER",
+ severity: "error",
+ line: 2,
+ message: `front-matter ${error.instancePath || "/"} ${error.message ?? "failed validation"}`,
+ hint: "See packages/schemas/schemas/openprd-prd.schema.json"
+ });
+ }
+ }
+
+ /* ── 3. id matches the filename prefix ───────────────────────────────── */
+
+ if (doc.filePrefix && doc.frontMatter.id && doc.frontMatter.id !== doc.filePrefix) {
+ add({
+ code: "OP-C-ID-MISMATCH",
+ severity: "error",
+ line: 2,
+ message: `front-matter id "${doc.frontMatter.id}" does not match filename prefix "${doc.filePrefix}"`
+ });
+ }
+
+ /* ── 4. The eight sections, present and in order ─────────────────────── */
+
+ const present = doc.sections.map((section) => section.name);
+ const expected = [...SECTIONS];
+
+ for (const name of expected) {
+ if (!present.includes(name)) {
+ add({
+ code: "OP-C-SECTION-MISSING",
+ severity: "error",
+ message: `missing required section "## ${name}"`,
+ hint: `The eight sections are: ${expected.join(", ")}`
+ });
+ }
+ }
+
+ const required = present.filter((name) => expected.includes(name as (typeof SECTIONS)[number]));
+ const ordered = expected.filter((name) => required.includes(name));
+ if (required.length === ordered.length && required.join("|") !== ordered.join("|")) {
+ add({
+ code: "OP-C-SECTION-ORDER",
+ severity: "error",
+ message: `sections are out of order: found ${required.join(" → ")}, expected ${ordered.join(" → ")}`
+ });
+ }
+
+ const extra = present.filter((name) => !expected.includes(name as (typeof SECTIONS)[number]));
+ for (const name of extra) {
+ add({
+ code: "OP-L-EXTRA-SECTION",
+ severity: "info",
+ line: doc.sections.find((s) => s.name === name)?.line,
+ message: `"## ${name}" is not one of the eight standard sections`,
+ hint: "Use a ### subsection inside a standard section instead"
+ });
+ }
+
+ /* ── Lint from here down ─────────────────────────────────────────────── */
+
+ if (options.expectedVersion && doc.frontMatter.openprd !== options.expectedVersion) {
+ add({
+ code: "OP-L-VERSION",
+ severity: lint,
+ line: 2,
+ message: `declares openprd "${doc.frontMatter.openprd}" but the collection targets "${options.expectedVersion}"`
+ });
+ }
+
+ for (const section of doc.sections) {
+ if (!expected.includes(section.name as (typeof SECTIONS)[number])) continue;
+ if (!section.empty) continue;
+ add({
+ code: "OP-L-EMPTY-SECTION",
+ severity: isTemplate ? "info" : lint,
+ line: section.line,
+ message: `section "## ${section.name}" is empty`,
+ hint: "A single line such as _None._ is enough"
+ });
+ }
+
+ if (!isTemplate) {
+ if ((doc.frontMatter.authors?.length ?? 0) === 0) {
+ add({
+ code: "OP-L-NO-AUTHOR",
+ severity: lint,
+ line: 2,
+ message: "no authors listed",
+ hint: "The standard expects at least one author"
+ });
+ }
+
+ if (doc.slug && doc.frontMatter.title) {
+ const fromTitle = slugify(doc.frontMatter.title);
+ if (fromTitle && doc.slug !== fromTitle && !fromTitle.startsWith(doc.slug) && !doc.slug.startsWith(fromTitle)) {
+ add({
+ code: "OP-L-SLUG-DRIFT",
+ severity: "info",
+ message: `slug "${doc.slug}" does not summarize the title (expected something like "${fromTitle}")`
+ });
+ }
+ }
+
+ if (doc.heading && doc.frontMatter.title && doc.heading !== doc.frontMatter.title) {
+ add({
+ code: "OP-L-HEADING-DRIFT",
+ severity: "info",
+ message: `H1 "${doc.heading}" differs from front-matter title "${doc.frontMatter.title}"`
+ });
+ }
+ }
+
+ /* ── Requirements ────────────────────────────────────────────────────── */
+
+ const requirementsSection = doc.sections.find((section) => section.name === "Requirements");
+ if (requirementsSection && !requirementsSection.empty && doc.requirements.length === 0 && !isTemplate) {
+ add({
+ code: "OP-L-NO-REQUIREMENTS",
+ severity: lint,
+ line: requirementsSection.line,
+ message: "Requirements section has no numbered R# entries",
+ hint: "One capability per line: - R1 [P0] …"
+ });
+ }
+
+ const seen = new Map();
+ for (const requirement of doc.requirements) {
+ if (!requirement.priority) {
+ add({
+ code: "OP-L-REQ-PRIORITY",
+ severity: lint,
+ line: requirement.line,
+ message: `${requirement.id} has no priority tag`,
+ hint: "Prefix each requirement with [P0], [P1], or [P2]"
+ });
+ }
+ const first = seen.get(requirement.number);
+ if (first !== undefined) {
+ add({
+ code: "OP-L-REQ-DUPLICATE",
+ severity: "error",
+ line: requirement.line,
+ message: `duplicate requirement id ${requirement.id} (first seen on line ${first})`
+ });
+ } else {
+ seen.set(requirement.number, requirement.line);
+ }
+ }
+
+ const numbers = [...seen.keys()].sort((a, b) => a - b);
+ numbers.forEach((n, index) => {
+ if (n === index + 1) return;
+ const previous = index === 0 ? 0 : (numbers[index - 1] as number);
+ if (n === previous + 1) return;
+ add({
+ code: "OP-L-REQ-NUMBERING",
+ severity: lint,
+ line: seen.get(n),
+ message: `requirement numbering jumps from R${previous} to R${n}`,
+ hint: "Number requirements contiguously from R1"
+ });
+ });
+
+ /* ── Dates and supersession ──────────────────────────────────────────── */
+
+ const { created, updated, status } = doc.frontMatter;
+ if (created && updated && updated < created) {
+ add({
+ code: "OP-L-DATE-ORDER",
+ severity: "error",
+ line: 2,
+ message: `updated (${updated}) is before created (${created})`
+ });
+ }
+
+ const supersededBy = doc.frontMatter["superseded-by"];
+ if (status === "Superseded" && !supersededBy) {
+ add({
+ code: "OP-L-SUPERSEDED-BY",
+ severity: "error",
+ line: 2,
+ message: "status is Superseded but superseded-by names no replacement"
+ });
+ }
+ if (supersededBy && status !== "Superseded") {
+ add({
+ code: "OP-L-SUPERSEDED-STATUS",
+ severity: lint,
+ line: 2,
+ message: `superseded-by is set to ${supersededBy} but status is ${status}`
+ });
+ }
+ if (doc.frontMatter.supersedes && doc.frontMatter.supersedes === doc.frontMatter.id) {
+ add({
+ code: "OP-L-SELF-REFERENCE",
+ severity: "error",
+ line: 2,
+ message: "supersedes points at this PRD itself"
+ });
+ }
+
+ return findings;
+}
+
+/**
+ * Collection-level rules: numbering with no gaps, unique ids, resolvable
+ * cross-references, and an index that matches what is on disk.
+ */
+export function validatePrdCollection(
+ collection: PrdCollection,
+ options: ValidateOptions & { expectedIndex?: string } = {}
+): ValidationReport {
+ const findings: Finding[] = [];
+ const lint: Severity = options.strict ? "error" : "warning";
+
+ for (const { file, reason } of collection.unparsed) {
+ findings.push({ code: "OP-P-PARSE", severity: "error", file, message: reason });
+ }
+
+ for (const doc of [...(collection.template ? [collection.template] : []), ...collection.documents]) {
+ findings.push(...validatePrdDocument(doc, options));
+ }
+
+ const byId = new Map();
+ for (const doc of collection.documents) {
+ const id = doc.frontMatter.id ?? doc.filePrefix ?? "????";
+ byId.set(id, [...(byId.get(id) ?? []), doc]);
+ }
+
+ for (const [id, docs] of byId) {
+ if (docs.length > 1) {
+ findings.push({
+ code: "OP-C-DUPLICATE-ID",
+ severity: "error",
+ file: docs.map((d) => d.file).join(", "),
+ message: `id ${id} is used by ${docs.length} files`
+ });
+ }
+ }
+
+ // "Four-digit, zero-padded, monotonically increasing, no gaps."
+ const numbers = collection.documents
+ .map((doc) => Number.parseInt(doc.filePrefix ?? "", 10))
+ .filter((n) => Number.isInteger(n))
+ .sort((a, b) => a - b);
+
+ numbers.forEach((n, index) => {
+ const expected = index + 1;
+ if (n === expected) return;
+ const previous = index === 0 ? 0 : (numbers[index - 1] as number);
+ if (n === previous + 1) return;
+ findings.push({
+ code: "OP-C-NUMBERING-GAP",
+ severity: "error",
+ message: `numbering jumps from ${String(previous).padStart(4, "0")} to ${String(n).padStart(4, "0")}`,
+ hint: "Ids are monotonically increasing with no gaps; 0000 is reserved for the template"
+ });
+ });
+
+ if (collection.documents.some((doc) => doc.filePrefix === "0000")) {
+ findings.push({
+ code: "OP-C-TEMPLATE-ID",
+ severity: "error",
+ message: "0000 is reserved for the template",
+ hint: "Rename the PRD to the next free number"
+ });
+ }
+
+ const ids = new Set(collection.documents.map((doc) => doc.frontMatter.id ?? doc.filePrefix));
+ for (const doc of collection.documents) {
+ for (const [field, target] of [
+ ["supersedes", doc.frontMatter.supersedes],
+ ["superseded-by", doc.frontMatter["superseded-by"]]
+ ] as const) {
+ if (!target) continue;
+ if (!ids.has(target)) {
+ findings.push({
+ code: "OP-C-UNKNOWN-REFERENCE",
+ severity: "error",
+ file: doc.file,
+ line: 2,
+ message: `${field} points at ${target}, which is not in this collection`
+ });
+ continue;
+ }
+ const other = collection.documents.find((d) => (d.frontMatter.id ?? d.filePrefix) === target);
+ const reciprocal = field === "supersedes" ? other?.frontMatter["superseded-by"] : other?.frontMatter.supersedes;
+ if (reciprocal !== (doc.frontMatter.id ?? doc.filePrefix)) {
+ findings.push({
+ code: "OP-L-ONE-SIDED-REFERENCE",
+ severity: lint,
+ file: doc.file,
+ line: 2,
+ message: `${field}: ${target} is not reciprocated by ${other?.file ?? target}`,
+ hint: "Supersession should be recorded on both PRDs"
+ });
+ }
+ }
+ }
+
+ if (!collection.template) {
+ findings.push({
+ code: "OP-L-NO-TEMPLATE",
+ severity: "info",
+ message: "no 0000-template.md in the collection",
+ hint: "Run `logicsrc prd init` to add the template and index"
+ });
+ }
+
+ if (options.expectedIndex !== undefined) {
+ if (collection.indexRaw === null) {
+ findings.push({
+ code: "OP-L-NO-INDEX",
+ severity: "info",
+ message: "no README.md index in the collection",
+ hint: "Run `logicsrc prd index --write`"
+ });
+ } else if (collection.indexRaw.trim() !== options.expectedIndex.trim()) {
+ findings.push({
+ code: "OP-L-INDEX-STALE",
+ severity: lint,
+ file: "README.md",
+ message: "index does not match the PRDs on disk",
+ hint: "Run `logicsrc prd index --write`"
+ });
+ }
+ }
+
+ const counts: Record = { error: 0, warning: 0, info: 0 };
+ for (const finding of findings) counts[finding.severity] += 1;
+
+ return {
+ ok: counts.error === 0,
+ findings,
+ counts,
+ checked: {
+ documents: collection.documents.length,
+ sections: collection.documents.reduce((n, doc) => n + doc.sections.length, 0),
+ requirements: collection.documents.reduce((n, doc) => n + doc.requirements.length, 0)
+ }
+ };
+}
+
+/** Report for a single document, without collection-level rules. */
+export function reportFor(doc: PrdDocument, options: ValidateOptions = {}): ValidationReport {
+ const findings = validatePrdDocument(doc, options);
+ const counts: Record = { error: 0, warning: 0, info: 0 };
+ for (const finding of findings) counts[finding.severity] += 1;
+ return {
+ ok: counts.error === 0,
+ findings,
+ counts,
+ checked: { documents: 1, sections: doc.sections.length, requirements: doc.requirements.length }
+ };
+}
diff --git a/packages/openprd/tsconfig.json b/packages/openprd/tsconfig.json
new file mode 100644
index 0000000..d38ac70
--- /dev/null
+++ b/packages/openprd/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.test.ts"]
+}
diff --git a/packages/schemas/fixtures/openprd/conformance.json b/packages/schemas/fixtures/openprd/conformance.json
new file mode 100644
index 0000000..377036f
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/conformance.json
@@ -0,0 +1,104 @@
+{
+ "openprdConformance": "0.2",
+ "description": "Conformance fixtures for OpenPRD 0.2. Every valid fixture must parse and validate with no errors; every invalid fixture must fail with the stated code. `file` is the filename the fixture must be validated as, since the standard's rules depend on it.",
+ "valid": [
+ {
+ "fixture": "valid/minimal.md",
+ "file": "0001-expand-the-parked-domain-service.md"
+ },
+ {
+ "fixture": "valid/full.md",
+ "file": "0001-expand-the-parked-domain-service.md"
+ },
+ {
+ "fixture": "valid/bold-requirements.md",
+ "file": "0002-use-bold-requirement-markers.md"
+ },
+ {
+ "fixture": "valid/subsections.md",
+ "file": "0003-organize-a-long-requirements-section.md"
+ },
+ {
+ "fixture": "valid/superseded.md",
+ "file": "0004-retire-the-old-flow.md"
+ },
+ {
+ "fixture": "valid/none-sections.md",
+ "file": "0005-keep-every-section-even-when-empty.md"
+ }
+ ],
+ "invalid": [
+ {
+ "fixture": "invalid/missing-section.md",
+ "file": "0001-missing-a-section.md",
+ "code": "OP-C-SECTION-MISSING",
+ "reason": "The eight body sections are all required; Users is absent."
+ },
+ {
+ "fixture": "invalid/out-of-order.md",
+ "file": "0001-sections-out-of-order.md",
+ "code": "OP-C-SECTION-ORDER",
+ "reason": "Sections must appear in the order the standard fixes."
+ },
+ {
+ "fixture": "invalid/id-mismatch.md",
+ "file": "0001-id-does-not-match.md",
+ "code": "OP-C-ID-MISMATCH",
+ "reason": "front-matter id must equal the filename's numeric prefix."
+ },
+ {
+ "fixture": "invalid/bad-status.md",
+ "file": "0001-unknown-status.md",
+ "code": "OP-C-FRONTMATTER",
+ "reason": "status must be one of the seven lifecycle values."
+ },
+ {
+ "fixture": "invalid/missing-title.md",
+ "file": "0001-no-title.md",
+ "code": "OP-C-FRONTMATTER",
+ "reason": "title is required."
+ },
+ {
+ "fixture": "invalid/bad-id-format.md",
+ "file": "0001-two-digit-id.md",
+ "code": "OP-C-FRONTMATTER",
+ "reason": "id must be four digits, zero-padded."
+ },
+ {
+ "fixture": "invalid/unknown-key.md",
+ "file": "0001-unknown-front-matter-key.md",
+ "code": "OP-C-FRONTMATTER",
+ "reason": "Unknown front-matter keys are rejected."
+ },
+ {
+ "fixture": "invalid/superseded-without-replacement.md",
+ "file": "0001-superseded-with-no-replacement.md",
+ "code": "OP-L-SUPERSEDED-BY",
+ "reason": "Superseded must name the PRD that replaces it."
+ },
+ {
+ "fixture": "invalid/duplicate-requirement.md",
+ "file": "0001-duplicate-requirement-id.md",
+ "code": "OP-L-REQ-DUPLICATE",
+ "reason": "Requirement ids must be unique within a PRD."
+ },
+ {
+ "fixture": "invalid/dates-backwards.md",
+ "file": "0001-updated-before-created.md",
+ "code": "OP-L-DATE-ORDER",
+ "reason": "updated cannot precede created."
+ },
+ {
+ "fixture": "invalid/no-front-matter.md",
+ "file": "0001-no-front-matter.md",
+ "code": "OP-P-PARSE",
+ "reason": "A PRD must open with a YAML front-matter block."
+ },
+ {
+ "fixture": "invalid/bad-filename.md",
+ "file": "notes.md",
+ "code": "OP-C-FILENAME",
+ "reason": "A PRD lives at prd/-.md with a four-digit id."
+ }
+ ]
+}
diff --git a/packages/schemas/fixtures/openprd/invalid/bad-filename.md b/packages/schemas/fixtures/openprd/invalid/bad-filename.md
new file mode 100644
index 0000000..ece0f91
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/bad-filename.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Bad filename
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/bad-id-format.md b/packages/schemas/fixtures/openprd/invalid/bad-id-format.md
new file mode 100644
index 0000000..d4e106e
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/bad-id-format.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "1"
+title: Two digit id
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/bad-status.md b/packages/schemas/fixtures/openprd/invalid/bad-status.md
new file mode 100644
index 0000000..aec5c5d
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/bad-status.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Unknown status
+status: Shipped
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/dates-backwards.md b/packages/schemas/fixtures/openprd/invalid/dates-backwards.md
new file mode 100644
index 0000000..1201a63
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/dates-backwards.md
@@ -0,0 +1,43 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Updated before created
+status: Draft
+authors:
+ - a@example.com
+created: 2026-07-20
+updated: 2026-07-01
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md b/packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md
new file mode 100644
index 0000000..d828fc1
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/duplicate-requirement.md
@@ -0,0 +1,42 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Duplicate requirement id
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First.
+- R1 [P1] First again.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/id-mismatch.md b/packages/schemas/fixtures/openprd/invalid/id-mismatch.md
new file mode 100644
index 0000000..e0a6cfa
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/id-mismatch.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0042"
+title: Id does not match
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/missing-section.md b/packages/schemas/fixtures/openprd/invalid/missing-section.md
new file mode 100644
index 0000000..5748625
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/missing-section.md
@@ -0,0 +1,37 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Missing a section
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/missing-title.md b/packages/schemas/fixtures/openprd/invalid/missing-title.md
new file mode 100644
index 0000000..7becd05
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/missing-title.md
@@ -0,0 +1,40 @@
+---
+openprd: "0.2"
+id: "0001"
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/no-front-matter.md b/packages/schemas/fixtures/openprd/invalid/no-front-matter.md
new file mode 100644
index 0000000..ef5603b
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/no-front-matter.md
@@ -0,0 +1,33 @@
+# No front matter
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
diff --git a/packages/schemas/fixtures/openprd/invalid/out-of-order.md b/packages/schemas/fixtures/openprd/invalid/out-of-order.md
new file mode 100644
index 0000000..bea5c62
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/out-of-order.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Sections out of order
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Goals
+
+_None._
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md b/packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md
new file mode 100644
index 0000000..59603a7
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/superseded-without-replacement.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Superseded with no replacement
+status: Superseded
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/invalid/unknown-key.md b/packages/schemas/fixtures/openprd/invalid/unknown-key.md
new file mode 100644
index 0000000..bc1ca5d
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/invalid/unknown-key.md
@@ -0,0 +1,42 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Unknown front matter key
+status: Draft
+authors:
+ - a@example.com
+priority: high
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/valid/bold-requirements.md b/packages/schemas/fixtures/openprd/valid/bold-requirements.md
new file mode 100644
index 0000000..75ed1ff
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/valid/bold-requirements.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0002"
+title: Use bold requirement markers
+status: Review
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- **R1 [P0]** Bold markers parse the same as plain ones.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/valid/full.md b/packages/schemas/fixtures/openprd/valid/full.md
new file mode 100644
index 0000000..3c97377
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/valid/full.md
@@ -0,0 +1,48 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Expand the parked-domain service
+status: Draft
+authors:
+ - anthony@profullstack.com
+created: 2026-07-12
+updated: 2026-07-12
+repo: profullstack/logicsrc
+tags:
+ - growth
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] The service MUST resolve a parked domain within 200 ms at p95.
+- R2 [P1] The service SHOULD report per-domain hit counts.
+- R3 [P2] The service MAY expose a JSON feed of recent lookups.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/valid/minimal.md b/packages/schemas/fixtures/openprd/valid/minimal.md
new file mode 100644
index 0000000..b076ee0
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/valid/minimal.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0001"
+title: Expand the parked-domain service
+status: Draft
+authors:
+ - anthony@profullstack.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/valid/none-sections.md b/packages/schemas/fixtures/openprd/valid/none-sections.md
new file mode 100644
index 0000000..a59d6b4
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/valid/none-sections.md
@@ -0,0 +1,41 @@
+---
+openprd: "0.2"
+id: "0005"
+title: Keep every section even when empty
+status: Accepted
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/valid/subsections.md b/packages/schemas/fixtures/openprd/valid/subsections.md
new file mode 100644
index 0000000..c72debf
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/valid/subsections.md
@@ -0,0 +1,47 @@
+---
+openprd: "0.2"
+id: "0003"
+title: Organize a long requirements section
+status: Draft
+authors:
+ - a@example.com
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+### Identity
+
+- R1 [P0] Capability one.
+
+### Storage
+
+- R2 [P0] Capability two.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/packages/schemas/fixtures/openprd/valid/superseded.md b/packages/schemas/fixtures/openprd/valid/superseded.md
new file mode 100644
index 0000000..ca7e1da
--- /dev/null
+++ b/packages/schemas/fixtures/openprd/valid/superseded.md
@@ -0,0 +1,42 @@
+---
+openprd: "0.2"
+id: "0004"
+title: Retire the old flow
+status: Superseded
+authors:
+ - a@example.com
+superseded-by: "0005"
+---
+
+## Problem
+
+The thing is broken and it costs us money every week.
+
+## Goals
+
+_None._
+
+## Non-Goals
+
+_None._
+
+## Users
+
+_None._
+
+## Requirements
+
+- R1 [P0] First required capability.
+
+## UX Notes
+
+_None._
+
+## Success Metrics
+
+_None._
+
+## Risks & Open Questions
+
+_None._
+
diff --git a/prd/README.md b/prd/README.md
index d2cae8c..cff3687 100644
--- a/prd/README.md
+++ b/prd/README.md
@@ -1,8 +1,13 @@
# LogicSRC PRDs
-Numbered [OpenPRD](../docs/openprd.md) product requirements documents for this repo. One file per PRD at `prd/-.md`, four-digit ids, no gaps. `0000-template.md` is the copy-paste starting point.
+Numbered [OpenPRD](../docs/openprd.md) product requirements documents for this repo. One file
+per PRD at `prd/-.md`, four-digit ids, no gaps. `0000-template.md` is the copy-paste
+starting point.
-Status lives in each file's front-matter and is the source of truth: `Draft → Review → Accepted → Final`, or `Rejected` / `Withdrawn` / `Superseded`.
+Status lives in each file's front-matter and is the source of truth:
+`Draft → Review → Accepted → Final`, or `Rejected` / `Withdrawn` / `Superseded`.
+
+
| ID | Title | Status | Tags |
| --- | --- | --- | --- |