Repository navigation
feat(pm): add the stale finding label sweep (D1) - #17994
Conversation
`finding` is defined by the PM state model as transient — it comes off in the same write as the grade — but nothing mechanises that, so both boards carry a large population of graded cards still wearing it and the label stops answering the one question it exists for. This is the one-time sweep half: a dry-run-by-default `scripts/pm/` tool that lists the open cards carrying `finding` beside a grading state or a priority, and with `--write` removes only that one label, in bounded batches, with a `/rate_limit` reading printed around each batch, stopping on the first 403/429 with a resume cursor. Each removal reads the card's labels, computes the survivor set, subtracts the one label by name, and reads back and diffs; any difference refuses to proceed. The genuinely ungraded pool and `pm:seat` posts are left alone and reported. `--self-test` runs the whole thing offline through an injected transport: 91 cases across 9 batteries, including a write-path spy proving a dry run writes nothing and a 403 fixture proving the stop prints its cursor. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① Seat-verified on the head (merge-base Implemented-by: claude/issue-16904-finding-label-sweep-d1 (mode:subagent) Verdict: PASS. Path face clean: the seat lands it — ready + auto-merge SQUASH once the four running checks complete. The one-time sweep itself ( Generated by Claude Code |
|
Landing step (NOT GOVERNED, measured: Generated by Claude Code |
…t block, and `finding` outliving its grade (objectstack-ai#18033) Fixes objectstack-ai#17009 Fixes objectstack-ai#16904 Both cards' D2 halves. objectstack-ai#17009's D1 landed as `ea1da645` (PR objectstack-ai#17515) and objectstack-ai#16904's as `ae1b2588` (PR objectstack-ai#17994, plus the seat's one-time sweep, readings 5652947318); these two rows are the last deliverable on each card, so merging this closes both. Two report-only rows in `scripts/pm/check-half-states.mjs`, one per card, plus the CI wiring objectstack-ai#16904's D1 report left unfired. No `--write` path anywhere, no label written, no gate added. ## H62 (objectstack-ai#17009) — a decision card whose face carries no four-facet block An open `needs-user-decision` card whose **body** carries no `os-decision-facets` marker, in either spelling (plain-text line or the HTML-comment form). Presence is a literal containment test over the raw body, which is the extraction rule the shape names for itself (「提取按字面 grep」); code-stripping and a positional read are refused **by name** in the docblock because each produces 「注释形读不到永不读作无四棱块」, the one direction of error the shape forbids. Body only: 「落卡即带」 makes the block part of the card face, so a marker in a comment is not the shape, and the row buys no thread. The `Prior rulings read:` line is a **second reading on the same row** — `present` / `unresolved` / `absent`, in the row text, never a second row. Its writer is `check-prior-rulings.mjs`, coupled here by **source text**: both import directions were measured and neither runs (static is a TDZ cycle; dynamic deadlocks against this module's own top-level await). The docblock records both measurements so nobody retries them. ## H63 (objectstack-ai#16904) — a graded card still wearing `finding` An open card carrying `finding` beside a grading state or any `priority:*`. The rule is **reused by import, never copied** — but the arrow is forced, not chosen: the sweep already imports `PM_STATE_LABELS` from the patrol and derives module-level constants from it, so the patrol importing the sweep throws `ReferenceError: Cannot access 'PM_STATE_LABELS' before initialization` the moment the patrol is the entry point, which it is on every run and every self-test. So the screen moved **down** into the module that owns the vocabulary, and `sweep-stale-finding.mjs` re-exports it under the names its callers and its 91-case self-test already use. The patrol's own self-test pins by source text that the sweep still aliases that definition and has not re-grown a copy. The row does not contradict the pins it sits beside, and the docblock names each: H13's set carries `finding` (which is exactly why H13 is silent on a graded carrier — the card is visible, to the wrong reader); H22 drops it and this row is open-only; H29 drops it as a card KIND rather than a position, and this row explicitly does not claim otherwise. **Wiring:** `check:pm-stale-finding` in `package.json` and a self-test step in `lint.yml`. `check-self-test-wired` population **208 → 209**, green in both directions — the tool shipped a 91-case self-test that nothing in CI ran. ## Acceptance greps, both directions | reading | before | after | |:--|--:|--:| | `H62 (objectstack-ai#17009)` / `H63 (objectstack-ai#16904)` in `check-half-states.mjs` | 0 / 0 | 3 / 3 | | lit control `Decision-box dependency flags (instruction ④)` | 4 | 4 | | `os-decision-facets` reader in `check-half-states.mjs` | 0 | 3 | | `check:pm-stale-finding` in `package.json` | 0 | 1 | | `check:pm-stale-finding` in `lint.yml` | 0 | 1 | | `check-half-states --self-test` cases | 3806 | 3915 | | `sweep-stale-finding --self-test` cases | 91 | 91 | | `check-self-test-wired` scripts CI runs | 208 | 209 | | `check:declared-population-live` declaring families with an unreachable population | 1 | 0 | **Untouched, proved by md5 of the whole exported function body** — H4 `1d13e919…`, H13 `fa79039c…`, H14 `1e88cafd…`, H22 `a45048e0…`, H29 `aaa6ea92…`: identical before and after. The instruction-④ section, `.claude/**` and the write-path half of `finding` stripping are untouched. ## Live readings, read-only, taken from this tree H63: the landed sweep's own dry run against this board — **33 open cards carry `finding`, 0 stale, 33 genuinely ungraded, 0 unjudged**; 1 listing, 0 writes, `rate_limit` core 14747 → 14746. Zero is the expected reading right after the seat's one-time sweep (19 stripped here, 36 on objectui), and it is what the row exists to watch refill. H62: the decision inbox is **1 open card** today, not the 27 the D1 measured — it fires on that one, second reading `absent`.⚠️ No live specimen carries the marker, so the clean direction has no lit control on today's board; its controls are the self-test's fixtures, in both spellings. ## Gates Derived on the merged head `1d52dd97` with `dispatch-gates --commands --repo objectstack-ai/objectstack` (no paths): **74 families, 74 run, every one exit 0**. Reconciled with `--ran` carrying a recorded exit code per family: 「74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED (a DERIVED zero — all 74 recorded an exit code and none of them is 3)」. Five build-reading families first answered `PREREQUISITE NOT MET` (exit 3) in a fresh worktree — recorded as NOT MEASURED, never as findings — and all five exit 0 after `pnpm build` under the shared verify lock (`VERDICT command-exit 0 · held 391s · waited 0s`, 73/73 tasks). One of the five, `check:type-check-debt`, refused a second time on a heap ceiling my own `NODE_OPTIONS` imposed (its own line says so: 「tsc runs under --max-old-space-size=4096 MB — the caller's NODE_OPTIONS, which is tighter」) and exits 0 at the CI-shaped 6144. `pnpm lint` run repo-wide rather than narrowed: `eslint --no-inline-config . --format json` exit 0, **6723 files linted, 0 with findings** — no narrowing claimed. Control-character sweep over every touched file: no match.⚠️ `origin/main` moved again after this merge; the derivation says the tree is 1 commit behind on 3 files it derives from. The merge queue rebuilds and re-runs on that generation, which is where that half is measured. ## Deviations, declared 1. **One file beyond the claim's surface.** `scripts/pm/sweep-stale-finding.mjs` is edited to hand the screen down, because ruling ② mandates reuse by import and the only import direction that runs points the other way (measured; the crash is reproduced in the docblock). Its public API, its behaviour and its 91-case self-test are unchanged. 2. **No header-roster entry.** The header roster stops at H28; every row since carries its own banner block above its predicate instead. These two follow that convention — adding two entries to a list 33 consecutive rows are absent from would make the header lie about its own coverage. 3. **Two more commits, both derivation bookkeeping the wiring forced, neither weakening a self-test.** - Wiring the sweep into `package.json` makes it a declaring family for `dispatch-gates`, and `check:declared-population-live` reds on the module-level fixture's repo slug. Fixed by the gate's own remedy: the `no-path-population` marker (true — the tool's whole input is the GitHub API) plus moving the fixture into the self-test body, where the module-body mask reaches it. - `dispatch-gates --self-test` then red on one case of 1682 (CI run 34759389219, `Lint & Repo Gates` step 32): 「listed but no longer a member: check:pm-half-states」. The patrol's two sibling source-text pins make the family placeable **by path** (`dispatch-gates scripts/pm/sweep-stale-finding.mjs` ⇢ 「program text read by scripts/pm/check-half-states.mjs」), so it left the root-walk residue population and its `ROOT_WALK_RESIDUE_LEDGER` row went stale — which that ledger reds on by its own contract. The row is removed with a note; the placement is true and is the opposite of the objectstack-ai#15753 one the row was written about. 4. **`origin/main` merged** (`1d52dd97`) because the derivation warned the tree was stale on a file it derives from — and it was load-bearing: the merged `check-clause2-carriers.mjs` adds `check:pm-clause2-carriers` to this card's family list (73 → 74). `skip-changeset`: `scripts/pm/**`, a root `package.json` script and a workflow publish nothing from any released package. Authored in Claude Code session `session_01DAcomhvR9kKizeYgg89Vo8`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #16904 — deliverable D1 only. The card stays open for D2 (the report-only patrol row in
check-half-states.mjs), so this body carries no closing keyword.What and why
The PM state model defines
findingas transient — 「定级即离标」 — but nothing mechanises the strip, so a graded card keeps the marker andlabel:findingstops answering the one question it exists for. Three independent measurements on this card: 385 of 395 open carriers already graded, then 185 of 226 two days later, then 38 of 100 stripped by hand on objectstack.This adds the one-time sweep,
scripts/pm/sweep-stale-finding.mjs: dry run by default,--repo owner/namefor either board,--writeto act, bounded batches with aGET /rate_limitreading printed before and after each, a stop on the first403/429that prints--cursor=Nto resume, and--self-testoffline with no token. Each removal is the four-step write — read the card's labels, compute the survivor set, write, read back and diff — and any difference refuses to proceed rather than racing another actor.It only ever subtracts one label: it adds nothing, synthesises no grade, posts no comment and touches no assignee. A card carrying
findingwith no grade is the genuinely ungraded pool and is left alone; apm:seatpost is never touched. Both are reported rather than silently skipped. ⛔ The script is not run against either board by this PR — the seat that owns a board runs it once, by hand, after reading a dry run.One measured deviation from the ruled shape
The grading fixed step 3 as "write the full set".
scripts/check-whole-set-label-write.mjsbans the whole-set replace across.github/workflows/**,.github/actions/**andscripts/**— its header carries the measurement, a label destroyed one second after it was written by another writer's whole-set replace. So step 3 isDELETE .../labels/finding, the one verb that names a single label and cannot rewrite a set. The four steps and the computed survivor set are unchanged (the survivor set is what step 4 diffs against); only the verb that realises it changed, and it strictly improves the property the read-back exists to detect. That gate is in this PR's derived set and passes.The file surface stays at one new file:⚠️ The consequence, stated rather than left implicit: no workflow runs this self-test, so the instrument is unfired in CI until someone wires it — a one-line follow-up for the seat, alongside D2.
check-self-test-wiredandcheck-self-test-workflow-commandsboth pass with nopackage.jsonentry, because their population is scripts a workflow names.Acceptance
node scripts/pm/sweep-stale-finding.mjs --self-testfinding+pm:queueis stalefinding+priority:p3is stale (no state at all)findingalone is not stalepm:seatpost is never touched403/429stop prints the cursor--cursor=22, exit 4, and card 3 is never touched--dry-runwrites nothing (write-path spy)GET, and not one of them is a per-card label read5167082…), green again--dry-runagainstobjectstack-ai/objectstack(exit captured before any pipe)finding; 19 stale, 23 genuinely ungraded, 0 unjudged;rate_limitprinted at run-start and run-end (core 14871/15000)pm:blocked· 3pm:on-hold· 2 priority-only · 1pm:dispatchedpm:seat: 13 open, 0 stale under the same exported predicate. Positive leg, so the zero is a measurement and not a silence: the same harness reads 19 stale over thefindingpopulation, matching the tool's own rundispatch-gates --commands, reconciled with--ran)exit 0— includingcheck-whole-set-label-write,check-self-test-wired,check-self-test-workflow-commands,check-scripts-symbol-anchors,check-nul-bytespnpm lint(repo-wide,eslint . --no-inline-config)b9f45331, 1m12s — no narrowing claimedChangeset:
skip-changeset.scripts/pm/**ships in no package'sfiles[], so nothing published moves.Acceptance notes
pm:blocked/pm:on-hold— the exact set the last hand pass deliberately left for "a lower-risk pass". The by-trigger breakdown in the report is what makes that visible before anyone writes.Generated by Claude Code