@lh8ppl/claude-memory-kit 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +12 -5
  2. package/bin/cmk-auto-extract.mjs +13 -0
  3. package/bin/cmk-compress-session.mjs +31 -17
  4. package/bin/cmk-inject-context.mjs +12 -2
  5. package/bin/cmk-weekly-curate.mjs +14 -2
  6. package/package.json +3 -2
  7. package/src/audit-log.mjs +6 -0
  8. package/src/auto-drain.mjs +59 -0
  9. package/src/auto-extract.mjs +117 -6
  10. package/src/auto-persona.mjs +544 -0
  11. package/src/bullet-lookup.mjs +59 -0
  12. package/src/capture-turn.mjs +54 -0
  13. package/src/compress-session.mjs +6 -8
  14. package/src/compressor.mjs +19 -4
  15. package/src/conflict-queue.mjs +8 -1
  16. package/src/daily-distill.mjs +19 -11
  17. package/src/doctor.mjs +74 -23
  18. package/src/forget.mjs +14 -0
  19. package/src/graduate-session.mjs +65 -0
  20. package/src/graduation.mjs +179 -0
  21. package/src/inject-context.mjs +206 -59
  22. package/src/install.mjs +52 -7
  23. package/src/lessons-promote.mjs +137 -0
  24. package/src/memory-write.mjs +2 -2
  25. package/src/native-memory.mjs +98 -0
  26. package/src/persona-portability.mjs +253 -0
  27. package/src/provenance.mjs +23 -5
  28. package/src/read-hook-stdin.mjs +47 -0
  29. package/src/register-crons.mjs +17 -8
  30. package/src/scratchpad.mjs +247 -19
  31. package/src/session-end-tasks.mjs +127 -0
  32. package/src/settings-hooks.mjs +33 -3
  33. package/src/subcommands.mjs +339 -16
  34. package/src/weekly-curate.mjs +53 -6
  35. package/src/write-fact.mjs +14 -0
  36. package/template/.claude/skills/memory-write/SKILL.md +47 -88
  37. package/template/.gitignore.fragment +6 -0
  38. package/template/CLAUDE.md.template +15 -9
  39. package/template/local/machine-paths.md.template +1 -12
  40. package/template/local/overrides.md.template +1 -11
  41. package/template/project/MEMORY.md.template +5 -26
  42. package/template/project/SOUL.md.template +1 -10
  43. package/template/user/fragments/INDEX.md.template +1 -1
  44. package/template/.claude/hooks/pre-tool-memory.js +0 -78
  45. package/template/.claude/hooks/transcript-capture.js +0 -69
  46. package/template/.claude/settings.json +0 -27
  47. package/template/support/scripts/auto-extract-memory.sh +0 -102
  48. package/template/support/scripts/refresh-distill-timestamp.py +0 -35
  49. package/template/support/scripts/register-crons.py +0 -242
  50. package/template/support/scripts/run-daily-distill.sh +0 -67
  51. package/template/support/scripts/run-weekly-curate.sh +0 -58
@@ -23,12 +23,18 @@ import { memoryWrite } from './memory-write.mjs';
23
23
  import { runMcpServer } from './mcp-server.mjs';
24
24
  import { dailyDistill } from './daily-distill.mjs';
25
25
  import { weeklyCurate } from './weekly-curate.mjs';
26
+ import { autoPersona } from './auto-persona.mjs';
27
+ import { exportPersona, importPersona } from './persona-portability.mjs';
28
+ import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
29
+ import { writeFact } from './write-fact.mjs';
30
+ import { createHash } from 'node:crypto';
26
31
  import { runLazyCompress } from './lazy-compress.mjs';
27
32
  import { runDoctor } from './doctor.mjs';
28
33
  import { importAnthropicMemory } from './import-anthropic-memory.mjs';
29
34
  import { extractTranscript, discoverSessions } from './transcripts.mjs';
30
35
  import { runRepair } from './repair.mjs';
31
36
  import { runRoll, ROLL_SCOPES } from './roll.mjs';
