AI coding

Teaching an Agent to Self-Review Its Diff Before Committing

Antonio Evans11 min read

A split-view terminal scene: on the left, a diff panel showing three modified files, one highlighted in red outside the task folder; on the right, a JSON self-review form with lines for each file.
Key points

Key points

  1. 1A pre-commit gate that fails on out-of-scope paths is the cheapest way to stop an agent's collateral edits before a human sees them.
  2. 2Comparing git diff --name-only output against a per-task allowlist turns "stay in scope" from a hope into a testable assertion.
  3. 3A structured self-critique that returns JSON, one entry per changed file plus a reason, forces the agent to surface files it would otherwise leave unmentioned.
  4. 4Local hooks that take more than about five seconds get bypassed, so keep the scope check fast and push heavy reasoning to a separate opt-in path.

Why the diff is the right place to catch an agent's mistakes

An agent that runs a plan-act-observe loop has already taken many actions by the time it writes its final summary. The failure that hurts is rarely a syntax error the linter catches. It is an agent that "helpfully" refactors an adjacent module, reformats a config file, or deletes dead code it noticed on the way. The Karpathy guidelines skill, which many teams install into coding agents, makes this the third of its four rules: "Touch only what you must. Clean up only your own mess," and it tells the model not to improve adjacent code or refactor things that are not broken the skill source. Instructions in a prompt are guidance; a hook is enforcement.

The gap between in-session feedback and the committed state is the technical reason to review the diff rather than the conversation. In-session hooks, such as Claude Code's PostToolUse hooks, fire while the agent is working and let it self-correct, but they only run inside that tool and do not gate what lands in repository history the pre-commit analysis. A pre-commit hook runs at git commit against the actual staged content and aborts the commit on a non-zero exit, regardless of whether a human or an agent produced the files Git hooks for AI agents.

That matters because the same principle shows up in code review research: small, focused diffs are reviewed better, and mixing unrelated changes in one change set is the first thing to avoid Pull Request Size Matters. An agent's stray edit is exactly an unrelated change smuggled into your task's diff.

The decision rule is simple. If a human would reject a file in review as "not part of this task," the agent should never have committed it. Encode that rule as a check, not a comment.

The three gates, and what each one catches

You are building three checks that run before git commit completes. They are complementary, not redundant.

GateMechanismCatchesMisses
1. Scope diffgit diff --cached --name-only compared to an allowlist of globsAny path outside the declared task scopeIn-scope files edited for the wrong reason
2. Structured self-critiqueAgent returns JSON: every changed file, reason, in-scope flagIn-scope-but-wrong edits, missing justifications, forgotten filesPaths the agent omits from its JSON unless cross-checked
3. Hook that blockspre-commit exits non-zero on failure from gate 1 or gate 2Any commit where the review did not passIntentional bypass via --no-verify

Gate 1 is deterministic and costs milliseconds. Gate 2 costs a model call. Gate 3 is the policy layer that decides what a failure means: block, warn, or report only. Keep them separate so you can run gate 1 alone on every commit and gate 2 only when a flag is set.

The comparison matters because teams often reach for one of these and assume it covers everything. A path allowlist is fast but blind to intent. A model critique is nuanced but can hallucinate completeness. A blocking hook is the only thing that makes either one matter. Used together, gate 1 shrinks the input to gate 2 to a short, checkable list, which is where the self-critique works best.

Gate 1: compute the changed paths and diff them against an allowlist

Start with what Git already knows. The staged file list is one command, and it is the authoritative set the gate must check:

changed=$(git diff --cached --name-only --diff-filter=ACMRD)

The --diff-filter=ACMRD keeps added, copied, modified, renamed and deleted files and drops anything else. That matters for the rename case: a rename shows up as a delete plus an add, so a scope check on paths catches it either way.

Declare the allowlist per task, not per repo. A task file sitting next to the agent's instructions is a good home for it, and it can be plain text of globs:

# .agent/scope.txt, written when the task is created
lib/api/client.ts
lib/api/client.test.ts

Then a small checker, run from the hook, decides pass or fail. The pre-commit framework is the standard way to version and share Git hooks: you define hooks in .pre-commit-config.yaml, install them with pre-commit install, and every clone gets the same enforcement the pre-commit framework. A local hook in that config that reads scope.txt and matches every staged path is the whole of gate 1.

