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>
239 lines
7.4 KiB
JavaScript
239 lines
7.4 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const SKIP_FOLDERS = new Set(["_templates", "prds"]);
|
|
const SKIP_FILES = new Set(["README.md", "_state.json"]);
|
|
|
|
/**
|
|
* Walk the `docs/work/` tree starting at `workRoot` and return a structured
|
|
* state object. Each epic folder must contain `_epic.md`; each story
|
|
* subfolder must contain `_story.md`.
|
|
*
|
|
* Returns:
|
|
* {
|
|
* updated_at: ISO string,
|
|
* epics: {
|
|
* <epic-id>: {
|
|
* status: "todo" | "in-progress" | "done",
|
|
* title: string,
|
|
* stories: {
|
|
* <story-id>: {
|
|
* status: "todo" | "in-progress" | "done",
|
|
* title: string,
|
|
* ac_total: number, // total checkboxes in Tasks section
|
|
* ac_completed: number, // - [x] checkboxes
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
*/
|
|
export function buildState(workRoot) {
|
|
const state = {
|
|
updated_at: new Date().toISOString(),
|
|
epics: {},
|
|
};
|
|
|
|
if (!fs.existsSync(workRoot)) return state;
|
|
|
|
for (const entry of fs.readdirSync(workRoot)) {
|
|
if (SKIP_FOLDERS.has(entry) || SKIP_FILES.has(entry)) continue;
|
|
const epicDir = path.join(workRoot, entry);
|
|
if (!fs.statSync(epicDir).isDirectory()) continue;
|
|
const epicFile = path.join(epicDir, "_epic.md");
|
|
if (!fs.existsSync(epicFile)) continue;
|
|
|
|
const epicMeta = parseFrontmatter(epicFile);
|
|
const epicEntry = {
|
|
status: epicMeta.status ?? "todo",
|
|
title: epicMeta.title ?? entry,
|
|
prd: epicMeta.prd && epicMeta.prd !== "null" ? epicMeta.prd : null,
|
|
stories: {},
|
|
};
|
|
|
|
for (const sub of fs.readdirSync(epicDir)) {
|
|
const subPath = path.join(epicDir, sub);
|
|
if (!fs.statSync(subPath).isDirectory()) continue;
|
|
const storyFile = path.join(subPath, "_story.md");
|
|
if (!fs.existsSync(storyFile)) continue;
|
|
const storyMeta = parseFrontmatter(storyFile);
|
|
const storyContent = fs.readFileSync(storyFile, "utf8");
|
|
const { total, completed } = countTaskCheckboxes(storyContent);
|
|
epicEntry.stories[storyMeta.id ?? sub] = {
|
|
status: storyMeta.status ?? "todo",
|
|
title: storyMeta.title ?? sub,
|
|
ac_total: total,
|
|
ac_completed: completed,
|
|
depends_on: Array.isArray(storyMeta["depends-on"])
|
|
? storyMeta["depends-on"]
|
|
: [],
|
|
blocks: Array.isArray(storyMeta.blocks) ? storyMeta.blocks : [],
|
|
};
|
|
}
|
|
|
|
state.epics[epicMeta.id ?? entry] = epicEntry;
|
|
}
|
|
|
|
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.
|
|
*
|
|
* "ready" = not done AND all depends_on stories are done
|
|
* "blocked" = not done AND at least one depends_on story is not done
|
|
*
|
|
* depends_on references are either same-epic (just story id) or cross-epic
|
|
* (`<epic>/<story>`).
|
|
*
|
|
* Returns { ready: [...], blocked: [...] } where each entry is:
|
|
* { epic, story, title, [waiting_on?] }
|
|
*/
|
|
export function computeReadyBlocked(state) {
|
|
// Build a flat map: "<epic>/<story>" => { status, title, depends_on }
|
|
const flat = new Map();
|
|
for (const [epic, epicEntry] of Object.entries(state.epics)) {
|
|
for (const [story, storyEntry] of Object.entries(epicEntry.stories)) {
|
|
flat.set(`${epic}/${story}`, {
|
|
epic,
|
|
story,
|
|
status: storyEntry.status,
|
|
title: storyEntry.title,
|
|
depends_on: storyEntry.depends_on,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Resolve a depends_on reference (which may be `<epic>/<story>` or just `<story>`)
|
|
// relative to the current epic.
|
|
function resolveRef(ref, currentEpic) {
|
|
if (ref.includes("/")) return ref;
|
|
return `${currentEpic}/${ref}`;
|
|
}
|
|
|
|
const ready = [];
|
|
const blocked = [];
|
|
for (const entry of flat.values()) {
|
|
if (entry.status === "done") continue;
|
|
const refs = entry.depends_on.map((r) => resolveRef(r, entry.epic));
|
|
const waitingOn = refs.filter((r) => {
|
|
const dep = flat.get(r);
|
|
return !dep || dep.status !== "done";
|
|
});
|
|
if (waitingOn.length === 0) {
|
|
ready.push({ epic: entry.epic, story: entry.story, title: entry.title });
|
|
} else {
|
|
blocked.push({
|
|
epic: entry.epic,
|
|
story: entry.story,
|
|
title: entry.title,
|
|
waiting_on: waitingOn,
|
|
});
|
|
}
|
|
}
|
|
return { ready, blocked };
|
|
}
|
|
|
|
/**
|
|
* Read a markdown file's YAML frontmatter (between leading `---` delimiters)
|
|
* and return a flat object of string or array values. Numeric / bool values are
|
|
* left as strings; array values like `[a, b, "c"]` are parsed into JS arrays.
|
|
*/
|
|
export function parseFrontmatter(filePath) {
|
|
const src = fs.readFileSync(filePath, "utf8");
|
|
const match = src.match(/^---\n([\s\S]+?)\n---/);
|
|
if (!match) return {};
|
|
const out = {};
|
|
for (const line of match[1].split("\n")) {
|
|
const m = line.match(/^([\w-]+):\s*(.*)$/);
|
|
if (!m) continue;
|
|
let value = m[2].trim();
|
|
// Array values like [a, b, "c"] or []
|
|
if (value.startsWith("[") && value.endsWith("]")) {
|
|
const inner = value.slice(1, -1).trim();
|
|
if (inner === "") {
|
|
out[m[1]] = [];
|
|
} else {
|
|
out[m[1]] = inner.split(",").map((s) => {
|
|
let v = s.trim();
|
|
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
|
|
return v;
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
// Strings (with optional quotes)
|
|
if (value.startsWith('"') && value.endsWith('"'))
|
|
value = value.slice(1, -1);
|
|
if (value.startsWith("'") && value.endsWith("'"))
|
|
value = value.slice(1, -1);
|
|
out[m[1]] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Count `- [x]` and `- [ ]` checkboxes inside the file's `## Tasks` section.
|
|
* Returns { total, completed }.
|
|
*/
|
|
export function countTaskCheckboxes(content) {
|
|
const lines = content.split("\n");
|
|
let inTasks = false;
|
|
let total = 0;
|
|
let completed = 0;
|
|
for (const line of lines) {
|
|
if (line.startsWith("## ")) {
|
|
inTasks = /^##\s+Tasks\b/i.test(line);
|
|
continue;
|
|
}
|
|
if (!inTasks) continue;
|
|
const m = line.match(/^[\s>-]*\[(.)\]/);
|
|
if (m) {
|
|
total++;
|
|
if (m[1] === "x" || m[1] === "X") completed++;
|
|
}
|
|
}
|
|
return { total, completed };
|
|
}
|