@lh8ppl/claude-memory-kit 0.1.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.
Files changed (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
package/src/doctor.mjs ADDED
@@ -0,0 +1,528 @@
1
+ // `cmk doctor` — health checks HC-1..HC-9 (Task 37, T-031).
2
+ //
3
+ // Public boundary:
4
+ // async runDoctor({projectRoot, userDir, now, promptUser?, ...overrides})
5
+ // → {action: 'completed' | 'error', checks: [HCResult], duration_ms}
6
+ //
7
+ // HCResult shape:
8
+ // {
9
+ // id: 'HC-1' | ... | 'HC-9',
10
+ // name: string,
11
+ // status: 'pass' | 'fail' | 'skip',
12
+ // message: string,
13
+ // recoveryCommand?: string, // surfaced on fail
14
+ // requiresInstall?: boolean, // if true, caller must promptUser first
15
+ // }
16
+ //
17
+ // Per design §14. Composes on:
18
+ // - cooldown.mjs (HC-3 distill freshness via cooldown marker mtime is
19
+ // NOT used — we read recent.md mtime directly, more accurate)
20
+ // - lazy-compress.mjs::cronSentinelPath (HC-6 cron registration check)
21
+ // - lock-discipline.mjs::detectStaleLocks (HC-9)
22
+ // - platform-commands.mjs — cross-platform repair command emission
23
+ //
24
+ // Critical rule per design §14 + tasks.md 37.5: any repair requiring
25
+ // `pip install` / `npm install` / system-level changes MUST ASK the
26
+ // user first. (I1 fix 2026-05-28: previously cited NFR-9 which is
27
+ // actually "Memory poisoning defense baseline" per requirements-revisions-proposed.md
28
+ // — the ask-before-install rule has no FR/NFR backing today; promoting
29
+ // it is a v0.1.x candidate.) runDoctor records `requiresInstall: true`
30
+ // on those HCResults and the CLI handler surfaces the command without
31
+ // auto-invoking it.
32
+
33
+ import {
34
+ existsSync,
35
+ mkdirSync,
36
+ readFileSync,
37
+ readdirSync,
38
+ statSync,
39
+ writeFileSync,
40
+ } from 'node:fs';
41
+ import { spawnSync } from 'node:child_process';
42
+ import { homedir } from 'node:os';
43
+ import { basename, join } from 'node:path';
44
+ import { nowIso } from './audit-log.mjs';
45
+ import { detectStaleLocks } from './lock-discipline.mjs';
46
+ import { cronSentinelPath } from './lazy-compress.mjs';
47
+
48
+ const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
49
+ const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
50
+ const TRANSCRIPTS_REL = ['context', 'transcripts'];
51
+ const SESSIONS_REL = ['context', 'sessions'];
52
+ const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
53
+ const MEMORY_INDEX_REL = ['context', 'memory', 'INDEX.md'];
54
+ const MEMORY_DIR_REL = ['context', 'memory'];
55
+ const LOCKS_REL = ['context', '.locks'];
56
+ const NATIVE_MEMORY_LOG_REL = ['context', '.locks', 'native-memory-status.log'];
57
+
58
+ // --- HC-1: memsearch installed ----------------------------------------
59
+ async function hc1Memsearch() {
60
+ // Layer 5b (semantic search) is OPTIONAL per ADR-0008. Missing
61
+ // memsearch → skip (not fail). The kit ships keyword-only as v0.1.0;
62
+ // semantic requires a separate `pip install memsearch[onnx]`.
63
+ // `requiresInstall: true` so the CLI prompts before auto-installing.
64
+ try {
65
+ const r = spawnSync('memsearch', ['--version'], {
66
+ encoding: 'utf8',
67
+ // M1 fix (skill-review 2026-05-28): 3.5s tolerates Windows
68
+ // cold-Python startup (AV scan + .pyc generation on first hit
69
+ // can push past 2s for a healthy install). HC-2..9 are file-
70
+ // system ops that complete in ≪100ms total, so HC-1 + the rest
71
+ // still fits comfortably inside the 5s NFR budget. Timeout →
72
+ // 'skip' so cmk doctor completes regardless.
73
+ timeout: 3_500,
74
+ shell: process.platform === 'win32',
75
+ });
76
+ if (r.status === 0) {
77
+ return {
78
+ id: 'HC-1',
79
+ name: 'memsearch installed (semantic search backend)',
80
+ status: 'pass',
81
+ message: `memsearch ${(r.stdout || '').trim() || 'detected'}`,
82
+ };
83
+ }
84
+ } catch {
85
+ // fall through to skip
86
+ }
87
+ // Lior 2026-05-28: make the feature impact explicit so users
88
+ // understand WHAT THEY LOSE by skipping the install, not just that
89
+ // a check failed. Matches Lior's directive: "ask before we do
90
+ // anything, explain if they dont install they dont get certain
91
+ // features".
92
+ return {
93
+ id: 'HC-1',
94
+ name: 'memsearch installed (semantic search backend)',
95
+ status: 'skip',
96
+ message:
97
+ 'memsearch not on PATH — Layer 5b semantic backend disabled. Features unavailable: `cmk search --mode=semantic` (will error), `cmk search --mode=hybrid` (will error). Keyword search (`cmk search --mode=keyword`, default) still works fully.',
98
+ recoveryCommand: 'python -m pip install "memsearch[onnx]"',
99
+ requiresInstall: true,
100
+ };
101
+ }
102
+
103
+ // --- HC-2: Stop + SessionStart hooks registered -----------------------
104
+ function hc2Hooks({ projectRoot }) {
105
+ // Per design §5 — the kit's hooks live in .claude/settings.json
106
+ // alongside its plugin manifest. Required for auto-extract +
107
+ // session-end compression to fire.
108
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
109
+ if (!existsSync(settingsPath)) {
110
+ return {
111
+ id: 'HC-2',
112
+ name: 'Stop + SessionStart hooks registered',
113
+ status: 'fail',
114
+ message: '.claude/settings.json missing — hooks not wired',
115
+ recoveryCommand: 'cmk repair --hooks',
116
+ };
117
+ }
118
+ let settings;
119
+ try {
120
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
121
+ } catch (err) {
122
+ return {
123
+ id: 'HC-2',
124
+ name: 'Stop + SessionStart hooks registered',
125
+ status: 'fail',
126
+ message: `.claude/settings.json parse error: ${err?.message ?? err}`,
127
+ recoveryCommand: 'cmk repair --hooks',
128
+ };
129
+ }
130
+ // B1 fix (skill-review 2026-05-28): walk the actual hooks.<Event>[].command
131
+ // structure instead of substring-matching against JSON.stringify(settings).
132
+ // Substring-match false-positives on ANY occurrence (description text,
133
+ // env value, stale TODO comment) and doesn't verify each hook is wired
134
+ // to its CORRECT event array. The walk pins both contracts: hook
135
+ // present + hook in the right event.
136
+ const required = [
137
+ { event: 'SessionStart', command: 'cmk-inject-context' },
138
+ { event: 'Stop', command: 'cmk-capture-turn' },
139
+ { event: 'SessionEnd', command: 'cmk-compress-session' },
140
+ ];
141
+ const hooks = settings?.hooks ?? {};
142
+ const missing = [];
143
+ for (const { event, command } of required) {
144
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
145
+ // Each entry may be either a string command or {command: '...'}.
146
+ // Anthropic's hook format uses the object form; the kit's bin
147
+ // wrapper docs use it too. Accept both for resilience.
148
+ const found = entries.some((e) => {
149
+ if (typeof e === 'string') return e.includes(command);
150
+ if (e && typeof e === 'object' && typeof e.command === 'string') {
151
+ return e.command.includes(command);
152
+ }
153
+ return false;
154
+ });
155
+ if (!found) missing.push(`${event}.${command}`);
156
+ }
157
+ if (missing.length > 0) {
158
+ return {
159
+ id: 'HC-2',
160
+ name: 'Stop + SessionStart hooks registered',
161
+ status: 'fail',
162
+ message: `missing hook references: ${missing.join(', ')}`,
163
+ recoveryCommand: 'cmk repair --hooks',
164
+ };
165
+ }
166
+ return {
167
+ id: 'HC-2',
168
+ name: 'Stop + SessionStart hooks registered',
169
+ status: 'pass',
170
+ message: 'all kit hooks wired to their correct event arrays in .claude/settings.json',
171
+ };
172
+ }
173
+
174
+ // --- HC-3: distill freshness (≤2 days) --------------------------------
175
+ function hc3DistillFreshness({ projectRoot, now }) {
176
+ const recentPath = join(projectRoot, ...RECENT_MD_REL);
177
+ if (!existsSync(recentPath)) {
178
+ return {
179
+ id: 'HC-3',
180
+ name: 'Daily distill is fresh (≤2 days)',
181
+ status: 'fail',
182
+ message: 'context/sessions/recent.md missing — distill never ran',
183
+ recoveryCommand: 'cmk daily-distill',
184
+ };
185
+ }
186
+ let mtimeMs;
187
+ try {
188
+ mtimeMs = statSync(recentPath).mtimeMs;
189
+ } catch (err) {
190
+ return {
191
+ id: 'HC-3',
192
+ name: 'Daily distill is fresh (≤2 days)',
193
+ status: 'fail',
194
+ message: `recent.md stat error: ${err?.message ?? err}`,
195
+ recoveryCommand: 'cmk daily-distill',
196
+ };
197
+ }
198
+ const nowMs = new Date(now ?? nowIso()).getTime();
199
+ const ageMs = nowMs - mtimeMs;
200
+ if (ageMs > TWO_DAYS_MS) {
201
+ return {
202
+ id: 'HC-3',
203
+ name: 'Daily distill is fresh (≤2 days)',
204
+ status: 'fail',
205
+ message: `recent.md ${Math.round(ageMs / (24 * 60 * 60 * 1000))}d old (cutoff: 2d)`,
206
+ recoveryCommand: 'cmk daily-distill',
207
+ };
208
+ }
209
+ return {
210
+ id: 'HC-3',
211
+ name: 'Daily distill is fresh (≤2 days)',
212
+ status: 'pass',
213
+ message: `recent.md ${Math.round(ageMs / (60 * 60 * 1000))}h old`,
214
+ };
215
+ }
216
+
217
+ // --- HC-4: transcripts firing (≤3 days) -------------------------------
218
+ function hc4Transcripts({ projectRoot, now }) {
219
+ const transcriptsDir = join(projectRoot, ...TRANSCRIPTS_REL);
220
+ if (!existsSync(transcriptsDir)) {
221
+ return {
222
+ id: 'HC-4',
223
+ name: 'Transcripts firing (≤3 days)',
224
+ status: 'fail',
225
+ message: 'context/transcripts/ missing — kit not capturing turn transcripts',
226
+ recoveryCommand: 'check that this project is the primary cwd in Claude Code, then reopen the project',
227
+ };
228
+ }
229
+ const nowMs = new Date(now ?? nowIso()).getTime();
230
+ const cutoffMs = nowMs - THREE_DAYS_MS;
231
+ let recentCount = 0;
232
+ for (const name of readdirSync(transcriptsDir)) {
233
+ if (!/\.md$/.test(name)) continue;
234
+ try {
235
+ const mtimeMs = statSync(join(transcriptsDir, name)).mtimeMs;
236
+ if (mtimeMs >= cutoffMs) recentCount += 1;
237
+ } catch {
238
+ // skip unreadable
239
+ }
240
+ }
241
+ if (recentCount === 0) {
242
+ return {
243
+ id: 'HC-4',
244
+ name: 'Transcripts firing (≤3 days)',
245
+ status: 'fail',
246
+ message: 'no transcripts within 3 days — likely this project is not Claude Code\'s primary cwd',
247
+ recoveryCommand: 'reopen this project as the primary cwd in Claude Code',
248
+ };
249
+ }
250
+ return {
251
+ id: 'HC-4',
252
+ name: 'Transcripts firing (≤3 days)',
253
+ status: 'pass',
254
+ message: `${recentCount} transcript(s) within 3 days`,
255
+ };
256
+ }
257
+
258
+ // --- HC-5: INDEX.md matches context/memory/ ---------------------------
259
+ function hc5IndexConsistency({ projectRoot }) {
260
+ const memoryDir = join(projectRoot, ...MEMORY_DIR_REL);
261
+ const indexPath = join(projectRoot, ...MEMORY_INDEX_REL);
262
+ if (!existsSync(memoryDir)) {
263
+ return {
264
+ id: 'HC-5',
265
+ name: 'INDEX.md matches context/memory/ files',
266
+ status: 'skip',
267
+ message: 'context/memory/ missing — no granular facts to index yet',
268
+ };
269
+ }
270
+ if (!existsSync(indexPath)) {
271
+ return {
272
+ id: 'HC-5',
273
+ name: 'INDEX.md matches context/memory/ files',
274
+ status: 'fail',
275
+ message: 'context/memory/INDEX.md missing',
276
+ recoveryCommand: 'cmk reindex',
277
+ };
278
+ }
279
+ // Count fact files (*.md excluding INDEX.md itself).
280
+ let factFiles;
281
+ try {
282
+ factFiles = readdirSync(memoryDir).filter(
283
+ (n) => /\.md$/.test(n) && n !== 'INDEX.md',
284
+ );
285
+ } catch (err) {
286
+ return {
287
+ id: 'HC-5',
288
+ name: 'INDEX.md matches context/memory/ files',
289
+ status: 'fail',
290
+ message: `readdir error: ${err?.message ?? err}`,
291
+ recoveryCommand: 'cmk reindex',
292
+ };
293
+ }
294
+ // Read INDEX.md and count entries that look like file references.
295
+ // We expect lines like `- [Description](file.md)` or `- file.md`.
296
+ let indexText;
297
+ try {
298
+ indexText = readFileSync(indexPath, 'utf8');
299
+ } catch (err) {
300
+ return {
301
+ id: 'HC-5',
302
+ name: 'INDEX.md matches context/memory/ files',
303
+ status: 'fail',
304
+ message: `INDEX.md read error: ${err?.message ?? err}`,
305
+ recoveryCommand: 'cmk reindex',
306
+ };
307
+ }
308
+ // M2 fix (skill-review 2026-05-28): constrain the regex to fact-file
309
+ // id shapes (`[PUL]-XXXXXXXX.md`) so unrelated markdown links inside
310
+ // INDEX.md (e.g., "see also design.md") don't false-positive as fact
311
+ // file references. Mirrors the kit's ID_PATTERN base32 alphabet
312
+ // (excluding 0/O/1/l/I/8).
313
+ const indexEntries = new Set();
314
+ const re = /\b([PUL]-[A-Za-z2-9]{8})\.md\b/g;
315
+ let m;
316
+ while ((m = re.exec(indexText)) !== null) {
317
+ indexEntries.add(m[1] + '.md');
318
+ }
319
+ const factSet = new Set(factFiles);
320
+ const inFactsNotIndex = [...factSet].filter((f) => !indexEntries.has(f));
321
+ const inIndexNotFacts = [...indexEntries].filter((f) => !factSet.has(f));
322
+ if (inFactsNotIndex.length === 0 && inIndexNotFacts.length === 0) {
323
+ return {
324
+ id: 'HC-5',
325
+ name: 'INDEX.md matches context/memory/ files',
326
+ status: 'pass',
327
+ message: `${factFiles.length} fact file(s); INDEX in sync`,
328
+ };
329
+ }
330
+ const parts = [];
331
+ if (inFactsNotIndex.length > 0) parts.push(`missing from INDEX: ${inFactsNotIndex.length}`);
332
+ if (inIndexNotFacts.length > 0) parts.push(`stale in INDEX: ${inIndexNotFacts.length}`);
333
+ return {
334
+ id: 'HC-5',
335
+ name: 'INDEX.md matches context/memory/ files',
336
+ status: 'fail',
337
+ message: parts.join('; '),
338
+ recoveryCommand: 'cmk reindex',
339
+ };
340
+ }
341
+
342
+ // --- HC-6: Cron jobs registered with host scheduler -------------------
343
+ function hc6CronRegistered({ projectRoot }) {
344
+ if (existsSync(cronSentinelPath(projectRoot))) {
345
+ return {
346
+ id: 'HC-6',
347
+ name: 'Cron jobs registered with host scheduler',
348
+ status: 'pass',
349
+ message: 'cron-registered sentinel present',
350
+ };
351
+ }
352
+ return {
353
+ id: 'HC-6',
354
+ name: 'Cron jobs registered with host scheduler',
355
+ status: 'fail',
356
+ message: 'no cron-registered sentinel — kit will use lazy-on-read fallback (still functional, slower)',
357
+ recoveryCommand: 'cmk register-crons',
358
+ };
359
+ }
360
+
361
+ // --- HC-7: memsearch backend reachable --------------------------------
362
+ function hc7MemsearchReachable(hc1Result) {
363
+ // Only relevant if HC-1 passed. Skip when memsearch isn't installed.
364
+ if (hc1Result.status !== 'pass') {
365
+ return {
366
+ id: 'HC-7',
367
+ name: 'memsearch backend reachable',
368
+ status: 'skip',
369
+ message: 'depends on HC-1 (memsearch installed) — skipped',
370
+ };
371
+ }
372
+ // HC-1 already proves memsearch --version succeeds. For HC-7 the
373
+ // additional check would be milvus reachability — out of scope for
374
+ // v0.1.0's keyword-only ship (Layer 5b is v0.1.x). Treat HC-7 as
375
+ // pass when HC-1 passes.
376
+ return {
377
+ id: 'HC-7',
378
+ name: 'memsearch backend reachable',
379
+ status: 'pass',
380
+ message: 'memsearch responds to --version (milvus reachability is Layer 5b / v0.1.x)',
381
+ };
382
+ }
383
+
384
+ // --- HC-8: Native Anthropic Auto Memory status -----------------------
385
+ function hc8NativeAutoMemory({ projectRoot, now }) {
386
+ // Per ADR-0011 — detect whether Anthropic's native Auto Memory is
387
+ // also active for this project. Non-fatal; informational. Log the
388
+ // result to context/.locks/native-memory-status.log so users can
389
+ // see whether the kit is supplementing or substituting.
390
+ const ts = now ?? nowIso();
391
+ // Anthropic uses the slug pattern `re.sub(r'[^a-zA-Z0-9]', '-', project_dir)`
392
+ // per claude-remember research (SOURCES.md). We approximate that here
393
+ // without invoking Python regex semantics.
394
+ const slug = projectRoot.replace(/[^a-zA-Z0-9]/g, '-');
395
+ const anthropicMemoryDir = join(homedir(), '.claude', 'projects', slug, 'memory');
396
+ let entry;
397
+ if (!existsSync(anthropicMemoryDir)) {
398
+ entry = { ts, active: false, last_modified: null, file_count: 0 };
399
+ } else {
400
+ let files = [];
401
+ try {
402
+ files = readdirSync(anthropicMemoryDir).filter((n) => /\.md$/.test(n));
403
+ } catch {
404
+ // unreadable → treat as unknown
405
+ entry = { ts, active: 'unknown', last_modified: null, file_count: 0, reason: 'unreadable' };
406
+ }
407
+ if (!entry) {
408
+ let lastMtime = 0;
409
+ for (const f of files) {
410
+ try {
411
+ const m = statSync(join(anthropicMemoryDir, f)).mtimeMs;
412
+ if (m > lastMtime) lastMtime = m;
413
+ } catch {
414
+ // skip unreadable
415
+ }
416
+ }
417
+ entry = {
418
+ ts,
419
+ active: files.length > 0,
420
+ last_modified: lastMtime > 0 ? new Date(lastMtime).toISOString() : null,
421
+ file_count: files.length,
422
+ };
423
+ }
424
+ }
425
+ // Write the current-state SNAPSHOT (single line, overwritten each
426
+ // run). I2 fix (skill-review 2026-05-28): clarified — earlier
427
+ // comment said "Append the audit entry" but the code uses
428
+ // writeFileSync (overwrite). Snapshot semantics is the right v0.1.0
429
+ // contract because `cmk doctor` is intended for "what's true RIGHT
430
+ // NOW" checks, not trend analysis. Trend logging is a v0.1.x
431
+ // candidate (would require append + rotation or a separate
432
+ // history.ndjson file).
433
+ const logPath = join(projectRoot, ...NATIVE_MEMORY_LOG_REL);
434
+ try {
435
+ mkdirSync(join(projectRoot, ...LOCKS_REL), { recursive: true });
436
+ writeFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
437
+ } catch {
438
+ // best-effort
439
+ }
440
+ return {
441
+ id: 'HC-8',
442
+ name: 'Native Anthropic Auto Memory status detected',
443
+ status: 'pass',
444
+ message:
445
+ entry.active === true
446
+ ? `Anthropic auto-memory ACTIVE (${entry.file_count} files; last: ${entry.last_modified ?? 'unknown'})`
447
+ : entry.active === false
448
+ ? 'Anthropic auto-memory not active for this project (kit is the sole memory source)'
449
+ : 'Anthropic auto-memory state unknown (directory unreadable)',
450
+ };
451
+ }
452
+
453
+ // --- HC-9: Stale lock files -------------------------------------------
454
+ function hc9StaleLocks({ projectRoot, userDir }) {
455
+ const stale = detectStaleLocks(projectRoot, { userDir }).filter((r) => r.stale);
456
+ if (stale.length === 0) {
457
+ return {
458
+ id: 'HC-9',
459
+ name: 'No stale lock files',
460
+ status: 'pass',
461
+ message: 'all locks healthy',
462
+ };
463
+ }
464
+ // Surface the first lock's recoveryCommand. M4 fix (skill-review
465
+ // 2026-05-28): when more than one stale lock exists, the message
466
+ // calls out the remaining count so the user knows to re-run.
467
+ const first = stale[0];
468
+ const moreNote = stale.length > 1
469
+ ? ` (+ ${stale.length - 1} more — re-run after cleaning to surface)`
470
+ : '';
471
+ return {
472
+ id: 'HC-9',
473
+ name: 'No stale lock files',
474
+ status: 'fail',
475
+ message: `${stale.length} stale lock(s); first: ${first.path} (${first.reason})${moreNote}`,
476
+ recoveryCommand: first.recoveryCommand,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Run the full 9-check health audit.
482
+ *
483
+ * @param {object} opts
484
+ * @param {string} opts.projectRoot
485
+ * @param {string} [opts.userDir]
486
+ * @param {string} [opts.now]
487
+ * @returns {Promise<{action, checks, duration_ms}>}
488
+ *
489
+ * Note: M3 fix (skill-review 2026-05-28) dropped the v0.1.0 `promptUser`
490
+ * forward-compat parameter. It was destructured-then-void-discarded; no
491
+ * caller passes it. When auto-repair with consent ships (v0.1.x), the
492
+ * parameter lands at that PR alongside the actual consent flow — not
493
+ * pre-empted in v0.1.0 to avoid the "forward-compat hooks rot" pattern.
494
+ */
495
+ export async function runDoctor({
496
+ projectRoot,
497
+ userDir,
498
+ now,
499
+ } = {}) {
500
+ const t0 = Date.now();
501
+ if (!projectRoot) {
502
+ return {
503
+ action: 'error',
504
+ checks: [],
505
+ errors: ['projectRoot is required'],
506
+ duration_ms: Date.now() - t0,
507
+ };
508
+ }
509
+ const ts = now ?? nowIso();
510
+ const resolvedUserDir = userDir ?? join(homedir(), '.claude-memory-kit');
511
+
512
+ // Run in order. HC-7 depends on HC-1's verdict.
513
+ const c1 = await hc1Memsearch();
514
+ const c2 = hc2Hooks({ projectRoot });
515
+ const c3 = hc3DistillFreshness({ projectRoot, now: ts });
516
+ const c4 = hc4Transcripts({ projectRoot, now: ts });
517
+ const c5 = hc5IndexConsistency({ projectRoot });
518
+ const c6 = hc6CronRegistered({ projectRoot });
519
+ const c7 = hc7MemsearchReachable(c1);
520
+ const c8 = hc8NativeAutoMemory({ projectRoot, now: ts });
521
+ const c9 = hc9StaleLocks({ projectRoot, userDir: resolvedUserDir });
522
+
523
+ return {
524
+ action: 'completed',
525
+ checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9],
526
+ duration_ms: Date.now() - t0,
527
+ };
528
+ }