Disk, not tool calls
PreToolUse and PostToolUse see a tool call. They tell you what Claude tried to do. FileChanged sees a change on disk, whoever made it. An edit by hand in another window, a script writing to the file, and Claude’s own Write tool all reach this event the same way.
One matcher does two jobs
Split on |, the matcher text becomes a set of literal filenames in the working directory, and Claude Code adds each one to the watch list. The same text then filters which handler groups run, by checking a changed file’s basename against that set. Text that looks like a regular expression is registered as a literal filename. A matcher of "*" watches a file named *. On a group that handles paths supplied at runtime, omit the matcher. Module 09 covers the narrower character set this event’s matcher accepts.
Three event values, and what the reference leaves out
The payload carries an absolute file_path and event: change for a modification, add for a new file, unlink for a deletion. The hooks reference does not document watcher debounce, event coalescing, delivery order across near-simultaneous changes, or how a platform rename maps onto those three values.
Growing the watch list from the hook
watchPaths in the JSON output, an array of absolute paths, replaces the dynamic watch list while the matcher-configured paths stay watched. The reference does not say what happens when two hooks return different arrays for the same change. The hook can also write variables to the file named by CLAUDE_ENV_FILE for later Bash commands. systemMessage appears as a brief notification in the terminal and does not enter the message stream an SDK program reads. continue is discarded.
The change stands
Exit 0 cannot undo a change already on disk, and the JSON applies. Exit 1 leaves the change too, and adds a hook error to the transcript. Exit 2 cannot block, and its stderr is shown to you only.
You watch package.json and run a formatter on it from the hook. The formatter writes the file. The watcher sees a change, from whichever process, and runs the hook again.
The sign is the hook firing more than once for one edit you made. Nothing on this event tells a hook that its own write is different from any other, so the loop is yours to break.
Skip the write when the formatted output equals what is already on disk, or write the result to a different file.