logicsrc/packages/openprd/src/scaffold.ts
Anthony Ettinger 296775e003
feat(openprd): implement the OpenPRD standard — engine, CLI, conformance bundle (#100)
OpenPRD has existed as a document (docs/openprd.md), a front-matter schema, a
template, and this repo's prd/ collection. Nothing enforced it. This adds the
reference implementation.

@logicsrc/openprd
  - parser: front-matter + the eight `##` sections + numbered requirements.
    `###` stays content so a long Requirements section can be organized, and
    headings or R#-shaped lines inside code fences are ignored
  - validation splits the standard's four conformance rules (filename,
    front-matter schema, id-matches-prefix, eight sections in order) from
    lint (empty section, missing priority tag, numbering gaps, duplicate R#,
    date order, one-sided supersession, stale index). Conformance failures are
    errors; --strict promotes the rest. Stable codes, file, line, hint
  - collection rules the per-file view cannot see: unique ids, monotonic
    numbering with no gaps, 0000 reserved for the template, cross-references
    that resolve
  - lifecycle enforced rather than advisory: Draft cannot jump to Final,
    terminal statuses do not resume, Superseded must name its replacement
  - deterministic index generation, so `prd index` is idempotent and CI can
    diff it
  - front-matter rewriting that leaves the body byte-identical
  - the optional LogicSRC task bridge the standard describes: each R# becomes
    one logicsrc.task, validated against logicsrc-task.schema.json before it
    is emitted; creator DID derived from the author email

CLI: logicsrc prd init|new|list|show|validate|lint|index|status|next|tasks|
export. Exit codes stable for CI (0 ok, 1 invalid, 2 usage, 3 not found).

Conformance bundle: packages/schemas/fixtures/openprd/ — 6 documents that must
validate and 12 that must fail, each naming the error code it must produce.
Several rules depend on the filename, so every fixture records the name it is
validated as.

Docs: an Implementation section in docs/openprd.md (CLI, validation model,
task bridge, conformance bundle), the spec added to the site's docs surface,
nav and sitemap entries, and a README section.

Verification: 76 new tests; full monorepo build and all 451 workspace tests
pass. The suite dogfoods this repo — prd/ validates with zero errors and zero
warnings, the embedded template is byte-identical to docs/openprd/0000-
template.md, and all 210 requirements in PRD 0001 map to schema-valid tasks.
prd/README.md is regenerated by the tool it now ships.

Refs: docs/openprd.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 04:11:07 -07:00

209 lines
6 KiB
TypeScript

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 };
}