#!/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 } 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}`; } /** * 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( "(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)", ); } async function executeDispatch() { const next = findNextTask(); if (!next) { console.log("No ready task to dispatch."); process.exit(0); } 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"}`, ); console.log( `Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`, ); const taskSpec = buildTaskSpec(next); 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); } // Build sandbox + agent providers based on auth mode 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); // Implementer const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md"); let implResult; try { implResult = await sandcastleRoot.run({ agent, sandbox, promptFile: implementerPrompt, promptArgs: { TASK_FILE_CONTENT: taskSpec }, cwd: REPO_ROOT, }); } catch (e) { console.error("✗ Implementer dispatch failed:", e.message); console.error( " See docs/guides/runbook.md → 'Using Sandcastle' for setup.", ); process.exit(1); } console.log( `Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`, ); // Reviewer 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 sandcastleRoot.run({ agent, sandbox, promptFile: reviewerPrompt, promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff }, cwd: REPO_ROOT, }); console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`); // V1: 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(); }