fix(hooks): watch the ccr auto-merge route in guard-governed-enqueue - #18447
os-elon-musk wants to merge 1 commit into
Conversation
The guard's declared job is to refuse an enqueue-class call on a governed PR that carries no authorized approval. It watched three spellings and a compliant seat could reach none of them: both MCP tool names sit in `permissions.deny`, there is no `gh` in the agent container, and the REST arm required a literal `/merge` segment that the protocol's only auto-merge route never carries. Adds the `PUT .../pulls/<n>/ccr/auto_merge` route to the same URL reader, feeding the SAME imported predicate chain the `/merge` arm feeds — no second mechanism, no arm weakened, no arm removed. The MCP arm stays as defence in depth. That route, unlike `/merge`, carries a non-enqueue verb on the same path: `DELETE` is the disarm. So the method is read there, and only there — the disarm and the read verbs are allowed, everything else on that path is the mount. `ccr/ready_for_review` is not an enqueue and is not an arm. Self-test: 66 -> 79 cases, covering block / allow / approved on the new spelling, the disarm, the ready flip, the merge arm unchanged under both a named and an unnamed method, and two wired rows (the Bash matcher door, the route named in code) so the arm cannot be dropped silently. Claude-Session: https://claude.ai/code/session_01HPfcjvF23QBoBj7P47DDxs Co-authored-by: Claude <noreply@anthropic.com>
维护者速读(终稿)skills 席, 改了什么 为什么改 风险与代价(含回滚) 席位意见 你要做的 Generated by Claude Code |
|
Held — this is today's fourth edit of Director seat, summon #24, ⇒ The maintainer's approval is asked to wait until objectstack#18468 (the gate's per-path prescription) is ruled; this PR then lands in the same window as that change — one re-seat instead of two. Recorded as a director sequencing call under 「技术任务间顺序与依赖」; the four-piece set on this PR is untouched, and it stays draft. Generated by Claude Code |
|
Hold lifted — ruling D on objectstack#18468 (maintainer 「D:整个去掉「重开」处方」, 2026-09-16T14:13Z) removes the re-seat prescription; the rule PR is dispatched · 2026-09-16T14:16Z Director seat, summon #24, Generated by Claude Code |
|
Ruling: batch #143 item 4 · letter B (remove the client-side enqueue guard; this PR is withdrawn) · maintainer 「b」 2026-09-16T14:21Z Director seat, summon #24, What was read before recommending B
Execution
Generated by Claude Code |
…ILL.md:92 become a seat-post reading (objectstack-ai#18476) Fixes objectstack-ai#18468 Maintainer ruling D on the card, verbatim: 「D:整个去掉「重开」处方」 ## What changed - `.claude/skills/pm-dispatch/SKILL.md:92` re-keyed in place: 812/812 lines, the edited line is 119 bytes, `:91` is byte-identical to base. The prescription 「否 ⇒ 收班、换新会话再派,⛔ 不推进共享检出」 is gone; the line now reads 「否 ⇒ 记座位贴,自然收班时换会话,⛔ 不中断批次;读数走 `scripts/pm/check-harness-current.mjs`。」 — the reading stays a reading and the pointer stays. - No twin in `references/core-rules.md`. `grep -n -e check-harness-current -e 收班 -e 换新会话 -e 不推进共享检出 -e harness .claude/skills/pm-dispatch/references/core-rules.md` hits only :19, :20 and :76 — the shift-handover readings, none of them a twin of :92. Repo-wide, the prescription strings lived only in SKILL.md:92 and this script (its header, verdict and self-test). - `scripts/pm/check-harness-current.mjs`: - (a) STALE verdict re-keyed from prescription to report: `…: note it on the seat post and pick it up at the seat's next natural shift boundary; ⛔ never interrupt a batch for it`. Exit codes 0/1/2 unchanged; the per-path `-- STALE` / `-- UNDECIDED` line-terminal markers and their positions unchanged (the file's own provenance contract); the CURRENT and UNDECIDED strings byte-identical. - (b) Header docblock: the load-moment claim is narrowed to what was measured, `.claude/settings.json` (the deny-roster measurement); the load moment of `.claude/hooks/*` and `.claude/agents/*.md` is stated as UNMEASURED and the tool asserts neither; the sentence naming re-seating as the remedy is gone. - (c) `--self-test`: the byte-for-byte STALE summary expectation is re-keyed to the new wording; one case is added pinning that the summary names the seat post and the shift boundary and names neither a re-seat nor advancing the shared checkout; `SELF_TEST_CASE_FLOOR` rises 14 → 15 so the added case cannot silently stop running. The ✗-line regex is unchanged because the ✗ line is unchanged. No assertion deleted or weakened. - Untouched, by ruling: the `settings.json` deny roster, `.claude/hooks/*`, `.claude/agents/*.md`, every other SKILL.md line. ## Readings Self-test, three exit codes (the PM's mechanism hypothesis, confirmed): - baseline at `8fe5cb8e` (origin/main at branch time): exit 0, 14 cases - after (a)+(b), before (c): exit 1 — exactly one failure, `and its summary line is the STALE one, byte for byte` - after (c): exit 0, 15 cases Ablation, one-shot from the committed state (taken at `c67c0df1`; the two files are byte-identical at the rebased head `6f008921`): the verdict line was mutated back to the old prescription (on-disk grep: injected clause 1 hit; the new wording left in the self-test expectation only, 1 hit) → `--self-test` exit 1 with exactly the two STALE pins failing (`byte for byte` and `a report, not a prescription`); restore via `git checkout HEAD -- PATH` → `git hash-object` equals the HEAD blob `616f53f6…`, `git diff HEAD` empty, `git status --porcelain` empty; control run on the restored tree exit 0, 15/15. Trap-guarded, absolute path. Gates: `node scripts/pm/dispatch-gates.mjs --commands` with no paths (changeset from merge-base) derives 40 families; all 40 were run with each exit code redirected to a file before any pipe, and `--ran` with per-command exit codes reconciles `40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN` (a DERIVED zero). One first-run reading was not a measurement: `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exited 3 `PREREQUISITE NOT MET` (unbuilt `@objectstack/formula` and `@objectstack/lint`); those two were built under `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0, held 194s) and the gate rerun: exit 0. `pnpm check:skill-frame-freshness` was also run, exit 0 (not derived; the PM asked for it). The 40-run was taken at `c67c0df1`; `origin/main` then moved by one comment-only commit (`0fe38a92`, `scripts/check-bash32-floor.mjs`, a derived family), so the branch was rebased onto it (no PR existed; the branch was unshared), the derivation re-run at `6f008921` (identical 40 families, no stale-tree warning) and these families rerun at `6f008921`, all exit 0: `check:bash32-floor`, `check:pm-skill-ratchet` (SKILL.md 812 lines, ceiling 812, headroom 0), `check:pm-skill-id-lint`, `check:skill-frame-sync`, `check:nul-bytes`, and the self-test. Changeset: `skip-changeset` — `.claude/**` and `scripts/pm/**` publish nothing; no released package's `files[]` content moves. Writes: REST only — `git push` ×2 (initial, then the lease-pinned rebase push), `POST /pulls` ×1, `POST /issues/N/labels` ×1 (`skip-changeset`), `POST objectstack-ai/issues/18468/comments` ×1 (the os-dev-report). MCP GitHub write tools: 0. ## Acceptance notes - noted, not filed: the self-test keeps one total floor (`SELF_TEST_CASE_FLOOR`) rather than the per-battery roster AGENTS.md's self-test section prescribes; the floor was raised to cover the added case, and the roster shape is not this card's surface. 承接者:无。 - `needs:contract-review` is the seat's to hang or strip; this PR neither hangs nor strips it. ## 维护者速读(草稿) **改了什么**:把「共享检出上的 harness 文件落后于 origin/main」这条读数,从「必须收班、换新会话再派」的处方,改成「记在座位贴、到自然收班时再换会话、不打断当前批次」的报告。落在三处:SKILL.md 第 92 行原地改(812 行不变),`check-harness-current` 脚本的判决文案与头部说明,以及它的自测期望。退出码不变,调用它的席位无感。 **为什么改**:裁决 D,原文「D:整个去掉「重开」处方」。今天同一个钩子返工三次,每次都让在岗的七个 PM 会话同时重开;而重开所防的缺口(旧 deny 清单少三个 MCP 写工具)整班一次都没被踩到,并且 ACCEPT 核验仍旧拒收任何点名 MCP 写工具的报告。 **风险与代价(含回滚)**:接受的残余是维护者原话:容器早于 deny 清单变更的席位,到下次自然收班前仍持有那几个被拒的工具;若席位违规,可用绑定账号改一次 PR 状态——可回退,受管 PR 仍由队列规则集与你的点击把守。行数代价为零(812/812)。回滚 = revert 这两个 commit,脚本自测随之回到旧文案,无数据迁移、无配置变更。 **席位意见**: **你要做的**:本 PR 触受管面(`.claude/**`),保持 draft,等你一句批准后由归属席位落地;随后 PR objectstack-ai#18447 按裁决 D 在同一批准窗口跟进(先规则、后钩子),一次收班顶两次。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Wj1HUjzyeiBQ8atRf1ZhaL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…roup queue guard is the line (objectstack-ai#18478) Fixes objectstack-ai#18470 Executes maintainer ruling **B** on PR objectstack-ai#18447 — reply verbatim 「b」, then 「这个卡你也直接派发」: remove the client-side enqueue guard hook entirely. **The server-side line stays**: `scripts/pm/check-governed-queue-guard.mjs` on the `merge_group` build (reads reviews live and refuses an unapproved governed PR) plus the `.claude/settings.json` `deny` roster for the MCP enqueue class, plus the approver's click. ## What changed | file | change | |---|---| | `.claude/hooks/guard-governed-enqueue.sh` | deleted (624 lines) | | `.claude/hooks/guard-governed-enqueue.selftest.sh` | deleted (633 lines) | | `.claude/settings.json` | the standalone MCP-enqueue matcher entry (`enable_pr_auto_merge` / `merge_pull_request`) removed — this hook was its only command; the one `guard-governed-enqueue.sh` command block removed from the `Bash` matcher. 13 lines, all removals. `permissions` (incl. the 17-entry `deny`) is byte-identical to `origin/main`; the Edit matcher and the other four Bash hooks are unchanged and in their original order. | | `scripts/pm/check-governed-merges.mjs` | two comments re-keyed: the `--test` stderr note no longer names the hook as the stdout consumer (stdout stays a machine-read `--json` surface for any caller). No code change. | | `scripts/pm/check-governed-queue-guard.mjs` | the `authorizedApprovalVerdict` docblock re-keyed: the importer note now records that the seat-side hook was removed by ruling and that the queue leg is this function's only consumer; the rename fence stays as "grep for importers in the same diff". Refusal logic untouched — 238 self-test cases pass unchanged. | | `scripts/pm/check-settings-deny-roster.mjs` | two header paragraphs re-keyed: the deny roster is now the only seat-side refusal of the MCP enqueue class; the hook is no longer described as defence in depth. `CONTENT_WRITE_TOOLS` untouched — 36 self-test cases pass, 17 declared = 17 enforced. | Not touched, by ruling: the `deny` roster, CODEOWNERS, `check-governed-queue-guard.mjs`'s refusal logic; and objectstack-ai#18468's surface (`.claude/skills/pm-dispatch/SKILL.md:92`, `scripts/pm/check-harness-current.mjs`). ## Premise check against origin/main (BASE `8fe5cb8e`) `git grep -l guard-governed-enqueue origin/main` → exactly six files: the two hook files, `.claude/settings.json`, `check-governed-merges.mjs`, `check-governed-queue-guard.mjs`, `check-settings-deny-roster.mjs`. Neither `scripts/pm/dispatch-gates.mjs` nor `package.json` names the hook or its self-test: no family runs it, and `lint.yml`'s hook self-test step discovers `*.selftest.sh` with `find` at run time, so the deletion needs no edit there (it now discovers 5). Prose grep (deliverable step 4), all zero lines describing the hook as a live control: `grep -rn -iE 'enqueue[ -]guard|governed[ -]enqueue|guard.governed.enqueue|GOVERNED_ENQUEUE|enqueue hook|seat-side hook|PreToolUse|client-side|客户端|钩子' .claude/skills .claude/agents AGENTS.md CLAUDE.md docs/adr`. The PreToolUse mentions in `AGENTS.md` (lines 221, 323) and `.claude/agents/os-dev.md` (line 27) are the worktree and stash guards; `platform-readings.md:395` names the CI context `Governed Surface Queue Guard` (server-side, stays); `scripts/pm/check-half-states.mjs` says "governed enqueue" three times about the queue guard's refusal, not the hook. So no SKILL.md, AGENTS.md or ADR line changes in this PR. ## Gates green by design — no exemption for a missing file, no weakened assertion Acceptance: `git ls-files .claude/hooks/guard-governed-enqueue*` → 0; `grep -rn guard-governed-enqueue .claude scripts AGENTS.md CLAUDE.md docs .github package.json` → 0 hits. `node scripts/pm/dispatch-gates.mjs --commands` (no paths — derived from the merge base) → 40 commands at `9784cd23`; identical list on the merged head `afd9fcb2`. Every command run with its exit code captured before any pipe (`cmd > log 2>&1; ec=$?`): - 39 exit 0 on the first pass. `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exited 3 = PREREQUISITE NOT MET (`@objectstack/formula` and `@objectstack/lint` unbuilt — nothing measured, not a finding); after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under `os-verify-lock.sh` (VERDICT command-exit 0, turbo cache hit) it re-ran at exit 0. - `dispatch-gates.mjs --ran` on `afd9fcb2`: 40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN — "a DERIVED zero — all 40 recorded an exit code and none of them is 3". - Named gates: `check:pm-settings-deny-roster` — 36 self-test cases, 17 content-write tools declared = enforced; `check:pm-governed-merges` — 328 assertions; `check-governed-queue-guard.mjs --self-test` — 238 cases; `check:pm-dispatch-gates`, `check:nul-bytes`, `check-harness-current.mjs --self-test`, `check:cross-package-test-inputs` — exit 0; `check:bash32-floor` — exit 0, re-run on the merged head after `main` moved that script (31 tracked shell files, none naming a bash 4+ construct). - Hook self-tests (what `lint.yml` discovers): 5 discovered, all pass — guard-main-checkout-bash 130, guard-main-checkout 120, guard-process-kill 69, guard-shared-stash 53, guard-tree-enum 38. - After merging `origin/main` (`85c6d76e`: two docs commits — `check-bash32-floor.mjs` +41, two SKILL.md lines): `check:skill-frame-sync`, `check-skills-token-ratchet.mjs` (and its `--self-test`), `check:pm-dispatch-gates` re-run at exit 0 on `afd9fcb2`. - Not run: `check:pm-skill-ratchet` / `check:pm-skill-id-lint` — no `.claude/skills/**` line changes in this diff (the SKILL.md ratchet stays 812/812). Lint, proven narrowing at `afd9fcb2`: ① population read from `eslint.config.mjs` — the base block lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`; `.claude/settings.json` reports "File ignored because no matching configuration was supplied", and the two deleted `.sh` files match no glob, so this diff's lint population is exactly the three `.mjs` files; ② `--format json` count: 3 files, 0 errors, 0 warnings; ③ invariance: the config "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file" (its own line 327), so no untouched file's verdict can move with this diff. The repo-wide `pnpm lint` is CI's run. ## Changeset `skip-changeset` — route 2 in `check-empty-changeset.mjs`: a PR touching only `.claude/` and `scripts/` releases nothing, and no package `files[]` ships either surface. Label applied by REST `POST /issues/NUMBER/labels` and read back. ## Acceptance notes - noted, not filed: `scripts/pm/check-half-states.mjs` (lines 9377, 9557, 29094) says "governed enqueue" about the queue guard's refusal — still true after this PR, no edit. 承接者:无. - Control-character scan on the four edited files: clean. Commit `9784cd23` carries the model-free trailer pair; `afd9fcb2` is the merge of `origin/main`. - Governed surface (`.claude/**`): this PR stays DRAFT; the dev seat flips nothing, requests nothing and arms nothing. `needs:contract-review` is the seat's. ## 维护者速读(草稿) **改了什么** 删掉客户端入队守卫钩子 `guard-governed-enqueue.sh` 及其自检脚本,`.claude/settings.json` 里的两处注册一并去掉;三个 PM 门禁脚本里把它描述为"在场控制"的注释改写成现状。`deny` 名单、CODEOWNERS、`check-governed-queue-guard.mjs` 的拒绝逻辑一字未动。 **为什么改** 执行 PR objectstack-ai#18447 上的裁决 B(维护者原话「b」,随后「这个卡你也直接派发」)。真正的硬线是 `check-governed-queue-guard.mjs` 在 `merge_group` 构建上的拒绝,加上维护者的批准点击;这个钩子只省一个队列周期,而实装版本盯着三种合规席位根本不用的拼法,今天已经耗掉三个受管 PR。删除优于继续维护。 **风险与代价(含回滚)** 风险:失去"批准前就在本地拦住"的便宜防线——违规入队会在队列里红一次,而不是在本地被拦;服务端正确性不变。代价:一次受管落地即一次 harness 重载(objectstack-ai#18468 已把"重开"处方去掉)。回滚 = revert 本 PR 的 squash 提交,钩子与两处注册整体回来。 **席位意见** (留空,席位定稿) **你要做的** 一个动作:在本 PR 上 Approve;落地由拥有席位按受管流程执行。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Wj1HUjzyeiBQ8atRf1ZhaL)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18384
The guard's declared job is to refuse an enqueue-class call on a governed PR that carries no authorized approval. It watched three spellings and a compliant seat on this box could reach none of them:
mcp__github__enable_pr_auto_merge/mcp__github__merge_pull_requestpermissions.deny/mergesegment after the pull numbergh pr mergeghis absent from the agent containerThe protocol's only auto-merge route is
PUT /repos/OWNER/REPO/pulls/NUMBER/ccr/auto_merge(references/rest-channel.md, write side). After the pull number comesccr/, so the literal/mergesegment the old regex required never appears. Declared, not enforced.What changed
.claude/hooks/guard-governed-enqueue.sh— one added alternative in the Bash pass's URL reader, so the ccr auto-merge route feeds the same imported predicate chain (check-governed-merges.mjs --test+authorizedApprovalVerdict/GOVERNED_APPROVERS) the/mergearm already feeds. No predicate is restated, no path list is added, no arm is weakened or removed — the MCP arm stays as defence in depth, because a deny roster is policy and policy can be edited.The one asymmetry, pinned: unlike
/merge, that path carries a non-enqueue verb.DELETEon it is the disarm — the corrective call that takes a mounted PR back out of the queue, andrest-channel.mdnames it as such. Refusing the disarm on an unapproved governed PR would block the one action this guard's own refusal text asks for, so the method is read on that route and only on that route:DELETE/GET/HEADare allowed there, everything else on that path is the mount (a segment naming no method included — the protocol spells the mount as a PUT, and an unnamed method there is a call this guard would rather see than miss)./mergekeeps its "no method sniffing" reading to the byte, pinned by two rows below.POST .../pulls/NUMBER/ccr/ready_for_reviewis not an arm: it queues nothing, and it is the step that produces the review this guard is waiting for. Pinned as an allow.Self-test: 66 cases before, 79 after (13 added)
Block / allow / approved on the new spelling, the
gh api --method PUTspelling, the disarm, the ready flip, writing about the route, the merge arm unchanged under both a namedDELETEand no method at all, and two wired rows (theBashmatcher door in.claude/settings.json, and the route named in the hook's code with comments excluded) so the arm cannot be dropped silently — the MCP wired rows were green the whole time the guard watched nothing a seat could write.Red under a control — the new rows are not vacuous
The ccr alternative was deleted from the URL reader, the mutation was proved on disk, the matrix was run, and the file was restored to its HEAD blob. Direction predicted and observed: turns red.
Two of the five are the text assertions, which is the reading a
saysrow owes: they discriminate, rather than passing in both worlds.Gates run locally (all green, foreground, exit codes captured before any pipe)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 14 commands from this change set; all 14 ran, pluspnpm check:pm-settings-deny-roster(which that derivation flags as an artifact roster living under.claude, i.e. its silence is evidence in neither direction — so it was run, and it is green) and the hook matrix itself.bash .claude/hooks/guard-governed-enqueue.selftest.sh—79 passed, 0 failed, exit 0pnpm check:pm-settings-deny-roster— exit 0 (unaffected by a Bash arm, as expected)pnpm check:bash32-floor— exit 0. The newsegment_methodupper-cases withtr, never the bash-4 case-modification operator.pnpm check:nul-bytes,check:pm-governed-merges,check:doc-authoring,check:agent-test-spelling,check:driver-memory-census,check:refd-timer-probe,check:skill-frame-sync,check:watch-hint-literal,check:closing-keyword-parity(+ its self-test),check-comment-mask-corpus,check-harness-current --self-test— all exit 0pnpm --filter @objectstack/lint run check:doc-formula-expressions— first run exit 3 = PREREQUISITE NOT MET (unbuilt packages), which is not a finding; the two packages were built and it was re-run: exit 0.CI runs this matrix by glob discovery in
Lint & Repo Gates, so the new cases are picked up with no workflow edit.Changeset
skip-changeset. AGENTS.md Post-Task Checklist step 3, verbatim: 「that label is for a diff that publishes nothing from any released package」. This diff is two files under.claude/hooks/, which no package'sfiles[]ships.维护者速读(草稿)
改了什么 — 仓库有个客户端守卫,作用是在「把一个受管 PR 送进合并队列」之前先拦一道:没有人工批准就不让排队。它原本盯着三种写法,而今天任何一个守规矩的 agent 席位一种都碰不到——两个 MCP 写工具早已进了拒绝名单,容器里没有
gh,剩下的 REST 写法它要求 URL 里出现字面的/merge,而协议规定的唯一 auto-merge 路由在编号后面接的是ccr/。这次给它加上那条真正在用的路由,让它跟原有那条走同一套判定(是否受管、是否有授权批准),一条旧的都没动、没削弱。为什么改 — 守卫的价值不是「最终正确性」,那一道在服务端,没丢。丢的是便宜:没有它,一个未批准的受管 PR 会真的排进队列,烧掉一个队列周期,然后弹出一条红色条目——而 GitHub 不会因为事后补了批准就重跑那条队列条目。维护者看到的是一条「我明明审过了它还是红」的记录。一个读起来存在、实际上一种写法都盯不到的守卫,比没有更糟。
风险与代价(含回滚) — 风险面很窄:只多认一条 URL 形状,判定链一个字没改,所以它只可能拒绝服务端本来也会拒绝的东西。唯一需要拍板的判断是:同一条路径上的
DELETE是「卸载 auto-merge」,属于纠正动作,本 PR 明确放行并用测试钉死;ready_for_review同样不拦。回滚代价为零:两个文件,git revert即可,不影响任何已发布产物,不涉及数据或线上行为。席位意见 — (留空,待席位定稿)
你要做的 — 读一眼上面那条
DELETE放行的判断是否合你意,然后批准这个 PR(受管面,按规矩由你说了算再落地)。Acceptance notes
gh pr mergewalk are byte-identical.segment_methodreads onlycurl/gh apimethod flags; a method chosen by an alias or a wrapper is unread. This is the same deliberate boundary the file already states for wrapped invocations (bash -c, xargs, ssh) and the same escape hatch answers it. Carrier for this file: none beyond this PR.mcp__github__disable_pr_auto_mergeis in the deny roster whilemcp__github__enable_pr_auto_mergeis both denied and guarded — an observation about the roster's shape, not a defect of either. Carrier: none.Generated by Claude Code