284 lines
8.6 KiB
JavaScript
284 lines
8.6 KiB
JavaScript
#!/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();
|
|
}
|