Commit ea1da64
feat(pm): mechanise the prior-ruling search for decision cards (#17515)
Part of #17009 — deliverable **D1 only** (the reader plus the one fixed
line). D2, the half-state row beside the facet block, is serial on the
`check-half-states.mjs` chain and is **not** in this PR, so the card
stays open. #17009 is not closed here.
## The class, and the two failures this mechanises
A decision card reached batch presentation carrying a recommendation to
**re-rule something an accepted ADR already decides**. The
governing-text step failed at both points where it applies:
* **at filing** — the card's `Governing text:` line named ADR-0105,
ADR-0021 D-C, two code symbols and a maintainer ruling, and did not name
ADR-0131, whose D8 answers the question verbatim (「nothing under
`single` … No layer has a NULL arm」);
* **at presentation** — the presenting seat re-ran the card's own
`re-check` commands and one targeted grep, not the charter's step,
`references/lanes/director.md:45`: 「呈报前逐卡逐仓重跑 `git grep -n -iE 'TERMS'
origin/main -- AGENTS.md docs/adr packages/spec/src`」.
The same class had already been answered once with TEXT (the `Governing
text:` requirement and that grep line). This PR is the mechanical half:
the charter's grep, run by a script instead of by a human who is about
to present five cards.
A card's own `re-check` commands cannot catch this class. They are
authored by the filer to re-verify the filer's PREMISES, so they confirm
the code still looks as described and say nothing about whether the
QUESTION is still open. A premise pass is not a prior-ruling pass.
## The reader's contract
`scripts/pm/check-prior-rulings.mjs` — report-only. It has **no write
path to GitHub in any mode**, and that is asserted structurally in its
own self-test rather than promised in prose.
```
node scripts/pm/check-prior-rulings.mjs --card 16934
node scripts/pm/check-prior-rulings.mjs --card 16934 --terms single,posture,tenant
node scripts/pm/check-prior-rulings.mjs --terms single,posture,tenant # no card, no token
node scripts/pm/check-prior-rulings.mjs --card 16934 --json --top 20
node scripts/pm/check-prior-rulings.mjs --self-test
```
**Inputs.** A card number, read once over REST from `PM_SWEEP_REPO`; or
`--terms`, which replaces the derivation entirely (a seat passing terms
has read the card and is narrowing on purpose). Terms come from the card
TITLE plus its governing-text carrier when one exists, and every term is
printed with its provenance.
**Corpus**, read at `origin/main` with the tip printed, because a seat's
checkout can be behind and a stale answer is wrong in exactly the
direction that matters — it cannot see the ruling that just landed:
| corpus | scanned | named? |
| --- | --- | --- |
| `docs/adr/**` decision units | 463 (399 headings + 64 bullets) | yes —
ranked, top-N |
| `AGENTS.md` | 940 non-blank lines | yes — top 5 |
| `packages/spec/src/**` docblocks | 11,380 | **count only** + 3
pointers |
**Exit codes**, deliberately identical to `check-half-states.mjs`:
* `0` — the read completed, 0 candidates or 400 alike (report-only).
* `1` — `--self-test` failed.
* `3` — PREREQUISITE NOT MET. Prints `Prior rulings read: unresolved`,
never `none`. `none` is a finding; `unresolved` is the absence of one,
and a seat pasting `none` because a token was missing would record a
search that never ran.
* `2` — could not complete for a reason the file cannot classify.
⛔ No retry loop on `403`/`429`, on any path: the fleet runs on one
shared identity, so a retry spends somebody else's quota to learn the
same thing twice. That is asserted structurally in the self-test too.
**Output** ends with the exact line a seat pastes into the four-facet
block.
## The one fixed line
`references/decision-analysis.md` 「## 四棱卡面块固定形状」 gains one bullet (the
literal is in the file; angle-bracket placeholders are spelled out here
so the body survives storage):
> 四棱后一行: backtick — Prior rulings read: TERMS arrow N hits; ADR-xxxx Dn
pipe none — backtick;未搜成整行写 unresolved。
Three spelling decisions, all mine to pin, all declared:
1. **`N` counts ADR decision CANDIDATES, not raw grep lines.** The line
records a prior-ruling reading, and "2,214 matching lines" is not one.
2. **`unresolved` is stated in the protocol text, not only in the
tool.** It is the whole anti-false-green property; a rule that lives
only in a script is a rule the seat pasting by hand never sees.
3. **The named set is accepted decisions only.** 〈升级与决策〉③ (⛔ 不推翻既有维护者裁决)
binds on accepted rulings. Non-accepted hits are still printed in the
report body, flagged, so a `proposed` near-miss is visible rather than
silently dropped.
**The line is paid for by a FOLD, not by a ceiling raise and not by a
re-wrap.** The file was at 50/50. The removed bullet —
「四棱行同受六项写法约束:论据从业务立场写,机制名词只作括号补充。」 — was two halves. Its CONTENT half
duplicated the 正文六项 item 「四轴从业务立场论证:论据是客户、产品或事故后果,机制名词只作括号补充」 almost
verbatim. Its BINDING half did not: it said the four-facet BLOCK is
itself bound by those writing rules, where the six-item entry binds the
body's four-axis prose and 「每卡 = 六项写法 + 四棱块」 only says a card carries
both parts. **A first attempt deleted the bullet outright and was sent
back at review** — 「被砍内容若在别处无家,砍掉就是丢规则」. So the binding was folded into
the six-item entry, which now reads 「四轴从业务立场论证(含四棱行)」, and only then was
the bullet removed as a true duplicate. The maintainer's suggested
parenthetical 「(四棱行同受此约束)」 measured 129 B against the ratchet's 120-byte
per-line budget, so the landed spelling is 「(含四棱行)」 at 117 B. Net: 50
lines, ceiling 50; ⛔ no ceiling moved, and no rule lost a home.
The ratchet also enforces a **120-byte per-line budget**, which the
first two spellings failed (145 B, then 122 B). The landed line is 113
B, and the byte budget is what pushed the generator's name
(`check:pm-prior-rulings`, 24 B with its backticks) out of the line. It
stays discoverable through `package.json` and the CI step.
## Live drill (read-only, 3 REST requests, one per card)
`--card 16934`, the case the card was filed on. **ADR-0131 D8 — the
ruling the card omitted — comes back at rank 1 of 270**:
```
check-prior-rulings: corpus read at origin/main (c7af6bd)
card: #16934 — [Decision] Under `single` posture, which tenant wall …
terms (42): single tenantid data posture tenant wall driver-sql posture-independent … [title]
adr-0105 tenancy postures adr-0021 boot layer inert getreadscope … [governing]
governing-text: present (inline form)
docs/adr: 270 hit of 463 searched
AGENTS.md: 124 hit of 940 searched
packages/spec/src: 3893 hit of 11381 searched (not named — see below)
ADR decision candidates: 270; distinct-term tiers {"1":143,"2":69,"3":30,"4":13,"5":9,"6":4,"7":1,"8":1}
ADR-0131 D8 — One predicate, computed once
docs/adr/0131-…-no-null-organization-id.md:483 terms: single,posture,tenant,engine,driver,adr-0105,tenancy,layer
status: Accepted (2026-09-04) — accepted by the merge that landed it on `main` …
ADR-0131 D3 — A row exists only when an organization authored it …
ADR-0076 D10 — "protocol" is a contract, not an implementation package …
ADR-0105 D1 — Tenancy becomes a three-posture spectrum …
ADR-0120 D5 — Authoring gates
Prior rulings read: single,tenantid,data,posture,tenant,wall,driver-sql,posture-independent,auto-scope,answers,admin,engine (+30 more) → 270 hits; ADR-0131 D8, ADR-0131 D3, ADR-0076 D10, ADR-0105 D1, ADR-0120 D5
```
`--card 17427` (a card that DOES carry a governing-text line) and
`--card 17321` (whose own governing text cites ADR-0087 §D2/§D3):
```
#17427 22 terms · docs/adr 124/463 · AGENTS.md 76/940
Prior rulings read: environmenttype,post,cloud,environments,refuse,values,wire,honour,seven,leave,silent,default (+10 more) → 124 hits; ADR-0006 D2, ADR-0119 D3, ADR-0128 D3
#17321 62 terms · docs/adr 264/463 · AGENTS.md 251/940
Prior rulings read: filter,losslessly,mappable,subset,record-form,real,conversion,stored,rows,loading,re-saving,refused (+50 more) → 264 hits; ADR-0087 D3, ADR-0131 D13, ADR-0087 D7
```
On #17321 the reader independently surfaces **ADR-0087 D3**, which is
the ruling that card's own author had to find by hand. That is the
intended behaviour demonstrated on a card nobody tuned it for.
## Judgement calls, on the four axes
### (a) Bounding the corpus — count `packages/spec/src` rather than name
it
Measured first: the whole 20 MB corpus reads in ~600 ms through one `git
cat-file --batch` per corpus, so **runtime never needed bounding**. What
needed bounding was OUTPUT. On #16934's terms, `docs/adr` returns 270
nameable decisions and `AGENTS.md` 124 lines, but the spec docblocks
return **3,893 of 11,380**.
* **实际业务需求** — the consumer is a seat about to present five cards. 3,893
named docblocks is not a reading anyone acts on; it is a wall that makes
the 270 real candidates harder to see. The count, by contrast, is acted
on: it tells the seat whether the term set is narrow enough to trust.
* **项目长远合理性** — the distinction is structural, not a size threshold: an
ADR decision has a `D` plus number identity and a docblock has none once
you leave its file. Naming is offered exactly where identity exists, so
the rule does not rot as the corpus grows.
* **防 AI 犯错** — the count is never suppressed. "Searched but too broad
to name" and "not searched" must not render alike, so the corpus prints
its scanned and hit totals in every mode, and three top pointers so the
claim is checkable.
* **创业阶段不扩散** — the alternative (a docblock-to-symbol attributor) is a
parser this repo has no other caller for. Declined.
### (b) Term derivation — the title is primary, because the ideal
population does not exist
The obvious design derives terms from the `Governing text:` line.
**Measured on the live board: of 27 open `needs-user-decision` cards, 4
carry a governing-text carrier and 23 carry none** — and the 4 use two
different shapes (one inline `**Governing text:**`, three as a `##
Governing text` section).
* **实际业务需求** — a derivation resting on that line produces an EMPTY term
set on 85% of the real population and prints `0 hits; none`. That is the
exact false green this card was filed against, rebuilt one layer down.
The title is the only always-present source, so it is primary and the
carrier is additive in both measured shapes.
* **防 AI 犯错** — an absent carrier is ANNOUNCED (`governing-text:
absent`, with the 23-of-27 measurement inline) rather than passed over
silently. Loud beats lenient: the seat learns the terms came from the
title alone and can narrow with `--terms`.
* **项目长远合理性** — as `Governing text:` adoption rises the reader gets
strictly better without a code change, because the carrier is additive
rather than required.
* **创业阶段不扩散** — ⛔ no new required field on cards, and ⛔ no gate
demanding the carrier. That would be a protocol expansion nobody asked
for; this PR only reads what is there.
### (c) Word-boundary matching instead of the charter's substring `git
grep -iE`
* **实际业务需求** — measured on #16934's terms: substring returns 215
candidates, word-boundary 166, and the term `data` alone drops from 110
units to 38 because it stops matching inside `metadata`/`database`. The
card's own omitted ruling moves UP, rank 3 → rank 2. Less noise and a
better answer are not a trade-off, so there is nothing to trade.
* **防 AI 犯错** — JavaScript's `\b` is wrong here (it treats `-` and `.`
as boundaries, so `driver-sql` would match inside `driver-sqlite`); the
character class is spelled out as "identifier character" and both
directions are pinned in the self-test.
* Deviation from the charter's literal grep is declared, and `--terms`
reproduces any hand grep exactly.
### (d) Status classification reads the FIRST status word, not a
precedence scan
The drill caught this, and it is the defect worth reading closely.
Precedence-scanning ("superseded beats accepted, anywhere in the line")
misclassified **two live accepted ADRs**: ADR-0076's status opens
`Accepted — …` and mentions a supersession of a sub-part later;
ADR-0105's opens `Accepted (2026-07-27) — …` and says `withdrawn` of a
withdrawn proposal further along. Both were being dropped from the
pasted reading.
* **防 AI 犯错** — hiding a standing ruling is the ONE failure this reader
may not have, and the lenient-looking rule was producing it. Every
measured shape states its verdict first, so position is the honest
reader. `unknown` is kept as its own loud bucket and ⛔ never folded into
`proposed`, and the raw status text prints beside every candidate so the
classification is checkable without opening the ADR.
* Both live shapes are now self-test cases.
### (e) `PM_SWEEP_REPO`, not a `--repo` flag — a declared deviation from
the suggested route
The dispatch suggested `--repo owner/name`. Declined, for the reason
`check-widening-tells.mjs` gives ("⛔ there is no `--repo` flag,
deliberately") and one of this file's own: the CORPUS is always this
checkout, so a flag naming another board would read a card from one repo
and answer it out of another repo's rulings with nothing in the output
saying so. The board resolver and the proxy re-exec plan are
**imported** from `check-half-states.mjs`, not copied — one source each,
so this reader and the patrol cannot disagree about which board is read
or how it is reached. `--rev` and `--top` were added.
## A falsified premise, and a transport reading
**The facet-block presence check does not exist.** The dispatch and the
card both state that an H row on facet-block presence lives in
`check-half-states.mjs` and that this PR must keep it working. Measured
on `origin/main`, with a positive control (`needs-user-decision` hits 5
scripts, so the search works):
```
git grep -n "os-decision-facets" -- .
.claude/skills/pm-dispatch/references/decision-analysis.md:39 ← the declaration, and nothing else
```
The three `four-facet` mentions in `check-half-states.mjs` are prose
inside other rows' remedy sentences, not a check. So the marker
`os-decision-facets` is declared 「首行机器可寻标记」 and read by nothing — which
also means only 5 of the 27 open decision cards carry it. **Nothing in
this PR depends on that premise** (the new line is extractable by the
same literal grep either way), but D2 was graded as "an H row BESIDE the
existing facet-block presence check", and there is no existing one for
it to sit beside. That is the seat's to re-read when D2 is dispatched.
**Node's `fetch` bypasses the proxy in the seat's container.**
`GITHUB_TOKEN` and `GH_TOKEN` both hold the literal `proxy-injected`;
`curl` gets 200 and an unrouted `fetch` gets 401. The reader re-execs
with `--use-env-proxy` before its one request, using the imported plan.
Without it the tool would report a credential problem and send a seat
hunting for a secret that does not exist.
## Verification
Gate families derived from the FINAL diff, `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (71 families), reconciled with `--ran`. Exit
codes captured before any pipe. Verdict lines quoted from each gate's
own output.
All **69** families ran; `dispatch-gates --ran` with exit codes recorded
reconciles clean:
```
Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
EXIT CODES — all 69 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED
(a DERIVED zero — all 69 recorded an exit code and none of them is 3).
```
Every one of the 69 exited **0**. Named verdict lines for the families
this diff actually moves:
```
node scripts/pm/check-prior-rulings.mjs --self-test EXIT=0
· live-tree existence pin: pinned
✓ check-prior-rulings self-test: 99 cases pass (…)
pnpm check:pm-skill-ratchet EXIT=0
✓ check-skill-line-ratchet: …/decision-analysis.md is 50 lines (ceiling 50; headroom 0).
✓ check-skill-line-ratchet: …/decision-analysis.md: widest table row is 0 bytes (pin 0; headroom 0).
pnpm check:pm-skill-id-lint EXIT=0
pnpm check:self-test-wired EXIT=0
pnpm check:self-test-workflow-commands EXIT=0
pnpm check:nul-bytes EXIT=0
pnpm check:pm-dispatch-gates EXIT=0
pnpm check:workflow-step-name-quoting EXIT=0
```
Six families first answered `PREREQUISITE NOT MET` (exit 3) on a fresh
worktree with no `dist/` — `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`, `check:type-check-debt`,
`check:doc-formula-expressions`. Those are NOT MEASURED, not findings.
`pnpm build` was run under the shared verify lock (`VERDICT command-exit
0 · held the lock 420s`, 73/73 tasks) and all six were re-run to **exit
0**.
Control-character sweep beyond the gate, over the diff's four files:
`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` — no match.
1 parent 400167a commit ea1da64
4 files changed
Lines changed: 1310 additions & 2 deletions
File tree
- .claude/skills/pm-dispatch/references
- .github/workflows
- scripts/pm
Lines changed: 2 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | | - | |
| 30 | + | |
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| 45 | + | |
45 | 46 | | |
46 | | - | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1152 | 1152 | | |
1153 | 1153 | | |
1154 | 1154 | | |
| 1155 | + | |
| 1156 | + | |
| 1157 | + | |
| 1158 | + | |
| 1159 | + | |
| 1160 | + | |
| 1161 | + | |
| 1162 | + | |
| 1163 | + | |
| 1164 | + | |
| 1165 | + | |
| 1166 | + | |
| 1167 | + | |
| 1168 | + | |
| 1169 | + | |
| 1170 | + | |
| 1171 | + | |
| 1172 | + | |
| 1173 | + | |
| 1174 | + | |
| 1175 | + | |
| 1176 | + | |
| 1177 | + | |
| 1178 | + | |
| 1179 | + | |
| 1180 | + | |
| 1181 | + | |
| 1182 | + | |
| 1183 | + | |
| 1184 | + | |
| 1185 | + | |
| 1186 | + | |
| 1187 | + | |
| 1188 | + | |
| 1189 | + | |
| 1190 | + | |
| 1191 | + | |
| 1192 | + | |
| 1193 | + | |
1155 | 1194 | | |
1156 | 1195 | | |
1157 | 1196 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| 82 | + | |
82 | 83 | | |
83 | 84 | | |
84 | 85 | | |
| |||
0 commit comments