waku-memory 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/capture.js CHANGED
@@ -13,63 +13,88 @@
13
13
  // "y" before anything else happens, including asking for a key.
14
14
  //
15
15
  // Zero dependencies, per the shim's package.json: only node:fs, node:path
16
- // and node:util (all built in) below.
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
- import { join } from 'node:path';
16
+ // and node:util (all built in) below, one constant from hook.ts -- the
17
+ // process budget the SessionEnd entry installs, which hook.ts's own network
18
+ // budget has to fit under -- and, since task 13, the bootstrap module (spec
19
+ // 011 §8): the third question's scan/render/parse (task 12) and send (task
20
+ // 13) all live in bootstrap.ts, so enable() below only orchestrates them.
21
+ // hook.ts imports nothing from here in return; bootstrap.ts imports from
22
+ // hook.ts (the watermark rule) but nothing from this file either.
23
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { dirname, join, resolve } from 'node:path';
19
25
  import { isDeepStrictEqual } from 'node:util';
20
- // The marker every hook command this tool writes contains, and the only
21
- // thing removeHookSettings uses to tell "ours" from a foreign hook on the
22
- // same event. Deliberately just the tail of the real command (not the full
23
- // "npx -y waku-memory hook" string) so a hand-edited variant --
24
- // different flags, a pinned version -- is still recognized as ours.
25
- export const WAKU_HOOK_MARKER = 'waku-memory hook';
26
- // No separate script file to install, version, or leave stale (spec 005):
27
- // every event runs the shim package itself via npx. `-y` skips npm's
28
- // install-confirmation prompt, which would otherwise hang a hook that has
29
- // no terminal to answer it.
30
- const WAKU_HOOK_COMMAND = `npx -y ${WAKU_HOOK_MARKER}`;
31
- // Must match hook.ts's own (unexported) CONFIG_FILE_NAME -- the two files
32
- // agree on this only by convention, not by import, because hook.ts is
33
- // deliberately dependency-free of this file (it is the piece that runs
34
- // unattended on every turn; this is the piece a human runs once).
35
- const CONFIG_FILE_NAME = 'config.json';
26
+ import { LIVE_WINDOW_MS, parseBootstrapAnswer, renderBootstrapList, runBootstrap, scanClaudeCode, } from "./bootstrap.js";
27
+ import { CONFIG_FILE_NAME, SESSION_END_HOOK_TIMEOUT_S } from "./hook.js";
36
28
  // `Stop` is where captured content is actually sent -- "async": true so it
37
29
  // runs in the background without blocking the turn (600s default budget,
