feat(pm): label-write.mjs — the four-step label write as one spelling, and rest-channel.md names it instead of the denied MCP fallback (#18085) - #18099
Conversation
SKILL.md requires every label write to be four steps (取现集 → 只增删目标 →
写合并集 → 回读 diff 对 union(现集, 增删)) and rest-channel.md named the
channels, but there was no single spelling: every seat retyped the steps, and
the retyping is where a step gets dropped.
Additive `POST .../labels` and directed `DELETE .../labels/{name}` first —
neither can strip a label a concurrent seat wrote inside the round trip. A
platform refusal falls back ONCE to `PATCH /issues/{n}` with the full target
set AND the current assignees echoed, because a whole-set card write clears
every field it does not pass. Step ④ reads back and diffs against the target:
a target label the read-back lacks was stripped underneath, so it is re-added
once and REPORTED.
⛔ Never `PUT /issues/{n}/labels` (check-whole-set-label-write.mjs bans it over
this subtree) and ⛔ never MCP `issue_write` (lock 1 denies it). A 403 whose
`x-ratelimit-remaining` is 0 is exhaustion, not a shut channel, so it stops at
exit 3 rather than reaching the fallback — rate-limit refusal binds the
identity, and continuing the same write through another channel is the same
act as retrying.
`--self-test` drives all four steps offline against a fake board whose
whole-set PATCH is modelled destructively, so the assignee echo is actually
under test.
Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8
Co-authored-by: Claude <noreply@anthropic.com>
…enied MCP fallback `references/rest-channel.md:39` sent a seat whose REST channel is shut to MCP `issue_write`. Lock 1 added that tool to `permissions.deny`, so the documented recovery path terminated in a denial and a gate-closed seat had no label channel named anywhere. Repaid equal-line (82 stays 82, each line inside the ratchet's 120-byte budget): the additive row keeps the two non-destructive verbs and now says additive-first outright; the seat instruction names `scripts/pm/label-write.mjs` as the ONE spelling; and the last line states plainly that a seat whose session classifier refuses every mutation has no channel and hands the write to a seat that has one — ⛔ never MCP `issue_write`. Wires `check:pm-label-write` the way every sibling `scripts/pm/` tool with a `--self-test` is wired: a root `package.json` alias plus an unconditional `lint.yml` step, which is what puts it inside `check:self-test-wired`'s population at all. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① derived judgments (seat-measured on the fetched head, ⛔ not taken from the report):
② semver: unchanged — nothing published; ③ boundary flags: (a) the Implemented-by: Verdict: PASS — ready + auto-merge by this seat. Generated by Claude Code |
Fixes #18085
references/rest-channel.md:39routed a seat whose REST channel is shut to MCPissue_write. Lock 1 (7ef05f997, #18072) added that tool topermissions.deny, so the documented recovery path terminated in a denial — and because it was the only spelling of the fallback, a gate-closed seat had no label channel named anywhere on the board.Two halves, and only the second is a documentation fix:
scripts/pm/label-write.mjs(new, non-governed PM tooling) — the four steps SKILL.md mandates (取现集 → 只增删目标 → 写合并集 → 回读 diff 对 union(现集, 增删)) as a program rather than a paragraph. Every invocation performs all four and prints each with the UTC stamp that step was taken at. AdditivePOST .../labelsand directedDELETE .../labels/{name}first; a platform refusal falls back once toPATCH /issues/{n}with the full target set and the current assignees echoed; step ④ reads back and diffs against the target either way.references/rest-channel.md:37–39— repaid equal-line (82 stays 82) to name the channel that exists.package.jsonand.github/workflows/lint.ymlwirecheck:pm-label-writethe way every siblingscripts/pm/tool with a--self-testis wired — an alias plus an unconditional lint step, which is what puts the script insidecheck:self-test-wired's population at all (that gate's population is "a script a workflow names", so apackage.jsonalias on its own would have left the self-test outside it and silently unrun).Premise readings
All four checked against
origin/maind438b3a9before the first edit, on 2026-09-14.rest-channel.md:39sends a gate-closed seat to MCPissue_write- 门关席位无此端点 ⇒ 回退 = MCP 读现值、并集、整组写、读回;读回是它安全的全部理由。File was 82 lines..claude/settings.jsondeniesmcp__github__issue_writemcp__github__issue_writeis the first. 47 allow entries.scripts/pm/writes labels or assigneesscripts/pm/, and with one correction worth recording:scripts/pr-labels.mjs(repo root, #10703) already writes PR labels additively frompr-automation.yml, andscripts/check-whole-set-label-write.mjs(#10778) already bansPUT .../labelsover.github/workflows/**,.github/actions/**andscripts/**. Neither is a seat-facing card tool and neither touches assignees, so the gap the card names is real — but the new script is a sibling of an existing discipline, not a first.check-label-desc-cap.mjsandensure-pm-labels.share description/existence tools as stated.sweep-closed-cards.mjs --writeis wired inhalf-state-patrol.ymlas the botNo premise was falsified, so the PR stands.
The live proof — one idempotent no-op on this card
Run against
objectstack-ai/objectstack#18085itself with--add domain:skills, a label it already carries. ⛔ Nothing on the card changed: step ③ made zero write calls, and the read-back is the card as it was.Exit 0. The
--dry-runof the same command at 01:33:40Z printed the fallback body it would send if every additive verb were refused, which is where the assignee echo is visible:{"labels":["priority:p1","pm:dispatched","domain:skills"],"assignees":["os-project-manager"]}.A second live reading — the additive POST leg, on this PR. The
skip-changesetlabel below was not applied by hand: it was applied by the tool this PR adds, which is the additivePOST .../labelsleg the no-op above could not exercise.Exit 0. Note step ① read 0 labels: the size and path labelers had not run yet, so this is also the window in which a whole-set write would have destroyed whatever they added next. The additive POST cannot, which is the entire point.
The classifier reading, stated exactly as measured. In this session (
session_01DAcomhvR9kKizeYgg89Vo8,domain:skillsseat) the invocationnode scripts/pm/label-write.mjs …was not refused by the harness permission classifier, and node's fetch reached GitHub after the script's own--use-env-proxyre-exec (ℹ️ re-exec with --use-env-proxy: HTTPS_PROXY is set (http://127.0.0.1:34703) and node's fetch does not read it.). ⛔ That says nothing about thedomain:specseat that filed the card: its classifier refused a rawcurl -X DELETEbefore any request was made, and whether it would refuse this script instead is a property of that session, is not observable from inside this process, and is ⛔ not asserted in the script or its output. The script's docblock says so in those terms.Permission rule — NOT in this diff, and why
The dispatch was amended mid-task to add two rules to
.claude/settings.jsonpermissions.allow:Bash(node scripts/pm/label-write.mjs:*)andBash(node scripts/pm/post-stamped.mjs:*). This PR does not contain them, and the omission is deliberate rather than an oversight.permissions.allowwidening is exactly that category. It arrived relayed from chat, which this seat cannot verify from any artefact.5657700923) states 「⛔.claude/settings.jsonuntouched(锁 1 停留;维护者关于放宽它的问题在 chat 中开放,未裁决)」. An unruled question is not a ruling.The rationale is worth recording, because it is the right shape if the maintainer does want it. A prefix rule naming the raw verbs does not work and is not merely less tidy:
Bash(curl -X PATCH:*)is a prefix rule, and the spelling a seat actually writes iscurl -sS -H "Authorization: Bearer $GITHUB_TOKEN" -X PATCH …— the verb is not at the prefix, so the rule misses it. Every seat then discovers its own passing spelling, which is the opposite of one channel;state: closedand a rewrittenbodyincluded, with none of the four-step discipline attached. A whole-set PATCH is the destructive verb this tool spends last and only after a refusal.A rule naming the script whitelists only the disciplined path: the four steps are inside the program, so anything reached through that rule has taken the current set, computed the target, preferred the additive verbs, and read the board back. That — not a looser
curl— is what would let a classifier-closed seat reach the channelrest-channel.mdnow names.⛔ Consequently
rest-channel.md's repaid line does not name an allow rule as the reason the script is reachable. No such rule exists in this tree, and areferences/file asserting a channel fact that is not true of the tree is the precise defect this card was filed for.What the repaid lines say
Equal-line, and the ratchet decided the shape twice: 82 lines is the ceiling with zero headroom, and it also enforces a 120-byte per-line budget that the first draft broke on all three lines (144B / 310B / 202B). The lines above are 111B / 119B / 114B.
node scripts/pm/check-skill-line-ratchet.mjsexits 0.Design notes a reviewer may want
PUT .../labelsis never issued, in any spelling.check-whole-set-label-write.mjsbans it overscripts/**and this file is in that root; the gate passes. ThePATCH /issues/{n}fallback is a different endpoint carrying the same hazard, which is why it is reached only after a refusal, echoes assignees, and is always read back.x-ratelimit-remaining: 0does not reach the fallback. It exits 3. Rate-limit refusal binds the identity, every seat on this board shares that identity, andrest-channel.md's own rule is 「换通道续写与重试同罪」 — so a fallback there would be the banned act, spelled as a recovery. Pinned in the self-test.check-half-states.mjs, never restated:PM_EXCLUSIVE_STATE_LABELS,PM_STATE_CLAIM,PM_RESIDUE_LABELS. A target carrying two ONE-OF states is refused before any write — H29's finding asked of the target, at the one moment the pair is still one keystroke from correct — with--allow-two-statesas the declared exception that still prints what it let through.concurrentAddsis reported and is ⛔ not a mismatch. Preserving another seat's additive label is the entire reason POST/DELETE come first; treating it as a failure would push a seat toward the whole-set write.Tests
pnpm check:pm-label-write—✓ label-write self-test: 64 cases pass across 8 batteries(the union/difference arithmetic, the ONE-OF refusal, the idempotent DELETE, the stripped-underneath re-add-and-report, the mismatch exit, the rate-limit non-fallback, and the assignee echo on the whole-set PATCH). The fake board it drives modelsPATCH /issues/{n}destructively on purpose — a fake that merged instead would pass every assertion while the echo went untested. Batteries carry a declared floor and a verdict handshake, so "every case held" and "the cases never ran" cannot print the same line.CLI exits verified directly: usage 2,
--help0, missing token 3.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 71 families from the four changed paths. All 71 were run with per-command redirect-then-capture, twice — once before theorigin/mainmerge and once after, at0e803e026— and the recorded exit codes were fed back:68 green at
0e803e026. The 3 NOT MEASURED arecheck:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt— every one exits 3, PREREQUISITE NOT MET, refusing because it reads a full workspace build that is not on disk. ⛔ Exit 3 is neither a pass nor a finding, and none of the three is read as either here.This is a declared narrowing, and the narrowing is proven, not asserted: the three are matched by the
Build Corejob'spackage.jsonpath filter, and nothing in this diff can move them —git diff --name-only origin/main...HEAD -- packages/returns 0 files;package.jsondelta is one line added to thescriptsmap, touching noexports, nofiles, no dependency;main's.CI's
Build Coreruns them against a real build, which is the right place for them. Three others (check:doc-formula-expressions,check:dts-closure,check:sourcemap-no-sources-content) also exited 3 at first and were measured rather than declared: they needed only@objectstack/formula+@objectstack/lintbuilt (pnpm exec turbo run build --concurrency=2 --filter=@objectstack/formula --filter=@objectstack/lint, throughscripts/pm/os-verify-lock.sh,VERDICT command-exit 0), and all three exit 0 after it.origin/mainmoved toa26a114d7after the readings above; re-deriving against it returns the identical 71-family list, so the reading stands and the PR's own CI covers the merge.check:nul-bytesis green, and a directgrep -naPfor control bytes over the four changed files finds none.维护者速读(草稿)
改了什么。 新增一个 PM 工具
scripts/pm/label-write.mjs,把「标签写恒四步」从一段规矩变成一个可执行程序;把references/rest-channel.md里那三行改成指向它。另加两处接线(package.json别名、lint.yml自检步骤)。共 4 个文件,不动任何包源码。为什么改。 锁 1 把
mcp__github__issue_write加进了 deny,而那个工具正是rest-channel.md给「通道关闭的席位」写的唯一退路。锁是对的,不该回滚;错的是没人把那句退路偿上。结果是一个席位无法认领、因此无法派发任何卡,只能在卡上写段散文。风险与代价(含回滚)。 风险很低:新脚本没有任何调用方,不进 CI 的活路径(CI 只跑它的离线
--self-test),不碰任何发布物。回滚就是 revert 这一个 PR,无残留。唯一值得注意的代价:rest-channel.md已到顶(82/82 行),以后再加行必须先删行。席位意见。
你要做的。 两件。① 这是受管面(
.claude/**),只能你亲自合,或给一个授权的 APPROVED review 让队列落地;席位不翻 ready、不入队、不挂 auto-merge。② 上面「Permission rule」一节里的问题需要你一句话:要不要把Bash(node scripts/pm/label-write.mjs:*)和Bash(node scripts/pm/post-stamped.mjs:*)加进permissions.allow。本席位没有加,因为改权限配置需要你本人的话,而不是另一个 agent 转述的话。加了以后,分类器关着的席位才真的能用上这个脚本;不加也不阻碍本 PR 落地,只是那半个缺口留着。Acceptance notes
Observed, ⛔ not filed, ⛔ not fixed here:
issue_writereferences are still standing, by dispatch.pm-dispatch/SKILL.md:378and.claude/skills/checklist-test/SKILL.md:123both still name the denied tool. They are the rules layer rather than the 事实 layer, and the dispatching seat holds the follow-up governed card for them. This PR deliberately does not widen into them — one governed file is already one too many to mix.curl. Nobody has run this script from a session in that state, and the script cannot measure it about itself. That is the open half of the card, and the permission-rule question above is what would settle it.references/rest-channel.mdis at its ceiling with zero headroom (82/82 lines, 120 bytes per line). Any future line there has to buy its space by deleting one. Worth knowing before the next channel fact needs recording; not a defect.scripts/pr-labels.mjsandscripts/pm/label-write.mjsare now two additive label writers with no shared code. That is correct today — one is a CI job writing PR size/path labels from a workflow, the other is a seat writing card state over the proxy, and they share neither transport nor vocabulary — but it is the kind of pair that drifts. No card: nothing is wrong yet, and a premature merge would couple a CI script to the PM vocabulary module.Clause-②: no
Generated by Claude Code