Uh oh!
There was an error while loading. Please reload this page.
feat(pm): H22 judges closed cards against a dated closure floor - #12906
Conversation
The half-state patrol's closed-card row reports a card that left the board still carrying a `pm:*` state label. A sibling install measured its own closed surface at ~87% residue density and had to ship with the row switched off: at that density H22 reports the CONVENTION rather than a defect, and the rows exhaust the anchor body budget every other predicate shares. That install's card framed the choice as two-way — either stripping is the rule and ~815 closed cards need a backfill first, or the row is simply not wanted. `PM_SWEEP_CLOSED_FLOOR` is the third option both readings omit: judge only cards closed on/after a cutover date. Historical `pm:*` on an old closed card is inert (the loop reads state on open cards only), so the row gets its whole value — residue produced from now on, while the paired write is still a live duty — at zero backfill and zero historical noise. No code path here writes a label, so no bulk rewrite of closed cards is reachable. Unset is the default and preserves today's behaviour exactly. A malformed floor is refused with exit 2 rather than degrading to "no floor", which on the install that needs one would restore the flood four times a day; the parse is round-tripped because `Date.parse` rolls `2026-02-31` to March rather than rejecting it. The rendered summary names the floor, so a floored pass cannot overstate what it judged.
The convention was ambiguous enough that two seats read the same board and landed on opposite rules. Fleet practice has since settled it — the 2026-08-27 /28 landing accounting stripped `pm:*` from every closed card as a hard step — so this writes the answer down where the loop reads it, which is the cure the original card asked for. Ratchet is zero-headroom, so this is net 0 lines. Cut ledger in the PR body.
os-litant
commented
Aug 28, 2026
Branch updated by the PM seat, and why: this PR's head tree was created in the 07:49–09:18 window and INHERITED the main-red combination (the ADR-0006 v4 addendum's freshly minted decision letters + the already-landed letter validation), so its PR-leg Known and accepted cost: the 10:35 approval was pinned to the OLD head Generated by Claude Code |
Part of objectstack-ai/objectui#5985 — the objectui leg carries the closing keyword. This PR is the upstream half: the closure-floor mechanism plus the one protocol sentence that writes the convention down.
What
scripts/pm/check-half-states.mjs— H22 (a closed card still carrying apm:*state label) can now be scoped by a dated closure floor:PM_SWEEP_CLOSED_FLOOR=YYYY-MM-DDjudges only cards closed on/after that date..claude/skills/pm-dispatch/SKILL.md— one sentence in the label-discipline section stating the convention: a closed card has itspm:*state labels stripped in the close stroke;domain:*and type labels stay.Why
objectstack's own board measured 26% closed-card residue and treats it as a live duty, so H22 runs unfloored here and this change is a no-op for this repo — unset is the default and preserves current behaviour exactly.
The floor exists for the sibling install. objectui measured 815 closed cards carrying
pm:dispatchedand ~347 of 400 window issues carrying somepm:*residue (~87%), and had to ship its patrol with the closed reader switched off: at that density H22 reports the convention, not a defect, and the rows exhaust the anchor body budget every other predicate shares.objectstack-ai/objectui#5985 framed the way out as two-way — backfill ~815 cards, or drop the row. The floor is the third option both readings omit.
pm:*on a card closed before the convention existed is inert history: the dispatch loop reads state on open cards only (is:openis in every inventory query), so nothing queries those labels as a claim of in-flight-ness. Judging from a cutover date forward buys the row's whole value — residue produced from now on, while the paired write is still a live duty someone remembers — at zero backfill and zero historical noise.⛔ No bulk label backfill of closed cards, under any code path. The sweeper writes no label at all; that stays true here.
An earlier objectui-lane comment on that card ruled the opposite — keep
pm:*on closed cards, H22 permanently off — on the reasoning that a rule nobody has ever followed is not a rule. That ruling is superseded by the 2026-08-28 skills-lane grading, on two grounds: fleet practice has since uniformly strippedpm:*on close (the 2026-08-27/28 landing accounting made it a hard step across ~20 closures), and the dated floor dissolves the backfill dilemma the old ruling was avoiding. This PR executes the newer grading.Design notes
resolveClosureFloor({})returns a null floor the predicate ignores. The floor is a per-install adaptation, not a policy shipped to everyone.resolveSweepRepoalready refuses by name.Date.parserejects2026-13-01(month out of ISO range) but silently rolls2026-02-31to March 3. A rolled date would move the floor days past where its author wrote it, silently — soresolveClosureFloorcompares the parsed date back against the input.closed_at. The card is judged, not skipped: without a date its position relative to the cutover is unknown, and dropping it would narrow the pass on unread data — the "could not read the input must never look like input is clean" rule this file applies everywhere else. The listing endpoint always carriesclosed_aton a closed issue, so this costs no noise in practice.H22 read 200 …with a floor silently applied would overstate what was judged, which is the same defect the count itself exists to prevent. A floored pass says the earlier closures are not a reading about them.Self-test grew by 35 cases covering: floor honoured both directions, the inclusive boundary at the cutover date, floor absent, fail-open, and every malformed spelling refused.
Cut ledger —
.claude/skills/pm-dispatch/SKILL.md(governed, zero headroom)Ceiling 1005, headroom 0, so the added sentence is paid in place. Net 0 lines.
关闭即摘 pm:* 状态标bullet in the 标签纪律 list — the convention sentence. 114 bytes, under the 120-byte cap.Restart-touch:write-side pointer in the 机会主义重启 clause:写侧即 Restart-touch: 行(行契约见 references/state-machine.md).Surviving homes for the cut text — it was stated three times, and the two that remain are the authoritative ones:
references/state-machine.mdcarries the write-side line contract in full (Restart-touch:followed by a repo-relative path, one path per line, case-sensitive, line-anchored, tracked-file requirement, and the H17 index it feeds).pm:on-holdstate-table row already points there for exactly this:行契约、双通道、Restart-touch: 触发文件与双查细则见 references/state-machine.md.The clause's read-side — intersecting the H17 on-hold trigger-file index against the dispatch file surface — is unique to SKILL.md and is kept verbatim. Re-flowing the remaining three lines was a consequence of deleting 81 bytes, not a line-buying re-wrap; no line exceeds the 120-byte cap.
No issue numbers were added to protocol prose (id-lint clean).
Gates — union derived mechanically, run on
7f9ae77a3node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderived 23 path-matched families plus 2 convention-triggered ones (this diff edits a gate script). All run green under the shared verify lock; exit codes captured with no pipe in between.check:pm-half-states✓ check-half-states self-test: 1551 cases pass.check:pm-skill-ratchet✓ check-skill-line-ratchet: .claude/skills/pm-dispatch/SKILL.md is 1005 lines (ceiling 1005; headroom 0).check:pm-skill-ratchet(width)✓ …SKILL.md: widest table row is 765 bytes (pin 765; headroom 0).check:pm-skill-id-lint✓ check-skill-id-lint: 23 file(s) clean (pattern /#[0-9]{3,}/g).check:pm-governed-prose✓ check-governed-prose: 2 instruction surface(s) name all 5 registered governed surfaces … and claim no others.check:skill-frame-sync✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 filescheck:pm-governed-mergeslive: the real generator declared 9 output(s) and certified this treecheck:pm-dispatch-gates✓ dispatch-gates self-test: 834 cases pass.bare-root-worklist --self-testOK self-test: 46 live row(s) … none stale, none missing, none contradicted.check:entry-guard✓ check:entry-guard: 172 scripts/ file(s) — every entry guard goes through invoked-as.mjscheck:agent-test-spelling·check:bash32-floor·check:cli-command-ids·check:cross-package-test-inputs·check:doc-authoring·check:doc-formula-expressions·check:parse-guard·check:partof-closing-keyword·check:pnpm-filter-targets·check:watch-hint-literal·check-ci-filter-parity·check-closing-keyword-parity·check-cross-package-test-inputsThe ceiling HOLDS: 1005 lines against a ceiling of 1005.
Two families are NOT MEASURED rather than red — both refuse for want of CI-supplied context and neither reaches its predicate:
node scripts/check-partof-closing-keyword.mjs→ exit 2,run it from the workflow that supplies the context … or locally with PR_BODY=…. Its packaged formpnpm check:partof-closing-keywordruns green (28 self-test cases).node scripts/pm/check-governed-queue-guard.mjs→ exit 1,could not read GITHUB_EVENT_PATH.check:doc-formula-expressionsfirst returnedPREREQUISITE NOT MET — @objectstack/lint is not built; it is green (exit 0) after building the dependency closure, which is the reading quoted above.Draft, and staying that way
This PR edits a governed surface (
.claude/skills/pm-dispatch/SKILL.md), so: draft only, no ready flip, no reviewers requested, no auto-merge, nothing approved.skip-changesetapplied — the diff releases nothing from any package (scripts/pm+.claude).Sequencing
The objectui leg (objectstack-ai/objectui#6641, carrying the closing keyword) re-enables its patrol against its own ported copy of this sweeper, so it does not import this branch. This leg should land first so the two copies do not diverge in the direction a future re-sync would clobber: objectui's copy now carries the floor, and a re-sync from an objectstack
mainthat lacked it would silently remove the floor and restore the ~87% flood.Provenance
Authoring session:
https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtqGenerated by Claude Code