diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 25c1e9d513..e71fa49596 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -795,6 +795,26 @@ jobs: - name: PM half-state sweeper self-test run: pnpm check:pm-half-states + # Clause-② carrier + declaration checker self-test (#13922 · #12409 · + # #13914). Same family and the same split as the two steps either side: + # the LIVE sweep reads a shared board over the API and is report-only, so + # only the offline self-test runs here — a clause-② desync on somebody + # else's pair is a fact about the board, not about the PR that happens to + # run CI next, and failing that PR would punish the wrong actor. + # + # ⛔ Do not "promote" the live sweep into this step. The script's own + # `--pair ` mode is the per-PR predicate, and it is a PRE-ARM tool a + # seat runs against its own pair — deliberately not a merge gate, because + # a new blocking context over board state is a required-set change and + # the required set is the maintainer's. + # + # The self-test is offline and imports `check-half-states.mjs` for the + # label constant, the `prDeliversCard` delivery relation and the shared + # PREREQUISITE-NOT-MET exit code — one source for each, so this checker + # and H31 cannot come to disagree about which PR delivers which card. + - name: Clause-② carrier checker self-test + run: pnpm check:pm-clause2-carriers + # Governed-merges audit self-test (#9495) — same family, same split as # the half-state sweeper above: the live sweep enumerates the governed # merges since a date/ref for the PM round report — report-only by diff --git a/package.json b/package.json index 3867da66a5..57c3bb0e80 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.mjs", "check:watch-hint-literal": "node scripts/check-watch-hint-literal.mjs --self-test && node scripts/check-watch-hint-literal.mjs", "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", + "check:pm-clause2-carriers": "node scripts/pm/check-clause2-carriers.mjs --self-test", "check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test", "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", "check:publish-smoke-pin": "node scripts/publish-smoke-pack.mjs --self-test", diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs new file mode 100644 index 0000000000..14f4dce5d6 --- /dev/null +++ b/scripts/pm/check-clause2-carriers.mjs @@ -0,0 +1,915 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-clause2-carriers — makes the clause-② enqueue gate's limbs LEGIBLE: + * the dual carrier is asserted by mechanism, and a limb nobody can read is + * reported as a missing reading rather than passing for a decision + * (#13922 · #12409 · #13914). + * + * node scripts/pm/check-clause2-carriers.mjs # live sweep, report-only + * node scripts/pm/check-clause2-carriers.mjs --pair 13910 # ONE PR: a pre-arm predicate + * node scripts/pm/check-clause2-carriers.mjs --json # the sweep, for round reports + * node scripts/pm/check-clause2-carriers.mjs --self-test # offline, no network + * + * ## The gate, and the three limbs it is supposed to stand on + * + * `scripts/pm/ensure-pm-labels.sh` states the gate in its own words: a PR whose + * ACTUAL diff touches the contract surface — or whose CARD'S CLAIM COMMENT + * declares `Clause-②: yes` (the content limb, judged from the card, + * path-independent) — but which was dispatched below the contract-review tier + * waits outside the queue under `needs:contract-review` until the review + * sub-round clears it. So: ① a path limb, ② a declaration limb, ③ the label + * itself, which the maintainer's ruling of 2026-08-22 puts on BOTH carriers + * (「简化一点是否可以直接挂 PR 侧」「两边都挂好」). + * + * ## What already existed, and why this file is not it (measured, 2026-08-31) + * + * ⚠️ The filing card says the dual-carrier rule "has no enforcement at all" and + * that "there is no check anywhere that a `needs:contract-review` on one + * carrier implies it on the other". That is NOT true and the correction is the + * reason this file has the shape it has: `h31ContractReviewCarrierSplit` in + * `scripts/pm/check-half-states.mjs` is exactly that check, both directions, it + * names the offending carrier, and it has a standing caller — + * `.github/workflows/half-state-patrol.yml`, four times a day, last green + * 2026-08-31T13:42:41Z. Building a second carrier comparison as though H31 did + * not exist would have produced two predicates that can disagree about the same + * pair, which is the drift this tree punishes everywhere else. So the split + * comparison here IMPORTS H31's label constant and H31's delivery relation + * rather than restating either. + * + * What H31 cannot do is the part the cards actually measured: + * + * 1. **Agreement on ABSENCE is H31's silent case, by construction.** It fires + * on a SPLIT. A pair carrying the gate on NEITHER carrier is, to H31, + * clean — and "silent on all three limbs at once" is precisely the shape + * #13922 measured live on PR #13910 / card #13476: the author had declared + * clause ② in their own PR body, and no limb was in a state to fire. A + * comparison of two carriers cannot report a gate that is missing from + * both of them. + * 2. **Nothing reads limb ②.** The declaration lives in the card's claim + * comment in a FIXED machine spelling, and this repo had no reader for it + * at all — measured by grep over `scripts/` and `.github/workflows/`: the + * token appears in a label DESCRIPTION and in prose, never in a predicate. + * #13914 measured the consequence on the board: 2 of 3 cards in one review + * round carried the declaration as prose or in the PR body instead, and a + * card with no declaration is today indistinguishable from a card that + * declared `no`. One of those is a decision; the other is a missing + * reading, and they must not look the same. + * 3. **Its delivery channel trims.** H31 is one row family among ~38 in a + * report rendered into one pinned issue body — measured on the 13:42Z run: + * 231 findings, 74 rendered, "157 further row(s) omitted to fit GitHub's + * issue-body limit". A gate row competes for space on equal terms with + * board-hygiene rows, and the anchor then reads clean on the gate. This + * file answers one question and prints only its own rows, so nothing it + * finds can be crowded out by something else's inventory. + * + * ## What this file deliberately does NOT do + * + * **It does not evaluate limb ① (the path limb), and that is a boundary, not a + * gap.** The contract surface is already declared once, in `SUSPECT_TIER_GLOBS` + * in `scripts/pm/dispatch-gates.mjs`, whose own docblock insists the path + * reading is "a HINT, never a verdict" because clause ② is judged from a card's + * CONTENT. A second declaration of that surface here would be a hand copy of a + * register — the exact drift `check:pm-governed-prose` exists to stop one + * family over — and it would cost a changed-file listing per pair for a reading + * that decides nothing this file reports. Every row below is derivable from the + * declaration and the two carriers alone. + * + * **It relaxes no spelling.** `Clause-②: yes` / `Clause-②: no` are the only two + * readings that count, and prose is not one of them. #12409 measured where the + * tolerant direction ends: on its corpus a strict `Contract review: PASS` + * marker matched 5 of 35 removals while an "any PASS token" reading matched 26 + * — a check that can barely fail. Decoration around the line is tolerated the + * way H4 tolerates it (a leading blockquote, a list bullet, backticks, bold), + * because that is markdown a seat writes without meaning anything by it; the + * KEY and the VALUE are literal. A near-miss spelling is reported IN the row so + * the residue is actionable, and it still does not count as a declaration. + * + * **It writes nothing.** No label, ever — hanging or clearing a review gate + * from a checker would be issuing the review verdict, which is 自查放行 and is + * the one thing the whole clause-② chain forbids. Same call H31 makes, for the + * same reason. + * + * ## Exit codes — the refusal to read as clean, in one table + * + * Sweep mode (default, and `--json`): + * 0 swept COMPLETELY. Zero rows and forty rows both exit 0: a board fact is + * not a fact about whichever PR happens to run CI next, so failing a build + * over it would punish the wrong actor (`check-half-states.mjs`'s header + * argues this at length, and this file is the same family). + * 1 bad usage — could not sweep at all. + * 2 swept, but INCOMPLETE: at least one pair whose card, labels or comments + * could not be read. An unread carrier is NOT a bare carrier and an unread + * thread is NOT an absent declaration (#4690): incomplete must never read + * as clean, which on a report-only tool is the sharper risk, because a + * quiet run looks exactly like a healthy board. + * 3 PREREQUISITE NOT MET — the transport could not reach the API at all. + * The constant is imported from `check-half-states.mjs` so the family has + * ONE code for this, and that script's `--probe` is the one classifier; + * this file points at it rather than growing a second one. + * + * `--pair` is a PREDICATE about the pair named on the command line — a fact + * about THAT PR, which is why it may answer adversely where the sweep may not. + * It shares 1/2/3 with the sweep and adds one code of its own: + * 0 the pair's clause-② limbs are legible and its carriers agree. + * 4 they do not. Deliberately NOT 3: a verdict about the PAIR must be + * impossible to confuse with "the environment could not answer", so a + * seat reading `$?` cannot turn a refusal into a clearance. And ⛔ never + * 0-with-a-message: silence is what this whole file exists against. + * + * ## The standing caller + * + * On demand, and the PM round — the `check:pm-governed-merges` posture, whose + * live half has no schedule either. The `check:pm-clause2-carriers` step in + * `lint.yml` runs the SELF-TEST only: the sweep reads a shared board over the + * network and its non-zero exits classify the ENVIRONMENT, which is not a + * verdict about the PR running it. ⛔ Do not "promote" the sweep into that step + * — that would be a new merge gate over board state, which is the thing the + * family has already decided against three times. + */ + +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from '../invoked-as.mjs'; +import { + CLAIM_COMMENT_MARKER, + CONTRACT_REVIEW_LABEL, + EXIT_PREREQUISITE_NOT_MET, + PROXY_FLAG, + governingClaim, + labelNames, + prDeliversCard, + proxyRearmPlan, + resolveSweepRepo, +} from './check-half-states.mjs'; + +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; +const SELF_PATH = fileURLToPath(import.meta.url); +const PROXY_REARM_GUARD = 'OS_CLAUSE2_CARRIERS_PROXY_REARMED'; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 1; +export const EXIT_INCOMPLETE = 2; +/** Re-exported so a caller reads ONE table, not two. Value owned by the sibling. */ +export { EXIT_PREREQUISITE_NOT_MET }; +/** `--pair` only: the pair's limbs are illegible or its carriers disagree. */ +export const EXIT_PAIR_ADVERSE = 4; + +// --------------------------------------------------------------------------- +// Limb ② — the declaration, read in the fixed spelling and nowhere near prose +// --------------------------------------------------------------------------- + +/** + * The two readings that ARE a declaration. Written out rather than derived from + * a pattern so that a reader of this file sees the closed set the way + * `SKILL.md` states it — 恰这两种拼写. + */ +export const CLAUSE2_VALUES = Object.freeze(['yes', 'no']); + +/** + * The key, and the decoration tolerated around it. + * + * Tolerated because a seat writes it without meaning anything by it, and + * reading it as absent is how #10063 sat blocked in silence (the H4 lesson, + * one item over): an optional leading blockquote `>` — SKILL.md's own claim + * template is a blockquote — an optional list bullet, and backtick or bold + * wrapping on the key. ⛔ NOT tolerated: a different key, a different case, a + * full-width colon, or the space-separated prose form `Clause ②:` that two of + * the three measured cards actually wrote. Those are near misses and are + * reported as such below; they are not declarations. + */ +const CLAUSE2_KEY_LINE = /^[ \t]*(?:>[ \t]*)?(?:[-*][ \t]+)?(?:\*\*)?`?Clause-②`?(?:\*\*)?[ \t]*:(.*)$/; + +/** + * A line that MENTIONS the clause without being the machine declaration — used + * only to make a "no reading" row actionable by quoting what was there instead. + * ⛔ It never produces a verdict: widening the predicate to absorb these is the + * tolerant-consumer direction #12409 bans by name. + */ +const CLAUSE2_NEAR_MISS_LINE = /^[ \t]*(?:>[ \t]*)?(?:[-*#][ \t]*)*(?:\*\*)?`?\s*Clause[ \t-]*(?:②|2|two)(?![\w]).*$/i; + +/** + * The value token, read immediately after the colon. + * + * ⚠️ Trailing text after the token is ACCEPTED, and the calibration is not + * mine: #13914's own control case is described as "the PM claim comment on + * #12297 carries `Clause-②: yes` **with reasoning**" and is recorded there as + * the shape that is CORRECT. A reader that rejected a reason on the same line + * would grade the card's own control as a defect — and on the live board + * 2026-08-31 it rejected four real claims whose reasoning was parenthetical. + * So the rule is: the token must be the FIRST thing after the colon, and it + * must be exactly `yes` or `no`. What a seat writes after it is their argument, + * which this file does not read and must not. + * + * ⛔ That is not a relaxation toward prose. `Clause-②: probably not`, + * `Clause-②: YES`, `Clause-②: nope` and an empty value all stay MALFORMED, + * because none of them opens with the token. The boundary is a character + * class, not a judgement. + */ +function readValueToken(raw) { + const rest = String(raw ?? '').replace(/^[ \t]+/, ''); + // Built from CLAUSE2_VALUES so the closed set is declared once: adding a + // third reading would have to be a deliberate edit to that constant. + const token = new RegExp(`^(?:\\*\\*)?(?:\`)?[ \\t]*(${CLAUSE2_VALUES.join('|')})(?![A-Za-z0-9_])`); + const m = token.exec(rest); + return m ? m[1] : null; +} + +/** A quoted line for a finding row — capped, because a claim comment can be long. */ +function quoteLine(line, cap = 160) { + const s = String(line ?? '').trim().replace(/\s+/g, ' '); + return s.length <= cap ? s : `${s.slice(0, cap)}…`; +} + +/** + * Read the declaration limb out of ONE comment or body. + * + * @param {string} text + * @returns {{ kind: 'declared', value: 'yes'|'no', line: string } + * | { kind: 'malformed', value: string, line: string } + * | { kind: 'near-miss', line: string } + * | null} + * + * Four-valued on purpose. `declared` and `malformed` are different facts about + * a line that IS the key; `near-miss` is a fact about a line that is not. Any + * collapse of these into "no" is the defect #13914 filed. + */ +export function readClause2Line(text) { + const lines = String(text ?? '').split(/\r?\n/); + let nearMiss = null; + for (const line of lines) { + const m = CLAUSE2_KEY_LINE.exec(line); + if (m) { + const value = readValueToken(m[1]); + if (value !== null) return { kind: 'declared', value, line: quoteLine(line) }; + return { kind: 'malformed', value: quoteLine(m[1], 60), line: quoteLine(line) }; + } + if (nearMiss === null && CLAUSE2_NEAR_MISS_LINE.test(line)) nearMiss = quoteLine(line); + } + return nearMiss === null ? null : { kind: 'near-miss', line: nearMiss }; +} + +/** + * The declaration limb of one CARD, judged over its comment thread. + * + * The designated carrier is the card's governing CLAIM comment — SKILL.md puts + * the line in the claim shape, and the enqueue gate's content limb is described + * as "judged from the card". So a declaration living anywhere else on the + * thread is a real, separate state (`misplaced`): the seat did the thinking and + * wrote it down, in a place the gate's predicate does not read. Reporting that + * as `absent` would blame the wrong thing, and reporting it as `declared` would + * bless a carrier the gate cannot use. + * + * @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'|'absent'|'unreadable', + * value?: 'yes'|'no', detail?: string }} + */ +export function cardDeclaration(commentRows) { + if (!Array.isArray(commentRows)) return { state: 'unreadable' }; + const claim = governingClaim(commentRows); + 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 + // read, so a claim written without a branch cannot make the declaration + // invisible. + const governing = claim + ? claimRows.filter((row) => (row?.created_at ?? null) === claim.createdAt) + : claimRows; + const pool = governing.length > 0 ? governing : claimRows; + + let malformed = null; + let nearMiss = null; + for (const row of pool) { + const read = readClause2Line(row?.body); + if (read?.kind === 'declared') return { state: 'declared', value: read.value, detail: read.line }; + if (read?.kind === 'malformed' && malformed === null) malformed = read; + if (read?.kind === 'near-miss' && nearMiss === null) nearMiss = read; + } + if (malformed) return { state: 'malformed', detail: malformed.line }; + + // Not in the claim carrier. Is it on the thread at all? That distinction is + // the whole point of this function. + for (const row of commentRows) { + const read = readClause2Line(row?.body); + if (read?.kind === 'declared') return { state: 'misplaced', value: read.value, detail: read.line }; + if (read?.kind === 'malformed' && malformed === null) malformed = read; + if (read?.kind === 'near-miss' && nearMiss === null) nearMiss = read; + } + if (malformed) return { state: 'malformed', detail: malformed.line }; + return { state: 'absent', detail: nearMiss?.line }; +} + +// --------------------------------------------------------------------------- +// The rows +// --------------------------------------------------------------------------- + +const NEVER_WRITES = + 'Report-only: ⛔ never a label written from this script — hanging or clearing a review gate ' + + 'from a checker would be issuing the verdict, which is 自查放行.'; + +/** + * ⚠️ The two directions of the split are NOT ranked here, and the refusal is + * deliberate: the two standing sources rank them OPPOSITELY, which is a real + * disagreement rather than a wording accident. + * + * `check-half-states.mjs` H31 calls the CARD-bare direction "the more + * dangerous half", because to the enqueue path an ungated card is a card that + * was never gated. + * #13922 calls the PR-bare direction the fail-open, because "it is the + * carrier the enqueue gate reads", and on #13910 it was the only limb left. + * + * Both readings are sound about their own consumer, and this file has no + * standing to choose between them — picking one would print an adjudication as + * a derivation. So each direction states its own consequence and neither is + * called worse. WHICH carrier the enqueue gate actually reads is a protocol + * question for the maintainer; until it is answered, a split is a split. + */ +const RANKING_UNSETTLED = + '⚠️ Which direction is the worse one is NOT settled: H31 calls the card-bare direction the ' + + 'more dangerous half (an ungated card enqueues past a live gate) while #13922 calls the ' + + 'PR-bare direction the fail-open (the PR is the carrier the enqueue gate reads). This row ' + + 'ranks neither — a split is a split, and which carrier the gate reads is a protocol question.'; + +const DUAL_CARRIER = + 'The gate is a DUAL carrier (maintainer 2026-08-22, 「两边都挂好」), hung in one stroke and ' + + 'cleared in one stroke. The second carrier is not decoration: it is the only machine-readable ' + + 'evidence that a gate was CLEARED rather than STRIPPED — a legitimate clear leaves two removals ' + + 'seconds apart, a strip leaves one — and 「闸门被剥不是红灯是放行」, so 「被剥」 and 「从未挂过」 ' + + 'are indistinguishable in the evidence without it.'; + +/** + * One pair, as this file reads it. + * + * @typedef {object} Pair + * @property {number} pr + * @property {boolean} [draft] + * @property {number} card + * @property {string[]|null} prLabels null = could not be read + * @property {string[]|null} cardLabels null = could not be read + * @property {{ body?: string, created_at?: string }[]|null} cardComments null = could not be read + */ + +/** Did this carrier's labels come back readable? */ +function gated(labels) { + return Array.isArray(labels) ? labels.includes(CONTRACT_REVIEW_LABEL) : null; +} + +/** + * C1 — the two carriers of one gate disagree. + * + * The same fact H31 reports, anchored on the PAIR rather than on the card, so a + * seat can ask it about the one PR in front of it. Both directions, and the + * dangerous one is named as such: an ungated card with a gated PR reads, to the + * enqueue path, as a card that was never gated. + */ +export function c1CarrierSplit(pair) { + const onCard = gated(pair?.cardLabels); + const onPr = gated(pair?.prLabels); + if (onCard === null || onPr === null) return null; // unreadable — accounted as UNJUDGED, not clean. + if (onCard === onPr) return null; + const draft = pair.draft ? ' (draft)' : ''; + if (onCard && !onPr) { + return ( + `\`${CONTRACT_REVIEW_LABEL}\` on card #${pair.card} while its delivering open PR ` + + `#${pair.pr}${draft} does NOT carry it — the pair was written half way: either the hang ` + + 'never reached the PR side (「PR 一存在即挂」, and the PR exists), or a PASS cleared the PR ' + + 'side and stopped there, leaving the card gated behind a review that has already passed. ' + + 'Consequence on this side: the PR carrier — the one a seat reads before flipping ready — ' + + `is bare, so nothing on the PR itself says a review is outstanding. ${RANKING_UNSETTLED} ` + + `${DUAL_CARRIER} ${NEVER_WRITES}` + ); + } + return ( + `\`${CONTRACT_REVIEW_LABEL}\` on delivering open PR #${pair.pr}${draft} while card ` + + `#${pair.card} does NOT carry it — the same split, written from the other end. ` + + 'Consequence on this side: to the enqueue path an ungated card is a card that was never ' + + 'gated, so the review this PR is still waiting on is invisible to the queue and the card ' + + 'can be enqueued straight past a gate that is demonstrably live one carrier over — and it ' + + 'is equally invisible to the review round\'s own label query over the cards. ' + + `${RANKING_UNSETTLED} ${DUAL_CARRIER} ${NEVER_WRITES}` + ); +} + +/** + * C2 — the declaration limb has no reading. + * + * ⭐ The row this file exists for. A card that declared `no` made a decision; a + * card with nothing to read made none, and until now the two looked the same to + * every consumer. The sentence therefore leads with which of the four states + * this is, and never prescribes a value: ⛔ nobody may fill the line in on + * another seat's behalf, because the declaration IS the judgement. + */ +export function c2DeclarationUnreadable(pair) { + const d = cardDeclaration(pair?.cardComments ?? null); + const head = `card #${pair.card} (delivering open PR #${pair.pr}${pair.draft ? ' (draft)' : ''})`; + const fixed = `the fixed spelling is \`Clause-②: yes\` or \`Clause-②: no\`, exactly those two`; + const notADecision = + 'A missing reading is NOT a declared `no`: one of those is a decision and the other is an ' + + 'absent one, and the enqueue gate\'s content limb — the ONLY limb that can fire for a PR ' + + 'whose diff touches no contract path — has nothing to read. ⛔ Do not fill the line in on ' + + 'the claiming seat\'s behalf; the declaration IS the judgement. ⛔ And do not relax the ' + + 'spelling to accept the prose: a predicate that reads prose is a heuristic, and the measured ' + + 'terminus of that direction is a check that can barely fail.'; + switch (d.state) { + case 'declared': + return null; + case 'unreadable': + return null; // accounted as UNJUDGED by the caller — never silently clean. + case 'misplaced': + return ( + `${head} — the \`Clause-②\` declaration is MISPLACED: the fixed spelling appears on the ` + + `thread (${JSON.stringify(d.detail)}) but NOT in the card's claim comment, which is the ` + + 'carrier the enqueue gate\'s content limb reads. The thinking was done and written down; ' + + `it is in a place the predicate does not look. Move the line into the claim comment — ` + + `${fixed}. ${NEVER_WRITES}` + ); + case 'malformed': + return ( + `${head} — the \`Clause-②\` line is MALFORMED: ${JSON.stringify(d.detail)} carries the key ` + + `but not one of the two values, so there is no reading. ${fixed}. ${notADecision} ` + + `${NEVER_WRITES}` + ); + default: + return ( + `${head} — NO READING on the declaration limb: the card's claim comment carries no ` + + `\`Clause-②:\` line in the fixed spelling` + + (d.detail ? `, and the nearest thing on the thread is ${JSON.stringify(d.detail)}` : '') + + `. ${fixed}. ${notADecision} ${NEVER_WRITES}` + ); + } +} + +/** + * C3 — a declared `yes` with the gate on NEITHER carrier. + * + * The fail-open direction, and the one a carrier COMPARISON is structurally + * blind to: agreement on absence is H31's silent case. Measured live on PR + * #13910 / card #13476 (2026-08-31), where the author had identified their own + * change as clause-② and written it down, and not one of the three mechanisms + * that exist to catch that was in a state to fire. + */ +export function c3DeclaredYesUngated(pair) { + const d = cardDeclaration(pair?.cardComments ?? null); + if (d.state !== 'declared' || d.value !== 'yes') return null; + const onCard = gated(pair?.cardLabels); + const onPr = gated(pair?.prLabels); + if (onCard === null || onPr === null) return null; + if (onCard || onPr) return null; + return ( + `card #${pair.card} declares \`Clause-②: yes\` while NEITHER it nor its delivering open PR ` + + `#${pair.pr}${pair.draft ? ' (draft)' : ''} carries \`${CONTRACT_REVIEW_LABEL}\` — the gate ` + + 'the declaration is supposed to bind is absent from both carriers, so the content limb fired ' + + 'in prose and nothing downstream is holding the door. ⚠️ A comparison of the two carriers ' + + 'cannot see this: agreement on ABSENCE is its silent case, which is why the declaration is ' + + `read here rather than inferred from the labels. ${NEVER_WRITES}` + ); +} + +/** Every row for one pair, in reporting order. */ +export function pairRows(pair) { + const rows = []; + const split = c1CarrierSplit(pair); + if (split) rows.push({ code: 'C1', text: split }); + const decl = c2DeclarationUnreadable(pair); + if (decl) rows.push({ code: 'C2', text: decl }); + const ungated = c3DeclaredYesUngated(pair); + if (ungated) rows.push({ code: 'C3', text: ungated }); + return rows; +} + +/** + * What this pair could NOT be judged on — the #4690 half. + * + * Returned separately from the rows so a caller can never render an incomplete + * pair as a clean one: the sweep counts these and exits 2 while still printing + * whatever it did manage to read. + */ +export function pairUnjudged(pair) { + const gaps = []; + 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`); + if (gaps.length === 0) return null; + return ( + `pair PR #${pair?.pr} / card #${pair?.card} — UNJUDGED: ${gaps.join(', ')} could not be read. ` + + 'An unread carrier is not a bare carrier and an unread thread is not an absent declaration; ' + + 'this pair is missing from the readings above, not clean in them.' + ); +} + +// --------------------------------------------------------------------------- +// Pairing — derived, never recalled +// --------------------------------------------------------------------------- + +/** + * The card each open PR delivers, via `prDeliversCard` — the SAME relation H8 + * and H31 read, imported rather than restated so this file can never disagree + * with them about which PR delivers which card. + * + * Measured on the 2026-08-31 board: every one of the seven pairs the filing + * cards tabulated carries a closing or `Part of` keyword in its PR body naming + * its card, and every branch name carries the fallback too — 7 of 7, both + * channels. Pairing is a derivation here, not an assumption. + * + * @param {object[]} openPrs + * @param {number[]} cardNumbers — the open cards the sweep holds. + */ +export function derivePairs(openPrs, cardNumbers) { + const pairs = []; + for (const pr of openPrs ?? []) { + if (!pr || pr.merged_at) continue; + for (const n of cardNumbers ?? []) { + if (prDeliversCard(pr, String(n))) pairs.push({ pr: pr.number, card: Number(n), draft: Boolean(pr.draft), prRow: pr }); + } + } + return pairs; +} + +// --------------------------------------------------------------------------- +// Live mode +// --------------------------------------------------------------------------- + +async function rest(path) { + const res = await fetch(`${API}${path}`, { + headers: { + accept: 'application/vnd.github+json', + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), + }, + }); + if (!res.ok) { + const err = new Error(`GET ${path} -> HTTP ${res.status}`); + err.status = res.status; + throw err; + } + return res.json(); +} + +/** + * Read one path, or `null` — used where a per-pair gap must not abort the + * sweep. + * + * One retry, because the failure this sweep provokes is a SECONDARY rate limit + * from issuing two reads per pair back to back, and a transient 403 that lands + * as an UNJUDGED row costs a reader the same attention as a real one. Measured + * on the 2026-08-31 board: 3 of 25 pairs came back unjudged on the first pass + * with no retry. ⛔ One retry, not a loop: a sweep that keeps trying is a sweep + * that hides an exhausted quota, and the UNJUDGED row is the correct answer + * once the read has genuinely failed. + */ +async function restOrNull(path) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + return await rest(path); + } catch { + if (attempt === 0) await new Promise((r) => setTimeout(r, 1500)); + } + } + return null; +} + +/** The #13544 re-exec, planned by the sibling and performed here. */ +function rearmThroughProxy(args) { + const plan = proxyRearmPlan({ + env: process.env, + execArgv: process.execArgv, + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + }); + if (plan.hint) { + console.error(`ℹ️ ${plan.reason}. A refusal below may be about the route, not this container.`); + return null; + } + if (!plan.rearm) return null; + if (process.env[PROXY_REARM_GUARD] === '1') return null; + console.error(`ℹ️ re-exec with ${plan.flag}: ${plan.reason}.`); + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') + ? ['--disable-warning=UNDICI-EHPA'] + : []; + const child = spawnSync(process.execPath, [plan.flag, ...quiet, SELF_PATH, ...args], { + stdio: 'inherit', + env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, + }); + if (typeof child.status === 'number') return child.status; + console.error( + `⚠️ could not re-exec with ${plan.flag} (${child.error?.message ?? 'no exit status'}); ` + + 'continuing in-process — every request will bypass the proxy.', + ); + return null; +} + +/** + * Every open PR on the board, paged to exhaustion. + * + * Each page gets the same ONE retry the per-pair reads get, and for a sharper + * reason: a failure here is TOTAL. Measured 2026-08-31 — a transient `HTTP 502` + * on page 1 refused a whole `--pair` run that would otherwise have answered in + * a second. The refusal itself was correct (exit 3, "0 pair(s) had been read", + * ⛔ never a clean board), which is exactly why it should not be spent on a + * blip. ⛔ Still one retry, not a loop: a second failure is the answer. + */ +async function listOpenPulls(repo) { + const out = []; + for (let page = 1; page <= 10; page++) { + const path = `/repos/${repo}/pulls?state=open&per_page=100&page=${page}`; + let batch; + try { + batch = await rest(path); + } catch { + await new Promise((r) => setTimeout(r, 1500)); + batch = await rest(path); // a second failure throws, and the caller refuses. + } + out.push(...batch); + if (batch.length < 100) break; + } + return out; +} + +/** + * Gather the pairs and everything each row needs. + * + * A PR's delivering card is read from the PR body/branch, so the card set is + * whatever those name — no open-issue listing is paged for it. That keeps the + * sweep's cost at one PR listing plus two reads per pair. + */ +async function gather(repo, prFilter = null) { + const pulls = (await listOpenPulls(repo)).filter((pr) => (prFilter ? pr.number === prFilter : true)); + const pairs = []; + for (const pr of pulls) { + const body = String(pr?.body ?? ''); + const named = new Set( + [...body.matchAll(/#(\d{1,7})\b/g)].map((m) => m[1]).concat(String(pr?.head?.ref ?? '').match(/issue-(\d+)/)?.[1] ?? []), + ); + for (const n of named) { + if (!prDeliversCard(pr, n)) continue; + const card = await restOrNull(`/repos/${repo}/issues/${n}`); + const comments = await restOrNull(`/repos/${repo}/issues/${n}/comments?per_page=100`); + pairs.push({ + pr: pr.number, + draft: Boolean(pr.draft), + card: Number(n), + prLabels: Array.isArray(pr.labels) ? labelNames(pr) : null, + cardLabels: card && Array.isArray(card.labels) ? labelNames(card) : null, + cardComments: Array.isArray(comments) ? comments : null, + }); + } + } + return { pulls, pairs }; +} + +function renderSweep({ repo, pulls, pairs }, { json = false } = {}) { + const rows = []; + const unjudged = []; + for (const pair of pairs) { + for (const row of pairRows(pair)) rows.push({ pr: pair.pr, card: pair.card, ...row }); + const gap = pairUnjudged(pair); + if (gap) unjudged.push({ pr: pair.pr, card: pair.card, text: gap }); + } + if (json) { + console.log(JSON.stringify({ repo, openPrs: pulls.length, pairs: pairs.length, rows, unjudged }, null, 2)); + } else { + console.log( + `check-clause2-carriers: ${pairs.length} card/PR pair(s) derived from ${pulls.length} open ` + + `PR(s) in ${repo} — ${rows.length} clause-② finding(s), ${unjudged.length} pair(s) UNJUDGED. ` + + 'Report-only: findings are patrol input, not a gate verdict.', + ); + for (const row of rows) console.log(`- **${row.code}** #${row.pr} / #${row.card} — ${row.text}`); + for (const gap of unjudged) console.log(`- **UNJUDGED** ${gap.text}`); + if (rows.length === 0 && unjudged.length === 0) { + console.log('- (no findings, and every pair was fully read — a clean board, not a short read.)'); + } + } + return unjudged.length > 0 ? EXIT_INCOMPLETE : EXIT_OK; +} + +function renderPair(pair) { + const gap = pairUnjudged(pair); + if (gap) { + console.error(`✗ check-clause2-carriers --pair: ${gap}`); + return EXIT_INCOMPLETE; + } + const rows = pairRows(pair); + if (rows.length === 0) { + console.log( + `✓ check-clause2-carriers: PR #${pair.pr} / card #${pair.card} — the clause-② declaration is ` + + 'readable in the fixed spelling and both carriers agree.', + ); + return EXIT_OK; + } + for (const row of rows) console.error(`✗ ${row.code} — ${row.text}`); + console.error( + `check-clause2-carriers: PR #${pair.pr} / card #${pair.card} is NOT clause-② legible ` + + `(exit ${EXIT_PAIR_ADVERSE}). ⛔ This is a verdict about this pair, not about the environment.`, + ); + return EXIT_PAIR_ADVERSE; +} + +function reportTransportFailure(err, { swept }) { + console.error( + `check-clause2-carriers: PREREQUISITE NOT MET — ${err.message}. ${swept} pair(s) had been read ` + + 'when it failed, so this run is NOT a reading of a clean board. This file does not classify ' + + 'transport: run `node scripts/pm/check-half-states.mjs --probe`, which is the family\'s one ' + + `classifier, and read its verdict. (Exit ${EXIT_PREREQUISITE_NOT_MET} — capture it BEFORE any ` + + 'pipe: `node scripts/pm/check-clause2-carriers.mjs > /tmp/c2.log 2>&1; echo "EXIT=$?"`.)', + ); + return EXIT_PREREQUISITE_NOT_MET; +} + +// --------------------------------------------------------------------------- +// Self-test — offline, and the fixtures are the measured board +// --------------------------------------------------------------------------- + +const CLAIM = (extra) => ({ + created_at: '2026-08-31T17:00:00Z', + body: `Claim: PM loop round R1\nBranch: \`claude/issue-13476-unresolvable-engine-403\`\n${extra ?? ''}`, +}); + +export function selfTest() { + const cases = []; + const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail }); + const says = (s, frag) => typeof s === 'string' && s.includes(frag); + + // -- the declaration reader: the fixed spelling, and everything that is not -- + t('the two fixed spellings read as declarations', readClause2Line('Clause-②: yes')?.value === 'yes' && readClause2Line('Clause-②: no')?.value === 'no'); + t('a blockquoted claim line still reads (SKILL.md writes the claim as a blockquote)', readClause2Line('> Clause-②: no')?.value === 'no'); + t('a bulleted, bolded, backticked line still reads — decoration is markdown, not meaning', readClause2Line('- **`Clause-②`**: **`yes`**')?.value === 'yes'); + t('the PROSE form is NOT a declaration', readClause2Line('## Clause ②: **yes**')?.kind === 'near-miss'); + t('…and it is reported as a near miss so the row can quote it', says(readClause2Line('## Clause ②: **yes**')?.line, 'Clause ②')); + t('an uppercase VALUE is malformed, not a yes', readClause2Line('Clause-②: YES')?.kind === 'malformed'); + t('a prose value on the key line is malformed, not a reading', readClause2Line('Clause-②: probably not')?.kind === 'malformed'); + t('an empty value is malformed', readClause2Line('Clause-②:')?.kind === 'malformed'); + // The #12297 control shape: the token, then the seat's reasoning. #13914 + // records that comment as CORRECT, so a reader that rejected it would grade + // the card's own control as the defect (measured: it rejected four real + // claims on the 2026-08-31 board before this case was written). + t('the token followed by REASONING is a declaration — #13914\'s control shape', readClause2Line('Clause-②: yes — widens the accept set')?.value === 'yes'); + t('…including the bold-wrapped, parenthesised form seats actually write', readClause2Line('**Clause-②: no**(仅移动 import/注释)')?.value === 'no'); + t('⛔ but a word merely STARTING with the token is not the token', readClause2Line('Clause-②: nope')?.kind === 'malformed' && readClause2Line('Clause-②: not applicable')?.kind === 'malformed'); + t('the closed set is read from CLAUSE2_VALUES, so a third reading needs an edit there', CLAUSE2_VALUES.length === 2 && CLAUSE2_VALUES.every((v) => readClause2Line(`Clause-②: ${v}`)?.value === v)); + t('a very long claim line is quoted back CAPPED, so one row cannot swamp the report', (readClause2Line(`Clause-②: maybe ${'x'.repeat(400)}`)?.line ?? '').length < 200); + t('a card that never mentions the clause reads null', readClause2Line('Claim: whatever\nBranch: x') === null); + t('⛔ the reader never invents a value from an adjacent word', readClause2Line('this card is clause 2 yes in substance')?.kind !== 'declared'); + + // -- the card-level declaration: four states, none collapsed into another --- + t('a claim comment carrying the line reads DECLARED', cardDeclaration([CLAIM('Clause-②: no')]).state === 'declared'); + t('…and keeps the value', cardDeclaration([CLAIM('Clause-②: yes')]).value === 'yes'); + t('a thread with no claim comment at all reads ABSENT', cardDeclaration([{ body: 'just a comment', created_at: '2026-08-31T10:00:00Z' }]).state === 'absent'); + t('the #13910 shape — a claim comment with no Clause-② line — reads ABSENT', cardDeclaration([CLAIM('Domain: `domain:engine`')]).state === 'absent'); + t('⛔ ABSENT is not `no`', cardDeclaration([CLAIM('Domain: x')]).state !== 'declared'); + t('the line in a NON-claim comment reads MISPLACED, not absent and not declared', cardDeclaration([CLAIM('Domain: x'), { body: 'Clause-②: yes', created_at: '2026-08-31T11:00:00Z' }]).state === 'misplaced'); + t('a malformed line in the claim comment reads MALFORMED', cardDeclaration([CLAIM('Clause-②: Yes')]).state === 'malformed'); + t('an UNREADABLE thread reads unreadable — never absent (#4690)', cardDeclaration(null).state === 'unreadable'); + t('the most recent claim governs — a re-claimed card is judged on the live claim', cardDeclaration([ + { created_at: '2026-08-30T09:00:00Z', body: 'Claim: old\nBranch: `claude/issue-13476-old`\nClause-②: yes' }, + { created_at: '2026-08-31T09:00:00Z', body: 'Claim: new\nBranch: `claude/issue-13476-new`\nClause-②: no' }, + ]).value === 'no'); + + // -- C1, replaying the 2026-08-31 measured table --------------------------- + const L = CONTRACT_REVIEW_LABEL; + const pair = (o) => ({ pr: 13910, card: 13476, draft: true, prLabels: [], cardLabels: [], cardComments: [CLAIM('Clause-②: no')], ...o }); + // The seven pairs the filing cards tabulated, at the moment a human looked. + const BOARD = [ + { pr: 13870, card: 13576, prLabels: [L], cardLabels: [L] }, // in sync + { pr: 13834, card: 13651, prLabels: [L], cardLabels: [L] }, // in sync + { pr: 13829, card: 13578, prLabels: [L], cardLabels: [] }, // card missing + { pr: 13857, card: 13608, prLabels: [L], cardLabels: [] }, // card missing + { pr: 13864, card: 13657, prLabels: [L], cardLabels: [] }, // card missing + { pr: 13910, card: 13476, prLabels: [], cardLabels: [L] }, // PR missing — the fail-open + { pr: 13923, card: 13762, prLabels: [L], cardLabels: [] }, // card missing (the 7th, minted later) + ].map((p) => pair(p)); + const split = BOARD.map((p) => c1CarrierSplit(p)); + t('replaying the measured board flags exactly the 5 desynced pairs', split.filter(Boolean).length === 5, JSON.stringify(split.map(Boolean))); + t('…and stays silent on the 2 pairs that were in sync', split[0] === null && split[1] === null); + t('…naming the PR on the card-side-missing direction', says(split[2], '#13829') && says(split[2], '#13578')); + t('…and stating the consequence of the PR-bare direction on its own terms', says(split[5], 'nothing on the PR itself says a review is outstanding')); + t('…and the card-bare direction on its own', says(split[2], 'invisible to the queue')); + t('⛔ neither direction is ranked — the two standing sources rank them oppositely', says(split[2], 'ranks neither') && says(split[5], 'ranks neither')); + t('…and the row names both readings so the disagreement is visible, not resolved by fiat', says(split[5], 'H31 calls the card-bare direction') && says(split[5], '#13922 calls the')); + t('a pair gated on neither carrier is NOT a split — agreement on absence is C1\'s silent case', c1CarrierSplit(pair({ prLabels: [], cardLabels: [] })) === null); + t('an unreadable carrier never manufactures a split', c1CarrierSplit(pair({ prLabels: null, cardLabels: [L] })) === null); + t('C1 states the dual-carrier rule it is asserting', says(split[5], '两边都挂好')); + t('C1 says a strip and a never-hung gate are indistinguishable without the second carrier', says(split[5], '被剥')); + t('C1 never prescribes a write from this script', says(split[5], '自查放行')); + + // -- C2, the row this file exists for -------------------------------------- + const absent = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Domain: x')] })); + t('a card with no Clause-② line in its claim comment produces a C2 row', typeof absent === 'string'); + t('…and says NO READING in as many words', says(absent, 'NO READING')); + t('…and states that it is not a declared `no`', says(absent, 'NOT a declared')); + t('…and refuses to have the line filled in on the seat\'s behalf', says(absent, 'Do not fill the line in')); + t('…and forbids relaxing the spelling to prose', says(absent, 'do not relax the')); + t('…and quotes the fixed spelling so the remedy is executable', says(absent, 'Clause-②: yes')); + const nearMiss = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('## Clause ②: **yes**')] })); + t('a prose declaration still produces the C2 row — prose is not a reading', typeof nearMiss === 'string'); + t('…and the row quotes what WAS there, so the residue is actionable', says(nearMiss, 'Clause ②')); + const misplaced = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Domain: x'), { body: 'Clause-②: yes', created_at: '2026-08-31T11:00:00Z' }] })); + t('a declaration outside the claim comment reads MISPLACED, with its own sentence', says(misplaced, 'MISPLACED')); + t('…and says the thinking was done, only in the wrong carrier', says(misplaced, 'not in a place') || says(misplaced, 'place the predicate does not look')); + t('a malformed value produces its own row rather than passing as `no`', says(c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Clause-②: Yes')] })), 'MALFORMED')); + t('a correctly declared card produces NO C2 row', c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Clause-②: no')] })) === null); + t('an unreadable thread produces no C2 row — it is UNJUDGED instead, never clean', c2DeclarationUnreadable(pair({ cardComments: null })) === null); + t('…and the unjudged accounting names the thread that could not be read', says(pairUnjudged(pair({ cardComments: null })), 'comment thread')); + + // -- C3, the direction a carrier comparison cannot see ---------------------- + const ungated = c3DeclaredYesUngated(pair({ prLabels: [], cardLabels: [], cardComments: [CLAIM('Clause-②: yes')] })); + t('a declared `yes` with the gate on neither carrier produces a C3 row', typeof ungated === 'string'); + t('…and says why a carrier comparison cannot see it', says(ungated, 'agreement on ABSENCE')); + t('a declared `no` with no gate anywhere is NOT a C3 row — that is a decision', c3DeclaredYesUngated(pair({ cardComments: [CLAIM('Clause-②: no')] })) === null); + t('a declared `yes` WITH the gate hung is not a C3 row', c3DeclaredYesUngated(pair({ prLabels: [L], cardLabels: [L], cardComments: [CLAIM('Clause-②: yes')] })) === null); + t('an ABSENT declaration is not a C3 row — C2 owns that fact, and no row owns it twice', c3DeclaredYesUngated(pair({ cardComments: [CLAIM('Domain: x')] })) === null); + t('an unreadable carrier never manufactures a C3 row', c3DeclaredYesUngated(pair({ cardLabels: null, cardComments: [CLAIM('Clause-②: yes')] })) === null); + + // -- the #13910 specimen, end to end --------------------------------------- + const specimen = pair({ pr: 13910, card: 13476, prLabels: [], cardLabels: [L], cardComments: [CLAIM('Domain: `domain:engine`')] }); + const specimenRows = pairRows(specimen).map((r) => r.code); + t('the measured #13910 specimen produces BOTH the split row and the no-reading row', specimenRows.includes('C1') && specimenRows.includes('C2'), JSON.stringify(specimenRows)); + t('…and no C3 row, because that card declared nothing at all', !specimenRows.includes('C3')); + + // -- pairing, derived from the same relation H8/H31 read -------------------- + const prRow = (n, card, ref) => ({ number: n, draft: true, labels: [], body: `Fixes #${card}\n`, head: { ref } }); + t('a PR body naming its card pairs with it', derivePairs([prRow(13910, 13476, 'claude/issue-13476-x')], [13476]).length === 1); + t('a PR body naming another card does NOT pair on a stale branch name', derivePairs([prRow(13910, 9999, 'claude/issue-13476-x')], [13476]).length === 0); + t('a branch name is the fallback when the body says nothing', derivePairs([{ number: 1, labels: [], body: 'no refs here', head: { ref: 'claude/issue-13476-x' } }], [13476]).length === 1); + + // -- the exit register is distinct in every direction it must be ----------- + const codes = [EXIT_OK, EXIT_USAGE, EXIT_INCOMPLETE, EXIT_PREREQUISITE_NOT_MET, EXIT_PAIR_ADVERSE]; + t('every exit code is distinct — a verdict can never be read as an environment complaint', new Set(codes).size === codes.length, JSON.stringify(codes)); + t('the adverse-pair code is NOT the prerequisite code', EXIT_PAIR_ADVERSE !== EXIT_PREREQUISITE_NOT_MET); + t('the prerequisite code is the sibling\'s, imported rather than re-picked', EXIT_PREREQUISITE_NOT_MET === 3); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ check-clause2-carriers self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ check-clause2-carriers self-test: ${cases.length} cases pass (fixed-spelling reader, the ` + + 'four declaration states, the 2026-08-31 seven-pair replay, and the exit register).', + ); + return 0; +} + +// --------------------------------------------------------------------------- + +async function main(argv) { + if (argv.includes('--self-test')) return selfTest(); + + const repoRes = resolveSweepRepo(process.env); + if (!repoRes.valid) { + console.error( + `check-clause2-carriers: ${repoRes.source}=${JSON.stringify(repoRes.repo)} is not an ` + + '`owner/name` repository. Refusing to fall back to a different board — a report about the ' + + 'wrong repo reads exactly like a report about this one.', + ); + return EXIT_USAGE; + } + const repo = repoRes.repo; + + const pairFlag = argv.indexOf('--pair'); + let only = null; + if (pairFlag !== -1) { + only = Number(argv[pairFlag + 1]); + if (!Number.isInteger(only) || only <= 0) { + console.error('check-clause2-carriers: --pair needs a PR number. ⛔ Silence is not a clearance.'); + return EXIT_USAGE; + } + } + + let swept = 0; + try { + const { pulls, pairs } = await gather(repo, only); + swept = pairs.length; + if (only !== null) { + if (pairs.length === 0) { + console.error( + `check-clause2-carriers: PR #${only} is not open, or names no card this file can derive ` + + '(no closing/`Part of` keyword in its body and no `issue-N` branch name). ⛔ Not a ' + + 'clearance — the pair could not be formed, so nothing about it was judged.', + ); + return EXIT_INCOMPLETE; + } + let worst = EXIT_OK; + for (const p of pairs) { + const code = renderPair(p); + if (code !== EXIT_OK) worst = code === EXIT_INCOMPLETE && worst === EXIT_PAIR_ADVERSE ? worst : code; + } + return worst; + } + return renderSweep({ repo, pulls, pairs }, { json: argv.includes('--json') }); + } catch (err) { + return reportTransportFailure(err, { swept }); + } +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + process.exit(selfTest()); + } else { + const rearmed = rearmThroughProxy(process.argv.slice(2)); + if (rearmed !== null) process.exit(rearmed); + main(process.argv.slice(2)).then((code) => process.exit(code)); + } +} diff --git a/scripts/pm/ensure-pm-labels.sh b/scripts/pm/ensure-pm-labels.sh index 2bf6254769..2cf403106b 100644 --- a/scripts/pm/ensure-pm-labels.sh +++ b/scripts/pm/ensure-pm-labels.sh @@ -258,6 +258,20 @@ done # object: measured 2026-08-20, that object was grey with an EMPTY description, # so the claim this comment used to make had never been true — nothing had ever # checked it. `--reconcile` is what makes it true. +# +# ⚠️ WHAT THIS FILE DOES AND DOES NOT DO, because a card read it the other way +# (#13922): everything here reconciles the label OBJECT — its colour and its +# description. It says nothing about whether the label is HUNG correctly on any +# pair, and it never did. Two other files answer that, and neither is this one: +# * `scripts/pm/check-half-states.mjs` H31 — the two carriers of one gate +# compared across the whole board, on the half-state patrol's schedule. +# * `scripts/pm/check-clause2-carriers.mjs` — the same comparison anchored on +# ONE pair (`--pair `, a pre-arm predicate), plus the two readings H31 +# cannot make: the DECLARATION limb quoted three paragraphs up has no other +# reader in this repo, and a gate missing from BOTH carriers is a carrier +# comparison's silent case. +# ⛔ None of the three writes the label. Hanging or clearing a review gate from +# a script would be issuing the review verdict, which is 自查放行. gh label create needs:contract-review -R objectstack-ai/objectstack -c d93f0b -d "Clause-② enqueue gate: dispatched below contract-review tier — blocked until the review clears it" 2>/dev/null || true # Routing labels exist only on the main backlog repo, and mark SEAM cards only