bantamkit-mcp 0.35.2 → 0.35.4

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.
@@ -0,0 +1,2009 @@
1
+ /**
2
+ * The Claude Code HOOK adapter. One module, every event, dispatched on `hook_event_name`.
3
+ *
4
+ * MOVED HERE 2026-09-20 (job61, J61-2) FROM `tools/hooks/bantamkit-hook.mjs`, AND THE MOVE IS
5
+ * THE POINT. The adapter worked and was unreachable from the product: `tools/` is not in the
6
+ * npm package. Measured at 0.35.3 with `npm pack --dry-run` — 174 files in the tarball,
7
+ * `package.json` declaring `files: ["dist","assets"]`, and zero entries matching `hook`. So an
8
+ * operator who installed bantamkit the only way it is published (`npx bantamkit-mcp`) had no
9
+ * adapter on disk at all, and `docs/hooks.md`'s registration line could not be written without
10
+ * a checkout. Living in `src/` puts it in `dist/`, which ships, and `bantamkit-mcp --hook` is
11
+ * the surface that reaches it.
12
+ *
13
+ * `src/` IS TYPESCRIPT, WHICH IS WHY THIS IS A PORT AND NOT A COPY. `tsconfig.json` sets
14
+ * `include: ["src"]` and no `allowJs`, so a `.mjs` dropped into `src/` would be neither
15
+ * compiled nor copied to `dist/` and would still not ship. There is exactly ONE adapter after
16
+ * this change: `tools/hooks/bantamkit-hook.mjs` is now a four-line shim onto this module, so
17
+ * the registration in every operator's `~/.claude/settings.json` keeps working and there is no
18
+ * second copy to drift.
19
+ *
20
+ * WHY THIS EXISTS AT ALL. Measured 2026-08-27 over the host's own MCP logs, last 7 days: 103
21
+ * bantamkit connections, 8 `memory_recall` calls, 3 of them outside this repo. An MCP
22
+ * server is PASSIVE — nothing in the host invokes a tool the model did not decide to call,
23
+ * and the model does not decide to call a recall whose answer already rides free in the
24
+ * system prompt. Hooks are the only deterministic channel the host offers, so the
25
+ * automatic half of the toolbox — recall before the turn, refuse a repeat read, nudge a
26
+ * save before the session ends, compact the index when it is nearly full — lives HERE,
27
+ * at the host boundary, and the MCP tools stay what the model calls when it wants more.
28
+ *
29
+ * THE THREE PROPERTIES, same as the statusline adapter beside it:
30
+ * 1. CHEAP. Loads the memory layer in-process; never starts an MCP server.
31
+ * 2. NEVER LOUD. Every arm ends in exit 0. A hook that throws would be rendered by the
32
+ * host as an error on the user's screen, so every failure is LOGGED and swallowed.
33
+ * 3. MEASURED. Every decision appends one line to `~/.bantamkit/hooks/hook-log.jsonl`
34
+ * (event, action, bytes injected, ms) so the claim "it fires and it is cheap" is a
35
+ * number the user can rerun, not a sentence.
36
+ *
37
+ * THE CONTRACT THE FLAG PINS, and it is deliberately narrow so both runtimes can be compared:
38
+ * one JSON object in on stdin, AT MOST ONE JSON object out on stdout, exit 0 ALWAYS.
39
+ *
40
+ * Registration (user scope, `~/.claude/settings.json`): see `docs/hooks.md`.
41
+ * Pure Node, POSIX + Windows; nothing here shells Python.
42
+ */
43
+ import { spawn, spawnSync } from 'node:child_process';
44
+ import { createHash } from 'node:crypto';
45
+ import fs from 'node:fs';
46
+ import os from 'node:os';
47
+ import path from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+ import { Memory } from './memory/component.js';
50
+ import { discoverProjectStore, resolveProjectStore } from './memory/layers.js';
51
+ import { DURABLE_TYPES, MemoryStore, pyEqualValue, pyText, tokens } from './memory/store.js';
52
+ import { keptManifest, keptPrefix } from './npminstall.js';
53
+ import { decide as decideUpdate, KEY as UPDATE_KEY, loadRecord, PROGRAM as UPDATE_PROGRAM, recordPath, STATE_AVAILABLE, } from './updatecheck.js';
54
+ /**
55
+ * The directory `p` names, as the KERNEL names it. `realpathSync.native`, not `realpathSync`.
56
+ *
57
+ * Added 2026-09-11 (J47-7); the mechanism is J47-3B's (`6766033`) and the reason is the same.
58
+ * `fs.realpathSync` hands its argument to `path.resolve` before it resolves anything, and
59
+ * `path.resolve` pops `..` LEXICALLY — before the symlink in front of it has been followed —
60
+ * so it throws `ENOENT` on a directory that is there. `realpathSync.native` is libuv's
61
+ * `uv_fs_realpath`, the platform's own `realpath(3)` / `GetFinalPathNameByHandle`: it pops
62
+ * `..` in the kernel's order, agrees with `os.path.realpath` byte for byte, and keeps the
63
+ * macOS `/var` vs `/private/var` resolution every comparison in this file was written for.
64
+ * `statSync` identity (`dev` + `ino`) was the other candidate and was not taken, for J47-3B's
65
+ * reason: `ino` is not dependable on every Windows filesystem, and this file runs there too.
66
+ *
67
+ * A path that is genuinely not on disk cannot be realpathed at all, and `path.resolve` is the
68
+ * fallback — it can only under-report a match, never invent one.
69
+ */
70
+ function realDir(p) {
71
+ try {
72
+ return fs.realpathSync.native(p);
73
+ }
74
+ catch {
75
+ return path.resolve(p);
76
+ }
77
+ }
78
+ const T0 = Date.now();
79
+ // HOME IS RESOLVED, NOT TAKEN AS SPELLED (2026-09-11, J47-7). `os.homedir()` hands back
80
+ // `$HOME` verbatim, and every `path.join` below pops a `..` inside it LEXICALLY. Measured on
81
+ // J47-3B's bed (`link -> <bed>/deep/real`, `HOME=<bed>/link/..`, the real home being
82
+ // `<bed>/deep`): `PROFILE` came out `<bed>/.bantamkit/memory`, a directory nothing had
83
+ // created, so the single-layer guard at the bottom of this file could not answer `true` no
84
+ // matter what predicate it used — and `STATE` put this hook's own log OUTSIDE the home. One
85
+ // kernel-order resolution here fixes both, and for a `HOME` with no symlink and no `..` it
86
+ // is the identity.
87
+ //
88
+ // IT IS READ PER RUN, NOT AT MODULE LOAD. `--hook` is one short-lived process, so the two are
89
+ // the same in production; but `HOME` is also the seam every test in `hooks.test.mjs` and
90
+ // `hookadapter.test.mjs` points at a scratch directory, and a module-level constant would
91
+ // freeze whatever the FIRST import saw. `runHook()` takes the reading once, at the top.
92
+ function homeDir() {
93
+ return realDir(os.homedir());
94
+ }
95
+ /** This module's own directory: `<package>/dist` in an install, `runtime-ts/dist` in a checkout. */
96
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
97
+ /** The built memory layer, beside this module. Was `<repo>/runtime-ts/dist/memory`. */
98
+ const DIST = path.join(HERE, 'memory');
99
+ // Budgets, in BYTES of injected context. SessionStart is paid once; UserPromptSubmit is
100
+ // paid on every later call of the session, which is why it is the small one.
101
+ const SESSION_INJECT_MAX = 3000;
102
+ const PROMPT_INJECT_MAX = 700;
103
+ const PROMPT_MIN_CHARS = 12;
104
+ const STOP_NUDGE_MIN_TOOL_CALLS = 20;
105
+ const COMPACT_AT = 0.9; // index >= 90% of budget → compact …
106
+ const COMPACT_TO = 0.8; // … down to 80%, past the no-op band measured in job40 (C6)
107
+ //
108
+ // AMENDED 2026-09-10 (job46, J46-6). The half-sentence "past the no-op band measured in
109
+ // job40 (C6)" is a record of a defect that is now CLOSED, and it stays because it is why
110
+ // this number exists at all. `docs/porting.md` register item 7 was that band: the degraded
111
+ // report warned at COMPACT_AT and named `compact`, while `compact`'s default target sat at
112
+ // `budget - largest index line`, so between the two the command archived nothing. Both
113
+ // runtimes closed it in job46 (`87cc1f7`, `55575c3`) by measuring the default reserve from
114
+ // the warning line instead of from the budget.
115
+ //
116
+ // COMPACT_TO STAYS, and NOT because the workaround is harmless — it is not. What it was
117
+ // doing was naming a budget this store does not have (0.8 * budget) so that `compact` would
118
+ // aim below the real one. Now that `compact` derives its own floor FROM the budget it is
119
+ // given, that lie COMPOUNDS: the aim became 0.9 * (0.8 * budget) - largest line. MEASURED on
120
+ // a read-only copy of this machine's project store (101 facts, 21819 bytes, largest index
121
+ // line 361, budget 24000), one auto-compaction:
122
+ //
123
+ // before job46, `--budget 19200`, default reserve : target 18839, archived 15
124
+ // after job46, `--budget 19200`, default reserve : target 16918, archived 23
125
+ // today, `--budget 24000 --reserve 4800` : target 19200, archived 13
126
+ //
127
+ // So the arm was quietly archiving eight more of the user's facts per fire than the 80% it
128
+ // advertises, and landing 2379 bytes below the number its own message prints. The fix is not
129
+ // to drop the aim — 80% is the HYSTERESIS that keeps this arm from re-firing on the next
130
+ // save, which matters more now that the default target sits just under the warning line — it
131
+ // is to ask for the aim in the argument that MEANS it. `--reserve` is the documented escape
132
+ // hatch from the new floor (both runtimes, both conformance suites), so the hook now names
133
+ // the real budget and the headroom it wants, and gets `budget - reserve` exactly. That is
134
+ // also why this is the durable spelling: it cannot be moved again by a future change to the
135
+ // DEFAULT reserve, which is exactly what moved it this time.
136
+ // PreCompact's steering, in BYTES. This string is paid TWICE: once as the summariser's
137
+ // `newCustomInstructions`, and once echoed onto the user's screen as
138
+ // `PreCompact [<command>] completed successfully: <output>` — the same bytes are both the
139
+ // steering and the display, and there is no quieter variant (see the channel note below).
140
+ // Unbounded, the arm's own worst case is PRECOMPACT_FILES_MAX absolute paths, and a single
141
+ // malformed unit title put 35,315 B on the user's screen in one probe. 4000 B is the bound:
142
+ // a third more than SESSION_INJECT_MAX, which buys the checkpoint line and the tail on top
143
+ // of a useful slice of the list, and it is paid per compaction rather than per prompt. The
144
+ // FIXED lines are never cut; the file list absorbs the whole trim.
145
+ const PRECOMPACT_STDOUT_MAX = 4000;
146
+ const PRECOMPACT_FILES_MAX = 40;
147
+ const CHECKPOINT_LINE_MAX = 600; // one checkpoint line: an absolute path, a cursor, a title
148
+ // The host kills the WHOLE hook at 10 s (`timeout: 10` in the registration), so the child's
149
+ // bound has to sit under that. A measured consolidation of the 121 facts on this machine
150
+ // took 72 ms, so the headroom is two orders of magnitude.
151
+ //
152
+ // THE ENV OVERRIDE IS A TEST SEAM AND NOTHING ELSE. Without it the timeout is a constant no
153
+ // test can reach, and this repo has been bitten twice by arithmetic in this file that drifted
154
+ // because nothing ran it. With it, `hooks.test.mjs` sets a 1 ms bound and exercises a REAL
155
+ // SIGTERM kill of a real child, which is the only way to show that a slow pass degrades to a
156
+ // log line instead of eating the session.
157
+ function dreamTimeoutMs() {
158
+ return Number(process.env['BANTAMKIT_DREAM_TIMEOUT_MS']) || 8000;
159
+ }
160
+ function newRun() {
161
+ const home = homeDir();
162
+ const state = path.join(home, '.bantamkit', 'hooks');
163
+ return {
164
+ home,
165
+ state,
166
+ log: path.join(state, 'hook-log.jsonl'),
167
+ profile: path.join(home, '.bantamkit', 'memory'),
168
+ dreamState: path.join(state, 'dream-state.json'),
169
+ storeScope: null,
170
+ };
171
+ }
172
+ function log(run, record) {
173
+ try {
174
+ fs.mkdirSync(run.state, { recursive: true });
175
+ fs.appendFileSync(run.log, `${JSON.stringify({ ts: new Date().toISOString(), ms: Date.now() - T0, ...record })}\n`);
176
+ }
177
+ catch {
178
+ /* logging is best-effort */
179
+ }
180
+ }
181
+ function emit(obj) {
182
+ process.stdout.write(JSON.stringify(obj));
183
+ }
184
+ /**
185
+ * PLAIN stdout, for the events whose steering the host reads as text rather than as the
186
+ * `hookSpecificOutput` envelope. See `preCompact` for why that distinction is not cosmetic.
187
+ *
188
+ * It used to carry a leading-`{` refusal here. That branch could not fire — every part
189
+ * `preCompact` assembles begins with one of three constant prefixes — and had it ever
190
+ * fired it would have DROPPED the whole steering with no trace, which is the exact failure
191
+ * this arm exists to have fixed. The channel contract it was guarding ("output that does not
192
+ * start with `{` is taken as plain text") is now pinned where it can actually go red: the
193
+ * `hooks.test.mjs` case "the steering never starts with { — for every shape of input the
194
+ * arm accepts", which drives user-controlled strings (a `{braces}` path, a `{odd}.json`
195
+ * checkpoint, a `{C}` cursor) through the real dispatch and judges stdout the way the host
196
+ * does. Returns the number of bytes ACTUALLY written, so the caller's log line cannot claim
197
+ * bytes that never left the process.
198
+ */
199
+ function emitText(text) {
200
+ const out = String(text).trim();
201
+ if (!out)
202
+ return 0;
203
+ process.stdout.write(out);
204
+ return Buffer.byteLength(out);
205
+ }
206
+ /** Truncate to at most `max` BYTES without splitting a UTF-8 character. */
207
+ function capBytes(text, max) {
208
+ const buf = Buffer.from(String(text), 'utf8');
209
+ if (buf.length <= max)
210
+ return String(text);
211
+ let end = max;
212
+ while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80)
213
+ end -= 1; // back off a continuation byte
214
+ return buf.subarray(0, end).toString('utf8');
215
+ }
216
+ function capLines(text, max) {
217
+ if (Buffer.byteLength(text) <= max)
218
+ return text;
219
+ const out = [];
220
+ let size = 0;
221
+ for (const line of text.split('\n')) {
222
+ const n = Buffer.byteLength(line) + 1;
223
+ if (size + n > max)
224
+ break;
225
+ out.push(line);
226
+ size += n;
227
+ }
228
+ return out.join('\n');
229
+ }
230
+ function ledgerPath(run, sessionId) {
231
+ return path.join(run.state, `ledger-${String(sessionId || 'nosession').replace(/[^\w-]/g, '_')}.json`);
232
+ }
233
+ function readLedger(run, sessionId) {
234
+ try {
235
+ const parsed = JSON.parse(fs.readFileSync(ledgerPath(run, sessionId), 'utf8'));
236
+ if (!parsed || typeof parsed !== 'object')
237
+ return { reads: {} };
238
+ if (!parsed.reads || typeof parsed.reads !== 'object')
239
+ parsed.reads = {};
240
+ return parsed;
241
+ }
242
+ catch {
243
+ return { reads: {} };
244
+ }
245
+ }
246
+ function writeLedger(run, sessionId, ledger) {
247
+ fs.mkdirSync(run.state, { recursive: true });
248
+ fs.writeFileSync(ledgerPath(run, sessionId), JSON.stringify(ledger));
249
+ }
250
+ // ------------------------------------- the host's auto-memory directory, and A's export
251
+ //
252
+ // WHAT THIS REPLACED, AND WHY IT HAD TO GO (2026-09-20, job62 / J62-6). Until this change the
253
+ // question "does the host have an auto-memory store here" was answered by
254
+ //
255
+ // const slug = cwd.replace(/[\\/:]/g, '-');
256
+ // fs.existsSync(path.join(run.home, '.claude', 'projects', slug, 'memory', 'MEMORY.md'))
257
+ //
258
+ // — an incomplete reimplementation of Claude Code's own slug, on the hook path. S0
259
+ // (`.shiftwork/notes-job62/S0-prep-probe.md`) dumped the real resolver out of `2.1.278`:
260
+ //
261
+ // resolveEntry() = CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
262
+ // ?? autoMemoryDirectory from policySettings, flagSettings,
263
+ // [localSettings, projectSettings], userSettings (in that order)
264
+ // ?? defaultPath()
265
+ // defaultPath() = join(<root>, "projects", ok(gitRoot(projectRoot) ?? projectRoot), "memory")
266
+ // ok(e) = k(e).length <= 200 ? k(e) : k(e).slice(0,200) + "-" + base36(hash(e))
267
+ //
268
+ // Three things the old rule is missing, and each one is a wrong answer on a real machine: the
269
+ // 200-character cap with its base36 hash suffix; the four-branch precedence in front of
270
+ // `defaultPath` at all; and — the one that matters most here — the KEY, which is the
271
+ // canonicalized git WORKTREE ROOT and not the session cwd. Job62 itself runs inside a
272
+ // worktree, which is precisely the case the old rule gets wrong.
273
+ //
274
+ // The fix is not a better slug. RULING Q1.2/Q1.6 of `.shiftwork/notes-job62/S1-delivery-path.md`
275
+ // forbids reimplementing `oS`/`ok`/`Gr` at all, because re-deriving a four-branch resolver from
276
+ // a minified binary is the exact instruction/surface drift job60 built its gate to stop. A
277
+ // learns the directory by OBSERVATION WITH VERIFICATION instead, and when no branch answers it
278
+ // exports nothing rather than guessing (RULING Q1.3): a directory at a slug the host does not
279
+ // resolve to is a store nobody reads and nobody prunes.
280
+ //
281
+ // `native_store_root()` in `runtime-py/src/bantamkit/memory/divergence.py` is a DIFFERENT
282
+ // function, off the MCP path, and RULING Q1.6 leaves it exactly where it is.
283
+ /** The file whose presence makes a candidate directory an ANSWER rather than a guess. */
284
+ const NATIVE_INDEX = 'MEMORY.md';
285
+ /** The host's top-precedence branch, and the one on the MCP passthrough allowlist. */
286
+ const NATIVE_OVERRIDE_ENV = 'CLAUDE_COWORK_MEMORY_PATH_OVERRIDE';
287
+ /**
288
+ * The one heading in the host's index that A writes under.
289
+ *
290
+ * A owns this line and nothing else in the file. It does NOT sort its entries into the host's
291
+ * own sections, because the host's taxonomy is the host's to change and a writer that guesses
292
+ * at it has to keep guessing right forever. One heading, appended once, at the end.
293
+ */
294
+ const NATIVE_SECTION = '## bantamkit';
295
+ /** Names exported per hook run. The binding bound in practice; see `exportToNative`. */
296
+ const NATIVE_EXPORT_MAX = 10;
297
+ /**
298
+ * Bytes A may append to `MEMORY.md` per hook run.
299
+ *
300
+ * This is the budget that means anything, because these are the only bytes the export puts in
301
+ * front of the model: the host injects its index, and reads a fact file only when something
302
+ * asks for it. 2000 B is two thirds of `SESSION_INJECT_MAX`, paid at most once per session and
303
+ * — unlike an injection — never paid again for a name already there.
304
+ */
305
+ const NATIVE_INDEX_BYTES_MAX = 2000;
306
+ /**
307
+ * A fact name A is willing to join into a path.
308
+ *
309
+ * `MemoryStore.save` enforces `^[a-z0-9][a-z0-9-]*$`, but `facts()` does NOT revalidate on
310
+ * READ — measured 2026-09-20: a hand-written `facts/*.md` whose frontmatter says
311
+ * `name: ../escape` parses and is handed out with that name. So this guard is reachable from
312
+ * disk, and `nativeexport.test.mjs` drives it with exactly that file.
313
+ */
314
+ const NATIVE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
315
+ /** `p` is a regular file this process can read. Not "exists": a directory is not an index. */
316
+ function readableFile(p) {
317
+ try {
318
+ if (!fs.statSync(p).isFile())
319
+ return false;
320
+ fs.accessSync(p, fs.constants.R_OK);
321
+ return true;
322
+ }
323
+ catch {
324
+ return false;
325
+ }
326
+ }
327
+ /**
328
+ * `os.path.lexists`: something is AT this path, symlink or not.
329
+ *
330
+ * `existsSync` follows links, so a dangling symlink would read as absent and A would write
331
+ * THROUGH it into whatever it points at. A dangling link is an entry A did not create, and
332
+ * "absent" is the only condition under which A writes.
333
+ */
334
+ function lexists(p) {
335
+ try {
336
+ fs.lstatSync(p);
337
+ return true;
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ }
343
+ /**
344
+ * RULING Q1.2 — the four branches, first one that ANSWERS wins.
345
+ *
346
+ * 1. `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE`, non-empty → that value IS the directory, with no
347
+ * verification: it is the host's own top-precedence branch, so there is nothing above it
348
+ * that could overrule what A sees.
349
+ * 2. `autoMemoryDirectory` from `<home>/.claude/settings.json` ONLY. A CANDIDATE, never an
350
+ * answer: three higher-precedence sources exist in the host (`policySettings`,
351
+ * `flagSettings`, and the env var) that A cannot read, so the candidate is accepted only
352
+ * when it holds a readable `MEMORY.md`.
353
+ * 3. `dirname(transcript_path)/memory`, with the same `MEMORY.md` test. This is reading back
354
+ * the slug the HOST computed, which is the whole distinction invariant 5 is about.
355
+ * 4. UNKNOWN. No fallback slug, no mkdir.
356
+ *
357
+ * Branch 3 is why A is a rider on B and not a feature of its own (RULING Q1.4): only a hook
358
+ * receives `transcript_path`.
359
+ */
360
+ function resolveNativeMemory(run, input) {
361
+ const tried = [];
362
+ const override = String(process.env[NATIVE_OVERRIDE_ENV] ?? '').trim();
363
+ if (override)
364
+ return { dir: override, branch: 'env', tried };
365
+ tried.push('env');
366
+ try {
367
+ const raw = fs.readFileSync(path.join(run.home, '.claude', 'settings.json'), 'utf8');
368
+ const settings = JSON.parse(raw);
369
+ const configured = settings['autoMemoryDirectory'];
370
+ if (typeof configured === 'string' && configured.trim()) {
371
+ const dir = configured.trim();
372
+ if (readableFile(path.join(dir, NATIVE_INDEX)))
373
+ return { dir, branch: 'settings', tried };
374
+ }
375
+ }
376
+ catch {
377
+ /* no settings file, unreadable, or not JSON — all of them mean "this branch has no answer" */
378
+ }
379
+ tried.push('settings');
380
+ const transcript = String(input.transcript_path ?? '').trim();
381
+ if (transcript) {
382
+ const dir = path.join(path.dirname(transcript), 'memory');
383
+ if (readableFile(path.join(dir, NATIVE_INDEX)))
384
+ return { dir, branch: 'transcript', tried };
385
+ }
386
+ tried.push('transcript');
387
+ return { dir: null, branch: 'unknown', tried };
388
+ }
389
+ /**
390
+ * Whitespace collapsed to single spaces, then trimmed.
391
+ *
392
+ * THE CLASS IS SPELLED OUT rather than `\s`, and the Python half must spell it the same way:
393
+ * `\s` is not the same set in the two languages (JavaScript's includes NBSP, BOM and the
394
+ * Unicode separators; Python's `str` pattern includes its own list), so a description carrying
395
+ * one of those characters would be exported differently by the two runtimes — a divergence
396
+ * invented by a helper nobody would think to compare.
397
+ */
398
+ function collapseWhitespace(text) {
399
+ return String(text)
400
+ .replace(/[ \t\n\r\f\u000b]+/g, ' ')
401
+ .trim();
402
+ }
403
+ /**
404
+ * One exported file, in the host's own auto-memory shape: `name`, `description`,
405
+ * `metadata.type`, and a body.
406
+ *
407
+ * THE BODY IS NOT THE FACT'S BODY, and the unit is titled for it: A exports DESCRIPTIONS. The
408
+ * file is an entry that names where the body lives, for two reasons. The description is what
409
+ * the host injects; and copying a user's fact bodies into a store whose dream rewords and
410
+ * deletes them would be duplicating the user's data into a place bantamkit does not own.
411
+ *
412
+ * `metadata.source: bantamkit` is provenance, so an operator reading this directory can see at
413
+ * a glance which entries are exports. Nothing reads it back — RULING Q1.5 forbids A treating
414
+ * its own writes as state, and the test that deletes both halves and re-runs pins that.
415
+ *
416
+ * The description is a JSON string, which is a valid YAML 1.2 double-quoted scalar, so the
417
+ * escaping needs no YAML writer on either side. `json.dumps(desc, ensure_ascii=False)` agrees
418
+ * with `JSON.stringify` byte for byte on this input.
419
+ */
420
+ function nativeFactText(name, type, description) {
421
+ return [
422
+ '---',
423
+ `name: ${name}`,
424
+ `description: ${JSON.stringify(description)}`,
425
+ 'metadata:',
426
+ ' node_type: memory',
427
+ ` type: ${type}`,
428
+ ' source: bantamkit',
429
+ '---',
430
+ '',
431
+ "Exported from bantamkit's project memory store. The body of this fact is not here: call",
432
+ `\`mcp__bantamkit__memory_recall\` with the name \`${name}\` to read it.`,
433
+ '',
434
+ 'Written once, only because the name was absent from this directory. bantamkit never',
435
+ 'rewrites and never deletes an entry here, so whatever the host does to this file stands.',
436
+ '',
437
+ ].join('\n');
438
+ }
439
+ /** One index line, in the host's own index shape. The em dash is a raw U+2014. */
440
+ function nativeIndexLine(name, description) {
441
+ return `- [${name}](${name}.md) — ${description}\n`;
442
+ }
443
+ /**
444
+ * A — the export, one way and non-destructive (RULING Q1.5).
445
+ *
446
+ * WRITE A NAME ONLY WHEN IT IS ABSENT. The two halves are gated INDEPENDENTLY: the fact file
447
+ * is written when nothing is at its path, the index line is appended when `](<name>.md)` does
448
+ * not appear in the index. They are independent because job59 measured Claude Code's own dream
449
+ * rewording or removing 8 of 8 foreign index lines and deleting one file, so the two halves
450
+ * really do go missing separately, and A must do exactly the one thing that is missing.
451
+ *
452
+ * A NEVER READS BACK ITS OWN WRITES AS STATE. The presence check above is not "did we export
453
+ * this" — it is "is this name in the host's directory right now", which is the write-when-absent
454
+ * predicate itself. When the dream removes an entry, A puts it back next session; when the
455
+ * dream REWORDS one, A leaves it alone, because the name is still there. Any later unit that
456
+ * wants "have we exported X" must answer it from bantamkit's own store.
457
+ *
458
+ * A NEVER CREATES ANYTHING THE HOST WOULD NOT HAVE. No `mkdir`, and no `MEMORY.md`: when the
459
+ * index is absent (reachable only through branch 1, which accepts its directory unverified)
460
+ * the files are written and no index is conjured into existence.
461
+ *
462
+ * ORDER IS `SESSION_DROP_RULE`, the same order the session block keeps facts in, so a store
463
+ * larger than one run's budget exports its most durable and most recently used facts first and
464
+ * the rest on later sessions. A failed write is caught, recorded and ends the run: an arm whose
465
+ * whole posture is exit 0 does not get to throw, and retrying every remaining name against a
466
+ * directory that just refused one is spending syscalls to learn the same thing again.
467
+ */
468
+ function exportToNative(dir, store) {
469
+ const result = {
470
+ exported: 0,
471
+ files: 0,
472
+ indexLines: 0,
473
+ bytes: 0,
474
+ indexBytes: 0,
475
+ skipped: [],
476
+ index: 'absent',
477
+ };
478
+ const indexFile = path.join(dir, NATIVE_INDEX);
479
+ let seeded = '';
480
+ if (readableFile(indexFile)) {
481
+ try {
482
+ seeded = fs.readFileSync(indexFile, 'utf8');
483
+ result.index = 'present';
484
+ }
485
+ catch {
486
+ /* readable a moment ago, not now: treat it as absent rather than as empty */
487
+ }
488
+ }
489
+ let named = seeded;
490
+ const appended = [];
491
+ try {
492
+ const { facts, indexLine } = new MemoryStore(store).internals();
493
+ const ranked = rankBySessionDropRule(facts().map((fact, position) => ({
494
+ fact,
495
+ position,
496
+ line: indexLine(fact),
497
+ name: pyText(fact.name),
498
+ })));
499
+ for (const f of ranked) {
500
+ if (result.exported >= NATIVE_EXPORT_MAX)
501
+ break;
502
+ const name = f.name;
503
+ if (!NATIVE_NAME_RE.test(name) || name === 'MEMORY') {
504
+ result.skipped.push(name);
505
+ continue;
506
+ }
507
+ const file = path.join(dir, `${name}.md`);
508
+ const needFile = !lexists(file);
509
+ const needLine = result.index === 'present' && !named.includes(`](${name}.md)`);
510
+ if (!needFile && !needLine)
511
+ continue;
512
+ const description = collapseWhitespace(f.fact.description == null ? '' : pyText(f.fact.description));
513
+ const line = needLine ? nativeIndexLine(name, description) : '';
514
+ if (result.indexBytes + Buffer.byteLength(line) > NATIVE_INDEX_BYTES_MAX)
515
+ break;
516
+ if (needFile) {
517
+ const text = nativeFactText(name, pyText(f.fact.type), description);
518
+ fs.writeFileSync(file, text, { flag: 'wx' });
519
+ result.files += 1;
520
+ result.bytes += Buffer.byteLength(text);
521
+ }
522
+ if (needLine) {
523
+ appended.push(line);
524
+ named += line;
525
+ result.indexLines += 1;
526
+ result.indexBytes += Buffer.byteLength(line);
527
+ result.bytes += Buffer.byteLength(line);
528
+ }
529
+ result.exported += 1;
530
+ }
531
+ if (appended.length) {
532
+ // The heading is emitted only when the file does not already carry it as a whole LINE,
533
+ // so a second session appends under the first session's heading. If the host later moves
534
+ // that heading, A's later lines land at the end of the file rather than under it — a
535
+ // cosmetic limit, accepted, because the alternative is A rewriting the host's index.
536
+ const separator = seeded.endsWith('\n') ? '' : '\n';
537
+ const heading = seeded.split('\n').includes(NATIVE_SECTION) ? '' : `\n${NATIVE_SECTION}\n\n`;
538
+ fs.appendFileSync(indexFile, `${separator}${heading}${appended.join('')}`);
539
+ }
540
+ }
541
+ catch (e) {
542
+ result.error = message(e);
543
+ }
544
+ return result;
545
+ }
546
+ // The rule a capped session block keeps facts by, in the words the block, the log and
547
+ // `docs/hooks.md` all quote. Until J50-2E (2026-09-12) the block was `capLines` over the
548
+ // store's index text, so what was dropped was whatever sorted LAST in the index — the
549
+ // alphabet, not any property of the fact. Reproduced on this machine's 20-fact profile
550
+ // store: the header said 20, the body carried 15, and the two facts the user had written
551
+ // to say that every job ends measured and that verification comes from a run were among
552
+ // the five nobody was told about. The rule below is the store's own eviction order
553
+ // (`MemoryStore.byEviction`) read backwards — the facts `compact` would archive LAST are the
554
+ // ones a session should see FIRST — so the hook invents no new notion of worth: durable
555
+ // types (`DURABLE_TYPES`) before decaying ones, then the most recent evidence of use
556
+ // (`last_recalled`, falling back to `created`), then name. Inside one class the date is
557
+ // the ONLY signal a fact carries on disk, and `last_recalled` is stamped by the recall path
558
+ // before its own byte cap (J49-B3), so this is a stated rule, not a claim of importance.
559
+ const SESSION_DROP_RULE = 'durable types first, then most recently recalled (else created) first, then name';
560
+ /**
561
+ * The index of `root` as ONE capped block: a header whose number is the number of fact
562
+ * lines IN the block, the fact lines that fit under `max` bytes, and — only when something
563
+ * did not fit — one disclosure line saying how many are missing and where they are named.
564
+ *
565
+ * Selection walks the facts in `SESSION_DROP_RULE` order and keeps each one whose whole
566
+ * index line still fits; a line that does not fit is skipped, never split, and never a
567
+ * barrier for a shorter one after it. The kept lines are then shown in the index's own
568
+ * order, so a block that lost nothing is byte-for-byte the index text, as before. The lines
569
+ * come from `MemoryStore.internals().indexLine`, the same function `indexText` joins, so
570
+ * the block never says a fact differently from the index the runtime would write.
571
+ *
572
+ * `header(count)` is handed `"15 of 20"` when something was dropped and `"20"` when not, so
573
+ * a reader who sees a bare number knows the block is whole.
574
+ */
575
+ /**
576
+ * `SESSION_DROP_RULE` as a sort, extracted 2026-09-20 (J62-6) so that the session block and
577
+ * A's export cannot come to disagree about which facts matter most.
578
+ *
579
+ * It was inline in `cappedIndex` and had exactly one caller; the export is the second, and a
580
+ * second copy of a four-key comparator is how "the same rule" stops being the same rule.
581
+ */
582
+ function rankBySessionDropRule(all) {
583
+ const text = (value) => (value == null ? '' : pyText(value));
584
+ const decays = (f) => DURABLE_TYPES.some((durable) => pyEqualValue(f.fact.type, durable)) ? 0 : 1;
585
+ const evidence = (f) => text(f.fact.last_recalled) || text(f.fact.created);
586
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
587
+ return [...all].sort((a, b) => decays(a) - decays(b) || cmp(evidence(b), evidence(a)) || cmp(a.name, b.name) || a.position - b.position);
588
+ }
589
+ function cappedIndex(root, header, max) {
590
+ const { facts, indexLine } = new MemoryStore(root).internals();
591
+ const all = facts().map((fact, position) => ({
592
+ fact,
593
+ position,
594
+ line: indexLine(fact),
595
+ name: pyText(fact.name),
596
+ }));
597
+ const ranked = rankBySessionDropRule(all);
598
+ const kept = new Set();
599
+ let size = 0;
600
+ for (const f of ranked) {
601
+ const n = Buffer.byteLength(f.line);
602
+ if (size + n > max)
603
+ continue;
604
+ kept.add(f);
605
+ size += n;
606
+ }
607
+ const shown = all.filter((f) => kept.has(f));
608
+ const dropped = ranked.filter((f) => !kept.has(f)).map((f) => f.name);
609
+ const body = capLines(shown
610
+ .map((f) => f.line)
611
+ .join('')
612
+ .replace(/\n$/, ''), max);
613
+ const lines = [header(dropped.length ? `${shown.length} of ${all.length}` : String(all.length))];
614
+ if (body)
615
+ lines.push(body);
616
+ if (dropped.length) {
617
+ lines.push(`[${dropped.length} of ${all.length} not shown — the block is capped at ${max} bytes; kept by rule: ${SESSION_DROP_RULE}; ~/.bantamkit/hooks/hook-log.jsonl names the dropped; mcp__bantamkit__memory_recall reads any fact by name]`);
618
+ }
619
+ return { block: lines.join('\n'), total: all.length, injected: shown.length, dropped };
620
+ }
621
+ function countFacts(store) {
622
+ try {
623
+ return fs.readdirSync(path.join(store, 'facts')).filter((n) => n.endsWith('.md')).length;
624
+ }
625
+ catch {
626
+ return 0;
627
+ }
628
+ }
629
+ // ------------------------------------------------------- the stale-install signal
630
+ // Reader 2 and Writer 1 of `docs/superpowers/specs/2026-09-19-stale-version-signal-design.md`.
631
+ // One line at SessionStart when the install this hook can SEE is older than what the package
632
+ // index serves, and a detached probe that keeps the record it reads from fresh.
633
+ //
634
+ // NOTHING ON THIS PATH REACHES THE NETWORK. The line is decided from one file on disk; the
635
+ // only thing here that asks a registry anything is `update-probe.mjs`, which is spawned
636
+ // `detached` with `stdio: 'ignore'` and never waited for. That is the whole design: measured
637
+ // 2026-09-19, one registry GET is 0.14-0.46 s on a good network and 10.0 s on a captive one,
638
+ // against a 0.09 s cold server boot, so a fetch anywhere on this function's path would be a
639
+ // visible pause at the top of every session for a fact that is not urgent.
640
+ /**
641
+ * The detached writer, which lives in the CHECKOUT and not in the package.
642
+ *
643
+ * `tools/hooks/update-probe.mjs` is 239 lines that no operator running `npx bantamkit-mcp`
644
+ * has on disk, for the same measured reason this module moved: `files: ["dist","assets"]`.
645
+ * Resolved relative to THIS module, `../../tools/hooks/update-probe.mjs` is the repo path in
646
+ * a checkout (`<repo>/runtime-ts/dist` → `<repo>/tools/hooks`) and is simply absent in an
647
+ * install — where `maybeProbe` already answers `'missing'` and the SessionStart arm is
648
+ * otherwise unchanged. That degradation is DELIBERATE and named rather than hidden: porting
649
+ * the probe into the package is a separate surface with its own network reach, and a unit
650
+ * that quietly gave an npx install a background registry fetch would be the opposite of the
651
+ * "defaults off" rule `--update` is registered under.
652
+ */
653
+ const UPDATE_PROBE = path.resolve(HERE, '..', '..', 'tools', 'hooks', 'update-probe.mjs');
654
+ /**
655
+ * At most one probe per 24 h, decided from the `checked_at` of the record already in hand.
656
+ *
657
+ * THE TTL LIVES HERE AND NOWHERE ELSE. Neither runtime's reader has one, deliberately: the
658
+ * comparison is between the version that is RUNNING and the version the record last saw, so an
659
+ * old record cannot manufacture a false "you are stale" — if the operator updated since,
660
+ * running >= recorded and the line goes quiet by itself. Freshness is the writer's problem, and
661
+ * this is the writer's side of the fence.
662
+ */
663
+ const UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
664
+ /**
665
+ * The line's byte budget, the same mechanism `SESSION_INJECT_MAX` and `PROMPT_INJECT_MAX` are:
666
+ * nothing this file injects is unbounded. The sentence itself is 137 bytes plus two version
667
+ * numbers; the one component that is not fixed is the operator's home path inside `{path}`, so
668
+ * the cap is generous and exists to bound that, not to trim the sentence.
669
+ */
670
+ const UPDATE_LINE_MAX = 500;
671
+ /**
672
+ * THE SENTENCE. Only in the stale state, silent in the other four.
673
+ *
674
+ * It is NOT `updatecheck.UPDATE_AVAILABLE` and must not be made into it: that one is the status
675
+ * tool's, prefixed `update:` and naming no path, because a caller of `bantamkit_status` already
676
+ * knows which endpoint answered it. This hook does not — the host may talk to any registered
677
+ * endpoint, and this line names the install it ACTUALLY compared so the claim can be checked.
678
+ * Same reason `_install_source_condition` is the one condition allowed to name a path.
679
+ *
680
+ * `{program}` is `updatecheck.PROGRAM` — the COMMAND, `bantamkit-mcp`, never a package name:
681
+ * the PyPI distribution is `bantamkit` and the npm package is `bantamkit-mcp`, and the command
682
+ * is the one word that is true on both sides.
683
+ *
684
+ * "then reconnect the host" is measured reason 1 in `selfupdate.py:13-20`: a running server
685
+ * keeps serving the code it loaded at startup, so an updated install does nothing for THIS
686
+ * session.
687
+ */
688
+ const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `{program} --update`, then reconnect the host.';
689
+ /** `{name}` substitution. A missing key is left alone rather than rendered `undefined`. */
690
+ function fillLine(template, values) {
691
+ return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
692
+ }
693
+ /**
694
+ * The kept install's `{path, version}`, or `null`.
695
+ *
696
+ * `<homedir>/.bantamkit/mcp/node_modules/bantamkit-mcp/package.json` — the install `--install`
697
+ * makes and a host launches. It is the only install this hook can see; the endpoint the host
698
+ * actually talks to may be another one entirely, which is exactly why the line names this path
699
+ * instead of claiming to speak for all of them. No manifest means no line: a machine with no
700
+ * kept install has nothing here to be stale.
701
+ *
702
+ * `keptPrefix`/`keptManifest` are IMPORTED rather than respelled, and that is the point:
703
+ * `npminstall` owns the kept install's paths and `updatecheck` owns the record path, the
704
+ * record key and the five-state decision. Writing any of those a second time here would be a
705
+ * second thing to keep in step with two runtimes. There is no comparator in this file.
706
+ */
707
+ function keptInstall(home) {
708
+ const manifest = keptManifest(keptPrefix(home));
709
+ try {
710
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
711
+ const version = parsed && typeof parsed.version === 'string' ? parsed.version.trim() : '';
712
+ return version === '' ? null : { path: manifest, version };
713
+ }
714
+ catch {
715
+ return null;
716
+ }
717
+ }
718
+ /**
719
+ * The line to inject (or `null`), and what was done about the record. Never throws.
720
+ *
721
+ * ORDER IS DELIBERATE: the record is read ONCE, the line is decided from it, and the probe
722
+ * decision is made from the SAME load. A second read would be a second answer.
723
+ */
724
+ function updateSignal(run) {
725
+ const file = recordPath(run.home);
726
+ const { source, record } = loadRecord(file);
727
+ let line = null;
728
+ let state = null;
729
+ const kept = keptInstall(run.home);
730
+ if (kept) {
731
+ // `updatecheck.KEY` is `npm` in this runtime, which is the registry the kept install came
732
+ // from — so the key the reader picks is right by construction, not by a choice made here.
733
+ const status = decideUpdate(kept.version, source, record, UPDATE_KEY);
734
+ state = status.state;
735
+ if (state === STATE_AVAILABLE) {
736
+ const entry = record ? record[UPDATE_KEY] : undefined;
737
+ const latest = entry ? String(entry.latest) : '';
738
+ line = capBytes(fillLine(UPDATE_LINE, {
739
+ program: UPDATE_PROGRAM,
740
+ installed: kept.version,
741
+ path: kept.path,
742
+ latest,
743
+ }), UPDATE_LINE_MAX);
744
+ }
745
+ }
746
+ return { line, state, probe: maybeProbe(run, record) };
747
+ }
748
+ /**
749
+ * Fork the probe and forget it, at most once per 24 h. Returns the word the log records.
750
+ *
751
+ * `detached: true` + `stdio: 'ignore'` + `.unref()` is the whole mechanism, and each third of
752
+ * it is load-bearing: `detached` puts the child in its own process group so the host reaping
753
+ * this hook does not reap it, `stdio: 'ignore'` means nothing it might ever print can reach the
754
+ * host's screen or hold a pipe open, and `unref()` releases the event loop so THIS process can
755
+ * exit while the child keeps running. Take any one away and SessionStart waits on a registry.
756
+ *
757
+ * A record that cannot say when it was written is treated as due, not as fresh — the same
758
+ * direction `updatecheck` takes it (an unparseable `checked_at` is not a record), and the safe
759
+ * one: the cost of an extra probe is a detached GET nobody waits for.
760
+ */
761
+ function maybeProbe(run, record) {
762
+ const stamp = record && typeof record['checked_at'] === 'string' ? record['checked_at'] : null;
763
+ const checkedAt = stamp === null ? NaN : Date.parse(stamp);
764
+ if (Number.isFinite(checkedAt) && Date.now() - checkedAt < UPDATE_TTL_MS)
765
+ return 'fresh';
766
+ if (!fs.existsSync(UPDATE_PROBE))
767
+ return 'missing';
768
+ try {
769
+ const child = spawn(process.execPath, [UPDATE_PROBE], { detached: true, stdio: 'ignore' });
770
+ child.unref();
771
+ return 'spawned';
772
+ }
773
+ catch (e) {
774
+ // A spawn this hook cannot make is a log line, never a session that fails to start.
775
+ log(run, { event: 'SessionStart', warn: `update-probe: ${message(e)}` });
776
+ return 'failed';
777
+ }
778
+ }
779
+ /** `String(e.message || e)`, which is what every `catch` in the original wrote by hand. */
780
+ function message(e) {
781
+ if (e instanceof Error)
782
+ return e.message || String(e);
783
+ return String(e);
784
+ }
785
+ // ---------------------------------------------------------------- SessionStart
786
+ function sessionStart(run, input) {
787
+ const cwd = input.cwd || process.cwd();
788
+ const parts = [];
789
+ const profileFacts = countFacts(run.profile);
790
+ let profile = { block: '', total: 0, injected: 0, dropped: [] };
791
+ if (profileFacts > 0) {
792
+ profile = cappedIndex(run.profile, (count) => `[bantamkit profile memory — ${count} facts learned across projects]`, SESSION_INJECT_MAX);
793
+ parts.push(profile.block);
794
+ }
795
+ // WHERE THE HOST'S OWN AUTO-MEMORY DIRECTORY IS, resolved ONCE per session and used twice:
796
+ // it decides whether the project index has to be injected at all, and it is where A exports.
797
+ // SessionStart is the event because it is the one that already had to answer this question,
798
+ // it is paid once per session, and `transcript_path` — branch 3, the only branch that works
799
+ // on a machine the operator has not configured — arrives on it.
800
+ const native = resolveNativeMemory(run, input);
801
+ //
802
+ // A project store that is NOT the profile dir, and one of two things:
803
+ // - the host has no auto-memory store we can find → inject the index, as before;
804
+ // - the host HAS one → export into it instead. Injecting as well would pay twice for the
805
+ // same facts, which is the cost the old `nativeMemoryExists` branch existed to avoid.
806
+ // ONLY THE PROJECT LAYER IS EXPORTED. The native directory is keyed on the host's project
807
+ // root, so a cross-project profile fact placed in it would be copied into every project's
808
+ // store — and the profile index is injected above on every session anyway, so exporting it
809
+ // buys nothing and costs the collision job50/J50-2A already paid for once.
810
+ let project = null;
811
+ let exported = null;
812
+ try {
813
+ const store = discoverProjectStore(cwd);
814
+ if (path.resolve(store) !== path.resolve(run.profile) && countFacts(store) > 0) {
815
+ if (native.dir === null) {
816
+ project = cappedIndex(store, (count) => `[bantamkit project memory — ${count} facts]`, SESSION_INJECT_MAX);
817
+ project.facts = countFacts(store);
818
+ parts.push(project.block);
819
+ }
820
+ else {
821
+ exported = exportToNative(native.dir, store);
822
+ }
823
+ }
824
+ }
825
+ catch (e) {
826
+ log(run, { event: 'SessionStart', warn: message(e) });
827
+ }
828
+ parts.push('[bantamkit] Toolbox is live: mcp__bantamkit__memory_recall reads the body of any fact above; memory_save stores a durable lesson (feedback|user|project|reference — never something derivable from the repo). Repeat reads of an unchanged file are refused once by the filegraph hook; a save nudge fires once at session end when nothing was saved.');
829
+ // LAST, AND ONLY WHEN IT IS TRUE. The toolbox line above is constant boilerplate the agent
830
+ // sees every session; this one appears in one of five states and asks for an action, so it
831
+ // gets the strongest position in the block. Everything above it is unchanged when it is
832
+ // absent, which is the other four states and most sessions.
833
+ let update = { line: null, state: null, probe: 'error' };
834
+ try {
835
+ update = updateSignal(run);
836
+ }
837
+ catch (e) {
838
+ log(run, { event: 'SessionStart', warn: `update-signal: ${message(e)}` });
839
+ }
840
+ if (update.line)
841
+ parts.push(update.line);
842
+ const ctx = parts.join('\n\n');
843
+ if (input.source === 'compact') {
844
+ // context was just rebuilt: earlier reads are gone, so the read ledger must not refuse them
845
+ try {
846
+ fs.unlinkSync(ledgerPath(run, input.session_id));
847
+ }
848
+ catch {
849
+ /* none */
850
+ }
851
+ }
852
+ // `profileFacts` keeps its old meaning — files in the store — so older records stay
853
+ // comparable; `profileInjected` / `profileDropped` are what the block carried and did
854
+ // not, and `dropRule` is the order the drop followed. The project trio appears only when
855
+ // a project block was injected at all.
856
+ log(run, {
857
+ event: 'SessionStart',
858
+ source: input.source,
859
+ cwd,
860
+ bytes: Buffer.byteLength(ctx),
861
+ profileFacts,
862
+ profileInjected: profile.injected,
863
+ profileDropped: profile.dropped,
864
+ ...(project
865
+ ? { projectFacts: project.facts, projectInjected: project.injected, projectDropped: project.dropped }
866
+ : {}),
867
+ dropRule: SESSION_DROP_RULE,
868
+ // A — the host's auto-memory directory. `nativeBranch` / `nativeTried` are the one line
869
+ // RULING Q1.3 asks for when nothing answered: they say WHICH branches were tried, so
870
+ // "bantamkit exported nothing" is never indistinguishable from "bantamkit did not look".
871
+ // The export trio appears only when an export actually ran.
872
+ nativeBranch: native.branch,
873
+ nativeTried: native.tried,
874
+ nativeDir: native.dir,
875
+ ...(exported
876
+ ? {
877
+ nativeExported: exported.exported,
878
+ nativeFiles: exported.files,
879
+ nativeIndexLines: exported.indexLines,
880
+ nativeBytes: exported.bytes,
881
+ nativeIndexBytes: exported.indexBytes,
882
+ nativeSkipped: exported.skipped,
883
+ nativeIndex: exported.index,
884
+ ...(exported.error ? { nativeError: exported.error } : {}),
885
+ }
886
+ : {}),
887
+ storeScope: run.storeScope,
888
+ // The stale-install signal, in three fields so the claim "it fired / it stayed quiet / it
889
+ // asked the registry" is a number the operator can rerun rather than a sentence. `state` is
890
+ // one of `updatecheck`'s five (or `null` when there is no kept install to compare);
891
+ // `probe` is `spawned` / `fresh` / `unbuilt` / `missing` / `failed` / `error`.
892
+ updateState: update.state,
893
+ updateProbe: update.probe,
894
+ updateBytes: Buffer.byteLength(update.line || ''),
895
+ });
896
+ emit({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: ctx } });
897
+ }
898
+ // ------------------------------------------------------------ UserPromptSubmit
899
+ /**
900
+ * One injected recall header, parsed: `[layer] [name] (type) description`.
901
+ *
902
+ * ONE regex, used both to FILTER the reply's lines and to read the fields off them, so the
903
+ * set of lines called headers cannot drift from the set of lines the log describes.
904
+ */
905
+ const RECALL_HEADER = /^\[([^\]]+)\] \[([^\]]+)\] \(([a-z]+)\) (.*)$/;
906
+ /**
907
+ * The store's own score for one injected header against this prompt.
908
+ *
909
+ * `MemoryStore.recall` computes `score` and throws it away — it returns `Fact[]`, and
910
+ * `recallOutcome` carries counts but no per-fact score, so no runtime API surfaces the
911
+ * number roadmap #6 has to gate on. It does not need to. The score IS
912
+ *
913
+ * |tokens(name + " " + description) ∩ tokens(query)|
914
+ *
915
+ * and all three inputs are here: `tokens` is exported from the runtime's own `store.js`
916
+ * (so this is the shipped tokenizer, not a second copy of it), the query is the prompt, and
917
+ * `name`/`description` are the two fields `Memory.format` interpolated into this very line.
918
+ * Re-deriving it is exact, not an estimate — for every fact `memory_save` writes, whose
919
+ * description is one line by construction. A hand-edited multi-line description would have
920
+ * already broken the header this parses, and would score its first line.
921
+ */
922
+ function scoreHeader(head, queryTokens) {
923
+ const m = RECALL_HEADER.exec(head);
924
+ if (!m)
925
+ return null;
926
+ const layer = m[1] ?? '';
927
+ const name = m[2] ?? '';
928
+ const type = m[3] ?? '';
929
+ const description = m[4] ?? '';
930
+ let score = 0;
931
+ for (const t of tokens(`${name} ${description}`))
932
+ if (queryTokens.has(t))
933
+ score += 1;
934
+ return { name, layer, type, score };
935
+ }
936
+ /**
937
+ * The prompt, as a fingerprint that cannot be read back.
938
+ *
939
+ * THE LOG PERSISTS TO DISK AND THE PROMPTS ARE THE USER'S. Nothing reconstructible goes in:
940
+ * a SHA-256 hex digest and two sizes, and no substring of the prompt at any length. The
941
+ * digest exists to tell two prompts apart and to recognise the same prompt twice — that is
942
+ * all #6 needs from it. It is a one-way function, not a secret: someone holding a GUESS at
943
+ * the prompt can confirm the guess by hashing it. That is inherent to any stable hash, and
944
+ * the alternative — a per-machine salt — would buy nothing here (the guesser has the salt
945
+ * too, it sits in the same home directory) at the cost of digests that stop matching across
946
+ * machines. So: stable, unsalted, and documented rather than dressed up.
947
+ */
948
+ function promptFingerprint(prompt) {
949
+ return {
950
+ sha256: createHash('sha256').update(prompt, 'utf8').digest('hex'),
951
+ chars: prompt.length,
952
+ bytes: Buffer.byteLength(prompt),
953
+ };
954
+ }
955
+ function userPromptSubmit(run, input) {
956
+ const prompt = String(input.prompt || '').trim();
957
+ if (prompt.length < PROMPT_MIN_CHARS || prompt.startsWith('/')) {
958
+ log(run, { event: 'UserPromptSubmit', action: 'skip', reason: 'short-or-command' });
959
+ return;
960
+ }
961
+ const m = Memory.layered(input.cwd || process.cwd());
962
+ const o = m.recallOutcome(prompt, 3);
963
+ if (o.status !== 'answered') {
964
+ log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates });
965
+ return;
966
+ }
967
+ // Only the HEADER line of each hit — `[layer] [name] (type) description`. The body costs
968
+ // ~1.5 KB a fact and would be re-sent on every later call; the header is ~150 B and
969
+ // tells the model exactly which name to pass to memory_recall if it wants the body.
970
+ const heads = o.reply.split('\n').filter((l) => RECALL_HEADER.test(l));
971
+ if (heads.length === 0) {
972
+ log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers' });
973
+ return;
974
+ }
975
+ const ctx = capLines(`[bantamkit recall — memories that match this prompt; call mcp__bantamkit__memory_recall with {"query":"<name>"} for the body]\n${heads.join('\n')}`, PROMPT_INJECT_MAX);
976
+ // `injected` is read back off `ctx`, NOT off `heads`. The byte cap drops whole lines, so a
977
+ // header that `recallOutcome` picked need not have left the process — and roadmap #6 asks
978
+ // "was an INJECTED name later used", a question a name the model never saw would poison.
979
+ // `hits` keeps its old meaning (headers picked, pre-cap) so the 487 records written before
980
+ // this change stay comparable; `dropped` is the difference the old shape could not show.
981
+ const queryTokens = tokens(prompt);
982
+ const injected = ctx
983
+ .split('\n')
984
+ .map((l) => scoreHeader(l, queryTokens))
985
+ .filter((x) => x !== null);
986
+ log(run, {
987
+ event: 'UserPromptSubmit',
988
+ action: 'inject',
989
+ hits: heads.length,
990
+ bytes: Buffer.byteLength(ctx),
991
+ source: o.source,
992
+ // The join key. Without it an injection record cannot be matched to the transcript that
993
+ // says what the model did next, which is why the 487 pre-existing records answer nothing.
994
+ session: input.session_id ?? null,
995
+ prompt: promptFingerprint(prompt),
996
+ injected,
997
+ dropped: heads.length - injected.length,
998
+ });
999
+ emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } });
1000
+ }
1001
+ // ------------------------------------------------------------- PreToolUse Read
1002
+ // The filegraph, over the OPERATOR's reads. `filegraph.md` measured the mechanism as net
1003
+ // positive and never a loss (cache pays only on a repeat); it could not reach these reads
1004
+ // because they never pass through bantamkit's Agent. A PreToolUse hook is the one place
1005
+ // that does see them. Keyed by transcript (a subagent has its own transcript and has NOT
1006
+ // seen the parent's read), by path+offset+limit, and by mtime+size so an edit re-arms it.
1007
+ // REFUSES ONCE: the second identical call goes through, so nothing can be hard-blocked.
1008
+ function preToolUseRead(run, input) {
1009
+ const ti = input.tool_input ?? {};
1010
+ const file = typeof ti['file_path'] === 'string' ? ti['file_path'] : '';
1011
+ if (!file)
1012
+ return;
1013
+ let st;
1014
+ try {
1015
+ st = fs.statSync(file);
1016
+ }
1017
+ catch {
1018
+ return; // missing file: let Read produce its own error
1019
+ }
1020
+ const offset = ti['offset'];
1021
+ const limit = ti['limit'];
1022
+ // The transcript is the CONTEXT dimension: a subagent has its own transcript and has not
1023
+ // seen the parent's reads. It is carried on the record as well as in the key, because
1024
+ // `preCompact` must filter on it and a path may itself contain the key's delimiter.
1025
+ const transcript = String(input.transcript_path || input.session_id || '');
1026
+ const key = `${transcript}|${file}|${offset ?? ''}|${limit ?? ''}`;
1027
+ const ledger = readLedger(run, input.session_id);
1028
+ const prev = ledger.reads[key];
1029
+ const sig = `${st.mtimeMs}|${st.size}`;
1030
+ if (prev && prev.sig === sig && !prev.refused) {
1031
+ prev.refused = true;
1032
+ prev.count += 1;
1033
+ writeLedger(run, input.session_id, ledger);
1034
+ const reason = `bantamkit filegraph: ${file}${offset != null ? ` (offset ${offset}${limit != null ? `, limit ${limit}` : ''})` : ''} was already read in this context at ${prev.at} and is unchanged on disk (same mtime and size). Use the content from that earlier read. If you genuinely need it again, repeat the exact same call — this refusal fires only once per unchanged file.`;
1035
+ log(run, { event: 'PreToolUse', action: 'refuse', file, size: st.size });
1036
+ emit({
1037
+ hookSpecificOutput: {
1038
+ hookEventName: 'PreToolUse',
1039
+ permissionDecision: 'deny',
1040
+ permissionDecisionReason: reason,
1041
+ },
1042
+ });
1043
+ return;
1044
+ }
1045
+ ledger.reads[key] = {
1046
+ sig,
1047
+ at: new Date().toISOString(),
1048
+ count: (prev ? prev.count : 0) + 1,
1049
+ refused: false,
1050
+ transcript,
1051
+ file,
1052
+ };
1053
+ writeLedger(run, input.session_id, ledger);
1054
+ log(run, {
1055
+ event: 'PreToolUse',
1056
+ action: prev ? 'allow-after-refuse-or-change' : 'record',
1057
+ file,
1058
+ size: st.size,
1059
+ });
1060
+ }
1061
+ // ---------------------------------------------- PostToolUse → the usage events log
1062
+ // One line per tool call, for `tools/ledger/tool-usage.mjs` to read when a transcript is gone.
1063
+ // The transcript is authoritative while it exists; this is the durable copy behind it, and it
1064
+ // is the ONLY record of a session whose transcript the host has since deleted (4 of 110 logged
1065
+ // sessions, measured 2026-09-04).
1066
+ //
1067
+ // `appendFileSync` and not the read-modify-write `writeLedger` above: the host fires one hook
1068
+ // PROCESS per tool call and a parallel tool block fires them concurrently, which loses 40–50 %
1069
+ // of a read-modify-write's records (roadmap-toolbox row 8, follow-up (q)). An append of a line
1070
+ // this size is atomic on both platforms, so this half has no such race.
1071
+ //
1072
+ // Folded in from tool-metrics' `hooks/scripts/log_event.py`; the field names are that file's,
1073
+ // so an events.jsonl written by either program reads in either.
1074
+ function appendUsageEvent(run, input) {
1075
+ const tool = input.tool_name || '?';
1076
+ const ti = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
1077
+ const record = {
1078
+ ts: new Date().toISOString(),
1079
+ session: input.session_id || '',
1080
+ // The HOST's slug, not tool-metrics' — `token-ledger.mjs` uses this same expression.
1081
+ // log_event.py mapped `.` to a dash as well, which the host does not: this machine has
1082
+ // both `-private-tmp-r3-p3.p1ex6K` and `-private-tmp-r3-p3-p1ex6K` as separate project
1083
+ // dirs, so the python slug split one project into two keys under `--group project` and
1084
+ // made `--project` miss the recovered rows entirely.
1085
+ project: String(input.cwd || '').replace(/[\\/:]/g, '-'),
1086
+ tool,
1087
+ server: tool.startsWith('mcp__') && tool.split('__').length >= 3 ? tool.split('__')[1] : 'builtin',
1088
+ // The dedupe key. Two writers append to this file by design and one machine can register
1089
+ // the hook at both user and project scope, so a call can be logged twice; the reader
1090
+ // dedupes on this the same way it dedupes transcript blocks. Rows written before this
1091
+ // field existed simply carry none.
1092
+ tool_use_id: String(input.tool_use_id ?? ''),
1093
+ detail: tool === 'Skill'
1094
+ ? String(ti['skill'] ?? '')
1095
+ : tool === 'Agent'
1096
+ ? String(ti['subagent_type'] || 'general-purpose')
1097
+ : '',
1098
+ };
1099
+ const dir = process.env['TOOL_METRICS_DIR'] || path.join(os.homedir(), '.claude', 'tool-metrics');
1100
+ const file = path.join(dir, 'events.jsonl');
1101
+ try {
1102
+ fs.mkdirSync(dir, { recursive: true });
1103
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`);
1104
+ pruneUsageEvents(run, file);
1105
+ }
1106
+ catch {
1107
+ /* the log is a convenience; never fail a tool call over it */
1108
+ }
1109
+ }
1110
+ // The log's ONLY reader is `tools/ledger/tool-usage.mjs`, and it reads ONLY the sessions whose
1111
+ // transcript the host has deleted — 4 of 110 when this was written, holding 115 of 40,873
1112
+ // lines. The other 99.7 % are dead weight that the reader re-materialises on every run, and
1113
+ // now that this arm is matcher-less the file grows once per tool call (~42k/month here).
1114
+ //
1115
+ // So: a line whose session still has a transcript is redundant BY CONSTRUCTION, and dropping
1116
+ // it loses nothing the reader would have used. Above the cap, that is exactly what this drops.
1117
+ //
1118
+ // The cost is paid the right way round. `statSync` runs on every call and is a few
1119
+ // microseconds; the walk and rewrite run only when the file is over the cap, and each prune
1120
+ // puts it far enough under that the next one is thousands of calls away. The walk reads
1121
+ // DIRECTORY ENTRIES, never file contents.
1122
+ const EVENTS_MAX_BYTES = 4_000_000;
1123
+ function pruneUsageEvents(run, file) {
1124
+ let size = 0;
1125
+ try {
1126
+ size = fs.statSync(file).size;
1127
+ }
1128
+ catch {
1129
+ return;
1130
+ }
1131
+ if (size <= EVENTS_MAX_BYTES)
1132
+ return;
1133
+ const projects = path.join(run.home, '.claude', 'projects');
1134
+ const onDisk = new Set();
1135
+ const walk = (dir) => {
1136
+ let entries = [];
1137
+ try {
1138
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1139
+ }
1140
+ catch {
1141
+ return;
1142
+ }
1143
+ for (const e of entries) {
1144
+ if (e.isDirectory()) {
1145
+ onDisk.add(e.name);
1146
+ walk(path.join(dir, e.name));
1147
+ }
1148
+ else if (e.name.endsWith('.jsonl')) {
1149
+ onDisk.add(e.name.slice(0, -'.jsonl'.length));
1150
+ }
1151
+ }
1152
+ };
1153
+ walk(projects);
1154
+ // A walk that found nothing is an unreadable projects dir, not a machine with no
1155
+ // transcripts. Pruning on that reading would delete the whole log.
1156
+ if (onDisk.size === 0) {
1157
+ log(run, { event: 'PostToolUse', action: 'prune-skipped', reason: 'no transcripts found', size });
1158
+ return;
1159
+ }
1160
+ const kept = [];
1161
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
1162
+ if (!line.trim())
1163
+ continue;
1164
+ let record;
1165
+ try {
1166
+ record = JSON.parse(line);
1167
+ }
1168
+ catch {
1169
+ kept.push(line); // keep what we cannot judge
1170
+ continue;
1171
+ }
1172
+ if (!record?.session || !onDisk.has(String(record.session)))
1173
+ kept.push(line);
1174
+ }
1175
+ const tmp = `${file}.prune-${process.pid}`;
1176
+ fs.writeFileSync(tmp, kept.length ? `${kept.join('\n')}\n` : '');
1177
+ fs.renameSync(tmp, file);
1178
+ log(run, { event: 'PostToolUse', action: 'prune', before: size, after: fs.statSync(file).size, kept: kept.length });
1179
+ }
1180
+ // ---------------------------------------------- the server's actual --index-budget
1181
+ // (and, since J50-1, the server's actual store pin — the walk below is shared by both)
1182
+ // `Memory.layered(cwd)` opens the project store at DEFAULT_INDEX_BUDGET unless told
1183
+ // otherwise, and the MCP server honours `--index-budget N` (`runtime-ts/src/cli.ts`).
1184
+ // A RUNNING server never writes that number down anywhere: `MemoryStore` keeps `indexBudget`
1185
+ // in memory only, so nothing publishes it and this file cannot be made to (that would be a
1186
+ // change inside `runtime-ts/src/memory`, a different layer and a different unit). The one
1187
+ // place the value survives between "the operator configured it" and "this short-lived hook
1188
+ // process needs it" is the SAME configuration a Claude Code session itself reads to decide
1189
+ // which server to start.
1190
+ // `tools/mcpdrift/mcpdrift.py`'s `discover()` already names the three scopes that can change
1191
+ // which server answers a session for a given project directory (user, local, project); this
1192
+ // reads the same three, for the same reason — those are the configs that can actually change
1193
+ // the answer, not a fourth format guessed at.
1194
+ //
1195
+ // WHAT THIS DELIBERATELY DOES NOT COVER: Claude Desktop / Cursor / Copilot configs (this
1196
+ // hook only ever runs under Claude Code, `docs/hooks.md`), enterprise-managed settings, and a
1197
+ // server started by hand outside all three files. None of those were covered before this fix
1198
+ // either — the old code always assumed the default — so leaving them uncovered narrows an
1199
+ // existing gap rather than regressing it.
1200
+ //
1201
+ // SCOPES DO NOT DISAGREE — THEY HAVE A PRECEDENCE, AND THIS FOLLOWS IT. Until 2026-09-06
1202
+ // this collected the DISTINCT `--index-budget` values across the three scopes and refused to
1203
+ // compact ("AMBIGUITY IS A REFUSAL, NOT A GUESS") whenever it found more than one. That
1204
+ // refusal fired on the NORMAL case: a machine with a user-scope default and a project-scope
1205
+ // override is configured, not ambiguous, and the cost was that automatic compaction silently
1206
+ // stopped for that project with nothing but a log line to show for it. Verified against the
1207
+ // host's own documentation on 2026-09-06 rather than reasoned about
1208
+ // (https://code.claude.com/docs/en/mcp, "MCP installation scopes"): when the same server name
1209
+ // is defined in more than one scope Claude Code connects to it ONCE, using the definition
1210
+ // from the highest-precedence source, and the precedence is
1211
+ //
1212
+ // local > project > user
1213
+ //
1214
+ // — local being `~/.claude.json` `.projects[<resolved cwd>].mcpServers`, project being
1215
+ // `<cwd>/.mcp.json`, user being `~/.claude.json` `.mcpServers`.
1216
+ //
1217
+ // THE UNIT OF PRECEDENCE IS THE WHOLE ENTRY, NOT THE FLAG, which is the part that is easy to
1218
+ // get wrong: the same page says fields are NEVER merged across scopes. So the search below is
1219
+ // for the highest-precedence scope that registers `bantamkit` AT ALL, and `--index-budget` is
1220
+ // then read from that entry alone. A local-scope entry with no `--index-budget` therefore
1221
+ // means the DEFAULT, even when a user-scope entry names a number — because the user-scope
1222
+ // entry is not what the session launched. Reading the flag scope-by-scope instead would
1223
+ // reintroduce exactly the wrong-denominator bug this arm exists to fix.
1224
+ //
1225
+ // THERE IS NO AMBIGUOUS CASE LEFT, so there is no refusal branch — dead code shaped like a
1226
+ // safety net is worse than none. Precedence is total over the three scopes, each scope holds
1227
+ // at most one `bantamkit` entry, and each entry yields at most one `--index-budget`. Finding
1228
+ // no entry at all means nothing configures it anywhere this hook can see, and the default is
1229
+ // then the honest answer, not a guess: it is the same default the CLI itself falls back to
1230
+ // when `--index-budget` is absent.
1231
+ //
1232
+ // ONE RESIDUAL, NAMED RATHER THAN GUESSED AT: a project-scope `.mcp.json` server is not
1233
+ // launched until the user approves it, and that answer is recorded in the SAME file, as
1234
+ // `.projects[<cwd>].enabledMcpjsonServers` / `.disabledMcpjsonServers` — both keys are real
1235
+ // and present in this machine's `~/.claude.json`, both empty here. This hook does NOT consult
1236
+ // them, because the pending state (in neither list) is not resolvable from the file and the
1237
+ // disabled state has not been reproduced end to end from a live host. Registered in
1238
+ // `docs/roadmap-toolbox.md` rather than half-implemented.
1239
+ function readJsonSafe(file) {
1240
+ try {
1241
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1242
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
1243
+ ? parsed
1244
+ : {};
1245
+ }
1246
+ catch {
1247
+ return {};
1248
+ }
1249
+ }
1250
+ /** Best-effort marker write. A marker that cannot be written costs a repeated dream, not a crash. */
1251
+ function writeJsonSafe(file, value) {
1252
+ try {
1253
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1254
+ fs.writeFileSync(file, JSON.stringify(value));
1255
+ }
1256
+ catch {
1257
+ /* the gate degrades to "always fires", which is safe and merely not free */
1258
+ }
1259
+ }
1260
+ function indexBudgetFromArgs(args) {
1261
+ if (!Array.isArray(args))
1262
+ return undefined;
1263
+ for (let i = 0; i < args.length; i += 1) {
1264
+ const a = args[i];
1265
+ if (a === '--index-budget' && i + 1 < args.length) {
1266
+ const n = Number(args[i + 1]);
1267
+ if (Number.isFinite(n))
1268
+ return n;
1269
+ }
1270
+ else if (typeof a === 'string' && a.startsWith('--index-budget=')) {
1271
+ const n = Number(a.slice('--index-budget='.length));
1272
+ if (Number.isFinite(n))
1273
+ return n;
1274
+ }
1275
+ }
1276
+ return undefined;
1277
+ }
1278
+ /**
1279
+ * The `--index-budget` the `bantamkit` registration a session in `cwd` would actually launch
1280
+ * carries — resolved by Claude Code's own MCP scope precedence, `local > project > user`, over
1281
+ * the WHOLE entry. Cheap: two small file reads, and none of it requires the server to be up.
1282
+ *
1283
+ * Returns `{ budget, scope }`. `budget` is `undefined` when the winning entry configures no
1284
+ * `--index-budget` and when no scope registers `bantamkit` at all; `scope` names the entry
1285
+ * that won, or is `null` when none did, so the log says WHICH file the number came from.
1286
+ */
1287
+ function configuredIndexBudget(run, cwd) {
1288
+ const { entry, scope } = winningRegistration(run, cwd);
1289
+ return entry ? { budget: indexBudgetFromArgs(entry['args']), scope } : { budget: undefined, scope: null };
1290
+ }
1291
+ /**
1292
+ * The `bantamkit` registration entry a session in `cwd` actually launched, and the scope it
1293
+ * came from — the walk `configuredIndexBudget` has always done, lifted out (J50-1) so that
1294
+ * `env` is read off the SAME entry as `args`. One walk, two fields; a second walk would be
1295
+ * the second place the precedence rule could be got wrong.
1296
+ *
1297
+ * Returns `{ entry, scope }`, both `null` when no scope registers `bantamkit` at all.
1298
+ */
1299
+ function winningRegistration(run, cwd) {
1300
+ const repo = path.resolve(cwd);
1301
+ const claudeJson = readJsonSafe(path.join(run.home, '.claude.json'));
1302
+ const mcpJson = readJsonSafe(path.join(repo, '.mcp.json'));
1303
+ const dig = (root, keys) => {
1304
+ let node = root;
1305
+ for (const key of keys) {
1306
+ if (!node || typeof node !== 'object' || Array.isArray(node))
1307
+ return undefined;
1308
+ node = node[key];
1309
+ }
1310
+ return node;
1311
+ };
1312
+ // Highest precedence first. Order is the whole point; do not sort or reorder.
1313
+ const scopes = [
1314
+ ['local', dig(claudeJson, ['projects', repo, 'mcpServers', 'bantamkit'])],
1315
+ ['project', dig(mcpJson, ['mcpServers', 'bantamkit'])],
1316
+ ['user', dig(claudeJson, ['mcpServers', 'bantamkit'])],
1317
+ ];
1318
+ for (const [scope, entry] of scopes) {
1319
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
1320
+ continue;
1321
+ return { entry: entry, scope };
1322
+ }
1323
+ return { entry: null, scope: null };
1324
+ }
1325
+ // ------------------------------------------------ the server's actual store pin (J50-1)
1326
+ //
1327
+ // `discoverProjectStore`, `resolveProjectStore` and `Memory.layered` — every store the hook
1328
+ // binds, in every arm — resolve through `pinnedStore()` in `memory/layers.js`, which
1329
+ // reads `process.env.BANTAMKIT_MEMORY_DIR` and outranks the walk. The SERVER sees a
1330
+ // registration's `env` because the host merges it into the server's process before spawning
1331
+ // it; this hook is spawned by the host too, but from the host's OWN environment, and the
1332
+ // registration's `env` never reaches it. So a registration that pins the store had the
1333
+ // server saving into the pinned directory while this hook injected from whatever the walk
1334
+ // found — from any cwd the walk would not have led to the pin, two different stores.
1335
+ //
1336
+ // MEASURED LATENT, NOT LIVE, on 2026-09-12: the only pin on this machine is the bantamkit
1337
+ // repo's LOCAL-scope entry, and it names the directory the walk finds from that cwd anyway
1338
+ // (`.shiftwork/notes-job50/PROBE.md`, B2). The user-scope pin that DID split the two was
1339
+ // removed on 2026-09-11. This closes the shape, not one instance of it.
1340
+ //
1341
+ // THE FIX FEEDS THE ONE RESOLUTION RATHER THAN ADDING A SECOND: the winning entry's value is
1342
+ // applied to this process's environment before any arm runs, and the same `pinnedStore()`
1343
+ // the server runs then sees the same value. Host merge semantics are what decide the edge
1344
+ // cases, and they are `{ ...inherited, ...entry.env }`: a key PRESENT in the winning entry
1345
+ // overrides whatever this process inherited (a blank one included — `pinnedStore` already
1346
+ // reads blank as "no pin", so the server and the hook then both walk), and a key ABSENT from
1347
+ // it leaves the inherited value alone, because that is what the server inherits too. The
1348
+ // whole-entry rule applies exactly as it does to `--index-budget`: a winning entry with no
1349
+ // `env` means the walk, even when a lower scope pins.
1350
+ //
1351
+ // NOT DONE HERE, NAMED RATHER THAN HALF-BUILT: the host expands `${VAR}` and `${VAR:-default}`
1352
+ // in `.mcp.json` `env` values before spawning the server. A pin written that way reaches
1353
+ // `pinnedStore` unexpanded here, and is refused by it as a relative path — loudly, on the
1354
+ // log, never silently as the wrong store. No registration on this machine uses the syntax.
1355
+ function applyRegistrationStorePin(run, cwd) {
1356
+ const { entry, scope } = winningRegistration(run, cwd);
1357
+ const env = entry ? entry['env'] : null;
1358
+ if (!env || typeof env !== 'object' || Array.isArray(env))
1359
+ return;
1360
+ if (!Object.prototype.hasOwnProperty.call(env, 'BANTAMKIT_MEMORY_DIR'))
1361
+ return;
1362
+ const value = env['BANTAMKIT_MEMORY_DIR'];
1363
+ if (typeof value !== 'string')
1364
+ return;
1365
+ process.env['BANTAMKIT_MEMORY_DIR'] = value;
1366
+ run.storeScope = value.trim() === '' ? null : scope;
1367
+ }
1368
+ // ---------------------------------------------- PostToolUse memory_save → compact
1369
+ function postSave(run, input) {
1370
+ const ledger = readLedger(run, input.session_id);
1371
+ ledger.saved = (ledger.saved || 0) + 1;
1372
+ writeLedger(run, input.session_id, ledger);
1373
+ const cwd = input.cwd || process.cwd();
1374
+ const configured = configuredIndexBudget(run, cwd);
1375
+ const budgetSource = configured.budget !== undefined ? 'configured' : 'default';
1376
+ const budgetScope = configured.budget !== undefined ? configured.scope : null;
1377
+ const m = Memory.layered(cwd, configured.budget !== undefined ? { indexBudget: configured.budget } : {});
1378
+ const [bytes, budget] = m.indexAccounting();
1379
+ if (bytes == null || bytes < COMPACT_AT * budget) {
1380
+ log(run, { event: 'PostToolUse', action: 'saved', bytes, budget, budgetSource, budgetScope });
1381
+ return;
1382
+ }
1383
+ // The user ruled compaction automatic (2026-08-24). `compact` archives the stalest facts
1384
+ // until the index sits at --budget; aiming at 80% skips the 90–99.2% band where the
1385
+ // default reserve makes it a no-op.
1386
+ //
1387
+ // AMENDED 2026-09-10 (job46, J46-6): the band is closed, and the aim is now asked for as a
1388
+ // RESERVE against the real budget rather than as a fake budget — see COMPACT_TO above for
1389
+ // the measurement. `compact`'s target is `budget - reserve`, so this lands at exactly
1390
+ // `target` instead of at whatever the default reserve makes of a budget it was misled
1391
+ // about. `reserve` is at least 1 for every budget >= 1 (both parsers refuse 0), and
1392
+ // 0.2 * budget is always under the `budget // 2` cap, so neither edge is reachable here.
1393
+ const target = Math.floor(COMPACT_TO * budget);
1394
+ const reserve = budget - target;
1395
+ const r = spawnSync(process.execPath, [
1396
+ path.join(DIST, 'cli.js'),
1397
+ 'compact',
1398
+ '--store',
1399
+ m.store.root,
1400
+ '--budget',
1401
+ String(budget),
1402
+ '--reserve',
1403
+ String(reserve),
1404
+ ], { encoding: 'utf8', timeout: 8000 });
1405
+ const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
1406
+ log(run, {
1407
+ event: 'PostToolUse',
1408
+ action: 'auto-compact',
1409
+ bytes,
1410
+ budget,
1411
+ target,
1412
+ reserve,
1413
+ budgetSource,
1414
+ budgetScope,
1415
+ exit: r.status,
1416
+ out: out.slice(0, 400),
1417
+ });
1418
+ emit({
1419
+ hookSpecificOutput: {
1420
+ hookEventName: 'PostToolUse',
1421
+ additionalContext: `[bantamkit] memory index was ${bytes}/${budget} B; auto-compacted to ≤${target} B. ${out.slice(0, 600)}`,
1422
+ },
1423
+ });
1424
+ }
1425
+ // -------------------------------------------------------------------- PreCompact
1426
+ // Steering for the summariser, derived from state the hook already holds: which files this
1427
+ // context read (from the read ledger) and which shiftwork unit is open. The ledger measured
1428
+ // 160k cache-read tokens per request; a summary that keeps the file list means the rebuilt
1429
+ // context does not re-read them, and the PostCompact reset lets the gate allow the ones it
1430
+ // genuinely needs.
1431
+ //
1432
+ // THE CHANNEL IS PLAIN STDOUT, NOT `hookSpecificOutput`. Verified against the host binary
1433
+ // (`2.1.259`, the PreCompact dispatcher `fK`): the summariser's instructions are
1434
+ //
1435
+ // newCustomInstructions: C.length > 0 ? C.join("\n\n") : undefined
1436
+ // C = results.filter(r => r.succeeded && !r.blocked && r.output.trim().length > 0)
1437
+ // .map(r => r.output.trim())
1438
+ //
1439
+ // — the hook's own trimmed stdout, verbatim. The `hookSpecificOutput` discriminated union
1440
+ // in that build has NO `"PreCompact"` member (its arms are PreToolUse, PostToolUse,
1441
+ // PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied, UserPromptSubmit,
1442
+ // UserPromptExpansion, SessionStart, Setup, Stop, SubagentStart, SubagentStop,
1443
+ // PreModelSwitch, PostModelSwitch, Notification, MessageDisplay, FileChanged, CwdChanged,
1444
+ // Elicitation, ElicitationResult, WorktreeCreate), so emitting that envelope here made the
1445
+ // host reject the output with "Hook JSON output validation failed", set `succeeded` false,
1446
+ // and DROP the text. Between b3625d9 and this change every compaction was unsteered.
1447
+ // Non-JSON stdout is accepted as-is ("Hook output does not start with {, treating as plain
1448
+ // text"), which is why `emitText` refuses a leading brace.
1449
+ //
1450
+ // The cost of the working channel is that the host also echoes the text back to the user as
1451
+ // `PreCompact [<command>] completed successfully: <output>`. There is no quieter variant —
1452
+ // the same string is both the steering and the display — so the arm stays short on purpose.
1453
+ const CHECKPOINT_MAX_BYTES = 4_000_000; // ONE checkpoint file
1454
+ // … and an AGGREGATE, because nothing bounded the COUNT. `.shiftwork` accumulates a
1455
+ // checkpoint per archived job and is never pruned: measured on this repo on 2026-09-04,
1456
+ // 136,885 B across 6 files, 11 KB of it added by a single session's orchestrator. Every one
1457
+ // of them was read and JSON-parsed on every compaction, so the worst case was n × 4 MB.
1458
+ // Candidates are scanned newest-first — the order that already decides the winner — so the
1459
+ // scan stops at the first OPEN one (on this repo: 1 file, 20,417 B, down from 6 and 136,885)
1460
+ // and what a spent budget drops is always the OLDEST, the least likely to be the open one.
1461
+ const CHECKPOINT_SCAN_MAX_BYTES = 1_000_000;
1462
+ const CHECKPOINT_SCAN_MAX_FILES = 64;
1463
+ /**
1464
+ * The structure `assets/schemas/shiftwork-checkpoint.json` requires of the parts this arm
1465
+ * reads: `plan.cursor` is a non-empty string and `plan.units` is a non-empty array of units
1466
+ * carrying `id` and `status`. Deliberately NOT a full schema validation — a hook that loads
1467
+ * a JSON-Schema validator stops being cheap, and a checkpoint that satisfies this shape but
1468
+ * fails the full schema still yields a true steering line.
1469
+ */
1470
+ function checkpointShape(doc) {
1471
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
1472
+ return null;
1473
+ const plan = doc['plan'];
1474
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan))
1475
+ return null;
1476
+ const cursor = plan['cursor'];
1477
+ const units = plan['units'];
1478
+ if (typeof cursor !== 'string' || cursor.length === 0)
1479
+ return null;
1480
+ if (!Array.isArray(units) || units.length === 0)
1481
+ return null;
1482
+ const ok = units.every((u) => !!u &&
1483
+ typeof u === 'object' &&
1484
+ typeof u['id'] === 'string' &&
1485
+ typeof u['status'] === 'string');
1486
+ return ok ? { cursor, units: units } : null;
1487
+ }
1488
+ /**
1489
+ * The OPEN checkpoint under `<cwd>/.shiftwork`, whatever it is called. Real jobs write named
1490
+ * checkpoints (`checkpoint-readlever.json`, `checkpoint-job41.json`, …), so the old hardcoded
1491
+ * `checkpoint.json` read whichever stale job happened to own that name. Open means: at least
1492
+ * one unit is neither `done` nor `dropped`. Most recently written wins, filename breaks the
1493
+ * tie, so the choice is deterministic. Every failure — no directory, unreadable file,
1494
+ * malformed JSON, wrong shape — is a SKIP, never a throw.
1495
+ *
1496
+ * Returns `null` when there is no `.shiftwork` at all, else the scan's accounting:
1497
+ * `{ winner, scanned, bytes, skipped }` — the caller logs the last three, because a scan
1498
+ * that silently stops on a budget is a scan nobody can audit.
1499
+ */
1500
+ function openCheckpoint(cwd) {
1501
+ const dir = path.join(cwd, '.shiftwork');
1502
+ let names;
1503
+ try {
1504
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
1505
+ }
1506
+ catch {
1507
+ return null;
1508
+ }
1509
+ // stat first (cheap, and the mtime is what orders the scan), read second (the expensive
1510
+ // half, and the one the budget bounds). Same comparator the winner was already chosen by,
1511
+ // so applying it before the read changes which files are READ, never which one wins.
1512
+ const candidates = [];
1513
+ for (const name of names) {
1514
+ const file = path.join(dir, name);
1515
+ try {
1516
+ const st = fs.statSync(file);
1517
+ if (!st.isFile() || st.size > CHECKPOINT_MAX_BYTES)
1518
+ continue;
1519
+ candidates.push({ file, mtimeMs: st.mtimeMs, size: st.size });
1520
+ }
1521
+ catch {
1522
+ /* unreadable: skip */
1523
+ }
1524
+ }
1525
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
1526
+ let bytes = 0;
1527
+ let read = 0;
1528
+ let winner = null;
1529
+ for (const c of candidates) {
1530
+ // The budget is checked BEFORE each read and never before the first, so the newest
1531
+ // candidate is always considered however large it is (bounded by CHECKPOINT_MAX_BYTES).
1532
+ if (read > 0 && (bytes >= CHECKPOINT_SCAN_MAX_BYTES || read >= CHECKPOINT_SCAN_MAX_FILES))
1533
+ break;
1534
+ let doc;
1535
+ try {
1536
+ doc = JSON.parse(fs.readFileSync(c.file, 'utf8'));
1537
+ }
1538
+ catch {
1539
+ read += 1;
1540
+ bytes += c.size;
1541
+ continue;
1542
+ }
1543
+ read += 1;
1544
+ bytes += c.size;
1545
+ const shape = checkpointShape(doc);
1546
+ if (!shape)
1547
+ continue;
1548
+ if (!shape.units.some((u) => u.status !== 'done' && u.status !== 'dropped'))
1549
+ continue;
1550
+ winner = { file: c.file, mtimeMs: c.mtimeMs, ...shape };
1551
+ break; // newest-first: the first open one IS the most recent open one
1552
+ }
1553
+ return { winner, scanned: read, bytes, skipped: candidates.length - read };
1554
+ }
1555
+ /**
1556
+ * "Already read in THIS context" is a claim about one transcript, not about one session. The
1557
+ * ledger file is per session, and a session holds the parent's reads and every subagent's:
1558
+ * `preToolUseRead` keys on the transcript for exactly that reason (docs/hooks.md — "a
1559
+ * subagent has its own transcript and is never refused for the parent's read"). Measured
1560
+ * before this filter existed, on this repo's own tree: of the 30 files handed to a parent's
1561
+ * summariser, 7 had been read only by a subagent whose output the parent never saw — so the
1562
+ * summary carried a false premise, and the PostCompact ledger reset cannot undo it because
1563
+ * the summary is already written. The predicate here is the one the refusal arm writes with.
1564
+ */
1565
+ function transcriptFiles(ledger, transcript) {
1566
+ const me = String(transcript || '');
1567
+ const files = [];
1568
+ const seen = new Set();
1569
+ for (const [key, rec] of Object.entries(ledger.reads || {})) {
1570
+ // Record fields where the entry has them; the key is the fallback for a ledger written
1571
+ // by an older adapter, so an in-flight session degrades quietly instead of losing its list.
1572
+ const parts = key.split('|');
1573
+ const owner = rec?.transcript ?? parts[0];
1574
+ const file = rec?.file ?? parts[1];
1575
+ if (!file || String(owner) !== me || seen.has(file))
1576
+ continue;
1577
+ seen.add(file);
1578
+ files.push(file);
1579
+ }
1580
+ return files;
1581
+ }
1582
+ function preCompact(run, input) {
1583
+ const ledger = readLedger(run, input.session_id);
1584
+ const mine = transcriptFiles(ledger, input.transcript_path || input.session_id);
1585
+ const files = mine.slice(0, PRECOMPACT_FILES_MAX);
1586
+ let scan = null;
1587
+ try {
1588
+ scan = openCheckpoint(input.cwd || process.cwd());
1589
+ }
1590
+ catch {
1591
+ scan = null;
1592
+ }
1593
+ const cp = scan && scan.winner;
1594
+ // The FIXED lines are assembled first and are never cut: they carry the instruction, and a
1595
+ // trimmed instruction steers worse than a trimmed list. Whatever budget they leave is what
1596
+ // the file list gets.
1597
+ const fixed = [];
1598
+ if (cp) {
1599
+ // The cursor names THE next unit; the schema keeps it at `plan.cursor`, never top level.
1600
+ const unit = cp.units.find((u) => u.id === cp.cursor);
1601
+ const at = unit
1602
+ ? `unit ${unit.id} (${unit.status})${unit.title ? ` — ${unit.title}` : ''}`
1603
+ : `unit ${cp.cursor}, which is not present in plan.units`;
1604
+ fixed.push(`${capBytes(`Open shiftwork checkpoint: ${cp.file}, cursor ${JSON.stringify(cp.cursor)} → ${at}.`, CHECKPOINT_LINE_MAX)} Preserve unit status and the next unit to clock in.`);
1605
+ }
1606
+ fixed.push('Preserve verbatim: every number the user was shown, every decision the user made, and any pending operator step.');
1607
+ const room = PRECOMPACT_STDOUT_MAX - Buffer.byteLength(fixed.join('\n\n')) - 2;
1608
+ let block = files.length
1609
+ ? capLines(`Files already read in this context (keep the list; do not re-read unchanged ones after compaction):\n${files.map((f) => `- ${f}`).join('\n')}`, Math.max(0, room))
1610
+ : '';
1611
+ // A header the budget left with no file under it steers nothing and costs bytes.
1612
+ const listed = (block.match(/^- /gm) || []).length;
1613
+ if (listed === 0)
1614
+ block = '';
1615
+ const ctx = [block, ...fixed].filter(Boolean).join('\n\n');
1616
+ const bytes = emitText(ctx);
1617
+ log(run, {
1618
+ event: 'PreCompact',
1619
+ trigger: input.trigger,
1620
+ ledgerFiles: mine.length,
1621
+ capped: files.length,
1622
+ listed,
1623
+ checkpoint: cp ? cp.file : null,
1624
+ cursor: cp ? cp.cursor : null,
1625
+ cpScanned: scan ? scan.scanned : 0,
1626
+ cpSkipped: scan ? scan.skipped : 0,
1627
+ cpBytes: scan ? scan.bytes : 0,
1628
+ bytes, // what LEFT the process, not what was considered
1629
+ });
1630
+ }
1631
+ // ------------------------------------------------------------------- PostCompact
1632
+ function postCompact(run, input) {
1633
+ try {
1634
+ fs.unlinkSync(ledgerPath(run, input.session_id));
1635
+ }
1636
+ catch {
1637
+ /* none */
1638
+ }
1639
+ log(run, { event: 'PostCompact', action: 'ledger-reset' });
1640
+ }
1641
+ // ------------------------------------------------------------------ Stop -> dream
1642
+ // Row 5 of `docs/roadmap-toolbox.md` shipped `memory_dream`'s MECHANISM and left its TRIGGER
1643
+ // open — the row still carries its own `STILL OPEN: the trigger` sentence. Before this arm
1644
+ // the string `dream` did not occur anywhere in this file, so the consolidation ran only when
1645
+ // a model chose to call the tool, which is precisely the operator-only design the user has
1646
+ // already overruled once: `memory_compact` was built as the model's path on refusal and the
1647
+ // user ruled compaction AUTOMATIC (2026-08-24). `postSave` is what that ruling produced, and
1648
+ // this arm is the same move for consolidation.
1649
+ //
1650
+ // AMENDED 2026-09-12 (J50-2A). "THE SAME MOVE" WAS ONE STEP TOO FAR, AND THE USER RULED IT
1651
+ // BACK. `postSave` moves facts inside ONE store. This arm moves facts OUT OF THE PROFILE
1652
+ // STORE, which is machine-wide, and J45 had bounded exactly that cost by making `dry_run`
1653
+ // default to TRUE — a real cross-layer merge was something somebody asked for. Firing the
1654
+ // dream from `Stop` with `dry_run=false` made the mitigation stop existing: every session
1655
+ // that ends inside a project whose store shares a name with the profile store archived the
1656
+ // profile copy, silently, at the end of a turn nobody was watching. Measured on the user's
1657
+ // own machine before this amendment: 14 of 20 profile facts in `~/.bantamkit/memory/archive/`,
1658
+ // and a restore of 4 consumed again at the very next Stop (`.shiftwork/backlog.md` J49-B1).
1659
+ //
1660
+ // THE RULING: THE AUTOMATIC TRIGGER RUNS THE DREAM IN DRY-RUN ONLY. It never writes to any
1661
+ // store. A real merge stays a deliberate call — the `memory_dream` MCP tool with
1662
+ // `dry_run=false`. What the arm keeps is its early-warning value: the log says what a merge
1663
+ // WOULD do, once per change to either layer, and a human decides. The accepted cost is
1664
+ // stated rather than hidden: duplicates across the two layers now ACCUMULATE until somebody
1665
+ // asks. This is not the self-merge of 2026-09-10 (row 13, closed by job47) — that was one
1666
+ // directory bound as two layers; this is the designed cross-layer merge (row 5) firing
1667
+ // without anyone asking for it. The tool is untouched; the TRIGGER is what changed.
1668
+ //
1669
+ // THE EVENT IS `Stop`, AND IT IS NOT THE ONE THE ROW NAMED. The row's spec said "run from a
1670
+ // `SessionEnd`/cron". Re-derived from the user's own `~/.claude/settings.json` on
1671
+ // 2026-09-10, the events that actually reach this hook are
1672
+ //
1673
+ // PostCompact · PostToolUse · PreCompact · PreToolUse[Read]
1674
+ // SessionStart[startup|resume|clear|compact] · Stop · UserPromptSubmit
1675
+ //
1676
+ // and `SessionEnd` is NOT among them. The host supports the event; this registration simply
1677
+ // does not carry it. A `SessionEnd` arm would therefore be INERT until the user edited their
1678
+ // own settings file, and a unit may not edit the user's permission and hook surface. `Stop`
1679
+ // already fires, already reads the per-session ledger, already decides whether to act, and
1680
+ // fires at the END of a turn when no tool call is in flight. The row named a mechanism; the
1681
+ // property it was after is that the consolidation happens without anyone asking, and `Stop`
1682
+ // is the event on this machine that delivers it the moment this merges.
1683
+ //
1684
+ // THE GATE IS THE STORE'S OWN FINGERPRINT, because `Stop` fires every turn and a dream on
1685
+ // every turn is a cost with no benefit. The pass runs only when a `facts/*.md` in either
1686
+ // layer has appeared, vanished, or changed size or mtime since the last dream. A quiet turn
1687
+ // costs one small JSON read, two `readdir`s and one `stat` per fact — measured at ~6 ms over
1688
+ // the 121 facts on this machine, with no store loaded and no subprocess spawned.
1689
+ //
1690
+ // BOTH LAYERS ARE FINGERPRINTED ON PURPOSE. The profile store is machine-wide, so another
1691
+ // project's session can add the very duplicate this session should consolidate. A gate keyed
1692
+ // on THIS session's saves would never see it. For the same reason the marker lives beside the
1693
+ // log in `~/.bantamkit/hooks/`, not in the per-session ledger: the question "has the store
1694
+ // changed since the last dream" outlives any one session.
1695
+ /**
1696
+ * Do two paths name the same directory? REALPATH, not `path.resolve`.
1697
+ *
1698
+ * `resolveProjectStore` hands back a realpath-resolved path and `os.homedir()` does not, so
1699
+ * on macOS the SAME directory arrives as `/private/var/folders/...` from one and
1700
+ * `/var/folders/...` from the other. `path.resolve` normalises `..` and makes a path
1701
+ * absolute; it does not follow symlinks, so it calls those two different and the guard below
1702
+ * would let a self-merge through. Measured: the first version of this guard did exactly that,
1703
+ * and the test that seeds a store under `tmpdir()` is what caught it.
1704
+ *
1705
+ * AMENDED 2026-09-11 (J47-7). Everything above stands as the reason this compares resolved
1706
+ * directories rather than strings, and the fallback's behaviour is unchanged. Two things it
1707
+ * says are now measured wrong.
1708
+ *
1709
+ * (1) THE MECHANISM. `fs.realpathSync` is not the kernel's realpath. It hands its argument to
1710
+ * `path.resolve` first, and `path.resolve` pops `..` LEXICALLY, before the symlink in front of
1711
+ * it has been followed. The comparison is now `realDir` (`realpathSync.native`) for J47-3B's
1712
+ * reasons (`6766033`), which closed the identical defect in the identical predicate in
1713
+ * `runtime-ts`'s `sameDirectory`.
1714
+ *
1715
+ * (2) "A PATH THAT CANNOT BE REALPATHED (IT DOES NOT EXIST YET)" IS THE WRONG DIAGNOSIS, and
1716
+ * it is wrong in the dangerous direction. Measured on J47-3B's bed (`link -> <bed>/deep/real`,
1717
+ * store at `<bed>/deep/.bantamkit/memory`, `HOME=<bed>/link/..`), the throwing argument was
1718
+ * the profile store, and the directory it names EXISTS: `realpathSync` threw because it had
1719
+ * already looked in the wrong place. `resolve` was not "the honest answer" there, it was the
1720
+ * wrong one, and this function answered `false` for one directory spelled two ways — so the
1721
+ * trigger below launched the dream it exists to skip.
1722
+ *
1723
+ * AND ON THAT BED THE `..` NEVER REACHED THIS FUNCTION, which is why repairing only this
1724
+ * predicate did not flip the decision and is worth writing down: `path.join` at the top of the
1725
+ * file had already popped it, so the profile path arrived here as `<bed>/.bantamkit/memory`, a
1726
+ * directory nothing had created — a real instance of the sentence above, arrived at by the
1727
+ * defect rather than by anyone's intent. The repair is therefore at BOTH ends: `HOME` is
1728
+ * resolved before it is joined, and this predicate no longer lets `path`'s lexical `..` decide.
1729
+ */
1730
+ function samePath(a, b) {
1731
+ return realDir(a) === realDir(b);
1732
+ }
1733
+ function storeFingerprint(roots) {
1734
+ const h = createHash('sha256');
1735
+ for (const root of roots) {
1736
+ h.update(`\u0000${root}\u0000`);
1737
+ let names = [];
1738
+ try {
1739
+ names = fs
1740
+ .readdirSync(path.join(root, 'facts'))
1741
+ .filter((n) => n.endsWith('.md'))
1742
+ .sort();
1743
+ }
1744
+ catch {
1745
+ /* a layer with no facts/ contributes its name and nothing else */
1746
+ }
1747
+ for (const n of names) {
1748
+ let st;
1749
+ try {
1750
+ st = fs.statSync(path.join(root, 'facts', n));
1751
+ }
1752
+ catch {
1753
+ continue;
1754
+ }
1755
+ h.update(`${n}\u0000${st.size}\u0000${st.mtimeMs}\u0000`);
1756
+ }
1757
+ }
1758
+ return h.digest('hex');
1759
+ }
1760
+ /**
1761
+ * PREVIEW the consolidation, at most once per change to either layer, in a bounded child
1762
+ * process. Since J50-2A the child runs `dreamOutcome(true)` — a dry run — so nothing this
1763
+ * arm does moves a file; see the amendment in the header above for the ruling and the cost.
1764
+ *
1765
+ * NOTHING IS EMITTED. `stop` may answer the host with `decision: 'block'`, and two JSON
1766
+ * objects on one stdout is not a protocol — so this arm reports only into the hook log.
1767
+ *
1768
+ * THE CHILD IS `node -e`, NOT A CLI SUBCOMMAND, because there is no `dream` subcommand to
1769
+ * call: `memory_dream` is an MCP tool over `Memory.dreamOutcome`, and the memory CLI's
1770
+ * choices are status/lint/compact/archived/archive/restore. Adding one would be a runtime
1771
+ * change in both runtimes plus a conformance case — a different layer and a different unit.
1772
+ * The child gets `postSave`'s discipline: a `spawnSync` with a timeout, everything logged,
1773
+ * and any failure degrading to a log line rather than an exception.
1774
+ *
1775
+ * WHAT IS LOGGED IS WHAT THE PASS ACTUALLY FOUND, parsed out of the child's stdout — `status`,
1776
+ * `changes`, the index before and projected, and the counts it WOULD merge and consume. J46-6
1777
+ * measured the cost of the other habit: a log record whose fields are computed BEFORE the
1778
+ * spawn is vacuous, and its own first repair of that was itself vacuous for exactly that
1779
+ * reason.
1780
+ *
1781
+ * THE LOG LINE IS A DIFFERENT ACTION WITH DIFFERENT FIELD NAMES, ON PURPOSE. A dry run's line
1782
+ * is `action: 'dream-preview'` carrying `dryRun: true`, `wouldMerge` and `wouldConsume`; the
1783
+ * line a real merge wrote was `action: 'dream'` with `merged` and `consumed`. The log is the
1784
+ * only place a human sees this arm, and "would have merged 14" must never read as "merged 14"
1785
+ * — so the preview carries NO field named `merged` at all, rather than the same field under a
1786
+ * flag a reader could miss.
1787
+ */
1788
+ function maybeDream(run, input) {
1789
+ const cwd = input.cwd || process.cwd();
1790
+ let projectRoot;
1791
+ try {
1792
+ projectRoot = resolveProjectStore(cwd).path;
1793
+ }
1794
+ catch (e) {
1795
+ log(run, { event: 'Stop', action: 'dream-skip', reason: 'unresolved-store', error: message(e) });
1796
+ return;
1797
+ }
1798
+ // THE TWO LAYERS MUST BE TWO DIRECTORIES, AND MEASURED ON 2026-09-10 THEY ARE NOT ALWAYS.
1799
+ // `resolveProjectStore` WALKS UP from the cwd, so a session whose cwd has no project store
1800
+ // above it resolves the profile store itself as the "project" store — `~/.bantamkit/memory`
1801
+ // bound as BOTH layers. `dream` then merges that store with ITSELF: every fact matches
1802
+ // itself by name, is merged into itself, and the "profile copy" — the same file — is
1803
+ // archived, which empties `facts/`.
1804
+ //
1805
+ // THIS IS NOT HYPOTHETICAL. It happened to the user's real profile store while this arm was
1806
+ // being written: a live `Stop` in a session running outside any project consolidated 20 of
1807
+ // 20 facts into the archive with `indexBefore == indexAfter == 3974`, the giveaway that the
1808
+ // "project" index and the profile index were one number because they were one store. The
1809
+ // facts were restored from `archive/` (`dream` is reversible by design, which is the only
1810
+ // reason that was recoverable) and this guard is why it cannot recur.
1811
+ //
1812
+ // AMENDED 2026-09-11 (J47-7). "THE GUARD LIVES HERE, IN THE TRIGGER, NOT IN `dream`" IS NO
1813
+ // LONGER TRUE. It is kept anyway, deliberately: `Memory.layered` now refuses to bind one
1814
+ // directory as two layers in BOTH runtimes — `_same_directory` in Python (J47-1, `0844ccb`),
1815
+ // `sameDirectory` in Node (J47-2, `a3afb7c`, corrected by J47-3B, `6766033`) — and the
1816
+ // refusal is gated by conformance cases (J47-3 `ecaf427`, J47-3B `1bd1a43`). This guard
1817
+ // costs one realpath compare, it refuses BEFORE a child process is spawned rather than
1818
+ // inside it, and a working refusal is not deleted on the strength of a change that shipped
1819
+ // the same day.
1820
+ //
1821
+ // AND UNTIL J47-7 IT WAS NOT ANSWERING CORRECTLY, which is the other half of that unit and
1822
+ // the reason the redundancy was worth having. On J47-3B's bed this trigger fired the dream
1823
+ // it exists to skip — measured, the hook's own log line was
1824
+ // {"event":"Stop","action":"dream","status":"no-profile-layer","merged":0,"consumed":0}
1825
+ // i.e. the MECHANISM refused and the TRIGGER did not: defence in depth working in the
1826
+ // direction nobody planned for. Two things were wrong and both are repaired above — `HOME`
1827
+ // is resolved in the kernel's order before it is joined (`path.join` was popping the `..`
1828
+ // lexically), and `samePath` no longer uses `fs.realpathSync`, which pops `..` the same way.
1829
+ if (samePath(projectRoot, run.profile)) {
1830
+ log(run, { event: 'Stop', action: 'dream-skip', reason: 'single-layer', root: projectRoot });
1831
+ return;
1832
+ }
1833
+ const roots = [projectRoot, run.profile];
1834
+ const fingerprint = storeFingerprint(roots);
1835
+ const prior = readJsonSafe(run.dreamState);
1836
+ if (prior['fingerprint'] === fingerprint) {
1837
+ log(run, { event: 'Stop', action: 'dream-skip', reason: 'unchanged', fingerprint: fingerprint.slice(0, 12) });
1838
+ return;
1839
+ }
1840
+ const script = `(async () => {
1841
+ const { Memory } = await import(${JSON.stringify(path.join(DIST, 'component.js'))});
1842
+ // DRY RUN, by the ruling of 2026-09-12 (J50-2A). Passing \`false\` here is what archived
1843
+ // the user's profile facts; \`true\` is the J45 default and the only value this arm may pass.
1844
+ const o = Memory.layered(process.argv[1]).dreamOutcome(true);
1845
+ process.stdout.write(JSON.stringify({ status: o.status, dryRun: o.dryRun, merged: o.merged, consumed: o.consumed,
1846
+ absolutised: o.absolutised, superseded: o.superseded, changes: o.result ? o.result.changes : 0,
1847
+ indexBefore: o.indexBefore, indexAfter: o.indexAfter, budget: o.budget }));
1848
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e)); process.exit(1); });`;
1849
+ const t = Date.now();
1850
+ const r = spawnSync(process.execPath, ['-e', script, cwd], {
1851
+ encoding: 'utf8',
1852
+ timeout: dreamTimeoutMs(),
1853
+ });
1854
+ const ms = Date.now() - t;
1855
+ let outcome = null;
1856
+ try {
1857
+ outcome = JSON.parse(r.stdout || '');
1858
+ }
1859
+ catch {
1860
+ /* a child that died has no JSON to give */
1861
+ }
1862
+ if (outcome === null) {
1863
+ // A failed or timed-out pass must NOT record the new fingerprint: the next Stop should
1864
+ // try again rather than treat an unconsolidated store as already dreamt.
1865
+ // `timedOut` AND NOT `signal`. `spawnSync` reports the timeout kill as an `ETIMEDOUT`
1866
+ // error on every platform, whereas `signal` is a POSIX notion: on Windows the kill is
1867
+ // `TerminateProcess` and there is no SIGTERM to report. `signal` is kept because it is
1868
+ // informative where it exists, but the field that MEANS "the bound stopped this" is the
1869
+ // portable one, and it is the one anything asserting on this arm should read.
1870
+ const err = r.error;
1871
+ log(run, {
1872
+ event: 'Stop',
1873
+ action: 'dream-failed',
1874
+ ms,
1875
+ exit: r.status,
1876
+ signal: r.signal ?? null,
1877
+ timedOut: err !== undefined && err.code === 'ETIMEDOUT',
1878
+ error: `${r.stderr || ''}`.trim().slice(0, 400) || String(err?.message || ''),
1879
+ });
1880
+ return;
1881
+ }
1882
+ // THE MARKER ADVANCES AFTER A DRY RUN, DELIBERATELY. It answers "has either layer changed
1883
+ // since the last look", never "is the store consolidated" — the status it records is the
1884
+ // pass's own (`previewed`, `nothing-to-consolidate`, ...), so a preview cannot mark anything
1885
+ // as merged. NOT advancing it would spawn a child on every Stop for as long as one duplicate
1886
+ // exists, which is the every-turn cost the header rules out; advancing it keeps the cadence
1887
+ // the arm always had — one report per change to either layer — and the deliberate
1888
+ // `memory_dream` merge that finally consumes the duplicate moves a file, so it re-arms the
1889
+ // gate by itself and the next Stop reports the store clean.
1890
+ //
1891
+ // The fingerprint is still recomputed AFTER the pass, as it was when the pass could move
1892
+ // files. For a dry run the two numbers must be equal, and `storeMoved` on the log line says
1893
+ // whether they were: a preview that moved anything is the bug this unit closed, come back.
1894
+ const after = storeFingerprint(roots);
1895
+ writeJsonSafe(run.dreamState, {
1896
+ fingerprint: after,
1897
+ at: new Date().toISOString(),
1898
+ status: outcome.status,
1899
+ dryRun: true,
1900
+ });
1901
+ log(run, {
1902
+ event: 'Stop',
1903
+ action: 'dream-preview',
1904
+ ms,
1905
+ dryRun: true,
1906
+ status: outcome.status,
1907
+ wouldMerge: outcome.merged,
1908
+ wouldConsume: outcome.consumed,
1909
+ wouldAbsolutise: outcome.absolutised,
1910
+ wouldSupersede: outcome.superseded,
1911
+ changes: outcome.changes,
1912
+ indexBefore: outcome.indexBefore,
1913
+ indexProjected: outcome.indexAfter,
1914
+ budget: outcome.budget,
1915
+ storeMoved: after !== fingerprint,
1916
+ });
1917
+ }
1918
+ // -------------------------------------------------------------------------- Stop
1919
+ // The experience collector. Once per session, when the session did real work and nothing
1920
+ // durable was written, hand the turn back with one instruction. The host's `type:prompt`
1921
+ // hook could judge this with a model call; a grep over the transcript is free.
1922
+ function stop(run, input) {
1923
+ if (input.stop_hook_active)
1924
+ return;
1925
+ const ledger = readLedger(run, input.session_id);
1926
+ if (ledger.stopNudged)
1927
+ return;
1928
+ let text = '';
1929
+ try {
1930
+ text = fs.readFileSync(String(input.transcript_path ?? ''), 'utf8');
1931
+ }
1932
+ catch {
1933
+ return;
1934
+ }
1935
+ const toolUses = (text.match(/"type":\s*"tool_use"/g) || []).length;
1936
+ const saved = (ledger.saved || 0) > 0 ||
1937
+ /"name":\s*"mcp__bantamkit__memory_save"/.test(text) ||
1938
+ /"file_path":"[^"]*[\\/]memory[\\/][^"]*\.md"/.test(text);
1939
+ if (toolUses < STOP_NUDGE_MIN_TOOL_CALLS || saved) {
1940
+ log(run, { event: 'Stop', action: 'pass', toolUses, saved });
1941
+ return;
1942
+ }
1943
+ ledger.stopNudged = true;
1944
+ writeLedger(run, input.session_id, ledger);
1945
+ log(run, { event: 'Stop', action: 'nudge', toolUses });
1946
+ emit({
1947
+ decision: 'block',
1948
+ reason: `bantamkit: this session made ${toolUses} tool calls and saved no memory. Before stopping, decide whether anything durable was learned that is NOT derivable from the repo, git history, or docs — a correction or preference the user stated (feedback), a fact about ongoing work or a decision (project), a URL/ticket/dashboard (reference). If so, call mcp__bantamkit__memory_save for each (at most 3, description written as the words a future query would use). If nothing qualifies, stop with one line saying so. This nudge fires once per session.`,
1949
+ });
1950
+ }
1951
+ // ---------------------------------------------------------------------- dispatch
1952
+ /** Read the whole of stdin. One JSON object is the entire input contract. */
1953
+ async function readStdin() {
1954
+ let raw = '';
1955
+ for await (const chunk of process.stdin)
1956
+ raw += chunk;
1957
+ return raw;
1958
+ }
1959
+ /**
1960
+ * `bantamkit-mcp --hook`: ONE JSON object in, AT MOST ONE JSON object out, and the caller
1961
+ * exits 0 whatever happens.
1962
+ *
1963
+ * NOTHING HERE THROWS PAST THE CALLER. `cli.ts` wraps this in the same `catch → log → 0` the
1964
+ * standalone script's last line was, because a hook that exits non-zero or writes a stack to
1965
+ * stderr is rendered by the host as an error on the user's screen.
1966
+ */
1967
+ export async function runHook() {
1968
+ const run = newRun();
1969
+ const raw = await readStdin();
1970
+ let input = {};
1971
+ try {
1972
+ const parsed = JSON.parse(raw || '{}');
1973
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
1974
+ input = parsed;
1975
+ }
1976
+ catch {
1977
+ log(run, { event: 'parse-error', raw: raw.slice(0, 200) });
1978
+ return;
1979
+ }
1980
+ const ev = input.hook_event_name;
1981
+ // Before ANY arm binds a store: the winning registration's `BANTAMKIT_MEMORY_DIR`, applied
1982
+ // to this process so `pinnedStore()` sees what the server sees (J50-1). Two small file
1983
+ // reads, measured at 0.6 ms on this machine's 164 kB `~/.claude.json`.
1984
+ applyRegistrationStorePin(run, input.cwd || process.cwd());
1985
+ switch (ev) {
1986
+ case 'SessionStart':
1987
+ return sessionStart(run, input);
1988
+ case 'UserPromptSubmit':
1989
+ return userPromptSubmit(run, input);
1990
+ case 'PreToolUse':
1991
+ return input.tool_name === 'Read' ? preToolUseRead(run, input) : undefined;
1992
+ case 'PostToolUse':
1993
+ appendUsageEvent(run, input); // every tool, not just bantamkit's — it is a usage denominator
1994
+ return input.tool_name === 'mcp__bantamkit__memory_save' ? postSave(run, input) : undefined;
1995
+ case 'PreCompact':
1996
+ return preCompact(run, input);
1997
+ case 'PostCompact':
1998
+ return postCompact(run, input);
1999
+ case 'Stop':
2000
+ maybeDream(run, input); // gated on the store changing; logs only, never emits
2001
+ return stop(run, input);
2002
+ default:
2003
+ log(run, { event: ev, action: 'ignored' });
2004
+ }
2005
+ }
2006
+ /** The `catch` half of the contract, so `cli.ts` states it in one line. */
2007
+ export function logHookFailure(e) {
2008
+ log(newRun(), { event: 'error', error: String((e instanceof Error && e.stack) || e) });
2009
+ }