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.
266 lines
8.9 KiB
JavaScript
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));
|
|
}
|