二重実行しない Coding Agent の commit 検証

Claude Code に commit 前のチェックをスキップされたので、commit 検証を入れました。その過程で、よくある pre-commit の定番と少し違うやり方を採用したので紹介します。

目次

目次


背景

最近、自作 CDK を開発しています。

AWS CDK は裏で CloudFormation が動きますが、こちらでは CloudFormation でなく AWS SDK でリソースが構築されるように、CDK CLI 自体と CloudFormation のエンジン部分をフルスクラッチしています。おかげで爆速なデプロイが実現できています。

github.com


この cdkd における開発中、Claude Code に「commit 前に必ず /check skill (typecheck / lint / build / tests) を走らせる」というルールを設けていました。CLAUDE.md だけに書いていて、サボって hook (PreToolUse hook) を使っておらず、Claude Code が一度スキップして commit してしまう場面に遭遇しました。


やはりルールに書くだけでは限界があるので、機械的に止める仕組み (= commit 検証) を入れることにしました。ただ、hook で lint などのコマンドを直接叩く定番の方法とは少し違うアプローチを採用しました。


hook がコマンドを実行する方式

まずは、いわゆる定番のアプローチのお話からです。

Claude Code の PreToolUse hook に限らず、husky や lefthook などの git hooks でも、hook の中で lint/test などの検証コマンドを直接実行して、失敗したら commit を止めるというやり方が定番です。シンプルで分かりやすく事例も豊富です。


最初に cdkd で検討したのも、この方式でした。Coding Agent に commit まで任せる運用なので、Claude Code 経由の tool 呼び出しに発火する PreToolUse hook を選択しました。

// .claude/settings.json の例
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "pnpm run lint && pnpm test",
        "if": "Bash(git commit*)"
      }]
    }]
  }
}


二重実行の問題

しかし cdkd の場合、プロジェクト共通の手順としてすでに /check (typecheck / lint / build / tests) という skill が定義されていました。

また、その /check skill を、実装用の skill の中でも自動で走らせるようにしていました。さらに、開発者(人間)が手動で走らせることもあります。


その場合、hook の中で検証コマンドを直接実行するようにすると、このような状況が発生します。

  • Claude Code が実装 skill のフロー内で /check を走らせた → 続けて Claude Code 経由で git commit → hook でもう一度走る
  • 開発者が commit 前に手動で /check を走らせた → 続けて Claude Code 経由で git commit → hook でもう一度走る


つまり、hook で検証コマンドを実行するケースでは、毎コミットで同じ検証コマンドを 2 回実行することになるわけです。

昨今の linter / formatter やテストツールの処理はどんどん高速化されて数秒で済んでいるのですが、Coding Agent とのイテレーションでは commit 回数が増えがちなので、その数秒も積み重なるとバカになりません。そのため、やはり「さっき実行したのに」と無駄に感じてしまう部分がありました。(もちろん各種テストツールなどのキャッシュ機構はありますが...)


hook でカバーしたいのは「Claude Code が skill をスキップしたケース」だけです。スキップしなかった大多数のケースのために、毎回二重実行のコストを払うのは避けたいと考えました。


マーカーで確認する方式

そこで、hook の責務を 「検証コマンドを実行する」から「skill が検証コマンドを成功させたかどうかを確認する」 に変えました。


仕組みはシンプルで、/check skill が成功した時に 「そのときの状態を表すハッシュ」 (現在の HEAD SHA + 未コミット変更の content hash) を一時ファイル (マーカー) に書いておき、hook は現在の状態と照合するだけです。

状態が変わる (commit で HEAD が進む、あるいは新しい編集が入る) と、ハッシュも変わってマーカーは自動で無効になります。

  1. /check skill が検証コマンドを実行
    • 全 pass なら /tmp/cdkd-check-marker.json{ HEAD SHA, content hash } をマーカーとして書く
  2. git commit (Claude Code 経由)
    • PreToolUse hook がマーカーを読んで現状と照合
    • 一致なら allow
    • マーカーが無い / 古い場合は exit 2 で「先に /check を走らせて」と止める


