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>
163 lines
4.5 KiB
JavaScript
163 lines
4.5 KiB
JavaScript
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 });
|
|
}
|
|
});
|
|
});
|