Claude Code SessionStart hookと4つのsourceの使い分け

Claude Code SessionStart hookと4つのsourceの使い分け | mohablog

朝イチで claude --resume してから「で、今どのブランチで何やってたんだっけ」と聞き直す時間、地味に効きます。SessionStart hookを設定しておくと、ブランチ・未pushコミット・進行中チケットといった作業状態をセッション開始時にモデル側へ自動で渡せるので、最初の1〜2ターンが丸ごと不要になります。

調べてみたら、SessionStart hookは Claude Code v2.1系の標準機能で、Hooks リファレンスの SessionStart Hook Reference セクションにJSON入出力まで明記されているのに、日本語の解説で4つのsourceを正面から扱った記事はそこまで多くありません。今回は Claude Code v2.1.131 を前提に、JSON入出力と4つのmatcher(startup / resume / clear / compact)の使い分け、ハマりどころまで一通り整理します。

目次

SessionStart hookで何が解決できるのか

そもそもの動機を確認しておきます。Claude Codeを長く使うと、コンテキストの劣化と起動コストの2つに悩まされます。

セッションが切れるたびに状況説明が必要になる

長いタスクを/clearで区切ったり、自動圧縮(auto compaction)が走ったりすると、CLAUDE.mdに書いていない一時的な状態(現在のブランチ、未マージのPR、作業中のチケット番号など)はモデル側から消えます。次のターンで「で、今どこまでやってたっけ」と確認するためのトークンを毎回消費するのは、わりと無駄です。

CLAUDE.mdは静的なルール、SessionStartは動的な状態

公式ドキュメントのRe-inject context after compactionでも触れられていますが、CLAUDE.mdに書くのは「コーディング規約」「コミットメッセージのルール」のような静的な情報です。一方で「今何のチケットを触っているか」「どのブランチ作業中か」といった動的な状態は、毎回コマンドを叩いて取得する必要があります。SessionStart hookはこの動的部分を担当する場所だと考えるとスッキリします。

「読まれる仕組み」がないと意味がない

個人的に一度ハマったのが、CLAUDE.mdに作業ログを書き溜めたら長くなりすぎて、結局Claudeが冒頭しか参照しなくなったケースです。書く仕組みがあっても読む仕組みがなければゼロ、というやつですね。SessionStart hookは「セッション冒頭に毎回必ず注入される」のが保証されているので、読まれる仕組みとしてはかなり強い場所になります。

SessionStart hookのJSON入出力

Hooks リファレンスのSessionStart Hook Referenceセクションに沿って、入出力の中身を見ていきます。stdinに渡されるJSONとstdoutに返すJSONの両方を理解すると、応用が一気に効きます。

stdinで受け取る入力

セッションが開始(または再開)されると、Claude Codeはhookスクリプトのstdinに次のようなJSONを渡してきます。

{
  "session_id": "abc123",
  "transcript_path": "/Users/sayed/.claude/projects/-Users-sayed-app/00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
  "cwd": "/Users/sayed/app",
  "hook_event_name": "SessionStart",
  "source": "startup",
  "model": "claude-sonnet-4-6"
}
$ echo '...' | jq -r '.source'
startup

共通フィールドのsession_idtranscript_pathcwdhook_event_nameに加えて、SessionStart固有のsourcemodelが乗ってきます。cwdはそのセッションの作業ディレクトリなので、$CLAUDE_PROJECT_DIRと組み合わせるとプロジェクトごとに振る舞いを変えやすいです。

stdoutで返す出力—2つの書き方

出力は2パターンあって、雑に書くなら標準出力に文字列をechoするだけでもコンテキストに追加されます。構造化したいときはhookSpecificOutput.additionalContextを使います。

#!/bin/bash
# 雑バージョン: stdoutが丸ごとコンテキストに追加される
echo "Current branch: $(git branch --show-current)"
echo "Uncommitted: $(git status --porcelain | wc -l | tr -d ' ') files"
exit 0
Current branch: feat/auth-refactor
Uncommitted: 7 files

こちらが構造化バージョン。複数のhookを同時に動かしたり、スクリプトの出力にデバッグログを混ぜたりするときに役立ちます。

#!/bin/bash
BRANCH=$(git branch --show-current)
DIRTY=$(git status --porcelain | wc -l | tr -d ' ')

jq -n \
  --arg ctx "Current branch: ${BRANCH}\nUncommitted files: ${DIRTY}" \
  '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'
exit 0
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "Current branch: feat/auth-refactor\nUncommitted files: 7"
  }
}

ここで触ってみないと気づきにくいポイントが1つあって、SessionStart hookで返したテキストはClaudeのコンテキストの先頭に追加され、平文として読まれます。なので# Reminder:のように見出しを付けてあげると、モデル側でも判別しやすくなります。

4つのsourceで挙動を分ける

