fix(pm): bind H31's carrier pair through the closing keyword - #18243
Conversation
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` -- the wide body-first/branch-fallback delivery relation -- as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped: acting on it hangs the gate on a card nothing will ever close, where the stroke that clears gates never arrives. The comparison now runs over PRs bound by a closing keyword -- the binding that makes the landing reach the card, which is what the clearing stroke rides. The shared relation is untouched (its docblock forbids narrowing it), so H8, H31, H35 and H53 still agree about which PR delivers which card. Weaker bindings are DECLINED, never silently dropped: a weak-bound delivering PR whose carrier differs from the card's still produces a row that names both carriers and the binding it read, states that it declined, and prescribes no write. Agreement on a weak binding stays silent, as before. Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing provenance — skills seat, session Generated by Claude Code |
Fixes #18229
What changed
scripts/pm/check-half-states.mjsonly. H31 compares the two carriers of theneeds:contract-reviewgate, and it tookprDeliversCard— the widebody-first/branch-fallback delivery relation — as its population. That relation
counts a
Part of #Nline, which declares MEMBERSHIP in what #N tracks ratherthan a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew
a row saying the gate was missing from the card half of a dual carrier. The
row's remedy text is action-shaped, so acting on it hangs the gate on a card
nothing will ever close — where the stroke that clears gates never arrives.
Three edits:
bindingClosesCard(pr, n)— a new exported predicate, one read ofdeliveryEvidence's existing grading, never a second keyword parser. Itanswers "does this PR CLOSE #n", which is the question the gate's clearing
stroke rides.
part-of,part-of-inlineandbranch-nameall answer false.bindingClosesCardaccepts. Those rows are byte-identical to before.carrier differs from the card's still produces a row, one that names both
carriers and the binding it read, states that it declined to judge, and
prescribes no write. A weak-bound PR whose carrier agrees produces nothing,
exactly as before.
TRACKING_ANCHOR_LABELis extracted so the ruling-anchor state has one spellingacross H13's exemption list and H31's new clause.
Why this seam, and how the shared invariant survives
The shared relation is NOT narrowed.
prDeliversCard's docblock forbids it(「⛔ Do not narrow it here to serve H8: that would make the live half invisible
to the rows that exist to see it」), and H8's open side, H35's sibling resolver,
claimDelivery, H53 and thecheck-clause2-carrierspairing all still read itwide. The narrowing is H31's own filter over the population that relation hands
it, so the rows still agree about which PR delivers which card and differ only
about which binding makes a CARRIER PAIR — a question only H31 asks. Both
docblocks that state the invariant were updated to say so rather than left to
imply the old thing.
Silently dropping the weak bindings would have been #4690 in this row's own
uniform: a split that was never judged renders identically to a board whose two
carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」.
Hence the declined row, and hence its scope: it speaks only where the carriers
actually differ, because a standing row per tracker per sweep is the disease,
not the cure. The declined row is deliberately NOT marked
UNJUDGED_MARKER—that marker buys trim priority ahead of judged rows, and a decline must never
sort ahead of a real carrier split in the same
gateband.Measured on the live board
Anchor #9857's sweep at 2026-09-15T01:57Z, commit
b3b43b6, run 34919049964,carries exactly one H31 row: #14122 (
tracking), naming open PR #18212 (draft,via a
Part ofdeclaration). Replayed offline against the 9 open PRs and the 9open cards those PRs name (the exact H31 candidate set — a card no open PR names
can never reach the comparison):
trackingOne row changes, and it is the expected one. Every other delivering binding on
the live board is a closing keyword, so nothing else moves.
Tests
pnpm check:pm-half-states(=node scripts/pm/check-half-states.mjs --self-test): 4042 cases pass before, 4075 cases pass after, exit 0 —33 new cases.
The pin and the control both land, per the card's own remedies:
Part of-only tracker still produces a row (⛔ not a silent skip),the row says
DECLINES to judge, names the binding it read, explains why aruling anchor can never clear a gate, and prescribes NOTHING; it is asserted
to be neither the old action-shaped sentence nor a LOUD row nor an
UNJUDGED-ranked one.
with the unchanged action-shaped sentence.
without
trackingstill reports the split but without the ruling-anchorclause; an adjudicable split outranks a decline and names only the
closing-bound PR.
Ablation (one-off, restored; run from the committed fix). Mutating
bindingClosesCardback to the pre-fix wide population(
deliveryEvidence(pr, n) !== null) turns the self-test RED:The mutation is proved on disk by the blob hash moving off the HEAD blob and by
the two anchor occurrence counts flipping; the restore leg is proved by the blob
hash returning to the HEAD blob and by an empty
git diff HEAD, not by an exitcode. There is no build step and no
dist/for a repo-root.mjs, so therebuild half of the ablation preflight does not apply here.
Gates — derived with no paths, at head
e0a4efa:All 41 exited 0, each captured by redirect before any pipe. The gate script's
own wiring is inside that set:
pnpm check:pm-half-statesis the package.jsonstep lint.yml runs, and it is the script's
--self-test.Lint — a declared narrowing, not the farm.
eslint scripts/pm/check-half-states.mjs --no-inline-config --format jsonat heade0a4efa: 1 file linted, 0 errors, 0warnings, exit 0. The population is read from eslint's own config, not guessed
— an
ESLintinstance overgit ls-filesreportstracked=8674 in-scope=6755 ignored=1919 no-rules=0. Invariance: this repo runs oneeslint.config.mjswhich enables type-aware linting for no file at all (every
parserOptionsin itis exactly
{ ecmaVersion: 'latest', sourceType: 'module' }; zeroproject/projectServicehits; the config says so itself at itsQUERY_OPTIONS_TEST_GLOBSnote), and that config is not in this diff — so norule's verdict on a file this PR did not touch can move because of it. The
repo-wide
pnpm lintrun is CI's.skip-changeset:scripts/pm/**is PM tooling and ships in no package'sfiles[], so nothing published moves.Acceptance notes
scripts/pm/check-half-states.mjsis non-governed PM tooling, so this isin-seat review.
.github/workflows/half-state-patrol.ymland anchor [Half-state patrol] check-half-states live sweep — generated view (please pin) #9857 areuntouched: the workflow runs the script from
main, so the fix reaches theanchor at the first scheduled sweep after landing.
Part of #N— residual exit 4 with nothing wrong on the diff (#18213 / #14122) #18214 is the sibling shape one file over — the clause-② dual carrier thatscripts/pm/check-clause2-carriers.mjs --pairdemands on an epic trackerreached through a
Part ofline. It is out of scope here and remains open.bindingClosesCardis exported for it: that file already importsdeliveryEvidenceandprDeliversCardfrom this one, so the fix there is animport rather than a second copy of the predicate.
objectui(
scripts/pm/check-half-states.mjs) has already drifted from this copy(different md5 at
b3b43b6), while this file's header describes the pair ascopied VERBATIM. No gate can hold two repos equal, so the wording is an
aspiration rather than a claim — carrier: the next dev dispatched on a
cross-repo patrol card.
Generated by Claude Code