#!/usr/bin/env node /** * pnpm work — CLI for the local work-system. Routes subcommands to their * respective modules (state-builder, dispatch, decompose, prd-ship). Each * subcommand module also exposes a `runCli(args)` entry point that this * file calls directly; the sibling modules NEVER run as a side effect of * being imported. * * Subcommands: * rebuild-state Rewrites docs/work/_system/_state.json from the current markdown * status Prints a tree of all epics + their stories * next Prints the first ready story (or "All done" / "Blocked: ...") * ready Prints every ready story * blocked Prints every blocked story + what each is waiting on * dispatch Print the next dispatch plan; with --execute invokes * sandcastle to run the implementer + reviewer pair. * --execute LOOPS through every ready task by default; * bound with --once or --max-tasks N. After each * approved slice the orchestrator ticks the bullet, * flips story/epic status if complete, and commits * the state mutation as `chore(work): ...` on top of * the implementer's slice commit. * decompose Validate an approved PRD + print the decompose plan; * with --execute invokes sandcastle's decomposer agent * to write the epic folder + per-story files * prd-ship Flip a PRD's status to `shipped` (run after its * seed epic completes); --commits / --auto-commits * optional; idempotent on already-shipped */ 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"; import { runCli as runDecompose } from "./decompose.mjs"; import { runCli as runDispatch } from "./dispatch.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const WORK_ROOT = path.join(REPO_ROOT, "docs", "work"); const SYSTEM_DIR = path.join(WORK_ROOT, "_system"); const STATE_FILE = path.join(SYSTEM_DIR, "_state.json"); function rebuildState() { const state = buildState(WORK_ROOT); if (!fs.existsSync(SYSTEM_DIR)) fs.mkdirSync(SYSTEM_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n"); console.log( `Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`, ); } function printStatus() { const state = buildState(WORK_ROOT); const epicIds = Object.keys(state.epics).sort(); if (epicIds.length === 0) { console.log("No epics found under docs/work/."); return; } for (const epicId of epicIds) { const epic = state.epics[epicId]; const storyIds = Object.keys(epic.stories).sort(); const storyTotals = storyIds.reduce( (acc, sid) => ({ total: acc.total + epic.stories[sid].ac_total, done: acc.done + epic.stories[sid].ac_completed, }), { total: 0, done: 0 }, ); const epicMark = mark(epic.status); console.log( `${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`, ); for (const sid of storyIds) { const s = epic.stories[sid]; console.log( ` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`, ); } } } function printNext() { const state = buildState(WORK_ROOT); if (state.ready.length === 0) { if (state.blocked.length > 0) { console.log("No ready stories. Blocked:"); for (const b of state.blocked) { console.log( ` ${b.epic} / ${b.story} — waiting on: ${b.waiting_on.join(", ")}`, ); } } else { console.log("All epics + stories are done. ✓"); } return; } const r = state.ready[0]; console.log(`${r.epic} / ${r.story} — ${r.title}`); console.log(` (use \`pnpm work ready\` to see all ready stories)`); } function printReady() { const state = buildState(WORK_ROOT); if (state.ready.length === 0) { console.log( "No ready stories. Run `pnpm work blocked` to see what's waiting on what.", ); return; } console.log( `${state.ready.length} ready stor${state.ready.length === 1 ? "y" : "ies"}:`, ); for (const r of state.ready) { console.log(` ${r.epic} / ${r.story} — ${r.title}`); } } function printBlocked() { const state = buildState(WORK_ROOT); if (state.blocked.length === 0) { console.log("No blocked stories."); return; } console.log( `${state.blocked.length} blocked stor${state.blocked.length === 1 ? "y" : "ies"}:`, ); for (const b of state.blocked) { console.log(` ${b.epic} / ${b.story} — ${b.title}`); console.log(` waiting on: ${b.waiting_on.join(", ")}`); } } function mark(status) { if (status === "done") return "✓"; if (status === "in-progress") return "→"; if (status === "blocked") return "✗"; return "○"; } function usage() { console.log( "Usage: pnpm work ", ); console.log( " dispatch Print the next dispatch plan (use --execute to invoke sandcastle; loops by default — bound with --once / --max-tasks N)", ); console.log( " decompose Decompose an approved PRD into epic + stories (use --execute to invoke sandcastle)", ); console.log( " prd-ship Flip a PRD's status to `shipped` (run after its epic completes)", ); process.exit(2); } const cmd = process.argv[2]; if (cmd === "rebuild-state") rebuildState(); else if (cmd === "status") printStatus(); else if (cmd === "next") printNext(); else if (cmd === "ready") printReady(); else if (cmd === "blocked") printBlocked(); else if (cmd === "dispatch") { // dispatch.mjs handles its own --execute flag runDispatch(process.argv.slice(3)).catch((e) => { 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 if (cmd === "decompose") { runDecompose(process.argv.slice(3), { repoRoot: REPO_ROOT, workRoot: WORK_ROOT, }).then((code) => process.exit(code)); } else usage();