Files
agentic-dev/scripts/work/prd-ship.mjs
Danijel Martinek 32d20872e3 feat(work): pnpm work prd-ship + auto-flip integration in sandcastle
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>
2026-05-13 16:51:48 +02:00

238 lines
7.1 KiB
JavaScript

/**
* 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: <approved|in-review|draft> -> shipped
* - shipped: <ISO date> (today, UTC)
* - shipping-commits: [<sha1>, <sha2>, ...] (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 <prd-id>
* pnpm work prd-ship <prd-id> --commits sha1,sha2,sha3
* pnpm work prd-ship <prd-id> --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<string, string|string[]>, 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 <prd-id> [--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;
}