レッスン5: 関所に探針を置く: hooks と原因特定のフロー
学習目標:
- hooks とは何かを説明できる: エージェントのライフサイクルの決まった地点で自動実行されるユーザー定義のハンドラで、3つのケイデンス(セッションごと1回 / ターンごと1回 / ループ内のツール呼び出しごと)に整理されている。そのうえで観測ニーズに合ったイベントを選び、正しい matcher を書き、使うべきペイロードのフィールドを見分けられる
- 実際に噛みついてくる2つの罠を避ける: hook のサブプロセスはハーネスの OTel エクスポート設定を継承しない、そしてトランスクリプトファイルは非同期に書かれるため hook 発火時点では直近のメッセージを含んでいないことがある
- 非決定性の下で問題を特定する5ステップのフローを適用する: prompt id で絞り込む、最初の分岐点を見つける、同一入力で再生する、同じコンポーネントを繰り返し痛めつける、修正後は失敗地点から復帰する
前提: レッスン1〜4を完了(非決定性と「なぜかが分からない」、第一級の証拠としての生記録、構造化ログとメトリクス、トレースツリーとテレメトリパイプラインの罠) | 前: << レッスン4 | 次: レッスン6 >>
ツール実行の前後で1件ずつ記録したい
レッスン3では、自分のハーネスの中でツール呼び出しごとに構造化ログを1件書くことを学びました。レッスン4では、それらの記録をトレースツリーにつなぐことを学びました。あれはあなたのループで、ソースコードが手元にあり、好きな場所に log() を1行落とせました。しかし今、あなたは本番で Claude Code を動かしています。その runToolUses は手元にありません。やりたいことは複雑ではありません。ツール実行の前に1件(これからどんなパラメータを使うのか)、実行の後に1件(何が返ってきたのか)記録したいだけです。しかしその挿入地点は他人のプロセスの中にあります。
プロダクト側はこれを見越していました。その答えが hooks です。
hooks は、Claude Code のライフサイクルの特定の地点で自動的に実行される、ユーザー定義のシェルコマンド、HTTP エンドポイント、または LLM プロンプトです1。平たく言えば、「この瞬間になったら、このスクリプトを実行してくれ」と設定で宣言しておくと、Claude Code がその瞬間に到達したときに実行してくれます。しかも手ぶらでは来ません。イベントが発火して matcher が一致すると、Claude Code はそのイベントに関する JSON のコンテキストをあなたのハンドラに渡します1。
このシリーズの第7コースでは、ハーネスのループの中に承認ゲートを書きました。HIGH_IMPACT のツールに出会ったら止まり、人間の確認を待ってから実行するというものです。あれは手作りの介入地点でした。hooks も同じことをしますが、「ループのどの位置で止まる価値があるか」を名前つきのリストとして整理し、各位置に固定された頼れるペイロードを与えています。観測にとってこのリストの価値は「挙動を変えられる」ことではありません。挙動を変えずに見られることです。
3つのケイデンス: セッション、ターン、ツール呼び出し
イベントは3つのケイデンスに分かれます1。
- セッションごと1回:
SessionStart と SessionEnd
- ターンごと1回:
UserPromptSubmit、Stop、StopFailure(名前から見て StopFailure はターンが正常に終わらなかった側の出口に対応します。公式の引用はケイデンスの分類までしか与えていないので、正確な意味論はあなたのバージョンのリファレンスページで確認してください)
- エージェントループ内のツール呼び出しごと:
PreToolUse と PostToolUse
これを第7コースのループの解剖図に並べると、3つの層がすぐに対応します。
ケイデンスを選び間違えると、説明のつかない数字が出てきます。「1つのタスクでツール呼び出しを何回使ったか」を数えたいのにターン単位の関所にフックすると、ゼロしか得られません。イベントを選ぶ前に自問してください。自分が数えたいものは、1セッションあたり何回起きるのか。
もう1つ挙げておく価値のある細部があります。SessionStart は新しいセッションを開いたときに発火し、既存のセッションを再開したときにも発火します1。第9コースで --resume を扱いました。ループの視点ではあれは「まっさらから始める」ではありませんが、それでも SessionStart の鐘は鳴ります。「セッション開始時に新しいログを初期化する」と書いてしまうと、初めて resume した時点で前の区間を上書きします。
観測に使える関所の細部
PreToolUse は、Claude がツールのパラメータを作成した後、ツール呼び出しを処理する前に実行されます1。この隙間が効いてきます。パラメータは確定済み(モデルが何を使うつもりなのかがそのまま見える)で、ツールはまだ動いていません。レッスン2で実例を扱いました。チームは、Claude が検索ツールの query パラメータに不必要に 2025 を付け足し、結果に偏りを生んでいることを見つけました2。この種のバグの証拠は、PreToolUse から見えるパラメータの中にあります。
PostToolUse の hooks は、ツールがすでに正常に実行された後に発火します。その入力には、ツールに送られた引数である tool_input と、ツールが返した結果である tool_response の両方が含まれます1。1回の発火で完全な呼び出し記録が手に入るので、「どのリクエストがどのレスポンスに対応するか」を自分で組み立てる必要がありません。レッスン2が強調した原則、すなわち完全な往復こそが第一級の証拠である2という原則が、ここではフィールド単位で手渡されます。発火条件が「すでに正常に実行された」である点には注意してください1。パラメータは生成されたが実行が成功しなかったケースまで押さえたいなら、PreToolUse と組み合わせて両側を突き合わせる必要があります。
matcher の書き方: どのツールでも正常終了した後に hook を実行するには、matcher を省略するか "*" に設定します1。観測のシナリオが欲しいのはまさにこの1つの hook で全部記録する挙動です。どのツールが壊れるかは事前に分からないので、全部記録します。
ハンドラのデータの受け渡し方: コマンド hooks は stdin 経由で JSON データを受け取り、終了コード、stdout、stderr で結果を伝えます1。つまり最小の観測ハンドラは「stdin から JSON を読み、いくつかのフィールドを選んでファイルに追記し、終了コード0で抜ける」だけです。魔法は一切ありません。
これはレッスン3の JSON Lines ログそのものです。違うのは、ログを書く主体が「あなたのハーネス」から「他人のループにぶら下げた小さなスクリプト」に変わったことだけです。設定の正確な構造とフィールド名は、あなたのバージョンのリファレンスページで確認してください。
ペイロードには所要時間も入っているが、定義を確認すること: ペイロードはツールの実行時間をミリ秒で表すオプションのフィールドを持ちますが、これは権限プロンプトと PreToolUse hooks に費やされた時間を除外します1。後半が肝心です。ユーザーが体感する「このステップをどれだけ待たされたか」には確認をクリックしていた時間も含まれますが、この数字はそれを取り除いています。「ツールは遅いか」に答えるために使うのは正しく、「ユーザーはどれだけ待ったか」に答えるために使うと系統的に過小になります。レッスン4は同じことを別の言い方で述べていました。ツールスパンの下には2つの子スパンがあり、1つは権限判断を待つ時間、もう1つは実行そのものです3。これらが別々に記録されているのは、まさにこの2つの区間を混ぜてはいけないからです。
hooks 自身もトレースの中にいる: 各ユーザープロンプトが claude_code.interaction ルートスパンを開始し、API 呼び出し、ツール呼び出し、フック実行がその子として記録されます3。観測のしくみ自体が、観測される対象でもあります。
噛みついてくる2つの警告
1つ目: hook のサブプロセスは OTEL_* エクスポーター変数を継承しません。 継承されない変数群が1つあります。Claude Code は、hooks を含め、自分が起動するあらゆるサブプロセスから OTEL_* エクスポーター変数を取り除きます1。
この一文は、非常に自然に思いつくアイデアを真正面から殺します。「ハーネス側でエンドポイントもプロトコルも認証ヘッダーも設定済みなのだから、hook の中で OTel SDK を import すれば環境変数がそのまま効くはずだ」——効きません。ハンドラのプロセスが起動する時点で、それらの変数はすでに取り除かれています。データはエクスポートされないか、デフォルトのエンドポイントに当たって消えるかのどちらかです。誰かが叫んで警告してくれることは期待しないでください。レッスン4で、CLI 自身のエクスポートが静かに失敗することを扱いました4。そしてデフォルトでは、あなたが hook の中で自作したエクスポーターもそれ以上に騒がしくはありません。バックエンドにデータが届かないとき、両側とも沈黙しています。
進む道は2つあります。hook が自前の完全なエクスポート設定を持つか(エンドポイントと認証をスクリプトに明示的に書き、継承に頼らない)、あるいは hook からはテレメトリをまったく出さず、構造化ログを書かせてバックエンドで ID を使ってテレメトリと突き合わせるかです。2つ目の道には公式のサポートがあります。hook のペイロードにある、現在処理中のユーザープロンプトを識別する UUID は、OpenTelemetry イベントの prompt.id 属性と一致するので、単一のプロンプトについて hook の出力とテレメトリを相関させられます1。それぞれが自分の分を書き、最後に同じ prompt id で結合します。レッスン4の「相関 ID で木につなぐ」と同じ手ですが、今回は2つのデータソースをまたいでいます。
2つ目: トランスクリプトファイルは非同期に書かれます。 ペイロードは会話 JSON へのパスを与えてくれますが、このファイルは非同期に書かれ、メモリ上の会話に遅れることがあるため、hook が発火した時点では現在のターンの直近のメッセージをまだ含んでいないことがあります1。
罠は、エラーにならず、ただ古いデータを渡してくることです。「ペイロードのフィールドでは足りないから、トランスクリプトを直接読もう。あそこには全部ある」と考えて、断続的に中身が半分欠けた記録を作ってしまう人がいます。tool_input と tool_response が欲しいなら、ペイロードのフィールドを使ってください。それらは PostToolUse が明示的に提供を保証しているものです1。トランスクリプトは後から振り返るのには向いていますが、hook が発火した瞬間のリアルタイムなデータソースとして使うものではありません。
あなたのマシンに関わる注意も1つ。コマンド hooks は、あなたのユーザー権限をすべて持ってシェルコマンドを実行します。あなたのユーザーアカウントがアクセスできるあらゆるファイルを、変更、削除、アクセスできます。設定に追加する前に、すべての hook コマンドをレビューしてテストしてください1。
hook 自身が仕事をしないとき
hook をぶら下げたのに、ログファイルが空です。発火しなかったのか。matcher が一致しなかったのか。それともスクリプト自体が落ちたのか。この時点であなたが観測すべき対象は、観測のしくみそのものです。レッスン4の原則がここでも効きます。まず探針を検証すること。
hook の実行の詳細、すなわちどの hook が一致したか、その終了コード、stdout と stderr の全文は、デバッグログファイルに書き出されます1。入手方法は2通りです。claude --debug-file <path> で自分の指定した場所に書き出すか、claude --debug を実行してから ~/.claude/debug/<session-id>.txt を読むかです1。より細かい hook のマッチングの詳細が必要なら、CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose を設定すると、hook の matcher の一致数などの追加のログ行が見られます1。
原因特定のフロー: 非決定性の下でどう問題を追うか
レッスンの前半は探針をどこに置くかでした。後半は、そのデータを実際に使って現実の問題をどう追うかです。
まず難しさをはっきり述べておきます。レッスン1はこう言いました。エージェントは動的に判断し、同一のプロンプトであっても実行間で非決定的です。これがデバッグを難しくします5。従来のデバッグの第一歩は「再現」ですが、そのステップはここでは成立しません。もう一度動かせば、まったく別の、しかし同じくらい妥当な経路を通るかもしれません。
以下の5ステップはこのコース自身の編成であり、誰かの公式の方法論ではありません。ただし各ステップは一次資料の上に立っています。
第1ステップ: 絞り込む
レッスン1〜4を通じて蓄積してきた記録の中で、まず範囲を「問題のあったこの1つのプロンプト」に絞ります。レッスン4がその方法を与えてくれました。1つのプロンプトが引き起こしたすべてのアクティビティを追跡するには、特定の prompt.id の値でイベントをフィルタします3。
このステップの意義は技術的なものではなく、心理的なものです。「エージェントが壊れた」はデバッグできない命題です。「このプロンプト id の下にある11件のイベントのどれかがおかしい」はデバッグできる命題です。
第2ステップ: 最初の分岐点を見つける
先頭から前へ読み進めて、挙動が期待から逸れ始める最初のステップを見つけます。なぜ「最初」にこだわるのか。1つのステップの失敗がエージェントをまったく別の軌道の探索へ向かわせ、予測不能な結果につながるからです5。終盤で目にする不条理(存在しないファイルを参照する、繰り返しエラーになる、遠回りをする)は、その大半が下流のノイズです。イベント#8を直しても、たいていはイベント#4の失敗の後始末をしているだけです。
「逸脱」を判定するには、いくつかの使える型があります2。呼ぶべきでないツールを呼んだ、正しいツールを誤ったパラメータで呼んだ、正しいツールを呼ぶ回数が少なすぎた、ツールのレスポンスの処理を誤った、です。最後の型がもっとも見つけにくいものです。ツール自体は成功を返していて、ログはすべて緑だからです。おかしいのは、その成功した結果に対するエージェントの解釈です。
第3ステップ: 再生して観察する
公式のやり方はこうです。プロンプトの効果を理解するために、システムのプロンプトとツールをそのまま使ってシミュレーションを構築し、エージェントの動きを一歩ずつ観察しました。これによって、すでに十分な結果を得ているのに続行する、過度に冗長な検索クエリを使う、誤ったツールを選ぶ、といった失敗モードが即座に明らかになりました5。
「即座に明らかになった」は考えてみる価値があります。これらと同じバグは、集計されたメトリクスでは見えず(成功率はかなり高いままです)、事後のログでは1行ずつ読まないと気づけません。しかし一歩ずつ動くのを眺めていると、「もう十分なのに検索を続けている」を人間の目は数秒で見つけます。再生のときに制御すべきものが2つあります。入力が同一であること(プロンプトとツール定義を変えない)、そして観察が一歩ずつであることです。
第4ステップ: 同じコンポーネントを繰り返し痛めつける
疑いが特定のツールに向いた場合、数回動かしただけではおそらく何も出てきません。非決定性のせいで、バグは現れたり消えたりします。
チームはツールテスト用のエージェントを作りました。欠陥のある MCP ツールを与えると、それを使おうと試み、失敗を避けるようにツールの説明を書き換えるというものです。このツールを数十回テストすることで、このエージェントは重要な機微とバグを見つけました5。
「数十回」がポイントです。1回の実行なら隠れていられる不具合も、数十回やれば表に追い出されます。境界的な入力での妙な戻り値の形、曖昧な説明文、モデルに「再試行すればいい」と誤解させるエラー文言などです。レッスン3の診断的な読み方がここでつながります。重複したツール呼び出しが多いなら、ページネーションやトークン上限のパラメータを適正化すべきかもしれません。無効なパラメータによるツールエラーが多いなら、ツールの説明をより明確にしたり、よい例を足したりすべきかもしれません2。往復を大きく減らせることを1つ。ツール呼び出しがエラーを起こしたとき、不透明なエラーコードやトレースバックを投げるのではなく、具体的で実行可能な改善点を明確に伝えるように、エラーレスポンスをプロンプトとして書くことができます2。
第5ステップ: 修正後は失敗地点から復帰する
直した後、反射的に「最初からやり直し」を押さないでください。公式の言い方は直接的です。エラーが起きたとき、最初から再スタートするわけにはいきません。再スタートは高くつき、ユーザーにとってはいらだたしいものです。代わりに私たちは、エラーが起きた時点のエージェントの状態から再開できるシステムを作りました5。
第9コースで復帰のしくみの作り方を扱いました。あのレッスンの場面は「タスクが中断された後どう続きから走るか」でした。デバッグにおいては別の使い方になります。失敗地点より前の数十回のツール呼び出しは有効で、お金を払っていて、結果も正しいものでした。それを走り直しても、トークンを燃やす以上のものは何も得られませんし、新たな非決定性を大量に持ち込むので、「今回はうまくいった」が正しく直したからなのか運がよかったからなのか区別できなくなります。
「再現」について、正直に一言
エージェントを再現可能にするための一群のテクニックを、どこかで見たことがあるかもしれません。乱数シードを固定する、temperature を0にする、実際のツールの戻り値を記録して再生用のスタブとして使う、といったものです。
これらの実践はエンジニアリングの現場に確かに存在します。第8〜10コースの実習でも同じスタブクライアントのやり方を使っています。1回の実際の実行から tool_result を保存し、以降は毎回同じデータを返せば、ツール側は決定的になります。「自分が変えたあの1行がパースのロジックを壊していないか」を検証するには有効です。
しかし2つ言っておく必要があります。1つ目、これらのテクニックにはファーストパーティの根拠がありません。上の5ステップのフローには各ステップに引用を付けました。この段落には付けません。本当に存在しないからです。「エージェントの問題を再現するには temperature 0 が公式の推奨だ」と主張する人を見かけたら、リンクを求めてください。
2つ目、これらは聞こえるほど多くを固定してはくれません。ツールの戻り値をスタブ化すると環境側は固定できますが、モデル側は依然として非決定的です5。つまり「2つの変数が動く」状態を「1つの変数が動く」状態に変えるということです。それには価値がありますが、「同じ入力なら必ず同じ出力」という種類の再現ではありません。保証だと思わず、ノイズ削減だと思ってください。
どんな問題がこのフローに値するか
5ステップを歩き切るにはコストがかかります。絞り込みにはログを読む必要があり、再生にはシミュレーションを組む必要があり、コンポーネントを痛めつけるには数十回動かす必要があります。粗いけれど実用に耐える分け方はこうです。
- 低頻度で無害なゆらぎ — たとえば一度だけ検索を余分に1回呼んだが、結果は正しかった、といったもの。記録して溜めます。個々の事例は調査する価値がありませんが、十数件溜まれば共通のパターンが見えることが多く、その時点で一度調査するほうがずっと効率的です。
- 影響の大きいもの — 事実として誤った答えを出した、触ってはいけないファイルを変更した、返らずにハングした。頻度がどれだけ低くても案件を立ててください。1回の発生ですでに十分高くつきます。
- 繰り返すもの — 同じ失敗の形が3回目に現れたなら、それは運ではなく構造的なものです。案件を立ててください。
レッスン1のあの一節が、ここでの判断基準になります。たとえば、ユーザーはエージェントが "not finding obvious information"(明らかな情報を見つけられない)と報告してくるのに、なぜそうなるのかがこちらには見えませんでした。エージェントは悪い検索クエリを使っていたのか。質の低いソースを選んでいたのか。ツールの失敗にぶつかっていたのか5。ある問題を調査しないと決めるとき、あなたは「どの原因なのか分からない」を受け入れています。無害なゆらぎならそれで構いませんが、影響の大きい問題ではそれは賭けです。
このレッスンはここまで紙の上の話でした。レッスン6では両方の半分を実践で1つに合わせます。第7コースのハーネスに完全な観測レイヤーを組み込み、「なぜかが分からない」症状を1つ取り上げて、最後まで追い詰めます。
まとめ
- hooks は、Claude Code のライフサイクルの特定の地点で自動的に実行される、ユーザー定義のコマンド、HTTP エンドポイント、または LLM プロンプトです。イベントが発火して matcher が一致すると、Claude Code はそのイベントに関する JSON のコンテキストをあなたのハンドラに渡します1。
- イベントは3つのケイデンスに分かれます。セッションごと1回(
SessionStart/SessionEnd)、ターンごと1回(UserPromptSubmit/Stop/StopFailure)、ループ内のツール呼び出しごと(PreToolUse/PostToolUse)です1。イベントを選ぶ前にケイデンスを固めてください。SessionStart はセッションを再開したときにも発火します1。
PreToolUse は Claude がツールのパラメータを作成した後、ツール呼び出しを処理する前に実行されます。PostToolUse はツールの正常な実行後に発火し、入力に tool_input と tool_response の両方を運ぶので、1回の発火で完全な呼び出し記録が手に入ります1。1つの hook で全部記録するには matcher を省略するか "*" に設定します1。
- コマンド hooks は stdin 経由で JSON を受け取り、終了コード、stdout、stderr で応答します1。あの実行ミリ秒のフィールドは権限プロンプトと
PreToolUse に費やされた時間を除外している1ので、ユーザーの待ち時間として使ってはいけません。
- 2つの否定的な結論があります。Claude Code は自分が起動するあらゆるサブプロセス(hooks を含む)から
OTEL_* エクスポーター変数を取り除く1ので、hook からテレメトリを出したいなら自前のエクスポート設定を持たせる必要があります。ペイロードで渡されるトランスクリプトファイルは非同期に書かれ、hook の発火時点では現在のターンの直近のメッセージをまだ含んでいないことがあります1。コマンド hooks はあなたのユーザー権限をすべて持って実行されるので、導入前にレビューしてください1。
- hooks が動かないときは、
--debug-file を使ってデバッグログでどの hook が一致したか、終了コード、stdout/stderr の全文を確認します1。matcher の一致数が欲しいならログレベルを verbose にします1。hook のペイロードの prompt id とテレメトリイベントの prompt.id は同じ値なので、2つのデータソースを揃えられます1。
- 5ステップの原因特定フロー(このコースの編成):
prompt.id でイベントをフィルタしてこのプロンプトに絞る3 → 1つのステップの失敗が軌道全体を変えるので、最初の分岐点を見つける5 → 同一のプロンプトとツールで再生し、一歩ずつ観察する5 → 疑わしいコンポーネントを数十回痛めつけてバグを表に追い出す5 → 修正後は最初から再スタートせず、失敗地点から復帰する5。
- 固定シード、temperature 0、記録したツールの戻り値の再生といったテクニックにはファーストパーティの根拠がありません。このコースはそれらをエンジニアリングの実践として提示します。環境側は固定できますが、モデルは非決定的なまま5なので、再現の保証ではなくノイズ削減です。
- すべての問題がフル装備のフローに値するわけではありません。低頻度で無害なゆらぎは記録して溜め、パターンが見えてから調べます。影響の大きいものや繰り返すものは、すぐに案件を立ててください。
>> レッスン6: 実践: ハーネスに観測レイヤーを組み込む
💻 演習