Skip to content

feat(pm): H4 reads the prose blocker and prints its upstream's state - #17494

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-17365-h4-prose-blocker-candidates
Sep 10, 2026
Merged

feat(pm): H4 reads the prose blocker and prints its upstream's state#17494
os-litant merged 3 commits into
mainfrom
claude/issue-17365-h4-prose-blocker-candidates

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #17365

pm:blocked is defined as a pair — the label plus the body line Blocked-by: #N
(pm-dispatch SKILL.md :110) — and the unlock scan fires from that line and from
nothing else. Seats state the block in prose instead. A human reads it perfectly;
no machine reads it at all. So when the upstream closes, nothing anywhere returns the
card, and the card sits in pm:blocked past a condition that has already been met.

H4 already caught the missing line. What it could not say is the half that costs:
that the stated condition is already satisfied. H19 cannot reach the class either — it
resolves the target of a Blocked-by: line, and the whole defect is that there is no
such line.

The three measured specimens (from the filing card; all repaired in place already)

card the block, as written upstream held since
#15942 (p1 + security) "State: needs-user-decision -> pm:blocked (blocker: #16293) … Unblocks to pm:queue when #16293 merges" #16293 closed 09-06
#16674 「解锁条件:PR #16628 合并(或其分支释放)」 #16628 closed 09-08
#16545 「解除条件(唯一):PR #16380 合并或关闭」 #16380 closed 09-07

The filing card's own observation is why this is a row and not a discipline note: the
seat on #16545 drew the right lesson — 「一条只活在评论里的持有关系,对下一个按标签取卡的席位是
不存在的」 — added the pm:blocked label for exactly that reason, and still wrote no
Blocked-by: line
. A defect that survives someone correctly learning its lesson is not
a discipline problem.

What H4 prints — before and after, verbatim, from a live sweep

