# Claude Code Hooks CPU Refactor

Audience: AI coding agents first.

## Result

Root cause: statusline rendering, not registered hook fan-out, launches `hooks/lib/run-plan-scan.mjs --mode=count` after every tool update whenever current repository has a local `plan/*` integration branch. Scanner then runs a depth-6 `find` across repository. No cache, debounce, lock, or path pruning exists. Two simultaneous `find` processes mean overlapping statusline renders; scanner contains one `find` call per invocation and has no second per-tool caller.

Applied only behavior-preserving changes. No security/routing gate decision changed.

## Registered Hook Inventory

Frequency labels describe maximum normal cadence. Claude Code may render statusline separately after these events.

| Event | Matcher | Command | Frequency | Behavior | Cost driver |
|---|---|---|---|---|---|
| `MessageDisplay` | all | `node hooks/live-transcript-journal.mjs` | Every displayed assistant delta | Appends crash-journal assistant record | Node cold start; file+directory fsync; state temp write+fsync+rename |
| `PostToolUse` | `Agent` | `node hooks/dead-advisor-record.mjs` | Every successful Agent call | Records dead-advisor `agentId` only for matching subtype | Node cold start; matching call shells to `resolve.sh` |
| `PostToolUse` | `*` | `node hooks/live-transcript-journal.mjs` | Every successful tool call | Appends tool-use and tool-result records | Node cold start; synchronous durable writes/fsync |
| `PostToolUse` | `Edit|Write` | `/home/user/Projects/slopgate/hooks/edit-hook.sh` | Every successful Edit/Write | External slopgate enforcement | External script; excluded from source inspection by `/home/user/.claude` boundary |
| `PostToolUseFailure` | `*` | `node hooks/live-transcript-journal.mjs` | Every failed tool call | Journals failed tool result | Same journal cost |
| `PreCompact` | all | `node hooks/auto-handoff.mjs` | Per compaction | Writes deterministic handoff after transcript/git inspection | Full transcript parse; four git commands; 5-minute dedupe |
| `PreToolUse` | `Agent` | `node hooks/dead-advisor-dedupe.mjs` | Every Agent call | Denies duplicate dead-advisor spawn only | Node cold start; target call shells to `resolve.sh` |
| `PreToolUse` | `Bash` | `hooks/bg-gate.sh` | Every Bash call | Denies agent-initiated background Bash | Shell + four `jq` reads + Perl regex on candidate command |
| `PreToolUse` | `Bash` | `node hooks/deny-gate.mjs` | Every Bash call | Applies manifest wrapper deny/rewrite rules | Node cold start; reparses global and cwd manifests |
| `PreToolUse` | `Bash` | `hooks/fs-scan-gate.sh` | Every Bash call | Denies root `/` `find` scans | Shell + `jq` + Perl |
| `PreToolUse` | `Bash` | `hooks/bash-gate.sh` | Every Bash call | Rewrites noisy commands through `ft` | Shell + `jq`; fast token gate; Perl normalization/detection for candidates |
| `PreToolUse` | `Bash` | `/home/user/Projects/slopgate/hooks/commit-hook.sh` | Every Bash call | External commit enforcement | External script; not inspected |
| `PreToolUse` | `Bash|Edit|Write` | `/home/user/Projects/slopgate/hooks/baseline-guard.sh` | Every matching call | External baseline enforcement | External script; not inspected |
| `SessionEnd` | all | `node hooks/auto-handoff.mjs` | Per session | Writes deterministic handoff; spawns detached rich handoff | Full transcript/git scan; detached model process after write |
| `SessionStart` | all | `node hooks/run-plan-resume-detect.mjs` | Per start/compact/resume | Finds self-owned in-flight run plans | Git scan across project repos; one global depth-6 `find` only when plan branches exist |
| `SessionStart` | all | `node hooks/dead-advisor-remind.mjs` | Per session reset | Reinjects dead-advisor sidecar reminder | Node cold start; small sidecar read |
| `SessionStart` | all | `node hooks/auto-handoff.mjs --announce` | Per session | Announces newest cwd handoff | Node cold start; one stat |
| `SessionStart` | all | `node hooks/live-transcript-journal.mjs` | Per session | Journals permission mode | Durable journal writes |
| `SessionStart` | all | `hooks/context-mode-cache-heal.mjs` | Per session | Repairs stale context-mode install paths | Node cold start; plugin manifest and version-directory scans |
| `SessionStart` | all | `/home/user/Projects/slopgate/hooks/session-start.sh` | Per session | External slopgate initialization | External script; not inspected |
| `Stop` | all | `hooks/workflow-usage-report.py` | Every turn stop | Reports newly terminal workflow usage once | Python cold start; workflow glob; subagent transcript parse only for new terminal runs |
| `Stop` | all | `node hooks/stop-gate.mjs` | Every turn stop | Blocks first stop with open TodoWrite items | Node cold start; rereads and parses entire transcript |
| `Stop` | all | `node hooks/live-transcript-journal.mjs` | Every turn stop | Journals final assistant message | Durable journal writes |
| `StopFailure` | `*` | `node hooks/live-transcript-journal.mjs` | Every failed stop | Journals failed final message | Durable journal writes |
| `UserPromptSubmit` | all | `node hooks/live-transcript-journal.mjs` | Every prompt | Journals user prompt | Durable journal writes |

