---
name: od-hooks
description: Hooks/harness — CREATE a new hook (mandatory design ladder) or diagnose incidents: hook timeout, context spam, polling anti-patterns, silent shell death, a factory run stuck, hanging turn, crash mid-run, MCP/subagent resume. Triggers on hook, harness, add a hook, new hook, timeout, workflow stuck, advisor. Invoke BEFORE writing any hook or changing harness supervision.
---

# Hooks / harness — incident playbook

audience: AI coding agents first. BLUF: reproduce hook/harness failure in isolation → fix fail-closed → NEVER restore v1 daemon/auto-relaunch.

## Covers

**Absorbs:** `hook-timeout`, `context-spam`, `polling-anti-pattern`, `tool-reliability-fatigue` (harness-reliability subset — ids C033, C034, C035, C050, C080, C122, C125, C182, C192 per taxonomy `_meta`).

**Keywords:** hook, harness, timeout, factory, workflow, stuck, hanging turn, context spam, polling, advisor, mcp, subagent, resume, crashed mid-run.

**Registry coverage:** 24 of 217 (2026-08-08). Recurring: hook floods context; polling where notify exists; coordinator restart masquerading as resume.

## Doctrine

Reproduce hook/harness failure in isolation. Fix fail-closed. NEVER restore v1 daemon/auto-relaunch behavior.

## First steps — canonical spec FIRST

1. **Factory phase/retry/gate/permission edit:** `python3 -m pytest modules/harness/factory/tests/ -q` MUST stay green. NEVER weaken a gate to make a run pass. Mandatory per repo `AGENTS.md`.
2. **Isolate hook:** run failing hook script directly with same stdin/env the PreToolUse/PostToolUse path uses. Hook must fail-closed on bad input, not hang.
3. **Classify failure:** hook timeout vs harness scheduler vs journal corruption vs UI polling. Different owners — do not conflate.
4. **Factory run stuck:** it blocks in the foreground by design. Read its phase from `/factory` or `factory watch`; a run waiting on a human is in `factory decisions`. There is NO resume — `factory stop <adw_id>` then relaunch. NEVER add a watchdog/reconcile/daemon to "fix" a crash.
5. **Context spam:** measure hook output bytes; route heavy output through `ft` filter or quietcontext MCP — hook must not dump raw build logs into context.
6. **Polling anti-pattern:** if event source exists (SSE, journal tail, notify), replace poll loop. UI staleness ≠ authority to add more polling.
7. **Repro artifact:** minimal command sequence + observed hang/timeout before code change.

## Hook cost model — measured 2026-08-15, load-97 fire

Interpreter STARTS dominate hook cost, never the checks: ~50-60ms per bun gate, ~207ms
per python hook, cold-start. Legacy wiring: 28 PreToolUse + 5 PostToolUse + 7 Stop
separate commands PER tool call/turn — ~1.5 CPU-s of starts per call, ×50 sessions =
5-8 cores on hooks alone. Diagnosing hook CPU → count process starts FIRST, profile
logic never.

## Dispatcher architecture — one process per event

Target architecture (plan `docs/plans/2026-08-15-hook-dispatcher.md`; verify landed state
there before assuming):

- ONE dispatcher command per hook event in settings.json; gates are in-process modules.
- Matcher filtering preserved per gate — a Bash-only gate still sees only Bash calls.
- Per-module isolation: one gate's crash NEVER eats other gates' verdicts. A crashed
  BLOCKING gate fails CLOSED for its own verdict; dispatcher-process death fails OPEN.
- Gate LOGIC changes never ride a dispatcher change — mechanical wiring only.
- Adding a gate → add a module to the dispatcher manifest. NEVER add a new standalone
  hook command to settings.json — that recreates the swarm one hook at a time.

## Third-party plugin hooks — audit BEFORE enabling

A hook that calls a model multiplies by session count and bills the OWNER's OAuth quota —
the same pool real sessions run on. Case: security-guidance plugin ran two `opus`
analyses per code-changing turn end (~92 model reviews in half a day fleet-wide, findings
in 59 of 802 sessions, zero pattern-warning fires ever) and its per-prompt git snapshot
(two worktree walks, self-documented 2-5s each) ran even with the review toggled off.
Removed 2026-08-15: uninstalled, `blocklist.json` entry, enabledPlugins false in live +
seat template, no-op stub left at the old cache path for pre-removal sessions.

