Skip to content

feat(pm): close-cards packs one card into ONE fleet-write dispatch (comment, labels_remove, issue_patch) from a per-card --plan - #19873

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-19824-close-cards-one-dispatch
Sep 23, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-19824-close-cards-one-dispatch

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #19824
Clause-②: no

#19824 remains open until the seat's live one-card run of this tool closes it (the card's last acceptance step, owned by the skills seat at MERGED). This PR carries the code half: the pack, the per-card plan, the pre-flight and the read-back.

What changes

scripts/pm/close-cards.mjs is the only file changed.

  • One card = one write under the relay. On the dispatch transport each actionable card becomes ONE repository_dispatch carrying exactly [comment, labels_remove, issue_patch], in that order (PACK_ORDER). write-pace counts the dispatch as the card's one write, so the hourly cap is untouched (DEFAULT_HOURLY_MAX and the gap are not edited) and a card costs a third of what it did. ⛔ One card per dispatch, never two.
  • --plan FILE replaces --list / --comment / --reason. One row per card: N|REASON|COMMENT-FILE. The retired flags are refused by name, with the new spelling. The plan is refused before any request when a card is named twice, when two rows share one comment file, or when two comment files carry the same text once whitespace is collapsed and every digit run is masked. So posting the same comment text on many cards is structurally impossible in the tool.
  • The pre-flight is kept and reused, not re-written. Every row's comment goes through post-stamped's exported renderBody + claimKeyedLineRefusals (via the existing preflightComment) before card one. It runs again on the act's own clock when the card is packed. A refusal is ZERO writes on that card and on every card after it (exit 2).
  • The pack is judged by the relay's own validator before anything is sent. On dispatch, every row is packed once on placeholder labels before card one is read. So a payload over the platform's 64KB client_payload ceiling, or a body over the relay's body cap, is refused with zero writes on every card, never discovered on card 40.
  • Step ④ reads the BOARD back after every run, and must MATCH. The card (state, state_reason, labels) and the comments since the dispatch are read. The comment must be found by post-stamped's pickRelayComment (the platform footer tolerated) with its stamp verdict from post-stamped's readBackVerdict. The labels are judged by label-write's readBackVerdict + verdictIsClean against the ② target from computeLabelTarget. The close must read closed under the row's reason. A run that FAILED is read back the same way to say which steps landed: exit 5 when none did, exit 4 naming them otherwise.
  • The label strip is the card's pm-state plus every label PM_RESIDUE_LABELS names that it carries (e.g. pm:blocking). Identity stickers (pm:seat, pm:epic) stay. The vocabulary is imported, not restated.
  • The direct transport keeps its three writes, each by the tool that owns it (post-stamped child, runLabelWrite, PATCH). Three requests are three throttle writes on that path whatever the packing. The comment child is now handed OS_FLEET_TRANSPORT=direct, so it cannot resolve a route of its own that differs from the run's.
  • Exit 6 (UNCONFIRMED) is new for this tool. It covers a dispatch that was accepted but whose run did not appear or did not complete. ⛔ Not retried and ⛔ not fallen back to direct, even under auto. The pack's first action is a comment, so replaying a dispatch that may still run is a second closing comment, written as the seat's own user. The previous close-only relay path fell back to a direct PATCH on no-run under auto. That fall-back is gone because the dispatch now carries the comment.

The mechanism assumptions, measured (base 48c91e9, those files untouched here)

  1. fleet-write/ops.mjs:128 CLIENT_PAYLOAD_MAX_BYTES = 64 * 1024 (the platform ceiling, quoted with its source in that header); :113 MAX_ACTIONS = 20; :116 MAX_BODY_BYTES = 60_000. validate.mjs:203 refuses a serialised payload at or over the ceiling. The ceiling is reachable under the body cap, because JSON escaping inflates a body. The self-test fixture is 40,000 double quotes: under 60,000 body bytes, over 64KB serialised.
  2. fleet-write/execute.mjs:41: the first failure stops the run. So the pack's order keeps the old invariant: no strip and no close without the comment.
  3. fleet-write/dispatch.mjs:112: the dispatch POST is the paced write. Measured below: a dry run adds 0 records.
  4. runLabelWrite binds ③ and ④ into one call: its ③ sends its own dispatch (label-write.mjs:637). So the pack reuses the exported halves (computeLabelTarget, relayActions, readBackVerdict, verdictIsClean) instead of calling it on the relay path. No change to label-write.mjs. One piece of its ④ is deliberately NOT borrowed: re-adding a label stripped underneath (label-write.mjs:733). That is a write, and on the relay path it is a direct write (see Acceptance notes). A strip is reported as a READ-BACK MISMATCH (exit 4) instead.
  5. The live skip matrix (not open, assignee, pm:retriage, pm-state not exactly --expect-state, open-PR cross-reference) is unchanged and runs per card before its pack. A skipped card gets no payload, and its line names the reason.
  6. The self-test already had the pinned-battery shape (SELF_TEST_BATTERIES + SELF_TEST_BATTERY_FLOOR + selfTestReachedVerdict). New behaviour gets new named batteries. The list-file battery became the plan-file battery, and the close-only relay battery became four pack batteries. The roster floor moved 12 to 18. There is no total pin.

Why --plan over --list + --comment-dir

A plan row carries the card, its OWN reason and its OWN comment file in one reviewable line. The maintainer's closing replies are per card and differ in reason as well as text. --comment-dir pairs a comment to a card by file-name convention and keeps a batch-wide --reason. Both need the same content-identity check to make "one text on many cards" impossible. That check is judgePlanComments here, and the plan is the shape that also removes the shared reason.

