chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
79
scripts/work/bump-updated-timestamps.mjs
Normal file
79
scripts/work/bump-updated-timestamps.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stamp every staged `docs/work/**\/*.md` file's frontmatter `updated:` field
|
||||
* to the current ISO 8601 timestamp. Runs from `.husky/pre-commit` before
|
||||
* `pnpm work rebuild-state`, so `_state.json` sees the fresh value.
|
||||
*
|
||||
* Idempotent: re-running produces the same result. The script silently no-ops
|
||||
* on files without frontmatter or without an `updated:` line to replace —
|
||||
* adds the field after `created:` if missing.
|
||||
*
|
||||
* Only stamps files explicitly listed in the staged diff; never walks the
|
||||
* tree. That way the timestamp tracks "the last commit that actually
|
||||
* modified the file," not "the last commit period."
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = execSync("git rev-parse --show-toplevel", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
function stagedWorkDocs() {
|
||||
const out = execSync("git diff --cached --name-only --diff-filter=ACMR", {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return out
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(
|
||||
(p) =>
|
||||
p.startsWith("docs/work/") &&
|
||||
!p.startsWith("docs/work/archive/") &&
|
||||
p.endsWith(".md") &&
|
||||
!p.endsWith("README.md"),
|
||||
);
|
||||
}
|
||||
|
||||
function stampUpdated(content, isoNow) {
|
||||
const fmMatch = content.match(/^(---\n)([\s\S]+?)(\n---)/);
|
||||
if (!fmMatch) return content;
|
||||
const [full, openDelim, body, closeDelim] = fmMatch;
|
||||
|
||||
let newBody;
|
||||
if (/^updated:\s*/m.test(body)) {
|
||||
newBody = body.replace(/^updated:\s*.*$/m, `updated: ${isoNow}`);
|
||||
} else if (/^created:\s*/m.test(body)) {
|
||||
// Insert `updated:` immediately after the `created:` line.
|
||||
newBody = body.replace(/^(created:\s*.*)$/m, `$1\nupdated: ${isoNow}`);
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
return content.replace(full, `${openDelim}${newBody}${closeDelim}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = stagedWorkDocs();
|
||||
if (files.length === 0) return;
|
||||
const isoNow = new Date().toISOString();
|
||||
let touched = 0;
|
||||
for (const rel of files) {
|
||||
const abs = path.join(REPO_ROOT, rel);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
const original = fs.readFileSync(abs, "utf8");
|
||||
const next = stampUpdated(original, isoNow);
|
||||
if (next === original) continue;
|
||||
fs.writeFileSync(abs, next);
|
||||
execSync(`git add ${JSON.stringify(rel)}`, { cwd: REPO_ROOT });
|
||||
touched++;
|
||||
}
|
||||
if (touched > 0) {
|
||||
console.log(
|
||||
`bump-updated-timestamps: stamped ${touched} file(s) at ${isoNow}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
180
scripts/work/cli.mjs
Normal file
180
scripts/work/cli.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/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 <id> 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 <id> 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 <rebuild-state|status|next|ready|blocked|dispatch|decompose|prd-ship>",
|
||||
);
|
||||
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 <prd-id> Decompose an approved PRD into epic + stories (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);
|
||||
}
|
||||
|
||||
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();
|
||||
59
scripts/work/cli.test.mjs
Normal file
59
scripts/work/cli.test.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = path.join(__dirname, "cli.mjs");
|
||||
|
||||
function run(args) {
|
||||
try {
|
||||
return execSync(`node "${CLI}" ${args}`, { encoding: "utf8" });
|
||||
} catch (e) {
|
||||
return e.stdout + e.stderr;
|
||||
}
|
||||
}
|
||||
|
||||
describe("pnpm work cli", () => {
|
||||
it("prints usage when no subcommand is given", () => {
|
||||
const out = run("");
|
||||
expect(out).toContain("Usage:");
|
||||
expect(out).toContain("rebuild-state");
|
||||
expect(out).toContain("status");
|
||||
expect(out).toContain("next");
|
||||
expect(out).toContain("ready");
|
||||
expect(out).toContain("blocked");
|
||||
});
|
||||
|
||||
it("rebuild-state writes _state.json", () => {
|
||||
const out = run("rebuild-state");
|
||||
expect(out).toContain("Rebuilt");
|
||||
expect(out).toContain("epic");
|
||||
});
|
||||
|
||||
it("status prints a tree", () => {
|
||||
const out = run("status");
|
||||
// We expect at least one epic-line marker character
|
||||
expect(out).toMatch(/[✓→○]/);
|
||||
});
|
||||
|
||||
it("next prints the next non-done story OR confirms all done", () => {
|
||||
const out = run("next");
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ready prints something", () => {
|
||||
const out = run("ready");
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("blocked prints something", () => {
|
||||
const out = run("blocked");
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dispatch prints a plan", () => {
|
||||
const out = run("dispatch");
|
||||
expect(out).toContain("Dispatch plan");
|
||||
});
|
||||
});
|
||||
265
scripts/work/decompose.mjs
Normal file
265
scripts/work/decompose.mjs
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/work/decompose.mjs
|
||||
*
|
||||
* Decomposer dispatcher — takes an approved PRD and invokes the decomposer
|
||||
* agent to write the epic folder + per-requirement story files under
|
||||
* docs/work/epics/<epic-slug>/.
|
||||
*
|
||||
* Default mode (no --execute): print the dispatch plan + validate the PRD
|
||||
* (refuses to proceed on draft / in-review / shipped). Safe anywhere.
|
||||
*
|
||||
* --execute mode: requires @ai-hero/sandcastle + auth (Claude subscription
|
||||
* via ~/.claude OR ANTHROPIC_API_KEY). Mirrors `pnpm work dispatch --execute`.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm work decompose <prd-id>
|
||||
* pnpm work decompose <prd-id> --execute
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { findPrdPath, parseFrontmatter } from "./prd-ship.mjs";
|
||||
import { resolveClaudeAuth } 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 SANDCASTLE_DIR = path.join(REPO_ROOT, ".sandcastle");
|
||||
|
||||
/**
|
||||
* Validate that a PRD is in a decomposable state. Throws on draft (must go
|
||||
* through human review), in-review (review not yet complete), shipped (epic
|
||||
* already exists), or missing.
|
||||
*
|
||||
* Returns the parsed frontmatter + body for the caller to pass into the
|
||||
* decomposer.
|
||||
*/
|
||||
export function validatePrdForDecompose(prdPath) {
|
||||
if (!fs.existsSync(prdPath)) {
|
||||
throw new Error(`PRD file not found: ${prdPath}`);
|
||||
}
|
||||
const text = fs.readFileSync(prdPath, "utf8");
|
||||
const { frontmatter } = parseFrontmatter(text);
|
||||
const status = frontmatter.status;
|
||||
|
||||
if (status === "draft") {
|
||||
throw new Error(
|
||||
`PRD status is "draft" — human review required before decomposing. ` +
|
||||
`Flip status to "approved" in the PRD frontmatter, then re-run.`,
|
||||
);
|
||||
}
|
||||
if (status === "in-review") {
|
||||
throw new Error(
|
||||
`PRD status is "in-review" — review must complete (status -> approved) before decomposing.`,
|
||||
);
|
||||
}
|
||||
if (status === "shipped") {
|
||||
throw new Error(
|
||||
`PRD status is "shipped" — its epic should already exist under docs/work/. ` +
|
||||
`Re-decomposing a shipped PRD is not supported.`,
|
||||
);
|
||||
}
|
||||
if (status !== "approved") {
|
||||
throw new Error(
|
||||
`Unexpected PRD status "${status}" — expected "approved" to decompose.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { frontmatter, text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Print what would happen on --execute. Validation runs in both modes; this
|
||||
* is the "preview" companion of executeDecompose().
|
||||
*/
|
||||
export function printDecomposePlan(prdId, prdPath, frontmatter) {
|
||||
console.log("=== Decompose plan ===");
|
||||
console.log(` PRD: ${path.relative(REPO_ROOT, prdPath)}`);
|
||||
console.log(` Id: ${prdId}`);
|
||||
console.log(` Title: ${frontmatter.title ?? "(no title)"}`);
|
||||
console.log(` Status: ${frontmatter.status} (eligible to decompose)`);
|
||||
console.log();
|
||||
console.log(` Decomposer prompt: .sandcastle/decomposer.prompt.md`);
|
||||
console.log(
|
||||
` Output: docs/work/epics/<epic-slug>/_epic.md + per-requirement story files`,
|
||||
);
|
||||
console.log();
|
||||
console.log("To run for real:");
|
||||
console.log(
|
||||
" - With Claude subscription: `claude login` (one-time) then `pnpm work decompose <id> --execute`",
|
||||
);
|
||||
console.log(
|
||||
" - With API key: `ANTHROPIC_API_KEY=... pnpm work decompose <id> --execute`",
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see docs/guides/runbook.md.)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke sandcastle with the decomposer prompt + the PRD file content. The
|
||||
* decomposer agent writes the epic + stories to disk inside the sandbox; the
|
||||
* orchestrator then has those files in a branch the human can review.
|
||||
*/
|
||||
export async function executeDecompose(prdId, prdPath, prdText) {
|
||||
const auth = resolveClaudeAuth();
|
||||
if (auth.mode === "missing") {
|
||||
console.error("✗ --execute requires either:");
|
||||
console.error(
|
||||
" 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)",
|
||||
);
|
||||
console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)");
|
||||
console.error("");
|
||||
console.error(
|
||||
" Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
|
||||
);
|
||||
console.log(`Decomposing PRD: ${prdId}`);
|
||||
|
||||
let sandcastleRoot;
|
||||
let dockerProvider;
|
||||
try {
|
||||
sandcastleRoot = await import("@ai-hero/sandcastle");
|
||||
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
|
||||
dockerProvider = dockerModule.docker;
|
||||
} catch {
|
||||
console.error(
|
||||
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dockerOpts = {};
|
||||
const agentOpts = {};
|
||||
if (auth.mode === "subscription") {
|
||||
dockerOpts.mounts = [
|
||||
{
|
||||
hostPath: auth.hostPath,
|
||||
sandboxPath: auth.sandboxPath,
|
||||
readonly: false,
|
||||
},
|
||||
];
|
||||
} else if (auth.mode === "api-key") {
|
||||
agentOpts.env = auth.env;
|
||||
}
|
||||
const sandbox = dockerProvider(dockerOpts);
|
||||
const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts);
|
||||
|
||||
const decomposerPrompt = path.join(SANDCASTLE_DIR, "decomposer.prompt.md");
|
||||
let result;
|
||||
try {
|
||||
result = await sandcastleRoot.run({
|
||||
agent,
|
||||
sandbox,
|
||||
promptFile: decomposerPrompt,
|
||||
promptArgs: { PRD_FILE_CONTENT: prdText },
|
||||
cwd: REPO_ROOT,
|
||||
// Sandcastle's default maxIterations: 1 cut the agent off after its
|
||||
// first response — files were written inside the sandbox but never
|
||||
// captured as commits. Decompose is a small authoring task (read
|
||||
// context, write epic + stories, commit); 10 iterations is enough
|
||||
// room. Tune via env SANDCASTLE_DECOMPOSE_ITERATIONS.
|
||||
maxIterations: Number(process.env.SANDCASTLE_DECOMPOSE_ITERATIONS ?? 10),
|
||||
// Stop iterating the moment the agent emits this marker. Without it,
|
||||
// sandcastle re-invokes the model up to maxIterations even when the
|
||||
// work is already done — the prompt instructs the agent to emit
|
||||
// <promise>COMPLETE</promise> on its final line.
|
||||
completionSignal: "<promise>COMPLETE</promise>",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("✗ Decomposer dispatch failed:", e.message);
|
||||
if (/Image '.+' not found locally/.test(e.message ?? "")) {
|
||||
console.error(
|
||||
" One-time setup: pnpm exec sandcastle docker build-image",
|
||||
);
|
||||
}
|
||||
if (
|
||||
/Not logged in|Please run \/login/.test(e.message ?? "") &&
|
||||
process.platform === "darwin"
|
||||
) {
|
||||
console.error(
|
||||
" macOS users: Claude Code stores credentials in the Keychain, not in ~/.claude/. Extract once:",
|
||||
);
|
||||
console.error(
|
||||
` security find-generic-password -s "Claude Code-credentials" -a "$USER" -w > ~/.claude/.credentials.json`,
|
||||
);
|
||||
console.error(" chmod 600 ~/.claude/.credentials.json");
|
||||
console.error(
|
||||
" OR fall back to API key: export ANTHROPIC_API_KEY=sk-ant-...",
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
" See docs/guides/runbook.md → 'Using Sandcastle' for setup.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`Decomposer returned. Branch: ${result.branch}, Commits: ${result.commits.length}`,
|
||||
);
|
||||
console.log();
|
||||
console.log("=== Suggested next steps ===");
|
||||
console.log(
|
||||
` 1. Inspect the new epic folder under docs/work/epics/<epic-slug>/ on branch ${result.branch}`,
|
||||
);
|
||||
console.log(
|
||||
` 2. Review the generated stories + tasks; edit anything that should change`,
|
||||
);
|
||||
console.log(` 3. Merge the branch to main`);
|
||||
console.log(
|
||||
` 4. pnpm work rebuild-state && pnpm work next # see the first ready task`,
|
||||
);
|
||||
console.log(` 5. pnpm work dispatch --execute # dispatch it`);
|
||||
}
|
||||
|
||||
// ---- CLI ----
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: pnpm work decompose <prd-id> [--execute]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
export async function runCli(args, { workRoot }) {
|
||||
const positional = args.filter((a) => !a.startsWith("--"));
|
||||
const prdId = positional[0];
|
||||
if (!prdId) {
|
||||
usage();
|
||||
}
|
||||
|
||||
const prdPath = findPrdPath(workRoot, prdId);
|
||||
if (!prdPath) {
|
||||
console.error(`PRD with id="${prdId}" not found under docs/work/prds/`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let frontmatter;
|
||||
let prdText;
|
||||
try {
|
||||
const result = validatePrdForDecompose(prdPath);
|
||||
frontmatter = result.frontmatter;
|
||||
prdText = result.text;
|
||||
} catch (err) {
|
||||
console.error(`Cannot decompose ${prdId}: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (args.includes("--execute")) {
|
||||
await executeDecompose(prdId, prdPath, prdText);
|
||||
return 0;
|
||||
}
|
||||
printDecomposePlan(prdId, prdPath, frontmatter);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (invokedDirectly) {
|
||||
runCli(process.argv.slice(2), {
|
||||
repoRoot: REPO_ROOT,
|
||||
workRoot: WORK_ROOT,
|
||||
}).then((code) => process.exit(code));
|
||||
}
|
||||
162
scripts/work/decompose.test.mjs
Normal file
162
scripts/work/decompose.test.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
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 { validatePrdForDecompose, runCli } from "./decompose.mjs";
|
||||
|
||||
function writePrd(dir, id, status) {
|
||||
const file = path.join(dir, `${id}.prd.md`);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`---
|
||||
id: ${id}
|
||||
title: Test PRD
|
||||
type: prd
|
||||
status: ${status}
|
||||
author: tester
|
||||
created: 2026-05-13
|
||||
---
|
||||
|
||||
body content
|
||||
`,
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
function setupRepo() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "decompose-"));
|
||||
fs.mkdirSync(path.join(tmp, "prds"), { recursive: true });
|
||||
return tmp;
|
||||
}
|
||||
|
||||
describe("validatePrdForDecompose", () => {
|
||||
test("accepts an approved PRD", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const file = writePrd(path.join(tmp, "prds"), "test-1", "approved");
|
||||
const result = validatePrdForDecompose(file);
|
||||
assert.equal(result.frontmatter.status, "approved");
|
||||
assert.ok(result.text.includes("body content"));
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects draft (must go through human review)", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const file = writePrd(path.join(tmp, "prds"), "test-2", "draft");
|
||||
assert.throws(
|
||||
() => validatePrdForDecompose(file),
|
||||
/status is "draft".*Flip status to "approved"/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects in-review", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const file = writePrd(path.join(tmp, "prds"), "test-3", "in-review");
|
||||
assert.throws(
|
||||
() => validatePrdForDecompose(file),
|
||||
/status is "in-review"/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects shipped (epic already exists)", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const file = writePrd(path.join(tmp, "prds"), "test-4", "shipped");
|
||||
assert.throws(() => validatePrdForDecompose(file), /status is "shipped"/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown status", () => {
|
||||
const tmp = setupRepo();
|
||||
try {
|
||||
const file = writePrd(path.join(tmp, "prds"), "test-5", "cancelled");
|
||||
assert.throws(
|
||||
() => validatePrdForDecompose(file),
|
||||
/Unexpected PRD status "cancelled"/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects missing file", () => {
|
||||
assert.throws(
|
||||
() => validatePrdForDecompose("/nonexistent/path.prd.md"),
|
||||
/PRD file not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCli (print mode)", () => {
|
||||
test("returns 1 + writes error when PRD id not found", async () => {
|
||||
const tmp = setupRepo();
|
||||
const errors = [];
|
||||
const origError = console.error;
|
||||
console.error = (m) => errors.push(m);
|
||||
try {
|
||||
const code = await runCli(["nonexistent-id"], {
|
||||
repoRoot: tmp,
|
||||
workRoot: tmp,
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.ok(errors.some((e) => /not found under docs\/work\/prds/.test(e)));
|
||||
} finally {
|
||||
console.error = origError;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns 1 + writes error when PRD is draft", async () => {
|
||||
const tmp = setupRepo();
|
||||
const errors = [];
|
||||
const origError = console.error;
|
||||
console.error = (m) => errors.push(m);
|
||||
try {
|
||||
writePrd(path.join(tmp, "prds"), "draft-prd", "draft");
|
||||
const code = await runCli(["draft-prd"], {
|
||||
repoRoot: tmp,
|
||||
workRoot: tmp,
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.ok(errors.some((e) => /Cannot decompose/.test(e)));
|
||||
} finally {
|
||||
console.error = origError;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("prints plan when PRD is approved + returns 0", async () => {
|
||||
const tmp = setupRepo();
|
||||
const logs = [];
|
||||
const origLog = console.log;
|
||||
console.log = (m) => logs.push(m);
|
||||
try {
|
||||
writePrd(path.join(tmp, "prds"), "approved-prd", "approved");
|
||||
const code = await runCli(["approved-prd"], {
|
||||
repoRoot: tmp,
|
||||
workRoot: tmp,
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
const all = logs.join("\n");
|
||||
assert.match(all, /Decompose plan/);
|
||||
assert.match(all, /approved-prd/);
|
||||
assert.match(all, /eligible/);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
666
scripts/work/dispatch.mjs
Normal file
666
scripts/work/dispatch.mjs
Normal file
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pnpm work dispatch — orchestrator that picks the next ready task and
|
||||
* (with --execute) invokes sandcastle to run the implementer then reviewer.
|
||||
*
|
||||
* Default mode prints the dispatch plan without invoking sandcastle —
|
||||
* safe to run anywhere. --execute requires EITHER:
|
||||
* 1. Claude Code logged in on host (~/.claude/ — recommended for subscribers)
|
||||
* 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
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 SANDCASTLE_DIR = path.join(REPO_ROOT, ".sandcastle");
|
||||
|
||||
/**
|
||||
* Returns the first ready story's first unchecked AC bullet, or null if
|
||||
* there's no work to dispatch.
|
||||
*
|
||||
* Shape: { epic, story, title, storyPath, storyContent, bulletLine, bulletIndex }
|
||||
*/
|
||||
export function findNextTask(workRoot = WORK_ROOT) {
|
||||
const state = buildState(workRoot);
|
||||
if (state.ready.length === 0) return null;
|
||||
const next = state.ready[0];
|
||||
const storyPath = path.join(
|
||||
workRoot,
|
||||
"epics",
|
||||
next.epic,
|
||||
next.story,
|
||||
"_story.md",
|
||||
);
|
||||
if (!fs.existsSync(storyPath)) return null;
|
||||
const storyContent = fs.readFileSync(storyPath, "utf8");
|
||||
const { bulletLine, bulletIndex } = findFirstUncheckedBullet(storyContent);
|
||||
if (bulletLine === null) return null;
|
||||
return {
|
||||
epic: next.epic,
|
||||
story: next.story,
|
||||
title: next.title,
|
||||
storyPath,
|
||||
storyContent,
|
||||
bulletLine,
|
||||
bulletIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the story content for the first `- [ ]` bullet INSIDE the `## Tasks`
|
||||
* section. Returns the matched line + its 0-based index within the file's
|
||||
* line array (used by the orchestrator if it later tick-edits the file).
|
||||
*/
|
||||
export function findFirstUncheckedBullet(content) {
|
||||
const lines = content.split("\n");
|
||||
let inTasks = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith("## ")) {
|
||||
inTasks = /^##\s+Tasks\b/i.test(line);
|
||||
continue;
|
||||
}
|
||||
if (!inTasks) continue;
|
||||
if (/^[\s>-]*\[\s\]/.test(line)) {
|
||||
return { bulletLine: line, bulletIndex: i };
|
||||
}
|
||||
}
|
||||
return { bulletLine: null, bulletIndex: -1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the task spec string passed to sandcastle as TASK_FILE_CONTENT.
|
||||
* The implementer prompt template uses this verbatim. An optional
|
||||
* `rejection_notes` argument is appended when the orchestrator re-dispatches
|
||||
* the implementer after a reviewer reject.
|
||||
*/
|
||||
export function buildTaskSpec(next, rejectionNotes = null) {
|
||||
const base = `# Current task
|
||||
|
||||
## Epic
|
||||
${next.epic}
|
||||
|
||||
## Story
|
||||
${next.story} — ${next.title}
|
||||
|
||||
## Current bullet
|
||||
${next.bulletLine.trim()}
|
||||
|
||||
## Full story for context
|
||||
|
||||
${next.storyContent}`;
|
||||
if (!rejectionNotes) return base;
|
||||
return `${base}
|
||||
|
||||
## Previous attempt was REJECTED — fix these before re-committing
|
||||
|
||||
${rejectionNotes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the LAST structured JSON object emitted by the agent. The
|
||||
* implementer + reviewer prompts both ask the agent to return JSON; in
|
||||
* practice agents wrap it in a \`\`\`json ... \`\`\` fence, but we tolerate
|
||||
* a bare \`{ ... }\` block at the end of stdout too. Returns null on no
|
||||
* parsable match.
|
||||
*/
|
||||
export function parseAgentJson(stdout) {
|
||||
if (!stdout) return null;
|
||||
// 1. Code-fenced JSON: take the LAST ```json ... ``` block.
|
||||
const fenceMatches = [...stdout.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g)];
|
||||
if (fenceMatches.length > 0) {
|
||||
const inner = fenceMatches[fenceMatches.length - 1][1].trim();
|
||||
try {
|
||||
return JSON.parse(inner);
|
||||
} catch {
|
||||
// fall through to bare-brace fallback
|
||||
}
|
||||
}
|
||||
// 2. Bare braces: walk backwards from the last "}" to its match. Defensive
|
||||
// against partial output or extra trailing characters from the completion
|
||||
// signal.
|
||||
const lastClose = stdout.lastIndexOf("}");
|
||||
if (lastClose === -1) return null;
|
||||
let depth = 0;
|
||||
for (let i = lastClose; i >= 0; i--) {
|
||||
if (stdout[i] === "}") depth++;
|
||||
else if (stdout[i] === "{") depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = stdout.slice(i, lastClose + 1);
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the `- [ ]` checkbox at the given line index with `- [x]`. Pure
|
||||
* over the file's text — returns the new content.
|
||||
*/
|
||||
export function tickBulletInContent(content, bulletIndex) {
|
||||
const lines = content.split("\n");
|
||||
if (bulletIndex < 0 || bulletIndex >= lines.length) return content;
|
||||
lines[bulletIndex] = lines[bulletIndex].replace(/\[\s\]/, "[x]");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Count remaining `- [ ]` checkboxes inside the `## Tasks` section.
|
||||
*/
|
||||
export function countUncheckedBullets(content) {
|
||||
const lines = content.split("\n");
|
||||
let inTasks = false;
|
||||
let count = 0;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
inTasks = /^##\s+Tasks\b/i.test(line);
|
||||
continue;
|
||||
}
|
||||
if (!inTasks) continue;
|
||||
if (/^[\s>-]*\[\s\]/.test(line)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the `status:` line inside the leading `---\n...\n---` frontmatter
|
||||
* block. Returns the new content, or the original if no frontmatter or no
|
||||
* `status:` key was found.
|
||||
*/
|
||||
export function setFrontmatterStatus(content, newStatus) {
|
||||
const fmMatch = content.match(/^(---\n)([\s\S]+?)(\n---)/);
|
||||
if (!fmMatch) return content;
|
||||
const [full, openDelim, body, closeDelim] = fmMatch;
|
||||
if (!/^status:\s*/m.test(body)) return content;
|
||||
const newBody = body.replace(/^status:\s*.*$/m, `status: ${newStatus}`);
|
||||
return content.replace(full, `${openDelim}${newBody}${closeDelim}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `status:` value from frontmatter. Returns null on no frontmatter
|
||||
* or no key.
|
||||
*/
|
||||
export function readFrontmatterStatus(content) {
|
||||
const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
||||
if (!fmMatch) return null;
|
||||
const m = fmMatch[1].match(/^status:\s*(.*)$/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick the bullet in an epic's `## Stories` section that links to the given
|
||||
* story folder. Idempotent: returns false if the bullet is already ticked or
|
||||
* not present. Mirrors the per-task tick that already happens inside story
|
||||
* files, applied at the parent-epic granularity.
|
||||
*/
|
||||
export function tickStoryBulletInEpic(workRoot, epicId, storyId) {
|
||||
const epicFile = path.join(workRoot, "epics", epicId, "_epic.md");
|
||||
if (!fs.existsSync(epicFile)) return false;
|
||||
const content = fs.readFileSync(epicFile, "utf8");
|
||||
const lines = content.split("\n");
|
||||
let inStories = false;
|
||||
let changed = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith("## ")) {
|
||||
inStories = /^##\s+Stories\b/i.test(lines[i]);
|
||||
continue;
|
||||
}
|
||||
if (!inStories) continue;
|
||||
if (
|
||||
lines[i].includes(`(${storyId}/_story.md)`) ||
|
||||
lines[i].includes(`(./${storyId}/_story.md)`)
|
||||
) {
|
||||
if (/\[\s\]/.test(lines[i])) {
|
||||
lines[i] = lines[i].replace(/\[\s\]/, "[x]");
|
||||
changed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(epicFile, lines.join("\n"));
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* If all stories in an epic are `status: done`, flip the epic's own
|
||||
* frontmatter to `status: done`. Returns true if it flipped, false otherwise.
|
||||
*/
|
||||
export function flipEpicDoneIfAllStoriesDone(workRoot, epicId) {
|
||||
const epicDir = path.join(workRoot, "epics", epicId);
|
||||
const epicFile = path.join(epicDir, "_epic.md");
|
||||
if (!fs.existsSync(epicFile)) return false;
|
||||
const epicContent = fs.readFileSync(epicFile, "utf8");
|
||||
if (readFrontmatterStatus(epicContent) === "done") return false;
|
||||
|
||||
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 storyStatus = readFrontmatterStatus(
|
||||
fs.readFileSync(storyFile, "utf8"),
|
||||
);
|
||||
if (storyStatus !== "done") return false;
|
||||
}
|
||||
fs.writeFileSync(epicFile, setFrontmatterStatus(epicContent, "done"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the auth method for sandcastle dispatch.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Subscription (primary) — mount host's ~/.claude/ into the sandbox.
|
||||
* Active when the host's Claude creds directory exists. The path
|
||||
* defaults to ~/.claude/ and can be overridden via the
|
||||
* SANDCASTLE_CLAUDE_CREDS_DIR env var.
|
||||
* 2. API key (fallback) — pass ANTHROPIC_API_KEY (or OPENAI_API_KEY)
|
||||
* through to the sandbox env.
|
||||
* 3. Neither available → returns { mode: "missing" } and the dispatcher
|
||||
* prints a clear error before exiting.
|
||||
*
|
||||
* Returns: { mode: "subscription", hostPath, sandboxPath }
|
||||
* | { mode: "api-key", env }
|
||||
* | { mode: "missing" }
|
||||
*/
|
||||
export function resolveClaudeAuth({
|
||||
env = process.env,
|
||||
home = os.homedir(),
|
||||
} = {}) {
|
||||
// 1. Subscription path
|
||||
const credsHostPath =
|
||||
env.SANDCASTLE_CLAUDE_CREDS_DIR ?? path.join(home, ".claude");
|
||||
if (fs.existsSync(credsHostPath)) {
|
||||
return {
|
||||
mode: "subscription",
|
||||
hostPath: credsHostPath,
|
||||
// Inside the sandbox, claude looks at the agent user's home — tilde
|
||||
// expansion in MountConfig handles the actual /home/agent/.claude
|
||||
// resolution.
|
||||
sandboxPath: "~/.claude",
|
||||
};
|
||||
}
|
||||
// 2. API key fallback
|
||||
if (env.ANTHROPIC_API_KEY) {
|
||||
return {
|
||||
mode: "api-key",
|
||||
env: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY },
|
||||
};
|
||||
}
|
||||
if (env.OPENAI_API_KEY) {
|
||||
return { mode: "api-key", env: { OPENAI_API_KEY: env.OPENAI_API_KEY } };
|
||||
}
|
||||
// 3. Neither available
|
||||
return { mode: "missing" };
|
||||
}
|
||||
|
||||
function printPlan() {
|
||||
const next = findNextTask();
|
||||
if (!next) {
|
||||
console.log("No ready task to dispatch.");
|
||||
console.log("Run `pnpm work blocked` to see what's waiting on what.");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log("=== Dispatch plan ===");
|
||||
console.log(` Epic: ${next.epic}`);
|
||||
console.log(` Story: ${next.story} — ${next.title}`);
|
||||
console.log(` Bullet: ${next.bulletLine.trim()}`);
|
||||
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
|
||||
console.log();
|
||||
console.log("To execute this dispatch:");
|
||||
console.log(
|
||||
" - With Claude subscription: `claude login` (one-time) then `pnpm work dispatch --execute`",
|
||||
);
|
||||
console.log(
|
||||
" - With API key: `ANTHROPIC_API_KEY=... pnpm work dispatch --execute`",
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
"By default --execute LOOPS through every ready task. Flags to bound it:",
|
||||
);
|
||||
console.log(" --once stop after one approved slice");
|
||||
console.log(" --max-tasks N stop after N approved slices");
|
||||
console.log();
|
||||
console.log(
|
||||
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the macOS / sandcastle-image / auth hints when a sandcastle run
|
||||
* blows up. Shared between implementer + reviewer error paths.
|
||||
*/
|
||||
function explainSandcastleError(stage, e) {
|
||||
console.error(`✗ ${stage} dispatch failed:`, e.message);
|
||||
if (/Image '.+' not found locally/.test(e.message ?? "")) {
|
||||
console.error(" One-time setup: pnpm exec sandcastle docker build-image");
|
||||
}
|
||||
if (
|
||||
/Not logged in|Please run \/login/.test(e.message ?? "") &&
|
||||
process.platform === "darwin"
|
||||
) {
|
||||
console.error(
|
||||
" macOS users: Claude Code stores credentials in the Keychain, not in ~/.claude/. Extract once:",
|
||||
);
|
||||
console.error(
|
||||
` security find-generic-password -s "Claude Code-credentials" -a "$USER" -w > ~/.claude/.credentials.json`,
|
||||
);
|
||||
console.error(" chmod 600 ~/.claude/.credentials.json");
|
||||
console.error(
|
||||
" OR fall back to API key: export ANTHROPIC_API_KEY=sk-ant-...",
|
||||
);
|
||||
}
|
||||
console.error(" See docs/guides/runbook.md → 'Using Sandcastle' for setup.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one slice end-to-end: implementer + reviewer, with a fix-up cycle on
|
||||
* reject (capped at maxAttempts). Each slice is an independent sandcastle
|
||||
* session — sandcastle's `resumeSession` is incompatible with the
|
||||
* multi-iteration budgets a TDD slice requires (applies to iteration 1 only).
|
||||
*
|
||||
* Outcome variants:
|
||||
* "approved" (implJson, reviewJson)
|
||||
* "rejected-final" (lastRejectNotes)
|
||||
* "blocked" (implJson)
|
||||
* "error" (reason)
|
||||
*/
|
||||
async function runOneSlice({ sandcastleRoot, sandbox, agent, next }) {
|
||||
const maxAttempts = Number(process.env.SANDCASTLE_MAX_ATTEMPTS ?? 3);
|
||||
const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
|
||||
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
|
||||
|
||||
let rejectionNotes = null;
|
||||
let lastRejectNotes = null;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
const taskSpec = buildTaskSpec(next, rejectionNotes);
|
||||
|
||||
let implResult;
|
||||
try {
|
||||
implResult = await sandcastleRoot.run({
|
||||
agent,
|
||||
sandbox,
|
||||
promptFile: implementerPrompt,
|
||||
promptArgs: { TASK_FILE_CONTENT: taskSpec },
|
||||
cwd: REPO_ROOT,
|
||||
// Implementer runs a full TDD slice (read context, red test, green
|
||||
// impl, run all five gates, commit). 30 iterations matches typical
|
||||
// slice shape. Tune via env SANDCASTLE_IMPLEMENTER_ITERATIONS.
|
||||
maxIterations: Number(
|
||||
process.env.SANDCASTLE_IMPLEMENTER_ITERATIONS ?? 30,
|
||||
),
|
||||
// Stop iterating the moment the agent emits this marker. Without
|
||||
// it, sandcastle re-invokes the model up to maxIterations even
|
||||
// when the work is already done.
|
||||
completionSignal: "<promise>COMPLETE</promise>",
|
||||
});
|
||||
} catch (e) {
|
||||
explainSandcastleError("Implementer", e);
|
||||
return { outcome: "error", attempts, reason: e.message };
|
||||
}
|
||||
console.log(
|
||||
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
|
||||
);
|
||||
|
||||
const implJson = parseAgentJson(implResult.stdout);
|
||||
if (
|
||||
implJson?.status === "blocked" ||
|
||||
implJson?.status === "needs-clarification"
|
||||
) {
|
||||
return { outcome: "blocked", attempts, implJson };
|
||||
}
|
||||
|
||||
let diff = "";
|
||||
try {
|
||||
diff = execSync(`git diff main..${implResult.branch}`, {
|
||||
encoding: "utf8",
|
||||
cwd: REPO_ROOT,
|
||||
});
|
||||
} catch {
|
||||
diff = "(diff unavailable)";
|
||||
}
|
||||
|
||||
let reviewResult;
|
||||
try {
|
||||
reviewResult = await sandcastleRoot.run({
|
||||
agent,
|
||||
sandbox,
|
||||
promptFile: reviewerPrompt,
|
||||
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
|
||||
cwd: REPO_ROOT,
|
||||
// Reviewer reads the diff + task spec and decides (approve/reject).
|
||||
// Smaller surface than the implementer; 10 iterations is plenty.
|
||||
// Tune via env SANDCASTLE_REVIEWER_ITERATIONS.
|
||||
maxIterations: Number(process.env.SANDCASTLE_REVIEWER_ITERATIONS ?? 10),
|
||||
// See implementer comment above.
|
||||
completionSignal: "<promise>COMPLETE</promise>",
|
||||
});
|
||||
} catch (e) {
|
||||
explainSandcastleError("Reviewer", e);
|
||||
return { outcome: "error", attempts, reason: e.message };
|
||||
}
|
||||
const reviewJson = parseAgentJson(reviewResult.stdout);
|
||||
|
||||
if (reviewJson?.decision === "approve") {
|
||||
return { outcome: "approved", attempts, implJson, reviewJson };
|
||||
}
|
||||
if (reviewJson?.decision === "reject") {
|
||||
rejectionNotes =
|
||||
reviewJson.notes ?? "(reviewer rejected without notes — re-attempt)";
|
||||
lastRejectNotes = rejectionNotes;
|
||||
console.log(
|
||||
`↺ Attempt ${attempts}/${maxAttempts} rejected. Re-dispatching implementer with notes.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
outcome: "error",
|
||||
attempts,
|
||||
reason: `reviewer returned no parseable decision; stdout:\n${reviewResult.stdout}`,
|
||||
};
|
||||
}
|
||||
return { outcome: "rejected-final", attempts, lastRejectNotes };
|
||||
}
|
||||
|
||||
/**
|
||||
* After an `approved` slice: tick the bullet, flip the story status if all
|
||||
* bullets are now ticked (or todo→in-progress on the first tick), flip the
|
||||
* epic status if all its stories are done, and commit the mutation on the
|
||||
* host. The implementer's slice commit is already on main; this is a
|
||||
* separate bookkeeping commit so the slice commit stays clean.
|
||||
*/
|
||||
function applyApprovedState(next) {
|
||||
let content = fs.readFileSync(next.storyPath, "utf8");
|
||||
content = tickBulletInContent(content, next.bulletIndex);
|
||||
|
||||
const currentStatus = readFrontmatterStatus(content);
|
||||
let storyFlipped = false;
|
||||
if (countUncheckedBullets(content) === 0 && currentStatus !== "done") {
|
||||
content = setFrontmatterStatus(content, "done");
|
||||
storyFlipped = true;
|
||||
} else if (currentStatus === "todo") {
|
||||
content = setFrontmatterStatus(content, "in-progress");
|
||||
}
|
||||
fs.writeFileSync(next.storyPath, content);
|
||||
|
||||
let epicFlipped = false;
|
||||
let epicBulletTicked = false;
|
||||
if (storyFlipped) {
|
||||
epicBulletTicked = tickStoryBulletInEpic(WORK_ROOT, next.epic, next.story);
|
||||
epicFlipped = flipEpicDoneIfAllStoriesDone(WORK_ROOT, next.epic);
|
||||
}
|
||||
|
||||
const filesToStage = [path.relative(REPO_ROOT, next.storyPath)];
|
||||
if (epicFlipped || epicBulletTicked) {
|
||||
filesToStage.push(
|
||||
path.relative(
|
||||
REPO_ROOT,
|
||||
path.join(WORK_ROOT, "epics", next.epic, "_epic.md"),
|
||||
),
|
||||
);
|
||||
}
|
||||
const commitMsg = epicFlipped
|
||||
? `chore(work): finish epic ${next.epic}`
|
||||
: storyFlipped
|
||||
? `chore(work): finish ${next.story}`
|
||||
: `chore(work): tick task in ${next.story}`;
|
||||
|
||||
execFileSync("git", ["add", ...filesToStage], { cwd: REPO_ROOT });
|
||||
execFileSync("git", ["commit", "-m", commitMsg], {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log(`✓ ${commitMsg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a slice, dispatch implementer + reviewer (with reject fix-up cycle),
|
||||
* apply state mutation on approve, loop until exhausted or a cap is hit.
|
||||
*
|
||||
* Flags:
|
||||
* --once stop after one slice (legacy behavior)
|
||||
* --max-tasks N stop after N approved slices (default: unlimited)
|
||||
*/
|
||||
async function executeDispatch({ maxTasks }) {
|
||||
const auth = resolveClaudeAuth();
|
||||
if (auth.mode === "missing") {
|
||||
console.error("✗ --execute requires either:");
|
||||
console.error(
|
||||
" 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)",
|
||||
);
|
||||
console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)");
|
||||
console.error("");
|
||||
console.error(
|
||||
" Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
|
||||
);
|
||||
|
||||
let sandcastleRoot;
|
||||
let dockerProvider;
|
||||
try {
|
||||
sandcastleRoot = await import("@ai-hero/sandcastle");
|
||||
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
|
||||
dockerProvider = dockerModule.docker;
|
||||
} catch {
|
||||
console.error(
|
||||
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dockerOpts = {};
|
||||
const agentOpts = {};
|
||||
if (auth.mode === "subscription") {
|
||||
dockerOpts.mounts = [
|
||||
{
|
||||
hostPath: auth.hostPath,
|
||||
sandboxPath: auth.sandboxPath,
|
||||
readonly: false,
|
||||
},
|
||||
];
|
||||
} else if (auth.mode === "api-key") {
|
||||
agentOpts.env = auth.env;
|
||||
}
|
||||
const sandbox = dockerProvider(dockerOpts);
|
||||
const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts);
|
||||
|
||||
let approved = 0;
|
||||
while (true) {
|
||||
if (maxTasks !== null && approved >= maxTasks) {
|
||||
console.log(`\nHit --max-tasks=${maxTasks} cap; stopping.`);
|
||||
break;
|
||||
}
|
||||
const next = findNextTask();
|
||||
if (!next) {
|
||||
console.log("\nNo more ready tasks. Dispatch loop complete.");
|
||||
break;
|
||||
}
|
||||
console.log(
|
||||
`\n--- Slice ${approved + 1}: ${next.epic} / ${next.story} ---`,
|
||||
);
|
||||
console.log(` Bullet: ${next.bulletLine.trim()}`);
|
||||
|
||||
const result = await runOneSlice({ sandcastleRoot, sandbox, agent, next });
|
||||
if (result.outcome === "approved") {
|
||||
applyApprovedState(next);
|
||||
approved++;
|
||||
continue;
|
||||
}
|
||||
if (result.outcome === "rejected-final") {
|
||||
console.error(
|
||||
`\n✗ Slice rejected after ${result.attempts} attempts. Stopping dispatch loop.`,
|
||||
);
|
||||
if (result.lastRejectNotes) {
|
||||
console.error(`Last rejection notes:\n${result.lastRejectNotes}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.outcome === "blocked") {
|
||||
console.error(
|
||||
`\n✗ Implementer reported ${result.implJson?.status ?? "blocked"}. Stopping dispatch loop.`,
|
||||
);
|
||||
if (result.implJson?.notes) {
|
||||
console.error(`Implementer notes:\n${result.implJson.notes}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
// outcome === "error"
|
||||
console.error(`\n✗ Slice errored: ${result.reason ?? "(no reason)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nDispatched ${approved} slice(s).`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit CLI entry. Exported so cli.mjs can dispatch into this module
|
||||
* without relying on a top-level side effect (which would also fire when
|
||||
* sibling work scripts import `resolveClaudeAuth`, etc.).
|
||||
*
|
||||
* Flags:
|
||||
* --execute run sandcastle (default: print plan only)
|
||||
* --once stop after one approved slice (default: loop until done)
|
||||
* --max-tasks N stop after N approved slices
|
||||
*/
|
||||
export async function runCli(args) {
|
||||
if (!args.includes("--execute")) {
|
||||
printPlan();
|
||||
return;
|
||||
}
|
||||
let maxTasks = null;
|
||||
if (args.includes("--once")) maxTasks = 1;
|
||||
const maxTasksFlagIdx = args.indexOf("--max-tasks");
|
||||
if (maxTasksFlagIdx !== -1) {
|
||||
const raw = args[maxTasksFlagIdx + 1];
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
console.error(`✗ --max-tasks expects a positive integer, got: ${raw}`);
|
||||
process.exit(2);
|
||||
}
|
||||
maxTasks = parsed;
|
||||
}
|
||||
await executeDispatch({ maxTasks });
|
||||
}
|
||||
|
||||
// When invoked directly (`node scripts/work/dispatch.mjs ...`), run the CLI.
|
||||
// When imported by cli.mjs or any sibling, do nothing — the caller decides.
|
||||
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (invokedDirectly) {
|
||||
runCli(process.argv.slice(2));
|
||||
}
|
||||
200
scripts/work/dispatch.test.mjs
Normal file
200
scripts/work/dispatch.test.mjs
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
findNextTask,
|
||||
findFirstUncheckedBullet,
|
||||
buildTaskSpec,
|
||||
resolveClaudeAuth,
|
||||
} from "./dispatch.mjs";
|
||||
|
||||
function makeWorkTree({ epics }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-"));
|
||||
for (const [epicId, epicData] of Object.entries(epics)) {
|
||||
const epicDir = path.join(root, epicId);
|
||||
fs.mkdirSync(epicDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(epicDir, "_epic.md"),
|
||||
`---\nid: ${epicId}\ntitle: ${epicId}\nstatus: ${epicData.status ?? "in-progress"}\n---\n`,
|
||||
);
|
||||
for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) {
|
||||
const storyDir = path.join(epicDir, storyId);
|
||||
fs.mkdirSync(storyDir, { recursive: true });
|
||||
const tasks = (storyData.tasks ?? [])
|
||||
.map((t) => `- [${t.done ? "x" : " "}] ${t.label}`)
|
||||
.join("\n");
|
||||
const deps = storyData.depends_on
|
||||
? `depends-on: [${storyData.depends_on.join(", ")}]\n`
|
||||
: "";
|
||||
fs.writeFileSync(
|
||||
path.join(storyDir, "_story.md"),
|
||||
`---\nid: ${storyId}\ntitle: ${storyId}\nstatus: ${storyData.status ?? "todo"}\n${deps}---\n\n## Tasks\n${tasks}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("findFirstUncheckedBullet", () => {
|
||||
it("returns the first - [ ] line under ## Tasks", () => {
|
||||
const content = `## Tasks
|
||||
- [x] done
|
||||
- [ ] open
|
||||
- [ ] another
|
||||
`;
|
||||
const { bulletLine } = findFirstUncheckedBullet(content);
|
||||
expect(bulletLine).toBe("- [ ] open");
|
||||
});
|
||||
|
||||
it("returns null when no unchecked bullets remain", () => {
|
||||
const content = `## Tasks
|
||||
- [x] done
|
||||
- [x] also done
|
||||
`;
|
||||
expect(findFirstUncheckedBullet(content).bulletLine).toBeNull();
|
||||
});
|
||||
|
||||
it("only checks inside the Tasks section", () => {
|
||||
const content = `## Goal
|
||||
- [ ] not a task
|
||||
|
||||
## Tasks
|
||||
- [x] done
|
||||
`;
|
||||
expect(findFirstUncheckedBullet(content).bulletLine).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNextTask", () => {
|
||||
it("returns the next bullet from the first ready story", () => {
|
||||
const root = makeWorkTree({
|
||||
epics: {
|
||||
e: {
|
||||
stories: {
|
||||
s: {
|
||||
status: "todo",
|
||||
tasks: [
|
||||
{ label: "a", done: true },
|
||||
{ label: "b", done: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const next = findNextTask(root);
|
||||
expect(next.epic).toBe("e");
|
||||
expect(next.story).toBe("s");
|
||||
expect(next.bulletLine.trim()).toBe("- [ ] b");
|
||||
});
|
||||
|
||||
it("returns null when no stories are ready", () => {
|
||||
const root = makeWorkTree({
|
||||
epics: {
|
||||
e: {
|
||||
stories: {
|
||||
s: {
|
||||
status: "done",
|
||||
tasks: [{ label: "a", done: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(findNextTask(root)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when ready story has no unchecked bullets", () => {
|
||||
const root = makeWorkTree({
|
||||
epics: {
|
||||
e: {
|
||||
stories: {
|
||||
s: {
|
||||
status: "todo",
|
||||
tasks: [{ label: "a", done: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(findNextTask(root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskSpec", () => {
|
||||
it("includes epic, story, bullet, and full story content", () => {
|
||||
const next = {
|
||||
epic: "e",
|
||||
story: "s",
|
||||
title: "Story",
|
||||
storyContent: "## Goal\n\nSomething.\n## Tasks\n- [ ] do thing",
|
||||
bulletLine: "- [ ] do thing",
|
||||
};
|
||||
const spec = buildTaskSpec(next);
|
||||
expect(spec).toContain("e");
|
||||
expect(spec).toContain("s — Story");
|
||||
expect(spec).toContain("- [ ] do thing");
|
||||
expect(spec).toContain("## Goal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClaudeAuth", () => {
|
||||
it("returns subscription mode when ~/.claude exists on host", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-sub-"));
|
||||
fs.mkdirSync(path.join(tmpHome, ".claude"));
|
||||
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
|
||||
expect(result.mode).toBe("subscription");
|
||||
expect(result.hostPath).toBe(path.join(tmpHome, ".claude"));
|
||||
expect(result.sandboxPath).toBe("~/.claude");
|
||||
});
|
||||
|
||||
it("honours SANDCASTLE_CLAUDE_CREDS_DIR override", () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "auth-override-"));
|
||||
const overrideDir = path.join(tmpRoot, "custom-claude");
|
||||
fs.mkdirSync(overrideDir);
|
||||
const result = resolveClaudeAuth({
|
||||
env: { SANDCASTLE_CLAUDE_CREDS_DIR: overrideDir },
|
||||
home: "/nonexistent",
|
||||
});
|
||||
expect(result.mode).toBe("subscription");
|
||||
expect(result.hostPath).toBe(overrideDir);
|
||||
});
|
||||
|
||||
it("falls back to ANTHROPIC_API_KEY when ~/.claude does not exist", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-key-"));
|
||||
// No .claude directory created
|
||||
const result = resolveClaudeAuth({
|
||||
env: { ANTHROPIC_API_KEY: "sk-test" },
|
||||
home: tmpHome,
|
||||
});
|
||||
expect(result.mode).toBe("api-key");
|
||||
expect(result.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" });
|
||||
});
|
||||
|
||||
it("falls back to OPENAI_API_KEY when only that is set", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-openai-"));
|
||||
const result = resolveClaudeAuth({
|
||||
env: { OPENAI_API_KEY: "sk-openai" },
|
||||
home: tmpHome,
|
||||
});
|
||||
expect(result.mode).toBe("api-key");
|
||||
expect(result.env).toEqual({ OPENAI_API_KEY: "sk-openai" });
|
||||
});
|
||||
|
||||
it("returns missing when neither subscription nor API key available", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-missing-"));
|
||||
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
|
||||
expect(result.mode).toBe("missing");
|
||||
});
|
||||
|
||||
it("prefers subscription over API key when both available", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-both-"));
|
||||
fs.mkdirSync(path.join(tmpHome, ".claude"));
|
||||
const result = resolveClaudeAuth({
|
||||
env: { ANTHROPIC_API_KEY: "sk-test" },
|
||||
home: tmpHome,
|
||||
});
|
||||
expect(result.mode).toBe("subscription");
|
||||
});
|
||||
});
|
||||
240
scripts/work/prd-ship.mjs
Normal file
240
scripts/work/prd-ship.mjs
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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> -> shipped
|
||||
* - shipped: <ISO date> (today, UTC)
|
||||
* - shipping-commits: [<sha1>, <sha2>, ...] (optional)
|
||||
*
|
||||
* Refuses to flip from `draft` (must go through human review first —
|
||||
* draft -> approved is the human step, NOT automated by this command).
|
||||
* 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 epicsRoot = path.join(workRoot, "epics");
|
||||
if (!fs.existsSync(epicsRoot)) return [];
|
||||
const entries = fs.readdirSync(epicsRoot, { withFileTypes: true });
|
||||
let epicDir = null;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const epicFile = path.join(epicsRoot, 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/epics/${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/);
|
||||
});
|
||||
});
|
||||
235
scripts/work/state-builder.mjs
Normal file
235
scripts/work/state-builder.mjs
Normal file
@@ -0,0 +1,235 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Walk the `docs/work/epics/` tree under `workRoot` and return a structured
|
||||
* state object. Each epic folder under `epics/` 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: {},
|
||||
};
|
||||
|
||||
const epicsRoot = path.join(workRoot, "epics");
|
||||
if (!fs.existsSync(epicsRoot)) return state;
|
||||
|
||||
for (const entry of fs.readdirSync(epicsRoot)) {
|
||||
const epicDir = path.join(epicsRoot, 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 };
|
||||
}
|
||||
387
scripts/work/state-builder.test.mjs
Normal file
387
scripts/work/state-builder.test.mjs
Normal file
@@ -0,0 +1,387 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
buildState,
|
||||
parseFrontmatter,
|
||||
countTaskCheckboxes,
|
||||
computeReadyBlocked,
|
||||
} from "./state-builder.mjs";
|
||||
|
||||
function makeWorkTree({ epics }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-"));
|
||||
for (const [epicId, epicData] of Object.entries(epics)) {
|
||||
const epicDir = path.join(root, epicId);
|
||||
fs.mkdirSync(epicDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(epicDir, "_epic.md"),
|
||||
`---
|
||||
id: ${epicId}
|
||||
title: ${epicData.title ?? epicId}
|
||||
status: ${epicData.status ?? "todo"}
|
||||
---
|
||||
|
||||
`,
|
||||
);
|
||||
for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) {
|
||||
const storyDir = path.join(epicDir, storyId);
|
||||
fs.mkdirSync(storyDir, { recursive: true });
|
||||
const tasksBlock = (storyData.tasks ?? [])
|
||||
.map((t) => `- [${t.done ? "x" : " "}] ${t.label}`)
|
||||
.join("\n");
|
||||
fs.writeFileSync(
|
||||
path.join(storyDir, "_story.md"),
|
||||
`---
|
||||
id: ${storyId}
|
||||
title: ${storyData.title ?? storyId}
|
||||
status: ${storyData.status ?? "todo"}
|
||||
---
|
||||
|
||||
## Tasks
|
||||
${tasksBlock}
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("buildState", () => {
|
||||
it("returns an empty epics map for a non-existent workRoot", () => {
|
||||
const state = buildState("/nonexistent/path");
|
||||
expect(state.epics).toEqual({});
|
||||
expect(typeof state.updated_at).toBe("string");
|
||||
});
|
||||
|
||||
it("collects epics + stories with status + task counts", () => {
|
||||
const root = makeWorkTree({
|
||||
epics: {
|
||||
epic1: {
|
||||
status: "in-progress",
|
||||
stories: {
|
||||
story1: {
|
||||
status: "done",
|
||||
tasks: [
|
||||
{ label: "a", done: true },
|
||||
{ label: "b", done: true },
|
||||
],
|
||||
},
|
||||
story2: {
|
||||
status: "todo",
|
||||
tasks: [
|
||||
{ label: "c", done: false },
|
||||
{ label: "d", done: false },
|
||||
{ label: "e", done: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const state = buildState(root);
|
||||
expect(state.epics).toEqual({
|
||||
epic1: {
|
||||
status: "in-progress",
|
||||
title: "epic1",
|
||||
stories: {
|
||||
story1: {
|
||||
status: "done",
|
||||
title: "story1",
|
||||
ac_total: 2,
|
||||
ac_completed: 2,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
},
|
||||
story2: {
|
||||
status: "todo",
|
||||
title: "story2",
|
||||
ac_total: 3,
|
||||
ac_completed: 0,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skips _templates and prds folders", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-"));
|
||||
fs.mkdirSync(path.join(root, "_templates"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "_templates", "_epic.md"),
|
||||
"---\nid: x\n---",
|
||||
);
|
||||
fs.mkdirSync(path.join(root, "prds"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---");
|
||||
expect(buildState(root).epics).toEqual({});
|
||||
});
|
||||
|
||||
it("skips directories without _epic.md", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip2-"));
|
||||
fs.mkdirSync(path.join(root, "incomplete"), { recursive: true });
|
||||
expect(buildState(root).epics).toEqual({});
|
||||
});
|
||||
|
||||
it("parses depends-on and blocks frontmatter arrays", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-dep-"));
|
||||
const epicDir = path.join(root, "epic1");
|
||||
fs.mkdirSync(epicDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(epicDir, "_epic.md"),
|
||||
`---
|
||||
id: epic1
|
||||
title: epic1
|
||||
status: todo
|
||||
---
|
||||
`,
|
||||
);
|
||||
const story1Dir = path.join(epicDir, "story1");
|
||||
fs.mkdirSync(story1Dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(story1Dir, "_story.md"),
|
||||
`---
|
||||
id: story1
|
||||
title: story1
|
||||
status: todo
|
||||
depends-on: []
|
||||
blocks: [story2, other-epic/x]
|
||||
---
|
||||
`,
|
||||
);
|
||||
const story2Dir = path.join(epicDir, "story2");
|
||||
fs.mkdirSync(story2Dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(story2Dir, "_story.md"),
|
||||
`---
|
||||
id: story2
|
||||
title: story2
|
||||
status: todo
|
||||
depends-on: [story1]
|
||||
blocks: []
|
||||
---
|
||||
`,
|
||||
);
|
||||
|
||||
const state = buildState(root);
|
||||
expect(state.epics.epic1.stories.story1.depends_on).toEqual([]);
|
||||
expect(state.epics.epic1.stories.story1.blocks).toEqual([
|
||||
"story2",
|
||||
"other-epic/x",
|
||||
]);
|
||||
expect(state.epics.epic1.stories.story2.depends_on).toEqual(["story1"]);
|
||||
expect(state.epics.epic1.stories.story2.blocks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countTaskCheckboxes", () => {
|
||||
it("counts both unchecked and checked", () => {
|
||||
const content = `## Tasks
|
||||
- [x] done one
|
||||
- [ ] open one
|
||||
- [X] capital X also counts
|
||||
`;
|
||||
expect(countTaskCheckboxes(content)).toEqual({ total: 3, completed: 2 });
|
||||
});
|
||||
|
||||
it("only counts inside the ## Tasks section", () => {
|
||||
const content = `## Tasks
|
||||
- [x] in tasks
|
||||
|
||||
## Notes
|
||||
- [x] outside, should not count
|
||||
`;
|
||||
expect(countTaskCheckboxes(content)).toEqual({ total: 1, completed: 1 });
|
||||
});
|
||||
|
||||
it("returns zeros when no Tasks section exists", () => {
|
||||
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({
|
||||
total: 0,
|
||||
completed: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("extracts simple string keys", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-"));
|
||||
const fp = path.join(dir, "f.md");
|
||||
fs.writeFileSync(
|
||||
fp,
|
||||
`---
|
||||
id: x
|
||||
title: A title
|
||||
status: done
|
||||
---
|
||||
|
||||
Body`,
|
||||
);
|
||||
expect(parseFrontmatter(fp)).toEqual({
|
||||
id: "x",
|
||||
title: "A title",
|
||||
status: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns {} when no frontmatter present", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm2-"));
|
||||
const fp = path.join(dir, "f.md");
|
||||
fs.writeFileSync(fp, "# Just a heading\n");
|
||||
expect(parseFrontmatter(fp)).toEqual({});
|
||||
});
|
||||
|
||||
it("parses array frontmatter values", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-arr-"));
|
||||
const fp = path.join(dir, "f.md");
|
||||
fs.writeFileSync(
|
||||
fp,
|
||||
`---
|
||||
id: x
|
||||
items: [a, b, "c"]
|
||||
empty: []
|
||||
---
|
||||
`,
|
||||
);
|
||||
expect(parseFrontmatter(fp)).toEqual({
|
||||
id: "x",
|
||||
items: ["a", "b", "c"],
|
||||
empty: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeReadyBlocked", () => {
|
||||
it("returns ready for stories whose depends_on are all done", () => {
|
||||
const state = {
|
||||
updated_at: "x",
|
||||
epics: {
|
||||
e: {
|
||||
status: "in-progress",
|
||||
title: "e",
|
||||
stories: {
|
||||
a: {
|
||||
status: "done",
|
||||
title: "a",
|
||||
ac_total: 0,
|
||||
ac_completed: 0,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
},
|
||||
b: {
|
||||
status: "todo",
|
||||
title: "b",
|
||||
ac_total: 0,
|
||||
ac_completed: 0,
|
||||
depends_on: ["a"],
|
||||
blocks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { ready, blocked } = computeReadyBlocked(state);
|
||||
expect(ready).toEqual([{ epic: "e", story: "b", title: "b" }]);
|
||||
expect(blocked).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns blocked for stories whose depends_on are not done", () => {
|
||||
const state = {
|
||||
updated_at: "x",
|
||||
epics: {
|
||||
e: {
|
||||
status: "in-progress",
|
||||
title: "e",
|
||||
stories: {
|
||||
a: {
|
||||
status: "todo",
|
||||
title: "a",
|
||||
ac_total: 0,
|
||||
ac_completed: 0,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
},
|
||||
b: {
|
||||
status: "todo",
|
||||
title: "b",
|
||||
ac_total: 0,
|
||||
ac_completed: 0,
|
||||
depends_on: ["a"],
|
||||
blocks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { ready, blocked } = computeReadyBlocked(state);
|
||||
expect(ready).toEqual([{ epic: "e", story: "a", title: "a" }]);
|
||||
expect(blocked).toEqual([
|
||||
{ epic: "e", story: "b", title: "b", waiting_on: ["e/a"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves cross-epic refs", () => {
|
||||
const state = {
|
||||
updated_at: "x",
|
||||
epics: {
|
||||
e1: {
|
||||
status: "done",
|
||||
title: "e1",
|
||||
stories: {
|
||||
a: {
|
||||
status: "done",
|
||||
title: "a",
|
||||
ac_total: 0,
|
||||
ac_completed: 0,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
e2: {
|
||||
status: "in-progress",
|
||||
title: "e2",
|
||||
stories: {
|
||||
b: {
|
||||
status: "todo",
|
||||
title: "b",
|
||||
ac_total: 0,
|
||||
ac_completed: 0,
|
||||
depends_on: ["e1/a"],
|
||||
blocks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { ready, blocked } = computeReadyBlocked(state);
|
||||
expect(ready).toEqual([{ epic: "e2", story: "b", title: "b" }]);
|
||||
expect(blocked).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips done stories", () => {
|
||||
const state = {
|
||||
updated_at: "x",
|
||||
epics: {
|
||||
e: {
|
||||
status: "done",
|
||||
title: "e",
|
||||
stories: {
|
||||
a: {
|
||||
status: "done",
|
||||
title: "a",
|
||||
ac_total: 1,
|
||||
ac_completed: 1,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { ready, blocked } = computeReadyBlocked(state);
|
||||
expect(ready).toEqual([]);
|
||||
expect(blocked).toEqual([]);
|
||||
});
|
||||
});
|
||||
68
scripts/work/state-sync-guard.mjs
Normal file
68
scripts/work/state-sync-guard.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pre-commit guard: refuses the commit if docs/work/_system/_state.json is staged
|
||||
* but is not byte-identical to what `pnpm work rebuild-state` would emit
|
||||
* given the current markdown content of docs/work/.
|
||||
*
|
||||
* Run from .husky/pre-commit AFTER lint-staged and AFTER the conditional
|
||||
* rebuild-state + re-stage step. By that point the staged _state.json
|
||||
* should already match the rebuild output. This script is the safety net
|
||||
* for the case where someone hand-edits _state.json without going through
|
||||
* rebuild-state.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — _state.json is in sync (or not staged at all)
|
||||
* 1 — _state.json is staged but out of sync
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync } from "node:child_process";
|
||||
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, "_system", "_state.json");
|
||||
|
||||
function stagedFiles() {
|
||||
const out = execSync("git diff --cached --name-only", {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return out.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const staged = stagedFiles();
|
||||
const stateRel = path.relative(REPO_ROOT, STATE_FILE);
|
||||
if (
|
||||
!staged.includes(stateRel) &&
|
||||
!staged.some((f) => f.startsWith("docs/work/") && f.endsWith(".md"))
|
||||
) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (!fs.existsSync(STATE_FILE)) {
|
||||
console.error(
|
||||
"✗ state-sync-guard: docs/work/_system/_state.json missing. Run `pnpm work rebuild-state`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const onDisk = fs.readFileSync(STATE_FILE, "utf8");
|
||||
const fresh = JSON.stringify(buildState(WORK_ROOT), null, 2) + "\n";
|
||||
// The `updated_at` timestamp will always differ. Strip it from both sides
|
||||
// before comparing.
|
||||
const stripUpdatedAt = (s) =>
|
||||
s.replace(/"updated_at":\s*"[^"]+",?\s*\n?/, "");
|
||||
if (stripUpdatedAt(onDisk) === stripUpdatedAt(fresh)) {
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(
|
||||
"✗ state-sync-guard: docs/work/_system/_state.json is out of sync with markdown.",
|
||||
);
|
||||
console.error(" Run: pnpm work rebuild-state");
|
||||
console.error(" Then: git add docs/work/_system/_state.json");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
16
scripts/work/state-sync-guard.test.mjs
Normal file
16
scripts/work/state-sync-guard.test.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const GUARD = path.join(__dirname, "state-sync-guard.mjs");
|
||||
|
||||
describe("state-sync-guard smoke", () => {
|
||||
it("exits 0 when run against the repo's current state (since main is in sync)", () => {
|
||||
// Run the guard; if main's _state.json drifts, this test will fail and tell us.
|
||||
execSync(`node "${GUARD}"`, { encoding: "utf8" });
|
||||
// No assertion on output; the exit code is 0 if we got here without throwing.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user