Repository navigation
Commit f62c170
fix(pm): the relay's adding ops refuse the PR-vs-issue state-label rule, and pm labels match case-insensitively (#31357)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} -->
_Requested by **Irene** · [project
thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xWi79fxRons6ki5r3iaB51c)_
Fixes #31253
Clause-②: no
Metadata-protocol: none
`dispatch-gates --tier` reads no path-derived model mandate for this
diff. Any mandate above the thread's would be waived by the maintainer's
standing order in this project (2026-11-03). The dispatching seat
(domain:skills#3) runs the contract review.
## 维护者速读
- 改了什么:fleet-write 的 `dispatch.mjs`
在发送前对三个加标签入口(`pr_create`、`issue_create`、`labels_add`)执行 PR/issue
状态标签规则:六个状态标签不上 PR,`pm:awaiting-merge` 不上 issue。规则与拒绝文案直接调用
`label-write.mjs` 的 `refuseKindLabels` /
`readTargetKind`,不复述。`label-write.mjs` 的 `refuseTwoStates`
与种类拒绝改为大小写不敏感(`labelKey`)。
- 为什么改:PR #31250 只在 label-write 这一道门执行规则;relay
自己的加标签入口绕过它,且两道门都按大小写精确比较,而 GitHub 标签名不分大小写,`Needs-User-Decision`
能绕过检查(#31253,#31250 契约复审 S1/N1)。
- 风险与回滚:只动两个脚本,无 skill 行、无 validate/execute 改动、无 ratchet
上调;拒绝发生在任何写之前,整个 actions 文件一笔都不发;移除标签从不拒绝;不发版(`skip-changeset`);回滚即
revert。
- 先看:`dispatch.mjs` 头部新节「The kind rule」、`kindRuleRows` /
`readLabelTargetKinds`、`main` 里两处调用;`label-write.mjs` 的 `labelKey`。
- 两分钟验证:`node scripts/pm/fleet-write/dispatch.mjs --self-test`(349 例,新电池
18 例);`node scripts/pm/label-write.mjs --self-test`(171 例)。
- 需要你做的:无。`check-governed-merges --test`(两个路径,+247/-14)读数为「NOT
governed」,即普通队列落地,不是工单预期的 Tier S;席位契约复审 PASS 后由席位落地。
## What changes
### `scripts/pm/fleet-write/dispatch.mjs`
- `kindRuleRows(actions)` (pure) walks the three adding ops
(`RETIRED_LANE_REWRITE_OPS`, the list the retired-lane rewrite already
uses; `labels_remove` is not in it). A create names its own kind
(`pr_create` is a pull request, `issue_create` an issue), so it is
judged with `refuseKindLabels({ kind, add: labels })` and no read. A
`labels_add` goes to the read list only when `refuseKindLabels({ kind:
null, add })` is non-null, which is exactly "it adds a label the rule
governs" — the predicate is asked, not restated.
- `readLabelTargetKinds(reads, …)` does the read (below) and judges it
with `refuseKindLabels`.
- `main`: the create refusal runs right after the lane rewrite, before
the session and the packer (usage 2, the rule quoted). The `labels_add`
read runs after the packer and before the dry-run exit, beside the
existing trailer-signed `issue_patch` read, sharing its token check and
proxy re-exec. Any refusal returns before `sendFleetWrite`, so nothing
in the whole actions file is sent.
- Header: a new section 「The kind rule」, and the exit-code lines for 2
and 3 name the new refusals.
### `scripts/pm/label-write.mjs`
- New export `labelKey(label)` (lower-cases a string). `refuseTwoStates`
and `refuseKindLabels` compare the vocabulary through it.
- A refusal whose add was spelled differently names it as written, e.g.
"GitHub matches label names case-insensitively: `Needs-User-Decision` is
`needs-user-decision`."
- The header's 「The kind rule」 section says both refusals are
case-insensitive and that dispatch.mjs asks the same `refuseKindLabels`.
Sample refusal (dry run, no network):
```
fleet-write/dispatch: REFUSED — action 1 (pr_create): `needs-user-decision` is a pm card STATE and its target is a pull request — .claude/skills/pm-dispatch/SKILL.md: its 状态模型 lists the six states as 「issue 上的信号」 and says 「`pm:awaiting-merge` 只挂 PR、不入六态」, and of `needs-user-decision` 「只挂决策卡(issue)、⛔ 永不挂 PR」 — so a PR waiting on the maintainer carries `pm:awaiting-merge` instead; removing a label is never refused. GitHub matches label names case-insensitively: `Needs-User-Decision` is `needs-user-decision`.
fleet-write/dispatch: nothing was sent.
```
## How `labels_add` learns the kind
Before this PR, dispatch.mjs read no `labels_add` target. It read a
target only for a trailer-signed `issue_patch` body
(`readPullRequestTargets`).
Now a `labels_add` whose labels include one of the seven governed labels
(any case) costs **one extra `GET /repos/{repo}/issues/{issue}`** per
distinct number. An ungoverned `labels_add` (e.g. `tooling`) and every
`labels_remove` read nothing. The answer is asked by label-write's own
`readTargetKind`, so `pull_request` and `html_url` must agree, as in
label-write's step ①. The read happens after the packer and before the
dry-run exit, so a dry run refuses what a send would. Outcomes:
| Read | Verdict | Exit |
|---|---|---|
| 200, kind on which the labels are legal | pass, said on stderr |
continues |
| 200, wrong kind | refused, rule quoted | 2 |
| 200, `pull_request` and `html_url` disagree | refused "rather than
guessed" (label-write's null-kind sentence) | 2 |
| any other status, or no answer | PREREQUISITE NOT MET, rule quoted | 3
|
| no `GITHUB_TOKEN` / `GH_TOKEN` | PREREQUISITE NOT MET before any
request | 3 |
Exit 2 is the code dispatch already uses for a refusal before transport.
Exit 3 for an unread target follows dispatch's own existing rule for the
`issue_patch` read. (label-write splits a failed card read into 3 or 5
by status; dispatch has no 5-before-send case, so it keeps its own
convention.)
## Self-tests
| Script | Before (`e7354255`) | After |
|---|---|---|
| `fleet-write/dispatch.mjs --self-test` | 331 cases / 28 batteries |
349 / 29 (new battery: 18 cases; battery floor 28 → 29) |
| `label-write.mjs --self-test` | 165 / 13 | 171 / 13 (ONE-OF battery 6
→ 8, kind battery 21 → 25) |
| `fleet-write/validate.mjs --self-test` | 340 / 17 | 340 / 17
(unchanged file) |
| `fleet-write/execute.mjs --self-test` | 284 / 24 | 284 / 24 (unchanged
file) |
Cases per door and direction:
- `pr_create` + state: pure and CLI (usage 2, zero reads, no payload).
- `issue_create` + `pm:awaiting-merge`: pure, and CLI in mixed case.
- `labels_add` + state on a target that reads as a PR: CLI, one GET,
usage 2.
- `labels_add` + `pm:awaiting-merge` on a target that reads as an issue:
CLI, usage 2.
- Mixed case: `Needs-User-Decision` on `pr_create` and on `labels_add`;
`PM:Awaiting-Merge` on `issue_create`. In label-write: a state in mixed
case on a PR, `PM:Awaiting-Merge` on an issue, and two states in mixed
case in `refuseTwoStates`.
- Removal never refused: `labels_remove` in dispatch (zero reads, exit
0), and a mixed-case `--remove` in label-write.
- Unreadable kind on `labels_add`: 503 gives exit 3 with the rule
quoted; disagreeing members are refused; no answer is unread; no token
gives exit 3 with zero requests.
- Whole file: a real (not dry) run with a legal `comment` beside a
refused `labels_add` sends zero requests.
- Controls: each label on its own kind passes; mixed case does not widen
the rule.
## Ablations
Run with `scripts/ablation-replace.mjs`. Each one plants the old
behaviour, runs the self-test, and restores; the tool proves blob ==
HEAD and an empty `git diff HEAD` after each.
| # | File · predicate planted back | Self-test | Red cases |
|---|---|---|---|
| A1 | label-write · `refuseTwoStates` exact match (`(target ??
[]).includes(l)`) | 1 of 171 red | ⭐ a state spelled in another case IS
that state … so two are still REFUSED |
| A2 | label-write · kind refusal exact match (`new Set(adds)`) | 2 of
171 red | ⭐ a state in MIXED CASE on a PULL REQUEST is refused like the
lower-case name; ⭐ `pm:awaiting-merge` in MIXED CASE on an ISSUE is
refused the same way |
| A3 | dispatch · creates know no kind (`const kind = null`) | 6 of 349
red | ⭐ a pm state in pr_create's labels is refused with no read; ⭐
pm:awaiting-merge in issue_create's labels is refused with no read; ⭐
MIXED CASE at each create; control (creates fall to the read list); ⭐
CLI: pr_create with a state; ⭐ CLI: issue_create with pm:awaiting-merge
in MIXED CASE |
| A4 | dispatch · no `labels_add` read (`labelReads = []`) | 7 of 349
red | ⭐ CLI labels_add state on a PR; ⭐ `Needs-User-Decision` on a PR; ⭐
pm:awaiting-merge on an issue; ⭐ UNREADABLE kind; a state on an issue
packs (one GET); ⭐ a refusal sends NOTHING for the whole file; …with no
token exit 3 |
| A5 | dispatch · an unread kind passes (`verdict: 'pass'`) | 2 of 349
red | ⛔ a 503 and no answer are UNREAD; ⭐ CLI: an UNREADABLE kind on
labels_add is PREREQUISITE NOT MET (3) |
Blobs: label-write `ec6ce829a64f` (A1 → `fa3a509bae1c`, A2 →
`b6a6e1353123`); dispatch `0654a387bd57` (A3 → `707c50666b29`, A4 →
`60dd7d002198`, A5 → `93812d8188ee`). Each was restored to its HEAD
blob.
## Skill lines
None touched. `.claude/skills/pm-dispatch/**` is unchanged, so
`check:pm-skill-ratchet` is not owed.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 40 commands (exit 0). All 40 exit 0
on `79f03c1a` (base `e7354255`; `origin/main` has since moved to
`d0512c2f`, touching neither file). Run after `pnpm install
--frozen-lockfile --ignore-scripts` and `git fetch --deepen=300 origin
main`. `pnpm check:pm-dispatch-gates` was cut off once by the session's
background time limit, not by a failure; its re-run exits 0 (fast tier
293 s).
```
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0
node scripts/check-self-test-wired.mjs :: exit 0
node scripts/check-self-test-wired.mjs --self-test :: exit 0
node scripts/check-self-test-workflow-commands.mjs :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0
node scripts/check-whole-set-label-write.mjs :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:pm-close-cards :: exit 0
pnpm check:pm-dispatch-gates :: exit 0
pnpm check:pm-fleet-write-dispatch :: exit 0
pnpm check:pm-fleet-write-execute :: exit 0
pnpm check:pm-fleet-write-validate :: exit 0
pnpm check:pm-issue-create :: exit 0
pnpm check:pm-issue-transfer :: exit 0
pnpm check:pm-label-write :: exit 0
pnpm check:pm-post-stamped :: exit 0
pnpm check:pm-record-recognisers :: exit 0
pnpm check:pm-write-pace :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:pnpm-flag-spellings :: exit 0
pnpm check:ratchet-remedy-authority :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:scripts-symbol-anchors :: exit 0
pnpm check:watch-hint-literal :: exit 0
```
Also, outside the derived list:
```
node scripts/pm/fleet-write/dispatch.mjs --self-test :: exit 0
node scripts/pm/fleet-write/validate.mjs --self-test :: exit 0
node scripts/pm/fleet-write/execute.mjs --self-test :: exit 0
node scripts/pm/label-write.mjs --self-test :: exit 0
node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD :: exit 0
node scripts/pm/check-governed-merges.mjs --test scripts/pm/label-write.mjs scripts/pm/fleet-write/dispatch.mjs --additions 247 --deletions 14 :: exit 0 (NOT governed)
```
`check:pm-label-write` and `check:pm-fleet-write-dispatch` are in the
derived list above. Changed lines: 261 (+247 / -14), 2 files. No
changeset: `scripts/pm/**` publishes nothing (`skip-changeset`).
## open_questions
1. **Tier.** The order expects Tier S for `scripts/pm/**`.
`check-governed-merges --test` on the two paths reads "NOT governed —
ordinary queue landing" (0 of 13 surfaces hit). The PR body reports the
measured reading; the seat decides how it lands.
2. **Other doors.** `issue-create.mjs` and `close-cards.mjs` send
through `sendFleetWrite` directly, not through `dispatch.mjs`'s `main`,
so this check does not cover them. They were outside the file surface.
`issue-create.mjs` can put labels on a new issue, but only
`pm:awaiting-merge` is illegal on an issue. Say if a follow-up card is
wanted.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01J8VVAo5HSpfRwLPWCVpUjL
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 38fc529 commit f62c170
2 files changed
Lines changed: 247 additions & 14 deletions
0 commit comments