SessionStartが発火するタイミングは1つではありません。matcherに何を書くかで、どのトリガーで動くかを絞り込みます。

matcher発火タイミング典型的な使い道
startup新規セッション開始初回のフルスキャン(ブランチ・PR・チケット)
resume--resume / --continue / /resume差分だけ更新(最新コミット、未push分)
clear/clear必須ルールの再注入
compactauto/manualの圧縮後失われた進行中タスクの再注入

matcherを書かないと全部で動く

これが最初のハマりどころで、matcherを空にしておくと4つのsourceすべてで同じスクリプトが走ります。git logで重い集計を回すスクリプトをmatcherなしで登録すると、/clearを叩くたびに数秒待たされる、みたいな状態になります。

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/full-context.sh" }
        ]
      },
      {
        "matcher": "resume|compact",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/light-context.sh" }
        ]
      }
    ]
  }
}

パイプ区切りで複数matcherをまとめられるので、resumecompactのように「軽量で十分」なケースを共通化するのが運用上はラクです。

resumeの再発火に気をつける

公式リファレンスにも書かれていますが、resume時にもSessionStart hookは再度発火します。これは「再開時にブランチが変わっているかもしれないので、コンテキストをリフレッシュしたい」という意図があるためです。逆に言うと、resume用には「初回からの差分」だけを返すスクリプトに分けておかないと、毎回フルスキャンが走って起動が遅くなります。

compactの注意点

auto compactionが走ると、それまでの進行中タスクや一時的なメモが要約に押し込まれ、細部は失われます。compact matcherでは「圧縮されると困る情報」だけを再注入する設計が基本で、起動時のような重いスキャンは入れない方が無難です。

実装サンプル—git状態とTODOを差し込むスクリプト

サジェストでよく出る「session start hook error」の話に行く前に、まず動くサンプルから示します。プロジェクトルートに.claude/hooks/inject-context.shを作る前提です。

NGパターン: 直書きで雑に始める

最初に試しがちなNG例から。

#!/bin/bash
# .claude/hooks/inject-context.sh
echo "branch: $(git branch --show-current)"
echo "todo: $(cat TODO.md)"
branch: main
todo: # TODO
- [ ] auth refactor
- [ ] ...(数百行続く)

動くは動くんですが、TODO.mdが長くなると注入トークンが膨れ上がり、コンテキストの節約という当初の目的と逆行します。あと、set -eすら入っていないのでgitが見つからない環境(Dockerなど)でエラーが silent に握りつぶされます。

OKパターン: 必要分だけを構造化して返す

#!/bin/bash
# .claude/hooks/inject-context.sh
set -euo pipefail

INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source')

BRANCH=$(git branch --show-current 2>/dev/null || echo "(no git)")
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
UNPUSHED=$(git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' ')
TODO=$(grep -E '^- \[ \]' TODO.md 2>/dev/null | head -3 || echo "")

CTX="# Session context (source=${SOURCE})
Current branch: ${BRANCH}
Uncommitted files: ${DIRTY}
Unpushed commits: ${UNPUSHED}
Top TODO:
${TODO}"

jq -n --arg ctx "$CTX" \
  '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "# Session context (source=startup)\nCurrent branch: feat/auth-refactor\nUncommitted files: 7\nUnpushed commits: 2\nTop TODO:\n- [ ] OAuth2移行のmiddleware差し替え\n- [ ] PR #4211 のレビュー反映"
  }
}

登録する設定ファイル

このスクリプトをchmod +xした上で、.claude/settings.jsonに登録します。

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/inject-context.sh"
          }
        ]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIRを絶対パス代わりに使うのが定石で、これを書かずに相対パスにすると、サブディレクトリでclaudeを起動したときにスクリプトを見つけられず、後述のhook errorに直行します。

CLAUDE.mdとSessionStart hook、どっちに書くか

「ルール系を全部SessionStartに寄せたほうが動的に管理できて良いのでは」という発想は一度誰でも通る道で、個人的には1度寄せて、結局戻しました。役割が違うので両方使う前提で考えるのが正解です。

項目CLAUDE.mdSessionStart hook
得意なこと静的なルール・規約動的な状態(branch/PR/TODO)
更新頻度低い毎セッション変わる
記述Markdownシェル + JSON
条件分岐不可可能(sourceで分岐)
パフォーマンス影響ほぼなし遅いと起動が遅延

「変わらないものはCLAUDE.md、毎回計算する必要があるものだけSessionStart」で線引きすると、メンテが破綻しません。

CLAUDE.mdに書くべきもの

  • 言語・フレームワークのバージョン(変更頻度が低い)
  • コミットメッセージ規約・PR作成ルール
  • テストの実行方法、Lintコマンド
  • 触ってはいけないファイル・ディレクトリ

