Files
agentic-dev/scripts/work/decompose.mjs
Danijel Martinek 014578c9a8 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>
2026-05-13 17:46:57 +02:00

235 lines
7.4 KiB
JavaScript

#!/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));
}