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 <dir>", "collection directory", DEFAULT_DIR) + .option("--author <list>", "comma-separated authors") + .option("--status <status>", "initial status", "Draft") + .option("--repo <owner/name>", "target repository") + .option("--tags <list>", "comma-separated tags") + .option("--owner <did>", "accountable owner") + .option("--discussion <url>", "URL of the discussion thread") + .option("--supersedes <id>", "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 <dir>", "collection directory", DEFAULT_DIR) + .option("--status <list>", "comma-separated statuses to include") + .option("--tag <tag>", "only PRDs carrying this tag") + .option("--format <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("<ref>", "id, number, slug, or filename") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--format <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 <format>", "text, json, yaml, or markdown", "text") + .option("--id <ref>", "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 <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 <text>", "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("<ref>", "id, number, slug, or filename") + .argument("[status]", "new status; omit to list the allowed next steps") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--superseded-by <id>", "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<string, string | null> = { 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("<ref>", "id, number, slug, or filename") + .option("--dir <dir>", "collection directory", DEFAULT_DIR) + .option("--creator <did>", "LogicSRC DID for the created tasks") + .option("--board <path>", "board path, e.g. /prd/0001") + .option("--priority <list>", "only these priorities, e.g. P0,P1") + .option("--format <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 <format>", "json, ndjson, yaml, or markdown", "json") + .option("--out <file>", "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/<id>-<slug>.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`.", + "", + "<!-- generated by `logicsrc prd index --write`; edit the PRDs, not this table -->", + "", + "| 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<PrdStatus, PrdStatus[]> = { + 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, string | null> +): 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<string>(); + + 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/<id>`. */ + 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<Severity, number>; + 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<string, string>): ReturnType<typeof loadPrdCollection> { + 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/<id>-<slug>.md with a four-digit <id> + * - 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 <id>-<slug>.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<number, number>(); + 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<string, PrdDocument[]>(); + 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<Severity, number> = { 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<Severity, number> = { 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/<id>-<slug>.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/<id>-<slug>.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/<id>-<slug>.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`. + +<!-- generated by `logicsrc prd index --write`; edit the PRDs, not this table --> | ID | Title | Status | Tags | | --- | --- | --- | --- |