feat(scripts): dispatch.mjs — planner + execute-mode skeleton

This commit is contained in:
2026-05-13 08:18:58 +02:00
parent 1e7bd68b17
commit da811eb461
2 changed files with 342 additions and 0 deletions

203
scripts/work/dispatch.mjs Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env node
/**
* pnpm work dispatch — orchestrator that picks the next ready task and
* (with --execute) invokes sandcastle to run the implementer then reviewer.
*
* Default mode prints the dispatch plan without invoking sandcastle —
* safe to run anywhere. --execute requires ANTHROPIC_API_KEY in the env.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
import { buildState } from "./state-builder.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");
/**
* Returns the first ready story's first unchecked AC bullet, or null if
* there's no work to dispatch.
*
* Shape: { epic, story, title, storyPath, storyContent, bulletLine, bulletIndex }
*/
export function findNextTask(workRoot = WORK_ROOT) {
const state = buildState(workRoot);
if (state.ready.length === 0) return null;
const next = state.ready[0];
const storyPath = path.join(workRoot, next.epic, next.story, "_story.md");
if (!fs.existsSync(storyPath)) return null;
const storyContent = fs.readFileSync(storyPath, "utf8");
const { bulletLine, bulletIndex } = findFirstUncheckedBullet(storyContent);
if (bulletLine === null) return null;
return {
epic: next.epic,
story: next.story,
title: next.title,
storyPath,
storyContent,
bulletLine,
bulletIndex,
};
}
/**
* Scans the story content for the first `- [ ]` bullet INSIDE the `## Tasks`
* section. Returns the matched line + its 0-based index within the file's
* line array (used by the orchestrator if it later tick-edits the file).
*/
export function findFirstUncheckedBullet(content) {
const lines = content.split("\n");
let inTasks = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("## ")) {
inTasks = /^##\s+Tasks\b/i.test(line);
continue;
}
if (!inTasks) continue;
if (/^[\s>-]*\[\s\]/.test(line)) {
return { bulletLine: line, bulletIndex: i };
}
}
return { bulletLine: null, bulletIndex: -1 };
}
/**
* Builds the task spec string passed to sandcastle as TASK_FILE_CONTENT.
* The implementer prompt template uses this verbatim.
*/
export function buildTaskSpec(next) {
return `# Current task
## Epic
${next.epic}
## Story
${next.story}${next.title}
## Current bullet
${next.bulletLine.trim()}
## Full story for context
${next.storyContent}`;
}
function printPlan() {
const next = findNextTask();
if (!next) {
console.log("No ready task to dispatch.");
console.log("Run `pnpm work blocked` to see what's waiting on what.");
process.exit(0);
}
console.log("=== Dispatch plan ===");
console.log(` Epic: ${next.epic}`);
console.log(` Story: ${next.story}${next.title}`);
console.log(` Bullet: ${next.bulletLine.trim()}`);
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
console.log();
console.log("To execute this dispatch, run:");
console.log(" ANTHROPIC_API_KEY=... pnpm work dispatch --execute");
console.log();
console.log(
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and an agent API key.)",
);
}
async function executeDispatch() {
const next = findNextTask();
if (!next) {
console.log("No ready task to dispatch.");
process.exit(0);
}
if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) {
console.error(
"✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.",
);
process.exit(1);
}
console.log(
`Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`,
);
const taskSpec = buildTaskSpec(next);
let sandcastle;
let dockerSandbox;
try {
sandcastle = await import("@ai-hero/sandcastle");
({ docker: dockerSandbox } =
await import("@ai-hero/sandcastle/sandboxes/docker"));
} catch {
console.error(
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
);
process.exit(1);
}
const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
let implResult;
try {
implResult = await sandcastle.run({
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
sandbox: dockerSandbox({
imageName: `sandcastle-dispatch:local`,
}),
promptFile: implementerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec },
cwd: REPO_ROOT,
});
} catch (e) {
console.error("✗ Implementer dispatch failed:", e.message);
console.error(
" See .sandcastle/README.md for setup. Provider name(s) may need updating.",
);
process.exit(1);
}
console.log(
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
);
// Reviewer: pass the diff as DIFF prompt variable.
let diff = "";
try {
diff = execSync(`git diff main..${implResult.branch}`, {
encoding: "utf8",
cwd: REPO_ROOT,
});
} catch {
diff = "(diff unavailable)";
}
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
const reviewResult = await sandcastle.run({
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
sandbox: dockerSandbox({
imageName: `sandcastle-dispatch:local`,
}),
promptFile: reviewerPrompt,
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
cwd: REPO_ROOT,
});
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`);
// V1: the orchestrator does NOT auto-mutate state. Print what should happen.
console.log();
console.log("=== Suggested state mutation ===");
console.log(` Edit ${next.storyPath} — tick the bullet:`);
console.log(` ${next.bulletLine.trim().replace("[ ]", "[x]")}`);
console.log(
` Then: pnpm work rebuild-state && git add -A && git commit -m "feat(...): ..."`,
);
console.log();
console.log("(Automatic state mutation by the orchestrator is v2.)");
}
const args = process.argv.slice(2);
if (args.includes("--execute")) {
executeDispatch();
} else {
printPlan();
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
findNextTask,
findFirstUncheckedBullet,
buildTaskSpec,
} from "./dispatch.mjs";
function makeWorkTree({ epics }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-"));
for (const [epicId, epicData] of Object.entries(epics)) {
const epicDir = path.join(root, epicId);
fs.mkdirSync(epicDir, { recursive: true });
fs.writeFileSync(
path.join(epicDir, "_epic.md"),
`---\nid: ${epicId}\ntitle: ${epicId}\nstatus: ${epicData.status ?? "in-progress"}\n---\n`,
);
for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) {
const storyDir = path.join(epicDir, storyId);
fs.mkdirSync(storyDir, { recursive: true });
const tasks = (storyData.tasks ?? [])
.map((t) => `- [${t.done ? "x" : " "}] ${t.label}`)
.join("\n");
const deps = storyData.depends_on
? `depends-on: [${storyData.depends_on.join(", ")}]\n`
: "";
fs.writeFileSync(
path.join(storyDir, "_story.md"),
`---\nid: ${storyId}\ntitle: ${storyId}\nstatus: ${storyData.status ?? "todo"}\n${deps}---\n\n## Tasks\n${tasks}\n`,
);
}
}
return root;
}
describe("findFirstUncheckedBullet", () => {
it("returns the first - [ ] line under ## Tasks", () => {
const content = `## Tasks
- [x] done
- [ ] open
- [ ] another
`;
const { bulletLine } = findFirstUncheckedBullet(content);
expect(bulletLine).toBe("- [ ] open");
});
it("returns null when no unchecked bullets remain", () => {
const content = `## Tasks
- [x] done
- [x] also done
`;
expect(findFirstUncheckedBullet(content).bulletLine).toBeNull();
});
it("only checks inside the Tasks section", () => {
const content = `## Goal
- [ ] not a task
## Tasks
- [x] done
`;
expect(findFirstUncheckedBullet(content).bulletLine).toBeNull();
});
});
describe("findNextTask", () => {
it("returns the next bullet from the first ready story", () => {
const root = makeWorkTree({
epics: {
e: {
stories: {
s: {
status: "todo",
tasks: [
{ label: "a", done: true },
{ label: "b", done: false },
],
},
},
},
},
});
const next = findNextTask(root);
expect(next.epic).toBe("e");
expect(next.story).toBe("s");
expect(next.bulletLine.trim()).toBe("- [ ] b");
});
it("returns null when no stories are ready", () => {
const root = makeWorkTree({
epics: {
e: {
stories: {
s: {
status: "done",
tasks: [{ label: "a", done: true }],
},
},
},
},
});
expect(findNextTask(root)).toBeNull();
});
it("returns null when ready story has no unchecked bullets", () => {
const root = makeWorkTree({
epics: {
e: {
stories: {
s: {
status: "todo",
tasks: [{ label: "a", done: true }],
},
},
},
},
});
expect(findNextTask(root)).toBeNull();
});
});
describe("buildTaskSpec", () => {
it("includes epic, story, bullet, and full story content", () => {
const next = {
epic: "e",
story: "s",
title: "Story",
storyContent: "## Goal\n\nSomething.\n## Tasks\n- [ ] do thing",
bulletLine: "- [ ] do thing",
};
const spec = buildTaskSpec(next);
expect(spec).toContain("e");
expect(spec).toContain("s — Story");
expect(spec).toContain("- [ ] do thing");
expect(spec).toContain("## Goal");
});
});