feat(pm): H4 reads the prose blocker and prints its upstream's state - #17494
Merged
Conversation
`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>
…-prose-blocker-candidates
This was referenced Sep 10, 2026
os-litant
marked this pull request as ready for review
September 10, 2026 18:19
os-litant
enabled auto-merge
September 10, 2026 18:19
os-litant
deleted the
claude/issue-17365-h4-prose-blocker-candidates
branch
September 10, 2026 18:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17365
pm:blockedis defined as a pair — the label plus the body lineBlocked-by: #N(pm-dispatch
SKILL.md:110) — and the unlock scan fires from that line and fromnothing 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:blockedpast 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 nosuch line.
The three measured specimens (from the filing card; all repaired in place already)
security)needs-user-decision->pm:blocked(blocker: #16293) … Unblocks topm:queuewhen #16293 merges"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:blockedlabel for exactly that reason, and still wrote noBlocked-by:line. A defect that survives someone correctly learning its lesson is nota discipline problem.
What H4 prints — before and after, verbatim, from a live sweep
Before (
origin/main65ad77d, live sweep 2026-09-10, card #17212):After (this branch, same board, same card) — identical up to the last sentence, then:
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
JSON_COLUMN_FIELD_TYPEScannot serve both moved and unmoved deployments — ⚠️ the body's "reports the ruled end-state" framing is MEASURED FALSE, see comment 5588614136 #16184: "#15771CLOSED (2026-09-06T00:53:09Z),#15989open. 1 of 2 is ALREADY CLOSED — the block is at least partly expired, and nothing machine-readable is tracking any of it."DriverOptions.tenantId→applyTenantScope) does not honour the deployment'splatformGlobalObjectscarve-out (#12699): an exempted object stays walled at the driver while Layer 0 composes nothing #15831: "#15212open. They all still read OPEN, so the block itself looks live — what is missing is only the line that would let a machine see it when they close."row Nreferences are declaredunheld, so a row insertion can still silently falsify them — the residual #15869 left behind #15927 names eight held PRs on one line: "#15889CLOSED …,#15888CLOSED …,#15879CLOSED …,#15878CLOSED …,#15863CLOSED … (+3 more candidate(s) named but NOT resolved — the per-card cap is 5, so this list is a lower bound)."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 directivein 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
unresolvedcasesOnly 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
resolveBlockerTargetand its per-target cache, so a candidate another card already namedis 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
unresolvedper 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_ANCHORSholds eight spellings:blocked on,blocked by,blocked behind,blocker,held by,unblock, 「解锁条件」, 「解除条件」. Every one comesfrom the three specimens or from the 18-row H4 population measured on the live board on
2026-09-10. Bare
blockingis deliberately excluded: on this board it is the labelpm:blockingbeing 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.
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).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.
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.
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.
What that seat needs is "go look at this card", not an automated release.
pm:blocking, H19 fora released block): the producer of board state is a seat, so the row surfaces the
candidate and the seat acts.
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.
rollback story. None of that is bought here.
3. Deviation from the suggested route: the state vocabulary is
open/closed/unresolved, not fourThe dispatch's suggested route named
mergedas a fourth state. This PR does not separateit, deliberately:
"merges or closes". A merged PR is closed, so the distinction changes no reading of
any measured specimen.
resolveBlockerTargeta 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.
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 sameseenmap) and H4's answercannot move between the two points: it returns
nullfor any card whose body carries theline, 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.
counter serves nobody who is not auditing cost.
asked of a
pm:blockedcard's upstream.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:
A first derivation on the pre-merge tree printed
STALE TREEnamingscripts/measure-reserved-identity-name-census.mjs;origin/mainwas merged in and thederivation 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:
Reconciled with
--ran:Self-test (
pnpm check:pm-half-states, the command lint.yml runs):Path face —
node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-half-states.mjs, exit 0:ESLint —
npx eslint --no-inline-config scripts/pm/check-half-states.mjs, exit 0,clean.
--format jsonreports 1 file linted, 0 errors, 0 warnings. The narrowing issound rather than merely convenient: this repo runs one
eslint.config.mjswhich, in itsown words at :327, "never enables type-aware linting (no
parserOptions.project, no typed@typescript-eslintrules) for ANY file" — so a one-file diff cannot move the verdict onany 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
trapwith absolute paths.⭐ 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 wholeself-test and hiding every case after it. That is the exact anti-pattern this file already
documents on its
blockedByTargetscases, and it is what a real regression in this readerwould have produced. Those two assertions were rewritten to read through
.map(...).join(), and the second commit carries that fix; the run quoted above is therewritten suite.
Live sweeps (read-only, ⛔ no writes): 2026-09-10, this repo, report mode.
Both completed and exited 0.
--probereports the transport prerequisite met in thiscontainer — the filing card's note that the script "hangs without
gh" does notreproduce here:
ghis not installed at all, and the script never needed it (it usesGITHUB_TOKENover node'sfetch, re-execing through the proxy). One earlier attemptdied with a transient
fetch failed; re-run, it completed. See the request-bound sectionfor the before/after numbers.
Acceptance notes
--probepath re-execs through the proxy and reports thetransport prerequisite met in this container, while the filing card records the live
sweep "hanging" without
gh. Both readings can be true of different container classes,and the script's own header already carries the
check-half-states --probereports PREREQUISITE NOT MET in a container that reaches the API — Node's fetch bypasses the agent proxy, and the refusal reads as the container's verdict #13544 note about that. Successor: thenext card on this file (pm sweep: read the last
event=schedulerun of every scheduled non-blocking workflow and raise a RED one into the label inbox #17132). No defect measured here.That is the file's declared design (it pages three exhaustive listings) and no clause of
it is violated. Successor: none.
liveness-dead-propertyandliveness-live-elsewhere-propertycannot fire on 17.3.0 — 90dead+ 1live-elsewhereledger rows and not one setsauthorWarn#16094 carries aBlocked-by: #16362inside parentheses,mid-line, so the line-anchored directive scan cannot see it. That is a card in the
wrong shape, not a defect in the scan — the anchoring is deliberate and documented — and
the filing card explicitly scopes itself to why such cards leak, ⛔ not to repairing
them. This PR's own row is what now surfaces it. Successor: the seat that picks the row up.
Clause-②: no— internal PM-loop tooling; no accept set, published surface or publiccontract moves, and no package ships this file.
Changeset: none.
scripts/pm/**is on the no-publish fast track — nothing here is inany package's
files[], and the file is not imported by any published entry point. Theskip-changesetlabel is applied on this PR.Generated by Claude Code