## Non-Hook Per-Tool Path

`settings.json` registers `statusline-command.sh`. Claude Code refreshes it around ordinary tool activity.

Before refactor, each render performed:

1. Nine `jq` processes for normal payload/settings extraction; more when rate-limit cache is read or updated.
2. `date`, `sed`, Python `statusline-cost.sh`, `head`, and `tail` subprocesses.
3. One `git rev-parse` in any cwd.
4. In repositories with `plan/*`: `git for-each-ref`, then Node scanner, then scanner repeated branch enumeration with `git branch`, then `find <repo> -maxdepth 6 ...`.

After safe refactor: normal payload/settings extraction uses two `jq` processes, `sed` is gone, and statusline passes already-enumerated integration slugs to scanner. Depth-6 `find` remains.

## `run-plan-scan.mjs` Findings

- `planIndex()` invokes GNU `find` over every entry up to depth 6. It does not prune `.git`, `node_modules`, build output, caches, worktrees, or dependency trees.
- `repoFilter` restricts root to current repo; it does not make traversal cheap in a large repo.
- Statusline invokes count mode on every render when any integration branch exists, even if branch/run-plan state is unchanged.
- Each process rebuilds plan index, reparses matching JSONL, rereads owner/PID/live beacons, and reclassifies liveness.
- Multiple unfinished runs multiply `ownerSessionAgeS()` work: each run scans every immediate directory in `~/.claude/projects` and stats candidate owner transcript paths.
- SessionStart global scan and on-demand report share same module but do not overlap per tool. Report additionally runs git reconcile per run against scratch copies.
- Statusline formerly enumerated integration branches twice. Applied slug hints remove scanner's duplicate git subprocess while retaining identical rows.
- Scanner has exactly one `find` call. Repository search found no other statusline/per-tool `find` caller. Concurrent observed `find` processes therefore come from concurrent scanner processes.

## Measurements

Local measurements, five invocations unless noted; system was under active load. Treat as order-of-magnitude, not stable benchmark.

| Path | Mean wall | Range | Notes |
|---|---:|---:|---|
| statusline, no plan branch | 317 ms | 161–838 ms | Includes Python cost formatter |
| live journal early exit | 350 ms | 304–397 ms | Mostly Node/shim startup in this environment |
| dead-advisor record early exit | 332 ms | 268–420 ms | Every Agent post-call |
| dead-advisor dedupe early exit | 317 ms | 269–418 ms | Every Agent pre-call |
| deny-gate benign Bash | 305 ms | 267–341 ms | Node + two manifest reads |
| bg-gate benign Bash | 94 ms | 73–114 ms | Shell/`jq`/Perl |
| fs-scan-gate benign Bash | 33 ms | 22–51 ms | Shell/`jq`/Perl |
| bash-gate fast-path Bash | 25 ms | 20–31 ms | Existing candidate-token fast path |
| stop-gate early exit | 263 ms | 249–282 ms | Full transcript path costs more |
| workflow usage early exit | 107 ms | 85–165 ms | Terminal-run path costs more |
| depth-6 `find` over `/home/user/.claude` | 626 ms | 165–1533 ms | Encountered one unreadable nested lock path; Overdeck can be much larger |
| scanner with no plan branch | 255 ms | 242–274 ms | Returns before `find` |

Observed production evidence remains strongest: scanner reached 68% CPU and concurrent Overdeck `find` processes reached 120% and 28%.

## Redundancy and Cruft

- Removed tracked `hooks/bash-gate.sh.bak`.
- Removed `hooks/bash-gate.sh.orig` from hook tree; recoverable at `run/removed-hook-cruft/bash-gate.sh.orig`.
- Removed generated `hooks/__pycache__/workflow-usage-report.cpython-313.pyc` from hook tree; recoverable at `run/removed-hook-cruft/workflow-usage-report.cpython-313.pyc`.
- Dead-advisor scripts are not duplicates. Dedupe is pre-call enforcement, record is post-call persistence, remind is session-reset context restoration. Merging crosses hook lifecycle boundaries and cannot preserve behavior.
- Run-plan resume detection, statusline count, and on-demand report correctly share classification code. Their cost policies differ; only statusline causes per-tool repetition.
- Bash PreTool gates duplicate stdin parsing and process startup, but decisions are independent and security-sensitive. No merge applied.
- Live journal intentionally overlaps many events; it provides event-complete crash durability. No event removed.

## Applied Safe Wins

### 1. Batch statusline payload parsing

Files: `statusline-command.sh`, `tests/statusline-command.test.sh`.