38
- // research doc §2). `SessionStart` posts the liveness heartbeat on the same
39
- // fire-and-forget basis. `SessionEnd` is the flush: all SessionEnd hooks
40
- // share a 1.5s-by-default exit budget (raisable only to 60s) and cannot
41
- // block Claude Code's own exit -- hook.ts's SESSION_END_TIMEOUT_MS comment
42
- // has the measured detail -- so it carries a `timeout` instead of `async`.
43
- // `async` only affects whether a hook keeps running after Claude Code moves
44
- // on; for an event that already cannot block exit, that distinction does
45
- // not apply, so it is left off rather than set to a value the harness
46
- // ignores anyway (pinned by the given test's `sessionEnd.async === undefined`).
47
- const HOOK_EVENTS = {
48
- Stop: { type: 'command', command: WAKU_HOOK_COMMAND, async: true },
49
- SessionStart: { type: 'command', command: WAKU_HOOK_COMMAND, async: true },
50
- SessionEnd: { type: 'command', command: WAKU_HOOK_COMMAND, timeout: 10 },
51
- };
30
+ // research doc §2). `SessionStart` is synchronous instead, with a 5s
31
+ // timeout and a matcher naming the five events worth a brief: its stdout is
32
+ // printed straight into the session as context (spec 011 §6, task 9), which
33
+ // is exactly why it can no longer be fire-and-forget the way 0.1.x had it.
34
+ // `SessionEnd` is the flush: all SessionEnd hooks share a 1.5s-by-default
35
+ // exit budget and cannot block Claude Code's own exit, so it carries a
36
+ // `timeout` instead of `async` -- raised to SESSION_END_HOOK_TIMEOUT_S
37
+ // seconds, the process budget hook.ts's own network budget is derived from
38
+ // (its SESSION_END_TIMEOUT_MS comment has the measured detail). `async`
39
+ // only affects whether a hook keeps running after Claude Code moves on; for
40
+ // an event that already cannot block exit, that distinction does not
41
+ // apply, so it is left off rather than set to a value the harness ignores
42
+ // anyway (pinned by the given test's `sessionEnd.async === undefined`).
43
+ const SESSION_START_MATCHER = 'startup|resume|clear|compact|fork';
44
+ function hookEvents(run) {
45
+ const base = run.args
46
+ ? { type: 'command', command: run.command, args: run.args }
47
+ : { type: 'command', command: run.command };
48
+ return {
49
+ Stop: { hooks: [{ ...base, async: true }] },
50
+ SessionStart: { matcher: SESSION_START_MATCHER, hooks: [{ ...base, timeout: 5 }] },
51
+ SessionEnd: { hooks: [{ ...base, timeout: SESSION_END_HOOK_TIMEOUT_S }] },
52
+ };
53
+ }
54
+ // Ours in either form: a shell command that names the package and ends
55
+ // with the subcommand (0.1.x), or an exec-form entry whose last argument is
56
+ // `hook` and whose script lives under a `.waku-memory/hook/` directory.
57
+ // Deliberately not a single marker constant checked against one field
58
+ // anymore -- the exec form has no one field that both names the package and
59
+ // the subcommand the way the 0.1.x shell string did, so recognising it
60
+ // needs its own arm.
61
+ export function isOwnEntry(entry) {
62
+ // settings.json is only validated as a top-level plain object
63
+ // (readClaudeSettings's own comment) -- anything inside `hooks` is
64
+ // whatever some other tool wrote there, so `command` and `args` are
65
+ // `unknown` in practice despite what HookCommandEntry claims at compile
66
+ // time. An entry this cannot prove is shaped the way ours would be is
67
+ // never ours: false, not a thrown TypeError (final review M1 -- a real
68
+ // exec-form entry with a non-string argument used to throw out of here,
69
+ // reaching mergeHookSettings, reaching enable() *after* config.json is
70
+ // already written).
71
+ if (typeof entry.command !== 'string')
72
+ return false;
73
+ if (entry.args !== undefined) {
74
+ if (!Array.isArray(entry.args) || entry.args.some((a) => typeof a !== 'string'))
75
+ return false;
76
+ const args = entry.args;
77
+ return args[args.length - 1] === 'hook' && args.some((a) => a.replace(/\\/g, '/').includes('/.waku-memory/hook/'));
78
+ }
79
+ return entry.command.includes('waku-memory') && entry.command.trimEnd().endsWith(' hook');
80
+ }
52
81
  // A group is "ours" only if every hook inside it is ours -- mergeHookSettings
53
82
  // only ever writes single-hook groups, so in practice this means exactly
54
- // one hook whose command carries the marker, but the "every" check (rather
55
- // than "some") is what makes removeHookSettings's contract exact: it must
56
- // never delete a group that also carries someone else's hook.
83
+ // one hook isOwnEntry recognises, but the "every" check (rather than
84
+ // "some") is what makes removeHookSettings's contract exact: it must never
85
+ // delete a group that also carries someone else's hook.
57
86
  function isOwnGroup(group) {
58
- return group.hooks.length > 0 && group.hooks.every((h) => h.command.includes(WAKU_HOOK_MARKER));
87
+ return group.hooks.length > 0 && group.hooks.every(isOwnEntry);
59
88
  }
