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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,27 @@ 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.

#### Throttling the review gate

To prevent runaway usage, you can limit how often the review gate fires:

```bash
# Allow at most 5 stop-gate reviews per session
/codex:setup --review-gate-max 5

# Require at least 10 minutes between stop-gate reviews
/codex:setup --review-gate-cooldown 10

# Combine both limits
/codex:setup --enable-review-gate --review-gate-max 5 --review-gate-cooldown 10

# Remove a limit
/codex:setup --review-gate-max off
/codex:setup --review-gate-cooldown off
```

When a limit is reached, the stop-gate review is skipped (the session is allowed to end) and a note is logged. You can still run `/codex:review` manually at any time.

## Typical Flows

### Review Before Shipping
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/commands/setup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Check whether the local Codex CLI is ready and optionally toggle the stop-time review gate
argument-hint: '[--enable-review-gate|--disable-review-gate]'
argument-hint: '[--enable-review-gate|--disable-review-gate] [--review-gate-max <n|off>] [--review-gate-cooldown <minutes|off>]'
allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion
---

Expand Down
32 changes: 30 additions & 2 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function printUsage() {
console.log(
[
"Usage:",
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]",
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--review-gate-max <n|off>] [--review-gate-cooldown <minutes|off>] [--json]",
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
Expand Down Expand Up @@ -204,14 +204,16 @@ function buildSetupReport(cwd, actionsTaken = []) {
auth: authStatus,
sessionRuntime: getSessionRuntimeStatus(),
reviewGateEnabled: Boolean(config.stopReviewGate),
reviewGateMaxPerSession: config.stopReviewGateMaxPerSession ?? null,
reviewGateCooldownMinutes: config.stopReviewGateCooldownMinutes ?? null,
actionsTaken,
nextSteps
};
}

function handleSetup(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd"],
valueOptions: ["cwd", "review-gate-max", "review-gate-cooldown"],
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
});

Expand All @@ -231,6 +233,32 @@ function handleSetup(argv) {
actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`);
}

if (options["review-gate-max"] != null) {
const max = options["review-gate-max"] === "off" ? null : Number(options["review-gate-max"]);
if (max !== null && (!Number.isInteger(max) || max < 1)) {
throw new Error(`--review-gate-max must be a positive integer or "off".`);
}
setConfig(workspaceRoot, "stopReviewGateMaxPerSession", max);
actionsTaken.push(
max != null
? `Set review gate session limit to ${max} reviews per session.`
: `Removed review gate session limit.`
);
}

if (options["review-gate-cooldown"] != null) {
const cooldown = options["review-gate-cooldown"] === "off" ? null : Number(options["review-gate-cooldown"]);
if (cooldown !== null && (!Number.isInteger(cooldown) || cooldown < 1)) {
throw new Error(`--review-gate-cooldown must be a positive integer (minutes) or "off".`);
}
setConfig(workspaceRoot, "stopReviewGateCooldownMinutes", cooldown);
actionsTaken.push(
cooldown != null
? `Set review gate cooldown to ${cooldown} minute(s).`
: `Removed review gate cooldown.`
);
}

const finalReport = buildSetupReport(cwd, actionsTaken);
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
}
Expand Down
13 changes: 12 additions & 1 deletion plugins/codex/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ function appendReasoningSection(lines, reasoningSummary) {
}
}

function formatReviewGateLimits(report) {
const parts = [];
if (report.reviewGateMaxPerSession != null) {
parts.push(`max ${report.reviewGateMaxPerSession}/session`);
}
if (report.reviewGateCooldownMinutes != null) {
parts.push(`${report.reviewGateCooldownMinutes}m cooldown`);
}
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
}

export function renderSetupReport(report) {
const lines = [
"# Codex Setup",
Expand All @@ -186,7 +197,7 @@ export function renderSetupReport(report) {
`- codex: ${report.codex.detail}`,
`- auth: ${report.auth.detail}`,
`- session runtime: ${report.sessionRuntime.label}`,
`- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`,
`- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}${report.reviewGateEnabled ? formatReviewGateLimits(report) : ""}`,
""
];

Expand Down
4 changes: 3 additions & 1 deletion plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ function defaultState() {
return {
version: STATE_VERSION,
config: {
stopReviewGate: false
stopReviewGate: false,
stopReviewGateMaxPerSession: null,
stopReviewGateCooldownMinutes: null
},
jobs: []
};
Expand Down
52 changes: 52 additions & 0 deletions plugins/codex/scripts/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,51 @@ function filterJobsForCurrentSession(jobs, input = {}) {
return jobs.filter((job) => job.sessionId === sessionId);
}

function countSessionStopReviews(jobs) {
return jobs.filter(
(job) =>
job.jobClass === "review" &&
job.title === "Codex Stop Gate Review" &&
job.status === "completed"
).length;
}

// Expects jobs pre-sorted newest-first (via sortJobsNewestFirst from caller).
function findLastStopReviewTime(jobs) {
const stopReview = jobs.find(
(job) =>
job.jobClass === "review" &&
job.title === "Codex Stop Gate Review" &&
job.completedAt
);
return stopReview?.completedAt ? new Date(stopReview.completedAt).getTime() : null;
}

function checkThrottleLimits(config, jobs) {
const maxPerSession = config.stopReviewGateMaxPerSession ?? null;
if (maxPerSession != null && maxPerSession > 0) {
const count = countSessionStopReviews(jobs);
if (count >= maxPerSession) {
return `Stop-gate review skipped: session limit reached (${count}/${maxPerSession}). Run /codex:review manually if needed.`;
}
}

const cooldownMinutes = config.stopReviewGateCooldownMinutes ?? null;
if (cooldownMinutes != null && cooldownMinutes > 0) {
const lastTime = findLastStopReviewTime(jobs);
if (lastTime != null) {
const elapsed = Date.now() - lastTime;
const cooldownMs = cooldownMinutes * 60 * 1000;
if (elapsed < cooldownMs) {
const remainingMinutes = Math.ceil((cooldownMs - elapsed) / 60000);
return `Stop-gate review skipped: cooldown active (${remainingMinutes}m remaining). Run /codex:review manually if needed.`;
}
}
}

return null;
}

function buildStopReviewPrompt(input = {}) {
const lastAssistantMessage = String(input.last_assistant_message ?? "").trim();
const template = loadPromptTemplate(ROOT_DIR, "stop-review-gate");
Expand Down Expand Up @@ -163,6 +208,13 @@ function main() {
return;
}

const throttleNote = checkThrottleLimits(config, jobs);
if (throttleNote) {
logNote(throttleNote);
logNote(runningTaskNote);
return;
}

const review = runStopReview(cwd, input);
if (!review.ok) {
emitDecision({
Expand Down
6 changes: 5 additions & 1 deletion tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,16 @@ test("setup command can offer Codex install and still points users to codex logi
const setup = read("commands/setup.md");
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");

assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\]'/);
assert.match(setup, /argument-hint:.*--enable-review-gate\|--disable-review-gate/);
assert.match(setup, /AskUserQuestion/);
assert.match(setup, /npm install -g @openai\/codex/);
assert.match(setup, /codex-companion\.mjs" setup --json \$ARGUMENTS/);
assert.match(readme, /!codex login/);
assert.match(readme, /offer to install Codex for you/i);
assert.match(readme, /\/codex:setup --enable-review-gate/);
assert.match(readme, /\/codex:setup --disable-review-gate/);
assert.match(setup, /--review-gate-max/);
assert.match(setup, /--review-gate-cooldown/);
assert.match(readme, /--review-gate-max/);
assert.match(readme, /--review-gate-cooldown/);
});
Loading