merge-compile-guard: only a compile that finished red refuses; a slow read or a pending compile warns and merges
Leo-ask: ask-99c1b0ed16
Leo-ask: ask-265dff2c7c
Leo-ask: ask-bdaebcd48a
Leo-ask: ask-daa4b85426
Leo-ask: ask-4c8d8efef8
Leo, 2026-10-21: "IF THE MERGE HOOK IS WRONG THEN JUST FUCKING MEREG", "WTF ARE YOU DOING WITH FUCKING MERGE HOOKS JUST MERGE!?", "DUDE I FUCJKING HATE YOUR MERGE HOOKS".
This repo's PreToolUse merge hook (.claude/settings.json ā scripts/ci/guards-from-base.sh ā scripts/ci/merge_compile_guard.py) refused merges it couldn't judge. On cosmic-gray at load ~200, its GitHub reads timed out and refused #23660 and #23371 for hours. Both had a SHIP receipt at the head, green fast gates and a green compile. It also refused every merge whose compile was still queued or running.
HQ's landing gate (require-cmux-guards.py, cmuxterm-hq#2960 and #2976) already refuses only four things:
- no SHIP receipt at the head;
- a compile that finished red;
--admin;--autowithout a pinned head.
A pending compile or a slow read is only a warning there. This PR makes the repo hook agree.
What changes
-
Still refused: a Swift PR whose
cmux-next Release compile (Xcode 26)orcmux-next app compile (build fleet)finished red with no passing attempt. A red fleet compile refuses even while a Release compile is queued beside it, and a fleet compile-check pass doesn't clear it. -
Now a warning (a "Hold"), not a refusal:
- a compile that is still queued or running, hasn't been created yet, or was cancelled on every attempt;
- a GitHub read that failed or ran out of time;
- a merge the guard can't name (a shell variable, a GraphQL mutation);
- a checkout it can't read.
The hook prints a
systemMessageplusadditionalContext, with nopermissionDecision. The merge goes ahead, and both the person and the agent see why. -
A warning never hides a refusal. Every merge in the command is judged, and any red refuses.
-
guards-from-base.shruns both guards.- A refusal is the answer as soon as its guard gives it. It is read as JSON, whatever the spacing.
- Otherwise the guards' warnings are joined into one, so a merge's warning never hides the update-branch warning in the same command.
- A guard that crashes or exits non-zero is only a warning. Claude Code treats exit 2 as a block.
-
Guards that can't be loaded: when
guards-from-base.shor the settings hook can't load the guards from origin/feat-cmux-next, it now warns instead of refusing. -
Launcher failure: if the launcher itself fails, the settings hook exits 1, which doesn't block.
Overlap: #23858 (lane-host) replaces this guard with the installed HQ gate and deletes these files. It conflicts with feat-cmux-next and has been idle since 2026-10-21 23:50Z. Whichever PR lands second takes the other's side; nothing here blocks it.
Review
A Codex reviewer (gpt, xhigh, on cmux16) blocked three rounds. Each finding got a failing test first, then a fix:
- A red fleet compile next to a queued Release compile read as a wait. A guard exiting 2 blocked the tool call. A merge warning short-circuited the update-branch warning.
- A refusal written with other JSON spacing was merged into a warning.
- A known refusal waited behind the slower advisory hook and could be lost to the 120 s timeout.
Round 4: SHIP at the head.
Tests
python3 tests/test_merge_compile_guard.py: 54 OK.python3 tests/test_guard_hooks_from_base.py: 17 OK.
Every behaviour change has a failing test committed before its fix (d8d6e2a9754, 3b19f092067 and the two later (red) commits). The new cases:
- a slow GitHub (
ghsleeps pastCMUX_MERGE_GUARD_GH_TIMEOUT=1) and an unreadable GitHub let the merge through with a warning; - a pending compile warns; a red Release compile or fleet compile refuses;
- in
gh pr merge "$PR" ...; gh pr merge 5 ..., the warning on the first merge doesn't hide the red on the second; - a crashing guard or launcher never exits 2;
- a refusal comes back within 20 s while a 30 s advisory guard is still running;
- a refusal written with
indent=2and odd separators still refuses.
Observed / Not run
- Observed: both suites on cmux16. cmux16 has no
timeout, which the settings hook uses for its fetch, so a stand-in that runs the command went first on PATH.scripts/cmux-next/prepush-gate.py: no new violations. - Not run: a live merge under load. The stop condition is an under-load merge of a gated PR going through, and I'll report that once this is on feat-cmux-next.
Changelog
none
Agent session: Air-Blue
š¤ Generated with Claude Code