60
- // Appends one group per event in HOOK_EVENTS, skipping any event that
61
- // already carries one of ours -- running `enable` twice must not duplicate
62
- // entries (pinned by the brief's idempotence test and this file's "running
63
- // twice" orchestration test). A foreign group already on the same event is
64
- // never inspected beyond isOwnGroup's check and is never reordered or
65
- // rewritten -- only ours is ever added.
66
- export function mergeHookSettings(existing) {
89
+ // Replaces any group of ours on each event -- an older version's entry, in
90
+ // either form -- with one built from `run`, and keeps every foreign group
91
+ // untouched. Idempotent: merging the same invocation twice yields the same
92
+ // settings (pinned below), which is what makes running `enable` again, on
93
+ // the same version, safe.
94
+ export function mergeHookSettings(existing, run) {
67
95
  const hooks = { ...(existing.hooks ?? {}) };
68
- for (const [event, entry] of Object.entries(HOOK_EVENTS)) {
69
- const groups = hooks[event] ?? [];
70
- if (!groups.some(isOwnGroup)) {
71
- hooks[event] = [...groups, { hooks: [entry] }];
72
- }
96
+ for (const [event, group] of Object.entries(hookEvents(run))) {
97
+ hooks[event] = [...(hooks[event] ?? []).filter((g) => !isOwnGroup(g)), group];
73
98
  }
74
99
  return { ...existing, hooks };
75
100
  }
@@ -94,14 +119,86 @@ export function removeHookSettings(existing) {
94
119
  }
95
120
  return { ...existing, hooks };
96
121
  }
97
- // The exact two sentences spec 005's "Disclosure" section requires, shown
98
- // at the moment that discharges status.md item 9's obligation for capture:
99
- // "before anyone outside the three of us is invited, and no later than the
100
- // day a worker is deployed, they need to be told the environment can lose
101
- // their data and that importing [or capturing] sends its contents to
102
- // Anthropic." Both facts, two sentences, nothing softened.
122
+ // Copies the shim's own compiled dist/*.js, next to a one-line ESM
123
+ // package.json, into a version-numbered directory under configDir --
124
+ // installed rather than run via npx (2.5s per hook, measured, on top of
125
+ // SessionStart now being synchronous and printing: spec 011 §6) and
126
+ // versioned rather than a single fixed path so an in-flight hook from the
127
+ // previous version is never overwritten mid-run by the next `enable`
128
+ // (pruneHookCopies below cleans up once settings.json has moved on).
129
+ // A no-op when sourceDir already *is* the copy -- enable() re-run from
130
+ // inside an already-installed copy has nothing left to do.
131
+ export function installHookCopy(sourceDir, configDir, version) {
132
+ const target = join(configDir, 'hook', version);
133
+ const indexPath = join(target, 'index.js').replace(/\\/g, '/'); // Claude Code spawns this directly -- see hookInvocation
134
+ if (resolve(sourceDir) === resolve(target))
135
+ return indexPath;
136
+ mkdirSync(target, { recursive: true });
137
+ for (const name of readdirSync(sourceDir)) {
138
+ if (name.endsWith('.js'))
139
+ copyFileSync(join(sourceDir, name), join(target, name));
140
+ }
141
+ writeFileSync(join(target, 'package.json'), '{"type":"module"}\n', 'utf8'); // dist/*.js is ESM
142
+ return indexPath;
143
+ }
144
+ // After settings.json points only at `keep`, every other copy under
145
+ // configDir/hook is stale disk. A directory Windows refuses to delete
146
+ // (EBUSY: an old hook process still running) is reported and left for the
147
+ // next enable to retry -- the entries already point elsewhere, so a copy
148
+ // left behind here is wasted space, never a wrong hook running.
149
+ export function pruneHookCopies(configDir, keep) {
150
+ const root = join(configDir, 'hook');
151
+ let names;
152
+ try {
153
+ names = readdirSync(root);
154
+ }
155
+ catch {
156
+ return;
157
+ }
158
+ for (const name of names) {
159
+ if (name === keep)
160
+ continue;
161
+ try {
162
+ rmSync(join(root, name), { recursive: true, force: true });
163
+ }
164
+ catch (err) {
165
+ console.error(`Could not remove the old hook copy ${join(root, name)} -- ${err.message}. ` +
166
+ `It will be removed by the next "capture enable".`);
167
+ }
168
+ }
169
+ }
170
+ // Exec form (spec 011 §6): Claude Code spawns command/args directly, no
171
+ // shell. The command is the Node that ran `enable`, by absolute path, not
172
+ // the bare word "node" -- Claude Code ships no Node of its own and a
173
+ // launcher's PATH is not guaranteed to have one on it either.
174
+ export function hookInvocation(execPath, indexPath) {
175
+ return { command: execPath.replace(/\\/g, '/'), args: [indexPath, 'hook'] };
176
+ }
177
+ // The first two sentences are spec 005's "Disclosure" section, shown at the
178
+ // moment that discharges status.md item 9's obligation for capture: "before
179
+ // anyone outside the three of us is invited, and no later than the day a
180
+ // worker is deployed, they need to be told the environment can lose their
181
+ // data and that importing [or capturing] sends its contents to Anthropic."
182
+ // Both facts, two sentences, nothing softened.
183
+ //
184
+ // The next three sentences are spec 011 §9 (A15, task 14), added because
185
+ // "captured content is sent" on its own reads as "everything on this
186
+ // machine is sent" -- not true, and the gap matters enough to name: typed
187
+ // text, replies and tool names go; the files an agent reads and the
188
+ // commands it runs stay local *unless the agent's own reply quotes them
189
+ // back* (the one path by which their content can still leave, so the
190
+ // carve-out has to say so rather than imply a cleaner boundary than the
191
+ // hook actually draws); reasoning is never sent, full stop. The last
192
+ // sentence previews enable()'s own third question (task 13, spec 011 §8):
193
+ // "the next question" is literal, not rhetorical -- renderBootstrapList
194
+ // (bootstrap.ts) is the list it points at, printed immediately after this
195
+ // disclosure and the "y" it gates, before that question is ever asked.
103
196
  export const DISCLOSURE = 'Captured content is sent to our servers and to Anthropic for extraction. ' +
104
- 'This is an alpha whose data can be lost.';
197
+ 'This is an alpha whose data can be lost. ' +
198
+ 'What you type, what the agent replies, and the names of the tools it uses are sent; ' +
199
+ 'the files it reads and the commands it runs are not, except where the agent quotes them in its reply, ' +
200
+ 'and its reasoning is never sent. ' +
201
+ 'Enabling can also import the memory and history Claude Code already keeps here; the next question lists exactly what.';
105
202
  // Exported so cli.ts's usage text can point at the same URL without a
106
203
  // second copy of it drifting out of sync.
107
204
  //
@@ -140,6 +237,102 @@ function readClaudeSettings(settingsPath) {
140
237
  return undefined;
141
238
  }
142
239
  }
