The guard that watched 3 of 11 writers
A SessionStart hook exists specifically to catch analytics writers that die
silently. It ran every session for four months and reported green, while four files under
~/.claude/analytics/ had not been written since April. Its watch list named three
files. All three were alive.
Toggle the watch list
| file | last write | watched? | guard verdict |
|---|
Why it could not see them
The guard is a peer comparison: a file is dead if it is stale (48h+) while a sibling is fresh (24h-). That logic is correct and was never the problem. The watch list was the problem. It was written when three writers existed, and every writer added later was invisible to it by construction, including all four that died.
BEFORE AFTER
const WATCHED = [ readdirSync(dir)
'skill-usage.jsonl', <- alive .filter(f => f.endsWith('.jsonl'))
'agent-usage.jsonl', <- alive .filter(not a rotated archive)
'hook-timing.jsonl', <- alive .filter(not event-driven)
] .filter(not deliberately retired)
sees 3 of 11. the 8 it cannot sees every writer by default.
see include all 4 that died. a new writer is covered without
anyone remembering a list.
The root cause chain
- #1266 (v7.30.0, April) removed the
appendAnalyticscalls from cache-break-detector and sync-session-dispatcher, stating the data was "already in the emit path". - That path did not carry it. Neither file calls
emit()at all, andemit()fans out to sinks that write~/.claude/ork-telemetry/, not~/.claude/analytics/. That directory holds one file today:circuit.json. MeanwhileappendAnalyticsdescribes itself as the source of truth. - The hooks kept running. cache-break-detector still computed every shape delta each turn and then discarded it, which is why restoring the write is three lines rather than a rebuild.
- dx-signals was never real. A tree-wide search finds that name only in the forwarder's allowlist and its own test. It advertised an event stream nothing has ever produced.
- The guard reported green throughout, because of the watch list above.
A claim this PR deliberately refutes
The issue reported cache-break-detector as an orphan: zero references in
hooks.json, zero in the entries map, therefore it "cannot ever have fired."
Counting is right; the conclusion is not. It is fanned out by
prompt/unified-dispatcher, which is registered in both surfaces, so a
dispatched hook has zero direct references by design. It fires every turn, proven
by 19 live state files it wrote and by its minified body sitting in the shipped bundle.
Acting on the original reading would have been the actual regression: re-registering it
double-fires it and double-counts /goal turns, and deleting it breaks turn
counting outright.