最近、「Claude Code Hooks」が話題になっていますね!どうやら、Hooksを使うと、AIエージェントがテストを飛ばしたりLint違反を無視して作業完了してしまう問題を、Exit Codeを使って決定論的にブロックできるようです!
そこで今回は、Blogger環境で「Claude Code Hooks」のPostToolUse/Stop Hookの実装を行ってみました!実際にsettings.jsonとシェルスクリプトを書いて、テスト未実行やLint違反時にAIの作業完了を強制ブロックする仕組みを完全解説します。中級者向けの内容ですが、コード全文を記載していますので、ぜひ皆さんも記事を読んで試してみてください!
この記事で分かること
- Claude Code HooksのExit Code(0/1/2)の違いと、それぞれがClaudeの挙動にどう影響するか
- PostToolUse Hookでファイル編集後にLintを自動実行するsettings.jsonとシェルスクリプトの実装
- Stop Hookでテスト未実行時にAIの完了を強制ブロックする仕組みと、よくある失敗パターンの回避策
なぜCLAUDE.mdの指示だけではテストが実行されないのか
結論から言うと、CLAUDE.mdに「テストを実行してから完了すること」と書いても、AIエージェントはそれを確率的にしか守りません。これはClaude Codeのアーキテクチャに起因する構造的な問題です。
CLAUDE.mdの内容は、システムプロンプトではなくユーザーメッセージとしてClaudeに配送されます。つまり、ClaudeはCLAUDE.mdの指示を「参考情報」として読みますが、それを実行するかどうかはLLMの確率的な判断に委ねられています [web:23]。コンテキストウィンドウが長くなったり、タスクが複雑になったりすると、指示の優先順位が下がり、テスト実行がスキップされる確率が上がります。
これが「確率的遵守の問題」です。LLMは確率モデルなので、同じ指示を与えても実行されたりされなかったりします。100回中99回テストが実行されても、1回スキップされれば本番環境にバグが混入するリスクがあります。
Claude Code Hooksでできること
- ファイル編集直後にLintやフォーマットを自動実行する(PostToolUse Hook)
- AIがタスク完了を宣言するタイミングでテスト未実行を検知し、完了を強制ブロックする(Stop Hook)
- Exit Code(0/1/2)を使って、AI側の判断ではなくシェルの終了コードで確実に挙動を制御する
ここで、「CLAUDE.mdに書くのとHooksで設定するのは何が違うのか?」という疑問が出てくると思います。
大きな差としては、「LLMの確率的判断ではなく、OSレベルの終了コードで決定論的に挙動を制御できる」ことです。これにより、AIがテストを忘れていても、シェルスクリプトがexit code 2を返せば、Claude Codeは機械的にそのアクションをブロックします [web:1]。
今話題の、「Claude Code Hooks」によるテスト強制というやつですね!
比較すると、以下のようになります。
| 項目 | CLAUDE.mdの指示 | Hooks + Exit Code |
|---|---|---|
| 遵守メカニズム | LLMの確率的判断 | OSの終了コード(決定論的) |
| テスト未実行時の挙動 | スキップされる可能性がある | exit code 2で確実にブロック |
| チーム全体への適用 | 各自の読み次第 | .claude/settings.jsonをGit管理で強制可能 |
「Hooksの設定って難しそう…」と感じるかもしれませんが、この記事を読めば初心者の方でも大丈夫!順を追ってExit Codeの仕組みから実装まで解説します。
Exit Codeによる制御の仕組み
Claude Code Hooksにおける最重要概念はExit Code(終了コード)です。Hookスクリプトが返す終了コードによって、Claude Code側の挙動が機械的に切り替わります [web:1][web:4]。この仕組みを誤解していると「設定した気になっていたのに何も守られていなかった」という状態になります。
各Hookイベント(PreToolUse、PostToolUse、Stop等)は、登録されたシェルコマンドを実行し、その終了コードを受け取ります。終了コードは0・1・2の3種類があり、それぞれ以下のような意味を持ちます [web:1][web:12]。
Exit Code 0:正常終了(PROCEED)
Hookが正常に完了したことを示します。Claude Codeはそのまま処理を継続します。PostToolUseの場合は次のツール実行へ、Stopの場合はエージェントのセッションを終了します。標準出力の内容はClaudeに渡され、追加のコンテキストとして扱われます [web:1]。
Exit Code 1:非ブロッキングエラー(WARN)
エラーが発生したことを示しますが、重要な点として、Exit Code 1ではアクションはブロックされません [web:1]。Claude Codeは標準エラー出力の内容をユーザーに表示しつつ、処理をそのまま継続します。つまり、Exit Code 1を返しても「テスト未実行をブロックする」ことはできません。ここが最もよく誤解されるポイントです [web:4]。
Exit Code 2:ブロッキングエラー(BLOCK)
Claude Code Hooksにおける最も強力な制御信号です。Exit Code 2を返すと、対象のアクションが強制的にブロックされます [web:1]。PreToolUseであればツールの実行自体がキャンセルされ、Stop Hookであれば「完了」宣言が拒否され、Claudeは作業を継続するように強制されます [web:12]。stderrの内容はClaudeにフィードバックとして渡されるため、Claudeは「なぜブロックされたか」を理解して次の行動を調整できます。
三つのExit Codeの違いを整理すると、以下のようになります。
| Exit Code | 意味 | Claudeの挙動 | stderrの扱い |
|---|---|---|---|
| 0 | 正常終了 | 処理を継続(PROCEED) | stdoutと共にClaudeへ渡す |
| 1 | 非ブロッキングエラー | 処理を継続(警告のみ) | ユーザーに表示、Claudeには渡さない |
| 2 | ブロッキングエラー | アクションを強制ブロック | エラーメッセージとしてClaudeへ渡す |
つまり、テスト強制を実現するにはExit Code 2を使う必要があります。Exit Code 1ではブロックできないため、「Hookを設定したのにテストがスキップされる」問題が発生します [web:4]。この違いを理解することが、本記事の核心です。
PostToolUse Hookでファイル編集後にLintを自動実行する実装
PostToolUse Hookは、Claude Codeがツール(ファイル編集等)を実行した直後に自動発火するHookです。これを利用して、ファイルが編集されるたびにLintを自動実行し、違反があればClaudeにフィードバックを返す仕組みを実装します [web:27]。
実装はsettings.jsonのHook定義と、実行されるシェルスクリプトの2ファイルで構成されます。
手順1:settings.jsonにPostToolUse Hookを定義する
プロジェクトルートに.claudeディレクトリを作成し、settings.jsonを配置します。PostToolUse Hookは、tool_nameがWriteまたはEditの場合に発火するようにマッチングを設定します [web:1]。
// ファイル名: .claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/post-lint.sh"
}
]
}
]
}
}
matcherフィールドは正規表現でツール名をマッチングします。上記の例ではWriteツールとEditツールの両方にマッチさせます。PostToolUseの場合、Hookに渡されるJSON入力には編集されたファイルパス(tool_input.file_path)が含まれます [web:1]。
手順2:Lint実行スクリプトを作成する
次に、PostToolUse Hookから呼び出されるシェルスクリプトを作成します。このスクリプトは標準入力からJSONを受け取り、編集されたファイルに対してLintを実行します [web:27]。
基本的に方法Aがおすすめです。この後の説明も、方法Aを元に行います。
方法A:ESLintを直接実行する(おすすめ)
Node.jsプロジェクトを想定し、編集されたファイルに対してESLintを実行します。Lint違反があればexit code 2を返し、Claudeに修正を促します。
# ファイル名: .claude/hooks/post-lint.sh
#!/usr/bin/env bash
set -euo pipefail
# 標準入力からJSONを読み取る
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# ファイルパスが取得できなければ正常終了
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# .js/.ts/.jsx/.tsxファイルのみLint対象
if ! echo "$FILE_PATH" | grep -qE '\.(js|ts|jsx|tsx)$'; then
exit 0
fi
# ESLintを実行
LINT_OUTPUT=$(npx eslint "$FILE_PATH" 2>&1) || true
# Lint違反の有無を判定
if echo "$LINT_OUTPUT" | grep -q "error"; then
echo "Lint errors detected in $FILE_PATH:" >&2
echo "$LINT_OUTPUT" >&2
exit 2
fi
exit 0
方法B:Pythonプロジェクト向け(お試し向け)
Pythonプロジェクトの場合は、ruffまたはflake8を使います。構造は同じで、対象ファイルの拡張子判定とLintコマンドを差し替えます。
# ファイル名: .claude/hooks/post-lint-python.sh
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
if ! echo "$FILE_PATH" | grep -qE '\.py$'; then
exit 0
fi
LINT_OUTPUT=$(ruff check "$FILE_PATH" 2>&1) || true
if echo "$LINT_OUTPUT" | grep -qE 'error|Error'; then
echo "Lint errors detected in $FILE_PATH:" >&2
echo "$LINT_OUTPUT" >&2
exit 2
fi
exit 0
手順3:スクリプトに実行権限を付与して動作確認
作成したスクリプトに実行権限を付与します。
chmod +x .claude/hooks/post-lint.sh
chmod +x .claude/hooks/post-lint-python.sh
Claude Codeでファイルを編集すると、自動的にLintが実行され、違反があればexit code 2が返ってClaude側に「Lint errors detected in...」というメッセージがフィードバックされます。Claudeはこのメッセージを受け取り、自動的にLint違反を修正しようとします [web:27]。これにより、手動でLintを実行して指摘する手間が消えます。
Stop Hookでテスト未実行時に完了をブロックする実装
Stop Hookの仕組みと設定
Stop Hookは、Claude Codeがタスク完了を宣言するタイミングで発火するHookです。ここでexit code 2を返すと、Claudeの完了宣言が拒否され、作業を継続するように強制されます [web:1][web:12]。これにより、「テストを実行していないのに完了した」という状況を機械的に防げます。
ただし、これも私は仕組みを理解しているだけで、以下のように設定ファイルを書くだけです。
// ファイル名: .claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/post-lint.sh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/stop-check-tests.sh"
}
]
}
]
}
}
Stop Hookのmatcherは空文字列で全てにマッチします。Stopイベントには特定のツール名フィルタが存在しないため、空文字で全Stopイベントを捕捉します [web:1]。
テスト実行チェックスクリプト
Stop Hookから呼び出されるスクリプトは、テストが実行されたかどうかを判定し、未実行の場合はexit code 2でブロックします。判定方法はプロジェクトの構成によりますが、以下はタイムスタンプ比較方式の実装例です。
# ファイル名: .claude/hooks/stop-check-tests.sh
#!/usr/bin/env bash
set -euo pipefail
# テスト成果物ディレクトリ(.pytest_cache等)
TEST_CACHE_DIR=".pytest_cache"
# ソースディレクトリ
SRC_DIR="src"
# 現在時刻のタイムスタンプ(秒)
NOW=$(date +%s)
# テストキャッシュの最終更新時刻を取得
if [ -d "$TEST_CACHE_DIR" ]; then
LAST_TEST=$(stat -c %Y "$TEST_CACHE_DIR" 2>/dev/null || stat -f %m "$TEST_CACHE_DIR" 2>/dev/null || echo 0)
else
LAST_TEST=0
fi
# 最新のソースファイル編集時刻を取得
LATEST_SRC=0
if [ -d "$SRC_DIR" ]; then
while IFS= read -r f; do
TS=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo 0)
if [ "$TS" -gt "$LATEST_SRC" ]; then
LATEST_SRC=$TS
fi
done < <(find "$SRC_DIR" -name '*.py' -type f 2>/dev/null)
fi
# ソース編集後にテストが実行されていない場合、ブロック
if [ "$LATEST_SRC" -gt "$LAST_TEST" ]; then
echo "BLOCK: ソースコードが編集されましたが、テストが実行されていません。" >&2
echo "テストを実行してから再度完了してください。" >&2
echo "" >&2
echo "実行コマンド: pytest" >&2
exit 2
fi
# テストが実行されていても、失敗している場合はブロック
# (直近のテスト結果ファイルをチェックする方式)
TEST_RESULT=".test-results/last-run.xml"
if [ -f "$TEST_RESULT" ]; then
RESULT_TS=$(stat -c %Y "$TEST_RESULT" 2>/dev/null || stat -f %m "$TEST_RESULT" 2>/dev/null || echo 0)
if [ "$RESULT_TS" -lt "$LATEST_SRC" ]; then
echo "BLOCK: テスト結果が古いです。最新のソースコードに対してテストを再実行してください。" >&2
exit 2
fi
if grep -q 'failures="[1-9]' "$TEST_RESULT" 2>/dev/null; then
echo "BLOCK: テストが失敗しています。修正してから完了してください。" >&2
exit 2
fi
fi
exit 0
このスクリプトは2段階のチェックを行います。第1段階では、ソースファイルの最終編集時刻とテストキャッシュの最終更新時刻を比較し、ソース編集後にテストが実行されていなければブロックします。第2段階では、テスト結果ファイル(JUnit XML形式)を確認し、失敗がある場合もブロックします。
ブロック時のターミナル出力例
実際にStop Hookがブロックした場合、Claude Codeのターミナルには以下のような出力が表示されます。
$ claude
> 機能追加が完了しました。ファイルsrc/utils.pyを編集し、
> 新しいヘルパー関数を追加しました。
[Stop Hook] Running: bash .claude/hooks/stop-check-tests.sh
[Stop Hook] Exit code: 2
BLOCK: ソースコードが編集されましたが、テストが実行されていません。
テストを実行してから再度完了してください。
実行コマンド: pytest
[Claude] Stop hook blocked. Continuing work...
> 承知しました。テストを実行してから完了します。
> pytestを実行します。
$ pytest
============================= test session starts ==============================
collected 12 items
tests/test_utils.py ............ [100%]
============================== 12 passed in 0.34s ==============================
> テストが全て通過しました。これで作業を完了します。
[Stop Hook] Running: bash .claude/hooks/stop-check-tests.sh
[Stop Hook] Exit code: 0
[Claude] Task complete.
このように、Claudeが完了を宣言してもStop Hookがexit code 2を返すと、Claude Codeは「Stop hook blocked. Continuing work...」というメッセージを出力し、Claudeに作業継続を強制します [web:12]。Claudeはstderrのメッセージを読み取り、自らテストを実行してから再度完了を宣言します。これがExit Codeによるテスト強制の仕組みです。
Claude Code Hooksを使ってみて感じた気になった点
基本的にStop Hook + Exit Code 2の組み合わせは非常に強力で、LLMの確率的な挙動を機械的に補正できるため、チーム開発では必須レベルと感じました!ただ、実際に使って少し気になったことがあったので、それを共有しておきます。
気になった点1:Stop Hookの発火タイミングが完成宣言時のみ
Stop Hookはあくまで「Claudeが完了を宣言した時」に発火します。つまり、作業途中でテストをスキップして別の作業に移ることは防げません。「完了直前」のチェックポイントとしては完璧ですが、作業プロセス全体を監視したい場合はPostToolUse Hookと組み合わせる必要があります。
気になった点2:タイムスタンプ比較の精度依存
上記のスクリプトはファイルのタイムスタンプに依存します。Git操作でファイルがチェックアウトされた場合、ソースファイルのタイムスタンプが更新され、テスト未実行と誤判定されることがあります。この場合は、Gitのコミットハッシュを使った比較や、テスト実行時にマーカーファイルを生成する方式に切り替えることで回避できます。
よくある失敗と対処法
失敗パターン1:Hookが無限ループする問題
Claude Code Hooksを実運用で最も遭遇しやすい問題が、Hook自身がトリガーとなって無限ループに陥る現象です。典型的なシナリオは以下の通りです。
PostToolUse HookでLintを実行し、Lint違反をexit code 2でClaudeに返します。Claudeは違反を修正しようとしてファイルを編集します。すると再びPostToolUse Hookが発火し、再度Lintが実行されます。もしこの時、Claudeの修正が別のLint違反を生んだ場合、このサイクルが繰り返されます。
さらに悪いのは、Hookスクリプト自体がファイルを書き換えるケースです。たとえば、PostToolUse Hook内でprettier --writeを実行してフォーマットを自動修正すると、ファイルが変更されてPostToolUseが再発火し、永遠にループします。
対処法はシンプルです。Hookスクリプト内でファイルを変更しないこと。Lint結果をClaudeに返し、修正はClaudeに行わせます。また、Hookスクリプト内で状態ファイルを使って再入場を防ぐ方法もあります。
# ファイル名: .claude/hooks/post-lint-guarded.sh
#!/usr/bin/env bash
set -euo pipefail
# ガードファイルで再入場を防止
GUARD_FILE="/tmp/.claude-hook-lint-active"
if [ -f "$GUARD_FILE" ]; then
exit 0
fi
trap 'rm -f "$GUARD_FILE"' EXIT
touch "$GUARD_FILE"
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
if ! echo "$FILE_PATH" | grep -qE '\.(js|ts|jsx|tsx)$'; then
exit 0
fi
LINT_OUTPUT=$(npx eslint "$FILE_PATH" 2>&1) || true
if echo "$LINT_OUTPUT" | grep -q "error"; then
echo "Lint errors detected in $FILE_PATH:" >&2
echo "$LINT_OUTPUT" >&2
exit 2
fi
exit 0
失敗パターン2:Exit Code 2の誤用による意図しないブロック
Exit Code 2は強力なブロック機能ですが、誤用すると開発体験を著しく損ないます。よくある誤用パターンは「warningも含めて全てのLint出力でexit code 2を返す」ことです [web:4]。
ESLintのデフォルト出力には、errorだけでなくwarningも含まれます。上記のスクリプト例ではgrepでerrorをマッチングしていますが、これをgrep -E 'error|warning'に変更すると、警告レベルの軽微な問題でもClaudeの作業がブロックされ続けます。結果として、Claudeが「修正しては別の警告が出る」ループに陥り、作業が進まなくなります。
対処として、exit code 2を返すのはerrorのみに限定し、warningはstdout経由でClaudeに情報として渡す(exit code 0)設計にします。これにより、ブロックは致命的な問題に限定され、軽微な問題はClaudeの判断に委ねられます。
# ファイル名: .claude/hooks/post-lint-graded.sh
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
if ! echo "$FILE_PATH" | grep -qE '\.(js|ts|jsx|tsx)$'; then
exit 0
fi
# eslint --format=jsonで構造化された結果を取得
LINT_JSON=$(npx eslint --format=json "$FILE_PATH" 2>/dev/null) || true
# errorCountとwarningCountを抽出
ERRORS=$(echo "$LINT_JSON" | jq '[.[].errorCount // 0] | add // 0')
WARNINGS=$(echo "$LINT_JSON" | jq '[.[].warningCount // 0] | add // 0')
# errorがある場合はexit code 2でブロック
if [ "$ERRORS" -gt 0 ]; then
echo "ESLint errors: $ERRORS (warnings: $WARNINGS) in $FILE_PATH" >&2
echo "$LINT_JSON" | jq -r '.[].messages[] | select(.severity==2) | "\(.line):\(.col) \(.message)"' >&2
exit 2
fi
# warningのみの場合はexit code 0で情報として渡す
if [ "$WARNINGS" -gt 0 ]; then
echo "ESLint warnings: $WARNINGS in $FILE_PATH (non-blocking)"
exit 0
fi
exit 0
この設計では、errorが存在する場合のみexit code 2でブロックし、warningのみの場合はexit code 0で通常終了します。stdout経由で警告内容がClaudeに伝わるため、Claudeは任意で警告を修正できますが、ブロックはされません [web:1]。
プロジェクト単位での設定共有方法
.claude/settings.jsonをGit管理する運用設計
Claude Code Hooksの設定は、.claude/settings.jsonに記述されます。このファイルをGitリポジトリにコミットすることで、チーム全体に同一のHook設定を強制できます [web:1]。
ただし、settings.jsonにはhooks以外にもAPIキーなどの機密情報が含まれる可能性があるため、設定を分割することを推奨します。Claude Codeは複数の設定ファイルを階層的に読み込みます [web:8]。
project-root/
├── .claude/
│ ├── settings.json # Git管理(hooks定義のみ)
│ └── settings.local.json # .gitignore推奨(ローカル専用設定)
├── .claude/
│ └── hooks/
│ ├── post-lint.sh
│ ├── post-lint-guarded.sh
│ ├── post-lint-graded.sh
│ ├── stop-check-tests.sh
│ └── post-lint-python.sh
├── .gitignore
└── package.json
.gitignoreには以下を記述します。
# Claude Code local settings
.claude/settings.local.json
# Claude Code temporary files
.claude/cache/
/tmp/.claude-hook-*
settings.jsonにはhooks定義のみを記述し、個人のAPI設定やパス設定はsettings.local.jsonに切り出します。これにより、チーム全体にHooksを共有しつつ、個人のローカル設定が混入するリスクを防げます [web:8]。
シェルスクリプトの権限管理とCI/CD連携
Gitでシェルスクリプトを共有する場合、実行権限が保持されない問題があります。Gitはファイルの実行権限をトラッキングしますが、クローン直後は権限が失われていることがあります。チームメンバーがclone後に手動でchmodを実行する必要がありますが、これを忘れるとHookが動作しません。
対策として、プロジェクトのMakefileまたはsetupスクリプトに権限付与を組み込みます。
# ファイル名: scripts/setup-hooks.sh
#!/usr/bin/env bash
set -euo pipefail
HOOK_DIR=".claude/hooks"
if [ ! -d "$HOOK_DIR" ]; then
echo "Error: $HOOK_DIR not found" >&2
exit 1
fi
chmod +x "$HOOK_DIR"/*.sh
echo "Hook scripts permissions set successfully."
echo "Installed hooks:"
ls -la "$HOOK_DIR"/*.sh
package.jsonのscriptsセクションに組み込めば、npm install後に自動実行も可能です。
// ファイル名: package.json(scripts抜粋)
{
"scripts": {
"setup-hooks": "bash scripts/setup-hooks.sh",
"postinstall": "bash scripts/setup-hooks.sh"
}
}
CI/CD環境では、Claude Codeをヘッドレスモードで実行する際もHooksは有効です。CIのDockerイメージに.claudeディレクトリを含めておけば、CI上でもテスト強制とLint強制が統一されます。これにより、「ローカルではテストが走るがCIでは走らない」という不整合を防げます。
ただし、CI環境ではjqやeslint等の依存ツールがインストールされていることが前提になります。Dockerfileで明示的にインストールしておく必要があります。
# ファイル名: Dockerfile(抜粋)
FROM node:20-slim
RUN apt-get update && apt-get install -y \
jq \
bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN npm ci
RUN bash scripts/setup-hooks.sh
以上の設計により、Claude Code Hooksによるテスト強制とLint自動実行を、チーム全体かつCI/CD環境まで一貫して適用できます。Exit Code(0/1/2)の違いを正しく理解し、Exit Code 2をブロック専用に使うことが、この仕組みを安全に運用する鍵です [web:1][web:4]。