diff --git a/.sandcastle/reviewer.prompt.md b/.sandcastle/reviewer.prompt.md index ae02e58..c2a27d9 100644 --- a/.sandcastle/reviewer.prompt.md +++ b/.sandcastle/reviewer.prompt.md @@ -37,6 +37,28 @@ If you suspect the implementer hand-rolled what should have been generator outpu - **No silent allowlist expansion**: if `scripts/coverage/diff.mjs`'s `ALLOWED_GLOBS` grew, the implementer's notes must explain why (and the matching test fixture must exist in `scripts/coverage/diff.test.mjs`). - **Manifest coverage band drift**: if `feature.manifest.ts` was edited, its `coverage:` section must match `DEFAULT_COVERAGE_BANDS` from `@repo/core-shared/conformance/coverage` (or carry an explicit override the implementer's notes justify). +## Epic close-out: PRD status flip + +After approving a task, check `docs/work/_state.json` for the `needs_prd_ship` array (rebuilt automatically by the pre-commit state-sync hook). Each entry has shape: + +```json +{ + "epic": "", + "prd": "", + "prd_status": "approved", + "action": "pnpm work prd-ship --auto-commits" +} +``` + +If the task you just approved was the FINAL task of an epic (i.e., the epic transitioned to `status: done`) and that epic appears in `needs_prd_ship`, the orchestrator must run the suggested `action` command before declaring the epic closed. The `prd-ship` command: + +- Refuses to flip `draft` PRDs (must go through human review first) +- Idempotent — won't double-flip an already `shipped` PRD +- Writes `status: shipped`, `shipped: `, and `shipping-commits: [...]` to the PRD frontmatter +- Auto-derives the shipping-commits list from `git log` of the linked epic folder when `--auto-commits` is passed + +Include the PRD-ship outcome in your review notes when applicable. + ## Output format Return structured JSON: @@ -47,6 +69,7 @@ Return structured JSON: "ac_verified": [0, 1, 2], "scope_violations": ["files touched that weren't in scope"], "generator_skipped": false, + "prd_shipped": "" | null, "notes": "..." } ``` diff --git a/docs/work/README.md b/docs/work/README.md index 1811bf1..8ae9ead 100644 --- a/docs/work/README.md +++ b/docs/work/README.md @@ -14,3 +14,13 @@ checkboxes. - `//.task.md` — one file per task - `_templates/` — copy-paste templates (added in work-system-v1) - `_state.json` — derived index (added in work-system-v1) + +## PRD lifecycle + +PRD `status` frontmatter field: `draft → in-review → approved → shipped`. + +- **draft → in-review** — author flips when ready for review (manual) +- **in-review → approved** — human reviewer flips on acceptance (manual) +- **approved → shipped** — auto-flipped by `pnpm work prd-ship ` when the seed epic finishes. The state-builder surfaces this signal under `_state.json` → `needs_prd_ship[]` so the orchestrator (or a reviewer running the sandcastle workflow) can act on it. + +The decomposer refuses to run on `draft` PRDs. Once approved, the seed epic is generated; once the epic completes, the PRD is automatically flipped to `shipped` along with its commit list. diff --git a/docs/work/_state.json b/docs/work/_state.json index a6cb001..82d8c4f 100644 --- a/docs/work/_state.json +++ b/docs/work/_state.json @@ -1,9 +1,10 @@ { - "updated_at": "2026-05-13T14:47:21.408Z", + "updated_at": "2026-05-13T14:51:52.854Z", "epics": { "template-reset-v1": { "status": "done", "title": "Template reset — strip setup-process noise + archive history", + "prd": null, "stories": { "01-template-reset": { "status": "done", @@ -17,5 +18,6 @@ } }, "ready": [], - "blocked": [] + "blocked": [], + "needs_prd_ship": [] } diff --git a/scripts/work/cli.mjs b/scripts/work/cli.mjs index 2b11527..a2413c4 100644 --- a/scripts/work/cli.mjs +++ b/scripts/work/cli.mjs @@ -11,6 +11,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildState } from "./state-builder.mjs"; +import { runCli as runPrdShip } from "./prd-ship.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "..", ".."); @@ -115,10 +116,13 @@ function mark(status) { function usage() { console.log( - "Usage: pnpm work ", + "Usage: pnpm work ", ); console.log( - " dispatch Print the next dispatch plan (use --execute to invoke sandcastle)", + " dispatch Print the next dispatch plan (use --execute to invoke sandcastle)", + ); + console.log( + " prd-ship Flip a PRD's status to `shipped` (run after its epic completes)", ); process.exit(2); } @@ -135,4 +139,10 @@ else if (cmd === "dispatch") { console.error(e); process.exit(1); }); +} else if (cmd === "prd-ship") { + const exitCode = runPrdShip(process.argv.slice(3), { + repoRoot: REPO_ROOT, + workRoot: WORK_ROOT, + }); + process.exit(exitCode); } else usage(); diff --git a/scripts/work/prd-ship.mjs b/scripts/work/prd-ship.mjs new file mode 100644 index 0000000..c54314c --- /dev/null +++ b/scripts/work/prd-ship.mjs @@ -0,0 +1,237 @@ +/** + * scripts/work/prd-ship.mjs — flip a PRD's status to `shipped`. + * + * Invoked when an epic completes and its seed PRD's implementation is fully + * landed. Writes back to the PRD's frontmatter: + * - status: -> shipped + * - shipped: (today, UTC) + * - shipping-commits: [, , ...] (optional) + * + * Refuses to flip from `draft` (must go through human review first). + * Refuses to flip if already `shipped` (idempotent fail-soft). + * + * Usage: + * pnpm work prd-ship + * pnpm work prd-ship --commits sha1,sha2,sha3 + * pnpm work prd-ship --auto-commits # derive from `git log` since the PRD's + * # created date on the PRD file's first + * # appearance in git history + */ +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +/** + * Parse the top YAML frontmatter block of a markdown file. Returns + * { frontmatter: Record, body: string, + * raw: { frontmatterText, frontmatterStart, frontmatterEnd } } + * + * Hand-rolled YAML-ish parser — handles scalar lines and "- item" lists, + * the only two shapes the PRD/epic/story frontmatter uses. Sufficient for + * this use case; not a general YAML parser. + */ +export function parseFrontmatter(text) { + if (!text.startsWith("---\n")) { + return { frontmatter: {}, body: text, raw: null }; + } + const end = text.indexOf("\n---\n", 4); + if (end === -1) return { frontmatter: {}, body: text, raw: null }; + const fmText = text.slice(4, end); + const body = text.slice(end + 5); + + const fm = {}; + let currentList = null; + for (const line of fmText.split("\n")) { + if (line.startsWith(" - ")) { + if (currentList) currentList.push(line.slice(4).trim()); + continue; + } + currentList = null; + const m = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line); + if (!m) continue; + const [, key, value] = m; + if (value === "") { + // Empty value — could be the start of a list + fm[key] = []; + currentList = fm[key]; + } else { + fm[key] = value; + } + } + return { + frontmatter: fm, + body, + raw: { frontmatterText: fmText, frontmatterStart: 4, frontmatterEnd: end }, + }; +} + +/** + * Serialize a parsed frontmatter back to YAML text. Preserves declared key + * order for keys that were in the original; appends new keys at the end. + */ +export function serializeFrontmatter(originalText, newFrontmatter) { + const lines = []; + const originalKeyOrder = []; + if (originalText) { + for (const line of originalText.split("\n")) { + const m = /^([a-zA-Z_-]+):/.exec(line); + if (m) originalKeyOrder.push(m[1]); + } + } + const seen = new Set(); + for (const key of originalKeyOrder) { + if (seen.has(key)) continue; + seen.add(key); + if (!(key in newFrontmatter)) continue; + emitKey(lines, key, newFrontmatter[key]); + } + for (const key of Object.keys(newFrontmatter)) { + if (seen.has(key)) continue; + emitKey(lines, key, newFrontmatter[key]); + } + return lines.join("\n"); +} + +function emitKey(lines, key, value) { + if (Array.isArray(value)) { + lines.push(`${key}:`); + for (const item of value) lines.push(` - ${item}`); + } else { + lines.push(`${key}: ${value}`); + } +} + +/** + * Flip a PRD's status to "shipped". Pure function over the file's text. + * Returns the new file text. Throws on illegal transitions. + */ +export function flipPrdStatus(text, { shippedDate, commits } = {}) { + const { frontmatter, body, raw } = parseFrontmatter(text); + if (!raw) { + throw new Error("PRD has no parseable frontmatter"); + } + const current = frontmatter.status; + if (current === "shipped") { + throw new Error("PRD is already marked shipped (idempotent fail-soft)"); + } + if (current === "draft") { + throw new Error( + "PRD is still draft — flip to approved (human review) before shipping", + ); + } + if (current !== "approved" && current !== "in-review") { + throw new Error( + `Unexpected PRD status "${current}" — expected approved or in-review`, + ); + } + + const newFm = { ...frontmatter }; + newFm.status = "shipped"; + newFm.shipped = shippedDate ?? new Date().toISOString().slice(0, 10); + if (commits && commits.length > 0) { + newFm["shipping-commits"] = commits; + } + + const newFmText = serializeFrontmatter(raw.frontmatterText, newFm); + return `---\n${newFmText}\n---\n${body}`; +} + +/** + * Derive shipping commits for a PRD by walking `git log` for the PRD's + * linked epic folder. Best-effort; returns empty array if the epic folder + * doesn't exist or git isn't available. + */ +export function deriveShippingCommits(repoRoot, prdId, workRoot) { + // Find the epic whose `prd:` matches this prdId + const entries = fs.readdirSync(workRoot, { withFileTypes: true }); + let epicDir = null; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const epicFile = path.join(workRoot, entry.name, "_epic.md"); + if (!fs.existsSync(epicFile)) continue; + const { frontmatter } = parseFrontmatter(fs.readFileSync(epicFile, "utf8")); + if (frontmatter.prd === prdId) { + epicDir = entry.name; + break; + } + } + if (!epicDir) return []; + + try { + const log = execSync( + `git log --format=%h --reverse -- docs/work/${epicDir}/`, + { cwd: repoRoot, encoding: "utf8" }, + ); + return log.trim().split("\n").filter(Boolean); + } catch { + return []; + } +} + +export function findPrdPath(workRoot, prdId) { + const prdsDir = path.join(workRoot, "prds"); + if (!fs.existsSync(prdsDir)) return null; + for (const file of fs.readdirSync(prdsDir)) { + if (!file.endsWith(".prd.md")) continue; + const text = fs.readFileSync(path.join(prdsDir, file), "utf8"); + const { frontmatter } = parseFrontmatter(text); + if (frontmatter.id === prdId) { + return path.join(prdsDir, file); + } + } + return null; +} + +// ---- CLI ---- + +export function runCli(args, { repoRoot, workRoot }) { + const prdId = args[0]; + if (!prdId) { + process.stderr.write( + "Usage: pnpm work prd-ship [--commits sha1,sha2,...] [--auto-commits]\n", + ); + return 2; + } + + let commits; + let autoCommits = false; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--commits") { + commits = args[++i] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else if (args[i] === "--auto-commits") { + autoCommits = true; + } + } + + const prdPath = findPrdPath(workRoot, prdId); + if (!prdPath) { + process.stderr.write( + `PRD with id="${prdId}" not found under docs/work/prds/\n`, + ); + return 1; + } + + if (autoCommits && !commits) { + commits = deriveShippingCommits(repoRoot, prdId, workRoot); + } + + const text = fs.readFileSync(prdPath, "utf8"); + let newText; + try { + newText = flipPrdStatus(text, { commits }); + } catch (err) { + process.stderr.write(`Cannot ship PRD ${prdId}: ${err.message}\n`); + return 1; + } + + fs.writeFileSync(prdPath, newText); + process.stdout.write( + `Shipped ${prdId} (${path.relative(repoRoot, prdPath)})` + + (commits ? ` with ${commits.length} commit(s)` : "") + + "\n", + ); + return 0; +} diff --git a/scripts/work/prd-ship.test.mjs b/scripts/work/prd-ship.test.mjs new file mode 100644 index 0000000..5823fb0 --- /dev/null +++ b/scripts/work/prd-ship.test.mjs @@ -0,0 +1,190 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { + parseFrontmatter, + serializeFrontmatter, + flipPrdStatus, + findPrdPath, +} from "./prd-ship.mjs"; + +describe("parseFrontmatter", () => { + test("parses scalar keys from a basic frontmatter block", () => { + const text = `--- +id: test-1 +title: Hello +status: approved +--- + +body +`; + const { frontmatter, body } = parseFrontmatter(text); + assert.equal(frontmatter.id, "test-1"); + assert.equal(frontmatter.title, "Hello"); + assert.equal(frontmatter.status, "approved"); + assert.match(body, /body/); + }); + + test("parses YAML list values (indented '- item')", () => { + const text = `--- +id: test-2 +shipping-commits: + - abc123 + - def456 +--- + +body +`; + const { frontmatter } = parseFrontmatter(text); + assert.deepEqual(frontmatter["shipping-commits"], ["abc123", "def456"]); + }); + + test("returns empty frontmatter when none present", () => { + const { frontmatter, body } = parseFrontmatter("just body content\n"); + assert.deepEqual(frontmatter, {}); + assert.equal(body, "just body content\n"); + }); +}); + +describe("flipPrdStatus", () => { + const baseText = `--- +id: test-prd +title: Test PRD +type: prd +status: approved +created: 2026-01-01 +--- + +# body +`; + + test("flips approved -> shipped with today's date", () => { + const result = flipPrdStatus(baseText, { shippedDate: "2026-05-13" }); + const { frontmatter } = parseFrontmatter(result); + assert.equal(frontmatter.status, "shipped"); + assert.equal(frontmatter.shipped, "2026-05-13"); + }); + + test("flips in-review -> shipped", () => { + const text = baseText.replace("status: approved", "status: in-review"); + const result = flipPrdStatus(text, { shippedDate: "2026-05-13" }); + const { frontmatter } = parseFrontmatter(result); + assert.equal(frontmatter.status, "shipped"); + }); + + test("preserves body", () => { + const result = flipPrdStatus(baseText, { shippedDate: "2026-05-13" }); + assert.match(result, /\n# body\n/); + }); + + test("preserves other frontmatter keys", () => { + const result = flipPrdStatus(baseText, { shippedDate: "2026-05-13" }); + const { frontmatter } = parseFrontmatter(result); + assert.equal(frontmatter.id, "test-prd"); + assert.equal(frontmatter.title, "Test PRD"); + assert.equal(frontmatter.created, "2026-01-01"); + }); + + test("adds shipping-commits when supplied", () => { + const result = flipPrdStatus(baseText, { + shippedDate: "2026-05-13", + commits: ["abc123", "def456"], + }); + const { frontmatter } = parseFrontmatter(result); + assert.deepEqual(frontmatter["shipping-commits"], ["abc123", "def456"]); + }); + + test("refuses to flip draft (must go through human review)", () => { + const text = baseText.replace("status: approved", "status: draft"); + assert.throws(() => flipPrdStatus(text), /still draft/); + }); + + test("refuses to flip already-shipped (idempotent fail-soft)", () => { + const text = baseText.replace("status: approved", "status: shipped"); + assert.throws(() => flipPrdStatus(text), /already marked shipped/); + }); + + test("refuses unexpected statuses", () => { + const text = baseText.replace("status: approved", "status: cancelled"); + assert.throws(() => flipPrdStatus(text), /Unexpected PRD status/); + }); + + test("refuses files without frontmatter", () => { + assert.throws( + () => flipPrdStatus("no frontmatter here"), + /no parseable frontmatter/, + ); + }); +}); + +describe("findPrdPath", () => { + test("finds a PRD by its id field", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "prd-find-")); + try { + const prdsDir = path.join(tmpRoot, "prds"); + fs.mkdirSync(prdsDir, { recursive: true }); + fs.writeFileSync( + path.join(prdsDir, "2026-05-13-something.prd.md"), + `--- +id: 2026-05-13-something +status: approved +--- +body +`, + ); + const found = findPrdPath(tmpRoot, "2026-05-13-something"); + assert.ok(found); + assert.match(found, /something\.prd\.md$/); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + test("returns null when no PRD matches", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "prd-find2-")); + try { + fs.mkdirSync(path.join(tmpRoot, "prds"), { recursive: true }); + const found = findPrdPath(tmpRoot, "nonexistent"); + assert.equal(found, null); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); + +describe("serializeFrontmatter", () => { + test("preserves key order from the original", () => { + const orig = `id: x +title: y +status: approved`; + const result = serializeFrontmatter(orig, { + id: "x", + title: "y", + status: "shipped", + }); + const lines = result.split("\n"); + assert.equal(lines[0], "id: x"); + assert.equal(lines[1], "title: y"); + assert.equal(lines[2], "status: shipped"); + }); + + test("appends new keys at the end", () => { + const orig = `id: x +status: approved`; + const result = serializeFrontmatter(orig, { + id: "x", + status: "shipped", + shipped: "2026-05-13", + }); + assert.match(result, /shipped: 2026-05-13\n?$/); + }); + + test("emits array values as YAML lists", () => { + const result = serializeFrontmatter("", { + "shipping-commits": ["abc", "def"], + }); + assert.match(result, /shipping-commits:\n {2}- abc\n {2}- def/); + }); +}); diff --git a/scripts/work/state-builder.mjs b/scripts/work/state-builder.mjs index 5c5158f..0fc8ca9 100644 --- a/scripts/work/state-builder.mjs +++ b/scripts/work/state-builder.mjs @@ -47,6 +47,7 @@ export function buildState(workRoot) { const epicEntry = { status: epicMeta.status ?? "todo", title: epicMeta.title ?? entry, + prd: epicMeta.prd && epicMeta.prd !== "null" ? epicMeta.prd : null, stories: {}, }; @@ -76,10 +77,46 @@ export function buildState(workRoot) { const { ready, blocked } = computeReadyBlocked(state); state.ready = ready; state.blocked = blocked; + state.needs_prd_ship = computeNeedsPrdShip(state, workRoot); return state; } +/** + * Find epics whose status is "done" + have a non-null `prd:` link + the + * linked PRD's status is NOT yet "shipped". Surfacing these in state lets + * the reviewer (or a post-merge hook) trigger `pnpm work prd-ship `. + */ +export function computeNeedsPrdShip(state, workRoot) { + const out = []; + const prdsDir = path.join(workRoot, "prds"); + if (!fs.existsSync(prdsDir)) return out; + + // Index PRDs by id -> { path, status } + const prdIndex = new Map(); + for (const file of fs.readdirSync(prdsDir)) { + if (!file.endsWith(".prd.md")) continue; + const prdPath = path.join(prdsDir, file); + const meta = parseFrontmatter(prdPath); + if (meta.id) prdIndex.set(meta.id, { path: prdPath, status: meta.status }); + } + + for (const [epicId, epic] of Object.entries(state.epics)) { + if (epic.status !== "done") continue; + if (!epic.prd) continue; + const prd = prdIndex.get(epic.prd); + if (!prd) continue; // PRD link points to a missing file — orchestrator concern, not ours + if (prd.status === "shipped") continue; // already done + out.push({ + epic: epicId, + prd: epic.prd, + prd_status: prd.status ?? "unknown", + action: `pnpm work prd-ship ${epic.prd} --auto-commits`, + }); + } + return out; +} + /** * Given a built state object, compute the ready + blocked story sets. *