240
+ // Only printable ASCII can be part of a bearer key. Non-printable
241
+ // characters at either *end* came from the terminal, not the person: on
242
+ // 2026-09-04 a Windows console delivered Ctrl+V's own control code (0x16)
243
+ // ahead of the key, `.trim()` left it in place, and every hook call then
244
+ // failed with an opaque "fetch failed" (undici refusing the Authorization
245
+ // header). Whitespace and non-ASCII (an IME's full-width space, say) are
246
+ // stripped from the ends for the same reason.
247
+ //
248
+ // Anything non-printable left *inside* is a different thing: the paste
249
+ // held more than the key ("Bearer mem_sk_..."), and stripping it would
250
+ // glue two tokens into one wrong key that looks right -- the 0.1.1 review
251
+ // reproduced exactly that, with the next hook's 401 landing in a
252
+ // background process nobody watches. So the interior count is returned,
253
+ // not repaired, and askForKey refuses on it.
254
+ export function sanitizeKey(raw) {
255
+ const printable = (c) => {
256
+ const code = c.charCodeAt(0);
257
+ return code >= 0x21 && code <= 0x7e;
258
+ };
259
+ const chars = [...raw];
260
+ let start = 0;
261
+ let end = chars.length;
262
+ while (start < end && !printable(chars[start]))
263
+ start++;
264
+ while (end > start && !printable(chars[end - 1]))
265
+ end--;
266
+ const kept = chars.slice(start, end);
267
+ return {
268
+ key: kept.join(''),
269
+ removed: chars.length - kept.length,
270
+ interior: kept.filter((c) => !printable(c)).length,
271
+ };
272
+ }
273
+ const KEY_ATTEMPTS = 3;
274
+ // What every key minted at KEYS_PAGE_URL starts with (api_key_repository.py:
275
+ // PREFIX + token_urlsafe(32)). A key without it is warned about, not
276
+ // refused: the server's opinion is the only one that counts, and a refusal
277
+ // here would make a future prefix change strand every user on this shim.
278
+ const KEY_PREFIX = 'mem_sk_';
279
+ // Asks up to KEY_ATTEMPTS times. null means the person never pasted anything
280
+ // usable, and enable() then ends with the same "nothing was written"
281
+ // guarantee that declining the disclosure carries. No branch below ever
282
+ // prints the key or any part of it -- counts and a prefix only.
283
+ async function askForKey(deps) {
284
+ for (let attempt = 1; attempt <= KEY_ATTEMPTS; attempt++) {
285
+ const raw = await deps.prompt(`Paste an API key from ${KEYS_PAGE_URL} (it is shown once, at mint time): `);
286
+ const { key, removed, interior } = sanitizeKey(raw);
287
+ if (interior > 0) {
288
+ console.log(`The pasted text has ${interior} character(s) inside it that cannot be part of a key -- ` +
289
+ `more than the key was pasted. Paste the key itself, nothing else.`);
290
+ continue;
291
+ }
292
+ if (key === '') {
293
+ if (attempt < KEY_ATTEMPTS) {
294
+ console.log('That did not contain a usable key -- paste the key itself, nothing else.');
295
+ }
296
+ continue;
297
+ }
298
+ if (removed > 0) {
299
+ console.log(`Removed ${removed} non-printable character(s) from the ends of the pasted key -- ` +
300
+ `a terminal can add Ctrl+V's own code ahead of what it pastes.`);
301
+ }
302
+ if (!key.startsWith(KEY_PREFIX)) {
303
+ console.log(`Note: the key does not start with ${KEY_PREFIX}, which every key minted at ${KEYS_PAGE_URL} does. ` +
304
+ `Saving it as pasted; if the hook reports 401, run this command again with a fresh key.`);
305
+ }
306
+ return key;
307
+ }
308
+ return null;
309
+ }
310
+ // A parsed BootstrapAnswer still carries `invalid`; runBootstrap's own
311
+ // BootstrapSelection does not (its three real outcomes are `all`, `skip` and
312
+ // `exclude`) -- askBootstrapSelection's own re-ask loop below is where a
313
+ // still-invalid second answer becomes `skip`, so by the time this is called
314
+ // that case has already been decided; this is a plain, total mapping, never
315
+ // itself a decision point.
316
+ function toSelection(answer) {
317
+ if (answer.kind === 'exclude')
318
+ return { kind: 'exclude', numbers: answer.numbers };
319
+ if (answer.kind === 'all')
320
+ return { kind: 'all' };
321
+ return { kind: 'skip' }; // 'skip', or a still-invalid second answer
322
+ }
323
+ // The third question, spec 011 §8: asks '> ' once, and on an invalid answer
324
+ // prints the one-line hint and asks once more -- a second invalid answer is
325
+ // treated as skip rather than asked a third time, the same "stop pestering
326
+ // the person" reasoning A12's own parseBootstrapAnswer comment gives for
327
+ // only ever re-asking once.
328
+ async function askBootstrapSelection(projectCount, prompt) {
329
+ let answer = parseBootstrapAnswer(await prompt('> '), projectCount);
330
+ if (answer.kind === 'invalid') {
331
+ console.log('Enter, numbers, or n.');
332
+ answer = parseBootstrapAnswer(await prompt('> '), projectCount);
333
+ }
334
+ return toSelection(answer);
335
+ }
143
336
  // The one gesture that turns capture on. Order is deliberate and each step
