# Hook System — Architecture & Operations Guide

mdkb hooks inject memory context into Claude Code / Codex CLI sessions
automatically, without requiring the assistant to call MCP tools. This
document covers architecture, data flow, configuration, and improvement
opportunities.

## Overview

```
Claude Code / Codex CLI
  |
  |-- SessionStart -------> mdkb hook session-start -----> warmup context
  |-- UserPromptSubmit ----> mdkb hook user-prompt-submit -> recall context
  |-- PreToolUse ----------> mdkb hook pre-tool-use ------> CLI suggestions
  |    (matcher: Grep)
  |-- PostToolUse ---------> mdkb hook post-tool-use -----> reindex queue
       (matcher: Edit|Write|
        NotebookEdit|MultiEdit)
```

Four lifecycle hooks, each with a single responsibility:

| Hook | Trigger | Purpose | Output |
|------|---------|---------|--------|
| **SessionStart** | Session opens | Inject memory warmup so the assistant knows what's stored | `additionalContext` with entry summaries |
| **UserPromptSubmit** | Every user message | Match prompt keywords against memory FTS index, inject relevant entries | `additionalContext` with matched entries |
| **PreToolUse** | Before Grep | Classify grep pattern and suggest `mdkb search`/`mdkb code` CLI commands | `additionalContext` with CLI suggestion |
| **PostToolUse** | Edit/Write/NotebookEdit/MultiEdit | Queue changed files for reindexing | Append to `reindex-queue.jsonl` |

## Execution Model

### Daemon-first with in-process fallback

Every hook command generated by `mdkb setup hooks` follows this pattern:

```bash
if ! mdkb hook <event>; then
  MDKB_NO_DAEMON=1 mdkb hook <event>
fi
```

**Primary path** — `mdkb hook <event>` connects to the daemon via Unix
socket (`~/.mdkb/daemon-hook.sock`). If no daemon is running, it
auto-spawns one (`mdkb serve --daemon`) and retries with exponential
backoff (50, 100, 200, 400, 800 ms — ~1.55s total). The daemon keeps a
`RepoRegistry` in memory, making DB access and FTS queries fast.

**Fallback path** — if the daemon connection still fails (exit code != 0),
the shell re-runs with `MDKB_NO_DAEMON=1`. This opens an ephemeral
in-process `RepoRegistry`, queries the SQLite DB directly, and returns.
Slower (cold open) but always works.

### Fire-and-forget contract

All hooks exit 0 regardless of internal errors. Failures are logged to
stderr and swallowed — the host CLI is never blocked. When a hook has
nothing to contribute, it returns `{}`.

### Matcher (PostToolUse only)

The `PostToolUse` hook entry includes a `matcher` field:

```json
{
  "_managedBy": "mdkb",
  "matcher": "Edit|Write|NotebookEdit|MultiEdit",
  "hooks": [{"type": "command", "command": "..."}]
}
```

This tells Claude Code to only invoke the hook for file-editing tools.
Without it, every tool use (Read, Grep, Bash, Agent, etc.) would spawn
the `mdkb` process just to discard it at line 407 of `hooks.rs`.
SessionStart and UserPromptSubmit don't need matchers — they fire once
per session/prompt by definition.

## Hook Details

### SessionStart

**Flow:**
1. Check `.mdkbignore-hooks` marker → skip if present
2. Check `.mdkb/` directory exists → skip if absent
3. Read `[hooks]` config → skip if `session_start_enabled = false`
4. Open `Context` (DB connection)
5. Call `get_warmup_index(conn, warmup_limit)` → returns formatted memory
   entry summaries (id, type, title, tags)
6. Build markdown block: `## mdkb memory warmup\n\n- [type] id: title #tags\n...`
7. If elapsed > `latency_budget_ms`, log to `hook-slow.jsonl` and append
   truncation notice

**Output example:**
```
## mdkb memory warmup

- [problem] fk-violation: Code index FK on reindex #sqlite #foreign-key
- [decision] relative-paths: Use relative paths in index #code-index
```

