Block the Review Loop on a Shape the Reader Does Not Know - #608
Conversation
A Copilot review states how many of the changed files it read, and nothing parsed that line. A round that read part of the diff carries the right commit, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything the loop checks. Measured over 332 review bodies on this repository, five such rounds landed across three pull requests and all three merged. The line is the third instance of a shape already answered twice here, and generalizing is the point: every reader keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. The suppressed heading was reworded and the count went to zero, the section then moved inside another wrapper and it went to zero again. Each was found after it had landed rather than by the gate. So the digest now vets what the reviewer sent against an inventory measured from those bodies, seven headings, six summaries and three metadata labels, and blocks on anything outside it, a body with no heading and a reviewer login that is not the one every query filters on among them. The remedy is stated as filing an issue where the reader lives, and whether to merge regardless is the maintainer's decision rather than the agent's. Closes #607 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Tightens the repository’s Copilot review gate by teaching scripts/pr_review.py to (1) detect partial file coverage in a formal review and (2) fail closed when the reviewer output contains unrecognized structural markers, so the review loop cannot silently pass on a misread review body.
Changes:
- Extend
scripts/pr_review.pyto parse per-round “files reviewed” coverage lines, emitcoverage=...in the digest, and return blocking exit codes for partial/unvetted coverage and unrecognized shapes. - Add/expand unit tests to cover full/partial/unstated coverage, shape inventory enforcement, and new exit-code behavior for
status/wait. - Update governance/runbook documentation to incorporate the new merge-gate precondition and the “block on unknown shapes” remedy.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Adds a “measure” backlog cluster to survey whether structured (non-prose) Copilot review data exists. |
| scripts/test_pr_review.py | Adds coverage/shapes test coverage and new exit-code assertions for status/wait. |
| scripts/README.md | Documents the new coverage and “unrecognized shapes” behaviors/exit codes. |
| scripts/pr_review.py | Implements coverage parsing, shape inventory checks, digest fields, and new blocking exit codes. |
| GOVERNANCE.md | Updates the merge gate to require tooling-recognized reviewer output shapes. |
| .github/copilot-instructions.md | Updates the review runbook with coverage-of-diff guidance and the “unrecognized shape blocks” procedure. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Three findings from round 1, each correct. The digest renamed the no-count coverage state from `unknown` to `unstated` so it could not be read as the separate `shapes` field, and the runbook and the script README were both written before that rename and still named the old value. The runbook also described 43 as a coverage code, where it is now the unrecognized-shape code that an unreadable coverage wording exits through. One test signature dropped its `-> None` to fit the line, so the name is shortened rather than the annotation left off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
scripts/pr_review.py:453
coverage_statements()currently requires the literal substring "changed file" even for the- **Files reviewed:** ...spelling. If GitHub keeps theFiles reviewedbullet but drops the trailing "changed files" text (a plausible wording drift), this function will silently ignore the coverage line and the run will fall back tocoverage=unstatedinstead of failing closed / reporting an unrecognized coverage shape.
def coverage_statements(body: str) -> list[str]:
"""The lines this round states its file coverage on, quotations excluded."""
return [ln.strip() for ln in FENCE.sub('', body or '').splitlines()
if COVERAGE_OPENS.match(ln) and 'changed file' in ln.lower()]
scripts/pr_review.py:462
read_coverage()returns parsed counts without validating basic invariants. If the line parses but the counts are inconsistent (e.g. "reviewed 8 out of 7"),coverage_of()currently treats that asFULLdue to>=, which fails open on an obviously invalid coverage statement. This should instead be treated as an unvetted/unrecognized coverage line so the run blocks (exit 43).
m = COVERAGE_COUNTS.search(line)
if not m:
return None
reviewed, changed = (m.group(1), m.group(2)) if m.group(1) else (m.group(3), m.group(4))
return int(reviewed), int(changed)
scripts/pr_review.py:95
COVERAGE_COUNTSonly matches "changed files" (plural). If the reviewer emits the grammatically singular form (e.g. "1 out of 1 changed file"),read_coverage()will treat it as unvetted and block the review loop even though the meaning is unchanged.
This issue also appears in the following locations of the same file:
- line 450
- line 458
COVERAGE_COUNTS = re.compile(
r'reviewed\s+(\d+)\s+out of\s+(\d+)\s+changed files'
r'|\*\*Files reviewed:\*\*\s*(\d+)\s*/\s*(\d+)\s+changed files', re.IGNORECASE)
Round 2's suppressed findings, each correct and each the same shape as the bug this change exists to fix, one level down in the reader itself. The bullet spelling required the words after its counts, so a bullet that dropped them read as no statement at all and the round reported `unstated` rather than blocking. Its label is the marker, so the two openers now carry different text requirements: the bullet needs none, and the sentence keeps one because prose opens a line with the reviewer's name too. A pair of counts that cannot both be true read as full coverage through a `>=`. A round claiming it read more files than the pull request changed is one this script is parsing wrongly, so it now reads as unvetted and blocks, which is what every other unreadable wording does. The counts required the plural, so a one-file round saying `1 changed file` would stop the fleet over a grammatical agreement carrying no meaning. The opener already accepted the singular, so the two disagreed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2: three suppressed findings, all three correct, all three fixed in cf2b892Answered here rather than in a thread, since a suppressed finding reaches none. Each one is the same shape as the bug this pull request exists to fix, one level down in the reader itself, which is a fair thing for the round to have caught. 1.
Reproduced before fixing: The two openers now carry different text requirements, because the text each needs beside it differs. The bullet's own label is the marker, so it qualifies whatever follows the counts. The sentence opener is the reviewer's name, which prose also opens a line with, so that one keeps the requirement. 2.
Correct, and the
3.
Correct, and this one is the cry-wolf direction rather than the fail-open one, which makes it worth naming separately: a one-file round saying Evidence. All three reproduced against the code before the fix and verified after. The corpus sweep is unchanged at 0 unrecognized items across all 332 review bodies and |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/pr_review.py:103
- The bullet form is treated as a coverage line based on its label alone (see the comment above
COVERAGE_BULLETandis_coverage_line()), butCOVERAGE_COUNTScurrently requires the trailing "changed file(s)" words for the bullet alternative. If GitHub ever emits a shorter bullet like- **Files reviewed:** 4/4(no suffix), this will be classified as an unrecognized coverage line and block the loop unnecessarily.
Consider parsing the X/Y counts for the bullet form even when the trailing words are missing, while still requiring the words for the sentence form to avoid false positives.
COVERAGE_COUNTS = re.compile(
r'reviewed\s+(\d+)\s+out of\s+(\d+)\s+changed files?'
r'|\*\*Files reviewed:\*\*\s*(\d+)\s*/\s*(\d+)\s+changed files?', re.IGNORECASE)
Round 3, and the finding is the complement of the previous round's fix. The bullet became a coverage line on its label alone, correctly, but the counts pattern still required the words after them, so a bare `4/4` was detected and then failed to parse. That turned a silent `unstated` into a block on a line the script can read perfectly well, which is the cry-wolf direction rather than a fix. The trailing words are now optional for the bullet, for the same reason its label identifies the line, and required for the sentence, whose words are what separate it from prose. What is left blocking is a bullet carrying no counts, which genuinely states no coverage. The case that missed this asserted detection on the bare form and parsing only on the suffixed one, so it now asserts both on all three spellings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3: one suppressed finding, correct, fixed in f604f65
Correct, and it is the exact complement of what round 2 asked for and I half-delivered. Making the bullet a coverage line on its label alone moved the failure from a silent Blocking there is the cry-wolf direction: The class of bug is worth naming, since it is the third instance in this pull request: detection and parsing keyed on different things, so a line one half accepted the other half rejected. After this the two disagree only where the line genuinely carries no readable count, and a bullet like The case that missed it is the more useful finding. It asserted detection on the bare form and parsing only on the suffixed form: self.assertEqual(['- **Files reviewed:** 4/4'], pr_review.coverage_statements('- **Files reviewed:** 4/4'))
self.assertEqual((4, 4), pr_review.read_coverage('- **Files reviewed:** 4/4 changed file'))Two assertions written as if they covered one thing, split across two different inputs, so the gap sat exactly between them. It now asserts detection, parsing and the verdict across all three spellings, plus a case holding the no-counts bullet to blocking. Corpus sweep unchanged: 0 unrecognized items across all 332 bodies, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.github/copilot-instructions.md:173
- This exemption bullet calls the no-coverage shape "unknown", but the tooling/docs in this PR (and
scripts/pr_review.py status) use the literalcoverage=unstatedfor that case. Using a different term here makes it harder to map the runbook guidance to the actual digest output.
- **Exempt: a body stating no coverage at all.** 28 of those 332 bodies are an overview and a change list and nothing more. That shape is current, interleaves with the counted one throughout, and one pull request carries both across its two rounds, so treating it as a failure cries wolf on about one review in twelve and teaches an agent to work around the gate. It reads as **unknown**, never as a pass and never as a failure.
Round 4 found the exemption bullet in the runbook still calling the state "unknown" where the digest prints `coverage=unstated`. Round 1 raised the same mismatch two lines below it and the sweep that answered it stopped at the line the finding named. The constant carried the mismatch too. `UNKNOWN` printed as `unstated` through the field map, so the name in the source and the word in the output were two different words for one state, which is what the field was renamed to avoid in the first place. It is `UNSTATED` now, and the field map is back to being about casing alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 4: one suppressed finding, correct, fixed in b538613
Correct. Round 1 raised the same mismatch two lines below this one, and the sweep that answered it stopped at the line the finding named instead of grepping the term. That is the recurring failure in this repository's own rules, so it is worth stating plainly rather than quietly fixing: a finding names an instance, and the fix is for the term. Grepping The constant carried the same mismatch and is worth more than the doc line. Corpus sweep after the rename: 0 unrecognized items across all 332 bodies, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (4)
TODO.md:322
- Same issue as above:
[scripts/pr_review.py][scripts]resolves to./scripts/README.md(the[scripts]reference), not toscripts/pr_review.py. Using a code span for the file path avoids a misleading link.
- **Checked** - `develop` at `20916ad` on 2026-08-07, reading the live GraphQL schema by introspection and one review over REST, against the reader in [`scripts/pr_review.py`][scripts].
TODO.md:317
- The reference label
[scripts]points to./scripts/README.md, so linkingscripts/pr_review.pywith[scripts]is misleading (the link text looks like the file path but resolves to the README). Use an inline code span for the file path and keep[scripts]as the separate reference to the scripts README.
This issue also appears on line 322 of the same file.
One pull request, after a survey, deciding whether anything stands between this fleet's review loop and the raw prose of a Copilot review. Today [`scripts/pr_review.py`][scripts] reads the review body as text and holds a vetted inventory of the headings, collapsed sections, metadata labels and coverage wordings it recognizes, blocking on anything it does not. That design is correct for a prose surface and it carries a cost the maintainer has accepted deliberately: a wording change at GitHub blocks every open pull request in the fleet at once, until the inventory is updated. The cost is worth paying against a reviewer silently missing a raised finding, which is the failure it replaces, but it is worth paying only for as long as prose is the only surface on offer.
**State** `measure`. **Touches** [`scripts/pr_review.py`][scripts] and the runbook section in [`.github/copilot-instructions.md`][copilot-instructions], once the survey says whether there is anything to move to. **Cost** a survey first, then either nothing or a rewrite of the reading layer, which is the larger of the two outcomes and the reason the survey comes first.
scripts/test_pr_review.py:659
- This test name still uses
unknowneven though the implementation and assertions use thecoverage=unstatedspelling. Renaming the test improves consistency and avoids confusion with the separateanswered_outside_review=unknownstate.
def test_a_round_stating_no_coverage_at_all_is_unknown_rather_than_either_verdict(self) -> None:
scripts/test_pr_review.py:704
- This docstring uses "Unknown" for the no-coverage case, but the code models that state as
coverage=unstated. Using the same term as the digest output makes the test intent clearer.
"""Unknown is the absence of a statement rather than a bad one, so it loses to a count."""
Round 5. The backlog entry linked `scripts/pr_review.py` through the `[scripts]` label, which resolves to `scripts/README.md`, so the link text named one file and the link went to another. The reader that follows it lands on the README looking for a reader that is not there. Those two are code spans now, and the one place the entry does mean the README keeps the label. Two test names still spelled the no-count state `unknown`, which is the rename from the round before reaching the cases it also described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 5: four suppressed findings, all correct, all fixed in 2c3e12d1 and 2.
Correct, and worth more than a formatting nit: the link text names one file and the link goes to another, so a reader following it lands on the README looking for a reader that is not there. Both are code spans now. The one place the entry does mean the README, the 3 and 4. Two test names and a docstring still spelled the state
Correct. This is the previous round's rename reaching the cases that describe the state rather than assert on it, which the grep behind that round did not cover because it was scoped to the documents. Three of the last four rounds have found an instance of this same rename, each in a surface the previous sweep did not cover, which says the sweep kept being scoped to where the finding pointed rather than to the term. Recorded here because it is the repository's own documented failure mode and the record is worth more than the four one-word fixes. Corpus sweep unchanged: 0 unrecognized items across all 332 bodies, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/pr_review.py:1211
waitonly callsreport_verdict(final)whenreviewed_head(final)is true. That prevents the new unrecognized-shape gate (exit 43) from ever triggering in cases where the review did land butreviewed_headstays false (e.g. reviewer-login drift, whichunrecognized_shapes()is explicitly meant to catch). This can makewaittime out and exit asPENDING/30 instead of exiting 43 as documented.
if reviewed_head(final):
# Coverage of the head is what this returns 0 on.
# Coverage of the diff is a second reading it used to take on trust.
# A round can carry the head and have read only part of it.
return report_verdict(final)
Round 6, and the finding is the best of the run. `wait` called the verdict only where `reviewed_head` was true, and `reviewed_head` filters on the reviewer's login, so a renamed login left it false. The one drift the login check exists to catch was therefore the one case where the check could not reach an exit code: the digest printed `shapes=UNRECOGNIZED` and the wait returned 30, which is the digest disagreeing with the code, and a reader settles that by believing the code. The verdict is no longer gated on that reading. The loop stops on a drifted login too, since no amount of polling makes a login this cannot match appear, and the liveness query already carries the authors, so the reading costs the loop nothing. Without that the wait would have run its whole timeout out against a review sitting in plain sight, which is the failure the source comment beside it claims to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 6: one suppressed finding, correct, and the best of the run. Fixed in 467ade5
Correct, and it is the sharpest kind of finding: the gate could not fire for the one drift it was written to catch. The digest printing The verdict is no longer gated on that reading: The loop had a second half of the same bug. With the exit code fixed, a drifted login still polled the full 45-minute timeout before reaching it, because Three cases added: the drift reaching 43 without polling, the same gap through the body reader on a round covering no head, and an ordinary pending round still returning 30, so the gate cannot swallow the common case. Suite at 174. Corpus sweep unchanged: 0 unrecognized items across all 332 bodies, |
… that blocks on what it cannot read (#609) Promotes 14 commits from `develop`. Merge with a **merge commit** (`gh pr merge --merge`), never a squash, and **without `--delete-branch`**, since this pull request's head is `develop` itself. Closes #607 through the closing keyword already carried in `530cf71`, which is why it is not repeated here. ## The prose backlog, cleared end to end `#600`, `#604`, `#605`, `#606` took the tree from **557 findings across 45 files to 0 across 0**, in four batches ordered by surface: snippets, comments, hub-only Markdown, then the carried files. Each batch measured the checker's own exemption against the live corpus *before* sweeping, and twice the measured answer was **"do not change the checker"**, which is a result of that pass rather than a skipped one. `#594` added the floor that makes those numbers trustworthy: a diff-scoped run now asserts what it actually scanned, since a check whose scan matches nothing reports zero findings and reads exactly like a pass. One finding from that work is worth carrying up: an exemption that is too **loose** produces silence rather than false positives. #519 recorded the governance files as clean; today's checker reports 38 findings against those same files as they stood at the commit that measured them. ## A review loop that fails closed `#599`, `#601`, `#602`, `#603` and `#608` are one arc on `scripts/pr_review.py`, each removing a shape in which the loop reported a clean pass over a review it had misread: - **`#599`** removed the shape a reply kept failing in, by taking the thread's *words* rather than an id, so there is no argument a hand-typed `PRRT_...` fits in. - **`#602`** made `claims` resolve what a description points at rather than what it looks like. - **`#603`** gave a disproved claim a home the next round reads. - **`#608`** reads the file-coverage line, and then generalizes: every reader keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. The digest now vets headings, `<summary>` texts, metadata labels, coverage wordings and the reviewer login against an inventory measured from **332 review bodies**, and **blocks on anything outside it**, exit `43`, with the remedy stated as filing an issue on the hub. Whether to merge regardless is the maintainer's decision. `GOVERNANCE.md` merge gate went from four preconditions to **five** accordingly. ## Governance and tooling - **`#593`** states which checkout an agent works in and what the hub is, which is the host-wide routing the repositories that most need it cannot carry. - **`#596`** gates the pattern-detectable half of the representative-data rule, honest that no pattern closes the name-shaped case. - **`#598`** declares where a repository states what CI cannot verify. - **`#592`** regrouped `TODO.md` by what ships rather than by what it touches, so a `###` heading is one pull request. - **`#601`** ended a `gh push` argument list at a newline rather than only at `&&`, fixing a write-guard over-block. ## Verification Run on `develop` at `530cf71` immediately before opening this: `test_pr_review.py` (174), `test_prose_lint.py`, `test_repo_gate.py`, `spec/audit.py --selftest`, `gh-write-guard.py --selftest`, `spec/validate.py`, `repo_gate.py`, the prose gate in both CI invocations, markdownlint and editorconfig-checker. All clean. ## Not carried by this promotion - **#519 is complete and still open.** `TODO.md` holds its closing evidence under "Verified Complete, Awaiting Close". Closing it is the maintainer's call, so no keyword for it appears here. - **The re-vendor debt is now nine files.** `#606` queued seven, and `#608` changed `GOVERNANCE.md` "PR Review Etiquette" (`verbatim`) and `.github/copilot-instructions.md` (`intent`) on top. The `intent` half produces no hash and therefore no audit finding, which is why the Fleet Sweeps entry names those files by hand. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The branch was parked on 2026-08-06 pending the GitHub Actions outage, and develop moved 20 commits past it. Six regions conflicted, and one of them was not a text collision. Exit 42 now means two things. This branch defined it as the review loop closed over a check in a shape no wait clears, and #607, #608 and #613 have since defined 42 as a round that read fewer files than the pull request changed, with 43 as a shape the reader does not know. The check reading becomes 44, ranked under both. 43 says no field here can be believed, so it outranks everything. 42 says part of the diff has no review. 44 says the review itself is sound and a required check is wedged, which is only worth reporting once the two above are clear, so `wait` returns report_verdict's code where it has one and reads the checks only where it does not. The digest prints both sets of blocks, with the unrecognized-shape block still first, since it says how far the rest of them can be trusted. Resolved from develop's text rather than this branch's wherever develop had edited the same line, which the copilot-instructions bullet needed: the prose batch replaced its semicolons, and taking this branch's older copy would have reverted that and failed the gate that now checks it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mergeStateStatus` reports `BLOCKED` for a failed check, a required check nothing is running, an unresolved thread, and a missing approval alike, and the digest printed that word and stopped there. Found by running into it. A run polled `BLOCKED` for twenty-five minutes on a pull request whose only unfinished check was an aggregator job GitHub dispatched and never assigned a runner, and the cause came from the maintainer rather than from any field in the digest. That is the defect: the digest named a state it could not explain. This is the refusal defect of #584 one gate along. There, a review that covered nothing rendered as coverage. Here, a check nothing is running renders as patience, and both read as a pull request worth waiting on. ## What the digest says now Reconstructed from the observed job states of the run this hit, rather than described: ``` repo=ptr727/ProjectTemplate pr=590 head=aaaaaaaa rounds=1 review_on_head=yes refusal=no threads=0 unresolved=0 suppressed=0 (on_head=0 earlier=0) answered_outside_review=no requested=no merge=BLOCKED checks=2/3 stuck=NOT_PICKED_UP CHECK NOT PICKED UP ('Check pull request workflow status job', queued 15m with no runner assigned): nothing here starts it, because the runner pool is GitHub-hosted, so re-run the workflow or wait on that capacity ``` The rollup rides the existing full query, so reading the checks costs no extra round-trip, which is the reason this script exists at all. ## Three shapes, because each wants the opposite response - **`NOT_PICKED_UP`** is a job GitHub dispatched and assigned no runner. Read from the queued state rather than from a runner name, which GraphQL does not carry, and the state suffices because a job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so there is no dependency-blocked queue to mistake for a starved one. Nothing agent-side starts it, since the pool is hosted, so the remedy is a re-run or that capacity, and **not** a re-request, a rebase, or an empty commit. - **`RUNNING_LONG`** is deliberately the weaker reading and its wording says so, since duration alone cannot separate a hung job from a slow one. This repository's lint job legitimately runs nine to eleven minutes while its aggregator is a single shell conditional, so the threshold is generous, the elapsed time prints for the reader to judge against what the job costs, and nothing asserts a fault. - **`FAILED`** is a verdict rather than a stuck check, reported so no reader deduces a red check from `BLOCKED`. ## Exit 44, and the boundary that makes it mean something `wait` gains exit `44` for a review loop that closed against a check in one of those shapes, because `0` was saying the loop closing is the merge gate. A check **merely still running normally is not 44 and exits 0.** That boundary is the whole design: the wait returns the moment coverage lands, which on almost every pull request is mid-CI, so taking `42` for a pending check would make `42` the ordinary outcome, and a code that fires always carries nothing. The grace is the pickup grace's five minutes for the reason that one is, and the stall is thirty because a fleet repository building and testing .NET runs longer than this one. Both are flags, and a negative value for either is rejected by name rather than rendering a digest that reports every check stuck from its first read. ## Two defects this caught in itself - A finished check carrying **no conclusion yet** fell through to the unknown-conclusion branch and reported `FAILED`, inventing a red check out of a race in the API. Caught by the suite, which is also what said the first test helper was at fault rather than the code, since it failed eight cases at once. - **`EXPECTED`** was missing from the unstarted set, so a required status nobody has posted reached that same branch and reported that same false failure. Caught reading the diff back. Both have cases now. `SKIPPED` and `NEUTRAL` count as passes, since the fleet aggregator pattern skips the conditional jobs and four of the six checks on a green pull request here are skips. An unrecognized conclusion **is** reported, because a new enum member read as a pass is a red check rendering as a green digest. ## Docs The runbook's `BLOCKED` bullet said the cause is "usually" unresolved review threads, which is true and is exactly what misled the run. The qualifier stays and the counter-case joins it rather than replacing it. `GOVERNANCE.md` merge-gate condition 1 now requires the reason to be **read** rather than inferred, and a new paragraph states that `BLOCKED` is no more self-explaining than `CLEAN` is sufficient, including that hosted-runner capacity is not a reason to weaken a gate. `GOVERNANCE.md` is verbatim-carried, so a **fleet re-vendor is owed**, and the `TODO.md` re-vendor entry now names these sections. ## Verification 461 cases pass across the three `scripts/` suites, 217 of them in `test_pr_review.py`, which is 41 more than `develop` carries. The count read 291 when this was written and the suites have grown on both sides since, so it is restated against the merge rather than left as a number measured on a tree that no longer exists. `prose_lint.py --diff origin/develop`, `repo_gate.py`, `spec/validate.py`, and `markdownlint-cli2` over all 44 files are clean. A grep for restatements of the changed merge-gate wording across every markdown file found none stale, and the "all four preconditions" sentence still holds since condition 1 was amended rather than added to. The digest was also run live against a real pull request, where it reports `checks=6/6` and no stuck field. ## Renumbered from 42 to 44 on the forward-merge This branch was parked on 2026-08-06 pending the GitHub Actions outage, and `develop` took exit `42` in the meantime for a round that read fewer files than the pull request changed, with `43` for a shape the reader does not know, both from #607, #608 and #613. The check reading here is `44`, ranked under both: `43` says no field can be believed, `42` says part of the diff has no review, and only once those are clear is a wedged required check the thing worth reporting. `wait` returns `report_verdict`'s code where it has one and reads the checks only where it does not.
Adds the missing half of the selection procedure's integrity check, and amends one cluster with an upstream reference. ## The gap Step 1 confirms every open **issue** appears in this file. Nothing asked the same of an open **pull request**, so a pull request whose blocker has passed is invisible to the one procedure that would catch it. Nothing selects it, nothing closes it, and `develop` moves underneath it. [#591](#591) is the worked example and is carried in the step as its evidence. It was parked correctly on 2026-08-06, during a GitHub Actions major outage, with the reason written on the pull request. The reason then expired quietly. Three days later it was 20 commits behind `develop`, conflicting in six regions, and its central exit code had come to mean something else, because #607, #608 and #613 had taken `42` for a different reading in the meantime. The failure is not specific to this repository. The same shape was reported on Blog, where two pull requests were left open through the same outage and a day of new work landed on top of them. ## The rule Step 2 asks that every open pull request carries a **stated active blocker**: stated where the pull request itself carries it rather than held in a session that has ended, and active only while the thing it names is still true. A landed review round, a merged dependency and a passed outage each stop being one, and what they leave is a forgotten pull request rather than a parked one. The remedy is to finish it, close it, or write the current blocker down. ## Measured before writing, not after The lesson this repository keeps relearning about checkers is to measure the live corpus before shipping a rule, so it flags what it is for rather than the routine traffic: | Reading | Result | | --- | --- | | Open pull requests right now | **1** (#591) | | Dependabot pull requests, open to merged | ~**1 minute** (#611 and #612 both `02:24` to `02:25`) | The merge-bot takes bot traffic inside a minute, so it never sits long enough to owe a blocker, and the rule's working set is the handful of human pull requests that actually linger. ## Second disposition in this change **Amends "A Programmatic Reading of a Copilot Review"**. That cluster's open question is whether GitHub publishes anything but prose to read a Copilot review from, and its `Settled` line records that the public API does not. The ask is now filed upstream as [GitHub community discussion 204320](https://github.com/orgs/community/discussions/204320), which requests a versioned machine-readable schema carrying severity, category, suggestion and resolution state. It is unanswered, so the entry records it as a place to watch rather than a dependency to wait on. ## Verification `prose_lint.py` clean including `sentence-split`, `editorconfig-checker` exit 0, `markdownlint-cli2` 0 issues across 44 files, `TODO.md` at 497 of 497 CRLF lines. The renumbering was checked against the file's own cross-references, and the only one that names a position is step 1 calling itself first, which it still is.
…request sweep (#619) Promotes `develop` to `main`, carrying two merged pull requests. ## What is being promoted - **[#591](#591 `07ed74a`, reading why a merge is blocked instead of reporting one word. `scripts/pr_review.py` gains the check rollup, the four stuck shapes it tells apart, and exit `44` for a review loop that closed against a check no waiting clears, plus the [`GOVERNANCE.md`](./GOVERNANCE.md) and runbook wording that says `BLOCKED` never names its own cause. - **[#618](#618 `dd5fc90`, the open pull request sweep in [`TODO.md`](./TODO.md)'s selection procedure, plus an amendment recording the upstream ask for a machine-readable Copilot review schema. ## The exit code, since it changed meaning between branches #591 was authored before the outage of 2026-08-06 and defined exit `42` for its check reading. #607, #608 and #613 took `42` for a round that read fewer files than the pull request changed, and `43` for a shape the reader does not know, while it sat. The forward-merge renumbered the check reading to **44** and ranked it under both: `43` says no field can be believed, `42` says part of the diff has no review, and only once those are clear is a wedged required check the thing worth reporting. `wait` returns the coverage and shape verdict where it has one and reads the checks only where it does not. ## Review state Both were reviewed and merged on their own pull requests, so this promotion carries no unreviewed change. #591 ran 18 rounds across its life, 5 of them after the revival, and #618 ran 5. Every finding was accepted except one on #614, which was declined with evidence and recorded under "Disproved Claims" in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md). ## Why #591 was open long enough to need reviving It was parked correctly during the GitHub Actions major outage, with the reason written on the pull request, and the reason then expired quietly. Three days later it was 20 commits behind `develop`, conflicting in six regions, and carrying an exit code that meant something else. #618 is the procedural answer to that, and it is in this same promotion. ## Merge shape This is a promotion, so it merges as a **merge commit** rather than a squash, per [`GOVERNANCE.md`](./GOVERNANCE.md) "Branching Model". Its head is `develop` itself, so it must **not** be merged with `--delete-branch`.
Closes #607.
What was wrong
A Copilot review body states how many of the pull request's changed files it read, and nothing parsed that line. A round that read part of the diff carries the correct
commit.oid, raises no inline threads, and reports "generated no comments", so it is the clean pass byte for byte in everything the loop checks, andstatusprintedreview_on_head=yesover it and exited0.Measured over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed, and all three merged. #592 is the sharpest: three changed files, one never read, across both rounds, both reporting no comments.
What changed, and why it is wider than the issue asked
Partial coverage is the third instance of a shape this script already answers twice, and the generalization is the point. Every reader here keys on a structural marker, so a marker that changes spelling is a section the reader stops finding and reports as absent. All three failures on record have that shape:
suppressed=0over a body carrying findingssuppressed=0againEach was found by the maintainer after it had landed, rather than by the gate. So the digest now vets the reviewer's output as a whole and fails closed:
coverage=full/PARTIAL/unstated, with exit 42 on a partial round.shapes=ok/UNRECOGNIZED, with exit 43 on any heading,<summary>, metadata label, coverage wording or reviewer login the script has no vetted spelling for. It outranks 42, because a reader that does not understand the output cannot be believed about what it read of the diff.The
43message states the remedy in two parts: file an issue on the repository hosting the reader, quoting the body the shape came from, and the merge decision is the maintainer's. An unrecognized shape does not say the pull request is bad, only that nothing here can vouch for the review of it.The inventory is measured, not imagined
With fenced blocks dropped and text reduced to ASCII, all 332 bodies reduce to 7 headings, 6
<summary>texts and 3 metadata labels, and every body carries at least one. Counts normalize to(N)and the verdict headings' colored circle is dropped before comparing, since both change on every review without the section changing, and dropping the emoji is also what keeps the source inside the charset rule.Two exemptions, both required by the corpus:
unstated, never as pass or failure. 28 of the 332 are an overview and a change list, that shape is current and interleaves with the counted one, and one pull request carries both across its two rounds. Failing on it would cry wolf on about one review in twelve.The quietest reading is the reviewer login: a rename leaves every filter here matching nothing, so a review that landed reads as
rounds=0and a wait polls out its timeout against it.Evidence
Both readers were swept over the full corpus before this was written, and over it again after:
copilot-pull-request-reviewerandptr727).full 299 / unstated 28 / partial 5, the five being exactly Sweep the spaced-hyphen prose class out of the carried docs #476 (x2), Promote the prose sweeps and the suppressed-findings digest to main #479 and Group the backlog by what ships rather than by what it touches #592 (x2).coverage=PARTIAL shapes=okand exits42. Retire the scope-floor cluster and release the one it blocked #595, Clear the comment half of the prose backlog, exemption first #604 and Clear the carried half of the prose backlog, and plan its re-vendor #606 reportshapes=okand exit0.166 tests pass, up from 132. The two fixtures the issue named as unasserted filler are promoted to assertions, and a case reads the vetted coverage spellings out of the runbook and hands them to the script's own parser, so the pair fails in both directions on drift. The old fixtures crafted review bodies with no heading, which no real body has, so they were made realistic rather than the check loosened.
Also carried
.github/copilot-instructions.md- the verify step checkedcommit.oidonly, which is what this shows to be insufficient, plus a new section stating that an unrecognized shape blocks and earns an issue.GOVERNANCE.md- merge-gate precondition 4, and the "all four preconditions" restatement swept to five.scripts/README.md- both readings.TODO.md- ameasurecluster to survey whether GitHub publishes anything structured about a Copilot review, since this whole design rests on prose being the only surface. Introspection says it is:PullRequestReviewexposesbody,bodyTextandbodyHTMLand no field naming a finding or a file count, and the only Copilot-named GraphQL types configure review-on-push in a ruleset.The accepted cost
The shape scan reads every round rather than the head's, so once Copilot changes format, every open pull request blocks at once until the inventory is updated. That is deliberate and the maintainer's call: it is well balanced against a reviewer silently missing a raised finding.
🤖 Generated with Claude Code