Read why a merge is blocked instead of reporting one word - #591
Conversation
`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. A run polled it for twenty-five minutes on a pull request whose only unfinished check was an aggregator job GitHub dispatched and never assigned a runner. The cause came from the maintainer rather than from any field, which is the defect: the digest named a state it could not explain. This is the refusal defect's shape 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. The rollup rides the existing full query, so reading the checks costs no extra round-trip. `checks=N/M` sits beside the merge word, and a stuck check is named as `stuck=` with a block carrying its remedy, because the three shapes want opposite responses. NOT_PICKED_UP is a job dispatched with no runner, read from the queued state rather than a runner name GraphQL does not carry, which suffices because a job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so no dependency-blocked queue can be mistaken 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 cannot separate hung from slow when this repository's lint job legitimately runs eleven minutes against an aggregator that is one shell conditional. FAILED is a verdict, and it is reported so no reader deduces a red check from `BLOCKED`. `wait` gains exit 42 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 42 and exits 0: 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. Two defects the work caught in itself. A finished check carrying no conclusion yet fell through to the unknown-conclusion branch and reported FAILED, which invents a red check out of a race in the API, caught by the suite. `EXPECTED` was missing from the unstarted set, so a required status nobody has posted reached the same branch and reported the same false failure, caught reading the diff back. Both now have cases. `SKIPPED` and `NEUTRAL` count as passes, since the fleet aggregator pattern skips the conditional jobs and four of six checks on a green pull request here are skips. An unrecognized conclusion is reported rather than passed over, as a new enum member read as a pass is a red check rendering as a green digest. The runbook's `BLOCKED` bullet said the cause is "usually" unresolved threads, which is true and is what misled the run. The qualifier stays and the counter-case joins it. `GOVERNANCE.md` merge-gate condition 1 now requires the reason to be read rather than inferred, and its new paragraph states that `BLOCKED` is no more self-explaining than `CLEAN` is sufficient, including that capacity is not a reason to weaken a gate. That file is verbatim-carried, so a fleet re-vendor is owed and the backlog entry names these sections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Updates the scripts/pr_review.py digest and wait logic to explain why mergeStateStatus=BLOCKED occurs by reading the head commit’s check rollup, and documents the resulting behavior/exit codes across the runbook and governance guidance.
Changes:
- Extend
pr_review.py’s GraphQL query to includestatusCheckRollupfor the head commit, summarizechecks=N/M, and print per-check “stuck” explanations. - Add check-shape classification (
NOT_PICKED_UP,RUNNING_LONG,FAILED) and introducewaitexit code42when review coverage is present but checks are deemed non-mergeable. - Update docs/governance/runbook and expand the test suite to cover rollup normalization and new digest/wait behaviors.
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 | Updates the fleet re-vendor entry to mention the new merge-blocked reasoning requirement. |
| scripts/test_pr_review.py | Adds fixtures/helpers and extensive new unit coverage for rollup normalization, check-shape classification, digest output, and wait exit 42 behavior. |
| scripts/README.md | Documents the new checks=/stuck= digest fields and the new wait exit code 42 semantics. |
| scripts/pr_review.py | Implements rollup fetching, check normalization/classification, digest enhancements, and wait exit 42 handling. |
| GOVERNANCE.md | Tightens merge gate #1 to require reading the reason for BLOCKED and documents why BLOCKED is not self-explaining. |
| .github/copilot-instructions.md | Updates the merge-state “gotchas” section to reflect the new digest behavior and exit 42 meaning. |
`PENDING` is a member of both rollup enums and means the opposite thing in each. A CheckRun's is dispatched and not begun, while a StatusContext's reports a run the posting system says is under way. Both were read as unstarted, so a long external build reported as queued with no runner assigned, naming a cause it does not have on a system that did pick it up. The shape is knowable only in `check_nodes`, so the translation lives there and `EXPECTED` stays the one StatusContext state that means unposted. Exit 42 fired on any stuck check in the rollup, and a rollup carries checks the ruleset does not require, four of six on a green pull request here. So a stuck check nothing requires returned `CHECKS_NOT_MERGEABLE` on a mergeable pull request. The code now also requires `mergeStateStatus: BLOCKED`, which borrows GitHub's own reading of which checks gate a merge rather than reading the ruleset's contexts over another call, since `CLEAN` proves no required gate is outstanding whatever else the rollup is doing. The digest still names the check, so the narrower code costs the reader nothing. "judgement" is the British spelling and this repo's convention is US English. `scripts/prose_lint.py` already carries the `judgement` -> `judgment` mapping and the tree uses `judgment` twenty times, so the checker and the correction both existed. Its `spelling` rule reads README and HISTORY only, which is why neither fired on a script. 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 (4)
scripts/pr_review.py:313
age()only catchesValueError, butdatetime.fromisoformat()can return a naive datetime if the timestamp ever arrives without a timezone offset, and subtracting an awarenowwould raiseTypeError(crashingdigest/wait). CatchTypeErroras well (or normalize naive timestamps to UTC) so unknown/odd stamp formats degrade toNoneas intended.
def age(stamp: str, now: datetime) -> float | None:
"""Seconds from `stamp` to `now`, or None where the stamp is absent or unparseable.
None is returned rather than zero or a raise, because every caller treats an unknown age as
"cannot judge this" and reports nothing. Zero would read as a check that just started, which
is the reading that reports a job stuck for hours as fresh.
"""
if not stamp:
return None
try:
# `fromisoformat` reads the trailing Z only from 3.11, and a fleet machine may carry less.
return (now - datetime.fromisoformat(stamp.replace('Z', '+00:00'))).total_seconds()
except ValueError:
return None
scripts/pr_review.py:327
- This always reads the first commit node’s rollup, but the surrounding comments/docs indicate the rollup should be taken from the commit whose
oid == headRefOid(to avoid rendering a rollup from “a push ago”). Consider selecting the commit node by matchingpr['headRefOid'](and falling back safely if not found) so the code behavior matches the documented intent.
commits = ((pr.get('commits') or {}).get('nodes') or [])
rollup = ((commits[0].get('commit') or {}) if commits else {}).get('statusCheckRollup') or {}
out = []
scripts/test_pr_review.py:820
- These
wait-path tests intentionally use the real clock inpr_review.main(), but they feed it timestamps derived from the fixedNOW. That makes the test sensitive to wall-clock time (e.g., if the suite runs beforeNOW, the computed age is negative and the check won’t read as starved). To keep the tests stable long-term, generatestartedusing the real current time (e.g.,datetime.now(timezone.utc) - timedelta(...)) or patchpr_review.datetime.now()in the test so the “real clock” is controlled.
def test_wait_exits_forty_two_where_the_review_closed_but_a_check_is_starved(self) -> None:
"""Exit 0 was saying the review loop closing is the merge gate, and it is not.
The age is read against the real clock here rather than the suite's fixed NOW, so the
timestamp is one whose age only grows: fifteen minutes at NOW and more on any later run,
which is past the five-minute grace under every clock this ever runs on.
"""
self.answer(payload([review()], merge='BLOCKED', checks=[
check(name='gate', status='QUEUED', conclusion='', started=ago(900))]))
scripts/pr_review.py:629
- The code relies on
check_graceandcheck_stallbeing ordered (and a test asserts the constants are), but a user can currently pass--check-stallsmaller than--check-grace, which would invert the intended meanings (report “running long” earlier than “not picked up”). Consider validatinga.check_grace < a.check_stallfor CLI inputs and rejecting inversions with a clearargparseerror.
for name in ('check_grace', 'check_stall'):
if getattr(a, name) < 0:
ap.error(f'--{name.replace("_", "-")} cannot be negative')
…e clean None reached a thread, so the digest's suppressed-block reading is what surfaced them, which is the case that reading exists for. The rollup was taken as the connection's first node while the comment beside it claimed it was matched against the head, and the case meant to hold that asserted the fixture's commit equalled the head rather than that the code selected by it. So the case tested the payload and passed while the code read position. It now selects by oid, and a head that matches nothing reports nothing rather than falling back to another commit's rollup, since a fallback is the same stale read by a different route. The absence is not left silent, because `checks=0/0` reads as a fact about the head, so `checks_unreadable` names it as this reading having failed. A payload with no commits at all is not a failed match and does not say it. `age` caught `ValueError` alone. A stamp carrying no zone parses and yields a naive datetime that will not subtract from an aware now, so the `TypeError` escaped a function whose contract is to degrade to None, out of a call whose whole job is to report the state. A crash is the worst outcome here. The wait-path cases measured a check's age from the suite's fixed NOW while `main` read the real clock. That age grows on every later run and goes negative on a machine whose clock sits before NOW, so those cases now take a timestamp from the same clock the code reads. A case asserted `CHECK_GRACE < CHECK_STALL` while the flags could still be passed inverted, which reports a running check sooner than a starved one. That is the gap between a rule and its check, one level down, and the parser now rejects the inversion by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round 2 raised four findings as low-confidence, collapsed in the review body with no inline threads, so they are answered here with each quoted. All four are real and all four are fixed in 565055b. One of them was a false clean in this very change. 1.
The sharpest of the four, and it caught a defect in the test as much as in the code. The case meant to hold this asserted that the fixture's commit equalled the head, which tests the payload rather than the code, so it passed while the code read position. Position is not identity, and trusting it is the same shape as counting a review without reading it, which is the defect the rest of this script exists to prevent. Selection is now by oid. A head matching no commit reports nothing rather than falling back to another node, because a fallback is that same stale read reached by a different route. The absence is not left silent either: 2.
Correct, and the reason it matters is the direction of the failure. A zone-less stamp parses, so it never reaches the existing guard, and the 3. The wait-path cases measured an age from the suite's fixed
Right, and my docstring's "the age only grows" was the assumption talking rather than a property. Those cases now take 4.
A case asserted the two constants were ordered while the flags stayed free to invert them, which is the gap between a rule and its check one level down, and it is a shape this repository has been bitten by before. The parser rejects the inversion by name, equal values included, since equal thresholds collapse the two readings into one. 296 cases pass across the three |
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)
scripts/pr_review.py:570
- The
NOT_PICKED_UPmessage hard-codes a “no runner assigned / GitHub-hosted capacity” remedy, butNOT_PICKED_UPis also returned forStatusContextstateEXPECTED(meaning “required status not posted yet”). ForEXPECTED, this wording is inaccurate and can send readers toward the wrong remediation. Suggestion: preserve the original rollup node kind (e.g., addkind/typenameincheck_nodes) and either (a) splitEXPECTEDinto a separate shape (e.g.NOT_POSTED) with its own message, or (b) vary theNOT_PICKED_UPwording based on the underlying node type/state.
for node, shape in stuck:
# Each shape carries its own remedy, which is the whole point of telling them apart.
# A reader handed one word for all three retries the wrong thing, or waits on a queue.
elapsed = age(node.get('started') or '', now)
mins = 'age unknown' if elapsed is None else f'{int(elapsed // 60)}m'
if shape == 'NOT_PICKED_UP':
lines.append(f' CHECK NOT PICKED UP ({node["name"]!r}, queued {mins} 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')
scripts/pr_review.py:21
- The exit-code comment says 42 includes a required check that is “failed”, but the implemented condition later is
stuck and mergeStateStatus == 'BLOCKED'. If failed required checks can yield a differentmergeStateStatus(commonlyDIRTY/UNSTABLEin GitHub’s enum), 42 may not fire even though the digest can reportstuck=FAILED. Please either broaden the condition to match the documented behavior or narrow the documentation here (and inscripts/README.md) to the statuses the code actually treats as 42.
42 = the review loop closed but a required check is in a shape no wait clears:
queued with nothing acting on it, running far past what the job costs, or
failed. A check merely still running normally is not this, and exits 0.
scripts/pr_review.py:356
- The normalization treats every non-
CheckRuncontext as aStatusContext. That works for today’s query, but it will silently mis-normalize (and potentially mis-report) if GitHub adds a new union member understatusCheckRollup.contexts.nodesor if the query evolves. Suggestion (mandatory for robustness): make the branch explicit (elif __typename == 'StatusContext') and ignore/skip unknown types (optionally recording them for debugging) rather than forcing them into theStatusContextshape.
for n in ((rollup.get('contexts') or {}).get('nodes') or []):
if n.get('__typename') == 'CheckRun':
out.append({'name': n.get('name') or '', 'state': n.get('status') or '',
'conclusion': n.get('conclusion') or '',
'started': n.get('startedAt') or ''})
else:
# A StatusContext reports one field for both, so its state doubles as its conclusion.
# Its PENDING means the posting system reported the run as under way.
# A CheckRun's PENDING means the opposite, dispatched and not begun.
# So the same string is two states, and the shape is knowable only here.
# Left alone, a long external build reports as queued with no runner assigned.
# That names a cause the run does not have, on a system that did pick it up.
state = n.get('state') or ''
out.append({'name': n.get('context') or '',
'state': 'IN_PROGRESS' if state == 'PENDING' else state,
'conclusion': state, 'started': n.get('createdAt') or ''})
TODO.md:35
- This line’s indentation changed from an indented sub-bullet (
|- ...) to a top-level bullet (|- ...). If this item is meant to remain nested under a parent list item, Markdown rendering will change (and may break numbering/structure). Suggestion: restore the previous indentation level if nesting was intentional.
- Re-vendor the changed `verbatim` content across the fleet, which is one sweep covering five files. `repo-config/configure.sh` is carried `verbatim` with `appliesTo: "*"` and the hub swept it to one sentence per line. In `AGENTS.md`, "Context and Delegation Discipline" carries the wait rule's failure clause and "Where the Rules Live" carries a row for the new section named below. In `GOVERNANCE.md`, "Verification Discipline" carries the rule that a launched process is not a result, "PR Review Etiquette" carries the five outcomes that close a finding and now also a merge-gate condition 1 that reads why a merge is blocked rather than inferring it, plus the paragraph saying `BLOCKED` is no more self-explaining than `CLEAN` is sufficient, "Repository Boundaries and Write Safety" carries the rule that a refused write is reported rather than re-shaped, and "Representative Data in Agent-Authored Text" is an entirely new carried section that no downstream repo holds, which the audit reports as a missing section rather than as drift. Three further `GOVERNANCE.md` sections differ by a single word each, "Documentation Style Conventions", "Communicating with the User" and "Repository Details", where the format's name was capitalized to the convention `CODESTYLE.md` "Markdown and Spelling" now states, so they are byte-mismatched for a reason a reader of the diff would otherwise call cosmetic. Two comment lines in `.markdownlint-cli2.jsonc` took the same capitalization, and that file is `verbatim` and `whole`, so every downstream copy is byte-mismatched on a config nothing else changed about. `CODESTYLE.md` carries the new item and is the fifth file, at `intent` rather than `verbatim` fidelity, so it reaches the fleet as a rule each repo adopts in its own copy rather than as bytes to match, and the same mixed spelling is waiting in every downstream tree. Every repo already holding a copy of a changed section is byte-mismatched against the hub until it takes the new one, which the audit reports as stale rather than modified. This sweep is also the follow-through [#489][issue-489] and [#379][issue-379] were waiting on, and the `.editorconfig` line in [#353][issue-353] rides the same visit to each repo. Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since the committed copy is dated 2026-07-22 and therefore predates the router split, which shows in it naming "Repository Boundaries and Write Safety", "Git and Commit Rules" and "Verification Discipline" as `AGENTS.md` sections when all three now live in `GOVERNANCE.md`. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists.
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 (5)
scripts/pr_review.py:570
- The NOT_PICKED_UP message always says "no runner assigned", but NOT_STARTED also includes StatusContext EXPECTED (an unposted required status), which is not a runner-capacity issue. This can produce a misleading remedy for EXPECTED contexts.
mins = 'age unknown' if elapsed is None else f'{int(elapsed // 60)}m'
if shape == 'NOT_PICKED_UP':
lines.append(f' CHECK NOT PICKED UP ({node["name"]!r}, queued {mins} 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')
scripts/pr_review.py:735
- This exit-42 path and message claim a "required check" is stuck, but the code only checks
mergeStateStatus == "BLOCKED"plus presence of any stuck rollup context. SinceBLOCKEDcan also be caused by approvals/threads, and the rollup may include non-required checks, this can mislabel the merge blocker.
if stuck and final.get('mergeStateStatus') == 'BLOCKED':
print('status=CHECKS_NOT_MERGEABLE the review loop is closed, and a required check '
'is in a shape waiting does not clear: read the block above, since a starved '
'check wants a re-run, a long one a judgment, and a failed one a fix')
return 42
scripts/test_pr_review.py:728
check(status='PENDING')is not a valid GraphQLCheckRun.statusvalue (PENDING is a StatusContext state). To keep this test representative, use CheckRun QUEUED here to model "dispatched but not begun" vs StatusContext PENDING (running).
# The CheckRun spelling keeps the opposite reading, which is what makes the shape decide.
self.assertEqual('NOT_PICKED_UP',
self.shape(check(status='PENDING', conclusion='', started=ago(900))))
scripts/test_pr_review.py:633
- These fixtures exercise CheckRun.status values (WAITING/PENDING/REQUESTED) that GitHub GraphQL does not return for
CheckRunStatusState(it is QUEUED/IN_PROGRESS/COMPLETED). Using impossible states makes the test less representative of the live payload.
for state in ('QUEUED', 'WAITING', 'PENDING', 'REQUESTED'):
with self.subTest(state=state):
self.assertEqual('NOT_PICKED_UP', self.shape(
check(status=state, conclusion='', started=ago(900))))
scripts/pr_review.py:42
- NOT_STARTED lists states (WAITING/PENDING/REQUESTED) that are not returned by the GraphQL enums this script queries (CheckRun.status is QUEUED/IN_PROGRESS/COMPLETED; StatusContext.state uses EXPECTED/PENDING/etc). Keeping impossible values here (and in tests) reduces readability and makes fixtures less representative.
# A check that has not started, in every spelling the two rollup enums carry between them.
# QUEUED is the one a starved job wears, and WAITING, PENDING and REQUESTED cover a gate.
# Those three are a CheckRun's, where PENDING means dispatched and not begun.
# EXPECTED is a StatusContext's, meaning a required status nothing has posted yet.
# A StatusContext's own PENDING is the opposite and is translated away in check_nodes.
A second suppressed block from the same round, surfaced because the digest reads every round rather than the head. Four findings, three real. Folding `EXPECTED` into the queued states gave a required status nothing had posted the starved remedy, which is wrong in both directions. No runner is owed it, so re-running a workflow clears nothing, and the wording sent a reader at the runner pool over a missing poster. It is now `NOT_POSTED` with its own message. This is the fix for a finding creating the next one, since the previous round is where `EXPECTED` joined that set. The exit-code docstring listed a failed check among 42's shapes without naming the `BLOCKED` the code also requires. A failed *required* check does read `BLOCKED`, so the code was right and the doc was short of it, which is the direction that costs a reader trust in a field. The docstring now names the condition and every shape, and a case holds it to both. Normalization treated every non-CheckRun node as a StatusContext, so a third union member would render nameless and report as a red check, reading a label and a state that are not there. Unknown members are skipped instead, which loses a check that cannot be read where forcing invents a verdict for one, and the smaller total is what shows it. Declined: the TODO.md indentation finding. `git diff` shows `- Re-vendor` at column zero before and after, so the bullet's level is unchanged, and the edit was a substring replacement inside the line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
A second suppressed block from that same round, which the digest surfaced only because it reads every round rather than the head. I had answered one block of four and pushed before this one existed to be read, so this is the round-scoping hazard catching me in the middle of a change about exactly that class of miss. Three of the four are real and fixed in c2d56c6. One is declined with the diff. 1.
Right, and this is a finding created by the previous round's fix, since Taken as your option (a), a separate shape. 2. The 42 docstring lists a failed check but the code also requires
Narrowed the documentation, which is your second option, because the code is right: a failed required check reads 3. Every non-
Agreed, and the failure would be quiet in the worst way: a third member renders nameless, because the label is read under a key it does not use, and reports as a red check, because the state read off it is not there. So an unknown member would arrive as an anonymous failure. The branch is explicit now and unknown members are skipped, which loses a check that cannot be read where forcing invents a verdict for one, and 4. Declined:
Checked against the diff rather than the rendering, and the level is unchanged. Both begin 299 cases pass across the three |
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:133
- The rollup contexts are hard-limited to 100 (
contexts(first:100)), but the code doesn’t detect/report truncation. If a PR has >100 contexts, required checks could be omitted fromchecks=N/Mandstuck=(silent false-clean risk). Consider queryingpageInfo{hasNextPage}forcontextsand surfacing an explicit warning in the digest (e.g.,CHECKS TRUNCATED) whenhasNextPageis true.
commits(last:1){ nodes{ commit{ oid statusCheckRollup{ state
contexts(first:100){ nodes{
__typename
... on CheckRun{ name status conclusion startedAt }
... on StatusContext{ context state createdAt }
}}}}}}
scripts/pr_review.py:548
checks_tally(pr)andchecks_stuck(pr, ...)both callcheck_nodes(pr), so the rollup is normalized twice per digest. Consider callingcheck_nodes(pr)once indigest, then computing(ok, total)andstuckfrom that shared list to avoid duplicated parsing and keep the digest logic single-pass.
ok, total = checks_tally(pr)
stuck = checks_stuck(pr, now, grace, stall)
scripts/pr_review.py:361
- In normalized check nodes, the
startedfield isstartedAtforCheckRunbutcreatedAtforStatusContext. Since those timestamps have different semantics, the shared key name is misleading and makes later code read like it’s comparing equivalent ‘start times’. Consider renaming the normalized key to something shape-agnostic (e.g.,stamp/since) and updatingage(...)call sites accordingly.
out.append({'name': n.get('context') or '',
'state': 'IN_PROGRESS' if state == 'PENDING' else state,
'conclusion': state, 'started': n.get('createdAt') or ''})
Two more suppressed blocks landed from rounds queued earlier, so the repeated clear-and-re-request produced reviews that were slow rather than dropped, and each carried different findings. Eight in total, four real, three declined on the schema, one already fixed. The contexts connection had no truncation guard, which is the `window_blind` failure one connection along. A rollup past a hundred contexts drops the rest silently, so a required check among them is absent from both the tally and the stuck reading and the digest renders a clean pass over a check it never saw. A fleet repository with a matrix build reaches a hundred long before this one. `CHECKS TRUNCATED` reports it from the connection's own `hasNextPage`. `checks_tally` and `checks_stuck` each called `check_nodes`, so the rollup was parsed twice a digest, in a script whose reason for existing is that the read is the cost. Both now take the normalized list and the digest parses once. A case counts the calls, since the output was identical either way, which is what let the second one pass. The 42 message named a required check as the blocker, which nothing here can read: `BLOCKED` is also worn by an open thread or a missing approval, and the rollup carries checks no ruleset requires. It now states the two facts as a coincidence and says which of them gates the merge is not read here. The normalized stamp key was `started` while holding a CheckRun's `startedAt` for one shape and a StatusContext's `createdAt` for the other. One name over two meanings reads like a comparison of equivalents, so it is `since`. Declined, on schema introspection rather than on argument. Three findings held that `WAITING`, `PENDING` and `REQUESTED` are not `CheckStatusState` members and that the fixtures using them are unrepresentative. `__type(name: "CheckStatusState")` returns REQUESTED, QUEUED, IN_PROGRESS, COMPLETED, WAITING and PENDING, so all three are real states and the set and its cases stay. The union introspects to exactly CheckRun and StatusContext today, which is why the unknown-member skip is robustness rather than a live defect, as its comment already said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Two more suppressed blocks, from rounds queued earlier that landed late. Worth recording as a mechanism note: three clear-and-re-request cycles each eventually produced a review, so Real1.
The best finding of the eight, because it is the 2.
Correct, and pointed in a script whose stated reason for existing is that the reading is the cost. Both readers now take the normalized list and the digest parses once. A case counts the calls rather than reading the output, because the output is identical either way, which is exactly what let the second call go unnoticed. 3. The 42 message claims a required check is the blocker.
Right, and it is the same overclaim I had just narrowed the exit code to avoid, left standing in the wording. Gating on 4.
Agreed, renamed to Declined, on introspection rather than on argumentThree findings held that All three are real members, so the set is right and the cases model states GitHub can actually return. Keeping them is what makes the fixtures representative rather than less so. The While in the schema, the union: Two members today, which is why the unknown-member skip added a commit ago is robustness against a future addition rather than a fix for a live defect. Its comment said so, and this confirms it. Already fixedThe 302 cases pass across the three |
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:635
digest(..., checks=...)assumeschecksis already normalized to containname/state/conclusion/since, but the rendering path indexesnode[...]directly. If a caller accidentally passes raw GraphQL rollup nodes (or any other dict shape), this will raiseKeyErrorand take down the digest. Consider (mandatory) either (1) validating/normalizingchecksinsidedigest(e.g., always run through a normalizer when keys are missing), or (2) making the contract explicit in the type/name/docstring and using.get(...)defaults in the rendering path to avoid hard crashes.
if shape == 'NOT_PICKED_UP':
lines.append(f' CHECK NOT PICKED UP ({node["name"]!r}, queued {mins} 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')
elif shape == 'NOT_POSTED':
lines.append(f' CHECK NEVER POSTED ({node["name"]!r}, expected {mins} and not '
'reported): a required status whose poster has not spoken, so no runner '
'is owed it and re-running a workflow here clears nothing')
elif shape == 'RUNNING_LONG':
lines.append(f' CHECK RUNNING LONG ({node["name"]!r}, running {mins}): it has a '
'runner, so it is not starved, and whether this is hung or merely slow '
'is a judgment against what this job normally costs')
else:
lines.append(f' CHECK FAILED ({node["name"]!r}, {node["state"]}/'
f'{node["conclusion"]}): a verdict rather than a stuck check, so read '
'the run and fix it, since no wait and no re-run clears a real failure')
scripts/pr_review.py:619
minscan become negative if the local machine clock is behind GitHub timestamps (or any clock skew yieldselapsed < 0), producing confusing output likequeued -3mand potentially masking how long something has actually been waiting. Consider clamping negative elapsed to 0 (or treating it as unknown) before formatting/reporting.
elapsed = age(node.get('since') or '', now)
mins = 'age unknown' if elapsed is None else f'{int(elapsed // 60)}m'
scripts/pr_review.py:138
CHECKS_WINDOWwas introduced to keep the truncation message and the query window from drifting, but the query still hardcodescontexts(first:100). To make drift impossible even when tests aren’t run, consider definingQ_FULLwith string interpolation so it usesCHECKS_WINDOWdirectly (instead of duplicating100here).
commits(last:1){ nodes{ commit{ oid statusCheckRollup{ state
contexts(first:100){ pageInfo{ hasNextPage } nodes{
__typename
... on CheckRun{ name status conclusion startedAt }
... on StatusContext{ context state createdAt }
}}}}}}
The skip added two commits ago was the right half of an answer and silent was the wrong half. A member that is neither a CheckRun nor a StatusContext cannot be normalized, and dropping it quietly is the narrowing every other guard here exists against, since a check absent from the tally renders as a clean pass over something never seen. It is carried as a marker now, counted by neither reader, and named on a CHECKS PARTIALLY UNREAD line. CHECKS_WINDOW sat beside a hard-coded `contexts(first:100)`, so the constant documented the literal rather than driving it, and the case asserting the two agree holds only where someone runs it. Q_FULL is substituted from the constant, by `.replace` rather than an f-string, since GraphQL is braces from end to end and interpolation needs every one of them doubled. `wait` computed the stuck shapes and the digest recomputed them from the same list, which is duplicate work and a divergence between what is printed and what the exit code reads. The digest takes the caller's list. The rendering path indexed `node["name"]`, so a caller handing it an odd node shape would raise KeyError out of the one call whose job is to report the state. Every field reads with `.get` now, and a negative age clamps to zero for display, since a clock behind GitHub's renders `queued -3m`. Note the negative age is unreachable through the CLI: it cannot exceed a non-negative threshold and the parser refuses a negative one, so that half is hardening on the library path rather than a live defect, and the case says so. The `--check-grace` help said queued while the grace also governs the unposted shape. The runbook listed 42's shapes without `never posted` and without the BLOCKED condition, which the module docstring and README already carried, so the sweep missed a third surface. A grep for the old shape list is now clean. The payload fixture read `if checks`, so an empty list produced a null rollup and a rollup that exists carrying nothing could not be expressed. The two differ, since one is a pull request whose checks have not registered and the other has none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Four more suppressed blocks, raised on 1.
Raised three times across the blocks, and correct: a constant sitting beside a hard-coded 2.
Right, and the divergence is the part that matters here rather than the work: the exit code and the printed digest have to agree, and computing the shapes twice made that agreement a coincidence of the arguments passed rather than a property. This file already established the idiom for exactly that, in how 3. The rendering path indexed
Agreed and fixed, for the same reason 4.
Fixed by clamping the display while the comparison keeps the raw value, so skew cannot make a check report as stuck either. One correction to the premise, checked rather than assumed: through the CLI this is unreachable, because a negative age cannot exceed a non-negative threshold and the parser refuses a negative one, so no shape is judged and 5.
Fixed. The grace does govern both, deliberately, since an unposted status and an unstarted job are both judged on how long nothing has happened. 6. The runbook's 42 list omitted the unposted shape.
The one that stings, because it is a documented sweep failure rather than a code defect: I had updated the module docstring and 7. The payload fixture read
Correct, and the two states are genuinely different: an empty rollup is a pull request whose checks have not registered yet, which is what 309 cases pass across the three |
Parked pending the GitHub Actions outageNot blocked on anything in this change. GitHub Actions is in a major outage, incident
plus Copilot code review degradation and delayed webhook deliveries. That accounts for every anomaly here. State at parking
To finishOne review round on the current head and one green run. The last five batches were consistency and robustness findings with no new semantics, so the loop is converging rather than still opening. Then it is a squash into One follow-up this surfaced, deliberately not fixed here
|
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:814
main()computesstuckfor the exit-code decision, butdigest()recomputes stuck shapes again (and re-parses timestamps viaage()), which duplicates work and risks drift if the classification logic evolves. Consider passing the already-computedstuckintodigest()(e.g., an optionalstuck=parameter), or refactoring so a single function returns both the digest and the computed stuck shapes used for the exit code.
now = datetime.now(timezone.utc)
# Parsed here and handed down, so the digest and the exit code share one read of the rollup.
# Deriving the stuck shapes from that list costs no parse, which is what was doubled.
checks = check_nodes(final)
stuck = checks_stuck(checks, now, a.check_grace, a.check_stall)
out, _ = digest(owner, repo, a.number, pr=final, stalled=stalled, now=now,
grace=a.check_grace, stall=a.check_stall, checks=checks)
scripts/pr_review.py:760
- This error message prints the raw values but not the unit, even though flags are expressed in seconds. To make CLI failures self-explanatory, include the unit (e.g., "got 300s and 1800s") or echo the full flag/value pairs so users don’t misread them as minutes.
ap.error('--check-grace must be less than --check-stall, since a queued check is '
f'judged sooner than a running one (got {a.check_grace} and {a.check_stall})')
scripts/pr_review.py:324
stamp.replace('Z', '+00:00')replaces all occurrences ofZ, not just a trailing UTC suffix. It’s safer to only rewrite when the string endswith'Z'(or to slice/concatenate the last character) so an unexpectedZelsewhere can’t be silently altered before parsing.
return (now - datetime.fromisoformat(stamp.replace('Z', '+00:00'))).total_seconds()
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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (3)
scripts/README.md:138
- This sentence refers to the "
42message" for the stuck-check explanation, but the stuck-check exit code introduced/used by this PR is44. The message naming should match the implemented exit code to avoid confusion.
The contexts connection is guarded the way the review and comment windows are, since it has the same failure. A rollup past a hundred contexts would drop the rest silently, so a required check among them would be absent from the tally and the stuck reading alike and the digest would render a clean pass over a check it never saw, which a fleet repository with a large matrix build reaches long before this one does. `CHECKS TRUNCATED` says so instead. The rollup is normalized once per digest and the list handed to both readers, since the parse is what this script exists to spend once, and the two readers calling it separately was invisible in the output, which is what let it pass. The `42` message is worded as a coincidence rather than a cause, because nothing here proves the stuck check is the blocker: `BLOCKED` is also worn by an open thread or a missing approval, so naming the check as the blocker would assert a link this cannot read. The normalized stamp key is `since` rather than `started`, since it holds a `CheckRun`'s `startedAt` for one shape and a `StatusContext`'s `createdAt` for the other, and one name over two different meanings reads like a comparison of equivalents.
scripts/README.md:134
- This paragraph refers to exit code
42for the stuck-check path, but the code/docs in this PR define stuck required checks (withmergeStateStatus: BLOCKED) as exit44. Keeping42here will mislead readers about which conditionwaitis signaling.
This issue also appears on line 138 of the same file.
A check merely still running normally is **not** any of these and exits `0`. That boundary is the whole design, because `wait` returns the moment coverage lands and on almost every pull request the checks are still going at that instant, so taking `42` for a pending check would make `42` the ordinary outcome and a code that fires always carries nothing. `42` additionally requires `mergeStateStatus: BLOCKED`, which the module docstring's own list of shapes has to name rather than only implying, because a reader who sees `stuck=FAILED` and exit `0` on a merge that is `UNSTABLE` should find the condition written down rather than infer the field is unreliable. It is because a rollup carries checks the ruleset does not require and four of the six on a green pull request here are exactly that, so the code borrows GitHub's own reading of which checks gate a merge rather than reading the ruleset's contexts over another call. `CLEAN` proves no required gate is outstanding whatever else the rollup is doing, and without that condition a stuck check nothing requires returns `42` on a mergeable pull request. The digest names the check either way, so the narrower code costs the reader nothing. Both of those came out of this change's own review.
.github/copilot-instructions.md:319
- This bullet says
waitexits42for stuck required checks onmergeStateStatus: BLOCKED, but this PR introduces that behavior as exit code44(with42reserved for partial coverage). Updating this avoids sending readers to the wrong exit-code meaning.
- **`BLOCKED` never says which gate, so never infer one.** The same word covers a red check, a required check nothing is running, an unresolved thread, and a missing approval, and the bullet above says "most often" rather than "always" for that reason. Read the checks instead of guessing: `pr_review.py status` prints `checks=N/M` beside the merge word and names a stuck one, and it exits `42` from `wait` where the merge reads `BLOCKED`, the review loop closed, and a check is starved, expected and never posted, running far past what the job costs, or failed. A **queued check with no runner** is the case that reads exactly like patience: a run here 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. Nothing agent-side starts that job, because the pool is GitHub-hosted, so the remedy is a re-run of the workflow or waiting on that capacity, and it is **not** a re-request, a rebase, or an empty commit. A job held behind a `needs:` dependency does not enter the rollup until that dependency finishes, so a queued check is never a dependency waiting its turn.
Three suppressed findings, all the same defect and all mine. The merge commit renumbered the reading to 44 in the docstring, the code and the tests, and left three prose statements saying 42. Swept by term this time rather than by instance. Every remaining 42 in scripts/README.md and the runbook is the coverage reading, which is correctly 42, and no other file states a code for the stuck reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Answering the three suppressed findings raised on
All three correct, all three the same defect, and it is mine rather than the merge's. This branch was parked on 2026-08-06 and The renumber swept the docstring, the code and the tests, and stopped there. It missed three prose statements, which is sweeping by instance rather than by term, and it is the failure this repository has a rule about. Swept by term now. Every remaining |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
scripts/pr_review.py:1063
- The CHECK FAILED digest line reads "the run and fix it", which is grammatically incorrect and unclear about the next step. Make the message explicit (e.g., read logs/fix) so the digest remains actionable.
lines.append(f' CHECK FAILED ({name!r}, {node.get("state")}/'
f'{node.get("conclusion")}): a verdict rather than a stuck check, so '
'the run and fix it, since no wait and no re-run clears a real failure')
scripts/pr_review.py:51
- The PR description states that
waitgains exit code 42 for stuck required checks, but the implementation and docs use exit code 44 (42 is already used for partial coverage). Please align the PR description to avoid confusion for users relying on it.
44 = the review loop closed, the merge reads BLOCKED, and a check is in a shape no
wait clears: queued with nothing acting on it, expected and never posted, running
far past what the job costs, or failed. A check merely still running normally is
not this and exits 0, and neither is a stuck check on a merge that is not BLOCKED,
since the rollup carries checks no ruleset requires. The digest reports the check
in both cases, so a shape outside 44 is still named rather than lost.
Two suppressed findings, both correct. The CHECK FAILED line read "the run and fix it", which is missing a word and leaves the one shape whose remedy is a fix without a readable instruction. It reads "read the run and fix what failed" now. The second is against the pull request description rather than the code, which still promised exit 42 after the merge renumbered the reading to 44. Corrected, along with a test count measured on a tree that no longer exists, and the description now carries the renumber and why it happened. That is the same sweep-by-instance failure the previous commit answered, one surface further out: the code, the docs and the tests were swept and the description was not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Answering both suppressed findings raised on
Correct, and it lands on the one shape of the four whose remedy is actually a fix. A starved check wants a re-run, an unposted one wants its poster, a long one wants a judgment, and only this one wants someone to read the run, which is the line that failed to say so. It reads "read the run and fix what failed" now.
Correct, and it is the same failure as the round before, one surface further out. The renumber swept the code, the docstring, The description now carries One thing corrected beyond the finding, since re-reading the description for this found it. It claimed 291 cases across the three |
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`.
mergeStateStatusreportsBLOCKEDfor 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
BLOCKEDfor 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:
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_UPis 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 aneeds: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_LONGis 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.FAILEDis a verdict rather than a stuck check, reported so no reader deduces a red check fromBLOCKED.Exit 44, and the boundary that makes it mean something
waitgains exit44for a review loop that closed against a check in one of those shapes, because0was 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
42for a pending check would make42the 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
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.EXPECTEDwas 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.
SKIPPEDandNEUTRALcount 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
BLOCKEDbullet 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.mdmerge-gate condition 1 now requires the reason to be read rather than inferred, and a new paragraph states thatBLOCKEDis no more self-explaining thanCLEANis sufficient, including that hosted-runner capacity is not a reason to weaken a gate.GOVERNANCE.mdis verbatim-carried, so a fleet re-vendor is owed, and theTODO.mdre-vendor entry now names these sections.Verification
461 cases pass across the three
scripts/suites, 217 of them intest_pr_review.py, which is 41 more thandevelopcarries. 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, andmarkdownlint-cli2over 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 reportschecks=6/6and 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
developtook exit42in the meantime for a round that read fewer files than the pull request changed, with43for a shape the reader does not know, both from #607, #608 and #613. The check reading here is44, ranked under both:43says no field can be believed,42says part of the diff has no review, and only once those are clear is a wedged required check the thing worth reporting.waitreturnsreport_verdict's code where it has one and reads the checks only where it does not.