Skip to content

feat(pm): H22 judges closed cards against a dated closure floor - #12906

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-ui5985-h22-closure-floor
Aug 28, 2026
Merged

feat(pm): H22 judges closed cards against a dated closure floor#12906
os-litant merged 3 commits into
mainfrom
claude/issue-ui5985-h22-closure-floor

Conversation

@os-litant

@os-litantos-litant commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

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

  1. scripts/pm/check-half-states.mjs — H22 (a closed card still carrying a pm:* state label) can now be scoped by a dated closure floor: PM_SWEEP_CLOSED_FLOOR=YYYY-MM-DD judges only cards closed on/after that date.
  2. .claude/skills/pm-dispatch/SKILL.md — one sentence in the label-discipline section stating the convention: a closed card has its pm:* 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:dispatched and ~347 of 400 window issues carrying some pm:* 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:open is 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.

⚠️ A superseded ruling

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 stripped pm:* 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

  • Default unset = today's behaviour.resolveClosureFloor({}) returns a null floor the predicate ignores. The floor is a per-install adaptation, not a policy shipped to everyone.
  • Malformed is refused (exit 2), never defaulted. A typo'd floor that silently became "no floor" would restore the 87% flood on the one install that set it, four times a day — and a flooded anchor reads exactly like a working patrol. Same trap resolveSweepRepo already refuses by name.
  • The parse is round-tripped, not trusted.Date.parse rejects 2026-13-01 (month out of ISO range) but silently rolls2026-02-31 to March 3. A rolled date would move the floor days past where its author wrote it, silently — so resolveClosureFloor compares the parsed date back against the input.
  • Fail-open on an unreadable 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 carries closed_at on a closed issue, so this costs no noise in practice.
  • The summary names the floor.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.

Added (1 line)关闭即摘 pm:* 状态标 bullet in the 标签纪律 list — the convention sentence. 114 bytes, under the 120-byte cap.
Cut (1 line)The 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.md carries 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).
  • SKILL.md's own pm:on-hold state-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 7f9ae77a3

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 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.

GateVerdict lineExit
check:pm-half-states✓ check-half-states self-test: 1551 cases pass.0
check:pm-skill-ratchet✓ check-skill-line-ratchet: .claude/skills/pm-dispatch/SKILL.md is 1005 lines (ceiling 1005; headroom 0).0
check:pm-skill-ratchet (width)✓ …SKILL.md: widest table row is 765 bytes (pin 765; headroom 0).0
check:pm-skill-id-lint✓ check-skill-id-lint: 23 file(s) clean (pattern /#[0-9]{3,}/g).0
check:pm-governed-prose✓ check-governed-prose: 2 instruction surface(s) name all 5 registered governed surfaces … and claim no others.0
check:skill-frame-sync✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files0
check:pm-governed-mergeslive: the real generator declared 9 output(s) and certified this tree0
check:pm-dispatch-gates✓ dispatch-gates self-test: 834 cases pass.0
bare-root-worklist --self-testOK self-test: 46 live row(s) … none stale, none missing, none contradicted.0
check:entry-guard✓ check:entry-guard: 172 scripts/ file(s) — every entry guard goes through invoked-as.mjs0
check: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-inputsall green0

The 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 form pnpm check:partof-closing-keyword runs green (28 self-test cases).
  • node scripts/pm/check-governed-queue-guard.mjs → exit 1, could not read GITHUB_EVENT_PATH.

check:doc-formula-expressions first returned PREREQUISITE 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-changeset applied — 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 main that lacked it would silently remove the floor and restore the ~87% flood.

Provenance

Authoring session: https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtq

Generated by Claude Code

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-zhuang
os-zhuang marked this pull request as ready for review August 28, 2026 10:35
@os-litant
os-litant enabled auto-merge August 28, 2026 10:36
@os-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 Lint & Repo Gates failed at 08:00 on check-adr-anchors — the same phantom-D4 red that stalled the queue — and a re-run on the same head would fail identically (tree-inherent, not environmental). The fix landed on main at 09:18 (the tombstone-citation PR), so merging base into this branch is exactly the port the drive-to-green rule calls for; CI re-runs on the new head and goes green.

Known and accepted cost: the 10:35 approval was pinned to the OLD head 7f9ae77a3 and is now stale by the guard's own predicate — one fresh Approve on the new head re-arms the queue path (auto-merge stays armed and fires on green + pinned approval). No content changed; the diff against main is byte-identical to what was reviewed.


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

3 participants

@os-litant@os-zhuang@claude