Skip to Content
ConceptsHooks as Runtime

Hooks as Runtime

v8.0.0: the runtime is a Claude Code mod. The ops described on this page no longer run as Python processes started per event. On Claude Code 2.1.287 or newer they run inside Claude Code as one in-process TypeScript module (hooks/mod/register.tsx), ported with byte parity against the Python versions on generated fixtures. The Python dispatcher below stays registered as the fallback: on older Claude Code, where a policy blocks mods, or when an op crashes three times in a session, Python runs that op. The mod announces what it owns in NAVIGATOR_MOD_OWNS; the dispatcher skips owned ops and exits immediately when nothing is left for it. Both share the one state file. What the mod adds: the /nav pane, a status band above the prompt, a Pilot theme, and the read-only update notice. The rest of this page — the ops, the pipeline, the off-switches, fail-open, PILOT_EXECUTOR — holds for both runtimes.

Navigator v7.0.0 replaces prose instructions with mechanism. Everything the plugin used to ask the model to do — show a workflow check, run a session-start ritual, obey a forbidden-actions list — is now done by a hook runtime: deterministic code that runs on Claude Code’s lifecycle events, with a config off-switch for every behavior.

Why: prose is a suggestion

Through v6, Navigator’s workflow lived in CLAUDE.md as mandates: “show a WORKFLOW CHECK block before every task”, “you MUST load the navigator first”, a list of forbidden actions. Instructions like these are suggestions to a language model. They mostly work, they degrade as context fills, and they fail silently — the model just stops doing them and nothing notices.

Hooks are the opposite: they run whether or not the model remembers. A gate that scores prompts on UserPromptSubmit cannot be forgotten at exchange 30. So v7 retires the prose mandates entirely and moves each behavior into the runtime. CLAUDE.md still describes the behaviors, but as documentation of what the hooks do — not as the mechanism itself.

The dispatcher

Nine independent v6 hook scripts became one dispatcher:

hooks/nav_dispatch.py <event>

The dispatcher routes each event to op modules in hooks/ops/ through a fixed pipeline:

gates → responders → injectors → recorders
  • Gates can block or redirect a turn (workflow gating, the repeated-Read guard).
  • Responders can answer without invoking the model at all (Tier-1 instant answers).
  • Injectors add context to the turn (session context, intent briefs, markers).
  • Recorders observe and persist state (workflow/loop state, completion indicators).

Ops share a runtime library, hooks/nav_hook_lib/: unified prompt scoring, signal parsing, transcript and memory helpers, and a single state file — .agent/.nav-runtime-state.json (schema 2, file-locked, atomic writes). One state file means one read and one write per event, and no two ops disagreeing about what happened last turn.

The 13 events

The dispatcher handles every lifecycle event Claude Code exposes:

SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Stop, SubagentStart, PreCompact, PostCompact, TaskCreated, TaskCompleted, ConfigChange, Setup

Most behaviors concentrate on three of them: UserPromptSubmit (scoring, gating, briefs, Tier-1), PreToolUse (the Read guard), and Stop (state recording and the completion gate).

Behaviors, ops, off-switches

Every behavior is an op with an off-switch in .agent/.nav-config.json. Nothing is load-bearing prose anymore:

BehaviorOpOff-switch
Workflow gating (task-shaped prompts scored for loop triggers + complexity)prompt_gateworkflow_enforcer_hook.enabled, .strict_block
Intent briefs on ambiguous promptsprompt_briefbrief_hook.enabled
Repeated-Read guardread_guardread_guard_hook.enabled, .strict_block
Session context injectionsession_startsession_start_hook.enabled
Workflow/loop state recordingstop_stateworkflow_state_hook.enabled
Completion gate (forced continuation)stop_completionstop_completion.continue_enabled
Reject log: one JSON line per refusal, both runtimes (v8.2.0)the runtime itself, after every blocking op resultreject_log.enabled (default on)
Tier-1 instant answersprompt_tier1tier1.enabled, per-rule tier1.rules
Reply modes: adhd mode on/off, ste mode on/off answered with no model turn, rule blocks on every prompt while on (ADHD v7.9.0, STE v8.3.0)prompt_modesreply_modes.enabled; per mode adhd_mode.enabled, ste_mode.enabled; <key>_mode.on pins a repo
Context markers around compactioncompact_markercompact_hook.enabled
Typed judge behind the prompt scorers (v7.7.0)judge library, used by prompt_gate + prompt_briefjudge.enabled

Two behaviors deserve a note:

  • Tier-1 instant answers — five exact-match commands (nav stats, show features, list markers, graph health, nav version) answered deterministically with zero model invocation, rendered as terminal-style cards.
  • The completion gate — stop_completion derives six completion indicators from observable turn evidence (git tree clean, tests ran green, docs touched, marker created, ticket closed, code simplified). When a turn mutated the codebase but indicators are unmet and no exit signal is present, it can force one continuation, capped by stop_completion.max_continues.
  • The reject log (v8.2.0) — every refusal the three gates make (read-guard deny, prompt-gate block, stop-gate block) is one JSON line in .agent/.nav-rejects.jsonl: {ts, session, event, op, tool?, reason, evidence, suppressed?}. The op attaches the summary; the runtime writes the line from a single append point, in Python and in the mod alike, byte for byte. Under Pilot the block is stripped but the line stays, marked suppressed. Bounded to 500 lines, on by default: it observes and never blocks. Full page: Reject Log.

Fail-open by design

A dispatcher crash never blocks the session. If an op throws, the event completes as if Navigator weren’t installed — you lose an enhancement, not your work. The runtime is a layer on top of Claude Code, and a broken layer must degrade to absence, not to a locked session.

PILOT_EXECUTOR

Setting the PILOT_EXECUTOR environment variable disables all interactive and blocking behavior across every op — one policy point, not eight scattered checks. This exists for autonomous executors (like Pilot) where there is no human to answer a gate’s question: blocking a turn to ask would hang the run, so under PILOT_EXECUTOR the runtime observes and records but never interrupts.

How it was validated

Hooks that can block turns need more evidence than “seems to work”:

  • Empirical probes — six hook channels were probed on a live harness before design, so the architecture rests on observed Claude Code behavior, not assumptions about it.
  • Golden parity — all nine v6 behaviors were byte-matched against a recorded corpus, so the rewrite provably reproduces what it replaced.
  • Conformance suite — re-run against each Claude Code version, with results checked into the repo. When the platform changes underneath the runtime, the suite says so.
  • Dogfood — roughly seven weeks of daily use on Navigator’s own development before the release was tagged.

Blocking features are opt-in

New blocking or outbound features seed off. stop_completion.continue_enabled, tier1.enabled and judge.enabled ship disabled; strict blocking on the gates is a separate .strict_block flag. The default install observes, injects, and records — it starts enforcing only when you turn enforcement on.