Native PR review loop, 2.x — drives the Codex and Copilot GitHub reviewers to a clean signoff, then gates the merge. No daemon: the 1.x file bus is retired.

A team-grade code-review rhythm for solo developers. You write the code; GitHub's own reviewers, Codex and Copilot, review every pull request, and Claude Code works the fix → reply → resolve → re-request loop until they sign off. Then it stops and asks you before opening the second reviewer's phase, and again before merging, or takes the answers you gave in advance and runs unattended.
One install serves every project on your machine. No daemon: nothing keeps running between sessions.
The plugin is a driver for the native GitHub reviewers. Your session:
| Reviewer | Identity | How it is asked |
|---|---|---|
| Codex | chatgpt-codex-connector[bot] | a PR comment containing @codex review |
| Copilot | copilot-pull-request-reviewer[bot] | a review request for @copilot |
The reviewers judge relevance against what the PR says it set out to do, so the description is the one input the whole loop is calibrated on. Write it first.
Use it when a change deserves a second pair of eyes and there is nobody to ask: behaviour changes, anything touching money, auth, data loss or concurrency, a refactor you want checked, work you will not remember the reasoning behind. Skip it for a one-line typo, generated files, formatting-only commits, or a spike you will throw away. If a loop runs long, that is information: rounds that keep finding defects in the fixes usually mean the change is too large.
git, gh, jq, perl. gh must be authenticated with the repo scope (gh auth status). perl is a hard requirement: the helpers hand values to each other in files, and the shell has no safe spelling of the exclusive create and exact rename that takes. macOS ships it; on Linux, install it if your distribution does not.@codex replies with a setup link instead of a review.Works on Linux and macOS. Running one of the privileged helpers by hand goes through its #!/usr/bin/env -S bash -p shebang and needs an env that supports -S (GNU coreutils 8.30 or later, or BSD env). The skill itself never depends on it, pr-selfcheck.sh does not use it, and pr-origin.sh cannot be run by hand at all.
/plugin marketplace add p5ych0/watch-pr-skill
/plugin install watch-pr-skill@p5ych0-tools
Install once at user scope. To update:
/plugin marketplace update p5ych0-tools
/plugin update watch-pr-skill
/reload-plugins
What that takes is this repository as it stands, since the marketplace tracks it rather than a tag, so CHANGELOG.md is the record of what an update brings. The newest versions are also tagged and released at github.com/p5ych0/watch-pr-skill/releases, carrying the same entries.
2.0.0 replaced the plugin rather than extending it. The file bus, its two systemd --user daemons, the codex exec reviewer and the SessionStart hook are gone; both reviewers are GitHub apps, and the plugin runs under Claude Code only. Once, before updating:
--stop included. <plugin> below is that installed copy, $CLAUDE_PLUGIN_ROOT in a 1.x session and otherwise a directory under ~/.claude/plugins/cache/ or ~/.codex/plugins/cache/. <plugin>/skills/watch-prs/scripts/review-bus-codex-start.sh --stop
systemctl --user is-active review-bus-<owner>-<repo>-watcher review-bus-<owner>-<repo>-monitor
Only once is-active prints inactive, failed or unknown for both units — a unit that is gone reads the first, or the last on an older systemd — and after checking that the second directory below holds nothing you still want:
rm -rf /tmp/<owner>-<repo>-review-bus /tmp/<owner>-<repo>-claude-worktrees
git worktree prune
--stop stops both transient units and terminates the whole process group of a daemon an earlier 1.x launched under setsid, which killing the shell alone would leave running with its codex exec child. It needs a running systemd --user; without one, signal each daemon's process group yourself. It reports success even where a unit refused to stop, which is what the check between the two blocks is for. The second directory is where 1.x put implementer worktrees by default, and git worktree prune drops the registrations they leave in .git. Where you exported BUS_DIR, the bus lived there instead, so remove that directory. Remove the clone and the worktrees too where REVIEW_BUS_REPO_CLONE or CODEX_REVIEW_WORKTREE_ROOT put them outside that directory.
codex CLI, inotify-tools and systemd are no longer needed; perl is. Link the Codex connector as under Requirements, and enable Copilot code review on the repository only where you want the Copilot phase; a Codex-only merge needs none. Then follow Per-project setup, carrying anything stack-specific in .review-bus.md into AGENTS.md and .github/copilot-instructions.md on the way and deleting it after: nothing reads it, and the conventions live in the two files the reviewers read from the base branch.CODEX_REVIEW_ROUND_THRESHOLD is REVIEW_ROUND_THRESHOLD, counting reviewed heads per reviewer where 1.x counted enqueued heads per PR, so a carried-over value pauses at a different point. Unset every old CODEX_REVIEW_* and REVIEW_BUS_* export and BUS_DIR. Most are gone — model and effort are Codex account settings, and the round pause is a stop in the session — and REVIEW_BUS_REMOTE, REVIEW_BUS_OWNER and REVIEW_BUS_REPO are test seams here, which a stale export must not reach: they retarget the session's repository identity. The current list is under Configuration.Earlier releases could leave setup directories directly in your home: ~/watch-pr-setup.* and ~/watch-pr-setup-2.*, and before those ~/watch-pr.*, ~/watch-pr-2.*, ~/watch-pr-work.*, ~/watch-pr-pin.* and ~/watch-pr-pin-2.*. From 2.11.3 setup works under TMPDIR or ~/.watch-pr instead, and nothing removes what a session made. To tidy the old ones away, first make sure no session is running — each of those directories is a session's working files — then:
{ [ -d ~/.watch-pr ] || mkdir -m 700 ~/.watch-pr; } && find -H ~ -mindepth 1 -maxdepth 1 -type d \( -name 'watch-pr.*' -o -name 'watch-pr-2.*' -o -name 'watch-pr-setup.*' -o -name 'watch-pr-setup-2.*' -o -name 'watch-pr-work.*' -o -name 'watch-pr-pin.*' -o -name 'watch-pr-pin-2.*' \) -exec mv -n {} ~/.watch-pr/ \;
This only moves: mv -n never replaces a name already under ~/.watch-pr, so a clash leaves the old directory where it is, and -type d leaves a symlink or a file with a matching name alone. Once no session is running, what you keep under ~/.watch-pr is yours to delete.
gh for the repository.AGENTS.md for Codex, which reads it natively in PR review;.github/copilot-instructions.md for Copilot, which reads only that file and follows no pointers, so restate the policy inline.This repository's own copies are a working example: scope discipline, where an out-of-scope problem goes, what counts as a blocking finding, and who can waive one. Without them the reviewers use a generic review.
@codex review mention is then the only trigger, each round is requested deliberately, and a red head stops the round before anything is resolved or posted. With automatic review on and its review trigger set to every push, the push queues the pass, the summary has to follow the push, and each round costs two Codex passes instead of one; the loop supports no other automatic combination.Exhaustive review keeps looking after the first problem and is worth leaving on. For a repository that can be reviewed statically, as this one of shell and Markdown can, a Codex environment with a setup script or dependency installation only slows the pass; remove it there.
The session waits for verdicts through Claude Code's Monitor tool, which no Bash(…) permission rule covers, so allow it once in .claude/settings.local.json or the session asks before every wait:
{ "permissions": { "allow": ["Monitor", "TaskStop"] } }
TaskStop is what cancels a watch. This is deliberately not in the committed settings: granting a tool that runs background commands is each user's decision for their own checkout. An unattended session needs it: a wait that prompts is a stop.
Invoke the skill with /watch-pr-skill:watch-prs from a checkout of the repository, on the PR's branch. The session pins itself to that repository's origin at the start, so changing directory later does not retarget anything; to work on another repository, start a new session. The loop drives same-repository pull requests only: a PR whose head is in a fork is refused at the first push.
The session posts the request, waits, and reads the findings. For each one it reproduces the claim before acting on it, fixes the defect the finding names (including the same defect in another copy the PR already changes), and prefers removing a dependency over guarding it. Where a finding does not hold, it says so on the thread with evidence rather than changing code to satisfy it. It decides each answer itself; the edits it hands to a subagent, unless you tell it to make them too, and it reads the result before committing. Subagent model and effort below says how that subagent is sized. Each fix commit is proved to fail without the fix; where that cannot be constructed, the session writes the limitation at the site and stops for you.
Before closing a round the session runs this plugin's own pre-push self-check, which reports "not applicable" in any other repository and checks nothing of your project (your CI does that), then has the cold reviewer read the round's fixes (below), then checks the round boundary, since with automatic review on the push itself is the next request. It then hands the closing to pr-close-round.sh, which runs in two stages with the thread replies between them: the push, then the CI gate, which waits for that head's checks on the runner and lets the round close on green or on no checks configured, never on red or pending; then the replies and resolves, then the summary comment that carries the next request. The summary says what was addressed and what was intentionally skipped, as a past-tense disposition and an issue number, because it shares a comment with the review request and a request that describes work to be done is treated as one.
The plugin ships a subagent, watch-pr-skill:cold-reviewer, that reads the branch's changes against the PR's base the way Codex and Copilot will and reports what they would raise: one line per finding, graded by how likely a reviewer is to block on it, or clean. A round is the expensive part of the loop, so the session has it read the change before the first review request and again after each round's fixes, giving it the base branch, the PR description, the newest round summary, and the earlier rounds' findings with the replies they got. The session fixes what the read names as a defect the change introduced and files the rest. The read is not a gate: nothing refuses when it is skipped.
It reads the review policy from the base branch before any changed file: whichever of .github/copilot-instructions.md and the root's AGENTS.override.md, AGENTS.md and CLAUDE.md your repository keeps, taking a non-empty override in place of AGENTS.md as Codex does, and it says which it found. An AGENTS.md, AGENTS.override.md or CLAUDE.md below the root is not read unless a policy: line in your .cold-review.md names it, so a finding one would otherwise produce goes unpredicted. It is read-only by instruction and runs only git, test and readlink, with echo printing each path probe's answer. It leaves unopened a path that policy says not to open, any path named .env, .env.*, *.pem or *.key whose name does not end .example, and anything under a .env, .env.* or .ssh directory, templates included. It reads a link without following it, and probes every directory above a path as well, so a folder replaced by a link out of the checkout is reported rather than read through. That is a guard against an accident, not containment. The subagent runs in your session and sees what your session sees, so a secret kept under another name can reach its transcript. If that matters, do not run it on a checkout that holds live credentials.
Your project can add to it with a .cold-review.md at the root, read from the base branch like the policy files, so a pull request cannot widen its own review. Two headings, and nothing else is taken from the file:
## Checks
- An exception the PR body lists that no test covers.
- A PR body that no longer describes what the code does.
## Paths
policy: docs/api-contract.md
policy: services/worker/CLAUDE.md
secret: deploy-key.json
open: .env.sample
A check says what to look for, and changes nothing about what may be read or run. A policy: path is read from the base as a policy file, which is how a rule below the root reaches the read at all; one the secrets rule still marks is reported and left unread, like any other. A secret: name joins the rule above. An open: name unmarks the name half of that rule only: a marked directory still marks everything under it, and a path your policy says not to open stays unopened. An open: line is your word that the name holds no secret, and the disclosure is yours to accept, so name templates and never a live file. A path that is absolute, or that climbs out with .., is reported and ignored. Without the file, the reviewer behaves exactly as above.
The loop stops and asks at these points:
docs/decisions/.REVIEW_ROUND_THRESHOLD distinct reviewed heads per reviewer (default 10), the session stops: continue, merge, leave the PR open, or abandon. Saying "continue" is recorded on the PR, so the next check-in is a further threshold past that count.The body you supply is prose and is posted under your identity, so a line that reproduces one of the record markers a reader trusts from you — Review-Signoff:, Review-Signoff-Revoked:, Review-Pause-Acknowledged: — is refused rather than published: it would create the record it quotes. Indent it by four spaces or quote it inline with backticks; a fenced block does not help, since the readers scan the raw body. A body containing @codex review is refused wherever the comment is posted on its own, because any comment containing that text requests a pass. Where the loop writes the mention itself — the opening request with automatic review off, and a Codex round's summary — a body that already carries one is posted as it stands, so the loop never writes a second one above it. A mention indented or inside a fence triggers the pass just the same, which is why a body carrying one needs no help from the loop.
Every signoff is a Review-Signoff: comment on the PR naming the reviewer, the head and, where the phase could read it, the time of the verdict it answers, so a decision that arrives tomorrow, or on another machine, costs nothing already done. A new session reads the records back and resumes from the right stop.
Export WATCH_PR_AUTONOMOUS=1 and the session takes these answers at the decision stops instead of asking, recording each on the PR as it would your word:
It answers decisions, never readings: a review that is only replies, a limitation it cannot test, an unavailable reviewer and every failed or unreadable gate still stop, and a session stopped there resumes from the records exactly as an attended one does. An unattended wait needs the Monitor permission above.
On your word, the session resolves the head once and evaluates every gate before merging pinned to that head: each reviewer's verdict on the head that reviewer judged, the reviewed range (every commit past the Codex signoff must be a Copilot fix carrying a Review-Phase: copilot trailer), all checks and the base branch's required checks on the merge target, no unresolved threads, and the round boundary. A head that moved mid-gate is refused, not merged.
The merge uses --admin by default, a trade accepted for one case: a branch that requires an approval no eligible account can provide, where neither reviewer counts and dropping the flag removes the merge path rather than tightening it. The trade-off is recorded in docs/decisions/2026-08-06-merge-admin-default.md. Everywhere else set REVIEW_MERGE_STRICT=1: where the branch's rules are ones the loop can satisfy, and always where the branch uses a merge queue, which the default bypasses. GitHub then evaluates reviews, checks and conversations itself, at merge time. With required checks, conversation resolution, zero required approvals where no eligible approver exists, and bypass disallowed, that evaluation is atomic. If GitHub refuses, the merge does not happen and you decide.
Export these in the shell you start the session from. The helpers run as separate processes, and a value merely assigned in your shell never reaches them.
| Variable | Default | Meaning |
|---|---|---|
REVIEW_ROUND_THRESHOLD | 10 | reviewed heads per reviewer between check-ins; 0 disables the pause |
REVIEW_MERGE_STRICT | unset | 1 drops --admin, so GitHub enforces branch protection itself |
WATCH_PR_AUTONOMOUS | unset | 1 takes the answers under Running unattended at the decision stops; every failure still stops |
PR_CI_INTERVAL | 30 | seconds between polls while the pushed head's checks run |
PR_CI_TIMEOUT | 1800 | how long to wait for those checks before stopping rather than guessing |
PR_CI_GRACE | 90 | how long a round-closing answer, green or no checks configured, must hold before it is believed, since a workflow registers a moment after the push |
PR_CI_PROBE_TIMEOUT | 60 | bound on each gh call the check probe makes |
PR_WATCH_INTERVAL | 30 | seconds between polls while waiting for a verdict |
PR_WATCH_TIMEOUT | 3600 | how long to wait for a verdict before reporting a timeout |
PR_WATCH_PROBE_TIMEOUT | 60 | budget for each read the watch makes of the review state, one helper run of several gh calls; a read that stalls past it is retried afresh rather than run to the watch's timeout |
RB_SUITE_JOBS | 4 | how many test files the pre-push self-check runs at once (contributors) |
REVIEW_BUS_REMOTE, REVIEW_BUS_OWNER, REVIEW_BUS_REPO | derived from origin | override the repository identity (tests) |
There are no reviewer knobs: model, effort, exhaustiveness, automatic review and credit use are Codex account and repository settings.
The plugin ships one hooks module that sets the effort of two kinds of subagent from the size of the work, and logs one line saying what it picked. It needs a Claude Code build with function hooks, measured on 2.1.296; an older build does not run it and the loop runs as before.
origin/… ref the session's prompt names first, else with origin/HEAD. Which names are refs is git's answer: the module asks it for the remote's refs and takes the longest one written at that place, so nothing depends on the text that follows a name. A base the checkout has not fetched is not among them, and a fetched ref whose name begins it is taken in its place, so fetch the base before the read.review-fix: and small, medium or large, as in review-fix: large, 2 findings. It names the tier by the hardest finding, since one finding can need a redesign. Where it names no tier, the number of finhooks/tiering.ts 130 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
4const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
5const TIERS = ['small', 'medium', 'large'] as const
6
7type Kind = 'review' | 'fix'
8type Choice = { model?: (typeof MODELS)[number]; effort: (typeof EFFORTS)[number] }
9type Tier = (typeof TIERS)[number]
10type Size = { text: string } & ({ count: number } | { tier: Tier })
11
12const PROBE_MS = 10_000
13const REMOTES = 'refs/remotes/'
14const STATED = /^review-fix:\s*(?:([a-z]+)|(\d+))(?![\w-])/i
15
16const counted = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`
17
18const isOneOf = <T extends string>(list: readonly T[], value: string | undefined): value is T =>
19 list.some(item => item === value)
20
21function pickOf(value: unknown): Choice | undefined {
22 const [model, effort] = String(value).split('/')
23 if (!isOneOf(EFFORTS, effort)) return undefined
24 if (model === 'default') return { effort }
25 return isOneOf(MODELS, model) ? { model, effort } : undefined
26}
27
28function kindOf(type: string | undefined, description: string): Kind | undefined {
29 // A fork inherits its parent's model and effort whatever the call says.
30 if (type === 'fork') return undefined
31 if (type === 'cold-reviewer' || type?.endsWith(':cold-reviewer')) return 'review'
32 return /^review-fix\b/i.test(description) ? 'fix' : undefined
33}
34
35async function git($: EngineInterface, args: readonly string[], emptyAt?: number): Promise<string | undefined> {
36 const ran = await $.process.run(['git', '-c', 'core.fsmonitor=false', ...args], {
37 env: { GIT_OPTIONAL_LOCKS: '0' },
38 timeoutMs: PROBE_MS,
39 })
40 if (ran.exitCode === emptyAt) return ''
41 return ran.exitCode === 0 && !ran.isStdoutTruncated ? ran.stdout : undefined
42}
43
44// Text cannot say where a ref's name ends and the prose after it begins, so the names are git's.
45function namedRef(listed: string, prompt: string): string | undefined {
46 let named: { at: number; ref: string } | undefined
47 for (const line of listed.split('\n')) {
48 const ref = line.startsWith(REMOTES) ? line.slice(REMOTES.length) : ''
49 const at = ref === '' ? -1 : prompt.indexOf(ref)
50 if (at === -1) continue
51 if (named === undefined || at < named.at || (at === named.at && ref.length > named.ref.length)) {
52 named = { at, ref }
53 }
54 }
55 return named?.ref
56}
57
58async function changed($: EngineInterface, prompt: string): Promise<Size | undefined> {
59 // The reviewer reads untracked files, and the numstat below counts none of them.
60 if ((await git($, ['ls-files', '--others', '--exclude-standard', '--', ':/'])) !== '') return undefined
61 const listed = await git($, ['for-each-ref', '--format=%(refname)', `${REMOTES}origin/`])
62 if (listed === undefined) return undefined
63 for (const ref of new Set([namedRef(listed, prompt) ?? 'origin/HEAD', 'origin/HEAD'])) {
64 // Status 1 is git's answer that the two share no ancestor; any other failure is an error.
65 const base = (await git($, ['merge-base', `${REMOTES}${ref}`, 'HEAD'], 1))?.trim()
66 if (base === '') continue
67 if (base === undefined || !/^[0-9a-f]{40,64}$/.test(base)) return undefined
68 const stat = await git($, ['diff', '--numstat', '--no-renames', '--no-ext-diff', '--no-textconv', base])
69 if (stat === undefined) return undefined
70 const rows = stat.split('\n').filter(row => row !== '')
71 const lines = rows.reduce((sum, row) => {
72 const [added, deleted] = row.split('\t')
73 return sum + (Number(added) || 0) + (Number(deleted) || 0)
74 }, 0)
75 return { count: lines, text: `${counted(lines, 'changed line')} in ${counted(rows.length, 'file')}` }
76 }
77 return undefined
78}
79
80function stated(description: string): Size | undefined {
81 const [, word, digits] = STATED.exec(description) ?? []
82 const tier = word?.toLowerCase()
83 if (isOneOf(TIERS, tier)) return { tier, text: 'tier named by the session' }
84 const count = Number(digits)
85 return count > 0 ? { count, text: counted(count, 'finding') } : undefined
86}
87
88export const register: Register = (on, options) => {
89 const ladder = {
90 review: {
91 name: 'cold review',
92 smallMax: Number(options.reviewSmallLines),
93 largeMin: Number(options.reviewLargeLines),
94 small: pickOf(options.reviewSmall),
95 medium: pickOf(options.reviewMedium),
96 large: pickOf(options.reviewLarge),
97 },
98 fix: {
99 name: 'review fix',
100 smallMax: Number(options.fixSmallFindings),
101 largeMin: Number(options.fixLargeFindings),
102 small: pickOf(options.fixSmall),
103 medium: pickOf(options.fixMedium),
104 large: pickOf(options.fixLarge),
105 },
106 }
107
108 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
109 const kind = kindOf(e.subagent_type, e.description)
110 if (kind === undefined) return next(e)
111
112 const rung = ladder[kind]
113 // A git that cannot run, or outlives its bound, ends the sizing: the next probe would wait as long.
114 const size = kind === 'review' ? await changed($, e.prompt).catch(() => undefined) : stated(e.description)
115 if (size === undefined) {
116 $.ui.log(`${$.plugin.name}: ${rung.name} left as called, its size could not be measured`)
117 return next(e)
118 }
119
120 const tier =
121 'tier' in size ? size.tier : size.count <= rung.smallMax ? 'small' : size.count >= rung.largeMin ? 'large' : 'medium'
122 const pick = rung[tier]
123 if (pick === undefined) return next(e)
124
125 const chosen = pick.model === undefined ? `${pick.effort} effort` : `${pick.model} at ${pick.effort} effort`
126 $.ui.log(`${$.plugin.name}: ${rung.name}, ${size.text} (${tier}): ${chosen}`)
127 return next({ ...e, ...pick })
128 })
129}
130