**What the assistant sees:** This block appears as `additionalContext` in
the session start system reminder. It primes the assistant with knowledge
of what memory entries exist — encouraging MCP tool use for deeper lookup.

### UserPromptSubmit

**Flow:**
1. Extract `prompt` from stdin JSON → skip if empty
2. Check wrap-up markers (`/clear`, `/compact`, `/exit`, `/quit`, `/wrapup`) → skip
3. Check `.mdkbignore-hooks` and `.mdkb/` → skip if absent
4. Read `[hooks]` config → skip if `user_prompt_submit_enabled = false`
5. Build FTS query: tokenize prompt, strip stopwords (EN+IT), drop tokens < 3 chars,
   join with OR, quote each token
6. Call `search_entries_fts(conn, query, recall_limit)` → BM25-ranked results
7. Build markdown: `## mdkb: relevant context\n\n- [id] title — snippet (160 chars)\n...`
8. If elapsed > `latency_budget_ms`, log to `hook-slow.jsonl`

**Stopword handling:** The filter includes both English and Italian
stopwords (e.g. "il", "della", "perche"). Prompts consisting entirely of
stopwords or short words produce no FTS tokens → hook returns `{}`.

**What the assistant sees:** Relevant memory entries appear as system
reminder context before the assistant processes the prompt. This is
proactive recall — the assistant doesn't need to decide to search.

### PreToolUse

**Flow:**
1. Check `.mdkbignore-hooks` and `.mdkb/` → skip if absent
2. Extract `tool_input.pattern` from stdin JSON → skip if empty
3. Classify pattern:
   - Pure identifier (single word, no regex metacharacters) → suggest
     `mdkb search --scope symbols "{pattern}"`
   - Definition search (`fn X`, `func X`, `def X`, `class X`, etc.) →
     suggest `mdkb code callers {name}`
   - Callsite pattern (`name(`) → suggest `mdkb code callers {name}`
   - Other → return `{}`
4. Resolve binary path via `std::env::current_exe()`
5. Build suggestion: `Use '{binary} search --scope symbols "{pattern}"'
   or '{binary} code callers {pattern}' via Bash.`

**Matcher:** `Grep` — only Grep tool invocations trigger this hook.

**What the assistant sees:** A suggestion to use mdkb CLI commands
instead of (or in addition to) Grep. Works without MCP — the suggestion
references the CLI binary directly.

### PostToolUse

**Flow:**
1. Check `.mdkbignore-hooks` and `.mdkb/` → skip if absent
2. Read `[hooks]` config → skip if `post_tool_use_enabled = false`
3. Check `tool_name` against `REINDEX_TOOLS` (`Edit`, `Write`, `NotebookEdit`,
   `MultiEdit`) → skip if not in list
4. Extract file path from `tool_input.file_path` or `tool_input.notebook_path`
5. **Security:** `canonicalize_under_cwd()` resolves the path and verifies it's
   strictly under the working directory. Path traversal attempts
   (`../../etc/passwd`) are rejected with a warning
6. Append JSON line to `.mdkb/reindex-queue.jsonl`:
   `{"path": "/abs/path", "tool": "Edit", "at": 1713398400}`

**Queue consumption:** `mdkb update` drains the queue at the start of each
run. The queue itself is diagnostic — the actual reindexing uses mtime-based
differential comparison. The queue is deleted after draining.

**Output:** Always `{}` — PostToolUse must never inject context.

## Configuration

All settings in `.mdkb/config.toml` under `[hooks]`:

| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `session_start_enabled` | bool | `true` | Disable warmup injection |
| `user_prompt_submit_enabled` | bool | `true` | Disable recall injection |
| `pre_tool_use_enabled` | bool | `true` | Disable Grep interception suggestions |
| `post_tool_use_enabled` | bool | `true` | Disable reindex queue writes |
| `warmup_limit` | usize | `50` | Max entries in SessionStart warmup |
| `recall_limit` | usize | `5` | Max FTS results in UserPromptSubmit |
| `latency_budget_ms` | u64 | `200` | Overrun threshold; logs to `hook-slow.jsonl` |
| `min_recall_score` | f64 | `0.3` | Declared but **not wired** into FTS filtering |