Before (origin/main 65ad77d, live sweep 2026-09-10, card #17212):

H4 #17212 pm:blocked with a Blocked-by: line in NEITHER channel — not in the body, and not in any comment on the thread (both were read). Either channel discharges the duty: seats park the line in a comment on purpose, because rewriting a body through the MCP escaping hazard (#8813) is the riskier write. So this is not a formatting nit — no machine reader anywhere knows what this card is waiting for. The unlock sweep greps this literal line, so without it in SOME channel nothing can ever return this card to the queue — the block outlives its blocker in silence.

After (this branch, same board, same card) — identical up to the last sentence, then:

… This card DOES state its block — in PROSE, where no machine reads it. Candidate upstream(s) read off its anchored prose: #17211 CLOSED (2026-09-10T01:40:10Z). Every one of them is ALREADY CLOSED — the condition this card states in words is already satisfied, nothing is coming to release it, and the card is due back in pm:queue. ⚠️ A prose sentence is NOT a directive: these are CANDIDATES read off anchored prose (「blocked on」, 「HELD by」, 「blocker」, 「解锁条件」, 「解除条件」, 「unblock…」), never a verdict, and ⛔ no label is written from here. Read the card before acting, and fix it by writing the Blocked-by: #N line whatever the states above say.

The row is otherwise byte-identical: the clause is appended, and a card with no anchored
prose gets no clause at all.

The other three readings, also live

What the live board actually turned out to be holding

On the 2026-09-10 sweep this branch produced 17 H4 rows, of which 9 now carry a prose
clause
and 8 of those say the upstream is ALREADY CLOSED. Those eight are the same
defect the filing card measured on three cards, still live, and until this row nothing on
the board could see them. ⛔ No card was written to and no label touched — the sweep is
report-only and this PR keeps it that way.

⭐ The sharpest specimen the sweep found is #16094, whose comment says
"… is #16362, and this card is blocked on it" and whose next line says
"Labels: needs-user-decision -> pm:blocked (Blocked-by: #16362)" — with the directive
in parentheses, mid-line, where the line-anchored scan cannot see it. #16362 is closed.
That card was invisible to H4's old row, to H19, and to the unlock scan simultaneously.

The request bound, and the unresolved cases

Only a card already firing H4 is scanned (17 of 496 listed open cards at this
reading). Per card, at most H4_PROSE_CANDIDATE_CAP = 5 distinct candidates are resolved,
and the cap is applied before the fetch, so it bounds requests rather than rendering;
the clause declares whatever it held back. Every resolution goes through H19's own
resolveBlockerTarget and its per-target cache, so a candidate another card already named
is free, and an open local candidate is answered from the exhaustive open listing the
sweep already holds — for no request at all. What is left to pay for is exactly the
population the clause is about.

Measured on two full live sweeps of the same board, ~20 minutes apart:
801 requests before, 816 after — a delta of 15 for 9 clauses over 17 rows.

Cross-repo refs stay unresolved per install, as the header's standing rule says
(each install reads its own repo with its own repo-scoped token), and the clause then
says it claims nothing about whether the block still stands — never that the block is
live and never that it expired. ⛔ No retry loop anywhere: a per-target failure is a row,
not a rethrow, exactly as H19 handles the same case.

The judgement calls, on the four axes

1. The anchor list is MEASURED, not imagined

PROSE_BLOCKER_ANCHORS holds eight spellings: blocked on, blocked by,
blocked behind, blocker, held by, unblock, 「解锁条件」, 「解除条件」. Every one comes
from the three specimens or from the 18-row H4 population measured on the live board on
2026-09-10. Bare blocking is deliberately excluded: on this board it is the label
pm:blocking being discussed far more often than a block being stated (five such lines on
#16184 alone), and admitting it would fill the clause with refs nobody is waiting on.

  • 实际业务需求 — the anchors were read off the population, not invented. Nine of
    seventeen live rows produce a clause with this list, so it serves a real corpus rather
    than a hypothetical one. An anchor nobody writes costs a false candidate and a
    request; the judgement axis is measurement, and it changed the list (waiting on,
    depends on, 「上游」 were all considered and dropped as unmeasured or as noise).
  • 项目长远合理性 — the docblock states the extension rule as a contract: extend when a
    spelling is MEASURED, and record where. That keeps the list a ledger rather than a
    wish-list, which is the same posture every other census constant in this file holds.
  • 防 AI 写代码犯错 — this is the axis that argues for a tight list. A loose anchor
    produces a confident-looking row naming a card nobody is waiting on, and the reader's
    correct response to a false row is to distrust the row. Tight beats tolerant.
  • 创业阶段不扩散需求 — no speculative anchors, no config surface, no flag. Eight
    strings and one frozen array.

2. Report-only, and a candidate is never a verdict

The row names candidates and their state and stops. It writes no label, changes no exit
code (a completed sweep still exits 0 whether it found 0 or 40), and its remedy is still
H4's own: write the line.

  • 实际业务需求 — the consumer is a PM seat's patrol round reading the anchor issue.
    What that seat needs is "go look at this card", not an automated release.
  • 项目长远合理性 — matches the file's standing posture (H14 for pm:blocking, H19 for
    a released block): the producer of board state is a seat, so the row surfaces the
    candidate and the seat acts.
  • 防 AI 写代码犯错 — this is the decisive one. A prose sentence is not a directive, so a
    verdict derived from it would be a declared capability the runtime cannot honour. The
    clause says so in its own text, in every branch, and the self-test pins that sentence.
  • 创业阶段不扩散需求 — a writing row would need an approval path, an audit trail and a
    rollback story. None of that is bought here.

3. Deviation from the suggested route: the state vocabulary is open / closed / unresolved, not four

The dispatch's suggested route named merged as a fourth state. This PR does not separate
it, deliberately:

  • 实际业务需求 — the unlock condition the specimens actually write is 「合并或关闭」 /
    "merges or closes". A merged PR is closed, so the distinction changes no reading of
    any measured specimen.
  • 项目长远合理性 — separating it means teaching resolveBlockerTarget a fourth state,
    and that resolver is shared with H19, H26 and H28. This card's declared file surface is
    the H4 row; widening a shared resolver to render one extra word is exactly the
    scope creep the surface declaration exists to stop.
  • 防 AI 写代码犯错 — no metadata is involved; neutral.
  • 创业阶段不扩散需求 — a word with no consumer is not worth a shared-resolver change.

4. Deviation: the row moved to the blocker-liveness loop; no new summary counter

H4's row is now emitted in the blocker-liveness loop instead of the main loop, because the
resolver and its cache do not exist until the open listings are complete. The gathering
stays exactly where it was — ahead of the total-shortfall rethrow, which reads those stats.
The two populations are identical (pm:blocked, over the same seen map) and H4's answer
cannot move between the two points: it returns null for any card whose body carries the
line, so every card it can fire on is one the gate has already fetched comments for.

No new key was added to the sweep's summary-count contract. That contract is shared
machinery outside this card's declared file surface, and the coverage it would state is
already stated per row: every clause names each candidate it resolved and declares
anything the cap held back. The aggregate request delta is measured and reported above
instead.

  • 实际业务需求 — the per-row statement is what a patrolling seat reads; an aggregate
    counter serves nobody who is not auditing cost.
  • 项目长远合理性 — the row lands in its natural family, beside the other three questions
    asked of a pm:blocked card's upstream.
  • 防 AI 写代码犯错 — neutral.
  • 创业阶段不扩散需求 — the smallest diff that answers the card; a shared registry is not
    touched to buy one number.

Verification

All commands run in the worktree at final head, exit codes captured before any pipe.
Gate families derived from the FINAL diff, never recalled:

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> change set: 1 path(s) vs merge base fa23d6987 (three-dot)  ·  scripts/pm/check-half-states.mjs
  -> 38 command(s); no STALE TREE warning after merging origin/main

A first derivation on the pre-merge tree printed STALE TREE naming
scripts/measure-reserved-identity-name-census.mjs; origin/main was merged in and the
derivation re-run. The command list is byte-identical across both derivations, and the
newly landed file declares itself not wired to any workflow.

All 38, each exit code captured before any pipe — every one exit 0:

node packages/lint/scripts/check-reference-carrier-shape.mjs :: exit 0 (7s)
node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test :: exit 0 (0s)
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 (1s)
node scripts/check-changeset-no-major.mjs --self-test :: exit 0 (2s)
node scripts/check-ci-filter-parity.mjs :: exit 0 (0s)
node scripts/check-closing-keyword-parity.mjs :: exit 0 (1s)
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 (3s)
node scripts/check-comment-mask-corpus.mjs :: exit 0 (63s)
node scripts/check-declaration-mirrors.mjs :: exit 0 (0s)
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 (0s)
node scripts/check-scripts-symbol-anchors.mjs :: exit 0 (2s)
node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 (3s)
node scripts/check-self-test-wired.mjs :: exit 0 (1s)
node scripts/check-self-test-wired.mjs --self-test :: exit 0 (2s)
node scripts/check-self-test-workflow-commands.mjs :: exit 0 (40s)
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 (1s)
node scripts/check-whole-set-label-write.mjs :: exit 0 (2s)
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 (0s)
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 (16s)
node scripts/pm/board-snapshot.mjs --self-test :: exit 0 (0s)
node scripts/pm/sweep-closed-cards.mjs --self-test :: exit 0 (0s)
node scripts/report-test-timings.mjs --self-test :: exit 0 (0s)
pnpm check:agent-test-spelling :: exit 0 (2s)
pnpm check:bash32-floor :: exit 0 (2s)
pnpm check:changeset-gate-self-tests :: exit 0 (24s)
pnpm check:cli-command-ids :: exit 0 (8s)
pnpm check:cross-package-test-inputs :: exit 0 (12s)
pnpm check:driver-memory-census :: exit 0 (3s)
pnpm check:entry-guard :: exit 0 (25s)
pnpm check:nul-bytes :: exit 0 (2s)
pnpm check:parse-guard :: exit 0 (1s)
pnpm check:partof-closing-keyword :: exit 0 (1s)
pnpm check:pm-dispatch-gates :: exit 0 (834s)
pnpm check:pm-half-states :: exit 0 (2s)
pnpm check:pnpm-filter-targets :: exit 0 (2s)
pnpm check:ratchet-remedy-authority :: exit 0 (3s)
pnpm check:refd-timer-probe :: exit 0 (9s)
pnpm check:watch-hint-literal :: exit 0 (4s)

Reconciled with --ran:

✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED
  (a DERIVED zero — all 38 recorded an exit code and none of them is 3).

Self-test (pnpm check:pm-half-states, the command lint.yml runs):

before:  ✓ check-half-states self-test: 3171 cases pass.   exit 0
after:   ✓ check-half-states self-test: 3213 cases pass.   exit 0     (+42 cases)

Path facenode scripts/pm/check-governed-merges.mjs --test scripts/pm/check-half-states.mjs, exit 0:

governed-surface predicate: 0 of 1 path(s) hit the register (5 surfaces, repo-agnostic).
  ✅  NOT governed — ordinary queue landing applies to a PR with exactly this file list.

ESLintnpx eslint --no-inline-config scripts/pm/check-half-states.mjs, exit 0,
clean. --format json reports 1 file linted, 0 errors, 0 warnings. The narrowing is
sound rather than merely convenient: this repo runs one eslint.config.mjs which, in its
own words at :327, "never enables type-aware linting (no parserOptions.project, no typed
@typescript-eslint rules) for ANY file" — so a one-file diff cannot move the verdict on
any file it did not touch. The narrowing did not end up being needed anyway: the repo-wide pnpm lint
(eslint . --no-inline-config) was also run at this head and exited 0 with no output.

