Skip to content

fix(pm): ungate the blocker-liveness comment read and pair it with a stale-body-line row (H28) - #11874

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-11747-unlock-scan-overhaul
Aug 25, 2026
Merged

fix(pm): ungate the blocker-liveness comment read and pair it with a stale-body-line row (H28)#11874
hotlong merged 1 commit into
mainfrom
claude/issue-11747-unlock-scan-overhaul

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11747

Ruled option A from the re-charter (comment 5402127035), after the deliverable-3
diagnosis falsified the original charter's premise. The census's headline claim —
"the unlock scan greps bodies only" — is false on main; the real defect runs the
opposite way.

The defect

needsBlockedByComments() returns false whenever the body carries any
Blocked-by: line. That is correct for H4, which asks only whether the line
exists — a body line answers that without the network. But H19 and H26
borrowed the same gathering to ask a different question: is what the line names
still running?

On a re-park the two questions diverge. A seat that finds the body's upstream
closed, cards the real prerequisite, and writes the new blocker into a comment (the
cheaper, safer write) leaves a card whose body names a closed issue and whose
comment names an open one. The gated liveness read saw only the closed one,
found nothing else, and published "Every target it names is closed: nothing this
card declared a wait on is still running"
— a false unlock candidate. It was
published on three consecutive sweeps and acted on once: a card was released to
pm:queue while its real blocker was open and pm:dispatched.

The fix

  1. The liveness read is ungatedneedsBlockerLivenessComments() is the label
    alone, so every pm:blocked card contributes both channels unconditionally.
    H4's gate is left exactly as-is; its cheap question stays cheap. The
    gathering is placed in the label loop, beside the H4 fetch, so it rides the same
    shared comment cache (a card already fetched costs nothing) and so every fetch is
    counted before the total-shortfall rethrow reads those stats. Cost is the gate's
    own complement — one comment fetch per blocked card that has a body line, 15 of
    33 in the 2026-08-24 census.

  2. H28, the paired patrol row. Ungating alone only turns the false candidate
    into a PARTIAL one; H19 then reports that the block is half-expired but says
    nothing about which half is documentation, so the stale body line survives and
    the next re-park writes the same shape again. H28 fires on the conjunction — a
    body-named target that is CLOSED while a comment names one that is OPEN
    and absent from the body — and asks for the live blocker to be migrated to the
    body. That is what makes the canonical-home doctrine enforced at write time
    rather than trusted to prose; prose is precisely what failed here.

    Deliberately silent on each half alone: body-closed with no live comment target
    is H19's ordinary expired block; two open targets in two channels is untidy, not
    wrong; a target named in both channels is already in the canonical home and
    is not a migration candidate; an unresolved target on either side stays with
    H19's unjudged sentence rather than becoming a migration order built on a guess.

    Free — the same resolutions H19/H26 already hold, asked a third question (which
    channel each target arrived in).

  3. references/state-machine.md gains the two doc rows: the canonical marker
    (line-start Blocked-by: #N / owner/repo#N, undecorated) with the 2026-08-24
    measurement recorded, and the re-derive-before-discharging rule. No ceiling
    raise
    — the file sat at its ratchet ceiling (43, headroom 0) and the rows were
    paid for by consolidation: three status notes that had gone stale or historical
    were compressed, including one that said the pm:retriage age predicate was
    still to land when H18 has shipped.

Reverse verification

Both legs run over the measured card's recorded byte shape — body
Blocked-by: #9255 (closed 2026-08-19T11:28:26Z) plus the re-park comment stating
the new blocker in the backticked-key spelling the census recorded
(`Blocked-by:`#11501, open). Fixtures, not live cards.

legtargets resolvedH19 says
OLD (gated — as swept)#9255 only1 of 1 ... CLOSED, "Every target it names is closed", never PARTIAL
NEW (ungated)#9255 + #115011 of 2 ... PARTIAL, naming #11501 as still open

plus H28 firing alongside — naming the spent body target, the live comment target,
and the migration.

Two ablations confirm the new cases are load-bearing, each anchored on the exact
text changed and each with an EXIT-trap restore:

  • re-gating the liveness read (needsBlockerLiveness to needsBlockedByComments,
    injected 1 / deleted 0 on disk) turns the self-test red: "2 of 1102 case(s)
    failed"
    , on the gate cases.
  • neutering H28's conjunction (injected 1 / deleted 0) fails the self-test at the
    first positive H28 assertion (exit 1). Direction noted honestly: it surfaces as a
    TypeError on null.includes rather than named case failures — the same
    predicate(...).includes(...) shape the neighbouring H26 block already uses.

The tree was verified clean (git status --porcelain empty) after each restore.

Gates

Derived at commit e6a8452e with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path args — the script takes its own change set from
the merge base). It printed "--repo 'objectstack-ai/objectstack' checked against
this checkout's 'origin' remote — it holds"
and matched 17 families, all run,
all green at that commit. Exit codes captured before any pipe. Selected verdict
lines, each the gate's own:

  • ✓ check-half-states self-test: 1102 cases pass. (was 1058)
  • ✓ check-skill-line-ratchet: .claude/skills/pm-dispatch/references/state-machine.md is 43 lines (ceiling 43; headroom 0).
  • check-nul-bytes: OK (scanned 6611 text file(s) ... no raw ASCII control bytes).
  • check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords ...).
  • ✓ check-partof-closing-keyword self-test: 28 cases pass.
  • ✓ check-governed-merges --self-test: 129 assertions ...
  • ✓ check:entry-guard: 148 scripts/ file(s) — every entry guard goes through invoked-as.mjs ...
  • ✓ check:parse-guard: 147 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
  • ✓ check-skill-id-lint: 22 file(s) clean
  • ✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files
  • ✓ check-agent-test-spelling: 0 violations — 357 file(s) ...
  • ✓ doc authoring guard: 389 files clean
  • ✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 421 files / 1448 TS blocks judged clean (needed @objectstack/lint + @objectstack/formula built first)
  • OK: 16 package(s) read outside themselves, all declared (cross-package-test-inputs)
  • ✓ check:pnpm-filter-targets: 135/168 --filter occurrence(s) ... resolve

