mirror of
https://github.com/alleneubank/claude-code.git
synced 2026-09-14 14:06:56 +08:00
981b92e456
When the codex-reviewer and ralph-reviewed Stop hooks invoke `codex exec`, they pass `-o <outputFile>` so the verdict can be parsed back from a known path, then append the user's `extra_args`. Two latent bugs surfaced after the docs commit started promoting the shared ~/.claude/codex.json knobs to standalone users: 1. The previous filter dropped `-o` / `--output*` tokens individually but not the value that followed a bare flag. With extra_args: ["-o", "/tmp/other"], the filter left "/tmp/other" as a stray positional that Codex treated as an extra prompt argument. The new index-walk filter consumes both halves of paired flags (-o, --output, --output-last-message) and drops the inline --flag=value variants. Applied to both hooks. 2. ralph-reviewed honored `timeout_seconds` raw. A configured value at or above the 1800s Stop hook timeout let Claude kill the hook before it could parse Codex's output and surface a verdict. Now clamped to [60, 1680] (matching the existing codex-reviewer clamp), leaving a 120s buffer below the hook ceiling. Bumps ralph-reviewed to 3.0.2 and codex-reviewer to 1.6.10 in lockstep across each plugin.json and the root marketplace.json. The ralph-reviewed troubleshooting README is updated to describe the new clamp range rather than telling users to "increase to 1800".
715 lines
23 KiB
TypeScript
Executable File
715 lines
23 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* Stop hook for Ralph Reviewed plugin.
|
|
*
|
|
* Intercepts exit attempts during an active Ralph loop.
|
|
* When completion is claimed, triggers Codex review gate.
|
|
*
|
|
* State, prompt, and log operations are delegated to the `rl` CLI
|
|
* (@0xbigboss/rl). The hook only handles stdin parsing, Codex review,
|
|
* and the block/allow decision.
|
|
*
|
|
* Flow:
|
|
* 1. Check for active loop state file (at git repo root)
|
|
* 2. If no loop, allow exit
|
|
* 3. Read state via `rl status --json`
|
|
* 4. Check for completion/blocked flags
|
|
* - Not found: increment iteration, block exit, re-feed prompt
|
|
* - Found: trigger review gate
|
|
* 5. Review gate:
|
|
* - Call Codex CLI with review prompt
|
|
* - APPROVE: allow exit
|
|
* - REJECT: inject feedback, block exit, continue
|
|
*/
|
|
|
|
import { readFileSync, existsSync, appendFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
// --- Version ---
|
|
const HOOK_VERSION = "2026-03-21T14:00:00Z";
|
|
const HOOK_BUILD = "v3.0.0";
|
|
const STDIN_TIMEOUT_MS = 2000;
|
|
|
|
// --- Codex timeout bounds ---
|
|
// Must match the Stop hook timeout declared in hooks/hooks.json. If a user-supplied
|
|
// timeout_seconds reaches HOOK_TIMEOUT_SECONDS, Claude kills the hook before it can
|
|
// parse Codex's output and surface a verdict, so the gate falsely reports
|
|
// "No Codex output file created". Clamp to leave BUFFER_SECONDS for hook teardown.
|
|
const HOOK_TIMEOUT_SECONDS = 1800;
|
|
const BUFFER_SECONDS = 120;
|
|
const MIN_CODEX_TIMEOUT_SECONDS = 60;
|
|
const MAX_CODEX_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS - BUFFER_SECONDS; // 1680s
|
|
|
|
// --- User Config ---
|
|
|
|
interface CodexConfig {
|
|
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
|
|
approval_policy?: "untrusted" | "on-failure" | "on-request" | "never";
|
|
bypass_sandbox?: boolean;
|
|
extra_args?: string[];
|
|
timeout_seconds?: number;
|
|
}
|
|
|
|
interface UserConfig {
|
|
codex?: CodexConfig;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: UserConfig = {
|
|
codex: {
|
|
sandbox: "read-only",
|
|
approval_policy: "never",
|
|
bypass_sandbox: false,
|
|
extra_args: [],
|
|
timeout_seconds: 1200,
|
|
},
|
|
};
|
|
|
|
let userConfig: UserConfig = DEFAULT_CONFIG;
|
|
|
|
// --- Crash Reporting ---
|
|
const ralphsDir = `${homedir()}/.claude/ralphs`;
|
|
let sessionId = "unknown";
|
|
let sessionLogDir = ralphsDir;
|
|
let crashLogPath = `${ralphsDir}/startup.log`;
|
|
|
|
try {
|
|
mkdirSync(ralphsDir, { recursive: true });
|
|
} catch { /* ignore */ }
|
|
|
|
// --- Config Loading ---
|
|
|
|
function loadUserConfig(): UserConfig {
|
|
const standardPath = `${homedir()}/.claude/codex.json`;
|
|
const legacyPath = `${ralphsDir}/config.json`;
|
|
|
|
for (const configPath of [standardPath, legacyPath]) {
|
|
try {
|
|
if (existsSync(configPath)) {
|
|
const content = readFileSync(configPath, "utf-8");
|
|
const parsed = JSON.parse(content) as Partial<UserConfig>;
|
|
return {
|
|
codex: {
|
|
...DEFAULT_CONFIG.codex,
|
|
...parsed.codex,
|
|
},
|
|
};
|
|
}
|
|
} catch (e) {
|
|
try {
|
|
appendFileSync(`${ralphsDir}/startup.log`, `[${new Date().toISOString()}] Failed to load config from ${configPath}: ${e}\n`);
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
return DEFAULT_CONFIG;
|
|
}
|
|
|
|
userConfig = loadUserConfig();
|
|
|
|
function crash(msg: string, error?: unknown) {
|
|
const timestamp = new Date().toISOString();
|
|
let line = `[${timestamp}] [${sessionId}] ${msg}`;
|
|
if (error) {
|
|
if (error instanceof Error) {
|
|
line += `\n Error: ${error.message}\n Stack: ${error.stack}`;
|
|
} else {
|
|
line += `\n Error: ${String(error)}`;
|
|
}
|
|
}
|
|
line += "\n";
|
|
try {
|
|
appendFileSync(crashLogPath, line);
|
|
} catch {
|
|
console.error(line);
|
|
}
|
|
}
|
|
|
|
function setSessionId(id: string) {
|
|
sessionId = id;
|
|
sessionLogDir = `${ralphsDir}/${id}`;
|
|
try {
|
|
mkdirSync(sessionLogDir, { recursive: true });
|
|
} catch { /* ignore */ }
|
|
crashLogPath = `${sessionLogDir}/crash.log`;
|
|
}
|
|
|
|
crash(`Hook starting - version: ${HOOK_BUILD} (${HOOK_VERSION}), PID: ${process.pid}`);
|
|
|
|
// Global error handlers
|
|
let stateFilePath: string | null = null;
|
|
|
|
process.on("uncaughtException", (err) => {
|
|
crash("Uncaught exception", err);
|
|
if (stateFilePath) {
|
|
try { unlinkSync(stateFilePath); crash(`Cleaned up state file on uncaught exception: ${stateFilePath}`); } catch { /* ignore */ }
|
|
}
|
|
console.log(JSON.stringify({}));
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on("unhandledRejection", (reason) => {
|
|
crash("Unhandled rejection", reason);
|
|
if (stateFilePath) {
|
|
try { unlinkSync(stateFilePath); crash(`Cleaned up state file on unhandled rejection: ${stateFilePath}`); } catch { /* ignore */ }
|
|
}
|
|
console.log(JSON.stringify({}));
|
|
process.exit(1);
|
|
});
|
|
|
|
let debugLogPath = `${ralphsDir}/debug.log`;
|
|
let debugEnabled = process.env.RALPH_DEBUG === "1";
|
|
|
|
function debug(msg: string) {
|
|
const timestamp = new Date().toISOString();
|
|
const line = `[${timestamp}] [${sessionId}] ${msg}\n`;
|
|
try {
|
|
appendFileSync(crashLogPath, `[DEBUG] ${line}`);
|
|
} catch { /* ignore */ }
|
|
if (!debugEnabled) return;
|
|
appendFileSync(debugLogPath, line);
|
|
}
|
|
|
|
// --- Git Utilities ---
|
|
|
|
function getGitRoot(cwd: string): string | null {
|
|
try {
|
|
let dir = cwd;
|
|
while (true) {
|
|
const superResult = spawnSync("git", ["rev-parse", "--show-superproject-working-tree"], {
|
|
cwd: dir, encoding: "utf-8", timeout: 5000,
|
|
});
|
|
const superproject = superResult.status === 0 ? superResult.stdout.trim() : "";
|
|
if (!superproject) {
|
|
const rootResult = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
cwd: dir, encoding: "utf-8", timeout: 5000,
|
|
});
|
|
if (rootResult.status === 0 && rootResult.stdout) return rootResult.stdout.trim();
|
|
return null;
|
|
}
|
|
dir = superproject;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getStateFilePath(cwd: string): string {
|
|
const gitRoot = getGitRoot(cwd);
|
|
return join(gitRoot || cwd, ".rl", "state.json");
|
|
}
|
|
|
|
// --- rl CLI integration ---
|
|
|
|
let rlBinary: string | null = null;
|
|
|
|
function findRl(cwd: string): string {
|
|
if (rlBinary) return rlBinary;
|
|
|
|
// 1. Check if rl is on PATH
|
|
const which = spawnSync("which", ["rl"], { encoding: "utf-8" });
|
|
if (which.status === 0 && which.stdout.trim()) {
|
|
rlBinary = "rl";
|
|
return rlBinary;
|
|
}
|
|
|
|
// 2. Use .rl/rl wrapper (created by rl init with absolute path fallback)
|
|
const wrapper = join(cwd, ".rl", "rl");
|
|
if (existsSync(wrapper)) {
|
|
rlBinary = wrapper;
|
|
return rlBinary;
|
|
}
|
|
|
|
// 3. Last resort: try rl anyway (may fail)
|
|
rlBinary = "rl";
|
|
return rlBinary;
|
|
}
|
|
|
|
function callRl(args: string[], cwd: string): { ok: boolean; stdout: string } {
|
|
const rl = findRl(cwd);
|
|
const result = spawnSync(rl, args, { cwd, encoding: "utf-8", timeout: 10000 });
|
|
if (result.status !== 0) {
|
|
crash(`${rl} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
return { ok: false, stdout: result.stdout || "" };
|
|
}
|
|
return { ok: true, stdout: result.stdout || "" };
|
|
}
|
|
|
|
function rlStatusJson(cwd: string): Record<string, unknown> | null {
|
|
const result = callRl(["status", "--json"], cwd);
|
|
if (!result.ok) return null;
|
|
try {
|
|
return JSON.parse(result.stdout) as Record<string, unknown>;
|
|
} catch {
|
|
crash(`Failed to parse rl status output: ${result.stdout.slice(0, 200)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rlPrompt(cwd: string): string | null {
|
|
const result = callRl(["prompt", "--json"], cwd);
|
|
if (!result.ok) return null;
|
|
try {
|
|
const parsed = JSON.parse(result.stdout);
|
|
return typeof parsed === "string" ? parsed : null;
|
|
} catch {
|
|
return result.stdout.trim() || null;
|
|
}
|
|
}
|
|
|
|
// --- State File Cleanup ---
|
|
|
|
function cleanupStateFile(path: string): void {
|
|
try {
|
|
if (existsSync(path)) {
|
|
unlinkSync(path);
|
|
crash(`State file deleted: ${path}`);
|
|
debug(`[ralph-reviewed] Cleaned up state file: ${path}`);
|
|
}
|
|
} catch (e) {
|
|
crash(`Failed to delete state file: ${path}`, e);
|
|
}
|
|
}
|
|
|
|
// --- Last Reject Feedback ---
|
|
|
|
function getLastRejectFeedback(rlDir: string): string | null {
|
|
try {
|
|
const logFilePath = join(rlDir, "log.jsonl");
|
|
if (!existsSync(logFilePath)) return null;
|
|
const content = readFileSync(logFilePath, "utf-8").trim();
|
|
if (!content) return null;
|
|
|
|
const lines = content.split("\n");
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const parsed = JSON.parse(lines[i]);
|
|
if (parsed.type !== "review") continue;
|
|
if (parsed.decision !== "reject") return null;
|
|
return typeof parsed.feedback === "string" ? parsed.feedback : null;
|
|
} catch { continue; }
|
|
}
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
}
|
|
|
|
// --- Types ---
|
|
|
|
interface HookInput {
|
|
session_id: string;
|
|
transcript_path?: string;
|
|
cwd?: string;
|
|
permission_mode?: string;
|
|
hook_event_name: "Stop";
|
|
stop_hook_active?: boolean;
|
|
}
|
|
|
|
interface HookOutput {
|
|
decision?: "block";
|
|
reason?: string;
|
|
systemMessage?: string;
|
|
continue?: boolean;
|
|
stopReason?: string;
|
|
}
|
|
|
|
interface ReviewResult {
|
|
approved: boolean;
|
|
feedback: string;
|
|
}
|
|
|
|
// --- Codex Review ---
|
|
|
|
function callCodexReview(reviewCount: number, cwd: string): ReviewResult {
|
|
crash(`callCodexReview() started - reviewCount=${reviewCount}, cwd=${cwd}`);
|
|
|
|
const whichResult = spawnSync("which", ["codex"], { encoding: "utf-8" });
|
|
if (whichResult.status !== 0) {
|
|
crash("Codex CLI not found, approving by default");
|
|
return { approved: true, feedback: "" };
|
|
}
|
|
|
|
const reviewPrompt = `# Code Review
|
|
|
|
An agent worked on a task in an iterative loop and claims it's done. Review the work.
|
|
|
|
## Context
|
|
|
|
The \`.rl/\` directory contains loop state and tools. Start here:
|
|
- \`.rl/rl prompt\` — read the original task assignment
|
|
- \`.rl/rl status\` — check loop state
|
|
- \`cat .rl/log.jsonl\` — read the event log (phases, commits, decisions the agent made)
|
|
- \`.rl/rl log decision "your review notes"\` — log your own findings
|
|
|
|
## How to Review
|
|
|
|
1. Read the task with \`.rl/rl prompt\`
|
|
2. Read the event log to understand what the agent did and why
|
|
3. Review the actual code — read changed files, check both committed and uncommitted work
|
|
4. If the task includes verification commands or tests, run them
|
|
5. Judge: does this implementation satisfy the original request?
|
|
|
|
Review the code, not the process. The agent may not have committed everything — that's fine. What matters is whether the work is correct and complete relative to the task.
|
|
|
|
\`.rl/\` is loop infrastructure. Do not flag it.
|
|
|
|
## Verdict
|
|
|
|
End your response with exactly one of:
|
|
- \`<review>APPROVE</review>\` — the work satisfies the task
|
|
- \`<review>REJECT</review>\` — something is broken or a requirement is unmet
|
|
|
|
If rejecting, explain what's wrong and what needs to change. Be specific and actionable.
|
|
|
|
Review ${reviewCount + 1}.`;
|
|
|
|
const uniqueId = Date.now();
|
|
const rlDirPath = stateFilePath ? join(stateFilePath, "..") : "/tmp";
|
|
const outputFile = join(rlDirPath, `codex-review-${uniqueId}.txt`);
|
|
|
|
try {
|
|
const codexConfig = userConfig.codex || DEFAULT_CONFIG.codex!;
|
|
const codexArgs: string[] = ["exec", "-"];
|
|
|
|
if (codexConfig.bypass_sandbox) {
|
|
codexArgs.push("--dangerously-bypass-approvals-and-sandbox");
|
|
} else {
|
|
codexArgs.push("--sandbox", codexConfig.sandbox || "read-only");
|
|
codexArgs.push("-c", `approval_policy="${codexConfig.approval_policy || "never"}"`);
|
|
}
|
|
|
|
codexArgs.push("-o", outputFile);
|
|
|
|
// Drop any user-supplied output redirection (and the value that follows a
|
|
// bare flag) so it cannot steer Codex away from `outputFile` — the hook
|
|
// reads the verdict back from that exact path.
|
|
if (Array.isArray(codexConfig.extra_args)) {
|
|
const extra = codexConfig.extra_args;
|
|
const consumesNext = ["-o", "--output", "--output-last-message"];
|
|
for (let i = 0; i < extra.length; i++) {
|
|
const arg = extra[i];
|
|
if (typeof arg !== "string") continue;
|
|
if (consumesNext.includes(arg)) {
|
|
i++; // also skip the value paired with this flag
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--output=") || arg.startsWith("--output-last-message=")) {
|
|
continue; // inline `--flag=value`
|
|
}
|
|
codexArgs.push(arg);
|
|
}
|
|
}
|
|
|
|
// Clamp to a safe range so a misconfigured timeout cannot outlive the hook
|
|
// itself; if Codex runs past `HOOK_TIMEOUT_SECONDS - BUFFER_SECONDS` Claude
|
|
// kills the hook before it can parse the output file.
|
|
const requestedTimeout = codexConfig.timeout_seconds ?? 1200;
|
|
let effectiveTimeout = Math.min(requestedTimeout, MAX_CODEX_TIMEOUT_SECONDS);
|
|
effectiveTimeout = Math.max(effectiveTimeout, MIN_CODEX_TIMEOUT_SECONDS);
|
|
if (requestedTimeout < MIN_CODEX_TIMEOUT_SECONDS) {
|
|
crash(`WARNING: Clamping timeout from ${requestedTimeout}s to min ${MIN_CODEX_TIMEOUT_SECONDS}s`);
|
|
}
|
|
if (requestedTimeout > MAX_CODEX_TIMEOUT_SECONDS) {
|
|
crash(`WARNING: Clamping timeout from ${requestedTimeout}s to max ${MAX_CODEX_TIMEOUT_SECONDS}s (hook timeout ${HOOK_TIMEOUT_SECONDS}s - buffer ${BUFFER_SECONDS}s)`);
|
|
}
|
|
const timeoutMs = effectiveTimeout * 1000;
|
|
crash(`Codex args: ${JSON.stringify(codexArgs)}, timeout: ${timeoutMs}ms (effective=${effectiveTimeout}s, requested=${requestedTimeout}s, max=${MAX_CODEX_TIMEOUT_SECONDS}s)`);
|
|
|
|
const result = spawnSync("codex", codexArgs, {
|
|
cwd, encoding: "utf-8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, input: reviewPrompt,
|
|
});
|
|
|
|
crash(`Codex returned - status: ${result.status}, signal: ${result.signal}`);
|
|
if (result.stderr) crash(`Codex stderr: ${result.stderr.slice(0, 500)}`);
|
|
|
|
let codexOutput = "";
|
|
if (existsSync(outputFile)) {
|
|
codexOutput = readFileSync(outputFile, "utf-8");
|
|
crash(`Codex output: ${codexOutput.slice(0, 500)}`);
|
|
} else {
|
|
crash("No Codex output file created");
|
|
}
|
|
|
|
const reviewMatches = [...codexOutput.matchAll(/<review>\s*(APPROVE|REJECT)\s*<\/review>/gi)];
|
|
const verdict = reviewMatches.length > 0
|
|
? reviewMatches[reviewMatches.length - 1][1].toUpperCase()
|
|
: null;
|
|
|
|
crash(`Verdict: ${verdict}`);
|
|
|
|
if (verdict === "APPROVE") return { approved: true, feedback: codexOutput };
|
|
if (verdict === "REJECT") {
|
|
const lastTag = codexOutput.lastIndexOf("<review>");
|
|
const feedback = lastTag >= 0 ? codexOutput.slice(0, lastTag).trim() : codexOutput.trim();
|
|
return { approved: false, feedback };
|
|
}
|
|
|
|
crash("No APPROVE/REJECT found, approving by default");
|
|
return { approved: true, feedback: codexOutput };
|
|
} catch (e) {
|
|
crash("Codex review failed", e);
|
|
return { approved: true, feedback: "" };
|
|
}
|
|
}
|
|
|
|
// --- Main Hook Logic ---
|
|
|
|
async function readStdin(): Promise<string> {
|
|
if (process.stdin.isTTY) return "";
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
let data = "";
|
|
let resolved = false;
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
process.stdin.off("data", onData);
|
|
process.stdin.off("end", onEnd);
|
|
process.stdin.off("error", onError);
|
|
process.stdin.pause();
|
|
};
|
|
|
|
const tryResolve = () => {
|
|
if (resolved) return;
|
|
try {
|
|
JSON.parse(data);
|
|
resolved = true;
|
|
cleanup();
|
|
resolve(data);
|
|
} catch { /* keep reading */ }
|
|
};
|
|
|
|
const onData = (chunk: string | Buffer) => { data += chunk.toString(); tryResolve(); };
|
|
const onEnd = () => { if (resolved) return; resolved = true; cleanup(); resolve(data); };
|
|
const onError = (err: Error) => { if (resolved) return; resolved = true; cleanup(); reject(err); };
|
|
|
|
const timer = setTimeout(() => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
cleanup();
|
|
resolve(data);
|
|
}, STDIN_TIMEOUT_MS);
|
|
|
|
process.stdin.setEncoding("utf-8");
|
|
process.stdin.on("data", onData);
|
|
process.stdin.on("end", onEnd);
|
|
process.stdin.on("error", onError);
|
|
process.stdin.resume();
|
|
});
|
|
}
|
|
|
|
function output(result: HookOutput): void {
|
|
console.log(JSON.stringify(result));
|
|
}
|
|
|
|
async function main() {
|
|
crash("main() entered");
|
|
|
|
try {
|
|
crash("Reading stdin...");
|
|
const inputRaw = await readStdin();
|
|
crash(`Stdin received: ${inputRaw.length} bytes`);
|
|
|
|
let input: HookInput;
|
|
const trimmed = inputRaw.trim();
|
|
if (!trimmed) {
|
|
crash("No stdin payload received; using fallback context");
|
|
input = {
|
|
session_id: "unknown",
|
|
transcript_path: "",
|
|
cwd: process.env.CLAUDE_PROJECT_DIR || process.cwd(),
|
|
hook_event_name: "Stop",
|
|
};
|
|
} else {
|
|
try {
|
|
input = JSON.parse(trimmed);
|
|
} catch (parseErr) {
|
|
crash("Failed to parse input JSON", parseErr);
|
|
throw parseErr;
|
|
}
|
|
}
|
|
|
|
setSessionId(input.session_id || "unknown");
|
|
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
crash(`Input parsed: session_id=${input.session_id}, cwd=${cwd}`);
|
|
|
|
const gitRoot = getGitRoot(cwd);
|
|
stateFilePath = getStateFilePath(cwd);
|
|
debugLogPath = `${sessionLogDir}/debug.log`;
|
|
crash(`State file: ${stateFilePath}, Git root: ${gitRoot || "none"}, cwd: ${cwd}`);
|
|
|
|
// Fast gate: no state file means no active loop
|
|
if (!existsSync(stateFilePath)) {
|
|
crash("No state file found, approving exit");
|
|
output({});
|
|
return;
|
|
}
|
|
|
|
// Read full state via rl CLI (single call, cached for the hook invocation)
|
|
const rlCwd = gitRoot || cwd;
|
|
const state = rlStatusJson(rlCwd);
|
|
|
|
if (!state) {
|
|
crash("Failed to read state via rl, cleaning up");
|
|
cleanupStateFile(stateFilePath);
|
|
output({});
|
|
return;
|
|
}
|
|
|
|
if (!state.active) {
|
|
crash("Loop inactive, cleaning up stale state file");
|
|
cleanupStateFile(stateFilePath);
|
|
output({});
|
|
return;
|
|
}
|
|
|
|
if (state.debug) {
|
|
debugEnabled = true;
|
|
debug(`[ralph-reviewed] Debug enabled via state file`);
|
|
}
|
|
|
|
const iteration = state.iteration as number;
|
|
const maxIterations = state.max_iterations as number;
|
|
const reviewEnabled = state.review_enabled as boolean;
|
|
const reviewCount = state.review_count as number;
|
|
const maxReviewCycles = state.max_review_cycles as number;
|
|
const completionClaimed = state.completion_claimed === true;
|
|
const blockedClaimed = state.blocked_claimed === true;
|
|
|
|
debug(`[ralph-reviewed] Iteration: ${iteration}, done: ${completionClaimed}, blocked: ${blockedClaimed}`);
|
|
|
|
if (blockedClaimed) {
|
|
crash("BLOCKED claimed - terminating loop without review");
|
|
cleanupStateFile(stateFilePath);
|
|
output({
|
|
systemMessage: `# Ralph Loop: BLOCKED\n\n**Iteration:** ${iteration}\n\nTask reported as blocked. Loop terminated without review.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!completionClaimed) {
|
|
// Normal iteration - no completion claimed
|
|
const nextIteration = iteration + 1;
|
|
|
|
if (nextIteration >= maxIterations) {
|
|
debug(`[ralph-reviewed] Max iterations (${maxIterations}) reached, exiting loop`);
|
|
cleanupStateFile(stateFilePath);
|
|
output({
|
|
systemMessage: `# Ralph Loop: Max Iterations Reached\n\n**Iteration:** ${nextIteration}\n\nLoop ended without completion claim. Review the work and consider restarting if needed.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Update iteration via rl
|
|
callRl(["state", "set", "iteration", String(nextIteration)], rlCwd);
|
|
|
|
// Clear any stale completion/blocked flags
|
|
callRl(["state", "set", "completion_claimed", "false"], rlCwd);
|
|
callRl(["state", "set", "blocked_claimed", "false"], rlCwd);
|
|
|
|
// Build continuation prompt
|
|
const originalPrompt = rlPrompt(rlCwd) || "(no prompt found)";
|
|
let prompt = `# Ralph Loop \u2014 Iteration ${nextIteration}\n\n`;
|
|
|
|
const rlDir = join(rlCwd, ".rl");
|
|
const pendingFeedback = getLastRejectFeedback(rlDir);
|
|
if (pendingFeedback) {
|
|
prompt += `## Review Feedback from Previous Attempt\n\n${pendingFeedback}\n\nAddress the above feedback.\n\n---\n\n`;
|
|
}
|
|
|
|
prompt += originalPrompt;
|
|
prompt += `\n\nWhen complete, run: .rl/rl done`;
|
|
|
|
output({ decision: "block", reason: prompt });
|
|
return;
|
|
}
|
|
|
|
// Completion claimed - enter review gate
|
|
debug(`[ralph-reviewed] Completion claimed! Entering review gate...`);
|
|
|
|
if (!reviewEnabled) {
|
|
debug(`[ralph-reviewed] Reviews disabled, approving exit`);
|
|
cleanupStateFile(stateFilePath);
|
|
output({});
|
|
return;
|
|
}
|
|
|
|
if (!gitRoot) {
|
|
crash("Not in a git repository - BLOCKING (Codex requires git repo)");
|
|
output({
|
|
decision: "block",
|
|
reason: `# Review Gate Error: Not a Git Repository\n\nCodex requires a git repository to run.\n\n**Current directory:** \`${cwd}\`\n\n**To fix:** Initialize a git repository with \`git init\`.\n\n**To escape this loop:** Run \`/ralph-reviewed:cancel-ralph\` to remove the loop, then exit normally.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
debug(`[ralph-reviewed] Calling Codex for review...`);
|
|
|
|
const reviewResult = callCodexReview(reviewCount, gitRoot);
|
|
|
|
debug(`[ralph-reviewed] Review result: approved=${reviewResult.approved}`);
|
|
|
|
// Log review via rl
|
|
callRl(["log", "review", "--decision", reviewResult.approved ? "approve" : "reject", "--feedback", reviewResult.feedback], rlCwd);
|
|
|
|
if (reviewResult.approved) {
|
|
debug(`[ralph-reviewed] Codex approved! Exiting loop.`);
|
|
cleanupStateFile(stateFilePath);
|
|
output({
|
|
systemMessage: `# Ralph Loop: Codex APPROVED\n\n**Iteration:** ${iteration} | **Review cycle:** ${reviewCount + 1}\n\nReview gate cleared.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Rejected
|
|
const newReviewCount = reviewCount + 1;
|
|
|
|
if (newReviewCount >= maxReviewCycles) {
|
|
debug(`[ralph-reviewed] Max review cycles (${maxReviewCycles}) reached.`);
|
|
cleanupStateFile(stateFilePath);
|
|
output({
|
|
systemMessage: `# Ralph Loop: Max Review Cycles Reached\n\n**Iteration:** ${iteration} | **Review cycle:** ${newReviewCount}\n\nLoop ended without approval. Review feedback manually.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
const nextIteration = iteration + 1;
|
|
callRl(["state", "set", "iteration", String(nextIteration)], rlCwd);
|
|
callRl(["state", "set", "review_count", String(newReviewCount)], rlCwd);
|
|
callRl(["state", "set", "completion_claimed", "false"], rlCwd);
|
|
|
|
const reviewPromptText = rlPrompt(rlCwd) || "(no prompt found)";
|
|
const feedbackPrompt = `# Ralph Loop \u2014 Iteration ${nextIteration}
|
|
|
|
## Review Feedback (Cycle ${newReviewCount})
|
|
|
|
Your previous completion was reviewed and requires changes.
|
|
|
|
${reviewResult.feedback}
|
|
|
|
Address ALL open issues above, then run \`.rl/rl done\` when truly complete.
|
|
|
|
---
|
|
|
|
${reviewPromptText}`;
|
|
|
|
output({ decision: "block", reason: feedbackPrompt });
|
|
} catch (e) {
|
|
crash("main() caught exception", e);
|
|
if (stateFilePath) {
|
|
try { unlinkSync(stateFilePath); crash(`Cleaned up state file on main() exception: ${stateFilePath}`); } catch { /* ignore */ }
|
|
}
|
|
output({});
|
|
}
|
|
crash("main() exiting normally");
|
|
}
|
|
|
|
crash("About to call main()");
|
|
main().catch((e) => {
|
|
crash("main() promise rejected", e);
|
|
if (stateFilePath) {
|
|
try { unlinkSync(stateFilePath); crash(`Cleaned up state file on main() rejection: ${stateFilePath}`); } catch { /* ignore */ }
|
|
}
|
|
console.log(JSON.stringify({}));
|
|
process.exit(1);
|
|
});
|