diff --git a/scripts/work/cli.mjs b/scripts/work/cli.mjs index a2413c4..62e26ab 100644 --- a/scripts/work/cli.mjs +++ b/scripts/work/cli.mjs @@ -12,6 +12,7 @@ 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"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "..", ".."); @@ -116,13 +117,16 @@ function mark(status) { function usage() { console.log( - "Usage: pnpm work ", + "Usage: pnpm work ", ); 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( - " prd-ship Flip a PRD's status to `shipped` (run after its epic completes)", + " decompose Decompose an approved PRD into epic + stories (use --execute to invoke sandcastle)", + ); + console.log( + " prd-ship Flip a PRD's status to `shipped` (run after its epic completes)", ); process.exit(2); } @@ -145,4 +149,9 @@ else if (cmd === "dispatch") { 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(); diff --git a/scripts/work/decompose.mjs b/scripts/work/decompose.mjs new file mode 100644 index 0000000..001caf8 --- /dev/null +++ b/scripts/work/decompose.mjs @@ -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//. + * + * 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, + }); + } 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// 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)); +} diff --git a/scripts/work/decompose.test.mjs b/scripts/work/decompose.test.mjs new file mode 100644 index 0000000..a4096f9 --- /dev/null +++ b/scripts/work/decompose.test.mjs @@ -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 }); + } + }); +}); diff --git a/scripts/work/dispatch.mjs b/scripts/work/dispatch.mjs index 300b05b..9bb742d 100644 --- a/scripts/work/dispatch.mjs +++ b/scripts/work/dispatch.mjs @@ -275,9 +275,15 @@ async function executeDispatch() { console.log("(Automatic state mutation by the orchestrator is v2.)"); } -const args = process.argv.slice(2); -if (args.includes("--execute")) { - executeDispatch(); -} else { - printPlan(); +// Only run the CLI when this module is invoked directly. Without this guard, +// 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(); + } else { + printPlan(); + } }