AICR Cross-Review: Multi-Agent PR Review with Consensus
Three reviewers (Claude Code, Codex, CodeRabbit) plus a targeted integration impact
analysis, cross-reviewed to 2-of-3 consensus, with every confirmed finding
adversarially verified by a fresh agent. Orchestration runs as a Workflow
(scripts/workflow.mjs).
Claude Code only. If the Workflow tool is unavailable, stop and say why — do not
fall back to another review command. /code-review in particular posts its result to
the PR (see Phase 2), which this skill never does without an explicit request. Named
aicr-cross-review so it does not shadow a contributor's global cross-review skill.
When in doubt, stop. Every check below either passes or ends the review with an
explanation. The skill never executes the reviewed commit's code, and never posts to
the PR unless you explicitly ask (Phase 5).
Input
Raw arguments: $ARGUMENTS
$ARGUMENTS must be a PR number or a URL; both are normalized in Phase 0. There is no
no-argument mode: a fork PR cannot be found from the local branch name alone, since the
branch lives on the contributor's fork while the PR lives on NVIDIA/aicr. Stop and ask
for a PR reference rather than guessing. Do not write a parser; gh accepts both forms.
Phase 0: Pre-flight
Only the required lanes are hard requirements. Claude, Codex and integration analysis
must work — if one fails at runtime the review reports incomplete and stops.
CodeRabbit is best-effort: a missing or unauthenticated CLI is not an error, its vote
slot just records NONE.
for tool in gh git; do
which "$tool" >/dev/null || { echo "$tool not found — install it and retry."; exit 1; }
done
ls ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs >/dev/null 2>&1 \
|| { echo "Codex companion not found. Install the Codex plugin (Settings → Extensions → Codex)."; exit 1; }
echo "Pre-flight OK."
If either check fails, stop and report which tool is missing. Do not fall back to
another review command.
Resolve the PR number — before anything else needs it.
Every gh call and the ref fetch are scoped to NVIDIA/aicr literally, written out
in each command. Two reasons: in GitHub's standard fork layout the local repository is
the contributor's fork, which has neither the PR nor refs/pull/*; and a shell variable
would not survive anyway, since each Bash call is a fresh shell.
# $ARGUMENTS must be a PR number or URL — gh accepts either.
test -n "$ARGUMENTS" || { echo "usage: /aicr-cross-review <PR-number-or-URL>"; exit 1; }
gh pr view "$ARGUMENTS" --repo NVIDIA/aicr \
--json number,title,body,baseRefName,headRefName,headRefOid,files
Take <n> = .number and use that numeric value for every later temp path, scoped ref
name and gh call — never the raw argument. Keep the rest of the response; Phase 1 does
not re-fetch it.
Self-review guard. From the files list just fetched: if any changed path is under
.agents/skills/aicr-cross-review/, stop — the scripts you would execute are the
ones under review. Ask for a trusted checkout. This catches the accidental case only;
SKILL.md lives inside the reviewed repo, so it is not a security boundary.
Phase 1: Setup
Batch A — one parallel message:
From the Phase 0 response, pin HEAD_SHA = headRefOid. Every reviewer reviews
this exact commit. <n> is already resolved in Phase 0; do not re-fetch.
Worktree hygiene: git worktree prune, then git worktree list | wc -l. If the
count still exceeds ~15, stop and ask the user to clean up before retrying.
Do not remove worktrees yourself — a clean detached-HEAD worktree may be another
session's active review. (Each worktree adds sandbox deny-list paths; at ~70 the
profile exceeded the OS spawn-arg limit and every sandboxed Bash call failed with
E2BIG. Recovery needs a fresh session.)
Reap dead runs' pinned inputs. A session killed between Batch B and Phase 5 leaks
its two refs/cr/* and its temp diff file permanently — nothing else reclaims
them, and they accumulate in the same slow way the worktrees above do.
RUNS="$(git -C "<repo-path>" rev-parse --path-format=absolute --git-common-dir)/cr-runs"
find "$RUNS" -maxdepth 1 -type f -mmin +1440 -delete 2>/dev/null || true
git -C "<repo-path>" for-each-ref --format='%(refname)' 'refs/cr/pr*' 'refs/cr/base*' |
while read -r REF; do
KEY=${REF#refs/cr/} # want <n>-<SID>
case "$KEY" in pr*) KEY=${KEY#pr};; base*) KEY=${KEY#base};; esac
case "$KEY" in *-*) ;; *) continue;; esac # must have both components
case "${KEY%-*}" in ''|*[!0-9]*) continue;; esac # <n> is a PR number
case "${KEY##*-}" in ??????) ;; *) continue;; esac # <SID> is mktemp's six chars
[ -e "$RUNS/$KEY" ] || git -C "<repo-path>" update-ref -d "$REF"
done
find "${TMPDIR:-/tmp}" -maxdepth 1 -type f -name 'cross-review-pr*.??????' -mmin +1440 -delete 2>/dev/null || true
Liveness comes from the per-run marker Batch B drops in cr-runs/, not from the
ref and not from the diff file. Both alternatives are broken:
- Not the ref.
git gc packs refs/cr/* into packed-refs, after which the
per-ref file under .git/refs/cr/ no longer exists and an mtime gate silently
stops reaping — and git fetch, which Batch B runs, triggers gc --auto. The
two substitutes fail too: refs/cr/* has no reflog (core.logAllRefUpdates
covers only refs/heads, refs/remotes, refs/notes, and HEAD), and
%(creatordate) is the commit's date, so a ref created a minute ago on
yesterday's main reports "21 hours ago".
- Not the diff file.
TMPDIR is not stable across sessions, or even within
one: under Claude Code's sandbox it is /tmp/claude-<uid>, and with the sandbox
bypassed it is the shell default (/var/folders/…/T/ on macOS). A reaper that
tested for the diff file would miss a live session's file whenever the two
disagree and delete that session's pinned refs — the one thing this skill must
never do.
cr-runs/ sits next to the refs it guards, under the common git dir, so every
worktree of a clone shares one view, exactly as refs/cr/* are shared. Git ignores
unknown entries there, so nothing packs or prunes it and the marker keeps a real
creation timestamp. The last find is only a temp-file janitor: it reclaims diff
files in whatever TMPDIR this session sees, and reclaiming none is harmless.
The three case guards are the safety boundary, and all three are load-bearing.
A candidate must have both components, a numeric <n>, and a six-character <SID>
before it can be deleted. Prefix-stripping alone is not enough: refs/cr/pr* also
matches refs/cr/private-ABC123, which strips to ivate-ABC123 and would pass a
suffix-only check. Hand-made bookmarks (refs/cr/2183-r5, refs/cr/2187-test) are
deliberate, often pin active work, and must survive — the only names that now
collide are a literal pr<digits>-<six characters> or base<digits>-<six characters>, since the loop accepts both prefixes, so do not use either shape
for one. find … -delete rather than rm for the same reason as everywhere else in
this skill: managed permission policies gate rm:* behind a prompt. The janitor
is -type f so a directory that happens to match the diff-file pattern is never
removed.
The marker key is <n>-<SID>, never <SID> alone. mktemp guarantees the
full filename it returns is unique; it does not reserve the suffix. Two concurrent
reviews of different PRs pass different templates, so both can be handed the same
six characters — their refs stay distinct, but a <SID>-keyed marker would be one
shared file, and whichever run finished first would delete the other's protection.
Reviews of the same PR are safe whenever they share a TMPDIR, since mktemp
guarantees distinct names within one directory. It guarantees nothing across
directories, so same-PR runs under different TMPDIR roots can still collide —
but on $SID itself, which makes PRREF and BASEREF collide first. That is a
property of how Batch B derives $SID, not of this reaper, and is left to a
follow-up.
The gate is 24 hours, and it must stay far above any real run. Nothing enforces
an end-to-end limit on a review: Codex gets a five-wait, ~45-minute budget in the
Review phase and again in Cross-review, with Verify on top. A gate near the
expected duration would let a later Phase 1 reap a live run's marker, then its
refs, and the temp-file janitor would take its DIFFPATH with them — the run would
destroy itself. The gate measures age since Batch B stamped the marker, not
inactivity — the marker is written once and never refreshed — so a run still
alive a day later is outside every documented budget and is treated as dead. The
temp-file janitor is gated independently, on each diff file's own mtime, so
refreshing the marker would not cover DIFFPATH either way. Since this reaper
exists for leaks that accumulate over days, waiting a day to collect one costs
nothing. Do not tune it down toward the expected runtime.
Batch B — after A (needs HEAD_SHA and baseRefName). gh pr diff takes no
SHA argument, so pin the diff with git fetch. Refs and the diff file are
session-scoped: two sessions reviewing the same PR must not share, overwrite, or
delete each other's pinned input.
set -euo pipefail # a failed fetch or diff must abort, not leave an empty diff file
BASE="<baseRefName>" # from step 1 — never hardcode "main"
DIFFPATH=$(mktemp "${TMPDIR:-/tmp}/cross-review-pr<n>.XXXXXX") # must end in X on macOS
SID=${DIFFPATH##*.} # reuse mktemp's unique suffix to scope the refs
PRREF="refs/cr/pr<n>-$SID"; BASEREF="refs/cr/base<n>-$SID"
# Liveness marker for Batch A step 3's reaper, written BEFORE the refs exist so no
# concurrent reaper can ever see a ref without its marker. Keyed by <n>-$SID — mktemp
# guarantees the full filename is unique, not the suffix, so a $SID-only key would
# collide with a concurrent review of a DIFFERENT PR. Under the common git dir, so
# every worktree of this clone shares one view.
RUNS="$(git -C "<repo-path>" rev-parse --path-format=absolute --git-common-dir)/cr-runs"
RUNMARK="$RUNS/<n>-$SID"; mkdir -p "$RUNS"; : > "$RUNMARK"
# Fetch from the canonical repo by URL, not from `origin`: in GitHub's standard fork
# layout `origin` is the contributor's fork, and refs/pull/* exist only on the canonical
# repository.
git -C "<repo-path>" fetch "https://github.com/NVIDIA/aicr.git" \
"+refs/pull/<n>/head:$PRREF" "+refs/heads/$BASE:$BASEREF"
# Head moved → stop. Clean the refs we just created before exiting; set -e would
# otherwise abort before the names are ever printed, leaving them unreclaimable.
if [ "$(git -C "<repo-path>" rev-parse "$PRREF")" != "<HEAD_SHA>" ]; then
git -C "<repo-path>" update-ref -d "$PRREF"; git -C "<repo-path>" update-ref -d "$BASEREF"
find "$DIFFPATH" "$RUNMARK" -maxdepth 0 -delete
echo "HEAD moved since setup — restart the review"; exit 1
fi
# Echo the names FIRST: under `set -e` an empty or failing diff aborts, and any
# echo below it would never run — leaking the refs and the temp file with a random
# suffix nobody recorded, which Phase 5 then cannot clean up.
echo "DIFFPATH=$DIFFPATH"; echo "PRREF=$PRREF"; echo "BASEREF=$BASEREF"; echo "RUNMARK=$RUNMARK"
git -C "<repo-path>" diff "$BASEREF...$PRREF" > "$DIFFPATH"
test -s "$DIFFPATH" # a real PR diff is never empty
# repoNotes source, pinned to the BASE ref — a fork PR must not be able to rewrite
# the instructions fed to the reviewer. Absent on some repos; that is fine.
git -C "<repo-path>" show "$BASEREF":.claude/CLAUDE.md 2>/dev/null || echo "(no tracked CLAUDE.md)"
# BASE_SHA is the base branch tip. Its only consumer is CodeRabbit's --base-commit,
# and the CLI resolves the merge-base itself, so this stays consistent with the
# three-dot diff above without a second baseline to keep in sync.
echo "BASE_SHA=$(git -C "<repo-path>" rev-parse "$BASEREF")"
Capture DIFFPATH, BASE_SHA, PRREF, BASEREF, RUNMARK — shell variables do not persist
between Bash calls and Phase 5 needs the ref names.
Then build repoNotes for the Claude reviewer only (never fed to Codex — lean-context
rule): distill the base-pinned CLAUDE.md plus the local overlay into 3–6 lines of the
rules most likely to catch defects in the changed paths.
The check below reduces accidental exposure, but it is not a trust boundary:
reviewer subagents load the checkout's CLAUDE.md hierarchy automatically, before any
guard here runs. Treat repoNotes as a relevance digest, not a sanitiser.
For an untrusted or fork PR, run this skill from a session started in a trusted
checkout — the same operational remedy as the self-review guard in Phase 0. Git
overwrites ignored files during checkout without complaint, so checking out a fork
that force-added an ignored overlay silently replaces yours.
for f in AGENTS.local.md CLAUDE.local.md; do
[ -e "<repo-path>/$f" ] || continue
# Skip symlinks first. The tracked-status check applies to the link, not its target,
# so an untracked symlink pointing at a PR-tracked file would otherwise be reported
# TRUSTED while resolving to PR-controlled instructions.
[ -L "<repo-path>/$f" ] && { echo "SKIP $f — symlink"; continue; }
if git -C "<repo-path>" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then
echo "SKIP $f — tracked by this PR, not a trusted local overlay"
else
echo "TRUSTED $f" # regular untracked file: safe to read
fi
done
Read only the paths reported TRUSTED. AGENTS.local.md is normally a symlink to
CLAUDE.local.md, so it is skipped and the overlay is read through the real file —
no content is lost.
Verify the workflow script version before Phase 2. The script about to be passed as
scriptPath must contain the sentinel identifier codexResumeJobId:
grep -c codexResumeJobId "<skill-dir>/scripts/workflow.mjs" — expect a non-zero count.
If it is absent, STOP: the file is a stale or reverted copy, and running it silently
restores the old semantics (observed live: a concurrent session's git operation reverted
uncommitted skill files in a shared checkout, and a full review round ran the old script
unnoticed). The sentinel detects staleness relative to this revision only — if that
identifier is ever renamed, update this check in the same change.
Phase 1.5: Classify and extract the change list
Classify the PR: code-change | adr | config-change | documentation-only.
Extract a bounded change list so integration analysis verifies specific items
instead of fishing across the repo:
- Exported functions/types/constants added, removed, or modified
- Config keys added or changed (
.yaml, .toml, .json)
- Workflow inputs/triggers added or changed
- File/manifest paths renamed or restructured
- Behaviorally significant defaults changed (timeouts, versions, namespaces)
This skill never runs the PR's code. No build, test, or coverage step; every
reviewer prompt forbids it. Only trusted tools run (git, gh, the CodeRabbit CLI,
the Codex companion). Coverage is CI's job — see Phase 3.
Phase 2: Run the review workflow
Workflow({
scriptPath: "<skill-dir>/scripts/workflow.mjs",
args: {
pr: <number>,
repo: "<owner>/<name>",
repoPath: "<local checkout path>",
headSha: "<HEAD_SHA>",
baseSha: "<BASE_SHA>",
diffPath: "<DIFFPATH>",
prType: "<classification>",
changeList: ["<item 1>", "<item 2>"],
repoNotes: "<3-6 line digest, optional>"
}
})
Pass changeList as a real JSON array, not a stringified one. Every lane is
general-purpose and inherits the session model, so there is no model argument to
pass.
What the workflow does (scripts/workflow.mjs is the single source of truth for
the consensus mechanics):
Review — Claude Code (reviews the pinned diff directly; it deliberately does
not delegate to the code-review command, whose step 8 instructs its agent to
gh pr comment the result back to the PR), Codex (two chained agents: a dispatch
agent starts the remote background job and hands back its id, which the workflow
immediately writes to the progress log — Codex job <id> dispatched — review running remotely — then a wait agent runs a 9-min
bounded wait plus up to four continuation waits when the job is still running — about 45 min
for a live job), CodeRabbit (CLI against a detached worktree at HEAD_SHA, explicit
600000 ms timeout — the Bash tool caps any single call at 10 minutes, which is why
Codex exceeds it by waiting across several calls rather than waiting longer), and integration
analysis (bounded to changeList). Every lane is a
general-purpose agent. All
parallel, schema-validated, and none may execute the reviewed commit's code.
Merge — dedupe by path:line:normalized-summary:consumerPath:consumerLine;
duplicates merge to the highest severity and union their sources; a finding citing a file
the reporter never listed in filesChecked is flagged for extra scrutiny.
Two lanes wording one defect differently stay separate candidates, by design. Keying
on location alone was tried and reverted: it did merge those duplicates, but the
evaluation schema permits exactly one verdict per candidate id, so a merged pair of
distinct same-line defects has no correct verdict — confirming the real one also
confirms the false one, and refuting the false one dismisses the real one. Retaining both
summaries prevented data loss but not mis-adjudication, which is the worse failure.
Instead, candidates sharing a location — path:line and the same
consumerPath/consumerLine — are flagged as possible duplicates. The consumer half
matters: one changed declaration breaking two callers is deliberately two candidates, and
hinting that they might be duplicates would push reviewers to collapse a distinction the
key exists to preserve. The flag
reaches the cross-review candidate list and the refuter prompt, so reviewers decide
whether the two are one defect and evaluate them consistently. Equivalence stays an
explicit judgement rather than an assumption from a shared line number.
Merging also stops once candidates are presented: a late finding that merged into an
already-evaluated id would inherit votes cast before it existed. Late findings always
become their own candidate and, being unpresented, stay contested for the human.
Cross-review (one round, Claude + Codex only) — each re-reviews independently
first (anti-anchoring), then returns AGREE/DISAGREE/OPEN_QUESTION per candidate.
CodeRabbit does not take part: its CLI is a slow blocking cloud call and it
reviews Git changes generically, so it cannot adjudicate our candidate ids and a
second run over the same commit adds no signal. Its round-1 findings still stand as its AGREE votes, so
it can still corroborate a split it independently reported. Anything still split
afterwards is reported as contested for you to settle.
Consensus rule — confirmed = 2 of the 3 reviewer slots AGREE with evidence;
integration analysis is never a reviewer slot. A round-1 finding whose evidence is
blank or whitespace-only is dropped at intake, so it never registers its reporter as
a source. In the cross-review round an unevidenced AGREE/DISAGREE instead aborts the
run (incomplete) — dropping it would leave the reviewer's round-1 source vote to
decide the tally.
Verify — every confirmed finding goes to a fresh adversarial refuter
(REFUTED → dismissed; UNVERIFIABLE, no result, or a verdict without a citation →
the unresolved array). consensusReached is true only when both contested and
unresolved are empty — a finding that reached consensus but failed verification is
an open question, not a settled one.
Read adjudication on every contested entry. The bucket holds two different states
and they need different things from you. evaluated means the finding was presented,
the reviewers voted, and they did not reach 2-of-3 — a genuine split, so break the tie.
raised-late means it was raised during the cross-review round, after candidates were
presented, so nobody cross-evaluated it and its only position is its reporter's — it just
needs reading. Measured on a real run: 8 of 8 contested findings were raised-late, each
with a single AGREE and NONE elsewhere, so the count read as eight disagreements when
there were none. consensusReached counts both, deliberately: a late finding is
unadjudicated, and letting it report consensus would be the same overstatement this
skill exists to avoid.
Report incomplete and stop — Claude, Codex and integration analysis are required
in round 1, and Claude and Codex must each return exactly one evaluation per
candidate in the cross-review round. A missing lane, a missing evaluation, a
duplicate, or an unknown candidate id returns status: "incomplete" with the reason
and raw unverified findings. There is no degraded-consensus mode. CodeRabbit is the
only best-effort lane: when it does not run, its vote slot records NONE, which
raises the bar (Claude and Codex must then agree) rather than lowering it.
One deliberate exception, at the level of a finding rather than a lane. An
integration finding claims a specific consumer breaks, so one lacking
consumerPath/consumerLine cannot be verified as an integration claim — but if it
still locates a defect (its own path/line) with evidence, it is a perfectly reviewable
ordinary finding. It is therefore demoted — consumer fields stripped, flagged in the
candidate list, excluded from the integration severity escalation — rather than dropped,
with a log() naming what was demoted. Dropping was tried twice and cost a whole run
each time: first when the lane returned several findings and one legitimately
consumer-less observation failed the run; then, after per-finding dropping replaced
that, when the lane's ONLY finding was such an observation and the zero-survivor rule
stopped the run with all four lanes ok and no report produced (observed on PR 2097).
The run still stops when every integration finding lacks even a locatable defect or
evidence — that is the case the fail-closed rule exists for: silently dropping the
lane's only finding once yielded consensusReached: true while a required lane had
contributed nothing.
"Contributed nothing" is measured on what survives intake(): a demoted finding passes
through the same coordinate and evidence gates as every other candidate, so a
consumer-less, whitespace-evidence finding cannot slip through demotion into a
false-clean. One rule covers the stop: a non-empty integration result that yields no
accepted finding — neither as an integration claim nor as a demoted ordinary finding —
stops the run, and the message says how many went for each reason.
Coordinates are validated by a single shared rule, hasCoords, applied to a
finding's own path/line in intake() — every lane, not just integration — and to
consumerPath/consumerLine for the integration pair. The response schema requires
path and line and leaves consumerPath/consumerLine optional — deliberately, since
only integration findings carry a consumer — but it constrains none of the four, so
"", " ", 0 and -1 all satisfy it and all passed a truthiness/null check.
Tightening the schema instead would fail a whole lane on one bad field, which is the
all-or-nothing behavior this section exists to remove. It is one helper rather than two
call-site conditions for a specific reason: every earlier version of this guard fixed the
pair it was shown and left the other, and a shared rule is what stops the next field pair
from repeating that.
The zero-survivor rule applies to every required round-1 batch, not just integration.
Claude's and Codex's round-1 findings go through intakeBatch, which reports what
survived. A required lane whose round-1 findings are all malformed contributed
nothing, and letting them vanish silently is the same false-clean one lane over. Mixed
batches still proceed. CodeRabbit is exempt: a total loss there records NONE and
raises the bar, exactly as a lane that never ran. Rejection reasons are counted
separately (unlocatable vs unevidenced) rather than inferred from a subtraction, so
the message names the defect the reader should go looking for.
It deliberately does NOT apply to cross-review newFindings. Those go through the
same intakeBatch gate — a malformed late finding still never enters the tally — but a
total loss there is not fatal. The rule tests "this lane contributed nothing", which
round 1 can assert because findings are the lane's whole output. In the cross-review
round the lane's output is its evaluations, and the completeness gate has already
returned incomplete unless the lane evaluated every presented candidate; newFindings
are supplementary and volunteered. Aborting there would throw away a full set of
adjudications and the verification round over one imprecise extra finding — the same
disproportionate total loss seen on PR 1908, one round over. Malformed late findings are
dropped individually and each drop is logged with its reason.
Operational notes:
The workflow runs in the background — wait for its completion notification.
If it dies mid-run, resume, don't restart:
Workflow({scriptPath: ..., resumeFromRunId: "<wf_...>"}) — completed lanes replay
from cache. Empty or odd result → read <transcriptDir>/journal.jsonl first.
The Codex round-1 lane is two agents, deliberately. A dispatch agent composes the
lean Codex task, starts the background job (and owns the fast-transient retry-once
rule, decided inside a brief ~90-second launch watch that exactly covers the
under-60s retry window), and returns {jobId, dispatchNote}; the workflow then logs
Codex job <id> dispatched — review running remotely and hands the id to a wait
agent that runs the continuation-wait protocol unchanged and translates the result.
The split exists for progress visibility: a single opaque agent call shows "running"
from spawn, which cannot distinguish "remote job dispatched and working" from
"dispatch never happened" — a real run sat silent for 19 minutes with no way to tell
which. The logged job id is the visible "started" signal, and it doubles as the
recovery handle when everything after dispatch dies: a dispatch-agent failure or a
wait-agent loss surfaces exactly like any Codex-lane unavailability (incomplete,
with codexJobId whenever a live job id exists). The wait agent never dispatches.
The Codex lane fails in three distinct ways, and the dispatch and wait protocols
treat them differently:
- Lookup miss — the status call exits 1 with empty stdout and
No job found on
stderr. Companion state is keyed by workspace root and each Bash call is a fresh
shell, so an unpinned lookup resolves to a different workspace and reports a live job
as unknown; the miss is not evidence the job died. Always recheck exactly once
with --cwd pinned, whether or not the missing call already carried it — two causes
produce the identical message and only one is settled by adding the flag. The other is
transient: in companion v1.0.2 saveState writes state.json with a plain
fs.writeFileSync (truncate-then-write, no temp-and-rename) while loadState wraps
JSON.parse in a bare catch returning the default state, whose jobs list is
empty. A read landing inside that write window yields a well-formed No job found
rather than an error, and the background worker is rewriting that file precisely while
the status call runs. So an identical repeat need not return an identical answer. Once
a pinned recheck has also missed, return unavailable saying the job could not be
located — never re-dispatch (the original may still be running) and never record it as
exhausted budget.
- Fast transient failure — retried once, in the dispatch agent's launch
watch (the wait agent never dispatches), and only when all three hold:
.job.status is failed (never cancelled), the job died in under 60 seconds
by its own timestamps, and the error names a known-retryable cause such as an upstream
capacity rejection (Selected model is at capacity) or a transient dispatch fault.
The threshold is a number rather than a judgment because the observed cases are far
apart — a capacity rejection at ~10s against a genuine timeout at 10m19s — and an
undefined "quickly" is how a one-retry budget erodes.
A retry then costs seconds and these clear on their own.
cancelled is never retried — the companion emits it only for explicit
cancellation, so a retry would restart work someone deliberately stopped. Nor is a
late failure: .waitTimedOut false only means the job became terminal before the
inner deadline, which a failure at 8:59 also satisfies while having burned the whole
window. An unrecognised error is not assumed retryable either.
- Wait elapsed, job still alive (
.waitTimedOut true with .job.status still
queued/running) — not the end of the lane. The job is dispatched in the background
and outlives the Bash call waiting on it, so it needs more time, not another
attempt — and the protocol now gives it exactly that: up to four continuation waits on the
same job id in fresh calls. Those are not retries; nothing is re-dispatched and no
work is duplicated. A fifth .waitTimedOut is then genuinely exhausted budget, and
the lane returns unavailable with the job id in the structured jobId field —
required, not prose — so the result can be fetched or the run resumed later.
Measured on a real run: the lane timed out at the full 540000 ms while the job was
demonstrably mid-work, the job was still running long after the review was
abandoned, and its result stayed retrievable — so reporting exhausted budget there
discarded a required lane, and the whole review with it, over a job that had merely
not finished.
- Exhausted budget — a fifth
.waitTimedOut, or no parseable JSON at all because
the outer timeout killed the call (a dead broker). No retry, no further wait.
The ceiling is not tunable — the wait runs inside a Bash call, and that tool silently
kills any foreground command at 600000 ms. Exceeding 10 minutes requires polling across
several calls, which is exactly what the continuation wait above does: a live job gets
five waits, roughly forty-five minutes, without a longer single call. The budget was
three waits until a real ~53-minute job on a +1951/−153 PR outlasted even that — hence
five waits, and a resumable jobId on exhaustion instead of lost work. The inner wait is
therefore 540000 ms,
deliberately below the outer cap: were the two equal, Bash could kill the command
before it printed its JSON, leaving no .waitTimedOut to classify on. That is why an
unclassifiable kill counts as exhausted-budget rather than a fast failure — guessing
wrong there costs another full window for nothing. The lane still reports the job id
it was waiting on (jobId), so even that kill stays resumable.
Codex is required, so a lane that is still unavailable after its retry makes the run
report incomplete; re-run rather than interpreting a partial result.
incomplete with a codexJobId usually means the review is NOT lost. That field
is the Codex job the run was waiting on, surfaced top-level (alongside
reviewerStatus) precisely so recovery is mechanical, not improvised. The lane
attaches it to every unavailable wait result deliberately — losing a live id costs a
whole review, a wasted resume costs minutes — so it can also reference a job that
already failed or was cancelled: a poll that shows a terminal non-completed state, or
a resume that comes back unavailable again, confirms the job is dead and the review
must be re-run rather than resumed. For a live job: poll it
with the companion status command until it is terminal (a background 60-second loop is
fine — polling is cheap once the workflow is no longer holding a lane open for it).
Resolve the companion the same way the lane does, with -t — the lane's messages
refer to $comp but cannot export it across Bash calls:
comp=$(ls -t ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs | head -1)
node "$comp" status <job-id> --cwd "<repo-path>" --json
ls -t picks the most recently installed companion, which is the version the plugin
system has active. Dropping the -t sorts by version name instead and can select a
stale cached copy — polling a job with a different companion version than dispatched it
returns misleading results (status finding a job that result then reports as
unknown), which reads exactly like a dead job and is not one.
Then resume: Workflow({scriptPath, resumeFromRunId: "<wf_...>", args: {...prevArgs, codexResumeJobId: "<job id>"}}) — prevArgs is the previous run's args object, unchanged. The three completed lanes replay from cache, the
Codex dispatch agent is skipped entirely (the workflow logs
Codex resume: waiting on existing job <id>), the wait agent collects the existing
job without dispatching a second one, and the
run proceeds to cross-review and verification normally. A run interrupted between
dispatch and result needs no codexResumeJobId at all: on resumeFromRunId the
dispatch agent's cached {jobId} replays instantly, so the wait prompt is
byte-identical to the original run's and the resume lands in the same wait with no
re-dispatch. Proven live on PR 2097: a
~53-minute job outlasted the then-three-wait budget, and the resumed run recovered it
with zero re-dispatched work.
A dead job from broker teardown needs a private-broker resume, not a plain one.
When a Codex lane reports the job killed by concurrent-session broker teardown
(statusNote names infra-kill-by-concurrent-session-teardown — the confirmed
sessionRuntime fingerprint, which W2 and W4 both run; a late failed with a
reaper/null error is a symptom, not the gate, and an UNCONFIRMED cause gets an
ordinary re-run under the shared broker instead), the job is terminal — there is
nothing to poll and codexResumeJobId does not apply. A plain resumeFromRunId
does not help either: the lane completed with its unavailable result, so the
cache replays the failure verbatim. And a re-dispatch under the same shared broker
(keyed to the repo path) faces the same teardown risk while the concurrent sessions
that caused it are still running. The remedy: copy scripts/workflow.mjs to a
scratch path (never edit the checked-in file), add a one-line nonce to the affected
lane's prompt, and in the scratch copy replace ${repoPath} with the
session-private worktree path in that lane's --cwd interpolations — every
companion command: dispatch, status, result. Edit the interpolation itself, not an
appended prompt override: the generated prompt's literal commands pin
--cwd "${repoPath}" and insist on it for every call, so an override that
contradicts them may lose, and any call that keeps the shared path lands the
recovered job back under the broker being torn down. Then
Workflow({scriptPath: "<scratch copy>", resumeFromRunId: "<wf_...>", args: prevArgs}). Every other lane replays from cache; only the edited
lane re-runs, and its job lives under a private workspace broker that no concurrent
session's SessionEnd hook will tear down. The task prompt's pinned git -C reads
still name the original repo path, so the review context is unchanged. Proven live
2026-08-08 on PR 2097: two evaluation jobs died to teardown under the shared broker;
the third, dispatched under a private broker, completed and the run reached
consensus with zero re-reviewed lanes.
Execute the CodeRabbit lane's STEP blocks verbatim — same commands and paths, no
substitutions; in particular never swap the find … -delete cleanup for rm (an
invented rm -rf cleanup once blocked a run for hours on a managed-policy
confirmation prompt). The no-rm rule covers every command composed in the lane,
ad-hoc diagnostics included — a self-written .git/worktrees writability probe with
rm -f cleanup once blocked a round the same way. rmdir for empty dirs,
find <path> -maxdepth 0 -delete for files, always.
CodeRabbit slow runs: check the newest file in ~/.coderabbit/logs/ (429/queue lines
mean cloud-side queueing) and confirm which -a coderabbit resolves to the
brew-managed binary — a stale ~/.local/bin copy shadows it.
A sandboxed CodeRabbit run hangs instead of failing. The sandbox can deny the CLI
two independent ways, and both stall at connecting_to_review_service until the
timebox kills it: ~/.coderabbit outside the write allowlist (the CLI cannot create
its log or review store), or the coderabbit.ai hosts outside the allowed-hosts list.
The lane therefore probes both in step 1 — writability of ~/.coderabbit and
reachability of both CLI hosts, cli.coderabbit.ai (startup config fetch) and
ide.coderabbit.ai (the review session's WebSocket) — and runs the coderabbit
command (and only that command) with sandbox bypass when any check fails. The hosts
are probed separately because the allowlist is per-host: an entry naming only the
config host passes the first check and still hangs step 2 on the WebSocket connect.
Why probe-gated bypass rather than an allowlist assumption. Adding ~/.coderabbit
to the sandbox write allowlist is the narrower grant, but this skill is checked into
the repo and has to work on a contributor's machine as written: an allowlist entry
lives in each person's local settings, so a lane that assumed it would hand anyone
who has not made the edit the silent ten-minute hang above rather than a usable lane.
And the hang means "try sandboxed, fall back on failure" without a probe costs a full
timebox per wrong guess. The step-1 probe settles it in milliseconds: machines with
the allowlist entry run step 2 fully sandboxed and pay no bypass prompt at all;
every other machine gets the bypass from the start, portable and self-documenting at
the call site. If you run this often, add both grants — ~/.coderabbit (and
~/.claude/plugins/data, for the Codex companion's job log) to your local sandbox
filesystem.allowWrite, and the coderabbit.ai hosts to the network allowlist —
since that pair is what removes the per-round approval prompts. The skill must not
depend on either. Granting only the filesystem half is worse than granting neither:
it satisfies the write probe while the network stays blocked, and an earlier
writability-only probe read that state as sandbox-clean and hung the lane for a full
ten-minute timebox. The probe now tests both for exactly this reason.
Diagnose it from the log directory, since the two denials differ there. A stall at
connecting with no new file in ~/.coderabbit/logs/ is **filesyst
…(truncated)
1---2name: aicr-cross-review3description: Multi-agent PR review using Claude Code, Codex, and CodeRabbit. Runs parallel reviews with integration impact analysis, then one cross-review round to a 2-of-3 consensus, with every confirmed finding adversarially verified by a fresh agent. Never runs the reviewed commit's code, and never posts unless explicitly asked. Use when asked for a thorough cross-review or multi-reviewer analysis. Requires the Codex plugin; CodeRabbit is best-effort. Claude Code only — uses the Workflow and Agent tools, which are not available in other agents.4---56# AICR Cross-Review: Multi-Agent PR Review with Consensus78Three reviewers (Claude Code, Codex, CodeRabbit) plus a targeted integration impact9analysis, cross-reviewed to 2-of-3 consensus, with every confirmed finding10adversarially verified by a fresh agent. Orchestration runs as a **Workflow**11(`scripts/workflow.mjs`).1213**Claude Code only.** If the `Workflow` tool is unavailable, stop and say why — do not14fall back to another review command. `/code-review` in particular posts its result to15the PR (see Phase 2), which this skill never does without an explicit request. Named16`aicr-cross-review` so it does not shadow a contributor's global `cross-review` skill.1718**When in doubt, stop.** Every check below either passes or ends the review with an19explanation. The skill never executes the reviewed commit's code, and never posts to20the PR unless you explicitly ask (Phase 5).2122## Input2324Raw arguments: `$ARGUMENTS`2526`$ARGUMENTS` must be a PR number or a URL; both are normalized in Phase 0. There is no27no-argument mode: a fork PR cannot be found from the local branch name alone, since the28branch lives on the contributor's fork while the PR lives on `NVIDIA/aicr`. Stop and ask29for a PR reference rather than guessing. Do not write a parser; `gh` accepts both forms.3031## Phase 0: Pre-flight3233Only the required lanes are hard requirements. Claude, Codex and integration analysis34must work — if one fails at runtime the review reports `incomplete` and stops.35CodeRabbit is best-effort: a missing or unauthenticated CLI is not an error, its vote36slot just records `NONE`.3738```bash39for tool in gh git; do40 which "$tool" >/dev/null || { echo "$tool not found — install it and retry."; exit 1; }41done42ls ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs >/dev/null 2>&1 \43 || { echo "Codex companion not found. Install the Codex plugin (Settings → Extensions → Codex)."; exit 1; }44echo "Pre-flight OK."45```4647If either check fails, stop and report which tool is missing. Do not fall back to48another review command.4950**Resolve the PR number — before anything else needs it.**5152Every `gh` call and the ref fetch are scoped to `NVIDIA/aicr` **literally**, written out53in each command. Two reasons: in GitHub's standard fork layout the local repository is54the contributor's fork, which has neither the PR nor `refs/pull/*`; and a shell variable55would not survive anyway, since each Bash call is a fresh shell.5657```bash58# $ARGUMENTS must be a PR number or URL — gh accepts either.59test -n "$ARGUMENTS" || { echo "usage: /aicr-cross-review <PR-number-or-URL>"; exit 1; }60gh pr view "$ARGUMENTS" --repo NVIDIA/aicr \61 --json number,title,body,baseRefName,headRefName,headRefOid,files62```6364Take `<n>` = `.number` and use that numeric value for every later temp path, scoped ref65name and `gh` call — never the raw argument. Keep the rest of the response; Phase 1 does66not re-fetch it.6768**Self-review guard.** From the `files` list just fetched: if any changed path is under69`.agents/skills/aicr-cross-review/`, **stop** — the scripts you would execute are the70ones under review. Ask for a trusted checkout. This catches the accidental case only;71`SKILL.md` lives inside the reviewed repo, so it is not a security boundary.7273## Phase 1: Setup7475**Batch A — one parallel message:**76771. From the Phase 0 response, pin `HEAD_SHA` = `headRefOid`. Every reviewer reviews78 this exact commit. `<n>` is already resolved in Phase 0; do not re-fetch.792. Worktree hygiene: `git worktree prune`, then `git worktree list | wc -l`. If the80 count still exceeds ~15, **stop** and ask the user to clean up before retrying.81 Do not remove worktrees yourself — a clean detached-HEAD worktree may be another82 session's active review. (Each worktree adds sandbox deny-list paths; at ~70 the83 profile exceeded the OS spawn-arg limit and every sandboxed Bash call failed with84 `E2BIG`. Recovery needs a fresh session.)853. Reap dead runs' pinned inputs. A session killed between Batch B and Phase 5 leaks86 its two `refs/cr/*` and its temp diff file permanently — nothing else reclaims87 them, and they accumulate in the same slow way the worktrees above do.8889 ```bash90 RUNS="$(git -C "<repo-path>" rev-parse --path-format=absolute --git-common-dir)/cr-runs"91 find "$RUNS" -maxdepth 1 -type f -mmin +1440 -delete 2>/dev/null || true92 git -C "<repo-path>" for-each-ref --format='%(refname)' 'refs/cr/pr*' 'refs/cr/base*' |93 while read -r REF; do94 KEY=${REF#refs/cr/} # want <n>-<SID>95 case "$KEY" in pr*) KEY=${KEY#pr};; base*) KEY=${KEY#base};; esac96 case "$KEY" in *-*) ;; *) continue;; esac # must have both components97 case "${KEY%-*}" in ''|*[!0-9]*) continue;; esac # <n> is a PR number98 case "${KEY##*-}" in ??????) ;; *) continue;; esac # <SID> is mktemp's six chars99 [ -e "$RUNS/$KEY" ] || git -C "<repo-path>" update-ref -d "$REF"100 done101 find "${TMPDIR:-/tmp}" -maxdepth 1 -type f -name 'cross-review-pr*.??????' -mmin +1440 -delete 2>/dev/null || true102 ```103104 Liveness comes from the per-run marker Batch B drops in `cr-runs/`, not from the105 ref and not from the diff file. Both alternatives are broken:106107 - **Not the ref.** `git gc` packs `refs/cr/*` into `packed-refs`, after which the108 per-ref file under `.git/refs/cr/` no longer exists and an mtime gate silently109 stops reaping — and `git fetch`, which Batch B runs, triggers `gc --auto`. The110 two substitutes fail too: `refs/cr/*` has no reflog (`core.logAllRefUpdates`111 covers only `refs/heads`, `refs/remotes`, `refs/notes`, and `HEAD`), and112 `%(creatordate)` is the *commit's* date, so a ref created a minute ago on113 yesterday's `main` reports "21 hours ago".114 - **Not the diff file.** `TMPDIR` is not stable across sessions, or even within115 one: under Claude Code's sandbox it is `/tmp/claude-<uid>`, and with the sandbox116 bypassed it is the shell default (`/var/folders/…/T/` on macOS). A reaper that117 tested for the diff file would miss a live session's file whenever the two118 disagree and delete that session's pinned refs — the one thing this skill must119 never do.120121 `cr-runs/` sits next to the refs it guards, under the **common** git dir, so every122 worktree of a clone shares one view, exactly as `refs/cr/*` are shared. Git ignores123 unknown entries there, so nothing packs or prunes it and the marker keeps a real124 creation timestamp. The last `find` is only a temp-file janitor: it reclaims diff125 files in whatever `TMPDIR` this session sees, and reclaiming none is harmless.126127 **The three `case` guards are the safety boundary, and all three are load-bearing.**128 A candidate must have both components, a numeric `<n>`, and a six-character `<SID>`129 before it can be deleted. Prefix-stripping alone is not enough: `refs/cr/pr*` also130 matches `refs/cr/private-ABC123`, which strips to `ivate-ABC123` and would pass a131 suffix-only check. Hand-made bookmarks (`refs/cr/2183-r5`, `refs/cr/2187-test`) are132 deliberate, often pin active work, and must survive — the only names that now133 collide are a literal `pr<digits>-<six characters>` or `base<digits>-<six134 characters>`, since the loop accepts both prefixes, so do not use either shape135 for one. `find … -delete` rather than `rm` for the same reason as everywhere else in136 this skill: managed permission policies gate `rm:*` behind a prompt. The janitor137 is `-type f` so a directory that happens to match the diff-file pattern is never138 removed.139140 **The marker key is `<n>-<SID>`, never `<SID>` alone.** `mktemp` guarantees the141 full filename it returns is unique; it does not reserve the suffix. Two concurrent142 reviews of *different* PRs pass different templates, so both can be handed the same143 six characters — their refs stay distinct, but a `<SID>`-keyed marker would be one144 shared file, and whichever run finished first would delete the other's protection.145 Reviews of the *same* PR are safe whenever they share a `TMPDIR`, since `mktemp`146 guarantees distinct names within one directory. It guarantees nothing across147 directories, so same-PR runs under different `TMPDIR` roots can still collide —148 but on `$SID` itself, which makes `PRREF` and `BASEREF` collide first. That is a149 property of how Batch B derives `$SID`, not of this reaper, and is left to a150 follow-up.151152 **The gate is 24 hours, and it must stay far above any real run.** Nothing enforces153 an end-to-end limit on a review: Codex gets a five-wait, ~45-minute budget in the154 Review phase and again in Cross-review, with Verify on top. A gate near the155 expected duration would let a later Phase 1 reap a *live* run's marker, then its156 refs, and the temp-file janitor would take its `DIFFPATH` with them — the run would157 destroy itself. The gate measures **age since Batch B stamped the marker, not158 inactivity** — the marker is written once and never refreshed — so a run still159 alive a day later is outside every documented budget and is treated as dead. The160 temp-file janitor is gated independently, on each diff file's own mtime, so161 refreshing the marker would not cover `DIFFPATH` either way. Since this reaper162 exists for leaks that accumulate over days, waiting a day to collect one costs163 nothing. Do not tune it down toward the expected runtime.164165**Batch B — after A** (needs `HEAD_SHA` and `baseRefName`). `gh pr diff` takes no166SHA argument, so pin the diff with `git fetch`. Refs and the diff file are167**session-scoped**: two sessions reviewing the same PR must not share, overwrite, or168delete each other's pinned input.169170```bash171set -euo pipefail # a failed fetch or diff must abort, not leave an empty diff file172BASE="<baseRefName>" # from step 1 — never hardcode "main"173DIFFPATH=$(mktemp "${TMPDIR:-/tmp}/cross-review-pr<n>.XXXXXX") # must end in X on macOS174SID=${DIFFPATH##*.} # reuse mktemp's unique suffix to scope the refs175PRREF="refs/cr/pr<n>-$SID"; BASEREF="refs/cr/base<n>-$SID"176# Liveness marker for Batch A step 3's reaper, written BEFORE the refs exist so no177# concurrent reaper can ever see a ref without its marker. Keyed by <n>-$SID — mktemp178# guarantees the full filename is unique, not the suffix, so a $SID-only key would179# collide with a concurrent review of a DIFFERENT PR. Under the common git dir, so180# every worktree of this clone shares one view.181RUNS="$(git -C "<repo-path>" rev-parse --path-format=absolute --git-common-dir)/cr-runs"182RUNMARK="$RUNS/<n>-$SID"; mkdir -p "$RUNS"; : > "$RUNMARK"183# Fetch from the canonical repo by URL, not from `origin`: in GitHub's standard fork184# layout `origin` is the contributor's fork, and refs/pull/* exist only on the canonical185# repository.186git -C "<repo-path>" fetch "https://github.com/NVIDIA/aicr.git" \187 "+refs/pull/<n>/head:$PRREF" "+refs/heads/$BASE:$BASEREF"188# Head moved → stop. Clean the refs we just created before exiting; set -e would189# otherwise abort before the names are ever printed, leaving them unreclaimable.190if [ "$(git -C "<repo-path>" rev-parse "$PRREF")" != "<HEAD_SHA>" ]; then191 git -C "<repo-path>" update-ref -d "$PRREF"; git -C "<repo-path>" update-ref -d "$BASEREF"192 find "$DIFFPATH" "$RUNMARK" -maxdepth 0 -delete193 echo "HEAD moved since setup — restart the review"; exit 1194fi195# Echo the names FIRST: under `set -e` an empty or failing diff aborts, and any196# echo below it would never run — leaking the refs and the temp file with a random197# suffix nobody recorded, which Phase 5 then cannot clean up.198echo "DIFFPATH=$DIFFPATH"; echo "PRREF=$PRREF"; echo "BASEREF=$BASEREF"; echo "RUNMARK=$RUNMARK"199git -C "<repo-path>" diff "$BASEREF...$PRREF" > "$DIFFPATH"200test -s "$DIFFPATH" # a real PR diff is never empty201# repoNotes source, pinned to the BASE ref — a fork PR must not be able to rewrite202# the instructions fed to the reviewer. Absent on some repos; that is fine.203git -C "<repo-path>" show "$BASEREF":.claude/CLAUDE.md 2>/dev/null || echo "(no tracked CLAUDE.md)"204# BASE_SHA is the base branch tip. Its only consumer is CodeRabbit's --base-commit,205# and the CLI resolves the merge-base itself, so this stays consistent with the206# three-dot diff above without a second baseline to keep in sync.207echo "BASE_SHA=$(git -C "<repo-path>" rev-parse "$BASEREF")"208```209210Capture `DIFFPATH`, `BASE_SHA`, `PRREF`, `BASEREF`, `RUNMARK` — shell variables do not persist211between Bash calls and Phase 5 needs the ref names.212213Then build `repoNotes` for the Claude reviewer only (never fed to Codex — lean-context214rule): distill the base-pinned `CLAUDE.md` plus the local overlay into 3–6 lines of the215rules most likely to catch defects in the changed paths.216217The check below reduces accidental exposure, but it is **not a trust boundary**:218reviewer subagents load the checkout's `CLAUDE.md` hierarchy automatically, before any219guard here runs. Treat `repoNotes` as a relevance digest, not a sanitiser.220221**For an untrusted or fork PR, run this skill from a session started in a trusted222checkout** — the same operational remedy as the self-review guard in Phase 0. Git223overwrites *ignored* files during checkout without complaint, so checking out a fork224that force-added an ignored overlay silently replaces yours.225226```bash227for f in AGENTS.local.md CLAUDE.local.md; do228 [ -e "<repo-path>/$f" ] || continue229 # Skip symlinks first. The tracked-status check applies to the link, not its target,230 # so an untracked symlink pointing at a PR-tracked file would otherwise be reported231 # TRUSTED while resolving to PR-controlled instructions.232 [ -L "<repo-path>/$f" ] && { echo "SKIP $f — symlink"; continue; }233 if git -C "<repo-path>" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then234 echo "SKIP $f — tracked by this PR, not a trusted local overlay"235 else236 echo "TRUSTED $f" # regular untracked file: safe to read237 fi238done239```240241Read only the paths reported `TRUSTED`. `AGENTS.local.md` is normally a symlink to242`CLAUDE.local.md`, so it is skipped and the overlay is read through the real file —243no content is lost.244245**Verify the workflow script version before Phase 2.** The script about to be passed as246`scriptPath` must contain the sentinel identifier `codexResumeJobId`:247`grep -c codexResumeJobId "<skill-dir>/scripts/workflow.mjs"` — expect a non-zero count.248If it is absent, STOP: the file is a stale or reverted copy, and running it silently249restores the old semantics (observed live: a concurrent session's git operation reverted250uncommitted skill files in a shared checkout, and a full review round ran the old script251unnoticed). The sentinel detects staleness relative to this revision only — if that252identifier is ever renamed, update this check in the same change.253254## Phase 1.5: Classify and extract the change list255256**Classify** the PR: `code-change` | `adr` | `config-change` | `documentation-only`.257258**Extract a bounded change list** so integration analysis verifies specific items259instead of fishing across the repo:260261- Exported functions/types/constants added, removed, or modified262- Config keys added or changed (`.yaml`, `.toml`, `.json`)263- Workflow inputs/triggers added or changed264- File/manifest paths renamed or restructured265- Behaviorally significant defaults changed (timeouts, versions, namespaces)266267> **This skill never runs the PR's code.** No build, test, or coverage step; every268> reviewer prompt forbids it. Only trusted tools run (`git`, `gh`, the CodeRabbit CLI,269> the Codex companion). Coverage is CI's job — see Phase 3.270271272## Phase 2: Run the review workflow273274```275Workflow({276 scriptPath: "<skill-dir>/scripts/workflow.mjs",277 args: {278 pr: <number>,279 repo: "<owner>/<name>",280 repoPath: "<local checkout path>",281 headSha: "<HEAD_SHA>",282 baseSha: "<BASE_SHA>",283 diffPath: "<DIFFPATH>",284 prType: "<classification>",285 changeList: ["<item 1>", "<item 2>"],286 repoNotes: "<3-6 line digest, optional>"287 }288})289```290291Pass `changeList` as a real JSON array, not a stringified one. Every lane is292`general-purpose` and inherits the session model, so there is no model argument to293pass.294295**What the workflow does** (`scripts/workflow.mjs` is the single source of truth for296the consensus mechanics):297298- **Review** — Claude Code (reviews the pinned diff directly; it deliberately does299 *not* delegate to the `code-review` command, whose step 8 instructs its agent to300 `gh pr comment` the result back to the PR), Codex (two chained agents: a dispatch301 agent starts the remote background job and hands back its id, which the workflow302 immediately writes to the progress log — `Codex job <id> dispatched — review running303 remotely` — then a wait agent runs a 9-min304 bounded wait plus up to four continuation waits when the job is still running — about 45 min305 for a live job), CodeRabbit (CLI against a detached worktree at `HEAD_SHA`, explicit306 600000 ms timeout — the Bash tool caps any single call at 10 minutes, which is why307 Codex exceeds it by waiting across several calls rather than waiting longer), and integration308 analysis (bounded to `changeList`). Every lane is a309 `general-purpose` agent. All310 parallel, schema-validated, and none may execute the reviewed commit's code.311- **Merge** — dedupe by `path:line:normalized-summary:consumerPath:consumerLine`;312 duplicates merge to the highest severity and union their sources; a finding citing a file313 the reporter never listed in `filesChecked` is flagged for extra scrutiny.314315 **Two lanes wording one defect differently stay separate candidates, by design.** Keying316 on location alone was tried and reverted: it did merge those duplicates, but the317 evaluation schema permits exactly one verdict per candidate id, so a merged pair of318 *distinct* same-line defects has no correct verdict — confirming the real one also319 confirms the false one, and refuting the false one dismisses the real one. Retaining both320 summaries prevented data loss but not mis-adjudication, which is the worse failure.321322 Instead, candidates sharing a location — `path:line` **and** the same323 `consumerPath`/`consumerLine` — are **flagged** as possible duplicates. The consumer half324 matters: one changed declaration breaking two callers is deliberately two candidates, and325 hinting that they might be duplicates would push reviewers to collapse a distinction the326 key exists to preserve. The flag327 reaches the cross-review candidate list and the refuter prompt, so reviewers decide328 whether the two are one defect and evaluate them consistently. Equivalence stays an329 explicit judgement rather than an assumption from a shared line number.330331 Merging also stops once candidates are presented: a late finding that merged into an332 already-evaluated id would inherit votes cast before it existed. Late findings always333 become their own candidate and, being unpresented, stay contested for the human.334335- **Cross-review (one round, Claude + Codex only)** — each re-reviews independently336 first (anti-anchoring), then returns AGREE/DISAGREE/OPEN_QUESTION per candidate.337 CodeRabbit does *not* take part: its CLI is a slow blocking cloud call and it338 reviews Git changes generically, so it cannot adjudicate our candidate ids and a339 second run over the same commit adds no signal. Its round-1 findings still stand as its AGREE votes, so340 it can still corroborate a split it independently reported. Anything still split341 afterwards is reported as contested for you to settle.342- **Consensus rule** — confirmed = 2 of the 3 reviewer slots AGREE **with evidence**;343 integration analysis is never a reviewer slot. A round-1 finding whose evidence is344 blank or whitespace-only is dropped at intake, so it never registers its reporter as345 a source. In the cross-review round an unevidenced AGREE/DISAGREE instead aborts the346 run (`incomplete`) — dropping it would leave the reviewer's round-1 source vote to347 decide the tally.348- **Verify** — every confirmed finding goes to a fresh adversarial refuter349 (REFUTED → dismissed; UNVERIFIABLE, no result, or a verdict without a citation →350 the `unresolved` array). `consensusReached` is true only when both `contested` and351 `unresolved` are empty — a finding that reached consensus but failed verification is352 an open question, not a settled one.353354 **Read `adjudication` on every contested entry.** The bucket holds two different states355 and they need different things from you. `evaluated` means the finding was presented,356 the reviewers voted, and they did not reach 2-of-3 — a genuine split, so break the tie.357 `raised-late` means it was raised *during* the cross-review round, after candidates were358 presented, so nobody cross-evaluated it and its only position is its reporter's — it just359 needs reading. Measured on a real run: 8 of 8 contested findings were `raised-late`, each360 with a single AGREE and NONE elsewhere, so the count read as eight disagreements when361 there were none. `consensusReached` counts both, deliberately: a late finding is362 unadjudicated, and letting it report consensus would be the same overstatement this363 skill exists to avoid.364- **Report incomplete and stop** — Claude, Codex and integration analysis are required365 in round 1, and Claude and Codex must each return exactly one evaluation per366 candidate in the cross-review round. A missing lane, a missing evaluation, a367 duplicate, or an unknown candidate id returns `status: "incomplete"` with the reason368 and raw unverified findings. There is no degraded-consensus mode. CodeRabbit is the369 only best-effort lane: when it does not run, its vote slot records `NONE`, which370 raises the bar (Claude and Codex must then agree) rather than lowering it.371372 One deliberate exception, at the level of a **finding** rather than a lane. An373 integration finding claims a specific consumer breaks, so one lacking374 `consumerPath`/`consumerLine` cannot be verified *as an integration claim* — but if it375 still locates a defect (its own path/line) with evidence, it is a perfectly reviewable376 ordinary finding. It is therefore **demoted** — consumer fields stripped, flagged in the377 candidate list, excluded from the integration severity escalation — rather than dropped,378 with a `log()` naming what was demoted. Dropping was tried twice and cost a whole run379 each time: first when the lane returned several findings and one legitimately380 consumer-less observation failed the run; then, after per-finding dropping replaced381 that, when the lane's ONLY finding was such an observation and the zero-survivor rule382 stopped the run with all four lanes `ok` and no report produced (observed on PR 2097).383 The run still stops when every integration finding lacks even a locatable defect or384 evidence — that is the case the fail-closed rule exists for: silently dropping the385 lane's only finding once yielded `consensusReached: true` while a required lane had386 contributed nothing.387388 "Contributed nothing" is measured on what survives `intake()`: a demoted finding passes389 through the same coordinate and evidence gates as every other candidate, so a390 consumer-less, whitespace-evidence finding cannot slip through demotion into a391 false-clean. One rule covers the stop: a non-empty integration result that yields no392 accepted finding — neither as an integration claim nor as a demoted ordinary finding —393 stops the run, and the message says how many went for each reason.394395 **Coordinates are validated by a single shared rule**, `hasCoords`, applied to a396 finding's own `path`/`line` in `intake()` — every lane, not just integration — and to397 `consumerPath`/`consumerLine` for the integration pair. The response schema **requires**398 `path` and `line` and leaves `consumerPath`/`consumerLine` optional — deliberately, since399 only integration findings carry a consumer — but it constrains none of the four, so400 `""`, `" "`, `0` and `-1` all satisfy it and all passed a truthiness/null check.401 Tightening the schema instead would fail a whole lane on one bad field, which is the402 all-or-nothing behavior this section exists to remove. It is one helper rather than two403 call-site conditions for a specific reason: every earlier version of this guard fixed the404 pair it was shown and left the other, and a shared rule is what stops the next field pair405 from repeating that.406407 **The zero-survivor rule applies to every required round-1 batch**, not just integration.408 Claude's and Codex's round-1 findings go through `intakeBatch`, which reports what409 survived. A required lane whose round-1 findings are *all* malformed contributed410 nothing, and letting them vanish silently is the same false-clean one lane over. Mixed411 batches still proceed. CodeRabbit is exempt: a total loss there records `NONE` and412 raises the bar, exactly as a lane that never ran. Rejection reasons are counted413 separately (`unlocatable` vs `unevidenced`) rather than inferred from a subtraction, so414 the message names the defect the reader should go looking for.415416 **It deliberately does NOT apply to cross-review `newFindings`.** Those go through the417 same `intakeBatch` gate — a malformed late finding still never enters the tally — but a418 total loss there is not fatal. The rule tests "this lane contributed nothing", which419 round 1 can assert because findings are the lane's whole output. In the cross-review420 round the lane's output is its *evaluations*, and the completeness gate has already421 returned `incomplete` unless the lane evaluated every presented candidate; `newFindings`422 are supplementary and volunteered. Aborting there would throw away a full set of423 adjudications and the verification round over one imprecise extra finding — the same424 disproportionate total loss seen on PR 1908, one round over. Malformed late findings are425 dropped individually and each drop is logged with its reason.426427**Operational notes:**428429- The workflow runs in the background — wait for its completion notification.430- If it dies mid-run, **resume, don't restart**:431 `Workflow({scriptPath: ..., resumeFromRunId: "<wf_...>"})` — completed lanes replay432 from cache. Empty or odd result → read `<transcriptDir>/journal.jsonl` first.433- **The Codex round-1 lane is two agents, deliberately.** A dispatch agent composes the434 lean Codex task, starts the background job (and owns the fast-transient retry-once435 rule, decided inside a brief ~90-second launch watch that exactly covers the436 under-60s retry window), and returns `{jobId, dispatchNote}`; the workflow then logs437 `Codex job <id> dispatched — review running remotely` and hands the id to a wait438 agent that runs the continuation-wait protocol unchanged and translates the result.439 The split exists for progress visibility: a single opaque agent call shows "running"440 from spawn, which cannot distinguish "remote job dispatched and working" from441 "dispatch never happened" — a real run sat silent for 19 minutes with no way to tell442 which. The logged job id is the visible "started" signal, and it doubles as the443 recovery handle when everything after dispatch dies: a dispatch-agent failure or a444 wait-agent loss surfaces exactly like any Codex-lane unavailability (`incomplete`,445 with `codexJobId` whenever a live job id exists). The wait agent never dispatches.446- The Codex lane fails in three distinct ways, and the dispatch and wait protocols447 treat them differently:448 - **Lookup miss** — the status call exits 1 with empty stdout and `No job found` on449 stderr. Companion state is keyed by workspace root and each Bash call is a fresh450 shell, so an unpinned lookup resolves to a different workspace and reports a live job451 as unknown; the miss is **not** evidence the job died. **Always recheck exactly once**452 with `--cwd` pinned, whether or not the missing call already carried it — two causes453 produce the identical message and only one is settled by adding the flag. The other is454 transient: in companion v1.0.2 `saveState` writes `state.json` with a plain455 `fs.writeFileSync` (truncate-then-write, no temp-and-rename) while `loadState` wraps456 `JSON.parse` in a bare `catch` returning the **default** state, whose `jobs` list is457 empty. A read landing inside that write window yields a well-formed `No job found`458 rather than an error, and the background worker is rewriting that file precisely while459 the status call runs. So an identical repeat need not return an identical answer. Once460 a pinned recheck has also missed, return `unavailable` saying the job could not be461 located — never re-dispatch (the original may still be running) and never record it as462 exhausted budget.463 - **Fast transient failure** — retried **once**, in the dispatch agent's launch464 watch (the wait agent never dispatches), and only when all three hold:465 `.job.status` is `failed` (never `cancelled`), the job died in **under 60 seconds**466 by its own timestamps, and the error names a known-retryable cause such as an upstream467 capacity rejection (`Selected model is at capacity`) or a transient dispatch fault.468 The threshold is a number rather than a judgment because the observed cases are far469 apart — a capacity rejection at ~10s against a genuine timeout at 10m19s — and an470 undefined "quickly" is how a one-retry budget erodes.471 A retry then costs seconds and these clear on their own.472 **`cancelled` is never retried** — the companion emits it only for explicit473 cancellation, so a retry would restart work someone deliberately stopped. Nor is a474 late failure: `.waitTimedOut` false only means the job became terminal before the475 inner deadline, which a failure at 8:59 also satisfies while having burned the whole476 window. An unrecognised error is not assumed retryable either.477 - **Wait elapsed, job still alive** (`.waitTimedOut` true with `.job.status` still478 `queued`/`running`) — not the end of the lane. The job is dispatched in the background479 and outlives the Bash call waiting on it, so it needs more **time**, not another480 attempt — and the protocol now gives it exactly that: **up to four continuation waits** on the481 *same* job id in fresh calls. Those are not retries; nothing is re-dispatched and no482 work is duplicated. A fifth `.waitTimedOut` is then genuinely exhausted budget, and483 the lane returns `unavailable` with the job id in the structured `jobId` field —484 required, not prose — so the result can be fetched or the run resumed later.485 Measured on a real run: the lane timed out at the full 540000 ms while the job was486 demonstrably mid-work, the job was **still** `running` long after the review was487 abandoned, and its result stayed retrievable — so reporting exhausted budget there488 discarded a required lane, and the whole review with it, over a job that had merely489 not finished.490 - **Exhausted budget** — a fifth `.waitTimedOut`, or no parseable JSON at all because491 the outer timeout killed the call (a dead broker). No retry, no further wait.492493 The ceiling is not tunable — the wait runs inside a Bash call, and that tool silently494 kills any foreground command at 600000 ms. Exceeding 10 minutes requires polling across495 several calls, which is exactly what the continuation wait above does: a live job gets496 five waits, roughly forty-five minutes, without a longer single call. The budget was497 three waits until a real ~53-minute job on a +1951/−153 PR outlasted even that — hence498 five waits, and a resumable `jobId` on exhaustion instead of lost work. The inner wait is499 therefore 540000 ms,500 deliberately **below** the outer cap: were the two equal, Bash could kill the command501 before it printed its JSON, leaving no `.waitTimedOut` to classify on. That is why an502 unclassifiable kill counts as exhausted-budget rather than a fast failure — guessing503 wrong there costs another full window for nothing. The lane still reports the job id504 it was waiting on (`jobId`), so even that kill stays resumable.505- Codex is required, so a lane that is still unavailable after its retry makes the run506 report `incomplete`; re-run rather than interpreting a partial result.507- **`incomplete` with a `codexJobId` usually means the review is NOT lost.** That field508 is the Codex job the run was waiting on, surfaced top-level (alongside509 `reviewerStatus`) precisely so recovery is mechanical, not improvised. The lane510 attaches it to every unavailable wait result deliberately — losing a live id costs a511 whole review, a wasted resume costs minutes — so it can also reference a job that512 already failed or was cancelled: a poll that shows a terminal non-completed state, or513 a resume that comes back unavailable again, confirms the job is dead and the review514 must be re-run rather than resumed. For a live job: poll it515 with the companion status command until it is terminal (a background 60-second loop is516 fine — polling is cheap once the workflow is no longer holding a lane open for it).517518 Resolve the companion the same way the lane does, with `-t` — the lane's messages519 refer to `$comp` but cannot export it across Bash calls:520521 ```bash522 comp=$(ls -t ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs | head -1)523 node "$comp" status <job-id> --cwd "<repo-path>" --json524 ```525526 `ls -t` picks the most recently installed companion, which is the version the plugin527 system has active. Dropping the `-t` sorts by version *name* instead and can select a528 stale cached copy — polling a job with a different companion version than dispatched it529 returns misleading results (`status` finding a job that `result` then reports as530 unknown), which reads exactly like a dead job and is not one.531532 Then resume: `Workflow({scriptPath, resumeFromRunId: "<wf_...>", args: {...prevArgs,533 codexResumeJobId: "<job id>"}})` — `prevArgs` is the previous run's args object, unchanged. The three completed lanes replay from cache, the534 Codex dispatch agent is skipped entirely (the workflow logs535 `Codex resume: waiting on existing job <id>`), the wait agent collects the existing536 job without dispatching a second one, and the537 run proceeds to cross-review and verification normally. A run interrupted *between*538 dispatch and result needs no `codexResumeJobId` at all: on `resumeFromRunId` the539 dispatch agent's cached `{jobId}` replays instantly, so the wait prompt is540 byte-identical to the original run's and the resume lands in the same wait with no541 re-dispatch. Proven live on PR 2097: a542 ~53-minute job outlasted the then-three-wait budget, and the resumed run recovered it543 with zero re-dispatched work.544- **A dead job from broker teardown needs a private-broker resume, not a plain one.**545 When a Codex lane reports the job killed by concurrent-session broker teardown546 (`statusNote` names infra-kill-by-concurrent-session-teardown — the confirmed547 `sessionRuntime` fingerprint, which W2 and W4 both run; a late `failed` with a548 reaper/null error is a symptom, not the gate, and an UNCONFIRMED cause gets an549 ordinary re-run under the shared broker instead), the job is terminal — there is550 nothing to poll and `codexResumeJobId` does not apply. A plain `resumeFromRunId`551 does not help either: the lane *completed* with its unavailable result, so the552 cache replays the failure verbatim. And a re-dispatch under the same shared broker553 (keyed to the repo path) faces the same teardown risk while the concurrent sessions554 that caused it are still running. The remedy: copy `scripts/workflow.mjs` to a555 scratch path (never edit the checked-in file), add a one-line nonce to the affected556 lane's prompt, and in the scratch copy replace `${repoPath}` with the557 session-private worktree path in that lane's `--cwd` interpolations — every558 companion command: dispatch, status, result. Edit the interpolation itself, not an559 appended prompt override: the generated prompt's literal commands pin560 `--cwd "${repoPath}"` and insist on it for every call, so an override that561 contradicts them may lose, and any call that keeps the shared path lands the562 recovered job back under the broker being torn down. Then563 `Workflow({scriptPath: "<scratch copy>", resumeFromRunId:564 "<wf_...>", args: prevArgs})`. Every other lane replays from cache; only the edited565 lane re-runs, and its job lives under a private workspace broker that no concurrent566 session's SessionEnd hook will tear down. The task prompt's pinned `git -C` reads567 still name the original repo path, so the review context is unchanged. Proven live568 2026-08-08 on PR 2097: two evaluation jobs died to teardown under the shared broker;569 the third, dispatched under a private broker, completed and the run reached570 consensus with zero re-reviewed lanes.571- **Execute the CodeRabbit lane's STEP blocks verbatim** — same commands and paths, no572 substitutions; in particular never swap the `find … -delete` cleanup for `rm` (an573 invented `rm -rf` cleanup once blocked a run for hours on a managed-policy574 confirmation prompt). The no-`rm` rule covers **every** command composed in the lane,575 ad-hoc diagnostics included — a self-written `.git/worktrees` writability probe with576 `rm -f` cleanup once blocked a round the same way. `rmdir` for empty dirs,577 `find <path> -maxdepth 0 -delete` for files, always.578- CodeRabbit slow runs: check the newest file in `~/.coderabbit/logs/` (429/queue lines579 mean cloud-side queueing) and confirm `which -a coderabbit` resolves to the580 brew-managed binary — a stale `~/.local/bin` copy shadows it.581- **A sandboxed CodeRabbit run hangs instead of failing.** The sandbox can deny the CLI582 two independent ways, and both stall at `connecting_to_review_service` until the583 timebox kills it: `~/.coderabbit` outside the write allowlist (the CLI cannot create584 its log or review store), or the `coderabbit.ai` hosts outside the allowed-hosts list.585 The lane therefore **probes both** in step 1 — writability of `~/.coderabbit` *and*586 reachability of both CLI hosts, `cli.coderabbit.ai` (startup config fetch) and587 `ide.coderabbit.ai` (the review session's WebSocket) — and runs the `coderabbit`588 command (and only that command) with sandbox bypass when any check fails. The hosts589 are probed separately because the allowlist is per-host: an entry naming only the590 config host passes the first check and still hangs step 2 on the WebSocket connect.591592 **Why probe-gated bypass rather than an allowlist assumption.** Adding `~/.coderabbit`593 to the sandbox write allowlist is the narrower grant, but this skill is checked into594 the repo and has to work on a contributor's machine as written: an allowlist entry595 lives in each person's local settings, so a lane that *assumed* it would hand anyone596 who has not made the edit the silent ten-minute hang above rather than a usable lane.597 And the hang means "try sandboxed, fall back on failure" without a probe costs a full598 timebox per wrong guess. The step-1 probe settles it in milliseconds: machines with599 the allowlist entry run step 2 fully sandboxed and pay **no bypass prompt at all**;600 every other machine gets the bypass from the start, portable and self-documenting at601 the call site. If you run this often, add **both** grants — `~/.coderabbit` (and602 `~/.claude/plugins/data`, for the Codex companion's job log) to your local sandbox603 `filesystem.allowWrite`, *and* the `coderabbit.ai` hosts to the network allowlist —604 since that pair is what removes the per-round approval prompts. The skill must not605 depend on either. Granting only the filesystem half is worse than granting neither:606 it satisfies the write probe while the network stays blocked, and an earlier607 writability-only probe read that state as sandbox-clean and hung the lane for a full608 ten-minute timebox. The probe now tests both for exactly this reason.609610 **Diagnose it from the log directory**, since the two denials differ there. A stall at611 `connecting` *with no new file in `~/.coderabbit/logs/`* is **filesyst612613…(truncated)