「先に lint かけて」と毎回書かない ─ Claude Code Hooks 入門

Claude Code Hooks 入門 - 鉤フックがライフサイクル矢印に割り込むイメージ

「commit 前に lint かけ忘れて、CI で慌てた」「危険な rm -rf を Claude にうっかり走らせかけた」「セッション開始のたびに git status を貼り直している」。Claude Code を毎日使い始めると、こうした 「毎回手動で気づかせる作業」 が静かに積み上がります。本記事ではその出口になる Hooks公式 reference)を、ライフサイクル全体図と最小実装の両方で扱います。

シリーズ #10 API を curl で叩く生活から卒業する で MCP を扱いました。本記事は同じ「Phase D:外との接続」の中で、外との接続ではなく Claude Code 自身のライフサイクルに割り込む レイヤーです。

TL;DR

  • Hooks は Claude Code のライフサイクル特定地点で自動発火するスクリプト機構(公式 reference
  • 公式 docs の Hook lifecycle 表には 2026-05-16 時点で 29 イベントが列挙されている(該当セクション
  • 発火頻度を 3 つ(セッションあたり / ターンあたり / ツール呼び出しあたり)に分けて整理すると頭に入る
  • 4 つの handler type(command / prompt / agent / http)。日常運用は command が中心
  • matcher(hook を発火させる条件フィルタ)の型は hook イベントごとに違う。/hooks メニューで確認できる
  • 今週やる 1 つ:commit 前 lint の PreToolUse hook を if: "Bash(git commit*)" で 15 行書く

もし、Claude が commit 前に勝手に lint してくれたら

「先に lint かけてからコミットして」と毎回プロンプトに書いている人は、私だけではないと思います。Claude Code を使い始めた最初の月、私は次のような体験を 1 日に何度か繰り返していました。

  • 急いでいるときに「コミットして」とだけ送ってしまい、lint エラー入りで push してしまう
  • 危険な rm -rf をうっかり Claude に走らせかけて、/permission ask で止まったから事なきを得る
  • 毎朝セッションを開くたびに、git status と直近の作業メモを手で貼り直す

要するに「Claude にいつも同じ前提を、自分の頭から呼び出してプロンプトに書く」往復で時間が溶けます。もし Claude Code が、commit 前に必ず lint をかけてくれて、危険なコマンドを未然に止めてくれて、セッション開始時に作業前提を勝手に注入してくれたら ─ 便利だと思いませんか?

これを叶える仕組みが Hooks です。

問題の立て方が間違っている

「毎回プロンプトに 先に lint かけて と書く」を改善しようとすると、たいてい「短い CLAUDE.md にこのルールを書いておこう」「/morning-check のような slash command にまとめよう」という方向に行きます。どちらも有効ですが、いずれも Claude がそれを読みに行く必要があります。Claude が忘れたら効きません。

Hooks は逆向きです。Claude Code 本体がライフサイクルの特定の瞬間に、こちら側のスクリプトを 強制的に呼びに来ます。Claude の判断を経由しません。だから「Claude が忘れる」という事象自体が消えます。

書く場所も settings.json の hooks キーで、シリーズ #09 設定階層を理解すれば事故らない で扱った 5 階層スタックにそのまま乗ります。チーム共有なら .claude/settings.json、自分専用なら .claude/settings.local.json、全プロジェクトなら ~/.claude/settings.json です。

Hooks の全体像 ─ 29 イベントを 3 つの発火頻度で整理する

公式 docs の Hook lifecycle 表(2026-05-16 時点)には 29 種類のイベントが並んでいます。一気に覚えようとすると挫折するので、公式自身が冒頭文で示している 3 つの発火頻度(公式英語表記は cadence)で頭に入れます。公式から原文どおり引用します。

Events fall into three cadences: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop, StopFailure), and on every tool call inside the agentic loop (PreToolUse, PostToolUse):

イベントは 3 つの発火頻度に分かれます。セッションあたり 1 回(SessionStartSessionEnd)、ターンあたり 1 回(UserPromptSubmitStopStopFailure)、そしてエージェントループの中のツール呼び出しごとに 1 回(PreToolUsePostToolUse)。

この 3 帯のどこに自分の hook を置きたいかを決めれば、残り 23 種類は「派生・側枝」として整理できます。

flowchart TD
    subgraph S["per session(外側 — 1 セッションで 1 回)"]
        S1["SessionStart"]
        subgraph T["per turn(中 — 1 セッション中に複数回)"]
            T1["UserPromptSubmit"]
            subgraph A["per tool call(内側 — 1 ターン中に何度も発火)"]
                A1["PreToolUse"]
                A2["PostToolUse"]
            end
            T2["Stop"]
        end
        S2["SessionEnd"]
    end
    S1 --> T1
    T1 --> A1
    A1 --> A2
    A2 --> T2
    T2 --> S2

session の枠(外側)の中に turn の枠(中)、さらにその中に tool call の枠(内側)がネストしています。1 セッション中に turn は何度も繰り返され、1 ターン中に tool call は何度も発火する、という入れ子の発火頻度です。残り 23 イベント(side-effect 系・subagent / task / compact / worktree / MCP elicitation)はこの 3 帯の派生・側枝で、必要になったときに下の表から引きます。

公式の Hook lifecycle 表に列挙されている 29 イベントを、発火頻度別にまとめます。

発火頻度イベント
per sessionSessionStart / Setup / SessionEnd
per turnUserPromptSubmit / UserPromptExpansion / Stop / StopFailure
per tool callPreToolUse / PermissionRequest / PermissionDenied / PostToolUse / PostToolUseFailure / PostToolBatch
side-effect 系Notification / ConfigChange / CwdChanged / FileChanged / InstructionsLoaded
subagent / taskSubagentStart / SubagentStop / TaskCreated / TaskCompleted / TeammateIdle
compactPreCompact / PostCompact
worktreeWorktreeCreate / WorktreeRemove
MCP elicitationElicitation / ElicitationResult

29 イベントの全数と各イベントの input schema は公式 Hooks reference をご確認ください。

4 つの handler type ─ 何が起こるかを決める「中身」の選択

イベントが発火したときに 何が走るか は handler の type で決まります。公式 docs では 4 種類が定義されています(Hook handler fields)。

type仕組み主な用途
commandシェルコマンドを実行。stdin で JSON 受け取り、exit code と stdout で返すlint・format・通知音。日常はほぼこれ
promptClaude モデルに 1 ターン判定させ、{"ok": true/false, "reason": "..."} を返すコミットメッセージ規約のような「ルールでは書ききれない判断」
agentsubagent を spawn(独立した子セッションとして新しく立ち上げる)して多ターン検証(Read / Grep / Glob 使用可)テストが通っているか実ファイルで確認する必要があるとき
httpURL に JSON POST して応答を受け取る外部 webhook 連携・社内承認システムへの問い合わせ

最初に書くべきは command です。promptagent は「シェルでは判断しきれないが、Claude の判断は呼びたい」というレアケースで、http は v2.1.63 で追加された比較的新しい手段です( Anthropic 公式 CHANGELOG.md で確認できます)。

command 型の最小形は 4 行で書けます。

{
  "type": "command",
  "command": "echo 'tool called' >> /tmp/claude.log",
  "timeout": 5000,
  "async": true
}

timeout はミリ秒。async: true を付けると Claude Code 本体をブロックしません。lint や format のような「結果を後で見ればよい」処理は asyncblock / deny を返したい処理は同期 が原則です。

Hook が発火するまでの流れ

settings.json に書いた hook が、実際に発火するまでの中の流れはこうなります。

flowchart TD
    A["Claude Code 起動"] --> B["settings.json 5 階層を読む<br/>(Managed → CLI → local → project → user)"]
    B --> C["hook イベントが発生<br/>(PreToolUse, SessionStart, ...)"]
    C --> D{matcher 評価}
    D -->|一致しない| E["何も起きない"]
    D -->|一致する| F{handler type}
    F -->|command| G["シェル実行<br/>stdin で JSON 受け取り"]
    F -->|prompt| H["Claude にプロンプト送信"]
    F -->|agent| I["subagent を spawn"]
    F -->|http| J["URL に POST"]
    G --> K["exit code と stdout で<br/>decision を返す"]
    H --> K
    I --> K
    J --> K
    K --> L["Claude Code が結果を反映<br/>(allow / deny / context追加など)"]

ポイントは matcher の段です。matcher は「この hook イベントのうちどれを実際に発火させるか」を絞り込む照合条件で、例えば PreToolUse の matcher に Bash と書けば Bash ツール呼び出しのときだけ hook が走り、mcp__memory__.* と書けば memory MCP の全 tool 呼び出しで走ります。matcher の書き方を間違えると、hook は静かに発火しないままになります。

matcher の型は hook ごとに違う

matcher は hook イベントごとに対応する値の型が違います。主要な hook について、公式 docs の Per-Hook Matcher Reference から抜粋します。

イベントmatcher の対象値の例
PreToolUsetool_nameBash / Edit / Write / mcp__memory__.*
PostToolUsetool_name同上
PermissionRequesttool_name同上
Notificationnotification_typepermission_prompt / idle_prompt / auth_success
SubagentStart / SubagentStopagent_typeBash / Explore / Plan / カスタム名
SessionStartsourcestartup / resume / clear / compact
SessionEndreasonclear / resume / logout
PreCompact / PostCompactcompact_triggermanual / auto
FileChangedfilename(basename).envrc|.env|.env.local
UserPromptSubmit / Stop / TeammateIdlematcher 非対応、常に発火

PreToolUse の matcher は正規表現対応で、mcp__memory__.*(memory MCP の全 tool)や mcp__.*__write.*(任意 MCP の write 系 tool)のような書き方ができます。MCP 連携は #10 Claude Code MCP 入門 で扱った mcp__<server>__<tool> 命名規則がそのまま使えます。

active な hook と matcher を確認するには、Claude Code 内で /hooks メニューを開きます。[User] / [Project] / [Local] / [Plugin] のラベルで、どの階層の hook が登録されているかが一覧できます。

踏みやすい罠 4 つ

罠 1: 重い処理を同期 hook に入れて Claude Code が固まる

type: "command" の hook は、デフォルトでは同期実行で、timeout まで Claude Code 本体をブロックします。lint・format・通知音のような「結果を後で見ればよい」処理に async: true を付け忘れると、毎回のツール呼び出しが遅くなります。

This project uses async: true for all hooks since sound notifications are side-effects that don’t need to block execution.

このプロジェクトでは、サウンド通知は実行をブロックする必要のない副作用であるため、すべての hook で async: true を使用しています。

例えば HOOKS-README の運用も同じで、lint や通知音は async 一択です。決断を返したい hook(PreToolUse で deny したい等)だけ同期にします。

罠 2: matcher を間違えて hook が一度も発火しない

「PreToolUse の matcher に * と書いたら全 tool に発火すると思ったら、何も起きなかった」。* だけでは正規表現として「直前文字の 0 回以上」を意味するため、空文字列にしかマッチしません。全 tool を対象にしたいなら ".*" か、matcher 自体を省略します。

似た失敗で多いのは、SessionStart の matcher に Bash を入れてしまうケースです。SessionStart の matcher 対象は source で、値は startup / resume / clear / compact のいずれか。tool_name ではないので、Bash と書いても永遠に発火しません。

確認手順は次の通りです。

  1. /hooks メニューで対象 hook が登録済みか確認
  2. 公式 docs の Per-Hook Matcher Reference で、その hook の matcher 対象を確認
  3. 試しに matcher 自体を外してみて、hook そのものは正しく書けているか分離テスト

罠 3: 設定の置き場所を間違えてチーム全員に効いてしまう / 自分に効かない

「自分の作業用 hook をうっかり .claude/settings.json に commit してしまい、チーム全員の作業ログが汚れた」「逆に、チームに渡したい lint hook を .claude/settings.local.json に書いて、CI でだけ効かなくて詰んだ」。シリーズ #09 設定階層を理解すれば事故らない で扱った 5 層スタックがそのまま hook にも効きます。

  • チーム共有 hook → .claude/settings.json(commit 対象)
  • 自分専用 hook → .claude/settings.local.json.gitignore で除外、commit しない)
  • 全プロジェクト共通 hook → ~/.claude/settings.json