- BEFORE enabling any plugin: grep its hooks for `ANTHROPIC_AUTH_TOKEN`,
  `api.anthropic.com`, model names. Per-event model calls = owner decision, never default.
- Its removal must cover FOUR places or it returns silently: live settings.json
  enabledPlugins, `~/.claude.json` (incl. per-project), `plugins/installed_plugins.json`,
  and the repo seat-settings template (`modules/workstation/claude/settings.json`) that
  provisions buildboxes. `plugins/blocklist.json` is the durable no-return switch.

## Designing a NEW hook — MANDATORY ladder

Interpreter starts dominate; logic is free. Design counts process starts FIRST, profiles
logic never. Walk the ladder top-down; stop at the first rung that fits.

1. **JS/bun logic → a dispatcher MODULE.** Export `evaluate()` returning the verdict
   object, register the {id, matcher, timeoutMs} triple in `hooks/lib/dispatcher-
   registry.mjs` and the function import in the ONE matching
   `hooks/lib/dispatcher-manifest-<event>.mjs` (pretooluse/posttooluse/stop — one file
   per event, not one shared file). NEVER a new standalone settings.json command — that
   rebuilds the 28-process swarm one hook at a time. Thin CLI wrapper stays for direct
   invocation/tests. `mainOnly: true` on a registry entry (say-the-word-gate,
   git-decision-gate, stop-gate) skips it for a subagent call (`transcript_path` contains
   `/subagents/`, checked once per dispatcher invocation) — flag ONLY a module whose
   purpose is main-agent-to-owner discipline; guards that protect regardless of caller
   (secret-file, deny, curl-timeout, human-kill, pids-cap) and pure observability
   (journal, ledger) stay universal.
   - **Event-scoped imports, never a shared manifest file.** One file statically
     importing every event's gates cost 276ms/call (measured) — a PreToolUse call paid to
     load Stop-only modules it would never run. Each `dispatcher-manifest-<event>.mjs`
     imports ONLY its own event's gate functions; `dispatcher.mjs` picks the right one
     with a single `import()` keyed on the event name argument.
   - **Bundling sub-ladder (self-cost beyond static imports), stop at the first rung
     that actually measures better — interleaved A/B, not sequential runs (machine load
     swings 2x between samples):**
     1. Event-scoped static imports (above) — adopted. ~15-20% faster than per-call
        `import()`, and the only rung needed to fix the 276ms regression.
     2. Flat `bun build --target=bun` bundle per event — MEASURED, REJECTED. Once
        imports are event-scoped, a flat bundle measured slower in this environment/bun
        version (interleaved: 92.7ms unbundled vs 106.3ms bundled, n=30). A bundle built
        from a SHARED multi-event manifest additionally reproduced a `for await (const
        chunk of process.stdin)` "Premature close" failure — the dispatcher silently
        failed open (every gate no-op, exit 0, no verdict) on every invocation. Root
        cause not isolated (event-scoped per-entry bundles did not reproduce it); treat
        any future bundle candidate as unproven until stdin is smoke-tested standalone
        AND under the real settings.json wiring, not just imported and inspected.
     3. `bun build --compile` (native binary, ~90MB, ~5-10ms starts) — NOT EVALUATED.
        Gate: only after a flat bundle clears rung 2 with a real measured win AND
        bare-bun-start still dominates remaining cost. Rung 2 never cleared, so rung 3
        was never justified. If adopted later: wire the compile into deploy, and add a
        hash-drift test asserting the binary matches source — a stale binary is the
        exact failure class that broke slopgate 2026-08-15.
2. **Always-acting hook (journaling class — genuinely works on EVERY call): ride an
   existing runtime.** No prefilter can help it; a dedicated process is a full boot
   (~55ms) to append a line. Make it a module in the event's dispatcher pass.
3. **Standalone shell hook (guard class): builtins-first + literal prefilter.**
   - Read stdin with `payload=""; IFS= read -rd '' payload || true` — NEVER
     `$(</dev/stdin)` (re-OPENS the path; Claude Code hands hooks a SOCKET stdin and
     open() on a socket fails ENXIO — killed 5 guards fleet-wide 2026-08-15, fail-open)
     and NEVER `$(cat)` (external binary; dies under an emptied PATH, costs a spawn).
     `read` consumes fd 0 directly, works on pipe and socket alike; `|| true` because
     read exits 1 at EOF. Regression: socket-stdin cases in
     `hooks/test/sh-fastpath-verdicts.test.sh`.
   - Find the literal(s) every trigger REQUIRES; `case "$payload" in *lit*)` before ANY
     spawn. jq/perl/runtime live only behind the prefilter. Match in bash `[[ =~ ]]`,
     never `grep -Eq <<<` (each is a process).
   - JSON field extraction: jq behind the prefilter. NEVER hand-parse JSON in bash.