SessionStart hookに置くべきもの

  • 現在のブランチ、未pushコミット数、未コミット差分
  • 進行中チケット番号やPR番号
  • 直近のgit log(3〜5件)
  • 環境固有のメモ(本番接続中か、ローカルDBが起動しているかなど)

「SessionStart:startup hook error」の正体

サジェストにも出てくるclaude code sessionstart startup hook error。実際に踏むと焦りますが、原因のほとんどはこの3つに集約されます。

原因1: スクリプトに実行権限がない

chmod +xを忘れた状態だと、Claude Codeはhookを呼び出せず、トランスクリプトにSessionStart:startup hook errorが表示されます。サンプルJSONを流して手元で動作確認すると一発で分かります。

echo '{"source":"startup","cwd":"'$PWD'"}' \
  | ./.claude/hooks/inject-context.sh
echo "exit=$?"
zsh: permission denied: ./.claude/hooks/inject-context.sh
exit=126

終了コードが126や127なら、ほぼ実行権限かパスのどちらかです。

原因2: .zshrcの無条件echoがJSONを汚染する

これが地味にハマります。Claude Codeはhookを動かす際にシェルを生成し、~/.zshrc~/.bashrcをsourceします。プロファイルにecho "Welcome"のような無条件出力があると、それがhookのstdoutの先頭に紛れ込んで、JSON解析で落ちるという挙動になります。

Shell ready on arm64
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"..."}}

対処は、profile側のechoをインタラクティブシェル限定にすることです。

# ~/.zshrc
if [[ $- == *i* ]]; then
  echo "Welcome"
fi

$-のシェルフラグにi(interactive)が含まれているかをチェックする、というやつですね。Claude Codeはhookを非インタラクティブシェルで動かすので、これでecho行はスキップされます。

原因3: jqが入っていない

サンプルではjqを多用していますが、jqが入っていない環境(最小構成のDockerコンテナなど)だとそこで落ちます。command -v jq >/dev/null || exit 0のようにフォールバックを入れておくか、Pythonに置き換えるのが安全です。

CTX="branch: $BRANCH"
python3 -c "import json,sys;print(json.dumps({'hookSpecificOutput':{'hookEventName':'SessionStart','additionalContext':'$CTX'}}))"

動作確認とデバッグの定番手順

hookは「設定したのに動いていない」ことに気付きにくい仕組みなので、検証手順を決めておくと精神衛生に良いです。

/hooksメニューで登録を確認

Claude Code起動中に/hooksを打つと、登録済みhooksの一覧が表示されます。SessionStartの横に件数が表示されていれば、設定ファイル自体は正しく読まれています。表示されないなら、JSONの構文エラー(末尾コンマなど)を疑います。

–debug-fileで実行ログを取る

もう一段深く見たいときは、デバッグログを別ファイルに書き出す--debug-fileを使います。

claude --debug-file /tmp/claude.log
# 別ターミナルで
tail -f /tmp/claude.log | grep -i hook
[hooks] SessionStart matched 1 hook(s) for source=startup
[hooks] running: /Users/sayed/app/.claude/hooks/inject-context.sh
[hooks] exit_code=0 stdout_bytes=312

どのmatcherがヒットしたか、終了コードとstdoutのバイト数まで出るので、スクリプト側の問題か登録側の問題かが切り分けやすくなります。

手元でstdinを流して単体テスト

hook単体での挙動確認は、echoでJSONを流し込むのが一番手早いです。

echo '{"session_id":"x","cwd":"'$PWD'","hook_event_name":"SessionStart","source":"compact"}' \
  | ./.claude/hooks/inject-context.sh \
  | jq .
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "# Session context (source=compact)\n..."
  }
}

sourceを変えながら4パターン試すと、matcher分岐のミスにすぐ気付けます。関連トピックとして「Claude Codeのコンテキスト管理術」も合わせて読むと、トークン消費との兼ね合いがイメージしやすくなると思います。

まとめ

  • SessionStart hookは「セッション開始時に毎回必ず注入される」場所で、CLAUDE.mdが苦手な動的状態を担当する
  • matcherはstartup / resume / clear / compactの4種類。空にすると全部で発火するので、起動が遅いと感じたら見直す
  • stdinでsession_idcwdsourcemodelを受け取り、stdoutに直接echoするかhookSpecificOutput.additionalContextで構造化して返す
  • 「SessionStart:startup hook error」の典型原因は、実行権限・.zshrcのecho混入・jq不在の3つ
  • 登録確認は/hooks、実行確認は--debug-fileと手元のecho | hook.shの3点セットでだいたい片付く
  • Claude Code v2.1.85以降ならifフィールドでさらに細かい絞り込みも可能

毎セッションの「いまどこ」を手動で説明するのを止めると、それだけで体感の集中力がだいぶ違ってきます。最初はecho "branch: $(git branch --show-current)"だけのhookから始めて、運用しながら少しずつ育てていくのがおすすめです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次