Files
agentic-dev/scripts/work/dispatch.mjs
Danijel Martinek d5c01209ea feat(work): resume implementer session across same-story slices
Wires sandcastle's native `resumeSession` into the dispatch loop so
the implementer walks into task N already knowing what task N-1
discovered — repo layout, helper signatures, gate output, prior diff.
No scratchpad / no hand-curated context file; the agent's own Claude
Code conversation log is the carrier.

Three guardrails keep it bounded:

- Story boundary reset. `currentSession` is dropped whenever
  findNextTask returns a different story id. New domain ≈ new
  context — keeps story 03 from inheriting story 02's residue.
- Token-threshold reset. After each approved slice, sum the
  implementer's last-iteration usage (inputTokens +
  cacheCreationInputTokens + cacheReadInputTokens — caching saves
  dollars but doesn't free window space). If above
  SANDCASTLE_SESSION_TOKEN_RESET (default 140000 ≈ 70% of Sonnet
  4.6's 200k), drop the session before the next task. Configurable
  via env.
- Context-exhausted safety net. If the model rejects with
  "prompt is too long" / "context_length_exceeded" / similar, the
  retry loop drops the session and re-runs the attempt fresh
  exactly once. Doesn't count against SANDCASTLE_MAX_ATTEMPTS
  (different failure mode).

Reviewer always runs fresh — each approve/reject decision should be
independent of prior tasks to keep the gate honest. Within a single
slice's reject-fixup retries, the implementer also carries forward
across attempts (so attempt 2 sees attempt 1's reasoning + the
reviewer notes), but that's per-slice cumulative, not cross-slice.

runOneSlice now returns { sessionId, usage } so executeDispatch can
make the carry-or-reset decision per slice.
2026-05-13 20:13:30 +02:00

764 lines
25 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, 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, 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;
}
/**
* 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, 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.");
}
/**
* Sum of all input-token classes counted against the model's context
* window for one iteration. `cacheReadInputTokens` and
* `cacheCreationInputTokens` are cheap dollar-wise but still occupy the
* window, so they all count for the reset-threshold check.
*/
function totalInputTokens(usage) {
if (!usage) return 0;
return (
(usage.inputTokens ?? 0) +
(usage.cacheCreationInputTokens ?? 0) +
(usage.cacheReadInputTokens ?? 0)
);
}
/**
* Detect Claude / sandcastle errors that indicate the agent's input
* exceeded the model's context window. The orchestrator handles this by
* dropping the resumed session and retrying once with a fresh session.
*/
function isContextExhaustedError(e) {
const msg = String(e?.message ?? e ?? "");
return (
/prompt is too long/i.test(msg) ||
/context_length_exceeded/i.test(msg) ||
/context window/i.test(msg) ||
/too many tokens/i.test(msg)
);
}
/**
* Run one slice end-to-end: implementer + reviewer, with a fix-up cycle on
* reject (capped at maxAttempts). The implementer is invoked with
* `resumeSession` so its prior context (file reads, helper signatures,
* gate output) carries forward; the reviewer always runs fresh so each
* approve/reject decision is independent.
*
* Returns:
* { outcome, attempts, sessionId, usage, ... }
* where sessionId / usage are from the implementer's LAST iteration of
* its LAST attempt (used by the caller for the next slice's resume +
* threshold check). Outcome variants:
* "approved" (implJson, reviewJson)
* "rejected-final" (lastRejectNotes)
* "blocked" (implJson)
* "error" (reason)
*/
async function runOneSlice({
sandcastleRoot,
sandbox,
agent,
next,
resumeSession,
}) {
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;
// Across retries within this slice, the implementer resumes from the
// most recent session — so attempt N sees attempt N-1's reasoning + the
// reviewer's rejection notes. Caller's resumeSession (from the prior
// slice) seeds the first attempt; null = fresh session.
let currentSession = resumeSession ?? null;
let lastUsage = null;
let contextResetUsedThisSlice = false;
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: "<promise>COMPLETE</promise>",
// Resume from the prior slice's (or prior attempt's) session so
// the implementer doesn't re-explore the repo for every task in
// the same story.
resumeSession: currentSession ?? undefined,
});
} catch (e) {
// Context-exhaustion safety net: drop the resumed session and retry
// the same attempt fresh, exactly once per slice.
if (
isContextExhaustedError(e) &&
currentSession &&
!contextResetUsedThisSlice
) {
console.log(
"↺ Context window exhausted; dropping resumed session and retrying fresh.",
);
currentSession = null;
contextResetUsedThisSlice = true;
attempts--; // not counted against SANDCASTLE_MAX_ATTEMPTS
continue;
}
explainSandcastleError("Implementer", e);
return {
outcome: "error",
attempts,
reason: e.message,
sessionId: currentSession,
usage: lastUsage,
};
}
console.log(
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
);
const finalIter = implResult.iterations[implResult.iterations.length - 1];
currentSession = finalIter?.sessionId ?? currentSession;
lastUsage = finalIter?.usage ?? lastUsage;
const implJson = parseAgentJson(implResult.stdout);
if (
implJson?.status === "blocked" ||
implJson?.status === "needs-clarification"
) {
return {
outcome: "blocked",
attempts,
implJson,
sessionId: currentSession,
usage: lastUsage,
};
}
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: "<promise>COMPLETE</promise>",
// Reviewer always runs fresh — each approve/reject decision should
// be independent of prior tasks to keep the gate honest.
});
} catch (e) {
explainSandcastleError("Reviewer", e);
return {
outcome: "error",
attempts,
reason: e.message,
sessionId: currentSession,
usage: lastUsage,
};
}
const reviewJson = parseAgentJson(reviewResult.stdout);
if (reviewJson?.decision === "approve") {
return {
outcome: "approved",
attempts,
implJson,
reviewJson,
sessionId: currentSession,
usage: lastUsage,
};
}
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}`,
sessionId: currentSession,
usage: lastUsage,
};
}
return {
outcome: "rejected-final",
attempts,
lastRejectNotes,
sessionId: currentSession,
usage: lastUsage,
};
}
/**
* 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;
if (storyFlipped) {
epicFlipped = flipEpicDoneIfAllStoriesDone(WORK_ROOT, next.epic);
}
const filesToStage = [path.relative(REPO_ROOT, next.storyPath)];
if (epicFlipped) {
filesToStage.push(
path.relative(REPO_ROOT, path.join(WORK_ROOT, 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);
// Session is carried forward across slices WITHIN the same story so the
// implementer doesn't re-explore the repo each task. Reset on story
// boundaries (different repo area, different context) and when the prior
// session's input-token usage crosses the threshold (avoids hitting the
// model's context-window limit mid-slice).
const tokenResetThreshold = Number(
process.env.SANDCASTLE_SESSION_TOKEN_RESET ?? 140000,
);
let approved = 0;
let currentStory = null;
let currentSession = null;
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;
}
if (next.story !== currentStory) {
if (currentStory !== null && currentSession) {
console.log(
`\n(Story boundary — resetting implementer session from ${currentStory}${next.story})`,
);
}
currentSession = null;
currentStory = next.story;
}
console.log(
`\n--- Slice ${approved + 1}: ${next.epic} / ${next.story} ---`,
);
console.log(` Bullet: ${next.bulletLine.trim()}`);
if (currentSession) {
console.log(` Resuming session: ${currentSession.slice(0, 12)}`);
}
const result = await runOneSlice({
sandcastleRoot,
sandbox,
agent,
next,
resumeSession: currentSession,
});
if (result.outcome === "approved") {
const usedTokens = totalInputTokens(result.usage);
if (usedTokens > tokenResetThreshold) {
console.log(
`(Session at ${usedTokens} input tokens > threshold ${tokenResetThreshold} — resetting before next task)`,
);
currentSession = null;
} else {
currentSession = result.sessionId ?? null;
}
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));
}