diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index eea330b90c..92d43d0bb8 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -567,6 +567,8 @@ import { contractReviewHeadMatch, deliveryEvidence, deliveryEvidenceNote, + claimGovernance, + claimedBranches, governingClaim, isGateSemanticLabel, labelNames, @@ -619,6 +621,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the exit register is distinct in every direction it must be': 6, 'the argv contract and the board provenance (#16623)': 42, '#17366: the correction comment — the self-solvable exit, and the three things it is not': 65, + '#17149: a claim that parses to ZERO branches — malformed, never absent': 26, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -626,8 +629,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ // Raised by exactly the one battery #16304 adds, again by exactly the one // #17302 adds, and again by exactly the one #17366 adds, so the roster's // existing slack is preserved rather than tightened or loosened as a side -// effect. -const SELF_TEST_BATTERY_FLOOR = 17; +// effect, and once more by the one #17149 adds. +const SELF_TEST_BATTERY_FLOOR = 18; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1042,6 +1045,20 @@ const CLAUSE2_CORRECTION_KEY_TEXT = 'Clause-②-correction'; * is the claim comment. `missing` — a claim comment IS there and carries no * declaration line; the carrier exists and the line is what is owed. * + * ⭐ A THIRD fact sits underneath both of them, and it is not a reading of the + * declaration at all: WHICH claim comment is the carrier. The designated + * carrier is the GOVERNING claim, and governance is resolved by a branch parse + * — so a newest claim comment that names no parseable branch used to be + * discarded, silently handing this limb an OLDER claim's declaration to read. + * That is `claim-branch-unparsed`, and it is returned BEFORE any line is read + * from any comment, because every reading below it would be a reading of the + * wrong comment. It is neither of the two not-read states above and ⛔ never a + * `no`: an unparsed claim is an UNCLASSIFIED result, and the caller accounts it + * as UNJUDGED (exit 2) rather than as a verdict about this pair. The measured + * cost of the silence is on #16322 — the limb read a superseded `Clause-②: no` + * and exited 4 for two rounds — and its sharper half is the sibling that passed + * because the older claim happened to agree. + * * The predicate that separates them is `CLAIM_COMMENT_MARKER`, imported rather * than restated: a claim comment is one whose body carries a LINE BEGINNING * `Claim:` (or `Claimed:`, optionally blockquoted), and that one spelling is @@ -1053,15 +1070,35 @@ const CLAUSE2_CORRECTION_KEY_TEXT = 'Clause-②-correction'; * * @param {{ body?: string, created_at?: string }[]|null} commentRows — the REST * comment rows, or `null` when the thread could NOT be read. - * @returns {{ state: 'declared'|'malformed'|'misplaced'|'missing'|'absent'|'unreadable', + * @returns {{ state: 'declared'|'malformed'|'misplaced'|'missing'|'absent'|'unreadable' + * |'claim-branch-unparsed', * value?: 'yes'|'no', detail?: string, nearMissReason?: 'inline-key'|'spelling', - * correctionNote?: string }} — `correctionNote` rides alongside for exactly one - * purpose, the same way `nearMissReason` does: the rows below print it. ⛔ It is - * not part of the state union and no verdict, count or exit reads it. + * correctionNote?: string, malformedClaim?: object, governingClaim?: object }} — + * `correctionNote` rides alongside for exactly one purpose, the same way + * `nearMissReason` does: the rows below print it. ⛔ It is not part of the state + * union and no verdict, count or exit reads it. `malformedClaim` / + * `governingClaim` ride the same way, on the `claim-branch-unparsed` state only, + * so its sentence can name the comment and say what governance did instead. */ export function cardDeclaration(commentRows) { if (!Array.isArray(commentRows)) return { state: 'unreadable' }; - const claim = governingClaim(commentRows); + const governance = claimGovernance(commentRows); + // ⛔ FIRST, and ahead of the correction read (#17366) as well as of every + // line read below. When the newest claim comment parses to zero branches, + // `governing` is an OLDER claim or nothing at all — so the pool the reads + // below are built from is the wrong carrier, and a correction naming that + // older claim's id would be applied to it too. Every one of those readings + // would be about a comment the seat has already replaced, which is the + // silence this state exists to end. ⛔ Never resolved by guessing a branch + // out of the claim's prose: the reader is not widened here or anywhere. + if (governance.malformed) { + return { + state: 'claim-branch-unparsed', + malformedClaim: governance.malformed, + governingClaim: governance.governing, + }; + } + const claim = governance.governing; const claimRows = commentRows.filter((row) => CLAIM_COMMENT_MARKER.test(String(row?.body ?? ''))); // The governing claim is the one the board is waiting on; when no comment // names a branch, every claim-marked comment is still a claim carrier and is @@ -1310,6 +1347,13 @@ function c2Sentence(d, head, fixed, notADecision) { return null; case 'unreadable': return null; // accounted as UNJUDGED by the caller — never silently clean. + case 'claim-branch-unparsed': + // Same posture, same reason: the carrier itself is unresolved, so this is + // UNJUDGED rather than a verdict about the pair, and `pairUnjudged` prints + // the whole sentence and raises exit 2. ⛔ Returning a C2 row here would + // make an unclassified result exit 4 — a verdict — which is exactly the + // reading the filing card refuses ('never a no'). + return null; case 'misplaced': return ( `${head} — the \`Clause-②\` declaration is MISPLACED: the fixed spelling appears on the ` + @@ -1377,8 +1421,10 @@ function c2Sentence(d, head, fixed, notADecision) { * ⛔ Read through `cardDeclaration` — the same function, the same imported * `CLAIM_COMMENT_MARKER`, the same two spellings. Only a `declared` state on a * sibling's own claim comment is a carrier here: `misplaced`, `malformed`, - * `missing`, `absent` and `unreadable` are all NOT declarations on the sibling - * either, exactly as they are not on the subject card. Nothing about what + * `missing`, `absent`, `claim-branch-unparsed` and `unreadable` are all NOT + * declarations on the sibling either, exactly as they are not on the subject + * card — and the unparsed one least of all, since on that sibling this file + * cannot even say WHICH comment would have carried the line. Nothing about what * counts as an answer moves in this function; what moves is only which card is * being asked. * @@ -1424,7 +1470,11 @@ export function siblingDeclarations(pair, pairs) { * protocol, so a dispatched card whose declaration line is missing reads * `missing` and keeps its C2 row and its exit 4. `malformed`, `misplaced` and * `missing` all mean a seat DID read this card and owes it something a - * legitimate act can supply; none of them is covered here. + * legitimate act can supply; none of them is covered here. Neither is + * `claim-branch-unparsed`, and for a sharper reason: that state means this file + * could not resolve which comment is the carrier AT ALL, so borrowing a + * sibling's declaration would answer a question this card has not yet been able + * to ask. It is UNJUDGED (exit 2), which the caller reaches before any row. */ export function readsSiblingDeclaration(pair, pairs) { if (cardDeclaration(pair?.cardComments ?? null).state !== 'absent') return false; @@ -2401,9 +2451,12 @@ export function pairNotes(pair, pairs = null) { * * The tally reads the SAME `cardDeclaration` the rows read, so a count can * never disagree with the rows printed under it. `declared`, `misplaced`, - * `malformed` and `unreadable` are counted into neither — each is its own - * reading with its own row, and an unreadable thread is UNJUDGED rather than - * either not-read state. + * `malformed`, `claim-branch-unparsed` and `unreadable` are counted into + * neither — each is its own reading with its own row, and an unreadable thread + * or an unresolvable claim carrier is UNJUDGED rather than either not-read + * state. ⛔ Counting an unparsed claim under `absent` would be the old silence + * wearing a number: it would say "this card has no claim comment" about a card + * that has one. * * ⭐ Since #16304 the `absent` population is split once more, for the same * reason it was split from `missing` in the first place: a card with no claim @@ -2428,6 +2481,47 @@ export function declarationLimbTally(pairs) { return tally; } +/** + * The UNJUDGED sentence for a card whose newest claim comment parses to ZERO + * branches (#17149) — the declaration limb's carrier, unresolved. + * + * ⚠️ It reports a CARRIER problem and says nothing about the declaration: the + * card may well carry a correctly-spelled line, and this run cannot tell + * whether it is the current one. Reporting it as `missing` would send the seat + * looking for a line that is there; reporting it as a `no` would manufacture a + * decision nobody made. The sentence names the comment, says what governance + * did instead, and gives the one remedy — a `Branch:` line of its own. + * + * ⛔ Nothing here widens the branch reader. The remedy is on the WRITE side, + * which is the same call the sibling script's H34 makes for the claim marker + * and for the same standing ruling (⛔ 不放宽谓词). + */ +export function claimBranchUnparsedGap(pair, decl) { + const m = decl?.malformedClaim ?? {}; + const governing = decl?.governingClaim ?? null; + const which = m.id === null || m.id === undefined ? 'a comment carrying no readable id' : `comment ${m.id}`; + const instead = governing + ? `governance FELL BACK to an OLDER claim (${governing.createdAt ?? 'undated'}, naming ` + + `${(governing.branches ?? []).map((b) => `\`${b}\``).join(', ') || 'no branch'}), so the ` + + 'declaration this run would otherwise have read is that older comment\'s — a SUPERSEDED ' + + 'reading, and when it happens to agree with the current one the result is a green that is ' + + 'right for the wrong reason' + : 'NOTHING governs this card, so the limb would otherwise have read `absent` — "no claim ' + + 'comment was written", which is false: one was'; + return ( + `pair PR #${pair?.pr} / card #${pair?.card} — UNJUDGED: the card's NEWEST claim comment ` + + `(${which}, ${m.createdAt ?? 'undated'}) matches the claim marker but its \`Branch:\` directive ` + + `parses to ZERO branches, so the carrier this limb reads cannot be resolved. ${instead}. ` + + 'An unparsed claim is an UNCLASSIFIED result, ⛔ never an absent declaration and ⛔ never a ' + + 'declared `no` — this pair is missing from the readings above, not clean in them and not ' + + 'adverse in them. Remedy — the CLAIMING SEAT, with one comment: name the branch on a ' + + '`Branch:` line of its OWN (`` Branch: `claude/issue--` ``), ⛔ not inside the ' + + '`Claim:` sentence, which no reader parses. ⚠️ A whole shift of claims reading this way is a ' + + 'SEAT TEMPLATE fault, not a typo. ' + + NEVER_WRITES + ); +} + /** * What this pair could NOT be judged on — the #4690 half. * @@ -2440,6 +2534,14 @@ export function pairUnjudged(pair) { if (!Array.isArray(pair?.cardLabels)) gaps.push(`card #${pair?.card}'s labels`); if (!Array.isArray(pair?.prLabels)) gaps.push(`PR #${pair?.pr}'s labels`); if (!Array.isArray(pair?.cardComments)) gaps.push(`card #${pair?.card}'s comment thread`); + // The thread WAS read and the carrier still cannot be resolved (#17149). + // Placed after the read checks so an unread thread is never reported as a + // malformed claim, and before every limb below it because each of those + // reads a comment chosen by the governance this state says is broken. + if (gaps.length === 0) { + const decl = cardDeclaration(pair?.cardComments ?? null); + if (decl.state === 'claim-branch-unparsed') return claimBranchUnparsedGap(pair, decl); + } // The gate history is owed by C3 CANDIDATES only — the cost bound and the // accounting read one predicate, so a pair can never owe a stream the live // reader was never going to fetch. An unread stream is not a never-hung gate. @@ -4222,6 +4324,101 @@ export function selfTest() { t('⛔ no new exit code was minted for the correction reading', new Set([EXIT_OK, EXIT_USAGE, EXIT_INCOMPLETE, EXIT_PREREQUISITE_NOT_MET, EXIT_PAIR_ADVERSE]).size === 5); t('…and a repaired pair answers with the SAME code a never-broken one does', pairRows(repairedPair).length === pairRows(pair({ cardComments: [CLAIMED('Clause-②: no')] })).length); + // -- #17149: a claim that parses to ZERO branches --------------------------- + // + // The declaration limb's carrier is the GOVERNING claim, and governance is + // resolved by a branch parse. A newest claim comment naming no parseable + // branch used to be discarded, and this file then read a declaration off a + // comment the seat had already replaced — silently, and in BOTH directions: + // wrong when the two disagreed, and right-for-the-wrong-reason when they + // agreed. The fixtures below are the MEASURED bodies, quoted rather than + // paraphrased, so a future widening of the branch reader cannot make this + // battery pass by accident. + battery('#17149: a claim that parses to ZERO branches — malformed, never absent'); + // Card #16322, comments 5593513389 (2026-09-08T23:46:51Z) and 5594909614 + // (2026-09-09T02:35:21Z) as posted: the branch named INSIDE the `Claim:` + // sentence, with no `Branch:` line anywhere. The two declare OPPOSITE values, + // which is what made the fallback visible at all. + const INLINE_OLD = { + id: 5593513389, + created_at: '2026-09-08T23:46:51Z', + body: + 'Claim: session_01ADLdAs2pVcH17h9tZKWMBg — branch `claude/issue-16322-analytics-daterange-closed-vocabulary-drivers`\n\n' + + 'Clause-②: no\n', + }; + const INLINE_NEW = { + id: 5594909614, + created_at: '2026-09-09T02:35:21Z', + body: + 'Claim: session_01ADLdAs2pVcH17h9tZKWMBg — branch `claude/issue-16322-analytics-daterange-closed-vocabulary-drivers`\n\n' + + 'Clause-②: yes\n', + }; + // The live board, 2026-09-12T02:53Z: the same spelling, a different seat's + // template, four claims inside three seconds. Card #16175's newest claim is + // branchless while its 2026-09-06 claim parses — and names a DIFFERENT branch. + const LIVE_BRANCHLESS = { + id: 5642984850, + created_at: '2026-09-12T02:53:06Z', + body: 'Claim: session_012GKcPZbMoGq7WPzKLfRBTU · claude/issue-16175-staleness-mtime-false-refusal\nClause-②: no\n', + }; + const LIVE_PARSES = { + id: 5557414924, + created_at: '2026-09-06T06:19:06Z', + body: 'Claim: PM loop\nBranch: `claude/issue-16175-regen-sibling-stale-rules`\nClause-②: no\n', + }; + const INLINE_THREAD = [INLINE_OLD, INLINE_NEW]; + const unparsedDecl = cardDeclaration(INLINE_THREAD); + t('⭐ the measured inline spelling reads CLAIM-BRANCH-UNPARSED — the carrier could not be resolved', unparsedDecl.state === 'claim-branch-unparsed'); + t('⛔ …and NOT `declared`: the line that IS on the thread belongs to a comment this run cannot confirm is current', unparsedDecl.state !== 'declared'); + t('⛔ …nor `absent`, which would say no claim comment was written — one was', unparsedDecl.state !== 'absent'); + t('⛔ …nor `missing`, which would send the seat looking for a line that is there', unparsedDecl.state !== 'missing'); + t('…and it carries NO value — an unclassified result is never a reading', unparsedDecl.value === undefined); + t('the state names the comment it could not parse', unparsedDecl.malformedClaim?.id === 5594909614); + // The BEFORE-state, quantified rather than recalled: both comments carry a + // readable line, and they DISAGREE. That is why reading the wrong one was a + // wrong answer and not merely an unlucky one. + t('⭐ the superseded comment carried a readable declaration, and the two DISAGREE', readClause2Line(INLINE_OLD.body)?.value === 'no' && readClause2Line(INLINE_NEW.body)?.value === 'yes'); + // The reading PRINTS, and it is UNJUDGED (exit 2) rather than a verdict. + const unparsedPair = pair({ cardComments: INLINE_THREAD }); + const unparsedGap = pairUnjudged(unparsedPair); + t('⭐ the pair is UNJUDGED and the reading prints in full — ⛔ never silence', typeof unparsedGap === 'string' && unparsedGap.length > 0); + t('…naming the comment id, so a reader can open it', says(unparsedGap, '5594909614')); + t('…and the remedy, which is a `Branch:` line of its OWN', says(unparsedGap, '`Branch:` line of its OWN')); + t('…and saying in as many words that this is not a declared `no`', says(unparsedGap, 'never a declared `no`')); + t('…and that a whole shift reading this way is a TEMPLATE fault rather than a typo', says(unparsedGap, 'SEAT TEMPLATE fault')); + t('⛔ and it raises NO C2 finding — an unclassified result must never be rendered as an adverse verdict', pairRows(unparsedPair).every((r) => r.code !== 'C2')); + t('⛔ nor any other finding row on this pair', pairRows(unparsedPair).length === 0); + // The live specimen, and the fallback shape at its sharpest: the older claim + // names a DIFFERENT branch, so every reader downstream probes the wrong ref. + const liveDecl = cardDeclaration([LIVE_PARSES, LIVE_BRANCHLESS]); + t('⭐ the live 2026-09-12 specimen reads the same way', liveDecl.state === 'claim-branch-unparsed'); + t('…and the state names the older claim governance would have fallen back to', liveDecl.governingClaim?.createdAt === '2026-09-06T06:19:06Z'); + t('⚠️ …whose branch is a DIFFERENT one, so the fallback is not even about the same work', liveDecl.governingClaim?.branches.join(',') === 'claude/issue-16175-regen-sibling-stale-rules'); + t('…and the printed gap names that older claim rather than leaving the reader to guess', says(pairUnjudged(pair({ cardComments: [LIVE_PARSES, LIVE_BRANCHLESS] })), 'claude/issue-16175-regen-sibling-stale-rules')); + // CONTROLS — the accept set did not move in either direction. + t('⛔ CONTROL: a well-formed newest claim still governs, and its value is read', cardDeclaration([ + { id: 1, created_at: '2026-08-30T09:00:00Z', body: 'Claim: old\nBranch: `claude/issue-1-old`\nClause-②: yes' }, + { id: 2, created_at: '2026-08-31T09:00:00Z', body: 'Claim: new\nBranch: `claude/issue-1-new`\nClause-②: no' }, + ]).value === 'no'); + t('⛔ CONTROL: an OLDER branchless claim is spent and raises nothing — governance is correct', cardDeclaration([ + { id: 1, created_at: '2026-08-30T09:00:00Z', body: 'Claim: session_x · claude/issue-1-old\nClause-②: yes' }, + { id: 2, created_at: '2026-08-31T09:00:00Z', body: 'Claim: new\nBranch: `claude/issue-1-new`\nClause-②: no' }, + ]).state === 'declared'); + t('⛔ CONTROL: #16170\'s bulleted `Branch:` directive still parses, so its card is unaffected', cardDeclaration([ + { id: 1, created_at: '2026-08-31T09:00:00Z', body: 'Claim: PM loop\n- Branch: `claude/issue-15511-zh-gap-helptext`\nClause-②: no' }, + ]).state === 'declared'); + t('⛔ CONTROL: a thread with no claim comment at all still reads ABSENT', cardDeclaration([{ id: 1, body: 'a triage note', created_at: '2026-08-31T10:00:00Z' }]).state === 'absent'); + t('⛔ CONTROL: an unreadable thread still reads UNREADABLE — never the new state', cardDeclaration(null).state === 'unreadable'); + // The new state is nobody else's state: it is counted into neither not-read + // population and it is not the #16304 fourth reading's `absent`. + t('the tally counts it under NEITHER not-read population', declarationLimbTally([unparsedPair]).absent === 0 && declarationLimbTally([unparsedPair]).missing === 0); + t('⛔ …and the fourth reading is unavailable on it — a sibling cannot answer a question this card could not ask', readsSiblingDeclaration(unparsedPair, [unparsedPair, pair({ card: 999, cardComments: [CLAIM('Clause-②: yes')] })]) === false); + t('⛔ …nor is such a card a CARRIER for a sibling of its own', siblingDeclarations(pair({ card: 999, pr: 13910 }), [pair({ card: 999, pr: 13910 }), { pr: 13910, card: 13476, cardComments: INLINE_THREAD }]).length === 0); + // ⛔ The fix is the STATE, not a widening: the branch reader's accept set is + // byte-identical, which is what keeps the next unrecognised spelling loud. + t('⛔ the branch reader was NOT widened — the inline spelling still parses to zero', claimedBranches(INLINE_NEW.body).length === 0); + t('⛔ …and the claim marker still matches it, which is what makes the two-anchor split a STATE', CLAIM_COMMENT_MARKER.test(INLINE_NEW.body) === true); + // -- The floor: every declared battery RAN, and ran its cases (#13489) ----- // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -4282,7 +4479,9 @@ export function selfTest() { 'the correction comment that supersedes a claim declaration with the five measured prose ' + 'spellings held out as negatives, ' + 'the three read paths with their offline reader, the argv contract with its usage and its ' - + 'refusal, the board provenance line, and the exit register).', + + 'refusal, the board provenance line, the claim whose `Branch:` line parses to ZERO ' + + 'branches — reported as an unresolvable carrier rather than discarded — and the exit ' + + 'register).', ); selfTestReachedVerdict = true; diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 4b1dc895de..f9f869cdda 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -4456,6 +4456,103 @@ export function claimedBranches(body) { } return out; } +/** + * The claim state of one card's thread, in TWO parts: what GOVERNS, and what + * could not be CLASSIFIED. + * + * ## The silence this function exists to end + * + * `governingClaim` picks the newest claim comment from which at least one + * branch parses. A comment that matches `CLAIM_COMMENT_MARKER` and yields ZERO + * branches used to be DISCARDED at that point — and a discard is + * indistinguishable from an absence, so governance fell back to an OLDER claim + * and said nothing about it. The card then reads as claimed to a human and to + * a `grep`, as claimed-on-a-different-branch to every reader here, and as + * neither to nobody at all. + * + * ⭐ An unparsed claim is an UNCLASSIFIED result, ⛔ never a "no". Its three + * consequences are all silent and all wrong in different directions: H20/H27 + * probe a branch the seat is no longer on, `check-clause2-carriers` reads a + * SUPERSEDED declaration as if it were the current one (measured on #16322 — + * two rounds, a director re-review and a re-issued claim to clear), and the + * near-miss on the same card is sharper still — a fallback whose older claim + * happens to AGREE is a green that is right for the wrong reason and nothing + * ever reports it. + * + * ⛔ The repair is NOT to widen `claimedBranches` for whichever spelling was + * measured last. That is the treadmill #16170 already bought once: it closes + * one spelling and leaves the next one exactly as silent. What is returned here + * is the STATE, so the next unrecognised spelling is a visible error on its + * first occurrence instead of a wrong reading. + * + * ## What `malformed` means, precisely + * + * The NEWEST claim-shaped comment on the thread parses to zero branches. Newest + * by the same recency rule `governing` uses, so the two readings can never + * disagree about which comment is the current one. It is reported whether or + * not an older claim parses, because the two shapes have the same remedy and + * only one of them is even detectable from the outside: + * + * an older claim parses → governance SILENTLY FELL BACK; every reader below + * is answering about a claim the seat has replaced. + * none parses → `governing` is null, which every reader has always + * read as "no claim was written". A claim WAS + * written; it is the `Branch:` line that is missing, + * and that is a different remedy from H2's. + * + * ⚠️ A claim whose newest comment DOES parse leaves `malformed` null even when + * an older one was branchless: that older claim is spent, governance is + * correct, and a row about it would be noise on a card with nothing wrong. + * + * @param {{ id?: number, body?: string, created_at?: string }[]} commentRows + * @returns {{ governing: { branches: string[], createdAt: string|null } | null, + * malformed: { id: number|null, createdAt: string|null, + * supersedes: string|null } | null }} — `supersedes` is the `created_at` of + * the claim governance fell back to, or `null` when nothing governs at all. + */ +export function claimGovernance(commentRows) { + const rows = Array.isArray(commentRows) ? commentRows : []; + // The recency rule, written ONCE: `created_at` first and THREAD ORDER as the + // fallback for an unparseable stamp (#4690), with `>=` so a later row wins a + // tie. Both readings below call it, so the "newest claim" they each speak + // about is the same comment by construction rather than by care. + const newerThan = (candidate, incumbent) => + incumbent === null || + (candidate.stamp === null || incumbent.stamp === null + ? candidate.index > incumbent.index + : candidate.stamp >= incumbent.stamp); + let best = null; + let newest = null; + rows.forEach((row, index) => { + const body = String(row?.body ?? ''); + if (!CLAIM_COMMENT_MARKER.test(body)) return; + const branches = claimedBranches(body); + const parsed = Date.parse(row?.created_at ?? ''); + const stamp = Number.isFinite(parsed) ? parsed : null; + const candidate = { branches, createdAt: row?.created_at ?? null, stamp, index, id: row?.id ?? null }; + if (newerThan(candidate, newest)) newest = candidate; + if (branches.length === 0) return; + if (newerThan(candidate, best)) best = candidate; + }); + const governing = best === null ? null : { branches: best.branches, createdAt: best.createdAt }; + return { + governing, + malformed: + newest !== null && newest.branches.length === 0 + ? { + // ⚠️ `Number(null)` is 0 and `Number('')` is 0 — a finite check alone + // would turn a comment with NO id into comment zero, which is a worse + // lie than saying the id could not be read. + id: newest.id === null || newest.id === undefined || newest.id === '' + || !Number.isFinite(Number(newest.id)) + ? null + : Number(newest.id), + createdAt: newest.createdAt, + supersedes: governing === null ? null : governing.createdAt, + } + : null, + }; +} /** * The claim this card is CURRENTLY waiting on — the MOST RECENT claim comment @@ -4475,30 +4572,21 @@ export function claimedBranches(body) { * which the predicate treats as "must not read as fresh" — H10/H13/H18's * standing call on an unreadable timestamp (#4690). * + * ⚠️ This is `claimGovernance().governing` and nothing else — the branch half + * of a two-part reading, kept as its own export because most readers here + * legitimately need only the branches and must not be made to handle a state + * they have no row for. ⛔ A reader that DECIDES something about the card + * (dispatch liveness, the clause-② declaration limb) calls `claimGovernance` + * instead, or it will read a superseded claim as the current one and say + * nothing — the silence that function's header describes in full. + * * @param {{ body?: string, created_at?: string }[]} commentRows — the REST * comment rows, NOT bodies: this item is the only reader here that needs a * timestamp, which is why the sweep's cache holds rows. * @returns {{ branches: string[], createdAt: string|null } | null} */ export function governingClaim(commentRows) { - const rows = Array.isArray(commentRows) ? commentRows : []; - let best = null; - rows.forEach((row, index) => { - const body = String(row?.body ?? ''); - if (!CLAIM_COMMENT_MARKER.test(body)) return; - const branches = claimedBranches(body); - if (branches.length === 0) return; - const parsed = Date.parse(row?.created_at ?? ''); - const stamp = Number.isFinite(parsed) ? parsed : null; - const candidate = { branches, createdAt: row?.created_at ?? null, stamp, index }; - if (best === null) { - best = candidate; - return; - } - const newer = stamp === null || best.stamp === null ? index > best.index : stamp >= best.stamp; - if (newer) best = candidate; - }); - return best === null ? null : { branches: best.branches, createdAt: best.createdAt }; + return claimGovernance(commentRows).governing; } /** @@ -12831,6 +12919,110 @@ export function h59LinkageClause(counts = {}) { 'band leg is circumstantial and every row says so. Rows are a LOWER BOUND.' ); } +// --------------------------------------------------------------------------- +// H60 — a claim comment whose `Branch:` line parses to ZERO branches, so the +// claim silently stopped governing. +// +// ## The class, stated as the rule rather than as a spelling +// +// `claimGovernance`'s header carries the mechanism in full. The row is what +// makes it audible: a comment that IS a claim by `CLAIM_COMMENT_MARKER` and +// names no parseable branch is MALFORMED, ⛔ never absent, and the two have +// opposite remedies. Absent is H2's row — somebody must claim the card. +// Malformed is this one — somebody claimed it, correctly by every other +// measure, on a carrier the reader does not accept. +// +// ⛔ The row is deliberately NOT a widening of `claimedBranches`. Each of the +// three measured spellings arrived AFTER the last widening shipped: the +// bullet-prefixed directive (#16170), and then the branch named inline inside +// the `Claim:` sentence with no directive line at all. Widening buys one +// spelling and leaves the next one silent; a state buys every spelling that +// will ever be written, because what is reported is the parse FAILING rather +// than the shape that failed. +// +// ## Why this is its OWN row and not a widening of H20 +// +// H20 is a statement about the remote: a claimed branch has no ref. It is +// given the claim and a ref state per branch, and it has no way to speak about +// a card it was never handed branches for — its own gate (`h20NeedsRefProbe`) +// requires at least one. Folding this in would put an assertion about a REF on +// a card where no ref was ever named, which is the "fabricated row" H17's +// extractor refuses by name. This row asserts nothing about the remote at all. +// +// ## Its population, and why there is no age gate +// +// Every open `pm:dispatched` card whose thread this sweep read — the same set +// the dispatch-liveness loop already walks (H50's completing walk), so the row +// costs NO request. ⛔ No age gate, and the asymmetry with H20's 60 minutes is +// the point: H20 waits because a young dispatch is not yet stuck and a later +// sweep frees it. A branchless claim is wrong at the instant it is posted and +// no later sweep can free it — the claim protocol forbids a second `Claim:`, +// so nothing but a deliberate repair changes the reading. +// +// ## What it costs to be wrong here +// +// Nothing is written from this row (the family's standing posture) and the +// remedy is one comment. The risk in the other direction was measured: two +// rounds, a director re-review and a re-issued claim on one card, plus a +// sibling that passed for the wrong reason on the same shift and was found +// only by accident. +// --------------------------------------------------------------------------- + +/** + * H60 — null when the card's newest claim parses (or the card is out of + * scope), else the finding sentence. + * + * Takes the whole `claimGovernance` reading rather than the malformed half + * alone, so the row can say WHAT governance did instead — the fallback is the + * damaging half and a sentence that named only the broken comment would leave + * the reader to guess whether anything is governing at all. + * + * @param {object} issue — an OPEN issue. + * @param {{ governing: object|null, malformed: object|null }|null} governance — + * `claimGovernance(commentRows)`. A caller that read no thread passes `null`, + * which yields no row: an unread thread is not a malformed claim (#4690). + */ +export function h60ClaimBranchUnparsed(issue, governance) { + if (!labelNames(issue ?? {}).includes('pm:dispatched')) return null; + const malformed = governance?.malformed ?? null; + if (!malformed) return null; + const governing = governance?.governing ?? null; + const which = + `comment ${malformed.id === null ? 'carrying no readable id' : `\`${malformed.id}\``} ` + + `(${malformed.createdAt ?? 'undated'})`; + + const consequence = + governing === null + ? 'NOTHING governs this card as a result: `governingClaim` returns null, which every reader ' + + 'here has always read as "no claim comment names a branch" — so H20 can never report a ' + + 'missing remote ref for it and H27 can never report a dead claim, the two rows that exist ' + + 'to catch an abandoned dispatch. The card is claimed and is outside dispatch liveness ' + + 'entirely, which is invisible from the card itself.' + : `governance SILENTLY FELL BACK to an OLDER claim (${governing.createdAt ?? 'undated'}, ` + + `naming ${namedBranches(governing.branches.map((branch) => ({ branch, state: 'exists' })))}) — ` + + 'so every reader here is answering about a claim this seat has already replaced. H20 and H27 ' + + 'probe the superseded branch; `check-clause2-carriers` reads the superseded comment\'s ' + + '`Clause-②` declaration as if it were the current one. ⚠️ And when the older claim happens ' + + 'to AGREE, the fallback produces a GREEN that is right for the wrong reason and no row ' + + 'anywhere reports it — that near-miss is the sharper half of this class, not the mild one.'; + + return ( + `\`pm:dispatched\` and the NEWEST claim comment on this thread parses to ZERO branches — ${which} ` + + 'matches the claim marker (it IS a claim comment) and its `Branch:` directive yields no ' + + 'protocol-shaped ref. That is a MALFORMED claim, ⛔ not an absent one: the two states have ' + + `opposite remedies and only this row can tell them apart. ${consequence} Remedy — WHO can act, ` + + 'and HOW: the CLAIMING SEAT, with one comment. The branch must be named on a `Branch:` line of ' + + 'its OWN — `` Branch: `claude/issue--` `` — and ⛔ not inside the `Claim:` sentence, ' + + 'which no reader here parses. ⚠️ The claim protocol forbids a second `Claim:`, so the repair is ' + + 'an EDIT of the claim comment where the seat has that tool, and otherwise a withdrawal and a ' + + 're-claim by the seat that holds the card. ⛔ The reader is NOT widened to accept whichever ' + + 'spelling this card used: each widening closes one spelling and leaves the next one silent, ' + + 'which is the treadmill this row replaces. ⚠️ If a whole shift of claims reads this way, the ' + + 'cause is the SEAT TEMPLATE rather than a typo, and the fix is upstream of any one card. ' + + 'Report-only: ⛔ never a label written from this script.' + ); +} + // --------------------------------------------------------------------------- // Report rendering — pure over (findings, counts), so `--self-test` pins both // media offline. The live sweep below picks a renderer and prints it; nothing @@ -14054,6 +14246,16 @@ export const HALF_STATE_FAMILY_BAND = Object.freeze({ H49: 'state', H50: 'state', + // H60 is a `state` for H34's reason, one field over. H34 reads a claim whose + // SEPARATOR the marker does not accept; this one reads a claim whose BRANCH + // line the directive reader does not accept. Both are a live card that + // contradicts itself — claimed to a human, unclaimed (or claimed elsewhere) + // to the machine — and both are repaired on the board by the claiming seat + // re-writing one comment. ⛔ Not `stall`: this row does not claim the card is + // stopped, and it fires on a card whose dev may be working perfectly well; + // what is broken is the READING, not the dispatch. + H60: 'state', + // H51 is a `state` and ⛔ NOT a `gate`, and the distinction is the gate band's // own criterion rather than the subject's vocabulary. That band exists for the // row that can tell a STRIPPED gate from an ungated card — an absence reading @@ -18114,7 +18316,18 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen // claim posted five minutes ago can already be behind a ruling posted four. const blindClaim = h33ClaimPredatesRuling(issue, commentRows); if (blindClaim) findings.push([issue, 'H33', blindClaim]); - const claim = governingClaim(commentRows); + // H60 (#17149) — the claim's OWN legibility, read before anything is asked + // ABOUT the claim. `claimGovernance` is the two-part reading: `governing` + // is what `governingClaim` has always returned, and `malformed` is the + // newest claim-shaped comment when it parses to zero branches — the state + // that used to be a silent discard. Judged for EVERY `pm:dispatched` card + // whose thread was walked above, ahead of H20's age gate and for H33's + // reason: whether a claim is READABLE has nothing to do with how old it is, + // and the rows below are the ones being misled by it. + const governance = claimGovernance(commentRows); + const claim = governance.governing; + const unparsedClaim = h60ClaimBranchUnparsed(issue, governance); + if (unparsedClaim) findings.push([issue, 'H60', unparsedClaim]); if (!h20NeedsRefProbe(issue, claim)) continue; const states = []; for (const branch of claim.branches) states.push({ branch, ...(await resolveBranchRef(branch)) }); @@ -21281,6 +21494,88 @@ async function selfTest() { const many27 = Array.from({ length: 7 }, (_, i) => ({ branch: `claude/issue-1-b${i}`, state: 'exists', headCommittedAt: '2026-08-22T09:00:00Z' })); t('H27: the branch list is capped at the render budget', dead27Row({ refs: many27 }).includes(`+${7 - H20_BRANCH_LIST_CAP} more`), true); + + // -- H60: a claim comment that parses to ZERO branches (#17149) ------------ + // + // The measured specimens are quoted rather than paraphrased. Card #16322, + // comments 5593513389 (2026-09-08T23:46:51Z) and 5594909614 + // (2026-09-09T02:35:21Z): the branch named INSIDE the `Claim:` sentence, with + // no `Branch:` line anywhere. Cards #16344 and #16712 carried the same shape + // on 2026-09-08T07:10Z, and a second seat's template reproduced it four more + // times inside three seconds on 2026-09-12T02:53Z — which is what says the + // cause is a TEMPLATE and not a run of typos. + const claimRow60 = (id, createdAt, body) => ({ id, created_at: createdAt, body }); + const INLINE_16322_OLD = claimRow60(5593513389, '2026-09-08T23:46:51Z', + 'Claim: session_01ADLdAs2pVcH17h9tZKWMBg — branch `claude/issue-16322-analytics-daterange-closed-vocabulary-drivers`\n\nClause-②: no\n'); + const INLINE_16322_NEW = claimRow60(5594909614, '2026-09-09T02:35:21Z', + 'Claim: session_01ADLdAs2pVcH17h9tZKWMBg — branch `claude/issue-16322-analytics-daterange-closed-vocabulary-drivers`\n\nClause-②: yes\n'); + // 2026-09-12T02:53:06Z on card #16175, whose 2026-09-06 claim DOES parse — + // and names a different branch, so the fallback is not even about the same work. + const INLINE_LIVE = claimRow60(5642984850, '2026-09-12T02:53:06Z', + 'Claim: session_012GKcPZbMoGq7WPzKLfRBTU · claude/issue-16175-staleness-mtime-false-refusal\nClause-②: no\n'); + const PARSES_LIVE = claimRow60(5557414924, '2026-09-06T06:19:06Z', + 'Claim: PM loop\nBranch: `claude/issue-16175-regen-sibling-stale-rules`\nClause-②: no\n'); + + // ★ The two-part reading. `governing` is `governingClaim`'s answer, unmoved. + t('H60: the governing half IS `governingClaim` — one reading, two spellings', claimGovernance(claim8878).governing?.branches.join(','), governingClaim(claim8878)?.branches.join(',')); + t('H60: …and agrees on a thread where NOTHING parses', `${claimGovernance([INLINE_16322_NEW]).governing}`, `${governingClaim([INLINE_16322_NEW])}`); + t('H60: …and on an unreadable input', `${claimGovernance(undefined).governing}`, `${governingClaim(undefined)}`); + + // ★ The state that used to be a silent discard. + t('H60: a branchless NEWEST claim is reported as malformed', claimGovernance([INLINE_16322_OLD, INLINE_16322_NEW]).malformed?.id, 5594909614); + t('H60: …and the reading says what governance fell back to (here: nothing parses at all)', claimGovernance([INLINE_16322_OLD, INLINE_16322_NEW]).malformed?.supersedes, null); + t('H60: …while a thread whose OLDER claim parses records the fallback target', claimGovernance([PARSES_LIVE, INLINE_LIVE]).malformed?.supersedes, '2026-09-06T06:19:06Z'); + t('H60: …and governance did fall back to exactly that older claim', claimGovernance([PARSES_LIVE, INLINE_LIVE]).governing?.branches.join(','), 'claude/issue-16175-regen-sibling-stale-rules'); + t('⛔ H60: a well-formed NEWEST claim leaves nothing malformed, even beside an older branchless one', claimGovernance([INLINE_16322_NEW, claimRow60(2, '2026-09-10T00:00:00Z', claimBody8878)]).malformed, null); + t('⛔ H60: a thread with no claim comment at all is not malformed — that is H2\'s row', claimGovernance([{ id: 1, created_at: '2026-09-10T00:00:00Z', body: 'a triage note' }]).malformed, null); + t('⛔ H60: nor is an unread thread', claimGovernance(null).malformed, null); + t('H60: a comment carrying no readable id still yields the state, with a null id', claimGovernance([claimRow(minsAgo20(10), 'Claim: seat, on claude/issue-1-x')]).malformed?.id, null); + // Recency is the SAME rule the governing half uses — `created_at`, with + // thread order as the fallback for an unparseable stamp (#4690). + t('H60: an unparseable stamp falls back to thread order, as the governing half does', claimGovernance([claimRow60(1, 'not-a-date', claimBody8878), claimRow60(2, 'not-a-date', 'Claim: seat, on claude/issue-1-x')]).malformed?.id, 2); + t('H60: …and in the other order the newest is the one that parses, so nothing is malformed', claimGovernance([claimRow60(1, 'not-a-date', 'Claim: seat, on claude/issue-1-x'), claimRow60(2, 'not-a-date', claimBody8878)]).malformed, null); + + // ★ The row itself, and the facts its sentence must carry. + const gov60 = (rows) => claimGovernance(rows); + const h60Row = (rows, issueOver) => String(h60ClaimBranchUnparsed(issueOver ?? dispatchedCard(), gov60(rows)) ?? ''); + const fellBack60 = h60Row([PARSES_LIVE, INLINE_LIVE]); + const nothing60 = h60Row([INLINE_16322_OLD, INLINE_16322_NEW]); + t('H60: a dispatched card whose newest claim parses to zero branches -> finding', typeof h60ClaimBranchUnparsed(dispatchedCard(), gov60([PARSES_LIVE, INLINE_LIVE])), 'string'); + t('H60: …naming the comment, so a reader can open it', fellBack60.includes('`5642984850`'), true); + t('H60: …and calling the claim MALFORMED rather than absent', fellBack60.includes('MALFORMED claim, ⛔ not an absent one'), true); + t('H60: …and saying governance SILENTLY FELL BACK', fellBack60.includes('SILENTLY FELL BACK'), true); + t('H60: …naming the superseded branch it fell back to', fellBack60.includes('claude/issue-16175-regen-sibling-stale-rules'), true); + t('H60: …and the near-miss that is the sharper half', fellBack60.includes('right for the wrong reason'), true); + t('H60: the other shape says NOTHING governs, rather than reusing the fallback sentence', nothing60.includes('NOTHING governs this card'), true); + t('H60: …and names the two rows that then go blind', nothing60.includes('H20 can never report a missing remote ref') && nothing60.includes('H27 can never report a dead claim'), true); + t('H60: the remedy is a `Branch:` line of its own', fellBack60.includes('`Branch:` line of'), true); + t('H60: …and it is the CLAIMING SEAT\'s act', fellBack60.includes('the CLAIMING SEAT'), true); + t('H60: …stated as the write side, never as a widening of the reader', fellBack60.includes('⛔ The reader is NOT widened'), true); + t('H60: …and the template reading is offered for a whole shift of them', fellBack60.includes('SEAT TEMPLATE'), true); + t('H60: report-only, like every row here', fellBack60.includes('never a label written from this script'), true); + t('H60: not a loud finding', isLoudFinding(h60ClaimBranchUnparsed(dispatchedCard(), gov60([PARSES_LIVE, INLINE_LIVE]))), false); + + // ★ The gates, and the one deliberate asymmetry with H20. + t('H60: a card without `pm:dispatched` is out of scope', h60ClaimBranchUnparsed(dispatchedCard(['pm:queue']), gov60([PARSES_LIVE, INLINE_LIVE])), null); + t('H60: an UNASSIGNED dispatched card is still judged', typeof h60ClaimBranchUnparsed(dispatchedCard(['pm:dispatched'], []), gov60([PARSES_LIVE, INLINE_LIVE])), 'string'); + t('H60: a missing issue does not crash', h60ClaimBranchUnparsed(undefined, gov60([INLINE_LIVE])), null); + t('H60: a caller that read no thread gets no row — unread is not malformed (#4690)', h60ClaimBranchUnparsed(dispatchedCard(), null), null); + t('H60: a well-formed claim yields no row at all', h60ClaimBranchUnparsed(dispatchedCard(), gov60(claim8878)), null); + // ⛔ NO age gate, and the asymmetry with H20's 60 minutes is the point: a + // branchless claim is wrong the instant it is posted. + t('⛔ H60: a claim posted ONE MINUTE ago already fires — no age gate', typeof h60ClaimBranchUnparsed(dispatchedCard(), gov60([claimRow60(9, minsAgo20(1), 'Claim: seat, on claude/issue-1-x')])), 'string'); + t('⛔ H60: …while H20 would still be waiting on exactly that card', h20NeedsRefProbe(dispatchedCard(), gov60([claimRow60(9, minsAgo20(1), claimBody8878)]).governing, NOW_20), false); + t('⛔ H60: and H20 can never speak about a branchless claim at all — it is given no branches', h20DispatchedNoBranchRef(dispatchedCard(), gov60([INLINE_16322_NEW]).governing, [], NOW_20), null); + + // ★ The accept set did NOT move. This is the whole point: the fix is a STATE, + // so the next unrecognised spelling is loud on its first occurrence instead + // of being bought one widening at a time. + t('⛔ H60: the inline spelling still parses to zero branches — the reader is untouched', claimedBranches(INLINE_16322_NEW.body).length, 0); + t('⛔ H60: …and the claim marker still matches it, which is what makes this a STATE and not an absence', CLAIM_COMMENT_MARKER.test(INLINE_16322_NEW.body), true); + t('⛔ H60: #16170\'s bulleted directive still parses, so its card stays clean', h60ClaimBranchUnparsed(dispatchedCard(), gov60([claimRow60(1, '2026-09-01T00:00:00Z', 'Claim: PM loop\n- Branch: `claude/issue-15511-zh-gap-helptext`')])), null); + t('⛔ H60: …and the blockquoted template spelling too', h60ClaimBranchUnparsed(dispatchedCard(), gov60([claimRow60(1, '2026-09-01T00:00:00Z', 'Claim: PM loop\n> Branch: `claude/issue-6752-x`')])), null); + t('H60 band: the row is registered as a `state`, beside H34\'s claim-shape row', familyBand('H60'), familyBand('H34')); + // -- H16: open non-draft PR stuck in a merge conflict (2026-08-19 incident) -- // The single-PR payload shape, since `mergeable_state` is absent from the // listing rows this sweep otherwise runs on.