A Deterministic Merge Gate for a Self-Merging AI Agent

Because the rule is evaluated by the thing it constrains, its violation is invisible — a skipped test does not fail the suite — and it competes with a landing deadline that is far more concrete. A hook runs before the command, compares two facts, and returns a decision the agent cannot reinterpret.
Keep it narrow and mechanical: the test count did not drop versus the base branch, no new skip or focus markers appear in the diff, the pipeline is green on the head commit, and the diff size is within the ceiling you asked for. Four checks, no judgement about code quality, all verifiable by a person in seconds.
One that fires before the tool call, not after it. My first attempt used a post-verification hook, which ran after the merge had already happened and reduced the gate to reporting a fact. The gate must be able to return a block decision, not just an observation.
That is a decision you must make deliberately and write down. A gate that silently allows on error is indistinguishable from no gate at all, and a gate that blocks on every error will be switched off by whoever is on call. Pick one, document it, and test the error path.
With a small blast radius, yes. It suits a library with a strong test suite behind a narrow gate, a repository-scoped token, required platform checks, and a habit of reading merged commits periodically. It does not suit a service that deploys to production on merge.

Key Takeaway
If an agent merges its own pull requests, the safety mechanism cannot be an instruction — it must be code the agent cannot talk past. A pre-tool hook that intercepts the merge command and blocks it when the test count fell or tests were skipped catches the exact failure that honesty rules do not.
The moment my coding agent started merging its own pull requests, one question got sharper: what stops it from making the tests pass the easy way? Not maliciously — a model under pressure to finish will skip a stubborn test the same way a tired developer might, and it will write a perfectly reasonable commit message explaining why.
Instructions are not an answer to that, because the same reasoning that decides to skip the test also decides whether the instruction applies. The answer has to be deterministic, and it has to sit between the agent and the merge.
My first version of this was a paragraph in the agent's instruction file: never disable a test to make the suite pass. It is a good rule and it is not a control, for three reasons.
A hook has none of those properties. It runs before the command, it compares two facts, and it returns a decision the agent cannot reinterpret.

The hook fires on every tool call, ignores everything that is not a merge, and then checks two things that are both cheap to compute and hard to argue with.
#!/usr/bin/env bash
# merge-gate.sh — runs BEFORE the agent's merge command is executed.
# Deterministic, boring, and impossible to argue with.
set -euo pipefail
cmd=$(jq -r '.tool_input.command' <<< "$(cat)")
[[ "$cmd" == *"pr merge"* ]] || { echo '{}'; exit 0; }
deny() {
jq -nc --arg r "$1" '{action:"block", reason:$r}'
exit 0
}
# 1. The branch must not have fewer tests than main.
base=$(git show origin/main:package.json >/dev/null 2>&1 && \
git grep -c -E "\b(it|test)\(" origin/main -- 'src/**/*.test.ts' | \
awk -F: '{s+=$NF} END {print s+0}')
head=$(git grep -c -E "\b(it|test)\(" HEAD -- 'src/**/*.test.ts' | \
awk -F: '{s+=$NF} END {print s+0}')
(( head < base )) && deny "test count dropped from $base to $head"
# 2. No test may have been disabled to make the suite pass.
git diff origin/main...HEAD -- 'src/**/*.test.ts' \
| grep -E '^\+.*(\.skip|\.only|xit\()' \
&& deny "branch adds skipped or focused tests"
echo '{}' # allowNote what it does not attempt. It does not judge code quality, it does not assess whether the change is a good idea, and it does not read the diff for intent. Every check is a comparison between the branch and the base that a person could verify in ten seconds. That narrowness is what makes it trustworthy.
Choose the hook point that fires before the action, not after it. My first attempt used a post-verification hook, which was too late: by the time it ran, the merge had already happened and the gate was reduced to reporting a fact.
A guard rail that has never been observed blocking anything is a guess. I verified this one both ways before trusting it: a branch that deliberately deletes a test is blocked, and a clean branch merges normally.
The negative case matters as much as the positive one. A gate that blocks everything is quickly disabled by whoever is on call, and a gate that fires on legitimate merges trains you to bypass it — which is worse than not having it, because it also costs you the illusion of protection.
Keep the gate's failure mode explicit. If the hook itself errors — a missing dependency, a git command that fails in a fresh clone — decide deliberately whether that blocks or allows, and write the decision down. A gate that silently allows on error is indistinguishable from no gate at all.
Four checks cover the realistic ways an unattended agent can produce a green branch that should not merge. All four are mechanical.
| Check | What it catches | Why it is safe to automate |
|---|---|---|
| Test count did not drop | Tests deleted to make the suite pass | A count comparison against the base branch, with no judgement involved |
| No new skip or focus markers | Tests disabled or narrowed instead of fixed | A grep over added lines in the diff; false positives are rare and obvious |
| Pipeline is green on the head commit | A merge attempted before checks finished | The platform already knows the answer; the gate just refuses to ignore it |
| Diff size within a ceiling | A run that quietly rewrote a whole file instead of making a small change | A line-count threshold you set once, matching the scope you asked for |
I would not add a fifth. Every additional check is another chance to block a legitimate merge at three in the morning, and the value of this gate comes from being narrow enough that nobody is ever tempted to switch it off.

The gate is one layer. Four others make self-merging defensible rather than reckless.
It is worth being honest that this is still a risk trade. A modest free model merging to a published library, gated only on local tests and honesty rules, is a decision I made with open eyes — and the gate is the reason I was comfortable making it.
Four steps, in the order that gets you a working gate in an afternoon.
One deployment detail bit me: the hook script lives inside the image, so a change to it needs a rebuild rather than a copy into the running container. A gate that only exists in the container you were experimenting in is not a gate.
Letting an agent merge its own work is reasonable when the thing standing between it and the main branch is code rather than a promise. Keep the gate narrow, test both of its outcomes, make its own failure mode explicit, and it converts an uncomfortable amount of trust into a small, verifiable one.