To make changes made by an AI agent go through review before they are committed, it is more reliable to intercept the commit command itself with a hook and check the session log for “is there a review-agent call since the last commit?” than to write a rule that says “get reviewed.” A Claude Code PreToolUse hook receives the command and the session log path just before the Bash tool runs, so if it is a git commit and the target is a rules or hook file, the hook reads the log and returns deny when there is no review call. Small fixes such as typos pass if you write [verifier-skip: reason] in the commit message, but that text stays as is in the commit history.
This post is the fourth in a series that covers, one per day, the devices I built while turning my solo development environment around AI agents (AX, AI transformation). The last one was a hook that cuts subagent reports to 300 characters.
Why was the rule “always go through review” not followed?
My guidelines repository holds both the rules documents that set how agents work and the hook scripts that make a machine enforce those rules. A single wrong line in these files affects every session afterwards, so I set up early the rule that “a commit that changes rules or hooks goes through the review agent (verifier) first.”
The problem was that the rule existed only as a sentence. When an agent judged its own change to be a “small fix,” it skipped review and committed right away. Many of the changes were not small by a person’s standard. Rewriting the rule in stronger words did not stop it from happening again, and on 2026-10-18 I changed direction to “sentences do not work; let a machine check.”
Why not a git pre-commit hook?
The first place that came to mind was git’s pre-commit hook. It runs just before a commit and blocks the commit if it ends with a non-zero code, so it is a natural choice. But a git hook has no way of knowing “did the review agent run?” That fact lives not in git but inside the agent session.
Claude Code leaves every tool call of a session in a JSON Lines file (.jsonl) and passes that file path (transcript_path) to the PreToolUse hook. So if you intercept the commit command on the agent’s side instead of git’s, you can read everything about which tools were called so far in the same session. It was right to block on the side that knows whether review happened.
What does the hook check, and how?
The checks come in three steps. First, it splits the git calls in the command and looks for a commit whose target repository is the guidelines repository (it also catches forms with a path, such as git -C path commit). Second, it collects the files that go into this commit: those already staged, plus the arguments of the git add in the same command. If it is -A or ., it counts all changed files. If even one of them is a “review-target path,” such as a rules document, an output format, the hooks folder or the settings-sync script, it moves on to the third step. Third, it compares the order of the last successful git commit and the last review-agent call in the session log.
Shortened to just the core third step, it looks like this.
def reviewed_since_last_commit(transcript, current_id):
uses, failed = [], set()
for line in open(transcript, encoding="utf-8"):
for c in (json.loads(line).get("message") or {}).get("content") or []:
if c.get("type") == "tool_use" and c.get("id") != current_id:
uses.append(c)
elif c.get("type") == "tool_result" and c.get("is_error"):
failed.add(c.get("tool_use_id"))
last_review = last_commit = -1
for i, c in enumerate(uses):
if c["name"] == "Agent" and REVIEW_AGENT_RE.search(c["input"].get("subagent_type", "")):
last_review = i
elif c["name"] == "Bash" and c["id"] not in failed and re.search(r"\bgit\b[^;&|]*\bcommit\b", c["input"].get("command", "")):
last_commit = i
return last_review > last_commit
The line that drops failed tool calls (is_error) matters. If I tried a commit once after review and it failed for another reason, that failed commit must not cause a wrong judgment that “the review comes before the commit.” It also drops the very call being intercepted now (current_id).
For the agent names accepted as review, I allow only three through a regular expression. If I accepted everything that has “verify” or “review” in its name, there would be a way to pass through a skill call with a similar name.
REVIEW_AGENT_RE = re.compile(r"^(oh-my-claudecode:)?(verifier|code-reviewer|security-reviewer)$")
GATED_RE = re.compile(r"^(AGENTS\.md|claude-global\.md|output-styles/|scripts/hooks/|scripts/git-hooks/|scripts/sync-settings\.py)")
Registering the hook is the same as an ordinary PreToolUse. When it rejects, it returns permissionDecision as deny and sends the reason as a sentence. The agent reads that sentence, calls a review agent and then commits again.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/cc-guardrail.py\"", "timeout": 10 }] }
]
}
}
What did the review agent catch on the day I built it?
This hook is itself a rules or hook file, so on the day I built it, it was shown to a review agent first. The comments left in the 2026-10-18 commit body were three. First, commits run by unattended automation have no review call in the session and would all be rejected. Second, when the hook input lacks the ID of the current call, the current commit is taken as the “last commit,” so the judgment is wrong. Third, how to count a verification call made the skill way. I committed only after reflecting all three. The device that forces review exposed three holes on its first review, which actually firmed up my conviction that this approach is needed.
I tightened it twice more after that. On 2026-10-21, I changed it to require a Claude review-agent record first and to accept cross-verification, asking another model, only after that. On 2026-10-22, I split the growing guard script into modules by kind of check, so this check became a single 161-line file (as of 2026-10-22, the guard folder has 22 files).
How were the numbers obtained?
I counted the guidelines-repository commits with git log from 2026-10-18, when I put the hook in, through 2026-10-22. Of 289 commits in total, 150 touched review-target paths, and 38 of those passed with the [verifier-skip: reason] marker. The automatic consistency-repair marker ([auto-fix]) accounts for 2. The remaining 110 are commits that could be made only with a review-agent record.
38 may look like a lot, but I made the marker require a reason. So “why was it skipped?” can be looked at again later with a single git log. Rather than blocking skipping itself, I chose to have the reason for skipping stay in the history.
How has my day changed?
Before a commit that changes rules or hook files, a review-agent call now always steps in once. Even if the agent judges it a “small fix,” the hook does not ask about that judgment and looks only at the log. Just before writing this post today, I also edited one line of a guard module, and I failed twice to bring up a review agent. In that state the commit is rejected, so I can commit only after the review has actually run. It is inconvenient, but that is the reason for having this hook.
There is one cost. If it cannot read the session log, the hook does not block and lets it pass (fail-open). I judged that better than having every commit stop because the log file is broken, but I use it knowing that gap exists.
Frequently asked questions
Can I use it together with git’s pre-commit hook?
You can use both, and their roles differ. A git hook suits checks that can be decided by looking only at files, such as lint and tests, while this hook looks at facts git does not know, like “was a review agent called in the session?” If you also want to block skipping the git hook with --no-verify, you can reject that in the same PreToolUse hook by the command string.
Won’t the agent get around it with a different form of the command?
If you only look for the text git commit, you miss git -C path commit and cd path && git commit. So I split the command at &&, ; and | and interpret the git call and subcommand of each piece separately. If a new form still appears, I add it as a test case. This check comes with four tests: “commit without review,” “commit after review,” “commit after review, then commit again” and “pass with a marker.”
Does it also check whether the review agent passed it?
No. All the hook sees is “is there a call record,” and it does not read the review result to judge whether it passed. Fixing things in response to the result is left to the agent and the person. If the hook starts interpreting the review result too, it becomes much harder to fix when the judgment is wrong.