# Hooks page: per-hook fire telemetry

Status: DONE
Owner request (verbatim intent): surface per-hook fire telemetry on the Overdeck Hooks
page so hooks that never fire become visible removal candidates the owner decides on.
Motivation: a security plugin fired zero times across 802 sessions and nobody could see
that. "A hook that never fires is pure tax."

## Task IDs
- T1: collector source reading `~/.local/state/overdeck/hook-fires.jsonl`
- T2: collector aggregator merging JSONL counts with the dispatcher-manifest module
  inventory into 24h/7d/30d rollups + status verdict
- T3: `/hooks` page new section: per-module table with status chip
- T4: tests (collector unit + adapter, UI existing pattern)

## Data contract (frozen, owned by a parallel dispatcher lane — do not touch the writer)
Append-only JSONL at `~/.local/state/overdeck/hook-fires.jsonl`:
`{"ts":"<iso8601>","event":"PreToolUse|PostToolUse|Stop","module":"<manifest id>","action":"deny|advise|act"}`
File may not exist yet. Absent/empty renders "no data yet" — never fabricated zeros.

## Acceptance criteria
- Module inventory sourced from `modules/workstation/claude/hooks/lib/dispatcher-manifest.mjs`
  (module id = basename of `modulePath`, extension stripped) so a manifest module with
  zero fires still renders as a row.
- Per-module fires 24h/7d/30d, last-fired timestamp.
- Status chip: ACTIVE (fired in 7d) / SUSPECT (zero fires in >=7 observed days) /
  REMOVAL CANDIDATE (zero fires in >=30 observed days) / OBSERVING (day N) before
  the 7-day floor — "days observed" computed from the earliest telemetry line, never
  from wall-clock feature-ship date.
- Never auto-remove; page presents, owner decides.
- Existing Hooks page primitives kept as-is (`@platform-modules/ui-primitives`
  DataTable — page not yet ported to Astryx); tokens only, both themes.
- Zero new collector/web test failures vs pre-change baseline.

## Constraints / hard rules
- Never touch `settings.json`, `dispatcher.mjs`, `hooks/*.sh` (two other active lanes
  own those).
- Never touch the shared main checkout; worktree `wt/hooks-telemetry-ui` only.
- Staged deletions must be 0 before every commit.

## Execution steps
1. `collector/src/activity/sources/hookFires.ts` — JSONL reader modeled on
   `toolSuggest.ts`; wired into `collector/src/activity/registry.ts` and
   `collector/src/activity/types.ts` (`HookFiresSourceOptions`).
2. `collector/src/hook-fire-stats.ts` — new aggregator: parses the manifest (zod
   schema for JSONL records) + the same JSONL file, computes rollups/status; exposed at
   collector `/hooks/fire-stats`.
3. Web proxy: add `hooks/fire-stats` to the read-path allowlist in
   `apps/web/src/pages/api/collector/[...path].ts`.
4. Web client/query/types: `fetchHookFireStats`, `useHookFireStats`,
   `HookFireStatsResponse` mirroring the existing hook-inventory wiring.
5. `HooksContent.tsx`: new `SectionCard` below the existing hook tables — module,
   event, fires 24h/7d/30d, last fired, status chip.
6. Tests: `hookFires.test.ts`, `hook-fire-stats.test.ts` (fixture JSONL + manifest),
   UI test extension following `HooksContent.test.tsx`'s existing pattern (2
   pre-existing unrelated failures there — do not chase).

## Current receipt
Built, tested, and landed on origin/main as `9b965d908` (squash of `05b673556` +
`aa6119947`). Corrected the module-inventory source mid-implementation: the
dispatcher lane's landed writer (`e5b28a34c`) keys fires by `entry.id` from
`modules/workstation/claude/hooks/lib/dispatcher-registry.mjs`'s `REGISTRY`
(event-split, not the single `dispatcher-manifest.mjs` this plan originally
named) — `hook-fire-stats.ts` reads `REGISTRY`, not a path-derived module id.
Collector: 8 new tests (`hookFires.test.ts` x3, `hook-fire-stats.test.ts` x5),
zero new failures vs true pre-change baseline (866→874 pass, same 1
pre-existing unrelated `buildAdapters` failure). Web: `pnpm --filter web
typecheck|build` clean; `HooksContent.test.tsx` 16→21 tests, all green;
full web suite 455/455 pass.

Known data-contract caveat for the owner: `live-transcript-journal` and
`agent-session-ledger` are pure side-effecting modules that always return
`null` (by the writer's own design) and so NEVER produce a telemetry line
even though they fire on every call — this page will show them drifting
toward SUSPECT/REMOVAL CANDIDATE forever. That is a false-negative baked
into the frozen data contract, not a bug in this page; flag before anyone
acts on that specific row.

DEPLOY BLOCKED (external, not caused by this change): `~/.local/share/
overdeck/deploy` (the deploy clone) has another lane's uncommitted edits to
`modules/workstation/claude/hooks/*.sh` (bash-gate.sh, bg-gate.sh,
fs-scan-gate.sh, main-checkout-guard.sh, quietcontext-nudge.sh,
worktree-lock-gate.sh). `deploy-local.sh` refuses to run over a dirty clone
(by design — never discard another session's uncommitted work) and
`overdeck-deploy.path`/`.service` were stuck in a systemd start-limit crash
loop from rapid repeat triggers; reset once and restarted the `.path` watcher
so the next real trigger runs, but did NOT touch the dirty hook-script files
and did NOT force a deploy through them. Live `/hooks` still serves the
pre-change build (`/api/collector/hooks/fire-stats` returns 404) as of this
receipt.

## Next executable action
None — closed. See recovery receipt below.

## Recovery receipt (2026-08-16, board row manual-29381635baa6770f)
Feature was already fully built and landed on `origin/main` (verified
`05b673556` + `aa6119947`, matched by dispatcher writer `e5b28a34c`, all
ancestors of `origin/main`'s current tip `a8b6f5354`). The board row surfaced
this as an open ask because the row was never flipped to `shipped` — the
deploy-blocked receipt above was the last state recorded.

Verified live in this session:
- `overdeck-web.service` / `overdeck-collector.service` both running the
  current release.
- `curl http://127.0.0.1:31337/api/collector/hooks/fire-stats` → HTTP 200,
  real per-module rollup (`deny-gate` 93 fires/7d ACTIVE, `curl-timeout-gate`
  26 fires/7d ACTIVE, etc.) sourced from live
  `~/.local/state/overdeck/hook-fires.jsonl`.
- `HooksContent.tsx` renders the `Hook fires (N)` section keyed off
  `useHookFireStats()`, with SUSPECT/REMOVAL CANDIDATE chips per
  `statusFor()` in `collector/src/hook-fire-stats.ts` (7d / 30d thresholds,
  computed at read time, no cron).

NOT independently re-verified this session (relied on the prior receipt
above, which is trustworthy — landed commit, tests recorded green at merge
time): collector unit tests, web typecheck/build, and a fresh browser
screenshot of the rendered `/hooks` page (static HTML fetched here predates
hydration, so the section text does not appear in raw `curl` output — this
is expected for a client-fetched React island, confirmed by source, not by
pixels).

Board row `manual-29381635baa6770f` flipped `asked` → `shipped`, `proof_url`
pointed at the live collector endpoint above, `plan_ref` set to this file.