- Parse seven input fields with one null-delimited `jq` call.
- Retain separate settings read.
- Replace cwd `sed` with Bash prefix substitution.
- Red test observed nine `jq` processes. Green test observes two and asserts model, effort, and context output.

### 2. Reuse statusline branch enumeration

Files: `statusline-command.sh`, `hooks/lib/run-plan-scan.mjs`, `tests/run-plan-scan-slug-hint.test.mjs`.

- Add optional `integrationSlugHints`/repeated `--slug=` input.
- Statusline passes exact local `plan/*` integration slugs it already enumerated.
- Scanner skips its duplicate `git branch` subprocess only when hints are supplied for a repo-filtered scan.
- Test compares complete hinted and normal scan rows, then removes `git` from `PATH`; hinted scan still returns identical result.

### 3. Remove provable no-op Agent registration

Files: `settings.json`, `tests/bg-gate-matcher.test.mjs`.

- Narrow `bg-gate.sh` matcher from `Bash|Agent` to `Bash`.
- Script contract explicitly allows Agent backgrounding and only denies explicit background flag when `tool_name == Bash`; Agent payload matrix returns exit 0 with no stdout/stderr.
- Gate decisions remain identical. Only no-op Agent process launches disappear.

### 4. Remove stale artifacts

Files: `hooks/bash-gate.sh.bak`, `hooks/bash-gate.sh.orig`, `hooks/__pycache__/workflow-usage-report.cpython-313.pyc`.

- Backup source removed from versioned hook tree.
- Untracked `.orig` and generated bytecode moved to recoverable quarantine inside `/home/user/.claude/run`.

### 5. Keep verification inside authorized filesystem

Files: `hooks/test-run-plan-scan.sh`, `hooks/test-resume-detect.sh`.

- Honor `TMPDIR` instead of forcing `/tmp`; production behavior unchanged.

## Ranked Deferred Refactors

| Rank | Refactor | CPU win | Risk | Decision |
|---|---|---:|---:|---|
| 1 | Replace depth-6 `find` with controller-valid enumeration: direct `<worktree>/docs/plans`, worktrees from `git worktree list --porcelain`, exact slug filenames | Very high | Medium | Needs human review: current scanner also sees ignored, nested, and unconventional `*/docs/plans` paths. Narrowing changes discovery semantics in malformed/legacy layouts. |
| 2 | Add versioned shared plan-index cache plus single-flight lock; invalidate on branch refs, worktree list, plan-directory entries, cached path identity, and beacon state | Very high | Medium | Needs human review: proving discovery invalidation for newly created unconventional nested plan paths otherwise requires same walk. TTL/debounce alone can display stale stalled-run state. |
| 3 | Coalesce statusline into one long-lived helper/daemon that parses payload, cost, and run-plan state | High | Medium | Avoids repeated shell/Node/Python/`jq` startup. Needs lifecycle, crash recovery, and exact freshness contract. |
| 4 | Incremental stop-gate transcript cursor keyed by inode+size, with fail-closed full-rescan fallback | Medium/high in long sessions | High | Security/routing stop gate. Cache corruption/truncation/rotation must never change block decision. Human review required. |
| 5 | Batch live-journal fsync or debounce `MessageDisplay` deltas | High I/O/CPU | High | Changes crash-durability boundary and may lose the event the journal exists to preserve. Human review required. |
| 6 | One Bash PreTool dispatcher parses payload once and invokes gate functions in fixed order | Medium/high | High | Security gates and external slopgate hooks currently compose through Claude hook semantics. Merge can change deny/rewrite precedence, failure behavior, and timeouts. |
| 7 | Cache `tools.json` manifests for deny-gate | Low/medium | Medium | Per-process cache has no value because each hook is a cold Node process; persistent cache needs exact invalidation. |
| 8 | Switch Node hooks to Bun | Unknown | Medium | Benchmark first. Startup may improve, but Node/Bun filesystem, process, URL, and exit semantics must pass full hook suite. Repo policy alone is insufficient proof. |
| 9 | Merge dead-advisor scripts | Low | High | Reject: scripts operate at distinct pre/post/session lifecycle points. No literal duplicate work exists. |

## Required Acceptance for Deferred Rank 1–2

Before changing discovery:

1. Build fixtures for tracked, untracked, ignored, dated, undated, main-checkout, linked-worktree, `.wt-*`, duplicate-slug, deleted, renamed, malformed, and unreadable plans.
2. Compare old and new scanner rows byte-for-byte for count and report inputs.
3. Stress 20 concurrent statusline invocations while mutating branch, plan, and owner-beacon state.
4. Prove no stale count after each valid state transition.
5. Retain full-walk fallback on cache corruption or unsupported layout until migration evidence permits removal.

## Safety Statement

No deny/allow/rewrite/block decision changed in `deny-gate.mjs`, `bash-gate.sh`, `bg-gate.sh`, `fs-scan-gate.sh`, or `stop-gate.mjs`. `bg-gate.sh` still runs for every Bash call; only Agent invocations that provably always returned no decision were removed. Depth-6 plan discovery remains unchanged pending human review.
