Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted
> [!WARNING]
> The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session.

#### Bounding the review gate

By default the gate keeps blocking the stop until Codex is satisfied, which is what can create the loop above. Set `CODEX_REVIEW_GATE_MAX_ROUNDS` to cap how many consecutive gate rounds run in a single session before the stop is allowed through:

```bash
# allow at most 5 stop-gate review rounds per session, then let the stop proceed
export CODEX_REVIEW_GATE_MAX_ROUNDS=5
```

When unset or `0`, the gate is unbounded (the previous behavior). The count is per session, increments on each blocked round (tracked via `stop_hook_active`), and resets once a stop is allowed or a fresh user turn begins.

## Typical Flows

### Review Before Shipping
Expand Down
54 changes: 53 additions & 1 deletion plugins/codex/scripts/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 { getConfig, setConfig, 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";
Expand All @@ -17,6 +17,7 @@ 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.";
const GATE_ROUNDS_CONFIG_KEY = "stopReviewGateRoundsBySession";

function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
Expand All @@ -37,6 +38,40 @@ function logNote(message) {
process.stderr.write(`${message}\n`);
}

// Optional cap on how many consecutive stop-gate rounds run in one session.
// Unset or 0 keeps the previous unbounded behavior.
function getMaxRounds() {
const raw = process.env.CODEX_REVIEW_GATE_MAX_ROUNDS;
if (raw == null || raw === "") {
return 0;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}

function gateSessionId(input) {
return input.session_id || process.env[SESSION_ID_ENV] || "default";
}

function readGateRounds(workspaceRoot, sessionId) {
const rounds = getConfig(workspaceRoot)[GATE_ROUNDS_CONFIG_KEY];
if (!rounds || typeof rounds !== "object") {
return 0;
}
return Number(rounds[sessionId]) || 0;
}

function writeGateRounds(workspaceRoot, sessionId, count) {
const current = getConfig(workspaceRoot)[GATE_ROUNDS_CONFIG_KEY];
const next = current && typeof current === "object" ? { ...current } : {};
if (count > 0) {
next[sessionId] = count;
} else {
delete next[sessionId];
}
setConfig(workspaceRoot, GATE_ROUNDS_CONFIG_KEY, next);
}

function filterJobsForCurrentSession(jobs, input = {}) {
const sessionId = input.session_id || process.env[SESSION_ID_ENV] || null;
if (!sessionId) {
Expand Down Expand Up @@ -163,15 +198,32 @@ function main() {
return;
}

const sessionId = gateSessionId(input);
const maxRounds = getMaxRounds();
// A fresh user turn (not a gate-induced continuation) starts a new count.
const priorRounds = input.stop_hook_active ? readGateRounds(workspaceRoot, sessionId) : 0;

if (maxRounds > 0 && priorRounds >= maxRounds) {
writeGateRounds(workspaceRoot, sessionId, 0);
logNote(
`Codex stop-time review gate reached its limit of ${maxRounds} round(s) for this session; allowing the stop. ` +
"Set CODEX_REVIEW_GATE_MAX_ROUNDS to adjust, or run /codex:review --wait manually for another pass."
);
logNote(runningTaskNote);
return;
}

const review = runStopReview(cwd, input);
if (!review.ok) {
writeGateRounds(workspaceRoot, sessionId, priorRounds + 1);
emitDecision({
decision: "block",
reason: runningTaskNote ? `${runningTaskNote} ${review.reason}` : review.reason
});
return;
}

writeGateRounds(workspaceRoot, sessionId, 0);
logNote(runningTaskNote);
}

Expand Down