#!/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//. * * 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 * pnpm work decompose --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.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 --execute`", ); console.log( " - With API key: `ANTHROPIC_API_KEY=... pnpm work decompose --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 // COMPLETE on its final line. completionSignal: "COMPLETE", }); } 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// 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 [--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)); }