Agent Teams Hooksで強制する品質ゲートの仕組み
TeammateIdle/TaskCreated/TaskCompletedの3イベントで、テスト未通過のタスクを完了扱いにさせない品質ゲートの組み方を解説します。
はじめに — 3イベントで止められること
Agent Teamsには、teammateやタスクのライフサイクルに合わせてコマンドを自動実行できるフックイベントが3つあります。TeammateIdle・TaskCreated・TaskCompletedです。これらを使うと、「テストが通っていないのにタスクを完了扱いにする」「命名規則を無視したタスクが作られる」といった事態を、Claudeの判断任せではなく決定論的に止められます。
Claude Code Hooks完全ガイドではHooks全体の仕組みを扱っています。この3イベントのうち、teammateの動きに紐づくのはTeammateIdleだけです。TaskCreatedとTaskCompletedはタスクツールがあるセッションなら、チームを組んでいなくても発火します。発火の条件は後半の「発火しない・効かないときに確かめる4点」で扱います。
前提は2つです。Agent Teamsを有効化していること(CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1)と、Hooksの設定ファイルの場所(.claude/settings.json)を理解していること。
3イベントの発火タイミングと止め方
まず3つのイベントがそれぞれ何をトリガーに発火するかを押さえます。
| イベント | 発火タイミング | ブロックすると何が起きるか |
|---|---|---|
TeammateIdle | 発火タイミングteammateがターンを終えてアイドルになる直前 | ブロックすると何が起きるかteammateはアイドルにならず作業を継続する |
TaskCreated | 発火タイミングTaskCreateツールでタスクが作成されるとき | ブロックすると何が起きるかタスク作成がロールバックされる |
TaskCompleted | 発火タイミングタスクがcompleted状態にマークされるとき | ブロックすると何が起きるかタスクは完了扱いにならない |
3イベントともmatcherをサポートせず、条件分岐なしに毎回必ず発火します。絞り込みたいときは、スクリプトの中で入力JSONを見て判断します。
入力JSONの中身はイベントごとに違います。TeammateIdleは共通フィールドにteammate_name(と非推奨のteam_name)が加わります。TaskCreatedとTaskCompletedにはtask_idとtask_subjectが入り、task_description・teammate_name・team_nameは任意です。team_nameは将来のリリースで削除される非推奨フィールドなので、スクリプトでは使わないほうが安全です。
Step 1 — TeammateIdleでビルド成果物の存在を確認する
teammateが「作業を終えた」と判断してアイドルになる直前に、成果物が実際に存在するかを確認するフックです。
#!/bin/bash
if [ ! -f "./dist/output.js" ]; then
echo "Build artifact missing. Run the build before stopping." >&2
exit 2
fi
exit 0制御方法は2通りです。
exit code 2: teammateはstderrのメッセージをフィードバックとして受け取り、アイドルにならず作業を続ける- JSON
{"continue": false, "stopReason": "..."}:Stopフックと同様にteammateを完全に停止させる。stopReasonはユーザーに表示される
settings.jsonへの登録は次の形です。
{
"hooks": {
"TeammateIdle": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-build-artifact.sh"
}
]
}
]
}
}Step 2 — TaskCreatedでタスクの命名規則を強制する
タスクが作成されるタイミングで、タイトルの形式を検証します。チケット番号のないタスクを作らせたくない場合の例です。
#!/bin/bash
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject')
if [[ ! "$TASK_SUBJECT" =~ ^\[TICKET-[0-9]+\] ]]; then
echo "Task subject must start with a ticket number, e.g. '[TICKET-123] Add feature'" >&2
exit 2
fi
exit 0ブロック方法は2通りあります。どちらを使ってもClaude Codeはタスクを削除し、フックが返したメッセージをツールのエラーとしてClaudeに返します。
exit code 2: stderrのテキストがそのままメッセージになる- JSON
{"decision": "block", "reason": "..."}:reasonがメッセージになる
TaskCreatedはcontinue: falseを無視するので、ブロックしてもClaudeは作業を続けます。
Step 3 — TaskCompletedでテスト未通過のタスクを完了させない
もっとも実用性が高いのがこのフックです。タスクが完了マークされる直前にテストスイートを実行し、失敗していれば完了をブロックします。
#!/bin/bash
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject')
if ! npm test 2>&1; then
echo "Tests not passing. Fix failing tests before completing: $TASK_SUBJECT" >&2
exit 2
fi
exit 0TaskCompletedは2つの状況で発火し、状況によってcontinue: falseの効き方が変わります。
TaskCompletedが発火する2つの状況
TaskUpdateで完了にしたとき
任意のエージェントがTaskUpdateツールで明示的にタスクを完了させたときです。exit code 2は完了を止め、stderrがモデルへのフィードバックになります。continue: falseは無視されます。
teammateがターンを終えたとき
teammateが進行中のタスクを抱えたままターンを終えたときです。exit code 2は同じく完了を止めます。continue: falseはStopフック同様にteammateを完全停止させます。
settings.jsonへの登録は他の2イベントと同じ形です。イベント名のキーごとにフック配列を並べれば、3つの品質ゲートを同時に運用できます。
{
"hooks": {
"TaskCreated": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/validate-task-title.sh" }] }
],
"TaskCompleted": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests-before-complete.sh" }] }
],
"TeammateIdle": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-build-artifact.sh" }] }
]
}
}Claude Codeを起動せずにスクリプトを試す
フックはstdinでJSONを受け取り、終了コードで答えるだけのスクリプトです。登録する前に、サンプルJSONを流し込んで終了コードを見ておくと、ブロックが効かない原因の切り分けが楽になります。Step 2のスクリプトをvalidate-task-title.sh、Step 1のスクリプトをcheck.shとしてchmod +xした状態で試すと、次のようになります。
$ echo '{"hook_event_name":"TaskCreated","task_id":"1","task_subject":"Add login form"}' | ./validate-task-title.sh; echo "exit=$?"
Task subject must start with a ticket number, e.g. '[TICKET-123] Add feature'
exit=2
$ echo '{"hook_event_name":"TaskCreated","task_id":"1","task_subject":"[TICKET-123] Add login form"}' | ./validate-task-title.sh; echo "exit=$?"
exit=0
$ ./check.sh </dev/null; echo "exit=$?"
Build artifact missing. Run the build before stopping.
exit=2
$ mkdir dist && touch dist/output.js && ./check.sh </dev/null; echo "exit=$?"
exit=0メッセージはstderrに出て、終了コードが2のときだけブロックとして扱われます。jqが無い環境ではtask_subjectが空になるので、Step 2のスクリプトは全タスクを拒否します。先にjq --versionを確かめておきます。
入力JSONにキーが無いと、jq -rは文字列nullを返します。task_descriptionのような任意フィールドを検証するときは、空文字だけでなくnullとの比較も必要です。
3イベントを組み合わせたときの流れ
3つのフックを同時に設定すると、teammateの1サイクルはおおむね次の順に進みます。
teammateの1サイクルで働く3つのゲート
- 1
TaskCreatedがタイトルを検証する
タスクを作る瞬間に形式をチェックします。違反していればタスクは削除され、メッセージがツールのエラーとしてClaudeに返ります。
- 2
TaskCompletedがテストを走らせる
teammateが
TaskUpdateでタスクを完了にしようとすると、テストを実行します。失敗すれば完了はブロックされ、teammateはテストの修正に戻ります。 - 3
TeammateIdleが成果物を確かめる
すべてのタスクを片付けて手を止める最後の瞬間に、ビルド成果物の存在を最終確認します。
この3段構えの利点は、どの段階で基準を外れたかが自動的に切り分けられることです。タスクの作り方か、個々の完了基準か、チーム全体の成果物か。teammateへの指示文で毎回言い聞かせなくても、同じ基準が決定論的に適用され続けます。
品質ゲートの粒度をどう決めるか
3つのイベントに何を割り当てるかは、チェックしたい対象の粒度で決めます。
| チェックしたい対象 | 割り当てるイベント | 理由 |
|---|---|---|
| タスクの命名・記述ルール | 割り当てるイベントTaskCreated | 理由タスクが作られる瞬間にしか検証できない |
| 個々のタスクの完了基準(テスト・lint) | 割り当てるイベントTaskCompleted | 理由タスク単位の粒度で検証するのに最も自然 |
| チーム全体としての最終成果物 | 割り当てるイベントTeammateIdle | 理由teammateが本当に「終わった」と言えるかの最終ゲート |
TaskCompletedとTeammateIdleを両方使うと一見冗長に見えます。しかしTaskCompletedはタスク単位でテストを通す規律を、TeammateIdleは複数のタスクをまたいだ成果物(ビルド・デプロイ設定ファイル等)を確かめる役を担います。片方だけでは、タスクは全部テスト付きで完了しているのに最終成果物が揃っていない、という抜け穴が残ります。
逆にTeammateIdleだけにすると、成果物が欠けていたときにどのタスクが原因かを後から探す手間が増えます。小さく始めるならTaskCompletedだけを入れて効果を見て、足りなければ他の2つを足す順が無理のない導入です。
発火しない・効かないときに確かめる4点
つまずきの大半は、フックそのものではなく発火の前提にあります。
Taskツールがそのモデルに載っているか
TaskCreatedは、TaskCreateツールがないセッションでは発火しません。タスク管理ツール(TaskCreate・TaskGet・TaskUpdate・TaskList)は、既定ではClaude 3.x、Opus 4〜4.7、Sonnet 4〜4.6、Haiku 4.5だけに載ります。それより新しいモデルでは、次のいずれかで有効にしない限りツールが出ません。
CLAUDE_CODE_ENABLE_TODO_TOOLS=1を付けて起動する--allowedTools TaskCreateのように、ツール名を指定して起動する--toolsにタスクツールを含める
バックグラウンドセッションとクラウドセッションは、モデルを問わず最初からタスクツールが載ります。タスクツールがないセッションでは、エージェント同士が共有タスクリストの代わりにメッセージで調整します。
チームを組んでいなくても発火するイベントがある
TaskCreatedとTaskCompletedは、タスクツールを使う単一セッションでも発火します。TeammateIdleだけは、teammateがいなければ出番がありません。
Agent Teamsはインタラクティブセッションでしか動かない
teammateを起動できるのはインタラクティブなセッションだけです。-pを付けた非対話モードやAgent SDKのセッションではteammateが起動しないので、TeammateIdleの出番もありません。
スクリプトの基本動作
- 実行権限がない:
chmod +xしていないと、本来ブロックしたい場面でフックが何もせず素通りします - ログがstdoutに混ざる: exit code 2で返すメッセージはstderrに書きます。デバッグ用のechoをstdoutに残すと、意図しない文字列がフィードバックに紛れ込みます
- 重いテストを毎回回す: すべてのタスク完了でフルテストを回すと、タスクの粒度が細かいチームほど待ち時間が積み上がります。対象範囲を絞ったテストコマンドへ差し替えるのが実用的です
promptフック・agentフックで置き換えるときの違い
3イベントはコマンドフックだけでなく、promptフック(LLMが判定)とagentフック(ツールを使う検証役)にも対応しています。テストコマンドで機械的に判定できない基準、たとえば「成果物が依頼の趣旨に沿っているか」を見たいときの選択肢です。
注意点は、ok: falseを返したときの挙動がコマンドフックのexit code 2と揃っていないことです。
ok: falseのときの挙動
TeammateIdle
既定ではteammateが止まり、理由は警告行として表示されます。continueOnBlock: trueを付けたときだけ、理由がteammateへ返って作業が続きます。
TaskCreated / TaskCompleted
TaskCreatedと、ターン中に完了マークされたTaskCompletedは、理由がツールエラーとしてClaudeに返り、ターンは続きます。teammateの停止でTaskCompletedが発火したときは、TeammateIdleと同じく既定で停止します。
コマンドフックの感覚でTeammateIdleにpromptフックを置くと、「作業を続けさせたいのにteammateが止まる」ことになります。続けさせたいならcontinueOnBlockを明示します。
{
"hooks": {
"TeammateIdle": [
{
"hooks": [
{
"type": "prompt",
"prompt": "チームの成果物が依頼の要件を満たしているか判定してください。入力: $ARGUMENTS",
"continueOnBlock": true
}
]
}
]
}
}LLMはJSONでokとreasonを返す必要があります。timeoutは既定で30秒です。
単一セッションのサブエージェントhookとの違い
Sub-agentsにも、個別のサブエージェント定義にPreToolUseフックを埋め込んでツール実行を制限する仕組みがあります。たとえば読み取り専用のDBクエリしか許可しないサブエージェントを、frontmatterに直接hooksを書いて作ることができます。
これはサブエージェント1体に閉じた制御です。TeammateIdle・TaskCreated・TaskCompletedはタスクのライフサイクルにかかる制御で、チーム全体で「基準を満たさないと完了させない」という規律を敷けます。個々のツール呼び出しを縛りたいならサブエージェント側のhooks、タスクの作成と完了を縛りたいなら3イベント、と使い分けます。
よくある質問
一時的にゲートを無効化したいときはどうするか
方法は2つあります。.claude/settings.jsonから該当イベントのエントリを一時的に外すか、スクリプト冒頭に[ "$SKIP_QUALITY_GATE" = "1" ] && exit 0を入れておきます。後者はSKIP_QUALITY_GATE=1 claudeのように、起動前に環境変数を設定して使います。
まとめ
導入はTaskCompletedのテスト実行から始めると、効果が一番見えやすくなります。TeammateIdleにpromptフックを置く場合は、continueOnBlockの有無で結果が変わるので、先にそこを決めておきます。