@lh8ppl/claude-memory-kit 0.2.2 → 0.2.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.
@@ -26,9 +26,9 @@ import { weeklyCurate } from './weekly-curate.mjs';
26
26
  import { autoPersona } from './auto-persona.mjs';
27
27
  import { exportPersona, importPersona } from './persona-portability.mjs';
28
28
  import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
29
- import { writeFact } from './write-fact.mjs';
30
- import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
31
- import { createHash } from 'node:crypto';
29
+ import { rememberRich, richFactTitle, nonProjectTierNote } from './remember-core.mjs';
30
+ import { getObservations, citeLink, buildTimeline, recentActivity } from './read-core.mjs';
31
+ import { readHookStdin } from './read-hook-stdin.mjs';
32
32
  import { runLazyCompress } from './lazy-compress.mjs';
33
33
  import { runDoctor } from './doctor.mjs';
34
34
  import { importAnthropicMemory } from './import-anthropic-memory.mjs';
@@ -61,7 +61,7 @@ import { resolveReviewQueue } from './review-queue.mjs';
61
61
  import { createInterface } from 'node:readline';
62
62
  import { resolve as resolvePath, join, basename } from 'node:path';
63
63
 
64
- const NOTICE_PREFIX = 'not yet implemented in v0.1.0';
64
+ const NOTICE_PREFIX = 'not yet implemented';
65
65
 
66
66
  /**
67
67
  * Real `cmk install` action — wired in Task 3, extended in Task 4 with
@@ -249,10 +249,11 @@ function runLessonsPromote(id, options = {}) {
249
249
  /**
250
250
  * `cmk search` — Task 30. Hybrid keyword + optional semantic.
251
251
  *
252
- * v0.1.0 ships the keyword backend (FTS5 BM25 over the observations
253
- * index). Semantic + hybrid modes require the Layer 5b memsearch+Milvus
254
- * install which isn't bundled in v0.1.0; both error with exit code 2
255
- * and a clear "memsearch not installed" hint per tasks.md 30.2.
252
+ * The keyword backend (FTS5 BM25 over the observations index) always
253
+ * ships. Semantic + hybrid modes require the Layer-5b semantic backend,
254
+ * which is not yet shipped; both error with exit code 2 and a clear
255
+ * "not yet shipped" hint per tasks.md 30.2. The `semanticBackend` DI seam
256
+ * is the drop-in point for the future backend.
256
257
  *
257
258
  * Filter flags (per tasks.md 30.4):
258
259
  * --mode <keyword|semantic|hybrid> (default keyword)
@@ -323,6 +324,99 @@ function runSearch(queryParts, options) {
323
324
  }
324
325
  }
325
326
 
327
+ // --- Read verbs (Task 108b — CLI parity with the MCP read tools) ------
328
+ //
329
+ // `cmk get` / `timeline` / `cite` / `recent-activity` mirror the MCP tools
330
+ // mk_get / mk_timeline / mk_cite / mk_recent_activity by calling the SAME
331
+ // shared read cores (read-core.mjs) — identical results from CLI + MCP
332
+ // (ADR-0014). cite is pure (no DB); the rest open the index + reindex first
333
+ // (same fresh-install freshness guard as `cmk search`).
334
+
335
+ // `deps` (projectRoot / log / logError) are injection seams: production passes
336
+ // nothing (defaults to cwd + console), in-process tests pass a temp projectRoot
337
+ // + captured loggers so the glue is covered WITHOUT a subprocess (the D-86
338
+ // lesson — real-binary tests don't contribute line coverage). Exported for the
339
+ // unit tests.
340
+
341
+ /** Open the index DB, refresh it (best-effort), run `fn(db)`, always close. */
342
+ export function withReadDb(fn, deps = {}) {
343
+ const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
344
+ const userDir =
345
+ deps.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
346
+ const logError = deps.logError ?? console.error;
347
+ const db = openIndexDb({ projectRoot });
348
+ try {
349
+ try {
350
+ reindexBoot({ projectRoot, userDir, db });
351
+ } catch (err) {
352
+ logError(`cmk: index refresh failed (${err?.message ?? err}); using the existing index.`);
353
+ }
354
+ return fn(db);
355
+ } finally {
356
+ db.close();
357
+ }
358
+ }
359
+
360
+ export function runGet(ids, _options = {}, _command, deps = {}) {
361
+ const log = deps.log ?? console.log;
362
+ const list = Array.isArray(ids) ? ids : [ids];
363
+ const rows = withReadDb((db) => getObservations(db, list), deps);
364
+ log(JSON.stringify(rows, null, 2));
365
+ // All-missing/invalid → exit 2 (lets a script tell "nothing matched" from a hit).
366
+ if (rows.length > 0 && rows.every((r) => r.error)) process.exitCode = 2;
367
+ }
368
+
369
+ export function runCite(id, _options = {}, _command, deps = {}) {
370
+ const log = deps.log ?? console.log;
371
+ const logError = deps.logError ?? console.error;
372
+ const r = citeLink(id);
373
+ if (!r.ok) {
374
+ logError(`cmk cite: ${r.error}`);
375
+ process.exitCode = 2;
376
+ return;
377
+ }
378
+ log(r.link);
379
+ }
380
+
381
+ export function runTimeline(anchor, options = {}, _command, deps = {}) {
382
+ const log = deps.log ?? console.log;
383
+ const logError = deps.logError ?? console.error;
384
+ const r = withReadDb(
385
+ (db) =>
386
+ buildTimeline(db, {
387
+ anchor,
388
+ depthBefore: options.before !== undefined ? Number(options.before) : 5,
389
+ depthAfter: options.after !== undefined ? Number(options.after) : 5,
390
+ }),
391
+ deps,
392
+ );
393
+ if (!r.ok) {
394
+ logError(`cmk timeline: ${r.error}`);
395
+ process.exitCode = 2;
396
+ return;
397
+ }
398
+ log(JSON.stringify(r.timeline, null, 2));
399
+ }
400
+
401
+ export function runRecentActivity(options = {}, _command, deps = {}) {
402
+ const log = deps.log ?? console.log;
403
+ const logError = deps.logError ?? console.error;
404
+ const r = withReadDb(
405
+ (db) =>
406
+ recentActivity(db, {
407
+ window: options.window ?? '24h',
408
+ limit: options.limit !== undefined ? Number(options.limit) : 20,
409
+ }),
410
+ deps,
411
+ );
412
+ if (!r.ok) {
413
+ logError(`cmk recent-activity: ${r.error}`);
414
+ process.exitCode = 2;
415
+ return;
416
+ }
417
+ log(JSON.stringify(r.rows, null, 2));
418
+ }
419
+
326
420
  /**
327
421
  * `cmk reindex` — three modes.
328
422
  *
@@ -365,57 +459,27 @@ function runSearch(queryParts, options) {
365
459
  * carries injection seams for testing.
366
460
  */
