The fix is structural, not prompt-level: put the review behind one entrypoint, and let pre-commit, pre-push, CI, and the merge queue differ only by a policy record.
Most teams do the opposite. They paste a review script into .git/hooks/pre-commit, paste a slightly different one into the CI YAML, then paste a third into the merge queue. Six weeks later the three copies disagree about what "blocking" means, and nobody can tell which one is authoritative.
This post is about the wiring layer around a model-backed check — hooks, fixtures, flake control, and the green-to-merge path. Everything below is illustrative and was not executed while writing; treat the code as a design you adapt, and verify each step in your own repo.
The placement matrix decides everything else
Before writing a line of shell, decide which hook is allowed to block. A model-backed reviewer is slow and non-deterministic; putting it in the wrong slot is how you teach a team to use --no-verify.
| Call site | Runs on | Mode | Blocking | Budget |
|---|---|---|---|---|
pre-commit |
staged diff | stub only | no | seconds |
pre-push |
outgoing range | live | yes, 1 attempt | under a minute |
| CI job | PR head | live | no (advisory + artifact) | a few minutes |
| merge queue | merge result | live | yes, 1 attempt | a few minutes |
The rule behind the table: block early and cheaply, or block late and authoritatively, but never block twice on the same decision. A developer who is stopped at pre-push should not be re-litigated by a merge queue run that saw the same diff.
Step 1 — One entrypoint, four arguments
Prerequisites: bash, jq, GNU timeout, and hooks installed via git config core.hooksPath ci/hooks so every clone gets the same wiring.
#!/usr/bin/env bash
# ci/gate.sh — one entrypoint, four call sites. Illustrative, not executed.
set -uo pipefail
hook="${1:?usage: gate.sh <hook> <diff-range>}"
range="${2:-HEAD~1..HEAD}"
policy="ci/gate-policy.json"
# Lets contract tests exercise the wiring without any model call.
mode="${GATE_MODE_OVERRIDE:-$(jq -r --arg h "$hook" '.hooks[$h].mode' "$policy")}"
timeout_s="$(jq -r --arg h "$hook" '.hooks[$h].timeout_s' "$policy")"
blocking="$(jq -r --arg h "$hook" '.hooks[$h].blocking' "$policy")"
mkdir -p .gate
out=".gate/verdict-${hook}.json"
timeout "$timeout_s" ./ci/adapters/review --mode "$mode" --range "$range" --out "$out"
rc=$?
case "$rc" in
0) exit 0 ;;
1) # the reviewer says: do not merge
if [ "$blocking" = "true" ]; then
echo "gate: blocked by reviewer verdict ($out)"; exit 1
fi
echo "gate: advisory verdict at $hook, not blocking"; exit 0 ;;
2|124) # timeout, network error, unparsable output
echo "gate: infrastructure failure, not a code verdict"; exit 2 ;;
*) exit 2 ;;
esac
The exit code contract is the whole design. 0 = pass, 1 = a verdict against the code, 2 = we do not know. Collapsing 2 into 1 is the most common bug in this kind of gate: it silently converts a flaky network call into a red build, and the team learns to ignore red.
Step 2 — Keep the adapter swappable, the gate fixed
Your reviewer will change: a different endpoint, a different prompt, a cheaper model on the inner loop. None of that should touch gate.sh. Put the model behind one CLI with a stub mode and a live mode.
#!/usr/bin/env bash
# ci/adapters/review — one CLI, two modes. Illustrative.
set -uo pipefail
mode=""; range=""; out=""
while [ $# -gt 0 ]; do
case "$1" in
--mode) mode="$2"; shift 2 ;;
--range) range="$2"; shift 2 ;;
--out) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ "$mode" = "stub" ]; then
fixture="ci/fixtures/${range//\//_}.json"
[ -f "$fixture" ] || { echo "no fixture for $range"; exit 2; }
cp "$fixture" "$out"
else
./ci/adapters/model-review --range "$range" --out "$out" || exit 2
fi
status="$(jq -r '.status' "$out")" # pass | block | error
case "$status" in
pass) exit 0 ;;
block) exit 1 ;;
*) exit 2 ;;
esac
Because fixtures are addressed by diff range, your test suite never needs a live endpoint or a fixed clock. That is what makes the next step possible.
Step 3 — Contract-test the wiring, not the model
You cannot assert on model prose. You can assert that pre-push turns a block verdict into exit 1 and merge-queue turns a timeout into exit 2. Those are the properties that keep the gate honest.
#!/usr/bin/env bash
# ci/test-gate-contract.sh — asserts exit codes only. Illustrative.
set -uo pipefail
export GATE_MODE_OVERRIDE=stub
fail=0
expect() { # hook fixture expected_rc
./ci/gate.sh "$1" "$2" >/dev/null 2>&1
rc=$?
if [ "$rc" -eq "$3" ]; then
echo "ok $1 <- $2 (rc=$rc)"
else
echo "FAIL $1 <- $2 (rc=$rc, want $3)"; fail=1
fi
}
expect pre-commit clean 0
expect pre-push violation 1
expect pre-push clean 0
expect merge-queue timeout 2
exit "$fail"
Run this in CI before the live review job. If the contract test fails, the live job's result is meaningless — fail fast and skip it. Fixtures that encode pass, block, and error verdicts are the minimum set; treat stale fixtures as a separate problem worth its own gate.
Step 4 — Flake control: attempt budget plus a quarantine ledger with an expiry
Non-determinism needs a budget, not a retry loop. Set max_attempts: 1 at every blocking call site and 2 at the advisory CI job, where a second sample is cheap and informative rather than a way to get green.
The second tool is a quarantine ledger. A rule that keeps producing false positives gets quarantined — with an owner and a hard expiry date, never indefinitely.
{
"quarantined": [
{
"rule": "no-secret-literals",
"owner": "@platform",
"until": "2026-12-01",
"reason": "flags base64 test fixtures; deterministic scanner covers the real case"
}
]
}
Two behaviours make the ledger trustworthy. A quarantined rule may never produce a blocking exit code. And an entry past its until date makes the gate exit 2 — not 0 — so the expiry is noticed instead of quietly inherited.
There is a hard boundary here: do not quarantine deterministic checks. Secret scanning, license policy, and lockfile integrity should be scanners that either pass or fail. Quarantine is for judgement calls, and judgement calls are exactly what a model-backed reviewer is for.
Step 5 — Bind the verdict to the diff, then go green-to-merge
A verdict is worthless if it can be replayed against different code. Have the adapter record what it actually reviewed:
{
"status": "pass",
"diff_sha": "<sha256 of the patch the reviewer saw>",
"policy_sha": "<sha256 of ci/gate-policy.json>",
"prompt_version": "review-v7",
"attempts": 1
}
The merge-queue job recomputes diff_sha on the merge result and refuses any verdict that does not match. That is the green-to-merge path: green on a branch is not green on the merge result, and a pass earned by an earlier commit is not a pass at all.
Where the model endpoint plugs in
Only ci/adapters/model-review needs a model. Everything above — the entrypoint, the policy file, the fixtures, the contract tests, the ledger — runs without one.
That boundary is the point. This is the slot where MonkeyCode, an open-source project offering free model access and a free server option, can serve as the default endpoint, including for the self-hosted runner that executes the merge-queue job. Quota terms change and I have not independently verified them: figures I have seen quoted, such as a ten-million-token free allowance, should be confirmed on the project's own page before you plan a budget around them.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
What this design will not give you
- Determinism. Two runs on the same diff can disagree. The attempt budget and the quarantine ledger manage that; they do not remove it.
- Security guarantees. Move secrets, licenses, and signature checks to deterministic scanners before you add anything model-backed to the pipeline.
-
Free wall-clock time. A blocking gate that takes 90 seconds at
pre-pushwill be bypassed. Measure it before you make it blocking, and start withpre-commitadvisory to shake out false positives. -
A single-policy fit. Monorepos usually need per-path policy records; the
hookargument ingate.shbecomeshook:path-glob, and the dispatch stays in one file.
Who should not use this
Small repos with one contributor: a pre-push hook running a deterministic linter and tests will serve you better, and a model in the loop adds latency with no reviewer to arbitrate it. Regulated environments where every gate must be reproducible bit-for-bit: keep the model advisory-only and never let it emit a blocking exit code. And teams without an owner for the quarantine ledger — an unowned ledger is just a list of disabled checks with extra steps.
If you wire this up, the useful thing to report back is which call site you made blocking first, and what your false-positive rate looked like at pre-push. That number, not the speed of the reviewer, is what decides whether the gate survives contact with your team.
Top comments (0)