@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.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- 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
|
+
}
|