SLOPSHOPPER

codex-gate

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.

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-gate
│ ┃ codex-gate ✕ › fix the failing auth test and add an audit log call │ ┃ Codex gate │ ┃ Not run yet. /pr-gate starts it; opening a ⏺ Read(src/auth.ts) │ ┃ PR to a gated base starts it too. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr-gate │ ⎿ codex-gate: codex-gate: Not started: there are uncommitted chang │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · codex-gate
Codex gate Not run yet. /pr-gate starts it; opening a PR to a gated base starts it too.
README

codex-gate

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.

What happens

  1. An agent runs 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.
  2. Scope. The diff against 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).
  3. Review. A sub-agent invokes the 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.
  4. Fix. P0/P1 findings go to the main agent (or a sub-agent, per fixer), which verifies each against the code, fixes or rebuts it, and commits. On a high change another sub-agent reviews the fix first.
  5. Again. Codex reviews the new commits; rebuttals are put to Codex through the skill's consult mode. This repeats until no P0/P1 finding is left, or maxRounds (5) is reached; then the PR is opened either way.
  6. Advisories. Once nothing blocks, whatever else Codex raised (P2/P3) goes to the fixer, who fixes, rebuts or defers each one. New commits go back to Codex, and so does a rebuttal until round 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.
  7. PR. The main agent is prompted to push and open the pull request by the repository's own rules, and is given every finding Codex raised with what became of each, to report in the conversation. The held 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.

What counts as already reviewed

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:

  • a doc (*.md, docs/, specs/, CHANGELOG*, LICENSE*);
  • a submodule pointer whose new commit is already on that submodule's 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;
  • a 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.

The light lane (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.

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.

The pane

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.

Commands

/pr-gate [base]start the gate for the current branch
/pr-gate statusone line of where it stands, and open the pane
/pr-gate stopstop a running gate
/pr-gate waive <reason>let this commit's PR through without the gate; the person's alone

Options

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.

Needs

The gstack codex skill and an authenticated codex CLI. The superpowers:receiving-code-review skill is used by the fixer when present.

Running it

claude --plugin-dir /path/to/codex-gate

or name the folder in CLAUDE_CODE_PLUGIN_DIRS. Checks: claude plugin validate . and claude plugin test .

Source 3 files
hooks/register.tsx 1025 lines
1// 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}
1025
hooks/core.ts 422 lines
1// 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}
422
types/index.d.ts 109 lines
1export 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