このアプローチの場合、hook 自体は検証コマンドを一切走らせません。そのため、skill 側で実行済みの検証コマンドが二重に走ることはありません。

つまり、キャッシュのようなアプローチになります。

発想自体は、nx や turborepo のビルドキャッシュ、SLSA の CI attestation などと通ずるところがあります。これらの発想をローカルの commit 検証に落とし込んだ形です。


hook の中身

# .claude/hooks/check-gate.sh (抜粋)

MARKER=/tmp/cdkd-check-marker.json

# git commit でなければスルー
cmd=$(jq -r '.tool_input.command // ""')
printf '%s' "$cmd" | grep -qE '\bgit[[:space:]]+commit\b' || exit 0

# 現状の content hash を計算
head=$(git rev-parse HEAD)
content=$({
  git diff HEAD --name-only
  git ls-files --others --exclude-standard
} | sort -u | while IFS= read -r f; do
  if [ -f "$f" ]; then
    printf 'FILE:%s\n' "$f"; cat "$f"
  else
    printf 'DEL:%s\n' "$f"
  fi
done | shasum -a 256 | cut -c1-16)
current=$(printf '{"head":"%s","content":"%s"}' "$head" "$content")

# マーカーと照合
[ -f "$MARKER" ] || { echo "run /check first" >&2; exit 2; }
[ "$current" = "$(cat "$MARKER")" ] || { echo "state changed since /check" >&2; exit 2; }


content hash は git diff HEAD と untracked ファイルの両方を拾って計算しています。こうすることで、git add の前後で値が変わらないようにしています。

また、HEAD が動けば自動的にマーカーが古くなるため、TTL の管理は不要です。

実際の実装は cdkd の PR をご覧ください。


定番方式との比較

ケース hook がコマンドを実行する方式 マーカーで確認する方式
開発者が手動で /check 済 → commit 再実行 (二重) 即 commit
agent が skill で /check 済 → commit 再実行 (二重) 即 commit
agent が /check をスキップ → commit hook 内で検証実行 commit 拒否 → agent が /check を実行して再 commit

どちらの方式も「/check をスキップしたケース」で、検証をせずにコミットすることは防げますが、差が出るのは「/check 済みケース」です。マーカー方式なら検証コマンドは 1 回しか走らず、二重実行の無駄がありません。


hook に全部寄せない理由

hook だけで検証コマンドを完結させて、実装などの skill 内での検証を廃止する選択肢もありますが、cdkd ではそうしませんでした。

というのも、commit というタイミング以外にも、開発イテレーションの途中でチェックを走らせたい場面があるからです。

  • 実装用の skill の終わりに /check を自動で走らせる (まだ commit はしないが、実装が終わった時点で一旦動作確認したい)
  • 開発者 (人間) が手動で /check を叩く (デバッグ中や PR 前の確認など)

Claude Code の hook は commit 以外のイベントにも設定できますが、「実装 skill の中で一緒に検証したい」といったモチベーションもあったため、このような構成にしています。


他のユースケースへの応用パターン

今回の構造は、Coding Agent による skill のスキップを防ぎつつ、複数の場面で叩かれうる skill の二重実行を避けたい場面に応用できます。

  • PR 作成の前にドキュメント整合性をチェック (/check-docsgh pr create)
  • image の push 前に脆弱性スキャン (/security-scandocker push)
  • push 前にカバレッジレポート生成 (/coveragegit push)


最後に

あまり規模の大きくないプロジェクトであれば、定番の「hook でコマンドを直接実行する方式」でも十分だと思います。しかし、少しでも待ち時間を削減したかった、単に無駄を感じていた、他のユースケースにも応用できそう、といった理由で、今回の「マーカーで確認する方式」を採用しました。


追記: OSS 化しました

この方式を特定のプロジェクトに依存しない汎用的なマーカーゲート CLI として OSS 化しました。詳しい使い方や設計の話は別記事にまとめています。

go-to-k.hatenablog.com

github.com