There is prior art for the matching itself. SpecterOps' pr-size-check is a dependency-free action that counts additions and deletions and supports newline-separated glob patterns in an ignored-paths input, plus an exact, case-insensitive bypass-label and a fail-on-exceed flag for reporting-only mode the action repo. The glob matching and the "exact bypass" idea transfer directly to an allowlist check, even though that tool is for pull request size rather than scope.

Two design choices make gate 1 robust. First, normalize paths before comparing, because git reports paths relative to the repository root and an allowlist written by a human often includes a leading ./. Second, treat a missing allowlist as a failure, not a pass, so that a task that forgot to declare scope cannot sneak edits through. If you need an escape hatch, make it an exact, logged label rather than a wildcard.

A worked example, so the arithmetic is clear. Suppose the task is "add a retry to request() in lib/api/client.ts." The agent stages seven files: the client, its test, package-lock.json, docs/style.md, lib/util/date.ts, lib/api/types.ts and .eslintrc.json. The allowlist has two entries. Gate 1 passes two paths and flags five. Of those five, lib/api/types.ts might be legitimately needed if the retry option changes a type, so the human has one decision to make instead of seven, and the diff the human reads is at most three files.

That reduction is the real payoff. Research summarized on the Better Scientific Software site notes an inverse correlation between change size and defect rate and sets 400 changed lines as the threshold where a review is too large Pull Request Size Matters, and SmartBear's work cited in PR guidance finds review effectiveness drops sharply after 200 to 400 lines, with a table putting 1 to 100 lines in the high quality band at 15 to 30 minutes of review pull request best practices. Cutting a seven file, multi-hundred line diff to three files moves the review from the declining band into the good one.

Gate 2: a self-critique prompt that forces a per-file justification

The scope diff tells you which paths changed. It does not tell you whether the reason is credible. For that, ask the agent to review its own diff with a prompt that makes omission hard.

Two research threads justify spending a model call here. Self-reflection improves LLM problem solving: in a study that had agents reflect on incorrect answers and try again, the improvement was significant at p<0.001 across nine models Self-Reflection in LLM Agents. Reflexion turns environment feedback into verbal self-reflection carried into the next attempt and beats prior methods on Python and Rust code writing tasks the Reflexion guide. The practical lesson is that reflection works best when it has something concrete to reflect on, which for us is the staged diff.

Prompt it as a spec, not a chat message. Coding agents fail for structural reasons: missing scope, no stop condition, unverifiable acceptance criteria the agent prompting guide. Compare a chat-style nudge with a spec style instruction, where the latter names the exact files, the constraints, and the output format the agent prompting guide. And remember that a chatbot prompt breaks the moment the model runs in a loop, because the agent will improvise without a stop condition agent prompt engineering. Here is a prompt shaped for the task:

You are reviewing the diff you are about to commit. Do not change any files.

Task scope (allowlist):
{{scope_list}}

Staged files:
{{changed_paths}}

For every staged file, return one JSON object. No prose outside the JSON.

Schema:
{
  "reviewed_files": [
    {
      "path": "<repo-relative path>",
      "reason": "<one sentence, concrete: what changed and why this task needs it>",
      "in_scope": true | false
    }
  ],
  "out_of_scope": ["<paths>"],
  "untested_behavior": "<what a reviewer should test, or 'none'>",
  "verdict": "approve" | "revise"
}

Rules:
1. List every staged file exactly once.
2. If a file is not on the allowlist, mark in_scope false, even if you think it helps.
3. A reason that only restates the file name is invalid.

Three things make this prompt a gate rather than a formality. The staged file list is injected, so the agent cannot quietly omit a file. The JSON schema enforces one entry per file. The reason field forces a claim that the hook can check for emptiness and that a human can check for plausibility.

The cross-check that closes the omission hole

A model can still return fewer entries than there are staged files, so the hook must reconcile the two sets before trusting the verdict. This is the insight most implementations miss: the self-critique is only as good as the diff it is compared against. Do the reconciliation in code, not in the prompt:

