review-loop
Skill4 starsChanged 12 days agoApache-2.0View on GitHub
Whole Claude–Codex review cycle on a PR unattended: Codex reviews, a fixer fixes and pushes, until nothing important is left. Takes a PR number and an OpenSpec change. Use for „aż przestanie zgłaszać uwagi”. NOT a single pass (review-fix).
- Reads credentials
- Commits and pushes
54 other projects use a version of this.
Adopted by
Most recently taken up by
- revisit-studies/studytheir own version, first seen Nov 9, 2026
- Mmbugraaslan/motion-plustheir own version, first seen Nov 8, 2026
- zane-tv/holdframetheir own version, first seen Nov 8, 2026
- seankoji-com/.agentstheir own version, first seen Nov 8, 2026
- BK202503/open-claude-alltheir own version, first seen Nov 8, 2026
- Latand/delegatustheir own version, first seen Nov 8, 2026
Used with
Projects with it also use motion-design, polish and 9 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
soilmassSkillmotion-design
Create animations and motion graphics using industry standards. Use when asked to animate UI elements, create video content, implement transitions, or build motion systems. Implements W3C Design Tokens, Material Design 3 baseline, and Lottie specification compliance.
Also in 5 projects that use this
HK-hubSkillpolish
Performs a final quality pass fixing alignment, spacing, consistency, and micro-detail issues before shipping. Use when the user mentions polish, finishing touches, pre-launch review, something looks off, or wants to go from good to great.
Also in 3 projects that use this
GoogleSkillto-spec
Turn the current conversation into a spec and publish it to the project issue tracker — no interview, just synthesis of what you've already discussed.
Also in 4 projects that use this
GoogleSkillimprove-codebase-architecture
Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
Also in 5 projects that use this
AWSSkillship
Finalize dev work on an issue branch — update docs, push, and open a PR.
Also in 4 projects that use this
MicrosoftSkillcouncil
Convene the persona panel — six orthogonal review lenses — on a target. Cold independent fan-out, debate-to-consensus, synthesized verdict with recorded dissent and a roster manifest.
Also in 3 projects that use this
- +4 more from google
Other versions54 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 54
- didi/mpx★ 4ksame name
- blockscout/frontend★ 310same name
- microsoft/bocpy★ 184same name
- 9thLevelSoftware/legion★ 76same name
- microsoft/Trieste★ 53same name
- revisit-studies/study★ 46same name
- timharris707/skills★ 44same name
- Latand/delegatus★ 37same name
- spxrogers/agentsync★ 35same name
- mathruffian-dot/claude-motion-explainer★ 30same name
44 more not listed
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository57 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- openspec-apply-changeSkill
- openspec-archive-changeSkill
- openspec-bulk-archive-changeSkill
- openspec-continue-changeSkill
- openspec-exploreSkill
- openspec-ff-changeSkill
- openspec-new-changeSkill
- openspec-sync-specsSkill
- openspec-verify-changeSkill
- openspec-apply-changeSkill
- openspec-archive-changeSkill
- openspec-bulk-archive-changeSkill
- openspec-continue-changeSkill
- openspec-exploreSkill
- openspec-ff-changeSkill
- openspec-new-changeSkill
- openspec-sync-specsSkill
- openspec-verify-changeSkill
- openspec-apply-changeSkill
- openspec-archive-changeSkill
- openspec-bulk-archive-changeSkill
- openspec-continue-changeSkill
- openspec-exploreSkill
- openspec-ff-changeSkill
- openspec-new-changeSkill
- openspec-sync-specsSkill
- openspec-verify-changeSkill
- openspec-apply-changeSkill
- openspec-archive-changeSkill
- openspec-bulk-archive-changeSkill
- openspec-continue-changeSkill
- openspec-exploreSkill
- openspec-ff-changeSkill
- openspec-new-changeSkill
- openspec-sync-specsSkill
- openspec-verify-changeSkill
- business-consultantSkill
- cfoSkill
- environment-setupSkill
- find-skillsSkill
- legalSkill
- linkedin-contentSkill
- prdSkill
- prepare-goalSkill
- prepare-openspec-goalSkill
- process-mappingSkill
- research-add-fieldsSkill
- research-add-itemsSkill
- research-deepSkill
- research-reportSkill
- researchSkill
- review-fixSkill
- slidesSkill
- tax-advisorSkill
- vibe-codingSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
18 rules. 0 are common, 4 are distinctive, and 14 are very specific to this project.
0
Common
Most of the 47 edited versions we read keep these.
4
Distinctive
This author's own choices. Worth a look.
14
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 37
Run once before iteration 1.
- Very specific: line 46
Note: do not pipe gh api JSON output through jq — it may not be installed on all machines.
- Very specific: line 49
If state is not OPEN, abort with: PR #<PR> is not OPEN — current state: <state>.
- Distinctive: line 60
If missing, list nearby candidates:
- Very specific: line 67
Ask the user: *"I can't find openspec/changes/<change-name>/.
- Very specific: line 93
If pointer.ok is false, print one warning and continue: the repository's AGENTS.md does not point Codex at REVIEW.md and at …
- Very specific: line 117
If the log file exists, scan it for entries with "pr": <PR>.
- Very specific: line 129
If not present, print a warning: *".review-loop.log is not in .gitignore.
- Distinctive: line 130
Do not auto-add — user decides.
- Very specific: line 272
If iteration > max: termination_reason = "max-iterations", go to Step 6.
- Very specific: line 278
Every event is one JSON line appended to <repo-root>/.review-loop.log, timestamped in UTC:
- Very specific: line 306
When conventions is not empty, add below it:
- Very specific: line 377
Write the error text into $SCRATCH/error.txt with your file-writing tool, and put nothing else in that file: not the log tail, not the line …
- Very specific: line 457
The four values are persisted before the block ends: Every command below runs in a new shell that inherits nothing, so TITLE, REASON, …
- Very specific: line 695
When REPORT_MISSING is set:
- Very specific: line 765
Never: read a failed, timed-out or unreadable review as clean.
- Distinctive: line 767
Never: start another review after a round that fixed nothing important.
- Very specific: line 771
Never: read or reply to pull-request review comments.
“Common” is measured: kept by at least 6 in 10 of the 47 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Review Loop
Orchestrate an automated Claude↔Codex review cycle on a pull request. Each iteration is one
review-fix pass in a sub-agent: Codex reviews the branch against the pull request's base, the
fixer judges each finding against the OpenSpec change, fixes what holds up, records every outcome
in the change's review.md, verifies, commits and pushes. The loop ends when a review comes back
clean, when what is left is below the importance line or was rejected, on an error, or at the
iteration cap — and every ending opens a sign-off record that only a person can close.
Input: /review-loop <PR-number> <openspec-change-name> [--max N]
PR-number— required. The PR to iterate on. No fallback togh pr viewon the current branch — the PR must be explicit.openspec-change-name— required. Directory name underopenspec/changes/<name>/.--max N— safety-net iteration cap (default5). Every iteration spends one Codex review from the user's plan; this is what bounds it.
The helper. Codex readiness is checked with the script that ships in the sibling review-fix
skill:
HELPER="<this skill's base directory>/../review-fix/scripts/codex-review.mjs"Take the base directory from the line announced when this skill loads, and check the file exists before pre-flight relies on it.
Steps
1. Pre-flight validation
Run once before iteration 1. Nothing in this step edits, commits or pushes.
1.1. Detect repo and PR.
gh repo view --json nameWithOwner --jq .nameWithOwner
gh pr view <PR> --json number,url,headRefName,baseRefName,stateNote: do not pipe gh api JSON output through jq — it may not be installed on all machines.
Parse JSON inline. The --jq flag above is safe because it's processed by gh itself.
If state is not OPEN, abort with: PR #<PR> is not OPEN — current state: <state>. Otherwise
store headRefName, baseRefName, url and nameWithOwner. If the current branch
(git branch --show-current) is not headRefName, abort and say which branch to check out — the
review reads the local branch, and the loop does not switch branches in someone's working tree.
1.2. Verify OpenSpec change directory exists.
ls openspec/changes/<change-name>/If missing, list nearby candidates:
ls openspec/changes/
ls openspec/changes/archive/ | tail -10Ask the user: "I can't find openspec/changes/<change-name>/. Candidates listed above. Which is
correct, or should I abort?" — pause until user responds. Do not guess.
1.3. Check the user wants a cycle driven, not a review written.
Requests like "zrób review tego PR-a i powiedz co jest nie tak" trigger this skill at full rate,
and no wording of the description prevents it — three variants were measured against the trigger
set in evals/ and none moved the number. Topical overlap beats an exclusion clause, so the check
belongs here instead.
An explicit /review-loop <PR> <change> is itself the request for a cycle; this check is for a
skill picked from free text. If nothing in such a request implies repetition — no until, aż,
keep going, repeat, no round count, no complaint about having to re-run the review by hand —
then the user wants something else. An opinion with no changes is /codex:review, which the user types; one review-and-fix pass
is review-fix. Say so in one line and stop.
1.4. Codex is ready.
node "$HELPER" preflightExit 2 ("ok": false) means the Codex plugin is not installed, its install record points nowhere,
or Codex is not signed in. Abort before the first review with the error field and the next step:
run /codex:setup, then start the loop again. There is no fallback reviewer.
If pointer.ok is false, print one warning and continue: the repository's AGENTS.md does not
point Codex at REVIEW.md and at openspec/changes/*/review.md (pointer.missing names what is
absent), so the reviewer works without the policy and may raise again what an earlier round
rejected. The templates are templates/REVIEW_TEMPLATE.md and templates/AGENTS_REVIEW_POINTER.md
in this plugin. The fixer still catches repeats against the record; the warning is about wasted
rounds, not lost decisions.
1.5. The working tree is clean.
git status --porcelainAnything listed aborts the run: "The working tree has uncommitted changes. Commit or stash them, then start the loop again." Uncommitted work would be reviewed as part of the branch and then swept into the round's commit, under a message that does not describe it.
1.6. Check for prior incomplete run.
REPO_ROOT=$(git rev-parse --show-toplevel)
ls "$REPO_ROOT/.review-loop.log" 2>/dev/nullIf the log file exists, scan it for entries with "pr": <PR>. If the most recent entry for this PR
is an iter-start or iter-end (not a terminate) from the last 24 hours, prompt: "Previous loop
for PR #<PR> stopped at iteration <K>. Resume from iteration <K+1>, or start fresh from iteration
1?" — wait for user choice, then set iteration accordingly. Either way the record carries every
earlier round: review-fix numbers its rounds from review.md, not from this counter.
1.7. Ensure .review-loop.log is gitignored.
grep -q '^\.review-loop\.log$' "$REPO_ROOT/.gitignore" 2>/dev/nullIf not present, print a warning: ".review-loop.log is not in .gitignore. Consider adding it."
Do not auto-add — user decides.
1.8. Initialize loop state (in-memory):
iteration = 1 (or resumed value from 1.6)
totals = { important_fixed: 0, minor_fixed: 0, rejected: 0, repeated: 0 }
conventions = []
termination_reason = null
last_pushed_sha = null1.9. Write run-start log entry (step 5 has the format).
2. Run iteration N
2.1. Log iteration start (iter-start, step 5).
2.2. Dispatch the fixer sub-agent. Use the Agent tool with subagent_type="general-purpose",
description="review-fix iteration <N> for PR <PR>", and this prompt (substitute placeholders with
live values):
You are running one iteration of an automated Codex review-and-fix loop.
Context:
- PR number: <PR>
- Repository: <owner>/<repo>
- PR branch (headRefName): <branch>; base: <baseRefName>
- OpenSpec change: <change-name>
- Iteration: <N> of <max>
Before doing anything else, read these files to understand the change's intent and scope:
- openspec/changes/<change-name>/proposal.md
- openspec/changes/<change-name>/design.md (only if it exists)
- openspec/changes/<change-name>/tasks.md
- openspec/changes/<change-name>/specs/**/*.md
- openspec/changes/<change-name>/review.md (only if it exists — the decisions so far)
- REVIEW.md (only if it exists — the review policy)
Then invoke the review-fix skill for this PR and change:
Skill(skill="ss:review-fix", args="<PR> --change <change-name>")
It runs the Codex review itself. Judge each finding against the intent in proposal.md and
design.md: a finding that conflicts with a decision documented there is a candidate for a reasoned
rejection, and one the record already rejected is a repeat, not a new question.
CRITICAL RETURN FORMAT:
The LAST LINE of your response must be a single-line JSON object, nothing else on that line. Lines
above may contain prose summary.
{"important_found": <int>, "important_fixed": <int>, "minor_fixed": <int>, "rejected": <int>, "repeated": <int>, "clean": <bool>, "pushed_commit_sha": <"40-char sha"|null>, "conventions": [<"rule">, ...], "error": <"message"|null>}
- the counts come from this round's rows in review.md, as review-fix's summary defines them;
- `clean`: true only when the review itself came back clean;
- `pushed_commit_sha`: the full 40-character sha of the last commit you pushed (`git rev-parse
HEAD`, not `--short`), or null if nothing was pushed;
- `conventions`: the candidate rules review-fix listed for CLAUDE.md, or [];
- `error`: null on success, or the reason the round could not complete — the review's own error
text when the review failed, quoted, not paraphrased.
Both nullable fields take a JSON string or the bare literal null. Never the string "null" —
it is truthy, and the loop reads it as a real error and a real sha.
Fixed one P1, rejected a P2:
{"important_found": 2, "important_fixed": 1, "minor_fixed": 0, "rejected": 1, "repeated": 0, "clean": false, "pushed_commit_sha": "5b431277b031648832d09305755ed48431374a4c", "conventions": [], "error": null}
Clean review, record committed and pushed:
{"important_found": 0, "important_fixed": 0, "minor_fixed": 0, "rejected": 0, "repeated": 0, "clean": true, "pushed_commit_sha": "9c01e2a77f3b9d0a6c41e5b2d8f7a1c3e4b5d6f7", "conventions": [], "error": null}2.3. Parse the sub-agent return. Take the sub-agent's full return text, split on newlines, find
the last non-empty line, and JSON.parse it. If parsing fails:
- Log
iter-errorwith the raw line truncated to 200 characters. - Set
termination_reason = "error". - Show the user the raw sub-agent output (full, not truncated) so they can debug.
- Go to Step 6.
Then normalise. If pushed_commit_sha or error came back as the string "null" (or "none",
or empty), replace it with a real null; a missing count is 0, a missing clean is false, a
missing conventions is []. The string "null" is truthy, so without this Step 2.4 ends every
good iteration as an error. The return format is a prompt, not a schema the runtime enforces, so
the parser should not assume it was obeyed.
2.4. If error is non-null:
- Log
iter-errorwith the error. - Set
termination_reason = "error". - Show the error to the user.
- Go to Step 6.
This is where an exhausted Codex allowance, a review that timed out, output the helper could not read, a failed check, a test changed without a recorded reason and a rejected push all arrive. None of them is a clean round, whatever the counts say.
2.5. Update cumulative totals and log iteration end.
totals.important_fixed += parsed.important_fixed
totals.minor_fixed += parsed.minor_fixed
totals.rejected += parsed.rejected
totals.repeated += parsed.repeated
conventions += parsed.conventions (without duplicates)
if parsed.pushed_commit_sha: last_pushed_sha = parsed.pushed_commit_shaLog iter-end with the counts and the commit.
3. Decide next action
From parsed, first match wins:
3.1. Clean. parsed.clean is true: the review found nothing. termination_reason = "clean", go
to Step 6.
3.2. Push expected but missing. parsed.important_fixed > 0 and parsed.pushed_commit_sha is
null: the fixer reports fixes the pull request does not have. Log iter-error with
"important_fixed>0 but no pushed_commit_sha", termination_reason = "error", go to Step 6.
3.3. An important finding was fixed. parsed.important_fixed > 0: the fix is new material the
reviewer has not seen. Go to Step 4.
3.4. Only minor findings. parsed.important_found == 0: everything the review raised sat below
the importance line, and the fixer fixed or recorded it. Another review would be spent on findings
that by definition do not justify one. termination_reason = "minor-only", go to Step 6.
3.5. Important findings, none fixed. Otherwise every important finding was rejected or repeated.
Their decisions are committed in the record, and a review of the same code would raise the same
things. termination_reason = "no-fixes", go to Step 6.
Only an important fix starts another review. The line between important and minor is the one
REVIEW.md draws on its Important: line — P0–P2 by default. Under Important: P0-P1, a round
whose only fixes were P2 findings ends here as minor-only or no-fixes, never as another
round. Minor fixes ride along in the round's commit and wait for the next review that an important
fix earns.
4. Continue to the next iteration
iteration += 1If iteration > max: termination_reason = "max-iterations", go to Step 6. Otherwise return to
Step 2. There is no wait between iterations: each review runs as soon as the previous round's push
is done, inside the next sub-agent.
5. Run log
Every event is one JSON line appended to <repo-root>/.review-loop.log, timestamped in UTC:
{"ts":"<ISO8601-UTC>","pr":<PR>,"change":"<change-name>","event":"run-start"}
{"ts":"<ISO>","pr":<PR>,"event":"iter-start","iteration":<N>}
{"ts":"<ISO>","pr":<PR>,"event":"iter-end","iteration":<N>,"important_found":<n>,"important_fixed":<n>,"minor_fixed":<n>,"rejected":<n>,"repeated":<n>,"clean":<bool>,"commit":"<sha or null>"}
{"ts":"<ISO>","pr":<PR>,"event":"iter-error","iteration":<N>,"error":"<text>"}
{"ts":"<ISO>","pr":<PR>,"event":"terminate","reason":"<termination_reason>","iterations":<N>}iter-error carries "raw":"<first 200 characters>" instead of error when the return line did
not parse. The log is the local trace of the run and what 1.6 resumes from; the record of what was
found and decided is review.md, which travels with the pull request.
6. Terminate + report
6.1. Log termination event (terminate, step 5).
6.2. Print final report to the user — and write the same text to a file.
Review loop finished after <iteration> iteration(s) — reason: <termination_reason>
Totals: important fixed=<n>, minor fixed=<n>, rejected=<n>, repeated=<n>
Record: openspec/changes/<change-name>/review.md
Last commit: <last_pushed_sha or 'none'>
PR: <url>When conventions is not empty, add below it:
Candidate rules for CLAUDE.md (proposed, not written):
- <rule>The loop never edits CLAUDE.md or any other agent instructions itself; a person decides which of
these become rules.
Choose the scratch directory here, because 6.4 needs the same one, and echo it so the later blocks can be given it literally:
SCRATCH="${SCRATCH:-$(mktemp -d)}" # outside the repository; throwaway
echo "scratch: $SCRATCH"Then write that block into $SCRATCH/report.md with your file-writing tool — not with a
shell heredoc, for the reason 6.4 gives. Printing the report into the session is not enough:
6.4 concatenates that file into the record, and the session transcript is exactly what the
record exists to outlive. A report that only ever reached the terminal leaves 6.4 with nothing
to cat, and the gate is opened empty.
6.3. Add a per-reason follow-up line below the report — into the session and into
$SCRATCH/report.md:
clean: The latest Codex review found nothing on the branch — every finding of earlier rounds is fixed or recorded inreview.md.minor-only: The latest Codex review raised only findings below the importance line (REVIEW.md'sImportant:line,P0-P2without one); they were fixed or recorded, and none of them justified another review.no-fixes: Every important finding of the latest review was rejected or repeated an earlier rejection — the reasons are inreview.md. Read them: a rejection a person disagrees with is reopened by deleting its row and running the loop again.max-iterations: Hit--max <N>iteration cap after a round that fixed an important finding, so the fix itself has not been reviewed. Review it manually or re-run with a higher--max.error: Loop aborted due to an error. Manual intervention required — the error text and the tail of the run log follow directly below this line.
The error line points below itself, and nothing points "above". Above is the session
transcript, which the sign-off issue exists precisely to outlive; a reader opening that issue
tomorrow has no "above" at all. So the error text goes under the follow-up line in the
session too, and 6.4 concatenates it under the report in the record — the same direction in
both places.
On an error termination, also write two files, one per thing the follow-up line promises.
The error text itself, as the sub-agent or the failing call produced it, goes into
$SCRATCH/error.txt. The tail of the run log, the only place the preceding iterations are
recorded, goes into $SCRATCH/log-tail.txt, from the shell:
SCRATCH="<paste the path 6.2 echoed>" # 6.2 ran in a different shell; this one has nothing
REPO_ROOT=$(git rev-parse --show-toplevel) # 1.4 set it, and that shell is gone too
if ! tail -n 20 "$REPO_ROOT/.review-loop.log" > "$SCRATCH/log-tail.txt" 2> "$SCRATCH/tail-err.txt"; then
printf '> ⚠️ **The run log could not be read** (`%s`), so this record carries no iteration\n> history for this run.\n' \
"$(tr '\n' ' ' < "$SCRATCH/tail-err.txt")" > "$SCRATCH/log-tail.txt"
echo "log tail: FAILED — the record will say so in its own body"
fiA failed tail leaves its file behind, which is why the status is read rather than the
file. The > redirect creates log-tail.txt before tail ever runs, so a missing or
unreadable .review-loop.log leaves an empty file that 6.4 would concatenate happily: the
record goes out without the iteration history the spec requires of it, and nothing anywhere
says so. The branch replaces that empty file with a line stating the absence, so it reaches the
record by the same route the log would have, and the echo tells the person watching the run —
a different reader, who will not open the issue. The stderr text is substituted through
printf's %s argument rather than into its format string, so whatever tail wrote cannot be
read as formatting.
Every shell block from here to the end of step 6 opens by restoring what it reads. Shell
state does not survive a tool call, and these two variables are the ones that fail silently:
an unset REPO_ROOT makes the tail read /.review-loop.log — no log tail, so the record loses
the iteration history the spec requires of it — and an unset SCRATCH writes every file to the
filesystem root or dies on a permission error. Neither announces itself; both produce a gate
that looks written.
Write the error text into $SCRATCH/error.txt with your file-writing tool, and put nothing else
in that file: not the log tail, not the line about an unreadable log, not a note of your own.
The two files stay apart because 6.4 reads error.txt as evidence. Its test asks whether
the file has content, and the answer has to mean the error text arrived. A file that also
held the tail would have content whenever the tail block ran, so an error text that never
reached it, or reached it empty, would pass as delivered: the record would go out without the
error and without saying so. 6.4 puts the two files one under the other itself.
On every other termination reason leave both files absent. 6.4 decides what to do about them
from the termination reason, not from whether a file happens to exist: absent on a clean
run is the expected state and passes silently, absent on an error run is a record that
promised the error below the follow-up line and did not carry it, and says so.
These files are the reason 6.4 can promise a durable record: they are what the follow-up line points at, and they are what stays behind when the session ends.
6.4. Open the sign-off issue — this is the human gate.
The loop has just finished and, up to this point, nothing requires a person to look. The printed report dies with the session, and a failing check goes stale the moment the next push paints the branch green. So the record of "this needs human eyes" has to be an object that outlives the run and that only a person can close.
Find an existing one by exact title, never by search terms. GitHub treats a title query as
independent words, so review-loop PR #<PR> also matches any unrelated open issue carrying
those words and that number — appending the report there would leave the gate uncreated while
looking like success. Build the canonical title once and compare it whole:
SCRATCH="<paste the path 6.2 echoed>" # outside the repository; throwaway
TITLE="review-loop finished on PR #<PR> — human sign-off required"
REASON="<termination_reason>" # the body block needs it and cannot re-derive it
EXISTING=""; LOOKUP_FAILED=""
# Every open issue, not the newest page. `gh issue list --limit N` stops at N, so a gate
# older than N newer issues reads as absent and the loop opens a duplicate; `gh api
# --paginate` follows the Link headers to the end and merges the pages of an array
# endpoint into one array — measured on gh 2.92.0: 11 pages at `per_page=1` came back as
# one valid array of 11. Do not add `--slurp` here; it is for endpoints returning an
# object, and it would wrap this array in another one the `find` below would miss.
if gh api --paginate "repos/<owner>/<repo>/issues?state=open&per_page=100" \
> "$SCRATCH/open-issues.json" 2> "$SCRATCH/lookup-err.txt"; then
EXISTING=$(TITLE="$TITLE" node -e '
const all = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const hit = all.find(i => !i.pull_request && i.title === process.env.TITLE);
if (hit) console.log(hit.number);
' "$SCRATCH/open-issues.json" 2>> "$SCRATCH/lookup-err.txt") \
|| { EXISTING=""; LOOKUP_FAILED=1; }
else
LOOKUP_FAILED=1
fi
# Shell state does not survive to the next tool call; these four do have to.
{ printf 'TITLE=%q\n' "$TITLE"
printf 'REASON=%q\n' "$REASON"
printf 'EXISTING=%q\n' "$EXISTING"
printf 'LOOKUP_FAILED=%q\n' "$LOOKUP_FAILED"
} > "$SCRATCH/gate.env"Four things that block is written to avoid:
SCRATCHis carried, not re-derived. 6.2 chose it and echoed it; paste that literal path at the top of this block and of every block below.SCRATCH="${SCRATCH:-$(mktemp -d)}"run a second time would mint a fresh directory — the report and the error file written in 6.2/6.3 would be in the old one, and the body would assemble empty. An unset variable is worse still: every path collapses to/sign-off.mdand both--body-filecalls die on a permission error.- The title is compared in Node against the file, not inside a
--jqexpression.ghdoes not pass jq's--argthrough--jq: the flag consumes the next token as the whole expression, so--jq --arg t "$TITLE" '...'is a malformed call. Piping it throughheadthen masks the failure, leavingEXISTINGempty and the loop creating a fresh issue every run. Theissuesendpoint also returns pull requests, which is what!i.pull_requestdrops. - A lookup that errored is not a lookup that found nothing — and that covers the parse, not
just the fetch. The
ifreads the listing's own status rather than the status of a pipeline ending inhead; the||after the command substitution covers the other half, because anodethat cannot start or cannot parse the response also yields an emptyEXISTINGthat is otherwise indistinguishable from "no record matched". Both routes setLOOKUP_FAILED. Say so in the report, then take the create path anyway — by the rule below, a duplicate beats a missing gate. - The four values are persisted before the block ends. Every command below runs in a new
shell that inherits nothing, so
TITLE,REASON,EXISTINGandLOOKUP_FAILEDwould arrive empty: the loop would then take the create path on a pull request that already has a gate, create it with an empty title, and read every termination as a clean one.printf %qwrites them back in a form.can read. The blocks below begin with the two lines that restore them.
Build the body in a file, never as a shell argument. The report carries literal backticks and may carry error text from the sub-agent; inside double quotes a backtick becomes command substitution, and the record is mangled or executed while the call still returns success. Write it outside the repository.
A quoted heredoc is not enough either — the delimiter is still live. <<'BODY' stops
command substitution, but nothing stops a line reading exactly BODY: one such line anywhere
in the report, in the sub-agent's error text or in the log tail closes the heredoc early, the
record is truncated there, and everything after it is handed to the shell as commands. Sub-agent
output and log lines are not text you chose, so they never travel through a heredoc. They arrive
as files, written with your file-writing tool; only the fixed closing sentence, which you
author and which carries no delimiter, comes from a heredoc:
SCRATCH="<paste the path 6.2 echoed>"
. "$SCRATCH/gate.env"
# Written in 6.2 and 6.3, never through a shell heredoc; any of them may be missing or empty
# here, so each is tested on its own:
# $SCRATCH/report.md — the report from 6.2 and its follow-up line from 6.3
# $SCRATCH/error.txt — on an `error` termination, the error text and nothing else
# $SCRATCH/log-tail.txt — on an `error` termination, the last lines of the run log, or the
# line saying the log could not be read
REPORT_MISSING=""; ERROR_MISSING=""
{
if [ -n "$LOOKUP_FAILED" ]; then
cat <<'WARN'
> ⚠️ **The open-issue lookup failed**, so this record was opened without knowing whether one
> was already open for this pull request. Check for a duplicate before closing.
WARN
fi
if [ -s "$SCRATCH/report.md" ] && cat "$SCRATCH/report.md"; then :; else
REPORT_MISSING=1
cat <<'NOREPORT'
> ⚠️ **The run report could not be read**, so this record carries the closing sentence and
> little else. The report was produced in the session that opened this issue and cannot be
> recovered from here — re-run the loop on this pull request to get one.
NOREPORT
fi
if [ "$REASON" = "error" ]; then
printf '\n'
if [ -s "$SCRATCH/error.txt" ] && cat "$SCRATCH/error.txt"; then :; else
ERROR_MISSING=1
cat <<'NOERROR'
> ⚠️ **The error text could not be read.** This run ended in an error, and the follow-up line
> above promises the error text directly below it — it did not reach this record. It was
> produced in the session that opened this issue and cannot be recovered from here; re-run the
> loop on this pull request to get it.
NOERROR
fi
printf '\n\n' # the error text may lack a final newline; keep the tail off its last line
if [ -s "$SCRATCH/log-tail.txt" ] && cat "$SCRATCH/log-tail.txt"; then :; else
cat <<'NOLOG'
> ⚠️ **The tail of the run log did not reach this record**, so it carries no iteration history
> for this run.
NOLOG
fi
fi
cat <<'BODY'
Closing this issue is the sign-off. A person closes it after reading the pull request —
nothing else may: not this loop, not a later run, not a workflow.
BODY
} > "$SCRATCH/sign-off.md"
{ printf 'REPORT_MISSING=%q\n' "$REPORT_MISSING"
printf 'ERROR_MISSING=%q\n' "$ERROR_MISSING"
} >> "$SCRATCH/gate.env"cat of a file cannot terminate anything, so no line of the report and no line of the error
text can end the body early. That is the property a quoted delimiter alone does not give you.
A read that failed is not an empty report. The closing sentence comes from a heredoc and
always succeeds, so without the test above a missing or unreadable report.md yields a
footer-only body, a group that still exits 0, and a gate published as though it carried the
run. The record is still opened — a record nobody can read beats no record, which is the trade
this whole step is built on — but it says so in its own first lines, and REPORT_MISSING
carries the same fact into the report, because the two are read by different people. The brace
group runs in this shell rather than a subshell, which is what lets the flags survive the
redirect; they are appended to gate.env because the write block below is another tool call.
The error text is mandatory on an error termination, so it is keyed on the reason and not
on the file. [ -s error.txt ] alone answers "is there one", and a missing file then reads as
"there was nothing to say" — which on a clean run is true and on an aborted one is the record
losing the only thing it was opened to carry, while 6.3's follow-up line still promises it
below. REASON comes from gate.env for the same cause as everything else in this step: the
tool call that knew it has ended. On any other reason the branch is not entered at all, so a
clean run neither warns nor leaves an empty gap where the error would have been.
Inside that branch the test reads the error text and nothing else. That is why 6.3 keeps
the log tail in its own file. When the tail used to be appended to error.txt, the file had
content whenever the tail block ran, the line saying the log could not be read included, so an
error text that never arrived still passed the test. NOERROR could not fire, and the record
went out without the error and without saying so. The tail gets the same test of its own, and a
tail that never reached the body is stated where it would have been, not left as a gap.
The report and the error text are two flags, because they go missing separately. A single
flag set by either branch can choose only one session warning. A run whose report arrived and
whose error text did not would then tell the person watching that the report is missing. They
would look for the wrong part and never copy the error into the record. REPORT_MISSING and
ERROR_MISSING are each set only by their own branch, and both may be set at once. A missing log
tail sets neither. When tail failed, 6.3 has already told the session, and the record says so
where the tail would be.
On an error termination the body carries the error itself, not a pointer to it. The
follow-up line in 6.3 points below itself, and error.txt and log-tail.txt are concatenated
below the report, so the two agree. An earlier wording sent the reader to "the log entries
above" — which in the issue is nothing at all, since the entries above it are the session's,
and outliving that session is the whole purpose of the record.
Then append to the one you found, or create it — checking that it is still open before each attempt, and retrying once before giving up:
SCRATCH="<paste the path 6.2 echoed>"
. "$SCRATCH/gate.env"
# The listing was a snapshot, and so is every re-read of it. A person may close the record in
# the seconds before *either* attempt, and `gh issue comment` succeeds on a closed-but-unlocked
# issue — which would file this run under a sign-off already given, the one thing the
# closed-record rule below forbids. So the state is asked for inside the attempt, not once
# outside it: the retry five seconds later asks again rather than trusting what the first one
# was told. On anything other than a confirmed OPEN, create instead.
write_gate() {
if [ -n "$EXISTING" ]; then
STATE=$(gh issue view "$EXISTING" --repo <owner>/<repo> --json state --jq .state) || STATE=""
[ "$STATE" = "OPEN" ] || EXISTING="" # no `local`: the clear has to outlive the call
fi
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo <owner>/<repo> --body-file "$SCRATCH/sign-off.md"
else
gh issue create --repo <owner>/<repo> --title "$TITLE" --body-file "$SCRATCH/sign-off.md"
fi
}
GATE_WRITTEN=1
write_gate 2> "$SCRATCH/write-err.txt" || {
sleep 5
write_gate 2> "$SCRATCH/write-err.txt" || GATE_WRITTEN=""
}
# Both of these have to reach the reporting step, which is another tool call: EXISTING because
# the re-check inside the last attempt may have cleared it, GATE_WRITTEN because it is the only
# record that both attempts failed. `.` reads the last assignment of each, so appending is
# enough — and `write_gate` deliberately assigns EXISTING in the caller's scope, so the value
# persisted here is the one the attempt actually acted on.
{ printf 'EXISTING=%q\n' "$EXISTING"
printf 'GATE_WRITTEN=%q\n' "$GATE_WRITTEN"
} >> "$SCRATCH/gate.env"
if [ -n "$EXISTING" ]; then WHERE="append to #$EXISTING"; else WHERE="create"; fi
if [ -n "$GATE_WRITTEN" ]; then echo "gate: written ($WHERE)"
else echo "gate: NOT WRITTEN after two attempts ($WHERE) — the run is ungated"; fiCheck the exit status, and say it out loud when the write failed. Issues disabled, a
missing permission, GitHub briefly unavailable — any of them leaves the loop having printed a
report and created no gate, which is the single outcome this step exists to prevent. The
function above exists so the retry is one call rather than a second copy of the branch that can
drift from the first; an empty GATE_WRITTEN after it means both attempts failed.
The state check lives inside the function for the same reason the function exists. Put once above it, it is read by the first attempt and inherited by the second — and the five-second gap between them is exactly the window a person needs to read the pull request and close the record. The retry would then comment on a closed issue, succeed, and file the run under a signature already given: the failure this check exists to prevent, reintroduced by the retry that was supposed to make the step more robust. Inside, each attempt asks for itself, and a close landing between them turns the retry into a create.
GATE_WRITTEN is written down, not left in the shell. The block ends with a successful
assignment either way, so its exit status says nothing, and the variable itself dies with the
tool call — a reporting step reading it would see an unset variable on a failed write and on a
clean one alike, and would call an ungated run complete. It is appended to gate.env and
echoed; the reporting step restores SCRATCH, runs . "$SCRATCH/gate.env", and chooses its
warnings from GATE_WRITTEN, EXISTING, LOOKUP_FAILED, REPORT_MISSING and ERROR_MISSING
— with <error> taken from $SCRATCH/write-err.txt.
The re-check narrows the window; it does not close it. Nothing holds the issue open between
gh issue view and gh issue comment, so a close landing in that gap still appends to a
signed-off record. A few seconds instead of the whole body-assembly step is the most a
non-atomic pair of calls can offer — and it fails in the direction the rest of this step
prefers: an unreachable or ambiguous state reads as "not open" and takes the create path, so
the worst case is a duplicate rather than a report filed under somebody's signature.
Which line to print is decided by the values restored from gate.env, in this order. Exactly
one of the first three applies:
GATE_WRITTEN | EXISTING | |
| empty | empty | the create path failed |
| empty | set | the append path failed |
| set | either | the gate was written — add the lookup line below if LOOKUP_FAILED |
The create path failed — nothing at all records this pull request:
⚠️ Sign-off issue was NOT created (
<error>). Nothing records that this pull request needs human eyes — open one by hand before merging.The open-issue lookup also failed, so a record may already exist and this run could not see it — check before opening one. (this sentence only when
LOOKUP_FAILEDis set)
The append path failed — the gate exists and is open, it just does not carry this run:
⚠️ Sign-off issue #
<EXISTING>was NOT updated (<error>). The gate is open but its last report is from an earlier run — read the pull request itself before merging, and do not open a second issue.
The two are not interchangeable. Telling a reader that nothing was created when issue
#<EXISTING> is sitting open invites them to open the duplicate the exact-title lookup exists
to avoid.
The lookup failed and the write succeeded — the record exists, but was written blind.
LOOKUP_FAILED is why the same warning is built into the body; print it into the report too:
⚠️ The open-issue lookup failed (
<contents of $SCRATCH/lookup-err.txt>), so this run could not tell whether a sign-off issue was already open. It opened one anyway. If a duplicate is sitting beside it, close the one you did not read.
It goes in both places on purpose. The report is read by whoever watched the run; the issue is read by whoever opens it tomorrow, and only one of them knows the lookup never completed.
This line claims a record exists, so it is printed only when one does. Unconditionally, it sits under the create-path warning saying "it opened one anyway" directly below a line saying nothing was created — a report asserting both outcomes, which a reader resolves by believing whichever they read first. When both failed, the create-path warning governs and carries the lookup as its own second sentence.
The body was incomplete — printed alongside whichever of the three applies, one line for each flag that is set. Both may be.
When REPORT_MISSING is set:
⚠️ The sign-off record does not carry the run report — the report file could not be read while the body was assembled, so whatever was written carries its closing sentence and little else. Read the pull request on its own terms; the record cannot tell you what the loop did.
When ERROR_MISSING is set:
⚠️ The sign-off record does not carry the error text — the error file was missing, empty or unreadable while the body was assembled, so whatever was written says the run ended in an error without saying which. Copy the error from this session into the record before the session ends; nothing else still holds it.
Each line names only its own part, because each asks for something different. A missing report sends the reader to the pull request itself. A missing error text asks the person watching to copy it out of the session while they still can. A warning about the wrong part sends them to the wrong place, and the error is lost when the session ends.
Five things about this step are deliberate:
- It runs for every termination reason,
errorincluded. The reasons differ in what the person will find, not in whether one is needed; an aborted loop needs a human more than a clean one, not less. - The list-then-create is not atomic. Two loops finishing on the same pull request can both see nothing and both open an issue. A duplicate is noise; a missing one is a gate that was never there. Prefer the noise.
- A failed lookup takes the create path too, for the same reason:
LOOKUP_FAILEDmeans the loop does not know whether a gate exists, and guessing "yes" loses the record. - The lookup asks for open issues only, the state is re-read before every append, and a closed record is never reopened. A closed record is a sign-off somebody gave, for the state of the branch they read. A run finishing after it is work nobody has signed off, so it gets its own record rather than reviving a settled one. Reopening would be an automation undoing a person's close — the single act this whole step reserves for a human, and the reason a duplicate title may legitimately appear in the closed list over the life of a pull request.
- No label is passed, and none is required. This repository defines no label for the gate,
and the create path above deliberately does not invent one. Add
--labelonly in a repository where you have checked the label exists: a label that exists nowhere fails the create, and a failed create loses the record — which is the one outcome this step exists to prevent.
Error handling reference
| Scenario | Detection | Reaction |
| PR not OPEN | pre-flight gh pr view | abort, no loop |
| Current branch is not the PR's branch | pre-flight 1.1 | abort, name the branch to check out |
| Spec directory missing | pre-flight ls openspec/changes/<change>/ | prompt user, wait for correction |
| Nothing in the request implies repetition | pre-flight 1.3 | one line pointing at /codex:review or review-fix, stop |
| Codex plugin not installed, or its install record points nowhere | preflight exit 2 in 1.4 | abort before any review, name /codex:setup; nothing edited |
| Codex not signed in | preflight exit 2 in 1.4 | abort the same way, name /codex:setup |
No AGENTS.md pointer to REVIEW.md and the record | preflight → pointer.ok: false | one warning naming the templates; the loop runs |
| Uncommitted changes | git status --porcelain in 1.5 | abort: commit or stash first |
| Codex review fails: usage limit, non-zero exit, failure status | sub-agent error carries the reviewer's text | termination_reason="error", the text reaches the report and the sign-off record — never read as a clean review |
| Codex review runs past 540 s | the helper stops it; sub-agent error | termination_reason="error" |
| Review output announces findings the helper cannot parse, or names a path outside the repository | helper verdict error; sub-agent error | termination_reason="error", quoting the output |
| A check fails and the fixer cannot make it pass | sub-agent error | termination_reason="error", nothing from that round committed |
| An existing test changed without a reason in the record | the helper's test guard, exit 4; sub-agent error | termination_reason="error" naming the files, nothing from that round committed |
git push rejected | sub-agent error mentions push failure | termination_reason="error", user resolves rebase/merge |
| Sub-agent return line unparseable | JSON.parse fails on last line | termination_reason="error", show raw output |
| Important fixes reported but nothing pushed | Step 3.2 | termination_reason="error" |
| Review clean | clean: true | termination_reason="clean" |
| Only findings below the importance line | important_found == 0 | termination_reason="minor-only" — no further review |
| Every important finding rejected or repeated | important_found > 0, important_fixed == 0 | termination_reason="no-fixes" — no further review |
| An important finding fixed at the cap | Step 4 | termination_reason="max-iterations" |
| Session closed mid-run | the sub-agent never returns | the log keeps the last state; re-invoke offers resume via 1.6, and review.md holds every committed round |
Guardrails
- Never skip the OpenSpec read step inside the sub-agent prompt — judging a finding as fixed, rejected or repeated depends on it.
- Never read a failed, timed-out or unreadable review as clean. Only
clean: truefrom a completed review ends the loop asclean. - Never start another review after a round that fixed nothing important. Minor findings do not extend the loop.
- Never pipe
gh apioutput throughjqin generated commands —jqmay be absent. Parse JSON inline or withgh --jq(built-in, always available). - Never read or reply to pull-request review comments. The reviewer is Codex, and its findings
live in
review.md. - Never merge, close, approve, or request-changes on the PR —
review-looponly iterates on review findings. - Never close the sign-off issue from Step 6.4, in this run or any later one. An issue a machine can close is a gate that closes itself, and the whole point of it is that it waits for a person.
- Never edit
CLAUDE.mdorAGENTS.mdto adopt a candidate rule; the report proposes them. - Never invoke the
review-fixskill directly in the orchestrator session — always delegate via theAgenttool so the main session keeps a clean context across iterations. - Never guess the
openspec-change-nameif the directory is missing — always ask the user (Step 1.2).
Routing text before the description was cut
The description in the front matter is capped at 250 characters, because the list of skills a model sees has a character budget. The full routing text it carried before — triggers, boundaries, what the skill is not for — is kept here:
Run the whole Claude–Codex review cycle on a pull request unattended — Codex reviews the branch, a fixer judges and fixes the findings, pushes, and the next review runs, until nothing important is left. Use this whenever someone wants the review-and-fix back-and-forth to run by itself across several rounds: "aż przestanie zgłaszać uwagi", "zapętl review i poprawki", "nie chcę tego pilnować", "iterate until the review is clean", "keep cycling codex and fixes until it settles" — even when they never type /review-loop. Takes a PR number and an OpenSpec change name; the change gives the fixer the intent to judge findings against and holds the review record. Do NOT use for a single review-and-fix pass — that is review-fix — nor for an opinion on a pull request, waiting on CI, or polling a deployment.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