367
461
  export function runRememberRich(text, options = {}, deps = {}) {
368
- const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
369
462
  const log = deps.log ?? console.log;
370
463
  const logError = deps.logError ?? console.error;
371
- const write = deps.writeFact ?? writeFact;
372
464
 
373
- // M2: rich capture writes the project tier (P) in v0.1.x same deferral as
374
- // the terse path + mk_remember (U/L need per-tier scratchpad routing, design
375
- // §16). Terse mode ERRORS on a non-P --tier; rich mode notes it and proceeds
376
- // (a no-write surprise is worse than a captured-to-P note). Surface it so the
377
- // divergence isn't silent.
465
+ // Non-P --tier: capture at the project tier (P) + surface the note (don't
466
+ // silently honor it, don't hard-error). ONE shared note across CLI + MCP so
467
+ // the message can't drift (D-102 / Task 121).
378
468
  if (options.tier && options.tier !== 'P') {
379
- log(
380
- `cmk remember: --tier '${options.tier}' is v0.1.x — rich capture writes the project tier (P) for now.`,
381
- );
469
+ log(`cmk remember: ${nonProjectTierNote(options.tier)}`);
382
470
  }
383
471
 
384
- const headline = String(text).trim();
385
- const title = (options.title && String(options.title).trim()) || headline.split('\n')[0].slice(0, 80);
386
- const body = buildRichFactBody({ text: headline, why: options.why, how: options.how });
387
- const related = options.links
388
- ? String(options.links).split(',').map((s) => s.trim()).filter(Boolean)
389
- : undefined;
390
-
391
- const r = write({
392
- tier: 'P',
393
- type: options.type ?? 'feedback',
394
- slug: slugifyFact(title),
395
- title,
396
- body,
397
- writeSource: 'user-explicit',
398
- trust: options.trust ?? 'high',
399
- sourceFile: 'user-explicit',
400
- sourceLine: 1,
401
- // Content fingerprint for provenance/dedup — NOT a security context.
402
- // Matches the kit's sha1-of-content convention (memory-write.mjs,
403
- // index-rebuild.mjs); writeFact dedups by content-addressed id, this is
404
- // just the source_sha1 provenance field. // NOSONAR
405
- sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
406
-
407
- related,
408
- projectRoot,
409
- });
472
+ // The write is the shared core (remember-core.rememberRich) — the SAME one the
473
+ // MCP `mk_remember` rich path calls, so both surfaces emit identical fact files
474
+ // (ADR-0014). This wrapper only formats the CLI's messages from the result.
475
+ const r = rememberRich(text, options, deps);
410
476
 
411
477
  if (r.action === 'error') {
412
- // M1: a collision means a fact file with this title (→ slug) already exists
413
- // but with different content (different id). Give an actionable hint rather
414
- // than the raw "refusing overwrite" — the user almost certainly wants to
415
- // edit the existing fact or pick a new --title.
416
478
  if (r.errorCategory === 'collision') {
479
+ // M1: a same-title / different-content collision — actionable hint over
480
+ // the raw "refusing overwrite".
417
481
  logError(
418
- `cmk remember: a fact titled "${title}" already exists with different content. ` +
482
+ `cmk remember: a fact titled "${richFactTitle(text, options)}" already exists with different content. ` +
419
483
  `Edit it directly, or capture under a new --title.`,
420
484
  );
421
485
  return r;
@@ -431,11 +495,128 @@ export function runRememberRich(text, options = {}, deps = {}) {
431
495
  return r;
432
496
  }
433
497
 
434
- function runRemember(textParts, options) {
435
- const projectRoot = resolvePath(process.cwd());
498
+ /**
499
+ * Task 108.2 (108a) — parse a structured fact from the off-shell input channel
500
+ * (`--from-file <path>` or `--json` stdin). PURE + dependency-injected (the CLI
501
+ * passes real fs readers; tests pass fakes) so every parse/validate/allowlist
502
+ * branch is covered IN-PROCESS — the real-binary subprocess tests prove the CLI
503
+ * wiring but don't contribute line coverage.
504
+ *
505
+ * @param {object} options - subcommand options (fromFile/json + the rich flags).
506
+ * @param {object} deps
507
+ * @param {(path:string)=>string} deps.readFile - read a file to a string (throws on error).
508
+ * @param {()=>string} deps.readStdin - read stdin to a string ('' for TTY/empty).
509
+ * @returns {{ok:true,channel:string,fields:object,ignored:string[]}
510
+ * | {ok:false,channel:string,error:string,ignored:string[]}}
511
+ */
512
+ export function parseFactInput(options, { readFile, readStdin } = {}) {
513
+ const channel = options.fromFile ? '--from-file' : '--json';
514
+ // --from-file/--json are self-contained (the JSON is the whole fact); rich /
515
+ // terse flags passed alongside are ignored — surfaced so they aren't dropped silently.
516
+ const ignored = ['why', 'how', 'type', 'title', 'links', 'tier', 'trust', 'section']
517
+ .filter((k) => options[k] != null)
518
+ .map((k) => '--' + k);
519
+ const fail = (error) => ({ ok: false, channel, error, ignored });
520
+
521
+ let raw;
522
+ if (options.fromFile) {
523
+ try {
524
+ raw = readFile(options.fromFile);
525
+ } catch (e) {
526
+ return fail(`${channel} could not read ${options.fromFile}: ${e.message}`);
527
+ }
528
+ } else {
529
+ // '' = interactive TTY or empty pipe (read-hook-stdin returns '' for a TTY).
530
+ raw = readStdin();
531
+ if (!raw || !raw.trim()) {
532
+ return fail('--json expects a JSON object on stdin (pipe it in, or use --from-file).');
533
+ }
534
+ }
535
+
536
+ // Bound the input so a pathological file can't burn Poison_Guard regex time
537
+ // (the M1 concern that capped mk_remember). 64 KB is generous for one fact.
538
+ const MAX_INPUT_BYTES = 64 * 1024;
539
+ if (Buffer.byteLength(raw, 'utf8') > MAX_INPUT_BYTES) {
540
+ return fail(`${channel} fact is too large (max ${MAX_INPUT_BYTES / 1024} KB). Split it into smaller facts.`);
541
+ }
542
+
543
+ let parsed;
544
+ try {
545
+ parsed = JSON.parse(raw);
546
+ } catch (e) {
547
+ return fail(`${channel} could not parse JSON: ${e.message}`);
548
+ }
549
+ if (
550
+ !parsed ||
551
+ typeof parsed !== 'object' ||
552
+ Array.isArray(parsed) ||
553
+ typeof parsed.text !== 'string' ||
554
+ !parsed.text.trim()
555
+ ) {
556
+ return fail(`${channel} JSON must be an object with a non-empty "text" field.`);
557
+ }
558
+
559
+ // Allowlist the honored fields — NEVER forward the raw parsed object. A crafted
560
+ // JSON must not reach a field runRememberRich might read; provenance
561
+ // (write_source / source_file) stays hardcoded user-explicit in runRememberRich.
562
+ return {
563
+ ok: true,
564
+ channel,
565
+ ignored,
566
+ fields: {
567
+ text: parsed.text,
568
+ why: parsed.why,
569
+ how: parsed.how,
570
+ type: parsed.type,
571
+ title: parsed.title,
572
+ links: parsed.links,
573
+ tier: parsed.tier,
574
+ trust: parsed.trust,
575
+ },
576
+ };
577
+ }
578
+
579
+ export function runRemember(textParts, options, deps = {}) {
580
+ const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
436
581
  const userDir =
437
- process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
582
+ deps.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
583
+ const log = deps.log ?? console.log;
584
+ const logError = deps.logError ?? console.error;
585
+
586
+ // Task 108.2 (108a) — structured off-shell input. `--from-file`/`--json` carry
587
+ // the fact as a JSON object from a FILE or STDIN, so rich content (backticks,
588
+ // $(), quotes, newlines) never rides the shell command line — the D-81 fix.
589
+ // The parse/validate/allowlist lives in the pure parseFactInput() helper.
590
+ if (options?.fromFile || options?.json) {
591
+ const parsed = parseFactInput(options, {
592
+ readFile: (p) => readFileSync(p, 'utf8'),
593
+ readStdin: () => readHookStdin({ isTTY: process.stdin.isTTY }),
594
+ });
595
+ if (parsed.ignored.length) {
596
+ logError(
597
+ `cmk remember: ${parsed.channel} is self-contained — ignoring ${parsed.ignored.join(', ')} (put these in the JSON instead).`,
598
+ );
599
+ }
600
+ if (!parsed.ok) {
601
+ logError(`cmk remember: ${parsed.error}`);
602
+ process.exitCode = 2;
603
+ return;
604
+ }
605
+ runRememberRich(parsed.fields.text, parsed.fields, { projectRoot, log, logError });
606
+ return;
607
+ }
608
+
438
609
  const text = Array.isArray(textParts) ? textParts.join(' ') : textParts;
610
+ // Bare `cmk remember` — no positional text and no input channel. The positional
611
+ // arg is optional now (for --from-file/--json), so guard explicitly instead of
612
+ // falling through to a vague empty-text write error.
613
+ if (!text || !String(text).trim()) {
614
+ logError(
615
+ 'cmk remember: provide a fact to remember, or use --from-file <json> / --json (stdin).',
616
+ );
617
+ process.exitCode = 2;
618
+ return;
619
+ }
439
620
  // Rich mode: any of --why/--how/--type/--title/--links → write a real fact
440
621
  // file (the F1 fix) instead of a terse MEMORY.md bullet. M3: --trust and
441
622
  // --section are intentionally NOT triggers — --trust is shared by both forms
@@ -445,15 +626,15 @@ function runRemember(textParts, options) {
445
626
  runRememberRich(text, options, { projectRoot });
446
627
  return;
447
628
  }
448
- const tier = options?.tier ?? 'P';
449
- if (tier !== 'P') {
450
- console.error(
451
- `cmk remember: tier '${tier}' not yet supported v0.1.0 writes the project tier (P). ` +
452
- 'For machine-only config, edit context.local/machine-paths.md directly (v0.1.x will add --tier routing).',
453
- );
454
- process.exitCode = 2;
455
- return;
629
+ // Non-P --tier: capture at P + note (consistent with the rich path + the MCP
630
+ // tool D-102). A fact becomes cross-project via `cmk lessons promote`, not a
631
+ // direct tier write (direct U/L routing is the deferred feature in design §16.40). We do NOT
632
+ // hard-error losing the capture to an error is worse than landing it at P.
633
+ const requestedTier = options?.tier ?? 'P';
634
+ if (requestedTier !== 'P') {
635
+ log(`cmk remember: ${nonProjectTierNote(requestedTier)}`);
456
636
  }
637
+ const tier = 'P';
457
638
  const trust = options?.trust ?? 'high';
458
639
  const section = options?.section ?? 'Active Threads';
459
640
  const r = memoryWrite({
@@ -547,6 +728,11 @@ function runForget(idOrQuery, options /* , command */) {
547
728
  const result = forgetAction({
548
729
  idOrQuery,
549
730
  projectRoot,
731
+ // Pass the resolved userDir (same source as `cmk search`) so forget's
732
+ // in-band reindex covers all three tiers and its orphan-prune fires
733
+ // IMMEDIATELY (Task 110) — without it the prune is skipped here and only
734
+ // self-heals on the next search. Also lets forget tombstone U-tier facts.
735
+ userDir: resolveUserDir(),
550
736
  reason: options.reason,
551
737
  deletedBy: options.deletedBy,
552
738
  yes: true,
@@ -667,11 +853,19 @@ export async function runPersonaGenerate(opts = {}) {
667
853
  try {
668
854
  const backend =
669
855
  opts.backend ?? new (await import('./compressor.mjs')).HaikuViaAnthropicApi();
670
- const r = await autoPersona({ projectRoot, userDir, backend });
856
+ // Task 111 (F-2): `cmk persona generate` is an explicit one-shot with NO outer
857
+ // hook ceiling (unlike the 60s-bounded SessionEnd path), so it gives the Haiku
858
+ // classifier generous headroom — the whole-project facts sweep is a heavier
859
+ // call than a session summary, and the user is willing to wait for the command
860
+ // they ran. The corpus is byte-capped (PERSONA_CORPUS_BYTES) so this can't run
861
+ // unbounded. Overridable via opts.timeoutMs.
862
+ const r = await autoPersona({ projectRoot, userDir, backend, timeoutMs: opts.timeoutMs ?? 120_000 });
671
863
  if (r.action === 'error') {
672
- logError(
673
- `cmk persona generate: error (${r.errorCategory ?? 'unknown'})${(r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : ''}`,
674
- );
864
+ const detail = (r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : '';
865
+ const hint = /did not return within/.test(detail)
866
+ ? ' — the Haiku classifier timed out; this is usually a transient API slowdown. Re-run `cmk persona generate` (the weekly curate pass also retries it automatically).'
867
+ : '';
868
+ logError(`cmk persona generate: error (${r.errorCategory ?? 'unknown'})${detail}${hint}`);
675
869
  return;
676
870
  }
677
871
  const promoted = r.promoted?.length ?? 0;
@@ -795,9 +989,9 @@ function runRegisterCrons(options /* , command */) {
795
989
  // Helper: quote a path for the platform's cron-line shell.
796
990
  // Linux + macOS: double-quote (the cron line is single-quoted around the
797
991
  // whole `echo '...'`; double-quotes inside are safe).
798
- // Windows: the schtasks /TR value is already double-quoted by registerCron,
799
- // with `\"` escaping for inner quotesregisterCron's existing
800
- // escapedCommand handles this.
992
+ // Windows: registerCron execs schtasks with an args array (no shell), so this
993
+ // double-quoted /TR value is delivered verbatimno escaping needed (Task 109
994
+ // / D-83). macOS strips these wrapping quotes before building the plist.
801
995
  const quote = (s) => `"${s}"`;
802
996
 
803
997
  const jobs = [
@@ -988,40 +1182,44 @@ async function runRollCli(options /* , command */) {
988
1182
  }
989
1183
  }
990
1184
 
991
- async function runImportAnthropicMemory(options /* , command */) {
992
- const projectRoot = resolvePath(process.cwd());
1185
+ // Task 114 (F-13): dep-injectable (projectRoot / harnessRoot / log / logError) so
1186
+ // the real-import CLI path is verifiable on real input WITHOUT touching the user's
1187
+ // ~/.claude. Defaults are unchanged for production. Returns the core result.
1188
+ export async function runImportAnthropicMemory(options = {}) {
1189
+ const projectRoot = options?.projectRoot ?? resolvePath(process.cwd());
1190
+ const log = options?.log ?? console.log;
1191
+ const logError = options?.logError ?? console.error;
993
1192
  const dryRun = options?.dryRun === true;
994
1193
  const acceptAll = options?.yes === true;
1194
+ // options.importFn is a test seam (default = the real core) so the error +
1195
+ // catch branches below — unreachable via normal input — are coverable.
1196
+ const importFn = options?.importFn ?? importAnthropicMemory;
995
1197
  try {
996
- // I1 fix (skill-review 2026-05-28): userDir was unused, dropped.
997
- const r = await importAnthropicMemory({ projectRoot, dryRun, acceptAll });
1198
+ const r = await importFn({ projectRoot, dryRun, acceptAll, harnessRoot: options?.harnessRoot });
998
1199
  if (r.action === 'error') {
999
- console.error(`cmk import-anthropic-memory: error — ${(r.errors ?? []).join('; ')}`);
1200
+ logError(`cmk import-anthropic-memory: error — ${(r.errors ?? []).join('; ')}`);
1000
1201
  process.exitCode = 2;
1001
- return;
1202
+ return r;
1002
1203
  }
1003
1204
  if (r.reason === 'no-source') {
1004
- console.log(`cmk import-anthropic-memory: no Anthropic auto-memory found at ${r.sourcePath}`);
1005
- return;
1205
+ log(`cmk import-anthropic-memory: no Anthropic auto-memory found at ${r.sourcePath}`);
1206
+ return r;
1006
1207
  }
1007
1208
  if (r.mode === 'dry-run') {
1008
- console.log(`cmk import-anthropic-memory: dry-run — ${r.proposals.length} proposal(s), ${r.skipped} duplicate(s) skipped`);
1009
- for (const p of r.proposals) {
1010
- console.log(` + ${p.id}: ${p.text}`);
1011
- }
1012
- return;
1209
+ log(`cmk import-anthropic-memory: dry-run — ${r.proposals.length} proposal(s), ${r.skipped} duplicate(s) skipped`);
1210
+ for (const p of r.proposals) log(` + ${p.id}: ${p.text}`);
1211
+ return r;
1013
1212
  }
1014
1213
  if (r.mode === 'requires-confirmation') {
1015
- console.log(`cmk import-anthropic-memory: ${r.proposals.length} proposal(s) ready to apply.`);
1016
- console.log(' Re-run with --yes to apply, or --dry-run to inspect.');
1017
- for (const p of r.proposals) {
1018
- console.log(` + ${p.id}: ${p.text}`);
1019
- }
1020
- return;
1214
+ log(`cmk import-anthropic-memory: ${r.proposals.length} proposal(s) ready to apply.`);
1215
+ log(' Re-run with --yes to apply, or --dry-run to inspect.');
1216
+ for (const p of r.proposals) log(` + ${p.id}: ${p.text}`);
1217
+ return r;
1021
1218
  }
1022
- console.log(`cmk import-anthropic-memory: applied ${r.accepted} proposal(s), skipped ${r.skipped} duplicate(s)`);
1219
+ log(`cmk import-anthropic-memory: applied ${r.accepted} proposal(s), skipped ${r.skipped} duplicate(s)`);
1220
+ return r;
1023
1221
  } catch (err) {
1024
- console.error(`cmk import-anthropic-memory: unexpected error: ${err?.message ?? err}`);
1222
+ logError(`cmk import-anthropic-memory: unexpected error: ${err?.message ?? err}`);
1025
1223
  process.exitCode = 2;
1026
1224
  }
1027
1225
  }
@@ -1131,8 +1329,12 @@ async function runCompress(options /* , command */) {
1131
1329
 
1132
1330
  async function runMcpDispatch(childName) {
1133
1331
  if (childName === 'serve') {
1134
- const projectRoot = resolvePath(process.cwd());
1135
- const userDir = join(homedir(), '.claude-memory-kit');
1332
+ // Claude Code sets CLAUDE_PROJECT_DIR in the spawned MCP server's environment
1333
+ // to the project root (code.claude.com/docs/en/mcp). Prefer it over cwd so the
1334
+ // server indexes the right project even when Claude Code launches it with a
1335
+ // different working directory. Falls back to cwd for a manual `cmk mcp serve`.
1336
+ const projectRoot = resolvePath(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
1337
+ const userDir = process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
1136
1338
  // ALL logs to stderr per design §10.1; stdout is reserved for
1137
1339
  // JSON-RPC messages handled by the SDK's StdioServerTransport.
1138
1340
  // Don't console.log() anything before/during the server's run.
@@ -1169,43 +1371,44 @@ async function runQueueDispatch(childName) {
1169
1371
  * canonical kit usage). User-tier / language-tier conflicts queues
1170
1372
  * can be added when the kit's CLI gains explicit `--tier` selection.
1171
1373
  */
1172
- async function runQueueConflicts() {
1173
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1174
- const askOnce = (q) =>
1175
- new Promise((resolve) => {
1176
- rl.question(q, (answer) => resolve(answer));
1177
- });
1178
-
1179
- const VALID_DECISIONS = new Set(['keep-old', 'keep-new', 'merge-both', 'skip']);
1180
-
1181
- const prompter = async ({
1182
- proposedId,
1183
- proposedText,
1184
- proposedTrust,
1185
- existingId,
1186
- existingText,
1187
- existingTrust,
1188
- similarity,
1189
- }) => {
1190
- console.log('');
1191
- console.log('─── pending conflict ──────────────────────────────────────');
1192
- console.log(`existing (${existingId}, trust=${existingTrust}): ${existingText}`);
1193
- console.log(`proposed (${proposedId}, trust=${proposedTrust}): ${proposedText}`);
1194
- console.log(`similarity: ${Number(similarity).toFixed(4)}`);
1374
+ // Task 113 (F-9): conflict prompter LOGIC extracted as a factory over `ask`
1375
+ // (see buildReviewPrompter) so it's unit-testable without stdin.
1376
+ export function buildConflictPrompter({ ask, log }) {
1377
+ const VALID = new Set(['keep-old', 'keep-new', 'merge-both', 'skip']);
1378
+ return async ({ proposedId, proposedText, proposedTrust, existingId, existingText, existingTrust, similarity }) => {
1379
+ log('');
1380
+ log('─── pending conflict ──────────────────────────────────────');
1381
+ log(`existing (${existingId}, trust=${existingTrust}): ${existingText}`);
1382
+ log(`proposed (${proposedId}, trust=${proposedTrust}): ${proposedText}`);
1383
+ log(`similarity: ${Number(similarity).toFixed(4)}`);
1195
1384
  let decision = '';
1196
- while (!VALID_DECISIONS.has(decision)) {
1197
- const answer = await askOnce(
1198
- ` [keep-old / keep-new / merge-both / skip]: `,
1199
- );
1385
+ while (!VALID.has(decision)) {
1386
+ const answer = await ask(` [keep-old / keep-new / merge-both / skip]: `);
1200
1387
  decision = String(answer).trim();
1201
- if (!VALID_DECISIONS.has(decision)) {
1202
- console.log(
1203
- ` unknown answer "${decision}" — please type one of: keep-old, keep-new, merge-both, skip`,
1204
- );
1388
+ if (!VALID.has(decision)) {
1389
+ log(` unknown answer "${decision}" — please type one of: keep-old, keep-new, merge-both, skip`);
1205
1390
  }
1206
1391
  }
1207
1392
  return decision;
1208
1393
  };
1394
+ }
1395
+
1396
+ // Task 113 (F-9): dep-injectable (see runQueueReview). Defaults unchanged for prod;
1397
+ // a test injects { projectRoot, prompter, log, logError } to drive a real keep-old
1398
+ // / keep-new / merge-both resolution end-to-end without stdin. Returns the result.
1399
+ export async function runQueueConflicts(opts = {}) {
1400
+ const projectRoot = opts.projectRoot ?? process.cwd();
1401
+ const log = opts.log ?? console.log;
1402
+ const logError = opts.logError ?? console.error;
1403
+
1404
+ let rl = null;
1405
+ let prompter = opts.prompter;
1406
+ if (!prompter) {
1407
+ rl = createInterface({ input: process.stdin, output: process.stdout });
1408
+ const askOnce = (q) =>
1409
+ new Promise((resolve) => rl.question(q, (answer) => resolve(answer)));
1410
+ prompter = buildConflictPrompter({ ask: askOnce, log });
1411
+ }
1209
1412
 
1210
1413
  // merge-both wiring (Task 25b — closes Task 25's cross-layer
1211
1414
  // composition gap). The proposed bullet from the conflict queue
@@ -1251,34 +1454,35 @@ async function runQueueConflicts() {
1251
1454
  idB: proposedId,
1252
1455
  });
1253
1456
  if (result.action === 'error') {
1254
- console.error(
1457
+ logError(
1255
1458
  `cmk queue conflicts: merge-both for ${existingId} + ${proposedId} failed: ${result.errors.join('; ')}`,
1256
1459
  );
1257
1460
  } else {
1258
- console.log(
1259
- ` merge-both → ${existingId} + ${proposedId} merged into ${result.id}`,
1260
- );
1461
+ log(` merge-both → ${existingId} + ${proposedId} merged into ${result.id}`);
1261
1462
  }
1262
1463
  };
1263
1464
 
1465
+ // opts.resolve test seam (default = the real resolver) — see runQueueReview.
1466
+ const resolve = opts.resolve ?? resolveConflictQueue;
1264
1467
  try {
1265
- const result = await resolveConflictQueue({
1468
+ const result = await resolve({
1266
1469
  tier: 'P',
1267
- projectRoot: process.cwd(),
1470
+ projectRoot,
1268
1471
  prompter,
1269
1472
  mergeFn,
1270
1473
  });
1271
1474
  if (result.action === 'error') {
1272
- for (const e of result.errors) console.error(`cmk queue conflicts: ${e}`);
1475
+ for (const e of result.errors) logError(`cmk queue conflicts: ${e}`);
1273
1476
  process.exitCode = 2;
1274
- return;
1477
+ return result;
1275
1478
  }
1276
- console.log('');
1277
- console.log(
1479
+ log('');
1480
+ log(
1278
1481
  `cmk queue conflicts: ${result.resolved} resolved (${result.kept_old} kept-old, ${result.kept_new} kept-new, ${result.merged} merged), ${result.skipped} skipped`,
1279
1482
  );
1483
+ return result;
1280
1484
  } finally {
1281
- rl.close();
1485
+ if (rl) rl.close();
1282
1486
  }
1283
1487
  }
1284
1488
 
@@ -1291,59 +1495,74 @@ async function runQueueConflicts() {
1291
1495
  *
1292
1496
  * Resolves the PROJECT tier's review queue (the canonical kit usage).
1293
1497
  */
1294
- async function runQueueReview() {
1295
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1296
- const askOnce = (q) =>
1297
- new Promise((resolve) => {
1298
- rl.question(q, (answer) => resolve(answer));
1299
- });
1300
-
1301
- const VALID_DECISIONS = new Set(['promote', 'discard', 'skip']);
1302
-
1303
- const prompter = async ({ id, text, ts, provenance }) => {
1304
- console.log('');
1305
- console.log('─── pending review ────────────────────────────────────────');
1306
- console.log(`id: ${id}`);
1307
- console.log(`ts: ${ts}`);
1308
- console.log(`text: ${text}`);
1309
- if (provenance) console.log(`prov: ${provenance.trim()}`);
1498
+ // Task 113 (F-9): the interactive prompter LOGIC (formatting + the validate-retry
1499
+ // loop) extracted as a factory over an injectable `ask`, so it's unit-testable
1500
+ // without a real readline/stdin — only the 2-line createInterface wrapper in the
1501
+ // runner stays an uncovered shim.
1502
+ export function buildReviewPrompter({ ask, log }) {
1503
+ const VALID = new Set(['promote', 'discard', 'skip']);
1504
+ return async ({ id, text, ts, provenance }) => {
1505
+ log('');
1506
+ log('─── pending review ────────────────────────────────────────');
1507
+ log(`id: ${id}`);
1508
+ log(`ts: ${ts}`);
1509
+ log(`text: ${text}`);
1510
+ if (provenance) log(`prov: ${provenance.trim()}`);
1310
1511
  let decision = '';
1311
- while (!VALID_DECISIONS.has(decision)) {
1312
- const answer = await askOnce(` [promote / discard / skip]: `);
1512
+ while (!VALID.has(decision)) {
1513
+ const answer = await ask(` [promote / discard / skip]: `);
1313
1514
  decision = String(answer).trim();
1314
- if (!VALID_DECISIONS.has(decision)) {
1315
- console.log(
1316
- ` unknown answer "${decision}" — please type one of: promote, discard, skip`,
1317
- );
1515
+ if (!VALID.has(decision)) {
1516
+ log(` unknown answer "${decision}" — please type one of: promote, discard, skip`);
1318
1517
  }
1319
1518
  }
1320
1519
  return decision;
1321
1520
  };
1521
+ }
1522
+
1523
+ // Task 113 (F-9): dep-injectable so the CLI resolution path is verifiable on REAL
1524
+ // queued items. Defaults (readline over stdin + cwd + console) are unchanged for
1525
+ // production; a test injects { projectRoot, prompter, log, logError } to drive a
1526
+ // real promote/discard end-to-end without stdin. Returns the resolver result.
1527
+ export async function runQueueReview(opts = {}) {
1528
+ const projectRoot = opts.projectRoot ?? process.cwd();
1529
+ const log = opts.log ?? console.log;
1530
+ const logError = opts.logError ?? console.error;
1322
1531
 
1532
+ // Build the interactive prompter only when the caller didn't inject one — so a
1533
+ // test never opens a real readline on stdin.
1534
+ let rl = null;
1535
+ let prompter = opts.prompter;
1536
+ if (!prompter) {
1537
+ rl = createInterface({ input: process.stdin, output: process.stdout });
1538
+ const askOnce = (q) =>
1539
+ new Promise((resolve) => rl.question(q, (answer) => resolve(answer)));
1540
+ prompter = buildReviewPrompter({ ask: askOnce, log });
1541
+ }
1542
+
1543
+ // opts.resolve is a test seam (default = the real resolver) so the error-
1544
+ // handling branches below — unreachable via normal input since tier/prompter
1545
+ // are always valid here — are coverable (Task 113).
1546
+ const resolve = opts.resolve ?? resolveReviewQueue;
1323
1547
  try {
1324
- const result = await resolveReviewQueue({
1325
- tier: 'P',
1326
- projectRoot: process.cwd(),
1327
- prompter,
1328
- });
1548
+ const result = await resolve({ tier: 'P', projectRoot, prompter });
1329
1549
  if (result.action === 'error') {
1330
- for (const e of result.errors) console.error(`cmk queue review: ${e}`);
1550
+ for (const e of result.errors) logError(`cmk queue review: ${e}`);
1331
1551
  process.exitCode = 2;
1332
- return;
1552
+ return result;
1333
1553
  }
1334
- console.log('');
1335
- console.log(
1554
+ log('');
1555
+ log(
1336
1556
  `cmk queue review: ${result.promoted} promoted, ${result.discarded} discarded, ${result.skipped} skipped${result.errors && result.errors.length ? `, ${result.errors.length} errored` : ''}`,
1337
1557
  );
1338
1558
  if (result.errors && result.errors.length) {
1339
1559
  for (const err of result.errors) {
1340
- console.error(
1341
- ` error on ${err.id} (${err.decision}): ${err.errors.join('; ')}`,
1342
- );
1560
+ logError(` error on ${err.id} (${err.decision}): ${err.errors.join('; ')}`);
1343
1561
  }
1344
1562
  }
1563
+ return result;
1345
1564
  } finally {
1346
- rl.close();
1565
+ if (rl) rl.close();
1347
1566
  }
1348
1567
  }
1349
1568
 
@@ -1412,7 +1631,7 @@ export const subcommands = [
1412
1631
  name: 'remember',
1413
1632
  description: 'capture a durable fact (Poison_Guard + home-path abstraction). Terse → a MEMORY.md bullet; RICH (--why/--how/--type) → a granular fact file with rationale (the safe way to capture richly).',
1414
1633
  milestone: 24,
1415
- argSpec: [{ flags: '<text...>', description: 'the fact to remember' }],
1634
+ argSpec: [{ flags: '[text...]', description: 'the fact to remember (omit when using --from-file)' }],
1416
1635
  optionSpec: [
1417
1636
  { flags: '--tier <tier>', description: 'P (default; U/L are v0.1.x)' },
1418
1637
  { flags: '--trust <level>', description: 'high | medium | low (default: high)' },
@@ -1423,6 +1642,8 @@ export const subcommands = [
1423
1642
  { flags: '--type <type>', description: 'rich: feedback | project | reference | user (default: feedback)' },
1424
1643
  { flags: '--title <text>', description: 'rich: a short title (also the fact-file slug)' },
1425
1644
  { flags: '--links <a,b>', description: 'rich: related fact names for [[cross-links]]' },
1645
+ { flags: '--from-file <path>', description: 'rich: read the fact as a JSON object from a file — shell-safe (content never touches argv; the safe way to capture backtick/quote-heavy Why/How). JSON keys: text (required), why, how, type, title, links. Self-contained — other flags are ignored.' },
1646
+ { flags: '--json', description: 'rich: read the fact as a JSON object from stdin (pipe-safe, shell-safe) — same JSON keys as --from-file' },
1426
1647
  ],
1427
1648
  action: runRemember,
1428
1649
  },
@@ -1432,7 +1653,7 @@ export const subcommands = [
1432
1653
  milestone: 30,
1433
1654
  argSpec: [{ flags: '<query...>', description: 'query terms' }],
1434
1655
  optionSpec: [
1435
- { flags: '--mode <mode>', description: 'keyword | semantic | hybrid (default: keyword; semantic+hybrid need memsearch Layer 5b install, not in v0.1.0)' },
1656
+ { flags: '--mode <mode>', description: 'keyword | semantic | hybrid (default: keyword; semantic + hybrid need the Layer-5b semantic backend, not yet shipped)' },
1436
1657
  { flags: '--min-trust <level>', description: 'low | medium | high' },
1437
1658
  { flags: '--tier <tier>', description: 'U | P | L (filter to a single tier)' },
1438
1659
  { flags: '--since <date>', description: 'ISO date — exclude observations older than this' },
@@ -1441,6 +1662,41 @@ export const subcommands = [
1441
1662
  ],
1442
1663
  action: runSearch,
1443
1664
  },
1665
+ {
1666
+ name: 'get',
1667
+ description: 'fetch full observation bodies + provenance by ID (parity with the mk_get MCP tool)',
1668
+ milestone: 108,
1669
+ argSpec: [{ flags: '<ids...>', description: 'one or more citation IDs (e.g. P-S79MJHFN)' }],
1670
+ action: runGet,
1671
+ },
1672
+ {
1673
+ name: 'timeline',
1674
+ description: 'sequential context around an anchor observation — N before + N after (mk_timeline parity)',
1675
+ milestone: 108,
1676
+ argSpec: [{ flags: '<anchor>', description: 'citation ID to anchor the timeline on' }],
1677
+ optionSpec: [
1678
+ { flags: '--before <n>', description: 'observations before the anchor (default: 5)' },
1679
+ { flags: '--after <n>', description: 'observations after the anchor (default: 5)' },
1680
+ ],
1681
+ action: runTimeline,
1682
+ },
1683
+ {
1684
+ name: 'cite',
1685
+ description: 'render the canonical Markdown citation link for an observation (mk_cite parity)',
1686
+ milestone: 108,
1687
+ argSpec: [{ flags: '<id>', description: 'citation ID' }],
1688
+ action: runCite,
1689
+ },
1690
+ {
1691
+ name: 'recent-activity',
1692
+ description: 'list recent observation changes within a time window (mk_recent_activity parity)',
1693
+ milestone: 108,
1694
+ optionSpec: [
1695
+ { flags: '--window <w>', description: '1h | 24h | 7d (default: 24h)' },
1696
+ { flags: '--limit <n>', description: 'max results (default: 20)' },
1697
+ ],
1698
+ action: runRecentActivity,
1699
+ },
1444
1700
  {
1445
1701
  name: 'reindex',
1446
1702
  description: 'rebuild the markdown INDEX.md pointer index for the project tier',