#!/usr/bin/env node /** * pnpm work — CLI for the local work-system. * * Subcommands: * rebuild-state Rewrites docs/work/_state.json from the current markdown * status Prints a tree of all epics + their stories * next Prints the first non-done story in the first non-done epic */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildState } from "./state-builder.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 STATE_FILE = path.join(WORK_ROOT, "_state.json"); function rebuildState() { const state = buildState(WORK_ROOT); 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)", ); 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") { // Re-dispatch to the dispatch script so it can handle its own --execute flag import("./dispatch.mjs").catch((e) => { console.error(e); process.exit(1); }); } else usage();