mirror of
https://github.com/codeflash-ai/codeflash-agent.git
synced 2026-05-04 18:25:19 +00:00
Notable upstream fixes: - Fix working-tree review crash on untracked directories - Avoid embedding large adversarial review diffs - Inherit process.env in app-server spawn - Scope implicit resume-last and cancel to current session - Gracefully handle unsupported thread/name/set on older CLI - Use app-server auth status for Codex readiness
184 lines
5.4 KiB
JavaScript
184 lines
5.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs";
|
|
import process from "node:process";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { getCodexAvailability } from "./lib/codex.mjs";
|
|
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
|
|
import { getConfig, listJobs } from "./lib/state.mjs";
|
|
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
|
|
import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs";
|
|
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
|
|
|
const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000;
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
|
const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";
|
|
|
|
function readHookInput() {
|
|
const raw = fs.readFileSync(0, "utf8").trim();
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
function emitDecision(payload) {
|
|
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
}
|
|
|
|
function logNote(message) {
|
|
if (!message) {
|
|
return;
|
|
}
|
|
process.stderr.write(`${message}\n`);
|
|
}
|
|
|
|
function filterJobsForCurrentSession(jobs, input = {}) {
|
|
const sessionId = input.session_id || process.env[SESSION_ID_ENV] || null;
|
|
if (!sessionId) {
|
|
return jobs;
|
|
}
|
|
return jobs.filter((job) => job.sessionId === sessionId);
|
|
}
|
|
|
|
function buildStopReviewPrompt(input = {}) {
|
|
const lastAssistantMessage = String(input.last_assistant_message ?? "").trim();
|
|
const template = loadPromptTemplate(ROOT_DIR, "stop-review-gate");
|
|
const claudeResponseBlock = lastAssistantMessage
|
|
? ["Previous Claude response:", lastAssistantMessage].join("\n")
|
|
: "";
|
|
return interpolateTemplate(template, {
|
|
CLAUDE_RESPONSE_BLOCK: claudeResponseBlock
|
|
});
|
|
}
|
|
|
|
function buildSetupNote(cwd) {
|
|
const availability = getCodexAvailability(cwd);
|
|
if (availability.available) {
|
|
return null;
|
|
}
|
|
|
|
const detail = availability.detail ? ` ${availability.detail}.` : "";
|
|
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
|
|
}
|
|
|
|
function parseStopReviewOutput(rawOutput) {
|
|
const text = String(rawOutput ?? "").trim();
|
|
if (!text) {
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
"The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate."
|
|
};
|
|
}
|
|
|
|
const firstLine = text.split(/\r?\n/, 1)[0].trim();
|
|
if (firstLine.startsWith("ALLOW:")) {
|
|
return { ok: true, reason: null };
|
|
}
|
|
if (firstLine.startsWith("BLOCK:")) {
|
|
const reason = firstLine.slice("BLOCK:".length).trim() || text;
|
|
return {
|
|
ok: false,
|
|
reason: `Codex stop-time review found issues that still need fixes before ending the session: ${reason}`
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
"The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate."
|
|
};
|
|
}
|
|
|
|
function runStopReview(cwd, input = {}) {
|
|
const scriptPath = path.join(SCRIPT_DIR, "codex-companion.mjs");
|
|
const prompt = buildStopReviewPrompt(input);
|
|
const childEnv = {
|
|
...process.env,
|
|
...(input.session_id ? { [SESSION_ID_ENV]: input.session_id } : {})
|
|
};
|
|
const result = spawnSync(process.execPath, [scriptPath, "task", "--json", prompt], {
|
|
cwd,
|
|
env: childEnv,
|
|
encoding: "utf8",
|
|
timeout: STOP_REVIEW_TIMEOUT_MS
|
|
});
|
|
|
|
if (result.error?.code === "ETIMEDOUT") {
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
"The stop-time Codex review task timed out after 15 minutes. Run /codex:review --wait manually or bypass the gate."
|
|
};
|
|
}
|
|
|
|
if (result.status !== 0) {
|
|
const detail = String(result.stderr || result.stdout || "").trim();
|
|
return {
|
|
ok: false,
|
|
reason: detail
|
|
? `The stop-time Codex review task failed: ${detail}`
|
|
: "The stop-time Codex review task failed. Run /codex:review --wait manually or bypass the gate."
|
|
};
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(result.stdout);
|
|
return parseStopReviewOutput(payload?.rawOutput);
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
reason:
|
|
"The stop-time Codex review task returned invalid JSON. Run /codex:review --wait manually or bypass the gate."
|
|
};
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const input = readHookInput();
|
|
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
const config = getConfig(workspaceRoot);
|
|
|
|
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input));
|
|
const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running");
|
|
const runningTaskNote = runningJob
|
|
? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.`
|
|
: null;
|
|
|
|
if (!config.stopReviewGate) {
|
|
logNote(runningTaskNote);
|
|
return;
|
|
}
|
|
|
|
const setupNote = buildSetupNote(cwd);
|
|
if (setupNote) {
|
|
logNote(setupNote);
|
|
logNote(runningTaskNote);
|
|
return;
|
|
}
|
|
|
|
const review = runStopReview(cwd, input);
|
|
if (!review.ok) {
|
|
emitDecision({
|
|
decision: "block",
|
|
reason: runningTaskNote ? `${runningTaskNote} ${review.reason}` : review.reason
|
|
});
|
|
return;
|
|
}
|
|
|
|
logNote(runningTaskNote);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`${message}\n`);
|
|
process.exitCode = 1;
|
|
}
|