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>
This commit is contained in:
@@ -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 <id>`.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user