# GitHub CLI (gh)

- `gh` opens a pager (alternate buffer) that hangs the agent terminal.
- Always disable it: pipe `| cat` or set `GH_PAGER=cat` (e.g. `gh pr view 116 | cat`).
- `gh --no-pager` is not a supported flag and will error; use `GH_PAGER=cat` or `| cat` instead.
- **`gh repo list <owner>` works for a user or an org; `gh api /orgs/<owner>/repos` only works for an org.**
  The REST endpoint returns 404 on a personal account, so it is not a drop-in replacement for `gh repo list` even though it offers `--paginate`.
  `gh api /users/<owner>/repos` is the personal-account counterpart, and `gh api /users/<owner> --jq .type` returns `User` or `Organization` when you need to branch.
  This matters when enumerating repos across a mixed owner list: substituting the `/orgs/` form to get pagination silently drops every user account in the list.
  (Morrison-Lab/ai-config#833, 2026-07-29: a review suggested exactly that substitution to fix a `--limit 1000` truncation.
  `the repository owner` is a `User`, so it would have 404'd on the first owner in the list.
  The truncation was real, and the fix was to detect the ceiling instead, in the census command under "`gh search code` is not a reliable way to enumerate consumers".)
- **Rate limit is shared (5000/hr) and split GraphQL vs REST.**
  All tools/sessions/agents share the one user's 5000/hr, and `core` (REST)
  and `graphql` are **separate pools**.
  `gh pr checks`, `gh pr view --json comments`, and `gh pr list --json` use
  GraphQL.
  When one pool is spent, get the same data through the other: REST as
  `gh api repos/<o>/<r>/pulls/<n>`, `.../commits/<sha>/check-runs`,
  `.../issues/<n>/comments`; GraphQL as `gh api graphql -f query=...`.
  `gh api rate_limit --jq .resources` is **free** and doesn't count against
  either pool, so check `core` vs `graphql` remaining/reset before retrying.
  Don't tight-poll; use a background watcher with `sleep`, since parallel
  sessions drain the shared pool fast.
  **Don't assume which pool empties first --- read `rate_limit` rather than
  predicting.**
  An earlier version of this entry said GraphQL exhausts first, generalized
  from one session.
  The reverse happens just as readily: a session doing mostly REST work
  (per-PR `gh api` reads, check-run polls) exhausts `core` while `graphql`
  sits nearly untouched.
  So the fallback direction is whichever the free call says it is, in either
  direction.
  **GraphQL can carry a whole ARDI round on its own**, which is what makes
  the REST-exhausted case survivable rather than merely diagnosable:
  `addPullRequestReviewThreadReply` for a threaded reply,
  `resolveReviewThread` to resolve it, `addComment` for a top-level summary,
  and `pullRequest{ headRefOid mergeable reviewThreads statusCheckRollup }`
  for the fully-clean sweep.
  Note `statusCheckRollup.contexts` needs inline fragments, since `CheckRun` and `StatusContext` carry different fields (`name`/`status`/`conclusion` versus `context`/`state`).
  `CheckRun.status` (`CheckStatusState`: `QUEUED`, `IN_PROGRESS`, `REQUESTED`, `WAITING`, `PENDING`, `COMPLETED` --- measured 2026-08-25) has non-terminal states like `REQUESTED` (pre-queue) and `WAITING` (protection/approvals).
  `StatusContext.state` (`StatusState`: `EXPECTED`, `PENDING`, `SUCCESS`, `FAILURE`, `ERROR` --- measured 2026-08-25) treats `EXPECTED` as non-terminal.
  Gating code must fail-closed: require `CheckRun.status === 'COMPLETED'` and `StatusContext.state` in terminal states (`SUCCESS`, `FAILURE`, `ERROR` --- or require `state === 'SUCCESS'` on every context for an all-green gate),
  treating any other status or state as still in progress rather than allow-listing expected pending values.
  (Morrison-Lab/ai-config#816, 2026-07-29: `core` returned `403` mid-round with `graphql` at 4922/5000 --- the round's reply, thread-resolve, ARD summary, and clean-state verification all went through GraphQL, and `core` reset 11 minutes later.)
- **A session's egress proxy can block GraphQL entirely, as a session-scoped
  policy rather than an account-level quota --- distinct from the rate-limit
  case above, and easy to conflate with it.**
  The symptom looks identical at first glance: `gh pr view --json ...` fails.
  The cause is not shared.
  `gh api rate_limit` reports a healthy `graphql` pool, and `gh auth status`
  plus a plain `gh api <rest-endpoint>` both report success, so the session
  reads as fully authenticated and REST-capable while every GraphQL call
  fails identically:
  ```
  HTTP 403: This GraphQL query is not enabled for this session --- only the
  pinned set of PR-review operations is served. Use REST via
  `gh api repos/{owner}/{repo}/...` instead.
  ```
  Confirmed not scoped to one query: a minimal hand-written
  `gh api graphql -f query='{ ... }'` against the same PR gets the identical
  403.
  This breaks `scripts/check-pr-fully-clean.py` at its very first call
  (`gh pr view --json ...` inside `get_pr_info()`), before anything
  repo-specific runs, and the healthy `gh auth status`/REST readings make it
  easy to misdiagnose as "something else is wrong" rather than "GraphQL is
  closed here."
  Route around it the same way as the rate-limit case: REST
  (`gh api repos/<o>/<r>/pulls/<n>` plus `.../commits` for the head SHA) or
  the GitHub MCP tools, which already implement `pull_request_read` over
  REST.
  (`Morrison-Lab/ai-config#1330`, 2026-08-10, comment: this session's proxy
  refused every GraphQL call while `gh api user`/`gh api repos/<o>/<r>` both
  returned 200 --- a second, distinct root cause for the same
  `check-pr-fully-clean.py` failure symptom already tracked in that issue.)

  **A third root cause reaches that same first call, and it is the most
  common one: `gh` is not installed at all.**
  The two causes above both assume a working `gh` whose *requests* are
  refused, so both are diagnosed by reading a status code.
  Here there is no request, and since #1462 (2026-08-14) no traceback:

  ```text
  `gh` is not installed or not on PATH.
  `-R` alone cannot substitute for it, but the GitHub CLI is not required: score a JSON payload instead. `build-pr-payload.py` assembles one from plain REST, and needs GITHUB_TOKEN or GH_TOKEN set:
    python3 <scripts>/build-pr-payload.py OWNER/REPO N /tmp/pr.json
    python3 <scripts>/check-pr-fully-clean.py N -R OWNER/REPO --from-json /tmp/pr.json
  ```

  The two commands print as absolute paths derived from the script's own
  location, so they are runnable as printed from whatever directory the
  failing invocation used (ai-config#3113).

  It exits **2**, never 1 --- 1 is this script's "not clean" code, so a missing
  binary would otherwise read as a verdict rather than an environment failure.
  `command -v gh` discriminates the three in one read, and it is worth
  running before diagnosing anything else about the script.
  When it reports no executable in a local session, inspect the package
  manager prefix before treating `gh` as absent; on Apple Silicon macOS,
  Homebrew normally installs it under `/opt/homebrew/bin`.
  Repair the current shell's `PATH` from the package manager's shell setup
  (for Homebrew, `eval "$(/opt/homebrew/bin/brew shellenv)"`).
  If `gh` remains absent after that check, install it before falling back to
  another interface: `brew install gh` on macOS with Homebrew.

  What makes this worth recording rather than filing under "the CLI is
  missing" is **which** sessions it hits.
  [`github-remote-sessions.md`](github-remote-sessions.md)'s "GitHub access
  from bash in remote/web sessions" section states that there is no
  `gh`/`glab` CLI in these sessions, so this is the
  norm for a whole class of session rather than a misconfiguration --- and two
  corpus rules name that script as the instrument for deciding a PR is ready:
  [`ardi`](../shared/workflow/ardi.md) requires it for the single-PR loop, and
  [`fully-clean`](../shared/workflow/fully-clean.md) opens by saying the two
  criteria are "verified via `python3 scripts/check-pr-fully-clean.py
  <pr-number>`".
  So a mandated check is simply unavailable, and nothing in either rule says
  what to do about that.

  Route around it the same way this bullet already prescribes: the GitHub MCP
  tools, verifying by hand against
  [`fully-clean`](../shared/workflow/fully-clean.md)'s own criteria ---
  select verdict candidates on the `**Claude finished` body marker rather
  than an author login, anchor on the **last** `### Verdict` heading, read
  Copilot's posted review body rather than its check colour, and paginate the
  check-runs endpoint.

  - **Do:** run `command -v gh` before diagnosing a `check-pr-fully-clean.py`
    failure, so a missing binary, a blocked GraphQL call, and a rate limit are
    separated in one read.
  - **Do:** repair a local session's package-manager `PATH`, then install the
    required CLI when no executable is installed.
  - **Do:** report the script as **unavailable** in the status summary, naming
    the MCP checks run in its place, so a reader can tell a hand verification
    from an instrument's verdict.
  - **Don't:** stop after one shell reports a missing command when the local
    package manager may already provide it outside that shell's `PATH`.
  - **Don't:** use an MCP fallback in place of a locally installable required
    CLI without first repairing or installing the CLI.
  - **Don't:** read the script's absence as licence to skip criteria 1 and 2
    --- the criteria are the requirement, and the script is one way of
    reaching them.
  - **Don't:** report a PR clean without saying which instrument decided it;
    a hand check and a script run are different evidence and should not read
    alike.

  (`Morrison-Lab/ai-config#1403`, 2026-08-12: a remote session driving that
  PR's own check-in ran the script as instructed and got the traceback above.
  Every criterion was then verified through `pull_request_read` and
  `get_check_runs` instead, and the merge went ahead on that evidence --- but
  the summary had to say the instrument was unavailable rather than clean.)
- **The @claude review bot's author name differs by API:** its comment author is `claude[bot]` in REST (`.user.login`) but `claude` in GraphQL (`.author.login`).
  A watcher filtering REST comments for `.user.login == "claude"` silently finds nothing — use `"claude[bot]"`.
- **A third variant, and it is not one repo's quirk: the review comment can
  post as `github-actions[bot]` rather than `claude`/`claude[bot]`, and the
  same repo can do it on one round and not the next.**
  First recorded on `d-morrison/gha`; observed again on
  `Morrison-Lab/ai-config#1054` (2026-08-03), where round 2's verdict posted as
  `claude[bot]` at `02:12:52Z` and round 3's as `github-actions[bot]` at
  `03:04:19Z` --- so the login varied **between consecutive rounds of one PR**,
  and a login filter that had worked all session began silently returning the
  older comment.
  Note the failure differs by repo in a way that matters: where the login never
  matches, the filter returns **empty**, which at least looks wrong; where it
  matched earlier rounds, it returns a **stale but plausible** verdict, which
  does not.
  Filtering `.user.login == "claude"` (or `"claude[bot]"`) returns nothing on
  such a repo even though a real, complete review was posted --- the workflow's
  own `gather-context` job comment even says "REST author login is
  `claude[bot]`", which does not match what the bot actually posts under there.
  Don't conclude "no review yet" from an empty filter on one login string: if it
  comes back empty, list all comment authors
  (`gh api repos/<o>/<r>/issues/<N>/comments --jq '.[] | .user.login'`) and check
  the body for the `**Claude finished` marker regardless of which login posted
  it.
  (gha#278, 2026-07-21: `select(.author.login == "claude")` and
  `select(.user.login | test("claude"))` both came up empty; the actual review
  comments were under `github-actions[bot]`.)
- **Polling for the bot's verdict: match `Claude finished`, don't exclude a placeholder.**
  While a run is underway, the bot's comment holds an in-progress placeholder whose wording *varies between runs* ("### Review in progress …", "Claude Code is working…"),
  so a watcher that exits when comments exist, or when one known placeholder phrase disappears, fires early on the next differently-worded placeholder.
  Completed runs (review and agent alike) start the body with `**Claude finished`.
  **Filter on that body marker, not on an author login** --- the login itself varies by repo (see the `github-actions[bot]` variant in the bullet above), so a login-only filter can come up empty even once a review has posted.
  - **When re-triggering a run on a thread that already has a completed `**Claude finished` comment from an earlier run, also scope the filter to comments newer than a baseline ID captured before the trigger** --- otherwise the poll matches the *prior* run's already-finished comment immediately and never actually waits for the new one.
    **`gh api`'s own `--jq` flag has no way to inject a variable (neither `--arg` nor `--argjson`), and reaching for one fails in the shape of an empty result**: `gh` parses the unknown flag and its value as extra positionals, so `gh api <endpoint> --jq --arg h "$SHA" '<expr>'` exits 1 with an empty **stdout** and `accepts 1 arg(s), received 4` on **stderr** (measured on `gh` 2.92.0, 2026-08-13).
    Inside a `$(...)` whose stderr is not being watched --- a "reviews at the current head" query, say --- that reads as "no reviews exist" rather than as a broken command, which is why the remedy below is worth taking rather than retrying the flag.
    It **also only fetches the first REST page (30 comments) unless told to paginate, and `--paginate`'s `--slurp` companion flag is rejected outright when combined with `--jq`** --- pipe the raw paginated output into standalone `jq -s` instead, which supports both.
    **Enable `pipefail` in each shell process that runs one of these pipelines** so an upstream `gh api` failure does not get masked by a successful downstream `jq`:
    ```bash
    set -o pipefail
    BASELINE=$(gh api repos/<o>/<r>/issues/<N>/comments --paginate | jq -s '[.[][] | .id] | max // 0')
    # ... trigger the new run ...
    set -o pipefail
    gh api repos/<o>/<r>/issues/<N>/comments --paginate | jq -s --argjson baseline "$BASELINE" \
      '[.[][] | select(.id > $baseline and (.body | startswith("**Claude finished")))] | last | .body'
    ```
    When polling for the *first* run on a fresh thread (no prior completed comment to collide with), the simpler unscoped form still needs `--paginate` for the same >30-comment reason (a REST issue-comments page is oldest-first, so page 1 alone can miss the newest comment entirely once a thread grows past one page):
    `gh api repos/<o>/<r>/issues/<N>/comments --paginate | jq -s '[.[][] | select(.body | startswith("**Claude finished"))] | last | .body'`.
    (Cost two wasted watch rounds on ai-config#357 before keying on the marker;
    the login-filtered version of this command was flagged as stale by review on ai-config#636;
    the unscoped-across-reruns version was flagged by a follow-up review on ai-config#637 and confirmed concretely on gha#278, whose thread holds two separate `**Claude finished` comments, one per run;
    and the `gh api --jq --argjson`/pagination gaps in *that* fix were themselves flagged by a still-later review on the same PR, caught only after #637 had already merged.)
- **Triaging many open PRs at once: extract each PR's `review-data:` payload with one `jq` pipeline instead of dispatching a subagent to read each review.**
  Every completed `**Claude finished` review body ends with a machine-readable `<!-- review-data: {"schema_version": ..., "verdict": ..., ...} -->` comment (the same payload `scripts/lib/review_payload.py` parses).
  For a quick multi-PR scan, capture it directly:
  ```bash
  gh api repos/<owner>/<repo>/issues/<N>/comments --paginate \
    | jq -s '[.[][] | select(.body | test("\\*\\*Claude finished"))] | last.body
             | capture("review-data:\\s*(?<j>\\{[\\s\\S]*?\\})\\s*-->").j
             | fromjson | {verdict, findings: (.findings | length)}'
  ```
  The `fromjson` is load-bearing and easy to drop: `capture(...).j` yields a jq
  *string*, so a trailing bare `| jq .` re-emits it still escaped rather than
  parsing it, and nothing downstream (`.verdict`, `.findings`) is queryable.
  The failure is quiet --- the output still looks like JSON.
  Loop that over every open PR's issue-comments endpoint and the whole set's verdicts and finding counts come back without reading a single comment body by eye or spending a subagent per PR --- the deterministic-tool default [`algorithmatize-checks`](../shared/workflow/algorithmatize-checks.md) asks for, applied to review triage specifically.
  **Do not treat this raw regex capture as the final gating signal, though** --- it has none of `review_payload.py`'s code-fence masking, so a verdict quoted inside a fenced example in the review body (an ARD template, a quoted prior round) can be captured instead of the real one.
  Use the raw pipeline to decide *which* PRs need a closer look.
  For an actual clean/not-clean call on one PR, read the comment through `scripts/check-pr-fully-clean.py` (or `review_payload.py` directly), the same masking-aware path `fully-clean.md` already requires.
  - **Do:** run the jq pipeline across all open PRs first, to triage which need attention, before dispatching per-PR analysis.
  - **Don't:** use the naive jq capture's verdict as the basis for declaring a specific PR clean or not-clean --- re-read it through the masking-aware extractor for that call.
  (`d-morrison/rme` ardia sweep, 2026-09-06.)
- **`jq empty` exits 0 on empty stdin: pair it with an emptiness check `[ -n "$out" ]` when validating JSON.**
  `printf "" | jq empty` exits `0`, so using `echo "$out" | jq empty` alone to validate API responses accepts an empty response body as valid JSON.
  For robust validation of API responses (e.g. `gh api`), check both non-emptiness and JSON validity: `[ -n "$out" ] && echo "$out" | jq empty 2>/dev/null`.
  That pair has no correct one-token shortcut, and the two obvious candidates both fail.
  `jq -e empty` exits `4` on *every* input, valid non-empty JSON included, because `-e` takes its status from the filter's last output value and `empty` is defined never to emit one.
  `jq -e .` does separate empty from valid input, but then rejects the legitimate `null`/`false` bodies `gh api` returns routinely.
  Also note `gh api ... --jq '<expr>'` returns raw unquoted strings for string scalar expressions (such as `.head.sha`), which are not valid JSON on their own and fail `jq empty`.
  Fetch the endpoint JSON first and parse with `jq -r` instead. (Learned on gha#518.)
- **A reply posted via `gh pr comment`/`gh api` from within a session shows up under the *human user's own* GitHub account, not a bot identity — don't mistake it for an independent human review when auditing a PR's review state.**
  `gh` authenticates as whatever account is logged in locally (often the user's own, e.g. seen as `dem-extra1` on `Lacaedemon/sparta`), so when an agent (or a dispatched subagent) replies to an inline review comment on the user's behalf, `gh api repos/<o>/<r>/pulls/<N>/reviews` lists it as a `COMMENTED` review authored by the user — indistinguishable at a glance from the user genuinely opening the PR in a browser and typing a reply themselves.
  **Since 2026-08-24 the fix is on the posting side:** every comment an agent posts carries a trailing `_Posted by Claude Code (AI agent) --- not written by a human._` marker, so the body says what the author field cannot.
  See [`disclose-agent-authorship`](../shared/workflow/disclose-agent-authorship.md).
  That makes agent-authored comments identifiable **going forward**.
  Comments posted before that date carry no marker, so this warning still governs when auditing older threads.
  Before treating an unexpected review entry as a signal that the human intervened, check whether its body/inline-comment content reads like the agent's own scripted reply (referencing a specific commit SHA, restating verification numbers) rather than free-form human commentary — if so, it's the session's own tooling, not new human input.
  **The same ambiguity runs the other way, and there it arrives as a positive claim rather than an inference you might draw.**
  An automated reviewer reading the PR's own history sees that same bot-account commit and can describe it *in its review body* as the work of a human, e.g. "that finding was confirmed and fixed by a human reviewer (`dem-extra1`) in commit `<sha>`", stating as fact something no API field asserts.
  That is worse than the inference case above, because the claim is now published prose a later reader inherits, and "a human already verified this" is precisely the sentence that stops the next person checking.
  Correct it in the thread when you see it, naming which account is actually a session identity;
  don't let it stand just because the surrounding verdict was clean.
  (`ucdavis/bcs#532`, 2026-07-31: a `claude-review` pass reported a fix as human-confirmed when `dem-extra1` was the Claude session that made it, and no human had touched the PR at that point.)
- **`gh pr view --json` does not accept `merged` as a field.**
  Use `state` (returns `"MERGED"`) and `mergedAt` (ISO timestamp, null if not merged) to check merge status.
  Example: `gh pr view <N> --json state,mergedAt`.
  Verified 2026-08-09: `gh pr view <N> --json merged` fails with `Unknown JSON field: "merged"` and prints the full valid field list (`gh` 2.96.0), which includes `state`, `mergedAt`, `mergedBy`, `mergeCommit`, `closed`, and `closedAt` --- no bare `merged`.
  That absence is specific to `gh --json`'s own field-name allowlist, not to the underlying data.
  REST's `GET /repos/{owner}/{repo}/pulls/{number}` and the GraphQL `PullRequest.merged` field each carry a genuine `merged` boolean, verified 2026-08-09 against `Morrison-Lab/wai#57`: `gh api repos/<o>/<r>/pulls/<N> --jq .merged` returns `true`, and `gh api graphql -f query='{repository(owner:"<o>",name:"<r>"){pullRequest(number:<N>){merged}}}'` returns `true` as well.
  The GitHub MCP tool's `pull_request_read` `get` method carries it too --- see [`github-mcp-tools.md`](github-mcp-tools.md)'s note that `list_pull_requests` reports `merged: false` for every PR while `pull_request_read` `get` reports it correctly.
  So the fix differs by surface.
  Under `gh --json`, read `state`/`mergedAt`; under REST or the MCP `get` method, the `merged` field itself already works.
  **Never compare that `mergedAt` against a git timestamp as strings --- convert both to epochs first.**
  Every GitHub API timestamp is UTC (`...Z`), while git's `%cI`/`%cd` render in the *machine's local zone*, so a lexicographic `<` between them compares clock faces from two different zones and silently answers wrong.
  It fails in the unsafe direction west of UTC: a commit made *after* the merge still sorts first.
  Verified directly --- `tip="2026-07-30T18:00:00-07:00"` is `2026-07-31T01:00:00Z`, two hours *later* than `merged="2026-07-30T23:00:00Z"`, and `[[ "$tip" < "$merged" ]]` returns true.
  Use `%ct` (epoch seconds) plus jq's `fromdateiso8601`, and an integer test:
  ```bash
  tip=$(git log -1 --format='%ct' "<branch>")
  merged=$(gh pr view <N> --json mergedAt --jq '.mergedAt|fromdateiso8601')
  [[ "$tip" -lt "$merged" ]] && echo "tip predates merge" || echo "tip AFTER merge"
  ```
  `fromdateiso8601` is available in the jq that `gh --jq` embeds, confirmed by `gh pr view <N> --json createdAt --jq '.createdAt|fromdateiso8601'` returning an integer.
  So this needs no external `jq` and no `date -d`, which is GNU-only and absent on macOS.
  (Morrison-Lab/ai-config#908, 2026-07-30: the `clean-worktrees` merged-PR guard shipped the string comparison.
  Review caught it, and the repro above confirmed the failure direction before the fix went in.)
- **A MERGED (or closed) PR reads exactly like a "GitHub sync delay" --- check `state` before theorizing about lag.**
  Its signature is three symptoms that each look like webhook/replication lag:
  `gh pr view --json headRefOid` stays frozen at the last-merged SHA (lagging the actual branch tip),
  `mergeable`/`mergeStateStatus` read `UNKNOWN`,
  and pushing new commits to the branch triggers NO new synchronize review.
  All three are the merged/closed steady state, not a transient delay.
  Don't attribute them to a lag: `gh pr view <N> --json state` (or `mergedAt`/`mergeCommit`) returns `MERGED` immediately and settles it in one call.
  Corollary: after a squash-merge that auto-deletes the head branch, a later push RE-CREATES the branch as an orphan,
  so the pushed commit is NOT on `main` --- verify with `git merge-base --is-ancestor <sha> origin/main`.
  - **Do:** when a PR's head looks stuck and pushes don't trigger reviews, read `state`/`mergedAt` first.
  - **Don't:** read a frozen `headRefOid` plus `UNKNOWN` mergeable plus no-new-review as a sync lag --- that is the merged state.
  (gha#400, 2026-08-03: the PR merged at 15:54 PT as squash `03a046a`,
  but work continued on it for over an hour --- live verification, two nit-fix commits, posting evidence, resolving threads ---
  all on an already-merged PR, because the frozen-head/`UNKNOWN`-mergeable/no-new-review state was read as a sync delay instead of `state: MERGED`;
  the nit-fix commit ended up orphaned, not on `main`.)
  - **Mis-tracking a merged PR as still-open does not only misreport status --- it SILENTLY suppresses the "flag a good moment to `/clear`" suggestion, on false data.**
    The "Flag good moments to `/clear` in long-running sessions" rule in the user `CLAUDE.md` says not to flag a stopping point while any PR you opened or pushed to is still unmerged --- so "I still have open PRs" is that rule's own suppression clause.
    A remembered "it is still open" therefore does two things at once: it misreports the PR's status, and it invisibly cancels the stopping-point flag the user would otherwise get.
    The suppression fires correctly on stale input, so nothing looks wrong --- the user simply never receives the suggestion and has to ask.
    - **Do:** after confirming a merge, or at any long-session lull, re-query `gh pr view <N> --json state` for every PR you opened before either raising OR suppressing a stopping-point suggestion.
    - **Don't:** let a remembered "it is still open" both misreport status and silently cancel the `/clear` flag --- recollection about merge-state is exactly what the `--json state` query exists to replace.
    (This session, 2026-08-03/04, gha#400/#401 + ai-config#1111: three PRs were described as "open follow-ups I'm watching" after all three had merged, and no stopping-point flag was raised until the user asked whether to compact or start a new session.)
- **`gh pr list --state merged` plus a low `--limit` can miss recent merges:**
  The list is ordered by PR list order, effectively number/creation, before your `--jq` filter runs.
  That means an old, low-numbered PR that merged recently can sit below a page of higher-numbered PRs and never reach the filter.
  The result looks scoped by time while silently excluding the very merge checkpoint you were polling for.
  Use a query whose filter matches the question, such as `gh search prs --repo <owner>/<repo> --merged-at ">=<date>"`, or query each PR of interest directly.
  If you use `gh pr list --state merged`, set `--limit` far beyond the expected count and report how many merged PRs the command examined, not only how many passed the `mergedAt` filter.
  - **Do:** use `gh search prs --repo <owner>/<repo> --merged-at ">=<date>"`, direct `gh pr view <N>`, or an intentionally over-wide list with an examined count when answering "what merged since T".
  - **Don't:** trust `gh pr list --state merged --limit N --json mergedAt --jq '.[] | select(.mergedAt > T)'` as a time-window query.
  (Morrison-Lab/ai-config#969, 2026-08-01: `gh pr list --state merged --limit 15 --json number,mergedAt` plus a `mergedAt > 2026-08-01T08:00:00Z` filter returned only #1019, merged at `09:03:13Z`, and missed #969, merged at `09:14:38Z`.
  Raw `--limit 6` output showed #1013 at `05:36Z` before #1012 at `05:45Z`, proving the page was not sorted by merge time.
  Raising the limit to 30 returned both #1019 and #969.)
- **`gh pr edit` exiting 1 with the `projectCards` / "Projects (classic) is being deprecated" GraphQL error means gh is too old: upgrade it (`winget upgrade GitHub.cli`), do not work around it.**
  Measured 2026-10-03: gh 2.52.0 failed, 2.102.0 fixed it (verified with no-op `gh pr edit --add-label`, `--body-file` and `--remove-reviewer`, each exiting 0);
  see `memories/github.md`.
  On an old gh, `gh pr edit <N> --body "..."` / `--body-file <f>` returns exit code 1 with a GraphQL deprecation warning (`Projects (classic) is being deprecated…`).
  Sometimes the edit lands anyway;
  **sometimes it does not apply at all** (seen on sparta 2026-06-30: three `gh pr edit --body-file` attempts left the body unchanged with the `SHA_PLACEHOLDER` still in place).
  Either way, don't trust that run: verify with `gh api repos/<o>/<r>/pulls/<N> --jq .body`.
  Only where gh genuinely cannot be upgraded, use the REST PATCH as a stopgap, which exits 0 and applies: `gh api -X PATCH repos/<o>/<r>/pulls/<N> -f body="..."`.
  For a multi-line body, read it from a file with `-F body=@<path>` (capital `-F` to pull the field value from the file) rather than cramming it into `-f body="..."`.
- **PR description image embeds: use `raw.githubusercontent.com`, not `github.com/.../raw/...`.**
  Embedding a committed file in a PR body with `![](https://github.com/<owner>/<repo>/raw/<sha>/<path>)` may not render — the reviewer will flag it.
  The correct raw-content domain is `https://raw.githubusercontent.com/<owner>/<repo>/<sha>/<path>`.
  Reference the full commit SHA so the image keeps rendering after the branch is deleted on merge.
- **`raw.githubusercontent.com` FOLLOWS repository-rename redirects, so a `200` under the OLD owner proves nothing — only a `200` under the NEW owner is decisive.**
  To test whether a repo has moved, probe the *new* name and treat `404` there as "did not move".
  Run a known-moved repo as a control first, or the probe silently answers backwards: `d-morrison/gha` still returned `200` on `raw.githubusercontent.com` well after it became `Morrison-Lab/gha`, so an old-name probe reports every repo as "not moved".
  The REST API is not a substitute — behind an agent proxy `api.github.com/repos/<o>/<r>` can return `403` for every repo regardless of existence, which answers nothing in either direction.
  This matters before any blanket owner rewrite: probing all nine `d-morrison/*` references in ucdavis/bcs under the new owner showed only `gha` and `ai-config` had moved, so a find-and-replace would have broken `macros`, `altdoc`, `snapr`, `stats-allowlist`, `diffviewer`, `equation-anchors`, and `rme`.
  Note the bare `the repository owner` *username* (a `reviewer:` input, author metadata) is unaffected by a repo/org rename and must not be swept along.
  The Actions-side consequences of the same rename are in `github-actions.md` ("A repo/org rename breaks Actions `uses:` refs"). (2026-07-28.)
- **Download a user-pasted PR screenshot with `curl -L`.**
  When a user pastes an image into a GitHub PR comment, the file lives at `https://github.com/user-attachments/assets/<uuid>` and is publicly downloadable: `curl -L -o <dest>.png "https://github.com/user-attachments/assets/<uuid>"`.
  Retrieve the URL from the comment body via `gh api repos/<o>/<r>/issues/comments/<comment_id> --jq .body`.
- **Linking a GitHub sub-issue needs an integer DB id, not the number.**
  `POST /repos/<o>/<r>/issues/<parent>/sub_issues` takes `sub_issue_id` = the child's **database id** (`gh api repos/<o>/<r>/issues/<child> --jq .id`), *not* its issue number.
  Pass it with `-F` (typed, integer), never `-f` (string) — `-f sub_issue_id=…` fails with `422 Invalid property /sub_issue_id: "…" is not of type integer`.
  Full call: `gh api repos/<o>/<r>/issues/<parent>/sub_issues -F sub_issue_id=<child_db_id>`.
  Verify with `gh api .../issues/<parent>/sub_issues --jq '.[] | "#\(.number) \(.title)"'`.
- **Backticks in a double-quoted `-m` / `--body` string get command-substituted by the shell.**
  In the Bash tool, `` git commit -m "... `origin` ..." `` or `` gh pr comment --body "use `foo`" `` makes the shell run `` `origin` ``/`` `foo` `` as a command and splice the (usually empty/erroring) output into the message — silently mangling it (seen on sparta 2026-06-30: a commit body's `` `origin` `` and `` `killer` `` vanished, with `origin: command not found` in stderr).
  For any message/body containing backticks, use a single-quoted **heredoc** (`` -m "$(cat <<'EOF' … EOF)" `` — the quoted `'EOF'` disables all expansion) or a `--body-file`, never a bare double-quoted string.
  (Same root cause as ARD inline reply bodies too;
  use `-F body=@<file>` for `gh api .../pulls/<N>/comments`/`glab api .../notes` so backticks in Markdown never get shell-expanded.)
- **GitHub review inline comments are on a different API endpoint than top-level PR comments.**
  The top-level comment-view endpoint (`` `gh pr view <N> --json comments` `` or `gh api repos/<o>/<r>/issues/<N>/comments`) captures PR-level comments and bot-posted review overview summaries, but **not inline comments from formal reviews** (line-by-line inline findings).
  When a user links a specific review ID (e.g. `#pullrequestreview-4761444085`), fetch both the review overview and its inline comments separately: `gh api repos/<o>/<r>/pulls/<N>/reviews/<review-id> --jq '{state, body}'` for the overview, then `gh api repos/<o>/<r>/pulls/<N>/comments --jq '.[] | select(.pull_request_review_id == <review-id>) | {line: .line, body: .body}'` to get the inline findings.
  A review's overview body can be generic ("I reviewed the code") with all the actual findings in inline comments on specific lines — reading only the overview misses the findings.
  (Encountered on ai-config#647 review 4761444085: the overview body was generic, but the specific finding was in an inline comment on CLAUDE.md line 324.)

- **Replying to an inline review comment and editing one are two routes on the same comment id, and the destructive one is the shorter path.**
  The bullets above are about *reading* inline comments.
  Writing back to one has a trap they do not cover, because both routes take the same `<id>` and only the surrounding path distinguishes them:

  ```bash
  # REPLY: adds a comment alongside theirs. Note the PR number.
  gh api -X POST repos/<o>/<r>/pulls/<N>/comments/<id>/replies -F body=@<file>

  # EDIT: OVERWRITES their comment. No PR number.
  gh api -X PATCH repos/<o>/<r>/pulls/comments/<id> -F body=@<file>
  ```

  The discriminator is whether the PR number is present, which is the least memorable difference the two could have had, and the id-only form is the one that reads as the tidier of the two.
  Both were confirmed against GitHub's own reference: `PATCH /repos/{owner}/{repo}/pulls/comments/{comment_id}` updates a review comment, while `POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies` creates a reply.
  The underlying rule is that collection-scoped routes carry the PR number while single-comment-by-id routes do not, and that split cuts across the read/write divide rather than along it.
  `GET .../pulls/<N>/comments` lists a PR's comments and `GET .../pulls/comments/<id>` fetches one, so the id-only shape is already familiar from reading before you ever write with it.

  GitHub documents a second reply form, and it carries the PR number too: `POST .../pulls/<N>/comments` with `-F in_reply_to=<id>`, which is what [`ard`](../skills/ard/SKILL.md)'s step 4b uses.
  Either reply form is fine, and the discriminator holds for both, which is the point: every route that adds a comment names the PR, and the one that overwrites an existing comment does not.

  Nothing warns you.
  On a repo where you have write access the `PATCH` returns success, and success is exactly what an overwrite looks like.
  The review-comment REST surface exposes no edit-history read either, so a restore cannot be diffed against the original.
  The only durable trace is that `updated_at` stops matching `created_at`, and the comments render as edited from then on.

  The transferable shape is not about `gh`.
  A comment id addresses an artifact belonging to someone else, so a verb that writes *to* that id writes over their work rather than adding alongside it.
  The id being correct is therefore no evidence that the verb is, which is what makes this survive the check you would actually run: you verify the id, it is right, and the call succeeds.

  Use [`REPLY_REVIEW_COMMENT`](../tool-mappings.md) rather than composing the path by hand.
  That row carried a non-runnable `gh api (reply to review comment)` placeholder until this entry was written, which is the specific reason the path got improvised in the first place.

  - **Do:** reply with the `/replies` route, and read the PR number's presence as the check that you are on it.
  - **Do:** resolve the operation through `tool-mappings.yml`'s token rather than reconstructing a URL from the read endpoint you just used.
  - **Don't:** reach for `PATCH` on `pulls/comments/<id>` or `issues/comments/<id>` to respond to someone; that edits their comment.
  - **Don't:** read a `200` as confirmation you added something, on any id-addressed route you did not intend to write to.

  (Morrison-Lab/ai-config#1151, 2026-08-05: replying to five `claude[bot]` review findings was attempted with `-X PATCH repos/<o>/<r>/pulls/comments/<id>`, once per id, and all five findings were replaced by the reply text before anything reported a problem.
  They were restored from copies already read, and the replies reposted on the `/replies` route.
  The five comments (`3717322685`, `3717323117`, `3717323586`, `3717324073`, `3717324556`) were created `01:33:18Z` to `01:33:57Z` and last updated `01:40:35Z` to `01:40:38Z`, so the overwrite and the restore both fall inside that 7-minute bracket and cannot be separated any more finely than that, which is the missing-edit-history residual in concrete form.
  The five replies, created `01:41:03Z` to `01:41:07Z`, still carry `updated_at == created_at`.
  The correct route was already written down in [`skills/claude-agent-workflow/SKILL.md`](../skills/claude-agent-workflow/SKILL.md), so this was a placement failure rather than a knowledge gap: the command existed in a skill about a CI workflow, and the registry a person replying to a review would actually consult had a placeholder.)

- **One review round can post several review objects, so filtering by a single `pull_request_review_id` silently drops findings.**
  The bullet above is right that inline comments need their own endpoint, and its `select(.pull_request_review_id == <review-id>)` filter is the correct way to drill into *one* review.
  It is the wrong way to answer "what did this round find", because the round and the review object are not the same unit.
  A reviewer can emit two review objects seconds apart, one finding in each, and a linked review URL names only one of them --- so the filter returns a strict subset and reads exactly like a complete answer.
  Nothing in the output announces the omission.

  Enumerate unfiltered instead, and let the **thread list** decide what is outstanding:

  ```bash
  # every inline comment, whatever review it belongs to
  gh api repos/<o>/<r>/pulls/<N>/comments --paginate \
    --jq '.[] | "review_id=\(.pull_request_review_id) \(.path):\(.line) [\(.user.login)] \(.body[0:90])"'

  # the authoritative outstanding-work list
  gh api graphql -f query='{ repository(owner:"<o>", name:"<r>") {
    pullRequest(number:<N>) { reviewThreads(first:100) {
      totalCount
      nodes { id isResolved path line comments(first:1){nodes{databaseId}} } } } } }'
  ```

  Select `totalCount` and page at `first:100`, the guard `skills/pr-status/SKILL.md` and `skills/pr-status-all/SKILL.md` already use: a `totalCount` above the number of `nodes` means the 100-thread cap was hit, so the list is itself a truncated subset and cannot confirm clean --- exactly the silent-subset failure this bullet is about, one query lower.
  The unresolved-thread count is the check worth trusting: it is per-thread rather than per-review, so it cannot be split across review objects.
  Keep the id filter for drilling into a specific review a human pointed at.
  Never use it to decide a round is complete.
  The same caveat applies wherever this filter still appears as a drill-down --- `skills/ardi/SKILL.md` and `skills/pr-status-all/SKILL.md`.
  `skills/post-merge/SKILL.md` was the one call site using it as a completeness check, and now reads the inline comments unfiltered instead.
  (UCD-SERG/lab-manual#452, 2026-08-04: `claude[bot]` posted review `4851937544` at `07:57:27Z` and `4851938388` at `07:57:34Z`, one finding each, and the linked review's own body was **empty** so both findings were inline-only.
  Filtering on the linked id found the Wayland finding and missed the "Windows" one, which surfaced only from the unresolved-thread count after the first had been resolved.)

- **`repos/{owner}/{repo}/issues/comments` -- without a number -- is repo-wide, not PR-scoped, and it fails by returning another PR's review.**
  The bullet above gives the correct form, `issues/<N>/comments`.
  Dropping the `<N>` produces a path that still looks PR-shaped and still returns well-formed review JSON, so `--paginate | last` hands back whichever comment is newest **anywhere in the repository**.
  On a repo with several PRs in flight that is routinely a review of a different PR.
  Nothing in the payload announces the mismatch: it is a genuine review with genuine findings, and a reader who asked for "the latest review on this PR" has every reason to accept it.
  The damage runs both ways -- the PR you are on gets reported as blocked by findings that are not its own, and you go looking for defects in files it never touches.
  Worse, the wrong query is **intermittently correct**: whenever the PR you care about happens to hold the newest comment in the repo, it returns the right answer, so the method can survive several rounds before it bites.
  Treat "this worked last time" as no evidence at all here.
  Prefer `gh pr view <N> --json comments`, which cannot be mis-scoped.
  (`ucdavis/bcs`, 2026-07-30: an agent driving #473 was handed #468's "Needs more work" verdict, with two HIGH findings about restricted-data handling, and was three sentences into treating them as #473's before the body's own `## Code Review: ucdavis/bcs#468` header caught it.
  The same query had been used for two earlier rounds and was right both times, by luck.)
- **Finding the PR(s) linked to an issue from the CLI: use the REST timeline endpoint, not `gh issue view --json`.**
  `gh issue view --json` has no `timelineItems` field (that exists only on `gh pr view --json`), so `gh issue view <N> --json timelineItems` errors — and a `2>/dev/null` swallows the error so the check silently returns nothing and *looks* like it passed.
  Query the timeline instead, with three gotchas:
  (1) in a `cross-referenced` event, `source.type` is always `"issue"`, so a PR is one whose `source.issue.pull_request` is non-null (`source.type == "pull_request"` never matches);
  (2) `--paginate` is required, or `gh api` returns only the first 30 events and silently misses a later cross-reference;
  (3) filter `source.issue.state` if you only want open PRs.
  Full call: `gh api --paginate repos/<o>/<r>/issues/<N>/timeline --jq '.[] | select(.event == "cross-referenced") | .source.issue | select(.pull_request != null) | select(.state == "open") | "#\(.number) \(.title)"'`.
  (Learned over three review rounds on #287.)
- **Whether `#N` is an issue or a PR is decided by the `pull_request` key, and `gh issue view` cannot decide it.**
  GitHub's REST API models every PR as an issue, so `gh issue view <N>` resolves a **PR** number too, returning its
  `state` (`MERGED`) as though it were an issue's --- a success there is evidence of nothing.
  Only `gh pr view <N>` discriminates, and it does so by *failing* on an issue
  (`Could not resolve to a PullRequest`), so the informative answer is an error rather than a value, which is easy to
  read as a broken command.
  One call answers it directly: `gh api repos/<o>/<r>/issues/<N> --jq '.pull_request != null'` --- `true` for a PR,
  `false` for an issue.
  Reach for it whenever two sources disagree about what `#N` is, rather than trusting whichever one resolved;
  per `metacognitive-monitoring.md`, a two-source disagreement is a prompt for a third check, not a finding.
  (Measured 2026-08-09 on `Morrison-Lab/ai-config`: `#1328` returns `false` and `#1334` returns `true`, while
  `gh issue view 1334` returns `{"number":1334,"state":"MERGED",...}` without complaint.
  Same key the timeline bullet above relies on.
  What it adds is that the plain `gh issue view` path answers for both kinds, and so distinguishes neither.)
- **Plain `gh pr checks` does NOT say which checks are REQUIRED (`--required` does, and only within the current rollup), and the legacy protection endpoint 404s on ruleset-gated repos --- so the lazy check confirms the wrong answer.**
  Plain `gh pr checks` reports check *state* only.
  `gh pr checks --required` (present in `gh` 2.98.0, checked 2026-09-02) filters to the required ones among the checks in the current rollup, and neither form can show a required check that is absent from that rollup, which is the base-gained-a-check case.
  And `gh api repos/<o>/<r>/branches/<branch>/protection` returns `404 Branch not protected` on a repo that gates the branch with a **ruleset** rather than legacy branch protection, which reads as "nothing is required" and *confirms* the mistaken assumption.
  Query rulesets too, before any "ready to merge" or "that check doesn't gate us" claim:
  ```bash
  gh api "repos/<o>/<r>/rulesets" --jq '.[] | "\(.id) \(.name) \(.target) \(.enforcement)"'
  gh api "repos/<o>/<r>/rulesets/<id>" \
    --jq '.rules[] | select(.type=="required_status_checks")
          | .parameters.required_status_checks[].context'
  ```
  (ucdavis/bcs, 2026-07-26: a red `docs` check was twice reported non-required and a PR reported "ready" on that basis;
  `docs` is required under ruleset 11050897, so the merge was blocked the whole time and a queue-wide blocker was mislabeled a cosmetic flake.
  The legacy endpoint's 404 would have reinforced the error if consulted alone.)
  Note: the two commands above cover only **repo-level** rulesets.
  Org-level rulesets (`gh api "orgs/<org>/rulesets"`) can also gate branches in member repos and would still return "nothing required" with the repo queries alone;
  add that sweep when the repo belongs to an org.

  **One endpoint answers both scopes, and `bypass_actors` is what turns a merge into evidence.**
  `repos/<o>/<r>/rules/branches/<branch>` returns the rules actually **in effect** on that branch, org-level rulesets included, so it needs no separate org sweep;
  each entry carries `ruleset_source_type` and `ruleset_id`, which is how a repo rule is told from an org one.
  Then read the bypass fields off the single-ruleset object before treating a successful merge as evidence about the gates:
  `mergeable_state: "clean"` says no required check is missing only when the actor could not have bypassed the rule instead.
  ```bash
  gh api "repos/<o>/<r>/rules/branches/<branch>" \
    --jq '.[] | "\(.type) \(.ruleset_source_type) \(.ruleset_id)"'
  gh api "repos/<o>/<r>/rulesets/<id>" --jq '{current_user_can_bypass, bypass_actors}'
  ```
  `current_user_can_bypass` is one of `always`, `pull_requests_only`, `never`.
  `bypass_actors` can be **absent from the response entirely** rather than an empty array, so test for the key rather than for a length.
  (Measured 2026-08-31 against two rulesets, both reporting
  `current_user_can_bypass: "never"` and no `bypass_actors` key at all:
  `Morrison-Lab/ai-config` ruleset 17712474 and `UCD-SERG/shigella` ruleset 6339629.
  The second is worth its own mention because it is how the trap was met:
  a reader who tests the value rather than the key sees `None` from
  `dict.get`, reports it as `bypass_actors: null`, and has recorded a field
  that is not in the response.)
  These are plain REST endpoints, so `curl` with `GH_TOKEN` reaches every one of them in a session with no `gh` on `PATH` and no ruleset MCP tool.
  Do not read the absence of such a tool as the settings being unreadable, per
  [`growth-mindset`](../shared/workflow/growth-mindset.md)'s "A limitation you never tested leaves no error to diagnose" (UCD-SERG/shigella#46, 2026-08-31).

  **Required checks are not the only thing a ruleset carries -- Copilot code review is turned on there too.**
  A `copilot_code_review` rule schedules Copilot itself, so nothing in the PR requests the review and no per-PR reviewer entry explains where it came from.
  Read it off the same endpoint:
  ```bash
  gh api "repos/<o>/<r>/rulesets/<id>" \
    --jq '.rules[] | select(.type=="copilot_code_review") | .parameters'
  ```
  On `ucdavis/bcs` (2026-07-30) ruleset `19248641`, scoped to `~DEFAULT_BRANCH`, returns `{"review_on_push":true,"review_draft_pull_requests":true}` -- which is why draft PRs there get Copilot reviews at all.
  Check this before concluding that a Copilot review was requested by a person, or that its absence means nobody asked.

  **`required_approving_review_count: 1` plus no automated reviewer that ever issues `APPROVE` is a permanent block, and it looks identical to reviewers merely being slow.**
  `reviewDecision` in `gh pr view --json reviewDecision` is `REVIEW_REQUIRED` until some review lands in the `APPROVED` state --- and `@claude`'s review workflow posts a plain `COMMENTED` review (see [`self-review-fallback.md`](../shared/workflow/self-review-fallback.md)), never `APPROVE`, while a quota-stubbed Copilot review is also `COMMENTED`.
  So on a repo whose ruleset sets `required_approving_review_count: 1` with no human or approving bot in the loop, every PR sits `REVIEW_REQUIRED` forever, with green CI and a clean-reading review comment, and nothing distinguishes that from "waiting on a reviewer who hasn't gotten to it yet."
  Diagnose it in one pass: `gh pr view <N> --json reviewDecision,mergeStateStatus,reviews --jq '{reviewDecision, mergeStateStatus, reviews: [.reviews[] | {author: .author.login, state: .state}]}'` alongside `gh api "repos/<o>/<r>/rulesets/<id>" --jq '.rules[] | select(.type=="pull_request") | .parameters.required_approving_review_count'` --- a `REVIEW_REQUIRED` decision with every review in the list `COMMENTED` and a nonzero required count means the block is structural, not a queue position. (`ucdavis/rampp` ruleset `3889405`, 2026-09-09: `required_approving_review_count: 1`, and on PR #166 five `copilot-pull-request-reviewer` reviews, every one `COMMENTED` and carrying the identical quota-stub body.
  The `@claude` responses are worse for this purpose than a `COMMENTED` review: `gh api repos/ucdavis/rampp/pulls/166/reviews` returns **zero** entries for `claude[bot]`, because that workflow posts plain issue comments rather than submitting reviews.
  A reviewer that files no review object cannot move `reviewDecision` however clean its prose is, so check `pulls/<N>/reviews` rather than `issues/<N>/comments` when asking whether a reviewer can satisfy an approval rule.
  `mergeStateStatus` is the field that points at it, and only before the merge: it read `BLOCKED` with all 16 check runs green, which is the tell --- green checks plus `BLOCKED` means a rule other than status checks, and `reviewDecision` then names which.
  After the merge the same field reads `UNKNOWN`, so take the reading while the PR is still open.
  A human clicking Approve is the only way past it, or an admin bypass where the ruleset's `bypass_actors` allow one on pull requests.)

  **A required-context STRING is derivable from the default branch's workflow files, and a PR's check-run names are not the place to read it.**
  A caller job invoking a reusable workflow publishes `<caller job> / <inner job>`, where each half is a **job** display name -- its `name:` where one is set, its key otherwise -- and the caller *workflow*'s `name:` appears nowhere.
  So `check: {uses: Morrison-Lab/gha/.github/workflows/spellcheck.yml@v2}` in a workflow whose `name:` is `Spellcheck` publishes `check / spellcheck`, not `Spellcheck`.
  Both branches of that rule are locally measured on `ucdavis/rampp` (2026-09-03).
  Run `33688762211` prints `check / spellcheck`, where neither job sets `name:`, so both halves are keys.
  Run `33727364476` prints `Check-Changelog / Check Changelog Action`, where the left half is the **caller**'s job key in the repo's own `news.yaml` and the right half is the `name:` on `check-news.yml@v2`'s inner job -- whose key, also `Check-Changelog`, never appears.
  The coincidence of those two names is why this example needs spelling out: read the caller's job key for the left half, always, however the called workflow happens to key its own job.
  Read the definitions off the default branch by ref, never off a checkout and never off a PR's rollup:
  ```bash
  gh api "repos/<o>/<r>/contents/.github/workflows?ref=<default-branch>" --jq '.[].name'
  gh api "repos/<o>/<r>/contents/.github/workflows/<file>?ref=<default-branch>" --jq .content | base64 -d
  ```
  A run can corroborate that those definitions compose as you read them, but **which** run is usable is the subtle half -- a `pull_request` run resolves the file from the head-into-base merge, so its base and its touched paths both matter.
  [`verify-the-right-artifact`](../shared/workflow/verify-the-right-artifact.md)'s twelfth shape carries those conditions.
  Do not reconstruct them from a `gh api .../jobs` line here, since a placeholder that names no branch is exactly what drops them.
  - **Do:** read the workflow file at `?ref=<default-branch>` and compose the context string from its job names.
  - **Don't:** read a required-context string off any pull request's check runs -- merged, recent, or green.
  (ucdavis/rampp, 2026-09-03: bare `Spellcheck` and `Check Changelog Action` were added to ruleset `3889405` on the strength of check-run names read off merged PR #157.
  Those names were accurate for `main` when #157 merged.
  The move to called reusable workflows landed on `main` three hours later via #153, retiring them.
  `main` now emits `check / spellcheck` and `Check-Changelog / Check Changelog Action`.
  Such a context never turns red -- it sits as `Expected`, so there is no failing signal to find the mistake by.)

- **GitHub PR Reviews REST API (`POST /repos/{owner}/{repo}/pulls/{number}/reviews`) Requirements & Fallbacks**:
  - `pull_number` MUST be an explicit integer in the URL path (e.g. `/pulls/412/reviews`), not `'current'` or branch names.
    Query `number` and `headRefOid` via `gh pr view --json number,headRefOid`.
  - Line numbers must be `>= 1` and `line >= start_line`.
    Normalize ranges with `min(start_line, end_line)` and `max(start_line, end_line)` to avoid `422 Unprocessable Entity` errors on inverted range inputs.
  - Multi-line inline review comments require `start_line` (start line), `line` (end line), and `start_side: "RIGHT"`.
  - Inline comments on files or lines outside active PR diff hunks return `422 Unprocessable Entity`;
    automatically catch `gh api` non-zero exit status and fall back to top-level issue comments (`gh pr comment`).
  - Prepend matched section headers (e.g. `#### 1. 🚨 Critical Issue`) to inline comment bodies so comments retain context and severity indicators on GitHub diff cards. (Morrison-Lab/gha#412, 2026-08-05).

- **Regex Parsing for Automated Agent Reports (`re.VERBOSE`)**:
  - Standardize on Python's built-in `re.compile` with `re.VERBOSE` (`re.X`) instead of third-party DSL wrappers (`humre`) or dual regex fallback paths.
    Dual regex definitions introduce implementation drift between local unit tests and CI runners.
  - Prefer match-boundary splitting over lookahead section delimiters.

- **Avoid lookahead regexes across markdown finding bodies containing code blocks.**
  Single-line comments in code blocks (`# comment` in Python, Bash, R, Ruby, YAML) start with `# `.
  A lookahead like `\n\#{1,6}[ \t]+` for section headers then treats those code comments as markdown headings, cutting a code-block suggestion off mid-snippet.
  (Morrison-Lab/gha#412, 2026-08-05).
- **Line-anchor the fence pattern when masking code blocks.**
  Mask fenced blocks before location matching with a line-anchored fence (`^[ \t]{0,3}```...`) under `re.MULTILINE`, and match each block's opening and closing fence as a balanced pair.
  Do not span blocks with a single `re.DOTALL` match: an unclosed fence then swallows everything up to a *later* block's closing fence, masking the valid location headers in between.
  (Morrison-Lab/gha#412, 2026-08-05).
  - **Second occurrence, in ai-config's own scripts rather than gha's review parser.**
    `` re.compile(r"```.*?```", re.S) `` pairs backtick *runs* wherever they fall instead of tracking fence open and close positionally, so a document whose runs are 4, 3, 4, 3, 3 masks the wrong spans.
    `scripts/check-links.py:22` and `scripts/check-pr-fully-clean.py:191-192` both carry it;
    `scripts/check-context-closure.py:200-240` is the same repo's correct positional implementation, so the fix is to reuse that rather than to re-derive one.
    Filed as [#1567](https://github.com/Morrison-Lab/ai-config/issues/1567).
    Read this as the class recurring rather than as a new lesson: the bullet above already states the rule, and
    [`fact-check-code-logic`](../shared/coding/fact-check-code-logic.md)'s "When one parser construct becomes tolerant of a condition, audit its siblings" says to sweep sibling constructs --- which here means sibling *scripts* sharing one repo, not only sibling regexes sharing one file.
    (2026-08-16.)
- **Use match-boundary splitting instead of single-pass lookaheads.**
  Collect every finding's location header (`**Location:** [file.ext:L10]`) into `matches`, then slice each body between consecutive matches.
  An interior body is `content[matches[i].end():matches[i+1].start()]`.
  The last match has no `matches[i+1]`, so its body runs to `content[matches[-1].end():]` (end of content) rather than indexing past the list.
  This eliminates catastrophic backtracking on nested code blocks and `#` code comments.
  (Morrison-Lab/gha#412, 2026-08-05).
- **Anchor a location match to its heading, not just the `Location:` line.**
  When intro text sits between a section heading (`#### 1. Critical Bug`) and its `Location:` tag, a regex keyed only on the immediate prefix of `Location:` misses the heading, and finding bodies then absorb the adjacent heading.
  Precompute each heading's start position preceding its location match and slice bodies from there.
  (Morrison-Lab/gha#412, 2026-08-05).
- **Strip backticks and leading slashes from location file paths.**
  LLMs sometimes wrap the path in backticks (e.g. ``**Location:** [`file.py`:L12]``) or prefix a leading `/` (`/src/main.py`).
  `POST /repos/{owner}/{repo}/pulls/{number}/reviews` rejects a leading-slash path with `HTTP 422` (`path cannot start with /`), and a stray backtick yields `HTTP 422: File path does not exist`.
  Normalize with ``.strip("'\"` ").lstrip("/")`` so the API gets a clean relative path.
  (Morrison-Lab/gha#412, 2026-08-05).
- **Resolve candidate instruction paths relative to `GITHUB_WORKSPACE`.**
  Relative lookups for root files (`CLAUDE.md`, `AGENTS.md`) depend on the working directory, so a script invoked from a subdirectory misses them.
  Use `os.path.join(os.environ.get("GITHUB_WORKSPACE", "."), rel_path)`, and `.lstrip("/")` the `rel_path` first: `os.path.join` discards the base whenever its second argument is absolute.
  (Morrison-Lab/gha#412, 2026-08-05).
- **Require double newlines `\n\s*\n` (or a compound section phrase) when truncating summary headers.**
  Trimming a summary on single newlines (`\n+#{1,6}`) or a bare keyword (`Recommendation`) can cut an inline comment body short when a finding contains a sub-heading like `### Recommendation`.
  Requiring `\n\s*\n`, or matching a compound phrase (`Overall Summary`, `General Recommendations`) with a negative lookahead, keeps such sub-headings intact.
  (Morrison-Lab/gha#413, 2026-08-05).

- **Shell Script Fail-Closed Safety in Workflows**:
  - Under `set -e`, use `if ! CMD; then` to safely handle non-zero exit status without `set +e`.
    Using `set +e` turns off `errexit` for subsequent pipeline steps (e.g., `jq`), risking failing open instead of closed on JSON parse errors.
    (Morrison-Lab/gha#412, 2026-08-05).

- **`gh pr edit --add-reviewer` silently no-ops when the requested reviewer is the PR's author, and the CLI reports success anyway.**
  GitHub forbids a review request aimed at a PR's author, and [`skills/request-pr-review/SKILL.md`](../skills/request-pr-review/SKILL.md)'s edge case records that the REST POST answers HTTP **422** --- but the `gh pr edit <N> --add-reviewer <login>` wrapper swallows it: exit 0, no error, no warning.
  Observed on `Morrison-Lab/wai#93`, 2026-08-22 --- the session requested `the repository owner` and the PR author was `the repository owner`.
  Two runs of `gh pr edit 93 --add-reviewer d-morrison` both exited clean, and `GET /repos/Morrison-Lab/wai/pulls/93/requested_reviewers` returned no users after each.
  Here the empty `requested_reviewers` list **is** decisive negative evidence: the request was refused at creation, not accepted-and-hidden.
  Consequence for the skill's own step: human review cannot be requested from a PR's author --- when the intended reviewer is the author, "request human review" degenerates to reporting the PR to that person in chat.

  - **Do:** compare the target reviewer login against `gh pr view --json author --jq .author.login` before requesting review, and skip the request when they match, saying the PR awaits that reviewer's own review.
  - **Don't:** read exit code 0 or the printed URL from `--add-reviewer` as proof a reviewer was attached --- use the REST POST, whose 422 is visible, or confirm with one `GET .../requested_reviewers`.

- **The reviewer-request API is not the surface to check, and a `422` reported for it did not reproduce.**
  `POST /repos/<o>/<r>/pulls/<N>/requested_reviewers` with `reviewers[]=copilot-pull-request-reviewer[bot]` returned **201**, and the plain `Copilot` and `copilot` logins were accepted the same way.
  So the login spelling is not what decides the outcome, and a `422` seen elsewhere is likelier to be about whether Copilot review is enabled for that repo at all -- untested here, since bcs has it enabled.
  The 201 body lists Copilot under `requested_reviewers`, but an immediate `GET .../requested_reviewers` returns `{"users":[],"teams":[]}` and `gh pr view --json reviewRequests` returns `[]`.
  Neither surface therefore answers "was Copilot asked to review this", in either direction.
  (Probed on `ucdavis/bcs#479`, 2026-07-30.)

  **A GraphQL `requestReviews` mutation is a third way to add nobody, and `suggestedActors` is not the provisioning test it looks like.**
  The reviewer-request POST returning 201 and adding nobody invites the next hypothesis, that Copilot code review is simply not enabled for the repository --- and `suggestedActors` looks like the query that would settle it.
  It does not.
  Measured 2026-09-15 with `gh api graphql -f query='query { repository(owner: "<o>", name: "<r>") { suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 20) { nodes { login __typename } } } }'`:
  `ucdavis/lbt` returned exactly one `Bot`, `copilot-swe-agent`, and so did `Morrison-Lab/ai-config`, which had a `copilot-pull-request-reviewer[bot]` review on #3678 at `2026-09-15T05:32:36Z`.
  Identical answers, opposite states, so the query discriminates nothing here: `CAN_BE_ASSIGNED` is about the **coding agent** you can assign an issue to, a different product from the PR reviewer, whose login never appears in that list on either repo.
  A session also reported the GraphQL `requestReviews` mutation carrying the reviewer bot's node id behaving like the REST POST --- returning without errors while the reviewer list stayed empty --- but that one is narration rather than measurement here: no repo, PR, node id, or response body was kept, and re-running it would be an outward mutation.

  The repository's own history is the closest thing to an answer, and it is weaker than it looks.
  As of 2026-09-15, `gh api "repos/<o>/<r>/pulls/<N>/reviews"` over every PR `ucdavis/lbt` had that day returns no reviews at all.
  That is 1 and 3 through 8, with #3 closed unmerged and #8 still open.
  That is a fact about the repository, not about the endpoint that was probed, and it is still not a provisioning verdict: nobody controlled for whether a review was ever requested on those PRs, which is the confound the "both candidate directions are unconfirmed" passage below already states for this file.
  Where a per-head answer is what you need, [`memories/copilot-reviews.md`](copilot-reviews.md)'s check-run query separates "never ran" from "ran and posted nothing".
  This file's standing Do, to read the posted review body, is unchanged.

  - **Don't:** read a `suggestedActors` list whose only bot is `copilot-swe-agent` as evidence about PR code review --- ai-config returns the same single bot while being reviewed.
  - **Don't:** escalate from REST to GraphQL when the REST call already returned success ---
    the mutation was reported to add the same nobody, and each attempt spends quota that is often the real cause.

  That 201-then-empty disappearance is **not** explained by the `review_on_push: true` rule above, and [`shared/workflow/pr-on-claim.md`](../shared/workflow/pr-on-claim.md)'s "blocked-request test has a false positive" section owns the argument and the deriving queries.
  The short version: `Morrison-Lab/ai-config` reproduces the identical 201-then-empty signature while carrying no `copilot_code_review` rule at either scope, so an empty pending list is evidence neither that the request was blocked nor that a review is coming.
  Only the posted review **body** settles whether a review is actually coming.
  Measured on `Morrison-Lab/ai-config` on 2026-09-15, across the eight POSTs below: no body came for any of them, though #3678 took a `copilot-pull-request-reviewer[bot]` review the same day at `05:32:36Z`.
  Across roughly three hours and eight POSTs spread over three PRs (#3629, #3696, #3699), every request returned 200 with an empty `requested_reviewers`, and `gh pr view --json reviews` counted zero Copilot reviews at every head throughout.
  So for those requests the open question above resolves to "no review is coming", and no further: the review on #3678 is what stops this being a fact about the repository.
  What that does NOT affect is `hooks/no-unreviewed-pr.py`: its `_argv_request` discharges on a successful mutating POST to the endpoint, never on a review arriving, so the obligation is satisfiable here whatever Copilot does.
  What it does affect is [`copilot-review-before-human`](../shared/vendored/copilot-review-before-human.md), whose point is an AI review BEFORE a human one --- which those three PRs did not get from Copilot, so the adversarial self-review was the only AI reader they had.
  That is a claim about a repository setting and can change, so re-measure rather than carrying it forward.
  The timeline event described next settles the strictly narrower question of whether the request was *accepted*, which those three surfaces also cannot answer --- so the two conclusions divide the question rather than competing for it.

  **The issue timeline's `review_requested` event is a fourth surface, and it is the one that does discriminate whether the request landed.**
  Measured 2026-09-02 on [Morrison-Lab/ai-config#3004](https://github.com/Morrison-Lab/ai-config/pull/3004).
  The POST returned **200**, not 201, with `"requested_reviewers":[]` in its own response body.
  A follow-up `GET` of the same endpoint stayed empty too.
  Together those read as a silently failed request, and invited four repeated POSTs, each landing the same way.
  The timeline carried a `review_requested` event the entire time.

  **Read that event with `--paginate` and with its timestamp kept, or the check is unsound in two ways at once.**
  This file already requires `--paginate` on this endpoint, in the `cross-referenced` bullet above.
  Without it `gh api` returns only the first 30 events and silently drops later ones.
  A busy PR's newest request is then exactly what goes missing.
  Collapsing the logins --- `| unique` --- discards *when* each event happened.
  A request from an earlier head then confirms forever, which is the opposite of what the check is for.
  Take the newest event's timestamp and compare it against the push or POST you are asking about:

  ```bash
  set -o pipefail
  gh api --paginate "repos/<owner>/<repo>/issues/<N>/timeline" \
    | jq -s '[.[][] | select(.event == "review_requested")
                    | select((.requested_reviewer.login // "") | startswith("Copilot") or startswith("copilot"))
                    | .created_at] | max'
  ```

  Two details in that command are this file's own standing rules rather than taste, and skipping either produces a wrong answer quietly.
  `--jq` cannot be combined with `--paginate` for an aggregate: it evaluates each page separately, so `max` returns one maximum *per page* rather than one across the run.
  Pipe the raw pages into standalone `jq -s`, exactly as the comment-polling recipe above prescribes, and enable `pipefail` so a failed `gh api` is not masked by a successful `jq`.

  Cross-checked against [#2979](https://github.com/Morrison-Lab/ai-config/pull/2979), a PR Copilot did go on to review: the same event is there, and that review is present.
  Its login differs by surface, which matters to any jq that filters on one.
  REST (`gh api .../pulls/<N>/reviews`) returns `copilot-pull-request-reviewer[bot]`.
  GraphQL (`gh pr view <N> --json reviews`) returns `copilot-pull-request-reviewer`, with the suffix stripped.
  Match on a prefix rather than on equality, or a filter written against one surface silently returns nothing on the other.

  - **Do:** check the timeline's `review_requested` event before re-issuing a reviewer-request POST that read back empty.
  - **Do:** treat the timeline event as confirmation the request landed, and the posted review body as the separate, later question of whether a review follows.
  - **Don't:** re-POST a request because the pending-list or `reviewRequests` read came back empty --- both are already documented above as uninformative in either direction.
  - **Don't:** read the timeline event as proof a review is coming;
    it confirms the request, not the outcome.

  **A #3010 instance bears on the "not blocked" half, not the "nor that a review is coming" half, and is weaker evidence than it first looks.**
  An empty pending list followed by a landed review shows emptiness is not evidence the request was *blocked*.
  It says nothing about the other direction, which only a zero-review outcome after an empty read could speak to.
  On `Morrison-Lab/ai-config#3010` (2026-09-02) the session **reports** running the POST repeatedly and seeing `requested_reviewers` come back empty on the immediate read every time, and Copilot reviews did land on that PR anyway.
  Weigh that empty-read observation as narration rather than measurement: the pending list is only observable at request time, so unlike the reviews themselves it cannot be re-derived afterwards, and the same PR is where the session's own POST count turned out not to match its timeline.
  [`pr-on-claim.cases.md`](../shared/workflow/pr-on-claim.cases.md)'s "The blocking message prescribes a non-dischargeable shape" carries the derived figures;
  read them there rather than restating them here, since #3010 is open and any copy drifts.

  So this instance does not upgrade the claim.
  What is measured on #3010 is that reviews arrived;
  that they arrived *over an empty pending read* rests entirely on the session's own report, from the one session whose self-counting is known to have been wrong.
  The `ucdavis/bcs` #648/#649/#650 record further down this file is the zero-review candidate for the other direction, and it is confounded too: [`challenge-the-assignment.cases.md`](../shared/workflow/challenge-the-assignment.cases.md) records `_argv_close`'s docstring in `hooks/no-unreviewed-pr.py` documenting HTTP 200 on this endpoint as what GitHub returns for an already merged or closed PR, adding nobody by design, and that confound was confirmed to apply to some of those four calls without being shown to explain all of them.
  Both candidate directions are therefore unconfirmed, and the honest statement stays *unreliable*, not *uninformative*.

  - **Do:** poll the review bodies on the head when you need to know whether a reviewer engaged.
  - **Do:** read `review_requested` timeline events as a lower bound on requests that actually **added** a reviewer, never as a POST count.
    Why a POST can add nobody is unsettled here --- the untested 422 reconciliation below is one account, a silent no-op another --- so take the bound from the events themselves and leave the mechanism open.
  - **Don't:** treat an empty pending read as evidence the request failed, nor as evidence a review is coming;
    that was already the rule and neither new data point changes it.
  - **Don't:** cite either direction as settled --- the bcs status-code confound is open, and the #3010 empty reads are unverifiable after the fact.

  **Both response codes --- the `422` and the `201` above, not the two directions just discussed --- were genuinely observed on the same repo the same day, so do not flatten this into "it returns 201".**
  One session ran the POST once and got `422`;
  another ran it three times, across all three login spellings, and got `201` every time.
  Neither session was lying, and the first one's real mistake was not the observation but the generalisation -- it turned a single failed attempt into a stated property of the repository, wrote that into a PR body as settled fact, and steered two later rounds with it.
  The second session's report then invited the mirror-image error, of treating `201` as the settled answer.

  The likeliest reconciliation, **untested**: GitHub answers `422` when the requested reviewer is already pending.
  The `review_on_push: true` rule above re-requests Copilot on **every push**, so there is a window after each push in which Copilot is already a pending reviewer and a manual request is a duplicate.
  That would make the response depend on *when* you ask rather than on how, and it fits both observations without either being wrong.
  It stays untested on purpose: probing consumes the per-user quota that is usually the actual reason Copilot is absent, so the experiment damages the thing it would explain.

  **A third observation, on `Morrison-Lab/ai-config#3852` (2026-09-21), narrows what a `201` proves without resolving that disagreement.**
  The POST returned `201`.
  Afterwards `requested_reviewers` read `{"users":[],"teams":[]}`,
  the issue timeline carried no `review_requested` event among its three events,
  and `get_reviews` was still `[]`.
  So a `201` is not evidence that a reviewer is now pending, and the timeline settles it where the status code cannot.
  `mcp__github__request_copilot_review` returned no output and registered nothing on the same PR,
  which is a second sighting of that silent result after #3799.

  The operational advice does not depend on resolving it.
  Don't spend a call on this endpoint either way while a `copilot-pull-request-reviewer` check run is queued or in progress on the head -- the ruleset already requested the review, and neither response tells you whether one is pending.
  A completed run on an unchanged head is no veto: after a Rebut/Defer-only round with no push, `skills/ardi/SKILL.md` requires a fresh request, and this endpoint is how to make it.
  A ready-for-review head with no such run about a minute after the push is the other case, measured per push in [`memories/copilot-reviews.md`](copilot-reviews.md).

  **As of 2026-08-04, Copilot is quota-exhausted across Morrison-Lab, so do not request it at all while the moratorium stands.**
  Its live expiry is `MORATORIUM_END` in `hooks/no-unreviewed-pr.py`, extended to December 2026 on 2026-09-02.
  The user stated this directly on 2026-08-04, in the words "copilot is unavailable until september" and "stop trying to get copilot reviews".
  Until then, skip both the `requested_reviewers` POST for `copilot-pull-request-reviewer[bot]` and the `request_copilot_review` MCP tool: either only produces a `COMMENTED` review whose whole body is *"Copilot was unable to review this pull request because the user who requested the review has reached their quota limit."*, which wastes a round and is not a verdict.
  This is a time-bounded override of two standing instructions that otherwise say to request Copilot every round: `shared/workflow/pr-on-claim.md`'s "Request the external reviewer in the same stride" and `shared/workflow/review-verdict-pitfalls.md`'s fifth case ("Keep re-requesting each round anyway").
  Until `MORATORIUM_END`, rely on `claude-review` plus self-review, which is exactly the no-reachable-external-reviewer fallback that fifth case already describes.
  **Measured 2026-09-01, then superseded the next day.**
  Read the two as a sequence rather than a contradiction.
  The quota did return, and Copilot code reviews resumed across Morrison-Lab in September 2026
  with login `copilot-pull-request-reviewer` and formal verdict headers
  (`### 🟢 Approval recommended` for clean signoff, `### 🟡 Changes recommended` for changes requested).
  Review verification tools and mistake patterns (Pattern 40) require recognizing `copilot-pull-request-reviewer`
  and its clean/not-clean verdict patterns so Copilot reviews are tracked as part of the automated review gate.

  **On the 2026-09-01 (Pacific) sample, with Copilot reviewing again, the 201-then-empty `requested_reviewers` signature above still held, and the fix is to poll `reviews`, not `reviewRequests`.**
  On the PRs below, `gh api repos/<owner>/<repo>/pulls/<N>/requested_reviewers -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'` returned HTTP 201 each time.
  The section above records 422 and refusal as other outcomes.
  `gh pr view --json reviewRequests` stayed empty each time.
  On those same requests the review then landed (a few minutes later on [#2976](https://github.com/Morrison-Lab/ai-config/pull/2976)), visible under `gh pr view --json reviews` with author login `copilot-pull-request-reviewer`, which `startswith("copilot")` matches.
  That is an observation of those PRs, not a guarantee: the empty request list stays inconclusive (see the 201-then-empty section above), and the posted review is the only evidence of arrival.
  The inline comments of that review (`gh api repos/<owner>/<repo>/pulls/<N>/comments`) carry `user.login` `Copilot` instead, so a query over comments needs a case-insensitive test (`test("copilot"; "i")`) while a query over reviews does not.
  On those PRs a re-request after a push was followed by a fresh review on the new head.
  Count reviews per `commit.oid` to tell a new round from the old one.
  Measured 2026-09-01 on [#2975](https://github.com/Morrison-Lab/ai-config/pull/2975) through [#2979](https://github.com/Morrison-Lab/ai-config/pull/2979) and [#2983](https://github.com/Morrison-Lab/ai-config/pull/2983).

  - **Do:** poll `reviews` (count, login, and `commit.oid`) for the landed review rather than `reviewRequests`.
  - **Do:** match the inline-comment author case-insensitively, since it is `Copilot` there and `copilot-pull-request-reviewer` on the review.
  - **Don't:** read an empty `reviewRequests` as a failed request, nor as a review on its way --- it discriminates nothing, so read the check run and the posted review instead.
  - **Don't:** read a review on an older `commit.oid` as the verdict on the head you just pushed.

  **Two reviewers are two finding streams, and the checker's one-line verdict is not either of them.**
  Measured 2026-09-02 on [#2979](https://github.com/Morrison-Lab/ai-config/pull/2979):
  five Copilot rounds were read and answered while the Claude review repeated the same docstring finding five times unread,
  because each poll read only the verdict line of `check-pr-fully-clean.py` and the Copilot inline comments.
  The checker prints one or more blocker bullets per selected review (a review state such as `CHANGES_REQUESTED` and a body finding each get one).
  For a structured review payload that bullet carries the first finding's file and message.
  For free-text prose it names only the regex pattern that matched.
  In neither case is it the full findings list.
  Those bullets say which review still blocks.
  The findings themselves are in that review's body, and each reviewer's set has to be read after every push.
  - **Do:** after each push, read the checker's blocker bullets, then the Claude `### Findings` section and the Copilot review body and comments they point at.
  - **Don't:** treat the verdict line, or one reviewer's round, as the whole of what is open on the head.

  **Re-measured 2026-08-06 with a wider denominator:** across `Morrison-Lab/ai-config`'s last 60 merged PRs, every Copilot review object carries a refusal body and **zero** are substantive (query in [`shared/workflow/pr-on-claim.md`](../shared/workflow/pr-on-claim.md), which also explains why the object count drifts between runs while the zero does not).
  Say *refusals* rather than *quota refusals* when reporting a count like this, because the body alone does not name a cause: the 2026-08-06 Actions incident listed "Copilot code review" among its affected components, so a refusal inside that window has two candidate explanations.
  `Morrison-Lab/ai-config#1223` owns that discrimination and carries the timestamp table --- read it rather than re-deriving.
  Applying its two discriminators to this wider set clears it: all 39 objects predate the incident's `created_at` of `2026-08-06T15:22:49Z` (latest is `09:14:23Z`), and all 39 carry the quota-specific wording "because the user who requested the review has reached" rather than a generic error.
  Every one also postdates the override rather than explaining why it was written --- `select(.submittedAt >= "2026-08-04")` returns 39 of 39, earliest `2026-08-04T04:38:12Z`.
  So requests are still being issued despite this override, and each one spends a request on a guaranteed non-verdict.
  That every one of those PRs merged on `claude-review` alone is the same practice slippage
  [`shared/workflow/flag-practice-slippage.md`](../shared/workflow/flag-practice-slippage.md)
  already records at eight PRs, met here at larger scale and still live;
  read that fragment for the argument rather than re-deriving it.
  A `no-unreviewed-pr.py` `Stop` hook (ai-config#1041) enforces the opposite instruction, and until 2026-08-19 it collided with this override on every turn the quota was out.
  It fired every turn a PR opened or readied this session sat awaiting review, demanding a Copilot request -- the one action this override forbids -- so a session honoring the override never satisfied it and the demand repeated each turn.
  One Morrison-Lab/gha session spent over a dozen turns in this loop before the collision was recognized.
  That loop persisted even though each request POST succeeded, because the hook discharges only on a reviewer-request that is the **last simple command in the call** (its exit status is then unambiguous), and every turn chained a verify `gh pr view` after the POST,
  leaving it non-last.
  Running the POST as its own last command would discharge the hook, but that is still the Copilot request the override forbids.
  So no discharge available to a session honoring the override was ever the right fix.
  The collision was in the hook's own demand rather than in how any session answered it.
  (Reproduced on Morrison-Lab/ai-config#1128, 2026-08-04.)

  **Superseded 2026-08-19 (ai-config#1709): the script now honors this override itself, and nothing needs unregistering.**
  `hooks/no-unreviewed-pr.py` carries `MORATORIUM_END`, matching the expiry recorded above, and returns before scanning while that date is still ahead --- so the collision is gone on every installation shape and the guard re-arms itself on the day the override names, with nobody having to remember.
  Keep the two in step: extending the moratorium here means editing that constant in the same PR, or the memory and the hook start disagreeing again.

  The remedy this paragraph used to give --- unregister it from `~/.claude/settings.json`'s `Stop` hooks --- covered only ONE installation shape, and it is worth recording why rather than just deleting it.
  Where ai-config is installed as a **plugin**, the registration lives in ai-config's own `hooks/hooks.json` under `${CLAUDE_PLUGIN_ROOT}`, so there is nothing in the user's settings to remove: the edit succeeds, changes no behaviour, and the hook keeps firing.
  A remedy that silently does nothing is worse than none, because it reads as applied.
  The plugin shape is now the common one --- `d-morrison/rme#1074` migrates off the `.ai-config` submodule, and `Morrison-Lab/gha`'s `run-claude-review-attempt` installs the plugin for every review run --- which is the general lesson: a fix aimed at a local registration has to name which installation shape it assumes.

  - **Do:** treat the hook's `MORATORIUM_END` as the switch, and move it and this memory together.
  - **Don't:** unregister the hook from `~/.claude/settings.json` to quiet it --- that does nothing on a plugin install, and nothing is needed on any install now.

  The general form of that lesson --- any hook suppression aimed at a registration is blind to one of the two installation shapes --- lives in [`shared/workflow/keep-checkouts-fresh.md`](../shared/workflow/keep-checkouts-fresh.md), beside the enabling-direction hazard it mirrors.

  **Restated and widened 2026-08-19: the moratorium covers ALL REPOS, not just Morrison-Lab.**
  The user said "stop requesting copilot reviews until september", then, asked about scope, "ALL REPOS".
  So the paragraph above should be read with `ucdavis/bcs` and every other repo inside it, not only the Morrison-Lab org the 2026-08-04 measurement happened to cover.
  The expiry is deliberate rather than incidental: re-verify the quota at the end date and re-enable the per-round request, rather than letting the moratorium become permanent by default (`shared/writing/timestamp-volatile-claims.md`).

  **Extended 2026-09-02 to December 2026, on the directive "stop using copilot reviews".**
  The September expiry arrived and did exactly what it was designed to do: the guard re-armed on 2026-09-01, resumed demanding a Copilot request, and a session complied before the user stopped it.
  Derived rather than recalled, by counting `review_requested` events for Copilot across that session's PRs: six PRs received requests (`#3004`, `#3007`, `#3016`, `#3036`, `#3044`, `#3076`), nine request events in total, and only the PR carrying this extension received none.
  That is the mechanism working, not failing --- a date that re-arms is the whole reason the switch is a date and not an env flag --- but it does show the cost of a short window, since the re-arm lands mid-session with no announcement.
  So the new window is three months rather than a fortnight, far enough from the day-to-day that its expiry is unlikely to surprise an active session.

  **Reconfirmed 2026-09-10.**
  The directive, verbatim: "copilot is unavailable until october;
  remember this and stop trying to use it".
  Logged for the date and the wording, as each prior directive is.
  `MORATORIUM_END` is deliberately unchanged: December already covers October, so the constant remains the operative window and editing it down to the stated month would shorten the guard rather than extend it.

  `MORATORIUM_END` in [`hooks/no-unreviewed-pr.py`](../hooks/no-unreviewed-pr.py) is the live value and this paragraph is its prose pair;
  the constant's own comment requires editing both together, and an extension that moves one is a silent divergence.

  **Its test file derives every clock from that constant, and must keep doing so.**
  Before this extension the tests pinned literal dates --- an `AFTER` of `2026-09-02`, and a boundary pair of `2026-08-31`/`2026-09-01`.
  Moving the end date to December puts all three inside the new window, where `main()` returns at its first line.
  Measured by reverting the literals and running them against the December constant: **92 passed, 88 failed**.
  The split is the point rather than the totals.
  Every case expecting a block fails loudly, because an inert guard blocks nothing;
  every true-negative case passes vacuously, because "no block" is what it expected anyway.
  So half the suite goes quiet while the other half screams --- loud enough to notice, and the passing half still means nothing.

  An earlier draft of this paragraph claimed the whole file would pass vacuously, citing "179 cases".
  That number came from a run with the fix already half-applied, generalized to the unfixed case without rerunning it, and labelled "measured".
  It is recorded here rather than quietly replaced, because the correction is the same overclaim-from-one-observation this file's neighbours keep catching.

  - **Do:** edit the constant and this paragraph in the same commit.
  - **Do:** derive any date a test needs from `MORATORIUM_END`, never write one out.
  - **Don't:** read a passing suite after an extension as evidence the guard still works --- check that the pinned clock still sits outside the window.

  The same session tried four `requested_reviewers` POSTs for `copilot-pull-request-reviewer[bot]` on `ucdavis/bcs` PRs #648, #649, and #650.
  Each was accepted with HTTP **200**, and each left zero Copilot reviews behind, the pending request absent from both `gh pr view --json reviewRequests` and the `GET .../requested_reviewers` endpoint.
  Re-verified 2026-08-20: all three PRs still carry zero Copilot reviews and no pending Copilot request.

  **Read that as weak evidence, not as the 201-then-empty signature above, and the reason is the status code.**
  `hooks/no-unreviewed-pr.py`'s `_argv_close` docstring records that GitHub accepts this POST on a **merged or closed** PR with HTTP **200** and adds nobody, which is a different outcome from the **201** the signature above is defined by.
  Two of the three PRs fit that reading: #648 was closed at `2026-08-20T01:33:34Z` and #649 merged at `01:40:47Z`, both plausibly before the POSTs.
  #650 was open throughout and remains open, so at least one of the four is not explained that way, but the per-POST timings were not recorded and cannot now be recovered.
  So this measurement does not establish that a Copilot request fails on a live PR outside Morrison-Lab.
  It is not what the moratorium rests on either --- the maintainer's directive is --- and it is kept here only so a later reader does not re-derive it as fresh support.
  A clean version of the experiment would POST once on a PR known to be open and record the status code beside the timestamp.

  **This outranks `hooks/no-unreviewed-pr.py`, which demands a Copilot request after every PR open, ready, or re-head.**
  That hook's docstring names two legitimate deferrals, a draft PR and a redaction PR.
  A standing maintainer directive is a third, and it wins: a user instruction outranks a skill or a hook.
  Say so in the PR or the recap when you defer on this ground, so the omission reads as a recorded deferral rather than as a missed step --- that is the whole reason to write it down, since silence and compliance look identical from outside.
  Unregistering the hook, per the paragraph above, remains the fix for the turn-by-turn nag while the moratorium is live.

  - **Do:** skip the `requested_reviewers` POST and `request_copilot_review` on every repo while `MORATORIUM_END` is still ahead, and state the directive as the reason when a PR ships without a Copilot request.
  - **Do:** rely on `claude-review` plus self-review meanwhile, which is the same no-reachable-external-reviewer fallback the paragraph above already cites --- `shared/workflow/review-verdict-pitfalls.md`'s fifth case.
    Withheld-by-policy is not literally unreachable, so the fit is by analogy rather than by definition.
    Say which one applies when reporting a PR's review state.
  - **Don't:** read `hooks/no-unreviewed-pr.py`'s demand, or `shared/workflow/pr-on-claim.md`'s "request the external reviewer in the same stride", as overriding a standing user directive.
  - **Don't:** treat an accepted POST (200 or 201) as evidence the moratorium is over --- all four 2026-08-19 requests were accepted and none produced a review.
  - **Don't:** let `MORATORIUM_END` pass without re-verifying.
    The expiry is part of the rule, not a footnote to it.

  **Recurrence 2026-08-21 (Morrison-Lab/gha#571), and the new failure is what happened AFTER the request, not the request itself.**
  Copilot was requested on that PR on 2026-08-20 despite this moratorium, which is the slippage already recorded above.
  What had not been recorded is the second half: the absence of a review was then investigated as an **open question**, and `Morrison-Lab/gha#575` was filed asking whether Copilot is licensed for the org --- a question the 2026-08-06 re-measurement above had already answered, with a wider denominator than the sweep that re-asked it.
  The issue was retracted and closed within the hour.
  Read that as the more expensive error of the two.
  A forbidden request wastes one call.
  Treating its predictable silence as a mystery spends a search sweep, a negative-control sweep, and a filed issue,
  and it publishes a tracker item asserting that Copilot is "one of two cross-vendor reviewers still in service" while a standing directive says it is not.
  The rule this violates is already in the corpus and is not Copilot-specific: [`shared/workflow/grep-is-not-coverage.md`](../shared/workflow/grep-is-not-coverage.md) governs asserting a gap from a search, and the gap here was in the memory file rather than in the repo.

  **The moratorium's mechanism is enforced against the HOOK and not against the session, which is why a self-initiated request still gets through.**
  `hooks/no-unreviewed-pr.py`'s `moratorium_active()` makes the guard return before scanning, so it stops **demanding** a Copilot request.
  Nothing anywhere refuses one a session decides to make on its own initiative.
  Standing down and forbidding are different operations, and the paragraphs above describe only the first --- so the fix for the turn-by-turn nag is complete while the directive itself has no enforcement at the moment of action.
  Tracked as `Morrison-Lab/ai-config#1877`.

  **`reviewed-by:copilot-pull-request-reviewer` is not a way to ask whether Copilot has engaged, and its zero is a trap.**
  Measured 2026-08-21: that qualifier returns `total_count: 0` for both `Morrison-Lab/gha` and `Morrison-Lab/ai-config`, while ai-config demonstrably holds the 39 Copilot review objects counted above.
  So refusal reviews are not indexed by it, and a zero there means neither "never requested" nor "never answered".
  The posted review **body** remains the only surface that discriminates, exactly as [`shared/workflow/pr-on-claim.cases.md`](../shared/workflow/pr-on-claim.cases.md) already says for the pending-request list.

  **Two negative controls are needed before reading anything into a zero from a `reviewed-by:` search, and they test different things.**
  The first is the ordinary one this corpus already requires of any sweep: does the detector run at all?
  `repo:Morrison-Lab/ai-config reviewed-by:the repository owner` returns `total_count: 273`, so the qualifier works.
  The second is specific to a search keyed on a **login**, and it is free: GitHub rejects a nonexistent one outright rather than returning zero.
  `reviewed-by:Copilot` fails with `422 Validation Failed ... The listed users cannot be searched either because the users do not exist or you do not have permission to view the users`, whereas `copilot-pull-request-reviewer` returned `0` with no `422`.
  A quiet zero therefore establishes that the login resolved, and a `422` establishes that it did not --- so the error channel is the negative control for the predicate, and running the search against a deliberately bogus value is the cheapest way to prove the value you care about was understood.
  Note what neither control can reach: both came back clean here, and the zero was still uninformative for the reason in the paragraph above.

  - **Do:** run a bogus-value search alongside a login-keyed one, so a `422` versus a quiet zero tells you whether the value resolved.
  - **Do:** re-read this section before investigating a missing Copilot review --- the answer for any date before September 2026 is already here.
  - **Don't:** treat a moratorium the hook honors as a moratorium the session is prevented from breaking.
    Only the nag is mechanized.
  - **Don't:** file an issue asking why Copilot did not review, or whether it is licensed, while the moratorium is live.
- **`gh pr checks` prints the literal word `fail` for a CANCELLED job, but only
  when its output is not a terminal --- which is always, for an agent.**
  A cancellation and a real failure are therefore the same word in the column
  most people read, and they want opposite responses: a re-run versus a
  debugging round.
  `gh` itself distinguishes them internally and then discards the distinction
  on the way out.
  In `cli/cli` v2.92.0 (the installed version, checked with `gh --version`),
  `pkg/cmd/pr/checks/aggregate.go` gives `CANCELLED` its own bucket, separate
  from `ERROR`/`FAILURE`/`TIMED_OUT`/`ACTION_REQUIRED`:
  ```go
  case "CANCELLED":
      item.Bucket = "cancel"
  ```
  `pkg/cmd/pr/checks/output.go` renders that bucket as a muted `-` in a TTY,
  identically to `skipping` --- and then, for the non-TTY table:
  ```go
  if o.Bucket == "cancel" {
      tp.AddField("fail")
  } else {
      tp.AddField(o.Bucket)
  }
  ```
  So a human at a terminal sees a cancellation as a dash, and a piped or
  captured run sees `fail`.
  Two consequences worth keeping apart.
  A human's report of what they saw and an agent's are not describing the same
  output, so "it's showing as failing" from one is not corroboration for the
  other.
  And the fix is one flag, not a heuristic: **`--json name,state,bucket`**
  preserves `bucket: "cancel"` and `state: "CANCELLED"` distinctly from `fail`,
  which decides it exactly rather than by inference
  ([`algorithmatize-checks`](../shared/workflow/algorithmatize-checks.md)).
  Duration is a decent corroborating tell --- a review job cancelled by a
  concurrency race dies in seconds where a real one takes minutes --- but take
  it from `completed_at` minus `started_at` on a completed run, never from
  `status`, per [`fully-clean`](../shared/workflow/fully-clean.md) criterion 1.
  Prefer the flag to the tell: the flag is exact and the duration is a prior.
  For the cause of these cancellations, and why the *gate* job then reports
  failure too, see the `cancel-in-progress` entries in
  [`memories/debugging.md`](debugging.md) and
  [`pr-on-claim`](../shared/workflow/pr-on-claim.md).
  (2026-07-31: a 6-second "failing" `review / claude-review` was read as a real
  failure and debugged as one; it was a concurrency cancellation, and needed
  only a re-run.
  Confirmed against a real cancelled run on Morrison-Lab/ai-config commit
  `7b006485`, whose `review / claude-review` check run carries
  `conclusion: cancelled` while its dependent `review / require-review` carries
  `conclusion: failure`.)
- **`gh run list -w "Workflow Name"` fails if multiple workflow files share the same `name:` field in their YAML.**
  The command exits 1 with `could not resolve to a unique workflow; found: workflow-a.yml workflow-b.yml`.
  This happens commonly when reusable workflows (like a review action) are called by multiple caller workflows, or when two different files just happen to use the same `name`.
  The fix is to query by the **exact filename** instead of the display name: `gh run list -w workflow-b.yml`.

- **`gh issue comment` on a PR uses GraphQL `addComment`, requiring `pull_requests: write`.**
  When commenting on a pull request from a workflow job or token with only `issues: write` scope, `gh issue comment <pr-number>` fails with an authorization error.
  This happens because `gh issue comment` calls GraphQL's `addComment` mutation under the hood, and GitHub's GraphQL schema requires `pull_requests: write` whenever the commented issue is a pull request.
  In contrast, the REST endpoint `POST /repos/{owner}/{repo}/issues/{issue_number}/comments` with `Content-Type: application/json` accepts either `issues: write` or `pull_requests: write`.
  - **Do:** in workflows with `issues: write` commenting on PRs (e.g. comment-triggered dispatchers), use the REST endpoint piping JSON through stdin without an added newline and with explicit JSON content-type:
    `printf '%s' "$BODY" | jq -Rs '{body: .}' | gh api "repos/{owner}/{repo}/issues/{issue_number}/comments" --method POST -H "Content-Type: application/json" --input -`.
  - **Don't:** use `gh issue comment` in jobs lacking `pull_requests: write` when commenting on pull requests, or pass raw markdown bodies via unescaped `-f body=...` flags.
  (Measured 2026-09-24 on [Morrison-Lab/gha#931](https://github.com/Morrison-Lab/gha/issues/931) ([PR #933](https://github.com/Morrison-Lab/gha/pull/933)).)

- **`gh workflow run` without `--ref` queries GraphQL `repository.defaultBranchRef`, failing when missing `contents: read`.**
  Calling `gh workflow run <file.yml>` without an explicit `--ref` causes `gh` to attempt determining the default branch by querying GraphQL `repository.defaultBranchRef`.
  On private repositories or with limited-permission tokens (such as a GitHub Actions job with only `actions: write`), this query fails with `unable to determine default branch for <owner>/<repo>: GraphQL: Resource not accessible by integration (repository.defaultBranchRef)`.
  Explicitly passing `--ref "$DEFAULT_BRANCH"` (or `--ref <branch>`) skips the GraphQL query entirely and allows workflow dispatch under `actions: write` alone.
  - **Do:** always pass `--ref "$DEFAULT_BRANCH"` or `--ref <branch>` to `gh workflow run` on fallback dispatch paths and in automated workflow steps.
  - **Don't:** omit `--ref` in automated `gh workflow run` calls and rely on `gh` to query the default branch dynamically.
  (Measured 2026-09-24 on [Morrison-Lab/gha#931](https://github.com/Morrison-Lab/gha/issues/931) ([PR #933](https://github.com/Morrison-Lab/gha/pull/933)).)

## `gh pr view --json` returns ONLY the requested fields, so a consumer reading an unrequested key is dead

`gh pr view <N> --json a,b` emits an object with exactly `a` and `b`.
A key that was not asked for is absent, not null-valued and not defaulted, so anything reading it gets `None` and its caller silently takes the else branch.

This is the supply side of a rule [`verify-the-right-artifact`](../shared/workflow/verify-the-right-artifact.md) already states about the consuming side --- "a matcher is a claim about its input, not a supply of one".
A property is the same kind of claim.
Declaring `is_draft` as `self._data.get("isDraft")` asserts what the value means and supplies nothing;
the field list one function away is what decides whether it ever arrives.

Measured 2026-09-14 on `scripts/lib/pull_request.py` ([ai-config#3652](https://github.com/Morrison-Lab/ai-config/pull/3652)).
An `is_draft` property was added without `"isDraft"` being added to `_fetch_pr_data`'s field list, so it returned `False` for every real PR including actual drafts, and the `check-pr-fully-clean.py` branch it gates was unreachable.

**The test passed because the test was more generous than `gh`.**
That is the part worth carrying, because it is what made the defect invisible rather than merely present.
The suite built its `PullRequest` with `__new__` and injected `_data` with `isDraft` already in it, so 865 cases exercised the message formatting and none exercised the fetch.
A fixture that supplies a field the real command would have withheld cannot fail on a missing field, whatever else it checks.
The fix is a fetcher that MIMICS the filtering --- parse the `--json` argument, return only those keys --- so a property whose key is not requested fails in the suite instead of in production.

Two adjacent traps in the same area.
The first is recorded above: a field name `gh --json` rejects outright (`merged`) errors loudly, which makes it the easy case.
The second is new here --- the `--from-json` payload path does not filter at all (`PayloadFetcher._pr()` returns the whole `pr` object), so a defect in the `gh` field list is invisible from a remote session that only exercises payloads.
That is how this one survived a manual check: the verification ran through `--from-json`, where the field really does arrive.

- **Do:** add the field to the `--json` list in the same change that adds the property reading it.
- **Do:** make a fetch fixture filter by the requested field list, so it is never more generous than `gh`.
- **Don't:** read a property's existence as evidence its field is fetched.
- **Don't:** trust a suite that injects the parsed data to say anything about how that data is obtained.

## `gh pr update-branch` creates a merge commit and triggers CI

When a PR is out of date with the base branch,
`gh pr update-branch <PR>` is a convenient way to merge the base branch into the PR.
It avoids manually checking it out and running git merge or rebase.

However, note that this action creates a new merge commit on the PR branch.
This will trigger any CI pipelines or automated review workflows that run on push.
You must wait for those new runs to pass before the PR is fully clean again.

(Measured 2026-08-25 via `gh pr update-branch --help`)

**Confirmed live 2026-08-30 on Morrison-Lab/ai-config#2638, as the remedy for a merge refusal --- and the re-verify runs against the NEW head.**
`gh pr merge --squash` refused a review-clean PR whose `mergeable` read `MERGEABLE` with "not mergeable: the head branch is not up to date with the base branch" --- `MERGEABLE` reports only conflict-freedom, so it does not predict this refusal (branch protection requires up-to-date branches here: `gh api repos/Morrison-Lab/ai-config/branches/main/protection` reported `required_status_checks.strict: true`, read 2026-08-30).
`gh pr update-branch <N>` cleared it (the merge commit changes the branch, not the PR's diff against the base);
after the re-triggered runs described above, `python3 scripts/check-pr-fully-clean.py <N>` re-verified against the **new** head, and the merge went through.
[`github.md`](github.md)'s "not up to date with the base branch" section carries the fuller treatment, including pinning any poller to the head SHA `update-branch` actually produced.

- **Do:** on the "not up to date" refusal, run `gh pr update-branch <N>`, then re-verify fully-clean on the new head before merging.
- **Don't:** retry the merge unchanged, treat the old head's clean verdict as covering the new head, or read `MERGEABLE` as predicting the merge will be accepted.

## Strict branch protection makes a clean PR queue merge serially

Under branch protection with `required_status_checks.strict: true`,
`update-branch` (the section above) is also the toll every merge pays:
a PR whose checks passed against an older base reads `BEHIND`
and `gh pr merge` refuses it,
so a queue of clean PRs merges strictly serially ---
update one, wait out its CI and review re-run, merge,
and every remaining PR is `BEHIND` again.
Landing N simultaneously ready PRs thus costs O(N^2) review rounds and serial latency.
Enabling a GitHub merge queue ([`shared/workflow/merge-queue.md`](../shared/workflow/merge-queue.md))
eliminates this churn by building speculative merge trees on the forge side,
reducing the verification cost to O(N).
Batch-updating the queue without a merge queue wastes the re-runs:
all but the next PR go stale before their turn.
(Measured 2026-08-27 clearing the ai-config queue: five PRs,
one update-plus-rerun cycle each.)

- **Do:** update one PR at a time and merge it the moment it is green,
  then start the next PR's update (or use a merge queue where configured).
- **Don't:** batch-update the whole queue without a merge queue --- every PR but the next one
  goes `BEHIND` again before its turn, and its re-run is wasted.

## `gh pr merge` can land with no output surfaced at all

Measured 2026-09-03 on `Morrison-Lab/ai-config` through the Claude Code Bash tool: `gh pr merge <N> --squash --delete-branch` came back with no stdout, no stderr, and no error surfaced, and the merge had in fact landed.

One observation cannot separate `gh` printing nothing from the harness surfacing nothing, since the two are identical from here.
So the durable claim is about the *reading* rather than about which layer swallowed the output, and it covers any forge command whose effect is separately checkable --- [`claude-code.md`](claude-code.md) already draws the sibling line for a bounded probe, that empty output is not evidence it did nothing.
Settling the layer needs the same merge run outside the tool, which this measurement did not do.

Empty output is therefore not a signal in either direction here, and the two readings it invites are both wrong and both expensive.
Read as a failure, it prompts a retry, which then fails on the PR's already-merged state and reads as a second, different problem.
Read as a success, it is right by luck rather than by evidence.

One query settles it, and it is cheaper than either misreading:

```bash
gh pr view <N> --json state,mergedAt,mergeCommit
```

- **Do:** confirm a merge from a state query rather than from the merge command's own output.
- **Don't:** read empty output from `gh pr merge` as failure, and don't retry on it.

## `gh pr merge` needs `-R` for the standing-grant repo check to fire

`CLAUDE.md` gives PRs targeting `Morrison-Lab/ai-config` (and the shared macros repo) a standing `mwc` grant, and `hooks/no-unauthorized-merge.py` implements it by reading the merge's **target repo off the command**.

So the grant is not resolved from the checkout.
Run this from an ai-config worktree, on an ai-config PR, and it is refused:

```console
$ gh pr merge 3635 --squash --delete-branch
MECHANISTIC PROHIBITION: `gh pr merge` is strictly blocked without explicit permission.
```

Add `-R Morrison-Lab/ai-config` and the identical merge succeeds.
Nothing in the refusal says the repo could not be determined --- it reads as a policy denial, which invites the wrong remedy (asking for permission, or reaching for an override) when the fix is one flag.

The same reasoning covers the `gh api .../pulls/N/merge` form, which names the repo in the URL and so always resolves.

- **Do:** pass `-R <owner>/<repo>` on every `gh pr merge`, including from a checkout of that same repo.
- **Don't:** read the refusal as a missing grant --- check first whether the command names the repo the grant is scoped to.

(Measured 2026-09-14 merging ai-config#3635.)

## Antigravity `gh pr merge` gate bans parentheses in arguments

In Antigravity sessions, `gh pr merge` is governed by `plugins/ai-config/enforce-mwc-review-gate.py`,
which checks the raw command string against `CHAIN_CHARS` (`` (";", "&", "|", "\n", "$(", "`", "(") ``).
Parentheses in `--subject` or `--body` trip this check;
see [`antigravity.md`](antigravity.md).
