Repository navigation
Commit 3b56070
feat(pm): close-cards --stateless acts on a card with no pm-state, and a half-written close resumes from the board (#19956)
Fixes #19954
Clause-②: no
`scripts/pm/close-cards.mjs` is the only file changed. It gains the two
capabilities the card asks for, inside the existing
one-card-one-dispatch shape. Maintainer, verbatim from the card:
「车队唯一的关单工具 close-cards.mjs 按设计只收带恰好一个 pm:* 状态标签的卡。 这个派卡处理。」 The spelling
and the mechanism are the `domain:skills` seat's ruling at dispatch.
## What changes
**`--stateless`: a card that never carried a pm-state**
- A mode FLAG, never an `--expect-state` value: `--expect-state` still
accepts exactly the imported `PM_EXCLUSIVE_STATE_LABELS`, so
`--expect-state none` is refused (its refusal now points at
`--stateless`). The two flags together are usage exit 2 at the real
door; `--stateless=false` is refused rather than read as ON; the default
`pm:queue` is CLEARED under the flag rather than left beside it.
- Skip matrix: only the state row moves. Under `--stateless` a card
carrying ANY pm-state (one or two; `needs-user-decision` included) is
skipped, naming them; the assignee, `pm:retriage`, not-open and open-PR
rows stand unchanged. An ordinary run's no-state skip now names
`--stateless`.
- A stateless card packs `comment → issue_patch`: 2 actions, ONE
dispatch.
**Resume: only the steps the BOARD still owes**
- Before anything is sent, every actionable card's thread is read (paged
by number, like the timeline). A thread that cannot be read STOPS the
run with zero writes on that card (exit 3 / 5), exactly as an unread
timeline does.
- `priorClosingComment`: is THIS row's closing comment already on the
card? The row's text is re-rendered by post-stamped's `renderBody` on
the clock of each stamp the stored comment spells (`protocolStamps`),
and judged against the stored bytes by post-stamped's
`pickRelayComment`. No similarity of this file's own (not `burstKey`,
which masks every digit run and would read two different rulings as one
comment).
- `residualOps`: `PACK_ORDER` minus what the board shows. The comment
unless this row's is there (never a second one); `labels_remove` while
any label the close strips is still on the card; `issue_patch` always.
`packCard` now demands exactly that residue; it still refuses a label
plan that ADDS, and under an ordinary run still refuses a strip that
does not take the expected state.
- Ordinary run: row 4 still requires the state, so the residue can only
be the comment: `labels_remove → issue_patch`. Under `--stateless` both
can be gone: the measured half-write shape (comment posted, state
stripped, the close refused by the throttle) resumes as `issue_patch`
alone.
- Record lines: `RESUME — this row's closing comment is already on the
board: comment ID (posted …, stamped … by its own act) … Still owed: …`;
the dry run prints `WOULD CLOSE … (RESUME — comment ID already on the
board)`; the summary counts `N RESUMED`.
- Step ④ is unchanged in kind: all three effects are read back every
time. A resumed comment is read back off the board by id (the `since`
window reaches back to it); a strip that was not owed reads as nothing
surviving removal. "Nothing landed" (exit 5) is judged on the steps THIS
run carried, so a failed resume or stateless pack is not misreported as
a new half-write.
- The DIRECT transport follows the same residue: post-stamped is not
spawned when the comment is on the board, and `label-write` is not
called with nothing to strip.
- Still ONE card, ONE dispatch, ONE write on `write-pace`: a residual
pack is fewer actions, never more writes. `write-pace.mjs`,
`check-half-states.mjs` and `package.json` are untouched (grep:
`WIRED_WRITE_TOOLS` names the file and nothing pins its usage or
header).
One refinement the ruling's text does not spell, stated so it can be
corrected: under `--stateless` the strip is still owed when a
`PM_RESIDUE_LABELS` label that is not a state (today only `pm:blocking`)
is on the card; that pack is `comment → labels_remove → issue_patch`.
The header already defines the label step as "the pm-state plus its
residue"; dropping the residue would leave a claim of work in flight on
a closed card. Pinned by its own self-test row.
## Evidence (tree `ab2ee1ee`)
### Self-test
`pnpm check:pm-close-cards` → `OK close-cards self-test: 227 cases pass
across 20 batteries` (base `f7a3495a`: 173 across 18). Two new pinned
batteries (the stateless close, 13; the resume, 27); the CLI floor 14 →
22, the skip-matrix floor 14 → 21, the roster floor 18 → 20.
Three existing assertions were re-aimed, because the thread read is a
new GET: the call sequence (now `read, timeline, comments, read,
comments`); the read-back `since` count (filtered to `since=` calls);
and the unreadable-read-back case, whose canned 502 now hits the SECOND
comments read through a pass-through `null` in the fake's hook queue,
with a new `sent.length === 1` leg proving the dispatch happened.
Without the re-aim that case went on passing while it tested the new
thread-read stop instead.
The dispatch's self-test rows, each a case: stateless close = 2-action
pack · half-write resume = 1-action pack · `--stateless` + a pm-state =
skipped · two pm-states = skipped (both modes) · an assignee still skips
· a dry run prints both packs and sends none · ordinary "comment there,
state there" = `labels_remove → issue_patch` · `--stateless` +
`--expect-state` = usage 2 (driven through `main`).
### Ablations
One-shot, each through `scripts/ablation-replace.mjs` (anchor hit once,
blob changed), each restored and proven: blob == HEAD `c66f7a10`, `git
diff HEAD` empty.
| leg | mutation | self-test |
|---|---|---|
| A1 | the resume detector never matches | red, 16 cases |
| A2 | the pack always carries the comment | red, 12 |
| A3 | "nothing landed" counts steps already on the board | red, 2 |
| A4 | the stateless skip row removed | red, 26 |
| A5 | an unread thread does not stop the run | red, 1 |
| A6 | the mode clash not refused | red, 3 |
Control after the six legs: 227 pass. Predicted direction for every leg:
red; observed: red.
### The live specimen, read-only
The resume detector against the stored bytes of the only measured
half-write comment (5807380767 on #19334, read over REST; the row text
rebuilt by putting `{{NOW}}` back where its stamp stands): found, stamp
`2026-09-24T04:02Z`. Control, the same text with one card number
changed: not found.
### Acceptance dry runs (read-only, zero writes; the probe comment files
were never posted)
`node scripts/pm/close-cards.mjs --repo objectstack-ai/objectstack
--plan plan.txt --stateless --dry-run` → exit 0:
```text
#9180 ① 取现集 `tracking`, `domain:spec` → ② target `tracking`, `domain:spec` · strip none
#9180 ③ ONE dispatch, request fw-20260924T065047Z-7aa1d8: comment → issue_patch (2 actions · 352 of 65536 payload bytes)
#9180 → WOULD CLOSE `completed` — ⛔ nothing sent
#11592 ① 取现集 `tracking`, `domain:spec` → ② target `tracking`, `domain:spec` · strip none
#11592 ③ ONE dispatch, request fw-20260924T065048Z-158be8: comment → issue_patch (2 actions · 371 of 65536 payload bytes)
#11592 → WOULD CLOSE `completed` — ⛔ nothing sent
#15210 ① 取现集 `documentation`, `tracking`, `domain:spec` → ② target `documentation`, `tracking`, `domain:spec` · strip none
#15210 ③ ONE dispatch, request fw-20260924T065049Z-838228: comment → issue_patch (2 actions · 343 of 65536 payload bytes)
#15210 → WOULD CLOSE `completed` — ⛔ nothing sent
close-cards: DRY RUN — nothing was written. 3 read · 3 actionable · 0 skipped · 3 payload(s) packed, 0 sent
```
Control, the same plan without `--stateless` → exit 0, `3 read · 0
actionable · 3 skipped`, each line `SKIP carries no pm-state label, and
this run acts only on pm:queue (a card carrying none closes under
--stateless)`. `--stateless --expect-state pm:queue` at the real door →
exit 2.
NOT MEASURED: live resume — reason: the only specimen was closed by hand
at 05:22Z. The resume shape is proven by the self-test fixtures and by
the read-only specimen probe above.
### Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; change set from merge base
`f7a3495a4`: 1 path) → 31 commands, every one run, every one exit 0;
`--ran` → `31 derived famil(ies) accounted for — 31 run, 0
NOT-MEASURED`. The dispatch's named gates are all in that set:
`check:pm-close-cards`, `check:pm-write-pace` (113 cases),
`check:pm-dispatch-gates` (1925 cases), `check-scripts-symbol-anchors`
(and its self-test), `check-self-test-wired` (and its self-test),
`check-whole-set-label-write` (and its self-test), `check:nul-bytes`.
Lint, narrowed and proven: `eslint --no-inline-config --format json
scripts/pm/close-cards.mjs` → 1 file, 0 errors, 0 warnings. The file is
in eslint's population (`--print-config` resolves 2 enabled rules for
it, not an ignore), and no type-aware linting is configured
(`parserOptions.project` is null), so this one-file diff cannot move any
untouched file's verdict. Declared to CI: the path-scheduled `Test Core`
job and the families the derivation prints outside its 31.
## Acceptance notes
- `.claude/skills/pm-dispatch/references/platform-readings.md` still
reads `close-cards` 每步一次 dispatch, stale since the one-dispatch-per-card
landing. Not touched here: a governed surface outside this file surface.
Carrier: none named.
- The comment above `.github/workflows/lint.yml`'s `Card-closure helper
self-test` step lists what the self-test instruments and does not
mention the stateless mode or the resume: incomplete, not wrong.
Carrier: none named.
- First use on #9180 · #11592 · #15210 is the seat's, after landing.
This PR sent nothing to any card.
- No changeset: `scripts/pm/**` publishes nothing (`skip-changeset`).
## 维护者速读(草稿)
**改了什么:** 车队唯一的关单工具 `close-cards.mjs` 多了两种能力。① `--stateless`
模式:专门处理「从来没挂过 pm:* 状态标签」的记录卡(例如 tracking 卡),一张卡仍是一次 dispatch,只发「评论 →
关卡」两步。② 半写续接:动手前先读卡的评论线,如果这一行的关卡评论已经在板上(上次关到一半),就只补发还欠的步骤,永不再发第二条关卡评论。
**为什么改:** 本次召集里有五张维护者已批准关闭的卡,工具处理不了,只能手工补:#4606、#19334
已由维护者手工处理;#9180、#11592、#15210 还欠着。原因是工具按设计只收恰好带一个 pm:*
状态的卡,而且一次关卡半途失败后无法续接。
**风险与代价(含回滚):** 只改一个内部工具脚本,不发布任何包。新模式必须显式传 `--stateless`
才生效,默认行为不变;普通模式唯一新增的是先读评论线,读不到就停手、零写。自检 227 例全绿,6 次消融均按预期转红。回滚 = revert
本 PR。
**席位意见:**
**你要做的:** 无需操作。落地后由 skills 席用新工具处理 #9180、#11592、#15210 三张卡,并在卡上注明。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f5a7250 commit 3b56070
1 file changed
Lines changed: 518 additions & 116 deletions
0 commit comments