Skip to content

Commit 8ee771f

Browse files
committed
tooling(pm): the claim-side readers — a bulleted Branch: governs, and a Refs-only landing is not H8's full delivery
Three same-file cards, one flight. #16170 — `claimedBranches` admits an optional markdown list marker before the directive (`- Branch: …`). Measured live on #15511: `CLAIM_COMMENT_MARKER` has no list marker either, so on a claim whose first line was an undecorated `Claim:` and whose branch hung under it in a bullet list the two anchors failed at different points — the comment WAS a claim and GOVERNED nothing, with nothing in the artefact looking wrong. The claim marker is deliberately NOT widened with it (2026-08-11, 不放宽谓词): when both lines carry a bullet the marker misses too and "no claim comment" is then the true reading, so only the MIXED shape was malignant and admitting the marker here is what removes it. #16036 — H8's merged side stops reading a merged `Refs #N`-only PR on a card-named branch as a FULL delivery. Under the partial-dispatch rule that PR is a partial landing whose remainder is lawfully re-dispatched, and H8, which reads no thread, was prescribing "drop `pm:dispatched`" against a card a second dev holds. A second, narrower reader (`prFullyDeliversCard`) calls H49's own `refsOnlyLinksFor`, so what H49 owns is exactly what H8 excludes. The shared `prDeliversCard` relation is untouched: H8's OPEN side, H31 and `claimDelivery` all need the wide reading, because a `Refs` half still open is live work. #16037 — the three remaining SKILL.md line-number citations deleted, quoted text kept, no other sentence moved. Self-test 2535 -> 2553. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
1 parent 4e090ec commit 8ee771f

1 file changed

Lines changed: 179 additions & 32 deletions

File tree

scripts/pm/check-half-states.mjs