staged = set(subprocess.check_output(
    ["git", "diff", "--cached", "--name-only"], text=True).splitlines())
review = json.loads(run_agent(review_prompt))
reviewed = {f["path"] for f in review["reviewed_files"]}

missing = staged - reviewed
extra = reviewed - staged
if missing or extra or review["verdict"] != "approve":
    print("Self-review failed:", {"missing": missing, "extra": extra})
    sys.exit(1)

The extra check catches a subtler failure: an agent listing a file it did not change. That is usually a sign it is describing a plan rather than the diff. Treat both as failures. In practice this reconciliation is what converts the self-critique from a letter of intent into a checkable assertion.

A cheap fallback when the model call is too slow

The rule of thumb for local hooks is worth repeating because it decides the architecture: if pre-commit hooks take more than five seconds, developers will bypass them Git hooks for AI agents, and the same source, in a different article, sets under five seconds as the target for local hooks and moves heavy analysis to CI AI in Git hooks. A model call is unlikely to finish in that budget on every commit.

The resolution is to run gate 2 only when gate 1 finds nothing, or only with an opt-in flag, and always report the gate 1 result in under a second. If you do call a model in the fast path, use a small fast model for classification and keep the context to the staged paths and the scope file. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key, which makes swapping a fast model in for the pre-commit path a config change rather than an integration project Quanta Inference.

Gate 3: wire the checks into a hook that actually blocks

A hook is only a gate if it aborts. Git aborts a commit when the pre-commit hook exits non-zero, and that is how a shell script that ends in npm run lint && npm test turns a failure into a stopped commit Git hooks for AI agents.

Version the hook so every clone gets it. Hooks in .git/hooks/ are not tracked by version control, so each developer must set them up by hand; either use the pre-commit framework's .pre-commit-config.yaml, which lives in the repo, or keep a .githooks/ directory checked in and point Git at it with git config core.hooksPath .githooks Git hooks for AI agents. The latter is the lower-dependency option:

#!/bin/sh
# .githooks/pre-commit
set -e
python scripts/check_scope.py || {
  echo "Scope check failed. Fix the diff or update .agent/scope.txt."
  exit 1
}
if [ "$AGENT_SELF_REVIEW" = "true" ]; then
  python scripts/self_review.py || exit 1
fi
exit 0

The error message is part of the design. An agent that hits a hook failure reads the output and adjusts before committing again, so the hook becomes codified context that teaches the project's standards through enforcement Git hooks for AI agents. Write the message so it tells the agent the next action: revert the out-of-scope file, or narrow the allowlist if the file is genuinely needed.

Deciding between block, warn and report

Not every check belongs in the blocking path. SpecterOps' checker exposes fail-on-exceed: false for a reporting-only mode alongside the default of failing the action repo, and the same two-mode design applies here. Use these rules:

  • Block when a path is outside the allowlist, because that is the failure you are specifically trying to stop and it costs milliseconds to detect.
  • Warn when the self-critique flags an in-scope file as weakly justified but the path list is clean, because a plausible reason is a judgment call for the human.
  • Report only for generated files, lock files and vendored paths, which agents legitimately touch and which would otherwise flood the gate.

Generated boilerplate is its own category. Agents sometimes leave template markers like TODO: or {{SERVICE_NAME}} in output, which a markdown linter will not catch because the text is syntactically valid the pre-commit analysis. Add that check to the warning layer and you get most of the value of a formal contract without blocking on ambiguous cases.

Tuning the prompt with the same skills that shape agent behavior

The self-critique prompt is the part of this pipeline you will tune most. Two habits shorten the loop.

First, adopt the review checklist mindset humans should already be using. A self review before pushing asks whether the change is in scope, whether the tests and docs it implies were added, and whether anything was added that was not in scope better pull request reviews. The last two questions are exactly what gates 1 and 2 enforce. The checklist also advises closing the loop with a test that fails before the change and passes after, which is the success criterion the Karpathy guidelines call goal-driven execution: turn "add validation" into "write tests for invalid inputs, then make them pass" the skill source.

Second, let the reasoning stay visible but bounded. Reflexion's self-reflection role uses the reward signal and the trajectory to produce specific feedback rather than generic advice the Reflexion guide. Translate that into the prompt: ask for specific feedback, such as "this file is flagged because the task did not mention it," not a grade. Specific language is what later becomes a rule in scope.txt or a new check.

