CLAUDE.md@hooks · git:20260923.dd5dbaa · 2026-09-23 · sha256 ddf6cb902ddf1116
CLAUDE.md@hooks git:20260923.dd5dbaaA
Immutable. This exact content never changes and is served at /api/v1/blob/ddf6cb902ddf1116.
# 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.
The same heal keeps an **installed** runtime current. The plugin moves when
Claude Code updates it; the runtime only moves when npm runs. So when the entry
resolved to `~/.eklavya/runtime` and its `package.json` version is older than
`plugin.json`'s, `healIfBehind` starts that background install before the
import — this run still uses the old runtime and never waits. It never
downgrades (migrations only go forward), leaves an `EKLAVYA_RUNTIME` or
checkout build alone, and does nothing when the global config says
`auto_update: false` — read straight from `config.json`, since the runtime's
config loader is not reachable here. A *missing* runtime is healed regardless:
that is bootstrap, not an upgrade. `eklavya doctor`'s `versions` row reports the
skew.
The `.installing` stamp is the one runtime lock, shared with `installRuntime`
and the updater; `mcp/src/install-lock.ts` states the rules and `run.mjs`
carries a copy, because it has to work before any runtime exists. After
spawning npm the heal rewrites the stamp to name npm's pid, so the claim lives
exactly as long as npm runs; the stamp npm leaves behind also throttles the heal
to once an hour. `mcp/test/install-lock.test.ts` races all three entry points
against a fake npm.
The `npx` fallback retries `eklavya@latest` once, and only when npm says the
pinned version does not exist (`E404`/`ETARGET`/`No matching version`). That is
the release gap: semantic-release pushes the bumped `plugin.json` in its
`prepare` step, before `npm publish`, so a marketplace pull can pin a version
npm does not have yet for a minute or so. A server that started and then failed
is not restarted.
**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`
Seven scripts on six events: `checkpoint-quiz` is registered twice under
PostToolUse, so the table has eight rows.
| 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, start the background auto-update when one is due (`update.ts`) and add its one line — `Eklavya updated to X` once, or `Eklavya can't update itself · … · run: eklavya update` every session while it fails — to what the developer sees, show the developer the profile banner (`systemMessage`) and hand the model the recall and the standing log directive (`additionalContext`). A database that exists but will not open gets one line to the developer — `Eklavya paused · can't open its database · run: eklavya doctor`, or the SQLite/Node-mismatch variant — and a first run with no database stays silent |
| 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` (or `git merge --continue`) whose session gate has not passed. `commit-lib.ts` lexes the command like a shell — newlines, `env`/`sudo`/`timeout`/`VAR=x` prefixes, `bash -c`, subshells, substitutions, `git -C dir` — and accepts missing aliases and scripts: the git hook is the real enforcement |
| 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 light capture path — `identityOf`, `record`,
`batchIfFull` — lives in `capture-lib.ts`, so `capture-tool` and
`prompt-submit-nudge` never import the worker, the provider, recall or notify;
`memory-lib.ts` re-exports them for the seam hooks, and
`mcp/test/hook-isolation.test.ts` fails if a hot path starts loading a heavy
module again.
A Stop seam does not close a batch every turn: only at `SEAM_MIN_EVENTS` (8)
or once the oldest open event is `SEAM_MAX_AGE_MS` (20 minutes) old, because
with an observer configured every closed batch is a `claude -p` call. A session
start closes everything. The same seam runs the retention sweep
(`pruneIfDue`, at most every six hours, 5,000 events at a time) and gives all
notification sinks one shared 5s budget (`NOTIFY_BUDGET_MS`).
The seam never waits on inference. With `providers.observer` configured,
`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.