Lines changed: 179 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@
8787
*
8888
* H1 `pm:dispatched` with no assignee — the state model DEFINES the label
8989
* rather than describing a habit: 「`pm:dispatched` … 恒带 assignee」
90-
* (SKILL.md line 109), and the one lawful way for that field to empty is
90+
* (SKILL.md), and the one lawful way for that field to empty is
9191
* the release act, which clears it and posts a `Release:` line in the
92-
* SAME write (line 465). So an empty field under this label is the label
92+
* SAME write. So an empty field under this label is the label
9393
* contradicting its own definition, not a step somebody forgot — and the
9494
* release half of the same rule is H47's row.
9595
* H2 assignee set on a pm-tracked card, but no claim comment on the thread
@@ -1744,6 +1744,14 @@ export function branchNameTarget(ref) {
17441744
* exists. So a body that declares ANY delivery is authoritative, and the branch
17451745
* name is consulted only for the bodies that declare none — which is exactly
17461746
* the population the specimen came from, and no other.
1747+
*
1748+
* ⚠️ This relation answers "is there a PR on this card", NOT "is the card
1749+
* finished". H8's MERGED side needs the second question and asks it of
1750+
* `prFullyDeliversCard` below; every other reader here — H8's open side, H31's
1751+
* carrier comparison, `claimDelivery`, and the pairing `check-clause2-carriers`
1752+
* derives — wants this wide one, because a half in flight is live work. ⛔ Do
1753+
* not narrow it here to serve H8: that would make the live half invisible to
1754+
* the rows that exist to see it.
17471755
*/
17481756
export function prDeliversCard(pr, n) {
17491757
const target = String(n);
@@ -1757,12 +1765,51 @@ export function prDeliversCard(pr, n) {
17571765
return branchNameTarget(pr?.head?.ref) === target;
17581766
}
17591767

1768+
/**
1769+
* Does this PR deliver card `n` IN FULL — the narrower reading H8's merged side
1770+
* takes, and the only reader that takes it.
1771+
*
1772+
* ## Why a second reader, and not a narrower first one (#16036)
1773+
*
1774+
* The partial-dispatch rule gave `Refs #N (item k)` a meaning: a PR that lands
1775+
* ONE item of a card, releases the card back to `pm:queue` in the same stroke,
1776+
* and leaves the remaining items to be re-dispatched from the queue as a FRESH
1777+
* claim. That body declares no delivery, so `prDeliversCard` falls through to
1778+
* the branch name — and the branch is named for the card, because that is the
1779+
* branch the dev cut. H8 therefore read a PARTIAL landing as a full delivery and
1780+
* prescribed its destructive remedy ("drop `pm:dispatched` and re-grade the
1781+
* remainder") against a card a second dev is lawfully working. H8 reads no
1782+
* thread, so the fresh `Claim:` newer than the merge — the record that stands
1783+
* H49 down — is invisible to it, and the row kept firing from the re-claim until
1784+
* the remainder's own PR opened and the open side downgraded it.
1785+
*
1786+
* So the fallback keeps the recovery it was built for — a body that declares
1787+
* NOTHING, the #10757 specimen's own population — and declines for a body that
1788+
* SPOKE about this card in the one spelling the protocol reserves for a
1789+
* non-delivery. `Fixes`/`Part of` are untouched: those are deliveries whatever
1790+
* the branch is called, and a `Refs` beside either does not make them partial.
1791+
*
1792+
* The `Refs`-only test is `refsOnlyLinksFor` — H49's own predicate, CALLED
1793+
* rather than restated, so what H49 owns is exactly what H8 excludes and the two
1794+
* cannot drift into a gap or an overlap. Bound per card number, as H7 binds: a
1795+
* `Refs #A` on a PR whose branch is named for B narrows nothing about B.
1796+
*/
1797+
export function prFullyDeliversCard(pr, n) {
1798+
if (!prDeliversCard(pr, n)) return false;
1799+
return refsOnlyLinksFor([pr], n).length === 0;
1800+
}
1801+
17601802
/**
17611803
* H8 — null when clean, else the finding sentence.
17621804
*
1763-
* Delivery is `prDeliversCard` (body first, branch name as the fallback its
1764-
* docblock justifies). Only `merged_at`-set PRs count on the merged side —
1765-
* closed-unmerged is an abandoned attempt, not a delivery.
1805+
* The two sides read delivery through DIFFERENT questions, deliberately since
1806+
* #16036. The merged side asks whether the card is FINISHED and so reads
1807+
* `prFullyDeliversCard` — body first, branch name as the fallback its docblock
1808+
* justifies, minus the `Refs`-only landing H49 owns. The open side asks whether
1809+
* anything is still in flight and reads `prDeliversCard` unchanged: a `Refs`
1810+
* half still open is live work this row must not step on, whatever it will later
1811+
* mean for the card's completeness. Only `merged_at`-set PRs count on the merged
1812+
* side — closed-unmerged is an abandoned attempt, not a delivery.
17661813
*
17671814
* ## The open side, and why this row DOWNGRADES rather than falls silent
17681815
*
@@ -1795,7 +1842,7 @@ export function h8MergedPrStillDispatched(issue, mergedPrs, openPrs) {
17951842
const delivering = [];
17961843
for (const pr of mergedPrs ?? []) {
17971844
if (!pr?.merged_at) continue;
1798-
if (prDeliversCard(pr, n)) delivering.push(pr);
1845+
if (prFullyDeliversCard(pr, n)) delivering.push(pr);
17991846
}
18001847
if (delivering.length === 0) return null;
18011848
const list = delivering
@@ -3729,11 +3776,37 @@ export const H20_BRANCH_LIST_CAP = 5;
37293776
* fewer place where the code reads as if it honoured a spelling it does not.
37303777
* See `CLAIM_COMMENT_MARKER`'s note for the codepoint reading and the live
37313778
* probe behind it.
3779+
*
3780+
* ## The list marker, and the silence it used to buy (#16170)
3781+
*
3782+
* A markdown BULLET before the directive — 「- Branch: `claude/issue-…`」, the
3783+
* natural way to write a claim whose first line is a heading-style `Claim:` and
3784+
* whose details hang under it in a list — is admitted, because the measured
3785+
* failure of refusing it was SILENT in the worst possible way. `CLAIM_COMMENT_MARKER`
3786+
* has no list marker either, so the two anchors failed at DIFFERENT points on the
3787+
* same comment: the marker matched the undecorated `Claim:` line (the comment IS
3788+
* a claim), while this reader returned nothing (the claim GOVERNS nothing). Live
3789+
* on #15511 the result was a claim complete by every other measure that steered
3790+
* a seat, and then a whole dispatching round, toward a rule about the `Claim:`
3791+
* line that was not the blocker at all.
3792+
*
3793+
* ⛔ The claim MARKER is deliberately NOT widened with it, and the asymmetry is
3794+
* the point rather than an oversight: when BOTH lines carry a bullet the marker
3795+
* misses too, the comment is not a claim to any reader here, and the sibling's
3796+
* "no comment on the card's thread is a claim comment" is then TRUE. The
3797+
* malignant shape is only the MIXED one, and admitting the marker here is what
3798+
* removes it. Widening `Claim:` itself is the 2026-08-11 ruling's territory
3799+
* (⛔ 不放宽谓词), not this reader's.
3800+
*
3801+
* ⚠️ The marker is admitted only BEFORE the optional blockquote (`- > Branch:`),
3802+
* which is the anchor as ruled; a bulleted line INSIDE a blockquote
3803+
* (`> - Branch:`) is still out, and the self-test pins that as a measured fact
3804+
* rather than as a contract anybody chose.
37323805
*/
37333806
export function claimedBranches(body) {
37343807
const out = [];
37353808
const text = String(body ?? '');
3736-
for (const line of text.matchAll(/^\s*>?\s*Branch(?:es)?\s*:\s*(.*)$/gim)) {
3809+
for (const line of text.matchAll(/^\s*(?:[-*+]\s+)?>?\s*Branch(?:es)?\s*:\s*(.*)$/gim)) {
37373810
for (const hit of String(line[1] ?? '').matchAll(CLAIM_BRANCH_SHAPE)) {
37383811
if (!out.includes(hit[0])) out.push(hit[0]);
37393812
}
@@ -9145,15 +9218,19 @@ export function h48GovernedVerdictWithoutBrief(pr, governed, commentRows) {
91459218
//
91469219
// The merged `Refs` PR is invisible to every delivery reader by construction:
91479220
// GitHub closes nothing (no keyword), and `prDeliversCard` reads `Part of` and
9148-
// the keywords, falling back to the BRANCH NAME only. So when the head is named
9149-
// for the card H8 does fire; when it is not (a family branch, another card's
9150-
// slug), the card sits `pm:dispatched`, assigned, with a complete `Claim:` and
9151-
// no dev, and H1/H2/H3/H24 are each correctly silent. This row asks the
9152-
// question none of them can: is the NEWEST `Refs`-linked PR on this card
9153-
// MERGED, with no `Release:` or `Claim:` on the thread newer than that merge?
9154-
// H8, where it fires, cannot ask it either — it reads no thread, so a remainder
9221+
// the keywords, falling back to the BRANCH NAME only. On a head NOT named for
9222+
// the card (a family branch, another card's slug) nothing saw it at all. On a
9223+
// head named FOR the card H8 used to fire through that fallback — a
9224+
// full-delivery verdict on a partial landing, prescribing the destructive
9225+
// de-label against a lawfully re-dispatched remainder — which is why its merged
9226+
// side now excludes the `Refs`-only shape (`prFullyDeliversCard`, #16036). So in
9227+
// BOTH head shapes the card sits `pm:dispatched`, assigned, with a complete
9228+
// `Claim:` and no dev, and H1/H2/H3/H8/H24 are each correctly silent. This row
9229+
// asks the question none of them can: is the NEWEST `Refs`-linked PR on this
9230+
// card MERGED, with no `Release:` or `Claim:` on the thread newer than that
9231+
// merge? H8 could never have asked it — it reads no thread, so a remainder
91559232
// lawfully re-dispatched (a fresh `Claim:` after the merge) is exactly the case
9156-
// this row stands down on. Adjacent rows, not a redundant pair; the self-test
9233+
// this row stands down on. Adjacent rows with no overlap left; the self-test
91579234
// pins both halves of that adjacency.
91589235
//
91599236
// ## Population, and what is OUT by ruling
@@ -14003,7 +14080,7 @@ function selfTest() {
1400314080
body,
1400414081
title,
1400514082
});
14006-
// `.claude/skills/pm-dispatch/SKILL.md` line 465, VERBATIM — one constant so
14083+
// `.claude/skills/pm-dispatch/SKILL.md`, VERBATIM — one constant so
1400714084
// the two rows that quote it (H24's remedy, H47's both legs) are pinned
1400814085
// against the SAME bytes. Rewriting a maintainer's line is rewriting the
1400914086
// ruling, and a quote that drifts from its source in one row while the other
@@ -14582,31 +14659,60 @@ function selfTest() {
1458214659
head: { ref },
1458314660
});
1458414661

14585-
// Direction 1 — the measured specimen's shape: merged, body carries NEITHER
14586-
// recognised spelling (`Refs #N` is not one), branch named for the card.
14662+
// Direction 1 — the DELIVERY relation, unchanged: a body carrying neither
14663+
// recognised spelling (`Refs #N` is not one) still reaches its card through
14664+
// the branch name, which is what the live-work readers need.
14665+
const REFS_ONLY_10757 = onBranch(10824, 'Refs #10757', 'claude/issue-10757-dedupe-per-request-queries');
1458714666
t(
1458814667
'H8 branch: a `Refs #N`-only body delivers via its branch name',
14589-
typeof h8MergedPrStillDispatched(
14590-
dispatched(10757),
14591-
[onBranch(10824, 'Refs #10757', 'claude/issue-10757-dedupe-per-request-queries')],
14592-
),
14668+
prDeliversCard(REFS_ONLY_10757, 10757),
14669+
true,
14670+
);
14671+
// …and the FULL-delivery reading H8's merged side takes DECLINES it (#16036).
14672+
// The body spoke about this card in the one spelling the protocol reserves for
14673+
// a non-delivery, so the landing is partial and H49 owns it — H8 must not
14674+
// prescribe dropping `pm:dispatched` off a lawfully re-dispatched remainder.
14675+
t(
14676+
'H8 branch: …but a `Refs`-only landing is NOT a full delivery, so H8 is clean (#16036)',
14677+
h8MergedPrStillDispatched(dispatched(10757), [REFS_ONLY_10757]),
14678+
null,
14679+
);
14680+
t(
14681+
'H8 branch: …and the narrower reader says so in its own right',
14682+
prFullyDeliversCard(REFS_ONLY_10757, 10757),
14683+
false,
14684+
);
14685+
// A `Refs` naming ANOTHER card narrows nothing about this one — bound per
14686+
// card number, as H7 binds.
14687+
t(
14688+
'H8 branch: a `Refs` for another card leaves this card\'s fallback intact',
14689+
typeof h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, 'Refs #9999', 'claude/issue-4321-x')]),
1459314690
'string',
1459414691
);
14692+
// …and a body that DELIVERS is a delivery however it spells the reference
14693+
// beside it — the same binding H49 makes from the other end.
1459514694
t(
14596-
'H8 branch: …and the finding names the delivering PR',
14597-
h8MergedPrStillDispatched(
14598-
dispatched(10757),
14599-
[onBranch(10824, 'Refs #10757', 'claude/issue-10757-dedupe-per-request-queries')],
14600-
).includes('#10824'),
14601-
true,
14695+
'H8 branch: `Refs #N` beside a closing keyword is still a full delivery',
14696+
typeof h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, 'Refs #4321 (item ①)\n\nFixes #4321', 'claude/issue-4321-x')]),
14697+
'string',
1460214698
);
14603-
// An empty body is the same population — nothing declared, so the branch is
14604-
// the only evidence there is.
14699+
t(
14700+
'H8 branch: …and beside `Part of #N` likewise',
14701+
typeof h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, 'Part of #4321\nRefs #4321', 'claude/issue-4321-x')]),
14702+
'string',
14703+
);
14704+
// An empty body is the population the fallback WAS built for — nothing
14705+
// declared, so the branch is the only evidence there is, and H8 still fires.
1460514706
t(
1460614707
'H8 branch: an empty body delivers via its branch name',
1460714708
typeof h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, '', 'claude/issue-4321-x')]),
1460814709
'string',
1460914710
);
14711+
t(
14712+
'H8 branch: …and the finding names the delivering PR',
14713+
h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, '', 'claude/issue-4321-x')]).includes('#4400'),
14714+
true,
14715+
);
1461014716