If you are maintaining this as a shared asset, the skills pattern is worth a look. The Karpathy guidelines skill is a plain Markdown SKILL.md that any agent reading the Agent Skills format can load on demand, including via a folder copy into a skills directory, and it is provider-agnostic instructions rather than code the skill listing. A self-review skill that ships the prompt, the JSON schema and the reconciliation checklist in that format travels across coding agents without a rewrite. Quanta Skills is a library of those installable skills that you can put into a coding agent, or hand the job to an expert Quanta Skills.

Measuring whether the gate earns its keep

A gate you cannot measure is a gate you will disable at the first slow build week. Track three numbers, all derivable from the hook's log output.

The first is the share of commits where gate 1 fires. A rate near zero means the allowlists are guessing or the prompt is already tight. A rate that climbs after a prompt change means the change loosened scope, and you can tie the change to the rate.

The second is time to block, measured on the p50 and p95 commits that fail. This is the number that determines whether developers keep the hook installed. The under-five-seconds threshold cited above applies to the whole local hook chain, not just your check AI in Git hooks. If gate 2 pushes past it, split it out.

The third is the size of what reaches review after the gate passes. Aim to move diffs into the band research associates with high review quality: 1 to 100 changed lines at 15 to 30 minutes of review, with effectiveness declining after 200 to 400 pull request best practices. A clean gate that still passes a 900 line diff has not solved the problem, only relabeled it.

Finally, log the reasons, not just the pass or fail. When five commits in a row fail on the same out-of-scope file, the fix is usually one of three: add the path to the task template, tighten the agent's instructions, or add a warning layer for that path class. That feedback loop is the same one Reflexion describes, applied to your own gate rather than the model the Reflexion guide.

FAQ

FAQ

Does the self-critique replace human review?

No. It filters the diff so the human sees a smaller, justified set of files. The reasons it writes are claims to verify, not verdicts to accept. Human review is still what catches an in-scope but wrong edit, and the whole point of the gate is to spend that review on the right lines.

What happens when the agent legitimately needs to touch a file outside the allowlist?

The commit is blocked and the agent reports the path. The human then either adds the path to scope.txt, which is a one-line change that goes through normal review, or asks the agent to revert it. Making the allowlist update an explicit act prevents the agent from expanding its own scope, which is the failure mode the gate exists to stop.

How do I keep the gate from slowing every commit?

Run the deterministic scope check on every commit, since it is a glob match over a file list and finishes in milliseconds, and run the model-based self-critique only when an environment flag is set or when gate 1 is clean. The five-second ceiling for local hooks is the constraint that forces this split AI in Git hooks.

Can a developer bypass the hook?

Yes, git commit --no-verify skips hooks, and that is true of any pre-commit gate. The mitigation is to make the same check a required status check in CI so a bypassed local commit still fails before merge. Treat the local hook as the fast path and the server-side check as the backstop.

Which parts of this can I install as a skill instead of writing from scratch?

The self-critique prompt, the JSON schema and the reconciliation checklist are good candidates for a reusable skill, because they are instructions rather than code and work across agents, as the Karpathy guidelines skill does the skill listing. The scope diff checker itself stays as a repo script, since it needs to read your task's allowlist and call Git.

Sources

Sources

  1. skills - karpathy-guidelines - GitHub
  2. Pre-Commit Hooks for Agentic Output Validation
  3. Git hooks are your best defense against AI-generated mess
  4. Pull Request Size Matters
  5. SpecterOps/pr-size-check
  6. Pull Request Best Practices: A Complete Guide (2026)
  7. Self-Reflection in LLM Agents: Effects on Problem-Solving ...
  8. Reflexion
  9. The Complete Guide to Prompting AI Coding Agents (2026)
  10. What is Agent Prompt Engineering? - by Paolo Perrone
  11. Using AI in Git Hooks for Pre-Commit Checks
  12. Better Pull Request Reviews - Pratik's Pakodas - Substack
  13. Karpathy Guidelines by multica-ai, AI Skill for Any LLM | TypingMind
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles