Skip to content

Commit d87dff6

Browse files
feat(pm): a silent claim is reclaimed by triage on a named clock through the relay (label-write --release) (#22458)
Fixes #22053 Clause-②: no A claim with no PR, no commit and no word on the card could sit for days: `seat-lifecycle.md:37` said a silent seat 「即可降级回收」 and named no actor, no clock and no write path, so a serial dependent waited on a dead seat. This PR lands both halves the card's **Done when** asks for — the protocol text and the relay-only write path — and the pin on both fixture cards. ## The door: `label-write.mjs --release`, not `close-cards.mjs` Measured on both tools before writing a line: - A release **is** a label/assignee transition — `pm:dispatched` → `pm:queue`, every assignee cleared — with a provenance comment in front of it. Its arithmetic (`computeLabelTarget`, `computeAssigneeTarget`, `relayActions`, `readBackVerdict`, `verdictIsClean`) and its ONE-OF refusal already live in `label-write.mjs`; the comment half is borrowed from `post-stamped.mjs` (`relayAction`, `renderBody`, `pickRelayComment`, `readBackVerdict`) exactly the way `close-cards.mjs` borrows it, imported the same way. `post-stamped.mjs` does not import `label-write.mjs`, so no new module cycle opens (and nothing from it is touched at module scope, since post-stamped reaches this file through `fleet-write/dispatch.mjs`). - `close-cards.mjs`'s plan file, skip matrix and resume are built to act on **unowned** `pm:queue` cards and to **close** them; a release acts on an **owned** `pm:dispatched` card and leaves it open — the inverse of every skip row there. Importing its readers into label-write would also have opened a label-write ↔ close-cards cycle. - Done-when ② says relay-only, which removes the one thing label-write lacked for a comment (a direct post-stamped child path): the release has no direct leg at all. ## The write half — `scripts/pm/label-write.mjs` ``` node scripts/pm/label-write.mjs --issue N --release --recheck-comment ID --comment-file release.md --resume-from BRANCH@SHA|none [--dry-run] [--json] ``` Order of judgement, every refusal before the write with zero writes: 1. **The card** — open, carries `pm:dispatched`, has an assignee. Otherwise there is nothing to release. 2. **The knock** — `GET /issues/comments/ID` must belong to this card and its first line (after markdown heading marks) must be the key `Owned-24h re-check:` — the spelling triage already uses on the board. ⛔ No release without the knock: `--recheck-comment` is required, and a comment that is not the knock is refused. 3. **The clock** — the knock's `created_at` against the **server's** clock: the `Date` header of the very answer that returned the knock (`rest()` now returns `date`). ⛔ Never `Date.now()` — the self-test runs this process's clock in 2030 and a case where the local clock says "old" while the server says "young" still refuses. Younger than `RECLAIM_AFTER_HOURS = 12` ⇒ refused, printing the remainder (`7h 0m remain`). A missing `Date` header is a prerequisite refusal, not a fallback to the local clock. 4. **Silence** — the knock must still be the newest comment (comments since it, paged by number until a short page, capped at 30 pages). Anything said after it is read by a seat, not by this tool: refuse, and triage re-knocks if that was no answer. 5. **The branch** — `--resume-from BRANCH@SHA` is checked against `GET /branches/BRANCH` (tip must start with the sha) and against `GET /pulls?state=open&head=OWNER:BRANCH` (an open PR is produce, not silence). `none` declares that no branch was ever pushed and skips both reads. 6. **The comment** — the operator's file must open with the `Release:` line (judged by `check-half-states`' `RELEASE_COMMENT_MARKER` through `markerMatches`, the reader that owns the key, on the first line alone) and must not pre-write `Resume-from:`; the tool appends `Resume-from: BRANCH@SHA` and one provenance line naming the knock id, its instant, the server instant and the hours — both instants declared as `{{WAS:…}}` so post-stamped's stamp contract reads them as quoted readings beside the operator's `{{NOW}}`. 7. **The stroke** — ONE `repository_dispatch`: `comment` → `labels_add pm:queue` → `labels_remove pm:dispatched` → `unassign` every holder. Every action names the card, so the holder's seat post cannot be touched by construction. 8. **Relay-only** — a route that resolves to `direct` is a PREREQUISITE refusal (exit 3) with zero writes; an accepted dispatch with no run is UNCONFIRMED (exit 6), never a direct write after it; a failed run is exit 5, never retried. 9. **Read-back** — the card's labels and assignees against the ② target, and the release comment located on the board by its stored bytes (`pickRelayComment`). A board that disagrees is exit 4 and is **not** repaired — under the relay a re-add is a second write. `RECLAIM_AFTER_HOURS = 12` lives in the tool and in `triage-duties.md` under the same name; the self-test reads the text and pins the two equal, and pins both transition labels as members of the imported `PM_EXCLUSIVE_STATE_LABELS`. ### Self-test (offline, zero network) New battery (30 cases pinned, 11 batteries now, floor raised 10 → 11): the two fixture cards the card names — a knock 13 h old is released and reads back `pm:queue` with no assignee in one dispatch of exactly `comment → labels_add → labels_remove → unassign`; a knock 5 h old is refused with `7h 0m remain` and zero writes — plus relay-only under a direct route, UNCONFIRMED under auto with no run, a timed-out run, a failed run, a run that lies, and every refusal row (another card's comment, a non-knock, a later comment, no `pm:dispatched`, no assignee, closed, wrong tip, missing branch, open PR, unknown knock id, bad comment file, pre-written resume line, unreadable file, no `Date` header, dry run, CLI exclusivity). ### Reverse verification (one-time, from the committed state, both restores proven byte-identical) - Ablation 1 — the relay-only guard `if (route.transport !== 'dispatch') {` → `if (false) {` (blob `a90f9bf7a9a9` → `fdd5c269b1b2`, anchor 1 → 0): the self-test went RED on exactly the relay-only pin (`got [2,null,0,[],false], want [3,"relay-only",0,[],true]`), 1 of 134. Restore: blob back to `a90f9bf7a9a9`, `git diff HEAD` empty, anchor count 1. - Ablation 2 — the no-run branch `record(unconfirmedText(sent, 'label-write --release'));` → `return result(EXIT_OK, { relay: sent });` (blob → `1c2e6287b634`): RED on the two UNCONFIRMED pins (`got [0,…] want [6,…]`), 2 of 134. Restore proven the same way. Both ablations went in the expected direction (red); neither reversed or multiplied. ## The text half — four governed files, all at their ceilings Every file stays at its ceiling; each new line is paid by deleting a line the same file restated elsewhere (no re-wrap, no ceiling raise); every prose line ≤ 120 bytes (the one 125-byte line is inside the fenced claim template, which the ratchet exempts); no issue number enters operative text. | file | before | after | added | deleted (its surviving home) | |---|---|---|---|---| | `references/triage-duties.md` | 120/120 | 120/120 | 4 lines: the dispatched-card rule rewritten with its one exception; the knock (first-line key `Owned-24h re-check:`); the clock `RECLAIM_AFTER_HOURS = 12` → `label-write.mjs --release`, one relay stroke, the seat post untouched; the stroke's four parts | `(a) 不分功能与体验…` (filing-gate.md ① line; os-dev.md rule 3); `Acceptance notes 前先答「哪个 PR 或人会碰到它」…` (filing-gate.md 「动手的读者」 line; os-dev.md `carrier:` line); `可逆且有推荐默认的事项按否决窗口…` (SKILL.md 升级与决策 「维护者否决窗口而非许可门」; this file's ④ 执行是否决窗口不是许可门); and the rewritten rule itself (net +3 −3, the fourth is in place) | | `references/seat-lifecycle.md` | 96/96 | 96/96 | line 37 becomes two: the actor (the triage round) and the clock (逾一天 ⇒ the seat post is demoted on the spot; a silent claim gets its knock first) and the release (`RECLAIM_AFTER_HOURS` unanswered ⇒ triage releases in one relay stroke, detail in `triage-duties.md`) | `退场只有两个入口:维护者交接令…,或被惰性回收。` (SKILL.md 入口与角色 「班 … 只以交接或惰性回收结束」; the handover checklist pointer stays at this file's 接管/移交 line) | | `SKILL.md` | 319/319 | 319/319 | claim template: `Resume-from: BRANCH@SHA (only when the card's newest Release: carries one; the dev starts from that sha, never from zero)` | `多席可写面恒读回;API 200 不等于落地正确。` (the two 四步/回读 lines right above it; 竞态三招 写后回读 in seat-lifecycle.md; the post-后读回 line two below) | | `.claude/agents/os-dev.md` | 402/402 | 402/402 | rule 1: a claim carrying `Resume-from: BRANCH@SHA` ⇒ build the worktree from `origin/BRANCH` under the same name and merge `origin/main` first (a shared branch, so never rebased) | `新 worktree 里写裸 pnpm install。` (rule 1's own `pnpm install`; the line above it says pnpm 10 rejects the flag) | Widest-table-row pin for `SKILL.md` (342 bytes) untouched: the template is a fence, not a table. ## Premise check against `origin/main` — one mechanism assumption falsified The dispatch's assumption 5 (and the card's wording "`triage-duties.md` has the owned-24h re-check but no release step") does not hold on `origin/main`: `grep -n -E "owned-24|24 ?h|复查|敲门" references/triage-duties.md` → 0 lines. The re-check existed only as triage's live practice (the knock on the reproduction card, whose first line is `## Owned-24h re-check: …`); the only 24h text in the skill is the **seat-post** patrol in `dispatch-runbook.md`, which already names the actor and clock for seat posts and says in-flight claims are followed by their claimant. So the "second half" had no first half to attach to. The fix adds both halves to `triage-duties.md` and pins the knock by the spelling triage already uses — no second spelling invented — which keeps the card's intent (the tool recognises the knock by its first-line marker) true rather than nominal. The premise the card exists for (no actor, clock or write path for a silent claim) stands, and the proposed N = 12 is adopted as the named constant. ## Gates Derived on this head with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (44 commands, identical before and after the merge of `origin/main`), run in full on `44044d72` and reconciled with `--ran` (`44 derived, 44 run, 0 NOT-MEASURED, 0 UNRUN`): 43 of the 44 exit 0 on this head, each exit captured before any pipe into a ledger (the ratchet family among them: `check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:skill-frame-sync`, `check:agent-model-declared`, `check:nul-bytes`, `check:pm-governed-prose`; the tool family: `check:pm-label-write` 134 cases / 11 batteries, `check:pm-close-cards` 227, `check:pm-fleet-write-dispatch` 200 incl. the no-run conformance over every sender, `check:pm-post-stamped` 630, `check:pm-half-states` 4912; `check:doc-formula-expressions` after `@objectstack/formula` and `@objectstack/lint` were rebuilt under the verify lock). Also run outside the derivation because they import this file: `check:pm-close-cards`, `check:pm-fleet-write-dispatch`, `check:pm-post-stamped`, `check:pm-governed-merges`. Not run locally: repo-wide `pnpm lint` (CI's run). The 44th, the `check:pm-dispatch-gates` battery (≈20 min on this box): 2011 cases pass on the pre-merge tree (`c963c2c9` + working tree, 1178.7 s); its re-run on `44044d72` was in progress when this PR opened — the result is in the report comment on the card. ## Changeset `skip-changeset`: the diff touches `.claude/**` and `scripts/pm/**` only, neither published by any package's `files[]`. ## Acceptance notes - `runLabelWrite`'s step ④ re-adds a stripped label with a **direct** `POST` even when the stroke went through the relay — pre-existing, outside this card; the release path deliberately reports a mismatch instead (exit 4, no second write). carrier: 承接者:无. - With the relay-only guard ablated, a direct route still produced zero writes, because the packer refuses an envelope with no session; the guard is what makes the reason and the exit (3, `relay-only`) legible rather than a validator's exit 2. Noted, not filed. - `label-write.mjs` now carries the literal `.claude/skills/pm-dispatch/references/triage-duties.md` (the self-test's read), so `dispatch-gates.mjs` will derive `check:pm-label-write` for any card touching that file — the intended coupling; `check:pm-dispatch-gates` is the gate that would notice if it disagreed. ## 维护者速读(草稿) **改了什么** — 给「静默认领」补上了回收机制的两半。文本半:分诊职责文里写明先敲门(`Owned-24h re-check:`)、敲门后 12 小时无答复由分诊一笔中继释放,座位生命周期第 37 行点名执行者(分诊轮)与时钟,认领模板加可选 `Resume-from:` 行,dev 定义写明带该行的认领从那个 sha 续写。工具半:`label-write.mjs --release`,只走中继,读服务器时钟判年龄,年轻就拒绝并报还剩多久,一笔写:释放评论 + 回队 + 清 assignee,写后回读。 **为什么改** — 一张 p1 卡曾静默 34 小时无 PR,串行依赖它的卡干等;规则只说「即可回收」,没说谁、何时、怎么写。这次最终是维护者手工释放的;以后分诊按时钟自动做,不再等人。 **风险与代价(含回滚)** — 四份协议文本都在行数上限,新增行以删除复述行付账,每条删除都在别处有家(表在 PR 正文)。工具是新增模式,不改既有 `label-write` 行为;自测离线钉死两张样板卡与「永不直写」。回滚 = revert 本 PR,无数据迁移。 **席位意见** — (留空) **你要做的** — 席内达档复核 PASS 后由席位入队落地;维护者无需操作。 --- _Generated by [Claude Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_ Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 587bd96 commit d87dff6

5 files changed

Lines changed: 712 additions & 13 deletions

File tree

‎.claude/agents/os-dev.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ model: opus
2424

2525
1. **Worktree-first。** 任何编辑之前建专用 worktree,`cd` 进去 `pnpm install`。
2626
- `git fetch origin main && git worktree add --no-track ../<repo>-issue-<n> -b claude/issue-<n>-<slug> origin/main`
27+
- 认领带 `Resume-from: BRANCH@SHA` ⇒ 从 `origin/BRANCH` 建同名分支续写,先合 `origin/main`。
2728
- ⛔ 永不编辑共享检出(PreToolUse 钩子会拦);修复横跨姊妹仓时一仓一 worktree。
2829
- 动笔前记下基点 `BASE=$(git rev-parse HEAD)`,它是标准条款节里家族规则的锚。
2930
- 建好分支后的第一个动作是推空分支:任何编辑之前 `git push -u origin <branch>`。
@@ -111,7 +112,6 @@ model: opus
111112
- `--workspace-concurrency=2` 只对过滤后的 run-command(`run` / `build`)成立,放在 `--filter` 之前。
112113
- 放在 filter 之后会被转发给底层脚本,且该 flag 不叫 `--concurrency`。
113114
- pnpm 10 在 `install` 上直接拒绝它(`ERROR Unknown option: 'workspace-concurrency'`)。
114-
- 新 worktree 里写裸 `pnpm install`。
115115
- pnpm `overrides` 只住在 `pnpm-workspace.yaml`;加进 `package.json` 的那份什么都不改。
116116
- OSV override 的上界 ⛔ 永不写成恰好排除修复版:上界放在 major 边界,只挪替换目标。
117117
- 新 fake engine 的 `delete()` 开头用 `@objectstack/objectql` 的 `assertEngineDeleteDispatch(options)`。

‎.claude/skills/pm-dispatch/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
146146
- 代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。
147147
- 标签写恒四步:取现集 → 只增删目标 → 写合并集 → 回读 diff 对 union(现集, 增删)。
148148
- union 有而回读缺 = 被并发剥掉,重挂并报告。
149-
- 多席可写面恒读回;API 200 不等于落地正确。
150149
- PM 写进 GitHub 的文本 ⛔ 不用尖括号路径占位符,改写成后跟显式路径的说法。
151150
- 携带易损片段的评论(派发令与裁决)post 后读回;写侧形状见 `references/platform-readings.md`。
152151

@@ -316,4 +315,5 @@ Clause-②: yes | no
316315
Responsibility: `<whose code produces the risk | the platform path that already covers it, or none | who reaches it, and whether anyone uses it today>` (defect cards only, else `n/a — not a defect card`; the dispatch prompt's ruling section repeats the three answers)
317316
Thread-read: <id of the newest comment on the card at the moment this claim is written, or none>
318317
Serial constraints cleared: `<点名同文件/同包的前驱 PR 与在飞认领,及分诊点名的任意车道在飞兄弟卡中本卡 pin 断言其行为者;无则 none>`
318+
Resume-from: `BRANCH@SHA` (only when the card's newest `Release:` carries one; the dev starts from that sha, never from zero)
319319
```

‎.claude/skills/pm-dispatch/references/seat-lifecycle.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
- 读侧只读贴正文 + 晚于正文最后编辑的评论;刷新前读完评论、回执未读敲门,再刷。
3535
- 热文件串行队是正文具名段:区域写不清就只能整文件串行。
3636
- 座位贴每仓登记合并是否对外发布及其验证层;登记缺失视为未验证。
37-
- 无心跳,活性惰性判定:超过一天无自有产出即可降级回收。
37+
- 无心跳,活性由分诊轮判定:逾一天无自有产出 ⇒ 座位贴当场降级,静默认领同钟先敲门。
38+
- 敲门后满 `RECLAIM_AFTER_HOURS` 无答复即由分诊一笔中继释放,细则 `triage-duties.md`。
3839
- ⛔ 永不从贴的 `updated_at` 或标题推死活;活性只认在任者自己的产出。
3940
- 竞态三招:动手前重 fetch 正文、审计评论时间戳先到先得、写后回读。
4041
- 收班 = 状态 flush + 在飞显式安置,不是在飞归零;派发口径失效,简报 ⛔ 不列作继承裁决。
@@ -84,7 +85,6 @@
8485
- 常设座位队列清空不等于退场,是切换待命姿态:巡检间隔按 `execution-duties.md` 收集节②。
8586
- 待命五项各以其主节为准,⛔ 此处不另抄:findings 配合;`pm:blocked`/`pm:on-hold` 解锁扫描。
8687
- 在飞/已入队 PR 跟到 MERGED;决策箱仅在报告中列出 ⛔ 不 nag;跨车道备忘跟进。
87-
- 退场只有两个入口:维护者交接令(走交接收尾清单),或被惰性回收。
8888

8989
## 断粮与跨墙恢复(5 小时用量墙)
9090

‎.claude/skills/pm-dispatch/references/triage-duties.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,11 @@
4141
- `finding` = 三类内待定级:(a) 可复现缺陷(复现或失败探针);(b) 违背已声明契约(引契约文);
4242
- (c) AI 写元数据会被运行时拒收或静默丢弃的陷阱;其余进 PR `## Acceptance notes`,⛔ 不立卡。
4343
- (a) 的分界是不完整 vs 错误,不是文档 vs 代码:示例照抄即失败是 (a),漏列成员不是。
44-
- (a) 不分功能与体验:能用但用户可见地错(截断、遮挡、白占首屏、误导文案)也是 (a)。
4544
- 门禁头注、self-test 文案与 `check-*` 处方句 ⛔ 非已声明契约;(b) 须用户或已发布包读得到。
4645
- (c) 元数据 = 由写它的人以外的人存储并再作者化的键:React prop 不是,存储的视图配置是。
4746
- 把作者引向运行时会兑现却让事情更糟的元数据的警告不在 (c) 内;记为边界,⛔ 不扩类。
4847
- 三类同欠 `reach:`,分诊只把关不测;观察、休眠、零拉动 ⛔ 不立卡、不进收口卡、无配额。
4948
- 缺 `reach:` 且非例外 ⇒ 定 `priority:p3`,并入收口卡或清扫卡,⛔ 不单独派发、不因此关卡。
50-
- Acceptance notes 前先答「哪个 PR 或人会碰到它」;答不出 ⇒ 仍然关,理由写「承接者:无」。
5149
- 先修复:正文被 sanitizer 截断的卡不可派发,评论修复指令后跳过。
5250
- 停摆指令判据必须比其它分类更硬(双读取),事后证伪同处公开作废。
5351
- 决策箱勤务:入箱校验/补全四棱块、速读与 `Prior rulings on this card:` 行;存量卡低频回填。
@@ -81,7 +79,10 @@
8179
- 三类外首触关闭只清门前存量,关 not planned(不等批准可否决重开);三类内无证据拒收。
8280
- 三类内再过时前提检查,三选一:晋级 / 关闭 not planned / hold;判级只在此轮,不在立单时。
8381
- ⛔ 写入前对每张卡现读当前状态;任何列表快照读数一律作废。
84-
- ⛔ 带 `pm:dispatched` / assignee / 任何 open 或 merged PR 引用的卡,一律不动。
82+
- ⛔ 带 `pm:dispatched`/assignee/open 或 merged PR 引用的卡不动;例外仅下三行:敲门在先。
83+
- 在飞活性复查:认领逾一天无自有产出(commit/PR/评论)⇒ 敲门一次,首行键 `Owned-24h re-check:`。
84+
- 敲门后 `RECLAIM_AFTER_HOURS = 12` 小时无答复 ⇒ `label-write.mjs --release` 一笔中继释放,贴不动。
85+
- 释放:`Release:` 评论(出处三件)+ `Resume-from:` 行 + `pm:dispatched`→`pm:queue` + 清 assignee。
8586
- 车道席可附证据/前提重验,⛔ 不定级不改标;skills 车道 finding 由该席自分诊,全仓轮跳过。
8687
- 域分批与 sweep 打包晋级五条照用;每批约定与积压告警见 `references/dispatch-runbook.md`。
8788
- `pm:retriage` 每 fire 先答异议评论所求,答后同笔摘标;须维护者答的进收件箱,标照摘。
@@ -116,5 +117,4 @@
116117
- 需裁事项攒批(≤5 项/批,每项一行带推荐),一句同意即全链执行。
117118
- 中间状态与限流/重试类运维细节只进座位贴和触发器,⛔ 不弹给维护者。
118119
- 汇报只在里程碑、真阻塞、被问到时。
119-
- 可逆且有推荐默认的事项按否决窗口:声明即执行,异议再回滚。
120120
- 人工地板项(发版、天花板、契约扩大)仍需明确字句,但攒批不逐项弹。

0 commit comments

Comments
 (0)