Two gates first reported PREREQUISITE NOT MET in the fresh worktree (a missing
yaml dep, an unbuilt @objectstack/formula) — not findings; both green after
pnpm install and the dependency-closure build.

skip-changeset applies: the diff is .claude/** + scripts/pm/** only, so it
publishes nothing.

Notes for the reviewer


Generated by Claude Code

… stale-body-line row (H28)
`needsBlockedByComments()` returns false whenever the body carries any
`Blocked-by:` line. That is correct for H4 — it asks only whether the line
EXISTS, and a body line answers that without the network — but H19/H26
borrowed the same gathering to ask a different question: is what the line
names still RUNNING. On a RE-PARK (body upstream closed, real prerequisite
carded, new blocker written into a comment) the liveness read therefore
resolved ONLY the spent body target, found it closed, and published "every
target it names is closed" — a FALSE unlock candidate, on three consecutive
sweeps, acted on once.
The liveness read is now ungated (`needsBlockerLivenessComments` — the label
alone), so every `pm:blocked` card contributes both channels. H4's gate is
left exactly as-is; its cheap question stays cheap.
Ungating alone only turns the false candidate into a PARTIAL one, so H28 is
its pair: a body-named target that is CLOSED while a comment names an OPEN
one that is absent from the body ⇒ the body line is stale, migrate the live
blocker to the body (the canonical home). That enforces the canonical-home
doctrine at the moment the wrong shape is written rather than by prose.
Self-test 1058 -> 1102 cases, including both legs of the reverse
verification over the measured card's recorded byte shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation labels Aug 24, 2026
@hotlong
hotlong marked this pull request as ready for review August 25, 2026 00:35
@hotlong
hotlong added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 3f571a6Aug 25, 2026
33 checks passed
@hotlong
hotlong deleted the claude/issue-11747-unlock-scan-overhaul branch August 25, 2026 00:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@hotlong@claude