Skip to content

Commit f62c170

Browse files
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

File tree

0 commit comments

Comments
 (0)