Skip to content

feat(pm): close-cards --stateless acts on a card with no pm-state, and a half-written close resumes from the board - #19956

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-19954-close-cards-stateless-and-resume
Sep 24, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-19954-close-cards-stateless-and-resume

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

#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

维护者速读(草稿)

改了什么: 车队唯一的关单工具 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

@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 24, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review September 24, 2026 07:19
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 3b56070 Sep 24, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-19954-close-cards-stateless-and-resume branch September 24, 2026 07:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ough the allow-listed ccr pair; a landing denial stops and surfaces (objectstack-ai#19997)

Fixes objectstack-ai#19990
Clause-②: no

Rule text only, in three `pm-dispatch` references. This PR adds no allow
row, no tool and no gate. `.claude/settings.json`, `scripts/pm/**`,
`SKILL.md` and `AGENTS.md` are untouched. Line counts are unchanged (183
/ 101 / 37), and every edited line is at or under 120 bytes.

The maintainer's words, in the `domain:engine#1` seat's session, quoted
on the card verbatim and in order:

> 「你的pr为什么没有挂在当前session上」
> 「写一个 skills 卡片,更新技能」
> 「包括你刚才为什么不能merge,我当前session设置的是auto」

The same words reached the `domain:skills` seat directly (claim comment
5817962037): 「你的pr应该挂在当前 session上,对应的卡片优先派发」.

## What changed

| file · line (after) | bytes | rule |
|---|---|---|
| `execution-duties.md` :149 (new) | 118 | Case 1. When a report names a
PR, a session seat subscribes it at once (`subscribe_pr_activity`) and
lists it on the seat post. Reason, stated once: a PR the relay opens is
never attached to the session automatically. |
| `execution-duties.md` :147 | 87 → 116 | The collection line now covers
both modes itself ("(两种模式)", "评论与返回消息皆无"), replacing the deleted
report-channel line (see *Line budget*). |
| `landing-operations.md` :49 | 82 → 117 | (b). The landing executes the
verdict of record (ACCEPT, or the contract-review PASS). It is not a
self-approval. |
| `landing-operations.md` :51 | 65 → 115 | (a). Ready and auto-merge go
only through the two ccr commands that `settings.json` allow-lists
(`rest-channel.md` :51 / :55). |
| `landing-operations.md` :54 (new) | 120 | (c). A classifier denial
during landing means: stop, report to the maintainer, and record the
command and the denial reason on the card. ⛔ Never respell the command
or switch to the relay to get around it. |
| `landing-operations.md` :77 | 106 + 89 → 111 | Case 1, landing side.
Every PR in a session seat's window must be subscribed; subscribe any
that is missing. The optional 「关键 PR」 wording is gone. The old :77 "not
before the report" clause is folded in as 「⛔ 不早于报告」. Routine seats keep
polling. |
| `reading-discipline.md` :23 | 82 → 120 | (d). A timer text carries no
verdict or landing write verb. |

Wording choices that differ from the dispatch text:
- **`判决`, not `裁决`, at :49.** In this corpus `裁决` is a maintainer
ruling, and `判决` is the review verdict (`execution-duties.md` :180–:183,
「判决 ACCEPT / REWORK / ESCALATE」).
- **`判决与落地类写动词`, not only `落地类写动词`, at :23.** The timer that was denied
`[Self-Approval]` told the seat to post the ACCEPT as well as run the
two landing ops.

## Why no new allow row is owed: case 2 (a)

The allow-listed landing route already exists. The skill already names
it, and this PR only makes §B's landing step name it too.

- `.claude/settings.json` :61–:66 allow-lists `curl -sS -X POST
…/pulls/*/ccr/ready_for_review` and `curl -sS -X PUT
…/pulls/*/ccr/auto_merge` for all three repos. The hotcrm pair was added
on 2026-09-24 by `e6a5ecb9`. That commit also deliberately gave
`fleet-write/dispatch.mjs` no row. `git grep -n 'with-fleet'
.claude/settings.json` gives 0 hits; the control `git grep -n
'label-write' .claude/settings.json` gives 2.
- `SKILL.md` :201 says 「ready/draft 走 ccr 路」, and `platform-readings.md`
:48 says 「undraft 单通道:席位凭据走 `POST .../pulls/{n}/ccr/ready_for_review`」.
- Measured on the timeline (`GET /issues/N/timeline`,
2026-09-24T16:3xZ):
- PRs objectstack-ai#19873, objectstack-ai#19895, objectstack-ai#19902, objectstack-ai#19941, objectstack-ai#19948, objectstack-ai#19956, objectstack-ai#19970 and objectstack-ai#19993
were landed by the `domain:skills` seat under auto mode. Each has
`ready_for_review` and `added_to_merge_queue` with actor `os-zhuang`
(the ccr route, which writes as the seat's linked user).
- PRs objectstack-ai#19971, objectstack-ai#19972 and objectstack-ai#19979 have the same two events with actor
`objectstack-fleet[bot]` (the relay route).
- Both routes work. The ccr pair is the one with an allow row. The relay
route has none, so under auto mode the classifier judges it call by
call.

## Where the standing authorization is recorded: case 2 (b)

It is already recorded in the tree, so this PR adds only the one clause
at :49:
- `AGENTS.md` Prime Directive objectstack-ai#14: Tier S lands "by the owning seat on a
contract-tier review of record".
- `AGENTS.md` Multi-agent discipline §7 and Post-Task Checklist step 2:
arm auto-merge on a PR that is green and accepted.
- `landing-operations.md` :59 (Tier S).

Whether that is enough for a seat landing a PR written by its own
`mode:subagent` dev is put to the maintainer below. This PR does not
rule on it.

## Line budget: what left, and where each fact still lives

All three files stand at headroom 0. Each new line is paid for by
deleting content, not by re-wrapping or raising a ceiling.
- **`execution-duties.md` old :147 deleted.** It read 「报告通道统一:GitHub
是两种模式共用的真相源;dev 终报先落 issue 评论、再作返回消息。」
- The dev-side ordering lives in `.claude/agents/os-dev.md` :17–:18
(「报告交付两次,GitHub 优先:同一段 JSON 先作 issue 评论 … 再作为终报消息」).
- "GitHub is authoritative in both modes" lives in `os-dev.md` :324
(「两种派发模式(`mode:subagent` 与 `mode:cloud`)下 GitHub 都是报告的权威源」). It also
stays on the collection line as 「(两种模式)」.
- **`landing-operations.md` old :77, second clause, deleted.** It read
「订阅是感知补充,⛔ 不替代 flip 定点」. The fact lives on:
  - :50: the flip timer is set at ACCEPT.
  - :52: 「CI success webhook 不可靠:⛔ 不坐等」.
- `platform-readings.md` :40: 「订阅来的 `check_suite.completed` 是唤醒不是放行读数」.
  - Its first clause is kept on :77 as 「⛔ 不早于报告」.
- **`landing-operations.md` old :76 rewritten in place.** It dates from
`42af12fe7` (the 2026-08-07 ruling on subscribing *key* PRs). The newer
maintainer words quoted above replace its optional scope.

## Measured risk that stays open

- An allow row does not stop a denial based on content.
`mcp__Claude_Code_Remote__send_later` is allow-listed (`settings.json`
:23, present since before 2026-09-20), yet the engine seat's timer was
denied `[Self-Approval]`. `platform-readings.md` :435 records another
content-based `[Self-Approval]` denial.
- Two explanations are possible: that session did not load this settings
file, or the classifier judges content over an allow row. Which one
holds was not measured. The eight ccr landings are the positive reading.
The new :54 line covers the negative case.
- **Write identity, a tension this PR did not create.** The ccr pair
writes as the seat's linked user, `os-zhuang`, which is in
`GOVERNED_APPROVERS` (`scripts/pm/check-governed-queue-guard.mjs` :576).
Three texts point the other way:
- `AGENTS.md`: "Every GitHub write leaves through `scripts/pm/`, as
`objectstack-fleet[bot]` … ⛔ Never a bare `curl` … write".
  - `SKILL.md` :92: 「批准账号永不跑席位或作其关联用户」.
  - `SKILL.md` :94: 「写侧恒为 `objectstack-fleet[bot]`」.

`SKILL.md` :201 already routes ready/draft through ccr, so this tension
predates this PR. The new :51 states the same route more plainly. The
choice is the maintainer's; see the question below.

## Verification

At `04357257d`, every command from `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` was run, with the exit
code captured before any pipe. All exited 0: 17 derived commands, plus
`pnpm check:pm-governed-prose`, `node
scripts/check-skills-token-ratchet.mjs` and `pnpm
check:pm-settings-deny-roster`. The reconciliation `dispatch-gates
--ran` reports: "17 derived famil(ies) accounted for — 17 run, 0
NOT-MEASURED (a DERIVED zero …)".

Verdict lines:
- `check:pm-skill-ratchet`: `execution-duties.md is 183 lines (ceiling
183; headroom 0)` · `landing-operations.md is 101 lines (ceiling 101;
headroom 0)` · `reading-discipline.md is 37 lines (ceiling 37; headroom
0)`.
- `check:pm-skill-id-lint`: `34 file(s) clean`.
- `check:skill-frame-sync`: `the one declared copy of the decision frame
is internally coherent`.
- `check:nul-bytes`: `OK … no raw ASCII control bytes`.
- `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET,
unbuilt `@objectstack/formula` / `@objectstack/lint`). It exited 0 after
`turbo run build` for those two packages under the verify lock. That
first run measured nothing; it was not a failure.

No build, test, reverse check or ablation applies to this change: it is
rule text only, with no code path.

## Acceptance notes

- `dispatch-runbook.md` :128 has cloud cards subscribe as soon as the
draft PR exists. The folded 「⛔ 不早于报告」 agrees with it only because a
cloud dev reports at draft-PR time (runbook :135). No change is made.
- `origin/main` was not merged before opening. It moved by one commit (a
`docs(qa)` change touching none of these files), and the queue rebuilds
on current `main`.

## 维护者速读(草稿)

**改了什么**
- dev 报告里点名了 PR,席位当场订阅这个 PR 的动态,并记进座位贴。规则里写明原因:经中继开出的 PR
永远不会自动挂到会话上。落地说明里原来"给关键 PR 挂订阅"是可选说法,现在改成"落地窗口里每个 PR 都要挂上,缺了就补"。
- 落地(转 ready、挂 auto-merge)只走 `.claude/settings.json` 已放行的两条 ccr
命令。席位落地是在执行已记录的复核判决,不是自己批准自己。
- 落地过程中如果被权限分类器拒绝:停手,报给您,在卡上记下命令和拒绝原因;不换写法,也不改走中继绕过去。
- 定时提醒的文本里不再写"判决/落地"这类写操作,只写"到时重读什么、满足什么条件"。

**为什么改**
- 您问了两个问题。
- PR 为什么没挂在当前 session 上:中继开的 PR 不会自动挂上,而技能里只有可选的"关键 PR 挂订阅"。
- auto 模式下为什么不能 merge:engine 席被拒了两次,一次是一条回读命令,一次是一条写着"发 ACCEPT
并落地"的定时器。它随后改走中继落地,而中继没有放行规则。放行的路本来就有:本席今天在 auto 模式下用它落地了 8 个 PR,一次都没被拒。

**风险与代价(含回滚)**
- 只改三个技能参考文件的规则文本。行数不变,每行不超过 120 字节。回滚就是 revert 本 PR。
- ccr 两条命令记在 os-zhuang 名下。而 AGENTS.md 写的是"所有 GitHub 写都经 scripts/pm,以
objectstack-fleet[bot] 身份,永不裸 curl 写",SKILL.md
也写"批准账号永不作席位的关联用户"。这个矛盾早就存在(SKILL.md 本来就写"ready/draft 走 ccr 路"),本 PR
没有新造,只是把它写得更明确。
- 放行规则不保证分类器一定放行。`send_later` 在放行清单里,engine
席那条定时器还是按内容被拒了。所以新加了"被拒就停手上报"这一条。

**席位意见**

**你要做的**
- 回一句话,确认下面两件事,或者指出要改哪一件:
- ① AGENTS.md 第 14 条(Tier S 由所属席位在达档复核记录在案后落地)和 Multi-agent discipline 第
7 条(PR 全绿且已验收就挂 auto-merge),就是席位落地自己子代理所写 PR 的常设授权,不用另外记。
- ② 用 ccr 两条命令落地,算 AGENTS.md「写只经 scripts/pm」这条规则的例外。是把这个例外写进
AGENTS.md,还是改走中继并加一条新的放行规则,都由您决定。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants