Claude Media
FileChanged hookでディスク上のファイル変更を検知する

FileChanged hookでディスク上のファイル変更を検知する

FileChanged hookはBashコマンドや外部プロセスによる書き換えにも反応し、ファイルシステムの変更をディスク上で直接検知します。設定方法とwatchPathsによる動的な監視対象の指定、よくあるつまずきを解説します。

Claude CodeのHooksには、ツール呼び出しをきっかけに動くもの以外に、ディスク上の変化そのものをきっかけに動く種類があります。FileChanged hookはその代表で、監視対象に指定したファイルが変わった瞬間に発火します。ClaudeのEditツールで書き換えたのか、Bashで実行したスクリプトが書き換えたのか、Claude Codeの外で動く別プロセスが書き換えたのかを問いません。ファイルシステムの監視機能で変化そのものを見ているため、書き換えの手段を選ばずに拾えるのが最大の特徴です。

FileChanged hookで何ができるか

ツール呼び出しを検査するのではなく、ファイルシステムウォッチャーで変化を検知する仕組みなので、Claude Codeとは無関係な外部プロセスが書き換えたファイルにも反応します。向いているのは、エディタやほかのツールが外から書き換える設定ファイルを読み直す用途です。

典型的な使い方は、プロジェクトの設定ファイルが変わったときに環境変数を再読み込みする処理です。.envや.envrcのようなファイルを監視しておき、変化を検知したらその内容を読み直してセッションに反映する、という運用がそのままあてはまります。

FileChangedは決定制御(decision control)を持ちません。ツール呼び出し前に発火するPreToolUseのように許可・拒否を返す仕組みがなく、変化を止めることはできません。役割はあくまで「変化への反応」であって「変化のゲート」ではない、と割り切って設計されています。

設定の書き方 — matcherが2つの役割を兼ねる

FileChangedのmatcherは、他のイベントには無い二重の役割を持ちます。1つは監視リストを組み立てること、もう1つは発火時にどのフックグループを実行するかを絞り込むことです。次の設定は、data.csvが変わるたびに改行コードを正規化するフックです。

{
  "hooks": {
    "FileChanged": [
      {
        "matcher": "data.csv",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/normalize-line-endings.sh"
          }
        ]
      }
    ]
  }
}

matcherの値は|で分割され、各セグメントが作業ディレクトリ内のリテラルなファイル名として登録されます。".envrc|.env"と書けば、監視されるのはその2つだけです。同じ値は、変化が起きたときにどのフックグループを動かすかのフィルターとしても、変化したファイルのベース名に対して再利用されます。

文字種で判定の経路が変わる

FileChangedとStopFailureの2つだけは、完全一致として扱う文字が英数字・アンダースコア・|に絞られています。他のイベントなら完全一致の扱いになるハイフン、スペース、カンマを含めると、正規表現の経路に回ります。区切りとして働くのは|だけです。

この規則を表のとおりに当てはめると、data.csvや.envrcのようにドットを含む名前も、発火時のフィルターでは正規表現として評価されます。正規表現は前方・後方を固定しない照合なので、.envというmatcherのグループは、.envrcが変わったときにも動く計算です。.env用と.envrc用に別々のグループを置く構成では、1回の変化で両方のグループが走る可能性があります。.を含むmatcherの発火時の挙動は公式ドキュメントに例がないため、実際に使う前に手元で試しておくと安心です。

手元でフックの動きを再現する

公式ドキュメントの改行コード正規化スクリプトは、フックを動かすClaude Codeを介さなくても単体で試せます。標準入力に渡すJSONは、後述する入力フィールドと同じ形で足ります。次の出力は、一時ディレクトリでjqとperlを使って実行した結果です(Claude Codeを介さない、スクリプト単体の実行)。

printf 'a,b,c\r\n' > data.csv
echo '{"file_path":"'$PWD'/data.csv","event":"change"}' > in.json
od -c data.csv
./norm.sh < in.json
od -c data.csv
0000000    a   ,   b   ,   c  \r  \n
0000007
0000000    a   ,   b   ,   c  \n
0000006

7バイトだったdata.csvが、\rの消えた6バイトになりました。ここまでは想定どおりです。問題は、このスクリプトからガードを外した場合です。同じファイルにガード付きとガードなしのスクリプトを順に実行し、inodeと更新時刻を並べます。