Ablation — both legs, predicted direction declared before the run: RED

Predicted: with the extractor disabled, the prose specimens read empty and the new
assertions fail by name. Mutation applied to the committed tree, proven on disk, restored
under a trap with absolute paths.

HEAD blob:      1b4efc286a60fc0706a5412ac33d186e7014e275
worktree blob:  1b4efc286a60fc0706a5412ac33d186e7014e275
-- before mutation --   deleted-text count: 1   injected-text count: 0
mutation written        (the anchor test replaced by an unconditional `continue`)
-- after mutation --    deleted-text count: 0   injected-text count: 1
mutated blob:   bb7e6b37addcded9b4f31c568421c2fd6fcb79f5   (differs from HEAD blob)

ABLATED VERDICT command-exit: 1        16 named failures, among them:
  ✗ H4 prose: the #15942 specimen names its blocker (got "", want "16293,16293")
  ✗ H4 prose: the #16674 specimen names its blocker (got "", want "16628")
  ✗ H4 prose: the #16545 specimen names its blocker (got "", want "16380")
  ✗ H4 prose: HELD by an open PR (#17069) (got "", want "17438")
  ✗ H4 prose: 机械解除条件 (#17193) (got "", want "16194")

-- RESTORE LEG --
restored blob:  1b4efc286a60fc0706a5412ac33d186e7014e275   (equals HEAD blob)
deleted-text count: 1   injected-text count: 0
git diff HEAD: empty

⭐ The ablation earned its keep beyond the direction it confirmed: the first ablated run
did not fail — it threw, on proseBlockerRefs(...)[0].repo, aborting the whole
self-test and hiding every case after it. That is the exact anti-pattern this file already
documents on its blockedByTargets cases, and it is what a real regression in this reader
would have produced. Those two assertions were rewritten to read through
.map(...).join(), and the second commit carries that fix; the run quoted above is the
rewritten suite.

Live sweeps (read-only, ⛔ no writes): 2026-09-10, this repo, report mode.
Both completed and exited 0. --probe reports the transport prerequisite met in this
container — the filing card's note that the script "hangs without gh" does not
reproduce here: gh is not installed at all, and the script never needed it (it uses
GITHUB_TOKEN over node's fetch, re-execing through the proxy). One earlier attempt
died with a transient fetch failed; re-run, it completed. See the request-bound section
for the before/after numbers.

Acceptance notes

Clause-②: no — internal PM-loop tooling; no accept set, published surface or public
contract moves, and no package ships this file.

Changeset: none. scripts/pm/** is on the no-publish fast track — nothing here is in
any package's files[], and the file is not imported by any published entry point. The
skip-changeset label is applied on this PR.


Generated by Claude Code

`pm:blocked` is a PAIR — the label plus the body line `Blocked-by: #N` —
and the unlock scan fires from that line and nothing else. Seats state the
block in prose instead, which a human reads perfectly and no machine reads
at all, so when the upstream closes nothing returns the card. Three cards
were measured in that shape, each past a satisfied unlock, one of them
`priority:p1` + `security` and stalled four days after its own ruling.

H4 already caught the missing line; it could not say the half that costs —
that the stated condition has already been met. H19 cannot reach this class
either, because it resolves the target of a `Blocked-by:` line and the whole
defect is that there is no such line.

So H4 now scans the anchored prose of a card it is already firing on, reads
the candidate upstreams out of it, and prints each candidate's current state.
The row reads "line missing, and the upstream already closed" when it is.

- `PROSE_BLOCKER_ANCHORS` is a MEASURED list: the three specimens plus every
  H4-firing card on the 2026-09-10 board. Bare `blocking` is deliberately out
  (it is the label being discussed, not a block being stated).
- Report-only, exit codes unchanged. A candidate is never a verdict: the row
  names candidates and their state, writes no label, and keeps H4's remedy.
- Cost rides H19's per-target cache: only a card already firing H4 is scanned,
  at most `H4_PROSE_CANDIDATE_CAP` (5) candidates per card are resolved, an
  open local candidate is answered free from the listing already in hand, and
  the clause declares anything the cap held back.
- The row is emitted in the blocker-liveness loop rather than the main loop,
  because that is where the resolver lives; the gathering stays where it was,
  ahead of the total-shortfall rethrow. Same population, same answer.

Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
The ablation leg that empties the anchor set made `proseBlockerRefs(...)[0].repo`
throw, aborting the whole self-test and hiding every case after it — the exact
shape the `blockedByTargets` cases already warn about, and the shape a real
regression in this reader produces. Read through `.map(...).join()` instead, so
an empty reading is a failed assertion with a name.

Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 10, 2026 18:19
@os-litant
os-litant enabled auto-merge September 10, 2026 18:19
@os-litant
os-litant added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 10da5c4 Sep 10, 2026
38 checks passed
@os-litant
os-litant deleted the claude/issue-17365-h4-prose-blocker-candidates branch September 10, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants