AIエージェントが作った変更を、コミットする前にレビューへ通すようにするには、「レビューを受けること」という規則を書くよりも、コミットのコマンドそのものをフックで横取りして、「最後のコミットの後にレビューエージェントの呼び出し記録があるか」をセッション記録で確認するほうが確実です。Claude CodeのPreToolUseフックは、Bashツールが実行される直前に、コマンドとセッション記録のパスを受け取るので、git commit で、対象が規則・フックのファイルなら、記録を読んで、レビューの呼び出しがないときに deny を返せばよいのです。誤字のような小さな修正は、コミットメッセージに [verifier-skip: 理由] を書いて通しますが、その文字がコミット履歴にそのまま残るようにします。
この記事は、一人で使う開発環境をAIエージェント中心に変えながら(AX、AI転換)作った仕組みを、1日に1つずつ整理する連載の第4回です。前回はサブエージェントの報告を300字に絞るフックでした。
「レビューは必ず通す」という規則は、なぜ守られなかったのか?
私の指針リポジトリには、エージェントの働き方を決めた規則文書と、その規則を機械に守らせるフックのスクリプトが、一緒に入っています。これらのファイルは、1行間違えただけでも、その後のセッションすべてに影響するので、「規則・フックを変えるコミットは、先にレビューエージェント(verifier)を通す」という規則を、早くから置いていました。
問題は、規則が文としてしかなかったことです。エージェントは、自分の変更を「小さな修正」と判断すると、レビューを飛ばして、すぐにコミットしました。人が見て小さくない変更も多くありました。規則をより強い言葉で書き直しても、同じことが繰り返され、2026-10-18に「文ではだめだ、機械で確認しよう」と方向を変えました。
gitのpre-commitフックでは、なぜだめなのか?
最初に思い浮かんだ場所は、gitのpre-commitフックでした。コミットの直前に動き、0でないコードで終われば、コミットが止まるので、自然な選択です。ところが、gitフックは、「レビューエージェントが動いたか」を知る術がありません。その事実は、gitではなく、エージェントのセッションの中にあります。
Claude Codeは、セッションのすべてのツール呼び出しを、JSON Linesファイル(.jsonl)として残し、PreToolUseフックにそのファイルのパス(transcript_path)を一緒に渡してくれます。ですから、コミットのコマンドを、gitではなくエージェントの側で横取りすれば、同じセッションで今までにどのツールを呼んだかを、すべて読めます。レビューがあったかを知っている側で止めるのが正しかったのです。
フックは、何をどう確認するのか?
確認の順序は3段階です。1つ目は、コマンドに含まれるgit呼び出しを分けて、commit があり、対象のリポジトリが指針リポジトリかを見ます(git -C パス commit のようにパスを渡した形も一緒に捉えます)。2つ目は、今回のコミットに入るファイルを集めます。すでにステージングされたものに、同じコマンドの git add の引数を足します。-A や . なら、変わったファイルすべてとみなします。そのうち、規則文書・出力形式・フックフォルダ・設定同期スクリプトのような「レビュー対象のパス」が1つでもあれば、3つ目へ進みます。3つ目は、セッション記録で、最後に成功した git commit と、最後のレビューエージェント呼び出しの順序を比べます。
核心の3つ目の段階だけを短くすると、こうなります。
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
失敗したツール呼び出し(is_error)を除く行が重要です。レビューの後にコミットを一度試みて、別の理由で失敗した場合、その失敗したコミットのせいで、「レビューがコミットより前にある」と誤って判定してはならないからです。今まさに横取りしている呼び出し自体(current_id)も除きます。
レビューとして認めるエージェント名は、正規表現で3つだけにします。名前に「verify」や「review」が入っていれば何でも認めると、似た名前のskill呼び出しで通り抜ける道ができてしまいます。
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)")
フックの登録は、普通のPreToolUseと同じです。拒否するときは、permissionDecision を deny で返し、理由を文として一緒に送ります。エージェントはその文を読んで、レビューエージェントを呼んでから、もう一度コミットします。
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/cc-guardrail.py\"", "timeout": 10 }] }
]
}
}
作った日に、レビューエージェントは何を見つけたのか?
このフック自体も規則・フックのファイルなので、作った日に、先にレビューエージェントに見せました。2026-10-18のコミット本文に残っている指摘は3つでした。無人の自動化が回すコミットは、セッションにレビュー呼び出しがないので、すべて拒否されること、フックの入力に今の呼び出しのIDがない場合、今のコミットが「最後のコミット」として捉えられ、判定が間違うこと、そしてskill方式の検証呼び出しをどう数えるか、でした。3つを反映してから、はじめてコミットしました。レビューを強制する仕組みが、最初のレビューで3つの穴をあらわにしたわけで、この方式が必要だという確信が、かえって固まりました。
その後も、2回締めました。2026-10-21には、Claudeのレビューエージェントの記録を先に求め、別のモデルに尋ねる交差検証は、その後にだけ認めるように変えました。2026-10-22には、大きくなっていたガードスクリプトを検査の種類ごとのモジュールに分け、この検査は161行のファイル1つになりました(2026-10-22時点で、ガードフォルダのファイルは22個)。
数字はどう出したのか?
フックを入れた2026-10-18から2026-10-22まで、指針リポジトリのコミットを、git log で数えてみました。全体289件のうち、レビュー対象のパスに触れたコミットが150件で、そのうち38件が、[verifier-skip: 理由] の印で通りました。整合性の自動復旧の印([auto-fix])は2件です。残りの110件は、レビューエージェントの記録がなければコミットできなかったものです。
38件は少なくなく見えるかもしれませんが、印には必ず理由を書かせています。そのため、「なぜ飛ばしたのか」を、後から git log 1回でもう一度見られます。飛ばすこと自体を止めるよりも、飛ばした理由が履歴に残るほうを選びました。
今の1日はどう変わったのか?
規則・フックのファイルを変えるコミットの前には、今では常に、レビューエージェントの呼び出しが1回挟まります。エージェントが「小さな修正」と判断しても、フックはその判断を問わず、記録だけを見ます。今日このブログを書く直前にも、ガードモジュールを1行直しましたが、レビューエージェントを立ち上げるのに2回失敗しました。その状態ではコミットが拒否されるので、レビューが実際に動いた後でなければ、コミットできません。不便ですが、それがこのフックを置いた理由です。
代償が1つあります。セッション記録を読めなければ、フックは止めずに通します(fail-open)。記録ファイルが壊れたという理由で、すべてのコミットが止まるよりはましだと判断しましたが、その隙間があることを承知で使っています。
よくある質問
gitのpre-commitフックと一緒に使えますか?
一緒に使えて、役割が違います。gitフックは、リントやテストのようにファイルだけを見て判定できる検査に向き、このフックは、「セッションでレビューエージェントを呼んだか」のように、gitが知らない事実を見ます。ただし、--no-verify でgitフックを飛ばすことまで防ぎたければ、それも同じPreToolUseフックで、コマンドの文字列で拒否すればよいのです。
エージェントが別の形のコマンドで回避しませんか?
git commit という文字だけを探すと、git -C パス commit や cd パス && git commit を見逃します。そのため、コマンドを &&・;・| で分けてから、断片ごとにgit呼び出しとサブコマンドを別々に解釈します。それでも新しい形が出てくれば、テストの事例として追加します。この検査には、「レビューなしのコミット」「レビュー後のコミット」「レビュー後のコミットの後にもう一度コミット」「印で通過」の4つのテストが付いています。
レビューエージェントが合格させたかも確認しますか?
いいえ。フックが見るのは「呼び出し記録があるか」だけで、レビューの結果を読んで合否を判定することはしません。結果を反映して直すのは、エージェントと人の役目としました。フックがレビューの結果まで解釈し始めると、判定が間違ったときに、直すのがはるかに難しくなります。