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.
- package/README.md +48 -42
- package/dist/artifacts.js +2 -2
- package/dist/artifacts.js.map +1 -1
- package/dist/assets/artifact-template.html +2 -2
- package/dist/assets/dashboard.html +12 -9
- package/dist/assets/tokens.css +6 -4
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/explainer.md +5 -3
- package/dist/plugin/cli/CLAUDE.md +60 -108
- package/dist/plugin/hooks/CLAUDE.md +118 -369
- package/dist/plugin/skills/CLAUDE.md +106 -298
- package/dist/user-skill/eklavya-artifacts/SKILL.md +23 -4
- package/package.json +1 -1
|
@@ -1,371 +1,120 @@
|
|
|
1
1
|
# Working in `hooks/`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|