guarded 2nd run     inode 158593469 mtime_ns 1791119631158500032
unguarded 3rd run   inode 158593486 mtime_ns 1791119633541073724

ガード付きの2回目は、1回目の実行後と同じinodeと更新時刻のままで、ファイルに触れていません。ガードなしのperl -piは置換するものが無くてもファイルを作り直すため、inodeも更新時刻も変わりました。FileChangedはこの作り直しを新しい変化として検知するので、ガードのないフックは自分自身を呼び続けます。公式ドキュメントのスクリプトでgrepが先に走るのは、この無限ループを避けるためです。

実際にClaude Code上で試すなら、Claudeに「data.csvにCRLFの行をBashで追記して」と頼みます。Bashが書き換えた場合でもフックが走り、ファイルがLFに揃えば成功です。

入力と出力のフィールド

FileChanged hookは、共通の入力フィールドに加えてfile_pathとeventを受け取ります。

フィールド内容
file_path内容変化したファイルの絶対パス
event内容change(変更)、add(作成)、unlink(削除)のいずれか
{
  "session_id": "abc123",
  "cwd": "/Users/my-project",
  "hook_event_name": "FileChanged",
  "file_path": "/Users/my-project/.envrc",
  "event": "change"
}

出力側で意味を持つのはwatchPathsとsystemMessageです。continueフィールドを返しても無視されます。systemMessageはインタラクティブなセッションでは短い通知として画面に表示されますが、SDKのメッセージストリームには届きません。終了コード2でエラーを返しても、変化そのものは止まらず、標準エラー出力はユーザーにだけ表示されます。

eventがunlinkのときは、file_pathが指すファイルはもうありません。スクリプトがgrepやperlでそのパスを開くと失敗するので、削除イベントを受ける設計ではeventの値で分岐しておきます。

もう1つ重要なのがCLAUDE_ENV_FILEです。FileChanged hookは、SessionStart・Setup・CwdChangedと並んでこの環境変数へのアクセスを持ちます。ここにexport文を書き込むと、以降のBashコマンドにその変数が引き継がれます。ただし永続的ではなく、次のCwdChangedイベント(作業ディレクトリの変更)が起きた時点でClaude Codeがクリアします。

動的にwatchPathsを返して監視対象を広げる

起動時にすべての監視対象を列挙できない場合は、フックがwatchPathsを返して監視リストを後から変えます。返せるのはFileChangedだけでなく、SessionStartやCwdChangedのフックからも同じフィールドです。

手順

動的な監視を始めるまで

  1. 1

    種になる名指しを1つ置く

    ウォッチャーは、誰かがファイルを名指ししたときにだけ起動します。静的なファイル名を持つFileChangedグループか、watchPathsを返すSessionStart・CwdChangedのフックを最低1つ用意します。

  2. 2

    動的な監視を受けるグループはmatcherを省略する

    matcherを省略したグループは、監視リストに何も足さず、監視中のすべてのファイルの変化に反応します。

  3. 3

    watchPathsに絶対パスの配列を返す

    フックの標準出力のJSONに、監視したい絶対パスを並べます。

注意したいのは、watchPathsが追加ではなく置き換えである点です。返した配列は、それまでの動的な監視リストをそっくり入れ替えます。matcherで指定したパスは常に監視されますが、動的に足したパスは、次に返した配列に含めなければ監視から外れます。空の配列を返すと動的なリストが空になります。

次の出力例は、新しいファイルを見つけたフックが2件を監視対象にする場合の形です。

{
  "watchPaths": [
    "/Users/my-project/config/app.yaml",
    "/Users/my-project/config/local.yaml"
  ]
}

先に返した3件に1件を足したいなら、フックは既存の3件を自分で持ち直し、4件の配列を返します。置き換えの挙動は、CwdChangedの側ではディレクトリ移動のたびに前のディレクトリの監視を空にして新しく組み直す、という使い方にそのまま合っています。direnvのようにディレクトリごとに環境を切り替えるツールと相性がよく、CwdChangedとペアで使う設計が公式ドキュメントでも紹介されています。

PreToolUse・PostToolUseとの使い分け

同じ「ファイルの変更」を扱うフックでも、発火のタイミングと検知できる範囲が違います。