claude-code-best-practice では、もう一段細かく .claude/hooks/config/hooks-config.json(共有)と .claude/hooks/config/hooks-config.local.json(個人)でオン・オフを切り替える運用が組まれていますが、これは個人と team の責任分界を強くしたい場合の追加レイヤーです。最初は settings.json の 2 層だけで十分です。

罠 4: PreToolUse の decision control を旧 API で書く

「古い Blog 記事を見て {"decision": "block"} を返したら効かない」。PreToolUse の decision control は、現行(v2.1 系)では hookSpecificOutput.permissionDecision を返す形に変わっています。公式から原文どおり引用します。

The PreToolUse hook previously used top-level decision and reason fields for blocking tool calls. These are now deprecated. Use hookSpecificOutput.permissionDecision and hookSpecificOutput.permissionDecisionReason instead.

PreToolUse hook は以前、ツール呼び出しをブロックするためにトップレベルの decision および reason フィールドを使用していました。これらは現在非推奨です。代わりに hookSpecificOutput.permissionDecision および hookSpecificOutput.permissionDecisionReason を使用してください。

旧と現行の対応はこうです。

旧(deprecated)現行
{"decision": "approve"}{"hookSpecificOutput": {"permissionDecision": "allow"}}
{"decision": "block"}{"hookSpecificOutput": {"permissionDecision": "deny"}}