## Opt-out Mechanisms

From most to least granular:

1. **`.mdkbignore-hooks` marker file** — place at repo root. All hooks
   return `{}` for any CWD under this marker. Searched upward to `$HOME`.
2. **Per-event toggle** — `session_start_enabled = false` etc. in config.toml.
3. **Disable at install** — `mdkb setup hooks claude --disable post-tool-use`.
4. **Uninstall** — `mdkb setup remove hooks claude|codex`, or remove
   hook entries from settings file manually.

## Observability

- **`mdkb stats`** — shows `HooksSummary` with `reindex_queue_pending`
  count and `slow_events_7d` (hooks that exceeded latency budget in
  last 7 days).
- **`.mdkb/hook-slow.jsonl`** — one line per budget overrun, with event
  name, elapsed/budget ms, and timestamp.
- **`.mdkb/reindex-queue.jsonl`** — pending reindex entries. Grows between
  `mdkb update` runs.
- **stderr** — all internal errors are logged via `tracing::warn`.

## Security

- **Path traversal protection** — `canonicalize_under_cwd()` resolves
  symlinks, collapses `..`, and rejects any path that escapes the working
  directory. On macOS, handles `/tmp` → `/private/tmp` symlink resolution.
- **Shell injection protection** — `hook_command_line()` uses single-quote
  escaping for the binary path, neutralizing `$()`, backticks, and
  metacharacters.
- **FTS injection protection** — each search token is double-quoted to
  neutralize FTS5 operators embedded in user prompts.

## Improvement Opportunities

### 1. Wire `min_recall_score` (low effort)

The config field exists but `search_entries_fts` doesn't filter by it.
Adding a `.filter(|r| r.score >= cfg.min_recall_score)` after the FTS
query would reduce noise on loosely-matching prompts.

### 2. PostToolUse latency tracking (low effort)

SessionStart and UserPromptSubmit have latency budget checks.
PostToolUse doesn't, though it does file I/O (canonicalize + append).
Adding an `Instant::now()` + budget check would complete observability.

### 3. Semantic recall alongside FTS (medium effort)

UserPromptSubmit uses BM25-based FTS only. Adding embedding-based
semantic search (the infrastructure already exists in the MCP `search`
tool with `scope="memory"`) would catch conceptually-related entries
that don't share exact keywords with the prompt.

### 4. Queue-driven targeted reindex (medium effort)

Currently the reindex queue is diagnostic — `mdkb update` does full
mtime-based differential reindexing regardless. Using the queue to
target specific files would make incremental reindex faster for large
repos with many files but few edits per session.

### 5. Debounce rapid edits (low effort)

Multiple saves to the same file in quick succession each append a queue
entry. A dedup pass (by path, keeping latest `at`) either at write time
or at drain time would shrink the queue and reduce noise in stats.

### 6. Warmup ranking by recency + frequency (medium effort)

`get_warmup_index` currently returns entries in index order. Ranking by
a combination of last-access time and access frequency would surface
the most relevant entries first, making better use of the `warmup_limit`.

## Test Coverage

| Test file | Scope |
|-----------|-------|
| `tests/e2e_hooks.rs` | Full e2e: warmup, recall, queue lifecycle |
| `tests/hooks/post_tool_use_test.rs` | Queue writes, tool filtering, traversal, marker |
| `tests/cli/setup_hooks_test.rs` | Claude settings writer, idempotency, matcher |
| `tests/cli/setup_hooks_codex_test.rs` | Codex hooks.json writer |
| `tests/e2e_hook_client.rs` | Daemon JSON-RPC round-trip |
| `tests/e2e_daemon_sockets.rs` | IPC server socket tests |
| Unit tests in `src/cli/hooks.rs` | Path canonicalization, traversal rejection |
