Repository navigation
fix(pm): check-half-states takes the checkout that SERVES the board, and refuses a foreign one - #19288
Conversation
…and refuses a foreign one
The three local git reads — `ls-files` (H17's tracked-file oracle), `rev-parse
--show-toplevel` and `remote get-url origin` (H57's workflow files) — ran with no
`cwd`, so each inherited whatever tree the shell stood in while `PM_SWEEP_REPO`
named another board. Measured 2026-09-19: a sweep of objectui validated its H17
trigger-file index against objectstack's 8888 tracked files (objectui has 7945)
and H57 refused for the whole run, hiding a scheduled lane dead through five
consecutive fires.
`PM_SWEEP_CHECKOUT` now names the tree. Set, the three reads take it as `cwd`
through one `gitRead` helper — the only `execFileSync('git'` site in the file, so
a fourth reader cannot be added without the cwd — and a checkout whose `origin`
is not the swept board refuses with the file's exit-3 PREREQUISITE NOT MET rather
than reading a second repo. `localCheckoutServes` resolves the knob first, so H57
judges when the knob names the right tree. Unset, behaviour is unchanged — plus
the H17 footer now names the tree it read (path + `origin`) beside its oracle
size, because a wrong-tree reading is internally consistent and the tree is the
only thing that distinguishes it from a right one.
Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2
Co-authored-by: Claude <noreply@anthropic.com>
… `cwd` The count pin proves there is exactly ONE `execFileSync` git site in the file; it says nothing about that site passing a `cwd`. Stripping the `cwd` left the whole suite green, i.e. the fix's central wiring had no failing test. This pins it on the source, assembled so the case cannot satisfy itself out of its own text. Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: In-seat review by the ① Derived judgments
② Semver levelNone — ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… the corpus repo (objectstack-ai#19353) Fixes objectstack-ai#19132 Clause-②: no Run from an **objectui** checkout, `scripts/pm/check-prior-rulings.mjs` refused — `docs/adr` parsed to zero decision units — so every objectui decision card was entitled to exactly one paste line, `Prior rulings read: unresolved`, and could never cite a prior ruling. Both halves of the lane direction (comment 5738994087) land here. ## Measured first — the structural difference between the two corpora The direction required the measurement **before** any widening. Taken this act at `objectui@0c2eb5e` and `objectstack@13d5294`. ### What counted as a decision unit before this PR Two shapes, both keyed on a **D-number**: a level-3/4 heading (`### D8 — One predicate, computed once`) and a decision bullet (`- **D4** — A tenancy kernel service…`) with the separator rule that rejects prose about a decision. Nothing else was a unit. ### Which marker objectui's ten files use | file | `**Status**:` line | D-number units | `## Decision` anchor | units (before → after) | |:--|:--|--:|:--|--:| | `0001-master-detail-subform.md` | yes | 0 | `## Decision` | 0 → 5 | | `0034-unified-runtime-metadata-persistence.md` | yes | 0 | — none — | 0 → 0 | | `0036-field-conditional-rules.md` | yes | 0 | — none — | 0 → 0 | | `0053-list-view-navigation-modes.md` | yes | 0 | `## Decision detail` | 0 → 7 | | `0054-ui-testability-contract.md` | yes | 0 | `## Decision detail` | 0 → 7 | | `0055-parameterized-bare-data-surface.md` | yes | 0 | — none — | 0 → 0 | | `0056-permission-editing-studio.md` | yes | 0 | `## Decision` | 0 → 6 | | `0057-console-ai-chat-one-conversation-docked.md` | yes | 0 | `## Decision` | 0 → 6 | | `0058-cascading-select-options.md` | yes | 0 | `## Decision: per-option …` | 0 → 5 | | `0059-action-params-shared-field-widgets.md` | yes | 0 | `## Decision` | 0 → 5 | | **total (10 files)** | **10 of 10** | **0** | **7 of 10** | **0 → 41** | **The dispatch's mechanism assumption 2 is REFUTED on its status half.** It read: no numbered D-units and 「as far as the seat can see」 no `**Status**:` line. The D-number half holds — zero across all ten. The status half does not: **all ten carry a `**Status**:` line**, in spellings this file already supports (`**Status**: Accepted — implementing (2026-06-05)`, `**Status**: Proposed (2026-06-22)`, …). So `extractStatusLine` and the positional `classifyStatus` needed **no change at all**, and objectui units arrive correctly classified. One measured consequence: `0034`'s `**Status**: Implemented (2026-06-06)` carries no word in `STATUS_WORDS`, so it classifies `unknown` — the intended loud bucket, not a regression. The whole structural difference is therefore **the decision unit, and nothing else**. objectui's decisions live under a level-2 `## Decision` / `## Decision detail` / `## Decisions` / `## Decision: TITLE` section whose parts are level-3 headings (`### C1 — Idempotent, direct triggers`) or numbered bold items (`1. **Two orthogonal fields, no mode discriminator.**`). ### The same blind spot is 57% of THIS repo | corpus | ADR files | files with zero D-number units | of those, carrying a `## Decision` anchor | |:--|--:|--:|--:| | objectstack `docs/adr` | 139 | **79** | **59** | | objectui `docs/adr` | 10 | **10** | **7** | This is not a foreign dialect the reader is being taught to tolerate — it is a shape the reader was already blind to in the corpus it was written for. ## The fix ### Half ① — the line names the repository whose `docs/adr` was read Derived from **this checkout's** `origin`, never `PM_SWEEP_REPO`: the corpus is a property of the working directory, and the two diverge in exactly the case the card names. Named in **both** arms, so the wrong-corpus case is visible in the line itself: ```text Prior rulings read: gate,required,spec,shape → 11 hits; ADR-0001 Decision §1; thread: not read (no --card); repo: objectstack-ai/objectui Prior rulings read: unresolved (repo: objectstack-ai/fakerepo) ``` The refusal headline names it too (`docs/adr` in objectstack-ai/fakerepo parsed to zero decision units), and with `--card` the report says **WRONG CORPUS** outright when the board and the corpus disagree — the card's second reading, the one six objectui cards were corrected in place for. ⛔ Still no `--repo` flag: the corpus is *reported*, never redirected. An unresolvable `origin` prints `repo: unknown` rather than dropping the clause, because a line that omits the repo reads like one that agrees with the board. The paste line stays **additive**: `references/decision-analysis.md` pins the terms / hits / named / thread field order, and the repo clause is appended **after** it, so the documented order and the literal `^Prior rulings read: ` grep are both unchanged. ### Half ② — a THIRD shape, scoped as a FALLBACK It runs **only on a file the two D-shapes parsed to zero units**. That is what keeps it from being a widening: the **53** D-carrying objectstack ADRs that *also* head a `## Decision` section are untouched, so a heading that merely *contains* their D-units can never double-count them. Prose is refused by three guards, each pinned: 1. the **structural** level-2 anchor — a heading whose title *opens* with `Decision`/`Decisions`, never a sentence that mentions one; 2. the **section-lead** unit needs a non-empty first paragraph, so a bare `## Decision` with nothing under it yields **0** — and an empty `docs/adr` still exits 3, the control kept; 3. the file must carry an ADR number, and a file that already speaks D-units is never re-read. A structural *part* carries its own line instead of needing a following paragraph, because a one-line item says everything on it (`ADR-0050`'s `1. **FormView.type = layout only**: …` would otherwise be dropped). Unit ids are positional (`Decision §1`, `Decision §2`, …): this shape has no D-number to *be* an identity, so the author's own label (`C1 — …`) rides in the heading beside it, and a bare `## Decision` takes the ADR's H1 so its lead unit is never nameless. ## Before / after, both checkouts, with exit codes | run — `--terms gate,required,spec,shape` | before | after | |:--|:--|:--| | `cwd` = `/home/user/objectui` (`0c2eb5e`) | **exit 3** — `docs/adr` parsed to zero decision units | **exit 0** — 41 units, 11 hits, line resolved | | `cwd` = objectstack | **exit 0** — 465 units, 117 hits | **exit 0** — 758 units, 180 hits | | empty `docs/adr` fixture (control) | **exit 3** | **exit 3** — and now names the repo | **The no-double-count invariant, measured rather than argued.** Every `docs/adr/*.md` in both repos parsed with the `origin/main` parser and this one, comparing unit id, line and text: | corpus | files | units before → after | files changed | of those, changed **from a non-zero count** | |:--|--:|:--|--:|--:| | objectstack | 139 | 465 → 758 | 59 | **0** | | objectui | 10 | 0 → 41 | 7 | **0** | Every changed file went `0 → N`. No file that already had units moved by a single unit, so no existing rank moved under them. ## Pins added — self-test 143 → 155 cases (`pnpm check:pm-prior-rulings`) Pins from both sides, as the direction required: - objectui's real structure (a `## Decision detail` of numbered bold items) → 2 units, ADR id from the filename, `accepted` from the status line objectui does carry, the item body present in the unit text so a term can match it, and the following `## Consequences` contributing nothing; - `## Decision` with an **empty body** → **0 units**; - a sentence *mentioning* a decision under `## Context` → **0 units**; - a file that **has** a `D1` heading *and* a `## Decision` section → `D1` only, ⛔ no double count; - a path with no ADR number → 0 units, anchor or not; - the repo clause in the resolved line, in the refusal, and its `unknown` fallback. The pre-existing fixture ranks are **unmoved**: the objectstack-ai#16934 fixture still surfaces `ADR-0131 D8` at rank 1, the superseded ADR is still found-but-unnamed, the substring-noise control still holds, and the fixture corpus still parses to exactly 6 units — it carries no `## Decision` section, so the fallback never fires on it. Seven paste-line pins were updated for the appended repo clause; each is the same assertion with `; repo: unknown` added. ## Gates 30 of 30 derived families run at head `8ba9049`, each exit code captured **before any pipe**; `dispatch-gates --ran` reconciles **30 derived / 30 run / 0 NOT-MEASURED — a DERIVED zero** (every family recorded a code and none is 3). Derivation asserted with `--repo objectstack-ai/objectstack`. `origin/main` moved to `2277d1f` while the battery ran (`check:pm-dispatch-gates` alone took 1047 s on a box running three copies of it for three parallel agents), so the family list is current as of merge-base `13d5294`; CI and the merge queue are the authority past that point. ## Acceptance notes - **noted, not filed:** the `owner/name` parse from an `origin` URL now exists twice — the new local helper here, and the regex inside `localCheckoutServes` in `scripts/pm/check-half-states.mjs` (landed in objectstack-ai#19288 while this branch was open). They answer different questions (a display name vs a boolean "does this checkout serve that board"), so neither can call the other today. The clean fix is one exported parser in `check-half-states.mjs`, which needs a file outside this card's surface **and** a new export against a `Clause-②: no` claim. Carrier: the next card in the objectstack-ai#19191 / objectstack-ai#19288 checkout-serving lane. - **noted, not filed:** `0034`'s `**Status**: Implemented (…)` classifies `unknown` because `Implemented` is not in `STATUS_WORDS`. The raw line prints beside it and `unknown` is the designed loud bucket, so this is not a defect. Carrier: whoever next widens `STATUS_WORDS`. - **noted, not filed:** 20 objectstack ADR files still parse to zero units — they carry neither a D-unit nor a `## Decision` anchor. Shrinking that further means a fourth shape, which this card's direction does not authorize and no measurement here justifies. Carrier: none today. - `scripts/pm/**` is not on the governed register (`docs/adr/**`, `.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md`, `docs/NORTH-STAR.md`), so no landing-tier record is owed. - Not published → `skip-changeset`, no changeset file. Net **+119** lines in the one file (141 added / 22 removed) against the PM's ≤ +120 budget. - The objectui copy is **not** edited here; it follows through the upstream port pin. --- _Generated by [Claude Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19191
Clause-②: no
scripts/pm/check-half-states.mjstook three readings off the LOCAL checkout with plainexecFileSyncgit calls and nocwd, so each inheritedprocess.cwd()while the board it reports onis named by
PM_SWEEP_REPO. The filer measured both directions on the same board with the same script(#19191, comment 5740947564): with
cwdin objectstack, H17's trigger-file index validated objectui'scandidate 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(#19259、#19225 正在改它)。
What changed, mechanically
PM_SWEEP_CHECKOUT— one knob, besidePM_SWEEP_REPOandPM_SWEEP_CLOSED_FLOORin--help,carrying the PATH of the checkout that serves the board.
resolveSweepCheckout(env)resolves andtrims it; whitespace is unset.
readTrackedFiles,readRepoRoot,readOriginUrl) now gothrough a single
gitRead(args, extra)helper that passes that path ascwd. That is the onlyexecFileSyncgit call left in the file, and the self-test pins the count at one — so a fourthreader cannot be added later without the
cwd. That is the card's mechanism assumption turned into ameasured property instead of a belief.
checkoutPrerequisite(sweepRepo, env, originUrl)is a pure verdict in the shapereportPrerequisiteNotMetprints:nullwhen the knob is unset (today's behaviour is not aprerequisite) 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 aforeign checkout costs zero requests and reads nothing.
localCheckoutServesresolves the same way: the knob leg first (it names the tree, so theoriginread IN that tree is the authority), thenGITHUB_REPOSITORY(which names the RUNNER'stree, i.e. exactly not the knob's), then the checkout's
origin. H57 therefore judges when the knobnames the right tree, instead of refusing for the whole run.
origin— beside its oracle size, in BOTHoracle 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
scripts/pm/check-half-states.mjslines--self-testcasesDerived gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat thisbranch → 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-reportcomment. Control-character self-scan over thechanged file: no match. No package contains
scripts/pm/**, so no dependency-closure build and nopackage 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/objectuiwithPM_SWEEP_CHECKOUT=/home/user/objectui, detached under the shared heavy-verify lock (held 398s, waited0s — shared-box seconds, not idle-box figures): exit 0, 502 half-states over 374 open
pm-/p0-labeled issues. The H17 footer:
8142 is objectui's LIVE tracked count today (
git -C /home/user/objectui ls-files | wc -l= 8142); thefiler 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 run35493590744, started2026-09-20T06:11:39Z, concluded
failure. This is the lane the card's re-run traced tofinding(release):
changeset-release.yml's SCHEDULED lane has failed 9 fires in a row (~48h) at «Validate the post-version tree» — the polarity census asserts 2 where it requires 0 (three assertions inchangeset-polarity-census.test.ts), so publish and the version-PR refresh are skipped while every push fire stays green because that job is SKIPPED there objectui#10010, still dead today..github/workflows/check-links.yml— latest scheduled run35489310168, started2026-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, nothingswept, nothing spent:
Reverse verification — both new pins can fail
Each ran from the COMMITTED state, mutated through
scripts/ablation-replace.mjs(which proves thewrite landed on disk and restores byte-exactly), and each turned exactly ONE case red out of 5,081.
The committed blob is
f49d566ed276.cwdfrom the one git-read sitef49d566ed276to994882b2fcfc#19191 knob: …and that one site takes the knob as its cwd— got false, want truef49d566ed276== HEAD,git diff HEADemptyreadRepoRootwith its ownexecFileSyncgit call — a fourth unrouted readerf49d566ed276toecd24ab63593#19191 knob: ONE git read site in the file, so a fourth reader cannot skip the cwd— got 2, want 1The first is the one that mattered most: before that pin existed, stripping the
cwdleft the whole5,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
PM_SWEEP_CHECKOUT. It joins the established 12-memberPM_*PM-tooling family(
PM_SWEEP_REPO,PM_SWEEP_CLOSED_FLOOR,PM_STATE_LABELS,PM_TOKEN, …) rather than theproduct's
OS_{DOMAIN}_{NAME}runtime family of Prime Directive [WIP] Create a new release version #9 — the card asked for a knob"beside
PM_SWEEP_REPO", and--helpgroups the three under one heading where a reader looks.Flagged because that directive's wording is categorical; a maintainer who prefers
OS_PM_SWEEP_CHECKOUTgets it for the price of one rename.runner's own checkout IS the swept board (
PM_SWEEP_REPO: ${{ github.repository }}, per-repoinstalls), so the knob would be a no-op there and
GITHUB_REPOSITORYalready answerslocalCheckoutServes. If it should be stated explicitly anyway, the exact line for the sweep step'senv:block in.github/workflows/half-state-patrol.ymlisPM_SWEEP_CHECKOUT: ${{ github.workspace }}— a separate, human-merged change, since ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe #19259 andci(pm): make the half-state patrol callable instead of copied #19225 are open on that file.
origin/mainwas NOT merged into this branch. It moved toe6a03e6while this ran and touchedscripts/pm/check-half-states.mjsin none of those commits (verified against a fetch into a ref thisworktree owns, not against the shared
origin/mainpointer), so the line budget's 36,176 baselinestill holds and no serial writer conflict exists. The merge queue rebuilds the PR onto current
mainand re-runs the required contexts there, which is where a jointly-wrong merge would surface.
sweep()andsweepScheduledWorkflowseach perform their ownreadOriginUrl()/readRepoRoot()— two duplicate local git reads per sweep (microseconds, zerorequests). Threading one reading through
sweepIntowould widen that function's signature, which isthe kind of change this card was told to keep out of. Successor: whoever lands finding(scripts/pm): H59 calls a deliberately REOPENED card a FALSE OPEN —
state_reason: 'reopened'is on the payload it already reads, and H22 in the same file reads it #19177 (H59) or finding(scripts/pm): H52 has no clearing act on apm:on-holdcard — the only three null-exits are close,needs-user-decision(which the row itself forbids here) and a newer dev report a held card can never get #19160(H52) is next on this writer.
state_reason: 'reopened'is on the payload it already reads, and H22 in the same file reads it #19177, finding(scripts/pm): H52 has no clearing act on apm:on-holdcard — the only three null-exits are close,needs-user-decision(which the row itself forbids here) and a newer dev report a held card can never get #19160, finding(scripts/pm):check-half-states.mjsreports its ownMEASURED_MERGES_PER_DAYpin has drifted to a factor of 0.40 (observed ~54.7/day against a pinned 137.5/day, measured 28d ago), so every page-capped window OVER-states the days it covered #19203, [finding] a pm:blocked card whose Blocked-by target is a needs-user-decision card can never be released — the unlock predicate is "closed", and Unlock-action recognizes exactly one PR-shaped spelling #19255 and [finding] a claim whoseSeat:line is not line-initial is silently read as seat 1 — three claims in one round misattributed, producing a false H38 stale row on another seat post #19108 queuebehind 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.## Contract reviewrecord, reads--pair, and lands it.Generated by Claude Code