Imported from michaelhvisser/ai (
plugins/workflow/skills/codex-ship/SKILL.md). Install upstream withnpx skills add michaelhvisser/ai --skill codex-ship. Copyright stays with the author.
Codex Ship — triage-gated Codex↔fix loop
Drive a PR to merge-readiness with the Codex connector: consume any Codex feedback already on the PR first, then trigger a review, judge each finding (real vs. slop) with a strong model, corroborate the judgment with a local Codex CLI second opinion, fix only the confirmed-real findings, and keep looping until Codex runs out of genuine value — then checkpoint with a human and merge (or hand off to whatever merge automation the repo uses).
Before requesting decisions or delegating work, read
${CLAUDE_PLUGIN_ROOT}/lib/driver-interaction.md and follow its
cross-platform capability-binding rules.
Read ${CLAUDE_PLUGIN_ROOT}/lib/decision-gates.md before resolving any workflow
choice.
Read ${CLAUDE_PLUGIN_ROOT}/lib/finding-bar.md before any triage: it is the shared bar
every finding is held to across this plugin's review skills, and the judge and
second-opinion prompts must carry its full text (paste it — subagents can't resolve the
plugin path).
Read ${CLAUDE_PLUGIN_ROOT}/lib/fix-at-the-root.md before any fix pass: it is the
doctrine the Fixer works under — fix the class of defect, not just the reported
instance — and every fixer dispatch must carry its full text (paste it — the fixer runs
in a language plugin or inline and cannot resolve this plugin's path).
Load the shared GitHub REST helpers before any GitHub operation:
source "${CLAUDE_PLUGIN_ROOT}/lib/github-rest.sh"
The loop's natural terminus is finding quality, not a round count. Keep going, round after round, as long as Codex keeps surfacing new, real, fixable defects. Stop when it goes clear (all-clear or nothing confirmed-real — only slop remains), or when it re-raises a finding a prior round already dismissed (a "repeat" — you're going in circles). Two further exits keep the loop honest against a reviewer that rations findings over an unbounded input space:
- Relevance is a bar, not a counter. Findings outside the target repo's declared
context (
finding-bar.md§"declared context") and example-text consistency nits are dismissed, which means a round of only those hits the standard clear exit above — no separate streak to track. The failure this prevents is the operator confirming out-of-context findings as real, which is how loops run to the cap. "Found nothing that matters here" is the reachable exit; "found nothing" is not. - Class-jump rule. When a round's findings attack the fix the previous round shipped, stop patching that mechanism — redesign it (preferably by deleting the state or fallback under attack) or dismiss the class, and say which in the ledger. Instance-patching a mechanism under repeated attack is how loops run to the cap.
Front-load the exhaustive pass. When no strong local review ran at the current head
(pr-details' QUALITY ladder answers this), run one — antagonist-review or the language
plugin's deep review — and fix its batch before the first trigger below. The connector
rations a handful of findings per round; spending its rounds on what one local pass would
have enumerated is the expensive way to find them.
The round cap (default 10) is a high safety backstop against oscillation, not the intended exit — most PRs converge well before it.
The defining feature is the anti-slop gate: rather than fixing every Codex finding blindly, this skill triages each one first, records why it dismissed anything, dismisses the slop on the PR itself (resolves the review thread with the reason, so it doesn't linger or block merging), and never silently waves slop through.
Usage: /workflow:codex-ship [PR-number|PR-URL] [--max-rounds <n>] [--second-opinion mandatory|auto|off]
Examples: /workflow:codex-ship 152 · /workflow:codex-ship 152 --max-rounds 15 ·
/workflow:codex-ship --second-opinion mandatory
Language plugin dispatch
This skill is language-agnostic, but two of the things it dispatches — address-review
(the fix pass) and ship (verify, watch CI, merge) — live in a language plugin, not
here. Resolve which one before the loop needs it, and store it as LANG_PLUGIN.
detect_checks (Phase 0, step 4) sets PROJECT_KIND. Map it:
PROJECT_KIND |
LANG_PLUGIN |
|---|---|
node |
ts-workflow |
go |
go-workflow |
| anything else | ask the driver |
For make / rust / unknown — or when the mapped plugin is not installed — ask the driver
(per driver-interaction.md) which installed plugin provides ship and address-review, and
use the name they give.
If no such plugin is installed, do not stall the loop: run the fix pass inline in this
session (apply the confirmed-real findings yourself, run LOCAL_CHECKS, then a plain
git commit + git push), and at the merge checkpoint hand the PR back to the user instead of
dispatching a ship skill. Every /${LANG_PLUGIN}:... dispatch below has that inline fallback.
Resolve LANG_PLUGIN once per run and reuse it; do not re-ask each round.
Model roles (edit this block; never hard-pin a model name in loop logic)
The loop assigns work to roles, not fixed models. Resolve each to whatever tier is available the week you run this. The cost story: the orchestrator does cheap mechanical babysitting; the judge and fixer need real reasoning; the second opinion is a different model family (local Codex CLI), so it catches what the primary models miss.
| Role | Default tier | Job |
|---|---|---|
| Orchestrator | cheapest fast tier | trigger, poll, SHA-freshness, round-count, dispatch, completion. Zero code judgment. |
| Judge | strong tier | per finding → verdict real / wrong / redundant / out-of-scope + one-line reason |
| Fixer | strong tier | one /${LANG_PLUGIN}:address-review pass on the confirmed-real set — fixed at the class level per fix-at-the-root.md — one commit, one push (or the inline fix pass when no language plugin is installed) |
| Second opinion | local codex exec CLI |
AGREE/DISAGREE per finding against the working tree |
Reference bar for the second-opinion policy = the current strong tier (whatever the frontier-quality model is when you run this). The rule below compares the judge against that bar, never against a model name.
Parse arguments
MAX_ROUNDS=10 # safety backstop against oscillation, NOT the intended exit
SECOND_OPINION_ARG="auto" # auto | mandatory | off
PR_NUM=""; URL_REPO=""
SKIP_NEXT=""
for arg in $ARGUMENTS; do
case "$SKIP_NEXT" in
rounds) MAX_ROUNDS="$arg"; SKIP_NEXT=""; continue ;;
so) SECOND_OPINION_ARG="$arg"; SKIP_NEXT=""; continue ;;
esac
case "$arg" in
--max-rounds) SKIP_NEXT="rounds" ;;
--second-opinion) SKIP_NEXT="so" ;;
https://*) # full PR URL: pins host and repository, so a fork
# checkout's same-numbered PR can never be addressed by
# mistake, and a GHE URL never falls back to github.com
URL_HOST=$(printf '%s' "$arg" | sed -E 's#^https?://([^/]+)/.*#\1#')
URL_REPO=$(printf '%s' "$arg" | sed -E 's#^https?://[^/]+/([^/]+/[^/]+)/pull/[0-9]+.*#\1#')
PR_NUM=$(printf '%s' "$arg" | sed -E 's#.*/pull/([0-9]+).*#\1#')
export GH_HOST="$URL_HOST" ;; # every gh call targets the URL's host
*) PR_NUM="$arg" ;;
esac
done
[ -z "$PR_NUM" ] && PR_NUM=$(github_current_pr 2>/dev/null | jq -r '.number // empty')
if [ -z "$PR_NUM" ]; then echo "ERROR: no PR. Pass a number or run from a PR branch."; exit 1; fi
# A URL-pinned repo wins; otherwise the ambient repository is the target.
# `--json` on any gh command is a GraphQL call; fall back to the git remote when it is drained.
# Two `sed -E` stages, not one: a lazy `+?` is a GNU extension and BSD/macOS sed errors on it.
REPO="${URL_REPO:-}"
[ -n "$REPO" ] || REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null) \
|| REPO=$(git remote get-url origin | sed -E 's#\.git$##' | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#')
echo "PR: #$PR_NUM | repo: $REPO | max-rounds: $MAX_ROUNDS | second-opinion arg: $SECOND_OPINION_ARG"
Store PR_NUM, REPO, MAX_ROUNDS, SECOND_OPINION_ARG.
Phase 0: Preflight
-
Working tree clean?
git status --porcelainmust be empty. If not, stop and ask. And the target must be the ambient repository: when a URL pinnedREPOand the ambient checkout's repository differs, stop — the fixer (address-reviewtakes only a number and resolves its repo from the checkout) and the ship phase both act on the ambient tree, so a cross-repo run would fix or ship the wrong PR. Tell the user to run from a checkout of$REPO. -
API budget — GitHub meters GraphQL and REST separately, and this skill leans on both. Check before the first round;
/rate_limitis free and never counts against either budget:GQL_LEFT=$(gh api rate_limit --jq '.resources.graphql.remaining') GQL_RESET=$(gh api rate_limit --jq '.resources.graphql.reset|todate') REST_LEFT=$(gh api rate_limit --jq '.resources.core.remaining') echo "api budget: graphql=$GQL_LEFT (resets $GQL_RESET) rest=$REST_LEFT"If
GQL_LEFTis under ~50, run in REST fallback mode (see below) rather than failing mid-round. GraphQL exhaustion is not hypothetical: a busy day ofgh pr view/gh pr checksdrains it while REST still has thousands left, and everyghcommand that takes--jsonis a GraphQL call under the hood. -
Linked issue — capture for the final report and closure:
gh pr view $PR_NUM -R "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[].number' # REST fallback (no closingIssuesReferences in REST — parse the PR body's closing keyword): gh api repos/$REPO/pulls/$PR_NUM --jq '.body' \ | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) #[0-9]+' | grep -oE '[0-9]+' | head -1Store the first as
ISSUE_NUM(may be empty — that's fine, just note it). -
Known non-findings & prior review state: locate the repo's AGENTS.md/CLAUDE.md and collect any "Not a finding" / accepted-behavior notes, plus any antagonist-review ledger for this PR that is still on disk. These are prior dismissals the judge must honor (finding-bar.md: the same ghost never costs the human twice) — a Codex finding matching one is dismissed with a pointer, not re-litigated, unless the diff changed the facts the dismissal rests on.
-
Resolve the repo's check commands so we don't burn Codex rounds on lint noise:
source "${CLAUDE_PLUGIN_ROOT}/lib/detect-checks.sh" detect_checksBuild
LOCAL_CHECKSfrom the non-empty results, in order:$CHECK_LINT,$CHECK_TYPECHECK,$CHECK_TEST.detect_checksreads the repo's real toolchain (package.jsonscripts,go.mod,Cargo.toml) and backfills any check the primary toolchain lacks from a same-namedMakefiletarget. An empty variable means that check does not exist — never guess a command for it. IfLOCAL_CHECKScomes out empty, ask the user for the repo's check commands.detect_checksalso setsPROJECT_KIND, which the language-plugin dispatch above needs — this is the call that resolves it. -
Local courtesy green: run
LOCAL_CHECKS. Fix trivial lint locally (≤5 attempts) and commit+push if needed. Don't chase deep test failures here — that's what the loop is for.
Print: === PHASE 0 COMPLETE: preflight clean, checks: <LOCAL_CHECKS>, issue #$ISSUE_NUM ===
REST fallback mode (GraphQL budget exhausted)
REST is not a drop-in replacement — one capability has no REST equivalent at all. Review
thread objects live only in GraphQL: REST's review-comment objects carry id, node_id,
path, body, user, in_reply_to_id, and pull_request_review_id, but no thread id and no
isResolved field (verified against the live API — the field simply does not exist in the REST
schema). So in fallback mode you can still read findings, reply, and unblock a review, but you
cannot read resolution state and cannot resolve a thread.
| Need | GraphQL | REST fallback |
|---|---|---|
| Repo name | gh repo view --json nameWithOwner |
git remote get-url origin |
| PR metadata / state | gh pr view --json … |
gh api repos/$REPO/pulls/$PR_NUM |
| CI status | gh pr checks |
gh api repos/$REPO/commits/$SHA/check-runs, …/status |
| Linked issue | closingIssuesReferences |
parse closing keyword from .body |
| Codex review comments | reviewThreads.nodes[].comments |
gh api repos/$REPO/pulls/$PR_NUM/comments --paginate |
| Codex reviews | gh api …/reviews (already REST) |
unchanged |
| Reply to a thread | addPullRequestReviewThreadReply |
POST …/pulls/$PR_NUM/comments/$COMMENT_ID/replies |
| Dismiss a blocking review | dismissPullRequestReview |
PUT …/pulls/$PR_NUM/reviews/$REVIEW_ID/dismissals |
Read isResolved |
reviewThreads.nodes[].isResolved |
none — GraphQL only |
| Resolve a thread | resolveReviewThread |
none — GraphQL only |
Reconstruct threads in REST by grouping pulls/$PR_NUM/comments on in_reply_to_id (a comment
with no in_reply_to_id is a thread root; replies point at that root's id). That reproduces
grouping and lets you post the dismissal reason, but never resolution state.
Because the last two rows have no fallback, in REST mode you must not report threads as resolved. Do the reply (the paper trail still lands), record each thread that still needs resolving, and take exactly one of:
- Wait it out. The GraphQL budget refills hourly;
$GQL_RESETfrom Phase 0 is the exact time. This is usually the right answer — a wait of minutes beats a half-finished ledger. - Finish in REST and hand off the remainder. Print the list of comment URLs whose threads are still unresolved and say plainly that resolution was deferred for API budget, not because the findings are open.
Never silently skip resolution and report success — a Codex thread left open still gates the merge, so a run that claims to have cleared them and hasn't is worse than one that stops.
The second opinion is mandatory when the judge is a cheaper/weaker model than the strong-tier reference bar — because you cannot trust a cheap model's solo triage — and discretionary (the judge invokes it per-finding, for the ambiguous ones) when the judge is at or above the bar.
Resolve it, then confirm with the user before the first round (use the active surface's
structured-input capability per driver-interaction.md):
JUDGE = <resolved Judge-role model>
if SECOND_OPINION_ARG == "mandatory": POLICY = MANDATORY
elif tier(JUDGE) < strong-tier bar: POLICY = MANDATORY # floor: cannot be lowered
elif SECOND_OPINION_ARG == "off": POLICY = OFF
else: POLICY = DISCRETIONARY
- MANDATORY → run the local
codex execjuror on every finding, both directions (confirm real and confirm slop). - DISCRETIONARY → the judge decides per finding; run the juror on anything it marks ambiguous/borderline.
--second-opinionmay raise the policy (force mandatory) but may never lower it below a mandatory floor — if the judge is below the bar,offis refused with a warning.
Announce the resolved policy and the judge model in one line, e.g.
Second opinion: MANDATORY (judge below the strong-tier bar — every finding cross-checked).
Print: === KICKOFF: max-rounds=$MAX_ROUNDS, second-opinion=$POLICY ===
The round loop
Initialise ROUND=0 and an empty ledger (findings seen, verdict, class statement,
second-opinion result, action, reason, github_dismissed once the slop thread is resolved on
the PR, and — for fixed findings — siblings_fixed/root_fix from the fixer's ladder). The
ledger is the artifact you present at the checkpoint — keep it faithfully.
Step A — Consume existing Codex feedback first; trigger only if there is none
Never post @codex while unaddressed Codex feedback is already sitting on the PR. A new
trigger on top of open threads burns a review, and the fresh review often re-raises the same
findings — which then looks like the "repeat" stop when it's really self-inflicted. Check in
this order:
- Unresolved Codex review threads? Reuse the Step E GraphQL query (unresolved threads
authored by
chatgpt-codex-connector). If any exist — from any prior round, this session or not — those ARE this round's findings. Skip the trigger and skip Step B entirely; take the thread bodies straight to Step C for judging. Mark themsource: preexistingin the ledger. - A Codex verdict for the current HEAD already posted? Do not assume a push produced
one. Codex's own about-box lists exactly three review triggers — open a PR, mark a draft
ready, and comment
@codex review— and a plain push to an already-open PR is not among them (observed in practice: after a fixer push, polling returned nothing until an explicit@codex reviewwas posted). So treat this check as opportunistic only: look once, and if nothing is there for this SHA, fall straight through to check 3 rather than waiting out the poll budget. Even when an all-clear comment for the current HEAD does exist, still post an explicit trigger and consume its verdict before declaring CLEAR — a requested review belongs on the record. Check for a review whose body containsReviewed commit: ${HEAD_SHA:0:10}:
Non-zero → consume that verdict directly (findings → Step C; all-clear → CLEAR). No trigger, no poll.HEAD_SHA=$(github_pr "$PR_NUM" | jq -r '.head.sha') SHA="${HEAD_SHA:0:10}" gh api repos/$REPO/pulls/$PR_NUM/reviews --paginate --jq \ "[.[] | select(.user.login|startswith(\"chatgpt-codex-connector\")) | select(.body|contains(\"$SHA\"))] | length" - Neither → trigger a fresh review:
gh pr comment $PR_NUM -R "$REPO" -b "@codex review" # official documented trigger; reviews current HEAD@codex reviewis the canonical trigger Codex's own about-box documents. A bare@codexis equivalent — either form triggers a fresh review of current HEAD. There is also@codex address that feedback, which makes Codex itself update the PR with fixes — this skill deliberately does not use it (its anti-slop gate judges each finding and fixes only the confirmed-real set; letting Codex auto-apply would wave slop through).
Codex reacts 👀 on the trigger comment when it picks the mention up. If no 👀 after ~10 min,
the trigger was dropped — re-post @codex review once, then continue waiting. When a review
has findings the body opens 💡 Codex Review — Here are some automated review suggestions...
with a Reviewed commit: <10-char SHA>; no findings = a 👍 reaction (or the all-clear
issue comment).
Step B — Poll for the verdict on THIS HEAD
Codex signals two different ways; watch both, filtered to HEAD_SHA:
- Findings → a review object on
pulls/$PR_NUM/reviews, body containsReviewed commit: ${HEAD_SHA:0:10}. Review bodies quote a 10-char short SHA, not the full 40-char SHA — a filter matching the full SHA silently times out every round. Pass--paginate./reviewsreturns 30 per page and the newest review is on the LAST page; a PR that has been through a few rounds already has more than one page, so an unpaginated poll never sees the review it is waiting for and times out with the verdict sitting on page 2. - All-clear → a plain issue comment starting
Codex Review: Didn't find any major issues. (All-clear is NOT reliably a 👍, and there is no review object in this case.) All-clear bodies do carryReviewed commit: <10-char SHA>, same as review bodies — so filter them by SHA and bycreatedAtafter this round's trigger time. Either filter alone is weaker: a stale all-clear from an earlier round is a false-positive waiting to happen.
The bot's login differs by API — match it with startswith, never ==. REST
(.user.login) returns chatgpt-codex-connector[bot]; GraphQL (.author.login) returns
chatgpt-codex-connector with no suffix. An == "chatgpt-codex-connector" filter against
REST matches nothing, so the all-clear is invisible and every clear round times out into a
false ESCALATE. Use select(.user.login | startswith("chatgpt-codex-connector")) and it
works on both.
Poll every ~2 min, timeout ~12 min. If the timeout hits with no signal, re-post bare @codex
once; if still nothing, ESCALATE (connector unavailable).
Parse with --jq on the gh api call itself — never echo "$RESULT" | jq. zsh's echo
interprets \n escape sequences inside JSON string values and corrupts the document (jq fails
with "control characters must be escaped"), so the poll loop runs forever seeing no signal.
Reference poll — every trap above is already handled here (10-char SHA, --paginate,
startswith login, trigger-time filter, --jq on the call). Capture TRIGGER from the
trigger comment's own created_at, not from local clock arithmetic:
SHA="${HEAD_SHA:0:10}"
TRIGGER=$(gh api repos/$REPO/issues/comments/$TRIGGER_COMMENT_ID --jq '.created_at')
for i in $(seq 1 7); do
sleep 100
REV=$(gh api repos/$REPO/pulls/$PR_NUM/reviews --paginate --jq \
"[.[] | select(.user.login|startswith(\"chatgpt-codex-connector\"))
| select(.body|contains(\"$SHA\"))] | length" 2>/dev/null || echo 0)
[ "${REV:-0}" != "0" ] && { echo "FINDINGS for $SHA"; break; }
CLEAR=$(gh api "repos/$REPO/issues/$PR_NUM/comments?since=$TRIGGER&per_page=100" --paginate --jq \
"[.[] | select(.user.login|startswith(\"chatgpt-codex-connector\"))
| select(.body|startswith(\"Codex Review: Didn't find any major issues\"))
| select(.created_at > \"$TRIGGER\")] | length" 2>/dev/null || echo 0)
[ "${CLEAR:-0}" != "0" ] && { echo "ALLCLEAR for $SHA"; break; }
done
Before concluding "no verdict", check by hand once. Both known polling bugs presented
identically to a genuinely silent connector — the loop reports nothing while the verdict is
already on the PR. On timeout, list Codex's reviews submitted_at > $TRIGGER with --paginate
and look before you re-post or escalate.
- All-clear signal → CLEAR, break the loop.
- Findings → continue to Step C.
Step C — Judge (strong model)
Hand the findings + the PR diff + the current ledger + the full text of
finding-bar.md + the Phase 0 known non-findings to the Judge role. For each finding,
return a verdict and a one-line reason:
real— a genuine defect that clears the finding bar's four points: introduced by this diff, fixable in this PR, survives self-refutation against the actual code, and has a concrete traced failure. Anything on the never-findings list is neverreal.wrong— Codex is mistaken (misread the code, false positive), or the finding fails the bar (theoretical, untraced, never-finding class).redundant— already handled elsewhere in the diff, a duplicate of a prior finding, or a match to a known non-finding / prior dismissal (dismiss with the pointer).out-of-scope— pre-existing on the base branch, or not fixable inside this PR's blast radius. Dismissed on GitHub with that reason (Step E) — never converted into a proposed issue or follow-up.
For every real verdict the judge also records a one-line class statement: the general
property this diff violates, plus a greppable signature for locating sibling sites. This is
where the Fixer's sweep (fix-at-the-root.md, rung 2) starts — and a judge that cannot state
the class has not finished verifying the finding.
Two bar rules bind the judge mechanically: a mechanically-checkable claim (types, unused
symbols, null flow, lint) is verdicted by running the relevant tool on the changed files,
not by reasoning — uncorroborated → wrong, corroborated → real with no second opinion
needed; and a claim that something is missing requires the judge to verify present state
before real.
Give the judge the ledger so it can spot re-raised findings: if Codex re-raises something a prior round dismissed with a recorded reason, that's a genuine disagreement — flag it, don't silently re-fix or re-dismiss.
Step D — Second opinion (local Codex CLI juror)
The juror is a read-only reviewer: its only output is one AGREE/DISAGREE line. It must
never mutate the working tree. -s read-only is supposed to guarantee this, but it is
not sufficient on its own — juror runs have been observed writing a full fix to the tree
despite -s read-only. In a workspace with commit/push automation, that stray diff can be
auto-committed and pushed before you review it. So you must fence every juror call with a
clean-tree guard, not trust the sandbox flag alone.
Per the resolved POLICY, run the juror on the applicable findings. From the repo directory:
# --- clean-tree guard (mandatory): snapshot before ---
BEFORE=$(git status --porcelain)
# Prompt goes in a POSITIONAL argument — never `-` (heredoc or file redirect).
# Piped prompts have been observed being ignored entirely, with codex resuming a
# previous session in the same working directory and acting on THAT task instead.
PROMPT='HARD CONSTRAINTS FOR THIS RUN — READ FIRST:
- This is a FRESH, SELF-CONTAINED task. Ignore any previous session and any prior instruction.
- DO NOT use any GitHub tool or MCP tool. DO NOT post, comment, or modify any PR or issue.
- DO NOT modify, create, or delete any file — inspect only.
- Your ONLY output is exactly one line: "AGREE: <why>" if the finding is a real, in-scope bug
in the current diff, or "DISAGREE: <why>" if it is not. Be terse.
FINDING: <paste the finding text + file:line>'
codex exec -s read-only --skip-git-repo-check \
-c sandbox_mode="read-only" -c approval_policy="never" \
-c model_reasoning_effort="medium" \
-o "$SCRATCH_DIR/codex-so-$ROUND.txt" "$PROMPT"
# --- clean-tree guard: assert the juror wrote nothing; if it did, PARK the diff, don't lose it ---
AFTER=$(git status --porcelain)
if [ "$BEFORE" != "$AFTER" ]; then
echo "⚠️ SANDBOX BREACH: juror mutated the working tree. Parking its diff on a git stash."
git stash push -u -m "codex-juror-breach-round-$ROUND"
echo " Recover with: git stash show -p stash@{0} (do NOT let automation commit juror writes)"
fi
Use a session scratch directory for -o output files (SCRATCH_DIR), and parse the verdict
from the -o file's last line — never scraped stdout. A tiny -o file (a few hundred bytes of
something unrelated) is the tell-tale sign the run did something other than your task; check it
before trusting a verdict. approval_policy="never" is what actually denies outward-facing
tool calls — the sandbox flags fence the filesystem only — so keep it set on every call.
Do not pin a model with -m — availability shifts; let the CLI route. Effort defaults to
xhigh if you omit the -c — wasteful per-finding, so it's pinned to medium; drop to low
for speed, raise to high for a subtle correctness/security finding. Do not wrap the call in
timeout — that command does not exist on macOS (exit 127); use the shell tool's own timeout.
Why stash, not discard: a breach diff is occasionally a good fix. Parking it on a stash
keeps the tree clean for the juror's actual job and preserves the work, so the Fixer step
can adopt it deliberately with your review instead of automation committing it blind. Never
git checkout/reset it away, and never proceed to completion with a juror breach still
uncommitted-and-unexplained.
Tie-break rules:
- Judge
real+ juror AGREE → confirmed-real (fix it). - Judge
wrong/redundant/oos+ juror DISAGREE → confirmed-dismiss (record reason). - Split (judge and juror disagree) → treat as real only for a correctness/security finding with a concretely traced failure path — per finding-bar.md the class label alone elevates nothing, and where a repro is cheap (a failing test, a script), run it and let it settle the split. Otherwise record as ambiguous and surface at the checkpoint. Never let a split silently drop.
Record every finding's {verdict, second_opinion, decision, reason} in the ledger.
Step E — Dismiss the slop on GitHub (mandatory, every round)
A dismissal that lives only in your ledger is invisible on the PR — the finding stays an
open review thread, fails any "review threads resolved" gate, and can block merging. So for
every finding this round decided confirmed-dismiss (slop / wrong / redundant /
out-of-scope), you must dismiss it on GitHub with a reason, not just in the ledger. Do this
before fixing, so the PR reflects reality regardless of how the round exits (CLEAR,
ESCALATE, or continue).
(Thread discovery/resolution is one of the few legitimate GraphQL uses in these workflows —
see the GraphQL-budget discipline in the sibling ship skill; everything else stays on REST.)
-
Resolve the review thread with a reason reply. Fetch Codex's unresolved threads, match each to a dismissed finding by
path/body, reply with the recorded reason, then resolve.If the GraphQL budget is drained, this step is the one that cannot be completed in REST — see "REST fallback mode": post the reply via
POST …/pulls/$PR_NUM/comments/$COMMENT_ID/replies, then either wait for$GQL_RESETor hand off the unresolved list. Do not report resolution you did not perform.OWNER=${REPO%%/*}; NAME=${REPO##*/} # from the REPO captured in Parse arguments # list unresolved Codex threads (id + first comment for matching) gh api graphql -f query=' query($o:String!,$n:String!,$num:Int!){ repository(owner:$o,name:$n){ pullRequest(number:$num){ reviewThreads(first:100){ nodes{ id isResolved comments(first:1){ nodes{ id author{login} path body } } } } } } }' \ -f o="$OWNER" -f n="$NAME" -F num=$PR_NUM \ --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | select(.comments.nodes[0].author.login|startswith("chatgpt-codex-connector")) | {id, path:.comments.nodes[0].path, body:.comments.nodes[0].body}' # reply with the dismissal reason (paper trail), then resolve the thread gh api graphql -f query=' mutation($t:ID!,$b:String!){ addPullRequestReviewThreadReply(input:{ pullRequestReviewThreadId:$t, body:$b}){ comment{ id } } }' \ -f t="$THREAD_ID" -f b="Dismissed (slop): <one-line recorded reason>. Second opinion: <AGREE/DISAGREE>." gh api graphql -f query=' mutation($t:ID!){ resolveReviewThread(input:{threadId:$t}){ thread{ isResolved } } }' \ -f t="$THREAD_ID" -
If Codex submitted a blocking review (state
CHANGES_REQUESTED) whose findings were all dismissed, dismiss the review object too so it stops gating merge:gh api graphql -f query=' mutation($r:ID!,$m:String!){ dismissPullRequestReview(input:{ pullRequestReviewId:$r, message:$m}){ pullRequestReview{ state } } }' \ -f r="$REVIEW_ID" -f m="Findings triaged as slop/out-of-scope; see resolved threads for per-finding reasons."
Only dismiss what you recorded a reason for. Never blanket-resolve Codex threads to clear a
gate — a confirmed-real finding's thread stays open until the Fixer's commit addresses it, and
an ambiguous/split finding stays open for the human checkpoint. Mark each dismissed finding
github_dismissed: true in the ledger once its thread is resolved.
Step F — Converge or fix
The two natural exits — both are the loop working as designed, not failures:
- confirmed-real is empty → Codex surfaced only slop/wrong/out-of-scope this round → it has run out of genuine value → CLEAR, break. (This "bad findings only" round is the expected terminus for most PRs.)
- Re-raised-dismissed finding present → Codex is repeating a finding a prior round already dismissed with a recorded reason → you're going in circles → ESCALATE (human settles the disagreement). This is the "repeat" stop.
Otherwise there are new, confirmed-real findings → keep going. Dispatch the Fixer:
/${LANG_PLUGIN}:address-review $PR_NUM --no-watch
(LANG_PLUGIN resolved per "Language plugin dispatch" above; with no language plugin
installed, do the fix pass inline and git commit + git push yourself.)
Restrict it to the confirmed-real set. --no-watch so it does one pass and exits — this loop
owns the outer cycle. It commits + pushes.
The fixer fixes classes, not instances. Hand it each finding's class statement from
Step C plus the full text of fix-at-the-root.md (paste it — the fixer cannot resolve
this plugin's path), and require the ladder: fix the reported instance, sweep this PR's diff
for traced siblings, fix the shared origin when one exists inside the blast radius. This
applies equally to the inline fallback. The payoff is convergence: a class fixed at the root
does not come back, while an instance-patched class is how round N+1 "discovers" findings
that are really the siblings of round N's. Copy each finding's siblings_fixed and
root_fix from the fixer's report into the ledger. (That push does not trigger a Codex review —
see Step A check 2. Next round must post an explicit @codex review; don't burn the poll
budget waiting on an auto-review.)
If address-review rebases and force-pushes, that is fine mid-loop — but it mints a new
HEAD SHA, which invalidates any per-SHA CI status or in-flight CI run. If your repo gates CI
behind a label or manual trigger, do not apply it mid-loop; wait for the final SHA (see
Completion). In a detached review worktree, gh pr checkout inside the fixer also breaks the
detached invariant — run the fixer from a normal branch checkout.
- Local safety net after the fixer: run
LOCAL_CHECKS; fix + commit + push if red. ROUND=$((ROUND+1)). IfROUND >= MAX_ROUNDS→ ESCALATE with the ledger. Hitting the cap is unusual — it means Codex kept surfacing new real findings for 10 straight rounds without converging, which is worth a human look (churn, a moving target, or a genuinely large PR).
Print each round: === ROUND $ROUND/$MAX_ROUNDS: F findings, R real, D dismissed, [continue|CLEAR|ESCALATE] ===
Pre-merge verification
Reach here only on CLEAR. Confirm the confidence gate:
- All Codex findings resolved or dismissed-with-reason (ledger complete)
-
LOCAL_CHECKSgreen locally on the final SHA - Branch rebased on the current base branch
- Every dismissed (slop) finding's review thread is resolved on GitHub with a reason
reply (Step E) — not just in the ledger. Verify zero unresolved Codex threads remain.
This check is GraphQL-only (REST has no
isResolved), so if the budget is drained it cannot be satisfied in REST: leave the box unchecked, and report the deferred threads rather than ticking it on the strength of the ledger alone.gh api graphql -f query=' query($o:String!,$n:String!,$num:Int!){ repository(owner:$o,name:$n){ pullRequest(number:$num){ reviewThreads(first:100){ nodes{ isResolved comments(first:1){ nodes{ author{login} } } } } } } }' \ -f o="${REPO%%/*}" -f n="${REPO##*/}" -F num=$PR_NUM \ --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | select(.comments.nodes[0].author.login|startswith("chatgpt-codex-connector"))] | length' # must print 0
If your repo gates CI behind a label or manual trigger (rather than running on every push), apply that trigger only now, once the head SHA is final — applying it mid-loop wastes a run, because the next fixer push or rebase strands the per-SHA status.
Checkpoint (human) — required before merging or hand-off
Present the ledger and stop. Do not merge or hand off without an explicit OK.
============================================
#$PR_NUM — CODEX SHIP: READY
============================================
Rounds: $ROUND / $MAX_ROUNDS
Confirmed & fixed: <n> <list: finding → fix (class · siblings_fixed · root_fix)>
Dismissed: <n> <list: finding → verdict → reason → second-opinion → GH thread resolved ✓>
Ambiguous/split: <n> <list, if any>
Local checks: <LOCAL_CHECKS> ✓ | rebased on base ✓
Linked issue: #$ISSUE_NUM
--------------------------------------------
Proceed to merge?
============================================
Every dismissal is shown with its reason so nothing is silently waved through. If any finding is ambiguous/split, call it out explicitly. Request the decision via the surface's structured-input capability, with options:
- Merge now via
/${LANG_PLUGIN}:ship— hands the PR to the language plugin's ship skill, which verifies, watches CI, handles remaining bot feedback, and merges. Offer this option only when a language plugin is resolved; otherwise offer "hand the PR back to me". - Stop here — for repos with their own merge automation (merge queues, board-driven lanes): report the ledger and leave the merge to that system.
Completion
- Merge now → dispatch
/${LANG_PLUGIN}:ship --no-mergefirst if the user wants a final human look at CI, otherwise/${LANG_PLUGIN}:ship. Ship owns CI-watching and the merge; do not duplicate its polling here. With no language plugin installed, report the final HEAD SHA and the ledger and let the user merge — this skill does not merge on its own. - Stop here → print the ledger summary, note the final HEAD SHA, and remind the user of anything their automation still needs (e.g. a CI-gating label on the final SHA, a board status flip). This skill never flips external project-board state itself.
Print: === CODEX SHIP COMPLETE: PR #$PR_NUM, $ROUND rounds, <merged|handed off> ===
Escalation & bail conditions
On any ESCALATE, stop and report the ledger + the reason — never merge past an unresolved disagreement or a round-cap.
| Condition | Cap | On bail |
|---|---|---|
| Codex trigger dropped (no 👀) | 1 re-post | Escalate (connector unavailable) |
| Poll for verdict | ~12 min | Check by hand once, re-post once, then escalate |
| Judge/juror split (non-correctness) | — | Record ambiguous, surface at checkpoint |
| Codex re-raises a dismissed finding | — | Escalate (human settles) — the "repeat" stop |
| Fix rounds | --max-rounds (default 10) |
Escalate with ledger — backstop, not the intended exit |
| Local lint fix (preflight) | 5 | Ask user |
| Juror sandbox breach | — | Stash the diff, warn, continue read-only work |
Never merge or hand off without the human checkpoint OK. Never blanket-resolve review threads without a recorded reason. Never apply a CI-gating trigger mid-loop — only on the final SHA.