1461114717
// Direction 2 — the RE-SCOPED branch, the false-fire this widening could
1461214718
// otherwise buy. Branch still named for 4321; body now delivers 9999. The
@@ -15832,12 +15938,48 @@ function selfTest() {
1583215938
t('H20 branch: no directive at all yields nothing', claimedBranches('Claim: seat.\nSession: `session_x`').length, 0);
1583315939
t('H20 branch: a missing body does not crash', claimedBranches(undefined).length, 0);
1583415940

15941+
// -- #16170: the directive written as a markdown BULLET --------------------
15942+
// Measured live on #15511: a claim whose first line was an undecorated
15943+
// `Claim:` and whose branch hung under it in a list. The marker matched and
15944+
// this reader did not, so the comment WAS a claim that GOVERNED nothing —
15945+
// and nothing in the artefact looked wrong.
15946+
t('H20 branch: a bulleted directive is read (#16170)', claimedBranches('- Branch: `claude/issue-15511-zh-gap-helptext`').join(','), 'claude/issue-15511-zh-gap-helptext');
15947+
t('H20 branch: …with `*` as the marker', claimedBranches('* Branch: claude/issue-1-a').join(','), 'claude/issue-1-a');
15948+
t('H20 branch: …and with `+`, on the plural spelling too', claimedBranches('+ Branches: claude/issue-1-a').join(','), 'claude/issue-1-a');
15949+
t('H20 branch: …indented under a parent item', claimedBranches(' - Branch: claude/issue-1-a').join(','), 'claude/issue-1-a');
15950+
t('H20 branch: …and the marker composes with the documented blockquote', claimedBranches('- > Branch: `claude/issue-6752-x`').join(','), 'claude/issue-6752-x');
15951+
// ⛔ The widening is a LIST MARKER before a `Branch:` DIRECTIVE and nothing
15952+
// else: a bullet naming any other field, or none, stays out of scope exactly
15953+
// as an undecorated one does.
15954+
t('H20 branch: a bullet that is not a `Branch:` line yields nothing', claimedBranches('- Worktree: `claude/issue-1-a`').length, 0);
15955+
t('H20 branch: …nor does a bulleted prose line that merely names a branch', claimedBranches('- rebased onto claude/issue-9-other yesterday').length, 0);
15956+
t('H20 branch: a NUMBERED list item is not admitted', claimedBranches('1. Branch: claude/issue-1-a').length, 0);
15957+
t('H20 branch: a HEADING is not admitted', claimedBranches('## Branch: claude/issue-1-a').length, 0);
15958+
// ⚠️ A measured RESIDUAL, pinned as a fact rather than as a contract anybody
15959+
// chose: the ruled anchor admits the marker only BEFORE the optional
15960+
// blockquote, so the other order — the natural markdown for a bulleted line
15961+
// INSIDE a blockquote — still yields nothing.
15962+
t('H20 branch: a blockquoted BULLET (`> - `) is still out — the anchor is marker-then-quote', claimedBranches('> - Branch: `claude/issue-1-a`').length, 0);
15963+
// ⛔ And the claim MARKER is NOT widened with it (2026-08-11, 不放宽谓词). The
15964+
// asymmetry is the fix rather than a residue: when BOTH lines carry a bullet
15965+
// the marker misses too, so the comment is not a claim to any reader here and
15966+
// the sibling checker's "no claim comment" reading is TRUE. Only the MIXED
15967+
// shape was malignant, and admitting the marker here is what removes it.
15968+
t('H20 branch: a bulleted `Claim:` is still not a claim comment', CLAIM_COMMENT_MARKER.test('- Claim: seat.'), false);
15969+
1583515970
// The governing claim — the MOST RECENT one, H19's double-check ① reasoning.
1583615971
const gov = (rows) => governingClaim(rows);
1583715972
t('H20 claim: a claim comment naming a branch is found', gov(claim8878).branches.join(','), 'claude/issue-8878-dispatch-latency');
1583815973
t('H20 claim: …and carries its timestamp', gov(claim8878).createdAt, minsAgo20(74));
1583915974
t('H20 claim: a `Branch:` line in a comment that is NOT a claim is ignored', gov([claimRow(minsAgo20(90), 'Branch: `claude/issue-1-a`')]), null);
1584015975
t('H20 claim: a claim comment naming NO branch yields nothing to check', gov([claimRow(minsAgo20(90), 'Claim: seat.\nSession: `session_x`')]), null);
15976+
// #16170 — the MIXED shape, which is the only malignant one: an undecorated
15977+
// `Claim:` (so the marker matches and the comment IS a claim) with the branch
15978+
// written as a bullet under it. It governs now.
15979+
t('H20 claim: the MIXED shape governs — undecorated `Claim:`, bulleted `Branch:` (#16170)', gov([claimRow(minsAgo20(30), 'Claim: this card is held by session `session_x`.\n\n- Branch: `claude/issue-15511-zh-gap-helptext`\n- Worktree: `/home/user/objectstack-15511`')]).branches.join(','), 'claude/issue-15511-zh-gap-helptext');
15980+
// …while a FULLY bulleted claim governs nothing, the marker having missed it
15981+
// — and there the sibling's "no claim comment" reading is the true one.
15982+
t('H20 claim: …and a fully bulleted claim still governs nothing, the marker having missed', gov([claimRow(minsAgo20(30), '- Claim: seat.\n- Branch: `claude/issue-1-a`')]), null);
1584115983
const reclaimed = [
1584215984
claimRow(minsAgo20(600), 'Claim: first seat.\nBranch: `claude/issue-8878-abandoned`'),
1584315985
claimRow(minsAgo20(74), claimBody8878),
@@ -19905,8 +20047,13 @@ Mutual exclusion: \`get_comments\` page 747 → \`[]\`, page 746 = my own R+117
1990520047
t('H49 adjacency: H8 is SILENT on a `Refs` PR whose head is not named for the card', h8MergedPrStillDispatched(TAKEN49, LANDED49, []), null);
1990620048
t('H49 adjacency: …which is the carrier this row exists for', typeof h49PartialLandingUnreleased(TAKEN49, LANDED49, [], CLAIMED49), 'string');
1990720049
const ON_CARD_BRANCH49 = [merged49(16010, 'Refs #16003 (item ①)', '2026-09-05T20:12:00Z', { head: { ref: 'claude/issue-16003-kanban-keys' } })];
19908-
t('H49 adjacency: on a head NAMED for the card H8 fires through its branch fallback', typeof h8MergedPrStillDispatched(TAKEN49, ON_CARD_BRANCH49, []), 'string');
19909-
t('H49 adjacency: …and this row fires beside it — two readings, no double silence', typeof h49PartialLandingUnreleased(TAKEN49, ON_CARD_BRANCH49, [], CLAIMED49), 'string');
20050+
// ⚖️ #16036 flipped the first of these. H8's merged side used to fire here
20051+
// through its branch fallback — a full-delivery verdict on a PARTIAL landing,
20052+
// prescribing the destructive de-label against a lawfully re-dispatched
20053+
// remainder — and its `Refs`-only exclusion now leaves this row the sole
20054+
// reader of BOTH head shapes.
20055+
t('H49 adjacency: on a head NAMED for the card H8 is silent too, since #16036', h8MergedPrStillDispatched(TAKEN49, ON_CARD_BRANCH49, []), null);
20056+
t('H49 adjacency: …and this row fires there — one reading now, and no silence', typeof h49PartialLandingUnreleased(TAKEN49, ON_CARD_BRANCH49, [], CLAIMED49), 'string');
1991020057
t('H49 adjacency: …and reads the thread H8 cannot — a fresh claim stands THIS row down', h49PartialLandingUnreleased(TAKEN49, ON_CARD_BRANCH49, [], [cm49(CLAIM49, T49_AFTER)]), null);
1991120058
t('H49 adjacency: H1 is silent on the carrier (the card HAS an assignee)', h1DispatchedNoAssignee(TAKEN49), false);
1991220059
t('H49 adjacency: H2 is silent (the claim comment is complete)', h2AssigneeNoClaimComment(TAKEN49, [CLAIM49]), false);

0 commit comments

Comments
 (0)