You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(pm): check-half-states reads every Blocked-by: directive on a line, and every ref in one (#19455)
Fixes#19439
Clause-②: no
`check-half-states.mjs` read **one** target from a line stating several,
and for a directive whose remainder is prose it read whichever number
the ref walk reached first. Both readings under-report a block, and
H19's row is what the unlock scan's release act fires from.
## The two measured shapes, before and after (live bodies, read this
pass)
| shape | before | after |
|---|---|---|
| objectui#8347 body — NINE backticked `·`-separated directives on
**one** line | `directiveValues` 1 value, `blockedByTargets` **1**
target (`#8345`) — H19 printed 「1 of 1 … CLOSED」 | 9 values, **9**
targets (`#8345, #8648 … #8655`) |
| objectui#8093 body — **one** directive whose remainder is prose naming
three numbers | **1** target, `objectui#7434` — a possessive that was
never a blocker; both real blockers dropped | **3** targets,
`objectui#7434, #8090, objectui#8065` — both real blockers read |
| DARK control — one directive | 1 target | 1 target |
| DARK control — prose naming `#9` and `#10` outside any directive | 0 |
0 |
⚠️ One correction to the reading this was dispatched on: the seat
measured 「nine directives on TWO lines → 2 targets」 from the card's
*quoted* (line-wrapped) rendering. objectui#8347's **live** body carries
all nine on ONE line, so the reading there is **1** target, not 2 —
which is exactly the 「1 of 1」 H19 published. The defect is one notch
worse than the claim stated.
## What lands
Two halves, in the two places they belong.
`scripts/pm/check-half-states.mjs` only, net **+79** lines (101 added /
22 removed), including 16 self-test cases.
1. **The shared decorated-directive reader (`directiveValues`) yields
EVERY directive on a line.** A line splits at every further key that
follows a **separator run** — decoration, spaces and one of `·•|,;、`,
and nothing else. ⛔ A key that follows a WORD is still prose, so the
anchoring contract that keeps 「seats park the `Blocked-by: #1` line in
comments」 out of the index is unchanged. Every directive family reads
through this one reader — `Blocked-by`, `Restart-when` (H9),
`Unlock-action`, `Maintainer-action`, `Prior-rulings`, `Reviewed-by`,
`Seam` — so all of them gain the multiplicity, and the pin that they
invent nothing from prose is asserted on a NON-`Blocked-by:` key.
2. **The `Blocked-by:` walk takes EVERY ref in a directive's value**,
not the leading run.
## The prose-remainder decision, on the four axes
The card offered two readings for a directive whose remainder is prose:
every `#N` in the value is a target, or the value is reported UNJUDGED
with the line quoted. **Every `#N` is a target.**
- **实际业务需求** — measured, not assumed. The prose-remainder shape is live
on both boards (8 of the 116 blocked cards that carry a directive), and
in 4 of them the old reader extracted **zero** targets from a directive
a seat really wrote: objectstack#14570, objectstack#13458,
objectui#9273, objectui#5987. A stated block that the machine turns into
nothing is the same silence this item exists to end.
- **项目长远合理性** — one rule ("a directive's value names blockers; every
reference in it is one") instead of two ("the leading run, unless…").
The UNJUDGED reading needs a second verdict channel out of a parser that
returns `{repo, number}` refs; H19's `unresolved` state is produced by
the RESOLVER, so feeding it from the extractor means a new return shape
through `buildBlockingIndex`, `blockerTargetsFor` and
`blockerChannelKeys` — and the card ⛔ forbids changing H19's UNJUDGED
arm.
- **防 AI 写错** — decisive. The leading run was taken to keep a context
`#N` out of the index, and it does not buy that: on objectui#8093 it
filed the possessive as a dependent AND dropped both real blockers. So
it files a phantom either way; what it ADDS is **substitution**, and a
block whose real blockers are invisible reads as expired. Reading every
ref can only ADD a target, and an extra target can only WITHHOLD a
discharge (H19 counts it open, names it on the row, and says PARTIAL) —
⛔ never found a release. That is this file's standing posture on an
ambiguity (never resolve one in the direction of release), applied to
the value instead of to the resolution.
- **创业阶段不扩散** — no new surface, no new verdict, no new flag, no config.
The declared file surface only.
⚠️ **The price, measured rather than asserted**: a context `#N` in a
directive's prose becomes a target. On these boards that is 13 new
reverse-index edges (see below) and, where a comment narrates in a
line-initial `Blocked-by:` sentence, a row that says PARTIAL where it
used to say 「every target it names is closed」. The row still fires and
names the extra target, and — unlike the comment archive this file
refuses to found a verdict on — the line carrying it is rewritable by
the seat that wrote it.
## The census the card asks for as the first act
Population: **140** open `pm:blocked` cards on both boards (objectstack
68, objectui 72), **body AND comments** (140 comment sets fetched), read
2026-09-20T23:10Z. Both readers run over the same texts; the old one is
objectstack at `57ceb9d`.
- **116** of the 140 carry at least one `Blocked-by:` directive
(objectstack 62, objectui 54) — 167 directive values under the old
reader (89 + 78).
- **LINES carrying ≥2 directives: 3 — all on objectui, all on ONE card**
(#8347: one body line, two comment lines). objectstack: **0**. ⇒ the
MULTIPLICITY shape is one card on two boards today.
- Texts whose extracted target set changes: **10**, on **9** cards
(objectstack 3, objectui 6). Nothing is lost on either board (−0 refs,
−0 index edges).
- Cards whose **JUDGED** set changes — H19's own count, body-first
carrier — **2 of 140**: objectui#8347 (1 → 9, the measured false-release
row) and objectui#9273 (0 → 1, a body-less card whose comment states its
blocker in prose). objectstack: **0 of 68**, because those narrating
lines are comment-borne beside body lines and the carrier rule keeps the
body authoritative.
- Reverse index (H14): objectstack 83 → 87 edges (+4, −0), objectui 30 →
39 (+9, −0). Of the 13 new edges, 8 name a CLOSED target (H14 judges
open cards, so no row), 1 names a card already carrying `pm:blocking`
(objectstack#13457), **3 are open `pm:blocked` cards that really are
#8347's blockers** (objectui#8649, #8652, #8654 — a correct
missing-cache row that was invisible before), and 1 is
objectstack#11333, named as the parent in a triage audit comment — the
single edge whose blocker status is a judgement call.
- ⭐ **Lit control**: objectui#8347 is in the population and moves 1 → 9;
and 116 of 140 cards read as carrying a directive — a scan that read
nothing prints 0 for both. **Dark control**: prose naming `#9`/`#10`
outside a directive → 0 targets under BOTH readers; one directive → 1
under both.
- ⛔ **Radius, stated**: open `pm:blocked` cards only. objectui#8093 —
triage's substitution specimen — is **NOT** in this population: it
carries `pm:queue` today, released since that reading. So the
substitution class is under-counted here by construction, not absent.
## Verification
- `node scripts/pm/check-half-states.mjs --self-test` :: exit 0 — 5160
cases pass (16 added), every battery above its pin;
`SELF_TEST_BATTERY_FLOOR` untouched (8), no new battery opened.
- The derived union, taken at the final commit `da8c088`: `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) → 42 commands, **42 run, every
one exit 0**, reconciled with `--ran` over a record carrying each exit
code: 「42 derived, 42 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero」.
`pnpm check:pm-dispatch-gates` took 962.5s on this box and is in that
42. Every exit code was captured before any pipe.
- New self-test cases: the two measured live shapes as fixtures (the
#8347 line byte-identical to the live body), both dark controls, the
family pin on `Restart-when` plus its prose control, the URL-fragment
control, and the end-to-end count — a two-directive body line with one
closed and one open target reads 「1 of 2」, ⛔ never 「1 of 1」.
- The pin this overturns is rewritten in place, not deleted:
`Blocked-by: #123 (see #456 for the background)` now reads `123,456`,
with the reason beside it.
## 维护者速读(草稿)
- **改了什么**:巡检脚本读 `Blocked-by:` 的两处修正——一行上写了几条指令就读几条;一条指令的值里有几个 `#N`
就算几个阻塞目标。
- **为什么改**:H19 的行是「解锁放行」的触发器。它在 objectui#8347 上打印「1 of 1
已关闭」,而该卡九个阻塞里三个还开着、卡面自己写着「未裁决完不得派发」;另一张卡把句中的所有格 `objectui#7434's`
当成了阻塞方,两个真阻塞被丢掉。
- **风险与代价(含回滚)**:代价已实测——指令值里的「顺带提到的卡号」也会算作目标,两板共新增 13 条反向索引边(其中 3
条是本来就该有的真阻塞),H19 可能把「全部关闭」改口为「部分关闭」并点名多出来的那张卡。方向只会扣住放行、不会造成放行。回滚 =
revert 本 PR 的单文件改动。
- **席位意见**:(留空,待席位定稿)
- **你要做的**:确认「宁可扣住放行,也不接受读错后放行」这条取舍;若要改为「读不准就报 UNJUDGED」,那是另一张卡(需要改 H19
的第三态入口,本卡明令不动)。
## Acceptance notes
- `noted, not filed`: four of the changed texts are **narration** lines
in comments that begin with a decorated `Blocked-by:` key and then
describe a re-point in prose (「`Blocked-by:` re-pointed `#14096` →
`#15195`…」). The line-anchored reader counts them as directives — that
is pre-existing and deliberate (it models the literal grep the unlock
scan runs), and it is what makes their prose numbers reachable at all.
Narrowing it is a change to WHICH LINES are read, which this card ⛔ does
not authorise. Carrier: the next card that touches `directiveValues`'
anchoring.
- `noted, not filed`: a cross-repo qualifier separated from its `#` by
decoration (`objectui**#7434**`) still reads as a LOCAL ref, before and
after — the ref grammar is unchanged in that respect. Carrier: none
today; no live specimen on either board in this census.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_
Co-authored-by: Claude <noreply@anthropic.com>
@@ -26713,13 +26755,13 @@ async function selfTest() {
26713
26755
numbersOf(blockedByTargets('Blocked-by: #9689 (the relocation it needs is the same edit; doing them in the other order means touching the line twice).')),
26714
26756
'9689',
26715
26757
);
26716
-
// The reason only the LEADING run is taken: a `#N` inside the trailing prose
26717
-
// is context, not a blocker, and indexing it would file a phantom
26718
-
// missing-cache row against a third card that did nothing wrong.
26758
+
// ⭐ The leading run is GONE: a `#N` in the trailing prose is now a target
26759
+
// too. It can only WITHHOLD a discharge, while the run it replaces
26760
+
// SUBSTITUTED a false blocker for two real ones (the #8093 shape below).
26719
26761
t(
26720
-
'blockedByTargets: a ref inside the trailing prose is NOT a blocker',
26762
+
'blockedByTargets: a ref inside the trailing prose is a target too, and can only withhold',
26721
26763
numbersOf(blockedByTargets('Blocked-by: #123 (see #456 for the background)')),
26722
-
'123',
26764
+
'123,456',
26723
26765
);
26724
26766
t('blockedByTargets: a comma-separated run is all blockers', numbersOf(blockedByTargets('Blocked-by: #6234, #6245')), '6234,6245');
26725
26767
t('blockedByTargets: the `and` connector is a separator', numbersOf(blockedByTargets('Blocked-by: #1 and #2')), '1,2');
@@ -26748,6 +26790,34 @@ async function selfTest() {
26748
26790
// live machinery and must be reported as part of the index it feeds.
26749
26791
t('blockedByTargets: a fenced line still counts (this reader greps, it does not read prose)', numbersOf(blockedByTargets('```\nBlocked-by: #42\n```')), '42');
26750
26792
26793
+
// -- Multiplicity and substitution, both MEASURED on live bodies ----------
26794
+
// objectui#8347: NINE `·`-separated backticked directives on ONE line. The
26795
+
// reader took the first and H19 printed 「1 of 1 … CLOSED」 — the row the
26796
+
// unlock scan releases from — while three of the nine were open.
t('blockedByTargets: the #8347 shape — nine directives on one line are NINE targets', numbersOf(blockedByTargets(nineOnOneLine)), '8345,8648,8649,8650,8651,8652,8653,8654,8655');
26801
+
t('directiveValues: …and each directive is its own CLEAN value, decoration stripped per directive', directiveValues(nineOnOneLine, 'Blocked-by').join('|'), '#8345|#8648|#8649|#8650|#8651|#8652|#8653|#8654|#8655');
26802
+
// objectui#8093: ONE directive whose remainder is prose naming three
26803
+
// numbers. The leading run returned the POSSESSIVE — never a blocker — and
26804
+
// dropped both real ones: a substituted target answers the liveness
26805
+
// question about the wrong card, which is the worse of the two failures.
26806
+
const proseRemainder =
26807
+
"`Blocked-by:` objectui#7434's PR #8090 (the `AGENTS.md` half, draft, awaiting human merge) " +
26808
+
'and whatever PR repairs objectui#8065 (the `skills/objectui` half).';
26809
+
t('blockedByTargets: the #8093 shape — both REAL blockers are read, not dropped', blockedByTargets(proseRemainder).map((r) => `${r.repo ?? ''}#${r.number}`).join(','), 'objectui#7434,#8090,objectui#8065');
26810
+
// The dark controls: one directive is one target, and prose OUTSIDE a
26811
+
// directive is not a directive however many numbers it names.
26812
+
t('blockedByTargets: ONE directive still yields exactly one target', numbersOf(blockedByTargets('`Blocked-by: #4242`')), '4242');
26813
+
t('blockedByTargets: prose naming `#n` OUTSIDE any directive invents nothing', numbersOf(blockedByTargets('We will wait for #9 and #10 before starting.')), '');
26814
+
t('blockedByTargets: a URL fragment inside a value is not a target', numbersOf(blockedByTargets('Blocked-by: #12 (trail: https://example.test/issues/8347#40)')), '12');
26815
+
// The whole directive family reads through `directiveValues`, so all of it
26816
+
// gains the multiplicity — pinned on a NON-`Blocked-by:` key, beside the pin
26817
+
// that a key after a WORD is still prose and invents nothing.
26818
+
t('directiveValues: a second directive on one line is read for EVERY family', directiveValues('`Restart-when: closed acme/w#9` · `Restart-when: manual — x`', 'Restart-when').join('|'), 'closed acme/w#9|manual — x');
26819
+
t('directiveValues: …and a key after a WORD is still prose, not a second directive', directiveValues('Restart-when: closed acme/w#9 — seats park the `Restart-when: manual` line in comments', 'Restart-when').length, 1);
t('H19: a two-directive line resolves TWO targets', twoResolved.map((tg) => tg.number).join(','), '2,3');
27222
+
t('H19: …so the row reads 1 of 2, never 1 of 1', h19row(twoOnOneLine, twoResolved, REPO_OS).includes('1 of 2 `Blocked-by:` target(s)'), true);
27223
+
t('H19: …and calls it a PARTIAL discharge rather than a full one', h19row(twoOnOneLine, twoResolved, REPO_OS).includes('Every target it names is closed'), false);
27145
27224
27146
27225
// UNRESOLVED — never reads as clean, and never reads as closed either.
0 commit comments