4. **Periodic render (statusline class): cache the OUTPUT.** Key = input-file mtimes +
   identity (account/slug) + relevant stdin fields. Recompute only on change; fail-open
   to a full recompute on any cache error.
5. **Re-parsed data file that changes rarely (rule-manifest class): cache the PARSED
   result, not the compiled matcher.** `tools.json` re-read + `JSON.parse`d on every
   `deny-gate`/`tool-suggest-check` call though it changes a few times a day — cached in
   `$XDG_RUNTIME_DIR/overdeck-tool-rules-cache` (tmpfs; never survives reboot, never
   touches the repo), keyed by mtime+size, stat-compared per call, rebuilt on any
   mismatch, fail-open to a direct parse on any cache read/write error. Compiled `RegExp`
   objects cannot cross a process boundary — cache the parsed rule JSON, recompile
   regexes fresh each call (µs-range, not the cost driver).
6. **Payload routing on a field the manifest already knows how to match (dispatcher
   class): regex-extract, don't JSON.parse.** `dispatcher.mjs` extracts `tool_name` with
   `/"tool_name"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/` and checks it against the loaded
   manifest's matcher union BEFORE any `JSON.parse` of the raw payload — PostToolUse
   payloads can carry megabytes of tool output, and a full parse scales with payload
   size while a regex scan does not. Extraction failing is NOT license to skip: fall
   through to a real `JSON.parse` and the normal matcher '*' behavior, exactly as an
   unparseable payload has always done. This only skips the DISPATCHER's own routing
   parse — a gate that actually runs still parses the raw string itself, same as always.
7. **Advisory-only work (never blocks): NOT fire-and-forget from inside a hook process —
   REJECTED for now.** Writing the verdict then continuing background work after
   `process.exit()` doesn't work (exit terminates synchronously; nothing after it runs),
   and there is no confirmed evidence Claude Code proceeds before the hook process
   actually exits — detaching work into a truly separate process to "save" wall time
   risks an orphaned/leaked process for an unproven latency win. If revisited: first
   prove empirically whether Code blocks on hook-process exit or on stdout EOF: if the
   latter, closing stdout early while keeping the process alive for detached follow-up
   work is the correct shape; if the former, this rung is dead. Until proven, every
   manifest entry (blocking or advisory) runs to completion inside one request/response
   cycle — see dispatcher-core.mjs's "every matching entry always runs" rule.

Cross-cutting laws, every rung:

- **Prefilter safety law: a prefilter may ONLY skip payloads the full logic would ALSO
  allow.** Any doubt → no prefilter for that branch. Before editing a guard, write its
  deny+allow test matrix; run before and after; commit it under `hooks/test/`.
- **Matcher = narrowest true trigger set.** PreToolUse `*` is waste unless a module acts
  on every tool (measured: `*` dispatcher cost ~80-111ms per Agent/Glob/Task/WebFetch
  call for a guaranteed no-op). PostToolUse `*` is correct only for always-acting
  modules. Keep a drift test: settings matcher == manifest union.
- **Fail placement:** a crashed BLOCKING gate fails CLOSED for its own verdict; the
  dispatcher/wrapper process death fails OPEN. Encode fail-closed inside the module.
- **PATH-less resilience:** builtins on the fast path so the hook stays silent/fail-open
  when PATH is broken (test suites simulate this).
- **No model calls from hooks without an explicit owner decision** — fleet-multiplied
  OAuth spend (see plugin section).