「動かない」と感じたら、まず最新の公式 reference を見直します。

今週やる 1 つ ─ commit 前 lint の PreToolUse を 15 行で書く

最初の hook は 「commit 前に lint をかける PreToolUse hook」 が現実的です。Bash tool で git commit が呼ばれる直前に npm run lint を走らせ、エラーなら commit を block します。.claude/settings.json に追記する形で書けば、チームで共有できます。

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "npm run lint",
        "timeout": 30000,
        "if": "Bash(git commit*)"
      }]
    }]
  }
}

ポイントは if フィールド(v2.1.85 以降、HOOKS-README に記載)です。

The if field adds conditional execution to hooks using permission rule syntax. When set, the hook process is only spawned if the condition matches — reducing unnecessary process spawning.

if フィールドは permission rule 構文を使った条件付き実行を hook に追加します。設定されている場合、条件が一致するときだけ hook プロセスが起動されるため、不必要なプロセス起動を削減できます。

if: "Bash(git commit*)" は「Bash tool で git commit から始まるコマンドが呼ばれたとき」だけ hook プロセスを起動します。lscat のような毎回の Bash 呼び出しでは一切 npm を立ち上げないので、CPU と時間の両方を節約できます。

npm run lint が exit code 0 以外で終了すると、Claude Code はそのツール呼び出しを失敗扱いにし、commit を続けるか中止するかの判断を Claude 側に促します。lint エラーで commit が止まる体験を 1 度すれば、「先に lint かけて」と毎回プロンプトに書く生活には戻れません。

まとめ

Hooks は Claude Code を「言われたことしかやらない助手」から「ライフサイクルの特定地点で勝手に動く相棒」に変える仕組みです。押さえる軸は次の 4 つです。

  • イベント: 29 種類を 3 つの発火頻度(セッションあたり / ターンあたり / ツール呼び出しあたり)で整理
  • handler type: command / prompt / agent / http の 4 種類。日常は command
  • matcher: hook ごとに対応する値の型が違う。/hooks メニューで確認
  • decision control: PreToolUse の旧 decision フィールドは deprecated。現行は hookSpecificOutput.permissionDecision

シリーズ #09 .claude/settings.json の 5 階層 と組み合わせれば、「チームに効かせたい lint hook は .claude/settings.json、自分専用の通知音は .claude/settings.local.json」のような階層運用が組めます。