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:
@@ -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": "<epic-slug>",
|
||||
"prd": "<prd-id>",
|
||||
"prd_status": "approved",
|
||||
"action": "pnpm work prd-ship <prd-id> --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: <today>`, 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": "<prd-id>" | null,
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -14,3 +14,13 @@ checkboxes.
|
||||
- `<epic-slug>/<story-slug>/<task-slug>.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 <prd-id>` 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.
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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 <rebuild-state|status|next|ready|blocked|dispatch>",
|
||||
"Usage: pnpm work <rebuild-state|status|next|ready|blocked|dispatch|prd-ship>",
|
||||
);
|
||||
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 <id> 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();
|
||||
|
||||
237
scripts/work/prd-ship.mjs
Normal file
237
scripts/work/prd-ship.mjs
Normal file
@@ -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: <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;
|
||||
}
|
||||
190
scripts/work/prd-ship.test.mjs
Normal file
190
scripts/work/prd-ship.test.mjs
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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