Pre-PR gate: rounds of adversarial Codex review (gstack /codex) and agent fixes, run locally before a PR to dev or main is opened, with a live progress pane.

Version 0.3.1. Part of pr-gate; MIT licensed, see LICENSE.
A Claude Code mod that runs rounds of adversarial Codex review on a branch before its pull request is opened, the way lint and tests run locally before a push. Codex is reached through the gstack /codex skill; the fixes are made by an agent; the mod keeps the rounds going without a person nudging each one.
gh pr create (or the GitHub MCP create-PR tool) against a gated base (dev, main). The mod holds the call and starts the gate. /pr-gate [base] starts it by hand.origin/<base> is sized: trivial (docs or already-reviewed changes only, Codex skipped; see below), standard, or high (a path matching highRiskPaths, over 400 changed lines, or over 25 files).codex skill in review mode and reports Codex's findings. On a high change a second sub-agent runs the skill's challenge mode in parallel.fixer), which verifies each against the code, fixes or rebuts it, and commits. On a high change another sub-agent reviews the fix first.maxRounds (5) is reached; then the PR is opened either way.advisoryMinRounds (1). This stops when Codex raises none, when the fixer changes nothing more, or after advisoryMaxRounds (3). These rounds are on top of maxRounds; a fix that adds a P0/P1 goes back to step 4.gh pr create now goes through, for the commit the gate finished on. A docs-only commit on top (a CHANGELOG line) rides on that verdict; any other new commit, or a rebase, needs a new gate.A promotion between gated bases (dev into main) is not gated, and neither is a PR for another repository (--repo) or from a branch that is not checked out.
The gate fails closed: a review that did not complete (timeout, auth, no verdict) stops it as an error and spends no round. A finding the fixer defers, because the fix reaches past the branch's scope, stops it as blocked for a person to decide.
Nothing is keyed on a docs/ branch name: a change is trivial because of what it changes. Codex is skipped, on any branch, when every changed file is one of these:
*.md, docs/, specs/, CHANGELOG*, LICENSE*);origin/main or origin/dev (a Backend pointer bump after promotion). A pointer to a commit on neither is unreviewed code arriving through the pointer, and stays high blast radius;package.json or package-lock.json change to "version" lines only.A branch whose every commit is already on another gated base (a back-sync of main into dev), with no merge commit carrying edits of its own, is skipped too.
chore/)A branch matching lightBranches (^chore/) that still needs Codex gets one round and no advisory rounds. Codex starts at lightEffort (low), or at high on a high blast-radius change. With onExhausted: open-pr (the default), what it raises does not hold the pull request: the gate ends exhausted (or passed), and the findings go to the main agent for the pull request's own review. With stop, a P0/P1 still holds it. The prefix is a weak signal, since hooks code can be named chore/ too, so a high blast-radius chore still reviews at high effort.
Codex starts at high on a high blast-radius change (highRiskPaths, or a large diff) and at reviewEffort (medium) otherwise. It never runs at xhigh, which timed out. When a review times out (the reviewer reports "error":"timeout", or the review step passes stepTimeoutMinutes), the same round reruns one level lower and spends no round: high, then medium, then low. A timeout at low fails closed. The next run on that branch starts one level below where the timed-out run ended. A run that stepped down and then completed its reviews keeps that effort for the branch: later runs start no higher than it. A branch that never stepped down keeps no such limit, so it still starts at high once it grows into high blast radius. A reviewer that was replaced is not stopped, since there is no way to; its late answer is ignored.
Codex gate shows the branch, the round, a line that changes in place as the agents call tools (in progress: codex round 2 · waiting on 1 agent), the to-do list of steps, and Codex's findings with what became of each. The surface decides where a pane sits: docked beside the transcript in a fullscreen terminal from 110 columns, above the prompt otherwise.
/pr-gate [base] | start the gate for the current branch |
/pr-gate status | one line of where it stands, and open the pane |
/pr-gate stop | stop a running gate |
/pr-gate waive <reason> | let this commit's PR through without the gate; the person's alone |
Set in /config or under pluginConfigs["codex-gate"].options: maxRounds, advisoryMinRounds, advisoryMaxRounds, bases, enforce, fixer (main or subagent), fixReview (auto, always, never), onExhausted (open-pr or stop), openPr, highRiskPaths, reviewEffort, lightBranches, lightEffort, stepTimeoutMinutes.
When the rounds run out with onExhausted: open-pr, the pull request says only that the gate did not converge, with a count. Findings, advisory ones included, are reported in the conversation and never pasted into the pull request.
The gate is an early review, not the last one: the prompt that opens the pull request tells the agent to run the repository's full PR lifecycle afterwards, non-required reviewers and checks included, whether the gate came back clean or ran out of rounds.
The gstack codex skill and an authenticated codex CLI. The superpowers:receiving-code-review skill is used by the fixer when present.
claude --plugin-dir /path/to/codex-gate
or name the folder in CLAUDE_CODE_PLUGIN_DIRS. Checks: claude plugin validate . and claude plugin test .
hooks/register.tsx 1025 lines1// codex-gate: rounds of adversarial Codex review and agent fixes, run locally
2// before a pull request to a gated base is opened.
3//
4// The rounds are a state machine the host keeps in `$.state`. Each step is
5// an agent; its `turn.complete` moves the machine on. Nothing here waits on
6// a step inside one dispatch.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, PluginOptions, Register } from 'claude-code'
10
11import type { Effort, Finding, PassRecord, Pending, Role, Run, RunStatus, Step } from '../types'
12import {
13 activityOf,
14 classify,
15 compileRisk,
16 fixPrompt,
17 isBlocking,
18 isVersionOnly,
19 lowerOf,
20 prPrompt,
21 readGitlinks,
22 readDispositions,
23 readPrCreate,
24 readReview,
25 readTrailer,
26 readVerify,
27 reviewPrompt,
28 slugOf,
29 stepDown,
30 stepOf,
31 verifyPrompt,
32} from './core'
33import type { PrCreate } from './core'
34
35const PANE = 'codex-gate'
36const COMMAND = 'pr-gate'
37const RUN = { plugin: 'codex-gate', key: 'run' } as const
38const runAtom = atom(RUN, null)
39const tickAtom = atom({ plugin: 'codex-gate', key: 'tick' } as const, 0)
40const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
41const GITHUB_CREATE_PR = 'mcp__plugin_github_github__create_pull_request'
42
43type Engine = EngineInterface
44
45type Config = {
46 maxRounds: number
47 advisoryMin: number
48 advisoryMax: number
49 bases: string[]
50 enforce: boolean
51 fixer: 'main' | 'subagent'
52 fixReview: 'auto' | 'always' | 'never'
53 onExhausted: 'open-pr' | 'stop'
54 openPr: boolean
55 highRisk: RegExp | undefined
56 stepTimeoutMs: number
57 /** Effort for a standard change; a high blast-radius change always starts at high. */
58 effort: Effort
59 /** Effort for a light-lane (chore/) branch that is not high blast radius. */
60 lightEffort: Effort
61 light: RegExp | undefined
62}
63
64const effortOf = (value: unknown, fallback: Effort): Effort =>
65 value === 'high' || value === 'medium' || value === 'low' ? value : fallback
66
67function configOf(options: PluginOptions): Config {
68 const number = (key: string, fallback: number): number => {
69 const value = Number(options[key])
70 return Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback
71 }
72 const bases = String(options.bases ?? 'dev,main')
73 .split(',')
74 .map(one => one.trim())
75 .filter(one => one !== '')
76 const advisoryMaxRaw = Number(options.advisoryMaxRounds ?? 3)
77 const advisoryMax = Number.isFinite(advisoryMaxRaw) && advisoryMaxRaw >= 0 ? Math.min(Math.floor(advisoryMaxRaw), 10) : 3
78 return {
79 maxRounds: Math.min(number('maxRounds', 5), 10),
80 advisoryMin: Math.min(number('advisoryMinRounds', 1), advisoryMax),
81 advisoryMax,
82 bases: bases.length > 0 ? bases : ['dev', 'main'],
83 enforce: options.enforce !== false,
84 fixer: options.fixer === 'subagent' ? 'subagent' : 'main',
85 fixReview: options.fixReview === 'always' || options.fixReview === 'never' ? options.fixReview : 'auto',
86 onExhausted: options.onExhausted === 'stop' ? 'stop' : 'open-pr',
87 openPr: options.openPr !== false,
88 highRisk: compileRisk(String(options.highRiskPaths ?? '')),
89 stepTimeoutMs: number('stepTimeoutMinutes', 30) * 60_000,
90 effort: effortOf(options.reviewEffort, 'medium'),
91 lightEffort: effortOf(options.lightEffort, 'low'),
92 light: compileRisk(String(options.lightBranches ?? '^chore/')),
93 }
94}
95
96const storeKey = (root: string, branch: string): string => `gate:${root}:${branch}`
97
98const opensPr = (status: RunStatus, config: Config): boolean =>
99 status === 'passed' || status === 'waived' || (status === 'exhausted' && config.onExhausted === 'open-pr')
100
101const elapsed = (ms: number): string => {
102 const seconds = Math.max(0, Math.round(ms / 1000))
103 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
104}
105
106/** Sets a step, adding it ahead of the closing "open PR" step when it is new. */
107function withStep(steps: readonly Step[], step: Step): Step[] {
108 if (steps.some(one => one.id === step.id)) {
109 return steps.map(one => (one.id === step.id ? { ...one, ...step } : one))
110 }
111 const last = steps.findIndex(one => one.id === 'pr')
112 return last < 0 ? [...steps, step] : [...steps.slice(0, last), step, ...steps.slice(last)]
113}
114
115// The module's own: what `register` was last given, and what a reload resets.
116let config: Config = configOf({})
117// Unknown after a reload, so the first event looks; false once no run is on.
118let isLive = true
119let ticker: { cancel: () => void } | undefined
120
121 const getRun = async ($: Engine): Promise<Run | null> => (await $.state.get(RUN)).value ?? null
122
123 /** Applies a change to the run in flight; a run that ended or was replaced is left alone. */
124 const patch = async ($: Engine, id: string, change: (run: Run) => Run): Promise<Run | null> => {
125 let after: Run | null = null
126 await update($, runAtom, run => {
127 after = run !== null && run !== undefined && run.id === id && run.status === 'running' ? change(run) : (run ?? null)
128 return after
129 })
130 return after
131 }
132
133 const git = async ($: Engine, cwd: string | undefined, args: string[], timeoutMs = 30_000) => {
134 const ran = await $.process.run(['git', ...args], { cwd, timeoutMs })
135 return { isOk: ran.exitCode === 0, out: ran.stdout.trim(), err: ran.stderr.trim() }
136 }
137
138 const stopTicker = (): void => {
139 ticker?.cancel()
140 ticker = undefined
141 }
142
143 const startTicker = ($: Engine): void => {
144 stopTicker()
145 ticker = $.clock.every(1000, () => {
146 void (async () => {
147 const now = await $.clock.now()
148 const run = await getRun($)
149 if (run === null || run.status !== 'running') {
150 stopTicker()
151 isLive = false
152 return
153 }
154 await update($, tickAtom, () => now)
155 if (now - run.phaseStartedAt > config.stepTimeoutMs) {
156 const why = `The ${run.phase} step ran past ${elapsed(config.stepTimeoutMs)}.`
157 if (run.phase === 'review') await retryLower($, run, why)
158 else await finish($, run.id, 'error', why, true, true)
159 }
160 })().catch(() => undefined)
161 })
162 }
163
164 // ------------------------------------------------------------- the end
165
166 const finish = async (
167 $: Engine,
168 id: string,
169 status: RunStatus,
170 message: string,
171 shouldPrompt = true,
172 hasTimedOut = false,
173 ): Promise<void> => {
174 const now = await $.clock.now()
175 const willOpen = shouldPrompt && config.openPr && opensPr(status, config)
176 const run = await patch($, id, one => ({
177 ...one,
178 status,
179 phase: 'done',
180 message,
181 endedAt: now,
182 pending: [],
183 activity: '',
184 steps: one.steps.map(step =>
185 step.id === 'pr'
186 ? { ...step, state: willOpen ? 'running' : opensPr(status, config) ? 'pending' : 'skipped', detail: willOpen ? 'handed to the main agent' : undefined }
187 : step.state === 'running'
188 ? { ...step, state: status === 'error' || status === 'stopped' ? 'failed' : 'done' }
189 : step,
190 ),
191 }))
192 stopTicker()
193 isLive = false
194 if (run === null) return
195 const head = await git($, run.root, ['rev-parse', 'HEAD'])
196 // A review that completed below the branch's default sets where the next run starts; a run that never completed one keeps the last.
197 const hasReviewed = status === 'passed' || status === 'exhausted' || status === 'blocked'
198 const prior = (await $.store.get(storeKey(run.root, run.branch))) as PassRecord | undefined | null
199 const passedEffort = hasReviewed ? run.effort : prior?.passedEffort
200 const start = run.startEffort ?? 'high'
201 const isBelow = passedEffort !== undefined && passedEffort !== start && lowerOf(passedEffort, start) === passedEffort
202 const record: PassRecord = {
203 sha: head.isOk ? head.out : run.headSha,
204 base: run.base,
205 status,
206 at: now,
207 rounds: run.round,
208 note: message,
209 effort: run.effort,
210 ...(hasTimedOut ? { hasTimedOut: true } : {}),
211 ...(isBelow ? { passedEffort } : {}),
212 }
213 await $.store.set(storeKey(run.root, run.branch), record)
214 $.ui.status(undefined)
215 $.ui.toast(`codex-gate ${status}: ${message}`.slice(0, 300))
216 if (willOpen) void $.prompt.submit({ text: prPrompt(run, status !== 'exhausted') }).catch(() => undefined)
217 }
218
219 // ----------------------------------------------------------- the steps
220
221 const spawn = async (
222 $: Engine,
223 run: Run,
224 role: Role,
225 prompt: string,
226): Promise<{ isStarted: boolean; agentId?: string }> => {
227 const started = await $.agent.spawn({
228 prompt,
229 description: `codex-gate r${run.round} ${role}`,
230 subagentType: 'general-purpose',
231 cwd: run.root,
232 })
233 return 'deny' in started && typeof started.deny === 'string' ? { isStarted: false } : { isStarted: true, agentId: started.agentId }
234 }
235
236 const beginReview = async ($: Engine, id: string): Promise<void> => {
237 const now = await $.clock.now()
238 const opened = await patch($, id, run => {
239 const roles: Role[] = run.tier === 'high' ? ['review', 'challenge'] : ['review']
240 return {
241 ...run,
242 phase: 'review',
243 phaseStartedAt: now,
244 pending: [],
245 activity: 'starting the Codex reviewer…',
246 steps: roles.reduce(
247 (steps, role) =>
248 withStep(steps, {
249 id: `r${run.round}-${role}`,
250 label: `Round ${run.round} · codex ${role} (${run.effort})`,
251 state: 'running',
252 detail: undefined,
253 }),
254 run.steps,
255 ),
256 }
257 })
258 if (opened === null) return
259 const roles = opened.tier === 'high' ? (['review', 'challenge'] as const) : (['review'] as const)
260 $.ui.status(`codex-gate r${opened.round}: codex reviewing (${opened.effort})`)
261 // Parallel sub-agents: on a high blast-radius change the review and the challenge run side by side.
262 const started = await Promise.all(roles.map(role => spawn($, opened, role, reviewPrompt(opened, role))))
263 if (started.some(one => !one.isStarted)) {
264 await finish($, id, 'error', 'A Codex reviewer sub-agent could not be started.')
265 return
266 }
267 await patch($, id, run => ({
268 ...run,
269 pending: roles.map((role, at) => ({ role, step: stepOf(run, role), agentId: started[at]?.agentId })),
270 activity: 'waiting on the Codex reviewer…',
271 }))
272 }
273
274 const beginFix = async ($: Engine, id: string, notes: string | undefined, kind: Run['fixKind'] = 'blocking'): Promise<void> => {
275 const now = await $.clock.now()
276 const isMain = config.fixer === 'main'
277 const opened = await patch($, id, run => ({
278 ...run,
279 fixKind: kind,
280 advisoryRound: kind === 'advisory' ? run.advisoryRound + 1 : run.advisoryRound,
281 phase: 'fix',
282 phaseStartedAt: now,
283 pending: isMain ? [{ role: 'fix', step: stepOf(run, 'fix'), isMain: true }] : [],
284 activity: isMain ? 'waiting on the main agent to fix…' : 'starting the fixer…',
285 steps: withStep(run.steps, {
286 id: `r${run.round}-fix${notes === undefined ? '' : '-again'}`,
287 label:
288 kind === 'advisory'
289 ? `Round ${run.round} · advisories ${run.advisoryRound + 1}/${run.advisoryMax} (${isMain ? 'main agent' : 'sub-agent'})`
290 : `Round ${run.round} · ${notes === undefined ? 'fix' : 'fix again'} (${isMain ? 'main agent' : 'sub-agent'})`,
291 state: 'running',
292 }),
293 }))
294 if (opened === null) return
295 $.ui.status(`codex-gate r${opened.round}: ${kind === 'advisory' ? `advisories ${opened.advisoryRound}/${opened.advisoryMax}` : 'fixing'}`)
296 const prompt = fixPrompt(opened, notes, isMain)
297 if (!isMain) {
298 const { isStarted, agentId } = await spawn($, opened, 'fix', prompt)
299 if (!isStarted) {
300 await finish($, id, 'error', 'The fixer sub-agent could not be started.')
301 return
302 }
303 await patch($, id, run => ({ ...run, pending: [{ role: 'fix', step: stepOf(run, 'fix'), agentId }], activity: 'waiting on the fixer…' }))
304 return
305 }
306 // The prompt waits for the session to go idle; it is armed once its turn starts,
307 // so a turn already running when the findings came in is never read as the fix.
308 void $.prompt
309 .submit({ text: prompt })
310 .then(async entered => {
311 if ('drop' in entered && typeof entered.drop === 'string') {
312 await finish($, id, 'error', `The findings could not be sent to the main agent: ${entered.drop}`)
313 return
314 }
315 await patch($, id, run =>
316 run.phase === 'fix' ? { ...run, pending: run.pending.map(one => ({ ...one, isArmed: true })) } : run,
317 )
318 // The step's clock starts with the turn, not with the wait for the session to go idle.
319 const startedAt = await $.clock.now()
320 await patch($, id, run => (run.phase === 'fix' ? { ...run, phaseStartedAt: startedAt } : run))
321 })
322 .catch(() => undefined)
323 }
324
325 const beginVerify = async ($: Engine, id: string, fromSha: string): Promise<void> => {
326 const now = await $.clock.now()
327 const opened = await patch($, id, run => ({
328 ...run,
329 phase: 'verify',
330 phaseStartedAt: now,
331 pending: [],
332 activity: 'starting the fix reviewer…',
333 steps: withStep(run.steps, { id: `r${run.round}-verify`, label: `Round ${run.round} · review the fix`, state: 'running' }),
334 }))
335 if (opened === null) return
336 $.ui.status(`codex-gate r${opened.round}: reviewing the fix`)
337 const { isStarted, agentId } = await spawn($, opened, 'verify', verifyPrompt(opened, fromSha))
338 if (!isStarted) {
339 await finish($, id, 'error', 'The fix reviewer sub-agent could not be started.')
340 return
341 }
342 await patch($, id, run => ({ ...run, pending: [{ role: 'verify', step: stepOf(run, 'verify'), agentId }], activity: 'waiting on the fix reviewer…' }))
343 }
344
345 /**
346 * Codex timed out: rerun the same round one effort lower, spending no round. At low it
347 * fails closed. A reviewer still running is not stopped (there is no way to); its late
348 * answer is ignored, as `isMine` knows a sub-agent by the id it was given.
349 */
350 const retryLower = async ($: Engine, run: Run, why: string): Promise<void> => {
351 const lower = stepDown(run.effort)
352 if (lower === undefined) {
353 await finish($, run.id, 'error', `${why} Codex timed out at low effort, the lowest the gate uses.`, true, true)
354 return
355 }
356 const now = await $.clock.now()
357 let isMoved = false
358 const moved = await patch($, run.id, one => {
359 // Once per timeout: a second tick, or a late answer, finds the effort already lowered.
360 if (one.effort !== run.effort || one.phase !== 'review') return one
361 isMoved = true
362 return {
363 ...one,
364 effort: lower,
365 phaseStartedAt: now,
366 pending: [],
367 steps: withStep(one.steps, {
368 id: `r${one.round}-timeout-${one.effort}`,
369 label: `Round ${one.round} · codex timed out at ${one.effort}`,
370 state: 'failed',
371 detail: `rerunning at ${lower}`,
372 }),
373 }
374 })
375 if (moved === null || !isMoved) return
376 $.ui.toast(`codex-gate: Codex timed out at ${run.effort}; rerunning round ${run.round} at ${lower}.`)
377 await beginReview($, run.id)
378 }
379
380 const nextRound = async ($: Engine, id: string): Promise<void> => {
381 const moved = await patch($, id, run => ({ ...run, round: run.round + 1, hasRefixed: false }))
382 if (moved !== null) await beginReview($, id)
383 }
384
385 // ------------------------------------------------- what each answer does
386
387 const afterReview = async ($: Engine, run: Run): Promise<void> => {
388 const results = run.pending.map(one => ({ role: one.role, result: readReview(one.answer ?? '') }))
389 const fresh: Finding[] = []
390 for (const { role, result } of results) {
391 for (const one of result.findings) {
392 fresh.push({
393 ...one,
394 id: `R${run.round}-${fresh.length + 1}`,
395 round: run.round,
396 source: role === 'challenge' ? 'challenge' : 'review',
397 status: 'open',
398 })
399 }
400 }
401 const blocking = fresh.filter(isBlocking)
402 const failed = results.find(one => one.result.gate === 'ERROR')
403 if (failed !== undefined && failed.result.failure === 'timeout') {
404 // Nothing of this round is kept, a parallel reviewer's findings included: it reruns whole.
405 await retryLower($, run, `Codex ${failed.role} timed out at ${run.effort}: ${failed.result.note ?? 'no verdict'}.`)
406 return
407 }
408 const after = await patch($, run.id, one => ({
409 ...one,
410 pending: [],
411 // What an earlier round left open and Codex no longer raises is settled.
412 findings: [...one.findings.map(old => (old.status === 'open' ? { ...old, status: 'fixed' as const } : old)), ...fresh],
413 steps: results.reduce(
414 (steps, { role, result }) =>
415 withStep(steps, {
416 id: `r${one.round}-${role}`,
417 label: `Round ${one.round} · codex ${role}`,
418 state: result.gate === 'ERROR' ? 'failed' : 'done',
419 detail:
420 result.gate === 'ERROR'
421 ? (result.note ?? 'did not complete')
422 : `${result.gate} · ${result.findings.filter(isBlocking).length} blocking, ${result.findings.filter(f => !isBlocking(f)).length} advisory`,
423 }),
424 one.steps,
425 ),
426 }))
427 if (after === null) return
428 if (failed !== undefined) {
429 // Fail closed: a review that did not complete is never a pass, and no round is spent on it.
430 await finish($, run.id, 'error', `Codex ${failed.role} did not complete: ${failed.result.note ?? 'no verdict'}`)
431 return
432 }
433 if (blocking.length === 0) {
434 // Nothing blocks the PR. What Codex still raised goes to the fixer, a bounded number of times.
435 if (fresh.length > 0 && after.advisoryRound < after.advisoryMax) {
436 await beginFix($, run.id, undefined, 'advisory')
437 return
438 }
439 const isUnverified = results.every(one => one.result.gate === 'UNVERIFIED')
440 const left = fresh.length === 0 ? '' : ` ${fresh.length} advisory finding${fresh.length === 1 ? '' : 's'} left after ${after.advisoryRound} advisory round${after.advisoryRound === 1 ? '' : 's'}.`
441 await finish(
442 $,
443 run.id,
444 'passed',
445 `No high/critical finding after ${after.round} round${after.round === 1 ? '' : 's'}${isUnverified ? ' (Codex tagged nothing; its prose was read as clean)' : ''}.${left}`,
446 )
447 return
448 }
449 // Advisory rounds are on top of the blocking budget.
450 if (after.round >= after.maxRounds + after.advisoryRound) {
451 await finish($, run.id, 'exhausted', `${blocking.length} high/critical finding${blocking.length === 1 ? '' : 's'} still open after ${after.round} rounds.`)
452 return
453 }
454 await beginFix($, run.id, undefined)
455 }
456
457 const afterFix = async ($: Engine, run: Run): Promise<void> => {
458 const said = readDispositions(run.pending[0]?.answer ?? '')
459 const head = await git($, run.root, ['rev-parse', 'HEAD'])
460 const dirty = await git($, run.root, ['status', '--porcelain', '--untracked-files=no'])
461 const fromSha = run.headSha
462 const newSha = head.isOk ? head.out : fromSha
463 const count = (action: string): number => said.filter(one => one.action === action).length
464 const after = await patch($, run.id, one => ({
465 ...one,
466 pending: [],
467 headSha: newSha,
468 findings: one.findings.map(finding => {
469 const answer = said.find(each => each.id === finding.id)
470 return answer === undefined ? finding : { ...finding, status: answer.action, note: answer.note }
471 }),
472 steps: one.steps.map(step =>
473 step.state === 'running' && step.id.startsWith(`r${one.round}-fix`)
474 ? {
475 ...step,
476 state: 'done',
477 detail:
478 said.length === 0
479 ? newSha === fromSha
480 ? 'no change, no report'
481 : 'committed, no report'
482 : `${count('fixed')} fixed · ${count('rebutted')} rebutted${count('deferred') > 0 ? ` · ${count('deferred')} deferred` : ''}`,
483 }
484 : step,
485 ),
486 }))
487 if (after === null) return
488 if (dirty.isOk && dirty.out !== '') {
489 await finish($, run.id, 'error', 'The fixer left uncommitted changes. Codex reads committed code only: commit them and run /pr-gate again.')
490 return
491 }
492 if (after.fixKind === 'advisory') {
493 const advisories = after.findings.filter(one => one.round === after.round && !isBlocking(one))
494 const hasRebuttal = advisories.some(one => one.status === 'rebutted')
495 // New commits always go back to Codex. With none, Codex is asked again only to answer
496 // a rebuttal, and only until the minimum number of advisory rounds has run.
497 if (newSha === fromSha && !(hasRebuttal && after.advisoryRound < after.advisoryMin)) {
498 const open = advisories.filter(one => one.status !== 'fixed').length
499 await finish($, run.id, 'passed', `No high/critical finding after ${after.round} round${after.round === 1 ? '' : 's'}. ${open} advisory finding${open === 1 ? '' : 's'} left as ${open === 1 ? 'it is' : 'they are'} after ${after.advisoryRound} advisory round${after.advisoryRound === 1 ? '' : 's'}.`)
500 return
501 }
502 await nextRound($, run.id)
503 return
504 }
505 const current = after.findings.filter(one => one.round === after.round && isBlocking(one))
506 const deferred = current.filter(one => one.status === 'deferred')
507 if (deferred.length > 0) {
508 await finish($, run.id, 'blocked', `${deferred.map(one => one.id).join(', ')} need${deferred.length === 1 ? 's' : ''} a decision from you: the fix reaches past this branch's scope.`)
509 return
510 }
511 if (newSha === fromSha && !current.some(one => one.status === 'rebutted')) {
512 await finish($, run.id, 'blocked', 'The fixer changed nothing and rebutted nothing, so another Codex round would say the same.')
513 return
514 }
515 const wantsVerify = config.fixReview === 'always' || (config.fixReview === 'auto' && after.tier === 'high')
516 if (wantsVerify && !after.hasRefixed && newSha !== fromSha) {
517 await beginVerify($, run.id, fromSha)
518 return
519 }
520 await nextRound($, run.id)
521 }
522
523 const afterVerify = async ($: Engine, run: Run): Promise<void> => {
524 const verdict = readVerify(run.pending[0]?.answer ?? '')
525 const after = await patch($, run.id, one => ({
526 ...one,
527 pending: [],
528 hasRefixed: !verdict.isOk,
529 steps: withStep(one.steps, {
530 id: `r${one.round}-verify`,
531 label: `Round ${one.round} · review the fix`,
532 state: 'done',
533 detail: verdict.isOk ? 'fix holds' : 'sent back to the fixer',
534 }),
535 }))
536 if (after === null) return
537 if (verdict.isOk) await nextRound($, run.id)
538 else await beginFix($, run.id, verdict.notes ?? 'The fix does not settle the findings.')
539 }
540
541 /** One agent's turn ended: record its answer, and move on once the step's agents are all in. */
542 const onAnswer = async ($: Engine, agentId: string | undefined, answer: string, reason: string): Promise<void> => {
543 const run = await getRun($)
544 if (run === null || run.status !== 'running') {
545 isLive = false
546 return
547 }
548 // A sub-agent is known by the id the engine gave it, or by the step it names in its
549 // trailer; the main agent by the turn that began once its prompt had entered.
550 const named = readTrailer(answer)?.step
551 const isMine = (one: Pending): boolean =>
552 one.isMain === true
553 ? agentId === undefined && one.isArmed === true
554 : one.agentId !== undefined
555 ? one.agentId === agentId
556 : named === one.step
557 if (!run.pending.some(isMine)) return
558 if (reason === 'aborted') {
559 await finish($, run.id, 'stopped', `The ${run.phase} step was interrupted.`)
560 return
561 }
562 if (reason !== 'answer') {
563 await finish($, run.id, 'error', `The ${run.phase} step ended on ${reason === 'refusal' ? 'a refusal' : 'an API error'}.`)
564 return
565 }
566 const after = await patch($, run.id, one => ({
567 ...one,
568 pending: one.pending.map(each => (isMine(each) ? { ...each, answer } : each)),
569 }))
570 if (after === null || after.pending.some(one => one.answer === undefined)) return
571 if (after.phase === 'review') await afterReview($, after)
572 else if (after.phase === 'fix') await afterFix($, after)
573 else if (after.phase === 'verify') await afterVerify($, after)
574 }
575
576 // ------------------------------------------------------------ starting
577
578 const startGate = async ($: Engine, want: { base?: string; cwd?: string; isHeldCall?: boolean }): Promise<string> => {
579 const existing = await getRun($)
580 if (existing !== null && existing.status === 'running') {
581 return `The gate is already running for ${existing.branch} (round ${existing.round}).`
582 }
583 const top = await git($, want.cwd, ['rev-parse', '--show-toplevel'])
584 if (!top.isOk) return 'Not started: this is not a git repository.'
585 const root = top.out
586 const base = want.base ?? config.bases[0] ?? 'dev'
587 const branch = (await git($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])).out
588 if (branch === '' || branch === 'HEAD') return 'Not started: HEAD is detached, so there is no branch to open a PR from.'
589 if (branch === base) return `Not started: you are on ${base} itself. Check out the branch the PR will come from.`
590 await git($, root, ['fetch', 'origin', base], 120_000)
591 if (!(await git($, root, ['rev-parse', '--verify', '--quiet', `origin/${base}`])).isOk) {
592 return `Not started: origin/${base} does not exist here.`
593 }
594 const dirty = await git($, root, ['status', '--porcelain', '--untracked-files=no'])
595 if (dirty.out !== '') return 'Not started: there are uncommitted changes. Codex reviews committed code only; commit them first.'
596 const head = (await git($, root, ['rev-parse', 'HEAD'])).out
597 const stat = await git($, root, ['diff', '--numstat', `origin/${base}...HEAD`])
598 if (!stat.isOk) return `Not started: git could not diff against origin/${base}: ${stat.err.slice(0, 200)}`
599 const rows = stat.out === '' ? [] : stat.out.split('\n').map(line => line.split('\t'))
600 const files = rows.map(row => row[2] ?? '').filter(file => file !== '')
601 const lines = rows.reduce((sum, row) => sum + (Number(row[0]) || 0) + (Number(row[1]) || 0), 0)
602 if (files.length === 0) return `Not started: ${branch} has no changes against origin/${base}.`
603 const settled = await settledFiles($, root, base, files)
604 const landedOn = await isAlreadyOnBase($, root, base)
605 const scope =
606 landedOn !== undefined
607 ? { tier: 'trivial' as const, why: `already reviewed: every commit is on ${landedOn}` }
608 : classify(files, lines, config.highRisk, settled)
609 const lane = config.light !== undefined && config.light.test(branch) ? ('light' as const) : ('full' as const)
610 // High blast radius starts at high; the rest at the configured effort, lower on a light-lane branch.
611 const startEffort: Effort = scope.tier === 'high' ? 'high' : lane === 'light' ? config.lightEffort : config.effort
612 const last = (await $.store.get(storeKey(root, branch))) as PassRecord | undefined | null
613 // The branch's last run timed out at its lowest step: this one starts a level below where that one ended.
614 // Otherwise it starts no higher than the effort a review last completed at on this branch.
615 const afterTimeout = last?.hasTimedOut === true && last.effort !== undefined ? (stepDown(last.effort) ?? 'low') : startEffort
616 const effort = lowerOf(lowerOf(startEffort, afterTimeout), last?.passedEffort ?? startEffort)
617 const now = await $.clock.now()
618 const run: Run = {
619 id: `${now}`,
620 cwd: want.cwd ?? root,
621 root,
622 branch,
623 base,
624 headSha: head,
625 tier: scope.tier,
626 tierWhy: scope.why,
627 lane,
628 effort,
629 startEffort,
630 round: 1,
631 maxRounds: lane === 'light' ? 1 : config.maxRounds,
632 status: 'running',
633 phase: 'scope',
634 hasRefixed: false,
635 fixKind: 'blocking',
636 advisoryRound: 0,
637 advisoryMin: lane === 'light' ? 0 : config.advisoryMin,
638 advisoryMax: lane === 'light' ? 0 : config.advisoryMax,
639 steps: [
640 {
641 id: 'scope',
642 label: 'Scope & blast radius',
643 state: 'done',
644 detail: `${scope.tier}${lane === 'light' ? ', light lane' : ''}: ${scope.why}`,
645 },
646 { id: 'pr', label: `Open PR → ${base}`, state: 'pending' },
647 ],
648 findings: [],
649 pending: [],
650 activity: '',
651 startedAt: now,
652 phaseStartedAt: now,
653 }
654 await update($, runAtom, () => run)
655 await update($, tickAtom, () => now)
656 isLive = true
657 startTicker($)
658 void $.ui.open({ id: PANE, title: 'Codex gate' }).catch(() => undefined)
659 if (scope.tier === 'trivial') {
660 await patch($, run.id, one => ({
661 ...one,
662 steps: withStep(one.steps, { id: 'r1-review', label: 'Codex review', state: 'skipped', detail: scope.why }),
663 }))
664 await finish($, run.id, 'passed', `Codex skipped: ${scope.why}.`, want.isHeldCall !== true)
665 return `Gate passed without Codex: ${scope.why}.`
666 }
667 await beginReview($, run.id)
668 const tierNote = scope.tier === 'high' ? ' Review and challenge run in parallel, and fixes are reviewed before each new round.' : ''
669 const budget =
670 lane === 'light'
671 ? `one Codex round (light lane: ${branch} matches lightBranches); what it raises goes to the pull request`
672 : `up to ${run.maxRounds} rounds on high/critical findings, then up to ${run.advisoryMax} on the rest`
673 return `Gate started for ${branch} → ${base}: ${scope.tier} blast radius (${scope.why}), Codex at ${effort} effort, ${budget}.${tierNote}`
674 }
675
676 /**
677 * Changed files whose content was reviewed elsewhere: a submodule pointer to a commit
678 * already on that submodule's origin/main or origin/dev, and a package.json or lock change
679 * that touches only "version" lines. A pointer to an unlanded commit is not settled.
680 */
681 const settledFiles = async ($: Engine, root: string, base: string, files: readonly string[]): Promise<string[]> => {
682 const out: string[] = []
683 const raw = await git($, root, ['diff', '--raw', `origin/${base}...HEAD`])
684 for (const link of raw.isOk ? readGitlinks(raw.out) : []) {
685 const at = `${root}/${link.path}`
686 await git($, at, ['fetch', 'origin', 'main', 'dev'], 120_000)
687 for (const ref of ['origin/main', 'origin/dev']) {
688 if ((await git($, at, ['merge-base', '--is-ancestor', link.sha, ref])).isOk) {
689 out.push(link.path)
690 break
691 }
692 }
693 }
694 for (const file of files.filter(one => /(^|\/)package(-lock)?\.json$/.test(one))) {
695 const diff = await git($, root, ['diff', '-U0', `origin/${base}...HEAD`, '--', file])
696 if (diff.isOk && isVersionOnly(diff.out)) out.push(file)
697 }
698 return out
699 }
700
701 /**
702 * The other gated base whose origin already holds every commit this branch adds (a
703 * back-sync of main into dev), with no merge commit carrying edits of its own; else undefined.
704 */
705 const isAlreadyOnBase = async ($: Engine, root: string, base: string): Promise<string | undefined> => {
706 const merges = await git($, root, ['rev-list', '--merges', `origin/${base}..HEAD`])
707 const isCleanMerges =
708 merges.isOk &&
709 (
710 await Promise.all(
711 (merges.out === '' ? [] : merges.out.split('\n')).map(async sha => {
712 const own = await git($, root, ['show', '--cc', '--format=', sha])
713 return own.isOk && own.out === ''
714 }),
715 )
716 ).every(Boolean)
717 for (const other of isCleanMerges ? config.bases.filter(one => one !== base) : []) {
718 await git($, root, ['fetch', 'origin', other], 120_000)
719 // A back-sync can be one clean merge and nothing else: every commit it brings is on the other base.
720 const left = await git($, root, ['rev-list', '--no-merges', `origin/${base}..HEAD`, `^origin/${other}`])
721 if (left.isOk && left.out === '') return `origin/${other}`
722 }
723 return undefined
724 }
725
726 // ----------------------------------------------------------- the guard
727
728 /** Whether a PR to a gated base may be opened now; a refusal starts the gate. */
729 const judge = async ($: Engine, pr: PrCreate & { head?: string }): Promise<string | undefined> => {
730 const base = pr.base ?? config.bases[0] ?? 'dev'
731 if (!config.bases.includes(base)) return undefined
732 const top = await git($, pr.cwd, ['rev-parse', '--show-toplevel'])
733 if (!top.isOk) return undefined
734 const root = top.out
735 if (pr.repo !== undefined) {
736 const origin = await git($, root, ['remote', 'get-url', 'origin'])
737 if (origin.isOk && slugOf(origin.out) !== slugOf(pr.repo)) return undefined
738 }
739 const branch = (await git($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])).out
740 // A PR from a branch that is not checked out here (a release PR, dev into main) is not this gate's to review.
741 if (pr.head !== undefined && pr.head.replace(/^[^:]+:/, '') !== branch) return undefined
742 // A promotion between gated bases (dev into main) carries only what was gated on its way in.
743 if (config.bases.includes(branch)) return undefined
744 const head = (await git($, root, ['rev-parse', 'HEAD'])).out
745 const key = storeKey(root, branch)
746 const covers = (record: PassRecord | undefined | null): record is PassRecord =>
747 !!record && (record.base === base || record.base === '*') && opensPr(record.status, config)
748 const record = (await $.store.get(key)) as PassRecord | undefined | null
749 if (covers(record) && record.sha === head) return undefined
750 if (covers(record)) {
751 // Commits since the gate finished: docs alone (a CHANGELOG line) ride on its verdict; code does not.
752 const since = await git($, root, ['diff', '--numstat', record.sha, 'HEAD'])
753 const files = since.out === '' ? [] : since.out.split('\n').map(line => line.split('\t')[2] ?? '')
754 if (since.isOk && files.length > 0 && classify(files, 0, config.highRisk).tier === 'trivial') {
755 await $.store.set(key, { ...record, sha: head })
756 return undefined
757 }
758 }
759 const started = await startGate($, { base, cwd: root, isHeldCall: true })
760 // A change Codex is not needed for passes at once: the held call goes straight through.
761 const now = (await $.store.get(key)) as PassRecord | undefined | null
762 if (covers(now) && now.sha === head) return undefined
763 return [
764 `codex-gate: a pull request to ${base} waits for the pre-PR Codex gate to finish for ${branch} at ${head.slice(0, 7)}.`,
765 started,
766 'Do not retry this command and do not open the pull request another way. End your turn: the gate reviews the branch, sends back any findings to fix, and prompts you to open the pull request when it is done.',
767 `(The person can type /${COMMAND} status, or /${COMMAND} waive <reason> to open it without the gate.)`,
768 ].join('\n')
769 }
770
771 const markOpened = async ($: Engine): Promise<void> => {
772 await update($, runAtom, run =>
773 run === null || run === undefined
774 ? null
775 : { ...run, steps: run.steps.map(step => (step.id === 'pr' ? { ...step, state: 'done' as const, detail: 'opened' } : step)) },
776 )
777 }
778
779export const register: Register = (on, options) => {
780 config = configOf(options)
781
782 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
783 const pr = config.enforce ? readPrCreate(e.command) : undefined
784 if (pr === undefined) return next(e)
785 const head = e.command.match(/(?:^|\s)(?:--head|-H)(?:=|\s+)["']?([^\s"']+)/)?.[1]
786 const refusal = await judge($, { ...pr, head })
787 if (refusal !== undefined) return { deny: refusal }
788 const ran = await next(e)
789 if (ran.deny === undefined && ran.isError !== true) await markOpened($)
790 return ran
791 }).catch(($, e, next) =>
792 next.called || readPrCreate(e.command) === undefined
793 ? next(e)
794 : { deny: `codex-gate: its guard failed, so this pull request is held. The person can type /${COMMAND} waive <reason>.` },
795 )
796
797 on('tool.call', { tool: GITHUB_CREATE_PR }, async ($, e, next) => {
798 if (!config.enforce) return next(e)
799 const input = e as unknown as Record<string, unknown>
800 const refusal = await judge($, {
801 base: typeof input.base === 'string' ? input.base : undefined,
802 head: typeof input.head === 'string' ? input.head : undefined,
803 repo: typeof input.owner === 'string' && typeof input.repo === 'string' ? `${input.owner}/${input.repo}` : undefined,
804 cwd: undefined,
805 })
806 if (refusal !== undefined) return { deny: refusal }
807 const ran = await next(e)
808 if (ran.deny === undefined && ran.isError !== true) await markOpened($)
809 return ran
810 }).catch(($, e, next) =>
811 next.called ? next(e) : { deny: `codex-gate: its guard failed, so this pull request is held. The person can type /${COMMAND} waive <reason>.` },
812 )
813
814 // -------------------------------------------- the line that changes in place
815
816 on('tool.call', ($, e, next) => {
817 if (isLive) {
818 void (async () => {
819 const run = await getRun($)
820 if (run === null || run.status !== 'running') {
821 isLive = false
822 return
823 }
824 const who = run.pending.find(one =>
825 one.isMain === true ? e.agentId === undefined && one.isArmed === true : one.agentId !== undefined && one.agentId === e.agentId,
826 )
827 if (who === undefined) return
828 const activity = activityOf(who.role, String(e.tool), e as unknown as Record<string, unknown>)
829 if (activity !== run.activity) await patch($, run.id, one => ({ ...one, activity }))
830 })().catch(() => undefined)
831 }
832 return next(e)
833 })
834
835 on('turn.complete', async ($, e, next) => {
836 const done = await next(e)
837 if (isLive) {
838 try {
839 await onAnswer($, e.agentId, e.answer, e.reason)
840 } catch (error) {
841 const run = await getRun($)
842 if (run !== null && run.status === 'running') {
843 await finish($, run.id, 'error', `The gate itself failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 300))
844 }
845 }
846 }
847 return done
848 })
849
850 // --------------------------------------------------- command and session
851
852 on('session.start', async ($, e, next) => {
853 await $.command.register({
854 name: 'pr-gate',
855 description: 'Run the pre-PR Codex review gate on this branch (start, status, stop, waive)',
856 argumentHint: '[base] | status | stop | waive <reason>',
857 immediate: true,
858 })
859 const run = await getRun($)
860 if (run !== null && run.status === 'running') {
861 // A reload dropped the module's timer, not the run: the host kept it.
862 startTicker($)
863 void $.ui.open({ id: PANE, title: 'Codex gate' }).catch(() => undefined)
864 } else {
865 isLive = false
866 }
867 return next(e)
868 })
869
870 on('command.run', { command: 'pr-gate' }, async ($, e) => {
871 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
872 const run = await getRun($)
873 if (verb === 'status') {
874 // Says where the pane stands too: a surface that places no panes gives its reason here.
875 const pane = await $.ui
876 .open({ id: PANE, title: 'Codex gate' })
877 .then(opened => (opened.isPlaced ? ' The pane is open.' : ` The pane is not drawn here: ${opened.reason}`))
878 .catch(() => ' The pane could not be opened.')
879 if (run === null) return { text: `codex-gate has not run in this session.${pane}` }
880 const open = run.findings.filter(one => one.round === run.round && isBlocking(one) && one.status === 'open').length
881 return {
882 text: `codex-gate ${run.status} for ${run.branch} → ${run.base}: round ${run.round}${run.advisoryRound > 0 ? ` (advisory ${run.advisoryRound}/${run.advisoryMax})` : ''}, codex at ${run.effort}, ${run.findings.length} findings raised, ${open} high/critical open.${run.message === undefined ? '' : ` ${run.message}`}${pane}`,
883 }
884 }
885 if (verb === 'stop') {
886 if (run === null || run.status !== 'running') return { text: 'codex-gate is not running.' }
887 await finish($, run.id, 'stopped', 'Stopped by the person.')
888 return { text: 'codex-gate stopped. Pull requests to a gated base stay held until it passes or is waived.' }
889 }
890 if (verb === 'waive') {
891 const reason = rest.join(' ').trim()
892 // A waiver is the person's alone: an agent's or a plugin's command run never grants one.
893 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return { text: 'Only the person can waive the gate.' }
894 if (reason === '') return { text: `Give a reason: /${COMMAND} waive <reason>` }
895 if (run !== null && run.status === 'running') await finish($, run.id, 'waived', `Waived: ${reason}`)
896 const top = await git($, undefined, ['rev-parse', '--show-toplevel'])
897 if (!top.isOk) return { text: 'Not a git repository.' }
898 const branch = (await git($, top.out, ['rev-parse', '--abbrev-ref', 'HEAD'])).out
899 const head = (await git($, top.out, ['rev-parse', 'HEAD'])).out
900 const now = await $.clock.now()
901 // A waiver covers this commit for every gated base.
902 const record: PassRecord = { sha: head, base: '*', status: 'waived', at: now, rounds: run?.round ?? 0, note: reason }
903 await $.store.set(storeKey(top.out, branch), record)
904 return { text: `codex-gate waived for ${branch} at ${head.slice(0, 7)}: ${reason}. A new commit needs a new gate or waiver.` }
905 }
906 const base = verb === '' || verb === 'start' ? rest[0] : verb
907 return { text: `codex-gate: ${await startGate($, { base })}` }
908 })
909
910 // ------------------------------------------------------------ the pane
911
912 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
913 const { Box, Button, Text } = $.ui.resolve(e)
914 const run = await read($, runAtom)
915 const now = await read($, tickAtom)
916
917 if (run === null) {
918 return (
919 <Box flexDirection="column">
920 <Text bold>Codex gate</Text>
921 <Text dimColor>Not run yet. /{COMMAND} starts it; opening a PR to a gated base starts it too.</Text>
922 </Box>
923 )
924 }
925
926 const isRunning = run.status === 'running'
927 const frame = SPINNER[Math.floor(now / 1000) % SPINNER.length] ?? '⠋'
928 const waiting = run.pending.filter(one => one.answer === undefined).length
929 const headline =
930 run.phase === 'review'
931 ? `codex round ${run.round}`
932 : run.phase === 'fix'
933 ? run.fixKind === 'advisory'
934 ? `addressing advisories ${run.advisoryRound}/${run.advisoryMax}`
935 : `fixing round ${run.round}`
936 : run.phase === 'verify'
937 ? `reviewing the fix, round ${run.round}`
938 : 'sizing the change'
939 const mark = (step: Step): string =>
940 step.state === 'done' ? '✔' : step.state === 'failed' ? '✖' : step.state === 'skipped' ? '–' : step.state === 'running' ? (isRunning ? frame : '◐') : '○'
941 const current = run.findings.filter(one => one.round === run.round)
942 const earlier = run.findings.filter(one => one.round !== run.round)
943 const room = Math.max(4, (e.viewport?.rows ?? 30) - run.steps.length - 12)
944 const shown = [...current, ...earlier].slice(0, room)
945 const hidden = run.findings.length - shown.length
946 const statusColor = run.status === 'passed' ? 'success' : run.status === 'running' ? 'claude' : run.status === 'waived' ? 'warning' : 'error'
947
948 return (
949 <Box flexDirection="column">
950 <Text bold wrap="truncate-end">
951 {run.branch} → {run.base}
952 </Text>
953 <Text dimColor wrap="truncate-end">
954 round {run.round}
955 {run.advisoryRound > 0 ? ` · advisory ${run.advisoryRound}/${run.advisoryMax}` : ` of ${run.maxRounds}`} · {run.tier} blast radius{run.lane === 'light' ? ' · light lane' : ''} · codex {run.effort} · {elapsed((run.endedAt ?? now) - run.startedAt)}
956 </Text>
957
958 <Box marginTop={1} flexDirection="column">
959 {isRunning ? (
960 <Text color="claude" wrap="truncate-end">
961 {frame} in progress: {headline}
962 {waiting > 0 ? ` · waiting on ${waiting} agent${waiting === 1 ? '' : 's'}` : ''} · {elapsed(now - run.phaseStartedAt)}
963 </Text>
964 ) : (
965 <Text color={statusColor} bold wrap="truncate-end">
966 {run.status.toUpperCase()}
967 </Text>
968 )}
969 {isRunning && run.activity !== '' && (
970 <Text dimColor wrap="truncate-end">
971 {' '}
972 {run.activity}
973 </Text>
974 )}
975 {!isRunning && run.message !== undefined && <Text wrap="wrap">{run.message}</Text>}
976 </Box>
977
978 <Box marginTop={1} flexDirection="column">
979 <Text bold>To do</Text>
980 {run.steps.map(step => (
981 <Text
982 key={`step-${step.id}`}
983 wrap="truncate-end"
984 dimColor={step.state === 'pending' || step.state === 'skipped'}
985 color={step.state === 'failed' ? 'error' : step.state === 'running' ? 'claude' : undefined}
986 >
987 {mark(step)} {step.label}
988 {step.detail === undefined ? '' : ` — ${step.detail}`}
989 </Text>
990 ))}
991 </Box>
992
993 <Box marginTop={1} flexDirection="column">
994 <Text bold>
995 Codex findings ({run.findings.length}
996 {current.some(isBlocking) ? `, ${current.filter(one => isBlocking(one) && one.status === 'open').length} blocking open` : ''})
997 </Text>
998 {run.findings.length === 0 && <Text dimColor>None yet.</Text>}
999 {shown.map(one => (
1000 <Text
1001 key={`finding-${one.id}`}
1002 wrap="truncate-end"
1003 dimColor={one.round !== run.round || !isBlocking(one)}
1004 color={one.round === run.round && isBlocking(one) && one.status === 'open' ? 'error' : undefined}
1005 strikethrough={one.status === 'fixed'}
1006 >
1007 {one.status === 'fixed' ? '✔' : one.status === 'rebutted' ? '↩' : one.status === 'deferred' ? '⏸' : isBlocking(one) ? '●' : '·'} {one.id} [
1008 {one.severity}] {one.title}
1009 {one.file === undefined ? '' : ` (${one.file}${one.line === undefined ? '' : `:${one.line}`})`}
1010 {one.status === 'open' ? '' : ` — ${one.status}`}
1011 </Text>
1012 ))}
1013 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
1014 </Box>
1015
1016 {isRunning && (
1017 <Box marginTop={1}>
1018 <Button key="stop" label="Stop the gate" onPress={() => void finish($, run.id, 'stopped', 'Stopped by the person.')} />
1019 </Box>
1020 )}
1021 </Box>
1022 )
1023 })
1024}
1025hooks/core.ts 422 lines1// The gate's pure half: reading what the agents answered, sizing a change,
2// reading a `gh pr create`, and the words each agent is sent. No `$` here.
3
4import type { Effort, Finding, Role, Run, Severity, Tier } from '../types'
5
6export const TRAILER = 'CODEX_GATE_RESULT'
7
8const SEVERITIES: readonly Severity[] = ['P0', 'P1', 'P2', 'P3']
9
10export const isBlocking = (finding: Pick<Finding, 'severity'>): boolean =>
11 finding.severity === 'P0' || finding.severity === 'P1'
12
13// ---------------------------------------------------------------- answers
14
15/** The JSON object an agent ended its answer with, after the trailer word. */
16export function readTrailer(answer: string): Record<string, unknown> | undefined {
17 const at = answer.lastIndexOf(TRAILER)
18 if (at < 0) return undefined
19 const tail = answer.slice(at + TRAILER.length)
20 const open = tail.indexOf('{')
21 const close = tail.lastIndexOf('}')
22 if (open < 0 || close <= open) return undefined
23 try {
24 const parsed: unknown = JSON.parse(tail.slice(open, close + 1))
25 return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
26 ? (parsed as Record<string, unknown>)
27 : undefined
28 } catch {
29 return undefined
30 }
31}
32
33export type ReviewGate = 'PASS' | 'FAIL' | 'UNVERIFIED' | 'ERROR'
34
35/** Why a review did not complete; only a timeout is worth a rerun at lower effort. */
36export type ReviewFailure = 'timeout' | 'other'
37
38export type ReviewResult = {
39 gate: ReviewGate
40 findings: Omit<Finding, 'id' | 'round' | 'source' | 'status'>[]
41 note?: string
42 failure?: ReviewFailure
43}
44
45const TIMEOUT_WORDS = /\btim(e|ed|ing)[\s-]?out\b|\bstall(ed)?\b|exit(ed)?( code)? 124\b/i
46
47/** The failure kind a reviewer reported, or read from its note when it named none. */
48function failureOf(said: unknown, note: string | undefined): ReviewFailure {
49 const kind = String(said ?? '').toLowerCase()
50 if (kind === 'timeout') return 'timeout'
51 if (kind !== '') return 'other'
52 return note !== undefined && TIMEOUT_WORDS.test(note) ? 'timeout' : 'other'
53}
54
55const EFFORTS: readonly Effort[] = ['high', 'medium', 'low']
56
57/** One level below, or undefined at the bottom. */
58export const stepDown = (effort: Effort): Effort | undefined => EFFORTS[EFFORTS.indexOf(effort) + 1]
59
60/** The lower of two efforts. */
61export const lowerOf = (a: Effort, b: Effort): Effort => (EFFORTS.indexOf(a) >= EFFORTS.indexOf(b) ? a : b)
62
63const text = (value: unknown): string | undefined =>
64 typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
65
66/**
67 * A reviewer's answer as a gate verdict. Fails closed: an answer with no
68 * readable verdict, or a FAIL that names no blocking finding, is an ERROR,
69 * never a pass.
70 */
71export function readReview(answer: string): ReviewResult {
72 const trailer = readTrailer(answer)
73 if (trailer === undefined) {
74 const said = answer.match(/\bGATE:\s*(PASS|FAIL|UNVERIFIED)\b/)?.[1]
75 if (said === 'PASS' || said === 'UNVERIFIED') return { gate: said, findings: [] }
76 const note =
77 said === 'FAIL' ? 'The reviewer reported GATE: FAIL without listing its findings.' : 'The reviewer ended without a verdict.'
78 return { gate: 'ERROR', findings: [], note, failure: failureOf(undefined, answer) }
79 }
80 const gate = String(trailer.gate ?? '').toUpperCase()
81 const listed = Array.isArray(trailer.findings) ? trailer.findings : []
82 const findings: ReviewResult['findings'] = []
83 for (const one of listed) {
84 if (typeof one !== 'object' || one === null) continue
85 const row = one as Record<string, unknown>
86 const severity = String(row.severity ?? '').toUpperCase() as Severity
87 const title = text(row.title)
88 if (!SEVERITIES.includes(severity) || title === undefined) continue
89 findings.push({
90 severity,
91 title: title.slice(0, 300),
92 file: text(row.file),
93 line: typeof row.line === 'number' ? row.line : undefined,
94 detail: text(row.detail)?.slice(0, 4000),
95 })
96 }
97 const note = text(trailer.note)
98 if (gate !== 'PASS' && gate !== 'FAIL' && gate !== 'UNVERIFIED') {
99 return { gate: 'ERROR', findings, note: note ?? 'The review did not complete.', failure: failureOf(trailer.error, note ?? answer) }
100 }
101 const hasBlocking = findings.some(isBlocking)
102 if (gate === 'FAIL' && !hasBlocking) {
103 return {
104 gate: 'ERROR',
105 findings,
106 note: note ?? 'FAIL with no P0/P1 finding listed: the review did not complete.',
107 failure: 'other',
108 }
109 }
110 // A blocking finding outranks whatever word the agent put on the gate.
111 return { gate: hasBlocking ? 'FAIL' : gate, findings, note }
112}
113
114export type Disposition = { id: string; action: 'fixed' | 'rebutted' | 'deferred'; note?: string }
115
116/** What the fixer said it did with each finding; empty when it said nothing. */
117export function readDispositions(answer: string): Disposition[] {
118 const listed = readTrailer(answer)?.dispositions
119 if (!Array.isArray(listed)) return []
120 const out: Disposition[] = []
121 for (const one of listed) {
122 if (typeof one !== 'object' || one === null) continue
123 const row = one as Record<string, unknown>
124 const id = text(row.id)
125 const action = String(row.action ?? '').toLowerCase()
126 if (id === undefined) continue
127 if (action !== 'fixed' && action !== 'rebutted' && action !== 'deferred') continue
128 out.push({ id, action, note: text(row.note)?.slice(0, 2000) })
129 }
130 return out
131}
132
133/** The fix reviewer's verdict; an unreadable one counts as fine (Codex decides next). */
134export function readVerify(answer: string): { isOk: boolean; notes?: string } {
135 const trailer = readTrailer(answer)
136 if (trailer === undefined) return { isOk: true }
137 return { isOk: trailer.ok !== false, notes: text(trailer.notes)?.slice(0, 4000) }
138}
139
140// ------------------------------------------------------------ blast radius
141
142const DOCS = /(\.(md|mdx|txt|rst)$)|(^|\/)(docs?|specs)\/|(^|\/)(LICENSE|CHANGELOG)[^/]*$/i
143
144export type Scope = { tier: Tier; why: string }
145
146/** Sizes a change from its file list and line count: what the rounds cost follows from it. */
147export function classify(
148 files: readonly string[],
149 changedLines: number,
150 highRisk: RegExp | undefined,
151 /** Files whose change was reviewed elsewhere: a landed submodule pointer, a version-only bump. */
152 settled: readonly string[] = [],
153): Scope {
154 if (files.length === 0) return { tier: 'trivial', why: 'no changes against the base' }
155 const rest = files.filter(file => !settled.includes(file))
156 if (rest.every(file => DOCS.test(file))) {
157 if (rest.length === files.length) {
158 return { tier: 'trivial', why: `docs only (${files.length} file${files.length === 1 ? '' : 's'})` }
159 }
160 const docs = rest.length === 0 ? '' : ` and ${rest.length} doc${rest.length === 1 ? '' : 's'}`
161 return { tier: 'trivial', why: `already reviewed: ${files.filter(file => settled.includes(file)).join(', ')}${docs}` }
162 }
163 const risky = highRisk === undefined ? [] : rest.filter(file => highRisk.test(file))
164 if (risky.length > 0) {
165 const more = risky.length > 3 ? ` +${risky.length - 3}` : ''
166 return { tier: 'high', why: `touches ${risky.slice(0, 3).join(', ')}${more}` }
167 }
168 if (changedLines > 400 || files.length > 25) {
169 return { tier: 'high', why: `${changedLines} lines across ${files.length} file${files.length === 1 ? '' : 's'}` }
170 }
171 return { tier: 'standard', why: `${changedLines} lines across ${files.length} file${files.length === 1 ? '' : 's'}` }
172}
173
174/** Submodule pointers a `git diff --raw` changes: path and the commit it now points at. */
175export function readGitlinks(raw: string): { path: string; sha: string }[] {
176 const out: { path: string; sha: string }[] = []
177 for (const line of raw.split('\n')) {
178 const row = line.match(/^:\d+ 160000 [0-9a-f]+ ([0-9a-f]+) [A-Z]\d*\t(.+)$/)
179 if (row?.[1] !== undefined && row[2] !== undefined && !/^0+$/.test(row[1])) out.push({ path: row[2], sha: row[1] })
180 }
181 return out
182}
183
184/** Whether a `git diff -U0` changes only `"version"` lines: a release bump of package.json and its lock. */
185export function isVersionOnly(diff: string): boolean {
186 const changed = diff.split('\n').filter(line => /^[+-]/.test(line) && !/^(\+\+\+|---) /.test(line))
187 return changed.length > 0 && changed.every(line => /^[+-]\s*"version":\s*"[^"]*",?\s*$/.test(line))
188}
189
190export function compileRisk(source: string): RegExp | undefined {
191 if (source.trim() === '') return undefined
192 try {
193 return new RegExp(source, 'i')
194 } catch {
195 return undefined
196 }
197}
198
199// ------------------------------------------------------------ gh pr create
200
201export type PrCreate = { base: string | undefined; cwd: string | undefined; repo: string | undefined }
202
203/** Reads a shell command for `gh pr create`: its base, the directory it first enters, its `--repo`. */
204export function readPrCreate(command: string): PrCreate | undefined {
205 if (!/\bgh\s+pr\s+create\b/.test(command)) return undefined
206 const flag = (names: string): string | undefined =>
207 command.match(new RegExp(`(?:^|\\s)(?:${names})(?:=|\\s+)["']?([^\\s"']+)`))?.[1]
208 return {
209 base: flag('--base|-B'),
210 repo: flag('--repo|-R'),
211 cwd: command.match(/(?:^|&&|;)\s*cd\s+["']?([^\s"';&]+)/)?.[1],
212 }
213}
214
215/** `owner/repo` from a remote URL or a `--repo` value, lowercased. */
216export function slugOf(remote: string): string | undefined {
217 return remote
218 .trim()
219 .match(/(?:^|[:/])([^/:\s]+\/[^/\s]+?)(?:\.git)?$/)?.[1]
220 ?.toLowerCase()
221}
222
223// ------------------------------------------------------------------ words
224
225export function activityOf(role: Role, tool: string, input: Record<string, unknown>): string {
226 const who =
227 role === 'review' || role === 'challenge' ? 'codex reviewer' : role === 'verify' ? 'fix reviewer' : 'fixer'
228 const command = typeof input.command === 'string' ? input.command : ''
229 if (tool === 'Bash' && /\bcodex\s+(review|exec)\b/.test(command)) {
230 return role === 'challenge' ? 'codex is attacking the diff…' : 'codex is reviewing the diff…'
231 }
232 if (tool === 'Skill') return `${who}: loading /${String(input.skill ?? 'skill')}`
233 const what =
234 tool === 'Bash'
235 ? command.replace(/\s+/g, ' ').slice(0, 60)
236 : String(input.file_path ?? input.pattern ?? input.description ?? '').slice(-60)
237 return `${who}: ${tool}${what === '' ? '' : ` ${what}`}`
238}
239
240const where = (run: Run): string =>
241 `Repository: ${run.root}\nBranch: ${run.branch}\nBase: origin/${run.base} (already fetched)\nHEAD: ${run.headSha}`
242
243/** A step's name: what its agent repeats in its trailer, so its answer is known for this step's. */
244export const stepOf = (run: Pick<Run, 'round'>, role: Role): string => `r${run.round}-${role}`
245
246const trailerRule = (step: string, shape: string): string =>
247 `End your final message with the word ${TRAILER} on its own line, followed by one JSON object and nothing after it. Copy "step" exactly:\n${TRAILER}\n{"step":"${step}",${shape.slice(1)}`
248
249const listFindings = (findings: readonly Finding[]): string =>
250 findings
251 .map(one => {
252 const at = one.file === undefined ? '' : ` (${one.file}${one.line === undefined ? '' : `:${one.line}`})`
253 const detail = one.detail === undefined ? '' : `\n ${one.detail.replace(/\n/g, '\n ')}`
254 const note = one.note === undefined ? '' : `\n ${one.status}${one.note === '' ? '' : `: ${one.note}`}`
255 return `- ${one.id} [${one.severity}] ${one.title}${at}${detail}${note}`
256 })
257 .join('\n')
258
259export function reviewPrompt(run: Run, role: 'review' | 'challenge'): string {
260 // A run started before effort was tracked (a reload mid-run) keeps the old ceiling.
261 run = { ...run, effort: (run.effort as Effort | undefined) ?? 'high' }
262 const rebutted = run.findings.filter(one => one.round === run.round - 1 && one.status === 'rebutted')
263 const mode =
264 role === 'review'
265 ? 'Review mode (Step 2A), including the gate verdict it computes'
266 : 'Challenge mode (Step 2B)'
267 const lines = [
268 `You are the Codex ${role} step of codex-gate, a pre-PR gate. Round ${run.round}.`,
269 where(run),
270 '',
271 `1. Invoke the Skill tool with skill "codex" and args "${role} --effort ${run.effort}", and follow that skill's instructions exactly as written: its preamble, its probes, and its ${mode}.`,
272 ` The base branch is origin/${run.base}: use it wherever the skill says <base>, whatever its own base detection prints.`,
273 ` Reasoning effort is ${run.effort} for every Codex dispatch in this step, consult included. Never pass --xhigh or a higher --effort, and prefix each Codex dispatch with GSTACK_CODEX_EFFORT=${run.effort} so no setting raises it: higher efforts time out here.`,
274 '2. You are read-only. Do not edit files, do not fix anything, do not commit, push or open a pull request.',
275 '3. Do not ask the user anything: nobody is watching this step. Where the skill would ask, take its documented default and say so.',
276 ]
277 if (role === 'review') {
278 lines.push(
279 "4. Report every finding Codex tagged [P0] to [P3], with Codex's own severity and title. Do not downgrade, merge or drop a finding.",
280 " gate is the skill's verdict: PASS, FAIL or UNVERIFIED. If Codex did not complete (non-zero exit, timeout, empty output, auth), gate is ERROR, error is timeout when Codex timed out or stalled and other otherwise, and note says why.",
281 )
282 } else {
283 lines.push(
284 '4. Challenge mode output carries no severity tags. Read the code for each problem Codex raised, then report it as P1 only when it is a concrete, reachable failure or security hole in code this branch changes; report the rest as P2. gate is FAIL when any P1 stands, else PASS. If Codex did not complete, gate is ERROR, and error is timeout when Codex timed out or stalled, other otherwise.',
285 )
286 }
287 if (rebutted.length > 0 && role === 'review') {
288 lines.push(
289 '',
290 'The fixer rebutted these findings from the previous round instead of changing code:',
291 listFindings(rebutted),
292 "After the review, put each rebuttal to Codex through the same skill's consult mode (one prompt listing them all): does Codex concede it, or hold the finding? Report every finding Codex holds, with its original severity, even when the fresh review did not raise it again. Leave out the ones Codex concedes.",
293 )
294 }
295 lines.push(
296 '',
297 trailerRule(
298 stepOf(run, role),
299 '{"gate":"PASS|FAIL|UNVERIFIED|ERROR","findings":[{"severity":"P1","title":"...","file":"path","line":1,"detail":"one or two sentences"}],"error":"only with ERROR: timeout|other","note":"optional"}',
300 ),
301 )
302 return lines.join('\n')
303}
304
305/** The advisory round's words: no high/critical finding is left, and Codex's other findings are put to the fixer. */
306function advisoryPrompt(run: Run, isMain: boolean): string {
307 const advisory = run.findings.filter(one => one.round === run.round && !isBlocking(one))
308 const lines = [
309 `codex-gate, advisory round ${run.advisoryRound} of ${run.advisoryMax}: Codex left no high/critical finding on ${run.branch} against origin/${run.base}, and raised ${advisory.length} advisory finding${advisory.length === 1 ? '' : 's'} (P2/P3). They do not block the PR; address each one before it opens.`,
310 where(run),
311 '',
312 'Advisory (P2/P3):',
313 listFindings(advisory),
314 '',
315 'What to do:',
316 '1. Invoke the superpowers:receiving-code-review skill first, if it is available. Verify every finding against the code before acting on it.',
317 '2. Give every finding one answer. Fix it when it is real and the fix is small and inside this branch. Rebut it with file:line evidence when it is wrong. Defer it, with the reason, when it is real but belongs in other work.',
318 '3. Run the checks that cover what you touched, then commit your fixes on this branch. Codex reviews the new commits, so keep them small: a fix that adds a high/critical finding costs a blocking round.',
319 '4. Do not push and do not open a pull request: the gate does that when the rounds are over.',
320 '',
321 trailerRule(
322 stepOf(run, 'fix'),
323 '{"dispositions":[{"id":"R1-1","action":"fixed|rebutted|deferred","note":"what changed, the evidence, or why it waits"}]}',
324 ),
325 ]
326 if (isMain) {
327 lines.push('', 'Then end your turn. The gate picks up from there: Codex looks again when you changed code or rebutted a finding, and the PR opens after that.')
328 }
329 return lines.join('\n')
330}
331
332export function fixPrompt(run: Run, notes: string | undefined, isMain: boolean): string {
333 if (run.fixKind === 'advisory') return advisoryPrompt(run, isMain)
334 const current = run.findings.filter(one => one.round === run.round)
335 const blocking = current.filter(isBlocking)
336 const advisory = current.filter(one => !isBlocking(one))
337 const lines = [
338 `codex-gate, round ${run.round}: Codex found ${blocking.length} high/critical finding${blocking.length === 1 ? '' : 's'} on ${run.branch} against origin/${run.base}. The PR stays unopened until they are settled.`,
339 where(run),
340 '',
341 'Blocking (P0/P1):',
342 listFindings(blocking),
343 ]
344 if (advisory.length > 0) {
345 lines.push('', 'Advisory (P2/P3), fix only where it is cheap and in scope:', listFindings(advisory))
346 }
347 if (notes !== undefined) lines.push('', 'A reviewer of your previous fix says it is not done:', notes)
348 lines.push(
349 '',
350 'What to do:',
351 '1. Invoke the superpowers:receiving-code-review skill first, if it is available. Verify every finding against the code before acting on it.',
352 '2. A real finding: fix it with the smallest change that settles it, with a test where one fits. A false positive: rebut it with file:line evidence, and change nothing for it.',
353 "3. Weigh the blast radius. If the honest fix reaches past this branch's scope (a schema change, a public contract, another service), mark the finding deferred and say what decision it needs; do not widen the change on your own.",
354 '4. Run the checks that cover what you touched, then commit your fixes on this branch. The next Codex round reads committed code only.',
355 '5. Do not push and do not open a pull request: the gate does that when the rounds are over.',
356 '',
357 trailerRule(
358 stepOf(run, 'fix'),
359 '{"dispositions":[{"id":"R1-1","action":"fixed|rebutted|deferred","note":"what changed, or the evidence"}]}',
360 ),
361 )
362 if (isMain) {
363 lines.push('', 'Then end your turn. The gate picks up from there and starts the next Codex round by itself.')
364 }
365 return lines.join('\n')
366}
367
368export function verifyPrompt(run: Run, fromSha: string): string {
369 const current = run.findings.filter(one => one.round === run.round && isBlocking(one))
370 return [
371 `You review a fix for codex-gate, a pre-PR gate. Round ${run.round}.`,
372 where(run),
373 '',
374 `Codex raised these high/critical findings, and the fixer answered each (\`git diff ${fromSha}..HEAD\` is what it changed):`,
375 listFindings(current),
376 '',
377 'For each one: does the committed code now settle it, or does the rebuttal hold against the code? Look for a fix that only moves the problem, a missing test, and anything the fix broke. You are read-only: do not edit, commit or push.',
378 '',
379 trailerRule(stepOf(run, 'verify'), '{"ok":true,"notes":"when ok is false: what is still wrong, by finding id"}'),
380 ].join('\n')
381}
382
383export function prPrompt(run: Run, hasConverged: boolean): string {
384 const blocking = run.findings.filter(one => one.round === run.round && isBlocking(one)).length
385 const total = run.findings.length
386 const left = run.findings.filter(one => one.round === run.round && !isBlocking(one) && one.status !== 'fixed').length
387 const advisory =
388 run.advisoryRound === 0 ? '' : ` ${run.advisoryRound} advisory round${run.advisoryRound === 1 ? '' : 's'} run, ${left} advisory finding${left === 1 ? '' : 's'} not fixed.`
389 const summary =
390 run.tier === 'trivial'
391 ? `codex-gate skipped Codex for ${run.branch}: ${run.tierWhy}.`
392 : hasConverged
393 ? `codex-gate passed for ${run.branch} at ${run.headSha.slice(0, 7)}: no high/critical finding left after ${run.round} round${run.round === 1 ? '' : 's'} (${total} finding${total === 1 ? '' : 's'} raised in all).${advisory}`
394 : `codex-gate did not converge for ${run.branch}: ${blocking} high/critical finding${blocking === 1 ? '' : 's'} still open after ${run.round} rounds.`
395 const lines = [
396 summary,
397 '',
398 `Now open the pull request to ${run.base}, following this repository's own rules for one (its pre-PR checks and preflight, the title and body conventions, the push).`,
399 'Add one line to the body under a "Pre-PR Codex gate" heading: the rounds run and the counts above.',
400 `The gate's verdict is for commit ${run.headSha.slice(0, 7)}. A docs-only commit on top (a CHANGELOG line) still rides on it; any other new commit, or a rebase, holds the pull request until the gate has run again, so do not add one unless a check requires it.`,
401 ]
402 lines.push(
403 '',
404 hasConverged
405 ? 'This gate is one early review, not the last one. Once the pull request is open, run the whole pull-request lifecycle as this repository does it: wait for every reviewer and every check, the ones that are not required included, read each review in full, and fix or answer what they raise. A clean gate is no reason to skip, shorten or dismiss any of them.'
406 : 'The rounds ran out, so the review is not finished: it carries on in the pull request. Once it is open, run the whole pull-request lifecycle as this repository does it: wait for every reviewer and every check, the ones that are not required included, read each review in full, and keep fixing, starting with the findings still open below.',
407 )
408 if (run.findings.length > 0) {
409 lines.push(
410 '',
411 'Everything Codex raised, and what became of each:',
412 listFindings(run.findings.map(one => (one.note === undefined && one.status !== 'open' ? { ...one, note: '' } : one))),
413 '',
414 'Tell the user about these here in the conversation, the ones still open first, each with why it is open. The pull request gets the counts only: do not paste a finding into it.',
415 )
416 }
417 if (!hasConverged) {
418 lines.push('In the pull request say only that the gate did not converge, with the count.')
419 }
420 return lines.join('\n')
421}
422types/index.d.ts 109 lines1export type Severity = 'P0' | 'P1' | 'P2' | 'P3'
2
3export type FindingStatus = 'open' | 'fixed' | 'rebutted' | 'deferred'
4
5export type Finding = {
6 id: string
7 round: number
8 source: 'review' | 'challenge'
9 severity: Severity
10 title: string
11 file?: string
12 line?: number
13 detail?: string
14 status: FindingStatus
15 note?: string
16}
17
18export type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
19
20export type Step = { id: string; label: string; state: StepState; detail?: string }
21
22export type Role = 'review' | 'challenge' | 'fix' | 'verify'
23
24export type Pending = {
25 role: Role
26 /** The step's name, which the agent repeats in its trailer. */
27 step: string
28 /** A sub-agent's id, as the engine gave it. */
29 agentId?: string
30 /** True for the main agent, which has no id. */
31 isMain?: true
32 /** Main agent only: true once its turn has started. */
33 isArmed?: boolean
34 answer?: string
35}
36
37export type RunStatus =
38 | 'running'
39 | 'passed'
40 | 'exhausted'
41 | 'blocked'
42 | 'error'
43 | 'stopped'
44 | 'waived'
45
46export type Tier = 'trivial' | 'standard' | 'high'
47
48/** Codex reasoning effort. Never xhigh: those reviews timed out. */
49export type Effort = 'high' | 'medium' | 'low'
50
51/** full: the whole gate. light: a branch named by `lightBranches` (chore/), one round and no advisories. */
52export type Lane = 'full' | 'light'
53
54export type Run = {
55 id: string
56 cwd: string
57 root: string
58 branch: string
59 base: string
60 headSha: string
61 tier: Tier
62 tierWhy: string
63 lane: Lane
64 /** The effort Codex runs at now; a timeout steps it down and reruns the round. */
65 effort: Effort
66 /** The effort this branch's tier and lane call for, before any carried or timeout step-down. */
67 startEffort?: Effort
68 round: number
69 maxRounds: number
70 status: RunStatus
71 phase: 'scope' | 'review' | 'fix' | 'verify' | 'done'
72 hasRefixed: boolean
73 /** What the fix step in flight (or the last one) was sent: blocking findings, or advisories alone. */
74 fixKind: 'blocking' | 'advisory'
75 /** Advisory rounds started so far; they do not count against maxRounds. */
76 advisoryRound: number
77 advisoryMin: number
78 advisoryMax: number
79 steps: Step[]
80 findings: Finding[]
81 pending: Pending[]
82 activity: string
83 startedAt: number
84 phaseStartedAt: number
85 endedAt?: number
86 message?: string
87}
88
89export type PassRecord = {
90 sha: string
91 base: string
92 status: RunStatus
93 at: number
94 rounds: number
95 note?: string
96 /** The effort Codex last ran at for this branch. */
97 effort?: Effort
98 /** The run ended because Codex timed out at its lowest effort, or on the step timeout. */
99 hasTimedOut?: boolean
100 /** The effort a review last completed at, kept while it is below the branch's default; the next run starts no higher. */
101 passedEffort?: Effort
102}
103
104declare module 'claude-code' {
105 interface PluginState {
106 'codex-gate': { run: Run | null; tick: number }
107 }
108}
109