Closes the PRD-lifecycle gap surfaced by the user: when sandcastle
finishes an epic's last task, the seed PRD should auto-flip from
approved -> shipped. Builds the mechanism, wires it into the work
CLI + state index + reviewer prompt + docs.
scripts/work/prd-ship.mjs (new):
- parseFrontmatter / serializeFrontmatter — minimal YAML-ish parser
sufficient for PRD frontmatter (scalar + list shapes)
- flipPrdStatus — pure function: takes PRD text, returns new text
with status=shipped + shipped=<date> + optional shipping-commits.
Refuses to flip draft, idempotent fail-soft on already-shipped,
rejects unexpected statuses
- deriveShippingCommits — best-effort git log of the linked epic
folder for the --auto-commits flag
- findPrdPath — id -> path lookup under docs/work/prds/
- runCli — wiring for `pnpm work prd-ship <id> [--commits|--auto-commits]`
scripts/work/prd-ship.test.mjs (new, 17 tests):
- Frontmatter parser handles scalars + lists + missing frontmatter
- flipPrdStatus covers all transitions + refusals + body/key preservation
- findPrdPath + serializeFrontmatter coverage
scripts/work/state-builder.mjs:
- Epic entries gain a `prd` field
- New computeNeedsPrdShip surfaces epics done with PRD status not yet
shipped: state.needs_prd_ship[] with action commands
scripts/work/cli.mjs:
- New subcommand `pnpm work prd-ship <id>`
.sandcastle/reviewer.prompt.md:
- "Epic close-out: PRD status flip" section instructing reviewer to
check _state.json.needs_prd_ship and run the suggested action
- JSON output extends with prd_shipped: "<id>" | null
docs/work/README.md:
- "PRD lifecycle" section documenting the 4 statuses + auto-flip
Future PRDs follow the lifecycle automatically: decomposer refuses
draft, human flips to approved, sandcastle ships the epic, reviewer
runs prd-ship on the final task, PRD lands as shipped with its
commit trail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
5.4 KiB
JavaScript
191 lines
5.4 KiB
JavaScript
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/);
|
|
});
|
|
});
|