Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "bash -c 'i=$(cat); case $i in *\"pr merge\"*|*\"/merge\"*) m=1 ;; *update-branch*) m=0 ;; *) exit 0 ;; esac; d=${CLAUDE_PROJECT_DIR:-.}; if [ \"$m\" = 1 ] && command -v timeout >/dev/null 2>&1; then timeout 10 git -C \"$d\" fetch --quiet --no-tags origin feat-cmux-next 2>/dev/null; fi; f=$(mktemp) || exit 1; trap \"rm -f -- \\\"$f\\\"\" EXIT; if ! git -C \"$d\" show origin/feat-cmux-next:scripts/ci/guards-from-base.sh > \"$f\" 2>/dev/null; then why=\"cmux guards: cannot read scripts/ci/guards-from-base.sh at origin/feat-cmux-next, so the merge guards did not run; fetch origin feat-cmux-next and retry\"; if [ \"$m\" = 1 ]; then printf \"{\\\"systemMessage\\\": \\\"%s; merging anyway\\\", \\\"hookSpecificOutput\\\": {\\\"hookEventName\\\": \\\"PreToolUse\\\", \\\"additionalContext\\\": \\\"%s; merging anyway\\\"}}\\n\" \"$why\" \"$why\"; exit 0; fi; echo \"$why\" >&2; exit 1; fi; printf %s \"$i\" | bash \"$f\" \"$d\" || exit 1'",
"command": "bash -c 'i=$(cat); case $i in *\"pr merge\"*|*\"/merge\"*|*mergePullRequest*|*PullRequestAutoMerge*) m=1 ;; *update-branch*) m=0 ;; *) exit 0 ;; esac; d=${CLAUDE_PROJECT_DIR:-.}; t=$(command -v timeout || command -v gtimeout); if [ \"$m\" = 1 ] && [ -n \"$t\" ]; then \"$t\" 10 git -C \"$d\" fetch --quiet --no-tags origin feat-cmux-next 2>/dev/null; fi; f=$(mktemp) || exit 1; trap \"rm -f -- \\\"$f\\\"\" EXIT; if ! git -C \"$d\" show origin/feat-cmux-next:scripts/ci/guards-from-base.sh > \"$f\" 2>/dev/null; then why=\"cmux guards: cannot read scripts/ci/guards-from-base.sh at origin/feat-cmux-next, so the merge guards did not run; fetch origin feat-cmux-next and retry\"; if [ \"$m\" = 1 ]; then g=$HOME/.claude/hooks/require-cmux-guards.sh; if [ -f \"$g\" ]; then o=$(printf %s \"$i\" | bash \"$g\" 2>/dev/null); if python3 -I -c \"import json, sys; s = json.loads(sys.argv[1]).get(\\\"hookSpecificOutput\\\") or {}; sys.exit(0 if isinstance(s, dict) and s.get(\\\"permissionDecision\\\") == \\\"deny\\\" else 1)\" \"$o\" 2>/dev/null; then printf \"%s\\n\" \"$o\"; exit 0; fi; fi; printf \"{\\\"systemMessage\\\": \\\"%s; merging anyway\\\", \\\"hookSpecificOutput\\\": {\\\"hookEventName\\\": \\\"PreToolUse\\\", \\\"additionalContext\\\": \\\"%s; merging anyway\\\"}}\\n\" \"$why\" \"$why\"; exit 0; fi; echo \"$why\" >&2; exit 1; fi; printf %s \"$i\" | bash \"$f\" \"$d\" || exit 1'",
"timeout": 120
}
]
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/cmux-next.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1502,11 +1502,9 @@ jobs:
python3 tests/test_dev_release_product_key.py
# A reused DEV Release app is trusted and intact, or the job compiles; DEV lane only.
python3 tests/test_dev_release_product_reuse.py
# A hand merge of a Swift PR waits for its Release compile (scripts/ci/merge_compile_guard.py).
python3 tests/test_merge_compile_guard.py
# The advisory test-compile check: its plan, the lane phase and red only for a compile failure.
python3 tests/test_ci_compile_test_plan.py
# The merge guards run from the feat-cmux-next tip, never a pull request's checkout.
# A merge goes to the landing gate installed from cmuxterm-hq, never a guard in a checkout.
python3 tests/test_guard_hooks_from_base.py
# ci-guards.yml's `ci` group runs for feat-cmux-next too (cmux-next-guards.yml).
python3 tests/test_feat_ci_guard_group.py
Expand Down Expand Up @@ -2203,7 +2201,7 @@ jobs:
# A same-repository pull request's app compile, on the build fleet instead
# of the Mac Release queue (2026-10-21: 76 of ~350 queued Mac jobs were
# Release compiles). It compiles and links the app at the PR's head, the
# commit merge_compile_guard.py and the landing hooks ask about, in about a
# commit the landing gate (require-cmux-guards) asks about, in about a
# minute. A fleet compile error fails this job: the PR's own red. No verdict
# (no client, the fleet down, a timeout) is a notice, and release-compile
# runs on a Mac instead. A full-ci PR, a fork, and every push and dispatch
Expand Down
10 changes: 3 additions & 7 deletions .github/workflows/fleet-compile-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,9 @@ run-name: fleet compile-check ${{ inputs.sha }}
# Release compile: that build uses Apple's compiler and the optimizer, and can
# fail where this passes. So a red Release compile always wins here, and the
# Release compile still runs on the base after the merge.
# scripts/ci/merge_compile_guard.py accepts a success here in place of a
# queued "cmux-next Release compile (Xcode 26)" (#22845 sat queued over an
# hour after its fleet pass, with 60 minis offline). It trusts the status only
# when it links a finished, green run of this file dispatched from
# feat-cmux-next (its commit on that branch) and titled with the full sha; a
# status a token posts itself, or a run of a copy of this file on another
# branch or a tag, does not count. It never overrides a red Release compile.
# It does not stand in for the Release compile: the landing gate (cmuxterm-hq
# tools/agent-guards/require-cmux-guards.py) and next-autoland wait for a green
# "cmux-next Release compile (Xcode 26)" on the exact head.
#
# gh workflow run fleet-compile-check.yml --ref feat-cmux-next -f sha=<full head sha>

Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/fleet-test-compile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ name: fleet test-compile
# fleet Mac, and fail this check when one does not compile.
#
# fleet/compile-check (fleet-compile-check.yml) compiles only the app
# executable, which links no test target, and merge_compile_guard.py accepts
# it in place of a queued Release compile. #23360 (a throwing call inside
# executable, which links no test target. #23360 (a throwing call inside
# #expect without try) and #23163 (a fake that missed a protocol requirement)
# passed it and broke feat-cmux-next's test build. This check covers that gap
# until the Linux pool compiles test targets (its SDK gains Xcode's Testing
# search paths with the next pool image, cmuxterm-hq #2750).
#
# It gates nothing: fleet/compile-check, merge_compile_guard.py and the
# Release compile neither wait for it nor read it. A red here is a test
# It gates nothing: fleet/compile-check, the landing gate and the Release
# compile neither wait for it nor read it. A red here is a test
# target that does not compile at the PR's merge commit.
#
# It runs on its own gateway label, vars.CI_TEST_COMPILE_GATEWAY, never the
Expand Down
113 changes: 80 additions & 33 deletions scripts/ci/guards-from-base.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,49 @@
# session's checkout (.claude/settings.json reads this file from origin/feat-cmux-next and runs it).
# A pull request's worktree holds that branch's copy of the guards: a branch made before a guard fix
# kept the old guard, and a pull request could edit the guard that judges its own merge
# (palette-audit, 2026-10-21). So this takes each guard and its hook from origin/feat-cmux-next
# (the settings hook fetched it first for a merge), into a temporary directory:
# - merge-compile-guard-hook.sh (merge_compile_guard.py): refuses a hand merge of a Swift PR
# before its Release compile passed;
# - base-merge-warning-hook.sh (base_merge_warning.py): warns before an update-branch cancels CI.
# A refusal from either is the answer; otherwise their warnings are joined into one answer on stdout. A
# merge whose guards can't be read from the base, or whose guard fails, goes ahead with a warning: only a
# compile that finished red refuses (merge_compile_guard.py).
# (palette-audit, 2026-10-21).
#
# A merge is judged by the landing gate installed from cmuxterm-hq main (tools/agent-guards'
# require-cmux-guards, which guards-sync keeps current every 10 minutes), never by a copy in this
# repository: after hq#2830, #23472 still ran a copy here without that fix (deputy-2, 2026-10-21). When
# this account registers the gate, Claude Code runs it and this defers; when only its file is
# installed, this runs it; when it is not installed, the merge goes ahead with a warning that says how
# to install it (2026-10-21: no guard blocks a merge it can't judge, only a compile that finished red).
#
# The update-branch warning (base-merge-warning-hook.sh, base_merge_warning.py) still comes from
# origin/feat-cmux-next (the settings hook fetched it first for a merge), into a temporary directory.
# A refusal is the answer; otherwise every warning is joined into one answer on stdout.
#
# Usage: printf '%s' "$payload" | bash guards-from-base.sh CHECKOUT
set -uo pipefail
dir="${1:-.}"
input=$(</dev/stdin)
merge=0
case $input in *"pr merge"* | *"/merge"*) merge=1 ;; esac
# A merge the guards can't judge goes ahead with a warning the person and the agent both see, as
# merge_compile_guard.py lets through one it can't read; the update-branch warning goes to stderr.
case $input in *"pr merge"* | *"/merge"* | *mergePullRequest* | *PullRequestAutoMerge*) merge=1 ;; esac
said=()
# A guard that can't run is a warning: for a merge the person and the agent both see it and the merge goes
# ahead; otherwise it goes to stderr.
cannot() {
if ((merge)); then
python3 -I -c 'import json, sys; text = sys.argv[1] + "; merging anyway"
json.dump({"systemMessage": text, "hookSpecificOutput": {"hookEventName": "PreToolUse",
"additionalContext": text}}, sys.stdout)' "$1"
exit 0
said+=("$1")
return
fi
echo "$1" >&2
exit 1
}
base="$(git -C "$dir" rev-parse -q --verify 'origin/feat-cmux-next^{commit}')" \
|| cannot "cmux guards: no origin/feat-cmux-next in $dir to run the merge guards from"
work="$(mktemp -d)" || cannot "cmux guards: no temporary directory for the merge guards"
trap 'rm -rf "$work"' EXIT
git -C "$dir" archive "$base" scripts/ci/merge-compile-guard-hook.sh scripts/ci/merge_compile_guard.py \
scripts/ci/base-merge-warning-hook.sh scripts/ci/base_merge_warning.py | tar -x -C "$work" \
|| cannot "cmux guards: cannot read the guards at origin/feat-cmux-next ${base:0:12}"
# Each guard runs: a refusal from either is the answer. Otherwise their warnings are joined, so a merge's
# warning never hides the update-branch warning of the same command. A guard that crashes or exits
# non-zero (exit 2 would block the tool call in Claude Code) is only a warning: no guard blocks on its own failure.
said=()
for hook in merge-compile-guard-hook.sh base-merge-warning-hook.sh; do
out="$(printf '%s' "$input" | bash "$work/scripts/ci/$hook" 2>/dev/null)"
# judge NAME COMMAND...: runs one guard on the payload. A refusal is the answer the moment its guard gives it,
# read as JSON whatever its spacing: the next guard could run the hook past its timeout with the refusal
# unprinted. A guard that crashes or exits non-zero (exit 2 would block the tool call in Claude Code) is only a
# warning: no guard blocks on its own failure.
judge() {
local name=$1 out status
shift
out="$(printf '%s' "$input" | "$@" 2>/dev/null)"
status=$?
if ((status != 0)); then
out="cmux guards: $hook exited $status, so it judged nothing"
out="cmux guards: $name exited $status, so it judged nothing"
fi
[[ -n "$out" ]] || continue
# A refusal is the answer the moment its guard gives it, read as JSON whatever its spacing: the next
# guard could run the hook past its timeout with the refusal unprinted.
[[ -n "$out" ]] || return 0
if python3 -I -c 'import json, sys
try:
spec = json.loads(sys.argv[1]).get("hookSpecificOutput") or {}
Expand All @@ -60,7 +56,58 @@ sys.exit(0 if isinstance(spec, dict) and spec.get("permissionDecision") == "deny
exit 0
fi
said+=("$out")
done
}
if ((merge)); then
gate="$HOME/.claude/hooks/require-cmux-guards.sh" # where tools/agent-guards/install.py puts it
if [[ ! -f $gate ]]; then
cannot "cmux guards: the landing gate (require-cmux-guards, cmuxterm-hq tools/agent-guards) is not \
installed on this machine, so nothing judges this merge. Install it with guards-sync (cmuxterm-hq \
tools/agent-guards/guards-sync, which also keeps it current) or python3 tools/agent-guards/install.py from a \
cmuxterm-hq checkout"
# A registration: a PreToolUse command of this account's settings, under a matcher that covers Bash (none, "*",
# or a pattern Bash matches), that runs the gate's file: one of its words is that file, not just a mention of it.
elif ! python3 -I - "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" <<'PY'; then
import json, os, re, shlex, sys
def covers_bash(matcher):
if matcher in (None, "", "*"):
return True
try:
return re.fullmatch(str(matcher), "Bash") is not None
except re.error:
return False
def runs_gate(command):
try:
words = shlex.split(str(command))
except ValueError:
return False
return any(os.path.basename(word) in ("require-cmux-guards.sh", "require-cmux-guards.py") for word in words)
try:
with open(sys.argv[1]) as f:
pre = (json.load(f).get("hooks") or {}).get("PreToolUse") or []
sys.exit(0 if any(covers_bash((e or {}).get("matcher")) and runs_gate((h or {}).get("command", ""))
for e in pre for h in (e or {}).get("hooks") or []) else 1)
except (OSError, ValueError, AttributeError, TypeError):
sys.exit(1)
PY
judge require-cmux-guards.sh bash "$gate"
fi
fi
work=""
if base="$(git -C "$dir" rev-parse -q --verify 'origin/feat-cmux-next^{commit}')"; then
if work="$(mktemp -d)"; then
trap 'rm -rf "$work"' EXIT
if git -C "$dir" archive "$base" scripts/ci/base-merge-warning-hook.sh scripts/ci/base_merge_warning.py \
| tar -x -C "$work"; then
judge base-merge-warning-hook.sh bash "$work/scripts/ci/base-merge-warning-hook.sh"
else
cannot "cmux guards: cannot read the guards at origin/feat-cmux-next ${base:0:12}"
fi
else
cannot "cmux guards: no temporary directory for the guards"
fi
else
cannot "cmux guards: no origin/feat-cmux-next in $dir to run the guards from"
fi
((${#said[@]})) || exit 0
# No refusal: one answer from every warning. A guard's JSON gives its systemMessage and additionalContext,
# any other text is a note. The person sees the systemMessage, the agent the additionalContext.
Expand Down
9 changes: 0 additions & 9 deletions scripts/ci/merge-compile-guard-hook.sh

This file was deleted.

Loading
Loading