SLOPSHOPPER

watch-pr-skill

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.

newguardprocess
★ 2v2.13.0GPL-2.0updated 2026-10-13p5ych0/watch-pr-skill
A shopper browsing a rack in a slop shop
README

watch-pr-skill

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.

What it does

The plugin is a driver for the native GitHub reviewers. Your session:

  1. states the task on the PR and requests a Codex review;
  2. waits for the verdict without polling by hand;
  3. reads the findings, fixes what each one names and nothing else, replies on every thread, resolves it, and requests the next pass;
  4. repeats until Codex is clean, then stops and asks whether to merge on that signoff alone or open the Copilot phase on the same head;
  5. runs the Copilot phase the same way, then stops and asks again;
  6. evaluates every merge gate immediately before merging, pinned to one commit.
ReviewerIdentityHow it is asked
Codexchatgpt-codex-connector[bot]a PR comment containing @codex review
Copilotcopilot-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.

Requirements

  • 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.
  • The Codex GitHub connector, linked once per account at chatgpt.com/codex/cloud/settings/connectors. Until it is linked, @codex replies with a setup link instead of a review.
  • Copilot code review on the repository, for the two-reviewer loop. A Codex-only merge is supported as a decision you make at the first stop. What there is deliberately no switch for is skipping a reviewer silently: the mode is chosen at the stop, by you or by the unattended rule below, recorded on the PR and passed to the merge gate by name.
  • Claude Code. Both reviewers run in GitHub's cloud; nothing is installed for them here.

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.

Install

/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.

Upgrading from 1.x

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:

  1. In every repository where the bus ran, stop it and then remove its directories, in that order and from inside that checkout, with the 1.x copy still installed: the launcher recreates the bus directory and its clone on every start, --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.

  1. Update as above, or, where 1.x was installed in the Codex CLI only, install in Claude Code as under Install. The skill keeps its name. Remove the Codex CLI copy of the plugin where you installed one: Codex is a reviewer now, not a driver.
  2. The 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.
  3. 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.
  4. There is no request or close-round command to run: the skill drives the loop and stops where a decision is yours. Read A session.

Setup directories from before 2.11.3

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.

Per-project setup

  1. Authenticate gh for the repository.
  2. Commit review conventions the reviewers read from the base branch:
  3. AGENTS.md for Codex, which reads it natively in PR review;
  4. .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.

  1. On the Codex Code review settings page, turn Automatic review off for the repository. The @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.

Watching without prompts

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.

A session

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 Codex phase

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 cold reviewer

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 stops

The loop stops and asks at these points:

  1. A review that is only replies. Reviewers sometimes deliver a clean verdict, or a retraction, as a reply. Nothing in the text separates the two, so the session stops and asks you to read it. If it was a clean verdict, say so and a signoff is recorded for that head; if it was a finding, fix it and push.
  2. A limitation it cannot test, as above. Accepting it is your call, and it becomes binding as a dated record landed on the base branch by its own PR, under docs/decisions/.
  3. The round check-in. Every 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.
  4. Codex is clean. Merge on that signoff alone, or open the Copilot phase on the same head. The Codex-only merge is the narrower gate, not a looser one: it requires the head to be the commit Codex signed.
  5. Copilot is clean. Merge, leave the PR open, or, only if the Copilot phase produced commits, ask Codex once more as fault tolerance over what it changed. Where the phase produced no commits, both signoffs name the same head and the pass is not offered.

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.

Running unattended

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:

  • the round check-in: acknowledge and continue, so the record of a long loop stays on the PR;
  • Codex is clean: merge on that signoff alone if Codex approved the opening request, otherwise open the Copilot phase; a session resumed at this stop opens the Copilot phase, or merges where a Copilot signoff already stands;
  • Copilot is clean: merge if the phase produced no commits; otherwise run the fault-tolerance pass and merge on its signoff, with no third Copilot phase over the pass's own fixes.

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.

The merge gate

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.

Configuration

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.

VariableDefaultMeaning
REVIEW_ROUND_THRESHOLD10reviewed heads per reviewer between check-ins; 0 disables the pause
REVIEW_MERGE_STRICTunset1 drops --admin, so GitHub enforces branch protection itself
WATCH_PR_AUTONOMOUSunset1 takes the answers under Running unattended at the decision stops; every failure still stops
PR_CI_INTERVAL30seconds between polls while the pushed head's checks run
PR_CI_TIMEOUT1800how long to wait for those checks before stopping rather than guessing
PR_CI_GRACE90how 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_TIMEOUT60bound on each gh call the check probe makes
PR_WATCH_INTERVAL30seconds between polls while waiting for a verdict
PR_WATCH_TIMEOUT3600how long to wait for a verdict before reporting a timeout
PR_WATCH_PROBE_TIMEOUT60budget 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_JOBS4how many test files the pre-push self-check runs at once (contributors)
REVIEW_BUS_REMOTE, REVIEW_BUS_OWNER, REVIEW_BUS_REPOderived from originoverride the repository identity (tests)

There are no reviewer knobs: model, effort, exhaustiveness, automatic review and credit use are Codex account and repository settings.

Subagent model and effort

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.

  • A cold review is sized by the lines added and deleted against the merge base with the remote-tracking 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.
  • A fix pass takes the tier the session names for it. The session decides the answer to each finding and then has a subagent make the edits, starting the subagent's description with 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 fin
Source 1 files
hooks/tiering.ts 130 lines
1import 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