「テスト書いてって言ったのに、なんでテストなしでコミットしてるの...」
AIエージェントにコードを書かせると、実装そのものはかなり良い。ロジックは合ってるし、型も通る。でもテストを書かずにコミットしたり、lintエラーを無視してPRを出してきたりする。人間の新人エンジニアなら「テスト書いてからマージリクエスト出してね」で済む話ですが、AIは3分後にはその指示を忘れています(金魚よりは長いけど、信頼は置けない)。
CLAUDE.mdに「必ずテストを書くこと」と書いても、長い会話の途中でトークン圧縮が走ると、その約束は蒸発する。お願いベースの品質管理には限界がある...そんな経験、ありませんか?
私たちも同じ壁にぶつかりました。その解決策が、Claude Code Hooksとdev-validateスキルを組み合わせた3層品質ゲートです。
この記事で学べること
- PreToolUse / PostToolUse / Stop Hookの発火タイミングと品質ゲートへの適用
- dev-validateスキルとHooksの連携設計
- 3層品質ゲート(Hook → Skill → Subagent)のアーキテクチャ
- コピペで使えるsettings.jsonテンプレート
前提知識
- Claude Codeの基本操作を理解している
- settings.jsonのhooks設定について概要を把握している
- Skillsの基本概念を知っている(知らない方はこちらの記事を先にどうぞ)
Claude Code Hooksのおさらい:3つの発火ポイント
Claude Code Hooksは、AIエージェントのツール実行に対してプログラム的にフックを差し込む仕組みです。settings.jsonに定義すると、AIの「お願い忘れ」に関係なく100%発火します。
| Hook | 発火タイミング | 品質ゲートでの役割 |
|---|---|---|
| PreToolUse | ツール実行前 | 危険な操作をブロック |
| PostToolUse | ツール実行後 | 自動フォーマット・lint修正 |
| Stop | エージェント停止時 | 最終品質チェック |
PreToolUse:「それ、やっちゃダメ」を物理的に止める
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash(git commit:*)",
"hooks": [
{
"type": "command",
"command": "UNSTAGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' '); NOTEST=$(find . -name '*.test.*' -newer $(git log -1 --format=%ci HEAD 2>/dev/null || echo '2000-01-01') 2>/dev/null | wc -l | tr -d ' '); if [ \"$NOTEST\" -eq 0 ] && [ \"$UNSTAGED\" -gt 0 ]; then echo '⛔ テストファイルが更新されていません。テストを書いてからコミットしてください' 1>&2; exit 2; fi; exit 0"
}
]
}
]
}
}
コミット時に「テストファイルが更新されていなければブロック」するhookです。exit 2で処理が中断され、AIエージェントに「テストを書いてください」というメッセージが返ります。
CLAUDE.mdに「テスト書いて」と書くのはお願い。PreToolUseでブロックするのは物理的な壁。この差は、運用してみると笑えるくらい大きい。お願いの遵守率が体感7割だとしたら、hookは100%です(「信頼するけど検証する」、レーガン大統領かな?)。
PostToolUse:書いたら即座に整える
{
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm run lint:fix --silent 2>/dev/null; exit 0"
}
]
}
]
}
ファイルを編集するたびに自動でlint修正が走ります。「フォーマットして」という指示トークンが毎回節約できるだけでなく、lint警告が積もり積もって最後にまとめて直す地獄を防げます(金曜夕方、PR出す直前のlintエラー87件...あの絶望を二度と味わいたくない)。1日に50回Editが走るとして、毎回のlint修正で最終的なエラー数がゼロに近づく。塵も積もれば、PRレビューの修正指摘が激減します。
Stop Hook:最後の砦
{
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "cd \"$PWD\" && npm test --silent 2>&1 | tail -5; EXIT=$?; if [ $EXIT -ne 0 ]; then echo '❌ テストが失敗しています。修正してから完了してください' 1>&2; exit 1; fi; exit 0"
}
]
}
]
}
AIエージェントが「完了しました」と言って停止する瞬間に、テストを全件実行します。1件でも失敗していたら停止を許可しない。人間が「テスト通った?」と確認する前にプログラムがチェックしてくれるので、レビューの心理的負荷がかなり軽くなります(テスト落ちてるPRを30分かけてレビューした後に気づいた日、帰り道のビールが苦かった)。
dev-validateスキル:テスト自動化のエンジン
Hookが「いつ検証するか」を制御する門番なら、dev-validateは「何を検証するか」を担当するエンジンです。
dev-validateの仕組み
$SKILLS_DIR/dev-validate/scripts/validate.sh [--fix] [--strict] [--worktree <path>]
| オプション | 説明 |
|---|---|
--fix | lint問題を自動修正 |
--strict | lintがスキップされたら失敗扱い |
--worktree | 検証対象のworktreeパス |
内部ではプロジェクトタイプを自動検出して、適切なコマンドを実行します。中身を見てみましょう(コアロジック部分を抜粋)。
# テスト実行:プロジェクトの設定ファイルで言語を自動判定
run_tests() {
if [[ -f "package.json" ]]; then
if grep -q '"test"' package.json 2>/dev/null; then
local pm="npm"
[[ -f "yarn.lock" ]] && pm="yarn"
[[ -f "pnpm-lock.yaml" ]] && pm="pnpm"
$pm test >/dev/null 2>&1 && TEST_RESULT="passed" \
|| { TEST_RESULT="failed"; TEST_EXIT=1; }
else
TEST_RESULT="no_test_script"
fi
elif [[ -f "Cargo.toml" ]]; then
cargo test >/dev/null 2>&1 && TEST_RESULT="passed" \
|| { TEST_RESULT="failed"; TEST_EXIT=1; }
elif [[ -f "go.mod" ]]; then
go test ./... >/dev/null 2>&1 && TEST_RESULT="passed" \
|| { TEST_RESULT="failed"; TEST_EXIT=1; }
elif [[ -f "pytest.ini" ]] || [[ -f "pyproject.toml" ]]; then
pytest >/dev/null 2>&1 && TEST_RESULT="passed" \
|| { TEST_RESULT="failed"; TEST_EXIT=1; }
fi
}
# lint実行:--fixモードならlint:fixを優先
run_lint() {
if [[ -f "package.json" ]] && grep -q '"lint"' package.json 2>/dev/null; then
local pm="npm"
[[ -f "yarn.lock" ]] && pm="yarn"
[[ -f "pnpm-lock.yaml" ]] && pm="pnpm"
if $FIX_MODE && grep -q '"lint:fix"' package.json 2>/dev/null; then
$pm run lint:fix >/dev/null 2>&1 && LINT_RESULT="passed" \
|| { LINT_RESULT="failed"; LINT_EXIT=2; }
else
$pm run lint >/dev/null 2>&1 && LINT_RESULT="passed" \
|| { LINT_RESULT="failed"; LINT_EXIT=2; }
fi
elif [[ -f "Cargo.toml" ]]; then
cargo clippy >/dev/null 2>&1 && LINT_RESULT="passed" \
|| { LINT_RESULT="failed"; LINT_EXIT=2; }
fi
# Go: golangci-lint, Python: ruff も同様に対応
}
やっていることはシンプルです。package.jsonがあればNode.js、Cargo.tomlがあればRust。設定ファイルの存在だけで言語を判定して、対応するテスト・lintコマンドを実行する。開発者が何も設定しなくても動くのがポイントです(逆に言えば、設定ファイルがないプロジェクトでは何も起きない。明示的で分かりやすい)。
出力はJSON:Hookやワークフローから判定しやすい
テストとlintの実行結果は、最終的にJSON1つにまとめて出力します。
# --strict: lintスキップも失敗扱い
OVERALL="pass" EXIT_CODE=0
[[ "$TEST_EXIT" -ne 0 ]] && { OVERALL="fail"; EXIT_CODE=1; }
[[ "$LINT_EXIT" -ne 0 ]] && { OVERALL="fail"; EXIT_CODE=2; }
[[ "$STRICT_MODE" == "true" && "$LINT_RESULT" == "skipped" ]] \
&& { OVERALL="fail"; EXIT_CODE=2; }
cat <<JSONEOF
{
"worktree": "$WORK_DIR",
"changes": {"files": $FILES_CHANGED, "insertions": $INSERTIONS, "deletions": $DELETIONS},
"tests": "$TEST_RESULT",
"lint": "$LINT_RESULT",
"overall": "$OVERALL",
"exit_code": $EXIT_CODE
}
JSONEOF
overall: "fail"なら先に進まない、tests: "failed"ならテスト修正に戻る。この判断をHookやスキルのワークフロー内で自動化できるのが強みです。フルソースはGitHubリポジトリで公開しています。
3層品質ゲートのアーキテクチャ
ここからが本題です。Hookとdev-validateを組み合わせた品質ゲートは、3つの層で防衛線を張る設計になっています。
Layer 1: Hook -- 条件反射的なガードレール
設定場所: ~/.claude/settings.json
Hookは無条件で発火するのがポイントです。AIの判断を介さない。PreToolUseでコミット前のテスト有無チェック、PostToolUseで編集後の自動lint、Stopで最終テスト。この3つが常時稼働していれば、「テストなしでコミットされた」事故はゼロになります。
ただし、Hookは軽い処理に向いています。複雑なテスト実行や問題の切り分けはHookの守備範囲外。そこをカバーするのがLayer 2です。
Layer 2: Skill -- ワークフロー内の品質チェックポイント
実行タイミング: dev-kickoffのPhase 4
dev-kickoffの6フェーズ開発フローの中で、dev-validateはPhase 4に位置します。
Phase 1: git-prepare(worktree作成)
↓
Phase 2: dev-issue-analyze(要件分析)
↓
Phase 3: dev-implement(実装)
↓
Phase 4: dev-validate --fix ← ここ
↓
Phase 5: git-commit(コミット)
↓
Phase 6: git-pr(PR作成)
実装が終わったら即座にdev-validateが走り、テスト失敗やlintエラーがあればPhase 3に差し戻し。--fixオプション付きなので、自動修正可能な問題はその場で直してくれます。
HookがLayer 1で個別のアクションを監視するのに対し、SkillのLayer 2はワークフロー全体の中での品質チェックポイントとして機能します。「実装→検証→コミット」の流れに品質ゲートが構造的に組み込まれているので、「検証をスキップしてコミットしちゃった」が起きない。
Layer 3: Subagent -- コンテキストを消費しない専門エージェント
実行方法: Task(quality-engineer)
テスト実行のログ出力は長い。テストが20件あれば、そのログだけでコンテキストウィンドウの相当な部分を消費します。メインのエージェントが実装に集中しているときに、テスト結果のログで頭がいっぱいになるのはもったいない。
dev-kickoffでは、Phase 4のdev-validateをTask toolで別エージェントに委譲できます。
メインエージェント(dev-kickoff)
│
├── Phase 3: 実装に集中
│
└── Phase 4: Task(quality-engineer) に委譲
└── dev-validate --fix --worktree $PATH
├── テスト実行(ログはsubagentのcontextに収まる)
├── lint実行
└── 結果JSONだけメインに返す
メインエージェントのコンテキストを汚さずに、品質チェックの結果だけを受け取る。テストが100件あっても、メインに返るのは「pass」か「fail」かの小さなJSONだけ。コンテキストウィンドウの節約効果は、テスト数が多いプロジェクトほど顕著です(テスト100件のログを全部読まされるのは、会議の議事録を全文音読させられるようなもの。要点だけでいい)。
settings.jsonテンプレート:コピペで使える品質ゲート設定
実際に私たちが使っているsettings.jsonから、品質ゲート関連の設定を抜き出したテンプレートです。
ミニマル構成(個人開発・小規模プロジェクト向け)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash(git push:*)",
"hooks": [
{
"type": "command",
"command": "BR=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); case \"$BR\" in dev|develop|main|master) echo '⛔ 保護ブランチへの直接pushはブロックされています' 1>&2; exit 2;; esac; exit 0"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm run lint:fix --silent 2>/dev/null; exit 0"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm test --silent 2>&1 | tail -3; EXIT=$?; if [ $EXIT -ne 0 ]; then echo '❌ テスト失敗' 1>&2; exit 1; fi; exit 0"
}
]
}
]
}
}
これだけで「保護ブランチpushブロック + 自動lint + 停止時テスト」の3点セットが稼働します。まずはここから始めて、プロジェクトに合わせて育てていくのがおすすめです。
フル構成(チーム開発・Skill連携込み)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash(git push:*)",
"hooks": [
{
"type": "command",
"command": "BR=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); case \"$BR\" in dev|develop|main|master) echo '⛔ 保護ブランチへの直接pushはブロックされています' 1>&2; exit 2;; esac; exit 0"
}
]
},
{
"matcher": "Bash(git commit:*)",
"hooks": [
{
"type": "command",
"command": "RESULT=$($SKILLS_DIR/dev-validate/scripts/validate.sh 2>/dev/null); OVERALL=$(echo \"$RESULT\" | grep -o '\"overall\":\"[^\"]*\"' | cut -d'\"' -f4); if [ \"$OVERALL\" = 'fail' ]; then echo '⛔ dev-validate失敗。テスト/lintを修正してください' 1>&2; echo \"$RESULT\" 1>&2; exit 2; fi; exit 0"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm run lint:fix --silent 2>/dev/null; exit 0"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "RESULT=$($SKILLS_DIR/dev-validate/scripts/validate.sh --strict 2>/dev/null); OVERALL=$(echo \"$RESULT\" | grep -o '\"overall\":\"[^\"]*\"' | cut -d'\"' -f4); if [ \"$OVERALL\" = 'fail' ]; then echo \"$RESULT\" 1>&2; echo '❌ 品質チェック失敗。修正してから完了してください' 1>&2; exit 1; fi; exit 0"
}
]
}
]
},
"permissions": {
"deny": ["Bash(rm -rf:*)", "Bash(sudo:*)", "Bash(git push --force:*)"]
}
}
フル構成では、PreToolUseのコミット前チェックにdev-validateスキルを直接呼び出しています。validate.shがJSONを返すので、overallフィールドを見てfailならブロック。Stop Hookでも--strictオプション付きでdev-validateを実行し、lintスキップも許さない厳格モードにしています。
Hookのカスタマイズポイント
matcherのパターン設計
matcherは「どのツール実行にhookを適用するか」を指定します。
"Bash(git commit:*)" → git commitコマンドにマッチ
"Bash(git push:*)" → git pushコマンドにマッチ
"Write|Edit" → ファイル書き込み・編集にマッチ
"Notebook.*" → NotebookRead, NotebookEdit等にマッチ
"mcp__memory__.*" → MCPサーバーのツールにマッチ
粒度が細かすぎると管理が面倒で、粗すぎると意図しないタイミングで発火します。私たちの経験では、コミット・プッシュ系は個別指定、編集系はまとめてマッチが実用的なバランスです。
exit codeの使い分け
| exit code | 意味 | AIエージェントの挙動 |
|---|---|---|
| 0 | 成功(続行) | そのまま処理を続ける |
| 1 | 警告(レポート) | 警告メッセージを認識するが続行 |
| 2 | ブロック(中断) | 処理を中断し、エラー内容を報告 |
PreToolUseでexit 2を返すと、ツール実行そのものが中断されます。PostToolUseの場合はツール実行済みなので、exit 2は「結果に問題がある」というシグナルになります。
dev-kickoff × dev-validate の連携フロー
Hookだけでなく、開発ワークフロー全体でどう品質ゲートが機能するか、実際のフローで見てみましょう。
/dev-flow 42(Issue #42の開発を開始)
│
├── dev-kickoff Phase 1: git-prepare
│ └── worktree作成、.env配置
│
├── Phase 2: dev-issue-analyze
│ └── 要件・受け入れ条件を抽出
│
├── Phase 3: dev-implement --strategy tdd
│ ├── テストを先に書く(TDD)
│ ├── 最小限の実装でテスト通す
│ └── リファクタリング
│ │
│ └── [PostToolUse Hook] 毎回のEdit後にlint:fix自動実行
│
├── Phase 4: dev-validate --fix ← Layer 2の品質ゲート
│ ├── テスト全件実行
│ ├── lint --strict
│ └── fail → Phase 3に差し戻し
│
├── Phase 5: git-commit
│ └── [PreToolUse Hook] コミット前の最終チェック ← Layer 1
│
└── Phase 6: git-pr
└── [Stop Hook] PR作成完了時にテスト再実行 ← Layer 1
3つの層がそれぞれ異なるタイミングで品質を検証しているのが分かると思います。Layer 2(dev-validate)が「ワークフローの節目」、Layer 1(Hook)が「個別操作の瞬間」を守っている。
TDDストラテジーを指定している場合、Phase 3の時点でテストが先に書かれるので、Phase 4のdev-validateは「テストが存在し、それが通るかどうか」の確認になります。テストの有無チェックではなく、テストの品質チェックに焦点が移る。テスト駆動開発とHooksの組み合わせは、品質管理の観点からかなり相性がいいです。
実際に運用してみて
数字で見る効果
導入前後で変わった指標を正直に共有します(厳密な計測ではなく体感値ベースなので、参考程度に)。
| 指標 | 導入前 | 導入後 |
|---|---|---|
| テストなしコミット | 週3〜5回(毎週月曜のPRレビューが憂鬱だった) | 0回 |
| PRレビューでのlint指摘 | 毎PR 2〜3件 | ほぼ0件(レビュー時間、半分は昼寝に回せる) |
| 「テスト書いて」の手動指示 | 1日5〜10回(人間の上司かな?) | 不要に |
| dev-flow完遂率 | 約70%(残り30%は手動で尻拭い) | 約95% |
テストなしコミットがゼロになったのは、PreToolUseで物理的にブロックしているので当然です。でもこの「当然」がすごく大事。AIに「テスト書いてね」と10回言うストレスと、hookを1回設定する手間を比べたら、答えは明らかですよね(1週間の「テスト書いて」回数を数えてみてください。たぶん想像の3倍言ってます)。
予想外だったこと
- PostToolUseのlint:fixが思ったより効く: 1回1回は歯磨きみたいな小さな習慣。でも3ヶ月後に虫歯ゼロで歯医者に褒められる感覚。PR作成時のlintエラーがほぼゼロになった
- Stop Hookの心理的安全性: 「AIが完了と言ったならテスト通ってる」と信頼できるようになった。以前は「完了しました」の報告を受けるたびに
npm testを手動で叩いてた。疑い深い上司みたいで嫌だった - Subagent委譲のコンテキスト節約: テストが多いプロジェクトでは顕著。メインエージェントが実装に集中している横で、別のエージェントが黙々とテストを回している。優秀なQAチームを雇った気分
まだ課題として残っていること
- Hookの実行時間: validate.shをフルで走らせると、プロジェクトによっては数十秒かかる。Stop Hookは許容範囲だが、PreToolUseで毎回フル検証は遅すぎる場面がある
- matcherの設計: プロジェクトごとにツール名が違う場合の対応。汎用的なmatcherパターンの整理はまだ途上
- 偽陽性の管理: 外部APIに依存するテストがHookで落ちて、関係ない修正がブロックされることがある。テストの分類(unit / integration)とHookの紐付けが次の課題
まとめ
Claude Code Hooksとdev-validateスキルを組み合わせた3層品質ゲートは、**「AIにテストを書かせる」のではなく「テストを書かないと先に進めない構造にする」**というアプローチです。
| 層 | 仕組み | 守備範囲 |
|---|---|---|
| Layer 1 | Hook(settings.json) | 個別操作の瞬間チェック |
| Layer 2 | Skill(dev-validate) | ワークフローの節目チェック |
| Layer 3 | Subagent(Task委譲) | コンテキスト節約+専門チェック |
「お願い」で品質を担保する時代は終わりました(終わらせました)。AIエージェントとの共同作業で品質を維持するには、人間のコードレビューと同じく、仕組みで担保するのが現実的です。
まずはミニマル構成のsettings.jsonテンプレートから試してみてください。設定に5分、効果は半永久。保護ブランチpushブロックだけでも、日曜夜の「明日のPRレビュー大丈夫かな...」が1つ消えます。



