diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml
new file mode 100644
index 000000000..ae3e427aa
--- /dev/null
+++ b/.github/workflows/half-state-patrol.yml
@@ -0,0 +1,364 @@
+name: Half-State Patrol
+
+# The standing caller for `scripts/pm/check-half-states.mjs`.
+#
+# ## ⚠️ THIS IS THE ADOPTED COPY (objectui#5791) — upstream is objectstack
+#
+# Ported from `objectstack-ai/objectstack` (PR #11294), NOT authored here. The
+# prose below is upstream's and its issue numbers (#9844, #4449, #9575, #4690,
+# #7412, #11217 …) are OBJECTSTACK numbers — do not read them as objectui cards.
+#
+# Every divergence from upstream is listed here, once, so a future re-sync knows
+# exactly what it must not clobber:
+#
+# 1. `PM_SWEEP_CLOSED_WINDOW_PAGES: 0` on the sweep step — H22's closed-card
+# reader is OFF in this install. Measured 2026-08-24: 815 closed cards here
+# carry `pm:dispatched`, and ~347 of the 400 issues in upstream's window
+# carry some `pm:*` residue label (~87%, against the 26% upstream measured).
+# Stripping `pm:*` on close was never this lane's practice, so H22 here
+# reports the CONVENTION, not a defect — ~347 rows that would consume the
+# whole body budget and trim every other predicate out of the anchor. The
+# sweeper says "UNREAD, not clean" in its summary rather than reporting 0.
+# ⛔ Re-enabling is a BACKFILL decision (strip the residue first), never a
+# quiet default flip. The script's default is still upstream's 4.
+# 2. `scripts/invoked-as.mjs` is in the `paths:` filter below — the sweeper
+# imports it, and it was ported alongside.
+# 3. `scripts/pm/check-half-states.mjs` carries `DEFAULT_SWEEP_REPO =
+# 'objectstack-ai/objectui'` so a bare terminal run here sweeps THIS board.
+#
+# Nothing else diverges; the predicates are untouched.
+#
+# ## Why a workflow, and not "a seat should run it"
+#
+# The sweeper carries thirteen predicates over the dispatch protocol's
+# label/assignee/PR invariants, and for most of its life its documented consumer
+# was "a PM seat's patrol round" — which is to say, nobody's calendar. A shift
+# covering two lanes declared a queue empty from memory while eight malformed
+# claims (H2) and an unenumerated backlog sat on the board. Not one predicate had
+# fired. A healing mechanism with no scheduled caller heals only in the
+# counterfactual, and an alarm added to a script nobody runs is still silence.
+#
+# "Some seat should run it" also kept not happening for a MEASURED reason, not a
+# discipline one: the live sweep cannot run inside a PM session container at all
+# (#7412 class 1 — api.github.com refuses that egress in both directions, with and
+# without a token). The fix therefore had to move the caller somewhere the
+# transport prerequisite is actually met. A GitHub Actions runner with the
+# workflow's own `GITHUB_TOKEN` is that place — #7412 class 2, the triage Routine
+# container, is the same shape and measured reachable with 15,000 core quota.
+#
+# ## What lands where
+#
+# One pinned ANCHOR ISSUE, rewritten in place every run (`ANCHOR_ISSUE` below).
+# Never a comment per run: the board is one board, a per-run comment stream would
+# be a second tracker that nobody prunes, and GitHub's edit history is already the
+# archive this needs. The body is owned end-to-end by the generator, so no run can
+# leave half of it stale.
+#
+# The `Swept` timestamp in that body is the patrol's heartbeat and is deliberately
+# refreshed even when the findings are unchanged: a timestamp that stops advancing
+# is how a reader learns the standing caller died. That is the whole defect class
+# this workflow exists to close, so the run must not "optimize away" the no-op
+# edit that proves it is alive.
+#
+# ## Report-only, and the one thing that is NOT report-only
+#
+# Findings never fail anything. A completed sweep exits 0 whether it found 0 or 40
+# half-states, this job never writes a label, never closes a card, never fixes a
+# state, and no H-predicate is a blocking gate — the script's own header argues
+# that at length (a half-state is a fact about a live shared board, not about
+# whichever PR happens to run CI next).
+#
+# The job DOES fail when the sweep could not run, or when its report could not be
+# delivered. That is not a gate on the board; it is the patrol reporting its own
+# death. A workflow that quietly does nothing because a credential lapsed is the
+# exact shape this repo keeps having to fix (#4449, #9575), and it is doubly
+# unacceptable here: silent non-delivery would leave a stale anchor body that
+# reads exactly like a clean board — the #4690 failure ("could not read the input"
+# must never look like "input is clean") with a timestamp on it. Failing costs
+# nobody a PR: this workflow gates no branch and blocks no queue.
+#
+# ## Adopting this in a sibling repo (#11217)
+#
+# This file is REPO-AGNOSTIC and is meant to be copied verbatim. It was not:
+# installed in objectstack alone, it left 37 of the fleet's 59 open `pm:blocked`
+# cards outside any patrol, and a hand-run of H19's predicate over objectui's
+# blocked inventory found 7 blocks whose blocker had already closed — 58% of
+# that repo's machine-readable blocks were false, one of them for a week. The
+# same predicate had been catching objectstack's four every hour. The difference
+# was never discipline; it was that one repo had a caller.
+#
+# To adopt, in the sibling repo:
+#
+# 1. copy `scripts/pm/check-half-states.mjs` and this file, unchanged;
+# 2. open one `tracking`-labeled anchor issue there and set the repository
+# VARIABLE `HALF_STATE_ANCHOR_ISSUE` to its number
+# (Settings → Secrets and variables → Actions → Variables).
+#
+# That is the whole install. The swept repo needs no configuration at all: it is
+# `github.repository`, so the copy reads the board it lives in — a hardcoded
+# default was how a copied file could have swept THIS repo and written the
+# findings into a sibling's anchor, a fully green report about the wrong board.
+#
+# ⛔ Each install uses its OWN `secrets.GITHUB_TOKEN` and reads its own repo. No
+# cross-repo credential, no matrix over repos, no PAT: that route was refused at
+# grading (it buys no coverage a per-repo install lacks and raises the
+# credential floor for every repo at once). The accepted consequence is that a
+# cross-repo `Blocked-by:` target stays UNJUDGED in each install — H19 says so
+# in its own row rather than reading it as a healthy block.
+
+on:
+ schedule:
+ # Four times a day, six hours apart, at :37 past the hour.
+ #
+ # The minute is offset ON PURPOSE. The triage Routine that heals these same
+ # states fires hourly near the top of the hour, and a patrol landing at the
+ # same minute would keep reading the board mid-heal — reporting half-states
+ # the healer is in the middle of pairing, i.e. manufacturing findings that
+ # clear themselves. :37 puts this sweep in the quiet part of the healer's
+ # cycle in both directions. Four runs/day rather than hourly: H13's own
+ # threshold is 2h and the incident it comes from sat ~26h, so six-hourly
+ # detection is two orders of magnitude better than the status quo (never)
+ # while staying cheap on the core quota this sweep shares with the loop's
+ # hot path.
+ - cron: '37 1,7,13,19 * * *'
+ workflow_dispatch: {}
+ # Changes to the patrol itself get exercised before they merge — the same
+ # posture as engine-split-metric.yml. On a pull_request run the sweep still
+ # executes (that is the point: the transport, the flags and the rendering are
+ # proven on a real runner), but the anchor write is skipped and the rendered
+ # body goes to the run's step summary instead. A PR must never rewrite the
+ # board's pinned view.
+ pull_request:
+ paths:
+ - 'scripts/pm/check-half-states.mjs'
+ # objectui#5791: the sweeper imports this helper, so a change to it can
+ # break the patrol without touching either file above.
+ - 'scripts/invoked-as.mjs'
+ - '.github/workflows/half-state-patrol.yml'
+
+# Least privilege: this job reads the repo and writes exactly one issue BODY.
+# `issues: write` is the narrowest scope GitHub offers for that edit; the job
+# never uses it for labels, comments, assignees or state, and the sweeper it
+# calls is read-only against the API by construction.
+permissions:
+ contents: read
+ issues: write
+
+# One patrol at a time. A scheduled run overlapping a manual dispatch would have
+# two runs racing to rewrite the same body, and the loser's findings would vanish
+# with no trace but an edit-history entry.
+concurrency:
+ group: half-state-patrol
+ cancel-in-progress: false
+
+env:
+ # The pinned anchor issue whose body this workflow owns — the ONE per-repo
+ # input this file takes (#11217).
+ #
+ # Resolution: the repository variable `HALF_STATE_ANCHOR_ISSUE` if set, else
+ # this repo's own pinned number, else EMPTY — and empty makes the job refuse
+ # to write rather than guess (see the "Resolve the anchor" step). The literal
+ # is guarded by the repository name on purpose: an unguarded fallback is what
+ # would let a verbatim copy in objectui rewrite ITS #9857 — some unrelated
+ # card — with this board's findings, silently and four times a day. A number
+ # is only ever meaningful in the repo it was minted in.
+ #
+ # TO ROTATE (here): open a new `tracking`-labeled issue, put its number below,
+ # and note the handover in the OLD issue's body before closing it (its edit
+ # history is the archive and does not travel).
+ # TO ADOPT (a sibling repo): change NOTHING here — set the repository variable.
+ #
+ # The anchor deliberately carries `tracking` and NO `domain:*` label: `tracking`
+ # is in the sweeper's own H13_EXEMPT_LABELS, so the anchor can never appear as a
+ # finding in the sweep it hosts.
+ #
+ # ⚠️ Folded scalar, and every continuation line sits at the SAME indent on
+ # purpose: a more-indented line in a `>-` block keeps its newline literally
+ # (measured on this very value), which would hand the expression parser a
+ # multi-line string instead of one expression.
+ ANCHOR_ISSUE: >-
+ ${{ vars.HALF_STATE_ANCHOR_ISSUE
+ || (github.repository == 'objectstack-ai/objectstack' && '9857')
+ || '' }}
+
+jobs:
+ patrol:
+ name: Live half-state sweep
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: '22'
+
+ # No `pnpm install`: the sweeper imports nothing but `node:process` and
+ # global `fetch`. Installing the workspace here would buy nothing and would
+ # give a scheduled patrol a lockfile it could fail on.
+ - name: Run the live sweep
+ id: sweep
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # WHICH board this run reads: the repo this workflow is installed in,
+ # always. The sweeper would resolve the same answer on its own from
+ # the runner's `GITHUB_REPOSITORY` (`resolveSweepRepo`), and it is
+ # passed explicitly anyway so the wiring is visible to a reader of the
+ # workflow — the two agree by construction and a copy of this file
+ # cannot end up sweeping the repo it was copied FROM.
+ PM_SWEEP_REPO: ${{ github.repository }}
+ # objectui#5791 — H22's closed-card reader is OFF here. See divergence
+ # (1) in this file's header for the measurement that decided it, and
+ # `resolveClosedWindowPages` in the sweeper for what 0 means. The
+ # rendered body reports the surface as UNREAD, never as clean.
+ PM_SWEEP_CLOSED_WINDOW_PAGES: '0'
+ PROVENANCE: >-
+ run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ · commit `${{ github.sha }}` · trigger `${{ github.event_name }}`
+ run: |
+ set +e
+ node scripts/pm/check-half-states.mjs \
+ --format=markdown \
+ --provenance="$PROVENANCE" \
+ > "$RUNNER_TEMP/report.md" 2> "$RUNNER_TEMP/report.err"
+ code=$?
+ set -e
+ # Captured with NO pipe in between. `cmd | tail` would report the
+ # PIPE's status — `tail` essentially never fails, so a green and a red
+ # sweep both read as 0, and the script's own header calls this trap out
+ # by name (its exit codes are 0 / 2 / 3 and the split is the point).
+ echo "exit_code=$code" >> "$GITHUB_OUTPUT"
+ echo "check-half-states exited $code"
+ cat "$RUNNER_TEMP/report.err" >&2 || true
+
+ - name: Resolve the anchor issue
+ # An install with no anchor configured has nowhere to land its report,
+ # and the ONLY safe behaviour is to say so loudly (#11217). The two
+ # alternatives are both the failure this file exists to prevent:
+ # guessing a number would rewrite an unrelated card in this repo, and
+ # skipping the write quietly would leave a patrol that runs, finds, and
+ # tells nobody — indistinguishable from a clean board.
+ #
+ # Placed AFTER the sweep so the run summary still carries the rendered
+ # findings (the same "land the truth, then raise the alarm" order the
+ # final step keeps), and skipped on a pull_request run, which never
+ # writes an anchor at all.
+ if: github.event_name != 'pull_request'
+ run: |
+ if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ]; then
+ echo "::error::No anchor issue configured for ${{ github.repository }}. The sweep RAN (see the run summary) but has nowhere to land. Open a \`tracking\`-labeled anchor issue in this repo and set the repository variable HALF_STATE_ANCHOR_ISSUE to its number (Settings -> Secrets and variables -> Actions -> Variables)."
+ exit 1
+ fi
+ case "$ANCHOR_ISSUE" in
+ *[!0-9]*|'') echo "::error::HALF_STATE_ANCHOR_ISSUE is '$ANCHOR_ISSUE', which is not an issue number."; exit 1 ;;
+ esac
+ echo "anchor: #$ANCHOR_ISSUE in ${{ github.repository }}"
+
+ - name: Update the pinned anchor issue
+ # A pull_request run proves the sweep; it must not touch the board.
+ if: github.event_name != 'pull_request'
+ uses: actions/github-script@v9
+ env:
+ SWEEP_EXIT: ${{ steps.sweep.outputs.exit_code }}
+ with:
+ # Delivery is retried, never assumed (#9575): this single PATCH is the
+ # entire product of the run, and a transient answer from the issues
+ # endpoint would otherwise discard a completed sweep.
+ retries: 3
+ script: |
+ const fs = require('fs');
+ const path = require('path');
+ const exitCode = Number(process.env.SWEEP_EXIT);
+ const anchor = Number(process.env.ANCHOR_ISSUE);
+ const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
+ const read = (name) => {
+ try { return fs.readFileSync(path.join(process.env.RUNNER_TEMP, name), 'utf8'); }
+ catch { return ''; }
+ };
+
+ // The composition split, deliberately: a COMPLETED sweep renders its
+ // own body (in the script, where --self-test pins every property of
+ // it). Only the did-not-run body is composed here, because saying
+ // "my callee failed" is the caller's job and the script's classified
+ // output is already the authored explanation — this wraps it, it
+ // does not re-word it.
+ let body;
+ if (exitCode === 0) {
+ body = read('report.md');
+ if (!body.trim()) {
+ throw new Error('the sweep exited 0 but produced an empty report — refusing to blank the anchor');
+ }
+ } else {
+ const classified = (read('report.err') || read('report.md') || '(no output captured)').trim();
+ const kind = exitCode === 3
+ ? 'PREREQUISITE NOT MET — the runner could not reach the board'
+ : 'SWEEP FAILED — an unclassified failure';
+ body = [
+ 'os-half-state-sweep — machine-findable marker for this generated view.',
+ '',
+ `# ⛔ THE SWEEP DID NOT RUN (exit ${exitCode})`,
+ '',
+ `_Attempted ${new Date().toISOString()} · [run log](${runUrl}) · ${kind}._`,
+ '',
+ 'Nothing below is a finding. **No issue was judged**, so this body says nothing about whether',
+ 'the board carries half-states — it is not a clean board and it is not a dirty one, it is no',
+ 'reading at all. A sweep that could not run must never read as a clean board.',
+ '',
+ 'The standing patrol is DOWN until this is fixed; the previous run\'s findings are in this',
+ 'issue\'s edit history. The sweeper\'s own classified output:',
+ '',
+ '```',
+ classified,
+ '```',
+ ].join('\n');
+ }
+
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: anchor,
+ body,
+ });
+ core.info(`anchor #${anchor} updated (${body.length} chars, sweep exit ${exitCode})`);
+
+ - name: Publish the rendered body to the run summary
+ # Always: on a PR this IS the delivery, and on a scheduled run it makes
+ # the run log self-contained when someone opens it after an alert.
+ if: always()
+ run: |
+ {
+ echo "### Half-state patrol — sweep exit ${{ steps.sweep.outputs.exit_code }}"
+ echo
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ echo "_Anchor write skipped: a pull_request run proves the sweep without touching the board._"
+ echo
+ fi
+ echo 'Rendered anchor body
'
+ echo
+ cat "$RUNNER_TEMP/report.md" 2>/dev/null || echo '(no report produced)'
+ echo
+ echo ' '
+ echo
+ echo 'stderr
'
+ echo
+ echo '```'
+ cat "$RUNNER_TEMP/report.err" 2>/dev/null || true
+ echo '```'
+ echo
+ echo ' '
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Fail the run if the sweep could not run
+ # LAST, on purpose: the anchor is updated with the did-not-run report
+ # BEFORE the job goes red. Land the truth, then raise the alarm — a run
+ # that failed early would leave the previous body in place with its old
+ # timestamp, which is precisely the stale-reads-as-clean shape above.
+ #
+ # Findings are NOT a failure condition and never appear here: exit 0 with
+ # 40 half-states is a successful patrol.
+ if: steps.sweep.outputs.exit_code != '0'
+ run: |
+ echo "::error::check-half-states exited ${{ steps.sweep.outputs.exit_code }} — the standing patrol did not read the board. See the anchor issue and this run's stderr."
+ exit 1
diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md
index 83c80bd5c..d02a1792d 100644
--- a/content/docs/guide/ci-cd-pipeline.md
+++ b/content/docs/guide/ci-cd-pipeline.md
@@ -44,6 +44,7 @@ one has its own section below.
| `check-links.yml` | Check Links | Weekly cron `17 4 * * 0`; manual | n/a — reports, never gates |
| `published-dist-gate.yml` | Published Dist Tooling Scan | Nightly cron `41 3 * * *`; push to `main` touching the gate; manual | No — the blocking copy runs on the publish path, not here |
| `node-esm-load-gate.yml` | Node ESM Load Scan | Nightly cron `17 4 * * *`; push to `main` touching the gate; manual | No — the per-PR half is `pnpm check:esm-specifiers` in **Type Check** |
+| `half-state-patrol.yml` | Half-State Patrol | 6-hourly cron `37 1,7,13,19 * * *`; manual; PR touching the sweeper or the workflow | No — **report-only**; it fails only when the sweep could not run |
The path filters explain most "why did nothing run on my PR?" questions:
@@ -1007,6 +1008,45 @@ neither is a subset of the other: `critical`, `bug` and `enhancement` exempt iss
— `pinned`, `security`, `critical`, `in-progress` — which was wrong in both directions for
both resources ([#3724](https://github.com/objectstack-ai/objectui/issues/3724)).
+### Half-State Patrol (`half-state-patrol.yml`)
+
+**Trigger:** Four times a day at `:37` past the hour (cron `37 1,7,13,19 * * *`), manual dispatch,
+or a pull request touching `scripts/pm/check-half-states.mjs`, `scripts/invoked-as.mjs` or the
+workflow itself.
+
+Runs `scripts/pm/check-half-states.mjs` against **this** repository's issue board and rewrites one
+pinned anchor issue's body with what it found. The sweeper carries a family of predicates over the
+dispatch protocol's label/assignee/PR invariants — a `pm:dispatched` card with no assignee, a card
+carrying both `pm:queue` and `pm:dispatched`, a merged PR whose card still says it is in flight, a
+`Blocked-by:` block whose blocker already closed, and so on.
+
+**Report-only, and this is a rule rather than a description.** The job never writes a label, never
+closes a card, never fixes a state, and no finding fails anything: a completed sweep exits 0 whether
+it found 0 half-states or 40. Its one write is the anchor issue's body, and `permissions:` grants
+nothing beyond `contents: read` + `issues: write`. A pull-request run proves the sweep on a real
+runner but skips the anchor write entirely, publishing the rendered body to the run summary instead.
+
+The run *does* go red when the sweep could not run or its report could not be delivered — that is
+the patrol reporting its own death, not a gate on the board. A workflow that quietly does nothing
+because a credential lapsed would leave a stale anchor body that reads exactly like a clean board.
+For the same reason the `Swept` timestamp is refreshed even when the findings are unchanged: a
+timestamp that stops advancing is how a reader learns the standing caller died.
+
+**One manual setup step.** The anchor issue is named by the repository *variable*
+`HALF_STATE_ANCHOR_ISSUE` (Settings → Secrets and variables → Actions → Variables). Until it is set
+the job fails loudly *after* sweeping, with the findings preserved in the run summary — it will not
+guess an issue number and rewrite an unrelated card.
+
+**Ported from objectstack, with the divergences listed in the workflow header.** The pair
+(`scripts/pm/check-half-states.mjs` + this workflow) is adopted from `objectstack-ai/objectstack`
+and is meant to stay re-syncable, so this install keeps its differences in one place. The
+behavioural one: the sweeper's closed-card reader (`pm:*` labels left on cards that already closed)
+is switched **off** here via `PM_SWEEP_CLOSED_WINDOW_PAGES: '0'`. Stripping `pm:*` on close was
+never this repo's practice — 815 closed cards carry `pm:dispatched`, ~87% of the reader's window —
+so that predicate would report the convention rather than a defect and bury every other finding.
+The rendered summary says that surface is **UNREAD**, never that it is clean
+([#5791](https://github.com/objectstack-ai/objectui/issues/5791)).
+
### Dependabot Auto-Merge (`dependabot-auto-merge.yml`)
**Trigger:** PRs on `main`/`develop` authored by `dependabot[bot]`.
diff --git a/scripts/__tests__/check-half-states.test.ts b/scripts/__tests__/check-half-states.test.ts
new file mode 100644
index 000000000..0b3da5cd2
--- /dev/null
+++ b/scripts/__tests__/check-half-states.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+// Plain-JS CI helper; types are INFERRED from the .mjs by `tsconfig.scripts.json`
+// (`allowJs`), so no `@ts-expect-error` here. See objectui#3494.
+import {
+ CLOSED_ISSUE_WINDOW_PAGES,
+ DEFAULT_SWEEP_REPO,
+ h22ClosedCardPmResidue,
+ resolveClosedWindowPages,
+ resolveSweepRepo,
+ summaryLine,
+} from '../pm/check-half-states.mjs';
+
+/**
+ * objectui#5791 — the half-state patrol, PORTED from objectstack (PR #11294).
+ *
+ * ## What this file is for, and what it deliberately is not
+ *
+ * The sweeper carries its own ~1,077-case `--self-test`, and that suite is the
+ * authority on the twenty-odd predicates. Re-asserting predicates here would
+ * fork the pin: two copies drifting apart, one of them not the one upstream
+ * maintains. So the first test below simply RUNS that suite in CI — the point
+ * being that a port whose self-test nobody executes is the #4690 shape again
+ * (a check that reads as enforcement while nothing invokes it).
+ *
+ * Everything after it pins the ADAPTATIONS instead — the handful of places this
+ * install diverges from upstream. Those are exactly the lines a future verbatim
+ * re-sync from objectstack would clobber silently, and each one is load-bearing:
+ * dropping any of them does not break the patrol loudly, it makes the patrol
+ * report something false quietly.
+ */
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+const sweeperPath = path.join(repoRoot, 'scripts/pm/check-half-states.mjs');
+const workflowPath = path.join(repoRoot, '.github/workflows/half-state-patrol.yml');
+const workflow = fs.readFileSync(workflowPath, 'utf8');
+
+describe('check-half-states — the ported sweeper', () => {
+ it('passes its own self-test', () => {
+ // Exit status is the assertion; a non-zero exit throws out of execFileSync.
+ const out = execFileSync(process.execPath, [sweeperPath, '--self-test'], {
+ encoding: 'utf8',
+ cwd: repoRoot,
+ });
+ expect(out).toMatch(/✓ check-half-states self-test: \d+ cases pass\./);
+ });
+
+ it('lives at the path the workflow invokes', () => {
+ // Path parity with objectstack is what keeps a re-sync a straight copy. If
+ // the file moves, the workflow's `node scripts/pm/...` line and its
+ // `paths:` filter both rot — and the patrol would simply stop running.
+ expect(fs.existsSync(sweeperPath)).toBe(true);
+ expect(workflow).toContain('node scripts/pm/check-half-states.mjs');
+ });
+
+ it('ports the helper it imports, rather than the import alone', () => {
+ // `scripts/invoked-as.mjs` is objectstack-resident and had no objectui
+ // equivalent; without it the sweeper cannot even be loaded.
+ expect(fs.existsSync(path.join(repoRoot, 'scripts/invoked-as.mjs'))).toBe(true);
+ });
+});
+
+describe('check-half-states — sweeps THIS board (objectui#5791 adaptation)', () => {
+ it('defaults to this repository, not the repository it was ported from', () => {
+ // Upstream's constant is `objectstack-ai/objectstack`. Carried over
+ // unchanged, a bare `node scripts/pm/check-half-states.mjs` in this repo
+ // would render a fully green report about a DIFFERENT board — the precise
+ // "report about the wrong repo reads exactly like a report about this one"
+ // failure the parameterisation exists to prevent.
+ expect(DEFAULT_SWEEP_REPO).toBe('objectstack-ai/objectui');
+ expect(resolveSweepRepo({}).repo).toBe('objectstack-ai/objectui');
+ });
+
+ it('still lets an explicit target win, so the workflow wiring is unchanged', () => {
+ expect(resolveSweepRepo({ GITHUB_REPOSITORY: 'objectstack-ai/cloud' }).repo).toBe('objectstack-ai/cloud');
+ expect(workflow).toContain('PM_SWEEP_REPO: ${{ github.repository }}');
+ });
+});
+
+describe('check-half-states — H22 closed-card reader is OFF here (objectui#5791)', () => {
+ /**
+ * The measurement that decided this, re-measured 2026-08-24 for the port:
+ * 815 closed cards in objectui carry `pm:dispatched`, and ~347 of the 400
+ * issues inside upstream's window carry some `pm:*` residue label (~87%,
+ * against the 26% upstream measured on its own board). Stripping `pm:*` on
+ * close was never this lane's practice, so H22 here reports the CONVENTION
+ * rather than a defect — ~347 rows that would exhaust the body budget and
+ * trim every other predicate out of the anchor on day one.
+ */
+ it('keeps upstream\'s default in the script, so the port stays a straight copy', () => {
+ // The divergence lives in the WORKFLOW, not in the script's default. This
+ // is what lets the predicate file be re-synced verbatim.
+ expect(CLOSED_ISSUE_WINDOW_PAGES).toBe(4);
+ expect(resolveClosedWindowPages({}).pages).toBe(4);
+ expect(resolveClosedWindowPages({}).source).toBe('default');
+ });
+
+ it('is switched off by the workflow, and visibly so', () => {
+ expect(workflow).toMatch(/PM_SWEEP_CLOSED_WINDOW_PAGES: '0'/);
+ expect(resolveClosedWindowPages({ PM_SWEEP_CLOSED_WINDOW_PAGES: '0' }).pages).toBe(0);
+ expect(resolveClosedWindowPages({ PM_SWEEP_CLOSED_WINDOW_PAGES: '0' }).valid).toBe(true);
+ });
+
+ it('refuses a malformed page count instead of silently defaulting', () => {
+ // Silently falling back to 4 would re-open the reader this install shut,
+ // and the anchor would carry the residue as though someone chose that.
+ for (const raw of ['O', '-1', '1.5', 'four']) {
+ expect(resolveClosedWindowPages({ PM_SWEEP_CLOSED_WINDOW_PAGES: raw }).valid).toBe(false);
+ }
+ expect(resolveClosedWindowPages({ PM_SWEEP_CLOSED_WINDOW_PAGES: ' 2 ' }).pages).toBe(2);
+ });
+
+ it('leaves the H22 predicate itself untouched', () => {
+ // The adaptation is a window, not a rewritten rule: handed a closed card
+ // with residue the predicate must still say so. Re-enabling the reader is
+ // therefore a one-variable decision, not a code change.
+ const closedCard = {
+ state: 'closed',
+ state_reason: 'completed',
+ labels: [{ name: 'pm:dispatched' }],
+ };
+ expect(h22ClosedCardPmResidue(closedCard)).toContain('`pm:dispatched`');
+ expect(h22ClosedCardPmResidue({ ...closedCard, state: 'open' })).toBeNull();
+ });
+
+ it('reports the closed surface as UNREAD, never as clean', () => {
+ // ⛔ The property the whole adaptation turns on (#4690). A disabled reader
+ // and an empty result are the same number and opposite facts; if this ever
+ // renders "H22 read 0", the anchor starts asserting a clean closed surface
+ // that nothing looked at.
+ const counts = { repo: 'objectstack-ai/objectui', issues: 3, unscoped: 4, prs: 1, merged: 2, closed: 0 };
+ const disabled = summaryLine({ ...counts, closedWindowDisabled: true }, 0);
+ expect(disabled).toContain('is DISABLED in this install');
+ expect(disabled).toContain('UNREAD');
+ expect(disabled).not.toContain('H22 read 0');
+ // …and it names the way back, so the choice is reversible by a reader.
+ expect(disabled).toContain('PM_SWEEP_CLOSED_WINDOW_PAGES');
+
+ // The other direction: an enabled reader that found nothing IS a clean
+ // reading of the surface and must keep saying so.
+ const enabled = summaryLine(counts, 0);
+ expect(enabled).toContain('H22 read 0 recently-closed issue(s)');
+ expect(enabled).not.toContain('is DISABLED in this install');
+ });
+});
+
+describe('half-state-patrol.yml — report-only, as ruled on objectui#5791', () => {
+ it('writes exactly one issue body and nothing else', () => {
+ // The patrol surfaces half-states for a human or a seat to action. A patrol
+ // that acts on its own findings is a different and much larger card, so the
+ // mutating calls are pinned ABSENT rather than left to review.
+ for (const forbidden of [
+ 'addLabels',
+ 'removeLabel',
+ 'setLabels',
+ 'createComment',
+ 'addAssignees',
+ 'issues.create(',
+ ]) {
+ expect(workflow, `the patrol must not call ${forbidden} — it is report-only`).not.toContain(forbidden);
+ }
+ expect(workflow).toContain('issues.update(');
+ });
+
+ it('asks for no permission beyond that one write', () => {
+ expect(workflow).toMatch(/permissions:\s*\n\s*contents: read\s*\n\s*issues: write/);
+ });
+
+ it('never fails the run on findings, only on a sweep that could not run', () => {
+ // `exit 1` is reachable only through the sweep's own non-zero exit code.
+ expect(workflow).toContain("if: steps.sweep.outputs.exit_code != '0'");
+ });
+
+ it('does not rewrite the board from a pull_request run', () => {
+ // This PR's own run proves the sweep on a real runner; it must not touch
+ // the anchor. Both board-writing steps carry the guard.
+ const guards = workflow.match(/if: github\.event_name != 'pull_request'/g) ?? [];
+ expect(guards.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('exercises the ported helper through its paths filter', () => {
+ expect(workflow).toContain("- 'scripts/invoked-as.mjs'");
+ });
+});
diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs
index ab642c612..12c320a90 100644
--- a/scripts/dependabot-merge-gate.mjs
+++ b/scripts/dependabot-merge-gate.mjs
@@ -197,6 +197,8 @@ export const NOT_A_GATE = Object.freeze({
'live-e2e.yml is declared INFORMATIONAL and NON-REQUIRED in its own header and runs `continue-on-error: true`; ci-cd-pipeline.md says in as many words not to add it to required checks.',
label:
'labeler.yml applies labels. It is a mutation, not a verdict — nothing about the change is judged by it.',
+ 'Live half-state sweep':
+ 'half-state-patrol.yml is REPORT-ONLY by ruling (objectui#5791): a completed sweep exits 0 whether it found 0 half-states or 40, and the job gates no branch and blocks no queue. It goes red only when the sweep could not RUN — the patrol reporting its own death, which is a fact about the patrol, not a verdict on the pull request. Its pull_request trigger is also path-filtered to the sweeper and the workflow, so a Dependabot bump never produces this check at all.',
});
/**
diff --git a/scripts/invoked-as.mjs b/scripts/invoked-as.mjs
new file mode 100644
index 000000000..58b1ac3e0
--- /dev/null
+++ b/scripts/invoked-as.mjs
@@ -0,0 +1,255 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * invoked-as -- the ONE answer to "was this module run, or imported?"
+ *
+ * node scripts/invoked-as.mjs --self-test
+ *
+ * Every CLI script in `scripts/` has to separate "node ran me" from "something
+ * imported me" before it decides whether to do anything. Each one used to
+ * answer that with its own hand-typed comparison of `process.argv[1]` against
+ * `import.meta.url`, and the copies had drifted into ELEVEN distinct spellings
+ * across 33 files -- measured, not estimated. Nine of the eleven were wrong,
+ * and wrong in a direction nothing in CI can see.
+ *
+ * ## The bug every hand-typed spelling had
+ *
+ * **Node resolves symlinks for the module graph but leaves `process.argv[1]`
+ * as the caller typed it.** Reach a script through a symlink and the two name
+ * different paths, the guard answers `false`, and the script does NOTHING --
+ * with exit 0 and no output.
+ *
+ * That is the silent-success direction this tree treats as worse than no check
+ * at all. The CI wrappers spawn these tools and hold `result.status` only, so
+ * an inert child is a GREEN gate. Measured on the tree that motivated this
+ * module:
+ *
+ * scripts/pm/check-governed-merges.mjs --test AGENTS.md
+ * direct : exit=3, "GOVERNED -- no seat arms auto-merge"
+ * symlink : exit=0, no output
+ *
+ * and `EXIT_TEST_NOT_GOVERNED` is 0. So through a symlink the register's
+ * "this PR is GOVERNED, a human merge is the review record" answer and its
+ * "NOT governed, ordinary queue landing applies" answer are the SAME EXIT CODE.
+ * A seat reading the status rather than the printed verdict gets a clearance
+ * to arm auto-merge from a tool that never ran, on the one surface where human
+ * merge IS the review record (Prime Directive #14).
+ *
+ * ## The two other directions the copies failed in
+ *
+ * **Basename matching** -- `import.meta.url.endsWith(argv[1].split('/').pop())`
+ * and `argv[1].endsWith('qa-rollup.mjs')`. These survive a symlink that keeps
+ * the basename, so they look fine, but they answer TRUE for ANY entry script
+ * sharing the basename: they fire on IMPORT. The failure is the opposite
+ * direction -- a module that runs its whole CLI inside someone else's process.
+ *
+ * **Percent-encoding** -- `new URL(import.meta.url).pathname === argv[1]`
+ * compares an ENCODED pathname against a raw argv, and
+ * ``new URL(`file://${argv[1]}`)`` bypasses the encoder `pathToFileURL`
+ * applies. Both go inert on any checkout path containing a character that
+ * needs encoding, with no symlink involved at all. Measured: a `#` in any
+ * parent directory name is enough.
+ *
+ * ## The shape that survives
+ *
+ * Two comparisons. The plain `resolve` equality is the fast path and answers
+ * the ordinary case. The `realpath` comparison is the half that keeps a
+ * checkout REACHED THROUGH A SYMLINK from reading as "imported". It falls back
+ * to `false` rather than throwing -- an entry path that cannot be read is not
+ * this module.
+ *
+ * Comparing resolved PATHS (never URL strings) is what keeps percent-encoding
+ * out of the answer entirely: there is no encoder to disagree about.
+ *
+ * ## Why callers should reach for `isEntrypoint`, not `invokedAs`
+ *
+ * `isEntrypoint(import.meta.url)` takes ONE argument and reads `process.argv`
+ * itself, so a call site has nothing left to spell wrongly -- no `argv[1]`, no
+ * `fileURLToPath`, no comparison. `invokedAs` is the testable core underneath
+ * it, exported so the predicate is pinned by cases rather than trusted by
+ * reading.
+ *
+ * `scripts/check-entry-guard.mjs` enforces this: a `process.argv[1]` in an
+ * entry-guard position anywhere in `scripts/**` that is not this module is a
+ * failure. That gate is what stops a TWELFTH spelling, which is the whole
+ * reason this file exists rather than a one-time sweep.
+ *
+ * ## The sibling in `packages/cli`, and why the duplication is deliberate
+ *
+ * `packages/cli/src/utils/invocation.ts` exports `isProcessEntry`, the same
+ * predicate for the same reason (its header cites this defect). It is NOT
+ * imported here and this is not imported there: `scripts/` runs as plain .mjs
+ * against a possibly-unbuilt tree, and making the whole tooling layer depend on
+ * a package build to answer "was I run?" trades this bug for a worse one.
+ *
+ * The duplication is therefore structural, but DIVERGENCE is not allowed --
+ * two predicates answering this question differently is precisely the defect
+ * being closed. Both carry the same two legs: realpath for symlinks, and
+ * directory resolution for `node
`. Change one, change the other.
+ */
+
+import { spawnSync } from 'node:child_process';
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, relative, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+/**
+ * Is `entryArg` -- a `process.argv[1]` -- the module at `selfPath`?
+ *
+ * @param {string | undefined} entryArg `process.argv[1]`, as node left it.
+ * @param {string} selfPath An absolute filesystem path to the module asking.
+ * @returns {boolean}
+ */
+export function invokedAs(entryArg, selfPath) {
+ if (!entryArg) return false;
+ const self = resolve(selfPath);
+ const entry = resolve(entryArg);
+
+ // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution:
+ // `argv[1]` can name the directory whose index this module is. Latent in
+ // `scripts/` today (nothing here is an `index`), carried because the sibling
+ // predicate in `packages/cli` carries it, and two predicates that answer this
+ // question differently is the defect this module exists to close.
+ const candidates = [entry, join(entry, 'index.mjs'), join(entry, 'index.js')];
+ if (candidates.includes(self)) return true;
+
+ const realSelf = realOrSelf(self);
+ return candidates.some((c) => realOrSelf(c) === realSelf);
+}
+
+/** The realpath of `p`, or `p` itself when it cannot be read. */
+function realOrSelf(p) {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
+}
+
+/**
+ * Was the module identified by `importMetaUrl` run by node, rather than
+ * imported? The form every `scripts/**` entry guard should use:
+ *
+ * if (isEntrypoint(import.meta.url)) { ... }
+ *
+ * @param {string} importMetaUrl The caller's own `import.meta.url`.
+ * @returns {boolean}
+ */
+export function isEntrypoint(importMetaUrl) {
+ return invokedAs(process.argv[1], fileURLToPath(importMetaUrl));
+}
+
+// ---------------------------------------------------------------------------
+// Self-test -- REAL symlinks, not a model of one
+// ---------------------------------------------------------------------------
+
+/**
+ * The cases that matter here cannot be written as string comparisons, because
+ * the bug IS the difference between what node puts in `argv[1]` and what it
+ * puts in `import.meta.url`. So the fixture spawns a real probe module through
+ * a real symlink and reads what it printed.
+ *
+ * A model of a symlink would have passed against every one of the eleven
+ * broken spellings this module replaces.
+ */
+export function selfTest() {
+ const cases = [];
+ const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail });
+
+ const SELF = fileURLToPath(import.meta.url);
+
+ // ── the predicate, directly ────────────────────────────────────────────────
+ t('an absent argv[1] is not this module -- the `node --eval` importer', !invokedAs(undefined, SELF) && !invokedAs('', SELF));
+ t('an exact path is this module', invokedAs(SELF, SELF));
+ t('a relative path resolving to this module is this module', invokedAs(relative(process.cwd(), SELF), SELF));
+ t('an unrelated existing file is not this module', !invokedAs(resolve(SELF, '..', 'js-comment-mask.mjs'), SELF));
+ t('an entry path that cannot be read is not this module (no throw)', !invokedAs(resolve(SELF, '..', 'no-such-file-here.mjs'), SELF));
+
+ // ── the fixture: a probe reached three ways ────────────────────────────────
+ const dir = mkdtempSync(join(tmpdir(), 'invoked-as-'));
+ try {
+ // A directory whose name needs percent-encoding, because two of the
+ // replaced spellings went inert on exactly this with no symlink involved.
+ const deep = join(dir, 'a#b c');
+ mkdirSync(deep, { recursive: true });
+
+ const probe = join(deep, 'probe.mjs');
+ writeFileSync(
+ probe,
+ `import { isEntrypoint } from ${JSON.stringify(pathToFileURL(SELF).href)};\n` +
+ `if (isEntrypoint(import.meta.url)) console.log('RAN');\n`,
+ );
+
+ const sameName = join(dir, 'probe.mjs');
+ const diffName = join(dir, 'not-the-same-name.mjs');
+ symlinkSync(probe, sameName);
+ symlinkSync(probe, diffName);
+
+ const run = (f) => {
+ const r = spawnSync(process.execPath, [f], { encoding: 'utf8' });
+ return { out: (r.stdout || '').trim(), status: r.status };
+ };
+
+ const direct = run(probe);
+ t('a probe run directly RUNS', direct.out === 'RAN' && direct.status === 0, JSON.stringify(direct));
+
+ // THE case. Every spelling this module replaces failed here, silently.
+ const viaSame = run(sameName);
+ t('a probe reached through a SYMLINK runs', viaSame.out === 'RAN' && viaSame.status === 0, JSON.stringify(viaSame));
+
+ // ...and not because the basenames happen to match: the two basename
+ // spellings that were replaced pass the case above and fail this one.
+ const viaDiff = run(diffName);
+ t('a probe reached through a symlink under a DIFFERENT NAME runs', viaDiff.out === 'RAN' && viaDiff.status === 0, JSON.stringify(viaDiff));
+
+ // The importer direction: the guard must stay false, or a module runs its
+ // whole CLI inside someone else's process. This is the direction the
+ // basename spellings got wrong.
+ const importer = join(dir, 'importer.mjs');
+ writeFileSync(importer, `await import(${JSON.stringify(pathToFileURL(probe).href)});\nconsole.log('IMPORTED');\n`);
+ const imported = run(importer);
+ t('importing the probe does NOT run it', imported.out === 'IMPORTED' && imported.status === 0, JSON.stringify(imported));
+
+ // `node ` — the entry argument, and only it, gets directory
+ // resolution, so the index module must recognise the DIRECTORY as itself.
+ const pkgDir = join(dir, 'as-a-directory');
+ mkdirSync(pkgDir, { recursive: true });
+ // `node ` reaches the index through package.json `main` — the
+ // directory alone is not enough, which is why this leg needs a real fixture.
+ writeFileSync(join(pkgDir, 'package.json'), '{"type":"module","main":"index.mjs"}\n');
+ writeFileSync(
+ join(pkgDir, 'index.mjs'),
+ `import { isEntrypoint } from ${JSON.stringify(pathToFileURL(SELF).href)};\n` +
+ `if (isEntrypoint(import.meta.url)) console.log('RAN');\n`,
+ );
+ const viaDir = run(pkgDir);
+ t('a directory run as `node ` RUNS its index', viaDir.out === 'RAN' && viaDir.status === 0, JSON.stringify(viaDir));
+
+ // ...including when the importer shares the probe's basename, which is
+ // precisely what basename matching cannot tell apart.
+ const twinDir = join(dir, 'twin');
+ mkdirSync(twinDir, { recursive: true });
+ const twin = join(twinDir, 'probe.mjs');
+ writeFileSync(twin, `await import(${JSON.stringify(pathToFileURL(probe).href)});\nconsole.log('IMPORTED');\n`);
+ const viaTwin = run(twin);
+ t('an importer sharing the probe basename does NOT run it', viaTwin.out === 'IMPORTED' && viaTwin.status === 0, JSON.stringify(viaTwin));
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+
+ 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(`✗ invoked-as self-test: ${failed.length} of ${cases.length} case(s) failed.`);
+ return 1;
+ }
+ console.log(`✓ invoked-as self-test: ${cases.length} cases pass (real symlink, different-name symlink, percent-encoding path, and both import directions).`);
+ return 0;
+}
+
+if (isEntrypoint(import.meta.url)) {
+ if (process.argv.includes('--self-test')) process.exit(selfTest());
+ console.log('usage: node scripts/invoked-as.mjs --self-test');
+}
diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs
new file mode 100644
index 000000000..40828ca31
--- /dev/null
+++ b/scripts/pm/check-half-states.mjs
@@ -0,0 +1,9159 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * PM half-state sweeper (#7341 item 2) — REPORT-ONLY enumeration of the
+ * label/assignee invariants the dispatch protocol calls "过夜半状态".
+ *
+ * node scripts/pm/check-half-states.mjs # sweep the live repo
+ * node scripts/pm/check-half-states.mjs --probe # can live mode run HERE? (no sweep)
+ * node scripts/pm/check-half-states.mjs --self-test # verify the predicates offline
+ * node scripts/pm/check-half-states.mjs --format=markdown [--provenance='…']
+ * # the same sweep, rendered for an issue body
+ *
+ * ## The standing caller (#9844)
+ *
+ * For most of this file's life its consumer was "a PM seat's patrol round" —
+ * which is to say, nobody's calendar. A shift covering two lanes declared a
+ * queue empty from memory while eight malformed claims (H2) and an unenumerated
+ * backlog sat on the board; not one predicate here had fired, because nothing
+ * standing ever called them. An alarm added to a script nobody runs is still
+ * silence, and the transport note below explains why "some seat should run it"
+ * kept not happening: the live sweep cannot run inside a PM session container
+ * at all.
+ *
+ * So the caller is now `.github/workflows/half-state-patrol.yml` — a scheduled
+ * workflow, on a runner where the transport prerequisite is met, landing the
+ * result by rewriting ONE pinned anchor issue in place (edit history is the
+ * archive; never a comment per run). `--format=markdown` exists for exactly
+ * that consumer, and `--provenance` lets the caller stamp its own run identity
+ * into a body this script otherwise renders repo-agnostically.
+ *
+ * What did NOT change, and must not: this stays report-only. The workflow never
+ * fails a build over findings and never writes a label. The one thing it DOES
+ * treat as a failure is its own non-delivery — a patrol that cannot land its
+ * report is the disease, not a finding.
+ *
+ * ## Adopting the patrol in a sibling repo (#11217)
+ *
+ * The pair (this file + that workflow) is copied VERBATIM into a sibling repo;
+ * the only per-repo input is one repository variable naming that repo's anchor
+ * issue (`HALF_STATE_ANCHOR_ISSUE`). Each install runs on its own runner with
+ * its own `GITHUB_TOKEN` and reads its own board — ⛔ no cross-repo credential
+ * anywhere, which is the route ruled at grading rather than a matrix job.
+ *
+ * Two things make "verbatim" actually safe, and both are new: the swept repo is
+ * resolved from the runner's own `GITHUB_REPOSITORY` rather than a hardcoded
+ * default (`resolveSweepRepo` carries the argument), and the workflow REFUSES
+ * to run with an unconfigured anchor instead of writing this repo's anchor
+ * number in someone else's repo. It was measured worth doing: with three of the
+ * four repos uninstalled, 37 of the fleet's 59 open `pm:blocked` cards had
+ * never been swept, and 7 of objectui's 12 machine-readable blocks were
+ * already expired when a human read them by hand.
+ *
+ * What still does NOT travel, stated so a reader does not assume it does:
+ * cross-repo `Blocked-by:` targets stay unresolvable per install (each token
+ * reads its own repo), so H19 reports them as UNJUDGED — accepted, and made
+ * loud separately.
+ *
+ * ## Why report-only, and why the exit code is ALWAYS 0 on a completed sweep
+ *
+ * The pm-dispatch state model (.claude/skills/pm-dispatch/SKILL.md, "State
+ * model") says the labels ARE the state machine, and its label discipline says
+ * 「状态变更不过夜」: a label applied without its paired signal is a state no
+ * sweep can interpret. Those half-states occur in practice — a card carried
+ * `pm:queue` AND `pm:dispatched` simultaneously for ~14 hours (#5925's
+ * 2026-08-09 correction comment); another sat dispatched with an assignee and
+ * no claim for 48h+ (the #5925 stale-claim reclaim) — and today finding them
+ * is a manual read of every card. This script is the mechanical enumerator.
+ *
+ * It is deliberately NOT a gate: a half-state is a fact about a live, shared
+ * board, not about the PR that happens to run CI next — failing an unrelated
+ * PR over board state would punish the wrong actor (the same reasoning that
+ * keeps `check:platform-checklist` out of CI, lint.yml's own note). So a
+ * completed sweep exits 0 whether it found 0 or 40 violations; the findings
+ * are the output, and the consumer is a PM seat's patrol round (the standby
+ * posture in SKILL.md documents the invocation). Only a sweep that could not
+ * run (network, auth, bad usage) exits non-zero — per #4690, "could not read
+ * the input" must never look like "input is clean".
+ *
+ * ## The invariants (each names its protocol source)
+ *
+ * H1 `pm:dispatched` with no assignee — dispatch marks a claim; a claim is
+ * assign + claim comment (state model / step 4).
+ * H2 assignee set on a pm-tracked card, but no claim comment on the thread
+ * (a comment whose body carries a "Claim:" line) — the assignee field
+ * alone cannot say WHICH session owns it (step 4; #4588). The marker is
+ * read with an OPTIONAL leading blockquote ">", because step 4's own
+ * claim template is a blockquote (SKILL.md, "> Claim: …") — the predicate
+ * used to reject the exact shape the skill tells every seat to write, and
+ * reported a correctly-claimed card as a half-state (#7488, measured on
+ * #6752). The strictness either side of that marker is deliberate and
+ * stays: the line must BEGIN with the word, so ordinary prose containing
+ * "claim" is not a claim comment.
+ * H3 `pm:queue` + `pm:dispatched` both present — reads as available to the
+ * queue view and in-flight to the lane view; neither is trustworthy
+ * (#5925 2026-08-09 correction, the measured specimen).
+ * H4 `pm:blocked` with a `Blocked-by:` line in NEITHER channel — body nor
+ * comment. The machine half of the label is that line; without it the
+ * unlock sweep can never return the card (state model, label
+ * discipline). It reads TWO channels because seats write two: the MCP
+ * body-escaping hazard (#8813) makes a body rewrite the riskier write,
+ * so the line is deliberately parked in a comment — 26 of 40 blocked
+ * cards were body-clean at the 2026-08-19 census, and a body-only read
+ * reported every one of them as having left the machine nothing. Either
+ * channel discharges the duty; the finding names both so a reader knows
+ * which one to fix (#8941, and the #9948 gauge-recalibration ruling).
+ * The line may be DECORATED in either channel — 「`Blocked-by: #9612`」
+ * is the natural markdown for a line meant to be grepped, and reading it
+ * as absent left #10063 permanently blocked in silence (#10102). H9's
+ * `Restart-when:` shares that anchor AND, since #10403, this two-channel
+ * read; the history of the closed asymmetry is stated at H9.
+ * H5 `pm:seat` sticker whose title/assignee pair is out of sync — the
+ * seat-sticker protocol makes 标题、assignee、正文 a same-write triple:
+ * a title claiming 🟢 must have that login as assignee; a title
+ * claiming ⏳ vacant must have none. (Routine seats declare 🟢 Routine
+ * and are exempt from the assignee half — bots can't be assigned.)
+ * H6 `pm:seat` sticker whose body exceeds ~10 KB — the seat-post protocol
+ * bounds the live body to current state (six-section template, #7583,
+ * maintainer-accepted 2026-08-11); an oversized body means shift
+ * narration is accreting where per-card state already lives (cards,
+ * PRs, round reports). Soft report-only signal: the remedy is a
+ * takeover-style compaction (edit history is the archive), never
+ * truncation. #6019 reached ~61 KB and exceeded tool read limits
+ * before this rule existed.
+ * H7 an OPEN PULL REQUEST whose body declares `Part of #N` while ALSO
+ * carrying a closing keyword bound to that same `#N` — contradictory by
+ * construction. `Part of` is the protocol saying "merging this must NOT
+ * close the card"; a closing keyword is GitHub being told it must.
+ * GitHub wins, silently, on merge. This was the first item over PULL
+ * REQUESTS rather than issues, because the PR body is the surface where
+ * the fact is still fixable — see the next section.
+ * H8 a card's delivering PR is MERGED while the card still carries
+ * `pm:dispatched` — the merge's paired write (drop the label, re-grade
+ * the remainder) never landed (#8683). Delivery is read from merged PR
+ * bodies with H7's code-stripped extractors (`Part of #N`, or a closing
+ * keyword bound to `#N` — either way an OPEN dispatched card named by a
+ * merged PR is a half-state, whichever mechanism failed), and — for the
+ * bodies that declare NO delivery at all — from the PR's own branch name
+ * (#11036: a merged PR whose body said only `Refs #10757` left its card
+ * dispatched and unreported for ~22h in a sweep that got six other H8
+ * rows right). The precedence is deliberate and the widening's whole
+ * safety margin; `prDeliversCard` carries the argument. Live mode
+ * feeds H8 a bounded window of recently merged PRs, so it is a patrol
+ * accelerator, never an exhaustive audit: a delivery older than the
+ * window is invisible, and the finding clears when the paired write
+ * lands, not when the PR ages out.
+ * H8 is ALSO handed the open-PR list the sweep already holds (#10468):
+ * a card delivered in halves keeps `pm:dispatched` legitimately while
+ * its last half is open, and the row used to fire on every sweep until
+ * that half landed — prescribing a DESTRUCTIVE de-labelling against the
+ * most active card on the board. That case now emits a distinct quieter
+ * sentence that names both sides and says the label is correct, rather
+ * than falling silent: silence would lose the genuine #8683 case where
+ * the last half is later ABANDONED. See the predicate.
+ * H9 `pm:on-hold` without a machine-fireable `Restart-when:` line in
+ * EITHER channel — the state model (post 2026-08-16 ruling) makes the
+ * hold state legal
+ * ONLY with a machine-readable exit: `Restart-when: closed #N`
+ * (fired by the same unlock scan as `Blocked-by:`, and since #10403 over
+ * the same TWO channels — body OR comment, the H4/H14 contract; a
+ * comment-parked exit fired and sat unnoticed ~2 days under the old
+ * body-only read) or a one-line executable predicate. A hold nothing can fire
+ * is indistinguishable from an abandoned card. `Restart-when: manual — …`
+ * counts as MISSING, deliberately: the protocol says a card no mechanism
+ * can revive is closed `not planned` (reason + provenance in the closing
+ * comment), so accepting a `manual` line here would hand every seat a
+ * one-word spelling that defeats the invariant this item enforces.
+ * H10 `priority:p0`, open, unassigned, and no activity past the threshold —
+ * p0 is queue-jump priority (dispatched immediately, past batch and
+ * round boundaries), so an unclaimed p0 holding still for longer than
+ * any legal round latency almost always means NO seat's scan scope
+ * covers the queue it sits in (the measured specimen: a correctly
+ * triaged p0 that sat ~36h because the label queue it was routed to had
+ * no named reader). Staleness is read from `updated_at` — an
+ * unparseable timestamp reports as a finding, never as fresh (#4690:
+ * "could not read the input" must not look like "input is clean").
+ * H11 the important-parked inventory — a card carrying an importance signal
+ * (native type `Bug`, or a `bug` / `security` / `priority:*` label)
+ * sitting in `pm:blocked` or `pm:on-hold` and open past the threshold.
+ * Maintainer concern, 2026-08-16, verbatim: 「我担心的优先的,重要的问
+ * 题,比如bug 被放进 blocked 或者 on-hold 没人理会」. Distinct from H10
+ * (p0 + UNASSIGNED regardless of state): H11 is the broader
+ * importance × parked-state cross, so every triage fire prints the
+ * inventory of important cards that a parked state could otherwise
+ * hide indefinitely. Report-only like everything here — the remedy is
+ * the triage round re-checking the card's exit liveness, not a gate.
+ * H12 an OPEN, non-draft PR with auto-merge unarmed and no activity past
+ * the threshold — the orphan-landing detector (queue-steward
+ * retirement, maintainer-ruled 2026-08-16: the retired seat's one
+ * genuine gap). In this protocol a dev PR is flipped ready only at
+ * review ACCEPT, so ready = reviewed by construction; a reviewed PR
+ * that left the merge queue (or never entered it) with nobody handling
+ * it would otherwise wait silently forever. Patrol input, not a gate.
+ * H13 a card carrying `domain:*` with NO pm-state label, aged past one
+ * sweep cycle — the half-annotated shape the protocol's own
+ * single-label writes produce by design, which the triage sweep's
+ * disjunct ③ ("有 domain:* 无 pm-state") exists to heal hourly. In that
+ * shape the card is invisible to every seat's candidate query (routing
+ * landed, the state machine never did), and it is ALSO invisible to
+ * every label-scoped listing in this sweep — the shape is defined by
+ * the absence of the labels the other listings key on — so H13 is the
+ * one item that needs an UNSCOPED listing. Aged past a cycle it is a
+ * defect of the HEALING LOOP, not inventory (maintainer, 2026-08-19,
+ * verbatim: 「项目经理等分诊,但是没有切换 label,导致挂了很久。」「我
+ * 刚和他说了他才处理。」— the measured specimen, its body self-declaring
+ * P0/data-integrity, sat ~26h until poked by hand). A louder line fires
+ * when the card's own title/body self-declares P0/data-integrity: for
+ * that class the emergency-triage channel (immediate triage subagent)
+ * is the mandated move, never the hourly Routine.
+ * H14 `pm:blocking` cache incoherence, in BOTH directions, read off the
+ * `Blocked-by:` reverse index this sweep already has the bodies for.
+ * `pm:blocking` is not a state a seat sets: the state model makes it a
+ * CACHE the triage sweep derives from that index (「分诊 sweep 自
+ * `Blocked-by:` 索引推导的缓存,⛔ 不手工挂」), and the lane selection
+ * order ranks it second only to `priority:p0`. A cache with a ranking
+ * consumer and no coherence check drifts in two different ways, and
+ * each lies differently: a card carrying the label that NOTHING targets
+ * is boosted past fresher work on the authority of a dependency that
+ * does not exist (worse than an absent label — it lies with authority),
+ * while a card that IS targeted and does NOT carry it is a real
+ * unblocker the selection order cannot see. Report-only, and pointedly
+ * so: the producer is the triage sweep's derivation pass, so the remedy
+ * is always a derivation that runs, never a label written from here.
+ * The index it reads is the UNION of both channels (body ∪ comment),
+ * for the same reason H4 reads both — and here the cost of reading one
+ * was measured: #9465 and #9968 were reported stale while their
+ * dependents (#9709/#9828 and #9969/#9652) stated the wait in comments.
+ * That is the 「量具错位」 the 2026-08-19 ruling on #9948 named — 「修量
+ * 具而非追假 stale」 — and both shapes are regression pins now. The two
+ * directions treat an INCOMPLETE index differently on purpose: stale is
+ * a claim about absent evidence and is suspended when any gated comment
+ * fetch failed, while missing is a claim about evidence in hand and
+ * cannot be manufactured by reading more. See the predicate.
+ * Both directions were non-empty at the reading this item landed on
+ * (2026-08-19, 234 open cards, 17 `Blocked-by:` body lines): ONE stale
+ * card and FIVE missing ones, with not a single coherent pairing on the
+ * board — the cache had no reader checking it and had drifted to 0%
+ * agreement with the index it is derived from.
+ * H15 the oldest UNCLAIMED `pm:blocking` card and its age — one row, no
+ * threshold, selection-order compliance made visible (maintainer,
+ * 2026-08-19: 「然后车道项目经理应该优先处理 blocking」). Where H14 asks
+ * whether the cache is TRUE, H15 asks whether anyone acted on it: a
+ * lane that keeps taking fresher picks past an unclaimed blocking card
+ * now says so on the anchor, by name. Deliberately not an alarm and
+ * deliberately unconditional — see the rationale on the predicate for
+ * why this one carries no threshold constant.
+ * H16 an OPEN, non-draft PR sitting in a MERGE CONFLICT
+ * (`mergeable_state` = `dirty`) past the threshold — the one board state
+ * no instrument here could express before it (devx incident,
+ * maintainer-approved 2026-08-19, verbatim: 「同意你的建议」). A conflict
+ * is not a red check: it starts no CI run, raises no event, and turns no
+ * check-run red, so every signal a patrol reads by proxy keeps reporting
+ * health — and when auto-merge is ARMED the PR additionally reads as
+ * "the queue is handling it" (H12's own reading, correct there and
+ * exactly wrong here). The measured specimen hung ~4h with nobody aware.
+ * The incident's lesson, verbatim: 「一个无法表达某状态的仪器,会把它报成
+ * 它能表达的最近状态。」 — which is what H1–H15 were doing to this state.
+ * Two consequences shape the item. It is the only one needing a per-PR
+ * GET (`mergeable_state` is absent from the `/pulls` LIST payload and
+ * lives on the single-PR endpoint alone), taken for CANDIDATES only and
+ * never fatal to the sweep. And it deliberately does NOT read
+ * `auto_merge` in the finding-reducing direction H12 does: auto-merge
+ * does not resolve conflicts, so an armed dirty PR is the disease, not a
+ * handler. `unknown`/null readings are SKIPPED and never vouched for —
+ * GitHub computes mergeability asynchronously, so that reading is the
+ * platform saying "ask again later", not a state to name.
+ *
+ * ## H17 — the one item here that is NOT an invariant
+ *
+ * H17 the on-hold TRIGGER-FILE INDEX — an inventory SECTION, not a
+ * predicate, and the only thing in this file that can never produce a
+ * finding. Each row is an open `pm:on-hold` card and the repo-relative
+ * files its hold comment(s) or body name as opportunistic-restart
+ * triggers. A card appearing in it is a hold in perfectly good standing;
+ * the row exists so a dispatching seat can intersect its file surface
+ * against the board's held cards by GLANCING at the anchor it already
+ * reads at the top of every round.
+ *
+ * It is here because that intersection was measured NOT RUNNING (#10034,
+ * 2026-08-19): across six cards the named trigger files were touched
+ * NINETEEN times and the rider was carried ZERO times. The mechanism is
+ * real and maintainer-accepted (2026-08-11), but it existed only as a
+ * remembered protocol step — in SKILL.md and in the hold comments
+ * themselves — and a written step nobody executes is worse than none,
+ * because holds are PRICED assuming it runs.
+ *
+ * ⛔ Why it is NOT in `dispatch-gates.mjs`, where the intersection is
+ * actually wanted: that script runs in a seat container, whose live
+ * GitHub read is 403 — the same transport fact this file's prerequisite
+ * classifier exists to name. Building the intersection there would
+ * re-create the disease one layer down: a second mechanism that cannot
+ * execute. The patrol runs on a runner where the transport prerequisite
+ * is met, so it gathers and renders; the seat reads. No new seat-side
+ * dependency, and the behaviour closes through a surface that already
+ * has a standing caller.
+ *
+ * Extraction is deterministic and refuses to guess: a closed set of
+ * anchor terms (`H17_TRIGGER_ANCHOR_TERMS`, derived from a nine-card
+ * census) plus the canonical `Restart-touch:` channel locate the clause,
+ * and every candidate token is validated against `git ls-files`.
+ * Anything unverifiable is DROPPED, so the index under-reports and never
+ * invents — a fabricated row would send a seat to intersect against a
+ * path that does not exist, and that intersection would silently never
+ * hit, which is the original defect wearing a new mask.
+ *
+ * ## H18 — `pm:retriage` aged past one triage cycle
+ *
+ * H18 an open card carrying `pm:retriage` past the threshold — the label's
+ * own state model (maintainer ruling 2026-08-19/20, verbatim: 「同意 并
+ * 存」) has it COEXIST with the card's standing `pm:*` label rather than
+ * replace it: the objecting seat applies it alongside its evidence
+ * comment, and the triage Routine — which re-judges every `pm:retriage`
+ * card each fire, high priority (SKILL.md) — is the only remover. That
+ * division of duties is H13's healing-loop shape again: a label nobody
+ * re-checks for age is the next "state nobody is watching", and without
+ * this item nothing here would say so. Aged past one cycle the row names
+ * the card and its coexisting standing `pm:*` label; `pm:retriage`
+ * present with NO such label gets its own note instead — the
+ * coexistence the state model requires is itself missing, so the
+ * disputed grading is unidentifiable (异议对象不明) from the label set
+ * alone. Age is read from `updated_at`, the same proxy H13 uses for the
+ * same reason: this sweep makes no per-card timeline fetch for the
+ * label-APPLICATION event, and every triage re-judgement (grade kept or
+ * changed) bumps `updated_at`, so a stale reading means nothing touched
+ * the card since some triage pass — which is exactly the failure this
+ * item exists to name. Report-only and NOT loud, like H14–H16: the
+ * remedy is the triage Routine's next fire, never a label written here.
+ *
+ * ## H19 — the question that ENDS a block
+ *
+ * H19 an open `pm:blocked` card whose `Blocked-by:` target has CLOSED — the
+ * block has outlived its blocker. Two items already read that line and
+ * NEITHER expires a block: H4 asks whether the line EXISTS, H14 asks the
+ * REVERSE index (does anything target THIS card). Nothing asked whether
+ * the issue the line NAMES is still open, so an expired block sat in
+ * complete silence with a well-formed line, a correct label and no row
+ * anywhere. Two measured instances, both found by READING and neither by
+ * any gauge: a card whose comment-borne `Blocked-by:` target closed at
+ * 09:03:37Z and which sat blocked ~4.5h after that, released only when a
+ * human walked the lane's dependency graph; and one whose body-borne,
+ * backtick-decorated target closed at 07:58:08Z and which was released
+ * only by a manual triage pass. Targets are read from BOTH channels
+ * through the shared decorated-directive reader — one of the two
+ * measured cards states its blocker only in a comment, so a body-only
+ * read would have seen half the evidence the item was filed on — and
+ * cross-repo targets are resolved rather than dropped (the opposite call
+ * from `buildBlockingIndex`, and both are right: the index asks which
+ * LOCAL card is waiting, H19 asks whether THAT issue is still open).
+ * Three target states, never two: a target that could not be resolved
+ * fires its own quieter row saying the liveness is UNJUDGED, because a
+ * silently dropped target reads as a healthy block forever — this item's
+ * own disease in a new mask (#4690). Report-only, and pointedly: the
+ * release is a protocol procedure with two mechanical double-checks
+ * (state model, 「放行双查」) over the card's conversion comments and its
+ * merged-PR timeline, so this row surfaces the candidate and the unlock
+ * sweep releases it — ⛔ never a label written from this script.
+ *
+ * ## H20 — the question that says a dispatch actually HAPPENED
+ *
+ * H20 an open `pm:dispatched` card whose claim comment names a branch for
+ * which NO REMOTE REF EXISTS AT ALL, older than the lane's
+ * dispatch→first-commit baseline (`DISPATCHED_NO_REF_STALE_MINUTES`).
+ * Claiming and dispatching are two acts with a gap between them: the
+ * claim is an atom the protocol defines carefully (assign + label swap
+ * in one write, the claim comment, a race re-read) and LAUNCHING the dev
+ * is a third act outside it that nothing binds to the first two. A seat
+ * interrupted between them — a maintainer message answered, a tool
+ * error, lost context — leaves a card that is `pm:dispatched`, assigned,
+ * carrying a full claim comment naming a branch and a worktree, with no
+ * agent anywhere working it. ⭐ It is INVISIBLE FROM THE CARD: every
+ * field is correct, and only the absence of something elsewhere is
+ * wrong, which is why a skill sentence could not carry it and a row
+ * must. Measured on #8878 (2026-08-20): claim comment at ~14:05Z, the
+ * dispatch call never made, 74 minutes `pm:dispatched` with nobody on it
+ * until a patrol tick compared branch heads. Every adjacent row declines
+ * it for a reason of its own — H2 wants a MISSING claim comment and this
+ * one is complete, H4/H14 want `Blocked-by:`, H9 wants a hold, H8 wants
+ * a merged PR and nothing here ever ran — so the shape had no reader.
+ * ⛔ The key is NO REF AT ALL and never "no PR yet": a dev inside a long
+ * build legitimately has a ref and no PR for over an hour (measured
+ * repeatedly on this lane), so a PR-keyed row would fire hardest on the
+ * healthiest dev on the board. The guarantee is structural — the
+ * predicate is handed a ref state and nothing else. Three ref states,
+ * never two, exactly as H19 landed them: `exists` is healthy, `absent`
+ * fires, and an UNREADABLE probe fires its own quieter row saying the
+ * dispatch is unjudged, because a ref read dropped in silence reads as a
+ * healthy dispatch forever (#4690). ⚠️ The symptom is identical to a dev
+ * agent that DIED and the remedies are opposite (a dead agent needs a
+ * probe, an undispatched claim needs a dispatch), so the row names both
+ * readings rather than diagnosing one. Report-only like everything here:
+ * the remedy is a dispatch or a withdrawn claim, never a label written
+ * from this script.
+ * H21 an OPEN PULL REQUEST whose body binds a closing keyword to a `#N` the
+ * body never declared itself `Part of`, inside a SENTENCE that reads as
+ * not closing it ("Filed, not fixed: #10240", "out of scope: closes
+ * #N"). H7's own rationale covers this whole class while H7's predicate
+ * covers one spelling of it — H7 is bound to a `Part of #N` declaration
+ * and a body that declares `Part of` for nothing is silent by
+ * construction, however plainly it says the card stays open. Measured
+ * specimen (#10392): PR #10241 carried no `Part of` and the sentence
+ * "Filed, not fixed: #10240"; #10240 closed `completed` two seconds
+ * after that merge and read as finished until a human reopened it a day
+ * later. ⛔ The trigger is the NEGATION WINDOW and never keyword
+ * presence: 277 of the 300 most recently merged bodies carry a closing
+ * keyword bound to a number (301 matches), so a presence rule would
+ * report every correct PR in the corpus. The negation is the author's
+ * own statement of intent contradicting the instruction beside it, which
+ * is what makes accident separable from intent at all. The window is a
+ * SENTENCE, measured: widening it to the whole body turns 0 false
+ * positives into 13, all of them one legitimate fourteen-card close
+ * (#10714). Disjoint from H7 by construction — a number already declared
+ * `Part of` is H7's row and is skipped here. Report-only, and
+ * deliberately not imported by the blocking gate that reuses H7's
+ * predicate: widening the class must not silently widen a check that
+ * fails builds.
+ * H22 a CLOSED card still carrying a `pm:*` STATE label — the one item here
+ * that reads closed issues, and the reason it has to (#10688). H8's
+ * subject is a write that has not happened yet, but the card is usually
+ * closed by the same merge that discharges the PR, so whether H8 ever
+ * fired was decided by a race it normally loses: once the card closes,
+ * no run looks at it again and the duty is discharged by disappearance.
+ * Measured at filing: 129 of the 500 most recently updated closed cards
+ * carried a live `pm:` label, 118 of them `pm:dispatched`. Direction A of
+ * that card — ONE bounded closed reader, every other collector still
+ * open-only, so the race closes without widening the sweep. The window is
+ * the stated boundary and it is load-bearing here: a 2026-08-22 re-measure
+ * paged past 500 closed `pm:dispatched` carriers repo-wide, so an
+ * unbounded read would bury every other item under one-time historical
+ * residue. Recent residue is a live duty; the deep tail is a backfill
+ * question. Report-only like the rest — the remedy is a label write a
+ * seat performs, never one this script performs.
+ * H23 a SQUASH COMMIT MESSAGE on the default branch carrying `Part of #N`
+ * and a closing keyword bound to that same `#N` — H7's contradiction on
+ * the SECOND surface GitHub closes cards from, and the only item here
+ * that reads commits (#10942). Every closing-keyword reader this repo
+ * owns takes a PR body: H7 and H21 above, and the blocking gate
+ * `scripts/check-partof-closing-keyword.mjs`. GitHub's parser also acts
+ * on commit messages that land on the default branch, and this repo
+ * squash-merges, so every merged PR writes exactly one such message that
+ * nothing read. ⭐ The message is COMPOSED AT MERGE TIME from the
+ * branch's own commit messages, not from the PR body — so the
+ * contradiction can exist on `main` while every body was clean, and a
+ * body-side guard is not merely looking in the wrong place, it is
+ * looking at a text that never contained it (measured on PR #9478: body
+ * clean under H7 and under the gate, squash message carrying both).
+ * Measured 2026-08-22 over all 1,546 first-parent messages in the pinned
+ * window 2026-08-11T00:00Z…08-22T18:00Z: 270 bindings across 234
+ * messages, 6 carrying the contradiction, all 6 multi-commit branches
+ * whose squash concatenated a `Part of` trailer and a closing trailer.
+ * ⛔ The extractors run at `markdown: false` here and MUST: a commit
+ * message is not markdown, so backticks do not neutralise a keyword, and
+ * the finding sentence therefore prescribes REWORDING and never H7's
+ * backtick remedy — an author who has internalised the body remedy is
+ * exactly who will misapply it here. H21's negation window is
+ * deliberately NOT ported: it flags 0 of the 270 on this surface, because commit messages carry no
+ * `## Out of scope` register. Report-only and measure-first by ruling
+ * (2026-08-22); a blocking posture for this surface is a later card on
+ * its own baseline.
+ *
+ * ## H24 + H25 — the queue/assignee contradiction, and the state that closes it
+ *
+ * H24 an OPEN card carrying `pm:queue` with a NON-EMPTY assignee — the queue
+ * view reads it as dispatchable, the claim rule reads it as taken, and
+ * both readers are right about the field they read, so the card is
+ * available to everyone and forbidden to everyone at once. A pure
+ * intersection of two fields: no threshold, no timestamp, no identity
+ * test. Every adjacent row declines the shape for a reason of its own —
+ * H1 wants NO assignee, H2 wants a MISSING claim comment (the measured
+ * carriers have complete ones), H3 wants two LABELS while here the
+ * second half of the contradiction is a FIELD — which is how 17 cards
+ * across three repos (2026-08-23 census: 6 objectstack, 10 objectui, 1
+ * cloud) sat in it with nothing reporting them. The measured origin is a
+ * state ROLLBACK that swaps the label and leaves the field: of the three
+ * rollback paths, only dead-claim reclamation ever named the assignee
+ * drop, so H8's and H19's remedy sentences now name it too (「同笔摘
+ * assignee」). ⚠️ The field carries two meanings — dead agent claims and
+ * genuine human ownership — and the row deliberately does NOT try to tell
+ * them apart: the ruling of 2026-08-23 puts the rule FIRST and any
+ * true-ownership exemption in an explicit marker LATER, never the other
+ * way round. It names the login instead, and states the asymmetric
+ * remedy (an agent may clear agent residue; ⛔ never a human's).
+ * H25 `pm:awaiting-maintainer` coexisting with another pm STATE label — the
+ * exclusivity half of the new state ruled in on 2026-08-23 (「可以新标
+ * 签,最好 pm: 开头」). The state exists because the board had no legal
+ * place for "everything mechanical is done, a human must now act":
+ * `needs-user-decision` is the ruling inbox (the ruling here is already
+ * given) and `pm:on-hold` requires a machine-fireable `Restart-when:`
+ * (H9), which this card can never have. Each forbidden pairing is a
+ * specific lie about which mechanism will release the card, and the row
+ * names the one it found. Written while the population is zero, which is
+ * the cheapest moment to pin a vocabulary. The label also joins H11's
+ * parked inventory, H13's state vocabulary and H22's residue set, so the
+ * new state is a first-class citizen of every reader rather than a hole
+ * four rows wide. ⛔ Deferred, declared rather than dropped: the SKILL.md
+ * state-model row (its protocol face) and applying the label to the
+ * specimen card, which is a seat's write.
+ *
+ * ## H26 — the block that nothing can ever release
+ *
+ * H26 an open `pm:blocked` card whose resolvable `Blocked-by:` target is OPEN
+ * and parked in a state that can never close — `pm:on-hold` or
+ * `needs-user-decision`, both by definition states a card sits in WHILE
+ * OPEN. The unlock predicate is "the target closed", so such a block is
+ * structurally indefinite and nothing reported it: the waiting card is
+ * perfectly well-formed (H4 clean, target resolves, target open, so H19
+ * clean, label correct), and H9 — the nearest neighbour — audits the HELD
+ * card rather than the waiting one. Six measured instances, all found by
+ * a human reading: two cloud cards on one hold parked since July, a third
+ * on another, and objectos's ENTIRE blocked inventory (2 of 2) waiting on
+ * its single unanswered decision card, which is also the only item in the
+ * fleet's decision inbox — one ruling clears that repo. A second leg on
+ * the same data flags a target that is itself `pm:blocked` (the wait is
+ * transitive: the measured chain was real one hop up and false two hops
+ * up, its target being an H19 finding on the same sweep) — it names the
+ * hop rather than chasing it, which would cost a request per hop and can
+ * cycle. FREE: H19 already resolves every distinct target, and a resolved
+ * target's labels rode in on a payload this sweep had already paid for.
+ * ⛔ Not a judgement that the block is wrong — waiting on a deferred card
+ * is sometimes right; the row says the wait has no releasing mechanism,
+ * which is a fact a human should be handed rather than discover.
+ * Deliberately NOT reported: a target labelled `pm:queue` while titled
+ * `[Decision]` (one of the six). That is a mislabelling, not a fact in
+ * the labels, and a title heuristic would make this sweeper guess at
+ * intent.
+ *
+ * ## H27 — the claim is perfect and the claimant is dead
+ *
+ * H27 an open `pm:dispatched` card whose claim is TEXTBOOK-CORRECT — assignee
+ * set, a first-line `Claim:` comment, a named branch that EXISTS on the
+ * remote — where that branch has not moved since the claim, no PR
+ * delivers the card, and the claim is older than the protocol's own
+ * ~24h stale line (`DEAD_CLAIM_STALE_HOURS`). This is the shape a dev
+ * agent that DIED leaves behind, and the measured cause arrives in
+ * batches: one shared-account capacity limit killed three concurrently
+ * dispatched agents at 05:50Z, leaving three cards on which every
+ * predicate in this file passed. ⭐ Its danger is not that it goes
+ * unreported but that it reads as HEALTHY: the next PM's round-open
+ * mutual-exclusion read looks for the latest non-self `Claim:` on a
+ * lane's dispatched cards, so a dead claim is read as a live claim by
+ * another session and the lane stays off the card — the protocol's own
+ * mutual-exclusion mechanism converting a corpse into a lane-wide block.
+ * H20 is the near neighbour and misses it BY CONSTRUCTION rather than by
+ * oversight: `.claude/agents/os-dev.md` makes pushing the empty branch
+ * the FIRST action of the task (a write-route probe), so a
+ * protocol-compliant agent that dies still leaves a ref and lands
+ * outside H20's no-ref-at-all population. The better the dev follows the
+ * protocol, the more invisible its death — which is why the two rows are
+ * disjoint by construction (H20 fires only when NO branch resolves, H27
+ * only when one does) and why neither could be widened into the other.
+ * ⛔ The threshold is QUOTED, not measured: the fleet has 2 liveness
+ * samples and that is not a distribution, so the row mechanizes SKILL.md's
+ * existing 死认领回收 line (「认领 >~24h」) — the same 24h the seat-post
+ * patrol already calls 「与既有回收线同一条」, one number in the protocol
+ * with two readers. ⚠️ Reporting is not reclaiming, and the row says so:
+ * the protocol's reclaim rule applies to a branch that does NOT exist and
+ * states 「有带提交活分支的认领永不回收」, so this row prescribes the
+ * three-state recovery INSPECTION (remote / container disk / gone, found
+ * work handed on flagged UNVERIFIED — `references/dispatch-runbook.md`)
+ * and never an assignee drop. It deliberately under-reports the dev that
+ * pushed one commit and then died (its branch moved after the claim), for
+ * the same reason — that is precisely the card the protocol protects.
+ *
+ * ## The close mechanism, measured (#8293)
+ *
+ * A half-delivered card (#8131) was closed `completed` two seconds after its
+ * PR (#8277) merged, although that PR's body opened with `Part of #8131` and
+ * carried an explicit warning against auto-closing it. The card was filed on
+ * the hypothesis that GitHub's *development-sidebar* link closes on merge
+ * "regardless of the description's wording", the keyword path having been ruled
+ * out by a scan for closing keywords.
+ *
+ * That hypothesis is REFUTED and the scan was wrong. The PR body's own warning
+ * sentence read, verbatim: "…the PM should close #8131 deliberately once #8136
+ * lands." GitHub's closing-keyword parser matches `close` + `#8131` and ignores
+ * every bit of the surrounding prose — the modal "should", the negation in the
+ * clause before it, the whole paragraph arguing the card must stay open. The
+ * sentence written to PREVENT the auto-close is what performed it.
+ *
+ * Four live readings pin the parser's actual shape, and each is a fixture in
+ * the self-test below:
+ *
+ * 1. keyword + `#N` in PROSE closes it — #8277's `close #8131`: closing link
+ * created, card closed on merge.
+ * 2. the SAME body's `#8136`, one clause later behind the word "once" and no
+ * keyword, got NO closing link and survived the merge untouched (it was
+ * closed deliberately 3.5 h later). Same body, same merge, opposite
+ * outcomes — which no sidebar-link hypothesis can explain, and which is
+ * the measurement that refutes it.
+ * 3. `Part of #N` alone does NOT close — #8261/#8103, the same round's other
+ * partial-delivery PR, which stayed open exactly as the protocol intends.
+ * The "non-uniformity" the card flagged as its lead is fully explained by
+ * the presence or absence of a keyword; nothing else differed.
+ * 4. keyword + `#N` inside INLINE CODE does NOT close — measured live on open
+ * PR #8454, whose body says "the dispatch asked for `Fixes #8284`" inside
+ * backticks while #8284 carries no closing link at all. This is why the
+ * predicate strips markdown code before scanning: without that step it
+ * flags the exact shape a careful author writes when EXPLAINING that they
+ * deliberately did not use the keyword.
+ *
+ * 5. keyword + `#N` inside a FENCED BLOCK does not close either — and the
+ * closing link is created at PR-OPEN time, not at merge. Both were settled
+ * by one controlled reading on 2026-08-13 (#8476 step 1). A throwaway PR
+ * (#8523, empty commit, closed unmerged) carried three arms in ONE body at
+ * one moment: `Fixes #8520` inside a fenced block, `Fixes #8521` inside an
+ * inline span, and a plain-prose `Fixes #8522`. Read seconds after that PR
+ * opened, `closed_by_pull_requests` was EMPTY on #8520 and on #8521, and
+ * carried #8523 (state OPEN) on #8522. The prose arm is the positive
+ * control that makes the two nulls readable at all: without it, "no link
+ * on the fenced arm" cannot be told apart from "closing links only
+ * materialize on merge".
+ *
+ * So the strip rule is a false negative in neither direction, and the merge is
+ * no part of the mechanism: the contradiction exists, and is fixable, from the
+ * moment a PR opens. That is what lets the same predicate back a PR-scoped
+ * BLOCKING gate (`scripts/check-partof-closing-keyword.mjs`, which imports
+ * `h7PartOfWithClosingKeyword` from here) as well as this report-only sweep.
+ * H7 stays in the sweep regardless — patrol coverage of PRs whose CI predates
+ * that gate — and nothing about this file's report-only contract changes.
+ *
+ * Scope of the remedy that lands HERE: this is the report-only detector, not a
+ * suppression. Suppressing at source means telling authors not to put a closing
+ * keyword next to another card's number, which is protocol text living outside
+ * `scripts/pm/**` — deliberately left to the card that owns that text.
+ *
+ * The body half of H5 (the 「当前 PM」 paragraph) is NOT machine-checked here:
+ * seat-sticker bodies are prose with no pinned grammar, and a fuzzy parser
+ * would report phantom desyncs — the #4690 shape in mirror image. The
+ * title/assignee pair is the mechanical half; the sweep prints the sticker
+ * URL so the patrol reads the body itself.
+ *
+ * ## Transport prerequisite — MEASURED per run, never assumed (#7412)
+ *
+ * Live mode talks to `api.github.com` over node's global `fetch`, and that needs
+ * two things this repo's agent containers do NOT uniformly provide:
+ *
+ * 1. a route to `api.github.com` from NODE. Node's `fetch` (undici) ignores
+ * `HTTPS_PROXY`, so it does not share the path `curl`, `gh` and the
+ * `mcp__github__*` tools take. A container where `curl https://api.github.com`
+ * answers 200 can be one where this script reaches nothing, and the reverse
+ * also occurs. `curl` is therefore NOT a valid pre-flight for this script;
+ * the probe below is.
+ * 2. either NO token, or a token that really is a GitHub credential.
+ * `GITHUB_TOKEN` / `GH_TOKEN` being SET does not make them GitHub tokens:
+ * in agent containers both are commonly the agent proxy's own 14-character
+ * `prox…` placeholder. Sending that as a Bearer earns a hard 401 — strictly
+ * WORSE than sending nothing, because the token fallback at `TOKEN` turns a
+ * container where anonymous access WOULD have worked into one where the
+ * sweep cannot start.
+ *
+ * The paragraph this replaces claimed "unauthenticated works at 60 req/h". That
+ * is not a fact about this script's environment. Four container classes have
+ * been measured and no two agree:
+ *
+ * PM seat session (#7412 as filed) — proxy denies the host (curl 403 with and
+ * without the token), node fetch 401. GitHub access is MCP-only there, so
+ * live mode cannot run at all.
+ * Triage Routine (#7412 comment, 2026-08-11) — host reachable AND the injected
+ * `GITHUB_TOKEN` is a real credential (`/rate_limit` 200, 15000 core
+ * quota). Live mode runs fully.
+ * Cloud dev session (this change's own measurement, 2026-08-11) — node fetch
+ * reaches the host, but NEITHER identity can read the board: the token is
+ * the `prox…` placeholder (401 Bad credentials), and anonymous is 403
+ * `API rate limit exceeded for ` because the 60 req/h anonymous quota
+ * is counted per EGRESS IP and was already spent by other containers
+ * behind the same NAT. Meanwhile `curl` answered 200 BOTH ways, because it
+ * honours HTTPS_PROXY and the proxy substitutes a real credential — the
+ * misleading pre-flight point 1 warns about, measured.
+ * Proxy-mediated cloud session (#9946, measured 2026-08-19) — the same
+ * container class as above, but with node routed THROUGH the agent proxy
+ * (`NODE_OPTIONS=--use-env-proxy`, which is what a seat is told to use for
+ * live GitHub reads from node). The proxy substitutes a real credential
+ * for the placeholder, so ACCOUNT-scoped endpoints answer as GitHub and
+ * the identity is genuine — while every REPO-scoped endpoint is refused by
+ * the proxy itself. Live mode cannot run, and the reading that used to say
+ * it could is the reason this probe now has a second stage.
+ *
+ * That fourth class is the one that broke the probe, and its mechanism is worth
+ * stating exactly, because the obvious hypothesis is WRONG and was measured to
+ * be wrong. `/rate_limit` there is not the proxy fabricating a quota: it carries
+ * `server: github.com`, a real `x-github-request-id`, and a 15000-limit core
+ * quota, and `GET /user` on the same transport returns the real login. GitHub
+ * really did answer, and the credential really does authenticate. What the proxy
+ * intercepts is the OTHER side — the repo-scoped reads:
+ *
+ * GET /rate_limit -> 200, remaining 14979, server: github.com
+ * GET /user -> 200, the real login
+ * GET /repos/objectstack-ai/objectstack -> 403, NO x-ratelimit-* headers,
+ * NO server: github.com, and a body
+ * from the proxy vendor, not GitHub
+ *
+ * So no amount of care applied to `/rate_limit` can classify this container: the
+ * account-scoped observation is genuinely healthy, and is identical to the
+ * Routine runner's. Only a REPO-scoped read separates them, which is why the
+ * probe now takes one — and why `GET /user` would not have done: it is
+ * account-scoped and answers 200 here. The discriminator is not "a real
+ * endpoint", it is "the KIND of endpoint the sweep actually needs".
+ *
+ * Two things this fourth class deliberately does NOT do, both because the file
+ * has already recorded the decision against them:
+ * - it does not pattern-match the proxy's refusal body. That body is a vendor
+ * string that can change under us, and this classifier stays narrow about
+ * what it will name (the `looksLikeStaleWorkspaceDist` posture below).
+ * - it does not read the 14-character `prox…` token shape as disqualifying.
+ * Token shape enriches wording and never gates a request — an unknown future
+ * prefix must still be SENT so GitHub gets to be the judge — and in this very
+ * class that placeholder IS swapped for a working credential, so the shape
+ * would have mispredicted the outcome in both directions.
+ * What it matches instead is a pure structural contradiction, with no vendor
+ * string and no shape test in it: the quota endpoint reports thousands of core
+ * requests available, and a core request just got refused.
+ *
+ * The third class also produced the trap worth naming: `/rate_limit` is EXEMPT
+ * from the limit it reports. With the quota spent it still answers 200 (carrying
+ * `x-ratelimit-remaining: 0`) while every other endpoint answers 403 — so a
+ * probe that reads only the status code cheerfully green-lights a sweep that
+ * cannot make one request. The first draft of the probe below did exactly that.
+ * `probeIsUsable` is that lesson, and the self-test pins it.
+ *
+ * So the script PROBES before it sweeps, in two stages, and the staging is the
+ * whole cost story:
+ *
+ * 1. `GET /rate_limit` — costs no core quota, and is what separates the first
+ * three classes from each other (unreachable / bad credential / exhausted).
+ * Any verdict OTHER than `reachable` stops here, exactly as before.
+ * 2. `GET /repos/{OWNER_REPO}` — fired ONLY when stage 1 came back
+ * `reachable`, i.e. only on the path that used to return a green. Costs
+ * one core request, accepted deliberately (#9946): a probe whose answer
+ * does not predict what the sweep can do is worth less than the request it
+ * saves, and the sweep it green-lights spends a request per label page
+ * anyway.
+ *
+ * Consequence worth being explicit about: the three FAILING classes cost exactly
+ * what they cost before (one request, no core quota), and the healthy path costs
+ * one core request more than it did. Nothing on a failing path got slower or
+ * more expensive.
+ *
+ * A failed probe prints a classified PREREQUISITE NOT MET report naming
+ * which of the two requirements is unmet and the one command that satisfies it —
+ * never a sweep result. `--probe` runs that check alone, which is what a seat
+ * should use to answer "can live mode run in THIS container?".
+ *
+ * The repo-scoped stage is an OPTIONAL observation on the pure classifier, not a
+ * required one: `classifyTransportProbe` handed no `repo` reading classifies the
+ * account-scoped evidence alone, exactly as it always did. That keeps the other
+ * importer of this classifier (`scripts/pm/ci-failure.mjs`, which reads the
+ * Actions API and gathers its own account-scoped observations) behaving
+ * identically. The guarantee that THIS script never green-lights on stage 1
+ * alone therefore lives in `probeTransport` / `needsRepoProbe`, which is where
+ * the gathering policy belongs — and it is pinned in the self-test.
+ *
+ * Deliberately NOT decided here: whether these scripts should grow an MCP-backed
+ * transport or a required-real-token doctrine. That depends on where
+ * `scripts/pm/**` live modes are meant to execute, which is a maintainer call
+ * (#7412 triage, explicitly out of scope). This change only stops the file from
+ * lying about the transport it has. It does not drop, substitute or re-route the
+ * token, so a container where the sweep worked before works identically after.
+ *
+ * REST only, never GraphQL (Operational notes 3: the loop's hot path stays on
+ * the core quota).
+ *
+ * ## Exit codes
+ *
+ * 0 the sweep completed — 0 or 40 findings alike (report-only, see above).
+ * 3 PREREQUISITE NOT MET — a classified transport failure. Nothing was swept,
+ * and the report says so instead of implying a clean board.
+ * 2 the sweep could not run for a reason this file cannot classify. The
+ * pre-existing catch-all, kept so an unfamiliar failure stays loud (#4690).
+ *
+ * 2 and 3 are both non-zero, so any wrapper reading non-zero as failure behaves
+ * exactly as before. The split exists so a patrol can tell "this container was
+ * never able to run the live sweep" (3 — expected, go run it elsewhere) from
+ * "something broke" (2 — investigate).
+ */
+
+import process from 'node:process';
+import { execFileSync } from 'node:child_process';
+import { isEntrypoint } from '../invoked-as.mjs';
+
+/**
+ * The repo this file sweeps when nothing says otherwise. It is a FALLBACK for a
+ * seat's terminal, never the answer on a runner — see `resolveSweepRepo`.
+ */
+export const DEFAULT_SWEEP_REPO = 'objectstack-ai/objectui';
+
+/** `owner/name`, GitHub's own character set for both halves. */
+export const SWEEP_REPO_SHAPE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
+
+/**
+ * WHICH repo this sweep reads — resolved, and the resolution is the whole
+ * point of the parameterisation (#11217).
+ *
+ * ## The trap this closes, measured
+ *
+ * The patrol pair (this file + `.github/workflows/half-state-patrol.yml`) is
+ * installed in objectstack only, so 37 of the fleet's 59 open `pm:blocked`
+ * cards had never been machine-swept, and a hand-run of H19's predicate over
+ * objectui's blocked inventory found SEVEN blocks whose blocker had already
+ * closed — 58% of that repo's machine-readable blocks were false, some for a
+ * week. The difference was never discipline: one repo has a caller and three
+ * do not.
+ *
+ * The fix is adoption by COPY — a sibling repo takes both files verbatim, runs
+ * them with its OWN `GITHUB_TOKEN` against its own board, and writes its own
+ * anchor (the route ruled at grading: per-repo installs, zero new credentials,
+ * ⛔ never a matrix with a cross-repo token). And a hardcoded default is
+ * exactly what makes "verbatim" unsafe: a copy of this file in objectui, run
+ * with no `PM_SWEEP_REPO`, would cheerfully sweep OBJECTSTACK and write the
+ * findings into objectui's anchor — a full, green, entirely wrong report, whose
+ * only symptom is card numbers that do not exist in the repo reading them.
+ *
+ * So the resolution order is:
+ *
+ * 1. `PM_SWEEP_REPO` — the explicit override, unchanged, and still first: a
+ * seat pointing this at another board is a deliberate act.
+ * 2. `GITHUB_REPOSITORY` — what Actions sets on every runner, i.e. the repo
+ * the workflow is INSTALLED IN. This is the line that makes a verbatim
+ * copy correct by default, and it is why the default below can never be
+ * reached on a runner.
+ * 3. `DEFAULT_SWEEP_REPO` — a seat's terminal, where neither is set.
+ *
+ * The objectstack leg is unchanged by construction: its runner sets
+ * `GITHUB_REPOSITORY=objectstack-ai/objectstack`, which is the same string the
+ * hardcoded default carried, so every request path is byte-identical. The
+ * workflow ALSO passes `PM_SWEEP_REPO: ${{ github.repository }}` — belt and
+ * braces, and it keeps the wiring visible where a reader of the workflow looks.
+ *
+ * A malformed value is REFUSED rather than silently replaced by the default:
+ * substituting a different board for the one the caller named is how a report
+ * about the wrong repo gets written, which is the disease above. The refusal
+ * happens at the CLI so that importers of this module (`ci-failure.mjs` takes
+ * the transport classifier) are unaffected by a variable they never read.
+ *
+ * @param {Record} [env]
+ * @returns {{ repo: string, source: string, valid: boolean }}
+ */
+export function resolveSweepRepo(env = {}) {
+ const candidates = [
+ ['PM_SWEEP_REPO', env.PM_SWEEP_REPO],
+ ['GITHUB_REPOSITORY', env.GITHUB_REPOSITORY],
+ ];
+ for (const [source, raw] of candidates) {
+ const value = String(raw ?? '').trim();
+ if (!value) continue;
+ return { repo: value, source, valid: SWEEP_REPO_SHAPE.test(value) };
+ }
+ return { repo: DEFAULT_SWEEP_REPO, source: 'default', valid: true };
+}
+
+const SWEEP_REPO = resolveSweepRepo(process.env);
+const OWNER_REPO = SWEEP_REPO.repo;
+const API = 'https://api.github.com';
+const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '';
+
+// ---------------------------------------------------------------------------
+// Predicates — pure functions over the REST issue shape, so the self-test can
+// drive them with fixtures and the live sweep stays a thin fetch loop.
+// ---------------------------------------------------------------------------
+
+export function labelNames(issue) {
+ return (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name));
+}
+
+/**
+ * The "awaiting a manual maintainer action" state (#11196 fix 5, maintainer
+ * ruling 2026-08-23, verbatim: 「可以新标签,最好 pm: 开头」 — the spelling is
+ * finalized here, in the implementing PR the ruling sent it to).
+ *
+ * It exists because the board had NO legal state for its shape, and the two
+ * adjacent states are both wrong in a way that made a card oscillate between
+ * them (the measured specimen, #7276: everything mechanical is done and the
+ * remaining action is the maintainer clicking through a Routines UI):
+ *
+ * `needs-user-decision` is the DECISION inbox — a question awaiting an
+ * answer. Here the decision is already made; what is
+ * outstanding is an ACT, and parking it in the inbox
+ * makes the inbox lie about how many rulings are owed.
+ * `pm:on-hold` requires a machine-fireable `Restart-when:` line
+ * (H9), and `Restart-when: manual — …` counts as
+ * MISSING there by deliberate design. A hold is the
+ * one state this card can never be well-formed in.
+ *
+ * ⛔ It is defined here, at the top of the predicates, and not beside the item
+ * that introduced it: four separate readers key on it (H11's parked inventory,
+ * H13's state vocabulary, H22's residue set, H25's exclusivity) and a module
+ * constant they all import cannot drift the way four string literals would —
+ * which is the failure family this whole file belongs to.
+ *
+ * Deliberately NOT decided here, and declared rather than dropped: the
+ * SKILL.md state-model table row (its protocol face — who applies it, who
+ * removes it, what a card in it owes) lands separately, and applying the label
+ * to the specimen card is the seat's write, never this script's.
+ */
+export const AWAITING_MAINTAINER_LABEL = 'pm:awaiting-maintainer';
+
+export function h1DispatchedNoAssignee(issue) {
+ const labels = labelNames(issue);
+ return labels.includes('pm:dispatched') && (issue.assignees ?? []).length === 0;
+}
+
+/**
+ * The claim-comment marker, shared by the two items that read it — H2 ("is
+ * there a claim at all") and H20 ("what does the current claim NAME"). One
+ * constant rather than two copies on purpose: a marker that drifts between
+ * readers produces the worst possible pair of answers, where one item calls a
+ * card claimed and the other calls the same comment prose.
+ *
+ * The strictness either side of it is H2's, unchanged and deliberate (#7488):
+ * an OPTIONAL leading blockquote `>` because SKILL.md step 4's own claim
+ * template is a blockquote, and the line must BEGIN with the word so ordinary
+ * prose containing "claim" is not a claim comment. No `g` flag — a shared
+ * regex carrying `lastIndex` between callers is a state bug waiting for its
+ * second reader.
+ */
+export const CLAIM_COMMENT_MARKER = /^\s*>?\s*Claim(?:ed)?\s*[::]/mi;
+
+export function h2AssigneeNoClaimComment(issue, commentBodies) {
+ const labels = labelNames(issue);
+ const pmTracked = labels.some((l) => l === 'pm:queue' || l === 'pm:dispatched');
+ if (!pmTracked || (issue.assignees ?? []).length === 0) return false;
+ return !commentBodies.some((b) => CLAIM_COMMENT_MARKER.test(b ?? ''));
+}
+
+export function h3QueueAndDispatched(issue) {
+ const labels = labelNames(issue);
+ return labels.includes('pm:queue') && labels.includes('pm:dispatched');
+}
+
+// ---------------------------------------------------------------------------
+// The `Blocked-by:` COMMENT channel — read by H4 and by the H14 index alike.
+//
+// Since #10403 the comment channel is no longer `Blocked-by:`-only: H9's
+// `Restart-when:` reads it too, through its own gated fallback in the sweep
+// (see the H9 section for the incident that closed the asymmetry). The two
+// directives share one anchor AND one channel contract now, so "H4 tolerates
+// X" and "H4 reads comments" both generalise to H9 — the boundary notes below
+// apply to either directive's comment read.
+//
+// ## Stated boundary: no read-closure cut-off, because REST carries none
+//
+// The seat-post protocol's read-closure rule is 「只读晚于正文最后编辑时间的
+// 评论」 — comments NEWER than the body's last edit — on the stated ground that
+// 「两个时刻都是平台盖章的硬读数,比对即得」. One of those two stamps is not
+// available here. A comment's `created_at` is on every row, but an ISSUE's
+// body-edit time is on no REST payload at all: the issues API carries
+// `created_at`/`updated_at` only, body edit history lives behind GraphQL
+// `userContentEdits`, and this file is REST-only by a standing operational
+// note (the loop's hot path stays on the core quota). The issue timeline
+// endpoint does not record body edits either.
+//
+// So the cut-off is NOT implemented, rather than implemented against a proxy.
+// The one proxy in reach — `updated_at` — is worse than nothing: it bumps when
+// a comment is POSTED, so "newer than the body's last edit" would be false for
+// every comment ever written, and the fallback would read nothing while
+// looking like it read. A check that cannot fail is the shape this file exists
+// to catch, not to add.
+//
+// Reading the whole first page instead errs in ONE direction, and it is the
+// safe one. Extra evidence can only ADD `Blocked-by:` edges: it can clear an
+// H4 row (the duty really was discharged, in the other channel) and it can
+// clear an H14 stale row (something really is waiting), which is the
+// recalibration #9948 ruled for. What it cannot do is invent a card's silence.
+// The residual cost is a genuinely OBSOLETE comment edge — a line written, then
+// the body rewritten to drop the dependency — which needs the source card to
+// still be open, still labelled, and still body-clean; in that state the source
+// card is itself mis-stated, and surfacing its edge is not the worse error.
+// The seat rule's own rationale (「评论无界增长」, a token tax) does not bite
+// here either: the fetch is gated to a bounded candidate set and capped at one
+// page, the same trade H2, H16 and H17 make.
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// DECORATED DIRECTIVE LINES — the shared anchor for `Blocked-by:` (H4, H14)
+// and `Restart-when:` (H9). #10102, ruled option C on 2026-08-20.
+//
+// ## What the old anchors required, and the two harms that cost
+//
+// Both directives are machine-readable lines written by hand IN MARKDOWN, so
+// authors code-format them: 「`Restart-when: …`」 is the natural spelling for a
+// line whose whole purpose is that a machine greps it. The original anchors
+// (`^\s*` for H9, `^[ \t]*` for H4/H14) accept only whitespace before the key,
+// and a backtick is not whitespace — so a DECORATED directive read as an
+// ABSENT one. Measured twice on 2026-08-20, once on each predicate, failing in
+// OPPOSITE directions:
+//
+// - #9591 — 「`Restart-when: the v18 major development cycle opens …`」 in
+// the body. H9 reported "no `Restart-when:` body line" against a legal,
+// maintainer-commissioned hold, and H9's own remedy text told the reading
+// seat to close it `not planned`. LOUD, and destructive if believed.
+// - #10063 — 「`Blocked-by: #9612`」 in the body. H4 reported the line absent
+// in both channels, and the unlock sweep — which greps that same literal —
+// could never have returned the card. SILENT and permanent: no row, no
+// alarm, no expiry. That half has no detection channel of its own, which
+// is why documenting the strictness (option B) was not enough.
+//
+// Nothing in either documented predicate ever required the line to start bare,
+// and no self-test covered a decorated one: an untested gap, not deliberate
+// strictness.
+//
+// ## What is tolerated
+//
+// In front of the key: indentation, ONE `-`/`*` list bullet, and any run of
+// inline-code backticks or `**` bold markers. A `**` may also sit between the
+// key word and its colon (`**Blocked-by**:`) or immediately after the colon
+// (`**Blocked-by:**`), because that is what a bolded directive actually looks
+// like; likewise a closing backtick when only the key was code-formatted
+// (「`Blocked-by:` #123」). A trailing marker MATCHING one that opened the line
+// is stripped off the captured value, so 「`Restart-when: closed acme/w#9`」
+// yields the same value the bare line yields — the trailing backtick never
+// reaches the `manual` test or the ref scan.
+//
+// ## What is deliberately NOT tolerated — the strictness that stays
+//
+// - The KEY's spelling and case. The unlock scan greps the literal, so
+// `restart-when:` is a line the machinery cannot see and must still be
+// flagged. Decoration is about what SURROUNDS the directive; the directive
+// itself stays byte-stable. A decorated lowercase key is still a finding.
+// - A bullet needs its trailing space (`- Blocked-by:`, never
+// `-Blocked-by:`) — that space is what makes it a list bullet rather than
+// the first character of a word.
+// - The whole prefix stays anchored to the line start, so a mid-sentence
+// mention 「seats park the `Blocked-by: #1` line in comments」 is prose,
+// not a directive — the property the old anchors were really protecting.
+// - A line whose value is nothing but decoration (「`Restart-when:`」) still
+// counts as valueless. Stripping can empty a value, so the extractor drops
+// empties AFTER cleaning rather than trusting the match to be non-empty.
+//
+// The residual is the shape this relaxation cannot reach — a key the grep
+// itself cannot see — and H9's finding text now names that possibility
+// explicitly instead of prescribing a close.
+// ---------------------------------------------------------------------------
+
+/** Decoration markers tolerated around a directive: inline code and bold. */
+const DIRECTIVE_MARKER = String.raw`(?:\`|\*\*)`;
+
+/**
+ * Everything a directive line may carry BEFORE its key, captured so the value
+ * cleaner can tell which trailing marker would be a MATCHING one.
+ *
+ * `[ \t]*` rather than `\s*` at the head, for both predicates now: `\s`
+ * matches newlines, so with a /g/m extractor a run of blank lines could let a
+ * match begin on an earlier line than the one it reads. H9 used `^\s*` and H4
+ * `^[ \t]*`; the strict one is right and loses no real match, since `^` under
+ * /m already anchors at the directive's own line start.
+ */
+const DIRECTIVE_PREFIX = String.raw`[ \t]*(?:[-*][ \t]+)?((?:${DIRECTIVE_MARKER}[ \t]*)*)`;
+
+/** The key itself, with the bold/code markers a decorated one may carry. */
+const directiveKey = (key) => String.raw`${key}(?:\*\*)?:(?:${DIRECTIVE_MARKER})?`;
+
+/**
+ * Drop the trailing decoration that MATCHES what opened the line.
+ *
+ * "Matching" is load-bearing: a value that legitimately ends in a backtick
+ * (「Blocked-by: #1 `see note`」) opened bare and keeps every byte, while a
+ * line that opened with a backtick gives its closing one back. Looping covers
+ * the nested form (「**`Blocked-by: #1`**」).
+ */
+function stripMatchingDecoration(value, opener) {
+ let out = String(value).trim();
+ for (;;) {
+ if (opener.includes('`') && out.endsWith('`')) {
+ out = out.slice(0, -1).trim();
+ continue;
+ }
+ if (opener.includes('**') && out.endsWith('**')) {
+ out = out.slice(0, -2).trim();
+ continue;
+ }
+ break;
+ }
+ return out;
+}
+
+/**
+ * Every value carried by a `:` directive line in this text, decoration
+ * removed, empties dropped — the one reader H9 and the `Blocked-by:` index
+ * share, so a decoration tolerated for one is tolerated for both.
+ *
+ * Exported for the self-test: the cleaning step has no other observable
+ * surface (H9 reports a sentence, the index reports refs), and a value that
+ * silently kept its trailing backtick is exactly the regression this shares a
+ * cause with.
+ *
+ * @param {string} text
+ * @param {'Blocked-by'|'Restart-when'} key
+ * @returns {string[]}
+ */
+export function directiveValues(text, key) {
+ const re = new RegExp(`^${DIRECTIVE_PREFIX}${directiveKey(key)}[ \\t]*(\\S.*)$`, 'gm');
+ const out = [];
+ for (const m of String(text ?? '').matchAll(re)) {
+ const value = stripMatchingDecoration(m[2], m[1]);
+ if (value) out.push(value);
+ }
+ return out;
+}
+
+/**
+ * Is there a machine-readable `Blocked-by:` line in this text?
+ *
+ * H4's presence test — the BODY channel and the COMMENT channel ask the same
+ * question of the same shape, rather than two subtly different ones.
+ *
+ * A presence test, deliberately, and NOT `blockedByTargets(...).length > 0`:
+ * the two readers of this line answer different questions and must keep
+ * doing so. H4 asks 「did the author leave the machine anything at all」 —
+ * `Blocked-by: TBD` and `Blocked-by: objectstack-ai/objectui#4356` both
+ * discharge the duty — while the index asks 「which LOCAL open card does this
+ * wait on」 and correctly extracts nothing from either. Collapsing them would
+ * make H4 fire on a card whose cross-repo blocker is stated perfectly well.
+ *
+ * It is also not `directiveValues(...).length > 0`, for one preserved
+ * behaviour: the `\s*` after the key spans newlines, so a value written on the
+ * FOLLOWING line has always cleared H4, and tightening that here would fire
+ * new rows on live cards for a reason this card never ruled on. What the
+ * decoration work adds is the leading prefix, a closing marker, and the
+ * requirement that SOMETHING which is not itself decoration follows — without
+ * that last clause 「`Blocked-by:`」 would read as a line whose value is its
+ * own closing backtick, turning a valueless line into a false clear.
+ */
+const BLOCKED_BY_PRESENT = new RegExp(
+ `^${DIRECTIVE_PREFIX}${directiveKey('Blocked-by')}\\s*(?:[\`*]+[ \\t]*)?[^\\s\`*]`,
+ 'm',
+);
+
+export function hasBlockedByLine(text) {
+ return BLOCKED_BY_PRESENT.test(text ?? '');
+}
+
+/**
+ * Which cards are worth a `Blocked-by:` comment fetch.
+ *
+ * Exported for the same reason `h17NeedsComments` and `h16NeedsDetail` are: a
+ * policy that decides what gets READ AT ALL is where a silent hole would live,
+ * so it is pinned by the self-test rather than buried in the sweep loop.
+ *
+ * Gated on a CLEAN BODY first, then on state:
+ *
+ * - `pm:blocked` — the population H4 judges, and the edge SOURCES the index
+ * is missing (a seat parks the line in a comment; the body stays clean).
+ * - `pm:blocking` — the population H14's stale direction judges. Their own
+ * comments matter as index sources too: a `pm:blocking` card that is
+ * itself waiting on another `pm:blocking` card contributes the very edge
+ * that defends that other card from a stale verdict.
+ *
+ * A card whose body already carries the line is never fetched: its duty is
+ * discharged in the channel the machinery already reads, and the census bound
+ * this gate exists to honour (~2/3 of blocked cards are body-clean) is exactly
+ * the complement.
+ */
+export function needsBlockedByComments(issue) {
+ if (hasBlockedByLine(issue?.body)) return false;
+ const labels = labelNames(issue ?? {});
+ return labels.includes('pm:blocked') || labels.includes('pm:blocking');
+}
+
+/**
+ * Every `Blocked-by:` ref carried by a card's comments, in order.
+ *
+ * The comment channel read the way the INDEX reads it — `blockedByTargets`
+ * per comment body, this file's one parser for the line, so cross-repo and
+ * self-reference filtering downstream behave identically whichever channel a
+ * ref arrived in.
+ */
+export function commentBlockedByTargets(commentBodies) {
+ const out = [];
+ for (const body of commentBodies ?? []) out.push(...blockedByTargets(body));
+ return out;
+}
+
+/**
+ * H4 — null when clean, else the finding sentence.
+ *
+ * ## Two channels, one duty (#8941 / #10061)
+ *
+ * The label's machine half is a `Blocked-by:` line, and the reason it must
+ * exist is the unlock sweep: without one, nothing can ever return the card.
+ * But seats deliberately park that line in a COMMENT rather than the body —
+ * the MCP body-escaping hazard (#8813) makes a body rewrite the riskier
+ * write — and 26 of 40 blocked cards measured on 2026-08-19 were body-clean
+ * for exactly that reason. Reading the body alone reported every one of them
+ * as a card that had left the machine nothing, which is false: the duty was
+ * met, in the other channel. So a comment carrying the line CLEARS H4, and the
+ * finding sentence names both channels so a reader can tell which one to fix.
+ *
+ * ## Three input states, never two (#4690)
+ *
+ * `commentBodies` distinguishes them deliberately:
+ *
+ * - `undefined` — the channel was not consulted (a caller reading bodies
+ * only). The sentence claims nothing about comments, and this is exactly
+ * the pre-#10061 reading, preserved rather than silently upgraded.
+ * - `null` — consulted and UNREADABLE. The row still FIRES, because going
+ * quiet here would make a transport failure shrink the patrol below where
+ * it stood before the fallback existed; the sentence says the second
+ * channel could not be read instead of asserting that it is empty.
+ * - `string[]` — read. Both channels judged, for real.
+ *
+ * The unreadable case fires where H14's stale direction goes QUIET on the same
+ * failure, and the asymmetry is deliberate, not an inconsistency. H4's remedy
+ * is "add a line" — idempotent, cheap, and harmless if a comment already had
+ * one. H14-stale's remedy is "drop a label the selection order depends on" —
+ * destructive, and the measured false positive this whole fallback exists to
+ * end. An unreadable reading must surface on the cheap side and must never
+ * drive the expensive one.
+ */
+export function h4BlockedNoBlockedBy(issue, commentBodies) {
+ if (!labelNames(issue).includes('pm:blocked')) return null;
+ if (hasBlockedByLine(issue.body)) return null;
+ const remedy =
+ ' The unlock sweep greps this literal line, so without it in SOME channel nothing can ' +
+ 'ever return this card to the queue — the block outlives its blocker in silence.';
+ if (commentBodies === undefined) {
+ return '`pm:blocked` without a `Blocked-by:` body line.' + remedy;
+ }
+ if (commentBodies === null) {
+ return (
+ '`pm:blocked` without a `Blocked-by:` body line, and this card\'s comment thread could ' +
+ 'NOT be read this sweep — so the second channel (a `Blocked-by:` line parked in a comment, ' +
+ 'which is how most blocked cards on this board state it) is unjudged, not empty. Read the ' +
+ 'thread by hand before acting: an unreadable channel is not an absent one (#4690).' + remedy
+ );
+ }
+ if (commentBodies.some((body) => hasBlockedByLine(body))) return null;
+ return (
+ '`pm:blocked` with a `Blocked-by:` line in NEITHER channel — not in the body, and not in any ' +
+ 'comment on the thread (both were read). Either channel discharges the duty: seats park the ' +
+ 'line in a comment on purpose, because rewriting a body through the MCP escaping hazard ' +
+ '(#8813) is the riskier write. So this is not a formatting nit — no machine reader anywhere ' +
+ 'knows what this card is waiting for.' + remedy
+ );
+}
+
+// H5 returns null (in sync), a string naming the desync, or undefined when the
+// title doesn't parse as a seat sticker (reported as its own finding — an
+// unparseable status board row is a desync of the board itself).
+export function h5SeatStickerDesync(issue) {
+ const m = /^\[PM seat\]\s*(.*?)\s*—\s*(.*)$/u.exec(issue.title ?? '');
+ if (!m) return 'title does not match 「[PM seat] — 」';
+ const status = m[2].trim();
+ const assignees = (issue.assignees ?? []).map((a) => a.login);
+ if (status.startsWith('🟢')) {
+ // The login is only the FIRST whitespace-delimited token after the emoji.
+ // Everything past it — the `(session_…)` parenthetical every active seat
+ // title carries by protocol, and any `·`-separated suffix (in-flight
+ // counts, queue depth, a body-edit timestamp) — is display, not identity,
+ // and must never be compared against the assignee list (#9926: this used
+ // to take the WHOLE remainder as the holder, so a consistent seat post
+ // like `🟢 os-warren (session_…)` mismatched `[os-warren]` on every
+ // sweep).
+ const holder = status.replace('🟢', '').trim().split(/\s+/u)[0] ?? '';
+ if (holder === 'Routine') return null; // Routine seats keep assignee empty by design
+ if (!assignees.includes(holder)) {
+ return `title says 🟢 ${holder} but assignees are [${assignees.join(', ') || 'none'}]`;
+ }
+ return null;
+ }
+ if (status.startsWith('⏳')) {
+ return assignees.length > 0
+ ? `title says ⏳ vacant but assignees are [${assignees.join(', ')}]`
+ : null;
+ }
+ if (status.startsWith('⏸️') || status.startsWith('⏸')) return null; // paused: assignee state is the maintainer's call
+ return `unrecognized status word 「${status}」`;
+}
+
+// H6 — soft size bound on seat-sticker bodies (#7583). Report-only like every
+// other item; the threshold is deliberately generous (the compacted #6019 body
+// is ~4.5 KB, the pathological one was ~61 KB) so a healthy six-section body
+// never trips it. Byte length, not code points: the read-limit failure this
+// guards against is byte-sized.
+export const SEAT_BODY_SOFT_LIMIT = 10_000;
+
+export function h6SeatBodyOversized(issue, limit = SEAT_BODY_SOFT_LIMIT) {
+ if (!labelNames(issue).includes('pm:seat')) return false;
+ return Buffer.byteLength(issue.body ?? '', 'utf8') > limit;
+}
+
+// ---------------------------------------------------------------------------
+// H7 — `Part of #N` contradicted by a closing keyword on the same PR body.
+//
+// Pure string predicates over a PR body, so the self-test drives them with the
+// real specimens from #8293 rather than with invented ones.
+// ---------------------------------------------------------------------------
+
+/**
+ * GitHub's closing keywords, exactly — `close`/`closes`/`closed`,
+ * `fix`/`fixes`/`fixed`, `resolve`/`resolves`/`resolved`.
+ *
+ * The `\b` after each alternative is load-bearing in the direction of FEWER
+ * findings: `closing` and `fixing` are NOT closing keywords, and both occur
+ * constantly in exactly the prose this predicate reads ("merging this and
+ * closing #8284 would drop the severe half" — open PR #8454, which must not be
+ * flagged for that sentence). A fresh regex per call: a module-level `/g`
+ * literal shared between `matchAll` calls is a `lastIndex` bug waiting to be
+ * introduced by the next reader.
+ *
+ * The separator is HORIZONTAL whitespace only (plus GitHub's optional colon),
+ * which is a deliberate narrowing in both directions. Allowing `\s*` lets the
+ * keyword bind to a reference on a LATER line — and since `stripMarkdownCode`
+ * blanks code lines rather than deleting them, a `close` before a fenced block
+ * then spliced onto a `#N` after it, producing a finding for two tokens that
+ * were never adjacent in the source. The self-test pins that splice. The cost
+ * is a keyword separated from its reference by a line break, a shape none of
+ * the measured specimens use.
+ */
+function closingKeywordRe() {
+ return /\b(clos(?:e|es|ed)|fix(?:es|ed)?|resolv(?:e|es|ed))\b[ \t]*:?[ \t]*#(\d+)\b/gi;
+}
+
+function partOfRe() {
+ return /\bPart of\s+#(\d+)\b/gi;
+}
+
+/**
+ * Blank out markdown code — fenced blocks and inline spans — so the scan sees
+ * only the text GitHub's own reference parser acts on.
+ *
+ * MEASURED for inline spans (#8293, reading 4): open PR #8454 carries
+ * "`Fixes #8284`" in backticks and #8284 has NO closing link, so GitHub does
+ * not fire inside a code span. Skipping this step would make the predicate
+ * report every author who correctly explains that they did NOT use the keyword
+ * — turning the guard into noise on precisely the careful PRs.
+ *
+ * MEASURED for fenced blocks too, as of 2026-08-13 (#8476 step 1): a throwaway
+ * PR (#8523) carried `Fixes #8520` inside a fence, `Fixes #8521` inside an
+ * inline span and a plain-prose `Fixes #8522` in ONE body, and seconds after it
+ * opened — unmerged — `closed_by_pull_requests` was empty on the fenced and
+ * inline targets while the prose target already carried the link. The prose arm
+ * is the positive control: it proves the link mechanism was live and readable
+ * during the reading, so the two nulls mean "the parser does not fire here" and
+ * not "links appear only on merge".
+ *
+ * That closes the one unknown this doc used to carry (the fence rule was
+ * previously taken on the argument that PR bodies routinely quote whole other
+ * bodies, templates and logs, and scanning those would bury real findings under
+ * quoted text). Both spellings are now measured, so stripping is correct rather
+ * than merely reasonable, and a blocking gate may rely on it.
+ *
+ * Lines are replaced by empty strings rather than deleted so that nothing is
+ * spliced together across a stripped block into an accidental match.
+ *
+ * ## `{ inline: false }` — the same fence parser, opposite need (H17)
+ *
+ * H17 reads the INSIDE of inline spans: a hold comment names its trigger files
+ * as backticked repo-relative paths, so blanking spans would delete the entire
+ * signal. It still wants fenced blocks gone, and for the same reason H7 does —
+ * a hold comment routinely quotes `git grep` output and file:line evidence
+ * inside a fence, and those are citations, not triggers (measured on #8656,
+ * whose fenced block lists three `packages/spec/src/**` paths that are prose
+ * evidence for the card and name no trigger at all).
+ *
+ * So the option exists rather than a second fence parser: one fence-closing
+ * rule, read two ways, and neither reader can drift from the other. The
+ * default is unchanged, so every existing caller keeps byte-identical output.
+ */
+export function stripMarkdownCode(body, { inline = true } = {}) {
+ const out = [];
+ let fence = null;
+ for (const line of String(body ?? '').split('\n')) {
+ const m = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(line);
+ if (fence !== null) {
+ // A fence closes on a marker of the same character, at least as long.
+ if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null;
+ out.push('');
+ continue;
+ }
+ if (m) {
+ fence = m[1];
+ out.push('');
+ continue;
+ }
+ out.push(inline ? line.replace(/`+[^`\n]*`+/g, ' ') : line);
+ }
+ return out.join('\n');
+}
+
+/**
+ * ## The `{ markdown: false }` half — the SURFACE the text came from (H23)
+ *
+ * Both extractors below take one option, and it selects a *surface* rather than
+ * a strictness. `markdown: true` (the default, and every pre-existing caller)
+ * means the text is a PR or issue BODY, where GitHub renders markdown before its
+ * reference parser runs and a keyword inside a code span or fence therefore does
+ * NOT fire — measured, twice, in `stripMarkdownCode`'s docblock.
+ *
+ * `markdown: false` means the text is a COMMIT MESSAGE. GitHub's closing-keyword
+ * parser reads commit messages on the default branch too, and a commit message
+ * is not markdown: there is no renderer in front of it, so backticks and fences
+ * are ordinary characters and a keyword sitting inside them binds exactly like
+ * one in plain prose. Stripping there would delete real bindings and hand the
+ * author a remedy that does not work — H23's whole point, argued at length in
+ * its section.
+ *
+ * ⛔ The asymmetry is not a tuning knob and must not be "unified" later: the two
+ * surfaces genuinely differ in GitHub's own behavior, so a single reading is
+ * wrong for one of them whichever way it is set. One extractor read two ways,
+ * the same shape `stripMarkdownCode`'s own `{ inline }` option takes, so the two
+ * surfaces can never drift onto two different grammars. The default is
+ * unchanged, so every existing caller keeps byte-identical output.
+ */
+
+/** The `#N` a body declares itself only PART of. */
+export function partOfTargets(body, { markdown = true } = {}) {
+ const text = markdown ? stripMarkdownCode(body) : String(body ?? '');
+ return new Set([...text.matchAll(partOfRe())].map((m) => m[1]));
+}
+
+/** `#N` -> the closing keyword bound to it (first occurrence wins, for the message). */
+export function closingKeywordTargets(body, { markdown = true } = {}) {
+ const found = new Map();
+ const text = markdown ? stripMarkdownCode(body) : String(body ?? '');
+ for (const m of text.matchAll(closingKeywordRe())) {
+ if (!found.has(m[2])) found.set(m[2], m[1]);
+ }
+ return found;
+}
+
+/**
+ * H7 — null when clean, else the finding sentence.
+ *
+ * Bound PER ISSUE NUMBER, never "body has `Part of` anywhere AND a keyword
+ * anywhere": a PR that is `Part of #A` and legitimately `Fixes #B` is a normal,
+ * correct shape and must stay clean. Open PR #8471 is the live specimen —
+ * `Part of #8247` with a keyword bound to #8245 — and it is not a finding.
+ */
+export function h7PartOfWithClosingKeyword(pr) {
+ const body = pr?.body ?? '';
+ const declared = partOfTargets(body);
+ if (declared.size === 0) return null;
+ const closing = closingKeywordTargets(body);
+ const clashes = [...declared].filter((n) => closing.has(n));
+ if (clashes.length === 0) return null;
+ return clashes
+ .map(
+ (n) =>
+ `body says \`Part of #${n}\` but also carries \`${closing.get(n)} #${n}\` — ` +
+ `GitHub's closing-keyword parser ignores the surrounding prose (negations and ` +
+ `modals included), so merging this closes #${n}. Reword to "#${n} is not ` +
+ `addressed here" / "out of scope: #${n}", or put the keyword in backticks.`,
+ )
+ .join('; ');
+}
+
+// ---------------------------------------------------------------------------
+// H8 — delivering PR merged, card still `pm:dispatched` (#8683).
+//
+// Pure over the shapes the sweep already consumes (REST issue + `/pulls`
+// rows), reusing H7's code-stripped extractors so the measured reference-
+// parser behavior (#8293) carries over. No new API layer.
+// ---------------------------------------------------------------------------
+
+/**
+ * The card number a protocol dev-branch NAMES — `claude/issue--` — as
+ * a string, or null when the ref is not that shape.
+ *
+ * Anchored end to end, and deliberately a second READER of one shape rather
+ * than a second shape: `CLAIM_BRANCH_SHAPE` is the same pattern spelled for
+ * `matchAll` over prose, and the self-test pins the two against each other so a
+ * future change to the branch convention cannot move one reader and leave the
+ * other answering the old way. (It is not literally that constant because a
+ * shared `g` regex carries `lastIndex` between callers — the warning on it.)
+ */
+export function branchNameTarget(ref) {
+ const m = /^claude\/issue-(\d+)-[A-Za-z0-9][A-Za-z0-9._-]*$/.exec(String(ref ?? '').trim());
+ return m ? m[1] : null;
+}
+
+/**
+ * Does this PR deliver card `n`? The one delivery relation H8 reads, shared by
+ * its merged side and its open side so the two can never drift apart.
+ *
+ * Two channels, in a deliberate PRECEDENCE rather than a disjunction:
+ *
+ * 1. **The body** — `Part of #N`, or a closing keyword bound to `#N`, read
+ * through `stripMarkdownCode` (a body QUOTING either spelling in backticks
+ * does not deliver). Bound per issue number exactly like H7.
+ * 2. **The branch name**, and ONLY when the body declares no delivery at all.
+ *
+ * ## Why the branch name is a FALLBACK and not a third `||` term
+ *
+ * Every dev branch here is `claude/issue--` by protocol, and every PR
+ * row already carries `head.ref` — so a delivery whose body spells the relation
+ * some third way (the measured specimen: a merged PR whose body said only
+ * `Refs #10757`, leaving its card dispatched and invisible for ~22h while the
+ * same sweep reported six other H8 rows correctly) is recoverable at no API
+ * cost. That is the widening this channel exists for.
+ *
+ * But widening the delivery relation has a cost the fix must pay, and reading
+ * the branch as merely one more disjunct does not pay it: a branch cut for card
+ * N and then RE-SCOPED — the body now delivering a different card — would be
+ * counted as delivering N forever, on the authority of a name nobody updated.
+ * The body is the channel an author actually maintains; the branch name is
+ * fixed at `git worktree add` time and is evidence only when nothing better
+ * exists. So a body that declares ANY delivery is authoritative, and the branch
+ * name is consulted only for the bodies that declare none — which is exactly
+ * the population the specimen came from, and no other.
+ */
+export function prDeliversCard(pr, n) {
+ const target = String(n);
+ const body = pr?.body ?? '';
+ const partOf = partOfTargets(body);
+ const closing = closingKeywordTargets(body);
+ if (partOf.has(target) || closing.has(target)) return true;
+ // The body spoke — about some OTHER card. A stale branch name does not
+ // overrule it (the re-scope case above).
+ if (partOf.size > 0 || closing.size > 0) return false;
+ return branchNameTarget(pr?.head?.ref) === target;
+}
+
+/**
+ * H8 — null when clean, else the finding sentence.
+ *
+ * Delivery is `prDeliversCard` (body first, branch name as the fallback its
+ * docblock justifies). Only `merged_at`-set PRs count on the merged side —
+ * closed-unmerged is an abandoned attempt, not a delivery.
+ *
+ * ## The open side, and why this row DOWNGRADES rather than falls silent
+ *
+ * `openPrs` is the open-PR list the sweep already holds (its summary line
+ * reports it), so consulting it costs no request. Without it H8 could not ask
+ * the question that decides the answer — *is there ALSO an unmerged PR
+ * delivering this card?* — and on a card delivered in halves it fired on every
+ * sweep from the first half's merge until the last half landed, pointing at the
+ * card whose remaining work was most active and prescribing a DESTRUCTIVE write
+ * against it: "drop `pm:dispatched`". A reader who followed that row de-labelled
+ * a card with an open PR, which then read as un-dispatched and was liable to be
+ * re-dispatched — two agents on one card, the exact outcome the claim protocol
+ * exists to prevent (#10468, measured on #9834 + open PR #10226).
+ *
+ * Silence would fix the harm and buy a new one, and the card's caveat says so:
+ * it loses the genuine #8683 case where the last half is later ABANDONED — the
+ * merged half really is delivered, the card really is stale, and nothing would
+ * ever say so again. So the half-delivered case gets its own quieter sentence
+ * instead: it names both sides, states the counts, and — the whole point —
+ * says `pm:dispatched` is CORRECT here and must not be dropped. The destructive
+ * prescription fires only when every delivering PR has merged.
+ *
+ * Drafts are deliberately NOT filtered out of the open side: the measured
+ * specimen (#10226) was `draft: true`, and a draft delivering half is exactly
+ * the live work this row must not step on.
+ */
+export function h8MergedPrStillDispatched(issue, mergedPrs, openPrs) {
+ if (!labelNames(issue).includes('pm:dispatched')) return null;
+ const n = String(issue.number);
+ const delivering = [];
+ for (const pr of mergedPrs ?? []) {
+ if (!pr?.merged_at) continue;
+ if (prDeliversCard(pr, n)) delivering.push(pr);
+ }
+ if (delivering.length === 0) return null;
+ const list = delivering
+ .map((p) => `#${p.number} (merged ${String(p.merged_at).slice(0, 10)})`)
+ .join(', ');
+
+ const stillOpen = [];
+ for (const pr of openPrs ?? []) {
+ // A merged row appearing in the open list is not an outstanding half; the
+ // merged side above already judged it.
+ if (pr?.merged_at) continue;
+ if (prDeliversCard(pr, n)) stillOpen.push(pr);
+ }
+ if (stillOpen.length > 0) {
+ const openList = stillOpen
+ .map((p) => `#${p.number}${p.draft ? ' (draft)' : ''}`)
+ .join(', ');
+ const total = delivering.length + stillOpen.length;
+ return (
+ `delivered IN PART — ${delivering.length} of ${total} delivering PR(s) merged ` +
+ `(${list}), while ${openList} is still OPEN against this card. ` +
+ `\`pm:dispatched\` is CORRECT here and must NOT be dropped: the card is not ` +
+ `finished, and de-labelling it would read as un-dispatched work and invite a ` +
+ `second seat onto it. No action — this row exists so an abandoned last half is ` +
+ `still visible, not to prescribe one.`
+ );
+ }
+
+ return (
+ `delivering PR ${list} is MERGED but the card still carries \`pm:dispatched\` — ` +
+ `the merge's paired write never landed. Drop \`pm:dispatched\` and re-grade the ` +
+ `remainder (re-queue, close, or block the un-delivered half) in the same stroke, ` +
+ `and 「同笔摘 assignee」 — the landing re-label owes the ASSIGNEE DROP too. A ` +
+ `re-graded card that keeps the finished dev's assignee lands straight in H24's ` +
+ `two-views contradiction (\`pm:queue\` + assigned = dispatchable to the queue view, ` +
+ `taken to the claim rule), which is how 17 cards across three repos got stuck where ` +
+ `nobody could legally move them (#11196). ⚠️ Agent identity only: a HUMAN assignment ` +
+ `may be real ownership and is ⛔ never cleared by an agent.`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H9 — `pm:on-hold` without a machine-fireable `Restart-when:` line in EITHER
+// channel.
+//
+// Same shape as H4 (the label's machine half is a written line): the same
+// decorated-directive anchor, and — since #10403 — the same channel contract:
+//
+// H4 / H14 `Blocked-by:` body OR comment (since PR #10075)
+// H9 `Restart-when:` body OR comment (since #10403)
+//
+// H9 was body-only on purpose while the unlock machinery was: a condition
+// parked in a comment genuinely did not exist to a body-only grep, and this
+// detector surfaced exactly that population rather than hiding it (#10102
+// documented the asymmetry so a seat generalising from H4 would not get an H9
+// row it could not explain). What retired the asymmetry is a measured cost on
+// the machinery side: a machine-fireable exit (`Restart-when: closed #N`)
+// parked in a COMMENT fired when its target closed, and nothing noticed for
+// ~2 days, because no reader consulted that channel. Seats park directives in
+// comments deliberately — the MCP body-escaping hazard (#8813) makes a body
+// rewrite the riskier write, the same measurement that widened `Blocked-by:`
+// (26 of 40 blocked cards body-clean, 2026-08-19). So scan and gauge moved
+// TOGETHER (#10403's one-PR scope): the sweep reads hold comments for
+// `Restart-when:` through the same gated-fallback pattern as `Blocked-by:`,
+// and H9 counts a comment-channel line as a line. Widening H9 alone would
+// have made the gauge claim coverage the machinery lacked — the split #10102
+// existed to prevent, run in the opposite direction.
+// ---------------------------------------------------------------------------
+
+/**
+ * Does this text carry a `Restart-when:` value some mechanism could fire?
+ *
+ * The one fireability test H9 and its comment-fetch gate share, so "the body
+ * already answers this card" means the same thing in both places. `manual…`
+ * counts as NOT fireable, deliberately — see H9's header note.
+ */
+export function hasFireableRestartWhen(text) {
+ return directiveValues(text, 'Restart-when').some((v) => !/^manual\b/i.test(v));
+}
+
+/**
+ * Which cards are worth a `Restart-when:` comment fetch — H9's gathering
+ * policy, exported and pinned for the same reason `needsBlockedByComments`
+ * is: a policy that decides what gets READ AT ALL is where a silent hole
+ * would live. Gated on the body NOT already answering (a fireable body line
+ * clears H9 without the network), then on the one label H9 judges.
+ */
+export function needsRestartWhenComments(issue) {
+ if (hasFireableRestartWhen(issue?.body)) return false;
+ return labelNames(issue ?? {}).includes('pm:on-hold');
+}
+
+/**
+ * H9 — null when clean, else the finding sentence.
+ *
+ * Legal iff SOME `Restart-when:` line in EITHER channel carries a value that
+ * is not `manual…`. The line may be decorated (see the shared directive
+ * reader) in either channel; the KEY may not. The spelling is case-sensitive
+ * and byte-stable like `Blocked-by:` (H4): the scan that fires these lines
+ * greps the literal, so a lowercase variant is a line the machinery cannot
+ * see and must be flagged, not tolerated.
+ *
+ * ## Three input states, never two (#4690) — the H4 contract, verbatim
+ *
+ * - `undefined` — the comment channel was not consulted (a caller reading
+ * bodies only). The sentence claims nothing about comments.
+ * - `null` — consulted and UNREADABLE. The row still FIRES and says the
+ * second channel could not be read instead of asserting it is empty:
+ * H9's remedy is "add or repair a line" — cheap and idempotent — so the
+ * unreadable reading surfaces on the cheap side, exactly as H4 argues.
+ * - `string[]` — read. Both channels judged, for real.
+ *
+ * ## The remedy text is deliberately not "close it" (#10102)
+ *
+ * This row's old sentence offered "add the line, or apply the protocol's
+ * default: … closed `not planned`" as co-equal branches. Read literally
+ * against a FALSE row — #9591, whose legal `Restart-when:` was invisible only
+ * because it was wrapped in backticks — that prescribes closing a
+ * maintainer-commissioned card, and it reads as a legitimate cleanup while
+ * doing it. Tolerating decoration removes the measured cause; it cannot remove
+ * the class, because any future unparsed spelling produces the same "no line"
+ * row. So the sentence now names the possibility it cannot rule out and orders
+ * the remedies: verify, unwrap, add — and only then, for a card that really
+ * has no fireable exit, close.
+ */
+export function h9OnHoldNoRestartWhen(issue, commentBodies) {
+ if (!labelNames(issue).includes('pm:on-hold')) return null;
+ if (hasFireableRestartWhen(issue.body)) return null;
+ const commentsRead = Array.isArray(commentBodies);
+ if (commentsRead && commentBodies.some((b) => hasFireableRestartWhen(b))) return null;
+ const values = [
+ ...directiveValues(issue.body, 'Restart-when'),
+ ...(commentsRead ? commentBodies : []).flatMap((b) => directiveValues(b, 'Restart-when')),
+ ];
+ const shape =
+ values.length > 0
+ ? 'its only `Restart-when:` is `manual`, which no mechanism can fire'
+ : commentsRead
+ ? 'no `Restart-when:` line in EITHER channel — not in the body, and not in any comment ' +
+ 'on the thread (both were read)'
+ : 'no `Restart-when:` body line this scan could read';
+ const unreadable =
+ commentBodies === null
+ ? ' And this card\'s comment thread could NOT be read this sweep — the second channel (a ' +
+ '`Restart-when:` line parked in a comment, which the unlock scan reads too) is unjudged, ' +
+ 'not empty. Read the thread by hand before acting: an unreadable channel is not an ' +
+ 'absent one (#4690).'
+ : '';
+ const unparsed =
+ values.length === 0
+ ? ` ⚠️ READ THE ${commentsRead ? 'BODY AND THE THREAD' : 'BODY'} BEFORE ACTING: a line ` +
+ `that IS there but which this scan cannot parse looks exactly like an absent one. ` +
+ `Decoration is tolerated (backticks, a \`-\`/\`*\` bullet, \`**\` bold), but a ` +
+ `mis-spelled or lowercased key is not — the unlock scan greps the literal. If the line ` +
+ `is there, unwrap or re-spell it; that is the whole fix, and no state change is due.`
+ : '';
+ return (
+ `\`pm:on-hold\` with ${shape} — the hold state is legal only with a machine-fireable exit ` +
+ `(\`Restart-when: closed #N\`, or a one-line executable predicate).${unreadable}${unparsed} ` +
+ `Add or repair the line first. Closing is the LAST resort and applies only to a card that ` +
+ `genuinely has no fireable exit: such a card is closed \`not planned\` with reason + ` +
+ `provenance in the closing comment (type:Bug holds re-route instead — see the state model's ` +
+ `Bug branch). Channel: like \`Blocked-by:\` (H4/H14), a \`Restart-when:\` line counts from ` +
+ `the body OR a comment — either channel discharges the duty, and the unlock scan reads both ` +
+ `(#10403 closed the old body-only gap).`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H10 — stale unclaimed p0 (routing-gap backstop).
+// ---------------------------------------------------------------------------
+
+/**
+ * H10 threshold — p0 protocol latency is measured in minutes-to-hours (queue
+ * jump, dispatch past batch limits), so 24h of silence while unclaimed exceeds
+ * any legal round latency severalfold while still tolerating weekend lulls;
+ * the measured no-reader specimen sat ~36h and would have been caught a day
+ * earlier.
+ */
+export const P0_UNCLAIMED_STALE_HOURS = 24;
+
+/**
+ * H10 — null when clean, else the finding sentence.
+ *
+ * Deliberately the bare conjunction the protocol names (p0 + open + unassigned
+ * + stale): no carve-out for decision/blocked/hold states, because a p0 aging
+ * in ANY box is exactly what the triage brief should be showing the maintainer
+ * — the flag is report-only and p0 volume is tiny by construction.
+ */
+export function h10StaleUnclaimedP0(issue, nowMs = Date.now()) {
+ if (!labelNames(issue).includes('priority:p0')) return null;
+ if ((issue.assignees ?? []).length > 0) return null;
+ const updated = Date.parse(issue.updated_at ?? '');
+ const ageHours = Number.isFinite(updated) ? (nowMs - updated) / 3_600_000 : null;
+ if (ageHours !== null && ageHours <= P0_UNCLAIMED_STALE_HOURS) return null;
+ const reading =
+ ageHours === null
+ ? 'its `updated_at` is unreadable (an unreadable timestamp must not read as fresh)'
+ : `no activity for ~${Math.round(ageHours)}h (threshold ${P0_UNCLAIMED_STALE_HOURS}h)`;
+ return (
+ `\`priority:p0\`, open and unassigned, with ${reading} — p0 is queue-jump priority, so a ` +
+ `stale unclaimed one usually means no seat's declared scan scope covers the queue it sits ` +
+ `in. Put it in the triage round brief / decision box and name its reader.`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H11 — the important-parked inventory (maintainer concern, 2026-08-16).
+// ---------------------------------------------------------------------------
+
+/**
+ * H11 threshold — importance signals parked in `pm:blocked`/`pm:on-hold` are
+ * exactly the cards the maintainer fears go unwatched; 7 days is one full
+ * triage week — long enough that a legitimate short park has cleared, short
+ * enough that a real defect cannot age a release cycle out of sight.
+ */
+export const IMPORTANT_PARKED_STALE_DAYS = 7;
+
+/**
+ * The states H11 counts as PARKED — every state in which an open card is
+ * legitimately not being worked, so an importance signal can sit inside it
+ * indefinitely without anyone's queue showing it.
+ *
+ * `pm:awaiting-maintainer` joined the set with the state itself (#11196 fix 5)
+ * rather than being left for a later card, because the omission would have
+ * re-created H11's own defect one state to the left: a `bug` card parked
+ * awaiting a manual action is the exact inventory the maintainer named
+ * (2026-08-16, 「我担心的优先的,重要的问题…被放进 blocked 或者 on-hold 没人理
+ * 会」), and a new parked state invisible to the inventory row is a new place
+ * for it to hide.
+ */
+export const PARKED_STATE_LABELS = ['pm:blocked', 'pm:on-hold', AWAITING_MAINTAINER_LABEL];
+
+/**
+ * H11 — null when clean, else the finding sentence.
+ *
+ * Importance is read from BOTH the native issue type (`Bug`, object or string
+ * shape — REST serializes it as an object) and the label vocabulary
+ * (`bug` / `security` / any `priority:*`), because the triage protocol only
+ * types new cards and deliberately does not backfill the stock — a label-only
+ * reading would hide exactly the older cards most at risk of being forgotten.
+ * Age is `created_at` ("open longer than", per the card); an unreadable
+ * timestamp flags rather than reading as fresh (#4690 direction, same as H10).
+ */
+export function h11ImportantParked(issue, nowMs = Date.now()) {
+ const labels = labelNames(issue);
+ const parked = labels.some((l) => PARKED_STATE_LABELS.includes(l));
+ if (!parked) return null;
+ const typeName = typeof issue.type === 'string' ? issue.type : issue.type?.name;
+ const signals = [];
+ if (typeName === 'Bug') signals.push('type:Bug');
+ for (const l of labels) {
+ if (l === 'bug' || l === 'security' || l.startsWith('priority:')) signals.push(l);
+ }
+ if (signals.length === 0) return null;
+ const created = Date.parse(issue.created_at ?? '');
+ const ageDays = Number.isFinite(created) ? (nowMs - created) / 86_400_000 : null;
+ if (ageDays !== null && ageDays <= IMPORTANT_PARKED_STALE_DAYS) return null;
+ const state = PARKED_STATE_LABELS.find((l) => labels.includes(l));
+ const age =
+ ageDays === null
+ ? 'an unreadable `created_at` (which must not read as fresh)'
+ : `open ~${Math.round(ageDays)}d`;
+ // The exit a parked card owes is state-specific, so the remedy names the one
+ // this card actually has: a hold/block is re-checked mechanically, while
+ // `pm:awaiting-maintainer` has no machine exit BY CONSTRUCTION (that is why
+ // it exists), and prescribing a `Restart-when:` re-check for it would send
+ // the reader to look for a line the state is defined by not having.
+ const exit =
+ state === AWAITING_MAINTAINER_LABEL
+ ? `This state has NO machine exit by construction — the release is the maintainer action the ` +
+ `card names — so an important card in it ages out of sight unless a human is re-asked. ` +
+ `Re-surface it to the maintainer in the triage round.`
+ : `Re-check the card's \`Blocked-by:\` / \`Restart-when:\` liveness in the triage round.`;
+ return (
+ `important card parked: ${signals.join(' + ')} sitting in \`${state}\`, ${age} ` +
+ `(threshold ${IMPORTANT_PARKED_STALE_DAYS}d) — the important-parked inventory exists so a bug ` +
+ `or security card cannot age out of sight inside a parked state. ${exit}`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H12 — orphan landing: a reviewed-and-ready PR out of the queue, unhandled
+// (queue-steward retirement, maintainer-ruled 2026-08-16).
+// ---------------------------------------------------------------------------
+
+/**
+ * H12 threshold — the landing cycle whose absence this flags is measured in
+ * minutes (flip → queue → merge ≈ 15–30 min per PR; a queue kick draws the
+ * merge-queue-triage workflow's comment within minutes of the red run).
+ * Every handling act — a re-queue, a triage or audit comment, a push, a
+ * label — bumps the PR's `updated_at`, so hours of TOTAL silence on a ready
+ * PR exceeds the whole cycle severalfold; 6h still tolerates a congested
+ * queue day and the longest measured landing latencies.
+ */
+export const ORPHAN_LANDING_STALE_HOURS = 6;
+
+/**
+ * H12 — null when clean, else the finding sentence.
+ *
+ * "Reviewed" is read from `draft === false`: this protocol flips a dev PR
+ * ready only at review ACCEPT (the ready → queue path), and parks everything
+ * else — un-reviewed work, ADR-class human-merge deliverables, dependency-red
+ * stashes — as DRAFTS, so ready = reviewed by construction and drafts are out
+ * of scope however old. A row without a real `draft` field is out of scope
+ * too: this predicate must not flag shapes it cannot read.
+ *
+ * `auto_merge` is read ONLY in the finding-reducing direction (armed = the
+ * queue machinery holds the PR = someone is handling it). The platform notes
+ * forbid that field as a landing VERDICT (timeline events are the authority);
+ * here a stale field costs at most a missed report-only flag, never a wrong
+ * landing decision. `changeset-release/*` heads are excluded by name: the
+ * Version Packages PR is born ready by the release bot and is the
+ * maintainer's alone to merge (Guardrails), so it would flag on every sweep
+ * by design. An unreadable `updated_at` flags rather than reads as fresh
+ * (#4690 direction, same as H10/H11).
+ */
+export function h12OrphanLanding(pr, nowMs = Date.now()) {
+ if (!pr || pr.draft !== false || pr.merged_at) return null;
+ if (pr.auto_merge) return null;
+ if ((pr.head?.ref ?? '').startsWith('changeset-release/')) return null;
+ const updated = Date.parse(pr.updated_at ?? '');
+ const ageHours = Number.isFinite(updated) ? (nowMs - updated) / 3_600_000 : null;
+ if (ageHours !== null && ageHours <= ORPHAN_LANDING_STALE_HOURS) return null;
+ const reading =
+ ageHours === null
+ ? 'an unreadable `updated_at` (which must not read as fresh)'
+ : `no activity for ~${Math.round(ageHours)}h (threshold ${ORPHAN_LANDING_STALE_HOURS}h)`;
+ return (
+ `ready (= reviewed, in this protocol) with auto-merge unarmed and ${reading} — an orphan ` +
+ `landing: the PR left the merge queue (or never entered it) and no one is handling it. ` +
+ `The owning lane PM's landing window should re-read the queue-triage comment and gate-job ` +
+ `conclusions, then re-queue, fix, or park it as a draft with a stated reason.`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H13 — domain:* without any pm-state label, aged past one sweep cycle
+// (maintainer-reported incident, 2026-08-19).
+// ---------------------------------------------------------------------------
+
+/**
+ * The label vocabulary that counts as "a pm-state" for H13, mirroring the
+ * triage sweep's disjunct ③: any of these makes the card visible to a named
+ * reader (queue view, lane view, unlock scan, decision inbox, finding
+ * grading round, epic index, seat registry), so their absence — with routing
+ * already present — is the invisible half-annotated shape. `pm:blocking` is
+ * deliberately NOT here: it is a derived priority cache, not a state, and a
+ * card carrying only it is exactly as invisible to candidate queries.
+ */
+export const PM_STATE_LABELS = [
+ 'pm:queue',
+ 'pm:dispatched',
+ 'pm:blocked',
+ 'pm:on-hold',
+ // A card awaiting a manual maintainer action HAS a state and a named reader
+ // (the triage round re-surfaces it), so it is not the half-annotated shape
+ // H13 reports. Omitting it here would make every card in the new state a
+ // standing H13 finding two hours after it entered — a new label that fires a
+ // false row on every carrier is worse than no label at all.
+ AWAITING_MAINTAINER_LABEL,
+ 'pm:epic',
+ 'pm:seat',
+ 'needs-user-decision',
+ 'finding',
+];
+
+/**
+ * Labels whose NORMAL shape is domain-without-pm-state, excluded by the
+ * sweep's own protocol text (SKILL.md, Backlog sweep): flagging them would
+ * report the protocol's design as a defect.
+ */
+export const H13_EXEMPT_LABELS = ['tracking', 'status:parked', 'qa-run'];
+
+/**
+ * H13 threshold — "one sweep cycle": the triage Routine fires HOURLY and its
+ * disjunct ③ heals exactly this shape every round, so a card still in it
+ * after 2h has survived at least one full healing round it should not have —
+ * the alarm reads a failure of the healing loop, never routine intake
+ * latency (a just-landed domain label sits here only for the sweep's own
+ * ~2-minute settle window, two orders of magnitude under the threshold).
+ * Age reads `updated_at`: it needs no timeline fetch, and every healing
+ * write would bump it, so a stale `updated_at` in this shape means nothing
+ * touched the card at all. The measured specimen sat ~26h; at 2h it would
+ * have been flagged a day earlier.
+ */
+export const DOMAIN_HALF_STATE_STALE_HOURS = 2;
+
+/**
+ * The prefix H13 stamps on a self-declared-P0 row. Exported because a SECOND
+ * reader now depends on it: the markdown renderer sorts loud rows to the top
+ * of the anchor body (see `renderMarkdown`). A shared constant, not a string
+ * literal in two files — the loudness and the thing that reads the loudness
+ * must never be able to drift apart, which is the whole failure family this
+ * script belongs to.
+ */
+export const P0_SUSPECT_MARKER = '🚨 P0-SUSPECT:';
+
+/**
+ * Whether the card's own title/body self-declares P0 / data-integrity — the
+ * incident card carried its emergency-triage trigger in its body while the
+ * seat that saw it "waited for triage" in session memory. Read through
+ * `stripMarkdownCode` (H7 reading 4's careful-author protection carries
+ * over: a body QUOTING `P0` in backticks is not a self-declaration).
+ */
+export function h13SelfDeclaredP0(issue) {
+ const text = stripMarkdownCode(`${issue?.title ?? ''}\n${issue?.body ?? ''}`);
+ return /\bp0\b/i.test(text) || /data[\s-]?integrity/i.test(text);
+}
+
+/**
+ * H13 — null when clean, else the finding sentence (louder for a
+ * self-declared P0/data-integrity card, whose mandated route is the
+ * emergency-triage channel, not the next Routine fire). An unreadable
+ * `updated_at` flags rather than reads as fresh (#4690 direction, same as
+ * H10/H11/H12).
+ */
+export function h13DomainWithoutPmState(issue, nowMs = Date.now()) {
+ const labels = labelNames(issue);
+ if (!labels.some((l) => l.startsWith('domain:'))) return null;
+ if (labels.some((l) => PM_STATE_LABELS.includes(l))) return null;
+ if (labels.some((l) => H13_EXEMPT_LABELS.includes(l))) return null;
+ const updated = Date.parse(issue.updated_at ?? '');
+ const ageHours = Number.isFinite(updated) ? (nowMs - updated) / 3_600_000 : null;
+ if (ageHours !== null && ageHours <= DOMAIN_HALF_STATE_STALE_HOURS) return null;
+ const reading =
+ ageHours === null
+ ? 'an unreadable `updated_at` (which must not read as fresh)'
+ : `~${Math.round(ageHours)}h without activity (threshold ${DOMAIN_HALF_STATE_STALE_HOURS}h)`;
+ const base =
+ `\`domain:*\` with no pm-state label and ${reading} — routing landed, the state machine ` +
+ `never did, so the card is invisible to every seat's candidate query. A half-state older ` +
+ `than one sweep cycle is a defect of the healing loop (triage sweep disjunct ③), not ` +
+ `inventory: pair the domain label with its pm-state in one write, oldest first.`;
+ if (!h13SelfDeclaredP0(issue)) return base;
+ return (
+ `${P0_SUSPECT_MARKER} the card's own title/body self-declares P0/data-integrity, and for that ` +
+ `class the emergency-triage channel (immediate triage subagent) is the mandated move, ` +
+ `never the hourly Routine. ${base}`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H14 + H15 — the `pm:blocking` cache and the order that consumes it
+// (maintainer-approved 2026-08-19, verbatim: 「同意」).
+//
+// Both items read ONE structure, built once per sweep from bodies the unscoped
+// pass already fetched: the `Blocked-by:` reverse index. Everything above is a
+// predicate over a single card; these two are the first that need the whole
+// open set, because incoherence is a relation between two cards and "oldest"
+// is a relation among many. That is a shape difference, not a contract change
+// — they stay pure functions over listings the caller supplies, so the
+// self-test drives them offline exactly like H1–H13.
+// ---------------------------------------------------------------------------
+
+/**
+ * The `Blocked-by:` line's refs, in the order written.
+ *
+ * ## Why this is the file's FIRST parser for a line H4 already reads
+ *
+ * H4 asks only "is there such a line", so its regex is a presence test and
+ * extracts nothing; no other script in the repo parses these lines at all
+ * (the unlock scan is a seat procedure over a grep, not code). So there is no
+ * second parser to converge on here — this is the first, and H4 keeps its own
+ * cheaper question rather than being rewritten around this one.
+ *
+ * ## Two decisions that change what gets reported
+ *
+ * 1. **Code context is NOT stripped from the TEXT**, unlike H7/H8/H13. Those
+ * predicates read PROSE and must protect the careful author who quotes a
+ * spelling in backticks. This one models a MACHINE READER: the state model
+ * calls the line 「机器可 grep 的反向索引」, and the unlock scan that
+ * consumes it greps the literal — so a fenced `Blocked-by:` line really
+ * does fire the live machinery, whatever the author meant. Skipping fenced
+ * regions here would report coherence against an index nothing uses; H4
+ * (the same line's other reader) does not skip them either.
+ *
+ * What IS removed is the decoration wrapping the directive itself —
+ * 「`Blocked-by: #9612`」 names a blocker (#10102, and see the shared
+ * reader above). That is the opposite move from H7/H8/H13's: they drop a
+ * line because it sits in code, this one reads a line whose code markers
+ * are the author formatting a directive. Both serve the same test — would
+ * the unlock sweep's grep act on this line — and its answer here is yes.
+ * 2. **Only the LEADING ref run is taken.** Real lines carry trailing prose —
+ * 「Blocked-by: #9689 (the relocation it needs is the same edit)」 — and
+ * prose can name a second card that is context, not a blocker. Scanning
+ * the whole value would manufacture a dependent for it, and the cost lands
+ * on a THIRD card (a phantom "missing cache" row against someone who did
+ * nothing wrong). So the scan walks refs and separators from the start of
+ * the value and stops at the first token that is neither.
+ *
+ * The key is matched case-sensitively and line-anchored, byte-stable like H4
+ * and H9: a lowercase or mid-sentence spelling is a line the real scan cannot
+ * see, and reading it here would report an index the machinery does not have.
+ *
+ * @param {string} body
+ * @returns {{ repo: string|null, number: number }[]}
+ */
+export function blockedByTargets(body) {
+ const out = [];
+ // The shared decorated-directive reader: the value arrives trimmed and with
+ // a matching trailing marker already removed, so the ref walk below sees
+ // 「#9823」 whether the author wrote it bare, bulleted, bolded or in code.
+ for (const value of directiveValues(body, 'Blocked-by')) {
+ let rest = value;
+ for (;;) {
+ const ref = /^[\s,;+、]*(?:and[ \t]+)?([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?)?#(\d+)/u.exec(rest);
+ if (!ref) break;
+ out.push({ repo: ref[1] ?? null, number: Number(ref[2]) });
+ rest = rest.slice(ref[0].length);
+ }
+ }
+ return out;
+}
+
+/**
+ * target issue number -> the open issue numbers whose bodies declare it a
+ * blocker, built from ONE listing (the unscoped open-issue pass).
+ *
+ * Cross-repo refs are dropped, and that is load-bearing rather than tidy:
+ * 「Blocked-by: objectstack-ai/objectui#4356」 is a real line on this board, and
+ * reading its number as a LOCAL target would invent a dependent for whatever
+ * this repo's #4356 happens to be — a phantom finding against an unrelated
+ * card. A ref qualified with this repo (either `owner/repo#N` or the bare
+ * repo name) is kept; a bare `#N` is local by definition.
+ *
+ * Self-references are dropped too: a card cannot be its own unblocker, and
+ * counting one would make it permanently `pm:blocking`-worthy on its own say-so.
+ *
+ * @param {{ number: number, body?: string }[]} issues — OPEN issues only. The
+ * index's whole meaning is "open cards that are waiting", so the caller's
+ * listing is what bounds it; a closed dependent must not hold a label alive.
+ * ## Two channels, UNIONED — never a priority order (#10061)
+ *
+ * `options.comments` supplies, per source card number, the comment bodies the
+ * sweep's gated fallback read. Refs found there are added to the refs found in
+ * the body; neither channel wins, because both are real. A card whose body
+ * says `Blocked-by: #A` and whose comment says `Blocked-by: #B` is waiting on
+ * BOTH, and a priority order would silently drop one of two live dependencies
+ * — the same class of loss as reading the body alone, just rarer.
+ *
+ * Dedup is per (target, source) as before, so a card naming one target in both
+ * channels is listed once. Cross-repo and self-reference filtering is applied
+ * to comment-borne refs identically: the ref is filtered by what it SAYS, and
+ * the channel it arrived in changes nothing about that.
+ *
+ * A source card absent from the map contributes its body only — which is every
+ * card the gate did not select, and is why the map is a bound on cost rather
+ * than a change of meaning.
+ *
+ * @param {{ repo?: string, comments?: Map }} [options] —
+ * `repo` is `owner/repo`, defaulting to the swept one.
+ */
+export function buildBlockingIndex(issues, options = {}) {
+ const ownerRepo = options.repo ?? OWNER_REPO;
+ const bareRepo = ownerRepo.split('/').pop();
+ const comments = options.comments ?? null;
+ const index = new Map();
+ for (const issue of issues ?? []) {
+ const refs = [
+ ...blockedByTargets(issue.body),
+ ...commentBlockedByTargets(comments?.get?.(issue.number)),
+ ];
+ for (const { repo, number } of refs) {
+ if (repo !== null && repo !== ownerRepo && repo !== bareRepo) continue;
+ if (number === issue.number) continue;
+ const deps = index.get(number) ?? [];
+ if (!deps.includes(issue.number)) deps.push(issue.number);
+ index.set(number, deps);
+ }
+ }
+ return index;
+}
+
+/**
+ * How many dependent card numbers a missing-cache row names before it counts
+ * the rest. The row exists to be ACTED on — a reader wants to see who is
+ * waiting — but the markdown renderer writes into a body with a hard cap and
+ * a fold, and an unbounded fan-out list is the one row that could push others
+ * off the end. Five names the whole set for every fan-out measured on this
+ * board (the largest was one) while bounding the pathological case.
+ */
+export const BLOCKING_DEPENDENT_LIST_CAP = 5;
+
+/**
+ * H14 — null when the cache agrees with the index, else the finding sentence.
+ *
+ * ## The two directions do NOT owe the index the same completeness (#10061)
+ *
+ * They make opposite claims, so an INCOMPLETE index endangers exactly one:
+ *
+ * - STALE ("nothing targets it") is a claim about ABSENT evidence. Every
+ * edge the sweep failed to read is a card that might be pointing at this
+ * one, so an index with any unread source cannot support the claim at all.
+ * `options.indexComplete === false` therefore silences this direction —
+ * unreadable evidence is not absent evidence (#4690), and the remedy this
+ * row prescribes (drop a label the selection order ranks second only to
+ * `priority:p0`) is destructive enough that a guess is worse than silence.
+ * The summary line's `comment fallback read on X of Y` is what states the
+ * gap, exactly as H16's and H17's `read X of Y` do for theirs.
+ * - MISSING ("targeted, but the label never landed") is a claim about
+ * evidence IN HAND. Reading more sources can only ADD edges, never remove
+ * one, so an incomplete index cannot manufacture this row. It stays live
+ * regardless — going quiet there would trade a real finding for nothing.
+ *
+ * This is the recalibration #9948 ruled for on 2026-08-19 (「修量具而非追假
+ * stale」): the measured false stales #9465 and #9968 were both cards whose
+ * dependents state the wait in a COMMENT, and both are pinned in the self-test.
+ *
+ * ## STALE is also repo-local, and the row says so (#10139)
+ *
+ * `buildBlockingIndex` only ever scans THIS repo's open-issue listing, so
+ * `indexComplete` measures whether the comment fallback read every candidate
+ * — it says nothing about sibling repos. A dependent living in `objectui` or
+ * `cloud` with a `Blocked-by:` line naming this card is invisible to the
+ * index by construction, not by any read failure, so `indexComplete` cannot
+ * gate it and never claims to. The STALE sentence therefore does not read as
+ * exhaustive over "no dependent anywhere" — only "no dependent in this
+ * repo" — and its remedy is conditional on a cross-repo check rather than an
+ * instruction to drop the label outright: `Blocked-by:` edges are
+ * protocol-legal across repos (contract-first splits use them routinely),
+ * and #7917 / objectui#4356 is a live one this row would otherwise have
+ * instructed a reader to sever.
+ *
+ * @param {object} issue — an OPEN issue.
+ * @param {Map} index — from `buildBlockingIndex`.
+ * @param {{ indexComplete?: boolean }} [options] — `false` when any gated
+ * comment fetch failed, i.e. the index is known to be missing edges.
+ */
+export function h14BlockingCacheIncoherent(issue, index, options = {}) {
+ const carries = labelNames(issue).includes('pm:blocking');
+ const dependents = index?.get?.(issue.number) ?? [];
+ const indexComplete = options.indexComplete ?? true;
+ if (carries && dependents.length === 0) {
+ if (!indexComplete) return null;
+ return (
+ '`pm:blocking` carried while no open card\'s `Blocked-by:` line — body OR comment — targets ' +
+ 'it, judged against the two-channel index — a stale derived cache, scoped to THIS REPO ONLY: ' +
+ 'no dependent found in this repo; cross-repo dependents are not swept, so this is not a claim ' +
+ 'of exhaustiveness over the population — `Blocked-by:` edges are legally cross-repo. The label ' +
+ 'is not a state a seat sets: the triage sweep derives it from the `Blocked-by:` reverse index, ' +
+ 'and the lane selection order ranks it second only to `priority:p0`. So a stale one is worse ' +
+ 'than an absent one — it boosts a card nothing depends on, with authority. Report-only: verify ' +
+ 'cross-repo dependents before the triage sweep\'s derivation pass drops the label (or the ' +
+ 'missing `Blocked-by:` line landing on the card that really is waiting), never a label written ' +
+ 'from this script.'
+ );
+ }
+ if (!carries && dependents.length > 0) {
+ const shown = dependents.slice(0, BLOCKING_DEPENDENT_LIST_CAP);
+ const named = shown.map((n) => `#${n}`).join(', ');
+ const more = dependents.length > shown.length ? ` +${dependents.length - shown.length} more` : '';
+ return (
+ `targeted by ${dependents.length} open card(s)' \`Blocked-by:\` line, body or comment ` +
+ `(${named}${more}), but ` +
+ 'NOT carrying `pm:blocking` — a real unblocker the selection order cannot see. The label is ' +
+ 'the derived cache that makes a card outrank everything but `priority:p0`; without it this ' +
+ 'card competes on age alone while the cards waiting on it cannot start. Report-only: the ' +
+ 'remedy is the triage sweep\'s derivation pass applying the label, never a hand-applied one.'
+ );
+ }
+ return null;
+}
+
+/**
+ * H15 — the oldest open, UNASSIGNED `pm:blocking` card, or null.
+ *
+ * ## No threshold constant, deliberately
+ *
+ * Every other aged item here (H10/H11/H12/H13) answers "has this been
+ * abandoned?", and a threshold is what turns silence into an alarm. This row
+ * answers a different question — "is the lane taking blocking cards first?" —
+ * and the honest answer is a NUMBER, every run, for the reader to judge. A
+ * threshold would re-introduce exactly the judgement call the row exists to
+ * hand over, and would go quiet on the days the board is worst behaved but
+ * still under it. So: unconditional, one row, no constant. Nothing reddens
+ * (nothing in this file ever does).
+ *
+ * ## The age is the CARD's, not the label's, and says so
+ *
+ * `created_at`, the same quantity the selection order's own within-rank
+ * tie-break reads (同级按卡龄). The label's age would be the sharper number
+ * and is not available: it lives in a per-card timeline fetch this sweep
+ * deliberately never makes (the H2 comment fetch is the one exception, and it
+ * is confined to candidates). Reporting card age and NAMING it as card age
+ * beats reporting a number whose meaning the reader has to guess.
+ *
+ * An unreadable `created_at` sorts as maximally old rather than being skipped
+ * — the #4690 direction the whole file keeps: a value that cannot be read must
+ * surface, never quietly drop out of a "the oldest is…" claim.
+ *
+ * @param {object[]} issues — the open listing.
+ * @returns {{ issue: object, message: string } | null}
+ */
+export function h15OldestUnclaimedBlocking(issues, nowMs = Date.now()) {
+ const candidates = [];
+ let blockingTotal = 0;
+ for (const issue of issues ?? []) {
+ if (!labelNames(issue).includes('pm:blocking')) continue;
+ blockingTotal++;
+ if ((issue.assignees ?? []).length > 0) continue;
+ const created = Date.parse(issue.created_at ?? '');
+ candidates.push({
+ issue,
+ ageHours: Number.isFinite(created) ? (nowMs - created) / 3_600_000 : null,
+ });
+ }
+ if (candidates.length === 0) return null;
+ candidates.sort(
+ (a, b) =>
+ (b.ageHours ?? Infinity) - (a.ageHours ?? Infinity) || a.issue.number - b.issue.number,
+ );
+ const [oldest] = candidates;
+ const age =
+ oldest.ageHours === null
+ ? 'an unreadable `created_at` (sorted as maximally old — a timestamp that cannot be read must ' +
+ 'surface, never drop out of an "oldest is…" claim)'
+ : `open ~${Math.round(oldest.ageHours)}h`;
+ return {
+ issue: oldest.issue,
+ message:
+ `oldest UNCLAIMED \`pm:blocking\` card: ${age}, ${candidates.length} of ${blockingTotal} open ` +
+ '`pm:blocking` card(s) unassigned. The lane selection order puts `pm:blocking` second only to ' +
+ '`priority:p0`, so an unclaimed one aging while fresher cards are picked is selection-order ' +
+ 'drift — visible here by name instead of only in a seat\'s memory. Age is the CARD\'s ' +
+ '(`created_at`, the same quantity the order\'s within-rank tie-break reads), not the label\'s: ' +
+ 'that would need a per-card timeline fetch this sweep never makes. Visibility row — no ' +
+ 'threshold, it reports unconditionally, and like everything here it is patrol input, not a verdict.',
+ };
+}
+
+// ---------------------------------------------------------------------------
+// H16 — an open, non-draft PR stuck in a merge conflict (devx incident,
+// maintainer-approved 2026-08-19: 「同意你的建议」).
+//
+// The first item whose input the sweep cannot get from a listing: everything
+// above reads rows the label/PR/merged passes already fetched, while
+// `mergeable_state` exists only on the single-PR endpoint. The gathering
+// policy that keeps that affordable is `h16NeedsDetail`, below, and it is
+// pinned in the self-test for the same reason `needsRepoProbe` is — a policy
+// that decides what gets READ AT ALL is where a silent hole would live.
+// ---------------------------------------------------------------------------
+
+/**
+ * H16 threshold — the window a normal conflict resolution gets before the
+ * conflict counts as STUCK.
+ *
+ * ## Which timestamp this ages, and why (the honest part)
+ *
+ * A merge conflict has NO timestamp of its own. `mergeable_state` is a verdict
+ * about the PR as it stands at the moment of the read; neither the PR row nor
+ * any listing this sweep makes records WHEN the PR became `dirty`. So the age
+ * read here is the PR's `updated_at` — the last time anything touched the PR —
+ * used as a PROXY, on the reading the incident supports: a freshly-pushed
+ * dirty PR is being worked (its author is mid-resolution), while a dirty PR
+ * nothing has touched for hours is one nobody has noticed.
+ *
+ * The proxy's error direction, stated here rather than discovered later: a
+ * conflict created MINUTES ago on a PR last touched hours ago flags at once,
+ * because `updated_at` measures silence on the PR and not the age of the
+ * conflict — and the usual cause (`main` advancing under an open PR) does not
+ * touch the PR row at all, so it does not bump the clock. That over-reports in
+ * exactly one shape and under-reports in none, which is the direction this
+ * file keeps everywhere: a report-only row whose remedy is identical either
+ * way (merge base, resolve) costs its reader one glance, while the opposite
+ * bias is the silence the incident is made of.
+ *
+ * ⛔ It must not be "fixed" by dating the conflict from a per-PR timeline
+ * fetch. That is an extra request per candidate to sharpen a report-only row,
+ * and this sweep declines that trade everywhere else it arises — H15 declines
+ * it by name for the age of a label. The proxy is named in the finding text so
+ * the reader knows which quantity they are being shown.
+ *
+ * ## Why 2h
+ *
+ * Conflicts on this board are overwhelmingly created by `main` advancing under
+ * an open PR (~18 merges on a working day), not by authors writing
+ * incompatible code, so resolution is mechanical — merge `main`, fix the
+ * overlap, push — and a lane PM's landing window turns over far faster than
+ * that. 2h leaves a normal resolution a full window while catching the
+ * measured incident (~4h unnoticed) at roughly half its life. It matches
+ * `DOMAIN_HALF_STATE_STALE_HOURS` for the same underlying reason rather than
+ * by coincidence: both measure a loop that should already have turned over,
+ * not intake latency.
+ */
+export const MERGE_CONFLICT_STALE_HOURS = 2;
+
+/**
+ * The card(s) a stuck PR is holding up, so the row names the delivery and not
+ * only the branch. Read with H7's code-stripped extractors, exactly as H8
+ * reads delivery: `Fixes #N` (any closing keyword bound to `#N`) or
+ * `Part of #N` — both mean "this card is waiting on this PR", which is the
+ * question a reader of a stuck-conflict row is actually asking. A body that
+ * merely QUOTES either spelling in backticks names nothing (#8293 reading 4).
+ *
+ * Returns numbers in ascending order; an empty array when the body declares no
+ * card, which is a normal shape (not every PR carries one) and is why the
+ * finding text appends the clause only when it is non-empty.
+ */
+export function h16HeldCards(body) {
+ const out = new Set();
+ for (const n of closingKeywordTargets(body).keys()) out.add(Number(n));
+ for (const n of partOfTargets(body)) out.add(Number(n));
+ return [...out].sort((a, b) => a - b);
+}
+
+/**
+ * Whether this PR is worth spending a per-PR GET on — the gathering policy,
+ * separate from the verdict and exported so the self-test can pin the one
+ * property that matters: it must never be NARROWER than the predicate, or the
+ * sweep would silently stop being able to find rows H16 would have flagged.
+ *
+ * It answers from the LIST row alone, using the halves of the predicate that
+ * do not need `mergeable_state`: non-draft, not merged, and either aged past
+ * the threshold or carrying a timestamp that cannot be read. So the request
+ * count is bounded by the STUCK population rather than the open one — the same
+ * candidate-gating idiom as H2's comment fetch, which is confined to the cards
+ * H2 can actually judge.
+ *
+ * An unreadable `updated_at` is a candidate deliberately: the predicate treats
+ * it as a finding rather than as fresh (#4690), so a gate that skipped it here
+ * would drop exactly the row the predicate promises to surface.
+ *
+ * `changeset-release/*` is deliberately NOT excluded, unlike in H12. There the
+ * Version Packages PR would flag on every sweep BY DESIGN (born ready, never
+ * armed, the maintainer's alone to merge). A dirty one is nothing of the kind:
+ * it is regenerated from `main` on every push, so it has no normal state in
+ * which it sits conflicted for hours — if it ever does, that is a real finding
+ * about the release bot, not a false positive to suppress.
+ */
+export function h16NeedsDetail(pr, nowMs = Date.now()) {
+ if (!pr || pr.draft !== false || pr.merged_at) return false;
+ const updated = Date.parse(pr.updated_at ?? '');
+ if (!Number.isFinite(updated)) return true;
+ return (nowMs - updated) / 3_600_000 > MERGE_CONFLICT_STALE_HOURS;
+}
+
+/**
+ * Whether the H16 detail pass failed as a TRANSPORT rather than leaving a
+ * bounded gap — the #4690 judgement at row granularity, pure so the self-test
+ * pins it (the sweep loop that consumes it is a thin `for`, deliberately).
+ *
+ * The distinction it draws is the whole posture. "Some candidates unread" is a
+ * gap the report states out loud (the summary line's `read X of Y`) and the
+ * rest of the sweep is still worth printing — every other item's findings are
+ * already gathered. "No candidate readable at all" is not a bounded gap: it is
+ * a sweep whose H16 pass examined nothing while printing as though it had, and
+ * a quiet H16 section is then indistinguishable from a board with no
+ * conflicts. That one must surface as the prerequisite failure it is.
+ *
+ * Zero candidates is NOT a failure: a board where nothing was stale enough to
+ * be worth a request is a real, clean reading, and treating it as a transport
+ * fault would fail the sweep on the healthiest possible board.
+ */
+export function h16DetailPassUnreadable(candidates, probed) {
+ return (candidates ?? 0) > 0 && (probed ?? 0) === 0;
+}
+
+/**
+ * H16 — null when clean, else the finding sentence. Takes the SINGLE-PR
+ * payload (the listing row carries no `mergeable_state`).
+ *
+ * ## The readings that are skips, not findings
+ *
+ * GitHub computes mergeability ASYNCHRONOUSLY. A read taken while that
+ * background job is still running answers `unknown` (with `mergeable` null),
+ * which is neither "clean" nor "dirty" — it is no reading at all. Only the
+ * literal `dirty` fires: an unknown is skipped in SILENCE, never vouched for
+ * and never guessed, which is this file's standing narrowness discipline (the
+ * transport classifier's refusal to name what it cannot name, same posture).
+ *
+ * That is the one place H16 departs from the #4690 direction the aged items
+ * take, and the asymmetry is deliberate rather than an inconsistency: an
+ * unreadable `updated_at` is a value that SHOULD have been readable and whose
+ * absence hides a real card, while `unknown` is the platform correctly saying
+ * "ask again later" — firing on it would put a row on the anchor for every PR
+ * whose mergeability happened to be cold at sweep time, which is noise that
+ * would bury the real rows. The timestamp half keeps the #4690 direction
+ * unchanged (an unreadable `updated_at` still flags).
+ *
+ * Drafts are out of scope (parked deliberately, H12's reading), and a row
+ * without a real `draft` field is out of scope too — this predicate must not
+ * flag a shape it cannot read.
+ *
+ * ## Why `auto_merge` is NOT read here, unlike H12
+ *
+ * H12 treats armed auto-merge as finding-REDUCING: the queue machinery holds
+ * the PR, so someone is handling it. H16 must not, and that is the whole
+ * incident — the measured specimen sat dirty with auto-merge ARMED, and the
+ * arming is precisely what made every proxy signal read healthy while nothing
+ * at all was happening. Auto-merge does not resolve conflicts: a PR armed
+ * while dirty simply never lands. Here the armed state is evidence OF the
+ * disease, never of a handler, and the self-test pins that in both directions.
+ */
+export function h16StuckMergeConflict(pr, nowMs = Date.now()) {
+ if (!pr || pr.draft !== false || pr.merged_at) return null;
+ if (pr.mergeable_state !== 'dirty') return null;
+ const updated = Date.parse(pr.updated_at ?? '');
+ const ageHours = Number.isFinite(updated) ? (nowMs - updated) / 3_600_000 : null;
+ if (ageHours !== null && ageHours <= MERGE_CONFLICT_STALE_HOURS) return null;
+ const reading =
+ ageHours === null
+ ? 'an unreadable `updated_at` (which must not read as fresh)'
+ : `untouched for ~${Math.round(ageHours)}h (threshold ${MERGE_CONFLICT_STALE_HOURS}h)`;
+ const held = h16HeldCards(pr.body);
+ const holding =
+ held.length === 0
+ ? ''
+ : `, holding ${held.length === 1 ? 'card' : 'cards'} ${held.map((n) => `#${n}`).join(', ')}`;
+ return (
+ `open, non-draft and in MERGE CONFLICT (\`mergeable_state: dirty\`), ${reading}${holding} — a ` +
+ 'conflict starts no CI run, raises no event and turns no check red, so every proxy signal ' +
+ 'keeps reading healthy (armed auto-merge included: it does NOT resolve conflicts, and a PR ' +
+ 'armed while dirty simply never lands). The owning lane PM merges `main` into the branch, ' +
+ 'resolves it, and re-arms afterwards. Age is the PR\'s `updated_at`, not the conflict\'s: a ' +
+ 'conflict carries no timestamp of its own, and base advancing does not touch the PR row.'
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H17 — the on-hold TRIGGER-FILE INDEX. Not a predicate: an inventory.
+//
+// ## What it is for (#10034, measured 0-for-19)
+//
+// The opportunistic-restart mechanism is a maintainer-accepted design
+// (2026-08-11): a hold comment names the FILES whose next edit should wake the
+// card, and a dispatching seat is supposed to intersect its dispatch's file
+// surface against those lists before dispatching. The audit that produced this
+// item measured the intersection never running in any lane: across six cards
+// the named trigger files were touched NINETEEN times and the rider was
+// carried ZERO times. The mechanism existed in SKILL.md and in hold comments;
+// no seat's loop executed it.
+//
+// The fix is not another written protocol step. It is to put the list where
+// the seat is already looking: the patrol anchor, which the dispatch protocol
+// now makes the first thing read each round. This section renders card → files
+// so the intersection is a GLANCE at a rendered table rather than a
+// remembered procedure over 79 hold comments nobody opens.
+//
+// ## Why it lives on the PATROL side and not in `dispatch-gates.mjs`
+//
+// The obvious shape — teach the dispatch gate to grep hold comments — cannot
+// run where the dispatch gate runs. A seat container's live GitHub read is 403
+// (the repo-scoped transport fact this whole file's prerequisite classifier
+// exists to name), so an intersection built into `dispatch-gates.mjs` would be
+// a second mechanism that never executes: exactly the disease, re-created one
+// layer down. The patrol runs on a GitHub Actions runner where the transport
+// prerequisite is met, so the gathering happens there and the seat reads the
+// rendered result. Zero new runtime dependency on the seat side.
+//
+// ## REPORT-ONLY, and more strictly than the predicates above
+//
+// Every H1–H16 row is an assertion that something is WRONG. An H17 row asserts
+// nothing of the kind: a hold naming trigger files is a hold in perfectly good
+// standing. So this item writes no label, has no staleness threshold, and can
+// never produce a finding — it is inventory, rendered next to the findings
+// because that is the page the reader already opens. The only bound in it is a
+// RENDER budget (`H17_INDEX_ROW_CAP`), which is the same class of constant as
+// `MARKDOWN_BODY_BUDGET` and not a judgement about the board.
+//
+// ## The extraction is deterministic, and drops what it cannot verify
+//
+// ⛔ No fuzzy parsing, and no LLM in the loop. Two stages, both closed:
+//
+// 1. ANCHOR. A line qualifies only if it carries one of a closed set of
+// terms (`H17_TRIGGER_ANCHOR_TERMS`) or is a canonical `Restart-touch:`
+// line. Everything else in the comment is ignored, however path-shaped.
+// 2. VALIDATE. Every candidate token is checked against `git ls-files`. A
+// token that is not a TRACKED FILE is DROPPED — never guessed at, never
+// normalised into something that would match. This is what makes the
+// index safe to render without review: a wrong row would send a seat to
+// intersect against a path that does not exist, and the intersection
+// would silently never hit.
+//
+// The census that produced the term set is in `H17_TRIGGER_ANCHOR_TERMS`.
+// ---------------------------------------------------------------------------
+
+/**
+ * The closed set of anchor terms, matched case-insensitively as substrings of
+ * a single line. Derived from a read of nine open/just-released `pm:on-hold`
+ * cards (2026-08-19, #10034's measurement round) — seven of which carry a
+ * trigger-file clause, and all seven of those are covered here:
+ *
+ * `trigger file` — #8897 (`**Trigger file: \`…\`**`), #8984
+ * (`Restart condition (named trigger files)`), #9139
+ * (`**Trigger files** (opportunistic-restart clause)`),
+ * #8662 (`**Opportunistic trigger files**`), #8883
+ * (`**Restart condition (trigger files):**`)
+ * `opportunistic` — #8656 (`3. **opportunistic:** any PR already editing …`),
+ * #9139, #8662
+ * `restart condition` — #8331 (`Named restart conditions: ① …`), #8883,
+ * #8984, #8662
+ *
+ * ⛔ `rider` is deliberately NOT in the set, though it is the mechanism's own
+ * name. It is the word the AUDIT and RELEASE comments use ("the armed rider
+ * fired three times without being carried — PRs #9869, #9990 and #10005 all
+ * touched `.github/workflows/lint.yml`"), so admitting it would harvest the
+ * post-mortem prose of holds that are no longer held, as though the file were
+ * still a live trigger. Measured on #8331's release comment, which contains a
+ * tracked path and names no trigger at all.
+ *
+ * The two cards in the sample with NO trigger clause (#9707, #9276 — both
+ * `Restart-when: closed …#N` holds) match no term and correctly contribute no
+ * row. That is the negative half of the census, and it is pinned in the
+ * self-test.
+ */
+export const H17_TRIGGER_ANCHOR_TERMS = ['trigger file', 'opportunistic', 'restart condition'];
+
+/**
+ * The CANONICAL machine-readable channel this index also reads, proposed by
+ * #10034 and not yet adopted anywhere on the board.
+ *
+ * Same discipline as `Blocked-by:` (H4) and `Restart-when:` (H9): a
+ * case-sensitive literal at the start of a line, ONE path per line, so the
+ * value needs no parsing at all. Today it matches ZERO live cards, and that is
+ * the intended state — the mechanism precedes the convention deliberately, so
+ * that the day a hold is written with `Restart-touch:` lines the index already
+ * reads them and no second change is owed. The prose-anchor extraction above
+ * is what serves the 79 holds written before it exists.
+ *
+ * Fresh regex per call: a shared module-level `/g` literal is a `lastIndex`
+ * bug waiting for the next reader (the same note `closingKeywordRe` carries).
+ */
+export function restartTouchRe() {
+ return /^[ \t]*(?:>[ \t]*)*(?:[-*+][ \t]+)?Restart-touch:[ \t]*(\S[^\n]*)$/gm;
+}
+
+/**
+ * How far past an anchor line the list scan will follow.
+ *
+ * Two of the seven measured clauses put their paths in a bulleted list UNDER
+ * the anchor sentence (#8984's three docs pages, #8662's two gate files)
+ * rather than on the anchor line itself, so the scan has to cross into the
+ * list — and once it does, something has to stop it from swallowing an entire
+ * card body when an anchor term happens to appear above a long unrelated list.
+ *
+ * The longest measured trigger list is 3 items. 12 leaves 4× headroom while
+ * bounding the blast radius of a stray anchor to a dozen lines. It is a
+ * PARSING bound, not a threshold on board state: nothing about the board
+ * changes what it means, and no row is suppressed by it that a hold author
+ * could not fix by writing a shorter list.
+ */
+export const H17_LIST_SCAN_LIMIT = 12;
+
+/**
+ * The rendered-row cap, and the reason it is a budget rather than a judgement.
+ *
+ * The index is reserved OUT of `MARKDOWN_BODY_BUDGET` before the findings rows
+ * are laid out, so that it can never be silently truncated away by a noisy
+ * board — but the reservation itself has to be bounded, or a pathological run
+ * could starve the findings list to render inventory. At the measured rate (7
+ * of 79 open holds carry a clause) 40 is ~5× headroom. An overflow is
+ * ANNOUNCED, never silent (#4690).
+ */
+export const H17_INDEX_ROW_CAP = 40;
+
+/** Is this line a markdown list item (the shape a trigger list is written in)? */
+function isListItemLine(line) {
+ return /^[ \t]{0,6}(?:[-*+]|\d{1,2}[.)])[ \t]+\S/.test(line);
+}
+
+/**
+ * Every backticked span on one line, unwrapped and trimmed.
+ *
+ * Backticks are the ONLY delivery shape read, and that narrowness is the
+ * precision. All seven measured clauses backtick their paths; admitting bare
+ * prose tokens would mean deciding whether `rest-server.ts` in a sentence is a
+ * trigger or a mention, which is the LLM-grade judgement this item refuses to
+ * make. A hold that names its trigger without backticks contributes no row and
+ * is invisible here — a stated boundary, and the argument for the
+ * `Restart-touch:` convention rather than a reason to widen the parser.
+ */
+function backtickedSpans(line) {
+ return [...String(line ?? '').matchAll(/`([^`\n]+)`/g)].map((m) => m[1].trim());
+}
+
+/**
+ * Stage 1 — the candidate tokens a text declares as trigger files, BEFORE any
+ * validation. Exported so the self-test can pin the anchor/continuation rules
+ * separately from the tracked-file oracle, which needs a checkout.
+ *
+ * Reads fenced blocks out (citations, not triggers) and inline spans IN (the
+ * signal itself) — the `{ inline: false }` half of `stripMarkdownCode`.
+ *
+ * The continuation rule, in the shape the measurement forced: from an anchor
+ * line, harvest that line's spans, then — allowing at most ONE blank line, as
+ * markdown requires before a list — consume the consecutive list block that
+ * follows, up to `H17_LIST_SCAN_LIMIT` items. A blank line AFTER the list has
+ * started ends it, so the scan cannot rejoin the prose on the far side.
+ *
+ * ## The under-report this leaves, found while reverse-verifying
+ *
+ * A path on a WRAPPED continuation line — the author hard-wrapped the clause
+ * and the path landed on the next source line, which is neither the anchor nor
+ * a list item — is not harvested. All seven measured clauses put the path on
+ * the anchor line or in a list item, because GitHub comment bodies are written
+ * as long unwrapped source lines, so the shape is currently hypothetical. It
+ * is recorded rather than fixed: widening the scan to "any following line"
+ * would re-admit the prose this bounds away, and the error direction here is
+ * the one this whole item keeps — a missing row costs a seat the intersection
+ * it would have got anyway before #10034, while a wrong row sends it to
+ * intersect against a file nobody nominated.
+ *
+ * @param {string} text an issue body or a single comment body
+ * @returns {string[]} raw candidate tokens, in document order, not deduped
+ */
+export function h17TriggerFileCandidates(text) {
+ const stripped = stripMarkdownCode(text, { inline: false });
+ const lines = stripped.split('\n');
+ const out = [];
+
+ // The canonical channel first — a whole-line value, so no span is needed and
+ // a bare (unbackticked) path is accepted here and ONLY here.
+ for (const m of stripped.matchAll(restartTouchRe())) {
+ out.push(m[1].trim().replace(/^`+|`+$/g, '').trim());
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ const lower = lines[i].toLowerCase();
+ if (!H17_TRIGGER_ANCHOR_TERMS.some((term) => lower.includes(term))) continue;
+ out.push(...backtickedSpans(lines[i]));
+
+ let j = i + 1;
+ let blanks = 0;
+ let started = false;
+ let taken = 0;
+ while (j < lines.length && taken < H17_LIST_SCAN_LIMIT) {
+ const line = lines[j];
+ if (line.trim() === '') {
+ if (started) break;
+ if (++blanks > 1) break;
+ j++;
+ continue;
+ }
+ if (!isListItemLine(line)) break;
+ out.push(...backtickedSpans(line));
+ started = true;
+ taken++;
+ j++;
+ }
+ }
+ return out;
+}
+
+/**
+ * Stage 2 — candidates from every text of one card, validated against the
+ * tracked-file set and returned sorted and deduped.
+ *
+ * `isTracked` is injected rather than read here so the whole extraction stays
+ * pure and the self-test can drive it with a fixture set instead of a
+ * checkout. A token the oracle does not recognise is DROPPED in silence: the
+ * measured decoys are `Field` and `FIXTURE_CAPTURED_NEGATED` (backticked
+ * identifiers sitting inside real trigger clauses on #8656 and #8662) and
+ * `scripts/check-type-check-coverage.mjs:1679` (a real path with a line suffix
+ * — tracked as a file, NOT as that token, so the suffix form correctly fails).
+ * Each of those is a row this index would otherwise have rendered wrong.
+ *
+ * @param {string[]} texts card body plus every hold-comment body
+ * @param {(path: string) => boolean} isTracked
+ * @returns {string[]}
+ */
+export function h17TriggerFiles(texts, isTracked) {
+ const found = new Set();
+ for (const text of texts ?? []) {
+ for (const token of h17TriggerFileCandidates(text)) {
+ if (token && isTracked(token)) found.add(token);
+ }
+ }
+ return [...found].sort();
+}
+
+/**
+ * The gathering policy — which cards are worth a comment fetch.
+ *
+ * Open `pm:on-hold` cards ONLY, which is exactly the population the index
+ * describes. The same candidate-gating idiom as H2's comment fetch and H16's
+ * detail GET: the request count is bounded by the population the item can
+ * actually speak about, never by the open board. Exported for the same reason
+ * `h16NeedsDetail` is — a policy that decides what gets READ AT ALL is where a
+ * silent hole would live.
+ */
+export function h17NeedsComments(issue) {
+ return labelNames(issue).includes('pm:on-hold');
+}
+
+/**
+ * Build the rendered index rows from cards already in hand.
+ *
+ * Cards contributing no validated path are omitted entirely rather than
+ * rendered empty: a hold with no trigger clause is the normal majority shape
+ * (2 of the 9 measured, and most of the 79 on the board), and printing 70
+ * empty rows would bury the handful that carry the signal this section exists
+ * to deliver.
+ *
+ * @param {Array<{ issue: object, texts: string[] }>} entries
+ * @param {(path: string) => boolean} isTracked
+ * @returns {Array<{ issue: object, files: string[] }>} ascending by number
+ */
+export function h17IndexRows(entries, isTracked) {
+ const rows = [];
+ for (const { issue, texts } of entries ?? []) {
+ const files = h17TriggerFiles(texts, isTracked);
+ if (files.length > 0) rows.push({ issue, files });
+ }
+ return rows.sort((a, b) => (a.issue?.number ?? 0) - (b.issue?.number ?? 0));
+}
+
+/**
+ * The tracked-file oracle. Returns a Set, or `null` when it could not be read.
+ *
+ * `-z` rather than plain `ls-files`: git QUOTES paths containing non-ASCII or
+ * special bytes in the default output ("packages/\303\251.ts"), and a quoted
+ * form would never match the token a hold comment backticks — silently
+ * dropping exactly the paths hardest to notice missing. The NUL-separated form
+ * is byte-exact.
+ *
+ * An EMPTY result reads as unavailable, not as "nothing is tracked". The
+ * difference is the whole #4690 posture at oracle granularity: an empty set
+ * would validate away every candidate and render a confidently empty index —
+ * the shape indistinguishable from a board where no hold names a trigger file,
+ * which is the silence this item exists to end.
+ */
+function readTrackedFiles() {
+ try {
+ const out = execFileSync('git', ['ls-files', '-z'], {
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ const set = new Set(out.split('\0').filter(Boolean));
+ return set.size > 0 ? set : null;
+ } catch {
+ return null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// H18 — `pm:retriage` aged past one triage cycle (maintainer-ruled
+// 2026-08-19/20, verbatim: 「同意 并存」). See the header doc for the full
+// rationale; the code here is deliberately small and mirrors H13's shape —
+// same threshold reasoning, same `updated_at` proxy, same unscoped population.
+// ---------------------------------------------------------------------------
+
+/**
+ * H18 threshold — one triage-Routine cycle, the same reasoning as
+ * `DOMAIN_HALF_STATE_STALE_HOURS`: the Routine fires HOURLY and re-judges
+ * every `pm:retriage` card "each fire, high priority" (SKILL.md, 「`pm:retriage`
+ * 重判每 fire 高优先处理」), so a card still carrying the label after 2h has
+ * survived at least one re-judgement pass it should not have. Age is read
+ * from `updated_at` rather than a per-card timeline fetch for the
+ * label-APPLICATION event: this sweep makes that fetch for no item (H13's
+ * same proxy choice, for the same reason — see `h13DomainWithoutPmState`),
+ * and every triage write on the card (grade kept or changed) bumps
+ * `updated_at`, so a stale reading here means nothing touched the card since
+ * any triage pass at all, which is exactly the failure this item exists to
+ * name.
+ */
+export const RETRIAGE_STALE_HOURS = 2;
+
+/**
+ * H18 — null when clean, else the finding sentence. `pm:retriage` coexists
+ * with the card's standing `pm:*` label by design (ensure-pm-labels.sh's own
+ * comment on the label object: "COEXISTS … and ⛔ never replaces it"), so a
+ * clean-shaped row names that coexisting label; `pm:retriage` present with NO
+ * other `pm:*` label is a shape the state model does not define, and that
+ * absence is worth its own sentence rather than a silently empty list — the
+ * disputed grading is unidentifiable (异议对象不明) from the label set alone.
+ * An unreadable `updated_at` flags rather than reads as fresh, same as
+ * H10–H13 (#4690).
+ */
+export function h18RetriageAged(issue, nowMs = Date.now()) {
+ const labels = labelNames(issue);
+ if (!labels.includes('pm:retriage')) return null;
+ const updated = Date.parse(issue.updated_at ?? '');
+ const ageHours = Number.isFinite(updated) ? (nowMs - updated) / 3_600_000 : null;
+ if (ageHours !== null && ageHours <= RETRIAGE_STALE_HOURS) return null;
+ const reading =
+ ageHours === null
+ ? 'an unreadable `updated_at` (which must not read as fresh)'
+ : `~${Math.round(ageHours)}h without activity (threshold ${RETRIAGE_STALE_HOURS}h)`;
+ const coexisting = labels.filter((l) => l.startsWith('pm:') && l !== 'pm:retriage');
+ const carrying =
+ coexisting.length > 0
+ ? `alongside its standing ${coexisting.map((l) => `\`${l}\``).join(', ')}`
+ : 'ALONE, with no coexisting standing `pm:*` label — the disputed grading is unidentifiable (异议对象不明)';
+ return (
+ `\`pm:retriage\` carried ${carrying}, ${reading} — the objecting seat's grade is still undecided past ` +
+ `one triage cycle. The triage Routine re-judges every \`pm:retriage\` card each fire (SKILL.md); a card ` +
+ `still here past the threshold is a re-judgement pass that did not run, not inventory: resolve the ` +
+ `grade (keep or change) and drop the label in the same write, oldest first.`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H19 — a block that OUTLIVED its blocker.
+//
+// The one question about a block that nothing here asked. Two items already
+// read the `Blocked-by:` line and NEITHER expires a block: H4 asks whether the
+// line EXISTS, H14 asks the REVERSE index (does anything target THIS card).
+// The question that actually ENDS a block — is the issue it names still open?
+// — had no reader at all, so a block outlives its blocker in complete silence:
+// well-formed line, correct label, no row anywhere.
+//
+// Measured on this board, both found by READING and neither by any gauge:
+//
+// • `Blocked-by: #10126` parked in a COMMENT; the target closed
+// 2026-08-20T09:03:37Z; the card sat blocked ~4.5 h past that and was
+// released only because a human walked the lane's dependency graph.
+// • 「`Blocked-by: #9612`」 in the BODY, backtick-decorated (the decorated-
+// directive shape); the target closed 2026-08-20T07:58:08Z; released only
+// by a manual triage pass.
+//
+// Both shapes are fixtures in the self-test, and between them they are why the
+// target list is read from BOTH channels through the shared reader rather than
+// from the body alone: one of the two measured cards states its blocker only
+// in a comment, so a body-only H19 would have seen exactly half of the
+// evidence this row was filed on.
+//
+// ## Report-only, and pointedly so
+//
+// The release is a protocol procedure with two mechanical double-checks the
+// state model spells out (`pm:blocked`/`pm:on-hold` row, 「放行双查(两查皆机
+// 械、零判断)」), and neither is a thing this file could perform: they read the
+// card's conversion-comment history and its merged-PR timeline. So this row
+// surfaces the CANDIDATE and the unlock sweep releases it. ⛔ Never a label
+// written from this script — the same posture H14 holds for `pm:blocking`.
+// ---------------------------------------------------------------------------
+
+/**
+ * One `Blocked-by:` ref as a canonical, comparable target.
+ *
+ * `blockedByTargets` returns the ref AS WRITTEN (`{repo: null | 'objectui' |
+ * 'objectstack-ai/objectui', number}`), and three spellings can name one
+ * issue. Collapsing them here is what makes the per-target cache a real cache
+ * rather than three cache entries and three requests for one answer.
+ *
+ * An UNQUALIFIED repo name (`objectui#4356`) is resolved against the swept
+ * repo's OWNER. That is a guess, and it is a SAFE one in the only direction
+ * that matters: if the owner is wrong the fetch fails and the target reports
+ * as `unresolved` — named on the row, never silently dropped and never read
+ * as closed. A guess that can only ever produce an "I could not tell" is
+ * worth making; one that could produce a false finding would not be.
+ *
+ * Note `buildBlockingIndex` makes the opposite call on the same ref shape and
+ * both are right: the INDEX drops cross-repo refs because reading `objectui#N`
+ * as a local number would invent a dependent for an unrelated card, while H19
+ * resolves them because the target's repo is part of the address it fetches.
+ * One asks "which LOCAL card is waiting", the other "is THAT issue still open".
+ *
+ * @param {{ repo: string|null, number: number }} ref
+ * @param {string} [ownerRepo] — `owner/repo`, defaulting to the swept one.
+ * @returns {{ key: string, repo: string, number: number, local: boolean }}
+ */
+export function blockerTargetKey(ref, ownerRepo = OWNER_REPO) {
+ const owner = ownerRepo.split('/')[0];
+ const written = ref?.repo ?? null;
+ const repo =
+ written === null ? ownerRepo : written.includes('/') ? written : `${owner}/${written}`;
+ const number = Number(ref?.number);
+ return { key: `${repo}#${number}`, repo, number, local: repo === ownerRepo };
+}
+
+/**
+ * Every DISTINCT `Blocked-by:` target one card names, both channels, in the
+ * order written — the input H19 resolves.
+ *
+ * Channels are UNIONED exactly as `buildBlockingIndex` unions them, and for
+ * the same reason: a card whose body says `#A` and whose comment says `#B` is
+ * waiting on BOTH, so a priority order would silently drop one live blocker.
+ * Dedup is by canonical key, so a card that states one target in both channels
+ * (the natural shape when a seat backfills the body line later) is resolved
+ * once and listed once.
+ *
+ * Self-references are dropped, as in the index: a card cannot be its own
+ * blocker, and resolving one would always answer `open` (the card is in the
+ * open listing by construction) — a permanent no-op that costs a row of noise
+ * in every explanation of what H19 read.
+ *
+ * ## The comment channel's stated boundary
+ *
+ * `commentBodies` is whatever the sweep's gated fallback read, and that gate
+ * (`needsBlockedByComments`) skips a card whose BODY already carries a line.
+ * So a card with a body line AND a second, different blocker parked in a
+ * comment has its comment-borne target invisible to H19 — a bound inherited
+ * from the gate, not a decision taken here. `undefined` (unconsulted) and
+ * `null` (consulted, unreadable) both contribute nothing; the `null` case is
+ * a card H4 is already firing on with a sentence that says the thread could
+ * not be read, which is the louder and more accurate place for it.
+ *
+ * @param {object} issue
+ * @param {string[]|null|undefined} commentBodies
+ * @param {string} [ownerRepo]
+ */
+export function blockerTargetsFor(issue, commentBodies, ownerRepo = OWNER_REPO) {
+ const refs = [
+ ...blockedByTargets(issue?.body),
+ ...commentBlockedByTargets(commentBodies),
+ ];
+ const out = [];
+ const seenKeys = new Set();
+ for (const ref of refs) {
+ const target = blockerTargetKey(ref, ownerRepo);
+ if (!Number.isFinite(target.number)) continue;
+ if (target.local && target.number === issue?.number) continue;
+ if (seenKeys.has(target.key)) continue;
+ seenKeys.add(target.key);
+ out.push(target);
+ }
+ return out;
+}
+
+/**
+ * Which cards H19 resolves targets for — exported for the same reason every
+ * other gathering policy here is: a policy that decides what gets READ AT ALL
+ * is where a silent hole would live.
+ *
+ * `pm:blocked` and nothing else, which is the ruled scope and also the exact
+ * population H4 judges — the two items then say complementary things about one
+ * set of cards ("did you leave the machine a line" / "is what the line names
+ * still running"). A card carrying a `Blocked-by:` line WITHOUT the label is
+ * deliberately out of scope: that is a different half-state (a wait nobody
+ * declared), and inventing a row for it here would report against cards whose
+ * line is documentation rather than state.
+ */
+export function needsBlockerLiveness(issue) {
+ return labelNames(issue ?? {}).includes('pm:blocked');
+}
+
+/**
+ * How many targets a row names before it counts the rest — the same render
+ * budget `BLOCKING_DEPENDENT_LIST_CAP` keeps, for the same reason (the
+ * markdown renderer writes into a body with a hard cap and a fold). Every
+ * blocked card measured on this board names one or two targets, so five names
+ * the whole set in practice while bounding the pathological case.
+ */
+export const H19_TARGET_LIST_CAP = 5;
+
+/** `#N` for a local target, `owner/repo#N` for a cross-repo one, + its note. */
+function namedTargets(rows) {
+ const shown = rows.slice(0, H19_TARGET_LIST_CAP);
+ const named = shown
+ .map((r) => {
+ const ref = `\`${r.local ? `#${r.number}` : r.key}\``;
+ if (r.state === 'closed') return `${ref}${r.closedAt ? ` (closed ${r.closedAt})` : ' (closed)'}`;
+ if (r.state === 'unresolved') return `${ref}${r.detail ? ` (${r.detail})` : ''}`;
+ return ref;
+ })
+ .join(', ');
+ const more = rows.length > shown.length ? ` +${rows.length - shown.length} more` : '';
+ return `${named}${more}`;
+}
+
+/**
+ * H19 — null when every named target is still open, else the finding sentence.
+ *
+ * ## Three target states, never two (#4690)
+ *
+ * A resolution is `open`, `closed`, or `unresolved`, and the third is the one
+ * the row exists to keep visible. "Could not be read" is not "still open": a
+ * target dropped in silence reads as a healthy block FOREVER, which is
+ * precisely this item's own disease wearing a new mask. So an unresolved
+ * target FIRES a row — a quieter one, which says the liveness is unjudged
+ * rather than asserting anything about the block.
+ *
+ * What the row deliberately does NOT do is name a CAUSE for an unresolved
+ * target. A 404 on `owner/repo#N` is equally "that repo is not reachable to
+ * this credential" and "that issue number does not exist in a perfectly
+ * reachable repo", and this file's standing posture is to refuse to name what
+ * it cannot distinguish (the transport classifier's narrowness, and H16's
+ * refusal to vouch for an `unknown` mergeability). The observation — the ref
+ * and the HTTP status — is reported; the diagnosis is the reader's.
+ *
+ * ## A PARTIAL discharge is reported as partial, not as an unblock
+ *
+ * A card naming two blockers where one has closed is very possibly still
+ * legitimately blocked. The row says how many closed and how many are still
+ * open and leaves the judgement where the protocol puts it — with the unlock
+ * sweep's double-checks. Report-only means the row never decides; it also
+ * means the row must not go quiet on a half-expired block, because "one of
+ * your two blockers landed" is exactly the state a seat cannot see by looking.
+ *
+ * @param {object} issue — an OPEN issue.
+ * @param {{ key: string, number: number, local: boolean,
+ * state: 'open'|'closed'|'unresolved', closedAt?: string|null,
+ * detail?: string|null }[]} resolutions — this card's targets, resolved.
+ */
+export function h19BlockOutlivedBlocker(issue, resolutions) {
+ if (!needsBlockerLiveness(issue)) return null;
+ const rows = resolutions ?? [];
+ if (rows.length === 0) return null;
+ const closed = rows.filter((r) => r.state === 'closed');
+ const unresolved = rows.filter((r) => r.state === 'unresolved');
+ const open = rows.filter((r) => r.state === 'open');
+ if (closed.length === 0 && unresolved.length === 0) return null;
+
+ const release =
+ ' Report-only, and the release is NOT this script\'s to make: the state model gives it two ' +
+ 'mechanical double-checks (`pm:blocked`/`pm:on-hold` row, 「放行双查」) — ① release only against the ' +
+ 'condition carried by the MOST RECENT conversion comment, never an earlier blocker on the thread (a ' +
+ 'condition already spent, re-fired, reinstates an expired premise as the current one), and ② refuse ' +
+ 'to release when the card carries a MERGED PR newer than that conversion comment (the card moved on ' +
+ 'after the condition was written, so the cited fact can be true and no longer current). This row ' +
+ 'surfaces the candidate; the unlock sweep releases it — ⛔ never a label written from this script. ' +
+ 'When that release does happen, its paired write includes 「同笔摘 assignee」: a card returned to ' +
+ '`pm:queue` still carrying the assignee of the seat that parked it is dispatchable to the queue ' +
+ 'view and taken to the claim rule at the same time (H24), which is the state the unlock scan was ' +
+ 'measured leaving behind — ⚠️ agent identity only, a HUMAN assignment is ⛔ never cleared by an agent.';
+
+ if (closed.length > 0) {
+ const rest =
+ open.length > 0
+ ? ` ${open.length} target(s) are still open (${namedTargets(open)}), so this is a PARTIAL ` +
+ 'discharge and the card may still be legitimately blocked — the row reports it, it does not ' +
+ 'decide it.'
+ : ' Every target it names is closed: nothing this card declared a wait on is still running.';
+ const alsoUnresolved =
+ unresolved.length === 0
+ ? ''
+ : ` A further ${unresolved.length} target(s) could not be resolved this sweep ` +
+ `(${namedTargets(unresolved)}) and are unjudged, not open (#4690).`;
+ return (
+ `\`pm:blocked\` while ${closed.length} of ${rows.length} \`Blocked-by:\` target(s) — read from body ` +
+ `OR comment — ${closed.length === 1 ? 'is' : 'are'} CLOSED (${namedTargets(closed)}): the block has ` +
+ 'outlived its blocker. Nothing else here asks this question — H4 asks whether the line EXISTS, H14 ' +
+ 'asks the REVERSE index — so an expired block sits with a well-formed line, a correct label and no ' +
+ 'row anywhere: one measured card sat ~4.5h past its blocker\'s close and was found only by a human ' +
+ 'walking the graph, another was released only by a manual triage pass.' +
+ rest +
+ alsoUnresolved +
+ release
+ );
+ }
+
+ return (
+ `\`pm:blocked\` and ${unresolved.length} of ${rows.length} \`Blocked-by:\` target(s) could NOT be ` +
+ `resolved this sweep (${namedTargets(unresolved)}) — so whether this block has outlived its blocker ` +
+ 'is UNJUDGED, not confirmed. Unread is not still-open (#4690): a target dropped in silence reads as ' +
+ 'a healthy block forever, which is the exact failure this item exists to end, so it is named here ' +
+ 'instead. A cross-repo target resolves when its repo answers this sweep\'s credential; the status is ' +
+ 'reported and the cause is not guessed at (a 404 is equally an unreachable repo and a number that ' +
+ 'does not exist).' +
+ (open.length > 0
+ ? ` The card's other ${open.length} target(s) did resolve, and are still open.`
+ : '') +
+ release
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H20 — a `pm:dispatched` card whose claimed branch has NO REMOTE REF AT ALL.
+//
+// Claiming and dispatching are two acts with a gap between them (#10312).
+// The protocol's CLAIM is a careful atom — assign + label swap in one write,
+// the claim comment, a race re-read — and LAUNCHING the dev is a third act
+// outside that atom which nothing binds to the first two. So a seat can
+// complete a perfectly well-formed claim and then not dispatch: a maintainer
+// message arrives and is answered, a tool errors, context is lost. What is
+// left behind is a card that is `pm:dispatched`, assigned, carrying a full
+// claim comment naming a branch and a worktree — and no agent anywhere is
+// working on it.
+//
+// ⭐ Why this needed a ROW and not a rule: the failure is INVISIBLE FROM THE
+// CARD. Every field on it is correct. Only the absence of something ELSEWHERE
+// is wrong, and no reader of the card can see it — every seat and every gauge
+// reads it as work in progress. A skill sentence ("a claim is not complete
+// until the agent is launched") is a rule, and it fails the same way the first
+// time someone is interrupted; the mechanism has to live where the absence is
+// observable.
+//
+// The adjacent rows each decline it, for a good reason of its own:
+// H2 assignee with NO claim comment — here the claim comment is present
+// and complete, which is exactly the point.
+// H4 / H14 `Blocked-by:` — not a blocked card.
+// H9 `pm:on-hold` without `Restart-when:` — not held.
+// H8 a MERGED PR while still dispatched — nothing merged; nothing ran.
+// Nothing asked whether a card labelled `pm:dispatched` is actually BEING
+// WORKED. That is the same missing question H19 asks about a block: a state
+// label asserting an external fact that nothing re-checks.
+//
+// ## Measured — #8878, 2026-08-20
+//
+// The claim comment (`5356927509`) was posted at ~14:05Z. Before the dispatch
+// tool was called a maintainer message arrived and was answered, and the
+// second half never happened. The card sat `pm:dispatched` with nobody on it
+// for 74 MINUTES, until a patrol tick compared branch heads and found no ref.
+//
+// ⚠️ The interruption is the MECHANISM, not an excuse: any seat that answers a
+// message, hits a tool error, or loses context between the claim and the launch
+// produces the same state. And ⚠️ the symptom is IDENTICAL to a dev agent that
+// died (no branch, no PR, no report) while the remedies are OPPOSITE — a dead
+// agent needs a probe, an undispatched claim needs a dispatch. So the row
+// reports the observation and names both readings; it does not diagnose one.
+//
+// ## ⛔ The key is "NO REF AT ALL" — never "no PR yet"
+//
+// This is the one thing the item must not get wrong, and the filing card is
+// explicit: a dev inside a long build legitimately has a ref and no PR for
+// over an hour, which that round measured repeatedly. Keying on the PR would
+// fire hardest on the healthiest dev on the board. The guarantee here is
+// structural rather than a matter of care — `h20DispatchedNoBranchRef` is
+// handed a REF STATE and nothing else, so it cannot see a PR and cannot key on
+// one — and the ref-exists-with-no-PR shape is a regression pin below.
+//
+// ## Three ref states, never two (#4690)
+//
+// `exists` → healthy, no row. `absent` → the finding. `unreadable` (any
+// non-404 failure) → its OWN quieter row saying the dispatch is unjudged. The
+// third is the one the row exists to keep visible: a ref read dropped in
+// silence reads as a healthy dispatch forever, which is this item's own
+// disease wearing a new mask. H19 landed the same three-way shape for the same
+// reason, and this follows it deliberately rather than inventing a second
+// spelling.
+// ---------------------------------------------------------------------------
+
+/**
+ * H20's threshold, and the only one in this file measured in MINUTES — because
+ * the interval it bounds is a minutes-scale quantity and rounding it to hours
+ * would either miss the measured incident or wait three times as long as it
+ * needs to.
+ *
+ * ## The measured basis
+ *
+ * This lane's dispatch→draft-PR latency was measured at 35–50 minutes, and a
+ * dev PUSHES ITS BRANCH well before the PR — the branch is the first thing an
+ * os-dev creates, ahead of the first edit (the branch-push probe is step 1 of
+ * the dev contract), so the observable this row keys on lands minutes into a
+ * run rather than at its end. 60 minutes therefore sits above the whole
+ * measured PR band while keying on a signal that arrives far earlier, which is
+ * why it can be this tight without firing on a slow dev.
+ *
+ * Against the specimen: #8878 sat 74 minutes, so 60 catches it with 14 minutes
+ * to spare. The card's own recommendation was "around 60 minutes with no ref at
+ * all", and this is that number.
+ */
+export const DISPATCHED_NO_REF_STALE_MINUTES = 60;
+
+/**
+ * The protocol's dev-branch shape — `claude/issue--`. This row has an
+ * observable at all only because the protocol already writes one: the claim
+ * comment NAMES the thing whose absence is the finding.
+ *
+ * A `Branch:` line naming some OTHER shape (a bare `main`, a hand-cut
+ * `feat/…`) is deliberately left unmatched, which puts the card out of this
+ * row's scope entirely. The alternative — probing whatever text follows the
+ * colon — would spend ref reads on prose and manufacture findings out of
+ * typos. Under-reporting on an unrecognised spelling is the same call H17's
+ * extractor makes, for the same reason: a fabricated row sends a reader to
+ * check something that was never there.
+ *
+ * ⚠️ `g` is load-bearing (`matchAll` requires it) and therefore this constant
+ * is for `matchAll` ONLY — `.test()`/`.exec()` on a shared global regex carry
+ * `lastIndex` between callers and answer differently on alternate calls.
+ */
+export const CLAIM_BRANCH_SHAPE = /claude\/issue-\d+-[A-Za-z0-9][A-Za-z0-9._-]*/g;
+
+/** How many branches one row names before it counts the rest — H19's budget, same grounds. */
+export const H20_BRANCH_LIST_CAP = 5;
+
+/**
+ * Every protocol-shaped branch named by a `Branch:` directive in one comment
+ * body, de-duplicated, in the order written.
+ *
+ * Only the `Branch:` LINE is read, not the whole comment: a claim comment also
+ * quotes worktree paths and sibling branches in prose, and the directive line
+ * is the field the protocol actually fills in. Decoration is expected and
+ * tolerated — 「Branch: `claude/issue-10312-…`」 is the natural markdown for a
+ * line meant to be grepped, and the same decorated-directive lesson H4 paid
+ * for (#10102) applies verbatim here.
+ */
+export function claimedBranches(body) {
+ const out = [];
+ const text = String(body ?? '');
+ for (const line of text.matchAll(/^\s*>?\s*Branch(?:es)?\s*[::]\s*(.*)$/gim)) {
+ for (const hit of String(line[1] ?? '').matchAll(CLAIM_BRANCH_SHAPE)) {
+ if (!out.includes(hit[0])) out.push(hit[0]);
+ }
+ }
+ return out;
+}
+
+/**
+ * The claim this card is CURRENTLY waiting on — the MOST RECENT claim comment
+ * that names a branch — or null when no comment does.
+ *
+ * ## Why the most recent, and not the first or the union
+ *
+ * The same call H19's release double-check ① makes about conversion comments,
+ * for the same reason: a claim already spent, re-read as current, reinstates
+ * an expired premise. A re-claimed card (the #5925 stale-claim reclaim is the
+ * measured shape) carries two claim comments, and the older one describes work
+ * the board is no longer waiting on — judging it would report a dead branch
+ * that everybody has already agreed is dead.
+ *
+ * An UNPARSEABLE `created_at` does not disqualify a comment: it falls back to
+ * thread order for the recency comparison, and the age it yields is `null`,
+ * which the predicate treats as "must not read as fresh" — H10/H13/H18's
+ * standing call on an unreadable timestamp (#4690).
+ *
+ * @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 };
+}
+
+/**
+ * How old the governing claim is, in minutes — `null` when the timestamp is
+ * unreadable, which is NOT the same as young (#4690) and is why this returns
+ * three-valued rather than a number with a sentinel.
+ */
+export function claimAgeMinutes(claim, nowMs = Date.now()) {
+ const posted = Date.parse(claim?.createdAt ?? '');
+ return Number.isFinite(posted) ? (nowMs - posted) / 60_000 : null;
+}
+
+/**
+ * Which cards buy a ref read — exported for the reason every gathering policy
+ * here is: a policy that decides what gets READ AT ALL is where a silent hole
+ * would live.
+ *
+ * `pm:dispatched` (regardless of assignee — an unassigned dispatched card is
+ * H1's finding and can still carry a claim naming a branch), a governing claim
+ * that names at least one protocol-shaped branch, and an age past the
+ * threshold. The age gate is a GATHERING gate as well as a predicate gate on
+ * purpose: a young card is not stuck, so probing it would spend a request to
+ * learn nothing, and the row says nothing about it either way. The predicate
+ * re-checks the age independently so an over-gathering caller still cannot
+ * produce a row about a fresh claim.
+ */
+export function h20NeedsRefProbe(issue, claim, nowMs = Date.now()) {
+ if (!labelNames(issue ?? {}).includes('pm:dispatched')) return false;
+ if (!claim || (claim.branches ?? []).length === 0) return false;
+ const age = claimAgeMinutes(claim, nowMs);
+ return age === null || age > DISPATCHED_NO_REF_STALE_MINUTES;
+}
+
+/** `` `branch` `` for each named ref, capped at the render budget, + its note. */
+function namedBranches(rows) {
+ const shown = rows.slice(0, H20_BRANCH_LIST_CAP);
+ const named = shown
+ .map((r) => `\`${r.branch}\`${r.state === 'unreadable' && r.detail ? ` (${r.detail})` : ''}`)
+ .join(', ');
+ return `${named}${rows.length > shown.length ? ` +${rows.length - shown.length} more` : ''}`;
+}
+
+/**
+ * H20 — null when the claimed branch exists (or the card is out of scope),
+ * else the finding sentence.
+ *
+ * ## What it is NOT given, and why that is the design
+ *
+ * It receives the card, the governing claim and a REF STATE per branch. It is
+ * given no PR list, no merge state and no timeline, so ⛔ "no PR yet" is not a
+ * thing this predicate could key on even by accident — the guarantee the
+ * filing card asked for, made structural instead of remembered.
+ *
+ * ## The three-state fold
+ *
+ * any `exists` → clean. Something IS on the board for this card, and
+ * whether it has a PR yet is none of this row's business.
+ * all `absent` → the finding. Nothing was ever pushed for this claim.
+ * any `unreadable` → the quieter row. "No ref at all" is an assertion about
+ * absence, and an unread probe cannot support it, so the
+ * row says the dispatch is UNJUDGED rather than either
+ * vouching for it or claiming a finding it did not
+ * measure.
+ *
+ * An EMPTY `refStates` means the caller never probed; that is a caller
+ * contract, not a reading, and it yields no row — H19's identical treatment of
+ * absent resolutions.
+ *
+ * @param {object} issue — an OPEN issue.
+ * @param {{ branches: string[], createdAt: string|null }|null} claim
+ * @param {{ branch: string, state: 'exists'|'absent'|'unreadable',
+ * detail?: string|null }[]} refStates
+ */
+export function h20DispatchedNoBranchRef(issue, claim, refStates, nowMs = Date.now()) {
+ if (!labelNames(issue ?? {}).includes('pm:dispatched')) return null;
+ if (!claim || (claim.branches ?? []).length === 0) return null;
+ const age = claimAgeMinutes(claim, nowMs);
+ if (age !== null && age <= DISPATCHED_NO_REF_STALE_MINUTES) return null;
+ const rows = refStates ?? [];
+ if (rows.length === 0) return null;
+ if (rows.some((r) => r.state === 'exists')) return null;
+ const unreadable = rows.filter((r) => r.state === 'unreadable');
+ const absent = rows.filter((r) => r.state === 'absent');
+ if (absent.length === 0 && unreadable.length === 0) return null;
+
+ const reading =
+ age === null
+ ? 'an unreadable claim timestamp (which must not read as fresh)'
+ : `~${Math.round(age)} min after the claim was posted (threshold ${DISPATCHED_NO_REF_STALE_MINUTES} min)`;
+
+ const remedy =
+ ' Report-only: the remedy is a DISPATCH or a withdrawn claim, ⛔ never a label written from this ' +
+ 'script — the same posture H14 holds for `pm:blocking` and H19 for a released block.';
+
+ if (unreadable.length > 0) {
+ return (
+ `\`pm:dispatched\` and the remote ref for ${unreadable.length} of ${rows.length} claimed branch(es) ` +
+ `could NOT be read this sweep (${namedBranches(unreadable)}) — so whether anything is working this ` +
+ 'card is UNJUDGED, not confirmed. Unread is not absent and it is not present either (#4690): a ref ' +
+ 'probe dropped in silence reads as a healthy dispatch forever, which is the exact failure this item ' +
+ 'exists to end, so it is named here instead. The status is reported and the cause is not guessed at.' +
+ (absent.length > 0
+ ? ` The card's other ${absent.length} claimed branch(es) DID resolve, and have no ref ` +
+ `(${namedBranches(absent)}) — but "no ref at all" is a claim about every branch this card names, ` +
+ 'and one unread probe is enough to withhold it.'
+ : '') +
+ remedy
+ );
+ }
+
+ return (
+ `\`pm:dispatched\` with a complete claim comment naming ${namedBranches(absent)} — and NO SUCH REMOTE ` +
+ `REF EXISTS, ${reading}. Claiming and dispatching are two acts with a gap between them: the claim is ` +
+ 'an atom (assign + label swap in one write, the claim comment, a race re-read) and LAUNCHING the dev ' +
+ 'is a third act outside it, so a seat interrupted between the two leaves exactly this card — every ' +
+ 'field correct, every gauge reading "in progress", nobody working it. It is invisible from the card ' +
+ 'itself: only the absence of something elsewhere is wrong. The measured specimen sat 74 min before a ' +
+ 'patrol tick compared branch heads. ⚠️ This symptom is IDENTICAL to a dev agent that died (no branch, ' +
+ 'no PR, no report) and the remedies are OPPOSITE — a dead agent needs a probe, an undispatched claim ' +
+ "needs a dispatch — so read the claiming seat's own action sequence before assuming either. ⛔ This " +
+ 'row keys on NO REF AT ALL, never on "no PR yet": a dev inside a long build legitimately has a ref ' +
+ 'and no PR for over an hour. One reading to rule out first: if this card\'s delivery already MERGED, ' +
+ 'the branch is gone by design and the missing paired write is H8\'s, not this one.' +
+ remedy
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H21 — a closing keyword bound to `#N` inside a sentence that NEGATES it
+// (#10392). H7's rationale, minus H7's `Part of` precondition.
+//
+// ## The gap, and the card it cost
+//
+// H7's header states the rule in fully general terms — GitHub's parser "matches
+// the keyword plus the number and ignores the surrounding prose entirely,
+// negations and modals included, so the sentence an author writes to PREVENT an
+// auto-close is exactly what performs it on merge". The PREDICATE behind that
+// sentence is narrower than the sentence: it is bound to a `Part of #N`
+// declaration and fires only when the same `#N` carries both. A body that
+// declares `Part of` for nothing is silent by construction, however plainly it
+// says it is not closing the card.
+//
+// Measured specimen — PR #10241, merged 2026-08-20T15:10:06Z. Its body carries
+// no `Part of` anywhere and this sentence under `## Out of scope`:
+//
+// Filed, not fixed: #10240 — the same leak through the **delete** verb.
+//
+// Issue #10240 closed `completed` at 15:10:08Z — two seconds later — with a
+// closing-link summary naming #10241 and nothing else. #10240 is a genuine
+// unfixed defect (attachment tombstoning no-ops on a predicate delete); it read
+// as finished until a human reopened it a day later. The author wrote the
+// sentence to record that the card was deliberately LEFT OPEN, and the sentence
+// closed it. H7 was silent, exactly as designed.
+//
+// ## Why this is a window and not "flag every closing keyword"
+//
+// The naive widening is not available and the corpus says so quantitatively: a
+// PR cannot declare which cards it intends to close except BY using the keyword,
+// so a rule over keyword-presence alone cannot separate intent from accident.
+// Over the 300 most recently merged PR bodies (below), 277 carry a closing
+// keyword bound to a number and there are 301 such matches — flagging keyword
+// presence would produce 301 findings, every one of them a correct PR.
+//
+// The negation is what makes the two separable, because it is the AUTHOR'S OWN
+// statement of intent sitting in the same sentence as the instruction that
+// contradicts it. That is a contradiction internal to one sentence, which is
+// the same shape H7 already reports across `Part of` and a keyword.
+//
+// ## The window bound is load-bearing — measured, not assumed
+//
+// Corpus (2026-08-21, this change's own stage-1 measurement): the 300 most
+// recently merged PRs into `main`, bodies as the API returns them,
+// 2026-08-19T15:13:23Z … 2026-08-21T19:11:30Z, 2,564,259 body characters, no
+// empty bodies. Read through `stripMarkdownCode`, exactly as H7 reads.
+//
+// window scope flagged / 301 keyword matches
+// sentence (this rule) 1 — PR #10241, the specimen. 0 false positives.
+// paragraph 1 — same single hit
+// whole body before match 14 — 13 false positives
+// anywhere in body 301 — the naive shape; useless
+//
+// All 13 whole-body false positives are ONE PR (#10714) which legitimately
+// closes fourteen cards with a wall of `Fixes #N` lines and merely contains a
+// negation word somewhere earlier in a long body. A body-scoped negation check
+// would red-flag the most correct multi-close PR in the corpus thirteen times.
+// That is why the window is a sentence and why it is stated in code rather than
+// left to a reviewer's judgement.
+//
+// The result is not clean for lack of opportunity, which is the failure mode a
+// 0-of-301 number invites: the same corpus carries 116 sentences in exactly the
+// deliberate-non-closure register this rule reads — `## Out of scope`,
+// "filed, not fixed here", "#N is not addressed here", "#N remains open". The
+// register is everywhere; only one author put a closing keyword next to the
+// number. Two near-misses are worth naming because they are the specimen's
+// wording almost exactly and are correctly clean: #10876's "## Out of scope —
+// filed, not repaired here" and #10851's "filed, not fixed here" — neither
+// binds a keyword to a number.
+//
+// Second corpus, a different surface and the same answer: all 1,418 squash
+// commit messages on `main` (2026-08-11 … 2026-08-21) — a surface GitHub's
+// closing-keyword parser also reads — carry 228 keyword+`#N` matches across 197
+// commits and produce 0 flags. That arm contributes no true positive either:
+// commit messages here do not carry the `## Out of scope` register at all.
+//
+// ## Report-only, and deliberately NOT wired to the blocking gate
+//
+// `scripts/check-partof-closing-keyword.mjs` imports `h7PartOfWithClosingKeyword`
+// and FAILS a PR on it. This predicate is deliberately a separate function that
+// that gate does not import, so widening the class cannot silently widen a
+// blocking check. Report-only first is the commissioned order (#10392 triage,
+// 2026-08-21): measure, ship the row, and let a promotion to blocking be its own
+// decision with these numbers in hand. Nothing about this file's report-only
+// contract changes, and the existing patrol workflow already calls the sweep, so
+// no workflow edit is part of this.
+//
+// ## Disjoint from H7 by construction
+//
+// A number already declared `Part of #N` is H7's row and is skipped here, so the
+// two never double-report one number. H7 keeps that class whether or not the
+// sentence is negated (its own self-test pins a negated `Part of` body as an H7
+// finding); this rule takes the class H7 cannot see — a keyword bound to a
+// number the body never declared itself part of.
+// ---------------------------------------------------------------------------
+
+/**
+ * The negation / filing markers, EXACTLY as measured above.
+ *
+ * The set is pinned to the measured one on purpose: a marker added later
+ * without re-running the corpus would inherit a "0 false positives" number it
+ * was never measured under. Each was also measured ALONE against both corpora
+ * and each is independently clean; `not` and `filed` are the two that fire on
+ * the specimen.
+ *
+ * The bare noun `file`/`files` is deliberately NOT a marker although it too
+ * measured clean. It carries no negation or filing sense — "this file fixes
+ * #123" is a normal, correct close — and it is one of the most common nouns in
+ * this repo's prose, so it is the marker most likely to turn into a false
+ * positive on a corpus this one did not sample.
+ */
+const NEGATED_CLOSE_MARKER_RE =
+ /\b(?:not|cannot|never|no longer|filed|filing|out of scope|rather than|instead of|without|remains open)\b|\bn't\b/i;
+
+/**
+ * The start offset of the sentence containing `idx`.
+ *
+ * Boundaries are sentence-ending punctuation, a blank line (paragraph break),
+ * and a markdown structural line start (heading, list item, table row, block
+ * quote). A PLAIN single newline is deliberately NOT a boundary: PR bodies are
+ * soft-wrapped but commit messages in this repo are hard-wrapped at ~72
+ * columns, so prose sentences routinely span lines there, and treating every
+ * newline as a break would blind the window on exactly the second corpus.
+ *
+ * Exported for the self-test: the window is the whole design, so it is pinned
+ * directly rather than only through the predicate's verdict.
+ */
+export function sentenceStartOffset(text, idx) {
+ const head = String(text ?? '').slice(0, idx);
+ let best = 0;
+ for (const re of [
+ /[.!?][)\]"'`]*[ \t\n]/g,
+ /\n[ \t]*\n/g,
+ /\n[ \t]*(?:#{1,6}\s|[-*+]\s|\d+\.\s|\||>)/g,
+ ]) {
+ let m;
+ while ((m = re.exec(head)) !== null) {
+ const end = m.index + m[0].length;
+ if (end > best) best = end;
+ }
+ }
+ return best;
+}
+
+/**
+ * H21 — null when clean, else the finding sentence.
+ *
+ * Reads the same code-stripped text H7 reads, so a keyword quoted in backticks
+ * or parked in a fence is not a finding here either — the measured reason is in
+ * `stripMarkdownCode`.
+ */
+export function h21NegatedClosingKeyword(pr) {
+ const body = pr?.body ?? '';
+ const text = stripMarkdownCode(body);
+ const declared = partOfTargets(body);
+ const hits = [];
+ for (const m of text.matchAll(closingKeywordRe())) {
+ const [full, keyword, number] = m;
+ if (declared.has(number)) continue; // H7 owns that number
+ const start = sentenceStartOffset(text, m.index);
+ if (!NEGATED_CLOSE_MARKER_RE.test(text.slice(start, m.index))) continue;
+ hits.push({
+ keyword,
+ number,
+ sentence: text.slice(start, m.index + full.length).replace(/\s+/g, ' ').trim(),
+ });
+ }
+ if (hits.length === 0) return null;
+ return hits
+ .map(
+ (h) =>
+ `body carries \`${h.keyword} #${h.number}\` in a sentence that reads as NOT closing it — ` +
+ `"${h.sentence}". GitHub's closing-keyword parser matches the keyword plus the number and ` +
+ `ignores the surrounding prose entirely, negations included, so merging this closes ` +
+ `#${h.number} and the sentence written to prevent that is what performs it. The measured ` +
+ `specimen (PR #10241, "Filed, not fixed: #10240") closed a genuine unfixed card two seconds ` +
+ `after merge, and a closed card reads as finished. Reword so no closing keyword sits next ` +
+ `to that number — "#${h.number} is not addressed here" / "out of scope: #${h.number}" / ` +
+ `"#${h.number} remains open" — or put the keyword in backticks.`,
+ )
+ .join('; ');
+}
+
+// ---------------------------------------------------------------------------
+// H22 — a CLOSED card still carrying a `pm:*` STATE label (#10688).
+//
+// Every other item here is scoped to open issues by construction, and for most
+// of them that is right. It is a gap for H8 specifically, because H8's whole
+// subject is a write that has not happened yet — and the card is usually closed
+// by the same merge that discharges the PR, often by a `Closes #N` in the same
+// instant. So whether H8 ever got to fire was decided by a RACE: if the patrol
+// happened to run between "PR merged" and "card closed" the finding was raised;
+// if the card closed first — the normal path — the duty was silently discharged
+// by disappearance, because no run would ever look at that card again.
+//
+// Measured at filing (2026-08-21, the 500 most recently updated closed issues):
+// 129 closed cards still carried a live `pm:` label, 118 of them
+// `pm:dispatched` — the signature of exactly the write H8 was built to catch,
+// unmet at scale because the card closed first.
+//
+// This is direction A of that card: keep the open-only default for every other
+// collector and add ONE bounded closed reader, so the race closes without
+// widening the sweep. It is the direction matching what H8 already claims to be
+// for. The counterargument — that labels on a closed card are historical
+// metadata — does not dispose of it: `pm:dispatched` is not descriptive, it is
+// a claim of in-flight-ness, and this file's own H8 treats leaving it set as a
+// defect worth a named rule. If that is a defect at 09:00 while the card is
+// open and not a defect at 09:01 once it closes, the rule is about the board's
+// tidiness rather than about the duty, and H8's text says otherwise.
+// ---------------------------------------------------------------------------
+
+/**
+ * The `pm:*` labels that are STATE CLAIMS, and therefore residue on a closed
+ * card. Exactly the five the #10688 census measured.
+ *
+ * ⚠️ This is deliberately NOT `PM_STATE_LABELS` (H13's), and the two must not
+ * be unified on the strength of the similar name. H13 asks "does any label make
+ * this card visible to a named reader?", so its list carries `finding`,
+ * `needs-user-decision`, `pm:epic` and `pm:seat` and deliberately OMITS
+ * `pm:blocking`. H22 asks a different question — "does this label CLAIM work is
+ * in flight?" — and the answers diverge in both directions: `pm:blocking` is
+ * such a claim (it is what the lane selection order ranks on) while `finding`
+ * and `needs-user-decision` are perfectly good states for a closed card to have
+ * ended in. Sharing one list would make H22 report every closed finding card on
+ * the board and miss the blocking-cache residue entirely.
+ *
+ * Three `pm:*` labels are excluded here because they are not claims that work
+ * is in flight:
+ *
+ * `pm:seat` a seat-registry post's TYPE sticker — the protocol carrier
+ * itself, whose label is what makes the seat list page a board.
+ * A closed seat card keeps it as identity, not as state.
+ * `pm:epic` a delegation marker on a parent, the same kind of identity.
+ * `pm:retriage` a request for re-judgement. Plausibly residue too, and it did
+ * not appear in the census — so it stays out until something
+ * measures it, rather than being widened in on a hunch. The
+ * set is one edit away when that measurement exists.
+ */
+export const PM_RESIDUE_LABELS = [
+ 'pm:dispatched',
+ 'pm:queue',
+ 'pm:blocked',
+ 'pm:on-hold',
+ 'pm:blocking',
+ // `pm:awaiting-maintainer` is residue on a closed card for the same reason
+ // `pm:on-hold` is: it claims an action is still OWED. It joins the set with
+ // the state itself (#11196 fix 5) rather than waiting for a census the way
+ // `pm:retriage` does, and the two cases are not alike — `pm:retriage` was
+ // measured absent from a live population, while this label has no live
+ // population at all yet. Adding it now costs nothing (it can only match a
+ // card that carries it) and means the state cannot accumulate exactly the
+ // closed-card residue this row exists to catch before anyone measures it.
+ AWAITING_MAINTAINER_LABEL,
+];
+
+/**
+ * H22 — null when the closed card is clean, else the finding sentence.
+ *
+ * Gated on the card being CLOSED: handed an open issue it returns null, so the
+ * predicate cannot double-report the population every other item already reads.
+ * That gate is the predicate's own, not the caller's, because it is the one
+ * thing separating this row from a restatement of H3.
+ */
+export function h22ClosedCardPmResidue(issue) {
+ if (issue?.state !== 'closed') return null;
+ const residue = labelNames(issue ?? {}).filter((l) => PM_RESIDUE_LABELS.includes(l));
+ if (residue.length === 0) return null;
+ const list = residue.map((l) => `\`${l}\``).join(', ');
+ const reason = issue.state_reason ? ` (closed \`${issue.state_reason}\`)` : '';
+ return (
+ `card is CLOSED${reason} but still carries ${list} — a state label is a claim that work ` +
+ `is in flight, and the card left the board without the paired write that clears it. ` +
+ `H8 would have flagged this while the card was open; it closed first, which is the ` +
+ `normal path rather than the rare one. Strip the \`pm:*\` state label(s); no other ` +
+ `write is owed, the card is already closed.`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H23 — the SECOND SURFACE: a squash commit message on the default branch that
+// carries `Part of #N` and a closing keyword bound to that same `#N` (#10942).
+//
+// ## The gap
+//
+// Every closing-keyword reader this repo owns is handed a PULL REQUEST BODY.
+// H7 and H21 above take a `pr` and read `pr.body`;
+// `scripts/check-partof-closing-keyword.mjs` — the blocking gate — is handed
+// `PR_BODY` by `.github/workflows/partof-closing-keyword-guard.yml`. GitHub's
+// closing-keyword parser acts on TWO surfaces: the PR body, and the COMMIT
+// MESSAGES of commits that land on the default branch. This repo squash-merges,
+// so every merged PR contributes exactly one commit message to `main`, and that
+// message was never read by anything here.
+//
+// ## The mechanism, and why a body-side guard could not have caught it
+//
+// The squash message is COMPOSED AT MERGE TIME from the branch's own commit
+// messages — not from the PR body. All six specimens below are multi-commit
+// branches where one commit's trailer said `Fixes #N` and another's said
+// `Part of #N`; the squash concatenated them, and the contradiction was
+// manufactured by the assembly. Measured on the clearest one, PR #9478: its
+// BODY carries a closing keyword bound to #9320 and no `Part of` anywhere, so
+// the body is clean under H7 and under the blocking gate, and correctly so —
+// the contradictory text existed in no body at all. This is why the row cannot
+// be "H7 with a wider input": there is a text on `main` that no body ever held.
+//
+// ## Measured (2026-08-22, this change's own stage-1 pass)
+//
+// Corpus: all 1,546 first-parent commit messages on `main` in the window
+// 2026-08-11T00:00:00Z … 2026-08-22T18:00:00Z (first and last message
+// 2026-08-11T01:21:16Z … 2026-08-22T17:59:02Z), read with the extractors above at
+// `markdown: false`. `main` is LINEAR — 1,975 commits reachable, 1,975 on the
+// first-parent walk, 0 merge commits — so the commit list and the squash-message
+// list are the same list, and the REST reader below needs no first-parent filter.
+//
+// ⚠️ The window bounds are spelled as full ISO instants on purpose. `git log
+// --since=2026-08-11` is an APPROXIDATE: git fills the unspecified time-of-day
+// from *now*, so a bare date silently slides the corpus forward as the clock
+// moves — two runs of this measurement twelve minutes apart returned 1,443 and
+// 1,441 messages for what read as one window. Anyone re-deriving these numbers
+// must pin both instants or they are measuring a different corpus.
+//
+// 270 closing-keyword bindings across 234 messages
+// 6 messages carry `Part of #N` AND a keyword bound to that same `#N`
+// sha card keyword (columns kept apart on purpose — see
+// 0c24898c0 10377 Fixes the remedy note below; this file
+// d7283250d 10219 Fixes must not itself put a keyword next
+// af2a989be 9320 Fixes to a live card number)
+// 3db37957c 8355 Fixes
+// 7e06f51ee 8060 Fixes
+// 30536e37c 7828 Fixes
+// 1,545 of 1,546 subjects end with the squash marker `(#PR)`
+// 0 of those 1,545 trailing markers are bound as a card by the extractor
+//
+// That last number is the PR-correlation guarantee, and it is measured rather
+// than argued: the separator in `closingKeywordRe` is horizontal whitespace and
+// an optional colon, so the `(` in `… (#11085)` stands between any preceding
+// keyword and the number and no subject's own PR marker can ever be read as a
+// card binding. `commitSubjectPrNumber` reads it as what it is instead.
+//
+// ⚠️ Six rows are evidence that the shape REACHES `main` unguarded, not six
+// adjudicated wrong closes. In every one the lead commit's `Fixes #N` looks
+// deliberate, so the finding sentence reports the contradiction and explicitly
+// declines to adjudicate it — the reader checks the card. Deciding whether this
+// class ever earns a BLOCKING posture is a later card on its own baseline
+// (grading ruling, 2026-08-22): this row exists to measure the surface first,
+// exactly the posture #10392 was required to take for the body surface.
+//
+// ## The remedy text differs from H7's and H21's, and that is the point
+//
+// H7 and H21 both end with "or put the keyword in backticks", which is CORRECT
+// for a body and FALSE here: a commit message is not markdown, nothing renders
+// it, and backticks are ordinary characters to the parser. An author who has
+// internalised the body remedy is precisely the author who will reach for it on
+// this surface, so the sentence says out loud that it does not work here and
+// gives the only remedy that does — reword, so no closing keyword sits next to
+// the number. The self-test pins the difference in BOTH directions (H7's
+// sentence carries the backtick clause; this one must never carry it), because
+// the realistic regression is someone copying H7's tail across.
+//
+// ## Why H21's negation window is NOT ported to this surface
+//
+// Measured, on the same 1,546-message corpus: H21's window and marker set flag
+// 0 of the 270 bindings. Commit messages here do not carry the deliberate-
+// non-closure register at all — no `## Out of scope`, no "filed, not fixed
+// here" — because that register belongs to a PR body's prose sections. Porting
+// it would add a second predicate over this surface with zero measured yield
+// and its own false-positive risk, so it stays out until something measures a
+// reason for it. (`sentenceStartOffset` was already written with this surface in
+// mind — its docblock declines to treat a plain newline as a boundary precisely
+// because commit messages hard-wrap at ~72 columns — so the port is available to
+// a later card at no design cost.)
+//
+// ## And why stripping is not merely "harmless to skip"
+//
+// On this corpus the two readings agree exactly — 270 bindings either way, and
+// the same 6 findings — so the asymmetry buys no finding today, and saying
+// otherwise would be a claim the measurement does not support. It is not clean
+// for lack of opportunity, which is the failure mode a 0-difference number
+// invites: 1,064 of the 1,546 messages DO carry markdown-looking code (a squash
+// body routinely quotes the PR body whole), 487 of those carry a `#N` inside the
+// code region and 361 carry a closing-keyword word inside it. But not one
+// carries a keyword and a number ADJACENT inside code — which is why the two
+// readings agree. The population is everywhere; no author has
+// yet landed the two adjacent inside a fence. The first who does — most likely
+// the author following the body-surface remedy — is the case the surface-correct
+// reading catches and a stripped reading would silently drop.
+// ---------------------------------------------------------------------------
+
+/**
+ * The squash marker a commit SUBJECT ends with — `(#PR)` — or null.
+ *
+ * Read off the first line only, and anchored to its end: that is where the
+ * merge writes it, and a `(#123)` in the message BODY is quoted prose from
+ * somewhere else, not this commit's delivery. Measured at 1,545 of 1,546
+ * subjects in the corpus above, which is why the correlation is worth having
+ * for free rather than through a per-commit `/pulls` request.
+ */
+export function commitSubjectPrNumber(message) {
+ const subject = String(message ?? '').split('\n', 1)[0];
+ const m = /\(#(\d+)\)\s*$/.exec(subject.trim());
+ return m ? m[1] : null;
+}
+
+/**
+ * H23 — null when the commit message is clean, else the finding sentence.
+ *
+ * Pure over the REST commit row (`{ sha, html_url, commit: { message } }`), like
+ * every predicate here, so the self-test drives it with the real specimens.
+ *
+ * The extractors are H7's, at `markdown: false` — same functions, different
+ * surface. Bound PER CARD NUMBER exactly as H7 binds: a message that is
+ * `Part of #A` and separately closes #B is the normal correct shape and stays
+ * clean.
+ */
+export function h23CommitMessageContradiction(commit) {
+ const message = commit?.commit?.message ?? '';
+ const declared = partOfTargets(message, { markdown: false });
+ if (declared.size === 0) return null;
+ const closing = closingKeywordTargets(message, { markdown: false });
+ const clashes = [...declared].filter((n) => closing.has(n));
+ if (clashes.length === 0) return null;
+
+ const sha = String(commit?.sha ?? '').slice(0, 9) || '(unknown sha)';
+ const pr = commitSubjectPrNumber(message);
+ const via = pr ? ` (landed by PR #${pr})` : '';
+ const cards = clashes.map((n) => `#${n}`).join(', ');
+ const pairs = clashes.map((n) => `\`Part of #${n}\` and \`${closing.get(n)}\` bound to #${n}`).join('; ');
+
+ return (
+ `squash commit \`${sha}\`${via} carries BOTH instructions in one message — ${pairs}. ` +
+ `GitHub's closing-keyword parser reads commit messages on the default branch exactly as it ` +
+ `reads a PR body, and ignores the surrounding prose, so this message already told GitHub to ` +
+ `close ${cards} when it landed. No body-side guard could have seen it: the squash message is ` +
+ `composed at merge time from the branch's own commit messages, so the contradiction need never ` +
+ `have existed in any body (measured on PR #9478, whose body is clean under H7 and under the ` +
+ `blocking gate). Re-read ${cards} and judge its state deliberately — this row reports the ` +
+ `contradiction and does NOT adjudicate whether the close was intended. ` +
+ `⚠️ For the next message: a commit message is NOT markdown — nothing renders it, so backticks ` +
+ `and fences are ordinary characters here and quoting the keyword does not neutralise it. That ` +
+ `is the PR-BODY remedy (H7's and H21's sentences end with it, correctly, for bodies) and it is ` +
+ `false on this surface. The only fix here is to REWORD, so that no closing keyword sits next to ` +
+ `a card number.`
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H24 — an OPEN card that is `pm:queue` AND assigned: the board saying two
+// contradictory things about one card at once (#11196 fix 1).
+//
+// ## Why it is its own row rather than a widening of H1/H2/H3
+//
+// Every adjacent row declines this shape for a reason of its own, which is how
+// 17 cards across three repos sat in it with nothing reporting them: H1 wants a
+// dispatched card with NO assignee (this one HAS one), H2 wants a MISSING claim
+// comment (the measured carriers have complete ones — they were claimed, worked
+// and then rolled back), and H3 wants two LABELS (here exactly one label is
+// present and the second half of the contradiction lives in a different FIELD).
+// The shape falls precisely between them.
+//
+// ## What the contradiction costs
+//
+// The two readers disagree and BOTH are right about what they read:
+//
+// the queue view reads `pm:queue` as "dispatchable now";
+// the claim rule reads a non-empty assignee as "taken, ⛔ never reassign".
+//
+// So the card is simultaneously available to everyone and forbidden to
+// everyone, and the outcome is not a race but PARALYSIS — a card nobody can
+// legally move, in the one state no seat has a reason to look at twice. The
+// census this row was filed on (2026-08-23, REST full pagination): 135 open
+// `pm:queue` cards in objectstack of which 6 were assigned, 146/10 in objectui,
+// 19/1 in cloud.
+//
+// ## Zero judgement, by construction — and the ordering that makes it safe
+//
+// The predicate is a pure intersection of two fields with no threshold, no
+// timestamp and no identity test in it. That is deliberate: the same census
+// found the assignee field carrying TWO different meanings (dead agent claims
+// left by a state rollback, and genuine human ownership on a handful of
+// objectui cards), and a row that tried to tell them apart would be guessing at
+// the one thing this file refuses to guess at. The maintainer's ruling settles
+// the order (2026-08-23): the rule lands FIRST and any true-ownership exemption
+// is an explicit marker LATER — never the other way round. An exemption
+// invented here, in the absence of that marker, would silently un-report the
+// exact population the row exists for.
+//
+// ⛔ And the remedy is asymmetric, so the sentence says so: an agent may clear
+// a dead agent claim on the evidence, and must NEVER clear a human's
+// assignment.
+// ---------------------------------------------------------------------------
+
+/**
+ * H24 — null when clean, else the finding sentence.
+ *
+ * Gated on the card being OPEN (like H22's gate, in mirror image): a closed
+ * card carrying `pm:queue` is H22's residue row, and reporting it here too
+ * would double-count one card under two items that prescribe different writes.
+ * `state` is absent from some fixtures and every live open listing sets it, so
+ * only an explicit `closed` declines — an unknown state is judged, never used
+ * as a silent exemption.
+ */
+export function h24QueuedWithAssignee(issue) {
+ if (issue?.state === 'closed') return null;
+ if (!labelNames(issue ?? {}).includes('pm:queue')) return null;
+ const logins = (issue?.assignees ?? [])
+ .map((a) => (typeof a === 'string' ? a : a?.login))
+ .filter(Boolean);
+ if (logins.length === 0) return null;
+ return (
+ `\`pm:queue\` while ASSIGNED to ${logins.map((l) => `\`${l}\``).join(', ')} — the board makes ` +
+ 'two contradictory claims about this one card: the queue view reads `pm:queue` as ' +
+ 'dispatchable NOW, and the claim protocol reads a non-empty assignee as TAKEN (⛔ never ' +
+ 'reassign). Both readers are right about what they read, so the card is available to everyone ' +
+ 'and forbidden to everyone at once — not a race, a card nobody can legally move. The measured ' +
+ 'origin is a state ROLLBACK that swapped the label and left the field: the landing re-label ' +
+ 'and the unlock scan both owe 「同笔摘 assignee」 and only dead-claim reclamation ever said ' +
+ 'so (17 carriers across three repos at the 2026-08-23 census). Remedy: whichever write set ' +
+ '`pm:queue` owes the assignee drop in the SAME stroke — do it now. ⚠️ Asymmetric: an agent ' +
+ 'identity in that field is dead-claim residue and may be cleared on its evidence; a HUMAN ' +
+ 'assignment may be real ownership and ⛔ must never be cleared by an agent — take it to the ' +
+ 'maintainer. This row fires either way and states the login so the reader can tell them ' +
+ 'apart: the rule lands first and an ownership exemption is an explicit marker later, never ' +
+ 'the other way round (ruling 2026-08-23).'
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H25 — `pm:awaiting-maintainer` coexisting with another pm STATE label
+// (#11196 fix 5, the exclusivity half of the new state).
+//
+// The state model makes the pm state labels ONE-OF: each is a claim about
+// where the card is, and two of them at once leaves every reader to pick. H3
+// is the same invariant for the one pair that was measured drifting, and this
+// row is the same invariant for the newest state — written now, while the
+// population is zero, because the cheapest moment to pin a vocabulary is
+// before anything can carry it.
+//
+// Each coexistence is a specific lie, not a generic tidiness complaint:
+//
+// + `pm:queue` dispatchable AND waiting on a human — H24's
+// two-views contradiction with a different second
+// half, and the shape this whole family is about.
+// + `pm:dispatched` a dev is on it AND nobody is: the label pair says
+// an agent is working a card whose next act is the
+// maintainer's.
+// + `pm:blocked` two different release mechanisms are declared at
+// once (an unlock scan over `Blocked-by:`, and a
+// human act), so neither reader can tell which one
+// will actually free it.
+// + `pm:on-hold` the hold requires a machine-fireable
+// `Restart-when:` (H9) and this state exists
+// precisely for the card that cannot have one.
+// Carrying both claims an exit that does not exist.
+// + `needs-user-decision` the decision inbox says a RULING is owed; this
+// state says a ruling was already given and an ACT
+// is owed. Both at once inflates the inbox with a
+// question nobody has to answer.
+//
+// ⛔ Deliberately NOT here, and deferred rather than dropped: any requirement
+// that the card NAME the awaited action. That is a grammar for a protocol face
+// the SKILL.md state-model row has not been written for yet, and inventing one
+// in the sweeper would make the sweeper the author of the protocol it audits.
+// ---------------------------------------------------------------------------
+
+/** The states `pm:awaiting-maintainer` must never coexist with. */
+export const AWAITING_MAINTAINER_EXCLUSIVE_LABELS = [
+ 'pm:queue',
+ 'pm:dispatched',
+ 'pm:blocked',
+ 'pm:on-hold',
+ 'needs-user-decision',
+];
+
+/**
+ * Why each coexistence is a contradiction — one clause per label, so the
+ * finding names the specific lie rather than "these two labels disagree".
+ */
+const AWAITING_MAINTAINER_CONFLICT_REASON = {
+ 'pm:queue': 'dispatchable now AND waiting on a human act',
+ 'pm:dispatched': 'an agent is working it AND the next act is the maintainer\'s',
+ 'pm:blocked': 'two different release mechanisms declared at once (unlock scan vs. a human act)',
+ 'pm:on-hold': 'a hold owes a machine-fireable `Restart-when:` (H9) and this state is for the card that cannot have one',
+ 'needs-user-decision': 'the decision inbox says a ruling is owed; this state says one was already given',
+};
+
+/** H25 — null when clean, else the finding sentence. */
+export function h25AwaitingMaintainerExclusivity(issue) {
+ if (issue?.state === 'closed') return null;
+ const labels = labelNames(issue ?? {});
+ if (!labels.includes(AWAITING_MAINTAINER_LABEL)) return null;
+ const conflicts = AWAITING_MAINTAINER_EXCLUSIVE_LABELS.filter((l) => labels.includes(l));
+ if (conflicts.length === 0) return null;
+ const named = conflicts
+ .map((l) => `\`${l}\` (${AWAITING_MAINTAINER_CONFLICT_REASON[l]})`)
+ .join('; ');
+ return (
+ `\`${AWAITING_MAINTAINER_LABEL}\` coexists with ${named} — the pm state labels are ONE-OF, ` +
+ 'and two state claims on one card leave every reader to pick which is true. The awaiting ' +
+ 'state is the card whose remaining work is a MANUAL maintainer action, which is exactly why ' +
+ 'it has no machine exit; pairing it with a state that declares a different exit tells the ' +
+ 'unlock scan, the queue view and the decision inbox three different stories. Keep the ONE ' +
+ 'state that is true and drop the other(s) in a single write.'
+ );
+}
+
+// ---------------------------------------------------------------------------
+// H26 — a block whose target can never CLOSE, and the stale chain (#11219).
+//
+// The unlock predicate is "the `Blocked-by:` target CLOSED". `pm:on-hold` and
+// `needs-user-decision` are, by definition, states a card sits in WHILE OPEN.
+// A block naming such a target is therefore structurally indefinite: nothing in
+// the machinery can ever fire it, and until this row nothing said so.
+//
+// ## Why every existing check passes on these cards
+//
+// The waiting card is perfectly well-formed — it has its machine-readable line
+// (H4 clean), its target resolves, the target is open (H19 clean), its label is
+// correct. H9 is the nearest neighbour and asks the mirror question: it audits
+// the HELD card for a fireable `Restart-when:`. Nobody audited the card WAITING
+// on one. So the card passes every gauge and still cannot move — which is why
+// the six measured instances were found by a human reading, not by any sweep:
+//
+// cloud#1119, cloud#799 -> cloud#987 (`pm:on-hold`), parked since July
+// cloud#861 -> cloud#855 (`pm:on-hold`)
+// objectos#75, #135 -> objectos#68 (`needs-user-decision`)
+// cloud#1332 -> cloud#1331 (`pm:queue`, titled `[Decision]`)
+//
+// The last row is deliberately NOT reported by this predicate: a decision card
+// wearing a work label is a mislabelling to fix, not a fact readable from the
+// labels this row reads, and inventing a title heuristic would make the sweeper
+// guess at intent. Two of the rows are one repo's ENTIRE blocked inventory
+// waiting on its one unanswered decision card — one ruling clears the repo.
+//
+// ## The second leg: the stale chain
+//
+// `cloud#1395` -> `objectstack#10101`, which is OPEN, so the block reads live.
+// But #10101 was itself an H19 finding on the same sweep: both of ITS blockers
+// had already closed. The block was real one level up and false two levels up,
+// and a single-level predicate cannot see that. Flagging a target that is
+// itself `pm:blocked` is the cheap, honest version of that: it does not chase
+// the chain (which would cost a request per hop and could cycle), it says the
+// wait is TRANSITIVE so a reader knows to look one level further.
+//
+// ## Quota
+//
+// Free. H19 already resolves each distinct target — from an open listing this
+// sweep holds, or with one GET — and a resolved target's LABELS are a field
+// that was already in the payload. Nothing here adds a request; the resolution
+// rows simply stopped throwing the labels away.
+//
+// Report-only, and pointedly not a judgement that the block is WRONG: waiting
+// on a deferred card is sometimes exactly right. The row says this block has no
+// mechanism that will ever release it, which is the thing a human should see
+// rather than discover in a hand sweep.
+// ---------------------------------------------------------------------------
+
+/**
+ * Target states that can never satisfy the unlock predicate, because they are
+ * states an OPEN card sits in. `pm:blocked` is deliberately not here — that is
+ * the chain leg below, and it says something different: the target CAN close,
+ * once its own blocker does.
+ */
+export const INDEFINITE_TARGET_LABELS = ['pm:on-hold', 'needs-user-decision'];
+
+/**
+ * H26 — null when every open target can still close on its own, else the
+ * finding sentence.
+ *
+ * ## What an unjudged target does here, and why it is silent rather than loud
+ *
+ * A row whose `labels` is not an array is one this sweep could not read, and
+ * every such target is ALREADY firing H19's unresolved branch on this very
+ * card, with a sentence that says the liveness is unjudged. Repeating it here
+ * would double-report one gap under two items; the #4690 duty is discharged,
+ * once, in the item that owns it.
+ *
+ * @param {object} issue — an OPEN issue.
+ * @param {{ key: string, number: number, local: boolean,
+ * state: 'open'|'closed'|'unresolved', labels?: string[]|null }[]} resolutions
+ */
+export function h26BlockOnIndefiniteTarget(issue, resolutions) {
+ if (!needsBlockerLiveness(issue)) return null;
+ const open = (resolutions ?? []).filter(
+ (r) => r?.state === 'open' && Array.isArray(r.labels),
+ );
+ if (open.length === 0) return null;
+
+ const indefinite = open
+ .map((r) => ({ row: r, states: INDEFINITE_TARGET_LABELS.filter((l) => r.labels.includes(l)) }))
+ .filter((r) => r.states.length > 0);
+ // A target that is BOTH parked and blocked is named once, under the reading
+ // that ends the wait forever rather than the one that merely lengthens it.
+ const chained = open.filter(
+ (r) => r.labels.includes('pm:blocked') && !indefinite.some((i) => i.row.key === r.key),
+ );
+ if (indefinite.length === 0 && chained.length === 0) return null;
+
+ const parts = [];
+ if (indefinite.length > 0) {
+ const named = indefinite
+ .slice(0, H19_TARGET_LIST_CAP)
+ .map(
+ ({ row, states }) =>
+ `\`${row.local ? `#${row.number}` : row.key}\` (${states.map((s) => `\`${s}\``).join(' + ')})`,
+ )
+ .join(', ');
+ const more =
+ indefinite.length > H19_TARGET_LIST_CAP
+ ? ` +${indefinite.length - H19_TARGET_LIST_CAP} more`
+ : '';
+ parts.push(
+ `\`pm:blocked\` on ${indefinite.length} target(s) that can never CLOSE: ${named}${more}. ` +
+ 'The unlock predicate is "the `Blocked-by:` target closed", and `pm:on-hold` / ' +
+ '`needs-user-decision` are by definition states a card sits in WHILE OPEN — so this ' +
+ 'block has NO MECHANISM THAT WILL EVER RELEASE IT. Every existing check passes on this ' +
+ 'card (the line is present, the target resolves, the target is open, the label is ' +
+ 'correct), which is why the measured instances were found by a human reading and by no ' +
+ 'gauge; H9 asks the mirror question about the HELD card and nothing asked about the ' +
+ 'WAITING one. ⚠️ Not a claim that the block is wrong — waiting on a deferred card is ' +
+ 'sometimes exactly right. It says the wait is indefinite BY CONSTRUCTION, so the release ' +
+ 'has to come from the target\'s own state changing (a ruling answered, a hold restarted) ' +
+ 'and someone has to want that.',
+ );
+ }
+ if (chained.length > 0) {
+ const named = chained
+ .slice(0, H19_TARGET_LIST_CAP)
+ .map((r) => `\`${r.local ? `#${r.number}` : r.key}\``)
+ .join(', ');
+ const more =
+ chained.length > H19_TARGET_LIST_CAP ? ` +${chained.length - H19_TARGET_LIST_CAP} more` : '';
+ parts.push(
+ `The wait is TRANSITIVE: ${named}${more} ${chained.length === 1 ? 'is' : 'are'} itself ` +
+ '`pm:blocked`, so this card is waiting on a card that is waiting. A single-level ' +
+ 'predicate cannot see past one hop, and the measured chain was real one level up and ' +
+ 'FALSE two levels up (the target was an H19 finding on the same sweep — both of ITS ' +
+ 'blockers had closed). This row does not chase the chain; it says to look one level ' +
+ 'further.',
+ );
+ }
+ return parts.join(' ');
+}
+
+// ---------------------------------------------------------------------------
+// H27 — the claim is PERFECT and the claimant is gone (#11248).
+//
+// H20 above asks whether a dispatch ever HAPPENED, and keys on NO REMOTE REF AT
+// ALL. This row asks the opposite-shaped question: the ref exists, the claim is
+// textbook-correct, and nothing has moved since. The two are disjoint by
+// construction — H20 fires only when no branch resolves, H27 only when one
+// does — and neither could be widened into the other without losing the
+// property that makes it safe.
+//
+// ## Why the ref EXISTS in the failure this row is built on
+//
+// The dev-agent definition makes pushing the empty branch the FIRST action of
+// the task, before any edit, as a write-route probe (`.claude/agents/os-dev.md`
+// rule 1). So an agent that dies at any point after its first minute — which is
+// every point that matters — leaves a branch ON THE REMOTE. That protocol step
+// is correct and worth keeping, but it has a side effect nobody priced: it
+// converts the dead-agent case out of H20's population and into a population
+// with no reader at all. The better the dev follows the protocol, the more
+// invisible its death.
+//
+// ## The measured incident
+//
+// Three devs were dispatched concurrently and all three died at once on one
+// shared-account capacity limit — fleet-wide exhaustion is a single point of
+// failure for every agent in flight, so this arrives in batches, not singly.
+// Each card was left `pm:dispatched`, assigned, carrying a claim comment whose
+// first line is literally `Claim:`, naming a branch that exists. Every
+// predicate in this file passed. Worse than merely unreported: the next PM's
+// round-open mutual-exclusion read looks for the latest non-self `Claim:` on a
+// lane's dispatched cards, so it reads those corpses as LIVE claims by another
+// session and stays off them. The protocol's own mutual-exclusion mechanism
+// converts a dead claim into a lane-wide block.
+//
+// ## Threshold: the protocol's own line, not a new heuristic
+//
+// SKILL.md already carries a stale-claim reclaim rule at 「认领 >~24h」, so this
+// row mechanizes an existing protocol threshold rather than inventing a
+// liveness distribution the fleet does not have (the filing card is explicit
+// that 2 samples is not a distribution). The same 24 hours is already the seat
+// -post patrol's own line — 「与既有回收线同一条」 — so the number has one
+// source in the protocol and two readers, rather than two numbers.
+//
+// ⚠️ The protocol's reclaim rule and this row are NOT the same act, and the
+// remedy sentence keeps them apart. That rule reclaims a claim whose branch
+// does not exist, and it says 「有带提交活分支的认领永不回收」 — a claim whose
+// branch carries commits is never reclaimed. This row fires on branches that DO
+// exist, some of them carrying commits, so it deliberately prescribes the
+// recovery INSPECTION and never the reclaim: reporting a card is not reclaiming
+// it, and the row must not be readable as authority to drop an assignee the
+// protocol protects.
+// ---------------------------------------------------------------------------
+
+/**
+ * The protocol's own stale-claim line, in hours (SKILL.md 死认领回收). The one
+ * threshold in this file that is QUOTED rather than measured — see the section
+ * note above for why a measured one would be worse here.
+ */
+export const DEAD_CLAIM_STALE_HOURS = 24;
+
+/** How old the governing claim is in HOURS — `null` when unreadable, as H20. */
+export function claimAgeHours(claim, nowMs = Date.now()) {
+ const minutes = claimAgeMinutes(claim, nowMs);
+ return minutes === null ? null : minutes / 60;
+}
+
+/**
+ * Which cards buy the branch-activity read — exported for the same reason
+ * `h20NeedsRefProbe` is: a policy that decides what is READ AT ALL is where a
+ * silent hole would live.
+ *
+ * A claim younger than the threshold is not stale, so it buys nothing and this
+ * row says nothing about it either way. An UNREADABLE claim timestamp is
+ * gathered rather than skipped — it must not read as fresh (#4690) — and the
+ * predicate then declines to judge it out loud.
+ */
+export function h27NeedsClaimLivenessRead(issue, claim, nowMs = Date.now()) {
+ if (!labelNames(issue ?? {}).includes('pm:dispatched')) return false;
+ if (!claim || (claim.branches ?? []).length === 0) return false;
+ const age = claimAgeHours(claim, nowMs);
+ return age === null || age > DEAD_CLAIM_STALE_HOURS;
+}
+
+/**
+ * Does any PR this sweep holds deliver card `n`? The delivery relation is
+ * `prDeliversCard` — H8's, deliberately shared rather than re-derived, so the
+ * two rows can never disagree about what "has a PR" means.
+ *
+ * Counted per channel because the two windows have different reach: the open
+ * listing is effectively complete (paged to exhaustion), while the merged one
+ * is a bounded recency window (`MERGED_WINDOW_PAGES`). That asymmetry is what
+ * the finding sentence has to disclose, so it is preserved here rather than
+ * collapsed into a boolean.
+ */
+export function claimDelivery(n, openPrs, mergedPrs) {
+ const target = String(n);
+ const open = (openPrs ?? []).filter((pr) => prDeliversCard(pr, target)).length;
+ const merged = (mergedPrs ?? []).filter((pr) => pr?.merged_at && prDeliversCard(pr, target)).length;
+ return { open, merged };
+}
+
+/**
+ * Did this branch move AFTER the claim was posted? Three-valued, never two:
+ * `true` / `false` / `null` when either timestamp is unreadable — an unread
+ * comparison is not a "no" (#4690), and collapsing it would let one unparseable
+ * date manufacture a finding about a card nobody measured.
+ */
+export function branchMovedSinceClaim(refState, claim) {
+ const head = Date.parse(refState?.headCommittedAt ?? '');
+ const posted = Date.parse(claim?.createdAt ?? '');
+ if (!Number.isFinite(head) || !Number.isFinite(posted)) return null;
+ return head > posted;
+}
+
+/**
+ * H27 — null when clean, else the finding sentence.
+ *
+ * ## The conjunction, and why each term is load-bearing
+ *
+ * `pm:dispatched` the card still claims to be in flight
+ * claim older than 24h the protocol's own stale line
+ * a claimed branch EXISTS (else it is H20's row, not this one)
+ * NO branch moved since the claim — nothing was pushed for this dispatch
+ * no PR delivers the card neither open nor within the merged window
+ *
+ * ⛔ Dropping the branch-activity term would give exactly the PR-keyed row H20
+ * refuses to be, and it is refused there for a measured reason: a dev inside a
+ * long build legitimately has a ref and no PR for over an hour. That objection
+ * is answered here by BOTH remaining terms and not by the threshold alone — a
+ * dev 24 hours in with commits landing is excluded by branch activity, and a
+ * dev with a PR open is excluded by delivery. What is left is a branch that has
+ * not moved since it was claimed, with nothing to show for a day.
+ *
+ * ## What it under-reports, stated rather than discovered
+ *
+ * A dev that pushed one commit and THEN died is not reported: its branch moved
+ * after the claim, so the activity term clears it. That is the measured shape
+ * of one of the three incident cards, and widening the term to "no activity in
+ * the last 24h" would catch it — at the cost of colliding with the protocol's
+ * 「有带提交活分支的认领永不回收」, which is a rule about exactly that card.
+ * Under-reporting on a card the protocol protects is the same call H17's
+ * extractor and H20's branch-shape matcher make: a row a reader cannot act on
+ * is worse than no row.
+ *
+ * @param {object} issue — an OPEN issue.
+ * @param {{ branches: string[], createdAt: string|null }|null} claim
+ * @param {{ branch: string, state: 'exists'|'absent'|'unreadable',
+ * headCommittedAt?: string|null }[]} refStates
+ * @param {{ open: number, merged: number }} delivery — `claimDelivery`.
+ */
+export function h27DeadClaimNoProgress(issue, claim, refStates, delivery, nowMs = Date.now()) {
+ if (!labelNames(issue ?? {}).includes('pm:dispatched')) return null;
+ if (!claim || (claim.branches ?? []).length === 0) return null;
+ const age = claimAgeHours(claim, nowMs);
+ if (age !== null && age <= DEAD_CLAIM_STALE_HOURS) return null;
+
+ const rows = refStates ?? [];
+ if (rows.length === 0) return null;
+ const present = rows.filter((r) => r.state === 'exists');
+ // No ref at all is H20's row; an unreadable probe is H20's quieter one. This
+ // row speaks only about branches it KNOWS are there.
+ if (present.length === 0) return null;
+
+ // A delivery in either channel ends the question: an open PR is live work (or
+ // work already handed over), and a merged one is H8's row about a paired
+ // write, never this row's about a dead agent.
+ const { open = 0, merged = 0 } = delivery ?? {};
+ if (open > 0 || merged > 0) return null;
+
+ const moved = present.map((r) => branchMovedSinceClaim(r, claim));
+ if (moved.some((m) => m === true)) return null;
+
+ const named = namedBranches(present.map((r) => ({ branch: r.branch, state: r.state })));
+ const recovery =
+ ' Report-only, and pointedly NOT a reclaim: the protocol reclaims a claim whose branch does ' +
+ 'NOT exist and states 「有带提交活分支的认领永不回收」, so a row about a branch that DOES ' +
+ 'exist can never be authority to drop an assignee. The remedy is the post-kill recovery ' +
+ 'inspection (`references/dispatch-runbook.md`): probe the claimant, then read all THREE ' +
+ 'states — on the remote / on the container disk only / gone — and hand anything found to a ' +
+ 'replacement flagged UNVERIFIED. ⛔ Never a label written from this script.';
+
+ if (moved.some((m) => m === null)) {
+ return (
+ `\`pm:dispatched\` with a complete claim naming ${named}, and whether that branch has MOVED ` +
+ 'since the claim could not be determined this sweep (an unreadable claim or head-commit ' +
+ 'timestamp) — so this dispatch is UNJUDGED, not confirmed healthy. Unread is not "no ' +
+ 'activity" and it is not "activity" either (#4690); a liveness comparison dropped in ' +
+ 'silence reads as a working dev forever, which is the exact failure this item exists to ' +
+ 'end. Read the branch and the claim by hand.' +
+ recovery
+ );
+ }
+
+ const reading =
+ age === null
+ ? 'an unreadable claim timestamp (which must not read as fresh)'
+ : `~${Math.round(age)}h after the claim was posted (threshold ${DEAD_CLAIM_STALE_HOURS}h, the ` +
+ "protocol's own stale-claim line)";
+
+ return (
+ `\`pm:dispatched\` with a PERFECT claim — assignee set, a first-line \`Claim:\` comment, and ` +
+ `${named} present on the remote — that has NOT MOVED SINCE IT WAS CLAIMED, with no PR ` +
+ `delivering the card, ${reading}. This is what a dev agent that DIED leaves behind, and the ` +
+ 'measured cause arrives in batches rather than singly: one shared-account capacity limit ' +
+ 'killed three concurrently-dispatched agents at once. ⭐ The card is indistinguishable from ' +
+ 'healthy in-flight work from the card itself — every field is correct, which is why no ' +
+ 'predicate here fired on it: H1 wants a missing assignee, H2 a missing claim comment, H8 a ' +
+ 'merged PR, and H20 no remote ref at all. H20 misses it BY CONSTRUCTION, not by accident: ' +
+ 'the dev-agent definition makes pushing the empty branch the first action of the task, so a ' +
+ 'protocol-compliant agent that dies still leaves a ref. Left unreported it does worse than ' +
+ "sit there — the next PM's round-open mutual-exclusion read treats a dead `Claim:` as a live " +
+ 'claim by another session and stays off the card, so one dead agent blocks the lane. ⛔ Rule ' +
+ 'out one reading first: a delivery that merged BEFORE this sweep\'s merged window ' +
+ `(${MERGED_WINDOW_PAGES} pages) is invisible here, so a card whose PR landed days ago and ` +
+ 'whose branch was never deleted can reach this row — check the card for a merged delivery ' +
+ 'before treating it as a death.' +
+ recovery
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Report rendering — pure over (findings, counts), so `--self-test` pins both
+// media offline. The live sweep below picks a renderer and prints it; nothing
+// about WHAT is swept or WHICH predicates fire depends on the format.
+//
+// Two media exist because this script gained a second consumer. The first is a
+// terminal: a patrol round reads the plain lines and scrolls. The second is a
+// pinned anchor ISSUE BODY, rewritten in place by the scheduled workflow that
+// gave this sweeper a standing caller (`.github/workflows/half-state-patrol.yml`)
+// — a surface with a fold, a hard size cap, and readers who will not scroll.
+// That difference, and only that, is why the two renderers order rows
+// differently; see `renderMarkdown`.
+// ---------------------------------------------------------------------------
+
+/** Accepted `--format` values. An unrecognized one is a usage error (exit 2). */
+export const OUTPUT_FORMATS = ['plain', 'markdown'];
+
+/**
+ * GitHub's hard cap on an issue body, and the budget the markdown renderer
+ * keeps under it. A body that exceeds the cap is REJECTED by the API — the
+ * whole run's report would vanish over one long row — so the renderer trims
+ * and SAYS it trimmed. Silent truncation is the #4690 shape (an unreadable
+ * result must not read as a clean one), so the omission notice is part of the
+ * rendered body, never a log line the anchor's reader never sees.
+ */
+export const ISSUE_BODY_LIMIT = 65536;
+export const MARKDOWN_BODY_BUDGET = 60000;
+
+/** Is this finding one of H13's louder self-declared-P0 rows? */
+export function isLoudFinding(message) {
+ return String(message ?? '').startsWith(P0_SUSPECT_MARKER);
+}
+
+/**
+ * The summary sentence both media end on — the one line that says what was
+ * READ, not just what was found. It is the difference between "the board is
+ * clean" and "nothing was swept", and it carries the report-only contract so
+ * a reader who sees only this line cannot mistake it for a gate verdict.
+ *
+ * H16's two numbers are here for a reason the other counts do not have: it is
+ * the only item whose input can fail PER ROW. A detail GET that fails leaves
+ * that PR unjudged and the sweep still prints — correctly, since every other
+ * item's findings are already gathered — so without these numbers a pass that
+ * read nothing would be indistinguishable from a board with no conflicts. That
+ * is the #4690 shape at row granularity, and the pair (`read X of Y`) is what
+ * makes it visible. `?? 0` rather than required: a caller assembling counts
+ * without them still renders a sentence, never the string `undefined`.
+ *
+ * @param {{ repo: string, issues: number, unscoped: number, prs: number,
+ * merged: number, closed?: number, conflictProbed?: number, conflictCandidates?: number,
+ * holdProbed?: number, holdCandidates?: number, fallbackProbed?: number,
+ * fallbackCandidates?: number, restartProbed?: number,
+ * restartCandidates?: number, blockerResolved?: number,
+ * blockerTargets?: number, commits?: number, commitBindings?: number,
+ * commitBindingMessages?: number,
+ * closedWindowDisabled?: boolean }} counts
+ * @param {number} findingCount
+ */
+export function summaryLine(counts, findingCount) {
+ const probed = counts.conflictProbed ?? 0;
+ const candidates = counts.conflictCandidates ?? 0;
+ const held = counts.holdProbed ?? 0;
+ const holdCandidates = counts.holdCandidates ?? 0;
+ // The third `read X of Y` pair, and the one with a consequence the other two
+ // do not have: a shortfall here does not merely leave rows out, it SILENCES
+ // H14's stale direction (see the predicate). A reader seeing a quiet stale
+ // section needs this number to tell "the cache is coherent" from "the sweep
+ // declined to judge it".
+ const fbProbed = counts.fallbackProbed ?? 0;
+ const fbCandidates = counts.fallbackCandidates ?? 0;
+ // H9's pair (#10403), same shape as H17's over the same fetches but for the
+ // candidate set H9 judges. A shortfall here is not silent — an unreadable
+ // thread fires its own card's H9 row — so like H19/H20 this is a total, owed
+ // because a pass that read no thread must not print as a board whose holds
+ // all answered from the body (#4690).
+ const rwProbed = counts.restartProbed ?? 0;
+ const rwCandidates = counts.restartCandidates ?? 0;
+ // The fourth pair, and the one whose shortfall is NOT silent: an unresolved
+ // `Blocked-by:` target fires its own H19 row on the card that names it, so
+ // this number is a total rather than the only place the gap is visible. It
+ // is still owed, for the reason every pair here is owed — a pass that
+ // resolved nothing must not read the same as a board whose blocks are all
+ // still live (#4690).
+ const btResolved = counts.blockerResolved ?? 0;
+ const btTargets = counts.blockerTargets ?? 0;
+ // The fifth pair, H20's, and the same shape as H19's for the same reason: an
+ // unreadable ref fires its own card's quieter row, so this is a total rather
+ // than the only place the gap shows. Still owed — a pass that read no ref at
+ // all must not read the same as a board where every dispatch is live (#4690).
+ const refRead = counts.dispatchRefRead ?? 0;
+ const refTargets = counts.dispatchRefTargets ?? 0;
+ // H23's coverage numbers. Not a `read X of Y` pair — nothing here can fail per
+ // row — but the same duty in the measure-first register the row was
+ // commissioned in (#10942): the row's yield is ~6 in 1,546, so a quiet H23 is
+ // the normal reading and the ONLY thing separating "this surface was read and
+ // is clean" from "no commit message was read at all" is these counts. The
+ // binding totals ride along because they are what a later blocking-promotion
+ // decision needs and they cost nothing to carry.
+ const commits = counts.commits ?? 0;
+ const commitBindings = counts.commitBindings ?? 0;
+ const commitBindingMessages = counts.commitBindingMessages ?? 0;
+ return (
+ `check-half-states: swept ${counts.issues} open pm-/p0-labeled issue(s), ${counts.unscoped} open ` +
+ `issue(s) in the unscoped pass (H13–H15, H18), ${counts.prs} open PR(s) ` +
+ `(merge state read on ${probed} of ${candidates} H16 candidate(s)) ` +
+ `and ${counts.merged} recently-merged PR(s) in ${counts.repo} — ${findingCount} half-state(s) found. ` +
+ (counts.closedWindowDisabled
+ ? 'H22 (closed-card `pm:*` state residue) is DISABLED in this install and NO closed issue was ' +
+ 'read — this sweep therefore says NOTHING about closed-card residue: that surface is UNREAD, ' +
+ 'not clean (see `resolveClosedWindowPages` and `PM_SWEEP_CLOSED_WINDOW_PAGES` in ' +
+ '`.github/workflows/half-state-patrol.yml`). '
+ : `H22 read ${counts.closed ?? 0} recently-closed issue(s) for \`pm:*\` state residue (bounded window; ` +
+ `older closed carriers are outside it by design). `) +
+ `H23 read ${commits} squash commit message(s) from the default branch's recent window, carrying ` +
+ `${commitBindings} closing-keyword binding(s) across ${commitBindingMessages} message(s) ` +
+ `(bounded window; a message that landed before it is invisible by design). ` +
+ `Hold comments read on ${held} of ${holdCandidates} H17 candidate(s). ` +
+ `\`Blocked-by:\` comment fallback read on ${fbProbed} of ${fbCandidates} candidate(s)` +
+ `${fbProbed < fbCandidates ? " — H14's stale direction is SUSPENDED for this sweep (the index is known incomplete)" : ''}. ` +
+ `\`Restart-when:\` hold comments read on ${rwProbed} of ${rwCandidates} H9 candidate(s)` +
+ `${rwProbed < rwCandidates ? " — each unread thread fires its own card's H9 row, never dropped" : ''}. ` +
+ `Blocker liveness (H19): targets resolved on ${btResolved} of ${btTargets} distinct \`Blocked-by:\` ` +
+ `target(s) named by open \`pm:blocked\` card(s)` +
+ `${btResolved < btTargets ? ' — each unresolved target is named on its own card\'s row, never dropped' : ''}. ` +
+ `Dispatch liveness (H20 + H27): remote branch read on ${refRead} of ${refTargets} distinct claimed ` +
+ `branch(es) named by open \`pm:dispatched\` card(s) past the ${DISPATCHED_NO_REF_STALE_MINUTES}-minute ` +
+ `threshold — one read serving both rows, so H27's ${DEAD_CLAIM_STALE_HOURS}h population is a subset ` +
+ 'of this one and costs no request of its own' +
+ `${refRead < refTargets ? ' — each unread branch is named on its own card\'s row, never dropped' : ''}. ` +
+ `Report-only: findings are patrol input, not a gate verdict.`
+ );
+}
+
+/**
+ * The H17 section, in either medium — one builder so the two renderers can
+ * never drift on WHAT the index says, only on how it is marked up.
+ *
+ * Returns `[]` when no index was supplied at all, which keeps every existing
+ * two-argument call byte-identical: a caller that does not gather the index
+ * gets the report it always got, rather than a section claiming an empty
+ * board.
+ *
+ * The three states it can be in are deliberately distinguishable, because two
+ * of them look identical if you let them (#4690):
+ *
+ * - oracle unreadable → says so, loudly, and claims nothing about holds
+ * - read, nothing found → says the holds were READ and name no tracked file
+ * - read, rows → the index
+ *
+ * @param {{ rows: Array<{issue: object, files: string[]}>, candidates?: number,
+ * probed?: number, tracked?: number|null }} [index]
+ * @param {{ markdown?: boolean }} [options]
+ */
+export function renderTriggerIndex(index, { markdown = false } = {}) {
+ if (!index) return [];
+ const rows = index.rows ?? [];
+ const probed = index.probed ?? 0;
+ const candidates = index.candidates ?? 0;
+ const read = `read on ${probed} of ${candidates} open \`pm:on-hold\` card(s)`;
+ const head = markdown
+ ? ['### On-hold trigger-file index (H17)', '']
+ : ['', 'On-hold trigger-file index (H17)'];
+
+ if (index.tracked == null) {
+ head.push(
+ `⚠️ The tracked-file oracle (\`git ls-files\`) could not be read, so NO candidate path was ` +
+ `validated and this index is EMPTY BY FAILURE, not by finding. Run the patrol from inside a ` +
+ `checkout. (${read}.)`,
+ );
+ return head;
+ }
+
+ const intro =
+ `Before dispatching, intersect your dispatch's file surface against this list and NAME any card ` +
+ `it hits in the dispatch brief. These are the trigger files open holds declare — the ` +
+ `opportunistic-restart mechanism (maintainer-accepted 2026-08-11) whose intersection was ` +
+ `measured at 0-for-19 while it lived only as a remembered protocol step (#10034). Report-only: ` +
+ `a card here is a hold in good standing, never a finding. Extraction is deterministic — every ` +
+ `path shown is a tracked file; anything unverifiable was dropped rather than guessed, so this ` +
+ `list under-reports and never invents. (${read}; ${index.tracked} tracked file(s) in the oracle.)`;
+ head.push(intro, '');
+
+ if (rows.length === 0) {
+ head.push(
+ markdown
+ ? '_No open hold names a tracked trigger file. The holds were READ — this is a clean reading, not an unread one._'
+ : ' (no open hold names a tracked trigger file — read, not unread)',
+ );
+ return head;
+ }
+
+ const shown = rows.slice(0, H17_INDEX_ROW_CAP);
+ for (const { issue, files } of shown) {
+ if (markdown) {
+ head.push(
+ `- [#${issue.number}](${issue.html_url}) — ${files.map((f) => `\`${f}\``).join(', ')}`,
+ );
+ } else {
+ head.push(` #${issue.number} ${files.join(', ')}`, ` ${issue.html_url}`);
+ }
+ }
+ if (rows.length > shown.length) {
+ const omitted = `… ${rows.length - shown.length} further card(s) omitted at the H17_INDEX_ROW_CAP render budget; the full list is in the workflow run log.`;
+ head.push(markdown ? `- _${omitted}_` : ` ${omitted}`);
+ }
+ return head;
+}
+
+/**
+ * The terminal report — byte-identical to what this script printed before the
+ * format switch existed. Findings arrive already sorted by issue number and
+ * that order is kept: a terminal has no fold, so there is nothing for a
+ * priority sort to buy here, and changing it would churn every seat's habit.
+ *
+ * The H17 index sits BETWEEN the findings and the summary line, which is the
+ * one placement the terminal medium allows: the summary sentence must stay the
+ * last line of the report (it is what a seat reads off the bottom of a scroll,
+ * and the self-test pins it there), while the index must not be separated from
+ * the rows by it. In the anchor body the ordering question resolves differently
+ * — see `renderMarkdown`.
+ */
+export function renderPlain(findings, counts, options = {}) {
+ const lines = findings.map(
+ ([issue, code, msg]) => ` ${code} #${issue.number} ${msg}\n ${issue.html_url}`,
+ );
+ lines.push(...renderTriggerIndex(options.triggerIndex, { markdown: false }));
+ lines.push(summaryLine(counts, findings.length));
+ return lines.join('\n');
+}
+
+/**
+ * Provenance is a one-line string the CALLER supplies (`--provenance=…`): the
+ * script knows it swept, it does not know it was a GitHub Actions run #123 at
+ * commit abc1234, and teaching it would couple a repo-agnostic sweeper to one
+ * caller. Collapsed to a single line and length-capped here rather than
+ * trusted: it is interpolated into a markdown italic line, and a newline in it
+ * would silently break the header apart.
+ */
+export function normalizeProvenance(text) {
+ return String(text ?? '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 300);
+}
+
+/**
+ * The anchor-body report.
+ *
+ * Row order differs from `renderPlain` on purpose, and the reason is the
+ * medium: this body is READ AT A FOLD and TRIMMED AT A CAP. A P0-SUSPECT row
+ * sitting at position 38 of 40 — or trimmed off the end entirely — is exactly
+ * the silence this sweeper's standing caller exists to end, so loud rows sort
+ * first and are therefore the last things truncation could ever reach. Within
+ * each band the issue-number order is preserved, so the list is still stable
+ * run to run and diffable in the anchor's edit history.
+ *
+ * The header is deliberately restated every run rather than left as a
+ * hand-written preamble the workflow must not clobber: the body is owned by
+ * this generator, end to end, so there is no half of it that a run can leave
+ * stale. First line is a bare literal marker with no angle brackets — the
+ * board's markers are grepped as literal text, never as comment syntax,
+ * because GitHub's body sanitizer eats short `<…>` fragments on write.
+ */
+export function renderMarkdown(findings, counts, options = {}) {
+ const provenance = normalizeProvenance(options.provenance);
+ const sweptAt = options.sweptAt instanceof Date ? options.sweptAt : new Date();
+ const rows = [...findings].sort(
+ (a, b) => Number(isLoudFinding(b[2])) - Number(isLoudFinding(a[2])) || a[0].number - b[0].number,
+ );
+ const loudCount = rows.filter(([, , msg]) => isLoudFinding(msg)).length;
+
+ const head = [
+ 'os-half-state-sweep — machine-findable marker for this generated view.',
+ '',
+ '**Generated view — not a second tracker.** Authority lives on each card and PR (one-board rule);' +
+ ' this body is rewritten IN PLACE by the scheduled patrol workflow' +
+ ' (`.github/workflows/half-state-patrol.yml`) on every run, and the edit history is the archive.' +
+ ' **Report-only**: every row is patrol input, never a gate verdict, and this sweep never fixes a' +
+ ' state. Each predicate and the protocol clause it enforces are documented in' +
+ ' `scripts/pm/check-half-states.mjs`.',
+ '',
+ `_Swept ${sweptAt.toISOString()}${provenance ? ` · ${provenance}` : ''}_`,
+ '',
+ 'The timestamp above is the patrol\'s own heartbeat: a `Swept` line that stops advancing means the' +
+ ' standing caller died, which is the failure this anchor was created to make visible. Read it' +
+ ' before you read the rows.',
+ '',
+ ];
+
+ if (loudCount > 0) {
+ head.push(
+ `🚨 **${loudCount} P0-SUSPECT row(s) in this sweep** — for that class the mandated move is the` +
+ ' emergency-triage channel (an immediate triage subagent), never waiting for the next hourly' +
+ ' Routine fire. They are sorted to the top of the list below.',
+ '',
+ );
+ }
+
+ head.push(`**${summaryLine(counts, rows.length)}**`, '');
+
+ // The H17 index is built BEFORE the findings are laid out and appended
+ // AFTER them: findings are alarms and keep the top of the body, while the
+ // index is the reference a dispatching seat reads on purpose. Building it
+ // first is what lets its length be RESERVED out of the budget below, so a
+ // noisy board can never truncate the index away — the trim then falls on
+ // finding rows, which announce their own omission and are recoverable from
+ // the run log. An index silently missing from the anchor would restore
+ // exactly the 0-for-19 silence this section exists to end.
+ const indexBlock = renderTriggerIndex(options.triggerIndex, { markdown: true });
+ const indexText = indexBlock.length > 0 ? `\n\n${indexBlock.join('\n')}` : '';
+
+ if (rows.length === 0) {
+ head.push(
+ '✅ No half-states found in this sweep. This line means the board was READ and is clean — a sweep' +
+ ' that could not RUN replaces this whole body with a prerequisite/failure report instead, so a' +
+ ' green anchor is never the sound of a broken sweeper.',
+ );
+ return `${head.join('\n')}${indexText}`;
+ }
+
+ head.push('### Findings', '', '');
+ const body = head.join('\n');
+ const rendered = [];
+ let used = body.length + indexText.length;
+ for (let i = 0; i < rows.length; i++) {
+ const [issue, code, msg] = rows[i];
+ const line = `- **${code}** [#${issue.number}](${issue.html_url}) — ${msg}`;
+ // Reserve room for the omission notice itself, so the trim can always
+ // announce itself even when it fires on the very last row.
+ const notice = `\n- _… ${rows.length - i} further row(s) omitted to fit GitHub's issue-body limit; the full list is in the workflow run log._`;
+ if (used + line.length + 1 + notice.length > MARKDOWN_BODY_BUDGET) {
+ rendered.push(notice.slice(1));
+ break;
+ }
+ rendered.push(line);
+ used += line.length + 1;
+ }
+ return `${body}${rendered.join('\n')}${indexText}`;
+}
+
+/**
+ * Output options off argv. Pure, so `--self-test` pins the usage errors too:
+ * a mistyped `--format` must be a LOUD non-zero exit, never a silent fallback
+ * to plain text that would leave the anchor updated with an unreadable body.
+ *
+ * @param {string[]} argv
+ * @returns {{ format: string, provenance: string, error?: string }}
+ */
+export function parseOutputOptions(argv) {
+ const out = { format: 'plain', provenance: '' };
+ for (const arg of argv ?? []) {
+ const fmt = /^--format=([\s\S]*)$/.exec(arg);
+ if (fmt) {
+ if (!OUTPUT_FORMATS.includes(fmt[1])) {
+ return {
+ ...out,
+ error: `unknown --format=${fmt[1]} — expected one of: ${OUTPUT_FORMATS.join(', ')}`,
+ };
+ }
+ out.format = fmt[1];
+ continue;
+ }
+ const prov = /^--provenance=([\s\S]*)$/.exec(arg);
+ if (prov) out.provenance = normalizeProvenance(prov[1]);
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Transport prerequisite — the classifier (pure) and the probe that feeds it.
+//
+// Modelled on `scripts/cli-build-prerequisite.mjs`: the knowledge lives in pure
+// functions the self-test can drive with the REAL measured observations, and the
+// WORDING stays here, next to the only code that knows what it did not check.
+// Kept in this file rather than shared with the CLI-build prerequisites — those
+// classify a subprocess's stderr, this classifies HTTP observations; a common
+// module would be one name over two unrelated corpora.
+// ---------------------------------------------------------------------------
+
+/** The exit code for a classified transport prerequisite failure (see header). */
+export const EXIT_PREREQUISITE_NOT_MET = 3;
+
+/**
+ * What the token in the environment LOOKS like — never whether it is valid; only
+ * GitHub can say that, and a 401 is it saying so. This exists to enrich the
+ * report ("…and it carries no GitHub token prefix"), never to pre-reject a token:
+ * pre-rejecting on shape would silently drop a credential in a format GitHub
+ * added after this line was written, which is the confident-wrong-diagnosis
+ * failure the sibling module is built to avoid.
+ *
+ * `redacted` is prefix-plus-length, the same form #7412 used to report the
+ * `prox…` placeholder. A real token's first four characters are its public
+ * prefix, so this is safe to print; the rest never is.
+ *
+ * @param {string} token
+ * @returns {{ present: boolean, shape: 'absent'|'github-prefix'|'legacy-40-hex'|'unrecognized', redacted: string }}
+ */
+export function describeToken(token) {
+ const t = String(token ?? '');
+ if (!t) return { present: false, shape: 'absent', redacted: '' };
+ const redacted = `${t.slice(0, 4)}… (len ${t.length})`;
+ if (/^(?:gh[pousr]_|github_pat_)/.test(t)) return { present: true, shape: 'github-prefix', redacted };
+ if (/^[0-9a-f]{40}$/.test(t)) return { present: true, shape: 'legacy-40-hex', redacted };
+ return { present: true, shape: 'unrecognized', redacted };
+}
+
+/**
+ * Whether a probe result means "requests will actually go through" — which is
+ * NOT the same as "the probe returned 200".
+ *
+ * `/rate_limit` is exempt from the rate limit it reports: with the anonymous
+ * quota spent it still answers 200, carrying `x-ratelimit-remaining: 0`, while
+ * `/repos/…/issues` answers 403 `API rate limit exceeded`. Measured on this
+ * change's own container, where the first draft of this probe green-lit a sweep
+ * that then failed on its very first page. A `null` remaining (header absent) is
+ * treated as usable: absence is not evidence of exhaustion, and the in-loop net
+ * is the backstop.
+ */
+export function probeIsUsable(result) {
+ if (!result || result.networkError) return false;
+ return result.status === 200 && result.rateLimitRemaining !== 0;
+}
+
+/**
+ * `x-ratelimit-remaining` as a number, or null when the header is absent.
+ *
+ * The null matters and `Number()` alone will not give it: `Number(null)` is 0,
+ * and a 401 carries no rate-limit headers at all — so the naive read turns every
+ * bad-credential response into "quota exhausted" and would misprescribe the
+ * remedy. Absent means unknown, and `probeIsUsable` treats unknown as usable.
+ */
+export function parseRemaining(raw) {
+ if (raw === null || raw === undefined || raw === '') return null;
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : null;
+}
+
+/** One probe result as a readable clause, for the report's evidence lines. */
+export function describeProbe(result) {
+ if (!result) return 'not attempted';
+ if (result.networkError) return `did not complete (${result.networkError})`;
+ if (result.status === 200 && result.rateLimitRemaining === 0) {
+ return 'HTTP 200 but with x-ratelimit-remaining: 0 — the quota endpoint is exempt from the limit it reports, so every other endpoint answers 403';
+ }
+ const left = result.rateLimitRemaining === null || result.rateLimitRemaining === undefined ? '' : ` (${result.rateLimitRemaining} left)`;
+ return `HTTP ${result.status}${left}`;
+}
+
+/**
+ * The exhausted-quota verdict, shared by the two observations that mean it: a
+ * 403 on a real endpoint, and `/rate_limit`'s exempt 200 with 0 remaining.
+ */
+function rateLimitedVerdict(tok, result, how) {
+ return {
+ kind: 'rate-limited',
+ headline: tok.present
+ ? 'the API rate limit for this credential is exhausted'
+ : 'the anonymous API rate limit (60 req/h) is exhausted for this egress IP',
+ detail: [
+ `\`GET /rate_limit\` -> ${describeProbe(result)}.`,
+ ...(how ? [`In this state ${how}.`] : []),
+ ``,
+ ...(tok.present
+ ? [`The quota refills on the hour.`]
+ : [
+ `The anonymous 60 req/h is counted per EGRESS IP, not per container, so in a`,
+ `shared-NAT agent container it is routinely already spent by neighbours — being`,
+ `"unauthenticated" is not a quota of one's own. It refills on the hour.`,
+ ]),
+ ],
+ fix: tok.present
+ ? ['wait for the quota window, or use a credential with a larger quota.']
+ : ['export GITHUB_TOKEN= (5,000+ req/h), or wait for the window.'],
+ };
+}
+
+/**
+ * The repo-scoped half of the verdict (#9946) — the stage-2 reading, judged
+ * against a stage-1 reading that already said the transport was healthy.
+ *
+ * Returns a verdict for a refusal, or null when the repo read is fine or is
+ * something this classifier declines to name. Kept as its own function for the
+ * same reason `rateLimitedVerdict` is: the wording lives next to the only code
+ * that knows what it did and did not check.
+ *
+ * The matching condition carries NO vendor string and NO token-shape test —
+ * both were considered and rejected on #9946, and the header says why. What it
+ * matches is the structural contradiction the two stages make together: the
+ * quota endpoint reports thousands of core requests available, and a core
+ * request just got refused. That is unambiguous regardless of who refused it,
+ * and it stays true if the intercepting proxy rewrites its message tomorrow.
+ *
+ * @param {{ present: boolean, shape: string, redacted: string }} tok
+ * @param {{ status?: number, rateLimitRemaining?: number|null }} primary the stage-1 reading
+ * @param {{ status?: number, rateLimitRemaining?: number|null, networkError?: string }} repo
+ */
+function classifyRepoRead(tok, primary, repo) {
+ if (!repo || repo.networkError) return null;
+ if (repo.status === 200 || repo.status === 301) return null;
+
+ // A genuine quota exhaustion that happened BETWEEN the two stages — rare, but
+ // it wears the same 403 and has a completely different remedy, so it must not
+ // be reported as a scope refusal.
+ if (repo.rateLimitRemaining === 0) return rateLimitedVerdict(tok, repo, 'the repo-scoped read is refused too');
+
+ const quota =
+ primary.rateLimitRemaining === null || primary.rateLimitRemaining === undefined
+ ? 'quota left'
+ : `${primary.rateLimitRemaining} core requests left`;
+
+ if (repo.status === 404) {
+ return {
+ kind: 'repo-not-visible',
+ headline: `\`${OWNER_REPO}\` is not visible to this identity — this container cannot read it`,
+ detail: [
+ `\`GET /rate_limit\` -> ${describeProbe(primary)}, but \`GET /repos/${OWNER_REPO}\` -> HTTP 404.`,
+ ``,
+ `GitHub answers 404 rather than 403 for a repository the caller may not know`,
+ `exists, so this is one of two things and the reading cannot say which: the`,
+ `repo name is wrong, or the credential cannot see it.`,
+ ],
+ fix: [
+ `check PM_SWEEP_REPO (currently \`${OWNER_REPO}\`), then the credential's repo scope.`,
+ ],
+ };
+ }
+
+ // 401/403 ONLY — the fourth container class as measured on 2026-08-19. Any
+ // other status is left unnamed on purpose (a 5xx is GitHub or the proxy being
+ // briefly unwell, not a scope decision), and the caller keeps its loud generic
+ // failure rather than being handed a confident wrong diagnosis.
+ if (repo.status !== 401 && repo.status !== 403) return null;
+
+ return {
+ kind: 'repo-scope-refused',
+ headline:
+ 'the transport authenticates but repo-scoped reads are refused — this container cannot make one repo-scoped request',
+ detail: [
+ `\`GET /rate_limit\` -> ${describeProbe(primary)}.`,
+ `\`GET /repos/${OWNER_REPO}\` -> HTTP ${repo.status}${
+ repo.rateLimitRemaining === null || repo.rateLimitRemaining === undefined
+ ? ' with no x-ratelimit-* headers at all'
+ : ` (${repo.rateLimitRemaining} left)`
+ }.`,
+ ``,
+ `Those two readings contradict each other: the quota endpoint reports ${quota},`,
+ `and a core request was just refused anyway. A quota that is not being spent`,
+ `cannot be what is blocking the read, so something between node and GitHub is`,
+ `answering for repo-scoped paths — the shape #9946 measured, where the account-`,
+ `scoped endpoints reached GitHub (\`server: github.com\`, real request ids) while`,
+ `every repo-scoped one was refused by the egress proxy with no GitHub headers.`,
+ ``,
+ `This is reported instead of a green precisely because the stage-1 reading here`,
+ `is INDISTINGUISHABLE from the healthy Routine runner's. Before this stage`,
+ `existed the probe said the prerequisite was met and the first repo-scoped`,
+ `request then 403'd — the #4690 inversion, inside the mechanism built to`,
+ `prevent it.`,
+ ],
+ fix: [
+ 'run this from a container whose egress allows repo-scoped reads (CI, or the',
+ 'Routine seat class); in a proxy-mediated seat, repo-scoped reads stay on the',
+ '`mcp__github__*` tools, which take a different path and do work here.',
+ ],
+ };
+}
+
+/**
+ * Turn probe OBSERVATIONS into a named prerequisite verdict. Pure — the network
+ * lives in `probeTransport` — so `--self-test` can pin every branch against the
+ * four container classes actually measured (#7412, #9946).
+ *
+ * Deliberately narrow, in the same direction as `looksLikeStaleWorkspaceDist`:
+ * an unrecognised status comes back as `null` (= "not a failure this classifier
+ * can name") and the caller keeps its pre-existing loud generic failure. A wrong
+ * confident diagnosis here would send a seat to fix a credential when GitHub was
+ * merely down.
+ *
+ * @param {{ token?: string, authed?: object|null, anon?: object|null, repo?: object|null }} obs
+ * `authed` / `anon` are each `{ status, rateLimitRemaining }` or
+ * `{ networkError }`; `anon` is only gathered when a token was used and failed.
+ * `repo` is the OPTIONAL repo-scoped reading (`GET /repos/{owner}/{repo}`),
+ * gathered only when the account-scoped evidence already reads `reachable`.
+ * Absent, this classifies the account-scoped evidence alone — the behaviour
+ * every caller had before #9946.
+ * @returns {{ kind: string, headline: string, detail: string[], fix: string[] } | null}
+ */
+export function classifyTransportProbe(obs) {
+ const token = obs?.token ?? '';
+ const tok = describeToken(token);
+ const authed = obs?.authed ?? null;
+ const anon = obs?.anon ?? null;
+ const repo = obs?.repo ?? null;
+ const primary = tok.present ? authed : anon;
+ if (!primary) return null;
+ const anonUsable = probeIsUsable(anon);
+
+ const shapeNote =
+ tok.shape === 'unrecognized'
+ ? `The value carries no GitHub token prefix (\`ghp_\`/\`gho_\`/\`ghs_\`/\`github_pat_\`) — in`
+ : `The value has a GitHub token shape, so it is a credential this account no longer holds —`;
+ const shapeNote2 =
+ tok.shape === 'unrecognized'
+ ? `agent containers this is normally the proxy's own placeholder, not a credential.`
+ : `expired, revoked, or scoped to a different repo.`;
+
+ if (primary.networkError) {
+ return {
+ kind: 'host-unreachable',
+ headline: '`api.github.com` is not reachable from node in this container',
+ detail: [
+ `\`GET /rate_limit\` did not complete: ${primary.networkError}`,
+ ``,
+ `Node's fetch does not use HTTPS_PROXY, so this says nothing about \`curl\`, \`gh\``,
+ `or the \`mcp__github__*\` tools — those may all work here and still not be this`,
+ `script's transport.`,
+ ],
+ fix: [
+ 'run this from a container with direct egress to api.github.com (CI, or the',
+ 'Routine seat class); in an MCP-only seat this stays manual — the',
+ '`mcp__github__*` tools take a different path and may still work.',
+ ],
+ };
+ }
+
+ // A 200 from `/rate_limit` is NOT sufficient, and finding that out is what the
+ // measurement below cost: GitHub exempts `/rate_limit` from the limit it
+ // reports, so it keeps answering 200 with `x-ratelimit-remaining: 0` while
+ // every other endpoint answers 403. A probe that read only the status would
+ // vouch for a sweep that cannot make a single request — the exact
+ // "green check that checked nothing" this file exists to refuse (#4690).
+ if (primary.status === 200 && primary.rateLimitRemaining === 0) {
+ return rateLimitedVerdict(tok, primary, 'every OTHER endpoint answers 403 `API rate limit exceeded`');
+ }
+
+ if (primary.status === 200) {
+ // Stage 2 (#9946). The account-scoped evidence is healthy — which in the
+ // fourth container class is TRUE and still does not mean the sweep can run.
+ // Only a repo-scoped reading separates that class from the Routine runner,
+ // and the two observations are indistinguishable without it.
+ const repoVerdict = repo ? classifyRepoRead(tok, primary, repo) : null;
+ if (repoVerdict) return repoVerdict;
+ if (repo && !(repo.status === 200 || repo.status === 301)) {
+ // Handed a repo reading this classifier cannot name (a 5xx, a transient
+ // network error moments after the host answered): stay unclassified
+ // rather than either vouching for the transport or blaming a credential.
+ // The caller keeps its loud generic failure — the same narrowness the
+ // account-scoped branches take.
+ return null;
+ }
+ return {
+ kind: 'reachable',
+ headline: tok.present
+ ? 'api.github.com is reachable and the token authenticates'
+ : 'api.github.com is reachable anonymously (no token in the environment)',
+ detail: [],
+ fix: [],
+ };
+ }
+
+ if (primary.status === 401 || (primary.status === 403 && anonUsable)) {
+ const anonWorks = anonUsable;
+ return {
+ kind: anonWorks ? 'bad-credential-anon-reachable' : 'bad-credential',
+ headline: anonWorks
+ ? 'the token in the environment is not a valid GitHub credential — and it is the ONLY thing stopping this container from reading'
+ : 'the token in the environment is not a valid GitHub credential',
+ detail: [
+ `\`GET /rate_limit\` with GITHUB_TOKEN/GH_TOKEN = ${tok.redacted} -> ${describeProbe(primary)}.`,
+ ...(anon ? [`The same request with NO token -> ${describeProbe(anon)}.`] : []),
+ ``,
+ `${shapeNote} ${shapeNote2}`,
+ ``,
+ ...(anonWorks
+ ? [
+ `The host IS reachable from node here and anonymous access has quota left, so`,
+ `the credential is the only thing in the way.`,
+ ``,
+ `This script does not drop the token on its own — which token to send is the`,
+ `caller's decision, and silently sweeping as a different identity is not a call`,
+ `a report-only tool should make (#7412 triage: transport doctrine is the`,
+ `maintainer's).`,
+ ]
+ : [
+ `Dropping the token would NOT be enough here: the anonymous path is unusable`,
+ `too, so this container needs a real credential rather than a re-run.`,
+ ]),
+ ],
+ fix: anonWorks
+ ? [
+ 'GITHUB_TOKEN= GH_TOKEN= node scripts/pm/check-half-states.mjs',
+ ' ↑ anonymous is 60 req/h and that quota is per EGRESS IP, shared with every',
+ ' other container behind it. A request-heavy run can exhaust it mid-run,',
+ ' which then surfaces as another PREREQUISITE NOT MET, never as a short',
+ ' finding list.',
+ ]
+ : ['export GITHUB_TOKEN= and re-run (see the anonymous reading above).'],
+ };
+ }
+
+ if (primary.status === 403) {
+ if (primary.rateLimitRemaining === 0) {
+ return rateLimitedVerdict(tok, primary, '');
+ }
+ return {
+ kind: 'host-unreachable',
+ headline: '`api.github.com` answers 403 in this container — the host is refusing, not rate-limiting',
+ detail: [
+ `\`GET /rate_limit\` -> HTTP 403${tok.present ? ` with GITHUB_TOKEN/GH_TOKEN = ${tok.redacted}` : ' (no token)'}.`,
+ ...(anon ? [`The same request with NO token -> ${anon.networkError ? anon.networkError : `HTTP ${anon.status}`}.`] : []),
+ ``,
+ `403 in both directions with quota left is the egress proxy refusing the host,`,
+ `not GitHub refusing the caller — the shape #7412 measured in a PM seat session.`,
+ `\`curl\` and the \`mcp__github__*\` tools take a different path and may still work.`,
+ ],
+ fix: [
+ 'run this from a container with direct egress to api.github.com (CI, or the',
+ 'Routine seat class); in an MCP-only seat this stays manual — the',
+ '`mcp__github__*` tools take a different path and may still work.',
+ ],
+ };
+ }
+
+ return null;
+}
+
+/**
+ * The observations, gathered from the live host. `/rate_limit` is the probe
+ * because it is the one endpoint that costs no core quota — asking "can I read
+ * this board?" must not spend the budget the sweep then needs.
+ *
+ * The second, token-less probe fires ONLY when a token was sent and failed. That
+ * is what separates "the credential is bad" from "the host is unreachable" —
+ * two facts with different remedies that the card's original measurement could
+ * not tell apart. The healthy path stays at exactly one request.
+ */
+async function probeRateLimit(token) {
+ try {
+ const res = await fetch(`${API}/rate_limit`, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ return { status: res.status, rateLimitRemaining: parseRemaining(res.headers.get('x-ratelimit-remaining')) };
+ } catch (err) {
+ return { networkError: err?.cause?.code ?? err?.cause?.message ?? err?.message ?? 'fetch failed' };
+ }
+}
+
+/**
+ * The stage-2 reading (#9946): one repo-scoped GET, the cheapest request that
+ * exercises the same scope the sweep's every listing needs.
+ *
+ * `GET /repos/{owner}/{repo}` and not `GET /user`: the measured fourth class
+ * answers 200 on `/user` — account-scoped endpoints reach GitHub there — so an
+ * "is this a real endpoint" probe would have green-lit it just as `/rate_limit`
+ * did. What has to be exercised is the SCOPE, not the realness.
+ *
+ * Costs one core request. `/repos/{owner}/{repo}` rather than a one-item issues
+ * page because it is the smaller body and the same authorization decision.
+ */
+async function probeRepoRead(token) {
+ try {
+ const res = await fetch(`${API}/repos/${OWNER_REPO}`, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ return { status: res.status, rateLimitRemaining: parseRemaining(res.headers.get('x-ratelimit-remaining')) };
+ } catch (err) {
+ return { networkError: err?.cause?.code ?? err?.cause?.message ?? err?.message ?? 'fetch failed' };
+ }
+}
+
+/**
+ * Whether the stage-1 verdict warrants spending a core request on stage 2.
+ *
+ * Only a `reachable` does — which is exactly the path that used to return a
+ * green without ever having read anything repo-scoped. Every failing class
+ * short-circuits here, so none of them costs a request more than it did before.
+ *
+ * Exported so `--self-test` can pin the sequencing: the guarantee that this
+ * script never green-lights on account-scoped evidence alone is a property of
+ * the GATHERING, not of the pure classifier (which, handed no repo reading,
+ * still classifies exactly as it always did for its other importer).
+ */
+export function needsRepoProbe(accountVerdict) {
+ return accountVerdict?.kind === 'reachable';
+}
+
+async function probeTransport() {
+ const first = await probeRateLimit(TOKEN);
+ const account = !TOKEN
+ ? classifyTransportProbe({ token: '', anon: first })
+ : first.status === 200
+ ? classifyTransportProbe({ token: TOKEN, authed: first })
+ : classifyTransportProbe({ token: TOKEN, authed: first, anon: await probeRateLimit('') });
+
+ if (!needsRepoProbe(account)) return account;
+
+ // Re-classified with the repo reading added, rather than patched on top of the
+ // stage-1 verdict: one classifier, one place where a verdict is named.
+ const repo = await probeRepoRead(TOKEN);
+ return TOKEN
+ ? classifyTransportProbe({ token: TOKEN, authed: first, repo })
+ : classifyTransportProbe({ token: '', anon: first, repo });
+}
+
+/**
+ * The prerequisite printer. Its load-bearing half is the closing paragraph: the
+ * whole point of #4690 is that "could not read the input" must never be legible
+ * as "the input is clean", and on a REPORT-ONLY tool that risk is sharper than
+ * on a gate — a silent run of this script looks exactly like a healthy board.
+ *
+ * `swept` keeps that paragraph TRUE when the failure arrives mid-run: the
+ * pre-sweep probe fires at 0, where "nothing was listed" is exact, while the
+ * in-loop net can fire after some labels were already read. Same invariant as
+ * `check-i18n-bundles`'s partial-round wording (#7681/#6033).
+ *
+ * @param {{ kind: string, headline: string, detail: string[], fix: string[] }} v
+ * @param {{ swept?: number }} [options]
+ */
+function reportPrerequisiteNotMet(v, options = {}) {
+ const { swept = 0 } = options;
+ const nothing =
+ swept === 0
+ ? [
+ ` Nothing was swept: no issue was listed, no predicate (H1–H16) ran, and the H17`,
+ ` trigger-file index gathered nothing, so this result says NOTHING about whether the`,
+ ` board carries half-states. It is not a clean board and it is not a dirty one — it`,
+ ` is no reading at all.`,
+ ]
+ : [
+ ` Nothing was judged: the transport failed after ${swept} issue(s) had been listed,`,
+ ` the rest were never fetched, and no finding line was printed — H2 in particular`,
+ ` needs a per-card comment fetch that never happened. An empty finding list here`,
+ ` is not a clean board.`,
+ ];
+ console.error(
+ `\ncheck-half-states: PREREQUISITE NOT MET — ${v.headline}\n\n` +
+ v.detail.map((l) => (l ? ` ${l}` : '')).join('\n') +
+ `\n\n Fix: ${v.fix[0] ?? 'unknown'}\n` +
+ v.fix.slice(1).map((l) => ` ${l}\n`).join('') +
+ `\n${nothing.join('\n')}\n` +
+ ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — but piping this\n` +
+ ` reports the PIPE's status, so \`… | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
+ );
+ process.exit(EXIT_PREREQUISITE_NOT_MET);
+}
+
+// ---------------------------------------------------------------------------
+// Live sweep
+// ---------------------------------------------------------------------------
+
+async function rest(path) {
+ const res = await fetch(`${API}${path}`, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
+ },
+ });
+ if (!res.ok) {
+ // The status rides along so the in-loop net can re-classify rather than
+ // re-parse the message — the same reason the CLI prerequisites return the
+ // matched sentence instead of a boolean.
+ const err = new Error(`GET ${path} -> HTTP ${res.status}`);
+ err.status = res.status;
+ throw err;
+ }
+ return res.json();
+}
+
+async function listIssues(label) {
+ const out = [];
+ for (let page = 1; page <= 10; page++) {
+ const batch = await rest(
+ `/repos/${OWNER_REPO}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100&page=${page}`,
+ );
+ out.push(...batch.filter((i) => !i.pull_request));
+ if (batch.length < 100) break;
+ }
+ return out;
+}
+
+async function sweep(options = {}) {
+ // Answered once, before any listing — so an unusable transport costs ONE
+ // classified verdict instead of a raw HTTP status from whichever label page
+ // happened to go first (`pm:dispatched`, in the failure #7412 recorded).
+ const pre = await probeTransport();
+ if (pre && pre.kind !== 'reachable') reportPrerequisiteNotMet(pre);
+
+ const findings = [];
+ const seen = new Map();
+ const seenPrs = new Map();
+ const seenMerged = new Map();
+ const seenUnscoped = new Map();
+ // H22's bounded closed-card window (#10688) — the one closed-issue read here.
+ const seenClosed = new Map();
+ // H16's per-row fetch is the one input that can fail partially, so its
+ // tally rides out of the sweep and into the summary line (see `summaryLine`).
+ const stats = {
+ conflictCandidates: 0,
+ conflictProbed: 0,
+ fallbackCandidates: 0,
+ fallbackProbed: 0,
+ // H9's coverage pair — `pm:on-hold` cards whose verdict the comment
+ // channel could change, and how many threads were actually read (#10403).
+ restartCandidates: 0,
+ restartProbed: 0,
+ // H19's coverage pair — distinct `Blocked-by:` targets seen, and how many
+ // got a definite open/closed answer.
+ blockerTargets: 0,
+ blockerResolved: 0,
+ // H23's coverage numbers (#10942) — how many commit messages this pass read
+ // and how much closing-keyword traffic they carry. Initialised to 0 here
+ // rather than left absent so a sweep that throws before the commit pass
+ // still renders numbers instead of the string `undefined`.
+ commits: 0,
+ commitBindings: 0,
+ commitBindingMessages: 0,
+ };
+ // H17's gathering rides out of the sweep the same way, because it has the
+ // same per-row failure mode as H16's detail pass and therefore owes the
+ // summary line the same `read X of Y`.
+ const hold = { entries: [], candidates: 0, probed: 0 };
+ try {
+ await sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seenClosed, stats, hold);
+ } catch (err) {
+ err.sweptSoFar = seen.size + seenPrs.size + seenMerged.size + seenUnscoped.size + seenClosed.size;
+ throw err;
+ }
+
+ findings.sort((a, b) => a[0].number - b[0].number);
+ const counts = {
+ repo: OWNER_REPO,
+ issues: seen.size,
+ unscoped: seenUnscoped.size,
+ prs: seenPrs.size,
+ merged: seenMerged.size,
+ closed: seenClosed.size,
+ // ADAPTED (objectui#5791): the summary line must be able to say "UNREAD"
+ // rather than "read 0" — a disabled reader and an empty result are the same
+ // number and opposite facts (#4690).
+ closedWindowDisabled: CLOSED_WINDOW.pages === 0,
+ conflictCandidates: stats.conflictCandidates,
+ conflictProbed: stats.conflictProbed,
+ holdCandidates: hold.candidates,
+ holdProbed: hold.probed,
+ fallbackCandidates: stats.fallbackCandidates,
+ fallbackProbed: stats.fallbackProbed,
+ restartCandidates: stats.restartCandidates,
+ restartProbed: stats.restartProbed,
+ blockerTargets: stats.blockerTargets,
+ blockerResolved: stats.blockerResolved,
+ commits: stats.commits,
+ commitBindings: stats.commitBindings,
+ commitBindingMessages: stats.commitBindingMessages,
+ };
+ // The oracle is read ONCE per sweep, after gathering: it is a local
+ // `git ls-files`, not a request, and every candidate token is checked
+ // against the same reading so the index cannot be internally inconsistent.
+ const tracked = readTrackedFiles();
+ const triggerIndex = {
+ rows: h17IndexRows(hold.entries, (path) => (tracked ? tracked.has(path) : false)),
+ candidates: hold.candidates,
+ probed: hold.probed,
+ tracked: tracked ? tracked.size : null,
+ };
+ console.log(
+ options.format === 'markdown'
+ ? renderMarkdown(findings, counts, { provenance: options.provenance, triggerIndex })
+ : renderPlain(findings, counts, { triggerIndex }),
+ );
+}
+
+async function listOpenPullRequests() {
+ const out = [];
+ for (let page = 1; page <= 10; page++) {
+ const batch = await rest(`/repos/${OWNER_REPO}/pulls?state=open&per_page=100&page=${page}`);
+ out.push(...batch);
+ if (batch.length < 100) break;
+ }
+ return out;
+}
+
+/**
+ * How this file states a bounded window's boundary, in ONE place (#11118).
+ *
+ * Every window here is a page cap, and a page cap is meaningless until it is
+ * divided by a rate. The three windows below used to state their boundaries in
+ * prose, each derived from a rate measured whenever that item was written — and
+ * one of them (H8's) was still quoting `~18 merges/day` from a repo that had
+ * since accelerated more than sevenfold. The sentence justifying the cap was
+ * describing an ~11-day reach for a window that had become ~1.8 days, and
+ * nothing in the file said so, because nothing in the file could: the
+ * arithmetic was prose, so no reader and no test could ever disagree with it.
+ *
+ * These two helpers make the derivation executable instead. The self-test pins
+ * them; the docblocks below quote numbers these functions produce.
+ *
+ * @param {number} rows — the window's size in rows (pages × per_page).
+ * @param {number} ratePerDay — the measured production rate of those rows.
+ * @returns {number|null} days of coverage, or null when the rate cannot divide.
+ */
+export function windowCoverageDays(rows, ratePerDay) {
+ if (!Number.isFinite(rows) || !Number.isFinite(ratePerDay) || ratePerDay <= 0) return null;
+ return rows / ratePerDay;
+}
+
+/** How many consecutive patrol runs see a given row before it ages out. */
+export function sweepOverlap(coverageDays, cadenceHours = PATROL_CADENCE_HOURS) {
+ if (!Number.isFinite(coverageDays) || !Number.isFinite(cadenceHours) || cadenceHours <= 0) {
+ return null;
+ }
+ return (coverageDays * 24) / cadenceHours;
+}
+
+/** The scheduled patrol's period — `cron: '37 1,7,13,19 * * *'` in the workflow. */
+export const PATROL_CADENCE_HOURS = 6;
+
+/**
+ * The default-branch merge rate, MEASURED — the divisor every window below
+ * uses, and the number the stale `~18/day` was replaced with.
+ *
+ * Window pinned as full ISO INSTANTS, deliberately: `git log --since=` is an
+ * approxidate that fills the time-of-day from *now*, and two runs twelve
+ * minutes apart returned 1,443 and 1,441 messages for what read as one window
+ * (#11118's own warning, which this re-derivation obeys rather than repeats).
+ *
+ * read 2026-08-23T08:42:15Z, `GET /repos/{repo}/commits`, 3 pages
+ * window 2026-08-21T04:00:19Z … 2026-08-23T08:22:47Z (2.18 days)
+ * rows 300 commits, 300 of them carrying the `(#N)` squash marker
+ * rate 300 / 2.18 = ~137.5 merges/day
+ *
+ * `main` is linear (measured on the same corpus at #10942's filing: 1,975
+ * reachable = 1,975 first-parent, 0 merge commits), so the commit count and the
+ * merge count are one count. The figure agrees with the independent 2026-08-22
+ * measurement this card was filed on (1,546 commits in 11.7 days ≈ 132/day),
+ * which is what makes it a rate rather than a spike.
+ */
+export const MEASURED_MERGES_PER_DAY = 137.5;
+
+/**
+ * The merged-PR window H8 reads: most recently UPDATED closed PRs, merged ones
+ * only, capped at four pages — a quota decision whose consequence is H8's
+ * stated boundary (a delivery older than the window is invisible).
+ * `sort=updated` so a long-lived PR that merges late is still in the window
+ * when it matters.
+ *
+ * ## The boundary, re-derived (#11118)
+ *
+ * The cap was two pages, justified by a sentence claiming they "reach well past
+ * the longest measured unexecuted-verdict latency" — true at ~18 merges/day,
+ * which is where that sentence came from, and false at the measured 137.5.
+ * Both readings, taken 2026-08-23T08:42:15Z over the live endpoint:
+ *
+ * 2 pages = 200 rows -> 197 merged, oldest merge 2026-08-21T14:00:28Z = 1.78d
+ * 4 pages = 400 rows -> 397 merged, oldest merge 2026-08-20T09:41:02Z = 2.96d
+ *
+ * (Derivation and reading agree: `windowCoverageDays(400, 137.5)` = 2.91d. The
+ * merged-only filter costs almost nothing — 397 of 400 closed PRs in the window
+ * were merged — so rows and merges are interchangeable here in practice.)
+ *
+ * Four pages is chosen over an honest restatement because this row's damage
+ * model is asymmetric in the direction that punishes a short window: H8 reports
+ * a card whose delivering PR merged while the card still says `pm:dispatched`,
+ * i.e. precisely the paired write NOBODY noticed — which correlates with age.
+ * The population most likely to age out is the population the row exists for.
+ * The measured H8 specimen (#11036) sat unreported ~22h, so a 1.78-day window
+ * left the row about 2x its own worst measured latency; 2.96 days restores the
+ * "comfortably past it" the docblock always claimed. Cost: two extra requests
+ * per sweep, four sweeps a day, against a 15,000/h core quota.
+ *
+ * At the 6-hourly cadence that is `sweepOverlap(2.96)` ≈ 11.8 consecutive runs
+ * that see a given merge — the window is a detection HORIZON, not a retry
+ * budget: past it the finding is not delayed, it is gone (H22 catches the part
+ * of that population whose card later closes; nothing catches the rest).
+ */
+export const MERGED_WINDOW_PAGES = 4;
+
+async function listRecentlyMergedPullRequests() {
+ const out = [];
+ for (let page = 1; page <= MERGED_WINDOW_PAGES; page++) {
+ const batch = await rest(
+ `/repos/${OWNER_REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=${page}`,
+ );
+ out.push(...batch.filter((p) => p.merged_at));
+ if (batch.length < 100) break;
+ }
+ return out;
+}
+
+/**
+ * The bounded closed-card window H22 reads (#10688) — most recently UPDATED
+ * closed issues, capped at two pages, the same `sort=updated` convention and
+ * the same quota decision as the merged-PR window above.
+ *
+ * The cap is the item's stated boundary, and here it carries more weight than
+ * usual: the label-scoped population of closed carriers is very large (a
+ * 2026-08-22 re-measure paged past 500 closed `pm:dispatched` carriers alone
+ * and was still going, because the label has been applied since the protocol
+ * began and dropped only sporadically). Reporting all of them would drown every
+ * other item in one-time historical residue. So this window deliberately
+ * reports the RECENT residue — the population where the paired write is still
+ * a live duty someone remembers — and the deep tail is a backfill question,
+ * not a patrol question. `state=closed` is the ONLY closed-issue read in this
+ * file; every other collector stays open-only by construction.
+ *
+ * ## The boundary, re-derived — and the surprise in it (#11118)
+ *
+ * "Recent" was never measured here; it was assumed to mean roughly what H8's
+ * window meant. It did not, and the divisor is the reason: this window is
+ * ordered by `updated`, and a closed card is BUMPED by every later comment,
+ * label write and cross-reference, so the rows are consumed by issue ACTIVITY
+ * rather than by closures. Read 2026-08-23T08:42:15Z over the live endpoint:
+ *
+ * 2 pages = 200 rows -> updated 2026-08-22T17:05:44Z … 2026-08-23T08:39:14Z
+ * = 0.65d (~15.6 HOURS of update-recency)
+ * 4 pages = 400 rows -> updated 2026-08-21T15:44:49Z … 2026-08-23T08:39:14Z
+ * = 1.70d
+ *
+ * At 6-hourly runs the old cap gave `sweepOverlap(0.65)` ≈ 2.6 consecutive
+ * sweeps — and the derived floor is tighter still (200 rows / ~308 updates/day
+ * ≈ 1.30d for four pages against the 1.70d measured), because the rate is
+ * BURSTY: a triage round that touches a few hundred closed cards can eject a
+ * fresh residue card inside one cadence, and it does so exactly when residue is
+ * being produced fastest. That correlation is what makes 2.6 sweeps thin rather
+ * than merely small.
+ *
+ * Four pages restores what the anti-drowning argument above was actually
+ * choosing — a couple of days of recent residue — rather than the fifteen hours
+ * it turned out to be buying. It does NOT reopen the deep tail: the population
+ * that argument refuses is the 500+ historical carriers spanning months, and
+ * 1.7 days is not in it. Cost: two extra requests per sweep.
+ */
+export const CLOSED_ISSUE_WINDOW_PAGES = 4;
+
+/**
+ * ⚠️ ADAPTED FOR THIS REPO (objectui#5791) — the one predicate whose upstream
+ * default is WRONG here, and the measurement that says so.
+ *
+ * Every other collector in this file is `state=open` by construction, so the
+ * port carries them unchanged. H22 is the single closed-issue reader, and it is
+ * the one row whose yield depends on a CONVENTION rather than on a defect —
+ * which is why the same code means different things in the two repos.
+ *
+ * ## The measurement (objectui, 2026-08-24, re-measured for this port)
+ *
+ * closed cards carrying `pm:dispatched`, repo-wide 815
+ * closed issues in this window (4 pages = 400 rows) 400
+ * …spanning updated 2026-08-18T03:36:15Z … 08-24T09:48Z 6.2 days
+ * of those 400, carrying a `PM_RESIDUE_LABELS` member:
+ * `pm:dispatched` 259
+ * `pm:queue` 86
+ * `pm:blocked` 1
+ * `pm:on-hold` 1
+ * ≈ 347 rows
+ *
+ * Upstream measured 129 of 500 (26%) and called that recent residue a live
+ * duty. Here it is ~87% of the window. That inversion is not this repo being
+ * behind on a chore — it is that stripping `pm:*` on close was never this
+ * lane's practice (objectui#5791's own thread records two seats reading the
+ * same corpus and landing on opposite conventions). A row that fires on ~87%
+ * of everything it reads is not a finding, it is the convention restated 347
+ * times, and it would consume the entire `MARKDOWN_BODY_BUDGET` and trim every
+ * OTHER predicate's rows out of the anchor body — the patrol dead on arrival,
+ * which is precisely what this card exists to prevent.
+ *
+ * So the window is PARAMETERISED rather than the predicate edited: `h22ClosedCardPmResidue`
+ * is untouched and still correct, and this install simply does not open the
+ * closed reader. `PM_SWEEP_CLOSED_WINDOW_PAGES=0` disables it; unset keeps
+ * upstream's 4, so the file's DEFAULT behaviour is byte-identical to objectstack
+ * and a future verbatim re-sync of the predicate cannot silently re-enable
+ * anything — the choice lives in the workflow, where it is one visible line.
+ *
+ * ⛔ Disabling is NOT the same as reading clean, and `summaryLine` says so in
+ * the rendered body rather than reporting `read 0` (#4690: an input that was
+ * never read must never render as an input that was read and found clean).
+ * Re-enabling is a BACKFILL decision — strip the historical residue first, then
+ * drop the variable — and never a quiet default flip.
+ *
+ * @param {Record} [env]
+ * @returns {{ pages: number, source: string, valid: boolean, raw: string }}
+ */
+export function resolveClosedWindowPages(env = {}) {
+ const raw = String(env.PM_SWEEP_CLOSED_WINDOW_PAGES ?? '').trim();
+ // Unset and whitespace are UNSET, not a value — the same reading
+ // `resolveSweepRepo` gives an unexpanded Actions expression.
+ if (raw === '') return { pages: CLOSED_ISSUE_WINDOW_PAGES, source: 'default', valid: true, raw };
+ // A malformed value is REFUSED at the CLI, never silently replaced by the
+ // default: a typo'd `PM_SWEEP_CLOSED_WINDOW_PAGES=O` that quietly restored
+ // the 4-page default would re-open the 347 rows this install disabled, and
+ // the anchor would read as though someone had chosen that.
+ if (!/^\d+$/.test(raw)) return { pages: CLOSED_ISSUE_WINDOW_PAGES, source: 'PM_SWEEP_CLOSED_WINDOW_PAGES', valid: false, raw };
+ return { pages: Number(raw), source: 'PM_SWEEP_CLOSED_WINDOW_PAGES', valid: true, raw };
+}
+
+const CLOSED_WINDOW = resolveClosedWindowPages(process.env);
+
+async function listRecentlyClosedIssues() {
+ // 0 pages = the closed reader is off in this install. Returning early (rather
+ // than letting the loop not execute) keeps the intent legible and makes it
+ // explicit that NO request is spent.
+ if (CLOSED_WINDOW.pages === 0) return [];
+ const out = [];
+ for (let page = 1; page <= CLOSED_WINDOW.pages; page++) {
+ const batch = await rest(
+ `/repos/${OWNER_REPO}/issues?state=closed&sort=updated&direction=desc&per_page=100&page=${page}`,
+ );
+ out.push(...batch.filter((i) => !i.pull_request));
+ if (batch.length < 100) break;
+ }
+ return out;
+}
+
+/**
+ * H23's bounded commit window (#10942) — the most recent commits on the
+ * repository's DEFAULT BRANCH, capped at three pages, the same "bounded window,
+ * stated boundary" discipline as the two windows above.
+ *
+ * ## Why REST and not `git log`, in a file that already shells out to git
+ *
+ * `readTrackedFiles` proves a git channel exists here, and a `git log` read
+ * would cost no API quota at all — so the choice needs a reason. It is the
+ * runner: `.github/workflows/half-state-patrol.yml` checks out with
+ * `actions/checkout@v7` and no `fetch-depth`, whose default is **1**. `git log`
+ * there would read exactly ONE commit message and report a clean surface, which
+ * is #4690 in its purest form — an unread input rendering as a clean one, four
+ * times a day, forever. This is not a hypothetical about someone else's
+ * container: the checkout this change was authored in arrived shallow at 375
+ * commits, and the 11-day corpus in H23's section only became readable after an
+ * explicit `git fetch --deepen`. A channel that is dark in the one place the
+ * sweep actually runs is not a cheaper channel.
+ *
+ * ## The page cap, in the units that decide it
+ *
+ * Measured over the corpus above: 1,546 commits in 11.7 days ≈ 132/day. Three
+ * pages ≈ 300 commits ≈ 2.3 days, against a patrol that fires every 6 hours —
+ * roughly a 9× overlap, so a message has to survive nine consecutive sweeps to
+ * age out unseen.
+ *
+ * RE-MEASURED 2026-08-23T08:42:15Z and unchanged, which is why this window
+ * alone kept its cap while H8's and H22's were widened (#11118): the same three
+ * pages read 300 commits spanning 2026-08-21T04:00:19Z … 2026-08-23T08:22:47Z
+ * = 2.18 days at ~137.5/day, i.e. `sweepOverlap(2.18)` ≈ 8.7 runs. This item's
+ * docblock was the only one that DERIVED its cap from a measured rate instead
+ * of quoting a remembered one, and it is the only one that survived contact
+ * with a re-measure — the argument for keeping the derivation executable
+ * (`windowCoverageDays`, `MEASURED_MERGES_PER_DAY`) rather than in prose.
+ *
+ * No `sha=` parameter: the endpoint defaults to the repository's own default
+ * branch, which keeps this reader repo-agnostic exactly like every other listing
+ * here — `PM_SWEEP_REPO` can name a repo whose default branch is not `main`.
+ * Ordering is the endpoint's own reverse-chronological walk of that branch and
+ * needs no first-parent filter here: `main` is linear (measured — 1,975 commits
+ * reachable, 1,975 on the first-parent walk, 0 merge commits), so the commit list
+ * and the squash-message list are the same list. A repo that DOES carry merge
+ * commits would simply feed this row a few branch-side messages, which are a
+ * surface GitHub's parser reads too — wider, never wrong.
+ */
+export const COMMIT_WINDOW_PAGES = 3;
+
+async function listRecentDefaultBranchCommits() {
+ const out = [];
+ for (let page = 1; page <= COMMIT_WINDOW_PAGES; page++) {
+ const batch = await rest(`/repos/${OWNER_REPO}/commits?per_page=100&page=${page}`);
+ out.push(...batch);
+ if (batch.length < 100) break;
+ }
+ return out;
+}
+
+/**
+ * The unscoped listing H13 needs: the domain-without-pm-state shape is
+ * DEFINED by the absence of every label the listings below key on, so no
+ * label page can ever return it — the very property that hides it from seat
+ * queries hides it from a label-scoped sweep too. Ten pages, the same cap as
+ * `listIssues`; an open backlog beyond the cap is invisible to H13 (stated
+ * boundary, same convention as H8's merged window — the finding clears when
+ * the paired write lands, not when the card ages out).
+ */
+async function listAllOpenIssues() {
+ const out = [];
+ for (let page = 1; page <= 10; page++) {
+ const batch = await rest(`/repos/${OWNER_REPO}/issues?state=open&per_page=100&page=${page}`);
+ out.push(...batch.filter((i) => !i.pull_request));
+ if (batch.length < 100) break;
+ }
+ return out;
+}
+
+async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seenClosed, stats = {}, hold = null) {
+ // `pm:awaiting-maintainer` is listed like every other state label (#11196
+ // fix 5). H25's exclusivity carriers would be reachable through the label
+ // they wrongly coexist with, but a card in the state ALONE would otherwise be
+ // swept by nothing at all — no H2 claim check, no H11 parked inventory — and
+ // "the patrol's input set is narrower than the states the board produces" is
+ // the defect this whole family is about. One label page per sweep, four
+ // sweeps a day, against a 15,000/h core quota.
+ for (const label of ['pm:dispatched', 'pm:queue', 'pm:blocked', 'pm:seat', 'pm:on-hold', AWAITING_MAINTAINER_LABEL, 'priority:p0']) {
+ for (const issue of await listIssues(label)) seen.set(issue.number, issue);
+ }
+
+ // At most ONE comment fetch per card, shared by the two items that need the
+ // thread: H2 reads it for the claim marker, H17 for trigger clauses. Without
+ // the memo a card carrying both `pm:dispatched` (assigned) and `pm:on-hold`
+ // — itself an H3-adjacent half-state, so exactly the card most likely to be
+ // on the board — would be fetched twice per sweep for no new information.
+ //
+ // The cache holds the REST ROWS rather than the bodies, because H20 is the
+ // one reader here that needs a `created_at` (how old is the claim?) and a
+ // second fetch to get one would defeat the memo this cache exists to be.
+ // Every other reader takes bodies through `commentsFor`, unchanged.
+ const commentCache = new Map();
+ const commentRowsFor = async (issue) => {
+ if (commentCache.has(issue.number)) return commentCache.get(issue.number);
+ const rows = await rest(`/repos/${OWNER_REPO}/issues/${issue.number}/comments?per_page=100`);
+ commentCache.set(issue.number, rows);
+ return rows;
+ };
+ const commentsFor = async (issue) => (await commentRowsFor(issue)).map((c) => c.body ?? '');
+ let lastHoldError = null;
+
+ // The `Blocked-by:` comment fallback (#8941 / #10061). Same shared cache, so
+ // a card that is `pm:blocked` AND assigned AND on hold still costs ONE fetch
+ // across H2, H17 and this. `comments` holds what was read; `unreadable` holds
+ // the cards whose fetch failed — the two are kept apart on purpose, because
+ // "read, and it carries nothing" and "could not be read" are the pair this
+ // whole item exists to stop conflating.
+ const fallback = { comments: new Map(), unreadable: new Set() };
+ let lastFallbackError = null;
+ const gatherBlockedByComments = async (issue) => {
+ if (fallback.comments.has(issue.number) || fallback.unreadable.has(issue.number)) return;
+ stats.fallbackCandidates = (stats.fallbackCandidates ?? 0) + 1;
+ try {
+ const bodies = await commentsFor(issue);
+ stats.fallbackProbed = (stats.fallbackProbed ?? 0) + 1;
+ fallback.comments.set(issue.number, bodies);
+ } catch (err) {
+ lastFallbackError = err;
+ fallback.unreadable.add(issue.number);
+ }
+ };
+ /** What H4 gets for a card: `undefined` unconsulted, `null` unreadable, else the bodies. */
+ const fallbackFor = (issue) =>
+ fallback.unreadable.has(issue.number) ? null : fallback.comments.get(issue.number);
+
+ // The `Restart-when:` comment fallback (#10403) — the same pattern, gated by
+ // `needsRestartWhenComments` and riding the same shared comment cache, so an
+ // on-hold card H17 fetches below costs no second request here. Its stats
+ // pair is separate because it answers for a different candidate set. No
+ // total-shortfall rethrow of its own, on H19/H20's grounds rather than the
+ // `Blocked-by:` fallback's: nothing goes QUIET on a failure here — every
+ // unreadable thread fires its own card's H9 row — and the all-holds-dark
+ // case is already fatal via H17's rethrow over the same fetches.
+ const restart = { comments: new Map(), unreadable: new Set() };
+ const gatherRestartWhenComments = async (issue) => {
+ if (restart.comments.has(issue.number) || restart.unreadable.has(issue.number)) return;
+ stats.restartCandidates = (stats.restartCandidates ?? 0) + 1;
+ try {
+ const bodies = await commentsFor(issue);
+ stats.restartProbed = (stats.restartProbed ?? 0) + 1;
+ restart.comments.set(issue.number, bodies);
+ } catch {
+ restart.unreadable.add(issue.number);
+ }
+ };
+ /** What H9 gets for a card: `undefined` unconsulted, `null` unreadable, else the bodies. */
+ const restartFor = (issue) =>
+ restart.unreadable.has(issue.number) ? null : restart.comments.get(issue.number);
+
+ for (const issue of seen.values()) {
+ const labels = labelNames(issue);
+ if (h1DispatchedNoAssignee(issue)) {
+ findings.push([issue, 'H1', '`pm:dispatched` with no assignee']);
+ }
+ if (h3QueueAndDispatched(issue)) {
+ findings.push([issue, 'H3', '`pm:queue` and `pm:dispatched` both present']);
+ }
+ // H24 + H25 — two field/label intersections over cards this loop already
+ // holds, so neither costs a request. H24's population is the `pm:queue`
+ // listing; H25's carriers are all listed too, either by the awaiting label
+ // page below or by the very state label they wrongly coexist with.
+ const queuedAndTaken = h24QueuedWithAssignee(issue);
+ if (queuedAndTaken) findings.push([issue, 'H24', queuedAndTaken]);
+ const doubleState = h25AwaitingMaintainerExclusivity(issue);
+ if (doubleState) findings.push([issue, 'H25', doubleState]);
+ // H4 — judged across BOTH channels. The fetch is gated by
+ // `needsBlockedByComments`, so it costs a request only for the body-clean
+ // cards whose verdict it can actually change (~2/3 of the blocked
+ // population by the 2026-08-19 census); a card whose body already carries
+ // the line is answered without touching the network, exactly as before.
+ if (needsBlockedByComments(issue)) await gatherBlockedByComments(issue);
+ const unblockedByNothing = h4BlockedNoBlockedBy(issue, fallbackFor(issue));
+ if (unblockedByNothing) findings.push([issue, 'H4', unblockedByNothing]);
+ // H9 — judged across BOTH channels since #10403, on the same gated-fetch
+ // trade as H4: a hold whose body already carries a fireable line is
+ // answered without the network; a body-clean one buys (at most) the one
+ // comment fetch H17 is about to make anyway, off the shared cache.
+ if (needsRestartWhenComments(issue)) await gatherRestartWhenComments(issue);
+ const restartless = h9OnHoldNoRestartWhen(issue, restartFor(issue));
+ if (restartless) findings.push([issue, 'H9', restartless]);
+ const staleP0 = h10StaleUnclaimedP0(issue);
+ if (staleP0) findings.push([issue, 'H10', staleP0]);
+ const parked = h11ImportantParked(issue);
+ if (parked) findings.push([issue, 'H11', parked]);
+ if (labels.includes('pm:seat')) {
+ const desync = h5SeatStickerDesync(issue);
+ if (desync) findings.push([issue, 'H5', desync]);
+ if (h6SeatBodyOversized(issue)) {
+ const kb = (Buffer.byteLength(issue.body ?? '', 'utf8') / 1024).toFixed(1);
+ findings.push([issue, 'H6', `seat body is ${kb} KB (soft bound ~10 KB) — compact to the six-section current-state template (#7583; edit history is the archive)`]);
+ }
+ } else if ((issue.assignees ?? []).length > 0 && labels.some((l) => l === 'pm:queue' || l === 'pm:dispatched')) {
+ // H2 needs the comment thread — fetched only for candidates (exactly the
+ // pm-tracked set h2 judges; the on-hold/p0 listings above must not buy
+ // comment fetches h2 would discard), and only their first pages: a claim
+ // comment is posted at claim time, so on a healthy card it is early in
+ // the thread; a >100-comment card with a late claim shows up as a
+ // finding the patrol then reads by hand.
+ const comments = await commentsFor(issue);
+ if (h2AssigneeNoClaimComment(issue, comments)) {
+ findings.push([issue, 'H2', 'assignee set but no claim comment on the thread']);
+ }
+ }
+
+ // H17 — the trigger-file index. Gathering only: the card's own body plus
+ // its hold comments, kept for the pure extraction the renderers consume.
+ // Fetched for open `pm:on-hold` cards ONLY (`h17NeedsComments`), which is
+ // the population the index speaks about, and never for the other label
+ // pages — the same candidate-gating trade H2 and H16 make.
+ //
+ // A failed fetch leaves ONE card out of the index and must not fail the
+ // sweep: every other item's findings are already gathered and worth
+ // printing, and the summary line's `read X of Y` is what states the gap.
+ if (hold && h17NeedsComments(issue)) {
+ hold.candidates += 1;
+ try {
+ const comments = await commentsFor(issue);
+ hold.probed += 1;
+ hold.entries.push({ issue, texts: [issue.body ?? '', ...comments] });
+ } catch (err) {
+ lastHoldError = err;
+ }
+ }
+ }
+
+ // …but if NO hold comment could be read at all, the index would render as
+ // "no open hold names a trigger file" — which is the 0-for-19 silence with a
+ // green face on it. That is the transport, not a clean board (#4690), so it
+ // is rethrown for the outer net to re-probe and classify. The predicate is
+ // H16's by name because that is where this judgement is documented; the
+ // shape is identical and deliberately shared rather than re-derived.
+ if (hold && h16DetailPassUnreadable(hold.candidates, hold.probed)) {
+ throw lastHoldError;
+ }
+
+ // H7 + H12 + H21 — the PR side. Listed straight from `/pulls` rather than
+ // filtered out of the label pages above: PRs carry no `pm:*` label, so the
+ // issue sweep cannot see them (it discards them explicitly). Drafts are
+ // INCLUDED for H7 and H21 — a draft is exactly where that is still cheap to
+ // fix, and both rows are only fixable while the PR is open, because the
+ // damage is done by the merge — and excluded by H12's own predicate (drafts
+ // are parked deliberately).
+ for (const pr of await listOpenPullRequests()) {
+ seenPrs.set(pr.number, pr);
+ const contradiction = h7PartOfWithClosingKeyword(pr);
+ if (contradiction) findings.push([pr, 'H7', contradiction]);
+ const negated = h21NegatedClosingKeyword(pr);
+ if (negated) findings.push([pr, 'H21', negated]);
+ const orphan = h12OrphanLanding(pr);
+ if (orphan) findings.push([pr, 'H12', orphan]);
+ }
+
+ // H16 — the only predicate here whose input no listing carries:
+ // `mergeable_state` lives on the single-PR endpoint alone. One GET per
+ // CANDIDATE (the gathering policy is `h16NeedsDetail`, which answers from
+ // the list row already in hand), so the cost is bounded by the stuck
+ // population rather than the open one, and only PRs this sweep already
+ // listed are ever fetched.
+ //
+ // A failed detail GET must NOT fail the sweep: every other item's findings
+ // are already gathered and are worth printing, and one unreadable PR is an
+ // unclassified row like any other unreadable reading here — it drops out of
+ // H16 and the summary line's `read X of Y` is what says so.
+ //
+ // But if NOTHING could be read, that is the transport rather than a clean
+ // board (#4690), so the last error is rethrown for the outer net to re-probe
+ // and classify. The distinction is the whole posture: "some rows unread" is
+ // a bounded gap the report states, while "no row readable" is a sweep whose
+ // H16 pass silently examined nothing and would otherwise print as quiet.
+ let lastDetailError = null;
+ for (const pr of seenPrs.values()) {
+ if (!h16NeedsDetail(pr)) continue;
+ stats.conflictCandidates = (stats.conflictCandidates ?? 0) + 1;
+ let detail;
+ try {
+ detail = await rest(`/repos/${OWNER_REPO}/pulls/${pr.number}`);
+ } catch (err) {
+ lastDetailError = err;
+ continue;
+ }
+ stats.conflictProbed = (stats.conflictProbed ?? 0) + 1;
+ const stuck = h16StuckMergeConflict(detail);
+ if (stuck) findings.push([pr, 'H16', stuck]);
+ }
+ if (h16DetailPassUnreadable(stats.conflictCandidates, stats.conflictProbed)) {
+ throw lastDetailError;
+ }
+
+ // H8 — one bounded merged-PR listing (window note at the helper), matched
+ // against the already-collected open `pm:dispatched` cards; no per-card fetch.
+ //
+ // The open-PR list is handed in alongside it (#10468). It is already in hand
+ // from the H7/H12/H21 pass above, so the half-delivered question costs no
+ // request — and without it this row prescribed a destructive label drop
+ // against cards whose remaining half was still open.
+ for (const pr of await listRecentlyMergedPullRequests()) seenMerged.set(pr.number, pr);
+ const mergedWindow = [...seenMerged.values()];
+ const openWindow = [...seenPrs.values()];
+ for (const issue of seen.values()) {
+ const stale = h8MergedPrStillDispatched(issue, mergedWindow, openWindow);
+ if (stale) findings.push([issue, 'H8', stale]);
+ }
+
+ // H22 — the one closed-issue read in this file (#10688). Kept in its own
+ // collection for the same reason H13's unscoped listing is: the open-only
+ // default of every other collector stays exactly as it was, and the summary
+ // line can say what this pass covered on its own terms.
+ for (const issue of await listRecentlyClosedIssues()) {
+ seenClosed.set(issue.number, issue);
+ const residue = h22ClosedCardPmResidue(issue);
+ if (residue) findings.push([issue, 'H22', residue]);
+ }
+
+ // H23 — the commit-message surface (#10942). The counting is not incidental:
+ // this row's measured yield is ~6 in 1,546, so a silent H23 is the normal
+ // reading, and the summary line's coverage numbers are the only thing that
+ // separates "read and clean" from "no message was read". They are gathered in
+ // the same walk as the verdicts so the two can never disagree.
+ //
+ // The finding row is keyed to the PR the squash marker names, because that is
+ // the artifact a reader searches for and it keeps this row shaped like the
+ // other PR-scoped rows (H7, H12, H16, H21). The LINK is the commit, which is
+ // the evidence. When a subject carries no marker (1 of 1,546 measured) the
+ // first contradicted card number stands in, so a row is never dropped for
+ // want of a number to sort by.
+ for (const commit of await listRecentDefaultBranchCommits()) {
+ const message = commit?.commit?.message ?? '';
+ stats.commits = (stats.commits ?? 0) + 1;
+ const bindings = closingKeywordTargets(message, { markdown: false });
+ if (bindings.size > 0) {
+ stats.commitBindings = (stats.commitBindings ?? 0) + bindings.size;
+ stats.commitBindingMessages = (stats.commitBindingMessages ?? 0) + 1;
+ }
+ const contradiction = h23CommitMessageContradiction(commit);
+ if (!contradiction) continue;
+ const pr = commitSubjectPrNumber(message);
+ const fallback = [...partOfTargets(message, { markdown: false })].find((n) => bindings.has(n));
+ findings.push([
+ { number: Number(pr ?? fallback ?? 0), html_url: commit?.html_url ?? '' },
+ 'H23',
+ contradiction,
+ ]);
+ }
+
+ // H13 — the one item whose population no label page can list (note at
+ // `listAllOpenIssues`). Kept out of `seen` so H1–H12 keep their exact
+ // inputs and the summary line stays honest about what each pass covered;
+ // the overlap with the label listings costs nothing (the predicate is
+ // label-gated and pure).
+ const unscoped = await listAllOpenIssues();
+ for (const issue of unscoped) {
+ seenUnscoped.set(issue.number, issue);
+ const halfState = h13DomainWithoutPmState(issue);
+ if (halfState) findings.push([issue, 'H13', halfState]);
+ // H18 — same population: `pm:retriage` can coexist with a label this
+ // sweep's label pages never fetch (e.g. `pm:blocking`) or with none at
+ // all, so only the unscoped listing is guaranteed to see every carrier.
+ const retriageAged = h18RetriageAged(issue);
+ if (retriageAged) findings.push([issue, 'H18', retriageAged]);
+ }
+
+ // H14 + H15 — the same unscoped listing, read a second way. It is the right
+ // population for BOTH halves and neither label page could substitute: a
+ // `pm:blocking` card need carry no other label (so the label pages above can
+ // miss the subject), and the `Blocked-by:` lines that judge it are written by
+ // cards of any label at all (so they can miss the evidence). No extra fetch:
+ // the bodies are already in hand, which is exactly the "derived from the same
+ // body reads the sweep already performs" this pair was specified as.
+ //
+ // …with ONE fetching pass in front of it, and it has to be here rather than
+ // in the label loop above: `pm:blocking` is not one of the labels that loop
+ // lists, so those cards are first visible in this listing. The gate is the
+ // same one H4 used, the cache is the same, and a card already gathered above
+ // is a no-op — so the union of the two passes is still at most one request
+ // per card.
+ for (const issue of unscoped) {
+ if (needsBlockedByComments(issue)) await gatherBlockedByComments(issue);
+ }
+ // Total failure is the transport, not a board where no card parks the line
+ // in a comment — the same #4690 judgement H16 and H17 make, and the same
+ // predicate, deliberately shared rather than re-derived. A PARTIAL shortfall
+ // is a bounded gap: it stays, and it costs H14's stale direction (below)
+ // plus a summary-line clause, rather than the sweep.
+ if (h16DetailPassUnreadable(stats.fallbackCandidates, stats.fallbackProbed)) {
+ throw lastFallbackError;
+ }
+
+ const blockingIndex = buildBlockingIndex(unscoped, { comments: fallback.comments });
+ const indexComplete = fallback.unreadable.size === 0;
+ for (const issue of unscoped) {
+ const incoherent = h14BlockingCacheIncoherent(issue, blockingIndex, { indexComplete });
+ if (incoherent) findings.push([issue, 'H14', incoherent]);
+ }
+
+ // One row, attached to the card it names — so it links, sorts and truncates
+ // exactly like every other row and neither renderer needs a special case.
+ const oldestBlocking = h15OldestUnclaimedBlocking(unscoped);
+ if (oldestBlocking) findings.push([oldestBlocking.issue, 'H15', oldestBlocking.message]);
+
+ // H20 — the dispatched card nobody is working. Two reads, both bounded by
+ // the `pm:dispatched` population rather than the board:
+ //
+ // • the comment thread, for the claim's branch and its timestamp. On the
+ // shared cache, so an assigned dispatched card — which H2 already
+ // fetched above — costs nothing here. The only new fetches are for
+ // UNASSIGNED dispatched cards, which are H1 findings in their own right
+ // and correspondingly rare.
+ // • ONE ref read per distinct claimed branch, cached, and taken only for
+ // candidates (`h20NeedsRefProbe`): a claim younger than the threshold is
+ // not stuck, so it buys no request and the row says nothing about it.
+ //
+ // Unreadable probes are NOT fatal, and that is the one place this pass
+ // deliberately differs from H16/H17/the comment fallback, which rethrow on a
+ // total shortfall. There, "nothing readable" makes the item read as CLEAN,
+ // so the sweep has to stop rather than print a green face on a silence.
+ // Here it is the opposite: every unreadable ref fires its own card's quieter
+ // row, so a pass that read nothing is the loudest possible output rather
+ // than the quietest, and killing an otherwise-gathered sweep would trade a
+ // whole report for a fact the rows already state. H19 makes the same call.
+ const refCache = new Map();
+ const resolveBranchRef = async (branch) => {
+ const cached = refCache.get(branch);
+ if (cached) return cached;
+ // `/branches/` is an EXACT-match lookup and a 404 there is the
+ // healthy-negative H20 is built on — not an error. The branch is
+ // segment-encoded rather than whole-encoded because the slashes in
+ // `claude/issue--` are path separators to this endpoint.
+ //
+ // ⭐ Why this endpoint rather than `/git/ref/heads/`, which H20
+ // asked originally: H27 (#11248) needs the head commit's DATE, and this
+ // response already carries it while the ref response carries only a sha —
+ // reading it there would cost a second request per branch. Both endpoints
+ // resolve the SAME underlying ref, so existence is answered identically
+ // and H20's three states are untouched; the extra field rides in on a
+ // payload the sweep had already paid for. That is H26's own "FREE" shape,
+ // and it keeps the request COUNT of this pass exactly as it was.
+ const path = `/repos/${OWNER_REPO}/branches/${branch.split('/').map(encodeURIComponent).join('/')}`;
+ let resolved;
+ try {
+ const row = await rest(path);
+ resolved = {
+ state: 'exists',
+ detail: null,
+ // Missing rather than null-coalesced to a date: an absent field must
+ // reach the predicate as "unknown" so it declines to judge, never as a
+ // timestamp that happens to compare false (#4690).
+ headCommittedAt: row?.commit?.commit?.committer?.date ?? null,
+ };
+ } catch (err) {
+ resolved =
+ err?.status === 404
+ ? { state: 'absent', detail: null, headCommittedAt: null }
+ : {
+ state: 'unreadable',
+ detail: err?.status ? `HTTP ${err.status}` : 'unreadable',
+ headCommittedAt: null,
+ };
+ }
+ refCache.set(branch, resolved);
+ return resolved;
+ };
+
+ for (const issue of seen.values()) {
+ if (!labelNames(issue).includes('pm:dispatched')) continue;
+ const claim = governingClaim(await commentRowsFor(issue));
+ if (!h20NeedsRefProbe(issue, claim)) continue;
+ const states = [];
+ for (const branch of claim.branches) states.push({ branch, ...(await resolveBranchRef(branch)) });
+ const undispatched = h20DispatchedNoBranchRef(issue, claim, states);
+ if (undispatched) findings.push([issue, 'H20', undispatched]);
+ // H27 — the same claim, the same ref states, the OTHER question: not "was
+ // this ever dispatched" but "is the thing that was dispatched still alive".
+ // Its gathering gate (24h) is a strict subset of H20's (60 min), so every
+ // card it can speak about has already been probed above and it adds NO
+ // request of its own. Delivery is read from the two PR windows this sweep
+ // already holds — H8's inputs, through H8's own delivery relation.
+ if (!h27NeedsClaimLivenessRead(issue, claim)) continue;
+ const deadClaim = h27DeadClaimNoProgress(
+ issue,
+ claim,
+ states,
+ claimDelivery(issue.number, openWindow, mergedWindow),
+ );
+ if (deadClaim) findings.push([issue, 'H27', deadClaim]);
+ }
+ // Distinct branches, which is the unit the cache and the request count are
+ // in — and the word is in the summary sentence for the same reason it is in
+ // H19's, so the number cannot be misread as a per-card count.
+ stats.dispatchRefTargets = refCache.size;
+ stats.dispatchRefRead = [...refCache.values()].filter((r) => r.state !== 'unreadable').length;
+
+ // H19 — blocker liveness. Last, because it is the only pass that reads a
+ // card this sweep did not list: every other item answers from a listing
+ // already in hand, while "is the target still open" is a fact about an
+ // issue that, if the answer is the interesting one, is CLOSED and therefore
+ // in no open listing by construction.
+ //
+ // ## The shortcut that makes the cost proportional to the FINDINGS
+ //
+ // A target already present in an open listing this sweep took is open —
+ // positive evidence, free, no request. ABSENCE is not evidence of closure
+ // (the listings are capped, and PRs are filtered out of them), so an absent
+ // target is FETCHED rather than assumed. The asymmetry is the whole cost
+ // story: a healthy block names an open card and costs nothing, so the
+ // request count is bounded by the number of expired blocks plus the
+ // cross-repo refs — the population the row is about — rather than by the
+ // number of blocked cards.
+ //
+ // One request per DISTINCT target, cached across cards: several cards
+ // waiting on one epic is the normal shape, and it costs one read.
+ //
+ // The map holds the ISSUE, not just its number, because H26 (#11219) asks a
+ // second question of the same target — is it parked in a state that can never
+ // close? — and the answer is a field the payload already carried. Free by
+ // construction: a locally-open target is answered from a listing in hand, and
+ // a fetched one arrives with its labels on the same response. Nothing here
+ // adds a request; the resolution rows simply stop discarding the labels.
+ const openLocalIssues = new Map();
+ for (const [number, issue] of seenUnscoped) openLocalIssues.set(number, issue);
+ for (const [number, issue] of seen) openLocalIssues.set(number, issue);
+ const blockerCache = new Map();
+ const resolveBlockerTarget = async (target) => {
+ const cached = blockerCache.get(target.key);
+ if (cached) return cached;
+ let resolved;
+ const localOpen = target.local ? openLocalIssues.get(target.number) : undefined;
+ if (localOpen) {
+ resolved = { ...target, state: 'open', closedAt: null, detail: null, labels: labelNames(localOpen) };
+ } else {
+ try {
+ const row = await rest(`/repos/${target.repo}/issues/${target.number}`);
+ resolved = {
+ ...target,
+ state: row.state === 'closed' ? 'closed' : 'open',
+ closedAt: row.closed_at ?? null,
+ detail: null,
+ labels: labelNames(row),
+ };
+ } catch (err) {
+ // Per-target, never fatal — and deliberately NOT the rethrow H16/H17
+ // and the comment fallback make on a total shortfall. Those passes
+ // read only THIS repo with a credential that either works or does
+ // not, so "nothing readable" there really is the transport. H19's
+ // candidates include refs to sibling repos this credential may quite
+ // legitimately not be able to read, so a zero-resolved pass is a
+ // possible HEALTHY reading here and must not kill a sweep whose other
+ // items are already gathered. The #4690 duty is discharged louder
+ // instead: every unresolved target fires its own card's row.
+ resolved = {
+ ...target,
+ state: 'unresolved',
+ closedAt: null,
+ detail: err?.status ? `HTTP ${err.status}` : 'unreadable',
+ };
+ }
+ }
+ blockerCache.set(target.key, resolved);
+ return resolved;
+ };
+
+ for (const issue of seen.values()) {
+ if (!needsBlockerLiveness(issue)) continue;
+ const resolutions = [];
+ for (const target of blockerTargetsFor(issue, fallbackFor(issue))) {
+ resolutions.push(await resolveBlockerTarget(target));
+ }
+ const expired = h19BlockOutlivedBlocker(issue, resolutions);
+ if (expired) findings.push([issue, 'H19', expired]);
+ // H26 — the same resolutions, asked the OTHER question: not "has the target
+ // closed" but "can it ever". Both rows can fire on one card (a two-target
+ // block where one blocker closed and the other is parked indefinitely), and
+ // they must: they name different halves of the same wait and prescribe
+ // different reads.
+ const indefinite = h26BlockOnIndefiniteTarget(issue, resolutions);
+ if (indefinite) findings.push([issue, 'H26', indefinite]);
+ }
+ // Distinct targets, which is the unit the cache and the request count are
+ // in — and the word is in the summary sentence so the number cannot be read
+ // as a per-card edge count.
+ stats.blockerTargets = blockerCache.size;
+ stats.blockerResolved = [...blockerCache.values()].filter((r) => r.state !== 'unresolved').length;
+}
+
+// ---------------------------------------------------------------------------
+// Self-test — predicates and the transport classifier; no network.
+// ---------------------------------------------------------------------------
+
+function selfTest() {
+ const cases = [];
+ const t = (name, actual, expected) => cases.push([name, actual, expected]);
+ const issue = (labels, assignees = [], body = '', title = '') => ({
+ labels: labels.map((name) => ({ name })),
+ assignees: assignees.map((login) => ({ login })),
+ body,
+ title,
+ });
+
+ t('H1: dispatched + no assignee -> finding', h1DispatchedNoAssignee(issue(['pm:dispatched'])), true);
+ t('H1: dispatched + assignee -> clean', h1DispatchedNoAssignee(issue(['pm:dispatched'], ['os-help'])), false);
+ t('H2: assignee + no claim comment -> finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['looks good', 'triage: routed']), true);
+ t('H2: assignee + claim comment -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Claim: PM loop round 3\nSession: session_x']), false);
+ t('H2: unassigned card is out of scope', h2AssigneeNoClaimComment(issue(['pm:queue']), []), false);
+ // #7488: SKILL.md step 4's claim template IS a blockquote, so the documented
+ // shape must read as a claim. Live specimen: #6752's "> Claim: PM loop wave 9".
+ t('H2: blockquote claim comment (the documented shape) -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['> Claim: PM loop wave 9 (seat #6019)\n> Session: `session_x`\n> Branch: `claude/issue-6752-x`']), false);
+ t('H2: indented blockquote claim -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), [' > Claimed: PM loop round 3']), false);
+ // …and the strictness the relaxation must NOT cost: the line still has to
+ // BEGIN with the word, blockquote or not (#7488's explicit width limit).
+ t('H2: prose containing the word claim -> still a finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Nobody will claim: this card is ready\nthe seat did not claim it']), true);
+ t('H2: blockquoted prose containing claim -> still a finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['> the next seat should claim: only after the ruling lands']), true);
+ t('H3: both queue labels -> finding', h3QueueAndDispatched(issue(['pm:queue', 'pm:dispatched'])), true);
+ t('H3: dispatched alone -> clean', h3QueueAndDispatched(issue(['pm:dispatched'])), false);
+ // H4 — the label gate and the BODY channel, unchanged by the two-channel
+ // read: a caller that does not consult comments gets the reading it always
+ // got, and the sentence it gets claims nothing about a channel nobody read.
+ t('H4: blocked without body line -> finding', typeof h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')), 'string');
+ t('H4: blocked with Blocked-by line -> clean', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'Blocked-by: #123')), null);
+ t('H4: unblocked card is out of scope', h4BlockedNoBlockedBy(issue([], [], '')), null);
+ t('H4: …and an unconsulted comment channel is not claimed as empty', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')).includes('NEITHER channel'), false);
+ t('H4: the body-only sentence still names the unlock sweep as the stake', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')).includes('unlock sweep greps'), true);
+
+ // H4 — the COMMENT channel (#8941 / #10061). Four shapes, positive and
+ // negative, plus the unreadable one that is neither.
+ t('H4: body clean but a comment carries the line -> clean', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream'), ['triage note', 'Blocked-by: #9465']), null);
+ t('H4: body line AND a comment line (the union shape) -> clean', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'Blocked-by: #123'), ['Blocked-by: #9465']), null);
+ t('H4: neither channel -> finding', typeof h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream'), ['triage note', 'graded p2']), 'string');
+ t('H4: …and the sentence names BOTH channels', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), ['nothing here']).includes('NEITHER channel'), true);
+ t('H4: …and says a comment discharges the duty too', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), ['nothing here']).includes('Either channel discharges'), true);
+ t('H4: an empty comment thread is a real reading, not an unconsulted one', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), []).includes('NEITHER channel'), true);
+ // Unreadable is neither of the two: the row FIRES (a transport failure must
+ // not shrink the patrol below its pre-fallback reach) and says why.
+ t('H4: an UNREADABLE comment thread still fires', typeof h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), null), 'string');
+ t('H4: …but never claims the second channel is empty', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), null).includes('NEITHER channel'), false);
+ t('H4: …and says the thread could not be read', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), null).includes('could'), true);
+ t('H4: …citing the unreadable-is-not-absent rule', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), null).includes('#4690'), true);
+ // A comment line clears H4 whatever the ref says: H4's question is "did the
+ // author leave the machine anything", which a cross-repo blocker answers.
+ t('H4: a cross-repo comment line still discharges the duty', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), ['Blocked-by: objectstack-ai/objectui#4356']), null);
+ t('H4: a valueless comment line does NOT (nothing follows the key)', typeof h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), ['Blocked-by:']), 'string');
+ t('H4: a mid-sentence mention in a comment is not a line', typeof h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting'), ['seats park the Blocked-by: #1 line in comments']), 'string');
+ t('H4: the label gate outranks the comment channel', h4BlockedNoBlockedBy(issue(['pm:queue'], [], 'waiting'), ['nothing']), null);
+
+ // The real measured comment, byte-for-byte from #9828's triage backfill —
+ // the shape the fallback exists to read (a `Blocked-by:` line on its own,
+ // wrapped in ordinary prose above and below).
+ const liveBackfillComment =
+ 'Triage backfill (machine-readable index line; the prose "Unblock when: epic #9465 completes" ' +
+ 'was invisible to the unlock scan\'s grep):\n\nBlocked-by: #9465\n\nNo state change — the epic ' +
+ 'is still open (4/5 sub-issues done); this line only makes the existing wait scannable.';
+ t('H4: the measured #9828 backfill comment clears the card', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'body carries no line'), [liveBackfillComment]), null);
+
+ // The gathering policy — what gets READ AT ALL.
+ t('gate: a body-clean pm:blocked card is a candidate', needsBlockedByComments(issue(['pm:blocked'], [], 'no line here')), true);
+ t('gate: a body-clean pm:blocking card is a candidate', needsBlockedByComments(issue(['pm:blocking'], [], 'no line here')), true);
+ t('gate: a pm:blocked card whose body already carries the line is NOT', needsBlockedByComments(issue(['pm:blocked'], [], 'Blocked-by: #1')), false);
+ t('gate: a pm:blocking card whose body already carries the line is NOT', needsBlockedByComments(issue(['pm:blocking'], [], 'Blocked-by: #1')), false);
+ t('gate: an ordinary queued card buys no fetch', needsBlockedByComments(issue(['pm:queue'], [], 'no line here')), false);
+ t('gate: an on-hold card buys no fetch from THIS item', needsBlockedByComments(issue(['pm:on-hold'], [], 'no line here')), false);
+ t('gate: a missing issue does not crash', needsBlockedByComments(undefined), false);
+
+ // The comment-side ref extractor — the index's reader of the same channel.
+ t('commentBlockedByTargets: one line across two comments', commentBlockedByTargets(['prose', 'Blocked-by: #7']).map((r) => r.number).join(','), '7');
+ t('commentBlockedByTargets: refs from several comments accumulate', commentBlockedByTargets(['Blocked-by: #7', 'Blocked-by: #8']).map((r) => r.number).join(','), '7,8');
+ t('commentBlockedByTargets: no comments at all', commentBlockedByTargets(undefined).length, 0);
+ t('commentBlockedByTargets: a cross-repo ref keeps its qualifier for filtering', commentBlockedByTargets(['Blocked-by: objectstack-ai/objectui#4356'])[0].repo, 'objectstack-ai/objectui');
+
+ // -- Decorated directive lines (#10102) ------------------------------------
+ // The shared value reader first, because it is the only place the CLEANING
+ // step is observable: H9 reports a sentence and the index reports refs, so a
+ // value that silently kept its trailing backtick would pass both while being
+ // wrong. Every decoration gets a positive AND a negative case.
+ const dv = (text, key = 'Restart-when') => directiveValues(text, key);
+ t('directiveValues: the bare line is unchanged', dv('Restart-when: closed acme/w#9').join('|'), 'closed acme/w#9');
+ // #9591, byte-for-byte in shape: the whole line wrapped in inline code.
+ t('directiveValues: backtick-wrapped line yields a CLEAN value', dv('`Restart-when: closed acme/w#9`').join('|'), 'closed acme/w#9');
+ t('directiveValues: `-` bullet', dv('- Restart-when: closed acme/w#9').join('|'), 'closed acme/w#9');
+ t('directiveValues: `*` bullet', dv('* Restart-when: closed acme/w#9').join('|'), 'closed acme/w#9');
+ t('directiveValues: indented bullet', dv(' - Restart-when: closed acme/w#9').join('|'), 'closed acme/w#9');
+ t('directiveValues: bold around the whole directive', dv('**Restart-when: closed acme/w#9**').join('|'), 'closed acme/w#9');
+ t('directiveValues: bold around the key, colon inside', dv('**Restart-when:** closed acme/w#9').join('|'), 'closed acme/w#9');
+ t('directiveValues: bold around the key, colon outside', dv('**Restart-when**: closed acme/w#9').join('|'), 'closed acme/w#9');
+ t('directiveValues: code around the key only', dv('`Restart-when:` closed acme/w#9').join('|'), 'closed acme/w#9');
+ t('directiveValues: bullet AND code together', dv('- `Restart-when: closed acme/w#9`').join('|'), 'closed acme/w#9');
+ t('directiveValues: bold AND code nested', dv('**`Restart-when: closed acme/w#9`**').join('|'), 'closed acme/w#9');
+ t('directiveValues: a decorated line mid-body is found', dv('Prose.\n> quote\n`Restart-when: closed acme/w#9`\nMore.').join('|'), 'closed acme/w#9');
+ t('directiveValues: two decorated lines both count', dv('- Restart-when: manual — x\n`Restart-when: closed acme/w#9`').join('|'), 'manual — x|closed acme/w#9');
+ // "Matching" is the whole rule: a value that ends in a backtick on a line
+ // that opened BARE keeps it, or the reader starts editing values it was
+ // only asked to read.
+ t('directiveValues: an UNMATCHED trailing backtick is kept', dv('Restart-when: run `npm view x`').join('|'), 'run `npm view x`');
+ t('directiveValues: …and an unmatched trailing bold is kept', dv('Restart-when: closed **acme/w#9**').join('|'), 'closed **acme/w#9**');
+ // Negatives — what decoration tolerance must NOT buy.
+ t('directiveValues: a backtick mid-line does not start a match', dv('we should add a `Restart-when: closed acme/w#9` line').length, 0);
+ t('directiveValues: prose before a bullet does not either', dv('see: - Restart-when: closed acme/w#9').length, 0);
+ t('directiveValues: a lowercase key stays invisible, decorated or not', dv('`restart-when: closed acme/w#9`').length, 0);
+ t('directiveValues: a bullet needs its space (`-Restart-when:` is a word)', dv('-Restart-when: closed acme/w#9').length, 0);
+ t('directiveValues: a decorated valueless line has no value', dv('`Restart-when:`').length, 0);
+ t('directiveValues: …nor one padded with a space', dv('`Restart-when: `').length, 0);
+ t('directiveValues: …nor a bolded valueless one', dv('**Restart-when:**').length, 0);
+ t('directiveValues: the bare valueless line is unchanged', dv('Restart-when:').length, 0);
+ t('directiveValues: the key selects (a Blocked-by line is not a Restart-when)', dv('`Blocked-by: #9612`').length, 0);
+ t('directiveValues: …and the same reader serves Blocked-by', dv('`Blocked-by: #9612`', 'Blocked-by').join('|'), '#9612');
+
+ // H4 — the decorated line clears the duty, in EITHER channel. #10063 is the
+ // measured body specimen: 「`Blocked-by: #9612`」 read as absent, and since
+ // the unlock sweep greps the same literal, nothing could ever have returned
+ // the card — the silent half of this defect.
+ const blocked = (body, comments) => h4BlockedNoBlockedBy(issue(['pm:blocked'], [], body), comments);
+ t('H4: the #10063 shape — a backticked BODY line -> clean', blocked('`Blocked-by: #9612`'), null);
+ t('H4: a backticked COMMENT line -> clean', blocked('waiting', ['`Blocked-by: #9465`']), null);
+ t('H4: a bulleted body line -> clean', blocked('- Blocked-by: #9612'), null);
+ t('H4: a `*`-bulleted body line -> clean', blocked('* Blocked-by: #9612'), null);
+ t('H4: a bolded body line -> clean', blocked('**Blocked-by:** #9612'), null);
+ t('H4: bold around the whole line -> clean', blocked('**Blocked-by: #9612**'), null);
+ t('H4: code around the key only -> clean', blocked('`Blocked-by:` #9612'), null);
+ t('H4: a bulleted, bolded comment line -> clean', blocked('waiting', ['- **Blocked-by:** #9465']), null);
+ // …and the negatives, which are the same list read the other way.
+ t('H4: a decorated VALUELESS line does not discharge the duty', typeof blocked('`Blocked-by:`'), 'string');
+ t('H4: …nor a bolded valueless one', typeof blocked('**Blocked-by:**'), 'string');
+ t('H4: …nor a decorated valueless COMMENT line', typeof blocked('waiting', ['`Blocked-by:`']), 'string');
+ t('H4: a decorated lowercase key is still invisible', typeof blocked('`blocked-by: #9612`'), 'string');
+ t('H4: a mid-sentence decorated mention is still prose', typeof blocked('seats park the `Blocked-by: #1` line in comments'), 'string');
+ // The value-in-code shape already cleared H4 before this change (its first
+ // value character is a backtick, and H4 asks only whether SOMETHING follows
+ // the key); pinned so the new "not just decoration" clause cannot take it.
+ t('H4: a value wrapped in code still discharges the duty', blocked('Blocked-by: `#9612`'), null);
+
+ // The index side of the same lines — H4 asking "is there a line" and the
+ // index asking "which card" must agree about what a line IS.
+ t('blockedByTargets: the #10063 backticked line names its blocker', blockedByTargets('`Blocked-by: #9612`').map((r) => r.number).join(','), '9612');
+ t('blockedByTargets: a bulleted line', blockedByTargets('- Blocked-by: #9823').map((r) => r.number).join(','), '9823');
+ t('blockedByTargets: a bolded key (the `**` must not reach the ref walk)', blockedByTargets('**Blocked-by:** #9823').map((r) => r.number).join(','), '9823');
+ t('blockedByTargets: bold around the whole line', blockedByTargets('**Blocked-by: #9823**').map((r) => r.number).join(','), '9823');
+ // `.map(...).join()` rather than `[0].repo`: an assertion that THROWS when
+ // the reader returns nothing aborts the whole self-test, hiding every case
+ // after it — which is precisely what a regression in this reader produces.
+ t('blockedByTargets: a decorated cross-repo ref keeps its qualifier', blockedByTargets('`Blocked-by: objectstack-ai/objectui#4356`').map((r) => r.repo).join(','), 'objectstack-ai/objectui');
+ t('blockedByTargets: a decorated comma run is all blockers', blockedByTargets('- `Blocked-by: #6234, #6245`').map((r) => r.number).join(','), '6234,6245');
+ t('blockedByTargets: a mid-sentence decorated mention yields nothing', blockedByTargets('seats park the `Blocked-by: #1` line in comments').length, 0);
+ t('blockedByTargets: a decorated lowercase key yields nothing', blockedByTargets('`blocked-by: #9612`').length, 0);
+ t('commentBlockedByTargets: a decorated comment line is a real edge', commentBlockedByTargets(['prose', '`Blocked-by: #7`']).map((r) => r.number).join(','), '7');
+ t('H5: 🟢 login matching assignee -> clean', h5SeatStickerDesync(issue(['pm:seat'], ['os-zhuang'], '', '[PM seat] domain:devx — 🟢 os-zhuang')), null);
+ t('H5: 🟢 login without assignee -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:devx — 🟢 os-zhuang')), 'string');
+ t('H5: ⏳ vacant with assignee -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], ['os-help'], '', '[PM seat] domain:cli — ⏳ vacant')), 'string');
+ t('H5: ⏳ vacant clean', h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:cli — ⏳ vacant')), null);
+ // #9926: the login-extraction fix. Both named shapes from the ruling.
+ t(
+ 'H5: 🟢 login with (session_…) parenthetical, matching assignee -> clean',
+ h5SeatStickerDesync(issue(['pm:seat'], ['os-x'], '', '[PM seat] domain:x — 🟢 os-x (session_abc123)')),
+ null,
+ );
+ t(
+ 'H5: 🟢 login with (session_…) parenthetical, no assignee -> finding',
+ typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:x — 🟢 os-x (session_abc123)')),
+ 'string',
+ );
+ // Reverse verification against the six titles pinned by the anchor sweep
+ // (#9857, run 32229942288) — four measured false positives, then the two
+ // true positives, predicted direction first: clean, clean, clean, clean,
+ // finding, finding.
+ t(
+ 'H5 reverse-verify: #7623 (os-warren, consistent) -> clean',
+ h5SeatStickerDesync(issue(['pm:seat'], ['os-warren'], '', '[PM seat] skills — 🟢 os-warren (session_01AeA3nU1B5Q2pgxqxgUrexd)')),
+ null,
+ );
+ t(
+ 'H5 reverse-verify: #6017 (os-elon, consistent) -> clean',
+ h5SeatStickerDesync(issue(['pm:seat'], ['os-elon'], '', '[PM seat] domain:spec — 🟢 os-elon (session_016D9wdJR14KKCxz1WgdAzcw)')),
+ null,
+ );
+ t(
+ 'H5 reverse-verify: #6026 (os-zhuang, consistent) -> clean',
+ h5SeatStickerDesync(issue(['pm:seat'], ['os-zhuang'], '', '[PM seat] repo:cloud — 🟢 os-zhuang (session_0137TnZzVmkSjXxoSVgPFS6S)')),
+ null,
+ );
+ t(
+ 'H5 reverse-verify: #9831 (os-warren, consistent) -> clean',
+ h5SeatStickerDesync(issue(['pm:seat'], ['os-warren'], '', '[PM seat] repo:objectos — 🟢 os-warren (session_01DXBoKN4MauvPdbMemPMqpr)')),
+ null,
+ );
+ t(
+ 'H5 reverse-verify: #6367 (no assignee, · title suffix) -> finding',
+ typeof h5SeatStickerDesync(
+ issue(['pm:seat'], [], '', '[PM seat] domain:engine — 🟢 os-elon (session_019yDEhPBC3tcGkW9bkce1HM) · 在飞 1 · 队列 0'),
+ ),
+ 'string',
+ );
+ t(
+ 'H5 reverse-verify: #6024 (no assignee, session id with no login) -> finding',
+ typeof h5SeatStickerDesync(
+ issue(
+ ['pm:seat'],
+ [],
+ '',
+ '[PM seat] domain:cli — 🟢 session_01WeN7F6jQFpcqW2BN56RdPa · 在飞 2 · 队列 1(串行等位) · 决策箱 1 · 正文 2026-08-19 04:1xZ',
+ ),
+ ),
+ 'string',
+ );
+ t('H5: Routine seat needs no assignee', h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] 分诊 — 🟢 Routine')), null);
+ t('H5: unparseable title -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', 'devx seat registry')), 'string');
+ t('H6: seat body over the soft bound -> finding', h6SeatBodyOversized(issue(['pm:seat'], [], 'x'.repeat(10_001), '[PM seat] domain:devx — ⏳ vacant')), true);
+ t('H6: seat body at the bound -> clean', h6SeatBodyOversized(issue(['pm:seat'], [], 'x'.repeat(10_000), '[PM seat] domain:devx — ⏳ vacant')), false);
+ // Byte length, not code points: multi-byte bodies trip the bound at the same
+ // byte size the read-limit failure cares about (3 bytes per CJK char).
+ t('H6: multi-byte body measured in bytes', h6SeatBodyOversized(issue(['pm:seat'], [], '账'.repeat(3_400), '[PM seat] domain:devx — ⏳ vacant')), true);
+ t('H6: oversized body without pm:seat is out of scope', h6SeatBodyOversized(issue(['pm:queue'], [], 'x'.repeat(20_000), 'big card')), false);
+
+ // -- H7: `Part of` contradicted by a closing keyword (#8293) ---------------
+ // Every fixture below is a REAL body from the incident or from the open PRs
+ // at the time this landed, so the predicate is pinned against the shapes the
+ // protocol actually produces rather than against invented ones.
+ const pr = (body) => ({ body });
+
+ // Specimen 1 — PR #8277, the body that closed #8131. Its SECOND sentence, the
+ // one written to prevent the auto-close, is what performed it: `close #8131`.
+ const pr8277 = pr(
+ 'Part of #8131\n\n' +
+ '⚠️ **Deliberately `Part of` and not `Fixes`.** This closes the card’s §1 only. ' +
+ 'Its §2 is out of this card’s declared file surface and is the surface of the in-flight #8136. ' +
+ 'Merging this must not auto-close a card with that half unaddressed; ' +
+ 'the PM should close #8131 deliberately once #8136 lands.',
+ );
+ t('H7: the #8277 specimen is a finding', typeof h7PartOfWithClosingKeyword(pr8277), 'string');
+ t('H7: …and it names the card it will close', h7PartOfWithClosingKeyword(pr8277).includes('Part of #8131'), true);
+ // The measurement that refutes the sidebar hypothesis: the SAME body names
+ // #8136 one clause later with no keyword, and #8136 took no closing link.
+ // The predicate must reproduce that asymmetry, not blanket-flag both numbers.
+ t('H7: …and does NOT implicate #8136 from the same sentence', h7PartOfWithClosingKeyword(pr8277).includes('#8136'), false);
+
+ // Specimen 2 — PR #8261 (`Part of #8103`), the same round's other partial
+ // delivery, which stayed open. No keyword anywhere near its number.
+ t(
+ 'H7: the #8261 specimen (Part of, no keyword) is clean',
+ h7PartOfWithClosingKeyword(
+ pr(
+ 'Part of #8103 — the **non-destructive half** only. The deletion half stays open ' +
+ 'and is being decided on #8259, which this PR does not address.',
+ ),
+ ),
+ null,
+ );
+
+ // Specimen 3 — open PR #8454. Its only keyword sits in an inline code span,
+ // and #8284 carries NO closing link: measured, and the reason the predicate
+ // strips code. Flagging this would punish the careful author.
+ t(
+ 'H7: the #8454 specimen (keyword inside backticks) is clean',
+ h7PartOfWithClosingKeyword(
+ pr(
+ 'Part of #8284\n\n⚠️ **Deliberately `Part of`, not `Fixes`** — the dispatch asked for ' +
+ '`Fixes #8284`, and this PR does not close it: one of the card’s two acceptance pins ' +
+ 'does not invert. Merging this and closing #8284 would drop the severe half on the floor.',
+ ),
+ ),
+ null,
+ );
+ // …and the same body proves `closing` is not a closing keyword. GitHub's list
+ // is close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved — the
+ // gerunds are not on it, and they are everywhere in this prose.
+ t(
+ 'H7: "closing #N" is not a closing keyword',
+ h7PartOfWithClosingKeyword(pr('Part of #8284\n\nMerging this and closing #8284 would drop the severe half.')),
+ null,
+ );
+ t(
+ 'H7: "fixing #N" is not a closing keyword either',
+ h7PartOfWithClosingKeyword(pr('Part of #900\n\nfixing #900 needs another round')),
+ null,
+ );
+
+ // Specimen 4 — open PR #8471: `Part of #8247` AND a keyword bound to #8245.
+ // Two different cards, so no contradiction. The binding is per number.
+ t(
+ 'H7: Part of #A with Fixes #B (the #8471 shape) is clean',
+ h7PartOfWithClosingKeyword(pr('Part of #8247\n\nFixes #8245 as the actionable half.')),
+ null,
+ );
+ t(
+ 'H7: Part of #A with Fixes #A on separate lines is a finding',
+ typeof h7PartOfWithClosingKeyword(pr('Part of #8247\n\nFixes #8247')),
+ 'string',
+ );
+
+ // The parser ignores negation and modals — that is the whole incident.
+ t(
+ 'H7: a NEGATED closing sentence still counts',
+ typeof h7PartOfWithClosingKeyword(pr('Part of #77\n\nThis does not fix #77.')),
+ 'string',
+ );
+ t('H7: colon form `Closes: #N`', typeof h7PartOfWithClosingKeyword(pr('Part of #77\n\nCloses: #77')), 'string');
+ t('H7: case-insensitive', typeof h7PartOfWithClosingKeyword(pr('part of #77\n\nRESOLVED #77')), 'string');
+ // A PR with no `Part of` declaration is out of scope entirely: `Fixes #N` on
+ // its own is the normal, correct full-delivery shape.
+ t('H7: plain `Fixes #N` with no Part of is out of scope', h7PartOfWithClosingKeyword(pr('Fixes #77')), null);
+ t('H7: empty / missing body', h7PartOfWithClosingKeyword(pr(undefined)), null);
+
+ // stripMarkdownCode — the step reading 4 forced.
+ t('strip: inline span is blanked', stripMarkdownCode('a `Fixes #1` b').includes('#1'), false);
+ t('strip: prose outside spans survives', stripMarkdownCode('a `x` Fixes #1').includes('#1'), true);
+ t(
+ 'strip: fenced block is blanked',
+ stripMarkdownCode('Part of #2\n\n```\nFixes #2\n```\n').includes('Fixes #2'),
+ false,
+ );
+ t(
+ 'strip: tilde fence is blanked',
+ stripMarkdownCode('~~~md\nFixes #2\n~~~').includes('Fixes #2'),
+ false,
+ );
+ t(
+ 'strip: text after a closed fence survives',
+ stripMarkdownCode('```\nquoted\n```\nFixes #3').includes('Fixes #3'),
+ true,
+ );
+ // Blanking keeps line structure, so nothing is spliced across a stripped
+ // block into a match that was never adjacent in the source.
+ t(
+ 'strip: no splicing across a stripped fence',
+ h7PartOfWithClosingKeyword(pr('Part of #4\n\nclose\n```\nx\n```\n#4')),
+ null,
+ );
+ t('H7: a fenced-only keyword is not a finding', h7PartOfWithClosingKeyword(pr('Part of #5\n\n```\nFixes #5\n```')), null);
+
+ // -- H21: a closing keyword inside a sentence that negates it (#10392) -----
+ // The positive fixture is the REAL specimen sentence, byte-for-byte from PR
+ // #10241's body, and the negative fixtures are real sentences from the same
+ // 300-body corpus the stage-1 measurement read — including the two that are
+ // the specimen's wording almost exactly and must stay clean.
+
+ // ★ Specimen — PR #10241 (merged 2026-08-20T15:10:06Z). No `Part of`
+ // anywhere in the body; #10240 closed `completed` two seconds later.
+ const pr10241 = pr(
+ '## Out of scope\n\n' +
+ 'Filed, not fixed: #10240 — the same leak through the **delete** verb. ' +
+ '`beforeDelete`→`afterDelete` hands ids over on the context stash, which the ' +
+ 'measurement above shows is lost on the predicate path.',
+ );
+ const fired21 = h21NegatedClosingKeyword(pr10241);
+ t('H21: the #10241 specimen FIRES', typeof fired21, 'string');
+ t('H21: …and names the card it will close', fired21.includes('`fixed #10240`'), true);
+ t('H21: …and quotes the offending sentence back', fired21.includes('Filed, not fixed: #10240'), true);
+ t('H21: …and says the parser ignores the negation', fired21.includes('negations included'), true);
+ t('H21: …and offers the safe rewordings', fired21.includes('#10240 is not addressed here'), true);
+ // H7 is silent on this body — the gap that made the row necessary. If this
+ // ever inverts, H21 is redundant rather than merely quiet.
+ t('H21: …and H7 is silent on it (the gap this row exists for)', h7PartOfWithClosingKeyword(pr10241), null);
+
+ // The window. `not` and `filed` both fire on the specimen; each marker was
+ // measured alone against both corpora.
+ t('H21: a bare negated close fires', typeof h21NegatedClosingKeyword(pr('This does not fix #77.')), 'string');
+ t('H21: "out of scope" fires', typeof h21NegatedClosingKeyword(pr('Out of scope: closes #77.')), 'string');
+ t('H21: "no longer" fires', typeof h21NegatedClosingKeyword(pr('#77 is no longer in scope, so this closes #77 only on paper.')), 'string');
+
+ // ⛔ The rule is the negation window, never keyword presence. 277 of 300
+ // measured bodies carry a plain closing keyword and every one is correct.
+ t('H21: a plain `Fixes #N` is clean', h21NegatedClosingKeyword(pr('Fixes #10171')), null);
+ t(
+ 'H21: a fourteen-card close list is clean (the #10714 shape)',
+ h21NegatedClosingKeyword(pr('Fixes #10581\nFixes #10582\nFixes #10583')),
+ null,
+ );
+ // …and it stays clean even when the body says "not" somewhere ELSE. This is
+ // the measured 13-false-positive case a body-scoped window produces.
+ t(
+ 'H21: a negation elsewhere in the body does not reach the keyword',
+ h21NegatedClosingKeyword(pr('This does not touch the loader.\n\n### Closing lines\n\nFixes #10581')),
+ null,
+ );
+
+ // Real corpus near-misses — the specimen's register, no keyword bound to a
+ // number. These are the 116-sentence population the row must not report.
+ t(
+ 'H21: "#N is not addressed here" is clean (the advised spelling)',
+ h21NegatedClosingKeyword(pr('#10526 is not addressed here — spec-side, another lane.')),
+ null,
+ );
+ t(
+ 'H21: "#N remains open" is clean',
+ h21NegatedClosingKeyword(pr('out of scope for it — #10368 remains open and untouched.')),
+ null,
+ );
+ t(
+ 'H21: the #10876 near-miss ("filed, not repaired here") is clean',
+ h21NegatedClosingKeyword(pr('## Out of scope — filed, not repaired here\n\nThe loader half stays open.')),
+ null,
+ );
+ t(
+ 'H21: the #10851 near-miss ("filed, not fixed here") is clean',
+ h21NegatedClosingKeyword(pr('## The divergence the suite found — filed, not fixed here')),
+ null,
+ );
+
+ // Disjoint from H7: a number already declared `Part of` is H7's row.
+ t(
+ 'H21: a `Part of #N` body is H7\'s row, not this one',
+ h21NegatedClosingKeyword(pr('Part of #77\n\nThis does not fix #77.')),
+ null,
+ );
+ t(
+ 'H21: …while a DIFFERENT number in the same body is still this row\'s',
+ typeof h21NegatedClosingKeyword(pr('Part of #77\n\nFiled, not fixed: #88.')),
+ 'string',
+ );
+
+ // Code stripping — inherited from H7, so a quoted keyword is not a finding.
+ t('H21: a keyword in backticks is clean', h21NegatedClosingKeyword(pr('Filed, not `fixed #10240`.')), null);
+ t('H21: a fenced keyword is clean', h21NegatedClosingKeyword(pr('not fixed:\n\n```\nFixes #10240\n```')), null);
+ t('H21: gerunds are not closing keywords', h21NegatedClosingKeyword(pr('This is not fixing #77.')), null);
+ t('H21: empty / missing body', h21NegatedClosingKeyword(pr(undefined)), null);
+
+ // The window itself, pinned directly — it is the whole design. Asserted as
+ // the TEXT the marker scan actually sees, not as an offset: the offset is an
+ // implementation detail, while "the previous sentence is not in the window"
+ // is the property the 13-false-positive measurement turns on.
+ const win = (text, idx) => text.slice(sentenceStartOffset(text, idx), idx);
+ t('H21 window: a sentence break bounds it', win('No. Fixes #1', 4), '');
+ t('H21 window: a blank line bounds it', win('not here\n\nFixes #1', 10), '');
+ // The structural boundary lands after the markdown marker, so the window is
+ // the heading's / item's own text — and the sentence BEFORE it is excluded.
+ t('H21 window: a heading line bounds it', win('not here\n## H\nFixes #1', 14), 'H\n');
+ t('H21 window: a list item bounds it', win('not here\n- item\nFixes #1', 16), 'item\n');
+ // ⛔ A plain newline is NOT a boundary: commit messages here are hard-wrapped
+ // at ~72 columns, so a sentence routinely spans lines on that corpus.
+ t('H21 window: a soft-wrapped newline does NOT bound it', sentenceStartOffset('not\nhere', 8), 0);
+ t(
+ 'H21: …so a hard-wrapped negated close still fires',
+ typeof h21NegatedClosingKeyword(pr('Filed, not\nfixed: #10240')),
+ 'string',
+ );
+
+ // -- H8: delivering PR merged, card still `pm:dispatched` (#8683) ----------
+ // Fixtures reuse H7's extractor pins, so the stripping and per-number-
+ // binding measurements carry over rather than being re-proved.
+ const dispatched = (n) => ({ ...issue(['pm:dispatched'], ['os-help']), number: n });
+ const mergedPr = (number, body, merged_at = '2026-08-13T10:00:00Z') => ({ number, body, merged_at });
+
+ t(
+ 'H8: merged Part-of PR + still dispatched -> finding',
+ typeof h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321 — the non-destructive half only.')]),
+ 'string',
+ );
+ t(
+ 'H8: …and the finding names the delivering PR',
+ h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321')]).includes('#4400'),
+ true,
+ );
+ t(
+ 'H8: …and prescribes the paired write, not just the fact',
+ h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321')]).includes('pm:dispatched'),
+ true,
+ );
+ // The closing-keyword arm: an OPEN dispatched card named by a merged PR's
+ // closing keyword is a half-state whichever mechanism failed (see header).
+ t(
+ 'H8: merged closing-keyword PR + still-open dispatched card -> finding',
+ typeof h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Fixes #4321')]),
+ 'string',
+ );
+ t(
+ 'H8: card without pm:dispatched is out of scope',
+ h8MergedPrStillDispatched({ ...issue(['pm:queue'], ['os-help']), number: 4321 }, [mergedPr(4400, 'Part of #4321')]),
+ null,
+ );
+ // Closed-unmerged is an abandoned attempt, not a delivery: demanding the
+ // paired write for work that never landed would be a phantom finding.
+ t(
+ 'H8: closed-unmerged PR is not a delivery',
+ h8MergedPrStillDispatched(dispatched(4321), [{ number: 4400, body: 'Part of #4321', merged_at: null }]),
+ null,
+ );
+ // Bound per issue number, exactly like H7.
+ t(
+ 'H8: merged PR delivering a DIFFERENT card -> clean',
+ h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #9999\n\nFixes #8888')]),
+ null,
+ );
+ // Strip reuse: a body QUOTING the spelling in backticks does not deliver —
+ // the same careful-author protection H7's reading 4 measured.
+ t(
+ 'H8: reference inside backticks does not deliver',
+ h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'the dispatch asked for `Fixes #4321` and `Part of #4321`')]),
+ null,
+ );
+ // A plain prose mention is neither declaration: only the two protocol
+ // spellings establish the delivering relation.
+ t(
+ 'H8: plain prose mention does not deliver',
+ h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'follow-up to #4321, measurement only')]),
+ null,
+ );
+ t(
+ 'H8: two merged deliverers -> both named',
+ h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, 'Part of #4321'), mergedPr(4500, 'Fixes #4321')]).includes('#4500'),
+ true,
+ );
+ t('H8: empty merged window -> clean', h8MergedPrStillDispatched(dispatched(4321), []), null);
+ t('H8: missing merged window -> clean', h8MergedPrStillDispatched(dispatched(4321), undefined), null);
+
+ // -- H8: the branch-name fallback (#11036) ---------------------------------
+ // The card's ⚠️ is the load-bearing clause: this WIDENS the delivery
+ // relation, so BOTH directions are pinned — the hit must report, and a
+ // re-scoped branch must not.
+ const onBranch = (number, body, ref, merged_at = '2026-08-21T14:00:28Z') => ({
+ number,
+ body,
+ merged_at,
+ head: { ref },
+ });
+
+ // Direction 1 — the measured specimen's shape: merged, body carries NEITHER
+ // recognised spelling (`Refs #N` is not one), branch named for the card.
+ t(
+ 'H8 branch: a `Refs #N`-only body delivers via its branch name',
+ typeof h8MergedPrStillDispatched(
+ dispatched(10757),
+ [onBranch(10824, 'Refs #10757', 'claude/issue-10757-dedupe-per-request-queries')],
+ ),
+ 'string',
+ );
+ t(
+ 'H8 branch: …and the finding names the delivering PR',
+ h8MergedPrStillDispatched(
+ dispatched(10757),
+ [onBranch(10824, 'Refs #10757', 'claude/issue-10757-dedupe-per-request-queries')],
+ ).includes('#10824'),
+ true,
+ );
+ // An empty body is the same population — nothing declared, so the branch is
+ // the only evidence there is.
+ t(
+ 'H8 branch: an empty body delivers via its branch name',
+ typeof h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, '', 'claude/issue-4321-x')]),
+ 'string',
+ );
+
+ // Direction 2 — the RE-SCOPED branch, the false-fire this widening could
+ // otherwise buy. Branch still named for 4321; body now delivers 9999. The
+ // body is the channel an author maintains, so it wins and 4321 stays clean.
+ t(
+ 'H8 branch: a re-scoped branch does NOT deliver the card it is NAMED for',
+ h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, 'Part of #9999', 'claude/issue-4321-x')]),
+ null,
+ );
+ t(
+ 'H8 branch: …and the card the re-scoped body DOES name still reports',
+ typeof h8MergedPrStillDispatched(dispatched(9999), [onBranch(4400, 'Part of #9999', 'claude/issue-4321-x')]),
+ 'string',
+ );
+ t(
+ 'H8 branch: a closing keyword for another card also suppresses the fallback',
+ h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, 'Fixes #9999', 'claude/issue-4321-x')]),
+ null,
+ );
+ // …and the widening does not reach past the merged/unmerged line, nor past
+ // the label gate, nor onto a non-protocol branch name.
+ t(
+ 'H8 branch: a closed-UNMERGED PR on the card branch is still not a delivery',
+ h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, '', 'claude/issue-4321-x', null)]),
+ null,
+ );
+ t(
+ 'H8 branch: a non-protocol branch name delivers nothing',
+ h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, '', 'feat/some-hand-cut-branch')]),
+ null,
+ );
+ t(
+ 'H8 branch: a branch named for a DIFFERENT card is clean',
+ h8MergedPrStillDispatched(dispatched(4321), [onBranch(4400, '', 'claude/issue-9999-x')]),
+ null,
+ );
+ t('H8 branch: a PR row with no head at all does not crash', h8MergedPrStillDispatched(dispatched(4321), [mergedPr(4400, '')]), null);
+
+ // The extractor itself, and its agreement with the prose-scanning constant —
+ // one branch SHAPE, two readers, pinned together so a convention change
+ // cannot move only one of them.
+ t('H8 branch: the target is the issue number as a string', branchNameTarget('claude/issue-10757-dedupe'), '10757');
+ t('H8 branch: surrounding whitespace is tolerated', branchNameTarget(' claude/issue-1-a '), '1');
+ t('H8 branch: a slug with dots and underscores survives', branchNameTarget('claude/issue-1-a.b_c-d'), '1');
+ t('H8 branch: a slugless branch is not the protocol shape', branchNameTarget('claude/issue-1'), null);
+ t('H8 branch: a trailing path segment is not the protocol shape', branchNameTarget('claude/issue-1-a/b'), null);
+ t('H8 branch: a prefixed ref is not the protocol shape', branchNameTarget('refs/heads/claude/issue-1-a'), null);
+ t('H8 branch: `main` yields nothing', branchNameTarget('main'), null);
+ t('H8 branch: a missing ref yields nothing', branchNameTarget(undefined), null);
+ t(
+ 'H8 branch: the anchored reader agrees with CLAIM_BRANCH_SHAPE on the protocol shape',
+ [...'claude/issue-10757-dedupe-per-request-queries'.matchAll(CLAIM_BRANCH_SHAPE)][0][0],
+ 'claude/issue-10757-dedupe-per-request-queries',
+ );
+
+ // -- H8: the open-PR side — a card delivered in HALVES (#10468) ------------
+ // The measured specimen: #9834's duration half merged as #10004 while its
+ // error-counter half sat OPEN as draft #10226. The old row fired every sweep
+ // and prescribed dropping `pm:dispatched` off a card with live work.
+ const openHalf = (number, body, draft = false) => ({ number, body, draft, merged_at: null });
+ const halves = (openPrs) =>
+ h8MergedPrStillDispatched(dispatched(9834), [mergedPr(10004, 'Part of #9834')], openPrs);
+
+ t('H8 open: a half-delivered card still reports', typeof halves([openHalf(10226, 'Part of #9834', true)]), 'string');
+ // The whole point of the downgrade: the destructive prescription must not
+ // fire on a card whose remaining half is open.
+ t(
+ 'H8 open: …and does NOT prescribe dropping the label',
+ halves([openHalf(10226, 'Part of #9834', true)]).includes('Drop `pm:dispatched`'),
+ false,
+ );
+ t(
+ 'H8 open: …and says the label is CORRECT here',
+ halves([openHalf(10226, 'Part of #9834', true)]).includes('must NOT be dropped'),
+ true,
+ );
+ t('H8 open: …and names the open half', halves([openHalf(10226, 'Part of #9834', true)]).includes('#10226'), true);
+ t('H8 open: …and the merged half too', halves([openHalf(10226, 'Part of #9834', true)]).includes('#10004'), true);
+ t('H8 open: …and counts them, N of M', halves([openHalf(10226, 'Part of #9834', true)]).includes('1 of 2'), true);
+ // A draft open half is the specimen's own shape — never filtered out.
+ t('H8 open: …and marks the open half as a draft', halves([openHalf(10226, 'Part of #9834', true)]).includes('(draft)'), true);
+ t('H8 open: a NON-draft open half counts identically', typeof halves([openHalf(10226, 'Part of #9834', false)]), 'string');
+
+ // …and the row it replaces is unchanged whenever every deliverer HAS merged —
+ // the genuine #8683 case, which must keep its prescription.
+ t(
+ 'H8 open: no open deliverer -> the destructive prescription still fires',
+ halves([]).includes('Drop `pm:dispatched`'),
+ true,
+ );
+ t('H8 open: a missing open list is the pre-#10468 reading', halves(undefined).includes('Drop `pm:dispatched`'), true);
+ t(
+ 'H8 open: an open PR delivering a DIFFERENT card does not downgrade the row',
+ halves([openHalf(10226, 'Part of #9999')]).includes('Drop `pm:dispatched`'),
+ true,
+ );
+ // No merged deliverer at all is still clean — the open side never MANUFACTURES
+ // a row, it only softens one the merged side already raised.
+ t(
+ 'H8 open: an open deliverer with no merged half is clean',
+ h8MergedPrStillDispatched(dispatched(9834), [], [openHalf(10226, 'Part of #9834')]),
+ null,
+ );
+ // The open side reads delivery through the SAME relation, branch fallback
+ // included — a `Refs #N` open half is as live as a `Part of #N` one.
+ t(
+ 'H8 open: the branch-name fallback applies to the open side too',
+ halves([{ number: 10226, body: 'Refs #9834', draft: false, merged_at: null, head: { ref: 'claude/issue-9834-error-counter' } }]).includes('must NOT be dropped'),
+ true,
+ );
+ // …and its re-scope guard travels with it.
+ t(
+ 'H8 open: a re-scoped open branch does not soften the row',
+ halves([{ number: 10226, body: 'Part of #9999', draft: false, merged_at: null, head: { ref: 'claude/issue-9834-x' } }]).includes('Drop `pm:dispatched`'),
+ true,
+ );
+ // A merged row appearing in the open list is not an outstanding half.
+ t(
+ 'H8 open: a merged row in the open list is not an open half',
+ halves([{ number: 10226, body: 'Part of #9834', merged_at: '2026-08-20T00:00:00Z' }]).includes('Drop `pm:dispatched`'),
+ true,
+ );
+
+ // -- H22: a CLOSED card still carrying a `pm:*` state label (#10688) -------
+ const closedCard = (labels, state_reason = 'completed') => ({
+ ...issue(labels),
+ number: 8531,
+ state: 'closed',
+ state_reason,
+ });
+
+ t('H22: closed + pm:dispatched -> finding', typeof h22ClosedCardPmResidue(closedCard(['pm:dispatched'])), 'string');
+ t('H22: …and names the residue label', h22ClosedCardPmResidue(closedCard(['pm:dispatched'])).includes('`pm:dispatched`'), true);
+ t('H22: …and names the close reason', h22ClosedCardPmResidue(closedCard(['pm:dispatched'])).includes('closed `completed`'), true);
+ t(
+ 'H22: …and prescribes only the label strip, no other write',
+ h22ClosedCardPmResidue(closedCard(['pm:dispatched'])).includes('already closed'),
+ true,
+ );
+ t('H22: a not_planned close is residue too', typeof h22ClosedCardPmResidue(closedCard(['pm:queue'], 'not_planned')), 'string');
+ t('H22: a missing state_reason still renders a sentence', typeof h22ClosedCardPmResidue({ ...closedCard(['pm:queue']), state_reason: null }), 'string');
+ t(
+ 'H22: …and never prints the string undefined',
+ h22ClosedCardPmResidue({ ...closedCard(['pm:queue']), state_reason: null }).includes('undefined'),
+ false,
+ );
+ t('H22: several residue labels are all named', h22ClosedCardPmResidue(closedCard(['pm:blocked', 'pm:blocking'])).includes('`pm:blocking`'), true);
+
+ // The gate that keeps this from restating H3: an OPEN card is never this
+ // row's, whatever it carries — every other item here already reads it.
+ t('H22: an OPEN card carrying pm:dispatched is out of scope', h22ClosedCardPmResidue({ ...issue(['pm:dispatched']), number: 1, state: 'open' }), null);
+ t('H22: a card with no state field is out of scope', h22ClosedCardPmResidue(issue(['pm:dispatched'])), null);
+ t('H22: a closed card with no pm label is clean', h22ClosedCardPmResidue(closedCard(['domain:cli', 'bug'])), null);
+ t('H22: a closed card with no labels at all is clean', h22ClosedCardPmResidue(closedCard([])), null);
+ t('H22: a missing issue does not crash', h22ClosedCardPmResidue(undefined), null);
+
+ // The identity stickers, pinned OUT — a closed seat card keeps `pm:seat` as
+ // what it IS, not as a claim that work is in flight (see PM_RESIDUE_LABELS).
+ t('H22: `pm:seat` on a closed card is identity, not residue', h22ClosedCardPmResidue(closedCard(['pm:seat'])), null);
+ t('H22: `pm:epic` likewise', h22ClosedCardPmResidue(closedCard(['pm:epic'])), null);
+ t('H22: `pm:retriage` is deliberately out of the measured set', h22ClosedCardPmResidue(closedCard(['pm:retriage'])), null);
+ // …but a seat card ALSO carrying a state label is still residue.
+ t('H22: `pm:seat` + a state label is residue for the state label', h22ClosedCardPmResidue(closedCard(['pm:seat', 'pm:dispatched'])).includes('`pm:dispatched`'), true);
+ t('H22: …and does not name the identity sticker', h22ClosedCardPmResidue(closedCard(['pm:seat', 'pm:dispatched'])).includes('`pm:seat`'), false);
+
+ // The census's five plus the state ruled in on 2026-08-23, each pinned — the
+ // set is the item's scope, so a silent edit to it should break a test rather
+ // than quietly change what patrols. `pm:awaiting-maintainer` is the ONE
+ // member not drawn from the #10688 census, and deliberately so: it had no
+ // live carriers to census when it was created, and admitting it at creation
+ // is what keeps the state from accruing the residue this row exists to catch
+ // before anyone thinks to measure it (#11196 fix 5).
+ t('H22: the residue set is the census five + the newly ruled state', PM_RESIDUE_LABELS.join(','), 'pm:dispatched,pm:queue,pm:blocked,pm:on-hold,pm:blocking,pm:awaiting-maintainer');
+ // …and the two similarly-named sets stay APART: H13's carries `finding`, this
+ // one carries `pm:blocking`, and unifying them would break both items.
+ t('H22: the residue set is NOT H13\'s visibility set', PM_RESIDUE_LABELS.join(',') === PM_STATE_LABELS.join(','), false);
+ t('H22: …H13\'s set carries `finding`, which is a fine state to close in', PM_STATE_LABELS.includes('finding'), true);
+ t('H22: …and this one does not', PM_RESIDUE_LABELS.includes('finding'), false);
+ t('H22: …while `pm:blocking` is residue here and absent from H13\'s', PM_RESIDUE_LABELS.includes('pm:blocking') && !PM_STATE_LABELS.includes('pm:blocking'), true);
+ for (const label of PM_RESIDUE_LABELS) {
+ t(`H22: \`${label}\` on a closed card is residue`, typeof h22ClosedCardPmResidue(closedCard([label])), 'string');
+ }
+
+ // The summary line's H22 clause — a pass that read nothing must not read the
+ // same as a board with no residue (#4690), so the count is always stated.
+ t('summary: the H22 clause states what the closed pass read', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0, closed: 200 }, 0).includes('H22 read 200 recently-closed issue(s)'), true);
+ t('summary: an absent closed count degrades to 0, never to undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('H22 read 0 recently-closed'), true);
+
+ // -- H23: the COMMIT-MESSAGE surface (#10942) -------------------------------
+ //
+ // Every case here drives the REST commit shape the sweep passes in, and the
+ // asymmetry cases are run against H7 in the same breath: the claim is not
+ // "this predicate fires", it is "these two surfaces answer DIFFERENTLY on one
+ // text", and only the paired assertion can say that.
+ const commitRow = (message, sha = 'abc123def0') => ({
+ sha,
+ html_url: `https://github.com/o/r/commit/${sha}`,
+ commit: { message },
+ });
+ /**
+ * The shape the squash actually produced in all six measured specimens: a
+ * lead commit whose trailer CLOSES the card, and a later commit on the same
+ * branch whose trailer says it is only `Part of` it — concatenated by the
+ * merge into one message. Reconstructed rather than quoted: the self-test is
+ * offline and pure, so it pins the SHAPE the corpus measured, not the bytes.
+ */
+ const squashOf = (card, pr, keyword = 'Fixes') =>
+ commitRow(
+ `fix(scope): the lead half (#${pr})\n\n` +
+ `* fix(scope): the lead half\n\nProse about the fix.\n\n${keyword} #${card}\n\n` +
+ `* test(scope): the second half\n\nMore prose.\n\nPart of #${card}\n\n---------\n\n` +
+ `Co-authored-by: Claude \n`,
+ `sha${pr}xx`,
+ );
+
+ // The six specimens the card measured, by (sha, card, PR). Columns kept apart
+ // deliberately, here and in the section docblock: this file must not put a
+ // closing keyword next to a live card number in any text a merge could read.
+ for (const [sha, card, pr] of [
+ ['0c24898c0', '10377', '10389'],
+ ['d7283250d', '10219', '10291'],
+ ['af2a989be', '9320', '9478'],
+ ['3db37957c', '8355', '8419'],
+ ['7e06f51ee', '8060', '8167'],
+ ['30536e37c', '7828', '8128'],
+ ]) {
+ t(`H23: the measured specimen shape (${sha}, card ${card}) fires`, typeof h23CommitMessageContradiction(squashOf(card, pr)), 'string');
+ }
+
+ // ⛔ THE ASYMMETRY — the card's point 2, pinned in both directions on ONE text.
+ // A commit message is not markdown, so a quoted keyword binds here; the same
+ // bytes in a PR body do not, and H7 must keep saying so.
+ const backticked = 'Part of #77\n\nThe blocking gate wanted `Fixes #77` here.\n';
+ t('H23: a backticked keyword in a COMMIT MESSAGE is a binding', typeof h23CommitMessageContradiction(commitRow(backticked)), 'string');
+ t('H23: …while the same bytes in a PR BODY are not (H7 stays clean)', h7PartOfWithClosingKeyword({ body: backticked }), null);
+ const fencedCommit = 'Part of #77\n\n```\nFixes #77\n```\n';
+ t('H23: a FENCED keyword in a commit message is a binding too', typeof h23CommitMessageContradiction(commitRow(fencedCommit)), 'string');
+ t('H23: …and the same bytes in a PR body are still not (H7 stays clean)', h7PartOfWithClosingKeyword({ body: fencedCommit }), null);
+ // The extractors themselves, at the two surfaces — the option is the whole
+ // mechanism, so it is pinned directly and not only through the verdicts.
+ t('extractor: a quoted keyword is invisible on the BODY surface (default)', closingKeywordTargets('a `Fixes #1` b').size, 0);
+ t('extractor: …and visible on the COMMIT surface', closingKeywordTargets('a `Fixes #1` b', { markdown: false }).size, 1);
+ t('extractor: `Part of` in a fence is invisible on the body surface', partOfTargets('```\nPart of #1\n```').size, 0);
+ t('extractor: …and visible on the commit surface', partOfTargets('```\nPart of #1\n```', { markdown: false }).size, 1);
+ t('extractor: the default is byte-identical to the pre-option reading', closingKeywordTargets('Fixes #1').get('1'), 'Fixes');
+
+ // The REMEDY TEXT. The realistic regression is someone copying H7's tail
+ // across, so H7's own sentence is asserted to CARRY the clause this one must
+ // never carry — a one-sided assertion would pass against a sentence that lost
+ // both.
+ const fired23 = h23CommitMessageContradiction(squashOf('9320', '9478'));
+ const fired7 = h7PartOfWithClosingKeyword({ body: 'Part of #77\n\nFixes #77' });
+ t('H23: the sentence prescribes REWORDING', fired23.includes('REWORD'), true);
+ t('H23: …and never the body-surface backtick remedy', fired23.includes('put the keyword in backticks'), false);
+ t('H23: …nor any "in backticks" advice at all', fired23.includes('in backticks'), false);
+ t('H23: …and says out loud that this surface is not markdown', fired23.includes('NOT markdown'), true);
+ t('H7: …while H7 KEEPS that remedy, which is correct for a body', fired7.includes('put the keyword in backticks'), true);
+ t('H23: the sentence names the commit sha', fired23.includes('sha9478xx'), true);
+ t('H23: …the bound card', fired23.includes('#9320'), true);
+ t('H23: …and the PR the squash marker names', fired23.includes('PR #9478'), true);
+ t('H23: …and it declines to adjudicate the close', fired23.includes('does NOT adjudicate'), true);
+
+ // Clean directions. H7's per-number binding carries over unchanged: a message
+ // that is part of one card and closes another is the normal correct shape.
+ t('H23: `Part of #A` + a keyword bound to #B -> clean', h23CommitMessageContradiction(commitRow('Part of #77\n\nFixes #88')), null);
+ t('H23: a plain closing trailer with no `Part of` -> clean', h23CommitMessageContradiction(commitRow('fix(x): a fix (#99)\n\nFixes #77')), null);
+ t('H23: `Part of` alone -> clean', h23CommitMessageContradiction(commitRow('Part of #77')), null);
+ t('H23: gerunds are not closing keywords here either', h23CommitMessageContradiction(commitRow('Part of #77\n\nStill fixing #77.')), null);
+ t('H23: empty / missing message', h23CommitMessageContradiction(commitRow(undefined)), null);
+ t('H23: a missing commit object', h23CommitMessageContradiction(undefined), null);
+ // H21's negation window is deliberately NOT ported to this surface: it flags 0
+ // of the 270 measured bindings, so this row is the `Part of` contradiction and
+ // nothing else. A future port is a card with its own numbers.
+ t('H23: a negated bare close is NOT this row (H21 not ported)', h23CommitMessageContradiction(commitRow('This does not fix #77.')), null);
+
+ // The PR correlation, and the measured property that makes it safe: the `(`
+ // of the squash marker stands between any preceding keyword and the number,
+ // so no subject's own marker can be read as a card binding (0 of 1,545).
+ t('commitSubjectPrNumber: reads the squash marker', commitSubjectPrNumber('fix(x): a subject (#11085)\n\nbody'), '11085');
+ t('commitSubjectPrNumber: absent marker -> null', commitSubjectPrNumber('fix(x): a subject\n\nbody'), null);
+ t('commitSubjectPrNumber: a marker in the BODY is not the subject\'s', commitSubjectPrNumber('fix(x): a subject\n\nquoted from another commit (#123)'), null);
+ t('commitSubjectPrNumber: empty message', commitSubjectPrNumber(''), null);
+ t('the squash marker is never bound as a card (the paren stands between)', closingKeywordTargets('fix(x): a subject that fixed (#11085)', { markdown: false }).size, 0);
+ t('…including the substring case the corpus is full of', closingKeywordTargets('fix(rest): optional KernelResolver.resolveEnvironment (#11085)', { markdown: false }).size, 0);
+
+ // The summary line's H23 clause — with a yield of ~6 in 1,546 a quiet row is
+ // the NORMAL reading, so the coverage numbers are the only thing separating a
+ // read surface from an unread one (#4690).
+ t('summary: the H23 clause states what the commit pass read', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0, commits: 300, commitBindings: 51, commitBindingMessages: 44 }, 0).includes('H23 read 300 squash commit message(s)'), true);
+ t('summary: …and the binding totals a promotion decision would need', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0, commits: 300, commitBindings: 51, commitBindingMessages: 44 }, 0).includes('51 closing-keyword binding(s) across 44 message(s)'), true);
+ t('summary: …and states the window boundary', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0, commits: 300 }, 0).includes('invisible by design'), true);
+ t('summary: absent H23 counts degrade to 0, never to undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('H23 read 0 squash commit message(s)'), true);
+
+ // -- H9: `pm:on-hold` without a machine-fireable `Restart-when:` ------------
+ const hold = (body) => issue(['pm:on-hold'], [], body);
+ t('H9: hold with no Restart-when line -> finding', typeof h9OnHoldNoRestartWhen(hold('parked until the train ships')), 'string');
+ t('H9: …and the finding prescribes the close default', h9OnHoldNoRestartWhen(hold('parked')).includes('not planned'), true);
+ t('H9: closed-upstream form -> clean', h9OnHoldNoRestartWhen(hold('Restart-when: closed acme/widgets#123')), null);
+ t('H9: executable-predicate form -> clean', h9OnHoldNoRestartWhen(hold('Restart-when: npm view create-objectstack dist-tags reports >= 17.0.0')), null);
+ t('H9: mid-body line -> clean', h9OnHoldNoRestartWhen(hold('Context first.\nRestart-when: closed acme/widgets#123\nMore prose.')), null);
+ // `manual` is a hold trying to opt out of having an exit — it counts as
+ // missing, or the one-word spelling defeats the invariant.
+ t('H9: manual form -> finding', typeof h9OnHoldNoRestartWhen(hold('Restart-when: manual — first EE customer asking')), 'string');
+ t('H9: …and the finding names the manual shape', h9OnHoldNoRestartWhen(hold('Restart-when: manual — reason')).includes('manual'), true);
+ t('H9: Manual case-insensitive as a VALUE -> finding', typeof h9OnHoldNoRestartWhen(hold('Restart-when: Manual — reason')), 'string');
+ t('H9: manual line + fireable line -> clean', h9OnHoldNoRestartWhen(hold('Restart-when: manual — x\nRestart-when: closed acme/widgets#9')), null);
+ // The KEY is byte-stable like `Blocked-by:` — a lowercase key is a line the
+ // unlock scan cannot see, so it must flag, not pass.
+ t('H9: lowercase key is invisible to the scan -> finding', typeof h9OnHoldNoRestartWhen(hold('restart-when: closed acme/widgets#123')), 'string');
+ t('H9: empty-valued line does not count', typeof h9OnHoldNoRestartWhen(hold('Restart-when:')), 'string');
+ t('H9: prose mentioning the literal inline does not count', typeof h9OnHoldNoRestartWhen(hold('add a Restart-when: line later')), 'string');
+ t('H9: card without pm:on-hold is out of scope', h9OnHoldNoRestartWhen(issue(['pm:queue'], [], 'no line at all')), null);
+ t('H9: missing body -> finding', typeof h9OnHoldNoRestartWhen(issue(['pm:on-hold'], [], undefined)), 'string');
+
+ // -- H9 and decorated directive lines (#10102) -----------------------------
+ // #9591's shape, byte-for-byte in structure: a legal, machine-fireable hold
+ // whose line is wrapped in inline code. The old anchor read it as absent and
+ // the old remedy text told the reading seat to close a maintainer-
+ // commissioned card.
+ const held9591 = '`Restart-when: the v18 major development cycle opens (first v18 changeset-major accepted on main), or a maintainer instruction pulls it forward`';
+ t('H9: the #9591 shape — a backticked line is a LEGAL hold', h9OnHoldNoRestartWhen(hold(held9591)), null);
+ t('H9: bulleted line -> clean', h9OnHoldNoRestartWhen(hold('- Restart-when: closed acme/widgets#123')), null);
+ t('H9: `*`-bulleted line -> clean', h9OnHoldNoRestartWhen(hold('* Restart-when: closed acme/widgets#123')), null);
+ t('H9: bolded key -> clean', h9OnHoldNoRestartWhen(hold('**Restart-when:** closed acme/widgets#123')), null);
+ t('H9: bold around the whole line -> clean', h9OnHoldNoRestartWhen(hold('**Restart-when: closed acme/widgets#123**')), null);
+ t('H9: bullet + code together -> clean', h9OnHoldNoRestartWhen(hold('Context.\n- `Restart-when: closed acme/widgets#123`\nMore prose.')), null);
+ // Negatives: decoration must not rescue a line the unlock grep cannot see,
+ // and must not turn its own markers into a value.
+ t('H9: a decorated lowercase key is still a finding', typeof h9OnHoldNoRestartWhen(hold('`restart-when: closed acme/widgets#123`')), 'string');
+ t('H9: a decorated valueless line is still a finding', typeof h9OnHoldNoRestartWhen(hold('`Restart-when:`')), 'string');
+ t('H9: …and one padded with a space (the closing marker is not a value)', typeof h9OnHoldNoRestartWhen(hold('`Restart-when: `')), 'string');
+ t('H9: a decorated line quoted mid-sentence is still a finding', typeof h9OnHoldNoRestartWhen(hold('someone should add a `Restart-when: closed acme/widgets#1` line')), 'string');
+ // The stripped value reaches the `manual` test intact — the decorated manual
+ // hold must still fire, or backticks would become the opt-out the bare
+ // spelling is denied.
+ t('H9: a decorated `manual` hold still fires', typeof h9OnHoldNoRestartWhen(hold('`Restart-when: manual — first EE customer asking`')), 'string');
+ t('H9: …and still names the manual shape', h9OnHoldNoRestartWhen(hold('**Restart-when: manual — reason**')).includes('manual'), true);
+
+ // -- H9's remedy text: verify/unwrap first, close last (#10102) ------------
+ const h9NoLine = h9OnHoldNoRestartWhen(hold('parked until the train ships'));
+ t('H9: the no-line row names the decorated/unparsed possibility', h9NoLine.includes('cannot parse'), true);
+ t('H9: …and tells the seat to read the body before acting', h9NoLine.includes('READ THE BODY BEFORE ACTING'), true);
+ t('H9: …and demotes closing to the last resort', h9NoLine.includes('Closing is the LAST resort'), true);
+ t('H9: …and says an unwrapped line needs no state change', h9NoLine.includes('no state change is due'), true);
+ // The `manual` row is NOT a parse failure — a line was read — so it must not
+ // carry the "maybe it is there" hedge, or the one row that really does mean
+ // "this hold has no exit" starts reading as uncertain.
+ t('H9: the manual row carries no unparsed hedge', h9OnHoldNoRestartWhen(hold('Restart-when: manual — reason')).includes('cannot parse'), false);
+ t('H9: …but does still demote closing', h9OnHoldNoRestartWhen(hold('Restart-when: manual — reason')).includes('Closing is the LAST resort'), true);
+ // The channel contract, stated in the row itself — two channels since
+ // #10403, symmetric with H4/H14. An undocumented difference between two
+ // adjacent rules is how the last two half-states on that lane were made,
+ // and an undocumented SAMENESS would repeat it in mirror image.
+ t('H9: the row states the two-channel contract', h9NoLine.includes('body OR a comment'), true);
+ t('H9: …and names the predicates it now matches', h9NoLine.includes('H4/H14'), true);
+ t('H9: …and the manual row states it too', h9OnHoldNoRestartWhen(hold('Restart-when: manual — x')).includes('body OR a comment'), true);
+
+ // -- H9's COMMENT channel (#10403) -----------------------------------------
+ // The incident fixture: a machine-fireable exit parked in a comment — the
+ // shape that fired unnoticed for ~2 days under the body-only read. A
+ // comment-channel line is a line.
+ t('H9: a comment-channel fireable line -> clean (the 2-day-expired shape)', h9OnHoldNoRestartWhen(hold('parked until upstream ships'), ['triage note', 'Restart-when: closed acme/widgets#123']), null);
+ t('H9: a body-channel line still clears with comments read (unchanged)', h9OnHoldNoRestartWhen(hold('Restart-when: closed acme/widgets#123'), ['just prose']), null);
+ // Composes with the decoration tolerance: the comment channel goes through
+ // the same shared reader, so `stripMatchingDecoration` applies there too.
+ t('H9: a DECORATED comment-channel line -> clean', h9OnHoldNoRestartWhen(hold('parked'), ['- `Restart-when: closed acme/widgets#123`']), null);
+ t('H9: an executable predicate in a comment -> clean', h9OnHoldNoRestartWhen(hold('parked'), ['Restart-when: npm view create-objectstack dist-tags reports >= 17.0.0']), null);
+ // The strictness the widening must not cost, per channel: `manual` opts out
+ // in a comment exactly as it does in the body, and a lowercase key is still
+ // a line the unlock grep cannot see.
+ t('H9: a manual-only comment line is still a finding', typeof h9OnHoldNoRestartWhen(hold('parked'), ['Restart-when: manual — first EE customer asking']), 'string');
+ t('H9: a lowercase key in a comment does not rescue', typeof h9OnHoldNoRestartWhen(hold('parked'), ['restart-when: closed acme/widgets#123']), 'string');
+ t('H9: a mid-sentence mention in a comment is not a line', typeof h9OnHoldNoRestartWhen(hold('parked'), ['someone should add a `Restart-when: closed acme/w#1` line']), 'string');
+ // Both channels read and empty: the sentence says EITHER, so the reader
+ // knows both were judged — and an unconsulted channel is never claimed.
+ t('H9: neither channel -> the sentence names EITHER channel', h9OnHoldNoRestartWhen(hold('parked'), ['no directive here']).includes('EITHER channel'), true);
+ t('H9: an empty comment thread is a real reading', h9OnHoldNoRestartWhen(hold('parked'), []).includes('EITHER channel'), true);
+ t('H9: an unconsulted comment channel is not claimed as read', h9OnHoldNoRestartWhen(hold('parked')).includes('EITHER channel'), false);
+ t('H9: …and the both-channels hedge tells the seat to read the thread too', h9OnHoldNoRestartWhen(hold('parked'), []).includes('BODY AND THE THREAD'), true);
+ // Unreadable is neither read nor absent (#4690): the row fires on the cheap
+ // side, says the thread could not be read, and never claims EITHER.
+ t('H9: an UNREADABLE comment thread still fires', typeof h9OnHoldNoRestartWhen(hold('parked'), null), 'string');
+ t('H9: …but never claims the second channel is empty', h9OnHoldNoRestartWhen(hold('parked'), null).includes('EITHER channel'), false);
+ t('H9: …and says the thread could not be read', h9OnHoldNoRestartWhen(hold('parked'), null).includes('could NOT be read'), true);
+ t('H9: …citing the unreadable-is-not-absent rule', h9OnHoldNoRestartWhen(hold('parked'), null).includes('#4690'), true);
+ t('H9: a fireable BODY line clears even an unreadable thread', h9OnHoldNoRestartWhen(hold('Restart-when: closed acme/widgets#123'), null), null);
+ // Manual across channels: a manual body line plus a fireable comment line is
+ // the mixed shape a seat actually writes when upgrading a hold in place.
+ t('H9: manual body line + fireable comment line -> clean', h9OnHoldNoRestartWhen(hold('Restart-when: manual — x'), ['Restart-when: closed acme/widgets#9']), null);
+ t('H9: manual lines in BOTH channels still name the manual shape', h9OnHoldNoRestartWhen(hold('Restart-when: manual — x'), ['Restart-when: manual — y']).includes('manual'), true);
+
+ // The gathering policy — what gets READ AT ALL (mirrors the H4 gate pins).
+ t('gate: a body-clean pm:on-hold card is an H9 candidate', needsRestartWhenComments(hold('no line here')), true);
+ t('gate: a manual-only body is still a candidate (the body does not answer)', needsRestartWhenComments(hold('Restart-when: manual — x')), true);
+ t('gate: a fireable body line buys no fetch', needsRestartWhenComments(hold('Restart-when: closed acme/widgets#123')), false);
+ t('gate: a non-hold card buys no fetch from THIS item', needsRestartWhenComments(issue(['pm:blocked'], [], 'no line here')), false);
+ t('gate: a missing issue does not crash', needsRestartWhenComments(undefined), false);
+
+ // -- H10: stale unclaimed p0 (routing-gap backstop) -------------------------
+ const NOW = Date.parse('2026-08-16T12:00:00Z');
+ const hoursAgo = (h) => new Date(NOW - h * 3_600_000).toISOString();
+ const p0 = (assignees, updatedAt, extra = []) => ({
+ ...issue(['priority:p0', ...extra], assignees),
+ updated_at: updatedAt,
+ });
+ t('H10: unassigned p0 past the threshold -> finding', typeof h10StaleUnclaimedP0(p0([], hoursAgo(36), ['pm:queue']), NOW), 'string');
+ t('H10: …and the finding names the threshold', h10StaleUnclaimedP0(p0([], hoursAgo(36)), NOW).includes(`${P0_UNCLAIMED_STALE_HOURS}h`), true);
+ t('H10: fresh unassigned p0 -> clean', h10StaleUnclaimedP0(p0([], hoursAgo(1)), NOW), null);
+ t('H10: exactly at the threshold -> clean (strictly beyond fires)', h10StaleUnclaimedP0(p0([], hoursAgo(P0_UNCLAIMED_STALE_HOURS)), NOW), null);
+ t('H10: assigned p0 is out of scope however old', h10StaleUnclaimedP0(p0(['os-help'], hoursAgo(200)), NOW), null);
+ t('H10: non-p0 card is out of scope', h10StaleUnclaimedP0({ ...issue(['pm:queue']), updated_at: hoursAgo(200) }, NOW), null);
+ // #4690 in miniature: an unreadable timestamp must not read as fresh.
+ t('H10: unparseable updated_at -> finding, not fresh', typeof h10StaleUnclaimedP0(p0([], 'not-a-date'), NOW), 'string');
+ t('H10: absent updated_at -> finding, not fresh', typeof h10StaleUnclaimedP0(p0([], undefined), NOW), 'string');
+ // The bare conjunction, no state carve-outs: a p0 aging in the decision box
+ // is exactly what the brief should show (report-only, tiny population).
+ t('H10: p0 aging under needs-user-decision still flags', typeof h10StaleUnclaimedP0(p0([], hoursAgo(48), ['needs-user-decision']), NOW), 'string');
+
+ // -- H11: important-parked inventory (2026-08-16 maintainer concern) --------
+ const daysAgo = (d) => new Date(NOW - d * 86_400_000).toISOString();
+ const parkedCard = (labels, { assignees = [], type, created = daysAgo(10) } = {}) => ({
+ ...issue(labels, assignees),
+ type,
+ created_at: created,
+ });
+ t('H11: type Bug + on-hold past threshold -> finding', typeof h11ImportantParked(parkedCard(['pm:on-hold'], { type: { name: 'Bug' } }), NOW), 'string');
+ t('H11: type as plain string is read too', typeof h11ImportantParked(parkedCard(['pm:on-hold'], { type: 'Bug' }), NOW), 'string');
+ t('H11: bug label + blocked -> finding', typeof h11ImportantParked(parkedCard(['bug', 'pm:blocked']), NOW), 'string');
+ t('H11: security label + on-hold -> finding', typeof h11ImportantParked(parkedCard(['security', 'pm:on-hold']), NOW), 'string');
+ t('H11: priority:p1 + blocked -> finding', typeof h11ImportantParked(parkedCard(['priority:p1', 'pm:blocked']), NOW), 'string');
+ t('H11: …and the finding names the parked state', h11ImportantParked(parkedCard(['bug', 'pm:blocked']), NOW).includes('pm:blocked'), true);
+ t('H11: …and the threshold', h11ImportantParked(parkedCard(['bug', 'pm:blocked']), NOW).includes(`${IMPORTANT_PARKED_STALE_DAYS}d`), true);
+ t('H11: fresh park is clean', h11ImportantParked(parkedCard(['bug', 'pm:on-hold'], { created: daysAgo(2) }), NOW), null);
+ t('H11: exactly at the threshold is clean (strictly beyond fires)', h11ImportantParked(parkedCard(['bug', 'pm:on-hold'], { created: daysAgo(IMPORTANT_PARKED_STALE_DAYS) }), NOW), null);
+ t('H11: important but not parked is out of scope', h11ImportantParked(parkedCard(['bug', 'pm:queue']), NOW), null);
+ t('H11: parked but unimportant is out of scope', h11ImportantParked(parkedCard(['pm:on-hold'], { type: { name: 'Task' } }), NOW), null);
+ // Distinct from H10: an ASSIGNED old parked p0 is out of H10's scope
+ // (assignee set) but squarely in H11's — the cross is the point.
+ t('H11: assigned parked p0 still flags (H10 would not)', typeof h11ImportantParked(parkedCard(['priority:p0', 'pm:blocked'], { assignees: ['os-help'] }), NOW), 'string');
+ t('H11: …and that same card is H10-clean', h10StaleUnclaimedP0({ ...parkedCard(['priority:p0', 'pm:blocked'], { assignees: ['os-help'] }), updated_at: daysAgo(10) }, NOW), null);
+ // #4690 direction, same as H10: unreadable age must not read as fresh.
+ t('H11: unreadable created_at -> finding, not fresh', typeof h11ImportantParked(parkedCard(['bug', 'pm:on-hold'], { created: 'not-a-date' }), NOW), 'string');
+
+ // -- H12: orphan landing (queue-steward retirement, 2026-08-16) -------------
+ const openPr = ({ draft = false, auto_merge = null, head = { ref: 'claude/issue-1-x' }, updated = hoursAgo(12) } = {}) => ({
+ draft,
+ auto_merge,
+ head,
+ updated_at: updated,
+ merged_at: null,
+ });
+ t('H12: ready + unarmed + stale -> finding', typeof h12OrphanLanding(openPr(), NOW), 'string');
+ t('H12: …and the finding names the threshold', h12OrphanLanding(openPr(), NOW).includes(`${ORPHAN_LANDING_STALE_HOURS}h`), true);
+ t('H12: …and prescribes the landing-window re-read, not just the fact', h12OrphanLanding(openPr(), NOW).includes('landing window'), true);
+ t('H12: draft is out of scope however old (parked deliberately)', h12OrphanLanding(openPr({ draft: true, updated: hoursAgo(200) }), NOW), null);
+ t('H12: armed auto-merge -> clean (queue machinery holds it)', h12OrphanLanding(openPr({ auto_merge: { merge_method: 'squash' } }), NOW), null);
+ t('H12: fresh ready PR -> clean', h12OrphanLanding(openPr({ updated: hoursAgo(1) }), NOW), null);
+ t('H12: exactly at the threshold -> clean (strictly beyond fires)', h12OrphanLanding(openPr({ updated: hoursAgo(ORPHAN_LANDING_STALE_HOURS) }), NOW), null);
+ t('H12: changeset-release head is the release bot\'s -> out of scope', h12OrphanLanding(openPr({ head: { ref: 'changeset-release/main' }, updated: hoursAgo(200) }), NOW), null);
+ t('H12: missing head ref does not crash and still flags', typeof h12OrphanLanding(openPr({ head: undefined, updated: hoursAgo(50) }), NOW), 'string');
+ // #4690 in miniature, same as H10/H11: unreadable must not read as fresh.
+ t('H12: unreadable updated_at -> finding, not fresh', typeof h12OrphanLanding(openPr({ updated: 'not-a-date' }), NOW), 'string');
+ // A row this predicate cannot read is out of scope, not a finding: `draft`
+ // must be a real false, so an issue-shaped or partial row never flags.
+ t('H12: missing draft field is out of scope', h12OrphanLanding({ auto_merge: null, updated_at: hoursAgo(50) }, NOW), null);
+ t('H12: merged row is out of scope', h12OrphanLanding({ ...openPr({ updated: hoursAgo(50) }), merged_at: '2026-08-13T10:00:00Z' }, NOW), null);
+
+ // -- H13: domain:* without any pm-state label, aged (2026-08-19 incident) --
+ const domainCard = (labels, updatedAt, extra = {}) => ({
+ ...issue(labels),
+ updated_at: updatedAt,
+ ...extra,
+ });
+ t('H13: aged domain card with no pm-state -> finding', typeof h13DomainWithoutPmState(domainCard(['domain:engine-core', 'bug', 'regression'], hoursAgo(26)), NOW), 'string');
+ t('H13: …and the finding names the threshold', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26)), NOW).includes(`${DOMAIN_HALF_STATE_STALE_HOURS}h`), true);
+ t('H13: …and blames the healing loop, not inventory', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26)), NOW).includes('healing loop'), true);
+ t('H13: pm:queue pairs the domain label -> clean', h13DomainWithoutPmState(domainCard(['domain:engine-core', 'pm:queue'], hoursAgo(26)), NOW), null);
+ t('H13: needs-user-decision is a state (the inbox reads it) -> clean', h13DomainWithoutPmState(domainCard(['domain:spec', 'needs-user-decision'], hoursAgo(200)), NOW), null);
+ t('H13: finding is a state (the grading round reads it) -> clean', h13DomainWithoutPmState(domainCard(['domain:cli', 'finding'], hoursAgo(200)), NOW), null);
+ // `pm:blocking` is a derived priority cache, not a state — a card carrying
+ // only it is exactly as invisible to candidate queries, so it still flags.
+ t('H13: pm:blocking alone is NOT a state -> still a finding', typeof h13DomainWithoutPmState(domainCard(['domain:services', 'pm:blocking'], hoursAgo(26)), NOW), 'string');
+ t('H13: status:parked exemption (its normal shape IS this one)', h13DomainWithoutPmState(domainCard(['domain:services', 'status:parked'], hoursAgo(200)), NOW), null);
+ t('H13: tracking exemption', h13DomainWithoutPmState(domainCard(['domain:devx', 'tracking'], hoursAgo(200)), NOW), null);
+ t('H13: qa-run exemption', h13DomainWithoutPmState(domainCard(['domain:cli', 'qa-run'], hoursAgo(200)), NOW), null);
+ t('H13: no domain label is out of scope however bare', h13DomainWithoutPmState(domainCard(['bug'], hoursAgo(200)), NOW), null);
+ t('H13: fresh half-state is intake latency, not a finding', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(1)), NOW), null);
+ t('H13: exactly at the threshold -> clean (strictly beyond fires)', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(DOMAIN_HALF_STATE_STALE_HOURS)), NOW), null);
+ // #4690 in miniature, same as H10/H11/H12: unreadable must not read as fresh.
+ t('H13: unreadable updated_at -> finding, not fresh', typeof h13DomainWithoutPmState(domainCard(['domain:engine-core'], 'not-a-date'), NOW), 'string');
+ t('H13: absent updated_at -> finding, not fresh', typeof h13DomainWithoutPmState(domainCard(['domain:engine-core'], undefined), NOW), 'string');
+ // The louder line — the measured card carried its trigger in its own body.
+ const p0Body = { body: 'P0 checklist-item failure (data-integrity DELETE regression) — priority label is triage’s to set' };
+ t('H13: body self-declaring P0 -> louder line', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26), p0Body), NOW).includes('P0-SUSPECT'), true);
+ t('H13: …which prescribes the emergency-triage channel', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26), p0Body), NOW).includes('emergency-triage'), true);
+ t('H13: data-integrity phrasing alone fires the louder line', h13SelfDeclaredP0({ title: '', body: 'a data integrity regression in DELETE' }), true);
+ t('H13: the title is scanned too', h13SelfDeclaredP0({ title: 'p0 suspect: rows vanish', body: '' }), true);
+ // Strip reuse (H7 reading 4): quoting the token in backticks is not a
+ // self-declaration, and `P0` inside a word is not the token.
+ t('H13: P0 only inside backticks is not a self-declaration', h13SelfDeclaredP0({ title: '', body: 'the card quotes `P0` in passing' }), false);
+ t('H13: P0 inside a word does not fire', h13SelfDeclaredP0({ title: '', body: 'the HTTP0 protocol note' }), false);
+ t('H13: a quiet body stays on the base line', h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26), { body: 'ordinary defect' }), NOW).includes('P0-SUSPECT'), false);
+
+ // -- H18: `pm:retriage` aged past one triage cycle (2026-08-19/20 ruling) --
+ // Reuses `domainCard` — a generic (labels, updated_at, extra) issue builder,
+ // not a domain-specific one despite the name.
+ t('H18: retriage past the threshold, coexisting pm:queue -> finding', typeof h18RetriageAged(domainCard(['pm:retriage', 'pm:queue'], hoursAgo(3)), NOW), 'string');
+ t('H18: …and the finding names the threshold', h18RetriageAged(domainCard(['pm:retriage', 'pm:queue'], hoursAgo(3)), NOW).includes(`${RETRIAGE_STALE_HOURS}h`), true);
+ t('H18: …and names the coexisting standing label', h18RetriageAged(domainCard(['pm:retriage', 'pm:queue'], hoursAgo(3)), NOW).includes('`pm:queue`'), true);
+ t('H18: multiple coexisting labels are all named', h18RetriageAged(domainCard(['pm:retriage', 'pm:blocked', 'pm:blocking'], hoursAgo(3)), NOW).includes('`pm:blocked`') && h18RetriageAged(domainCard(['pm:retriage', 'pm:blocked', 'pm:blocking'], hoursAgo(3)), NOW).includes('`pm:blocking`'), true);
+ // Under-threshold: fresh objection is normal intake latency, not a finding.
+ t('H18: retriage under the threshold -> clean', h18RetriageAged(domainCard(['pm:retriage', 'pm:queue'], hoursAgo(1)), NOW), null);
+ t('H18: exactly at the threshold -> clean (strictly beyond fires)', h18RetriageAged(domainCard(['pm:retriage', 'pm:queue'], hoursAgo(RETRIAGE_STALE_HOURS)), NOW), null);
+ // No `pm:retriage` label at all is out of scope, however old.
+ t('H18: no pm:retriage label -> out of scope however old', h18RetriageAged(domainCard(['pm:queue'], hoursAgo(200)), NOW), null);
+ // The disputed-target variant: `pm:retriage` alone, no coexisting `pm:*`.
+ t('H18: retriage ALONE (no coexisting pm:* label) -> finding', typeof h18RetriageAged(domainCard(['pm:retriage'], hoursAgo(3)), NOW), 'string');
+ t('H18: …and names the disputed-target note', h18RetriageAged(domainCard(['pm:retriage'], hoursAgo(3)), NOW).includes('异议对象不明'), true);
+ t('H18: …and does not claim a coexisting label it does not have', h18RetriageAged(domainCard(['pm:retriage'], hoursAgo(3)), NOW).includes('alongside its standing'), false);
+ // A non-`pm:*` label (e.g. `domain:*`) never counts as the coexisting label.
+ t('H18: a domain: label is not counted as a coexisting pm:* label', h18RetriageAged(domainCard(['pm:retriage', 'domain:skills'], hoursAgo(3)), NOW).includes('异议对象不明'), true);
+ // #4690 in miniature, same as H10–H13: unreadable must not read as fresh.
+ t('H18: unreadable updated_at -> finding, not fresh', typeof h18RetriageAged(domainCard(['pm:retriage'], 'not-a-date'), NOW), 'string');
+ t('H18: absent updated_at -> finding, not fresh', typeof h18RetriageAged(domainCard(['pm:retriage'], undefined), NOW), 'string');
+ // Report-only, ordinary row in both media — never loud (H14–H16's own
+ // property, and the card's explicit requirement for this item).
+ t('H18: not a loud finding', isLoudFinding(h18RetriageAged(domainCard(['pm:retriage', 'pm:queue'], hoursAgo(3)), NOW)), false);
+
+ // -- H14: `pm:blocking` cache coherence, both directions (2026-08-19) ------
+ // The fixtures below are REAL lines from the 2026-08-19 census of this board
+ // (234 open cards, 17 `Blocked-by:` body lines), so the parser is pinned
+ // against the shapes seats actually write rather than against invented ones.
+ const carded = (number, labels, body = '', extra = {}) => ({
+ ...issue(labels, [], body),
+ number,
+ ...extra,
+ });
+ const numbersOf = (refs) => refs.map((r) => r.number).join(',');
+
+ // The parser. Line-anchored and case-sensitive like H4/H9's readings of the
+ // same body channel.
+ t('blockedByTargets: the plain live shape', numbersOf(blockedByTargets('Blocked-by: #9823')), '9823');
+ t('blockedByTargets: a bare local ref carries no repo', blockedByTargets('Blocked-by: #9823')[0].repo, null);
+ t('blockedByTargets: mid-body line is found', numbersOf(blockedByTargets('Context first.\nBlocked-by: #7220\nMore prose.')), '7220');
+ // Live line from #9784 — trailing prose after the ref is normal.
+ t(
+ 'blockedByTargets: trailing prose after the ref is ignored',
+ numbersOf(blockedByTargets('Blocked-by: #9689 (the relocation it needs is the same edit; doing them in the other order means touching the line twice).')),
+ '9689',
+ );
+ // The reason only the LEADING run is taken: a `#N` inside the trailing prose
+ // is context, not a blocker, and indexing it would file a phantom
+ // missing-cache row against a third card that did nothing wrong.
+ t(
+ 'blockedByTargets: a ref inside the trailing prose is NOT a blocker',
+ numbersOf(blockedByTargets('Blocked-by: #123 (see #456 for the background)')),
+ '123',
+ );
+ t('blockedByTargets: a comma-separated run is all blockers', numbersOf(blockedByTargets('Blocked-by: #6234, #6245')), '6234,6245');
+ t('blockedByTargets: the `and` connector is a separator', numbersOf(blockedByTargets('Blocked-by: #1 and #2')), '1,2');
+ t('blockedByTargets: two lines on one card both count', numbersOf(blockedByTargets('Blocked-by: #6234\nBlocked-by: #6245')), '6234,6245');
+ // Live line from #7917 — the cross-repo shape whose number must never be
+ // read as local (see `buildBlockingIndex`).
+ t('blockedByTargets: a cross-repo qualifier is captured, not dropped', blockedByTargets('Blocked-by: objectstack-ai/objectui#4356')[0].repo, 'objectstack-ai/objectui');
+ t('blockedByTargets: …and its number is still parsed', numbersOf(blockedByTargets('Blocked-by: objectstack-ai/objectui#4356')), '4356');
+ // Byte-stable key, same discipline as H4/H9: a spelling the real grep cannot
+ // see must not become an index entry here.
+ t('blockedByTargets: lowercase key is invisible to the scan', numbersOf(blockedByTargets('blocked-by: #123')), '');
+ t('blockedByTargets: mid-sentence mention is not a line', numbersOf(blockedByTargets('seats park the Blocked-by: #123 line in comments instead')), '');
+ t('blockedByTargets: an empty-valued line yields nothing', numbersOf(blockedByTargets('Blocked-by:')), '');
+ t('blockedByTargets: missing body', numbersOf(blockedByTargets(undefined)), '');
+ // The self-reference trap: this sweeper's OWN rendered rows land in an issue
+ // body (the anchor the patrol rewrites), and those rows quote the literal.
+ // They are bullets, so the key never starts a line — pinned, because the day
+ // it does the sweeper starts indexing its own report.
+ t(
+ 'blockedByTargets: a rendered finding row does not parse as a Blocked-by line',
+ numbersOf(blockedByTargets('- **H14** [#5](https://example.test/5) — targeted by 1 open card(s)\' `Blocked-by:` body line (#7)')),
+ '',
+ );
+ // Deliberately NOT stripped, unlike H7/H8/H13: this models a machine reader
+ // (the unlock scan greps the literal), so a fenced line really does fire the
+ // live machinery and must be reported as part of the index it feeds.
+ t('blockedByTargets: a fenced line still counts (this reader greps, it does not read prose)', numbersOf(blockedByTargets('```\nBlocked-by: #42\n```')), '42');
+
+ // The index.
+ const idx = (issues) => buildBlockingIndex(issues, { repo: 'objectstack-ai/objectstack' });
+ t('index: a local ref creates an entry', idx([carded(9849, [], 'Blocked-by: #9823')]).get(9823).join(','), '9849');
+ t('index: a cross-repo ref creates NO local entry', idx([carded(7917, [], 'Blocked-by: objectstack-ai/objectui#4356')]).has(4356), false);
+ t('index: the bare repo name is still local', idx([carded(10, [], 'Blocked-by: objectstack#5')]).get(5).join(','), '10');
+ t('index: the full owner/repo qualifier is local', idx([carded(10, [], 'Blocked-by: objectstack-ai/objectstack#5')]).get(5).join(','), '10');
+ t('index: a self-reference is dropped (a card cannot unblock itself)', idx([carded(5, [], 'Blocked-by: #5')]).has(5), false);
+ t('index: two dependents on one target', idx([carded(10, [], 'Blocked-by: #5'), carded(11, [], 'Blocked-by: #5')]).get(5).join(','), '10,11');
+ t('index: one dependent naming the target twice is listed once', idx([carded(10, [], 'Blocked-by: #5\nBlocked-by: #5')]).get(5).join(','), '10');
+ t('index: a card with no line contributes nothing', idx([carded(10, [], 'no dependency here')]).size, 0);
+ t('index: an empty listing is an empty index', idx([]).size, 0);
+ t('index: a missing listing does not crash', buildBlockingIndex(undefined).size, 0);
+
+ // The comment channel in the index — a UNION with the body, never a priority
+ // order (#10061).
+ const idxc = (issues, comments) =>
+ buildBlockingIndex(issues, { repo: 'objectstack-ai/objectstack', comments: new Map(comments) });
+ t('index: a comment-only edge is a real edge', idxc([carded(9828, ['pm:blocked'], 'body has no line')], [[9828, ['Blocked-by: #9465']]]).get(9465).join(','), '9828');
+ t('index: body and comment edges UNION rather than override', idxc([carded(10, [], 'Blocked-by: #5')], [[10, ['Blocked-by: #6']]]).get(5).join(',') + '|' + idxc([carded(10, [], 'Blocked-by: #5')], [[10, ['Blocked-by: #6']]]).get(6).join(','), '10|10');
+ t('index: the same target in both channels is listed once', idxc([carded(10, [], 'Blocked-by: #5')], [[10, ['Blocked-by: #5']]]).get(5).join(','), '10');
+ t('index: two comments naming two targets both land', [...idxc([carded(10, [], '')], [[10, ['Blocked-by: #5', 'Blocked-by: #6']]]).keys()].sort((a, b) => a - b).join(','), '5,6');
+ t('index: a cross-repo ref in a COMMENT is dropped like a body one', idxc([carded(7917, [], '')], [[7917, ['Blocked-by: objectstack-ai/objectui#4356']]]).has(4356), false);
+ t('index: a self-reference in a COMMENT is dropped too', idxc([carded(5, [], '')], [[5, ['Blocked-by: #5']]]).has(5), false);
+ t('index: comments for a card not in the listing contribute nothing', idxc([carded(10, [], '')], [[99, ['Blocked-by: #5']]]).size, 0);
+ t('index: an absent comments map leaves the body reading untouched', buildBlockingIndex([carded(10, [], 'Blocked-by: #5')], { repo: 'objectstack-ai/objectstack' }).get(5).join(','), '10');
+ t('index: an empty comment list for a card is harmless', idxc([carded(10, [], 'Blocked-by: #5')], [[10, []]]).get(5).join(','), '10');
+
+ // Direction A — the label carried with nothing targeting it.
+ t('H14-A: pm:blocking with nothing targeting it -> finding', typeof h14BlockingCacheIncoherent(carded(7276, ['pm:queue', 'pm:blocking']), idx([])), 'string');
+ t('H14-A: …and it names the stale-cache reading', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('stale derived cache'), true);
+ t('H14-A: …and prescribes the derivation pass, never a label from here', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('derivation pass'), true);
+ t('H14-A: …and says why stale is worse than absent', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('with authority'), true);
+ // The repo-boundary wording (#10139): STALE reads as "no dependent in this
+ // repo", never as exhaustive over the population, and the remedy is
+ // conditional on a cross-repo check rather than an outright drop.
+ t('H14-A: …names the repo boundary', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('no dependent found in this repo'), true);
+ t('H14-A: …and says cross-repo dependents are not swept', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('cross-repo dependents are not swept'), true);
+ t('H14-A: …and the remedy is conditional on verifying cross-repo dependents', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('verify cross-repo dependents before'), true);
+ // The negative: the old exhaustive phrasing ("the full two-channel index",
+ // instructing an unconditional drop) must be gone — it is what would have
+ // told a reader to sever the live #7917 / objectui#4356 edge.
+ t('H14-A: …and the old exhaustive phrasing is GONE', h14BlockingCacheIncoherent(carded(7276, ['pm:blocking']), idx([])).includes('full two-channel index'), false);
+ // The negative for direction A: the label is EARNED, so nothing to report.
+ t(
+ 'H14-A: pm:blocking with a real dependent -> clean',
+ h14BlockingCacheIncoherent(carded(5, ['pm:blocking']), idx([carded(10, [], 'Blocked-by: #5')])),
+ null,
+ );
+
+ // Direction B — targeted, but the cache never landed.
+ const missingIdx = idx([carded(9650, ['pm:queue'], 'Blocked-by: #9832')]);
+ t('H14-B: targeted without pm:blocking -> finding', typeof h14BlockingCacheIncoherent(carded(9832, ['bug', 'pm:dispatched', 'domain:cli']), missingIdx), 'string');
+ t('H14-B: …and it names the waiting card', h14BlockingCacheIncoherent(carded(9832, ['pm:dispatched']), missingIdx).includes('#9650'), true);
+ t('H14-B: …and calls it an invisible unblocker', h14BlockingCacheIncoherent(carded(9832, ['pm:dispatched']), missingIdx).includes('selection order cannot see'), true);
+ // The negative for direction B: no label and nobody waiting is the ordinary
+ // shape of ~230 of this board's ~234 open cards. It must be silent, or the
+ // row means nothing.
+ t('H14-B: no label and nothing targeting it -> clean', h14BlockingCacheIncoherent(carded(4321, ['pm:queue', 'domain:cli']), idx([])), null);
+ // The cross-repo consequence, end to end: #7917's objectui blocker must not
+ // manufacture a direction-B row against this repo's #4356.
+ t(
+ 'H14-B: a cross-repo blocker does not flag the local card of that number',
+ h14BlockingCacheIncoherent(carded(4356, ['pm:queue']), idx([carded(7917, [], 'Blocked-by: objectstack-ai/objectui#4356')])),
+ null,
+ );
+ // Fan-out cap: named, then counted.
+ const manyDeps = idx(Array.from({ length: 7 }, (_, i) => carded(100 + i, [], 'Blocked-by: #5')));
+ t('H14-B: a large fan-out names the cap and counts the rest', h14BlockingCacheIncoherent(carded(5, ['pm:queue']), manyDeps).includes(`+${7 - BLOCKING_DEPENDENT_LIST_CAP} more`), true);
+ t('H14-B: …and reports the true total, not the capped one', h14BlockingCacheIncoherent(carded(5, ['pm:queue']), manyDeps).includes('targeted by 7 open card(s)'), true);
+ t('H14: a missing index does not crash and reads as untargeted', h14BlockingCacheIncoherent(carded(5, ['pm:blocking']), undefined) !== null, true);
+
+ // Reverse verification against the LIVE board, 2026-08-19 (234 open cards).
+ // Predicted before running: direction A fires on #7276 (the board's only
+ // `pm:blocking` card, targeted by nothing), direction B fires on the five
+ // targeted-but-unlabeled cards. Both held. Not one coherent pairing existed
+ // at that reading — the cache had drifted to 0% agreement with its index.
+ const liveBodies = [
+ carded(9849, ['pm:queue'], 'Blocked-by: #9823'),
+ carded(9784, ['pm:queue'], 'Blocked-by: #9689 (the relocation it needs is the same edit).'),
+ carded(9650, ['pm:queue'], 'Blocked-by: #9832'),
+ carded(9592, ['pm:queue'], 'Blocked-by: #9255'),
+ carded(9482, ['pm:queue'], 'Blocked-by: #9652'),
+ carded(9249, ['pm:queue'], 'Blocked-by: #9919'),
+ carded(7917, ['pm:queue'], 'Blocked-by: objectstack-ai/objectui#4356'),
+ carded(2657, ['pm:blocked'], 'Blocked-by: #6234\nBlocked-by: #6245'),
+ ];
+ const liveIdx = idx(liveBodies);
+ t('H14 reverse-verify: #7276 (the board\'s only pm:blocking card) -> stale finding', typeof h14BlockingCacheIncoherent(carded(7276, ['pm:queue', 'domain:devx', 'pm:blocking']), liveIdx), 'string');
+ t('H14 reverse-verify: #9832 (targeted by #9650, unlabeled) -> missing finding naming #9650', h14BlockingCacheIncoherent(carded(9832, ['bug', 'pm:dispatched', 'domain:cli']), liveIdx).includes('#9650'), true);
+ t('H14 reverse-verify: #9919 (targeted by #9249, unlabeled) -> missing finding', typeof h14BlockingCacheIncoherent(carded(9919, ['pm:queue', 'repo:cloud']), liveIdx), 'string');
+ // …and the four measured NON-findings from the same reading, which is what
+ // makes the six above readable as signal rather than as a predicate that
+ // flags everything: a dependent card, a closed target's dependent, the
+ // cross-repo number, and an ordinary untouched card.
+ t('H14 reverse-verify: #9650 (a waiting card, not an unblocker) -> clean', h14BlockingCacheIncoherent(carded(9650, ['pm:queue']), liveIdx), null);
+ t('H14 reverse-verify: local #4356 (the objectui blocker\'s number) -> clean', h14BlockingCacheIncoherent(carded(4356, ['pm:queue']), liveIdx), null);
+ t('H14 reverse-verify: #2657 (blocked on two CLOSED cards) -> clean', h14BlockingCacheIncoherent(carded(2657, ['pm:blocked']), liveIdx), null);
+ t('H14 reverse-verify: an ordinary open card -> clean', h14BlockingCacheIncoherent(carded(9913, ['pm:queue', 'repo:cloud']), liveIdx), null);
+
+ // -- H14 + the comment channel: the two MEASURED false stales (#10061) -----
+ //
+ // Both were reported stale on 2026-08-19 by a body-only index while their
+ // dependents stated the wait in comments. The comment bodies below are the
+ // real ones from those threads, trimmed to the load-bearing lines. These are
+ // regression pins: a body-only index makes each of the two "-> stale" rows
+ // pass and each of the two "-> clean" rows fail.
+ const falseStaleSources = [
+ // #9465 (epic, `pm:blocking`): both dependents are body-clean.
+ carded(9709, ['pm:blocked'], 'Card body: add the console-injection guard to release.yml.'),
+ carded(9828, ['pm:blocked'], 'Card body: cut-rc.yml points a curator at an expired deadline.'),
+ // #9968 (decision card, `pm:blocking`): same shape.
+ carded(9969, ['pm:blocked'], 'Card body: consumerless vendor `/admin/` surface posture.'),
+ carded(9652, ['pm:blocked'], 'Card body: set-role veto tension in the same vendor-admin family.'),
+ ];
+ const falseStaleComments = new Map([
+ [9709, ['First-touch grading (triage seat): promoted out of `finding` -> `pm:blocked`, type Task.\n\nBlocked-by: #9465\n\nPremise re-checked on current values.']],
+ [9828, [liveBackfillComment]],
+ [9969, ['Triage: graded `pm:blocked` · `domain:services` · type Task, blocked behind the family decision.\n\nBlocked-by: #9968\n']],
+ [9652, ['Triage: same family as #9968; the ruling there sets this one.\n\nBlocked-by: #9968\n']],
+ ]);
+ const bodyOnlyIdx = idx(falseStaleSources);
+ const unionIdx = buildBlockingIndex(falseStaleSources, {
+ repo: 'objectstack-ai/objectstack',
+ comments: falseStaleComments,
+ });
+ const epic9465 = carded(9465, ['domain:devx', 'pm:epic', 'pm:blocking']);
+ const decision9968 = carded(9968, ['pm:decision', 'pm:blocking']);
+ // The defect, pinned: this is what the body-only index reported.
+ t('H14 false-stale: #9465 reads STALE against a body-only index', h14BlockingCacheIncoherent(epic9465, bodyOnlyIdx)?.includes('stale derived cache'), true);
+ t('H14 false-stale: #9968 reads STALE against a body-only index', h14BlockingCacheIncoherent(decision9968, bodyOnlyIdx)?.includes('stale derived cache'), true);
+ // The fix: the same two cards against the two-channel index.
+ t('H14 false-stale: #9465 is CLEAN once comment edges are read', h14BlockingCacheIncoherent(epic9465, unionIdx), null);
+ t('H14 false-stale: #9968 is CLEAN once comment edges are read', h14BlockingCacheIncoherent(decision9968, unionIdx), null);
+ t('H14 false-stale: …because #9709 and #9828 both point at #9465', unionIdx.get(9465).join(','), '9709,9828');
+ t('H14 false-stale: …and #9969 and #9652 both point at #9968', unionIdx.get(9968).join(','), '9969,9652');
+ // Direction B rides the same union: a comment-only edge is enough to call a
+ // card an invisible unblocker.
+ t('H14-B: a comment-only edge produces a missing-cache row', h14BlockingCacheIncoherent(carded(9465, ['domain:devx']), unionIdx)?.includes('#9709'), true);
+ t('H14-B: …and the sentence names the channel pair', h14BlockingCacheIncoherent(carded(9465, ['domain:devx']), unionIdx).includes('body or comment'), true);
+ t('H14-A: …the stale sentence names both channels too', h14BlockingCacheIncoherent(epic9465, bodyOnlyIdx).includes('body OR comment'), true);
+
+ // -- H14 under an INCOMPLETE index (a gated comment fetch failed) ----------
+ //
+ // Asymmetric on purpose: stale is a claim about ABSENT evidence and must not
+ // be made from an index known to be missing edges (#4690); missing is a
+ // claim about evidence in hand, which reading more sources can only add to.
+ t('H14-A: stale is SUSPENDED when the index is known incomplete', h14BlockingCacheIncoherent(epic9465, bodyOnlyIdx, { indexComplete: false }), null);
+ t('H14-A: …and still fires when the index is complete', typeof h14BlockingCacheIncoherent(epic9465, bodyOnlyIdx, { indexComplete: true }), 'string');
+ t('H14-A: …and completeness defaults to true for body-only callers', typeof h14BlockingCacheIncoherent(epic9465, bodyOnlyIdx), 'string');
+ t('H14-B: missing SURVIVES an incomplete index', h14BlockingCacheIncoherent(carded(9465, ['domain:devx']), unionIdx, { indexComplete: false })?.includes('#9709'), true);
+ t('H14-B: …and an earned label stays clean either way', h14BlockingCacheIncoherent(epic9465, unionIdx, { indexComplete: false }), null);
+
+ // The summary line carries the third `read X of Y` pair, and says out loud
+ // when the shortfall cost H14 its stale direction.
+ const fbCounts = (fallbackProbed, fallbackCandidates) => ({
+ repo: 'objectstack-ai/objectstack', issues: 1, unscoped: 1, prs: 0, merged: 0,
+ fallbackProbed, fallbackCandidates,
+ });
+ t('summary: the fallback pair is reported', summaryLine(fbCounts(24, 26), 3).includes('comment fallback read on 24 of 26 candidate(s)'), true);
+ t('summary: a shortfall announces the suspended stale direction', summaryLine(fbCounts(24, 26), 3).includes('stale direction is SUSPENDED'), true);
+ t('summary: a complete pass says nothing about suspension', summaryLine(fbCounts(26, 26), 3).includes('SUSPENDED'), false);
+ t('summary: absent fallback counts still render a sentence', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('comment fallback read on 0 of 0'), true);
+
+ // -- H15: oldest unclaimed `pm:blocking` (selection-order visibility) -------
+ const blockingCard = (number, { assignees = [], created = daysAgo(3) } = {}) => ({
+ ...issue(['pm:blocking', 'pm:queue'], assignees),
+ number,
+ created_at: created,
+ });
+
+ t('H15: the oldest unclaimed card is the one reported', h15OldestUnclaimedBlocking([blockingCard(10, { created: daysAgo(2) }), blockingCard(20, { created: daysAgo(9) }), blockingCard(30, { created: daysAgo(5) })], NOW).issue.number, 20);
+ t('H15: …and the row states the age in hours', h15OldestUnclaimedBlocking([blockingCard(20, { created: hoursAgo(220) })], NOW).message.includes('open ~220h'), true);
+ t('H15: …and names the selection-order rank it exists to police', h15OldestUnclaimedBlocking([blockingCard(20)], NOW).message.includes('second only to'), true);
+ t('H15: …and declares the age is the CARD\'s, not the label\'s', h15OldestUnclaimedBlocking([blockingCard(20)], NOW).message.includes("Age is the CARD's"), true);
+ t('H15: …and declares itself thresholdless visibility, not an alarm', h15OldestUnclaimedBlocking([blockingCard(20)], NOW).message.includes('no threshold'), true);
+ t('H15: the count separates unclaimed from the total', h15OldestUnclaimedBlocking([blockingCard(10), blockingCard(20, { assignees: ['os-help'] }), blockingCard(30, { assignees: ['os-help'] })], NOW).message.includes('1 of 3 open'), true);
+ // The two "no row" shapes the card names.
+ t('H15: every pm:blocking card assigned -> no row', h15OldestUnclaimedBlocking([blockingCard(10, { assignees: ['os-help'] }), blockingCard(20, { assignees: ['os-warren'] })], NOW), null);
+ t('H15: no pm:blocking card at all -> no row', h15OldestUnclaimedBlocking([{ ...issue(['pm:queue']), number: 10, created_at: daysAgo(30) }], NOW), null);
+ t('H15: an empty listing -> no row', h15OldestUnclaimedBlocking([], NOW), null);
+ t('H15: a missing listing -> no row', h15OldestUnclaimedBlocking(undefined, NOW), null);
+ // #4690 direction, restated for an ORDERING rather than a threshold: a
+ // timestamp that cannot be read must not quietly drop out of a claim about
+ // which card is oldest.
+ t('H15: unreadable created_at sorts as maximally old', h15OldestUnclaimedBlocking([blockingCard(10, { created: daysAgo(9) }), blockingCard(20, { created: 'not-a-date' })], NOW).issue.number, 20);
+ t('H15: …and the row says so rather than printing a number', h15OldestUnclaimedBlocking([blockingCard(20, { created: 'not-a-date' })], NOW).message.includes('unreadable `created_at`'), true);
+ // Built without the fixture helper on purpose: its `created = daysAgo(3)`
+ // default fills an `undefined`, so passing one through it tests the default
+ // rather than the absent field (measured — this case was green against a
+ // 3-day-old card before the fixture was written out longhand).
+ t('H15: absent created_at is unreadable too', h15OldestUnclaimedBlocking([{ ...issue(['pm:blocking']), number: 20 }], NOW).message.includes('unreadable `created_at`'), true);
+ // Stable output run to run: equal ages break by issue number, so the anchor
+ // body does not churn between two equally-old cards.
+ t('H15: equal ages break by issue number', h15OldestUnclaimedBlocking([blockingCard(30, { created: daysAgo(4) }), blockingCard(12, { created: daysAgo(4) })], NOW).issue.number, 12);
+
+ // Reverse verification against the LIVE board, 2026-08-19. Predicted before
+ // running: NULL — the board's only `pm:blocking` card (#7276) is assigned to
+ // `os-project-manager`, which is the card's own "all-assigned -> no row"
+ // shape, measured rather than invented. Then the same card with the assignee
+ // removed, to prove the null is the board's state and not a dead predicate.
+ const live7276 = (assignees) => ({
+ ...issue(['pm:queue', 'domain:devx', 'pm:blocking'], assignees),
+ number: 7276,
+ created_at: '2026-08-10T04:30:43Z',
+ });
+ t('H15 reverse-verify: the live board (only blocking card assigned) -> no row', h15OldestUnclaimedBlocking([live7276(['os-project-manager'])], Date.parse('2026-08-19T09:00:00Z')), null);
+ t('H15 reverse-verify: …the same card unassigned DOES produce the row', h15OldestUnclaimedBlocking([live7276([])], Date.parse('2026-08-19T09:00:00Z')).issue.number, 7276);
+ t('H15 reverse-verify: …and its measured age', h15OldestUnclaimedBlocking([live7276([])], Date.parse('2026-08-19T09:00:00Z')).message.includes('open ~220h'), true);
+
+ // -- H19: a block that outlived its blocker (2026-08-20) -------------------
+ // The two measured instances are the fixtures, and they are DIFFERENT
+ // shapes on purpose: one states its blocker in a COMMENT and the other in a
+ // backtick-decorated BODY line. A body-only reader would have caught one of
+ // the two, which is why the target list unions both channels.
+ const keyOf = (ref) => blockerTargetKey(ref, 'objectstack-ai/objectstack').key;
+
+ // The canonical key — three spellings, one issue, therefore one request.
+ t('H19 key: a bare local ref qualifies against the swept repo', keyOf({ repo: null, number: 10126 }), 'objectstack-ai/objectstack#10126');
+ t('H19 key: …as does the bare repo name', keyOf({ repo: 'objectstack', number: 10126 }), 'objectstack-ai/objectstack#10126');
+ t('H19 key: …and the fully qualified form', keyOf({ repo: 'objectstack-ai/objectstack', number: 10126 }), 'objectstack-ai/objectstack#10126');
+ t('H19 key: a local ref is marked local', blockerTargetKey({ repo: null, number: 1 }, 'objectstack-ai/objectstack').local, true);
+ // An unqualified SIBLING repo takes the swept repo's owner. The guess can
+ // only ever produce an unresolved target, never a false finding.
+ t('H19 key: an unqualified sibling repo takes the swept owner', keyOf({ repo: 'objectui', number: 4356 }), 'objectstack-ai/objectui#4356');
+ t('H19 key: …and is NOT local', blockerTargetKey({ repo: 'objectui', number: 4356 }, 'objectstack-ai/objectstack').local, false);
+ t('H19 key: a foreign owner is preserved verbatim', keyOf({ repo: 'vercel/next.js', number: 7 }), 'vercel/next.js#7');
+
+ // The target list — both channels, deduped, self-references dropped.
+ const blockedCard = (number, body = '', labels = ['pm:blocked']) => ({ ...issue(labels, [], body), number });
+ const keysOf = (issueObj, comments) =>
+ blockerTargetsFor(issueObj, comments, 'objectstack-ai/objectstack').map((t2) => t2.key).join(' ');
+ t('H19 targets: the body channel', keysOf(blockedCard(1, 'Blocked-by: #9612')), 'objectstack-ai/objectstack#9612');
+ t('H19 targets: the comment channel', keysOf(blockedCard(1, 'no line here'), ['Blocked-by: #10126']), 'objectstack-ai/objectstack#10126');
+ t('H19 targets: both channels are UNIONED, never prioritised', keysOf(blockedCard(1, 'Blocked-by: #9612'), ['Blocked-by: #10126']), 'objectstack-ai/objectstack#9612 objectstack-ai/objectstack#10126');
+ t('H19 targets: one target stated in both channels is resolved once', keysOf(blockedCard(1, 'Blocked-by: #9612'), ['Blocked-by: #9612']), 'objectstack-ai/objectstack#9612');
+ t('H19 targets: a self-reference is dropped', keysOf(blockedCard(500, 'Blocked-by: #500')), '');
+ t('H19 targets: …but a same-numbered CROSS-REPO ref is not a self-reference', keysOf(blockedCard(4356, 'Blocked-by: objectui#4356')), 'objectstack-ai/objectui#4356');
+ t('H19 targets: an unreadable comment thread contributes nothing', keysOf(blockedCard(1, 'no line'), null), '');
+ t('H19 targets: an unconsulted comment thread contributes nothing', keysOf(blockedCard(1, 'no line'), undefined), '');
+ t('H19 targets: a card with no line anywhere has no targets (H4\'s row, not this one)', keysOf(blockedCard(1, 'waiting on upstream'), ['triage note']), '');
+ // The multi-ref line the index's own parser already handles, seen from here.
+ t('H19 targets: a two-ref line yields two targets, in order', keysOf(blockedCard(1, 'Blocked-by: #10126, #9612')), 'objectstack-ai/objectstack#10126 objectstack-ai/objectstack#9612');
+
+ // The gathering gate.
+ t('H19 gate: an open pm:blocked card is in scope', needsBlockerLiveness(blockedCard(1, 'Blocked-by: #2')), true);
+ t('H19 gate: a Blocked-by line WITHOUT the label is out of scope', needsBlockerLiveness(blockedCard(1, 'Blocked-by: #2', ['pm:queue'])), false);
+ t('H19 gate: a pm:blocking card is out of scope (that is H14\'s population)', needsBlockerLiveness(blockedCard(1, '', ['pm:blocking'])), false);
+ t('H19 gate: a missing issue does not crash', needsBlockerLiveness(undefined), false);
+
+ // The predicate. Resolutions are what the sweep resolved, so the offline
+ // fixtures are the three target states and their combinations.
+ const target = (number, state, extra = {}) => ({
+ key: `objectstack-ai/objectstack#${number}`,
+ repo: 'objectstack-ai/objectstack',
+ number,
+ local: true,
+ state,
+ closedAt: null,
+ detail: null,
+ ...extra,
+ });
+ const foreign = (repo, number, state, extra = {}) => ({
+ key: `${repo}#${number}`,
+ repo,
+ number,
+ local: false,
+ state,
+ closedAt: null,
+ detail: null,
+ ...extra,
+ });
+
+ // POSITIVE — a closed target fires.
+ const expired10112 = h19BlockOutlivedBlocker(blockedCard(10112), [target(10126, 'closed', { closedAt: '2026-08-20T09:03:37Z' })]);
+ t('H19: a CLOSED target fires', typeof expired10112, 'string');
+ t('H19: …and names the target', expired10112.includes('`#10126`'), true);
+ t('H19: …with the close timestamp, so the latency is readable off the row', expired10112.includes('closed 2026-08-20T09:03:37Z'), true);
+ t('H19: …and says the block outlived its blocker', expired10112.includes('outlived its blocker'), true);
+ t('H19: …and says nothing else here asks this question', expired10112.includes('H4 asks whether the line EXISTS'), true);
+ t('H19: …and hands the release to the unlock sweep\'s double-checks', expired10112.includes('放行双查'), true);
+ t('H19: …naming double-check ① (most recent conversion comment)', expired10112.includes('MOST RECENT conversion comment'), true);
+ t('H19: …and double-check ② (a newer merged PR refuses release)', expired10112.includes('MERGED PR newer than that conversion comment'), true);
+ t('H19: …and forbids a label written from this script', expired10112.includes('never a label written from this script'), true);
+ t('H19: a fully discharged block says every target is closed', expired10112.includes('Every target it names is closed'), true);
+ t('H19: …and does not claim a partial discharge', expired10112.includes('PARTIAL'), false);
+
+ // NEGATIVE — an open target is clean, and silence here is a real reading.
+ t('H19: an OPEN target -> clean', h19BlockOutlivedBlocker(blockedCard(1), [target(2, 'open')]), null);
+ t('H19: every target open -> clean', h19BlockOutlivedBlocker(blockedCard(1), [target(2, 'open'), foreign('objectstack-ai/objectui', 4356, 'open')]), null);
+ t('H19: no targets at all -> no row (H4 owns the missing line)', h19BlockOutlivedBlocker(blockedCard(1), []), null);
+ t('H19: absent resolutions -> no row', h19BlockOutlivedBlocker(blockedCard(1), undefined), null);
+ t('H19: the label gate outranks a closed target', h19BlockOutlivedBlocker(blockedCard(1, '', ['pm:queue']), [target(2, 'closed')]), null);
+
+ // PARTIAL — one of two closed. Fires, and says it is partial.
+ const partial = h19BlockOutlivedBlocker(blockedCard(1), [target(2, 'closed', { closedAt: '2026-08-20T07:58:08Z' }), target(3, 'open')]);
+ t('H19: one closed of two still fires', typeof partial, 'string');
+ t('H19: …and reports the count as 1 of 2', partial.includes('1 of 2 `Blocked-by:` target(s)'), true);
+ t('H19: …names it a PARTIAL discharge', partial.includes('PARTIAL'), true);
+ t('H19: …names the target that is still open', partial.includes('`#3`'), true);
+ t('H19: …and does not decide the card is unblocked', partial.includes('it does not decide it'), true);
+ t('H19: two closed of two reads as 2 of 2', h19BlockOutlivedBlocker(blockedCard(1), [target(2, 'closed'), target(3, 'closed')]).includes('2 of 2'), true);
+
+ // UNRESOLVED — never reads as clean, and never reads as closed either.
+ const unresolvedOnly = h19BlockOutlivedBlocker(blockedCard(1), [foreign('objectstack-ai/cloud', 88, 'unresolved', { detail: 'HTTP 404' })]);
+ t('H19: an UNRESOLVED target fires rather than reading clean', typeof unresolvedOnly, 'string');
+ t('H19: …saying the liveness is UNJUDGED', unresolvedOnly.includes('UNJUDGED, not confirmed'), true);
+ t('H19: …and never claims the block is expired', unresolvedOnly.includes('outlived its blocker. Nothing else here'), false);
+ t('H19: …citing the unreadable-is-not-absent rule', unresolvedOnly.includes('#4690'), true);
+ t('H19: …naming the cross-repo target in full owner/repo#N form', unresolvedOnly.includes('`objectstack-ai/cloud#88`'), true);
+ t('H19: …with the observed status', unresolvedOnly.includes('HTTP 404'), true);
+ t('H19: …and refuses to guess WHY it did not resolve', unresolvedOnly.includes('the cause is not guessed at'), true);
+ t('H19: …and still routes the release through the unlock sweep', unresolvedOnly.includes('放行双查'), true);
+ // An unresolved target alongside an open one still fires, and says which.
+ const mixedUnresolved = h19BlockOutlivedBlocker(blockedCard(1), [target(2, 'open'), foreign('objectstack-ai/objectui', 4356, 'unresolved', { detail: 'HTTP 403' })]);
+ t('H19: unresolved + open still fires', typeof mixedUnresolved, 'string');
+ t('H19: …and reports the resolved remainder as open', mixedUnresolved.includes("The card's other 1 target(s) did resolve, and are still open."), true);
+ // Closed AND unresolved: the closed row leads, the gap is appended.
+ const closedAndUnresolved = h19BlockOutlivedBlocker(blockedCard(1), [target(2, 'closed'), foreign('objectstack-ai/cloud', 88, 'unresolved', { detail: 'HTTP 404' })]);
+ t('H19: a closed target leads even when another is unresolved', closedAndUnresolved.includes('outlived its blocker'), true);
+ t('H19: …and the unresolved one is still declared unjudged', closedAndUnresolved.includes('unjudged, not open'), true);
+
+ // The render budget: many targets are capped and the row says it counted.
+ const manyClosed = h19BlockOutlivedBlocker(blockedCard(1), [2, 3, 4, 5, 6, 7, 8].map((n) => target(n, 'closed')));
+ t('H19: the target list is capped at the render budget', manyClosed.includes(`+${7 - H19_TARGET_LIST_CAP} more`), true);
+ t('H19: …and the count is the full one, not the shown one', manyClosed.includes('7 of 7'), true);
+
+ // Report-only, ordinary row in both media — never loud, like H14–H16/H18.
+ t('H19: not a loud finding', isLoudFinding(expired10112), false);
+
+ // -- H19: the two MEASURED instances, byte-for-byte ------------------------
+ // Instance ①: the comment-channel card. Its `Blocked-by:` line lives in a
+ // triage first-touch comment; the target closed at 09:03:37Z and the card
+ // sat blocked ~4.5h after that. This is the fixture that makes the comment
+ // channel load-bearing rather than a nicety.
+ const liveTriageComment =
+ 'Triage first-touch: graded **Bug · `domain:cli` · `pm:blocked`**.\n\nBlocked-by: #10126\n\nRationale: ' +
+ '#10126 (in flight, `priority:p0`, queue-incident layer ①) is building the gate that flags exactly ' +
+ "this site's class — a test resolving a sibling package's dist";
+ t('H19 measured ①: the live triage comment yields the target', keysOf(blockedCard(10112, 'body carries no line'), [liveTriageComment]), 'objectstack-ai/objectstack#10126');
+ t('H19 measured ①: …a body-only read would have found nothing', keysOf(blockedCard(10112, 'body carries no line')), '');
+ t(
+ 'H19 measured ①: …and the card fires once its target is resolved closed',
+ h19BlockOutlivedBlocker(blockedCard(10112, 'body carries no line'), [target(10126, 'closed', { closedAt: '2026-08-20T09:03:37Z' })]).includes('`#10126` (closed 2026-08-20T09:03:37Z)'),
+ true,
+ );
+
+ // Instance ②: the body-channel card, whose line is backtick-DECORATED —
+ // the shape that was invisible to the reader before the shared decorated-
+ // directive reader landed. Byte-for-byte from the live body.
+ const liveDecoratedBody =
+ 'Filed unassigned from #9612 (PR #10058), which implements package-closure narrowing at the runtime ' +
+ 'publish gate. Recording the half that card\'s fence could not reach.\n\n`Blocked-by: #9612`\n\n' +
+ '## What is true after #9612';
+ t('H19 measured ②: the decorated body line yields the target', keysOf(blockedCard(10063, liveDecoratedBody)), 'objectstack-ai/objectstack#9612');
+ t(
+ 'H19 measured ②: …and the card fires once its target is resolved closed',
+ h19BlockOutlivedBlocker(blockedCard(10063, liveDecoratedBody), [target(9612, 'closed', { closedAt: '2026-08-20T07:58:08Z' })]).includes('closed 2026-08-20T07:58:08Z'),
+ true,
+ );
+ // The prose around the line names #9612 four more times; only the DIRECTIVE
+ // line is a target. Reading the prose would manufacture duplicates and, on
+ // other cards, blockers that were only ever context.
+ t('H19 measured ②: prose mentions of the same number are not extra targets', blockerTargetsFor(blockedCard(10063, liveDecoratedBody), undefined, 'objectstack-ai/objectstack').length, 1);
+
+ // The summary line's fourth `read X of Y` pair. Unlike the other three a
+ // shortfall here suspends nothing — the unresolved targets fire their own
+ // rows — so the clause says where to look rather than announcing a silence.
+ const btCounts = (blockerResolved, blockerTargets) => ({
+ repo: 'objectstack-ai/objectstack', issues: 1, unscoped: 1, prs: 0, merged: 0,
+ blockerResolved, blockerTargets,
+ });
+ t('summary: the H19 coverage pair is reported', summaryLine(btCounts(11, 12), 1).includes('targets resolved on 11 of 12 distinct `Blocked-by:` target(s)'), true);
+ t('summary: …and says the unit is DISTINCT targets, not per-card edges', summaryLine(btCounts(11, 12), 1).includes('distinct'), true);
+ t('summary: …scoped to the population H19 judges', summaryLine(btCounts(11, 12), 1).includes('named by open `pm:blocked` card(s)'), true);
+ t('summary: an H19 shortfall points at the rows that carry it', summaryLine(btCounts(11, 12), 1).includes('each unresolved target is named on its own card\'s row, never dropped'), true);
+ t('summary: a complete H19 pass adds no shortfall clause', summaryLine(btCounts(12, 12), 1).includes('never dropped'), false);
+ t('summary: absent H19 counts degrade to 0, never to undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('resolved on 0 of 0 distinct'), true);
+ t('summary: …and the H19 clause never prints the string undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('undefined'), false);
+ t('summary: the report-only contract still ends the sentence', summaryLine(btCounts(12, 12), 1).endsWith('not a gate verdict.'), true);
+
+ // -- H20: a `pm:dispatched` card nobody is working (#10312) ----------------
+ //
+ // The specimen is #8878, 2026-08-20: a well-formed claim comment at ~14:05Z,
+ // the dispatch call never made, 74 minutes `pm:dispatched` with nobody on it.
+ // `NOW_20` is set 74 minutes past that claim so the measured age is the one
+ // the incident actually had, rather than a round number chosen to pass.
+ const NOW_20 = Date.parse('2026-08-20T15:19:00Z');
+ const minsAgo20 = (m) => new Date(NOW_20 - m * 60_000).toISOString();
+ const dispatchedCard = (labels = ['pm:dispatched'], assignees = ['os-help']) => issue(labels, assignees);
+ const claimRow = (createdAt, body) => ({ created_at: createdAt, body });
+ // The claim template as SKILL.md step 4 tells every seat to write it, and as
+ // the measured card carried it — a `Claim:` marker line, a session, and a
+ // BACKTICK-DECORATED `Branch:` directive.
+ const claimBody8878 = [
+ 'Claim: `domain:cli` execution seat.',
+ 'Session: `session_019bmVFqoQPq63zhKrxdYG1r`',
+ 'Branch: `claude/issue-8878-dispatch-latency`',
+ 'Worktree: dedicated per-task worktree off main (os-dev standard)',
+ ].join('\n');
+ const claim8878 = [claimRow(minsAgo20(74), claimBody8878)];
+
+ // The extractor. Decoration is the NORMAL shape, not the exception (#10102).
+ t('H20 branch: the decorated `Branch:` directive yields the branch', claimedBranches(claimBody8878).join(','), 'claude/issue-8878-dispatch-latency');
+ t('H20 branch: an UNdecorated directive yields the same', claimedBranches('Branch: claude/issue-8878-x').join(','), 'claude/issue-8878-x');
+ t('H20 branch: the blockquoted claim template (SKILL.md step 4) is read', claimedBranches('> Branch: `claude/issue-6752-x`').join(','), 'claude/issue-6752-x');
+ t('H20 branch: a slug with dots and underscores survives', claimedBranches('Branch: claude/issue-1-a.b_c-d').join(','), 'claude/issue-1-a.b_c-d');
+ t('H20 branch: two directives yield two branches, in order', claimedBranches('Branch: `claude/issue-1-a`\nBranch: `claude/issue-2-b`').join(','), 'claude/issue-1-a,claude/issue-2-b');
+ t('H20 branch: the same branch named twice is de-duplicated', claimedBranches('Branch: claude/issue-1-a\nBranches: claude/issue-1-a').length, 1);
+ // The two under-reporting boundaries, both deliberate: a branch-shaped token
+ // in PROSE is not a claim's branch field, and a non-protocol spelling leaves
+ // this row nothing it can recognise. Both put the card out of scope rather
+ // than into a fabricated probe.
+ t('H20 branch: a branch-shaped token in prose is NOT the claim field', claimedBranches('rebased onto claude/issue-9-other yesterday').length, 0);
+ t('H20 branch: a non-protocol branch name is out of scope, not a probe', claimedBranches('Branch: `main`').length, 0);
+ t('H20 branch: no directive at all yields nothing', claimedBranches('Claim: seat.\nSession: `session_x`').length, 0);
+ t('H20 branch: a missing body does not crash', claimedBranches(undefined).length, 0);
+
+ // The governing claim — the MOST RECENT one, H19's double-check ① reasoning.
+ const gov = (rows) => governingClaim(rows);
+ t('H20 claim: a claim comment naming a branch is found', gov(claim8878).branches.join(','), 'claude/issue-8878-dispatch-latency');
+ t('H20 claim: …and carries its timestamp', gov(claim8878).createdAt, minsAgo20(74));
+ t('H20 claim: a `Branch:` line in a comment that is NOT a claim is ignored', gov([claimRow(minsAgo20(90), 'Branch: `claude/issue-1-a`')]), null);
+ t('H20 claim: a claim comment naming NO branch yields nothing to check', gov([claimRow(minsAgo20(90), 'Claim: seat.\nSession: `session_x`')]), null);
+ const reclaimed = [
+ claimRow(minsAgo20(600), 'Claim: first seat.\nBranch: `claude/issue-8878-abandoned`'),
+ claimRow(minsAgo20(74), claimBody8878),
+ ];
+ t('H20 claim: a RE-claimed card is judged on the most recent claim', gov(reclaimed).branches.join(','), 'claude/issue-8878-dispatch-latency');
+ t('H20 claim: …and on that claim\'s timestamp, not the spent one', gov(reclaimed).createdAt, minsAgo20(74));
+ t('H20 claim: recency is read from the timestamp, not thread order', gov([...reclaimed].reverse()).branches.join(','), 'claude/issue-8878-dispatch-latency');
+ t('H20 claim: an unreadable timestamp still qualifies the comment', gov([claimRow('not-a-date', claimBody8878)]).branches.length, 1);
+ t('H20 claim: …and reports no age rather than a fresh one (#4690)', claimAgeMinutes(gov([claimRow('not-a-date', claimBody8878)]), NOW_20), null);
+ t('H20 claim: an empty thread yields nothing', gov([]), null);
+ t('H20 claim: a non-array input does not crash', gov(undefined), null);
+ t('H20 claim: the measured age is read back as ~74 minutes', Math.round(claimAgeMinutes(gov(claim8878), NOW_20)), 74);
+
+ // The gathering policy — what buys a ref read at all.
+ t('H20 gate: a dispatched card with an aged claim is a candidate', h20NeedsRefProbe(dispatchedCard(), gov(claim8878), NOW_20), true);
+ t('H20 gate: an UNASSIGNED dispatched card is still a candidate', h20NeedsRefProbe(dispatchedCard(['pm:dispatched'], []), gov(claim8878), NOW_20), true);
+ t('H20 gate: a young claim buys no request', h20NeedsRefProbe(dispatchedCard(), gov([claimRow(minsAgo20(10), claimBody8878)]), NOW_20), false);
+ t('H20 gate: a card without `pm:dispatched` is out of scope', h20NeedsRefProbe(dispatchedCard(['pm:queue']), gov(claim8878), NOW_20), false);
+ t('H20 gate: no claim -> nothing to probe (that shape is H2\'s row)', h20NeedsRefProbe(dispatchedCard(), null, NOW_20), false);
+ t('H20 gate: an unreadable claim timestamp is probed, never assumed fresh', h20NeedsRefProbe(dispatchedCard(), gov([claimRow('not-a-date', claimBody8878)]), NOW_20), true);
+ t('H20 gate: a missing issue does not crash', h20NeedsRefProbe(undefined, gov(claim8878), NOW_20), false);
+
+ // ★ The measured #8878 shape: a complete claim, and no ref anywhere.
+ const refState = (branch, state, detail = null) => ({ branch, state, detail });
+ const absentRef = [refState('claude/issue-8878-dispatch-latency', 'absent')];
+ const fired20 = h20DispatchedNoBranchRef(dispatchedCard(), gov(claim8878), absentRef, NOW_20);
+ t('H20: the measured #8878 shape FIRES', typeof fired20, 'string');
+ t('H20: …and names the branch that has no ref', fired20.includes('`claude/issue-8878-dispatch-latency`'), true);
+ t('H20: …and says NO SUCH REMOTE REF EXISTS', fired20.includes('NO SUCH REMOTE REF EXISTS'), true);
+ t('H20: …with the measured age and the threshold', fired20.includes(`~74 min after the claim was posted (threshold ${DISPATCHED_NO_REF_STALE_MINUTES} min)`), true);
+ t('H20: …and states the two-acts mechanism', fired20.includes('Claiming and dispatching are two acts with a gap between them'), true);
+ t('H20: …and that it is invisible from the card itself', fired20.includes('invisible from the card'), true);
+ t('H20: …and warns the symptom is identical to a DEAD agent', fired20.includes('IDENTICAL to a dev agent that died'), true);
+ t('H20: …naming the opposite remedies rather than diagnosing one', fired20.includes('a dead agent needs a probe, an undispatched claim needs a dispatch'), true);
+ t('H20: …and carries the ⛔ keying rule verbatim', fired20.includes('keys on NO REF AT ALL, never on "no PR yet"'), true);
+ t('H20: …with the reason a PR key would be wrong', fired20.includes('legitimately has a ref and no PR for over an hour'), true);
+ t('H20: …and routes an already-merged delivery to H8 instead', fired20.includes("the missing paired write is H8's"), true);
+ t('H20: …and forbids a label written from this script', fired20.includes('never a label written from this script'), true);
+ t('H20: not a loud finding', isLoudFinding(fired20), false);
+
+ // ★ The regression pin the filing card asked for by name: a dev inside a long
+ // build has a ref and NO PR, for longer than the threshold, and must be
+ // silent. The guarantee is structural — the predicate is handed a ref state
+ // and nothing else, so there is no PR input it could key on. The arity pin
+ // is what fails if a later hand adds one.
+ t(
+ 'H20: ref EXISTS and no PR anywhere, 10 hours in -> clean (⛔ never key on "no PR yet")',
+ h20DispatchedNoBranchRef(dispatchedCard(), gov([claimRow(minsAgo20(600), claimBody8878)]), [refState('claude/issue-8878-dispatch-latency', 'exists')], NOW_20),
+ null,
+ );
+ t('H20: …and the predicate takes NO pull-request input at all', h20DispatchedNoBranchRef.length, 3);
+
+ // ★ A young claim is not stuck. The dev may be seconds from its first push.
+ t(
+ 'H20: a young claim with no ref yet -> clean',
+ h20DispatchedNoBranchRef(dispatchedCard(), gov([claimRow(minsAgo20(10), claimBody8878)]), absentRef, NOW_20),
+ null,
+ );
+ t(
+ 'H20: exactly at the threshold -> still clean',
+ h20DispatchedNoBranchRef(dispatchedCard(), gov([claimRow(minsAgo20(DISPATCHED_NO_REF_STALE_MINUTES), claimBody8878)]), absentRef, NOW_20),
+ null,
+ );
+ t(
+ 'H20: one minute past the threshold -> fires',
+ typeof h20DispatchedNoBranchRef(dispatchedCard(), gov([claimRow(minsAgo20(DISPATCHED_NO_REF_STALE_MINUTES + 1), claimBody8878)]), absentRef, NOW_20),
+ 'string',
+ );
+
+ // ★ Three ref states, never two (#4690). An unreadable probe gets the QUIETER
+ // row: it must not read as healthy, and it must not claim the finding it did
+ // not measure.
+ const unread20 = h20DispatchedNoBranchRef(
+ dispatchedCard(),
+ gov(claim8878),
+ [refState('claude/issue-8878-dispatch-latency', 'unreadable', 'HTTP 500')],
+ NOW_20,
+ );
+ t('H20 unreadable: does NOT read as healthy', unread20 === null, false);
+ t('H20 unreadable: fires its own row', typeof unread20, 'string');
+ t('H20 unreadable: …which says the dispatch is UNJUDGED', unread20.includes('UNJUDGED, not confirmed'), true);
+ t('H20 unreadable: …and reports the observed status', unread20.includes('HTTP 500'), true);
+ t('H20 unreadable: …and does NOT assert the finding it did not measure', unread20.includes('NO SUCH REMOTE REF EXISTS'), false);
+ t('H20 unreadable: …citing the unread-is-not-absent rule', unread20.includes('#4690'), true);
+ t('H20 unreadable: …and refuses to guess WHY', unread20.includes('the cause is not guessed at'), true);
+ t('H20 unreadable: not a loud finding either', isLoudFinding(unread20), false);
+
+ // Mixed readings. "No ref at all" is a claim about EVERY branch the card
+ // names, so one unread probe is enough to withhold it — and one existing ref
+ // is enough to call the card healthy.
+ const mixedUnread20 = h20DispatchedNoBranchRef(
+ dispatchedCard(),
+ gov([claimRow(minsAgo20(74), 'Claim: seat.\nBranch: `claude/issue-1-a`\nBranch: `claude/issue-1-b`')]),
+ [refState('claude/issue-1-a', 'absent'), refState('claude/issue-1-b', 'unreadable', 'HTTP 502')],
+ NOW_20,
+ );
+ t('H20 mixed: absent + unreadable takes the quieter row', mixedUnread20.includes('UNJUDGED, not confirmed'), true);
+ t('H20 mixed: …and still names the branch that resolved absent', mixedUnread20.includes('`claude/issue-1-a`'), true);
+ t('H20 mixed: …explaining why one unread probe withholds the finding', mixedUnread20.includes('one unread probe is enough to withhold it'), true);
+ t(
+ 'H20 mixed: one branch that DOES exist reads the card as worked',
+ h20DispatchedNoBranchRef(
+ dispatchedCard(),
+ gov([claimRow(minsAgo20(74), 'Claim: seat.\nBranch: `claude/issue-1-a`\nBranch: `claude/issue-1-b`')]),
+ [refState('claude/issue-1-a', 'absent'), refState('claude/issue-1-b', 'exists')],
+ NOW_20,
+ ),
+ null,
+ );
+
+ // The remaining gates and the caller contract.
+ t('H20: the label gate outranks a missing ref', h20DispatchedNoBranchRef(dispatchedCard(['pm:queue']), gov(claim8878), absentRef, NOW_20), null);
+ t('H20: no claim -> no row (a missing claim is H2\'s row, not this one)', h20DispatchedNoBranchRef(dispatchedCard(), null, absentRef, NOW_20), null);
+ t('H20: an unprobed card yields no row (caller contract, as H19)', h20DispatchedNoBranchRef(dispatchedCard(), gov(claim8878), [], NOW_20), null);
+ t('H20: absent ref states -> no row', h20DispatchedNoBranchRef(dispatchedCard(), gov(claim8878), undefined, NOW_20), null);
+ t('H20: a missing issue does not crash', h20DispatchedNoBranchRef(undefined, gov(claim8878), absentRef, NOW_20), null);
+ const unstamped20 = h20DispatchedNoBranchRef(dispatchedCard(), gov([claimRow('not-a-date', claimBody8878)]), absentRef, NOW_20);
+ t('H20: an unreadable claim timestamp fires rather than reading fresh', typeof unstamped20, 'string');
+ t('H20: …and says so in place of an age', unstamped20.includes('an unreadable claim timestamp (which must not read as fresh)'), true);
+ const many20 = Array.from({ length: 7 }, (_, i) => refState(`claude/issue-1-b${i}`, 'absent'));
+ const capped20 = h20DispatchedNoBranchRef(
+ dispatchedCard(),
+ gov([claimRow(minsAgo20(74), `Claim: seat.\n${many20.map((r) => `Branch: \`${r.branch}\``).join('\n')}`)]),
+ many20,
+ NOW_20,
+ );
+ t('H20: the branch list is capped at the render budget', capped20.includes(`+${7 - H20_BRANCH_LIST_CAP} more`), true);
+
+ // The summary line's fifth `read X of Y` pair — H19's shape, and owed for the
+ // same reason: a pass that read no ref at all must not read like a board on
+ // which every dispatch is live.
+ const refCounts = (dispatchRefRead, dispatchRefTargets) => ({
+ repo: 'objectstack-ai/objectstack', issues: 1, unscoped: 1, prs: 0, merged: 0,
+ dispatchRefRead, dispatchRefTargets,
+ });
+ t('summary: the H20 coverage pair is reported', summaryLine(refCounts(4, 5), 1).includes('remote branch read on 4 of 5 distinct claimed branch(es)'), true);
+ t('summary: …scoped to the population H20 judges', summaryLine(refCounts(4, 5), 1).includes('named by open `pm:dispatched` card(s)'), true);
+ t('summary: …and names the threshold that bounded it', summaryLine(refCounts(4, 5), 1).includes(`past the ${DISPATCHED_NO_REF_STALE_MINUTES}-minute threshold`), true);
+ t('summary: an H20 shortfall points at the rows that carry it', summaryLine(refCounts(4, 5), 1).includes('each unread branch is named on its own card\'s row, never dropped'), true);
+ t('summary: a complete H20 pass adds no shortfall clause', summaryLine(refCounts(5, 5), 1).includes('each unread branch'), false);
+ t('summary: absent H20 counts degrade to 0, never to undefined', summaryLine({ repo: 'r', issues: 1, unscoped: 1, prs: 0, merged: 0 }, 0).includes('remote branch read on 0 of 0 distinct'), true);
+ // The pair is H27's coverage number too, and a reader seeing a quiet H27
+ // needs to know that — one read serves both rows.
+ t('summary: the pair is declared as serving BOTH rows', summaryLine(refCounts(5, 5), 1).includes('Dispatch liveness (H20 + H27)'), true);
+ t('summary: …naming H27\'s threshold', summaryLine(refCounts(5, 5), 1).includes(`H27's ${DEAD_CLAIM_STALE_HOURS}h population is a subset`), true);
+ t('summary: …and that it costs no request of its own', summaryLine(refCounts(5, 5), 1).includes('costs no request of its own'), true);
+ t('summary: the report-only contract still ends the sentence after H20', summaryLine(refCounts(5, 5), 1).endsWith('not a gate verdict.'), true);
+
+ // -- H27: the claim is PERFECT and the claimant is dead (#11248) ------------
+ //
+ // The specimen is the 2026-08-23 capacity kill: three agents dispatched at
+ // ~05:46Z, all three killed at ~05:50Z on one shared-account weekly limit.
+ // `NOW_27` is set past the protocol's own 24h stale line so the age the row
+ // reports is a real one rather than a round number chosen to pass.
+ const CLAIM_27 = '2026-08-23T05:46:00Z';
+ const NOW_27 = Date.parse('2026-08-24T08:00:00Z'); // ~26h after the claim
+ const claim27Body = [
+ 'Claim: `domain:devx` execution seat.',
+ 'Session: `session_0124Qg8rLvpXnQDwCmpKUmaJ`',
+ 'Branch: `claude/issue-5442-metadata-form`',
+ ].join('\n');
+ const claim27 = (createdAt = CLAIM_27) => governingClaim([claimRow(createdAt, claim27Body)]);
+ const BR_27 = 'claude/issue-5442-metadata-form';
+ // A branch that exists and has NOT moved since the claim: its head is the
+ // base commit it was cut from, which predates the claim. That is the shape
+ // the os-dev empty-branch push probe leaves behind.
+ const frozen = (headCommittedAt = '2026-08-23T05:10:00Z') => [
+ { branch: BR_27, state: 'exists', detail: null, headCommittedAt },
+ ];
+ const noDelivery = { open: 0, merged: 0 };
+ const dead27 = (over = {}) =>
+ h27DeadClaimNoProgress(
+ // `in` rather than `??` throughout: a test that passes an explicit
+ // `undefined` must reach the predicate, not be replaced by the default it
+ // is trying to displace (measured — the missing-issue case was green
+ // against a healthy card before this was written out longhand).
+ 'issue' in over ? over.issue : dispatchedCard(),
+ 'claim' in over ? over.claim : claim27(),
+ 'refs' in over ? over.refs : frozen(),
+ 'delivery' in over ? over.delivery : noDelivery,
+ NOW_27,
+ );
+
+ // ★ The finding itself, and the facts the sentence must carry.
+ t('H27: a frozen branch + no PR past 24h -> finding', typeof dead27(), 'string');
+ t('H27: …and names the branch', dead27().includes(`\`${BR_27}\``), true);
+ t('H27: …and says the branch has not moved since the claim', dead27().includes('NOT MOVED SINCE IT WAS CLAIMED'), true);
+ t('H27: …and reports the age against the protocol threshold', dead27().includes(`threshold ${DEAD_CLAIM_STALE_HOURS}h`), true);
+ t('H27: …calling that threshold the protocol\'s own line, not a heuristic', dead27().includes("protocol's own stale-claim line"), true);
+ t('H27: …and states the measured ~26h age', dead27().includes('~26h after the claim was posted'), true);
+ t('H27: …and names the lane-block consequence, not just the silence', dead27().includes('mutual-exclusion read'), true);
+ t('H27: …and explains WHY H20 cannot see it', dead27().includes('pushing the empty branch the first action'), true);
+ t('H27: …and rules out the pre-window merged delivery first', dead27().includes(`${MERGED_WINDOW_PAGES} pages`), true);
+ t('H27: not a loud finding', isLoudFinding(dead27()), false);
+
+ // ★ Report-only, and specifically NOT a reclaim — the protocol protects a
+ // claim whose branch carries commits, so this row must never read as
+ // authority to drop an assignee.
+ t('H27: the remedy is the recovery inspection', dead27().includes('post-kill recovery'), true);
+ t('H27: …naming all three recovery states', dead27().includes('on the remote / on the container disk only / gone'), true);
+ t('H27: …and the UNVERIFIED hand-off', dead27().includes('flagged UNVERIFIED'), true);
+ t('H27: …quoting the protocol rule that forbids reclaiming this card', dead27().includes('有带提交活分支的认领永不回收'), true);
+ t('H27: …and never a label written from this script', dead27().includes('Never a label written from this script'), true);
+
+ // ★ Disjoint from H20 BY CONSTRUCTION, in both directions, on one fixture.
+ const absent27 = [{ branch: BR_27, state: 'absent', detail: null, headCommittedAt: null }];
+ t('H27: no ref at all is H20\'s row, not this one', dead27({ refs: absent27 }), null);
+ t('H27: …and H20 does fire on it', typeof h20DispatchedNoBranchRef(dispatchedCard(), claim27(), absent27, NOW_27), 'string');
+ t('H27: an existing ref is clean for H20', h20DispatchedNoBranchRef(dispatchedCard(), claim27(), frozen(), NOW_27), null);
+ t('H27: …while H27 fires on exactly that card', typeof dead27(), 'string');
+ t('H27: an UNREADABLE probe is H20\'s quieter row, not this one', dead27({ refs: [{ branch: BR_27, state: 'unreadable', detail: 'HTTP 500', headCommittedAt: null }] }), null);
+
+ // ★ The branch-activity term. A dev that pushed after claiming is ALIVE for
+ // this row — and the under-report it implies is deliberate (see the docblock).
+ t('H27: a branch that moved AFTER the claim -> clean', dead27({ refs: frozen('2026-08-23T05:49:00Z') }), null);
+ t('H27: a branch whose head predates the claim -> finding', typeof dead27({ refs: frozen('2026-08-22T09:00:00Z') }), 'string');
+ t('H27: one moved branch among frozen ones clears the card', dead27({ refs: [...frozen(), { branch: 'claude/issue-5442-b', state: 'exists', headCommittedAt: '2026-08-23T09:00:00Z' }] }), null);
+ t('H27: activity is measured against the CLAIM, not the threshold', dead27({ refs: frozen('2026-08-23T05:47:00Z') }), null);
+ // Three-valued, never two (#4690): an unreadable comparison is not a "no".
+ t('H27: an unreadable head timestamp does NOT read as healthy', dead27({ refs: frozen(null) }) === null, false);
+ t('H27: …and fires the quieter UNJUDGED row instead', dead27({ refs: frozen(null) }).includes('UNJUDGED, not confirmed healthy'), true);
+ t('H27: …which does not assert the finding it did not measure', dead27({ refs: frozen(null) }).includes('NOT MOVED SINCE IT WAS CLAIMED'), false);
+ t('H27: …citing the unread-is-not-absent rule', dead27({ refs: frozen(null) }).includes('#4690'), true);
+ t('H27: an absent head field reads as unknown, not as an old date', dead27({ refs: [{ branch: BR_27, state: 'exists' }] }).includes('UNJUDGED'), true);
+ t('H27: branchMovedSinceClaim is three-valued', [branchMovedSinceClaim(frozen()[0], claim27()), branchMovedSinceClaim(frozen('2026-08-23T09:00:00Z')[0], claim27()), branchMovedSinceClaim(frozen(null)[0], claim27())].join(','), 'false,true,');
+
+ // ★ The delivery term, through H8's own relation so the two cannot drift.
+ t('H27: an OPEN PR delivering the card -> clean', dead27({ delivery: { open: 1, merged: 0 } }), null);
+ t('H27: a MERGED delivery is H8\'s row, not this one', dead27({ delivery: { open: 0, merged: 1 } }), null);
+ t('H27: claimDelivery reads the body relation', claimDelivery(5442, [{ number: 9, body: 'Part of #5442' }], []).open, 1);
+ t('H27: …and the branch-name fallback', claimDelivery(5442, [{ number: 9, body: '', head: { ref: 'claude/issue-5442-x' } }], []).open, 1);
+ t('H27: …counting merged deliveries separately', claimDelivery(5442, [], [{ number: 9, body: 'Fixes #5442', merged_at: '2026-08-23T10:00:00Z' }]).merged, 1);
+ t('H27: …and ignoring an unmerged closed PR in the merged window', claimDelivery(5442, [], [{ number: 9, body: 'Fixes #5442', merged_at: null }]).merged, 0);
+ t('H27: …and a PR for some OTHER card', claimDelivery(5442, [{ number: 9, body: 'Part of #9999' }], []).open, 0);
+ t('H27: a card delivered in halves is not a death', dead27({ delivery: claimDelivery(5442, [{ number: 9, body: 'Part of #5442' }], []) }), null);
+
+ // ★ Unlike H20, this predicate DOES take pull-request input — that is the
+ // deliberate difference between the two rows, pinned so it cannot be lost.
+ t('H27: the predicate takes a delivery input', h27DeadClaimNoProgress.length, 4);
+ t('H20: …and still takes none', h20DispatchedNoBranchRef.length, 3);
+
+ // The gathering gate, and that it is a strict subset of H20's.
+ t('H27 gate: a claim past 24h is read', h27NeedsClaimLivenessRead(dispatchedCard(), claim27(), NOW_27), true);
+ t('H27 gate: a claim inside 24h buys nothing', h27NeedsClaimLivenessRead(dispatchedCard(), claim27('2026-08-24T04:00:00Z'), NOW_27), false);
+ t('H27 gate: an unreadable claim timestamp is read, never assumed fresh', h27NeedsClaimLivenessRead(dispatchedCard(), claim27('not-a-date'), NOW_27), true);
+ t('H27 gate: a card without `pm:dispatched` is out of scope', h27NeedsClaimLivenessRead(dispatchedCard(['pm:queue']), claim27(), NOW_27), false);
+ t('H27 gate: no claim -> nothing to read (that shape is H2\'s row)', h27NeedsClaimLivenessRead(dispatchedCard(), null, NOW_27), false);
+ t('H27 gate: a missing issue does not crash', h27NeedsClaimLivenessRead(undefined, claim27(), NOW_27), false);
+ // The subset property is what makes H27 cost ZERO extra requests: every card
+ // it can speak about was already probed for H20.
+ t('H27 gate: every H27 candidate is already an H20 candidate', h27NeedsClaimLivenessRead(dispatchedCard(), claim27(), NOW_27) && h20NeedsRefProbe(dispatchedCard(), claim27(), NOW_27), true);
+ t('H27 gate: …and the threshold is strictly wider than H20\'s', DEAD_CLAIM_STALE_HOURS * 60 > DISPATCHED_NO_REF_STALE_MINUTES, true);
+
+ // The remaining gates and the caller contract, H20's shapes on H27's inputs.
+ t('H27: the label gate outranks everything', dead27({ issue: dispatchedCard(['pm:queue']) }), null);
+ t('H27: no claim -> no row', dead27({ claim: null }), null);
+ t('H27: an unprobed card yields no row (caller contract, as H19/H20)', dead27({ refs: [] }), null);
+ t('H27: absent ref states -> no row', dead27({ refs: undefined }), null);
+ t('H27: a missing issue does not crash', dead27({ issue: undefined }), null);
+ t('H27: a young claim -> no row even with a frozen branch', dead27({ claim: claim27('2026-08-24T04:00:00Z') }), null);
+ t('H27: exactly AT the threshold is not past it', dead27({ claim: claim27(new Date(NOW_27 - DEAD_CLAIM_STALE_HOURS * 3_600_000).toISOString()) }), null);
+ const unstamped27 = dead27({ claim: claim27('not-a-date') });
+ t('H27: an unreadable claim timestamp does not read as fresh', unstamped27 === null, false);
+ t('H27: …and yields the UNJUDGED row (the comparison is impossible)', unstamped27.includes('UNJUDGED'), true);
+ 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', dead27({ refs: many27 }).includes(`+${7 - H20_BRANCH_LIST_CAP} more`), true);
+
+ // -- 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.
+ const conflictPr = ({
+ draft = false,
+ mergeable_state = 'dirty',
+ auto_merge = null,
+ updated = hoursAgo(4),
+ body = '',
+ head = { ref: 'claude/issue-1-x' },
+ merged_at = null,
+ } = {}) => ({ draft, mergeable_state, auto_merge, head, body, updated_at: updated, merged_at });
+
+ t('H16: dirty beyond the threshold -> finding', typeof h16StuckMergeConflict(conflictPr(), NOW), 'string');
+ t('H16: …and the finding names the threshold', h16StuckMergeConflict(conflictPr(), NOW).includes(`${MERGE_CONFLICT_STALE_HOURS}h`), true);
+ t('H16: …and names the platform state it read', h16StuckMergeConflict(conflictPr(), NOW).includes('mergeable_state: dirty'), true);
+ t('H16: …and prescribes the merge-and-resolve remedy', h16StuckMergeConflict(conflictPr(), NOW).includes('merges `main` into the branch'), true);
+ // The proxy must be DECLARED in the row, not silently substituted: a reader
+ // shown "~4h" has to know it is silence on the PR, not the conflict's age.
+ t('H16: …and declares the age is the PR\'s updated_at, not the conflict\'s', h16StuckMergeConflict(conflictPr(), NOW).includes("Age is the PR's `updated_at`, not the conflict's"), true);
+ t('H16: dirty within the threshold -> clean (a fresh push is mid-resolution)', h16StuckMergeConflict(conflictPr({ updated: hoursAgo(1) }), NOW), null);
+ t('H16: exactly at the threshold -> clean (strictly beyond fires)', h16StuckMergeConflict(conflictPr({ updated: hoursAgo(MERGE_CONFLICT_STALE_HOURS) }), NOW), null);
+ t('H16: draft is out of scope however old (parked deliberately)', h16StuckMergeConflict(conflictPr({ draft: true, updated: hoursAgo(200) }), NOW), null);
+ t('H16: a clean PR is silent', h16StuckMergeConflict(conflictPr({ mergeable_state: 'clean', updated: hoursAgo(200) }), NOW), null);
+ // GitHub computes mergeability asynchronously: `unknown` is the platform
+ // saying "ask again later", so it is SKIPPED rather than vouched for or
+ // guessed — the one place H16 departs from the #4690 direction, on purpose.
+ t('H16: unknown is skipped, never reported', h16StuckMergeConflict(conflictPr({ mergeable_state: 'unknown', updated: hoursAgo(200) }), NOW), null);
+ t('H16: a null mergeable_state is skipped too', h16StuckMergeConflict(conflictPr({ mergeable_state: null, updated: hoursAgo(200) }), NOW), null);
+ // The wiring hazard this pins: a LIST row carries no `mergeable_state` at
+ // all, and feeding one here must skip rather than throw or flag. The summary
+ // line's `read X of Y` is what would expose that mistake in the live sweep.
+ t('H16: a listing row (no mergeable_state key) is skipped', h16StuckMergeConflict({ draft: false, merged_at: null, updated_at: hoursAgo(200) }, NOW), null);
+ // Every other non-dirty verdict is someone else's business: `behind` is the
+ // queue's to rebuild and `blocked` is a required check or a review, both of
+ // which DO produce a signal a patrol can already see.
+ t('H16: behind is not a conflict', h16StuckMergeConflict(conflictPr({ mergeable_state: 'behind', updated: hoursAgo(200) }), NOW), null);
+ t('H16: blocked is not a conflict', h16StuckMergeConflict(conflictPr({ mergeable_state: 'blocked', updated: hoursAgo(200) }), NOW), null);
+ t('H16: missing draft field is out of scope', h16StuckMergeConflict({ mergeable_state: 'dirty', updated_at: hoursAgo(50) }, NOW), null);
+ t('H16: merged row is out of scope', h16StuckMergeConflict(conflictPr({ merged_at: '2026-08-15T10:00:00Z', updated: hoursAgo(50) }), NOW), null);
+ // #4690 direction, same as H10/H11/H12/H13: unreadable must not read as fresh.
+ t('H16: unreadable updated_at -> finding, not fresh', typeof h16StuckMergeConflict(conflictPr({ updated: 'not-a-date' }), NOW), 'string');
+ t('H16: absent updated_at -> finding, not fresh', typeof h16StuckMergeConflict(conflictPr({ updated: undefined }), NOW), 'string');
+
+ // THE incident property: armed auto-merge must not quiet this row. H12 reads
+ // `auto_merge` as finding-reducing and is right to; here the arming is what
+ // made every proxy signal read healthy while the PR went nowhere.
+ t('H16: armed auto-merge does NOT suppress the row', typeof h16StuckMergeConflict(conflictPr({ auto_merge: { merge_method: 'squash' } }), NOW), 'string');
+ t('H16: …and the row says auto-merge does not resolve conflicts', h16StuckMergeConflict(conflictPr({ auto_merge: { merge_method: 'squash' } }), NOW).includes('does NOT resolve conflicts'), true);
+ // The contrast that makes the divergence deliberate rather than an oversight:
+ // one PR row, two predicates, opposite readings of the same armed field.
+ t('H16: …while H12 stays clean on that same armed PR (the divergence is by design)', h12OrphanLanding(conflictPr({ auto_merge: { merge_method: 'squash' }, updated: hoursAgo(50) }), NOW), null);
+
+ // The held-card clause — the row names the delivery, not only the branch.
+ t('H16: a `Fixes #N` body names the card it is holding', h16StuckMergeConflict(conflictPr({ body: 'Fixes #9763\n\nsome prose' }), NOW).includes('holding card #9763'), true);
+ t('H16: `Part of #N` counts as held too (H8\'s reading of delivery)', h16StuckMergeConflict(conflictPr({ body: 'Part of #9652' }), NOW).includes('holding card #9652'), true);
+ t('H16: two cards are pluralised and listed in order', h16StuckMergeConflict(conflictPr({ body: 'Fixes #9961\nFixes #9936' }), NOW).includes('holding cards #9936, #9961'), true);
+ t('H16: a body with no card carries no holding clause', h16StuckMergeConflict(conflictPr({ body: 'no card here' }), NOW).includes('holding'), false);
+ // #8293 reading 4 carries over: a body QUOTING the spelling names nothing.
+ t('H16: a backticked `Fixes #N` is not a held card', h16HeldCards('the dispatch asked for `Fixes #8284`').length, 0);
+ t('H16: h16HeldCards de-duplicates and sorts', h16HeldCards('Fixes #30\nPart of #12\nFixes #30').join(','), '12,30');
+ t('H16: h16HeldCards on an absent body does not crash', h16HeldCards(undefined).length, 0);
+
+ // -- H16 gathering policy: `h16NeedsDetail` --------------------------------
+ // Pinned for the reason `needsRepoProbe` is: a policy deciding what gets READ
+ // AT ALL is where a silent hole would live. THE property is that it can never
+ // be narrower than the predicate — anything H16 could flag must be fetched.
+ t('H16 gate: an aged non-draft PR is a candidate', h16NeedsDetail(conflictPr({ updated: hoursAgo(4) }), NOW), true);
+ t('H16 gate: a fresh PR is not worth a request', h16NeedsDetail(conflictPr({ updated: hoursAgo(1) }), NOW), false);
+ t('H16 gate: exactly at the threshold is not a candidate (matches the predicate)', h16NeedsDetail(conflictPr({ updated: hoursAgo(MERGE_CONFLICT_STALE_HOURS) }), NOW), false);
+ t('H16 gate: a draft is never fetched', h16NeedsDetail(conflictPr({ draft: true, updated: hoursAgo(200) }), NOW), false);
+ t('H16 gate: a merged row is never fetched', h16NeedsDetail(conflictPr({ merged_at: '2026-08-15T10:00:00Z', updated: hoursAgo(200) }), NOW), false);
+ t('H16 gate: missing draft field is never fetched', h16NeedsDetail({ updated_at: hoursAgo(200), merged_at: null }, NOW), false);
+ // The unreadable timestamp MUST be fetched: the predicate promises to surface
+ // it, so a gate that skipped it would drop the row it promises.
+ t('H16 gate: an unreadable updated_at IS a candidate (the predicate flags it)', h16NeedsDetail(conflictPr({ updated: 'not-a-date' }), NOW), true);
+ t('H16 gate: an absent updated_at IS a candidate', h16NeedsDetail(conflictPr({ updated: undefined }), NOW), true);
+ t('H16 gate: an absent row is not a candidate', h16NeedsDetail(undefined, NOW), false);
+ // The Version Packages PR is NOT excluded here, unlike in H12: it is
+ // regenerated from `main` on every push, so a dirty one is a real finding
+ // about the release bot rather than a by-design false positive.
+ t('H16 gate: a changeset-release head is still a candidate (unlike H12)', h16NeedsDetail(conflictPr({ head: { ref: 'changeset-release/main' }, updated: hoursAgo(200) }), NOW), true);
+ t('H16: …and a dirty Version Packages PR really does flag', typeof h16StuckMergeConflict(conflictPr({ head: { ref: 'changeset-release/main' }, updated: hoursAgo(200) }), NOW), 'string');
+ // The never-narrower invariant, asserted over the whole fixture table rather
+ // than case by case: for every row the predicate flags, the gate must fetch.
+ const h16Rows = [
+ conflictPr(),
+ conflictPr({ updated: 'not-a-date' }),
+ conflictPr({ updated: undefined }),
+ conflictPr({ auto_merge: { merge_method: 'squash' } }),
+ conflictPr({ head: { ref: 'changeset-release/main' }, updated: hoursAgo(200) }),
+ conflictPr({ body: 'Fixes #1' }),
+ conflictPr({ updated: hoursAgo(1) }),
+ conflictPr({ draft: true }),
+ conflictPr({ mergeable_state: 'clean' }),
+ conflictPr({ merged_at: '2026-08-15T10:00:00Z' }),
+ ];
+ t(
+ 'H16 gate: never narrower than the predicate (every flagged row is fetched)',
+ h16Rows.every((row) => h16StuckMergeConflict(row, NOW) === null || h16NeedsDetail(row, NOW)),
+ true,
+ );
+
+ // -- H16 detail-pass failure posture (#4690 at row granularity) ------------
+ // A partial read is a bounded gap the summary line states; a total one is a
+ // transport failure wearing a quiet H16 section, and must surface as such.
+ t('H16 pass: some candidates unread is a bounded gap, not a transport failure', h16DetailPassUnreadable(5, 3), false);
+ t('H16 pass: exactly one read out of many is still a real reading', h16DetailPassUnreadable(9, 1), false);
+ t('H16 pass: NO candidate readable is a transport failure', h16DetailPassUnreadable(4, 0), true);
+ // The healthiest possible board — nothing stale enough to be worth a request
+ // — must not fail the sweep.
+ t('H16 pass: zero candidates is a clean reading, never a failure', h16DetailPassUnreadable(0, 0), false);
+ t('H16 pass: absent counters degrade to a clean reading', h16DetailPassUnreadable(undefined, undefined), false);
+
+ // -- H16 incident fixture: PR #9826, the measured specimen -----------------
+ // The devx incident this item exists for: a conflict that hung ~4h while
+ // auto-merge was armed, and not one of H1–H15 could express the state.
+ //
+ // ⚠️ The `dirty` reading is the INCIDENT's, recorded on the card — not a
+ // live reading. Measured again here on 2026-08-19 while implementing H16,
+ // that PR answered `mergeable_state: "blocked"`: the conflict had since been
+ // resolved. Pinned as a historical shape deliberately, and said so here so
+ // nobody "verifies" this fixture against a live PR that no longer carries
+ // it. Body `Fixes #9763` is from the same live read.
+ const pr9826 = conflictPr({
+ body: 'Fixes #9763. Sub-issue of #9747 (the meta-card), in its **fails toward FALSE GREEN** half.',
+ auto_merge: { merge_method: 'squash' },
+ updated: hoursAgo(4),
+ head: { ref: 'claude/issue-9763-literal-collector-spellings' },
+ });
+ t('H16 incident: the #9826 shape is a finding', typeof h16StuckMergeConflict(pr9826, NOW), 'string');
+ t('H16 incident: …fires despite auto-merge being armed', h16StuckMergeConflict(pr9826, NOW).includes('MERGE CONFLICT'), true);
+ t('H16 incident: …and names the card it was holding', h16StuckMergeConflict(pr9826, NOW).includes('holding card #9763'), true);
+ t('H16 incident: …at its measured ~4h age', h16StuckMergeConflict(pr9826, NOW).includes('untouched for ~4h'), true);
+ t('H16 incident: …and the sweep would have spent a request on it', h16NeedsDetail(pr9826, NOW), true);
+ // The counterfactual that makes the fixture mean something: at the moment
+ // the conflict appeared, the same PR was silent — the threshold is what
+ // separates "being worked" from "stuck", and 4h is well past it.
+ t('H16 incident: …while one hour in, the same PR was correctly silent', h16StuckMergeConflict({ ...pr9826, updated_at: hoursAgo(1) }, NOW), null);
+
+ // -- H17: the on-hold trigger-file index (#10034) --------------------------
+ // Every fixture below is a VERBATIM excerpt from a real hold comment or card
+ // body, read on 2026-08-19 while measuring the census that produced
+ // `H17_TRIGGER_ANCHOR_TERMS`. Invented shapes would prove nothing here: the
+ // whole question this item had to answer first was whether hold comments
+ // have a greppable shape at all, and the answer is a fact about these nine
+ // cards, not about a format anyone designed.
+ //
+ // The tracked-file oracle is a fixture set, so the extraction is pinned
+ // without a checkout — and the DROP behaviour is pinned with it, because
+ // "what this refuses to emit" is the property that makes the index safe to
+ // render unreviewed.
+ const TRACKED = new Set([
+ '.github/workflows/lint.yml',
+ 'packages/rest/src/rest-server.ts',
+ 'packages/spec/src/data/field.zod.ts',
+ 'content/docs/ui/setup-app.mdx',
+ 'content/docs/automation/hook-bodies.mdx',
+ 'content/docs/kernel/index.mdx',
+ 'scripts/check-durability-degradation-log-level.mjs',
+ 'packages/lint/src/data-model-rules.ts',
+ 'packages/lint/src/validate-security-posture.test.ts',
+ 'scripts/check-where-matcher-conformance.mjs',
+ 'scripts/where-matcher-conformance.baseline.json',
+ 'scripts/check-type-check-coverage.mjs',
+ 'packages/spec/src/contracts/data-driver.ts',
+ ]);
+ const tracked = (p) => TRACKED.has(p);
+ const files = (text) => h17TriggerFiles([text], tracked).join('|');
+
+ // Shape A — anchor and path on ONE line, prose form. #8331's hold comment.
+ const c8331 =
+ 'Named restart conditions: ① #8330 merged AND the next queued card that already touches ' +
+ '`.github/workflows/lint.yml` — devx seat: name this card in that dispatch brief as a declared ' +
+ 'rider (comment-or-removal, dev decides against the card\'s trade-off analysis); ② the gate\'s ' +
+ 'filters and the workflow step\'s filters are ever observed to diverge.';
+ t('H17 #8331: `restart condition` anchor + inline path -> the path', files(c8331), '.github/workflows/lint.yml');
+
+ // Shape B — a bolded `Restart condition (trigger files):` label. #8883.
+ const c8883 =
+ '- **Restart condition (trigger files):** after PR #8887 (the #8850 extraction) is MERGED, the ' +
+ 'next dispatched card whose surface includes `packages/rest/src/rest-server.ts` metadata-endpoints ' +
+ 'region carries item 1 (the JSDoc header) as a declared rider.';
+ t('H17 #8883: `trigger files` anchor -> the path', files(c8883), 'packages/rest/src/rest-server.ts');
+
+ // Shape C — an `opportunistic:` numbered item, with a backticked IDENTIFIER
+ // on the same line. `Field` is not a tracked file and must be dropped: this
+ // is the decoy that proves the oracle is doing work rather than decorating.
+ const c8656 =
+ '3. **opportunistic:** any PR already editing the `Field` builder object in ' +
+ '`packages/spec/src/data/field.zod.ts` — closing the gap there is a cheap declared rider.';
+ t('H17 #8656: `opportunistic` anchor -> only the tracked path', files(c8656), 'packages/spec/src/data/field.zod.ts');
+ t('H17 #8656: …and the backticked identifier `Field` is dropped, not guessed at', files(c8656).includes('Field'), false);
+
+ // Shape D — anchor sentence, then an IMMEDIATE bullet list. #8984.
+ const c8984 =
+ '**Restart condition (named trigger files)**: the next PR touching any of\n' +
+ '- `content/docs/ui/setup-app.mdx`\n' +
+ '- `content/docs/automation/hook-bodies.mdx`\n' +
+ '- `content/docs/kernel/index.mdx`\n' +
+ '\n' +
+ 'carries the relabel as a **declared rider**.';
+ t(
+ 'H17 #8984: anchor + immediate bullet list -> all three paths',
+ files(c8984),
+ 'content/docs/automation/hook-bodies.mdx|content/docs/kernel/index.mdx|content/docs/ui/setup-app.mdx',
+ );
+ t('H17 #8984: …and the prose after the blank line is not rejoined', files(c8984).includes('rider'), false);
+
+ // Shape E — an explicit `Trigger file:` label inside a numbered item. #8897.
+ const c8897 =
+ '1. **Trigger file: `scripts/check-durability-degradation-log-level.mjs`** — any card or PR ' +
+ 'editing this file (including the #8901 design work) must decide options 1/2/3 in the same change.\n' +
+ '2. Any seam reporting through an injected receiver goes red with a "silent" message.';
+ t('H17 #8897: `Trigger file:` label -> the path', files(c8897), 'scripts/check-durability-degradation-log-level.mjs');
+
+ // Shape F — two paths on one anchor line, in a card BODY rather than a
+ // comment. #9139, which carries zero comments: the body channel is not
+ // optional, and reading only comments would have missed this card entirely.
+ const b9139 =
+ '- **Trigger files** (opportunistic-restart clause): `packages/lint/src/data-model-rules.ts` and ' +
+ '`packages/lint/src/validate-security-posture.test.ts` — any dispatch whose file surface ' +
+ 'intersects them must name this card.';
+ t(
+ 'H17 #9139: two paths on one anchor line (from the card BODY) -> both',
+ files(b9139),
+ 'packages/lint/src/data-model-rules.ts|packages/lint/src/validate-security-posture.test.ts',
+ );
+
+ // Shape G — anchor, BLANK LINE, then the list; and a third bullet that is
+ // prose naming a backticked fixture CONSTANT. #8662, the second decoy.
+ const c8662 =
+ '**Opportunistic trigger files** (per the hold discipline — a restart condition nobody can see is ' +
+ 'not a condition). Name this card in the dispatch order of any card whose file surface intersects:\n' +
+ '\n' +
+ '- `scripts/check-where-matcher-conformance.mjs`\n' +
+ '- `scripts/where-matcher-conformance.baseline.json`\n' +
+ '- the `FIXTURE_CAPTURED_NEGATED` self-test fixture\n';
+ t(
+ 'H17 #8662: anchor, blank line, then the list -> both tracked paths',
+ files(c8662),
+ 'scripts/check-where-matcher-conformance.mjs|scripts/where-matcher-conformance.baseline.json',
+ );
+ t('H17 #8662: …and the backticked constant is dropped', files(c8662).includes('FIXTURE'), false);
+
+ // The NEGATIVE half of the census — the two sampled holds with no trigger
+ // clause. Their exits are `Restart-when: closed …#N`, which H9 already
+ // judges; H17 must contribute no row for them, or the index would tell a
+ // dispatching seat to intersect against files nobody nominated.
+ const c9276 =
+ 'Restart-when: closed objectstack-ai/objectstack#5499\n\n' +
+ '(Earlier restart is legitimate only if the freeze ruling is narrowed on #5499 to exclude ' +
+ 'storage-contract normalization — that is a maintainer note to record there, not a seat call.)';
+ t('H17 #9276: a `Restart-when: closed` hold names no trigger file', files(c9276), '');
+ t(
+ 'H17 #9707: a card-closure hold names no trigger file either',
+ files('**Card status:** `pm:on-hold`, restart-when **objectui#5266 lands** — at which point the residual should be re-measured.'),
+ '',
+ );
+
+ // ⛔ `rider` is not an anchor term, and this is the specimen that decided it:
+ // #8331's RELEASE comment is post-mortem prose about a hold that is no
+ // longer held, and it contains a tracked path. Admitting `rider` would have
+ // rendered a dead trigger as a live one.
+ const release8331 =
+ 'Hold released → `pm:queue` (triage seat, rider-clause audit): the armed rider fired three times ' +
+ 'without being carried — PRs #9869, #9990 and #10005 all touched `.github/workflows/lint.yml` ' +
+ 'after the rider armed (verified on origin/main; `refreshBuiltClosure()` confirmed at ' +
+ '`scripts/check-type-check-coverage.mjs:1679`).';
+ t('H17: a release/audit comment saying "rider" is NOT an anchor', files(release8331), '');
+ // …and the same line's `path:line` citation form must fail validation even
+ // when something else on the line does anchor it.
+ t(
+ 'H17: a `path:line` citation is not a tracked file and is dropped',
+ files(`**Trigger file:** see \`scripts/check-type-check-coverage.mjs:1679\``),
+ '',
+ );
+
+ // Fenced blocks are citations, not triggers — #8656's evidence block lists
+ // real `packages/spec/**` paths that nominate nothing.
+ //
+ // ⚠️ The fixture QUOTES ANOTHER CARD'S CLAUSE inside the fence, and that
+ // detail is load-bearing. Written first with only #8656's verbatim
+ // `path:line` citations, this case passed with fence-stripping ENTIRELY
+ // REMOVED: the `:234` suffix makes that token fail the tracked-file check on
+ // its own, so the test was measuring the ORACLE while vouching for a fence
+ // parser it never exercised. A bare path in a fence does not exercise it
+ // either — nothing unbackticked is ever harvested from prose. The shape that
+ // actually needs the strip is the one these threads are full of: a comment
+ // quoting a PRIOR comment wholesale, anchor term and backticks included.
+ // Without the strip, the quoting card inherits a trigger file it never
+ // nominated, and the index sends a seat to intersect on the wrong card.
+ const fenced =
+ '**Restart condition**: unchanged. For reference, #8656 reads:\n\n' +
+ '```\n' +
+ '3. **opportunistic:** any PR already editing the `Field` builder object in `packages/spec/src/data/field.zod.ts` — a cheap declared rider.\n' +
+ '```\n';
+ t('H17: a clause quoted inside a fenced block is not harvested', files(fenced), '');
+ t(
+ 'H17: …but stripMarkdownCode({inline:false}) still keeps inline spans',
+ stripMarkdownCode('a `packages/rest/src/rest-server.ts` b', { inline: false }).includes('rest-server'),
+ true,
+ );
+ t(
+ 'H17: …and the default (inline:true) is unchanged for every existing caller',
+ stripMarkdownCode('a `Fixes #1` b').includes('#1'),
+ false,
+ );
+
+ // The canonical `Restart-touch:` channel — zero live matches today by
+ // design, so these are the only cases that exercise it.
+ t('H17 Restart-touch: a bare path on the canonical line is read', files('Restart-touch: packages/rest/src/rest-server.ts'), 'packages/rest/src/rest-server.ts');
+ t('H17 Restart-touch: a backticked value is read too', files('Restart-touch: `.github/workflows/lint.yml`'), '.github/workflows/lint.yml');
+ t('H17 Restart-touch: a bulleted canonical line is read', files('- Restart-touch: packages/spec/src/data/field.zod.ts'), 'packages/spec/src/data/field.zod.ts');
+ t('H17 Restart-touch: an UNTRACKED value is dropped, never emitted', files('Restart-touch: packages/gone/removed.ts'), '');
+ // Case-sensitive like `Blocked-by:` and `Restart-when:`: a lowercase variant
+ // is a line the machinery cannot see, and must not be quietly accepted.
+ t('H17 Restart-touch: the lowercase spelling is NOT the channel', files('restart-touch: packages/rest/src/rest-server.ts'), '');
+
+ // Continuation bounds.
+ t(
+ 'H17 continuation: prose immediately after the anchor stops the scan',
+ files('**Trigger files**: as follows.\nThis paragraph mentions `packages/rest/src/rest-server.ts` in passing.'),
+ '',
+ );
+ t(
+ 'H17 continuation: two blank lines before a list stop the scan',
+ files('**Trigger files**:\n\n\n- `packages/rest/src/rest-server.ts`'),
+ '',
+ );
+ const longList = `**Trigger files**:\n${Array.from({ length: 20 }, (_, i) => `- item ${i}`).join('\n')}\n- \`packages/rest/src/rest-server.ts\``;
+ t('H17 continuation: the scan stops at H17_LIST_SCAN_LIMIT', files(longList), '');
+ t('H17_LIST_SCAN_LIMIT is the documented parsing bound', H17_LIST_SCAN_LIMIT, 12);
+
+ // Multi-text merge: body + comments are one card's evidence, deduped and
+ // sorted. A path named in both the body and a comment is ONE row entry.
+ t(
+ 'H17: body and comments merge, dedupe and sort into one list',
+ h17TriggerFiles([b9139, c8331, '**Trigger files**: `.github/workflows/lint.yml`'], tracked).join('|'),
+ '.github/workflows/lint.yml|packages/lint/src/data-model-rules.ts|packages/lint/src/validate-security-posture.test.ts',
+ );
+
+ // The gathering policy — the same "must never be narrower than the thing it
+ // feeds" property `h16NeedsDetail` is pinned for.
+ t('H17 gating: an open pm:on-hold card is a candidate', h17NeedsComments(issue(['pm:on-hold'])), true);
+ t('H17 gating: a queued card is not', h17NeedsComments(issue(['pm:queue', 'domain:devx'])), false);
+ t('H17 gating: a dispatched card is not', h17NeedsComments(issue(['pm:dispatched'])), false);
+ t('H17 gating: a hold carrying other labels is still a candidate', h17NeedsComments(issue(['bug', 'pm:on-hold', 'domain:engine'])), true);
+
+ // Row assembly: cards with no validated path are omitted entirely (the
+ // majority shape — most of the 79 open holds name no file), and rows sort by
+ // number so the anchor body diffs cleanly run to run.
+ const card = (number, texts) => ({ issue: { number, html_url: `https://example.test/${number}` }, texts });
+ const rows17 = h17IndexRows(
+ [card(9139, [b9139]), card(9276, [c9276]), card(8331, [c8331])],
+ tracked,
+ );
+ t('H17 rows: a hold naming nothing is omitted, not rendered empty', rows17.length, 2);
+ t('H17 rows: …and rows ascend by card number', rows17.map((r) => r.issue.number).join(','), '8331,9139');
+ t('H17 rows: …carrying the validated files', rows17[1].files.join('|'), 'packages/lint/src/data-model-rules.ts|packages/lint/src/validate-security-posture.test.ts');
+
+ // -- report rendering, both media (#9844) ---------------------------------
+ // The standing caller writes the markdown into a pinned issue body, so the
+ // properties pinned here are the ones a broken body would cost: the plain
+ // output must not have moved, the loud rows must outrank truncation, the
+ // trim must announce itself, and a mistyped --format must be loud.
+ const finding = (number, code, msg) => [{ number, html_url: `https://example.test/${number}` }, code, msg];
+ const counts = { repo: 'o/r', issues: 3, unscoped: 4, prs: 5, merged: 6, conflictCandidates: 2, conflictProbed: 2 };
+ const quietRow = finding(200, 'H2', 'assignee set but no claim comment on the thread');
+ const loudRow = finding(900, 'H13', `${P0_SUSPECT_MARKER} the card self-declares P0. base sentence.`);
+
+ // The plain renderer is the pre-#9844 output, unchanged: two lines per
+ // finding (code/number/message, then the URL indented), summary last.
+ t(
+ 'plain: a finding renders as the pre-existing two-line shape',
+ renderPlain([quietRow], counts).split('\n').slice(0, 2).join('|'),
+ ' H2 #200 assignee set but no claim comment on the thread| https://example.test/200',
+ );
+ t('plain: the summary sentence ends the report', renderPlain([quietRow], counts).endsWith('not a gate verdict.'), true);
+ // renderPlain does NOT reorder: the live sweep hands it findings already
+ // sorted by issue number, and a terminal has no fold for a priority sort to
+ // buy anything at. Pinned in the direction that would actually regress —
+ // someone "helpfully" giving the plain path the markdown sort — by feeding
+ // it loud-first input and requiring the loud row to stay where it was put.
+ t('plain: preserves the caller\'s order, applying no priority sort', renderPlain([loudRow, quietRow], counts).indexOf('#900') < renderPlain([loudRow, quietRow], counts).indexOf('#200'), true);
+ // H19 rides both media as an ordinary row — no renderer special case, which
+ // is the property that lets a new item land without touching either.
+ const h19Row = finding(10112, 'H19', '`pm:blocked` while 1 of 1 `Blocked-by:` target(s) is CLOSED (`#10126`)');
+ t(
+ 'plain: an H19 row renders in the same two-line shape as every other item',
+ renderPlain([h19Row], counts).split('\n').slice(0, 2).join('|'),
+ ' H19 #10112 `pm:blocked` while 1 of 1 `Blocked-by:` target(s) is CLOSED (`#10126`)| https://example.test/10112',
+ );
+ t('markdown: an H19 row links the card it names', renderMarkdown([h19Row], counts).includes('- **H19** [#10112](https://example.test/10112)'), true);
+ t('markdown: …and is NOT sorted above the loud band', renderMarkdown([h19Row, loudRow], counts).indexOf('#900') < renderMarkdown([h19Row, loudRow], counts).indexOf('#10112'), true);
+ t('plain: …and the markdown renderer on the same input DOES sort loud first', renderMarkdown([quietRow, loudRow], counts).indexOf('#900') < renderMarkdown([quietRow, loudRow], counts).indexOf('#200'), true);
+ t('summaryLine: names what was READ, not only what was found', summaryLine(counts, 0).includes('swept 3 open pm-/p0-labeled issue(s)'), true);
+ t('summaryLine: the unscoped-pass clause names H18 alongside H13-H15', summaryLine(counts, 0).includes('unscoped pass (H13–H15, H18)'), true);
+ // H16's pair is the row-granular half of the same #4690 property: a detail
+ // pass that read NOTHING must not be indistinguishable from a board with no
+ // conflicts, so the sentence carries `read X of Y` rather than only findings.
+ t('summaryLine: reports the H16 detail reads, not only the H16 findings', summaryLine(counts, 0).includes('merge state read on 2 of 2 H16 candidate(s)'), true);
+ t('summaryLine: …and a partial read says so', summaryLine({ ...counts, conflictProbed: 1 }, 0).includes('read on 1 of 2'), true);
+ // Counts assembled without the pair still render a sentence, never `undefined`.
+ t('summaryLine: absent H16 counts degrade to 0, never to undefined', summaryLine({ repo: 'o/r', issues: 1, unscoped: 1, prs: 1, merged: 1 }, 0).includes('read on 0 of 0'), true);
+ t('summaryLine: …and never prints the string undefined', summaryLine({ repo: 'o/r', issues: 1, unscoped: 1, prs: 1, merged: 1 }, 0).includes('undefined'), false);
+
+ // The loudness contract between H13 and the renderer — one constant, two
+ // readers. If the prefix ever drifts, this pair fails rather than the alarm
+ // going quietly unsorted.
+ t('loudness: H13\'s P0 line is recognised by the renderer', isLoudFinding(h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26), p0Body), NOW)), true);
+ t('loudness: H13\'s base line is not', isLoudFinding(h13DomainWithoutPmState(domainCard(['domain:engine-core'], hoursAgo(26)), NOW)), false);
+
+ // Fold discipline: loud first, issue-number order within each band.
+ const mixed = renderMarkdown([quietRow, loudRow, finding(100, 'H1', '`pm:dispatched` with no assignee')], counts);
+ t('markdown: loud rows sort above quiet ones', mixed.indexOf('#900') < mixed.indexOf('#100'), true);
+ t('markdown: quiet rows keep issue-number order', mixed.indexOf('#100') < mixed.indexOf('#200'), true);
+ t('markdown: the alarm line counts the loud rows', mixed.includes('**1 P0-SUSPECT row(s) in this sweep**'), true);
+ t('markdown: no alarm line when nothing is loud', renderMarkdown([quietRow], counts).includes('P0-SUSPECT row(s) in this sweep'), false);
+ t('markdown: rows are links, not bare numbers', mixed.includes('[#200](https://example.test/200)'), true);
+ t('markdown: the literal marker leads the body (no angle brackets to sanitize)', mixed.startsWith('os-half-state-sweep'), true);
+ t('markdown: the body carries no HTML-comment marker the sanitizer could eat', mixed.includes('