Files
agentic-dev/scripts/work/decompose.mjs
Danijel Martinek 756e36c720 refactor(work): move epic folders into docs/work/epics/
The previous layout placed epic folders directly under docs/work/
alongside prds/ and _system/. Tightening: epics now live in their
own docs/work/epics/ subfolder, peer to prds/ and _system/. Same
shape as the existing prds/ bucket.

Final docs/work/ layout:
  README.md
  prds/<slug>.prd.md
  _system/_state.json
  epics/<slug>/_epic.md + <story-folder>/_story.md

Renames (git mv preserves history):
- docs/work/binder-wrap-helper/
    -> docs/work/epics/binder-wrap-helper/
- docs/work/library-evaluation-policy/
    -> docs/work/epics/library-evaluation-policy/
- docs/work/ci-security-and-supply-chain/
    -> docs/work/epics/ci-security-and-supply-chain/

Tooling updates:
- state-builder.mjs walks workRoot/epics/ directly; SKIP_FOLDERS
  obsoleted (no more sibling folders to filter out).
- dispatch.mjs's findNextTask, tickStoryBulletInEpic, and
  flipEpicDoneIfAllStoriesDone all join with "epics" segment.
- prd-ship.mjs's deriveShippingCommits walks workRoot/epics/ and
  git-logs docs/work/epics/<epic>/.
- decomposer.prompt.md emits epics under docs/work/epics/<epic-id>/.
- handoff + grill-with-docs glossary references updated.
- Glossary entry for Epic updated.

Reserved future shape: when a task-tracker integration (ClickUp,
Linear) ships, the epics/ subfolder hosts <task-id>-<slug>/
folders. Today it just hosts bare slugs.
2026-05-14 21:21:51 +02:00

266 lines
8.9 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/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));
}