Repository navigation
Commit d87dff6
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
- skills/pm-dispatch
- references
- scripts/pm
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| |||
111 | 112 | | |
112 | 113 | | |
113 | 114 | | |
114 | | - | |
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
146 | 146 | | |
147 | 147 | | |
148 | 148 | | |
149 | | - | |
150 | 149 | | |
151 | 150 | | |
152 | 151 | | |
| |||
316 | 315 | | |
317 | 316 | | |
318 | 317 | | |
| 318 | + | |
319 | 319 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
| 38 | + | |
38 | 39 | | |
39 | 40 | | |
40 | 41 | | |
| |||
84 | 85 | | |
85 | 86 | | |
86 | 87 | | |
87 | | - | |
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | 44 | | |
46 | 45 | | |
47 | 46 | | |
48 | 47 | | |
49 | 48 | | |
50 | | - | |
51 | 49 | | |
52 | 50 | | |
53 | 51 | | |
| |||
81 | 79 | | |
82 | 80 | | |
83 | 81 | | |
84 | | - | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
85 | 86 | | |
86 | 87 | | |
87 | 88 | | |
| |||
116 | 117 | | |
117 | 118 | | |
118 | 119 | | |
119 | | - | |
120 | 120 | | |
0 commit comments