#!/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 EITHER: * 1. Claude Code logged in on host (~/.claude/ — recommended for subscribers) * 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback) */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { execSync, execFileSync } 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, "epics", 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. An optional * `rejection_notes` argument is appended when the orchestrator re-dispatches * the implementer after a reviewer reject. */ export function buildTaskSpec(next, rejectionNotes = null) { const base = `# Current task ## Epic ${next.epic} ## Story ${next.story} — ${next.title} ## Current bullet ${next.bulletLine.trim()} ## Full story for context ${next.storyContent}`; if (!rejectionNotes) return base; return `${base} ## Previous attempt was REJECTED — fix these before re-committing ${rejectionNotes}`; } /** * Extract the LAST structured JSON object emitted by the agent. The * implementer + reviewer prompts both ask the agent to return JSON; in * practice agents wrap it in a \`\`\`json ... \`\`\` fence, but we tolerate * a bare \`{ ... }\` block at the end of stdout too. Returns null on no * parsable match. */ export function parseAgentJson(stdout) { if (!stdout) return null; // 1. Code-fenced JSON: take the LAST ```json ... ``` block. const fenceMatches = [...stdout.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g)]; if (fenceMatches.length > 0) { const inner = fenceMatches[fenceMatches.length - 1][1].trim(); try { return JSON.parse(inner); } catch { // fall through to bare-brace fallback } } // 2. Bare braces: walk backwards from the last "}" to its match. Defensive // against partial output or extra trailing characters from the completion // signal. const lastClose = stdout.lastIndexOf("}"); if (lastClose === -1) return null; let depth = 0; for (let i = lastClose; i >= 0; i--) { if (stdout[i] === "}") depth++; else if (stdout[i] === "{") depth--; if (depth === 0) { const candidate = stdout.slice(i, lastClose + 1); try { return JSON.parse(candidate); } catch { return null; } } } return null; } /** * Replace the `- [ ]` checkbox at the given line index with `- [x]`. Pure * over the file's text — returns the new content. */ export function tickBulletInContent(content, bulletIndex) { const lines = content.split("\n"); if (bulletIndex < 0 || bulletIndex >= lines.length) return content; lines[bulletIndex] = lines[bulletIndex].replace(/\[\s\]/, "[x]"); return lines.join("\n"); } /** * Count remaining `- [ ]` checkboxes inside the `## Tasks` section. */ export function countUncheckedBullets(content) { const lines = content.split("\n"); let inTasks = false; let count = 0; for (const line of lines) { if (line.startsWith("## ")) { inTasks = /^##\s+Tasks\b/i.test(line); continue; } if (!inTasks) continue; if (/^[\s>-]*\[\s\]/.test(line)) count++; } return count; } /** * Edit the `status:` line inside the leading `---\n...\n---` frontmatter * block. Returns the new content, or the original if no frontmatter or no * `status:` key was found. */ export function setFrontmatterStatus(content, newStatus) { const fmMatch = content.match(/^(---\n)([\s\S]+?)(\n---)/); if (!fmMatch) return content; const [full, openDelim, body, closeDelim] = fmMatch; if (!/^status:\s*/m.test(body)) return content; const newBody = body.replace(/^status:\s*.*$/m, `status: ${newStatus}`); return content.replace(full, `${openDelim}${newBody}${closeDelim}`); } /** * Read the `status:` value from frontmatter. Returns null on no frontmatter * or no key. */ export function readFrontmatterStatus(content) { const fmMatch = content.match(/^---\n([\s\S]+?)\n---/); if (!fmMatch) return null; const m = fmMatch[1].match(/^status:\s*(.*)$/m); return m ? m[1].trim() : null; } /** * Tick the bullet in an epic's `## Stories` section that links to the given * story folder. Idempotent: returns false if the bullet is already ticked or * not present. Mirrors the per-task tick that already happens inside story * files, applied at the parent-epic granularity. */ export function tickStoryBulletInEpic(workRoot, epicId, storyId) { const epicFile = path.join(workRoot, "epics", epicId, "_epic.md"); if (!fs.existsSync(epicFile)) return false; const content = fs.readFileSync(epicFile, "utf8"); const lines = content.split("\n"); let inStories = false; let changed = false; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith("## ")) { inStories = /^##\s+Stories\b/i.test(lines[i]); continue; } if (!inStories) continue; if ( lines[i].includes(`(${storyId}/_story.md)`) || lines[i].includes(`(./${storyId}/_story.md)`) ) { if (/\[\s\]/.test(lines[i])) { lines[i] = lines[i].replace(/\[\s\]/, "[x]"); changed = true; } break; } } if (changed) fs.writeFileSync(epicFile, lines.join("\n")); return changed; } /** * If all stories in an epic are `status: done`, flip the epic's own * frontmatter to `status: done`. Returns true if it flipped, false otherwise. */ export function flipEpicDoneIfAllStoriesDone(workRoot, epicId) { const epicDir = path.join(workRoot, "epics", epicId); const epicFile = path.join(epicDir, "_epic.md"); if (!fs.existsSync(epicFile)) return false; const epicContent = fs.readFileSync(epicFile, "utf8"); if (readFrontmatterStatus(epicContent) === "done") return false; for (const sub of fs.readdirSync(epicDir)) { const subPath = path.join(epicDir, sub); if (!fs.statSync(subPath).isDirectory()) continue; const storyFile = path.join(subPath, "_story.md"); if (!fs.existsSync(storyFile)) continue; const storyStatus = readFrontmatterStatus( fs.readFileSync(storyFile, "utf8"), ); if (storyStatus !== "done") return false; } fs.writeFileSync(epicFile, setFrontmatterStatus(epicContent, "done")); return true; } /** * Resolve the auth method for sandcastle dispatch. * * Priority: * 1. Subscription (primary) — mount host's ~/.claude/ into the sandbox. * Active when the host's Claude creds directory exists. The path * defaults to ~/.claude/ and can be overridden via the * SANDCASTLE_CLAUDE_CREDS_DIR env var. * 2. API key (fallback) — pass ANTHROPIC_API_KEY (or OPENAI_API_KEY) * through to the sandbox env. * 3. Neither available → returns { mode: "missing" } and the dispatcher * prints a clear error before exiting. * * Returns: { mode: "subscription", hostPath, sandboxPath } * | { mode: "api-key", env } * | { mode: "missing" } */ export function resolveClaudeAuth({ env = process.env, home = os.homedir(), } = {}) { // 1. Subscription path const credsHostPath = env.SANDCASTLE_CLAUDE_CREDS_DIR ?? path.join(home, ".claude"); if (fs.existsSync(credsHostPath)) { return { mode: "subscription", hostPath: credsHostPath, // Inside the sandbox, claude looks at the agent user's home — tilde // expansion in MountConfig handles the actual /home/agent/.claude // resolution. sandboxPath: "~/.claude", }; } // 2. API key fallback if (env.ANTHROPIC_API_KEY) { return { mode: "api-key", env: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }, }; } if (env.OPENAI_API_KEY) { return { mode: "api-key", env: { OPENAI_API_KEY: env.OPENAI_API_KEY } }; } // 3. Neither available return { mode: "missing" }; } 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:"); console.log( " - With Claude subscription: `claude login` (one-time) then `pnpm work dispatch --execute`", ); console.log( " - With API key: `ANTHROPIC_API_KEY=... pnpm work dispatch --execute`", ); console.log(); console.log( "By default --execute LOOPS through every ready task. Flags to bound it:", ); console.log(" --once stop after one approved slice"); console.log(" --max-tasks N stop after N approved slices"); console.log(); console.log( "(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)", ); } /** * Print the macOS / sandcastle-image / auth hints when a sandcastle run * blows up. Shared between implementer + reviewer error paths. */ function explainSandcastleError(stage, e) { console.error(`✗ ${stage} dispatch failed:`, e.message); if (/Image '.+' not found locally/.test(e.message ?? "")) { console.error(" One-time setup: pnpm exec sandcastle docker build-image"); } if ( /Not logged in|Please run \/login/.test(e.message ?? "") && process.platform === "darwin" ) { console.error( " macOS users: Claude Code stores credentials in the Keychain, not in ~/.claude/. Extract once:", ); console.error( ` security find-generic-password -s "Claude Code-credentials" -a "$USER" -w > ~/.claude/.credentials.json`, ); console.error(" chmod 600 ~/.claude/.credentials.json"); console.error( " OR fall back to API key: export ANTHROPIC_API_KEY=sk-ant-...", ); } console.error(" See docs/guides/runbook.md → 'Using Sandcastle' for setup."); } /** * Run one slice end-to-end: implementer + reviewer, with a fix-up cycle on * reject (capped at maxAttempts). Each slice is an independent sandcastle * session — sandcastle's `resumeSession` is incompatible with the * multi-iteration budgets a TDD slice requires (applies to iteration 1 only). * * Outcome variants: * "approved" (implJson, reviewJson) * "rejected-final" (lastRejectNotes) * "blocked" (implJson) * "error" (reason) */ async function runOneSlice({ sandcastleRoot, sandbox, agent, next }) { const maxAttempts = Number(process.env.SANDCASTLE_MAX_ATTEMPTS ?? 3); const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md"); const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md"); let rejectionNotes = null; let lastRejectNotes = null; let attempts = 0; while (attempts < maxAttempts) { attempts++; const taskSpec = buildTaskSpec(next, rejectionNotes); let implResult; try { implResult = await sandcastleRoot.run({ agent, sandbox, promptFile: implementerPrompt, promptArgs: { TASK_FILE_CONTENT: taskSpec }, cwd: REPO_ROOT, // Implementer runs a full TDD slice (read context, red test, green // impl, run all five gates, commit). 30 iterations matches typical // slice shape. Tune via env SANDCASTLE_IMPLEMENTER_ITERATIONS. maxIterations: Number( process.env.SANDCASTLE_IMPLEMENTER_ITERATIONS ?? 30, ), // Stop iterating the moment the agent emits this marker. Without // it, sandcastle re-invokes the model up to maxIterations even // when the work is already done. completionSignal: "COMPLETE", }); } catch (e) { explainSandcastleError("Implementer", e); return { outcome: "error", attempts, reason: e.message }; } console.log( `Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`, ); const implJson = parseAgentJson(implResult.stdout); if ( implJson?.status === "blocked" || implJson?.status === "needs-clarification" ) { return { outcome: "blocked", attempts, implJson }; } let diff = ""; try { diff = execSync(`git diff main..${implResult.branch}`, { encoding: "utf8", cwd: REPO_ROOT, }); } catch { diff = "(diff unavailable)"; } let reviewResult; try { reviewResult = await sandcastleRoot.run({ agent, sandbox, promptFile: reviewerPrompt, promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff }, cwd: REPO_ROOT, // Reviewer reads the diff + task spec and decides (approve/reject). // Smaller surface than the implementer; 10 iterations is plenty. // Tune via env SANDCASTLE_REVIEWER_ITERATIONS. maxIterations: Number(process.env.SANDCASTLE_REVIEWER_ITERATIONS ?? 10), // See implementer comment above. completionSignal: "COMPLETE", }); } catch (e) { explainSandcastleError("Reviewer", e); return { outcome: "error", attempts, reason: e.message }; } const reviewJson = parseAgentJson(reviewResult.stdout); if (reviewJson?.decision === "approve") { return { outcome: "approved", attempts, implJson, reviewJson }; } if (reviewJson?.decision === "reject") { rejectionNotes = reviewJson.notes ?? "(reviewer rejected without notes — re-attempt)"; lastRejectNotes = rejectionNotes; console.log( `↺ Attempt ${attempts}/${maxAttempts} rejected. Re-dispatching implementer with notes.`, ); continue; } return { outcome: "error", attempts, reason: `reviewer returned no parseable decision; stdout:\n${reviewResult.stdout}`, }; } return { outcome: "rejected-final", attempts, lastRejectNotes }; } /** * After an `approved` slice: tick the bullet, flip the story status if all * bullets are now ticked (or todo→in-progress on the first tick), flip the * epic status if all its stories are done, and commit the mutation on the * host. The implementer's slice commit is already on main; this is a * separate bookkeeping commit so the slice commit stays clean. */ function applyApprovedState(next) { let content = fs.readFileSync(next.storyPath, "utf8"); content = tickBulletInContent(content, next.bulletIndex); const currentStatus = readFrontmatterStatus(content); let storyFlipped = false; if (countUncheckedBullets(content) === 0 && currentStatus !== "done") { content = setFrontmatterStatus(content, "done"); storyFlipped = true; } else if (currentStatus === "todo") { content = setFrontmatterStatus(content, "in-progress"); } fs.writeFileSync(next.storyPath, content); let epicFlipped = false; let epicBulletTicked = false; if (storyFlipped) { epicBulletTicked = tickStoryBulletInEpic(WORK_ROOT, next.epic, next.story); epicFlipped = flipEpicDoneIfAllStoriesDone(WORK_ROOT, next.epic); } const filesToStage = [path.relative(REPO_ROOT, next.storyPath)]; if (epicFlipped || epicBulletTicked) { filesToStage.push( path.relative( REPO_ROOT, path.join(WORK_ROOT, "epics", next.epic, "_epic.md"), ), ); } const commitMsg = epicFlipped ? `chore(work): finish epic ${next.epic}` : storyFlipped ? `chore(work): finish ${next.story}` : `chore(work): tick task in ${next.story}`; execFileSync("git", ["add", ...filesToStage], { cwd: REPO_ROOT }); execFileSync("git", ["commit", "-m", commitMsg], { cwd: REPO_ROOT, stdio: "inherit", }); console.log(`✓ ${commitMsg}`); } /** * Pick a slice, dispatch implementer + reviewer (with reject fix-up cycle), * apply state mutation on approve, loop until exhausted or a cap is hit. * * Flags: * --once stop after one slice (legacy behavior) * --max-tasks N stop after N approved slices (default: unlimited) */ async function executeDispatch({ maxTasks }) { const auth = resolveClaudeAuth(); if (auth.mode === "missing") { console.error("✗ --execute requires either:"); console.error( " 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)", ); console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)"); console.error(""); console.error( " Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.", ); process.exit(1); } console.log( `Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`, ); let sandcastleRoot; let dockerProvider; try { sandcastleRoot = await import("@ai-hero/sandcastle"); const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker"); dockerProvider = dockerModule.docker; } catch { console.error( "✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.", ); process.exit(1); } const dockerOpts = {}; const agentOpts = {}; if (auth.mode === "subscription") { dockerOpts.mounts = [ { hostPath: auth.hostPath, sandboxPath: auth.sandboxPath, readonly: false, }, ]; } else if (auth.mode === "api-key") { agentOpts.env = auth.env; } const sandbox = dockerProvider(dockerOpts); const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts); let approved = 0; while (true) { if (maxTasks !== null && approved >= maxTasks) { console.log(`\nHit --max-tasks=${maxTasks} cap; stopping.`); break; } const next = findNextTask(); if (!next) { console.log("\nNo more ready tasks. Dispatch loop complete."); break; } console.log( `\n--- Slice ${approved + 1}: ${next.epic} / ${next.story} ---`, ); console.log(` Bullet: ${next.bulletLine.trim()}`); const result = await runOneSlice({ sandcastleRoot, sandbox, agent, next }); if (result.outcome === "approved") { applyApprovedState(next); approved++; continue; } if (result.outcome === "rejected-final") { console.error( `\n✗ Slice rejected after ${result.attempts} attempts. Stopping dispatch loop.`, ); if (result.lastRejectNotes) { console.error(`Last rejection notes:\n${result.lastRejectNotes}`); } process.exit(1); } if (result.outcome === "blocked") { console.error( `\n✗ Implementer reported ${result.implJson?.status ?? "blocked"}. Stopping dispatch loop.`, ); if (result.implJson?.notes) { console.error(`Implementer notes:\n${result.implJson.notes}`); } process.exit(1); } // outcome === "error" console.error(`\n✗ Slice errored: ${result.reason ?? "(no reason)"}`); process.exit(1); } console.log(`\nDispatched ${approved} slice(s).`); } /** * Explicit CLI entry. Exported so cli.mjs can dispatch into this module * without relying on a top-level side effect (which would also fire when * sibling work scripts import `resolveClaudeAuth`, etc.). * * Flags: * --execute run sandcastle (default: print plan only) * --once stop after one approved slice (default: loop until done) * --max-tasks N stop after N approved slices */ export async function runCli(args) { if (!args.includes("--execute")) { printPlan(); return; } let maxTasks = null; if (args.includes("--once")) maxTasks = 1; const maxTasksFlagIdx = args.indexOf("--max-tasks"); if (maxTasksFlagIdx !== -1) { const raw = args[maxTasksFlagIdx + 1]; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 1) { console.error(`✗ --max-tasks expects a positive integer, got: ${raw}`); process.exit(2); } maxTasks = parsed; } await executeDispatch({ maxTasks }); } // When invoked directly (`node scripts/work/dispatch.mjs ...`), run the CLI. // When imported by cli.mjs or any sibling, do nothing — the caller decides. const invokedDirectly = import.meta.url === `file://${process.argv[1]}`; if (invokedDirectly) { runCli(process.argv.slice(2)); }