git:20260906.876bbf0 to git:20260906.c323fc2

32 added, 1 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 five hooks, out of `hooks.json`
+ ## The six hooks, out of `hooks.json`
| Event | Matcher | Timeout | Script | Job |
|---|---|---|---|---|
| SessionStart | — | 10s | `session-start` | stamp `meta.current_session`, print the profile banner and the standing log directive |
| UserPromptSubmit | — | 10s | `prompt-submit-nudge` | 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` | in `enforced` mode only, deny a `git commit` whose session gate has not passed |
| PostToolUse | `mcp__.*log_session_concepts` | 10s | `checkpoint-quiz` | one mid-task question, `interleaved` cadence only |
| Stop | — | 15s | `stop-quiz-check` | block the turn and demand a quiz |
The PostToolUse matcher 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.
+
+ ## SubagentStart needs the JSON form, and skips the tutor
+
+ `SessionStart` may print its context as raw stdout. `SubagentStart` may 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 blocks with exit 2. `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.
+
+ **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). The one non-zero code is the
Stop hook's `return 2`, which is how Stop blocks; the reason goes on stderr.
## 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 `mode: ambient` in `get_config` the whole
time. Two tests encoded it as intended behaviour. `mode: off` is the off switch,
and it is the only one.
## The Stop hook blocks in `ambient` too
Commonly got wrong. `ambient` is not "never interrupts" — `stop-quiz-check.ts`
returns 2 in ambient as readily as in enforced. `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 `mode === 'off'` silences
everything.
## The loop guard
A Stop hook that blocks on every Stop blocks forever, and `stop_hook_active` is
no longer a documented input. So the rule is: block only when the count of
`origin = 'work'` rows in `session_concepts` has **grown** since the last block.
Blocking stamps that count 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. `max_stop_blocks_per_session` (default 3) caps even
genuinely new work. 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 different keys, one hook each (`mcp/src/config.ts` is the source of truth):
`min_minutes_between_checkpoints` (default 4) paces the single mid-task question
and is read by `checkpoint-quiz.ts`, against both the last checkpoint and the
last answer; `min_minutes_between_quizzes` (default 20) paces a whole Stop quiz,
is read by `stop-quiz-check.ts`, and applies **in `ambient` only**.
`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.