144
337
  // depends on the one before it having succeeded:
145
338
  // 1. refuse if Claude Code itself is not on this machine -- the directory
@@ -153,10 +346,37 @@ function readClaudeSettings(settingsPath) {
153
346
  // including asking for the key, so declining costs the user nothing
154
347
  // and asks for nothing;
155
348
  // 4. only then ask for the key;
156
- // 5. write config.json, then merge and write settings.json. Everything
157
- // before this point (refusing, declining) writes nothing at all --
158
- // that guarantee is real. This step itself is not atomic across the
159
- // two files, though: config.json is committed to disk first, so a
349
+ // 5. install a local copy of the shim under configDir/hook/<version> and
350
+ // run it for real -- verifyHook -- before anything is wired into
351
+ // settings.json. A copy that does not even start (a corrupted copy, a
352
+ // Node too old for it, ...) must never become the thing Claude Code
353
+ // calls on every turn, so a failed verification removes the copy this
354
+ // call made under configDir/hook/<version> (and configDir/hook itself,
355
+ // if that copy was the only thing under it) and refuses right here,
356
+ // before config.json or settings.json is ever touched. A refusal
357
+ // removes only a copy this call made, though: installHookCopy is a
358
+ // no-op when hookSourceDir already *is* configDir/hook/<version> --
359
+ // enable running from inside the copy already installed there, which
360
+ // is what hookSourceDir (derived from the running cli.js's own
361
+ // import.meta.url) gives once a copy is in place -- and then this call
362
+ // installed nothing, so a failed verification leaves that directory
363
+ // alone: it may be the very copy settings.json still points at from an
364
+ // earlier, successful enable, and it is not this call's to delete.
365
+ // installHookCopy's mkdirSync(..., { recursive: true }) creates every
366
+ // ancestor directory on the way down, though, so the one thing a
367
+ // first-ever run can still leave behind after refusing here is an
368
+ // empty configDir -- not removed, since it is the config directory
369
+ // itself, not anything this step wrote into it;
370
+ // 6. write config.json, then merge and write settings.json with the
371
+ // now-verified invocation. Everything before this point (refusing,
372
+ // declining, a failed verification) writes nothing new to disk -- step
373
+ // 5's own cleanup is what makes that true when a failed verification
374
+ // follows a real copy, down to the empty-configDir exception described
375
+ // there; when installHookCopy was instead a no-op, this call had
376
+ // nothing of its own to remove, and the pre-existing copy it leaves in
377
+ // place was never new. This step
378
+ // itself is not atomic across the two files, though: config.json is
379
+ // committed to disk first, so a
160
380
  // failure on the settings.json write right after -- disk full, a
161
381
  // permission error -- does leave a real credential saved with no
162
382
  // hook actually installed, i.e. capture silently not active despite
@@ -166,7 +386,22 @@ function readClaudeSettings(settingsPath) {
166
386
  // sets a nonzero exit code, so it is not silent; it fails toward
167
387
  // "capture not enabled" rather than a consent bypass; and re-running
168
388
  // `enable` repairs it, since writing config.json again is a plain
169
- // overwrite (capture.test.mjs's "running twice" test).
389
+ // overwrite (capture.test.mjs's "running twice" test);
390
+ // 7. prune every other hook/<version> copy now that settings.json points
391
+ // only at this one (pruneHookCopies) -- best-effort, never fatal, and
392
+ // only after the settings write so a prune failure can never leave
393
+ // settings.json pointing at a copy this step just deleted.
394
+ // 8. when deps.bootstrap.enabled (task 13, spec 011 §8): scan the machine
395
+ // for Claude Code's own memory files and transcripts (scanClaudeCode)
396
+ // and, only if that scan found something, print the one numbered list
397
+ // and ask the third and last question. This runs after the hooks are
398
+ // wired in and verified, deliberately: bootstrap is additional value
399
+ // once capture itself is real, not a precondition for it, and its own
400
+ // failure (a bad answer, a network error inside runBootstrap) must
401
+ // never undo a successful enable -- so nothing below this point can
402
+ // turn 'enabled' into anything else. An empty scan (nothing found, no
403
+ // user CLAUDE.md) says so and asks nothing at all: the person who has
404
+ // nothing to import should not be asked whether to skip importing it.
170
405
  export async function enable(url, deps) {
171
406
  if (!existsSync(deps.claudeDir)) {
172
407
  console.error(`Claude Code not found at ${deps.claudeDir} -- install Claude Code first, then run ` +
@@ -186,7 +421,35 @@ export async function enable(url, deps) {
186
421
  console.log('Capture not enabled -- nothing was written.');
187
422
  return 'declined';
188
423
  }
189
- const key = (await deps.prompt(`Paste an API key from ${KEYS_PAGE_URL} (it is shown once, at mint time): `)).trim();
424
+ const key = await askForKey(deps);
425
+ if (key === null) {
426
+ console.log('Capture not enabled -- no usable key was entered; nothing was written.');
427
+ return 'declined';
428
+ }
429
+ const indexPath = installHookCopy(deps.hookSourceDir, deps.configDir, deps.version);
430
+ const run = hookInvocation(deps.execPath, indexPath);
431
+ if (!deps.verifyHook(run)) {
432
+ // Undo the copy this call just installed -- but only if it made one.
433
+ // installHookCopy no-ops when hookSourceDir already *is* the target
434
+ // directory (enable re-run from inside the installed copy); deleting
435
+ // the target in that case would remove a pre-existing directory this
436
+ // call never wrote, possibly the very copy settings.json still points
437
+ // at from an earlier, successful enable.
438
+ if (resolve(deps.hookSourceDir) !== resolve(dirname(indexPath))) {
439
+ rmSync(dirname(indexPath), { recursive: true, force: true });
440
+ const hookRoot = join(deps.configDir, 'hook');
441
+ try {
442
+ if (readdirSync(hookRoot).length === 0)
443
+ rmSync(hookRoot, { recursive: true, force: true });
444
+ }
445
+ catch {
446
+ // hookRoot is already gone -- nothing left to prune
447
+ }
448
+ }
449
+ console.error(`The hook entry did not run (${run.command} ${(run.args ?? []).join(' ')}) -- ` +
450
+ `nothing was written.`);
451
+ return 'refused';
452
+ }
190
453
  mkdirSync(deps.configDir, { recursive: true }); // first run: ~/.waku-memory may not exist yet
191
454
  const configPath = join(deps.configDir, CONFIG_FILE_NAME);
192
455
  // {mode: 0o600} is a request, not a guarantee: Windows has no POSIX
@@ -199,11 +462,43 @@ export async function enable(url, deps) {
199
462
  // atomic temp-file-plus-rename technique earns its complexity there, not
200
463
  // here.
201
464
  writeFileSync(configPath, JSON.stringify({ url, key }, null, 2) + '\n', { mode: 0o600 });
202
- const merged = mergeHookSettings(existingSettings);
465
+ const merged = mergeHookSettings(existingSettings, run);
203
466
  deps.writeSettingsJson(deps.settingsPath, merged);
467
+ pruneHookCopies(deps.configDir, deps.version);
204
468
  console.log('');
205
469
  console.log(`Capture enabled -- wrote ${configPath} and merged hooks into ${deps.settingsPath}.`);
206
470
  console.log('Start a new Claude Code session to pick up the hooks.');
471
+ if (deps.bootstrap.enabled) {
472
+ const { windowDays } = deps.bootstrap;
473
+ const stateDir = join(deps.configDir, 'state');
474
+ const scan = scanClaudeCode(deps.claudeDir, stateDir, {
475
+ nowMs: Date.now(),
476
+ sinceMs: windowDays === null ? 0 : Date.now() - windowDays * 86_400_000,
477
+ liveWindowMs: LIVE_WINDOW_MS,
478
+ all: windowDays === null,
479
+ });
480
+ console.log('');
481
+ // renderBootstrapList already returns the one "nothing found" sentence
482
+ // for an empty scan (A12) -- reusing it here, rather than a second copy
483
+ // of that literal string, is what keeps the two from drifting apart.
484
+ // The question itself is skipped on exactly the same condition: a
485
+ // person with nothing to import should not be asked whether to skip it.
486
+ console.log(renderBootstrapList(scan, windowDays));
487
+ if (scan.projects.length > 0 || scan.userClaudeMd !== null) {
488
+ const selection = await askBootstrapSelection(scan.projects.length, deps.prompt);
489
+ const result = await runBootstrap(scan, selection, {
490
+ url,
491
+ key,
492
+ fetchImpl: deps.fetchImpl,
493
+ stateDir,
494
+ all: windowDays === null,
495
+ });
496
+ let line = `${result.memoryFiles} memory files queued, ${result.sessions} sessions queued in ${result.pieces} pieces`;
497
+ if (result.failed > 0)
498
+ line += `, ${result.failed} failed`;
499
+ console.log(line);
500
+ }
501
+ }
207
502
  return 'enabled';
208
503
  }
209
504
  // Removes exactly what enable() added and nothing else. Deliberately leaves