CLAUDE.md@hooks · diff
git:20260922.2661c32 to git:20260923.c4a8813
13 added, 3 removed. Audit A to A.
# Working in `hooks/`
This directory is wiring, not logic. Two files: `hooks.json` registers the
events, `run.mjs` is the one entry point every hook and the MCP server go
through. The behaviour lives in TypeScript under `mcp/src/hooks/` and ships
compiled as `dist/hooks/<name>.js`. Editing a hook almost always means editing
`mcp/src/hooks/`, not here.
## `run.mjs` is the launcher
`.sh` hooks are unreliable on Windows, so every entry in `hooks.json` — and
`.mcp.json` — is `"command": "node"` with
`["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "<name>"]`: `node.exe` is a real
executable and needs no shell. Keep that shape; `mcp/test/packaging.test.ts`
asserts the server entry is exactly that string.
`run.mjs` maps `<name>` to `dist/hooks/<name>.js` (`server` → `dist/server.js`)
and resolves it, most specific first: `EKLAVYA_RUNTIME`, then the checkout's own
`mcp/dist` (only when `mcp/node_modules/better-sqlite3` exists), then
`~/.eklavya/runtime/node_modules/eklavya`. In a development checkout your local
build wins — so rebuild before you test. With no build reachable, `server` falls
back to `npx eklavya@<pinned> serve` and a hook starts a detached background
`npm install` (once an hour at most, claimed by a `.installing` stamp before
spawning) and exits 0 saying nothing.
**It carries no version number.** It reads `.claude-plugin/plugin.json` at
runtime, and `mcp/test/packaging.test.ts` asserts the file matches no
`\d+\.\d+\.\d+` anywhere and does match `plugin\.json` — so writing any dotted
triple in here, even in a comment, fails that test on purpose.
`scripts/bump-version.sh` bumps `plugin.json` and `mcp/package.json`, nothing
else.
## The seven hooks, out of `hooks.json`
| Event | Matcher | Timeout | Script | Job |
|---|---|---|---|---|
| SessionStart | — | 10s | `session-start` | stamp this checkout's session pointer (`meta.current_session:<repo root>`), replay the spool, summarise the last session's batch, recall this project's memory, show the developer the profile banner (`systemMessage`) and hand the model the recall and the standing log directive (`additionalContext`) |
| UserPromptSubmit | — | 10s | `prompt-submit-nudge` | re-stamp this checkout's session pointer, then re-state the log directive in one line, but only for a session that has logged nothing after a grace window |
| SubagentStart | — | 10s | `subagent-start` | give a delegated agent the log directive the parent's SessionStart never reached it with |
| PreToolUse | `Bash` | 10s | `pre-tool-gate` | with `quiz.enforced` only, deny a `git commit` whose session gate has not passed |
| PostToolUse | — | 10s | `capture-tool` | record the tool use as memory evidence |
| PostToolUse | `mcp__.*log_session_concepts` | 10s | `checkpoint-quiz` | one mid-task question, `interleaved` cadence only |
| PostToolUse | `^(Bash\|Edit\|Write\|MultiEdit\|NotebookEdit)$` | 10s | `checkpoint-quiz` | the same hook, re-armed by the work: the model logs once per task, so without this the checkpoint asked once per task. No `statusMessage` — it would flash on every command |
| Stop | — | 15s | `stop-quiz-check` | block the turn and demand a quiz — this session's concepts first, then this project's backlog (`backlogConcepts`, shared with the planner), never backlog under `quiz.enforced` |
## Memory capture is not governed by `quiz`
`capture-tool` has no matcher, so it runs after **every** tool call — it is the
hook that fires most often in a session, and it does the least: resolve
identity, normalise one event, one insert, exit. It never quizzes, summarises
or calls a provider.
Four hooks now carry memory work, and all four do it **before** their `quiz`
check, because `memory.enabled` is a separate decision from the learning dials
(PRD CFG-01): `quiz.enabled: false` means no quizzes, not no project history.
That separation is why the `mode` dial was retired — it was always true and the
word `off` denied it, so `session-start` now says on screen which half stopped.
`session-start` replays the spool and recalls; `prompt-submit-nudge` captures
the prompt; `capture-tool` captures the tool use; `stop-quiz-check` closes the
batch at the seam. `mcp/src/hooks/memory-lib.ts` holds the shared helpers, and
every one of them swallows its own failures — a capture path that throws is a
throw on every tool call.
The seam never waits on inference. With `providers.observer` configured,
- `flushAtSeam` queues the batch and returns; the next session start, or
- `eklavya memory process`, drains it. A Stop hook that waits on an API call is
- exactly what PRD LRN-04 forbids.
+ `flushAtSeam` queues the batch and hands it to a detached `eklavya memory
+ process` — but only after winning the single machine-wide worker reservation
+ (`mcp/src/memory/reservation.ts`), and never while the queue is paused. A Stop
+ hook that waits on an API call is exactly what PRD LRN-04 forbids.
+
+ **Every hook is inert inside Eklavya's own `claude -p`.** The observer sets
+ `EKLAVYA_INTERNAL_OBSERVER=1`, and `run.mjs` and `run()` in `lib.ts` both exit 0
+ before reading stdin. Claude Code 2.1.280 ran plugin hooks inside `claude -p`
+ despite `disableAllHooks`, each helper's seam spawned a worker, and a 16 GB Mac
+ reached 165 workers. `--safe-mode` now suppresses them too, but correctness
+ rests on the env check: never move it after anything that opens the database
+ or spawns. `mcp/test/memory-observer-guard.test.ts` runs a stand-in `claude`
+ that executes every hook anyway.
The PostToolUse matcher on `checkpoint-quiz` is a regex over the MCP tool name, not a literal, because
the prefix depends on how the plugin was installed —
`mcp__eklavya__log_session_concepts` standalone,
`mcp__plugin_eklavya_eklavya__log_session_concepts` via `/plugin`. `mcp__.*`
catches both; anchoring it to one spelling silently disables the checkpoint for
half the installs.
## Two audiences, two channels
Every line a hook writes is for the developer or for the model, and each has
exactly one channel: top-level `systemMessage` is rendered to the developer,
`hookSpecificOutput.additionalContext` is read by the model. Plain stdout is
never used. On `SessionStart` it is accepted, but as context only — the banner
went out that way for months and every session opened in apparent silence while
the model read three lines meant for a person. And `systemMessage` is **top
level**: nested inside `hookSpecificOutput` the harness drops it, which is how
the checkpoint's one line to the developer went unseen for just as long.
`docs/verified-schemas.md` has the per-hook table; `mcp/test/hooks.test.ts`
asserts on `shown` and `context` separately, and its `systemMessage()` helper
throws on the nested placement, so a new hook cannot repeat either mistake
without a test saying so.
## SubagentStart needs the JSON form, and skips the tutor
`SessionStart` accepts raw stdout as context (Eklavya no longer uses it — see
above). `SubagentStart` does not: it
reads `hookSpecificOutput.additionalContext` and drops anything else in silence,
so the wrong form is a hook that runs, exits 0, and does nothing. That is the
one thing `subagent-start.ts` cannot get wrong, and `mcp/test/hooks.test.ts`
parses the envelope rather than asserting that something was printed.
It also stays silent for `eklavya-tutor`, matched as a substring so both
`eklavya-tutor` and `eklavya:eklavya-tutor` are caught. Not because the tutor
lacks `log_session_concepts` — so do `Explore` and `Plan`, and they are told
anyway. It is the directive's second sentence: *do not ask the developer
anything here* is an order not to do the only thing `agents/tutor.md` exists
for, so delivering it disables parallel tutoring in silence. An absent or
unrecognised `agent_type` fails **open** — a host that does not send the field
is a host where failing closed would turn the feature off with nothing to
report. `docs/subagent-policy.md` is the policy in full; keep the two in step.
`stop-quiz-check.ts` carries the same `agent_id` guard as `checkpoint-quiz.ts`,
and for a stronger reason: it keeps the turn going. `Stop` is believed to be
parent-only, since `SubagentStop` is a separate event — but nothing here has
verified that, and this hook is what made the path reachable, because before it
a subagent logged nothing and the Stop hook's `logged > last_logged` predicate
could never arm. Do not read that as making the guard optional now: under
`interleaved` the clock arms the sweep whether or not anything was logged, so the
`agent_id` check is the only thing standing between a subagent and a quiz it
cannot ask.
**And it pays the stdin cost on every delegated task.** It needs `agent_type`,
so it cannot keep ponytail's stdin-independent fast path; on a host that
swallows the pipe that is `idleMs` — 2s — per subagent spawn, the same trade
`PreToolUse` makes per `Bash` call.
## Every failure path exits 0
A hook that throws breaks the user's session, and a learning tool that breaks
sessions gets uninstalled. `run()` in `mcp/src/hooks/lib.ts` enforces it: parse
stdin defensively, run the body, exit 0 silently on any throw. A new hook body
goes inside `await run(async (input) => { ... })` and returns an exit code; it
never calls `process.exit` itself. Helpers follow the same rule —
`openExisting()` returns `null` rather than throwing on a missing or corrupt
database, and deliberately does not migrate or seed (several hooks racing a
migration on session start is a corruption story). Every hook returns 0 — the
Stop sweep included. It keeps the turn going with
`hookSpecificOutput.additionalContext` rather than exit 2, because exit 2 renders
to the developer as a hook error; `docs/verified-schemas.md` D1 has the table.
## A hook must never *wait*, either
Exiting 0 on a throw covers the loud failure. The quiet one is worse: a hook
that blocks never errors, never logs, and stalls the session on every tool call
that triggers it — with nothing for the developer to report except that Claude
Code got slow.
`readInput` used to be `for await (const chunk of process.stdin)`, which has
exactly one exit: EOF. Ponytail's issue #443 reports Claude Code on Windows
running a hook through a PowerShell block that swallows the piped JSON, so `end`
never fires. **Nothing here verifies that mechanism** — there is no Windows
machine in the loop — so what this defends against is the consequence, a stdin
that never ends, which the tests reproduce directly. `run.mjs` is careful about
everything else — Node version, four resolution candidates, a self-expiring heal
claim, exit 0 on every throw — and this was the one gap.
`mcp/src/stdin.ts` closes it, and `eklavya statusline` shares it: both read a
JSON blob the host pipes in, both must degrade rather than hang, and two copies
would be one copy getting the fix. Three things matter about it.
**The primary bound is on silence, not on total time.** A flat cap truncates a
payload still arriving when it fires, and truncated JSON does not fail loudly —
it fails as `{}`, so the hook runs to completion having quietly decided the
session has no cwd and no id. The idle timer resets on every chunk, so a payload
is safe as long as it keeps making progress. The total cap behind it *can* still
truncate, and claiming otherwise would be the same overclaim in the other
direction — it is a deliberate trade, and `totalMs` is generous against how long
a real payload takes.
**It pauses the stream, not just the listeners.** This is the line the rest of
it depends on. `setEncoding` puts stdin in flowing mode and a flowing stdin holds
an active libuv handle, so removing the `data` listener resolves the read and
leaves the process alive. The hooks hide that — `run()` ends in `process.exit` —
but `eklavya statusline` just returns, and under exactly the no-EOF condition
this exists for it printed the dials and then lingered forever, once per status
bar refresh. `test/stdin.test.ts` spawns the statusline for that reason: it is
the caller with no `process.exit` behind it, so it is the honest test of whether
the read lets go.
**Every timer is `unref`'d**, so a pending timer adds no latency to a hook that
has already finished. On its own that does not let the process exit — see above.
**There is an `error` handler.** A stream that errors never emits `end`, so
without one the read waits on something that is not coming. It is also
load-bearing beyond that: an unhandled `error` on `process.stdin` is an async
exception `run()`'s `try`/`catch` could not have caught.
**It costs something, and the cost is worth stating.** On a host that swallows
the pipe this turns an infinite hang into `idleMs` per invocation, and PreToolUse
matches every `Bash` call — so +2s per command until the host is fixed. Two
seconds a command is bad; a frozen session is worse.
`HOOK_STDIN` is 2s idle / 5s total, well under the 10s `hooks.json` grants (15
for Stop) — a read that outlives its host timeout is a read the developer waits
on, and `test/stdin.test.ts` asserts the relationship rather than the number.
`STATUSLINE_STDIN` is 150ms / 250ms, and the total is pinned at or below the
250ms flat cap the inline reader had before it: splitting one budget into idle
plus total made the worst case four times worse for the one caller whose latency
a human sees, which a test now prevents.
That suite spawns a real hook, writes a payload, and **never closes stdin**;
against the old code all three cases hang until the test kills them.
`stripBom` runs before every `JSON.parse` here. Some Windows shells prepend a
byte-order mark, and `JSON.parse` throws on input that looks perfectly
well-formed in a terminal and in any editor — another silent nothing-happens.
## `quiet` is not an off switch
It suppresses the session-start banner and the status bar — things the developer
looks at. It does **not** suppress the standing directive, and it does not gate
the `UserPromptSubmit` nudge, because both are `additionalContext` the model
reads rather than output anyone sees.
That was a bug for a while, and a bad one: `session-start` returned before
pushing the directive, so a developer who turned the greeting off logged
nothing, was never quizzed, and saw quizzing reported as enabled in
`get_config` the whole time. Two tests encoded it as intended behaviour.
`quiz.enabled: false` is the off switch — along with its session-scoped twin
below, which is the same switch with a shorter life.
## The Stop hook blocks when unenforced too
Commonly got wrong. Unenforced is not "never interrupts" — `stop-quiz-check.ts`
returns 2 without the gate as readily as with it. `quiz.enforced` changes three
things: the `min_minutes_between_quizzes` cooldown is skipped (a cooldown could
make a commit gate unpassable — decision G5); the one-question cap under
`interleaved` is lifted, so the sweep asks for the whole remaining budget; and
`pre-tool-gate` plus `cli/eklavya-gate` start holding commits. Only
`quiz.enabled: false` silences the questions — and it silences only those, not
the memory half.
## The per-session off switch
`isSessionOff(db, sessionId)` (`mcp/src/session.ts`) is a `meta` row written by
`set_config` at `scope: "session"`. **Every hook that speaks to the developer
checks it right after its `mode` check** — session-start, the nudge, the
checkpoint, Stop, subagent-start. `pre-tool-gate` is the deliberate exception
and reads it only to word its refusal; see below. It exists because the file-backed `off` outlives the urgent
afternoon that wanted it, and a developer who silences one hour by editing a
config file has quietly turned the product off for good.
Two rules it is easy to get wrong:
- It silences, it does not exempt. `cli/eklavya-gate` reads the project config and
never sees a session id, so an enforced repo still holds the commit. Making
the gate honour it would turn a per-session convenience into a gate bypass.
`pre-tool-gate` reads `isSessionOff` for one reason only: its refusal tells
the model to run a quiz, and in a silenced session the planner returns
`session_off` and nothing to ask, so the refusal has to name the way out.
Reading it to word the message is not the same as acting on it.
- Enforced mode is **not** the exception here that it is for the cooldown. The
cooldown is pacing Eklavya chose; this is the developer saying stop, in words.
## The loop guard
A Stop hook that blocks on every Stop blocks forever, and `stop_hook_active` is
no longer a documented input. What bounds it depends on the cadence, because the
two cadences block for different reasons.
Under **`end`**: block only when the count of `origin = 'work'` rows in
`session_concepts` has **grown** since the last block. That cadence delivers the
whole budget in one sweep, so a second sweep needs new work behind it.
Under **`interleaved`**: the pacing clock is the guard. The `logged > last_logged`
rule cannot work here — the model logs its whole batch in one call at the start
of a task, so "new work since the last block" is false for the rest of the
session and the sweep fired exactly once, ever. That capped a session at two
questions against a budget of four. Time re-arms it instead.
Either way, blocking stamps into `stop_markers` (`last_logged_count`,
`last_blocked_at`, `block_count`) *before* the block — a failure after it costs
a missed quiz, never a loop — and three caps bound every block: the pacing clock,
`max_stop_blocks_per_session` (default 3), and the remaining session budget.
Counting review-origin rows here would let answering a question re-arm the block
that asked it; don't. `checkpoint-quiz.ts` has the mirror-image guard for
mid-turn bursts, stamping `checkpoints` before it emits.
## Pacing
Two keys, and which one applies depends on what is being paced, not on which
hook is asking (`mcp/src/config.ts` is the source of truth).
`min_minutes_between_checkpoints` (default 4) paces a **single question**: the
mid-task checkpoint in `checkpoint-quiz.ts`, and the Stop sweep too whenever the
cadence is `interleaved`, since a sweep is one question there.
`min_minutes_between_quizzes` (default 20) paces a **whole quiz**, which only the
`end` cadence produces. Both apply **in `ambient` only**, and both are measured
against the last block *and* the last answer — checking only one would let the
hook block a turn the quiz plan then refuses as too soon. That is why
`get_session_quiz_plan` picks between the same two keys on the same cadence: the
hook's clock and the plan's cooldown have to be the same number, or the hook
blocks a turn and the plan hands back `questions_needed: 0`. Under `interleaved`
the Stop sweep floors its gap at one minute, because there the clock is the whole
loop guard and `0` is a legal value for the checkpoint it borrows.
`max_questions_per_task` (default 4) is a session allowance shared by both hooks:
every `attempts` row spends it, so the Stop hook asks for whatever the checkpoints
left. The two hooks' candidate queries share a WHERE clause verbatim; if you
change one, change both, or a concept gets asked twice or never.
## The SessionStart banner is a format string
The `[Eklavya] ...` line in `mcp/src/hooks/session-start.ts` is quoted verbatim
by `web/public/index.html` (the hero terminal script) and
`web/src/content/docs/docs/installing.mdx` (the no-history variant). Changing its
wording, order or fields means re-quoting both in the same commit.
## Testing a hook by hand
`dist/` must exist first — the tests run the built files, not the sources, and
`pretest` builds them. From `mcp/`:
```sh
npm run build
echo '{"session_id":"s1","cwd":"/path/to/repo","hook_event_name":"Stop"}' \
| EKLAVYA_DB=/tmp/k.db EKLAVYA_HOME=/tmp/eklavya-home \
node dist/hooks/stop-quiz-check.js; echo "exit $?"
```
`EKLAVYA_DB` and `EKLAVYA_HOME` point a hook at a scratch database and config;
`EKLAVYA_SESSION_ID` overrides the harness session id. `mcp/test/hooks.test.ts`
and `mcp/test/gate.test.ts` do exactly this.