feat(work): pnpm work decompose subcommand

Closes the gap surfaced by the user: `pnpm work` usage referenced
`decompose` (via docs + the to-prd skill) but the subcommand was
never built. Mirrors `pnpm work dispatch`'s shape.

scripts/work/decompose.mjs (new):
  - validatePrdForDecompose(prdPath) — refuses draft (must go
    through human review first), in-review (review incomplete),
    shipped (epic already exists); accepts only approved
  - printDecomposePlan(prdId, prdPath, frontmatter) — print-mode
    output showing the PRD's eligibility + sandcastle invocation
    plan + auth modes
  - executeDecompose(prdId, prdPath, prdText) — invokes sandcastle
    with .sandcastle/decomposer.prompt.md, passing PRD_FILE_CONTENT
    promptArg. The decomposer agent writes the epic + per-story
    files to disk on a sandcastle branch the human can review
  - runCli(args, { workRoot }) — entry point used by cli.mjs
  - Direct invocation also supported (mirrors dispatch.mjs's
    invokedDirectly guard, NEW pattern after this commit)

scripts/work/decompose.test.mjs (new, 9 tests, all green):
  - validatePrdForDecompose: accepts approved; rejects draft,
    in-review, shipped, unknown status, missing file
  - runCli: writes error + returns 1 on missing PRD; writes error
    + returns 1 on draft PRD; prints plan + returns 0 on approved

scripts/work/cli.mjs:
  - Adds `decompose` subcommand to usage + dispatch
  - Usage formatting realigned for the 3-line subcommand block

scripts/work/dispatch.mjs:
  - **Fix** the bug surfaced by the user: dispatch.mjs's CLI ran
    as a top-level side effect whenever any of its exports was
    imported. decompose.mjs imports resolveClaudeAuth from it, so
    importing decompose.mjs printed "No ready task to dispatch."
    Added an `import.meta.url === \`file://${process.argv[1]}\``
    guard so the CLI only runs when invoked directly. This unblocks
    cross-import without side effects.

Smoke-tested end-to-end:
  - `pnpm work decompose` (no id) prints usage + exits 2
  - `pnpm work decompose 2026-05-13-binder-wrap-helper` prints the
    decompose plan with status: approved (eligible)
  - 9/9 unit tests green
  - dispatch.mjs's existing direct-invocation path unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:46:57 +02:00
parent 799daa8db5
commit 014578c9a8
4 changed files with 419 additions and 8 deletions

View File

@@ -12,6 +12,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { buildState } from "./state-builder.mjs"; import { buildState } from "./state-builder.mjs";
import { runCli as runPrdShip } from "./prd-ship.mjs"; import { runCli as runPrdShip } from "./prd-ship.mjs";
import { runCli as runDecompose } from "./decompose.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", ".."); const REPO_ROOT = path.resolve(__dirname, "..", "..");
@@ -116,11 +117,14 @@ function mark(status) {
function usage() { function usage() {
console.log( console.log(
"Usage: pnpm work <rebuild-state|status|next|ready|blocked|dispatch|prd-ship>", "Usage: pnpm work <rebuild-state|status|next|ready|blocked|dispatch|decompose|prd-ship>",
); );
console.log( console.log(
" dispatch Print the next dispatch plan (use --execute to invoke sandcastle)", " dispatch Print the next dispatch plan (use --execute to invoke sandcastle)",
); );
console.log(
" decompose <prd-id> Decompose an approved PRD into epic + stories (use --execute to invoke sandcastle)",
);
console.log( console.log(
" prd-ship <id> Flip a PRD's status to `shipped` (run after its epic completes)", " prd-ship <id> Flip a PRD's status to `shipped` (run after its epic completes)",
); );
@@ -145,4 +149,9 @@ else if (cmd === "dispatch") {
workRoot: WORK_ROOT, workRoot: WORK_ROOT,
}); });
process.exit(exitCode); 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(); } else usage();

234
scripts/work/decompose.mjs Normal file
View File

@@ -0,0 +1,234 @@
#!/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/<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/<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,
});
} catch (e) {
console.error("✗ Decomposer dispatch failed:", e.message);
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/<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));
}

View 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 });
}
});
});

View File

@@ -275,9 +275,15 @@ async function executeDispatch() {
console.log("(Automatic state mutation by the orchestrator is v2.)"); console.log("(Automatic state mutation by the orchestrator is v2.)");
} }
const args = process.argv.slice(2); // Only run the CLI when this module is invoked directly. Without this guard,
if (args.includes("--execute")) { // importing any export (e.g. `resolveClaudeAuth` from sibling work scripts)
// triggers the CLI as a side effect.
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
const args = process.argv.slice(2);
if (args.includes("--execute")) {
executeDispatch(); executeDispatch();
} else { } else {
printPlan(); printPlan();
}
} }