ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe - #19259
Conversation
Two gates were registered in the root manifest and called by zero workflows: `scripts/check-issue-citations.mjs` (delivered by #18223) and `scripts/check-merged-result.mjs` (delivered by #18338). Both cards' file surfaces excluded `.github/workflows/**`, so both devs correctly stopped and filed the wiring instead of widening. Three entry points, two lanes, two deliberately opposite postures: - `lint.yml` / `Lint & Repo Gates`: the DIFF-SCOPED citation verdict, blocking. Two commands in one step -- the manifest alias (which is the checker's own `--self-test` and nothing else) and then the live diff run. Wiring the alias alone would run the self-test twice and scan nothing. - `lint.yml` / `Lint & Repo Gates`: `check:merged-result`, blocking. Offline, no credential, sub-second. - `half-state-patrol.yml`: `--census`, REPORT-ONLY, four times a day. The census verdict moves on a motionless tree -- three numbers in its own control set went 404 in four days with no change here -- so a tree-wide blocking verdict would red a repository nobody touched. The lint job gains `issues: read`: an explicit `permissions:` block sets every unnamed scope to `none`, and a blocking gate must not depend on repository visibility for its transport. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
…st go red Temporary, and reverted by the next commit on this branch. The card's acceptance asks for the red/green measurement to be taken ON THE CI SIDE, not only on the gate side (#18223 already did the gate-side pair with disk evidence). This commit is the red leg: one citation naming a number beyond this board's allocation frontier, inside a declared surface (`packages/**/src/**/*.ts`, comment-prose projection). Expected: the `Issue citations this change adds resolve on the board` step in `Lint & Repo Gates` exits 2 and the job goes red. The next commit removes the line and the same step must go green. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
… real runner
The census step landed on a runner in this PR's own patrol run and reported
3,628 unresolvable citation sites. A full-scope read of the same tree reports
2,168. The difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally,
so every citation naming a PR number was classified as a number the board
never had.
Cause: `GET /repos/{owner}/{repo}/issues` omits pull requests unless the token
holds `pull-requests`, and an explicit `permissions:` block sets every unnamed
scope to `none`. The same call is how the gate reads the allocation frontier
(`?per_page=1&sort=created&direction=desc`), so an understated frontier turns
later numbers into `never-issued` as well.
Both lanes take the row, read-only:
- `half-state-patrol.yml`: a report-only reading wrong by 67% is still a
machine-readable surface telling a lie.
- `lint.yml`: on the BLOCKING step it is worse than a wrong number — it is a
false red on a correct citation.
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…must go green The reset half of the CI-side ablation the card asks for. `packages/types/src/ index.ts` returns to the blob it carried before leg 1 (`0235d4e7d2ebe529c7101b5e1e004597b30c4554`), marker count 1 back to 0, and this branch's delta against `main` is again the two workflow files alone. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
… queue ahead of it This step's first queue build ejected the PR that added it, and the reason was neither the tree nor a crash: `exit 2` is this gate's own FINDINGS code, and the findings were real citations written by somebody else. MEASURED on queue entry `a7109d1f08`. A queue entry is built on the GROUP's base, which carries the entries AHEAD of it in the queue and has not landed on `main` yet. `merge-base origin/main HEAD` therefore lands at the PUBLISHED tip: `231283a6e` at 14:30:14Z, while the group's base `8271c81425` reached `main` only at 14:47:59Z. Everything between the two read as "added by this change" -- 15 file(s) / 16 citations judged, 3 unresolvable, and all three written by the two entries ahead: `#6361` twice from `ada701220` (#19364), `#18003` from `8271c81425` (#19363). Against the group's own base the same tree judges 0 file(s). The Governed Surface Queue Guard, in the same build, read `merge_group.base_sha` and correctly saw 1 commit and 178 changed lines. Two halves, and the second is not cosmetic: 1. `lint.yml` declares the base -- `OS_GATE_MERGE_GROUP_BASE_SHA`, the name and the expression this file already uses for that fact. It renders empty on `pull_request` and `push`, where the ref guesses are CORRECT and are kept: a PR's merge ref already contains the main it was computed against. The step is not `if:`-skipped on `merge_group` -- this file asserts that every gate step here runs there. 2. The gate verifies the base resolves before handing it to `git diff`. It did not: an unresolvable `--base` threw `fatal: bad object` and exited 1, a failed read wearing a code that is neither the clean answer, the findings answer, nor the refusal. It now refuses with PREREQUISITE NOT MET (exit 3) and names every spelling tried and what to pass instead. Half 1 alone is inert -- the pre-change gate ignores the variable entirely -- and half 1 is what makes an unverified base reachable, so both are required. Firing controls, on the real queue tree with `origin/main` pinned to the published main of 14:30:14Z: ref guess -> exit 2 with the three findings, reproducing the ejection; declared base -> exit 0; declared base absent from the checkout -> exit 3; `--base` absent -> exit 3 (was: uncaught throw, exit 1). `--self-test` covers all of it: 66 -> 73 cases, 7 batteries, and the `diff-scope` battery floor moves 6 -> 13 so the new cases cannot stop running unnoticed. Also corrects the gate docblock sentence this wiring falsifies ("Neither is installed here"). Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…re-issue-citations-to-ci
…re-issue-citations-to-ci
…tations-to-ci' into claude/issue-18224-wire-issue-citations-to-ci
PR #19225 made the half-state patrol callable: the patrol's steps now live in the composite action `.github/actions/half-state-patrol`, and the workflow `uses:` it. This branch adds the report-only `--census` leg of the citation gate to the OLD shape, so the two sides overlapped in two places. Conflict 1 (`on.pull_request.paths` + the `permissions:` note) is additive in both directions: the branch's `scripts/check-issue-citations.mjs` trigger row and main's `.github/actions/half-state-patrol/**` glob name different files, and main's rewritten `issues: write` paragraph is kept with this branch's `pull-requests: read` paragraph appended under it. Conflict 2 is not textual. Main emptied that region -- every step in it moved into the composite action -- and the census step is the one thing there that main did not relocate. It stays a step of the CALLER, and the placement is the argument rather than an accident: - `scripts/check-issue-citations.mjs` is objectstack-only, so inside the action its "Locate the patrol sources" step would either have to name it and refuse to run in every sibling that adopted the action, or not name it and fail on a missing file there. The repo-name gate only works in the caller. - The action runs its scripts from `steps.sources.outputs.root` (the tree the action ships from) while the board's checkout is `github.workspace`. A census of the WORKSPACE tree run from inside the action would, in a sibling, census this repo's release pages and report the count under the sibling's name. - `pull-requests: read` is granted by this workflow's `permissions:` block, which a composite action cannot carry and the action's inputs do not name. One behaviour had to be spelled rather than inherited: the step's guard is now `${{ !cancelled() && github.repository == '...' }}`. In the old shape the census sat above the only step that failed the job, so it ran whatever the sweep returned; the patrol is now one `uses:` step that goes red itself, and a default `success()` would have skipped the census on exactly the runs where the patrol is down. This is the guard main gave the closed-card sweep one step up, for the reason its own comment states. Nothing else changed: the blocking diff-scoped step in `lint.yml` and the `OS_GATE_MERGE_GROUP_BASE_SHA` declaration are untouched, and the census command stays a bare literal path so the gate derivation keeps seeing it. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
|
os-dev-report { Generated by Claude Code |
…and refuses a foreign one (objectstack-ai#19288) Fixes objectstack-ai#19191 Clause-②: no `scripts/pm/check-half-states.mjs` took three readings off the LOCAL checkout with plain `execFileSync` git calls and no `cwd`, so each inherited `process.cwd()` while the board it reports on is named by `PM_SWEEP_REPO`. The filer measured both directions on the same board with the same script (objectstack-ai#19191, comment 5740947564): with `cwd` in objectstack, H17's trigger-file index validated objectui's candidate paths against **objectstack's 8888** tracked files (objectui had **7945**) and H57 read NOTHING for the whole run — and the row it never read was hiding a scheduled lane dead through five consecutive fires (objectstack-ai/objectui#10010). Internally consistent, externally wrong. ## 维护者速读(草稿) **改了什么** —— 给这个巡检工具加了一个环境变量 `PM_SWEEP_CHECKOUT`,用来回答「哪个本地检出服务于这块 board」。设了它,三处本地 git 读取(H17 的 tracked-file 判据、H57 的 workflow 文件、`origin`)就都在那棵 树上读;那棵树的 `origin` 不是被扫的仓时,整轮巡检响亮拒绝(退出码 3),⛔ 绝不静默换一棵树读。不设它, 行为与今天逐字一致 —— 只多了一句:H17 页脚现在写明它到底在哪棵树上读的(路径 + `origin`)。 **为什么改** —— 这不是「跑错目录」的操作失误,而是一个没有症状的读数缺陷。跨仓巡检是常态(工具住在 objectstack,board 可以是 objectui),而 H17 的页脚正是在告诉派发席位「派发前请拿你的文件面和这张清单求 交集」。清单是拿另一个仓的文件列表验过的,只在另一个仓存在的触发文件被静默丢弃 —— 席位读到的是一份干净 清单,而那份干净是假的。H57 那半反而是诚实的:它明说自己什么都没读,代价是那一轮真有红行没人看见 —— 本 PR 的实测里那是两行,其中一行连卡都没提到。 **风险与代价(含回滚)** —— 风险面很窄:变量不设时是逐字旧行为(自测里有一条专门盯这件事),巡检 workflow 本身不设它,所以线上那条 lane 的行为不变。新增的唯一失败模式是「变量设错路径」,而那正好是本卡 要的那个响亮拒绝。回滚 = revert 这两个 commit,没有数据迁移、没有已发布面(`scripts/pm/**` 不随任何包 发布,故无 changeset)。 **席位意见** —— **你要做的** —— 无需维护者动手。若希望巡检 workflow 把这棵树写明(目前不需要,因为 runner 的检出就是被扫 的仓),那是 `.github/workflows/half-state-patrol.yml` 的一行 env,已写在下面的 Acceptance notes 里,留给 单独一个由人合的改动 —— 本 PR ⛔ 不碰 workflow(objectstack-ai#19259、objectstack-ai#19225 正在改它)。 ## What changed, mechanically - **`PM_SWEEP_CHECKOUT`** — one knob, beside `PM_SWEEP_REPO` and `PM_SWEEP_CLOSED_FLOOR` in `--help`, carrying the PATH of the checkout that serves the board. `resolveSweepCheckout(env)` resolves and trims it; whitespace is unset. - **One git read site.** All three readers (`readTrackedFiles`, `readRepoRoot`, `readOriginUrl`) now go through a single `gitRead(args, extra)` helper that passes that path as `cwd`. That is the only `execFileSync` git call left in the file, and the self-test pins the count at one — so a fourth reader cannot be added later without the `cwd`. That is the card's mechanism assumption turned into a measured property instead of a belief. - **The refusal.** `checkoutPrerequisite(sweepRepo, env, originUrl)` is a pure verdict in the shape `reportPrerequisiteNotMet` prints: `null` when the knob is unset (today's behaviour is not a prerequisite) or when the named tree really serves the board, otherwise the file's own exit-3 PREREQUISITE NOT MET, named. It is answered FIRST in `sweep()` — ahead of the transport probe — so a foreign checkout costs zero requests and reads nothing. - **`localCheckoutServes` resolves the same way**: the knob leg first (it names the tree, so the `origin` read IN that tree is the authority), then `GITHUB_REPOSITORY` (which names the RUNNER's tree, i.e. exactly not the knob's), then the checkout's `origin`. H57 therefore judges when the knob names the right tree, instead of refusing for the whole run. - **The H17 footer names the tree** it read — path plus `origin` — beside its oracle size, in BOTH oracle states (read, and EMPTY BY FAILURE), knob set or not. A wrong-tree reading is internally consistent; the tree it names is the only thing that tells it from a right one. ## Readings | reading | before | after | |:--|--:|--:| | `scripts/pm/check-half-states.mjs` lines | 36,176 | 36,256 — net **+80**, budget +80 | | `--self-test` cases | 5,063 | 5,081 (+18), exit 0 | | battery roster | 6 batteries, each above its pin | unchanged, each above its pin | Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at this branch → 39 runnable commands, all run locally with the exit code captured BEFORE any pipe, all 0. The per-command verdicts are in this card's `os-dev-report` comment. Control-character self-scan over the changed file: no match. No package contains `scripts/pm/**`, so no dependency-closure build and no package test suite is owed; repo-wide scans stay CI's. ## The firing pair, live Both legs ran at this branch's head against the objectui board, from the objectstack tool. **Knob = the tree that serves the board** — `PM_SWEEP_REPO=objectstack-ai/objectui` with `PM_SWEEP_CHECKOUT=/home/user/objectui`, detached under the shared heavy-verify lock (held 398s, waited 0s — shared-box seconds, not idle-box figures): **exit 0**, 502 half-states over 374 open pm-/p0-labeled issues. The H17 footer: ```text (read on 75 of 75 open `pm:on-hold` card(s); 8142 tracked file(s) in the oracle.) Read in `/home/user/objectui` (`origin` `https://github.com/objectstack-ai/objectui`). ``` 8142 is objectui's LIVE tracked count today (`git -C /home/user/objectui ls-files | wc -l` = 8142); the filer read 7945 on 2026-09-19 and the board has moved since. This worktree reads 9031, so the two trees are still 889 apart — the number that never used to appear is now the one printed, with the tree it came from beside it. H57 is judged rather than UNRESOLVED: `10 workflow(s) on the swept repo declare a schedule; 8 were judged against their latest event=schedule run and 0 are UNJUDGED rather than clean because that read failed.` It filed **two** red rows, neither reachable by any wrong-cwd sweep: - `.github/workflows/changeset-release.yml` — latest scheduled run `35493590744`, started 2026-09-20T06:11:39Z, concluded `failure`. This is the lane the card's re-run traced to objectstack-ai/objectui#10010, still dead today. - `.github/workflows/check-links.yml` — latest scheduled run `35489310168`, started 2026-09-20T04:30:22Z, concluded `failure`. Not named anywhere in the card. **Knob = a foreign tree** — same board, `PM_SWEEP_CHECKOUT=/home/user/objectstack`: **exit 3**, nothing swept, nothing spent: ```text check-half-states: PREREQUISITE NOT MET — PM_SWEEP_CHECKOUT="/home/user/objectstack" does not serve `objectstack-ai/objectui` this checkout's `origin` is `objectstack-ai/objectstack` while the sweep reads `objectstack-ai/objectui`. H17 validates every on-hold trigger path against that checkout and H57 classifies its workflow files there, so a foreign tree renders an index whose paths were checked against another repo. Fix: point PM_SWEEP_CHECKOUT at the checkout whose `origin` IS the swept board, or unset it and run the sweep from inside that checkout. ``` ## Reverse verification — both new pins can fail Each ran from the COMMITTED state, mutated through `scripts/ablation-replace.mjs` (which proves the write landed on disk and restores byte-exactly), and each turned exactly ONE case red out of 5,081. The committed blob is `f49d566ed276`. | ablation | mutation landed | case that went red | restore | |:--|:--|:--|:--| | strip the `cwd` from the one git-read site | anchor 1 to 0, blob `f49d566ed276` to `994882b2fcfc` | `objectstack-ai#19191 knob: …and that one site takes the knob as its cwd` — got false, want true | blob back to `f49d566ed276` == HEAD, `git diff HEAD` empty | | re-spell `readRepoRoot` with its own `execFileSync` git call — a fourth unrouted reader | anchor 1 to 0, blob `f49d566ed276` to `ecd24ab63593` | `objectstack-ai#19191 knob: ONE git read site in the file, so a fourth reader cannot skip the cwd` — got 2, want 1 | same | The first is the one that mattered most: before that pin existed, stripping the `cwd` left the whole 5,080-case suite green, i.e. the fix's central wiring had no test that could fail. The second is what makes the card's "the three readers are the ONLY local-tree reads" a property the suite enforces. ## Acceptance notes - **Knob name**: `PM_SWEEP_CHECKOUT`. It joins the established 12-member `PM_*` PM-tooling family (`PM_SWEEP_REPO`, `PM_SWEEP_CLOSED_FLOOR`, `PM_STATE_LABELS`, `PM_TOKEN`, …) rather than the product's `OS_{DOMAIN}_{NAME}` runtime family of Prime Directive objectstack-ai#9 — the card asked for a knob "beside `PM_SWEEP_REPO`", and `--help` groups the three under one heading where a reader looks. Flagged because that directive's wording is categorical; a maintainer who prefers `OS_PM_SWEEP_CHECKOUT` gets it for the price of one rename. - **The patrol workflow needs no env line, and this PR writes none.** On every real patrol fire the runner's own checkout IS the swept board (`PM_SWEEP_REPO: ${{ github.repository }}`, per-repo installs), so the knob would be a no-op there and `GITHUB_REPOSITORY` already answers `localCheckoutServes`. If it should be stated explicitly anyway, the exact line for the sweep step's `env:` block in `.github/workflows/half-state-patrol.yml` is `PM_SWEEP_CHECKOUT: ${{ github.workspace }}` — a separate, human-merged change, since objectstack-ai#19259 and objectstack-ai#19225 are open on that file. - **`origin/main` was NOT merged into this branch.** It moved to `e6a03e6` while this ran and touched `scripts/pm/check-half-states.mjs` in none of those commits (verified against a fetch into a ref this worktree owns, not against the shared `origin/main` pointer), so the line budget's 36,176 baseline still holds and no serial writer conflict exists. The merge queue rebuilds the PR onto current `main` and re-runs the required contexts there, which is where a jointly-wrong merge would surface. - **Noted, not filed**: `sweep()` and `sweepScheduledWorkflows` each perform their own `readOriginUrl()` / `readRepoRoot()` — two duplicate local git reads per sweep (microseconds, zero requests). Threading one reading through `sweepInto` would widen that function's signature, which is the kind of change this card was told to keep out of. Successor: whoever lands objectstack-ai#19177 (H59) or objectstack-ai#19160 (H52) is next on this writer. - **Nothing else in the file was touched**: objectstack-ai#19230, objectstack-ai#19177, objectstack-ai#19160, objectstack-ai#19203, objectstack-ai#19255 and objectstack-ai#19108 queue behind this card on the same writer. - `scripts/pm/**` publishes nothing from any released package, so this diff owes no changeset (`Clause-②: no`). No label writes. - Tier S: this stops at the draft PR. The owning seat writes the `## Contract review` record, reads `--pair`, and lands it. --- _Generated by [Claude Code](https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… so a deferred table stops deriving as a scan surface (objectstack-ai#19308) Fixes objectstack-ai#19260 Clause-②: no `EXCLUSION_DECL_NAME` in `scripts/pm/dispatch-gates.mjs` listed no `DEFERRED`, while `scripts/check-issue-citations.mjs` declares its exclusion table under exactly that word — `DEFERRED_SURFACES` / `DEFERRED_GLOBS` — and applies it as an exclusion: `surfaceFor` opens by returning `null` for every deferred glob. So the derivation read an **exclusion** table as a **scan** surface, and a `.changeset/**` path was told it triggers a gate that looks at nothing there. That derivation is the one PR objectstack-ai#19259 (card objectstack-ai#18224) trips on, and the assertion it reds — 「a changeset path alone reaches NO value-bearing family any more」 — is correct and is untouched here. One `DEFERRED` branch in the predicate; the measured census in the docblock above it re-measured and rewritten in the same stroke; one self-test case beside the existing named-spelling cases. ⛔ Nothing is renamed in `check-issue-citations.mjs` — `DEFERRED_SURFACES` and `DEFERRED_GLOBS` are exported and pinned by that gate's own self-test, and this docblock says the fix belongs on the derivation side: 「an author's next spelling should be met by this predicate rather than by a rediscovery of this card」. ## Acceptance notes ### Firing pair, both directions, on PR objectstack-ai#19259's head The script derives from the tree it LIVES in — `ROOT` comes from `import.meta.url`, and `trackedFiles()` reads that root — so a `cwd` does **not** redirect it at another tree. The fixed file was therefore copied into a detached worktree of PR objectstack-ai#19259's head (`6d1272b3850e5c0e756745dc6486d20aa330653d`, blob `5d78406d6` before, `8216826a5` while mutated) and run from there; the probe was restored with `git checkout HEAD -- scripts/pm/dispatch-gates.mjs` back to blob `5d78406d6`, `git diff HEAD` empty. `.changeset/test-abc.md` did not need to exist. | probe tree `6d1272b385` | `--commands` lines | `pnpm check:issue-citations` rows | |---|---|---| | subject `.changeset/test-abc.md`, before | 19 | **1** | | subject `.changeset/test-abc.md`, after | 18 | **0** | | control `packages/types/src/index.ts`, before | 44 | **1** | | control `packages/types/src/index.ts`, after | 44 | **1** | The subject's two command lists differ on exactly one line — `diff` reports hunk `13d12`, deleting the `pnpm check:issue-citations` line and nothing else — the control's two lists are byte-identical. Command: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack PATH`, exit 0 on all four runs. ### The census: the method it never stated, and what it now reads The block named its method only in prose and named no command, so the method is now spelled out in the block itself: enumerate `topLevelDecls` over every tracked JS/TS file under `scripts/`, keep the non-callable declarations, test each name against the predicate, and price the arm by diffing `extractWatchHints` against a build of this module whose predicate matches nothing. Attribution is the regex engine's own — leftmost position first, then alternation order (the old text said "first-match attribution" without saying which of the two, and they disagree on a multi-word name such as `EXCLUDED_SKIP`). Measured over the objectstack-ai/objectstack tree at `e6a03e6491`, with the export-visibility of six internal bindings as the only difference from the shipped file (a throwaway copy, never committed): | reading | docblock before | re-measured, no `DEFERRED` | re-measured, with `DEFERRED` | |---|---|---|---| | tracked JS/TS files under `scripts/` | 230 | 287 | 287 | | top-level VALUE declarations | 3,513 | 4,697 | 4,697 | | identifiers matching the predicate | 64 | 73 | **75** | | of those, carrying a string literal | 58 | 65 | **66** | | hints the set moves | 14 | 16 | **24** | | files it moves them on | 6 | 7 | **8** | | change no derivation / really leave one | 8 / 6 | — | **13 / 11** | Per-word tally, same ordering as the block: `SKIP 54, EXCLUDED 10, EXCLUSION 4, NOISE 2, SKIPPED 2, DEFERRED 2, EXCLUSIONS 1, and EXCLUDE / EXCLUDES / IGNORE / IGNORED 0` — four zero-scoring arms now, not three, because `EXCLUDES` went 1 → 0 on its own.⚠️ **Every one of those numbers was already stale before this card**: the middle column is the same tree with the OLD predicate, so the drift from `230 / 3,513 / 64 / 58 / 14` is the tree moving, not the arm. Nothing reds when it does, which is why the block now carries the tree ref it was read on. `DEFERRED` matches exactly two declarations on this tree, both in `check-issue-citations.mjs`, and both are that gate's own exclusion table. It moves 8 hints off that gate, 5 of which cost nothing — the test globs sit under the gate's own `packages/**` inclusion, so `hintCovers` still reaches a test path — and **3 of which were fabricated leads**: `.changeset/**` (the one the card measured), plus `scripts/**` and `docs/adr/**`, which nobody had named. The gate stays reachable through `packages/**`, `packages/**/src/**/*.ts` and its siblings, which is what keeps the control lit. Re-verified after the merge of `origin/main` `e6a03e6491`: every figure above reproduces, and the edit adds no top-level VALUE declaration to this file (112 before, 112 after), so the 4,697 holds for the delivered tree. ### Gates — every exit code captured before any pipe | command | exit | verdict line | |---|---|---| | `node scripts/pm/dispatch-gates.mjs --self-test` (this branch) | 0 | `✓ dispatch-gates self-test: 1867 cases pass.` | | `node scripts/pm/dispatch-gates.mjs --self-test` (pristine `e6a03e6491`) | 0 | `✓ dispatch-gates self-test: 1866 cases pass.` | | `pnpm check:pm-dispatch-gates` (detached, under the shared lock) | 0 | `check:pm-dispatch-gates: the battery took 1012.6s on this box.` | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | 0 | no output | | the other 27 derived families | 0 each | listed below | Case count 1866 → 1867: the one new case is the `DEFERRED` entry in the named-spelling loop beside the existing exclusion-vocabulary cases, ⛔ not at the tail of `selfTest()` — the tail regions that PR objectstack-ai#19162 (`:22173`–`:22203`, now landed as `e6a03e6`) and PR objectstack-ai#19024 (`:23763`) touch are untouched here, and the merge of `origin/main` carrying objectstack-ai#19162 was clean. Inside the battery the case 「⭐ a changeset path alone reaches NO value-bearing family any more」 — the one red on PR objectstack-ai#19259's head — now reads green. Families derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (28, recomputed post-merge and identical to the pre-merge list) and reconciled with `--ran`: `28 derived famil(ies) accounted for — 28 run, 0 NOT-MEASURED (a DERIVED zero — all 28 recorded an exit code and none of them is 3)`. The 27 besides the battery: `check-ci-filter-parity`, `check-closing-keyword-parity` (+ self-test), `check-comment-mask-corpus`, `check-declaration-mirrors` (+ self-test), `check-scripts-symbol-anchors` (+ self-test), `check-self-test-wired` (+ self-test), `check-self-test-workflow-commands` (+ self-test), `check-whole-set-label-write` (+ self-test), `check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:cross-package-test-inputs`, `check:declared-population-live`, `check:driver-memory-census`, `check:entry-guard`, `check:nul-bytes`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:ratchet-remedy-authority`, `check:refd-timer-probe`, `check:watch-hint-literal` — all exit 0. Control-byte self-scan beyond the gate, exit captured before any pipe: `grep -naP` over the changed file for the C0 range plus DEL exits 1 — none present. ### Line budget and shape `scripts/pm/dispatch-gates.mjs` 28,345 → 28,371 lines against `origin/main` `e6a03e6491`: **net +26** (+64 / −38), inside the +40 the dispatch set. Measured against the 28,337 the dispatch quoted at `7d0f911`/`7ec8534`, the file reads +34, of which +8 are PR objectstack-ai#19162's own. `git diff --stat` shows one file. No changeset: `scripts/` ships in no published package's `files[]` (checked across every non-private manifest in the tree), and `scripts/pm/**` is on the non-publishing fast track.⚠️ So `Check Changeset` needs the `skip-changeset` label, and this dispatch forbids label writes to the dev — **that one write is the seat's**, not left undone by accident. ### Noted, not filed **The census in this docblock was already stale before this card, and nothing reds when it goes stale.** The middle column of the census table above is the same tree read with the OLD predicate: `230 → 287` files, `3,513 → 4,697` declarations, `64 → 73` matches, `14 → 16` moved hints, and the tally's `EXCLUDES 1 → 0`. The sibling gate keeps its own census as code with a `measuredOn` ref (`CENSUS_17512` in `check-issue-citations.mjs`, marked 「⛔ Readings, not a budget」); this one is prose in a comment, so it rots silently. Not filed — it is an observation about a missing guard, not a reproducible defect, a broken declared contract or an authoring trap. 承接者: the standing queue on this same file (objectstack-ai#19070 → objectstack-ai#19104 → objectstack-ai#19105 → objectstack-ai#19106 → objectstack-ai#19172), any of which reads this block. The mitigation this PR does ship is the tree ref and the spelled-out method, so the next reader can tell a stale number from a current one. ## 维护者速读(草稿) **改了什么** — 派单工具 `dispatch-gates` 里那张「哪些常量名代表『这个门禁不看这里』」的词表,补上了 `DEFERRED` 这个拼写。顺带把该处注释里那份实测普查重新测了一遍并改写,因为它早已过期。 **为什么改** — `check-issue-citations` 这个门禁用 `DEFERRED_SURFACES` 声明它**故意不看**的路径。 词表不认这个词,于是工具把「不看的清单」读成了「要看的清单」,反过来告诉开发者:改一个 changeset 文件会触发这个门禁 —— 而该门禁对 changeset 路径其实什么都不做。后果不止是一条假线索:它还让 PR objectstack-ai#19259 在 `lint.yml` 第 32 步整条中止,objectstack-ai#18224 自己新加的两步从未执行,卡住了一张 p2。实测这条修法还顺手消掉 另外两条没人发现的假线索(`scripts/**` 与 `docs/adr/**`)。 **风险与代价(含回滚)** — 风险很低:改动是一个正则分支加注释,只影响派单提示里「你该跑哪些门禁」这份 清单,不影响任何门禁自身的判定,也不改任何对外发布的包。方向上只会**少给**一条线索、不会多给,而这一侧 的失效代价是「一张卡多跑一轮 CI」,比反方向「每张卡都被塞一条假线索」便宜得多 —— 这个不对称是该文件自己 写下的判据。回滚 = revert 这一个提交,单文件、无生成物、无迁移。 **席位意见** **你要做的** — ① 这是 draft PR,按席位流程补 `## Contract review` 记录后再走 ready + auto-merge; ② `skip-changeset` 标签需要席位来打(本单禁止开发侧写标签),否则 `Check Changeset` 会红; ③ 落地后 PR objectstack-ai#19259 / 卡 objectstack-ai#18224 即可重跑,它那条断言本身是对的、本 PR 未动。 --- _Generated by [Claude Code](https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18224
Two gates were registered in the root manifest and invoked by zero workflows:
scripts/check-issue-citations.mjs(delivered by #18223 / card #17512) andscripts/check-merged-result.mjs(delivered by #18338 / card #16287). Both cards' declaredfile surfaces excluded
.github/workflows/**, so both devs correctly stopped and filed thewiring rather than widening. This PR is that wiring.
The three entry points, and their postures
lint.yml·Lint & Repo Gatescheck:merged-resultself-testlint.yml·Lint & Repo Gatescheck-issue-citations --censushalf-state-patrol.yml(scheduled)The census posture is a ruling, not a preference, and the card carries the measurement that
forces it: #16783, #16786 and #16787 were measured RESOLVING on 2026-09-10 and 404 on
2026-09-14, with no change to this tree. A tree-wide blocking verdict would therefore red a
motionless repository because a third party deleted an issue. The diff-scoped half is the part
an author owns, it is small, and it is the only thing that stops 2,785 unresolvable sites
becoming 3,000.
The manifest alias is NOT the verdict
package.jsonmapscheck:issue-citationstonode scripts/check-issue-citations.mjs --self-testand nothing else -- the shape every credential-needing gate in this manifest uses
(
check:pm-half-states,check:pm-closed-card-sweep), because a live mode needs a board and acredential. Wiring that alias alone would have run the self-test twice and scanned nothing. So
the lint step holds two commands, self-test first:
The second spelling is lint.yml's documented gate-invocation idiom (
dispatch-gates.mjs's ownheader names it), and the first is what
check-self-test-wiredrequires of every script CI runs.check:merged-resultneeded no such split: the manifest key already ISnode scripts/check-merged-result.mjs --self-test, which is the whole gate.package.jsonis deliberately untouchedNo new manifest key was added. Both keys already exist and both are now named by
lint.yml, sothe wiring needs no manifest edit, and leaving the file alone keeps this PR textually disjoint
from PR #18414, which adds a key two lines from where a new one would have gone.
The non-step changes:
issues: readANDpull-requests: readThe
Lint & Repo Gatesjob'spermissions:block gainsissues: read. An explicitpermissions:block sets every unnamed scope tonone; this board is public today, but ablocking gate whose transport depends on repository visibility is a gate that goes red on a
settings change no file in this repo can assert. Read-only, one scope wide: the gate never
writes an issue, a comment, a label or an assignee.
Acceptance 2 -- both directions measured, on the CI side
PR #18223 measured the red/green pair on the gate side. This card asks for the same pair on
the CI side. Two instruments, both here.
A. The commits on this branch ARE the CI-side ablation.
test(ci): ABLATION LEG 1 of 2adds one citation naming a number beyond this board's allocation frontier, in a declared surface
(
packages/**/src/**/*.ts, comment-prose projection).ABLATION LEG 2 of 2removes it andrestores the file to the byte. The run on the first head is the red reading; the run on the final
head is the green one. Both run identifiers are recorded in the dev report on the card.
B. The exact command the new step holds, ablated locally with disk evidence. Three legs, run
from a committed state, each restored with
git checkout HEAD -- pathand proven by hash:Leg 3 is the control on leg 2: a green produced by a live board read, not by a run that never
asked the board anything.
Acceptance 3 -- where the census reports, how often, who pays
Written into the step itself, and repeated here:
::warning::carrying thesite count. Deliberately not the anchor issue: that body is owned end to end by
check-half-states.mjs's generator, and a second writer is how half a generated body goes stale.37 1,7,13,19) --plus any
workflow_dispatch, plus thepull_requestruns the paths filter admits. A row forscripts/check-issue-citations.mjswas added to that filter for the reason the file alreadygives for its two siblings: a step whose script can change without the trigger firing is a step
whose PR-time proof is a coincidence.
secrets.GITHUB_TOKENcore quota --the same 5,000/hour the job already draws the live sweep from. Four runs a day is roughly 636
requests/day, under half a percent of a single hour's allowance. No PAT and no cross-repo
credential, per this file's own standing rule.
The step is gated on
github.repository == 'objectstack-ai/objectstack', exactly as theclosed-card sweep above it is, because
half-state-patrol.ymlis copied verbatim into siblingrepos that do not carry this script -- a copy must skip the step, not fail on a missing file. It
is placed after the anchor write, unlike the closed-card sweep: the anchor is this patrol's
product, the job has a 15-minute timeout, and a report-only reading must never be able to starve
it. It always exits 0.
Acceptance 4 -- the 422 wall
This diff touches
.github/workflows/**, which is outside the PM seat's arming channel: theseat's
auto_mergeanswers HTTP 422 for this PR. It merges by a human. That is the samewall as PR #18096 and #18341, it is not a tool fault, and it must not be retried. No seat should
undraft this PR or arm auto-merge on it.
Note that
.github/workflows/**is not on theGOVERNED_SURFACESregister inscripts/pm/check-governed-merges.mjs, so the governed-merge machinery is not what holds thisone -- the 422 is.
Placement, and the three in-flight PRs on these files
Read before editing: #18414 (
lint.yml+package.json, green, awaiting a human merge),#19024 (
lint.yml), #19225 (half-state-patrol.yml, draft and frozen). Only additions here; noexisting step was moved, renumbered or reformatted.
lint.ymlthe two steps go above the#15149step-name-quoting step, which keeps thatstep's own documented placement ("immediately above" the duration-unit-keys step) true and keeps
the duration-unit-keys step last among the gates. feat(scripts): refuse an undeclared mode-160000 gitlink in the index #18414 inserts at the control-byte guard
(line ~411) and revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024 edits the typecheck lanes (lines ~5173 and ~6104), so all three hunks are
disjoint.
half-state-patrol.ymlthe census step goes between the summary publish and the finalfail step. ci(pm): make the half-state patrol callable instead of copied #19225 rewrites that file wholesale into a composite action and is frozen behind a
pm:blockedcard; a textual conflict there is expected and was accepted at dispatch.Measurement this PR does not relay
The card's prose carries three disagreeing counts for the manifest census. Re-measured on this
branch's base
0f42d36ff, with a firing control and a dark control:Both unreached keys are the two this PR wires, so the reading after this lands is 0.
Acceptance notes
check-self-test-wiredadmits a script when a workflow names it directly or through a rootmanifest alias, repo-wide rather than per workflow, so the patrol's census step needs no second
self-test invocation: the lint step above already runs it.
files[].Generated by Claude Code
Generated by Claude Code