- **Per-module fire telemetry — frozen contract, every dispatcher module gets it for
  free.** Any module registered in a `dispatcher-manifest-<event>.mjs` that returns a
  non-null result gets ONE line appended to `~/.local/state/overdeck/hook-fires.jsonl`:
  `{"ts":"<ISO-8601>","event":"PreToolUse"|"PostToolUse"|"Stop","module":"<id>","action":"deny"|"advise"|"act"}`
  — `deny` = blocked the tool/turn, `advise` = a non-blocking reason/context surfaced,
  `act` = neither (e.g. deny-gate's command rewrite). A module that always returns null
  (journaling: live-transcript-journal, agent-session-ledger) naturally never appears —
  no id-based exclusion needed, since it always fires and a line would be noise, not
  signal. Fail-open: `recordFire()` never throws into a verdict. This is the data an
  Overdeck hooks-page UI (a separate lane) uses to surface zero-fire-7-days suspects and
  30-day removal candidates for the OWNER to decide — never auto-removed from here.
  Standalone `.sh` gates do not emit this; that lane owns its own telemetry if/when
  it's built.
- **Resident hook server (long-lived process answering hook calls over a socket instead
  of one-shot processes) — REJECTED, not a rung on this ladder.** Would remove interpreter
  startup entirely, but a crashed/stale server either blocks every subsequent hook call
  (fail-closed on infra failure — unacceptable for guards this load-bearing) or silently
  no-ops every gate (fail-open on infra failure — the same class of regression the flat
  bundle's stdin bug produced, just permanent instead of a bad build). Only reconsider
  after `bun build --compile` (ladder item 1, bundling sub-ladder rung 3) is adopted AND
  proves insufficient, and only with a version-handshake fallback to direct one-shot exec
  so a stale/crashed server degrades to today's cost, never to silent no-op.

Before/after, the canonical shape (slopgate baseline-guard, 67ms → 5ms):

```bash
# DO NOT — boots a runtime on every call to run three regexes:
exec /usr/bin/bun "$HERE/guard.mjs"

# DO — answer the common case with builtins; pay for the runtime only on a live literal:
payload=""; IFS= read -rd '' payload || true
case "$payload" in
  *slopgate*) ;;          # every trigger pattern requires this literal
  *) exit 0 ;;
esac
printf '%s' "$payload" | "$RUNTIME" "$HERE/guard.mjs"
```

## Silent hook/shell death — check the MACHINE before the hook

A hook or Bash child dying rc=1 with ZERO output is usually NOT the hook's bug. Checked
in order (all three were live causes 2026-08-15):

1. **agent.slice CPU starvation** — a CPUQuota on the slice throttles every Bash-tool
   shell to death under fleet load (`cpu.stat` `nr_throttled` climbing). Quota is
   FORBIDDEN on agent.slice; weight only (see `50-no-starvation.conf`).
2. **pids-guard victim selection** — near the slice pids cap it killed the LARGEST scope,
   i.e. whoever did real work (full `git checkout`, `python3 <script>`). Fixed
   `0fe056c2d`: refuses victims holding <10% of accounted pids (`DIFFUSE-SATURATION`
   log line instead). Check `pids-guard` log for KILL lines before blaming a hook.
3. **ft output filter truncation** — the bash-gate/ft rewrite can swallow a command's
   entire output while it exits 1, and can truncate `git status` enough to hide a
   mass-deletion. Debug with output redirected to a FILE, read the file; never trust
   absent output as absence of events.

## Never-touch — applies here

From `modules/workstation/claude/incidents/never-touch.md`:

- NEVER bypass admission queue or prevent-band gates.
- NEVER write in shared main checkout.
- NEVER restore v1-style harness daemon, automatic coordinator relaunch, or reconcile timers.

## Placement map — read these paths

`modules/workstation/claude/incidents/placement-map.md`:

- `~/.claude` — hooks (`hooks/`), harness `bin/`, skills.
- `~/.local/state/overdeck` — harness journals, `items.jsonl`, run state.
- Deploy clone — collector incident brief assets; harness bundle resolution: `modules/harness/CLAUDE.md`.

## Resolve — exact CLI (never guess syntax)

```
od-incidents list [--type hooks-harness] [--state S]
od-incidents show <id>
od-incidents search <query>
od-incidents resolve <id> --artifact <ref> [--summary <line>]
```

`resolve` REFUSES without `--artifact` (exit 2). Artifact = isolated hook repro test passing, or harness checkpoint resume test, or fix sha on origin/main.

## Self-update — MANDATORY

This doc is the fleet's memory. You changed the architecture, wiring, commands, or
doctrine this skill describes — or a live incident just proved a rule here wrong or
missing → EDIT THIS FILE in the same landing as the change. Adopted AND rejected
decisions both go in, with the measured why. An owner reminder to record a lesson is
a failure of this rule, not the trigger for it.