くらべる

書き換えの手段ごとに、どのフックが反応するか

ツール呼び出しを見る

PostToolUse

Claude自身のEdit・Writeなどの呼び出しの直後に走ります。Bashスクリプトや外部プロセスによる書き換えには反応しません。PostToolUse hookは結果を見て後処理をする用途に向きます。

ディスクの変化を見る

FileChanged

ツール呼び出し、Bashスクリプト、外部プロセスのどれが書き換えても、変化を検知した時点で走ります。ブロックはできません。

書き込みそのものを止めたい、あるいは内容を書き換えてから実行させたいなら、FileChangedでは実現できません。変化はすでに完了した後に通知されるだけなので、そこは許可・拒否・改変を返せるPreToolUse hookの役割です。

Bashが変更したファイルに限れば、PostToolUseフックでも変更を受け取る道があります。Bashコマンドがgitリポジトリのファイルを変更したとき、Claude Codeはその差分を記録し、PostToolUseフックのtool_response.bashEditDiffに変更ファイルの一覧を渡します。記録されるのは次の範囲です。

  • 既定では、自動モードとbypassPermissionsモードで、Claude CodeがBash経由のファイル編集を指示した場合だけ
  • CLAUDE_CODE_BASH_EDIT_DIFFを1にするかbashEditDiffEnabledを有効にすると、すべてのパーミッションモードで記録。ただしbashEditDiffEnabledは、ユーザー設定・--settings・管理者設定でだけ効き、プロジェクト設定では効きません
  • gitが無視するファイルとサブモジュール内のファイルは一覧に載らない
  • バックグラウンドコマンドと読み取り専用コマンドには差分が付かない

この機能はClaude Code v2.1.269以降が前提です。コマンドの実行中なら、別のプログラムや別のBash呼び出しが変えたファイルも一覧に載ることがあります。ただしBashの外で起きる変更を常時拾う仕組みではなく、手段を問わない常時の検知はFileChangedの領分です。

シェルループで複数ファイルを一括変更する運用のように、Claude Codeの外で動くスクリプトが対象ファイルを直接書き換えるケースでも、FileChangedなら等しく検知できます。Editツールがタブインデントのファイルで書き換えに失敗を繰り返すとき、自分のエディタで直接編集した結果をFileChangedに拾わせて後処理をつなぐ手があります。失敗の原因と対処はClaude Codeでタブ編集できないエラーの原因と対処法にあります。

よくあるつまずき

正規表現を書いても効かない。 matcherに^\.envのような値を書くと、他のイベントの感覚では「.envで始まるファイル」を意図します。FileChangedでは、監視リストには文字どおり^\.envという名前のファイルとして登録されるだけです。ワイルドカードや前方一致が必要に見える場面でも、監視リストに入れられるのはリテラルなファイル名の並びに限られます。

ガードのないスクリプトが無限ループする。フックがファイルを書き換えると、その書き換え自体がまた新しい変化として検知され、同じフックが再び走ります。前の節の実行例のとおり、置換対象が無くてもperl -piはファイルを作り直すため、grepのような事前確認が要ります。

"*"を指定しても「すべて監視」にはならない。 matcherに"*"を書くと、発火フィルタとしてはすべてのファイルにマッチします。しかし監視リストには、*という名前の1ファイルがそのまま追加されるだけです。

何も起きない。設定したのにフックが一度も走らないなら、名前を指定したmatcherのグループも、watchPathsを返すフックも無い状態かもしれません。この場合は監視自体が始まっていないので、手順の1つ目の名指しを足します。

環境変数がいつの間にか消える。 CLAUDE_ENV_FILEに書き込んだ変数は、次のCwdChangedイベントでクリアされます。ディレクトリを移動する運用を挟むと、FileChangedで設定したはずの変数が失われていた、という状況が起こり得ます。

まとめ

FileChangedは検知して反応する側で、止める・戻すのは別の機能の仕事です。使い分けの軸は、反応のきっかけが書き込みの手段に依らないかどうかになります。

変更を検知してから元に戻したいなら、Agent SDK File Checkpointingのようにセッション内の変更を巻き戻す仕組みの方が目的に合います。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/env-vars
  3. code.claude.com/docs/en/settings-reference
この記事を共有:XはてブLinkedIn