Skip to content
Agent Orchestration
Hook

Hooks

What navigator runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
navigator
35531 skills6 agents1 hook

Claude Code

> /plugin marketplace add qf-studio/navigator
> /plugin install navigator@navigator-marketplace

What fires, and when

modules

Read hooks/hooks.json

Where it lives

  • hooks/nav_dispatch.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """nav_dispatch — single dispatcher entrypoint for every Navigator hook event (TASK-60).
    
    plugin.json routes the seven v6 surfaces here: python3 nav_dispatch.py <EventName>.
    Thin shim: read stdin once, delegate to nav_hook_lib.runtime.dispatch(), relay the
    single JSON doc + stderr (sentinels.emit_stderr, mem-034) + exit code. Fail-open:
    ANY failure — bad argv/stdin, missing runtime, escaping SystemExit, broken stdout
    pipe — exits 0 (BaseException catch-all: hooks are non-interactive). stdout flushes
    INSIDE the guard so a closed pipe cannot become exit 120 at interpreter shutdown.
    """
    import os
    import sys
    
    
    def main() -> int:
        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        from nav_hook_lib import hio, runtime, sentinels
    
        event = sys.argv[1]
        payload = hio.read_stdin_payload()
        result = runtime.dispatch(event, payload)
        if result.stdout is not None:
            print(result.stdout)
        sys.stdout.flush()  # broken pipe raises HERE, inside the catch-all
        if result.stderr is not None:
            sentinels.emit_stderr(result.stderr)
        return result.exit_code
    
    
    if __name__ == "__main__":
        try:
            exit_code = main()
        except BaseException:
            exit_code = 0  # fail-open: Navigator must never brick the harness
        try:
            sys.stdout.flush()
        except BaseException:
            os._exit(exit_code)  # dead stdout: bypass shutdown flush (exit-120 guard)
        sys.exit(exit_code)
    
  • hooks/test_nav_dispatch.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """Subprocess contract harness for hooks/nav_dispatch.py (TASK-60 Phase 5).
    
    Template: hooks/test_workflow_enforcer.py (TASK-45 precedent) — each test
    builds a throwaway project dir and drives the dispatcher as a subprocess
    (`python3 hooks/nav_dispatch.py <event>`) with a JSON payload on stdin,
    asserting on stdout/exit code only. stdlib unittest (pytest not installed).
    
    Mandatory cases from the TASK-60 acceptance list covered here:
    
      - Pristine v6.18.1 config fixture -> exit 0 on every v6 event surface,
        with at most ONE JSON document on stdout.
      - Project with NO .agent/ -> exit 0 AND empty stdout on every event, and
        the dispatcher never CREATES .agent/ (silent degradation).
      - PILOT_EXECUTOR=1 -> no blocking output on any event. NOTE: TASK-61 ops
        do not exist yet, so this is the dispatch-level synthetic check (clean
        exit, no block/deny/continue:false emissions); op-level bypass cases
        (e.g. read_guard deny suppressed under Pilot) land with the TASK-61
        ports that plug into this harness.
      - Per-behavior off-switch: a config with every v6 toggle block disabled
        (read_guard_hook.enabled=false etc.) -> exit 0 everywhere, and the
        config gate skips ops BEFORE import (no missing-module op_errors notes
        for gated-off ops).
      - Malformed stdin fails open (exit 0); missing/unknown event arg -> exit
        0, no output.
      - Crash-injection: NOT feasible at the subprocess level right now — the
        real op modules land in TASK-61, so there is nothing on the live
        registry to crash. Instead a tmp-dir driver script imports
        nav_hook_lib.runtime directly and dispatches a SYNTHETIC registry
        (contract: the `registry` param overrides EVENT_OPS for testability)
        with fake op modules: crash isolation, sentinel stderr hygiene
        (mem-034: no payload echo), meta.op_errors, health-file write +
        SessionStart surfacing, and gate short-circuit of rightward phases.
      - Timing: UserPromptSubmit dispatched 20x against
        nav_hook_lib/fixtures/timing_prompt.json; p95 <= 200ms * NAV_TIMING_MULT
        (env, default 1 — set >1 on slow CI runners).
    
    Out of scope here (other TASK-60 groups): mem-036 three-env-variant manifest
    command tests (need .claude-plugin/plugin.json — integrator), state
    read-once/write-once unit verification (runtime builder's colocated tests).
    """
    
    import json
    import math
    import os
    import subprocess
    import sys
    import tempfile
    import time
    import unittest
    from pathlib import Path
    
    HOOKS_DIR = Path(__file__).resolve().parent
    HOOK = str(HOOKS_DIR / "nav_dispatch.py")
    FIXTURES = HOOKS_DIR / "nav_hook_lib" / "fixtures"
    PRISTINE_CONFIG = (FIXTURES / "nav-config-v6.18.1.json").read_text()
    TIMING_FIXTURE = FIXTURES / "timing_prompt.json"
    
    # The seven v6 manifest event surfaces (registry.EVENT_OPS keys — TASK-60
    # registers ONLY these; new routing-matrix events belong to TASK-62).
    EVENTS = (
        "SessionStart",
        "UserPromptSubmit",
        "PreToolUse",
        "PostToolUse",
        "Stop",
        "PreCompact",
        "PostCompact",
    )
    
    # Realistic per-event payload shapes (tool_name chosen to HIT the coarse
    # registry matchers: 'Read' and 'Edit|Write|MultiEdit|NotebookEdit').
    EVENT_PAYLOAD_EXTRAS = {
        "SessionStart": {"source": "startup"},
        "UserPromptSubmit": {"prompt": "hello there"},
        "PreToolUse": {"tool_name": "Read", "tool_input": {"file_path": "/tmp/x.md"}},
        "PostToolUse": {
            "tool_name": "Edit",
            "tool_input": {"file_path": "/tmp/x.py"},
            "tool_response": {},
        },
        "Stop": {"stop_hook_active": False},
        "PreCompact": {"trigger": "manual"},
        "PostCompact": {},
    }
    
    # v6 toggle block names (from fixtures/nav-config-v6.18.1.json) -> the op
    # names the registry gates on them. Used by the off-switch tests.
    TOGGLE_BLOCKS = (
        "session_start_hook",
        "workflow_enforcer_hook",
        "brief_hook",
        "read_guard_hook",
        "task_graph_sync_hook",
        "profile_sync_hook",
        "workflow_state_hook",
        "compact_hook",
    )
    GATED_OPS = (
        "session_start",
        "prompt_gate",
        "prompt_brief",
        "read_guard",
        "graph_sync",
        "profile_sync",
        "stop_state",
        "compact_marker",
    )
    ALL_TOGGLES_OFF = json.dumps({block: {"enabled": False} for block in TOGGLE_BLOCKS})
    
    
    _ISOLATED_CONFIG_HOME = tempfile.TemporaryDirectory()
    
    
    def clean_env(extra=None):
        """Subprocess env with the escape hatches / project redirects removed.
    
        CLAUDE_PROJECT_DIR must go: with malformed stdin the payload is {}, and
        an inherited value would redirect hio.resolve_cwd() at the REAL repo.
        """
        env = os.environ.copy()
        for var in ("PILOT_EXECUTOR", "CLAUDE_PROJECT_DIR", "CLAUDE_USER_MESSAGE"):
            env.pop(var, None)
        # TASK-82: per-person settings (ADHD switch) live outside the repo; point
        # them at an empty dir so the developer's own switch never shapes a test.
        env["NAVIGATOR_CONFIG_HOME"] = _ISOLATED_CONFIG_HOME.name
        if extra:
            env.update(extra)
        return env
    
    
    def run_dispatch(project_dir, event=None, payload=None, raw_stdin=None, env_extra=None):
        """Invoke `python3 nav_dispatch.py [<event>]` rooted at project_dir.
    
        payload: sent as JSON on stdin ({} when None).
        raw_stdin: sent verbatim (overrides payload) — malformed-input cases.
        event: omitted from argv entirely when None (missing-arg case).
        """
        argv = [sys.executable, HOOK]
        if event is not None:
            argv.append(event)
        if raw_stdin is None:
            raw_stdin = json.dumps(payload if payload is not None else {})
        return subprocess.run(
            argv,
            input=raw_stdin,
            capture_output=True,
            text=True,
            cwd=project_dir,
            env=clean_env(env_extra),
        )
    
    
    def event_payload(project, event, session_id="s1"):
        payload = {"cwd": project, "session_id": session_id}
        payload.update(EVENT_PAYLOAD_EXTRAS.get(event, {}))
        return payload
    
    
    def parse_single_doc(testcase, stdout):
        """Contract: EXACTLY ONE JSON doc on stdout, or nothing. Returns doc/None.
    
        js
  • hooks/test_nav_dispatch_manifest.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """Manifest + shim contract tests for nav_dispatch (TASK-60, shim-manifest group).
    
    stdlib unittest only, subprocess-driven per the test_workflow_enforcer.py
    template. Three concerns:
    
    1. Manifest shape — .claude-plugin/plugin.json references ONLY
       hooks/nav_dispatch.py, registers exactly the seven v6 event surfaces
       plus the six TASK-62 events that survived the validate-or-drop gate
       (SubagentStart, PostToolUseFailure, TaskCreated, TaskCompleted,
       ConfigChange, Setup — all accepted by `claude plugin validate` on CC
       2.1.205), with the contract matchers (PreToolUse: Read|Write|Edit|Bash|Skill; PostToolUse:
       Edit|Write|MultiEdit|NotebookEdit; no matcher on any TASK-62 event) and
       timeouts (SessionStart 10, PostToolUse 10, PreCompact 30, PostCompact 10
       — the v6 allowances — all others 5, including every TASK-62 event).
       Each command is the fail-OPEN shell guard: the dispatcher
       file is [ -f ]-checked before exec, so a resolution miss exits 0
       silently instead of python3's loud exit 2 (which the harness treats as
       a block on every event).
    
    2. mem-036 env variants — every event's LITERAL manifest command runs as a
       subprocess (sh -c, so ${VAR:-fallback} expands exactly as the harness
       would) under three CLAUDE_PLUGIN_ROOT variants:
         * set-to-repo: resolves to this checkout; the dispatcher must exit 0 on
           an empty payload (fail-open), and a seeded health file proves the
           guard actually DISPATCHES (not silently skips) when the file exists.
         * unset: sh substitutes the $HOME fallback. HOME is pointed at a temp
           dir carrying a STALE marketplace clone (pre-v7: no nav_dispatch.py) —
           the realistic fallback state. The guard must exit 0 silently: the old
           loud-exit-2 behavior fails CLOSED and blocks every event.
         * empty string: the :- operator treats empty like unset, so behavior
           must match the unset variant (same fallback resolution).
    
    3. Shim contract — hooks/nav_dispatch.py stays under 40 lines and exits 0
       with no output on: empty JSON stdin, garbage stdin, missing event arg,
       unknown event (fail-open; must hold with or without nav_hook_lib.runtime).
    """
    
    import json
    import os
    import re
    import subprocess
    import tempfile
    import unittest
    from pathlib import Path
    
    HOOKS_DIR = Path(__file__).resolve().parent
    REPO_ROOT = HOOKS_DIR.parent
    SHIM = HOOKS_DIR / "nav_dispatch.py"
    PLUGIN_JSON = REPO_ROOT / ".claude-plugin" / "plugin.json"
    
    EVENTS = (
        "SessionStart",
        "UserPromptSubmit",
        "PreToolUse",
        "PostToolUse",
        "Stop",
        "PreCompact",
        "PostCompact",
        # TASK-62 event surfaces (validate-or-drop survivors, CC 2.1.205):
        "SubagentStart",
        "PostToolUseFailure",
        "TaskCreated",
        "TaskCompleted",
        "ConfigChange",
        "Setup",
    )
    # v6 allowances: PostToolUse hooks had 10s EACH, PostCompact 10s — the shared
    # dispatcher must not regress them to the 5s default.
    EXPECTED_TIMEOUTS = {
        "SessionStart": 10, "PostToolUse": 10, "PreCompact": 30, "PostCompact": 10,
    }
    DEFAULT_TIMEOUT = 5
    EXPECTED_MATCHERS = {
        "PreToolUse": "Read|Write|Edit|Bash|Skill",
        "PostToolUse": "Edit|Write|MultiEdit|NotebookEdit",
    }
    # Fail-OPEN shell guard: missing dispatcher file -> exit 0 silently (stdin
    # passes through the exec). %s is the event name.
    COMMAND_GUARD = (
        "sh -c 'f=\"${CLAUDE_PLUGIN_ROOT:-"
        "$HOME/.claude/plugins/marketplaces/navigator-marketplace}"
        "/hooks/nav_dispatch.py\"; "
        "if [ -f \"$f\" ]; then exec python3 \"$f\" %s; fi'"
    )
    FALLBACK_RELPATH = ".claude/plugins/marketplaces/navigator-marketplace"
    # Wedge guard: generous vs the 5-30s manifest timeouts; TimeoutExpired = fail.
    SUBPROCESS_TIMEOUT = 20
    
    
    def load_manifest():
        return json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
    
    
    def single_hook_entry(testcase, manifest, event):
        """Assert the event has exactly one matcher group with one hook; return both."""
        groups = manifest["hooks"][event]
        testcase.assertEqual(len(groups), 1, f"{event}: expected exactly one matcher group")
        hooks = groups[0]["hooks"]
        testcase.assertEqual(len(hooks), 1, f"{event}: expected exactly one hook entry")
        return groups[0], hooks[0]
    
    
    _ISOLATED_CONFIG_HOME = tempfile.TemporaryDirectory()
    
    
    def clean_env(extra=None, drop=()):
        env = os.environ.copy()
        for key in ("PILOT_EXECUTOR", "CLAUDE_PROJECT_DIR", "CLAUDE_USER_MESSAGE"):
            env.pop(key, None)
        env["NAVIGATOR_CONFIG_HOME"] = _ISOLATED_CONFIG_HOME.name  # TASK-82
        for key in drop:
            env.pop(key, None)
        if extra:
            env.update(extra)
        return env
    
    
    def run_shim(args, stdin="", env=None, cwd=None):
        return subprocess.run(
            ["python3", str(SHIM), *args],
            input=stdin,
            capture_output=True,
            text=True,
            cwd=cwd or REPO_ROOT,
            env=env or clean_env(),
            timeout=SUBPROCESS_TIMEOUT,
        )
    
    
    def run_manifest_command(command, env, cwd, stdin="{}"):
        """Run the literal manifest command through sh -c, as the harness does."""
        return subprocess.run(
            ["/bin/sh", "-c", command],
            input=stdin,
            capture_output=True,
            text=True,
            cwd=cwd,
            env=env,
            timeout=SUBPROCESS_TIMEOUT,
        )
    
    
    class ManifestShapeTest(unittest.TestCase):
        """plugin.json routes every event through nav_dispatch.py, nothing else."""
    
        def setUp(self):
            self.manifest = load_manifest()
    
        def test_only_nav_dispatch_referenced(self):
            raw = PLUGIN_JSON.read_text(encoding="utf-8")
            scripts = set(re.findall(r"hooks/[A-Za-z0-9_]+\.py", raw))
            self.assertEqual(scripts, {"hooks/nav_dispatch.py"})
    
        def test_exactly_the_registered_event_surfaces(self):
            # Seven v6 surfaces + six TASK-62 validate-or-drop survivors; every
            # registration maps to a committed dispatcher (v5.1.0 lesson) and to
            # a registry EVENT_OPS row (asserted in nav_hook_lib/test_registry).
            self.assertEqual(set(self.manifest["hooks"].

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

@qf-studio
Ships withnavigator

Finish What You Start Sessions that last. AI that learns. Features that ship.

35531 skills6 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
355
Star history of qf-studio/navigator on GitHub, a chart by star-history.com

More plugins that ship hooks.

oh-my-claudecode

oh-my-claudecode

Splits a complex task across several specialized Claude Code agents working in parallel, then keeps going until the result is checked. You describe what you want in plain language instead of memorizing commands.

84 skills+441k
@yeachan-heo@yeachan-heo
Install Plugin
ouroboros

ouroboros

Turns a rough feature idea into a written spec through guided questions, then has the agent build it and checks the result against that spec. Instead of hand-checking whether the work is finished, you get an automatic evaluation gate that runs in your coding agent.

26 skills+36.4k
@q00@q00
Install Plugin
myclaude

myclaude

A plugin that runs coding tasks through multiple AI backends (Codex, Claude, Gemini, OpenCode) with a structured development workflow, parallel execution, browser automation and multi-session task checkpointing.

11 skills+32.8k
@stellarlinkco@stellarlinkco
Install Plugin
harmonist

harmonist

Coordinates AI agents with mechanical protocol enforcement, a workflow gate, memory validation and a session hook. Includes a catalog of specialist agents and has no runtime dependencies.

199 agents+12.2k
@gammalabtechnologies@gammalabtechnologies
Install Plugin
bernstein

bernstein

Bernstein is a Claude Code plugin for orchestrating AI agents. It adds skills to list and inspect agents, review and approve tasks, create tasks, track costs and budgets, and show alerts for failed or stalled work.

26 skills+41.2k
@sipyourdrink-ltd@sipyourdrink-ltd
Install Plugin
fablize

fablize

A Claude Code plugin that enforces finishing multi-step tasks with evidence and verification at each stage. It works as a procedure for Opus or any Claude model, not as chance.

1 skill+2891
@fivetaku@fivetaku
Install Plugin
hello2cc

hello2cc

Adjusts how third-party models behave inside Claude Code so they run closer to a native Opus session, covering subagent lifecycle, session hooks and tool interception. It does not replace your gateway, provider mapping or account setup.

1 agent11 hooks690
@hellowind777@hellowind777
Install Plugin
bkit

bkit

Runs a PDCA workflow with phase gating, spec checks, and delegation to sub-agents for planning, QA, and deployment. Fires at session start and on prompts, edits, task creation, and phase transitions.

44 skills+3605
@ww-w-ai@ww-w-ai
Install Plugin

Questions about the hooks in navigator.

Which coding agents can use the hooks in navigator?
The hooks in navigator install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.