Tests (at c474dc5)

  • node scripts/pm/close-cards.mjs --self-test: OK close-cards self-test: 173 cases pass across 18 batteries — offline, no network, no token. (111 across 12 before). The three acceptance cases each have a named battery:
    • the pre-flight refusal battery: a comment refused before the pack is ZERO writes on its card (relay and direct, plus the plan-level refusal before any request);
    • the size ceiling battery: a payload over 64KB is refused before it is packed, with no dispatch and no card read;
    • the skip log battery: the assignee, pm:retriage, wrong-state and open-PR cards are skipped with their reasons named, and ONE payload goes for the one actionable card.
  • Mutation legs through scripts/ablation-replace.mjs (anchor hit 1 to 0, blob changed, restored to the HEAD blob 60a97257, git diff HEAD empty), each turning its target red:
    • M1, drop the pre-card pack preview: size-ceiling battery 2 of 173 fail;
    • M2, burst rule off: burst battery 5 fail;
    • M3, ④ ignores the label verdict: read-back battery 2 fail;
    • M5, strip only the pm-state: residue case 1 fail.
  • Live, read-only: node scripts/pm/close-cards.mjs --repo objectstack-ai/objectstack --plan PLAN --dry-run on close-cards: one card = one write — pack comment → pm:* strip → close into ONE fleet-write write set (write-pace cap unchanged, closing throughput ×3) #19824, PM corpus audit (maintainer-directed): classify every instruction line — keep / duplicate / demote / delete — then rewrite and re-lock every ratchet #13597 and Pace every seat write — one shared throttle in front of the five PM write scripts (maintainer 「截流应该要加」) #19572. Exit 0: re-exec through the proxy, transport dispatch, the pack preview accepted all three rows, and three SKIP lines naming assignee, assignee and not open (state closed/completed). write-pace.jsonl held 6 records before and after, with 0 stamped inside the run window. None of those cards is actionable, so the live payload print is covered offline by the relay-pack battery (#65 payload equals the packed JSON, 3 actions, 0 sent).
  • Live refusals: --list gives exit 2 with the retirement line naming --plan. A two-card plan whose texts differ only in the card number gives exit 2 with the burst refusal and NOTHING was read and NOTHING was written.

Gates (derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at c474dc5: 31 commands, plus six named by the dispatch)

All exit 0: the 30 derived commands other than check:pm-dispatch-gates, plus check:pm-post-stamped (610/21), check:pm-label-write (91/10), check:pm-fleet-write-validate (74/7), check:pm-fleet-write-execute (48/9), check:pm-fleet-write-dispatch (101/11) and check:pm-settings-deny-roster. Also check:pm-close-cards (173/18), check:pm-write-pace (113/12) and check:nul-bytes (9315 files, no raw control bytes).

check:pm-dispatch-gates: its self-test does not finish inside the container's ten-minute foreground cap (two attempts killed by the cap with 0 failures printed). It is being run to completion detached, and the report comment on #19824 carries its verdict. Until then: NOT MEASURED, reason: runtime over the foreground cap.

NOT MEASURED: live one-card run. Reason: the seat runs it on #19824 after MERGED.

Acceptance notes

  • class a, a cubic-time regex on the claim marker. CLAIM_COMMENT_MARKER (check-half-states.mjs:1188) is a multiline ^, then optional whitespace, an optional quote marker, optional whitespace, then Claim or Claimed and a colon. Its two optional-whitespace runs both match newlines, so a body with a long run of blank lines backtracks cubically. Measured at c474dc5 on the bare regex: 500 newlines take 53 ms, 1000 take 456 ms, 2000 take 3410 ms. Through claimKeyedLineRefusals (post-stamped :1916), 2000 take 6.5 s and 10,000 did not finish in 100 s. Every comment markerMatches reads with it is exposed, the patrol's included. Probe: markerMatches(CLAIM_COMMENT_MARKER, 'x' + '\n'.repeat(2000) + 'end.'). Reported for the seat to file; not fixed here (another file, another defect class).
  • class b, label-write's step ④ re-add leaves the relay. Under the dispatch route, runLabelWrite's ④ re-add of a label stripped underneath is doCall('label-add', …), the direct rest with the seat's token (label-write.mjs:733). In a cloud container that is a write as the seat's own user. The relay contract is that OS_FLEET_TRANSPORT=direct is the only way to do that (fleet-write/dispatch.mjs header, the fail-closed paragraph). Offline probe: runLabelWrite with a dispatch route and a relay that strips tooling inside the run records POST /repos/objectstack-ai/objectstack/issues/5/labels on the direct call. The pack here does not borrow that re-add. Reported for the seat to file.
  • Doc lines this PR makes stale, both in .claude/** (outside this PR's surface). Carrier: the skills seat, at landing.
    • .claude/skills/pm-dispatch/references/platform-readings.md:107 says close-cards spends one dispatch per step. Under the relay it is now one per card.
    • .claude/skills/pm-dispatch/references/rest-channel.md:19 lists close-cards' failed-run exit as 4. It is now 4 when the board shows a step landed and 5 when it shows none, with 6 for UNCONFIRMED.
  • Not done: the card's optional item 6 (a grading mode: comment plus label add/remove, no close). It is left out to keep this diff to the closure the card rules on.
  • The Card-closure helper self-test comment block in .github/workflows/lint.yml still describes the three-step shape. Its assertions all still hold on the direct path, and the step command is unchanged.

Generated by Claude Code

…omment, labels_remove, issue_patch) from a per-card --plan

Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 23, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review September 23, 2026 14:35
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 04a160d Sep 23, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-19824-close-cards-one-dispatch branch September 23, 2026 15:07
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/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants