diff --git a/scripts/work/dispatch.mjs b/scripts/work/dispatch.mjs new file mode 100644 index 0000000..33aa481 --- /dev/null +++ b/scripts/work/dispatch.mjs @@ -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(); +} diff --git a/scripts/work/dispatch.test.mjs b/scripts/work/dispatch.test.mjs new file mode 100644 index 0000000..7e80811 --- /dev/null +++ b/scripts/work/dispatch.test.mjs @@ -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"); + }); +});