Repository navigation
fix(pm): the clause-② correction remedy names THIS card's claim comment, never a specimen from another card - #18448
Conversation
…nt, never a specimen from another card Part (3) of the C2 remedy carried a LITERAL comment id — 5642248126, the #17366 specimen, which lives on card #17366 — so every C2 row on every card printed a real `Claim:` comment id belonging to a different card, inside a verdict about this one, and nothing in the output told a reader that. Nothing SELECTED that comment. A constant makes no selection, so a guard over the selection could never have fired; the cut is at the ids this file may PRINT. `correctionTarget` resolves them from the same pool `cardDeclaration` built its readings from, checks each against the card under test through the row's own `issue_url`, and withholds — loudly — any id it cannot show belongs to that card. Absence of `issue_url` is not read as a mismatch; only a positive disagreement is, and a reading with no card number names no id at all. No state, row or exit code moves: what changes is which digits part (3) carries, and whether it carries any. The self-test case that used to pin the foreign literal as the remedy's content is replaced by the property, and a new battery reds without the guard. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Contract review87/87 at-tier stamps (control: an ① Derived judgmentsScope of the diff: one file, scripts/pm/check-clause2-carriers.mjs, blob beed7f7bab to fe6601d55f, +257/-18, no other file at 2620cb2 differs from merge-base 55fd5ee; origin/main (read 2026-09-16T11:37:56Z as 66abef3) still carries beed7f7bab for this file, so base-vs-head here is also main-vs-head. Defect as actually found — CONFIRMED, and the card's offered shape could never have fired. On origin/main the id 5642248126 is a literal inside the CORRECTION_REMEDY string (line 1657), printed by every C2 row that carries the remedy, on every card. Nothing selected it. Card comments are read from /issues/{card}/comments (line 4143) and the card number comes from the PR body/branch, so on the live path every row already belongs to the card and a guard on the selected comment's issue_url has no input to fire on. The comment lookup the card relied on is re-verified: 5642248126 resolves to #17366 (claude[bot], "Claim: PM loop round 1"); control 5650083758 resolves to #17425. The round's account is correct; the card's mechanism attribution was wrong; the card's observation stands and is re-observable on any card via --pair-json. Direction A — nothing correct is silenced. The remedy is printed by exactly five states in c2Sentence (misplaced, malformed, missing, missing/describing, missing/inline-key); absent prints none. Each of the five was rendered on both copies from synthetic --pair-json documents (11:36:57Z): a C2 row prints on both, exit 4 on both, and the whole word-diff of the full output is 5642248126 replaced by the card's own governing claim id plus the clause "read from card #N's own thread". absent, a declared-with-yes pair (exit 4 via C3) and a declared-with-no pair (exit 0) are byte-identical across copies. Direction B — no exit code moves. Live, 19 open PRs, base then head per PR (11:34:34Z to 11:35:58Z): 17 pairs exit 0, 18420 and 17076 exit 2, identical 19/19 verdict-by-verdict; the only output delta is the x-ratelimit-remaining counter. Zero C2 rows printed live on either copy (control: 12 C2 rows on the synthetic set), so no live pair is on the printing path today and the live sweep exercises none of the changed lines; the synthetic set carries the accept/refuse comparison. 15 synthetic pairs: exits equal on both copies in every case (4/4, 0/0, 2/2). Fail-closed — correct, and absence is not conflated with disagreement. Rows without an id: no digits printed, sentence "found no claim comment id on card #N's own thread". Rows with an id but no issue_url: the id is printed (a row that states nothing contradicts nothing). A row declaring another parent: the id is withheld and the drop is printed with comment id and declared issue. Two ids in the pool at one timestamp: neither is printed, both are listed. Superseded plus governing claim: the governing id is printed. No card number (direct call): no id, "carries no card number". The printable pool is the same pool applicableCorrection accepts a correction from, so the remedy can only ever name an id that would land. Self-test — the pre-existing pin is real and the new battery bites. Base line 4452 pinned the literal as expected content, which is why 689 cases stayed green over the defect. Head replaces it with a property (key present, no foreign digits) and adds battery "#17919 ..." (25 cases, floor 24; roster floor 24 to 25; 689 + 25 + 1 = 715). Base 689 pass exit 0; head 715 pass exit 0. Ablation with part (3) restored to the literal: exit 1, 10 red, the sweep case naming all five printing states. Ablation with the foreign guard removed: exit 1 (by a TypeError at the battery's own foreign[0].card read; with that one access made optional, 4 red). Ablation loosening the fail-closed id rule: exit 1, 3 red. Direction predicted and observed in all three: the battery would not be green without the fix. Accept/refuse changes, one by one: (1) SELF_TEST_BATTERIES gains one entry, floor 24 to 25 — self-test only, tightens what a green self-test means. (2) commentCardNumber, correctionTarget — new pure exports of a repo-root script; no verdict reads them. (3) cardDeclaration takes an optional card and its withNote results carry a correctionTarget field — state, value and detail unchanged (asserted by the battery and confirmed by identical exits); no consumer serializes or deep-compares the object (0 hits, control 36 JSON.stringify sites). (4) CORRECTION_REMEDY constant becomes correctionRemedy(target) plus correctionTargetNote — text inside C2 rows only. (5) c2DeclarationUnreadable passes pair.card. Net: the gate accepts and refuses exactly what it did; only the digits in part (3) of the remedy, and a stated reason when there are none, change. Clause-②: no HOLDS. scripts/pm/ is under the private root package (@objectstack/spec-monorepo, private true); no package.json in the tree names scripts/pm (0 hits; control: 70 package.json files declare files[]); the two new exports are reachable only by importing the script by path. Nothing reaches a published package. Unexplained and NOT claimed by this fix — the 0 / 4 / 0 reading. The evidence-upgrade comment records the same command on the same pair answering 0, 4, 0 from two working directories with an identical script blob. This diff touches nothing in the fetch, the claim ordering, the repo resolution (env-driven, no cwd read) or the read-path selection, so it does not bear on that reading at all, and the reading remains open. I re-read #17425's 16 comments: every one has created_at equal to updated_at, so comment edits are excluded as the cause. The PR body's "without reproducing the non-determinism" is accurate; closing #17919 must not be read as settling the non-determinism. ② Semver levelskip-changeset, no changeset file — CORRECT. The rule: .claude/agents/os-dev.md lines 296-297 (the only criterion is that nothing published moves, published meaning what each package's files[] ships; fast path names scripts/pm/** as non-publishing), AGENTS.md lines 1064-1067 (the label is for a diff that publishes nothing from any released package), .github/workflows/pr-automation.yml lines 691-760 (route 2), .claude/skills/pm-dispatch/references/lanes/devx.md line 21 (root scripts/ implies skip-changeset), references/review-checklist.md line 20 (this repo takes the label, never an empty changeset). Verified: root package private, scripts/pm in no files[], labels read 11:33:00Z are size/m and skip-changeset, Check Changeset check run success on the head sha. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — at-tier PASS, landing pre-check clear
Landing pre-check, all three: ① the PASS above names this head. ②
|
…tack-ai#18681) Fixes objectstack-ai#18456 Clause-②: no `scripts/pm/` sits outside every workspace package, and the root package is private, so no `files[]` can ship this diff — `skip-changeset`. ## The defect `check-clause2-carriers --pair` is the landing pre-check every seat runs, and on one pair (PR objectstack-ai#17917 / card objectstack-ai#17425) it answered **0 at 02:57Z, 4 at 03:04:09Z and 0 at 03:58:33Z on 2026-09-13 with an identical script blob**. Two explanations were ruled out with controls (no comment on that thread was ever edited; the board is resolved from the environment, never from the working directory), so the cause is still UNKNOWN — and the three runs could not be compared, because not one of them had SAID what it read. The judging half is already deterministic given a fixed document (`--pair-json` proves that); what was unpinned is **what document the live path builds**. This states it: every `--pair` run now closes with a fenced `clause2 input record` block on stderr, with the same field roster on every exit, so two runs that disagree are settled by **diffing their two blocks** — never by re-running until one side wins. ⛔ No guess at the cause is dressed as a fix here: no predicate, no state, no row, no count and no exit code reads one character of the record, and the judging half is untouched. ## The record's field roster Rendered from `INPUT_RECORD_RUN_FIELDS` and `INPUT_RECORD_PAIR_FIELDS` and from nowhere else, so a field cannot silently disappear: a declared field this run could not fill renders with an explicit token rather than vanishing, and a field the builder fills that the roster does not declare is NAMED in the block (`record.undeclared`). Values too long for one line continue on indented lines under their key. | half | fields | |:--|:--| | run | `record.version` · `run.utc` · `run.mode` · `run.script.path` · `run.script.blob` · `run.script.bytes` · `run.node` · `board.repo` · `board.source` · `read.plan` · `read.api` · `read.token` · `read.served` · `read.pair-json` · `run.requests` · `pairs.derived` | | per pair (`pair.N.`) | `pr` · `card` · `derivation` · `head-sha` · `card-comments` · `card-comment-ids` · `card-comment-newest` · `pr-comments` · `pr-comment-ids` · `pr-comment-newest` · `claim.rule` · `claim.selected` · `claim.rejected` · `claim.clause2-line` · `pr-body.clause2-line` | Four of them are worth naming for WHY they are there: - **`run.requests`** — every read the run issued, in order, with its channel, its exact path and its **row count**. A page asked for with `per_page=100` that answers with exactly 100 rows is the one shape a truncated read and a complete one share, and nothing printed it. - **`claim.rule` + `claim.selected` + `claim.rejected`** — the carrier, the rule that picked it and every candidate it did not pick, each with its reason. That separates "the two runs selected different comments" from "the two runs applied different rules". - **`claim.selected`'s body fingerprint** (bytes + `sha256:`) — the field the measured 0/4/0 actually needs. A `misplaced` verdict on that thread requires the governing claim to have carried no readable declaration while a superseded one did; same ids with a different verdict is only possible if the BYTES differed, and the ids were all anybody could see. - **`run.script.blob`** — git's blob hash of this file, beside the path it ran from. "The blob was identical on both sides" was a claim in the incident; it is now a printed fact any seat checks with `git hash-object`. On this PR's head it reads `25d204236aa8296644813109fa77541d6efe1644`, which is exactly `git rev-parse HEAD:scripts/pm/check-clause2-carriers.mjs`. The `--json` sweep carries the same record under `inputs` — the same record, ⛔ never a second format. ## The two live blocks the card names `--pair 17917` — the pair from the card. Both it and objectstack-ai#18654 have since merged, so `--pair` answers **exit 2** on each today (the pair cannot be formed from a closed PR). ⭐ That is precisely the class of exit the old code said the least about, and the block is now complete on it: ```text ----- clause2 input record v1 ----- record.version: 1 run.utc: 2026-09-17T14:10:26.210Z run.mode: --pair 17917 run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above) run.script.bytes: 513169 run.node: v22.22.2 board.repo: objectstack-ai/objectstack board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered read.plan: (i) token then (ii) token-less public read read.api: https://api.github.com (REST, accept application/vnd.github+json) read.token: present read.served: token=1, public=0, pair-json=0 read.pair-json: (not named — this run read the network) run.requests: 1 read(s), in the order they were issued objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s)) pairs.derived: 0 pair(s) record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument. ----- end clause2 input record ----- ``` `--pair 18654` — the pair this seat landed today, which answered 0 at 12:32Z and is likewise merged now (**exit 2**): ```text ----- clause2 input record v1 ----- record.version: 1 run.utc: 2026-09-17T14:10:27.163Z run.mode: --pair 18654 run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above) run.script.bytes: 513169 run.node: v22.22.2 board.repo: objectstack-ai/objectstack board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered read.plan: (i) token then (ii) token-less public read read.api: https://api.github.com (REST, accept application/vnd.github+json) read.token: present read.served: token=1, public=0, pair-json=0 read.pair-json: (not named — this run read the network) run.requests: 1 read(s), in the order they were issued objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s)) pairs.derived: 0 pair(s) record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument. ----- end clause2 input record ----- ``` ⭐ `diff` of those two blocks is **four lines**: `run.utc` and `run.mode`, twice. Same roster, same order, same shape — which is the property the card asked for. ## A live block on exit 0 `--pair 18659` (open at the time of writing) — **exit 0**, the full pair half: ```text ----- clause2 input record v1 ----- record.version: 1 run.utc: 2026-09-17T14:10:36.744Z run.mode: --pair 18659 run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above) run.script.bytes: 513169 run.node: v22.22.2 board.repo: objectstack-ai/objectstack board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered read.plan: (i) token then (ii) token-less public read read.api: https://api.github.com (REST, accept application/vnd.github+json) read.token: present read.served: token=5, public=0, pair-json=0 read.pair-json: (not named — this run read the network) run.requests: 5 read(s), in the order they were issued objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s)) objectstack-ai#2 (i) token /repos/objectstack-ai/issues/18443 -> HTTP 200 objectstack-ai#3 (i) token /repos/objectstack-ai/issues/18443/comments?per_page=100 -> HTTP 200 (4 row(s)) objectstack-ai#4 (i) token /repos/objectstack-ai/objectstack/pulls/18659/files?per_page=100&page=1 -> HTTP 200 (1 row(s)) objectstack-ai#5 (i) token /repos/objectstack-ai/issues/18659/comments?per_page=100 -> HTTP 200 (1 row(s)) pairs.derived: 1 pair(s) pair.1.pr: 18659 pair.1.card: 18443 pair.1.derivation: `closing-keyword` (via a closing keyword) — body line: Fixes objectstack-ai#18443 pair.1.head-sha: 1344eb5 pair.1.card-comments: 4 row(s) pair.1.card-comment-ids: 5713976124,5714587497,5714873191,5715029659 pair.1.card-comment-newest: 5715029659 at 2026-09-17T13:19:56Z pair.1.pr-comments: 1 row(s) pair.1.pr-comment-ids: 5715030051 pair.1.pr-comment-newest: 5715030051 at 2026-09-17T13:19:57Z pair.1.claim.rule: the GOVERNING claim — the NEWEST comment whose body carries a line beginning `Claim:`/`Claimed:` AND whose `Branch:` line parses at least one protocol-shaped branch (newest by `created_at`; an unreadable stamp or a tie falls back to thread order, later row wins). The pool is every claim comment sharing that `created_at`; when NO claim names a branch at all, every claim comment is the pool. ⛔ Not earliest, ⛔ not a session match, ⛔ not the one whose body mentions the key. pair.1.claim.selected: 1 comment(s) in the pool 5714587497 at 2026-09-17T12:46:45Z — 2159 bytes, sha256:795e1df6c9fd pair.1.claim.rejected: none — every claim comment on this thread is in the pool pair.1.claim.clause2-line: DECLARED `no` — Clause-②: no pair.1.pr-body.clause2-line: DECLARED `no` — Clause-②: no⚠️ stated as an INPUT only — ⛔ no row here judges the PR body; the declaration limb is judged from the card, and `check-changeset-no-major.mjs` is what reads this line. record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument. ----- end clause2 input record ----- ``` ## Pins Battery **objectstack-ai#18456: the `--pair` input record — the same block on every exit, so two runs that disagree can be diffed**, registered in `SELF_TEST_BATTERIES` with a floor of **38**; **41** cases register. `SELF_TEST_BATTERY_FLOOR` raised 26 → 27 by exactly the one battery this adds. What is pinned, in the card's own terms: - the record is **present and complete on exit 0**, on the **exit-4 (MISPLACED)** shape and on a **refusal that formed no pair** — all three key lists asserted equal; - the **field roster** cannot lose a field: a declared field that was never filled still renders (with `INPUT_RECORD_UNSET`), an empty record still carries every declared key, and a key outside the roster is named rather than printed in silence; - the **selected-claim rule is stated**, and it is the one constant `claimCarrierSelection` applies — so the printed rule cannot drift from the applied one; - a **rejected candidate is named with its reason**, and a thread with nothing rejected says so; - a **`--pair-json` run names that read path as such** and names the document; - the body fingerprint **moves when only the bytes move** while every id field stays identical — the measured shape, asserted directly; - `gitBlobSha1` is pinned against two values `git hash-object` prints. ⛔ CONTROLS in the same battery: the block carries no verdict, no exit code and no finding row; building it changes no reading; and the selection the block prints IS the pool `cardDeclaration` judged (ONE derivation — `cardDeclaration` now calls `claimCarrierSelection` instead of deriving the pool inline, so the record and the verdict cannot describe two different comments). `--self-test` on this head: **786 cases pass, exit 0** (745 before; +41). ## Ablation From the committed tree, blob `25d204236aa8296644813109fa77541d6efe1644` (= this PR's head blob), the pair half of the record removed on disk, mutation proved before the run, restore by blob hash under a `trap`: ```text HEAD blob 25d2042 before: removed-text count=1 (want 1); injected count=0 (want 0) after : removed-text count=0 (want 0); injected count=1 (want 1) mutated blob e88355f70e8648f1e3d30147f0c82b7c3c157609 VERDICT ablation-mutated self-test exit=1 ← 14 cases red ✗ every declared PAIR field is present once per derived pair, prefixed by its index ✗ the SELECTION RULE is printed, not merely applied — two runs must be comparable on the rule too ✗ …and it is the one constant, so the printed rule cannot drift from the applied one ✗ the SELECTED carrier is named by id and by date ✗ ⭐ …with a BODY FINGERPRINT: the one field that tells "same ids, different bytes" apart ✗ ⭐ …and it MOVES when only the bytes move: same ids, same count, same newest, different verdict ✗ every REJECTED candidate is named, with the reason it is not the carrier ✗ …and a thread whose claims are all in the pool says THAT, rather than going quiet ✗ a claim that parses ZERO branches leaves NO carrier, and the block names that claim ✗ an UNREAD thread reads UNREAD, ⛔ never 0 rows ✗ the line READ from the carrier is stated — declared, near miss or nothing ✗ the PAIRING quotes the body line it was derived from ✗ …and the branch-name fallback names the head ref instead of quoting a line that does not exist ✗ the PR-BODY line is read and stated — ⛔ and stated as an INPUT, never as a limb restored blob 25d2042 (HEAD 25d2042) git diff HEAD --name-only: [] VERDICT ablation-restored self-test exit=0 ``` Direction predicted before the run and observed: **turns red**. The module is run directly from source by `node scripts/pm/…` — no build and no `dist/` between the edit and the run, so the on-disk proof is the whole preflight.⚠️ **A named gap, not a hidden one**: the battery drives the builder and the renderer, and it cannot see `main`'s **emission**. An ablation that deleted the two lines in `main`'s `finally` would come back green. What covers emission is the three live blocks quoted above, taken on this head across three different exits. ## Candidate cause, unproven — ⛔ not fixed here Two readings taken while wiring the record. Neither is acted on in this PR. **1. On the blob all three 2026-09-13 runs ran, exit 4 was the DETERMINISTIC answer for that pair — so what is unexplained is the two 0s, not the 4.** - The file's last change before those runs was `a5ed18ced` (2026-09-12T06:05:25Z, "a key-INITIAL clause-② line that QUOTES the spelling is not a declaration"); its next change was `4e3a496ba` at 2026-09-13T17:19:18Z, after all three runs. `a5ed18ced`'s blob is `aecbb2d86683eb908468fdacaac2ff53753f06ef` — the same blob PR objectstack-ai#18448's body independently cites as "the exact blob the 2026-09-13 readings were taken from". - The governing claim on card objectstack-ai#17425 at that moment was comment `5650083758` (2026-09-13T01:57:23Z). Its line 3 opens `Clause-②: no —` and then quotes the spelling again inside the same line. Run first-hand against **that historical blob's own `readClause2Line`**: `{"kind":"near-miss","reason":"describing"}`, and `cardDeclaration` on a one-claim thread reads `missing` — ⛔ not a declaration. Today's copy reads it identically. - A `--pair-json` document assembled from the REAL thread as it stood at 03:04:09Z (its 9 comments, both carriers' real label event streams) answers **exit 4, MISPLACED** on this PR's head, quoting the superseded `Clause-②: yes` and naming `5650083758` as the correction target — which is what the 03:04Z reviewer and the 02:53Z dev round both reported. - ⇒ The 4 is reproducible and mechanically explained. The 0s are not. ⭐ Exactly the difference the record's `claim.selected` fingerprint and `claim.clause2-line` would have shown, had the 0-runs printed one. -⚠️ Limits of this reading: the historical module was exercised for `readClause2Line` (self contained) and `cardDeclaration` (which imports today's sibling modules); the commit ordering is read from a shallow checkout, corroborated by objectstack-ai#18448's independent citation of the same blob. **2. The comment read — the one the declaration limb depends on — is the only read here with no page discipline.** `readCardComments` issues ONE request, `/issues/N/comments?per_page=100`, with no `page=` ladder and no short-read check. `readCarrierEvents` and `readPullFiles` both page to exhaustion and answer `null` (UNJUDGED, never clean) when their cap is hit, for the reason their own docblocks state. A card thread past 100 comments therefore loses its tail silently, and the claim pool is built from whatever came back. Not the cause on objectstack-ai#17425 (7 comments at 02:53Z, 16 today), but it is a live fail-open in this reading. The record makes it visible for the first time: request `objectstack-ai#3` prints its row count, so a `(100 row(s))` on a `per_page=100` request is now readable. ⛔ Not fixed here; the seat files or re-scopes. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree with no hand-fed path list; re-derived after rebasing onto current `main` (the derivation was STALE-TREE by 4 commits) — **identical command list**. All 34 run at head `f5773ce08`, exit codes captured redirect-then-`$?`: ```text 0 :: node scripts/check-adr-0087-registration.mjs --base origin/main 0 :: node scripts/check-adr-0087-registration.mjs --self-test 0 :: node scripts/check-changeset-no-major.mjs --base origin/main 0 :: node scripts/check-changeset-no-major.mjs --self-test 0 :: node scripts/check-ci-filter-parity.mjs 0 :: node scripts/check-closing-keyword-parity.mjs 0 :: node scripts/check-closing-keyword-parity.mjs --self-test 0 :: node scripts/check-comment-mask-corpus.mjs 0 :: node scripts/check-declaration-mirrors.mjs 0 :: node scripts/check-declaration-mirrors.mjs --self-test 0 :: node scripts/check-scripts-symbol-anchors.mjs 0 :: node scripts/check-scripts-symbol-anchors.mjs --self-test 0 :: node scripts/check-self-test-wired.mjs 0 :: node scripts/check-self-test-wired.mjs --self-test 0 :: node scripts/check-self-test-workflow-commands.mjs 0 :: node scripts/check-self-test-workflow-commands.mjs --self-test 0 :: node scripts/check-whole-set-label-write.mjs 0 :: node scripts/check-whole-set-label-write.mjs --self-test 0 :: node scripts/pm/bare-root-worklist.mjs --self-test 0 :: pnpm check:agent-test-spelling 0 :: pnpm check:bash32-floor 0 :: pnpm check:changeset-gate-self-tests 0 :: pnpm check:cli-command-ids 0 :: pnpm check:cross-package-test-inputs 0 :: pnpm check:driver-memory-census 0 :: pnpm check:entry-guard 0 :: pnpm check:nul-bytes 0 :: pnpm check:parse-guard 0 :: pnpm check:pm-clause2-carriers 0 :: pnpm check:pnpm-filter-targets 0 :: pnpm check:ratchet-remedy-authority 0 :: pnpm check:refd-timer-probe 0 :: pnpm check:watch-hint-literal 0 :: pnpm check:pm-dispatch-gates ``` Reconciled: `dispatch-gates --ran` ⇒ **34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN**. Repo-wide `pnpm lint` (`eslint . --no-inline-config`) at `f5773ce08`: **exit 0**. `grep -naP` for control bytes over the changed file: no hits. ⛔ Outside these 34, as the derivation itself prints: 53 artifact-roster families, 11 wide-population families, 7 pending-changeset families, 1 path-scheduled CI job and the always-runs tail. Their absence here is not a clearance. ## Acceptance notes Out of scope, noted and ⛔ not filed: - The read-path report and the input record now also print on the `--pair-json` **usage refusals** (a missing file, a non-JSON document, a board conflict), because everything past the board resolution moved inside one `try`/`finally`. One extra stderr line on those paths, in the direction the file's own header argues for. Carrier: whoever next edits `main`. - `main`'s `--pair` value is parsed in two places now (once for `run.mode`, once for the pair itself). Both read the same argv through `flagIndex`; a reader may prefer one. Carrier: whoever next edits `main`. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ Co-authored-by: Claude <noreply@anthropic.com>
… must answer what prints or executes it (objectstack-ai#19039) Fixes objectstack-ai#18958 Clause-②: no ## What changed One rule line lands in `.claude/skills/pm-dispatch/SKILL.md` 〈平台读数纪律〉, beside the zero-hit rules it mirrors: a POSITIVE grep hit offered as evidence that a defect is still alive must answer who prints or executes it; unanswered, it does not prove the defect alive; a hit in a comment, a docblock, a fixture or the remedy's own regression pin does not count. Nothing else in the file moves. The discipline already refused a bare zero (:163 「零命中须配同主体必中词,否则该零作废」, :165 「控制通过 ≠ 问题问对」) and said nothing about a bare positive — so "grep found it ⇒ still alive" had no rule behind it, and three seats in a row re-derived the same hits on a literal that no executed or printed path carries (specimen below). The card and the triage prescribe the shape, not the wording; the wording here is the seat's. ## The band, before / after (`.claude/skills/pm-dispatch/SKILL.md`, `main` `75c0dacff` → this branch `97d0a55bd`) Rule added after :166 (now :167), 115 bytes: ```text - 正命中须答谁打印/执行它,否则不证缺陷仍活;注释/文档块/夹具/修复的回归 pin 不算。 ``` Paid by compressing the band's two timestamp bullets (:170 106 B and :171 105 B on `main`) into one (now :171), 118 bytes: ```text before :170 - 板面/树/队列读数恒带 UTC 取数时刻;认领、派发令、复核、轮报与座位贴皆同。 before :171 - 时间戳形如 `YYYY-MM-DDThh:mmZ`,树读数另带 ref 或 tip;无时间戳的读数按未取处理。 after :171 - 读数与认领等座位文本恒带 UTC 取数时刻 `YYYY-MM-DDThh:mmZ`;树读数另带 ref/tip,无者按未取。 ``` What the fold deletes, named: the enumeration of reading kinds (板面/树/队列 — subsumed by 读数, which :171's own second clause already generalised: 无时间戳的读数按未取处理 applies to every reading) and the enumeration of seat texts (认领、派发令、复核、轮报与座位贴 — kept as the class 认领等座位文本). No rule leaves: the UTC stamp, its `YYYY-MM-DDThh:mmZ` shape, the tree reading's ref/tip and the no-stamp ⇒ not-taken verdict all survive on one line. This is compression in place, not a whitespace re-wrap: the two source lines sum to 211 bytes against the 120-byte cap, so no re-flow could have merged them — the ratchet header's own measurement (601 adjacent bullet pairs, zero merge under the cap) is why a paid line in this file is always a content compression, and the two prior commits on this band paid the same way. Ratchet: `.claude/skills/pm-dispatch/SKILL.md is 812 lines (ceiling 812; headroom 0)` before and after (gate line quoted from `check:pm-skill-ratchet` on `97d0a55bd`). `references/platform-readings.md` untouched (466 / 466; held by PR objectstack-ai#18961 — no fact row is owed: the incident is a discipline gap, not a platform behaviour). `references/core-rules.md` untouched (151 / 151; it is a declared subset — 「⛔ 不新增规则」 — and carries no mirror of this line). The band still reads as a pair after the edit: :163–:166 say what a ZERO owes (a lit same-subject control, a radius, a known target outside it); :167 says what a POSITIVE owes (who prints or executes it); :168–:169 (units and the counterfactual) follow unchanged; no orphaned 「同主体」 reference. ## The specimen the rule is written for Card objectstack-ai#18376 (state: completed) claimed the C2 remedy of `scripts/pm/check-clause2-carriers.mjs` printed a hardcoded claim-comment id. The remedy landed as `2502b8a1` (PR objectstack-ai#18448) on 2026-09-16T11:49:48Z — six and a half hours after the card was filed — and afterwards the triage seat re-derived the same coordinates and graded p2, and the execution seat judged the premise intact and dispatched. Three readings, all positive, none asked what prints or runs the hit. Measured on `main` `75c0dacff` (`git grep -n 5642248126 -- scripts/pm/check-clause2-carriers.mjs`): 13 lines, every one of them off the executed / printed path. | line | form | reachability | origin | |---|---|---|---| | :387 | docblock example (` * Clause-②-correction: …`) | header prose, never printed | pre-remedy | | :1333 | docblock prose | never printed | added by `2502b8a1` | | :1359 | docblock prose | never printed | added by `2502b8a1` | | :2442 | docblock prose | never printed | added by `2502b8a1` | | :6315 | `//` comment inside `selfTest()` | never executed | added by `2502b8a1` | | :6324 | self-test assertion `says(missingLine, '5642248126') === false` | regression pin asserting ABSENCE | added by `2502b8a1` | | :7446 | `const C_CLAIM_ID = 5642248126` | self-test fixture constant | pre-remedy | | :7914 | `//` comment inside `selfTest()` | never executed | added by `2502b8a1` | | :7939 | self-test assertion `=== false` | regression pin asserting ABSENCE | added by `2502b8a1` | | :7953 | self-test assertion `every(… !says(row, …))` | regression pin asserting ABSENCE on every rendered row | added by `2502b8a1` | | :7959 | `C19_CLAIM({ id: 5642248126, … })` | self-test fixture row (the foreign claim the remedy must drop) | added by `2502b8a1` | | :7963 | self-test assertion `=== false` | regression pin asserting ABSENCE in the remedy | added by `2502b8a1` | | :7964 | self-test assertion `says(C19_FOREIGN, 'Comment 5642248126')` | regression pin asserting the DROP diagnostic names the dropped id | added by `2502b8a1` | Totals: 4 docblock + 2 line comments + 2 fixtures + 5 self-test assertions (4 pin absence, 1 pins the loud-drop diagnostic) = 13; 11 of the 13 were written by the remedy itself (`git show 2502b8a`: 11 added lines carry the literal, 2 removed — the remedy string and the old presence pin; the pre-remedy tree `2502b8a1^` carried 4). The card's 「6 处是修复自己的回归 pin」 is this table's 5 assertions plus the fixture row that feeds them — same set, one boundary choice apart. Cheap reachability probes, all on `75c0dacff`: `--help` prints 19 lines, 0 hits; `--template` prints 40 lines, 0 hits; `--pair 18958` took the input-record path (exit 2, no card derivable from that number), 24 lines, 0 hits; the C2 remedy limb itself now interpolates — :2458 reads `whose FIRST line is \`Clause-②-correction: ${id}\``. The seat that greps this literal and finds 13 lines must now say which of them anything prints or runs before treating the defect as alive; here the answer is none, and the six pins asserting absence are the remedy's evidence of having landed. ## Reader test A seat that greps a literal and finds it must now say whether anything prints or runs it before calling the defect alive — the rule asks exactly that (须答谁打印/执行它), voids the hit otherwise (否则不证缺陷仍活), and names the four places a hit that fails the question usually lives (注释/文档块/夹具/修复的回归 pin). ## Verification (all on `97d0a55bd`, exit codes captured before any pipe) Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the change set (stderr line 1: `gate list derived from the tree of 'objectstack-ai/objectstack' at commit 75c0dac`): 20 commands, every one run, `COMMAND :: exit CODE`: ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/pm/check-governed-queue-guard.mjs --self-test :: exit 0 node scripts/pm/check-harness-current.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 3 (PREREQUISITE NOT MET, nothing measured) → built @objectstack/formula + @objectstack/lint under os-verify-lock (VERDICT command-exit 0, 148 s) → re-run :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-expected-skips :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-governed-prose :: exit 0 pnpm check:pm-half-states :: exit 0 (4978 self-test cases pass) pnpm check:pm-skill-id-lint :: exit 0 (27 files clean) pnpm check:pm-skill-ratchet :: exit 0 (SKILL.md is 812 lines, ceiling 812, headroom 0) pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` Named by the dispatch beyond the derivation: `node scripts/pm/check-governed-merges.mjs --test .claude/skills/pm-dispatch/SKILL.md :: exit 3` (GOVERNED — expected; `.claude/**` ×1); `pnpm check:pm-dispatch-gates` :: exit 0 (cap-exceeding per its own header, so started detached and waited on in the foreground with `tail --pid`; `dispatch-gates self-test: 1849 cases pass`, 751.7 s on this box). Reconciliation `dispatch-gates.mjs --ran` :: exit 0 — `20 derived famil(ies) accounted for — 20 run, 0 NOT-MEASURED` (the doc-formula line recorded at its post-build re-run, exit 0). The 120-byte line rule is part of `check:pm-skill-ratchet` (its `### Why 120 bytes` section), not of `check-skill-id-lint`; both ran. ## Acceptance notes - noted, not filed: `references/core-rules.md` :44–:45 carry the zero-hit rules in summary form and no positive-hit mirror; the file is a declared subset at 151 / 151, so a mirror there is a separate compression decision for the skills seat, not a gap in this PR. 承接者: skills seat, at the next core-rules compression. - noted, not filed: `check-clause2-carriers.mjs --pair N` reads N as a PR number, so `--pair` on a card number lands on the input-record path (exit 2) rather than the remedy limb; the filer's 「对本卡自身跑 --pair 出现 0 次」 was a probe of that path too. Not a defect (documented in `--help`); 承接者: 无. - No fact row for `references/platform-readings.md`: `git grep -c` counting matching lines (not occurrences) is git behaviour every seat already reads from `git grep --help`, not a platform reading this repo measured. - Skip-changeset: the diff touches `.claude/**` only — nothing any package's `files[]` ships. ## 维护者速读(草稿) **改了什么**:PM 技能的「平台读数纪律」一节加了一条规则:座位用 grep 查到某个字符串,要拿它当「缺陷还活着」的证据,必须先答一句「是谁在打印或执行它」;答不出,这个命中就不算证据;命中落在注释、文档块、测试夹具、或者修复自己写的回归 pin 里,都不算。为了不增加文件行数(棘轮 812 / 812),同一节里两条讲时间戳的规则压成了一条,规则内容没有丢。 **为什么改**:现有纪律只管「查不到」的情况(查不到要配一个必定能查到的对照词),不管「查到了」的情况。结果一张卡的修复已经合进 main 六个半小时后,立卡、分诊、执行三个席位各自又 grep 了一遍,三次都命中、三次都判「缺陷还在」,还派发了一次开发——而那 13 处命中全部在注释和测试里,其中 6 处正是修复自己写的、断言「这个字面量不许出现」的 pin。这是成文纪律的缺口,不是某一席手滑。 **风险与代价(含回滚)**:只改一段散文,不动任何代码或门禁;成本是一行读数纪律,每个席位每轮多答一句话。压缩的那两条时间戳规则丢掉的只是举例列表(板面/树/队列;认领、派发令、复核、轮报、座位贴),规则本身保留。回滚就是还原这一个文件的这两处改动。机械守卫(自动判断「这一行是不是注释」)是另一张卡,本 PR 不做。 **席位意见**:(留空,席位定稿) **你要做的**:这是受管面(`.claude/skills/**`)的规则层改动,需要你确认并人工合并;看一眼新增的那一行措辞是否合你的意,以及压缩后的时间戳规则是否可接受。 --- _Generated by [Claude Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17919
Clause-②: no
A CLI gate script exports no published symbol; the two new exports
(
commentCardNumber,correctionTarget) live inscripts/pm/, which nopackage's
files[]ships. The seat declared this on the card (claim comment5696260545) and the diff did not move it.The finding, and what it actually was
check-clause2-carriers --pairreported the clause-② declaration MISPLACED and,inside that message, named claim comment
5642248126— aClaim:on card#17366, unrelated to the card under test.
The card reads that as the script's selection. It is not. Part (3) of the
C2 remedy was a module-level constant that carried the id as a literal:
That is the #17366 specimen, hard-coded into the sentence every C2 row prints.
Nothing selected it, so no guard over a selection could have fired — a constant
makes no selection. The id printed on every card, in every C2 state that
carries the remedy, and had done so since the constant was written.
Verified on the exact blob the 2026-09-13 readings were taken from —
git cat-file -p aecbb2d86683eb908468fdacaac2ff53753f06ef | grep -n 5642248126→ line 1445, inside
CORRECTION_REMEDY. Same literal, same sentence.Reproduced deterministically, offline, on a card of its own
--pair-jsonjudges a pre-fetched pair with no network, so the reading isexactly reproducible. A synthetic card #99002 whose whole thread is comments
900000001and900000002, judged by theorigin/maincopy of the script:An id that is on neither comment of the thread it just read, printed inside the
verdict about that thread. ⇒ the class is re-observable today, on any card,
without reproducing the non-determinism the evidence upgrade measured.
The cut, and why it is here rather than where the card offered it
The card offers a guard that the selected comment's
issue_urlmatches the cardunder test, explicitly as a shape. Measurement moved it one level up: the only
ids this file may print are ids it read from the pool the reading was
built from, and each is checked against the card under test before it is
printed.
commentCardNumber(row)— the issue a row says it belongs to, read offissue_url; this is the card's own control, mechanised.correctionTarget(pool, card)— resolves the printable id from the SAME poolcardDeclarationbuilt its readings from. A positive disagreement drops theid and says so; ⛔ absence of
issue_urlis not read as a mismatch (anoffline document and this file's own fixtures carry rows without it, and a row
that states nothing contradicts nothing); a reading with no card number names
no id at all — fail-closed.
CORRECTION_REMEDYis a function of the card under test. When no id survives,part (3) names the key and no digits, plus the reason.
⛔ Nothing here resolves a state, a row or an exit code.
Both directions, judged — this is a gate other PRs must pass
Does the guard suppress a message that was correct? No. Word-diff of the
whole output, same input,
origin/maincopy vs this one:That is the entire delta. The MISPLACED verdict, its sentence, its remedy and
exit=4are byte-identical. A self-test case asserts each of the five remedy-carrying states still renders a non-empty row, so the controls cannot pass
vacuously.
Does any exit code move on a pair judged correctly? No. All 16 open PRs,
both copies run back to back, 2026-09-16T11:03–11:04Z:
Identical on both copies, 16/16, including all three non-zero verdicts.
Population — previously unmeasured, now partly measured
misplaced,malformed, and all threemissingvariants.absentdoes not print it (noclaim comment exists to correct). Enumerated mechanically: the ablation's own
failure detail names all five.
derived from 16 open PRs —
0with NO CLAIM COMMENT,0with a claim commentbut NO DECLARATION LINE ⇒ 0 pairs on the printing path today. The class is
a failure-path constant, not a standing one.
Tests
--self-test, the safety net, on2620cb2b3d: 715 cases pass, exit 0(689 before; +26). New battery
#17919: the correction remedy names THIS card's claim comment, never another card's, floor raised by exactly one.Ablation — the guard removed, part (3) restored to the pre-fix literal, on a
committed tree, mutation proved on disk before the run and the restore proved by
git diff HEADand a blob-hash comparison:Direction predicted before the run and observed: turns red. The module is
run directly from source by
node scripts/pm/…— there is no build and nodist/between the edit and the run, so the on-disk proof is the wholepreflight.
Gates: all 36 commands derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackrun at2620cb2b3d, exit 0 —including
pnpm check:pm-clause2-carriers,pnpm check:nul-bytesandpnpm check:pm-dispatch-gates(496.6s; a first run capped at 240s readexit=124, NOT MEASURED, and was re-run to completion). Reconciled:dispatch-gates --ran⇒ 36 derived, 36 run, 0 NOT-MEASURED, 0 UNRUN.No changeset: the root package is private and
scripts/pm/sits outside everyworkspace package, so no
files[]can ship it.Acceptance notes
Out of scope, noted and ⛔ not filed:
misplacedsentence says "NOT in the card's claim comment" on threads thathave no claim comment at all (
misplacedandmalformedare bothreachable from the thread-wide scan with an empty claim pool). Prose
imprecision on a reachable path, not a wrong verdict; the remedy now degrades
correctly there. Carrier: whoever next edits
c2Sentence.the 条款②声明载体是一扇单向门:席位能把自己写进一个自己出不去的状态 —— 一个会话里同一个坑被踩了 5 次 #17366 specimen id. It is a doc comment inside a paragraph that is
explicitly about 条款②声明载体是一扇单向门:席位能把自己写进一个自己出不去的状态 —— 一个会话里同一个坑被踩了 5 次 #17366, never printed, and left as written.
Generated by Claude Code