37
+ import { lessonsPromote } from './lessons-promote.mjs';
32
38
  import {
33
39
  markCronRegistered,
34
40
  unmarkCronRegistered,
@@ -42,6 +48,7 @@ import {
42
48
  } from './register-crons.mjs';
43
49
  import { fileURLToPath } from 'node:url';
44
50
  import { dirname } from 'node:path';
51
+ import { readFileSync } from 'node:fs';
45
52
 
46
53
  const __filename_subcommands = fileURLToPath(import.meta.url);
47
54
  const __dirname_subcommands = dirname(__filename_subcommands);
@@ -99,6 +106,11 @@ async function runInstall(options /* , command */) {
99
106
  ' Restart Claude Code to activate. Complete install — no separate /plugin step needed.',
100
107
  );
101
108
  }
109
+ // Task 60 / ADR-0011 heads-up: the kit coexists with Claude Code's native
110
+ // Auto Memory by default; surface the one-command opt-out (null when already
111
+ // opted out, so we don't nag).
112
+ const nativeNote = nativeMemoryInstallNote(result.projectRoot);
113
+ if (nativeNote) console.log(nativeNote);
102
114
  if (verbose) {
103
115
  console.log(
104
116
  ` files: ${result.created.length} created, ${result.skipped.length} already present` +
@@ -190,6 +202,49 @@ function runTrust(id, level /* , options, command */) {
190
202
  }
191
203
  }
192
204
 
205
+ /**
206
+ * `cmk lessons promote <id> [--to <file>] [--section <title>]` — Task 76.
207
+ *
208
+ * The explicit half of the wedge: carry a project observation across ALL your
209
+ * projects. Routes through the safe promote path (home-path sanitization,
210
+ * Poison_Guard, dedup, audit trail) — never hand-edits the user-tier files.
211
+ */
212
+ function runLessonsPromote(id, options = {}) {
213
+ const projectRoot = resolvePath(process.cwd());
214
+ const userDir =
215
+ process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
216
+ const result = lessonsPromote({
217
+ id,
218
+ projectRoot,
219
+ userDir,
220
+ to: options.to,
221
+ section: options.section,
222
+ });
223
+ if (result.action === 'promoted') {
224
+ console.log(`cmk lessons promote: ${result.id} → ${result.target} § ${result.section}`);
225
+ return;
226
+ }
227
+ if (result.action === 'queued') {
228
+ // Exit 3 (not 2): the fact is durably SAVED to the review queue — it didn't
229
+ // fail, it just needs a `cmk queue review` to land in the persona. Distinct
230
+ // from the genuine-error exits (2) so scripts can tell them apart.
231
+ console.error(
232
+ `cmk lessons promote: saved to the user-tier review queue (${result.reason}) — run \`cmk queue review\` to land it`,
233
+ );
234
+ process.exitCode = 3;
235
+ return;
236
+ }
237
+ if (result.action === 'not-found') {
238
+ console.error(`cmk lessons promote: ${result.errors[0]}`);
239
+ process.exitCode = 2;
240
+ return;
241
+ }
242
+ if (result.action === 'error') {
243
+ for (const e of result.errors) console.error(`cmk lessons promote: ${e}`);
244
+ process.exitCode = 2;
245
+ }
246
+ }
247
+
193
248
  /**
194
249
  * `cmk search` — Task 30. Hybrid keyword + optional semantic.
195
250
  *
@@ -299,11 +354,115 @@ function runSearch(queryParts, options) {
299
354
  * routing (same deferral as mk_remember, design §16) — the always-on home-path
300
355
  * abstraction is the privacy net regardless of tier.
301
356
  */
357
+ // Task 63 (F1): a slug derived from the title — lowercased, non-alphanumerics
358
+ // collapsed to '-', trimmed, capped. Always passes writeFact's SLUG_PATTERN.
359
+ function slugifyFact(s) {
360
+ // Collapse every run of non-alphanumerics to a single '-' (so dashes are
361
+ // never doubled), cap, then trim a leading/trailing dash without a regex
362
+ // quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
363
+ // dash is all that can remain after the collapse, so string ops suffice).
364
+ let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
365
+ if (base.startsWith('-')) base = base.slice(1);
366
+ if (base.endsWith('-')) base = base.slice(0, -1);
367
+ return base || 'fact';
368
+ }
369
+
370
+ // Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
371
+ function buildRichFactBody({ text, why, how }) {
372
+ const parts = [String(text).trim()];
373
+ if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
374
+ if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
375
+ return parts.join('\n\n');
376
+ }
377
+
378
+ /**
379
+ * `cmk remember --why … --how … --type … --title …` (Task 63 / F1) — RICH
380
+ * capture. Writes a real granular fact file (frontmatter + Why/How/links) via
381
+ * writeFact(), which ALREADY runs home-path sanitization + Poison_Guard + the
382
+ * correct schema. Restores v0.1.1 richness through v0.1.2's safe path. `deps`
383
+ * carries injection seams for testing.
384
+ */
385
+ export function runRememberRich(text, options = {}, deps = {}) {
386
+ const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
387
+ const log = deps.log ?? console.log;
388
+ const logError = deps.logError ?? console.error;
389
+ const write = deps.writeFact ?? writeFact;
390
+
391
+ // M2: rich capture writes the project tier (P) in v0.1.x — same deferral as
392
+ // the terse path + mk_remember (U/L need per-tier scratchpad routing, design
393
+ // §16). Terse mode ERRORS on a non-P --tier; rich mode notes it and proceeds
394
+ // (a no-write surprise is worse than a captured-to-P note). Surface it so the
395
+ // divergence isn't silent.
396
+ if (options.tier && options.tier !== 'P') {
397
+ log(
398
+ `cmk remember: --tier '${options.tier}' is v0.1.x — rich capture writes the project tier (P) for now.`,
399
+ );
400
+ }
401
+
402
+ const headline = String(text).trim();
403
+ const title = (options.title && String(options.title).trim()) || headline.split('\n')[0].slice(0, 80);
404
+ const body = buildRichFactBody({ text: headline, why: options.why, how: options.how });
405
+ const related = options.links
406
+ ? String(options.links).split(',').map((s) => s.trim()).filter(Boolean)
407
+ : undefined;
408
+
409
+ const r = write({
410
+ tier: 'P',
411
+ type: options.type ?? 'feedback',
412
+ slug: slugifyFact(title),
413
+ title,
414
+ body,
415
+ writeSource: 'user-explicit',
416
+ trust: options.trust ?? 'high',
417
+ sourceFile: 'user-explicit',
418
+ sourceLine: 1,
419
+ // Content fingerprint for provenance/dedup — NOT a security context.
420
+ // Matches the kit's sha1-of-content convention (memory-write.mjs,
421
+ // index-rebuild.mjs); writeFact dedups by content-addressed id, this is
422
+ // just the source_sha1 provenance field. // NOSONAR
423
+ sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
424
+
425
+ related,
426
+ projectRoot,
427
+ });
428
+
429
+ if (r.action === 'error') {
430
+ // M1: a collision means a fact file with this title (→ slug) already exists
431
+ // but with different content (different id). Give an actionable hint rather
432
+ // than the raw "refusing overwrite" — the user almost certainly wants to
433
+ // edit the existing fact or pick a new --title.
434
+ if (r.errorCategory === 'collision') {
435
+ logError(
436
+ `cmk remember: a fact titled "${title}" already exists with different content. ` +
437
+ `Edit it directly, or capture under a new --title.`,
438
+ );
439
+ return r;
440
+ }
441
+ logError(`cmk remember: ${(r.errors ?? [r.errorCategory ?? 'error']).join('; ')}`);
442
+ return r;
443
+ }
444
+ if (r.action === 'skipped') {
445
+ log(`cmk remember: already captured (${r.skipReason})${r.id ? ` [${r.id}]` : ''}`);
446
+ return r;
447
+ }
448
+ log(`cmk remember: saved rich fact → ${r.path}${r.id ? ` [${r.id}]` : ''}`);
449
+ return r;
450
+ }
451
+
302
452
  function runRemember(textParts, options) {
303
453
  const projectRoot = resolvePath(process.cwd());
304
454
  const userDir =
305
455
  process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
306
456
  const text = Array.isArray(textParts) ? textParts.join(' ') : textParts;
457
+ // Rich mode: any of --why/--how/--type/--title/--links → write a real fact
458
+ // file (the F1 fix) instead of a terse MEMORY.md bullet. M3: --trust and
459
+ // --section are intentionally NOT triggers — --trust is shared by both forms
460
+ // (rich reads it too), and --section is terse-only (a MEMORY.md heading, no
461
+ // meaning for a granular fact file). So `--trust high` alone stays terse.
462
+ if (options?.why || options?.how || options?.type || options?.title || options?.links) {
463
+ runRememberRich(text, options, { projectRoot });
464
+ return;
465
+ }
307
466
  const tier = options?.tier ?? 'P';
308
467
  if (tier !== 'P') {
309
468
  console.error(
@@ -504,6 +663,125 @@ async function runWeeklyCurate(/* options */) {
504
663
  }
505
664
  }
506
665
 
666
+ /**
667
+ * `cmk persona generate` (Task 45 follow-up) — run cross-project persona
668
+ * synthesis ON DEMAND. Classifies this project's captured facts, auto-promotes
669
+ * the high-confidence cross-project doctrine into the user tier (trust:medium),
670
+ * and saves the low/medium-confidence candidates to
671
+ * <userDir>/queues/persona-review.md. Same pipeline weekly-curate runs
672
+ * automatically — this is the manual trigger (a deterministic hook for the
673
+ * fresh-session live test, and for users who want to fill the user tier now).
674
+ */
675
+ // `opts` is the Commander options object in production (no relevant keys →
676
+ // every field falls back to its default). The injection seams (projectRoot,
677
+ // userDir, backend, log, logError) keep the wrapper unit-testable without a
678
+ // live `claude --print` spawn — see cli-auto-persona.test.js.
679
+ export async function runPersonaGenerate(opts = {}) {
680
+ const projectRoot = opts.projectRoot ?? resolvePath(process.cwd());
681
+ const userDir =
682
+ opts.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
683
+ const log = opts.log ?? console.log;
684
+ const logError = opts.logError ?? console.error;
685
+ try {
686
+ const backend =
687
+ opts.backend ?? new (await import('./compressor.mjs')).HaikuViaAnthropicApi();
688
+ const r = await autoPersona({ projectRoot, userDir, backend });
689
+ if (r.action === 'error') {
690
+ logError(
691
+ `cmk persona generate: error (${r.errorCategory ?? 'unknown'})${(r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : ''}`,
692
+ );
693
+ return;
694
+ }
695
+ const promoted = r.promoted?.length ?? 0;
696
+ const superseded = r.superseded?.length ?? 0;
697
+ const queued = r.queued?.length ?? 0;
698
+ const conflicts = r.conflicts?.length ?? 0;
699
+ log(
700
+ `cmk persona generate: ${r.action}${r.reason ? ` (${r.reason})` : ''} — promoted: ${promoted}, superseded: ${superseded}, queued: ${queued}, conflicts: ${conflicts}`,
701
+ );
702
+ if (queued > 0 && r.reviewQueuePath) {
703
+ log(` ${queued} lower-confidence candidate(s) saved for review → ${r.reviewQueuePath}`);
704
+ }
705
+ } catch (err) {
706
+ logError(`cmk persona generate: unexpected error: ${err?.message ?? err}`);
707
+ }
708
+ }
709
+
710
+ function resolveUserDir() {
711
+ return process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
712
+ }
713
+
714
+ /**
715
+ * `cmk persona export <file>` (Task 72) — bundle the user-tier persona into one
716
+ * portable file to carry to another of YOUR machines.
717
+ */
718
+ export function runPersonaExport(file, options = {}) {
719
+ const outFile = file ?? options.out;
720
+ if (!outFile) {
721
+ console.error('cmk persona export: give an output file, e.g. `cmk persona export persona-bundle.json`');
722
+ process.exitCode = 2;
723
+ return;
724
+ }
725
+ const r = exportPersona({ userDir: resolveUserDir(), outFile });
726
+ if (r.action === 'error') {
727
+ console.error(`cmk persona export: ${r.errors?.join('; ') || r.errorCategory}`);
728
+ process.exitCode = 2;
729
+ return;
730
+ }
731
+ console.log(`cmk persona export: ${r.fileCount} files → ${r.path} (${r.bytes} B)`);
732
+ console.log(' Carry it via your own private channel (USB / private git repo / Dropbox), then `cmk persona import` on the other machine.');
733
+ }
734
+
735
+ /**
736
+ * `cmk persona import <file>` (Task 72) — apply a persona bundle to this
737
+ * machine's user tier. Overwrites; any replaced file is backed up first.
738
+ */
739
+ export function runPersonaImport(file, options = {}) {
740
+ const inFile = file ?? options.from;
741
+ if (!inFile) {
742
+ console.error('cmk persona import: give a bundle file, e.g. `cmk persona import persona-bundle.json`');
743
+ process.exitCode = 2;
744
+ return;
745
+ }
746
+ const r = importPersona({ userDir: resolveUserDir(), inFile });
747
+ if (r.action === 'error') {
748
+ console.error(`cmk persona import: ${r.errors?.join('; ') || r.errorCategory}`);
749
+ process.exitCode = 2;
750
+ return;
751
+ }
752
+ const bkp = r.backedUp > 0 ? ` (backed up ${r.backedUp} existing file(s) → ${r.backupPath})` : '';
753
+ console.log(`cmk persona import: ${r.fileCount} files → ${resolveUserDir()}${bkp}${r.reindexed ? ' · search reindexed' : ''}`);
754
+ }
755
+
756
+ /**
757
+ * `cmk disable-native-memory` / `cmk enable-native-memory` (Task 60, ADR-0011)
758
+ * — write `autoMemoryEnabled` into the project's committable
759
+ * `.claude/settings.json`. Default install does NOT touch it (coexist); this
760
+ * is the one-command opt-in/out. `opts` carries injection seams for testing.
761
+ */
762
+ export function runSetNativeMemory(enabled, opts = {}) {
763
+ const projectRoot = opts.projectRoot ?? resolvePath(process.cwd());
764
+ const log = opts.log ?? console.log;
765
+ const logError = opts.logError ?? console.error;
766
+ const cmd = enabled ? 'enable-native-memory' : 'disable-native-memory';
767
+ const r = setNativeAutoMemory({ projectRoot, enabled });
768
+ if (r.action === 'error') {
769
+ logError(`cmk ${cmd}: ${(r.errors && r.errors.join('; ')) || 'could not update settings.json'}`);
770
+ return r;
771
+ }
772
+ const verb = enabled ? 'enabled' : 'disabled';
773
+ if (r.action === 'unchanged') {
774
+ log(`cmk ${cmd}: Anthropic native Auto Memory already ${verb} for this project (no change).`);
775
+ } else if (enabled) {
776
+ log('Anthropic native Auto Memory re-enabled for this project — native + kit memory will coexist again.');
777
+ log(` → ${r.settingsPath} (reverse with \`cmk disable-native-memory\`)`);
778
+ } else {
779
+ log('Anthropic native Auto Memory disabled for this project — the kit is now the sole memory layer (one lean snapshot at session start).');
780
+ log(` → ${r.settingsPath} (committable: travels with \`git clone\`; reverse with \`cmk enable-native-memory\`)`);
781
+ }
782
+ return r;
783
+ }
784
+
507
785
  /**
508
786
  * `cmk register-crons [--dry-run] [--unregister]` (Task 33) — register
509
787
  * the daily-distill cron entry on the current platform.
@@ -1150,13 +1428,19 @@ export const subcommands = [
1150
1428
  },
1151
1429
  {
1152
1430
  name: 'remember',
1153
- description: 'explicitly capture a durable fact to MEMORY.md (Poison_Guard + home-path abstraction + dedup; the safe alternative to hand-writing fact files)',
1431
+ 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).',
1154
1432
  milestone: 24,
1155
1433
  argSpec: [{ flags: '<text...>', description: 'the fact to remember' }],
1156
1434
  optionSpec: [
1157
1435
  { flags: '--tier <tier>', description: 'P (default; U/L are v0.1.x)' },
1158
1436
  { flags: '--trust <level>', description: 'high | medium | low (default: high)' },
1159
- { flags: '--section <name>', description: 'MEMORY.md section (default: Active Threads)' },
1437
+ { flags: '--section <name>', description: 'MEMORY.md section for the terse form (default: Active Threads)' },
1438
+ // Rich mode (Task 63 / F1): any of these → write a granular fact file.
1439
+ { flags: '--why <text>', description: 'rich: the rationale (becomes the **Why:** block)' },
1440
+ { flags: '--how <text>', description: 'rich: how to apply it (becomes the **How to apply:** block)' },
1441
+ { flags: '--type <type>', description: 'rich: feedback | project | reference | user (default: feedback)' },
1442
+ { flags: '--title <text>', description: 'rich: a short title (also the fact-file slug)' },
1443
+ { flags: '--links <a,b>', description: 'rich: related fact names for [[cross-links]]' },
1160
1444
  ],
1161
1445
  action: runRemember,
1162
1446
  },
@@ -1263,16 +1547,20 @@ export const subcommands = [
1263
1547
  },
1264
1548
  {
1265
1549
  name: 'lessons',
1266
- description: 'promote project-tier observations to the user-tier LESSONS.md',
1267
- milestone: 'v0.1.x',
1550
+ description: 'promote a project-tier observation to the user tier (carry it across all your projects)',
1551
+ milestone: 76,
1268
1552
  children: [
1269
1553
  {
1270
1554
  name: 'promote',
1271
- description: 'move a project observation to ~/.claude-memory-kit/LESSONS.md',
1272
- argSpec: [{ flags: '<id>', description: 'citation ID' }],
1555
+ description: 'move a project observation to the user tier through the safe promote path',
1556
+ argSpec: [{ flags: '<id>', description: 'citation ID (e.g. P-S79MJHFN)' }],
1557
+ optionSpec: [
1558
+ { flags: '--to <file>', description: 'target user-tier file: LESSONS.md (default) | HABITS.md | USER.md' },
1559
+ { flags: '--section <title>', description: 'landing section (default per-target)' },
1560
+ ],
1561
+ action: (id, options) => runLessonsPromote(id, options),
1273
1562
  },
1274
1563
  ],
1275
- action: stub('lessons', 'v0.1.x'),
1276
1564
  },
1277
1565
  {
1278
1566
  name: 'queue',
@@ -1335,6 +1623,44 @@ export const subcommands = [
1335
1623
  milestone: 34,
1336
1624
  action: runWeeklyCurate,
1337
1625
  },
1626
+ {
1627
+ name: 'persona',
1628
+ description: 'cross-project persona — synthesize "how you work everywhere" into the user tier, and carry it across YOUR machines',
1629
+ milestone: 45,
1630
+ children: [
1631
+ {
1632
+ name: 'generate',
1633
+ description: 'synthesize cross-project doctrine from this project\'s captured facts now: auto-promote high-confidence to the user tier, save the rest to queues/persona-review.md',
1634
+ action: runPersonaGenerate,
1635
+ },
1636
+ {
1637
+ name: 'export',
1638
+ description: 'export your cross-project persona (the user tier) to one portable bundle file, to carry to another of YOUR machines (the persona stays private — never committed to a project)',
1639
+ milestone: 72,
1640
+ argSpec: [{ flags: '<file>', description: 'output bundle path, e.g. persona-bundle.json' }],
1641
+ action: (file, options) => runPersonaExport(file, options),
1642
+ },
1643
+ {
1644
+ name: 'import',
1645
+ description: 'import a persona bundle (from `cmk persona export`) into this machine\'s user tier; any file it replaces is backed up first',
1646
+ milestone: 72,
1647
+ argSpec: [{ flags: '<file>', description: 'bundle path produced by `cmk persona export`' }],
1648
+ action: (file, options) => runPersonaImport(file, options),
1649
+ },
1650
+ ],
1651
+ },
1652
+ {
1653
+ name: 'disable-native-memory',
1654
+ description: 'opt out of Anthropic\'s native Auto Memory for THIS project (writes autoMemoryEnabled:false to the committable .claude/settings.json) so only the kit\'s memory runs — avoids the both-layers context bloat (ADR-0011)',
1655
+ milestone: 60,
1656
+ action: () => runSetNativeMemory(false),
1657
+ },
1658
+ {
1659
+ name: 'enable-native-memory',
1660
+ description: 're-enable Anthropic\'s native Auto Memory for this project (reverses cmk disable-native-memory)',
1661
+ milestone: 60,
1662
+ action: () => runSetNativeMemory(true),
1663
+ },
1338
1664
  {
1339
1665
  name: 'compress',
1340
1666
  description: 'lazy-on-read compression fallback for no-cron environments. Use `--lazy` to delegate to daily-distill or weekly-curate based on staleness (typically invoked detached from the SessionStart hook).',
@@ -1366,15 +1692,12 @@ export const subcommands = [
1366
1692
  description: 'print the cmk version (alias for --version)',
1367
1693
  milestone: 'always',
1368
1694
  action: function action() {
1369
- // version is special never a stub. Print and continue.
1370
- // The shared --version flag is wired in index.mjs; this verb
1371
- // exists for parity with design §12 and prints the same string.
1372
- // Implementation note: we resolve the version from package.json
1373
- // already done at program-build time, so this can simply call into
1374
- // the main flag handler via process.exit after printing.
1375
- // For v0.1.0 stub-only milestone, defer the actual print to the
1376
- // top-level --version handler.
1377
- console.log(`cmk version: see \`cmk --version\``);
1695
+ // Print the same bare version string as `cmk --version`, resolved from
1696
+ // package.json (the single source). Was a v0.1.0 stub that punted to
1697
+ // `cmk --version`; the live test surfaced that as unhelpful friction.
1698
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
1699
+ const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
1700
+ console.log(version);
1378
1701
  },
1379
1702
  },
1380
1703
  ];
@@ -45,6 +45,9 @@ import {
45
45
  touchCooldownMarker,
46
46
  } from './cooldown.mjs';
47
47
  import { dailyDistill } from './daily-distill.mjs';
48
+ import { autoPersona } from './auto-persona.mjs';
49
+ import { initUserTier } from './install.mjs';
50
+ import { autoDrainQueues } from './auto-drain.mjs';
48
51
 
49
52
  const DEFAULT_ARCHIVE_MAX_BYTES = 4096;
50
53
  const DEFAULT_RECENT_MAX_BYTES = 4096;
@@ -69,11 +72,12 @@ function buildCurateInstructions(archiveMaxBytes) {
69
72
  'Under each week heading, emit bullets that summarize the work across the days in that week. Each bullet is a single line ≤120 chars. Bullets within a week appear in chronological order.',
70
73
  '',
71
74
  'HARD RULES:',
72
- ' 1. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
73
- ` 2. Total output ${archiveMaxBytes} bytes.`,
74
- ' 3. Deduplicate aggressively: if the same fact appears across multiple days, emit it ONCE. The deterministic dedup pass after your output will collapse exact-after-canonical duplicates; YOUR job is to catch the looser semantic duplicates (paraphrases, restatements).',
75
- ' 4. No prose between bullets only the bulleted list per week section.',
76
- ' 5. Your output goes directly into archive.md. Do not address the user, do not refer to yourself.',
75
+ ' 1. Every bullet must be grounded in the daily summaries below. Do not infer or add any fact not explicitly present in them. When the summaries show a fact was later corrected, replaced, or reversed, keep ONLY the latest version of that fact — never list the superseded one alongside it (this resolves contradictions, NOT coexisting facts on different points). If unsure, omit it.',
76
+ ' 2. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
77
+ ` 3. Total output ${archiveMaxBytes} bytes.`,
78
+ ' 4. Deduplicate aggressively: if the same fact appears across multiple days, emit it ONCE. The deterministic dedup pass after your output will collapse exact-after-canonical duplicates; YOUR job is to catch the looser semantic duplicates (paraphrases, restatements).',
79
+ ' 5. No prose between bullets only the bulleted list per week section.',
80
+ ' 6. Your output goes directly into archive.md. Do not address the user, do not refer to yourself.',
77
81
  '',
78
82
  '=== BEGIN OLD DAILY SUMMARIES TO ARCHIVE ===',
79
83
  ].join('\n');
@@ -242,6 +246,7 @@ function writeCurateLogEntry({ projectRoot, date, entry }) {
242
246
  */
243
247
  export async function weeklyCurate({
244
248
  projectRoot,
249
+ userDir,
245
250
  backend,
246
251
  now,
247
252
  cooldownMs = DEFAULT_COOLDOWN_MS,
@@ -277,6 +282,12 @@ export async function weeklyCurate({
277
282
  };
278
283
  }
279
284
 
285
+ // Auto-drain the review + conflict queues (v0.2 Phase 2, D-6) — project
286
+ // tier always, user tier when a userDir is supplied (persona queues).
287
+ // Non-Haiku file IO; runs every weekly pass regardless of the cooldown.
288
+ const drained = { P: await autoDrainQueues({ tier: 'P', projectRoot }) };
289
+ if (userDir) drained.U = await autoDrainQueues({ tier: 'U', userDir });
290
+
280
291
  if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
281
292
  const duration_ms = Date.now() - t0;
282
293
  writeCurateLogEntry({
@@ -297,7 +308,38 @@ export async function weeklyCurate({
297
308
  skipped_reason: 'cooldown',
298
309
  },
299
310
  });
300
- return { action: 'skipped', reason: 'cooldown', duration_ms };
311
+ return { action: 'skipped', reason: 'cooldown', drained, duration_ms };
312
+ }
313
+
314
+ // Design-B auto-persona hook (Task 45). The weekly cycle is the natural
315
+ // trigger: once past the shared cooldown gate, synthesize cross-project
316
+ // doctrine from the granular fact archive and auto-promote it into the
317
+ // user tier. cooldownMs:0 — this Haiku call belongs to the SAME weekly
318
+ // cycle as the curate compress below (per §8.7.2, like the inline
319
+ // dailyDistill call). Skipped silently if userDir wasn't supplied (so
320
+ // existing project-only callers/tests are unaffected). D-14/D-15.
321
+ let persona;
322
+ if (userDir) {
323
+ // Ensure the user tier exists before auto-persona tries to promote into
324
+ // it. Without this, a user who never ran `cmk init-user-tier` has no
325
+ // USER.md/HABITS.md/LESSONS.md, so every promotion would silently route
326
+ // to `queued[]` (NOT_FOUND) and the cross-project tier would stay empty
327
+ // — the exact friend-handoff failure auto-persona exists to fix.
328
+ // initUserTier is idempotent (skips existing files).
329
+ try {
330
+ initUserTier({ userTier: userDir });
331
+ } catch {
332
+ // Best-effort: if scaffolding fails (perms, etc.), autoPersona still
333
+ // degrades gracefully (promotions route to queued[]); don't abort the
334
+ // whole curate cycle over a user-tier scaffold hiccup.
335
+ }
336
+ persona = await autoPersona({
337
+ projectRoot,
338
+ userDir,
339
+ backend,
340
+ now: ts,
341
+ cooldownMs: 0,
342
+ });
301
343
  }
302
344
 
303
345
  const files = listAllTodayFiles(projectRoot);
@@ -329,6 +371,8 @@ export async function weeklyCurate({
329
371
  action: 'skipped',
330
372
  reason: 'no-old-files',
331
373
  currentDays: current.length,
374
+ persona,
375
+ drained,
332
376
  duration_ms,
333
377
  };
334
378
  }
@@ -415,6 +459,7 @@ export async function weeklyCurate({
415
459
  now: ts,
416
460
  cooldownMs: 0,
417
461
  maxOutputBytes: recentMaxBytes,
462
+ skipDrain: true, // weeklyCurate already drained above; don't double-drain
418
463
  });
419
464
  if (recentResult?.outputPath) recentPath = recentResult.outputPath;
420
465
  }
@@ -449,6 +494,8 @@ export async function weeklyCurate({
449
494
  recentPath,
450
495
  bytesIn: input_bytes,
451
496
  bytesOut: output_bytes,
497
+ persona,
498
+ drained,
452
499
  duration_ms,
453
500
  };
454
501
  }
@@ -17,6 +17,7 @@ import { join } from 'node:path';
17
17
  import { generateId } from '@lh8ppl/cmk-canonicalize';
18
18
  import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
19
19
  import { parse, format } from './frontmatter.mjs';
20
+ import { reindex } from './reindex.mjs';
20
21
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
21
22
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
22
23
  import { sanitizeHomePaths } from './sanitize.mjs';
@@ -244,5 +245,18 @@ export function writeFact(opts = {}) {
244
245
  const frontmatter = buildFrontmatterObject(factOpts, { id, createdAt });
245
246
  writeFileSync(path, format({ frontmatter, body: `\n${factOpts.body}\n` }), 'utf8');
246
247
 
248
+ // Keep INDEX.md consistent on every create — the index is a derived view of
249
+ // the fact files, so the writer owns keeping it current. Without this, a fresh
250
+ // `cmk remember` left INDEX.md stale until a manual `cmk reindex`, and
251
+ // `cmk doctor` HC-5 failed from the first capture (Task 85; lior-test-7
252
+ // 2026-06-03 — "users should get it working from the start"). Best-effort: the
253
+ // fact is already durably on disk, so an index-rebuild hiccup must not turn a
254
+ // successful capture into an error — the next reindex/search self-heals.
255
+ try {
256
+ reindex({ tier: opts.tier, projectRoot: opts.projectRoot, userDir: opts.userDir, warn: () => {} });
257
+ } catch {
258
+ // index rebuild is best-effort; capture already succeeded
259
+ }
260
+
247
261
  return { action: 'created', id, path };
248
262
  }