eklavya 1.27.0 → 1.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,371 +1,120 @@
1
1
  # Working in `hooks/`
2
2
 
3
- This directory is wiring, not logic. Two files: `hooks.json` registers the
4
- events, `run.mjs` is the one entry point every hook and the MCP server go
5
- through. The behaviour lives in TypeScript under `mcp/src/hooks/` and ships
6
- compiled as `dist/hooks/<name>.js`. Editing a hook almost always means editing
7
- `mcp/src/hooks/`, not here.
8
-
9
- ## `run.mjs` is the launcher
10
-
11
- `.sh` hooks are unreliable on Windows, so every entry in `hooks.json` — and
12
- `.mcp.json` — is `"command": "node"` with
13
- `["${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs", "<name>"]`: `node.exe` is a real
14
- executable and needs no shell. Keep that shape; `mcp/test/packaging.test.ts`
15
- asserts the server entry is exactly that string.
16
-
17
- `run.mjs` maps `<name>` to `dist/hooks/<name>.js` (`server` → `dist/server.js`)
18
- and resolves it, most specific first: `EKLAVYA_RUNTIME`, then the checkout's own
19
- `mcp/dist` (only when `mcp/node_modules/better-sqlite3` exists), then
20
- `~/.eklavya/runtime/node_modules/eklavya`. In a development checkout your local
21
- build wins — so rebuild before you test. With no build reachable, `server` falls
22
- back to `npx eklavya@<pinned> serve` and a hook starts a detached background
23
- `npm install` (once an hour at most, claimed by a `.installing` stamp before
24
- spawning) and exits 0 saying nothing.
25
-
26
- The same heal keeps an **installed** runtime current. The plugin moves when
27
- Claude Code updates it; the runtime only moves when npm runs. So when the entry
28
- resolved to `~/.eklavya/runtime` and its `package.json` version is older than
29
- `plugin.json`'s, `healIfBehind` starts that background install before the
30
- import — this run still uses the old runtime and never waits. It never
31
- downgrades (migrations only go forward), leaves an `EKLAVYA_RUNTIME` or
32
- checkout build alone, and does nothing when the global config says
33
- `auto_update: false` — read straight from `config.json`, since the runtime's
34
- config loader is not reachable here. A *missing* runtime is healed regardless:
35
- that is bootstrap, not an upgrade. `eklavya doctor`'s `versions` row reports the
36
- skew.
37
-
38
- The `.installing` stamp is the one runtime lock, shared with `installRuntime`
39
- and the updater; `mcp/src/install-lock.ts` states the rules and `run.mjs`
40
- carries a copy, because it has to work before any runtime exists. After
41
- spawning npm the heal rewrites the stamp to name npm's pid, so the claim lives
42
- exactly as long as npm runs; the stamp npm leaves behind also throttles the heal
43
- to once an hour. `mcp/test/install-lock.test.ts` races all three entry points
44
- against a fake npm.
45
-
46
- The `npx` fallback retries `eklavya@latest` once, and only when npm says the
47
- pinned version does not exist (`E404`/`ETARGET`/`No matching version`). That is
48
- the release gap: semantic-release pushes the bumped `plugin.json` in its
49
- `prepare` step, before `npm publish`, so a marketplace pull can pin a version
50
- npm does not have yet for a minute or so. A server that started and then failed
51
- is not restarted.
52
-
53
- **It carries no version number.** It reads `.claude-plugin/plugin.json` at
54
- runtime, and `mcp/test/packaging.test.ts` asserts the file matches no
55
- `\d+\.\d+\.\d+` anywhere and does match `plugin\.json` — so writing any dotted
56
- triple in here, even in a comment, fails that test on purpose.
57
- `scripts/bump-version.sh` bumps `plugin.json` and `mcp/package.json`, nothing
58
- else.
59
-
60
- ## The seven hooks, out of `hooks.json`
61
-
62
- Seven scripts on six events: `checkpoint-quiz` is registered twice under
63
- PostToolUse, so the table has eight rows.
64
-
65
- | Event | Matcher | Timeout | Script | Job |
66
- |---|---|---|---|---|
67
- | 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 |
68
- | 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 |
69
- | SubagentStart | — | 10s | `subagent-start` | give a delegated agent the log directive the parent's SessionStart never reached it with |
70
- | 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 |
71
- | PostToolUse | — | 10s | `capture-tool` | record the tool use as memory evidence |
72
- | PostToolUse | `mcp__.*log_session_concepts` | 10s | `checkpoint-quiz` | one mid-task question, `interleaved` cadence only |
73
- | 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 |
74
- | 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` |
75
-
76
- ## Memory capture is not governed by `quiz`
77
-
78
- `capture-tool` has no matcher, so it runs after **every** tool call — it is the
79
- hook that fires most often in a session, and it does the least: resolve
80
- identity, normalise one event, one insert, exit. It never quizzes, summarises
81
- or calls a provider.
82
-
83
- Four hooks now carry memory work, and all four do it **before** their `quiz`
84
- check, because `memory.enabled` is a separate decision from the learning dials
85
- (PRD CFG-01): `quiz.enabled: false` means no quizzes, not no project history.
86
- That separation is why the `mode` dial was retired — it was always true and the
87
- word `off` denied it, so `session-start` now says on screen which half stopped.
88
- `session-start` replays the spool and recalls; `prompt-submit-nudge` captures
89
- the prompt; `capture-tool` captures the tool use; `stop-quiz-check` closes the
90
- batch at the seam. `mcp/src/hooks/memory-lib.ts` holds the shared helpers, and
91
- every one of them swallows its own failures — a capture path that throws is a
92
- throw on every tool call. The light capture path — `identityOf`, `record`,
93
- `batchIfFull` — lives in `capture-lib.ts`, so `capture-tool` and
94
- `prompt-submit-nudge` never import the worker, the provider, recall or notify;
95
- `memory-lib.ts` re-exports them for the seam hooks, and
96
- `mcp/test/hook-isolation.test.ts` fails if a hot path starts loading a heavy
97
- module again.
98
-
99
- A Stop seam does not close a batch every turn: only at `SEAM_MIN_EVENTS` (8)
100
- or once the oldest open event is `SEAM_MAX_AGE_MS` (20 minutes) old, because
101
- with an observer configured every closed batch is a `claude -p` call. A session
102
- start closes everything. The same seam runs the retention sweep
103
- (`pruneIfDue`, at most every six hours, 5,000 events at a time) and gives all
104
- notification sinks one shared 5s budget (`NOTIFY_BUDGET_MS`).
105
-
106
- The seam never waits on inference. With `providers.observer` configured,
107
- `flushAtSeam` queues the batch and hands it to a detached `eklavya memory
108
- process` — but only after winning the single machine-wide worker reservation
109
- (`mcp/src/memory/reservation.ts`), and never while the queue is paused. A Stop
110
- hook that waits on an API call is exactly what PRD LRN-04 forbids.
111
-
112
- **Every hook is inert inside Eklavya's own `claude -p`.** The observer sets
113
- `EKLAVYA_INTERNAL_OBSERVER=1`, and `run.mjs` and `run()` in `lib.ts` both exit 0
114
- before reading stdin. Claude Code 2.1.280 ran plugin hooks inside `claude -p`
115
- despite `disableAllHooks`, each helper's seam spawned a worker, and a 16 GB Mac
116
- reached 165 workers. `--safe-mode` now suppresses them too, but correctness
117
- rests on the env check: never move it after anything that opens the database
118
- or spawns. `mcp/test/memory-observer-guard.test.ts` runs a stand-in `claude`
119
- that executes every hook anyway.
120
-
121
- The PostToolUse matcher on `checkpoint-quiz` is a regex over the MCP tool name, not a literal, because
122
- the prefix depends on how the plugin was installed —
123
- `mcp__eklavya__log_session_concepts` standalone,
124
- `mcp__plugin_eklavya_eklavya__log_session_concepts` via `/plugin`. `mcp__.*`
125
- catches both; anchoring it to one spelling silently disables the checkpoint for
126
- half the installs.
127
-
128
- ## Two audiences, two channels
129
-
130
- Every line a hook writes is for the developer or for the model, and each has
131
- exactly one channel: top-level `systemMessage` is rendered to the developer,
132
- `hookSpecificOutput.additionalContext` is read by the model. Plain stdout is
133
- never used. On `SessionStart` it is accepted, but as context only — the banner
134
- went out that way for months and every session opened in apparent silence while
135
- the model read three lines meant for a person. And `systemMessage` is **top
136
- level**: nested inside `hookSpecificOutput` the harness drops it, which is how
137
- the checkpoint's one line to the developer went unseen for just as long.
138
- `docs/verified-schemas.md` has the per-hook table; `mcp/test/hooks.test.ts`
139
- asserts on `shown` and `context` separately, and its `systemMessage()` helper
140
- throws on the nested placement, so a new hook cannot repeat either mistake
141
- without a test saying so.
142
-
143
- ## SubagentStart needs the JSON form, and skips the tutor
144
-
145
- `SessionStart` accepts raw stdout as context (Eklavya no longer uses it — see
146
- above). `SubagentStart` does not: it
147
- reads `hookSpecificOutput.additionalContext` and drops anything else in silence,
148
- so the wrong form is a hook that runs, exits 0, and does nothing. That is the
149
- one thing `subagent-start.ts` cannot get wrong, and `mcp/test/hooks.test.ts`
150
- parses the envelope rather than asserting that something was printed.
151
-
152
- It also stays silent for `eklavya-tutor`, matched as a substring so both
153
- `eklavya-tutor` and `eklavya:eklavya-tutor` are caught. Not because the tutor
154
- lacks `log_session_concepts` — so do `Explore` and `Plan`, and they are told
155
- anyway. It is the directive's second sentence: *do not ask the developer
156
- anything here* is an order not to do the only thing `agents/tutor.md` exists
157
- for, so delivering it disables parallel tutoring in silence. An absent or
158
- unrecognised `agent_type` fails **open** — a host that does not send the field
159
- is a host where failing closed would turn the feature off with nothing to
160
- report. `docs/subagent-policy.md` is the policy in full; keep the two in step.
161
-
162
- `stop-quiz-check.ts` carries the same `agent_id` guard as `checkpoint-quiz.ts`,
163
- and for a stronger reason: it keeps the turn going. `Stop` is believed to be
164
- parent-only, since `SubagentStop` is a separate event — but nothing here has
165
- verified that, and this hook is what made the path reachable, because before it
166
- a subagent logged nothing and the Stop hook's `logged > last_logged` predicate
167
- could never arm. Do not read that as making the guard optional now: under
168
- `interleaved` the clock arms the sweep whether or not anything was logged, so the
169
- `agent_id` check is the only thing standing between a subagent and a quiz it
170
- cannot ask.
171
-
172
- **And it pays the stdin cost on every delegated task.** It needs `agent_type`,
173
- so it cannot keep ponytail's stdin-independent fast path; on a host that
174
- swallows the pipe that is `idleMs` — 2s — per subagent spawn, the same trade
175
- `PreToolUse` makes per `Bash` call.
176
-
177
- ## Every failure path exits 0
178
-
179
- A hook that throws breaks the user's session, and a learning tool that breaks
180
- sessions gets uninstalled. `run()` in `mcp/src/hooks/lib.ts` enforces it: parse
181
- stdin defensively, run the body, exit 0 silently on any throw. A new hook body
182
- goes inside `await run(async (input) => { ... })` and returns an exit code; it
183
- never calls `process.exit` itself. Helpers follow the same rule —
184
- `openExisting()` returns `null` rather than throwing on a missing or corrupt
185
- database, and deliberately does not migrate or seed (several hooks racing a
186
- migration on session start is a corruption story). Every hook returns 0 — the
187
- Stop sweep included. It keeps the turn going with
188
- `hookSpecificOutput.additionalContext` rather than exit 2, because exit 2 renders
189
- to the developer as a hook error; `docs/verified-schemas.md` D1 has the table.
190
-
191
- ## A hook must never *wait*, either
192
-
193
- Exiting 0 on a throw covers the loud failure. The quiet one is worse: a hook
194
- that blocks never errors, never logs, and stalls the session on every tool call
195
- that triggers it — with nothing for the developer to report except that Claude
196
- Code got slow.
197
-
198
- `readInput` used to be `for await (const chunk of process.stdin)`, which has
199
- exactly one exit: EOF. Ponytail's issue #443 reports Claude Code on Windows
200
- running a hook through a PowerShell block that swallows the piped JSON, so `end`
201
- never fires. **Nothing here verifies that mechanism** — there is no Windows
202
- machine in the loop — so what this defends against is the consequence, a stdin
203
- that never ends, which the tests reproduce directly. `run.mjs` is careful about
204
- everything else — Node version, four resolution candidates, a self-expiring heal
205
- claim, exit 0 on every throw — and this was the one gap.
206
-
207
- `mcp/src/stdin.ts` closes it, and `eklavya statusline` shares it: both read a
208
- JSON blob the host pipes in, both must degrade rather than hang, and two copies
209
- would be one copy getting the fix. Three things matter about it.
210
-
211
- **The primary bound is on silence, not on total time.** A flat cap truncates a
212
- payload still arriving when it fires, and truncated JSON does not fail loudly —
213
- it fails as `{}`, so the hook runs to completion having quietly decided the
214
- session has no cwd and no id. The idle timer resets on every chunk, so a payload
215
- is safe as long as it keeps making progress. The total cap behind it *can* still
216
- truncate, and claiming otherwise would be the same overclaim in the other
217
- direction — it is a deliberate trade, and `totalMs` is generous against how long
218
- a real payload takes.
219
-
220
- **It pauses the stream, not just the listeners.** This is the line the rest of
221
- it depends on. `setEncoding` puts stdin in flowing mode and a flowing stdin holds
222
- an active libuv handle, so removing the `data` listener resolves the read and
223
- leaves the process alive. The hooks hide that — `run()` ends in `process.exit` —
224
- but `eklavya statusline` just returns, and under exactly the no-EOF condition
225
- this exists for it printed the dials and then lingered forever, once per status
226
- bar refresh. `test/stdin.test.ts` spawns the statusline for that reason: it is
227
- the caller with no `process.exit` behind it, so it is the honest test of whether
228
- the read lets go.
229
-
230
- **Every timer is `unref`'d**, so a pending timer adds no latency to a hook that
231
- has already finished. On its own that does not let the process exit — see above.
232
-
233
- **There is an `error` handler.** A stream that errors never emits `end`, so
234
- without one the read waits on something that is not coming. It is also
235
- load-bearing beyond that: an unhandled `error` on `process.stdin` is an async
236
- exception `run()`'s `try`/`catch` could not have caught.
237
-
238
- **It costs something, and the cost is worth stating.** On a host that swallows
239
- the pipe this turns an infinite hang into `idleMs` per invocation, and PreToolUse
240
- matches every `Bash` call — so +2s per command until the host is fixed. Two
241
- seconds a command is bad; a frozen session is worse.
242
-
243
- `HOOK_STDIN` is 2s idle / 5s total, well under the 10s `hooks.json` grants (15
244
- for Stop) — a read that outlives its host timeout is a read the developer waits
245
- on, and `test/stdin.test.ts` asserts the relationship rather than the number.
246
- `STATUSLINE_STDIN` is 150ms / 250ms, and the total is pinned at or below the
247
- 250ms flat cap the inline reader had before it: splitting one budget into idle
248
- plus total made the worst case four times worse for the one caller whose latency
249
- a human sees, which a test now prevents.
250
-
251
- That suite spawns a real hook, writes a payload, and **never closes stdin**;
252
- against the old code all three cases hang until the test kills them.
253
-
254
- `stripBom` runs before every `JSON.parse` here. Some Windows shells prepend a
255
- byte-order mark, and `JSON.parse` throws on input that looks perfectly
256
- well-formed in a terminal and in any editor — another silent nothing-happens.
257
-
258
- ## `quiet` is not an off switch
259
-
260
- It suppresses the session-start banner and the status bar — things the developer
261
- looks at. It does **not** suppress the standing directive, and it does not gate
262
- the `UserPromptSubmit` nudge, because both are `additionalContext` the model
263
- reads rather than output anyone sees.
264
-
265
- That was a bug for a while, and a bad one: `session-start` returned before
266
- pushing the directive, so a developer who turned the greeting off logged
267
- nothing, was never quizzed, and saw quizzing reported as enabled in
268
- `get_config` the whole time. Two tests encoded it as intended behaviour.
269
- `quiz.enabled: false` is the off switch — along with its session-scoped twin
270
- below, which is the same switch with a shorter life.
271
-
272
- ## The Stop hook blocks when unenforced too
273
-
274
- Commonly got wrong. Unenforced is not "never interrupts" — `stop-quiz-check.ts`
275
- returns 2 without the gate as readily as with it. `quiz.enforced` changes three
276
- things: the `min_minutes_between_quizzes` cooldown is skipped (a cooldown could
277
- make a commit gate unpassable — decision G5); the one-question cap under
278
- `interleaved` is lifted, so the sweep asks for the whole remaining budget; and
279
- `pre-tool-gate` plus `cli/eklavya-gate` start holding commits. Only
280
- `quiz.enabled: false` silences the questions — and it silences only those, not
281
- the memory half.
282
-
283
- ## The per-session off switch
284
-
285
- `isSessionOff(db, sessionId)` (`mcp/src/session.ts`) is a `meta` row written by
286
- `set_config` at `scope: "session"`. **Every hook that speaks to the developer
287
- checks it right after its `mode` check** — session-start, the nudge, the
288
- checkpoint, Stop, subagent-start. `pre-tool-gate` is the deliberate exception
289
- and reads it only to word its refusal; see below. It exists because the file-backed `off` outlives the urgent
290
- afternoon that wanted it, and a developer who silences one hour by editing a
291
- config file has quietly turned the product off for good.
292
-
293
- Two rules it is easy to get wrong:
294
-
295
- - It silences, it does not exempt. `cli/eklavya-gate` reads the project config and
296
- never sees a session id, so an enforced repo still holds the commit. Making
297
- the gate honour it would turn a per-session convenience into a gate bypass.
298
- `pre-tool-gate` reads `isSessionOff` for one reason only: its refusal tells
299
- the model to run a quiz, and in a silenced session the planner returns
300
- `session_off` and nothing to ask, so the refusal has to name the way out.
301
- Reading it to word the message is not the same as acting on it.
302
- - Enforced mode is **not** the exception here that it is for the cooldown. The
303
- cooldown is pacing Eklavya chose; this is the developer saying stop, in words.
304
-
305
- ## The loop guard
306
-
307
- A Stop hook that blocks on every Stop blocks forever, and `stop_hook_active` is
308
- no longer a documented input. What bounds it depends on the cadence, because the
309
- two cadences block for different reasons.
310
-
311
- Under **`end`**: block only when the count of `origin = 'work'` rows in
312
- `session_concepts` has **grown** since the last block. That cadence delivers the
313
- whole budget in one sweep, so a second sweep needs new work behind it.
314
-
315
- Under **`interleaved`**: the pacing clock is the guard. The `logged > last_logged`
316
- rule cannot work here — the model logs its whole batch in one call at the start
317
- of a task, so "new work since the last block" is false for the rest of the
318
- session and the sweep fired exactly once, ever. That capped a session at two
319
- questions against a budget of four. Time re-arms it instead.
320
-
321
- Either way, blocking stamps into `stop_markers` (`last_logged_count`,
322
- `last_blocked_at`, `block_count`) *before* the block — a failure after it costs
323
- a missed quiz, never a loop — and three caps bound every block: the pacing clock,
324
- `max_stop_blocks_per_session` (default 3), and the remaining session budget.
325
- Counting review-origin rows here would let answering a question re-arm the block
326
- that asked it; don't. `checkpoint-quiz.ts` has the mirror-image guard for
327
- mid-turn bursts, stamping `checkpoints` before it emits.
328
-
329
- ## Pacing
330
-
331
- Two keys, and which one applies depends on what is being paced, not on which
332
- hook is asking (`mcp/src/config.ts` is the source of truth).
333
- `min_minutes_between_checkpoints` (default 4) paces a **single question**: the
334
- mid-task checkpoint in `checkpoint-quiz.ts`, and the Stop sweep too whenever the
335
- cadence is `interleaved`, since a sweep is one question there.
336
- `min_minutes_between_quizzes` (default 20) paces a **whole quiz**, which only the
337
- `end` cadence produces. Both apply **in `ambient` only**, and both are measured
338
- against the last block *and* the last answer — checking only one would let the
339
- hook block a turn the quiz plan then refuses as too soon. That is why
340
- `get_session_quiz_plan` picks between the same two keys on the same cadence: the
341
- hook's clock and the plan's cooldown have to be the same number, or the hook
342
- blocks a turn and the plan hands back `questions_needed: 0`. Under `interleaved`
343
- the Stop sweep floors its gap at one minute, because there the clock is the whole
344
- loop guard and `0` is a legal value for the checkpoint it borrows.
345
- `max_questions_per_task` (default 4) is a session allowance shared by both hooks:
346
- every `attempts` row spends it, so the Stop hook asks for whatever the checkpoints
347
- left. The two hooks' candidate queries share a WHERE clause verbatim; if you
348
- change one, change both, or a concept gets asked twice or never.
349
-
350
- ## The SessionStart banner is a format string
351
-
352
- The `[Eklavya] ...` line in `mcp/src/hooks/session-start.ts` is quoted verbatim
353
- by `web/public/index.html` (the hero terminal script) and
354
- `web/src/content/docs/docs/installing.mdx` (the no-history variant). Changing its
355
- wording, order or fields means re-quoting both in the same commit.
356
-
357
- ## Testing a hook by hand
358
-
359
- `dist/` must exist first — the tests run the built files, not the sources, and
360
- `pretest` builds them. From `mcp/`:
361
-
362
- ```sh
363
- npm run build
364
- echo '{"session_id":"s1","cwd":"/path/to/repo","hook_event_name":"Stop"}' \
365
- | EKLAVYA_DB=/tmp/k.db EKLAVYA_HOME=/tmp/eklavya-home \
366
- node dist/hooks/stop-quiz-check.js; echo "exit $?"
367
- ```
368
-
369
- `EKLAVYA_DB` and `EKLAVYA_HOME` point a hook at a scratch database and config;
370
- `EKLAVYA_SESSION_ID` overrides the harness session id. `mcp/test/hooks.test.ts`
371
- and `mcp/test/gate.test.ts` do exactly this.
3
+ `hooks.json` registers events; `run.mjs` resolves the runtime and dispatches into
4
+ `mcp/src/hooks/`. Read the root contract and `mcp/CLAUDE.md` before changing the
5
+ TypeScript implementation. Hook schemas and observed host behavior are recorded
6
+ in `docs/verified-schemas.md`.
7
+
8
+ ## Event map
9
+
10
+ Seven implementations are registered across six events. The checkpoint has two
11
+ registrations, for eight rows total.
12
+
13
+ | Event | Implementation | Responsibility |
14
+ |---|---|---|
15
+ | SessionStart | `session-start` | Set the checkout session pointer; migrate legacy config; replay, summarize and recall memory; start a due background update; show profile/status and supply the log directive |
16
+ | UserPromptSubmit | `prompt-submit-nudge` | Refresh the session pointer, capture the prompt, recall relevant memory and nudge a session that has not logged concepts |
17
+ | SubagentStart | `subagent-start` | Ask implementers to log, without asking questions; exempt the tutor |
18
+ | PreToolUse (`Bash`) | `pre-tool-gate` | Deny recognized commits when the enforced session gate has not passed |
19
+ | PostToolUse (all tools) | `capture-tool` | Record one memory event; no quiz, summarization or provider call |
20
+ | PostToolUse (`mcp__.*log_session_concepts`) | `checkpoint-quiz` | Ask a due interleaved question after concepts are logged |
21
+ | PostToolUse (`^(Bash|Edit|Write|MultiEdit|NotebookEdit)$`) | `checkpoint-quiz` | Recheck pacing as work continues; no spinner on every tool call |
22
+ | Stop | `stop-quiz-check` | Flush the memory seam and request the remaining eligible quiz |
23
+
24
+ The MCP matcher accepts both standalone and plugin-scoped names. Do not narrow
25
+ it to one prefix. Keep the work-tool regex anchored. Hook timeouts are 10 seconds,
26
+ except Stop at 15 seconds; consult the manifest before changing them.
27
+
28
+ ## Failures and latency
29
+
30
+ Every hook body runs inside `await run(async (input) => { ... })` and returns 0.
31
+ It never calls `process.exit` itself. `run()` catches failures; `openExisting()`
32
+ returns `null` for an unavailable database and never migrates or seeds it.
33
+ Stop continues a turn with JSON `additionalContext`, not exit 2.
34
+
35
+ Use the shared bounded reader in `mcp/src/stdin.ts`: reset the idle timer on
36
+ each chunk, retain the total cap, handle errors, strip BOM, unref timers and
37
+ pause the stream when finished. Removing listeners alone leaves stdin alive.
38
+ `HOOK_STDIN` is 2s idle / 5s total; `STATUSLINE_STDIN` is 150ms / 250ms. The total
39
+ cap can truncate a slow payload; on a swallowed pipe each hook can cost the idle
40
+ timeout. `test/stdin.test.ts` checks bounds and processes with stdin left open.
41
+
42
+ `EKLAVYA_INTERNAL_OBSERVER=1` must make both `run.mjs` and `run()` exit before
43
+ reading stdin, opening the database or spawning work. Never rely only on the
44
+ host's hook-disabling flag: recursive observer workers have occurred despite it.
45
+
46
+ ## Separate memory from questions
47
+
48
+ Memory runs before the `quiz.enabled` check. Disabling questions does not disable
49
+ history. Capture uses the lightweight `capture-lib.ts` path; importing worker,
50
+ provider, recall or notification modules there violates `hook-isolation.test.ts`.
51
+
52
+ Stop closes a batch at `SEAM_MIN_EVENTS` (8) or `SEAM_MAX_AGE_MS` (20 minutes);
53
+ SessionStart closes remaining work except on compaction, which uses the ordinary seam thresholds. Retention runs at most every six hours in
54
+ 5,000-event chunks. Notification sinks share a 5s budget. Read these constants
55
+ from source when changing or documenting them.
56
+
57
+ An observer batch is queued for a detached `eklavya memory process`, only after
58
+ winning the machine-wide reservation and only while the queue is unpaused.
59
+ Never wait on inference in a hook. `capture-tool` records subagent evidence;
60
+ prompt and Stop memory seams are parent-only.
61
+
62
+ ## Output channels and delegation
63
+
64
+ Put developer messages in top-level `systemMessage`, model instructions in
65
+ `hookSpecificOutput.additionalContext`, and the event name in `hookEventName`.
66
+ Plain stdout is not a user-visible banner. Stop's context is also rendered by
67
+ the host, so keep it short and let the planner carry pedagogy details.
68
+
69
+ `SubagentStart` requires the JSON envelope. Check `quiz.enabled`, honor a
70
+ session's silence if a database exists, and continue on a missing database.
71
+ Match `eklavya-tutor` as a substring to cover namespaced and bare names. Unknown
72
+ `agent_type` receives the directive. The tutor exemption matters because the
73
+ implementer's directive says not to ask questions.
74
+
75
+ Both checkpoint and Stop return on `agent_id`; retain both guards even if the
76
+ host appears to deliver Stop only to parents. See `docs/subagent-policy.md`.
77
+
78
+ ## Pacing, silence and gates
79
+
80
+ `quiet` hides the banner and status bar, not model directives or quizzes.
81
+ `quiz.enabled: false` disables questions. Session-scoped silence disables that
82
+ session's questions but never bypasses an enforced commit gate; `pre-tool-gate`
83
+ reads it only to explain how to resume and clear the gate.
84
+
85
+ Unenforced sessions can still receive Stop questions. Enforcement skips cooldown,
86
+ lifts the planner's interleaved cap and adds commit checks. It does not override
87
+ the developer's session silence.
88
+
89
+ | Cadence | Pacing and loop guard |
90
+ |---|---|
91
+ | `interleaved` | `min_minutes_between_checkpoints` (default 4) paces single questions; Stop floors its gap at one minute. Time can re-arm Stop without new logged work. |
92
+ | `end` | `min_minutes_between_quizzes` (default 20) paces the full sweep; work-origin concept count must grow since the previous block. |
93
+
94
+ Hooks and planner must use the same clock, checking both the last block and
95
+ last answer. Stamp `stop_markers` or `checkpoints` before emitting. The Stop
96
+ block cap (default 3) and remaining session question budget also bound repeats.
97
+ Every attempt consumes the shared `max_questions_per_task` allowance (default 4).
98
+ Review-origin concepts cannot re-arm the work-count guard. Change matching
99
+ candidate predicates in both hooks together.
100
+
101
+ The shell lexer in `commit-lib.ts` recognizes common wrappers and nested shell
102
+ forms, with deliberate misses for aliases, variables and scripts. The optional
103
+ git hook covers terminal commits; do not describe the Bash detector as complete.
104
+
105
+ ## Documentation and checks
106
+
107
+ Update the affected manual pages in the same PR: `first-session`, `dials`,
108
+ `how-it-works`, `memory`, `commit-gate` and `troubleshooting`. Keep the timeline
109
+ and flow labels aligned with ordering and guards. Schema/delegation changes also
110
+ update `docs/verified-schemas.md` and `docs/subagent-policy.md`.
111
+
112
+ When changing banner or checkpoint wording, search `web/public/index.html` and
113
+ manual examples for quoted output. Do not preserve a transcript that no longer
114
+ matches the source.
115
+
116
+ Run `npm test -- hooks gate stdin hook-isolation memory-observer-guard` from
117
+ `mcp/`; `pretest` builds the files the hook suites execute. Use a temporary
118
+ `EKLAVYA_HOME` and `EKLAVYA_DB` for manual probes. Behavior changes also need the
119
+ live checkpoint acceptance check in `CONTRIBUTING.md`; unit tests cannot prove
120
+ that the model asks a question and resumes work.