FileChanged hook not triggered by real file deletion (unlink) outside session cwd on Windows, despite correct settings.json config and watchPaths from SessionStart
Title
FileChanged hook not triggered by real file deletion (unlink) outside session cwd on Windows, despite correct settings.json config and watchPaths from SessionStart
Body
Summary
A FileChanged hook configured per the documented schema (matcher "SKILL.md", plus watchPaths supplied by a SessionStart hook) does not fire when a watched file is actually deleted (unlink) via an external process (Bash rm), even though:
- the
settings.jsonhook configuration is syntactically and structurally correct (verified by parsing it), - the
SessionStartcompanion hook that emitswatchPathsreturns the correct, valid absolute paths (including the exact file that was deleted), - the
FileChangedhook script's own logic is provably correct — calling it directly with a synthetic{file_path, event:"unlink"}payload produces the expected log entry andsystemMessageoutput.
The only step that does not happen is: the host never invokes the FileChanged hook command when the actual file deletion occurs.
Environment
- OS: Windows 11 (10.0.28120)
- Claude Code: current version as of 2026-08-28
- Working directory for the session under test: a non-git plain folder (
Carreer operations) - Watched files:
~/.claude/scheduled-tasks/*/SKILL.md(absolute paths, outside the session's owncwd)
Configuration (settings.json, relevant excerpt)
"hooks": {
"FileChanged": [
{
"matcher": "SKILL.md",
"hooks": [
{
"type": "command",
"command": "node \"C:\\Users\\<user>\\.claude\\scripts\\hooks\\scheduled-tasks-deletion-monitor.js\"",
"timeout": 10
}
]
}
],
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"C:\\Users\\<user>\\.claude\\scripts\\hooks\\scheduled-tasks-watch-seed.js\"",
"timeout": 10
}
]
}
]
}
scheduled-tasks-watch-seed.js reads the ~80 watched SKILL.md files under ~/.claude/scheduled-tasks/*/SKILL.md and returns:
{"watchPaths": ["C:\\Users\\<user>\\.claude\\scheduled-tasks\\aufgaben-board-abend\\SKILL.md", "...79 more..."]}
This output was verified correct by running the script directly (echo '{}' | node scheduled-tasks-watch-seed.js) — it lists the exact absolute path of the file used in the reproduction steps below.
Reproduction steps
- Confirm the
settings.jsonabove is active (session restarted //compactrun, so a freshSessionStartfires withtrigger: "compact"). - From the same session, run (via the Bash tool):
````
rm ~/.claude/scheduled-tasks/aufgaben-board-abend/SKILL.md
(a real filesystem deletion, not through the Edit/Write tool)
- Wait several seconds.
- Check whether the
FileChangedhook command ran, e.g. by checking its log file (~/.claude/logs/scheduled-tasks-deletions.jsonl) or itssystemMessageoutput.
Expected behavior
Per the documented FileChanged semantics ("it runs the hook no matter what changed the file: an Edit or Write tool call, a script Claude runs with Bash, or a process outside Claude Code entirely"), the hook command should run with {"file_path": "C:\\Users\\<user>\\.claude\\scheduled-tasks\\aufgaben-board-abend\\SKILL.md", "event": "unlink", ...} on stdin, producing a log entry and a systemMessage.
Actual behavior
No log entry is written, no systemMessage appears. This was tested twice:
- Once by appending a line to the watched file (a
"change"event) — by the hook's own design this correctly produces no alarm (only"unlink"alarms), so this test was inconclusive by itself. - Once with an actual
rmof the watched file (genuine"unlink") — no hook invocation observed at all, in either case.
What has been ruled out
- Config syntax/structure:
settings.jsonwas parsed withJSON.parseand manually verified —hooks.FileChangedandhooks.SessionStartboth present and structured per docs. - watchPaths content: the
SessionStarthook script was run standalone and returns the correct absolute path of the file used in the test. - Hook script logic:
scheduled-tasks-deletion-monitor.jswas invoked directly (both via CLI/stdin and by importing its exported functions) with a syntheticunlinkevent for the exact same file path — it correctly detects the watched path, writes the log entry, and returns the expectedsystemMessage. So the hook command itself, if invoked with the documented payload, behaves correctly. - SessionStart timing: the hook config was added mid-session (settings.json edited manually by the user after being blocked by the auto-mode classifier for direct edits); a
/compactwas run afterward specifically to trigger a freshSessionStart(trigger=compact) within the same session, on the theory thatwatchPathsneeds to be (re-)registered after aSessionStartthat has this config active. This did not change the outcome.
Documentation cross-check
Before filing, I re-checked every assumption above against the current official docs (code.claude.com/docs/en/hooks.md) directly, rather than guessing:
FileChanged: "Claude Code detects changes with a filesystem watcher, not by inspecting tool calls, so it runs the hook no matter what changed the file: an Edit or Write tool call, a script Claude runs with Bash, or a process outside Claude Code entirely." — this explicitly covers our case (Bashrm), so the docs say this should have fired.SessionStartoutput fields:watchPathsis documented as "Array of absolute paths to watch for FileChanged events during this session" — exactly the mechanism used here.FileChangedoutput: "watchPaths— Array of absolute paths. Replaces the current dynamic watch list. Paths from your matcher configuration are always watched." — confirmsmatcherand dynamically-suppliedwatchPathsare meant to be additive, matching our setup (matcher"SKILL.md"+watchPathsfromSessionStart).matcherforFileChanged: "the value is split on|and each segment is registered as a literal filename in the working directory" — confirms matcher alone would only cover the session's own cwd, which is exactly whywatchPathswas added for the other ~79 files outside cwd.
So per the documented spec, our configuration and expectations are correct, and the Bash rm reproduction case in particular should have triggered the hook even without watchPaths being involved at all (matcher-only coverage would still apply if cwd matched; watchPaths extends beyond that). Since it did not fire in either the matcher-covered or watchPaths-covered scenario, this looks like a genuine behavioral gap between docs and implementation rather than a configuration mistake — but posting here in case something Windows-specific or session-scoping-specific is undocumented.
Open question
Is there a known Windows-specific limitation in the filesystem watcher backing FileChanged/watchPaths (e.g. debounce/latency beyond what was tested, a required minimum file count, or scoping only to changes visible within the same OS-level watch handle)? Happy to provide more reproduction detail (e.g. via --debug output) if useful.
Why this matters
This hook exists specifically to catch accidental deletions of scheduled-task definition files caused by concurrent Claude Code sessions modifying the same shared ~/.claude/scheduled-tasks/ tree (we've had a real incident where one session's file was deleted by a race with another concurrent session). Without a working FileChanged + external-path watchPaths mechanism, there's no way to detect such deletions coming from outside the observing session.