Coding-Agent State Protocol

Pre-Push Hooks for AI Coding Agents: Gate Both Boundaries of the Session

An autonomous agent runs the checks it cannot skip — and, eventually, skips the rest. Drift is cheapest to catch at the two moments it would escape: when a session starts, and when a push leaves the machine.

13 July 2026 · 7 min read · deterministic verification

In the previous article we defined state drift — the gap that opens between a project's recorded state and what git actually contains — and the deterministic fix: validate the record against git and block the push when they disagree. This piece is about the part that decision leaves open: where the check has to fire. Because a check in the wrong place is a check that autonomous loops route around.

Discipline does not survive autonomy

When a human drives the loop, "run the check before you push" is workable advice. The human forgets sometimes; mostly they don't. An autonomous loop is different in kind, not degree. A coding agent — Claude Code running overnight, a Cursor session chewing through a backlog, a fleet of headless workers — commits and pushes as part of the loop itself. Any verification step that exists as instructions rather than mechanism is a step the loop will eventually skip: not from malice, but because the agent is a probabilistic process and "eventually skips a step" is what probabilistic processes do, given enough iterations.

The modern agent harness already self-verifies the code: it runs the tests, the typechecker, the linter, and loops until they pass. What it structurally cannot self-verify is its own record of where the project stands — the phase, the last shipped commit, the next step. That record is text the model wrote, and a model re-reading its own summary is the same process checking itself. The check on the record has to sit outside the model, and it has to fire whether or not the model remembers to invoke it.

That means wiring it into the two places every session must pass through.

Drift escapes at exactly two boundaries

Between boundaries, drift is cheap. A state.json that falls one commit behind mid-session costs nothing while the same agent, holding the real context, keeps working. It becomes expensive at precisely two moments:

Catch drift at those two boundaries and it cannot propagate: it can neither mis-orient the next session nor leave the machine. Miss them, and no amount of mid-session care recovers what already escaped.

Diagram: without casp, three agent sessions drift away from the git timeline unchecked; with casp, the same sessions pass through the casp check shield — FAIL blocks the git push, PASS lets the timeline continue.
Drift escapes at the boundary. The check fires where the push happens: FAIL blocks it, PASS lets it through.

A lie, recorded

Here is the failure in its natural habitat. A session closes; the agent bumps the state file and — plausibly, confidently — queues the next phase: next_prompt: docs/plan/sessions/PHASE-2-BILLING.md. It never wrote that file. Nothing about the summary looks wrong; it reads exactly like every honest close before it. The record now claims a next step that does not exist.

casp check does not read the summary and form an opinion about it. It resolves every claim in the state file against the repository and reports what it found:

$ casp check

casp:check · 12 PASS · 1 WARN · 1 FAIL
──────────────────────────────────────────────────────────────────
  [… 12 PASS lines elided …]
  FAIL  state.json.next_prompt points at a missing file · docs/plan/sessions/PHASE-2-BILLING.md does not exist
        → draft the prompt at that path (try `npx @justethales/casp new prompt --slug <slug>`) OR fix state.json.next_prompt
  WARN  last_commit is in history but not at HEAD · state=4c7c797 HEAD=2cec93f
        → if the new commits are out-of-band work, bump state.last_commit to 2cec93f

✗ 1 drift detected. Push blocked — fix before push.   (exit 1)

Every finding names the claim, the evidence, and the mechanical fix. Note the severity split: a claimed file that does not exist is a FAIL — an unverifiable claim, hard drift. A last_commit that sits in history but behind HEAD is a WARN — out-of-band commits are a normal thing that happens to repositories, so the validator instructs instead of blocking. The exit code turns 1 only on FAIL, and the same input produces the same verdict every run. No model is consulted.

An exit code alone, though, is still just a tool you have to remember to run. The point of this article is what happens next: wiring that exit code into both boundaries.

The exit boundary: a pre-push hook that runs the validator

casp install-hook writes one file — an executable .git/hooks/pre-push — and from that moment "check before every push" stops being discipline and becomes mechanism:

$ casp install-hook
write   .git/hooks/pre-push
pre-push gate installed — `casp check --quiet` now runs on every push

The hook runs casp check --quiet: silent when the state is clean, so an honest push feels like any other push. On the drifted repository above, the push itself now carries the verdict:

$ git push

casp:check · 12 PASS · 1 WARN · 1 FAIL
──────────────────────────────────────────────────────────────────
  FAIL  state.json.next_prompt points at a missing file · docs/plan/sessions/PHASE-2-BILLING.md does not exist

✗ 1 drift detected. Push blocked — fix before push.
error: failed to push some refs   (exit 1 — the false record never leaves the machine)

This is the piece that makes the gate survive autonomy. A hands-off loop will not run casp check by hand — but it will run git push, because pushing is the loop's own goal. The hook makes the one unavoidable step carry the verification, so the floor fires inside the loop instead of standing beside it hoping to be invoked.

The hook is deliberately conservative about your repository. It carries a marker identifying it as CASP-managed; installing refuses to overwrite a pre-existing foreign pre-push hook unless you pass --force, re-installing over its own hook is an idempotent refresh, and casp install-hook --remove uninstalls only a hook CASP wrote. It never touches core.hooksPath or any git config — and if core.hooksPath is set (git would silently ignore .git/hooks), it refuses with a clear message rather than writing a dead hook. Installation is explicit opt-in: casp init never installs it for you.

The start boundary: refuse to begin on drift

The push gate stops a false record from escaping. The start gate stops a false record from being believed. casp next is how a session begins — it prints the queued prompt the state file points at — and before printing, it runs the same validator casp check runs, in-process. On drift, it refuses:

$ casp next
state.next_prompt points at a missing file: docs/plan/sessions/PHASE-2-BILLING.md
  → `npx @justethales/casp check` will flag this. Fix state.next_prompt or draft the prompt.
(exit 1 — stdout stays empty)

The refusal goes to stderr and stdout stays empty, which matters for scripted loops: a harness that pipes casp next into an agent's opening context gets nothing rather than fiction, and the non-zero exit fails the pipeline loudly. The alternative — an agent cheerfully orienting itself against a phase that never existed — is exactly the compounding failure the start boundary exists to prevent.

There is one escape hatch, and it is explicit: casp next --no-check prints anyway. The flag is the point — starting a session on a drifted state is something you can choose, never something that happens to you silently. You own the drift you opt into.

Together the two verbs close the loop: you can neither start a session on a lying state nor finish one by pushing it.

What stays out of the gate

Both gates run the same deterministic validator, and nothing else. No LLM — not even an advisory one — participates in the verdict; a probabilistic check on a probabilistic record would be a second opinion, not a ground truth. The hook makes no network call, sends no telemetry, and needs no account. And because both boundaries are git-native rather than harness-native, they are model-agnostic: the same two gates hold for today's agents and for whatever ships next, because they never depended on any of them.

CASP still does exactly one job. It does not orchestrate agents, schedule work, review code, or manage tasks. It proves the recorded state matches git, at the two moments that proof is worth having — and blocks the boundary when it does not.

Start in two minutes

# install
npm i -g @justethales/casp

# in your repo
casp init            # seed the state files
casp check           # prove state == git (exit 0/1)
casp install-hook    # gate every push on drift

@justethales/casp · MIT · git-native · zero telemetry · source on GitHub

The model holds the context. CASP proves the state is true — against git.

Related articles