#!/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(); }