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:
| Behavior | Op | Off-switch |
|---|---|---|
| Workflow gating (task-shaped prompts scored for loop triggers + complexity) | prompt_gate | workflow_enforcer_hook.enabled, .strict_block |
| Intent briefs on ambiguous prompts | prompt_brief | brief_hook.enabled |
| Repeated-Read guard | read_guard | read_guard_hook.enabled, .strict_block |
| Session context injection | session_start | session_start_hook.enabled |
| Workflow/loop state recording | stop_state | workflow_state_hook.enabled |
| Completion gate (forced continuation) | stop_completion | stop_completion.continue_enabled |
| Reject log: one JSON line per refusal, both runtimes (v8.2.0) | the runtime itself, after every blocking op result | reject_log.enabled (default on) |
| Tier-1 instant answers | prompt_tier1 | tier1.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_modes | reply_modes.enabled; per mode adhd_mode.enabled, ste_mode.enabled; <key>_mode.on pins a repo |
| Context markers around compaction | compact_marker | compact_hook.enabled |
| Typed judge behind the prompt scorers (v7.7.0) | judge library, used by prompt_gate + prompt_brief | judge.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_completionderives 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 bystop_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, markedsuppressed. 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.
Related
- The /nav pane — what the v8 mod shows while you work
- Config schema — every off-switch and its default
- Migration guide — upgrading from v6 or v7
- Task Mode — complexity scoring, now run by
prompt_gate - Typed Prompt Judge — the opt-in model behind the keyword scorers
- Loop Mode — exit evaluation, now run on
Stop - Autonomous completion — indicators recorded by
stop_state