feat(scripts): dispatch.mjs — planner + execute-mode skeleton
This commit is contained in:
203
scripts/work/dispatch.mjs
Normal file
203
scripts/work/dispatch.mjs
Normal 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();
|
||||
}
|
||||
139
scripts/work/dispatch.test.mjs
Normal file
139
scripts/work/dispatch.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user