@lh8ppl/claude-memory-kit 0.1.0 → 0.1.2

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 (46) hide show
  1. package/README.md +77 -0
  2. package/bin/cmk-auto-extract.mjs +62 -0
  3. package/bin/cmk-capture-prompt.mjs +65 -0
  4. package/bin/cmk-capture-turn.mjs +76 -0
  5. package/bin/cmk-compress-lazy.mjs +0 -0
  6. package/bin/cmk-compress-session.mjs +64 -0
  7. package/bin/cmk-daily-distill.mjs +0 -0
  8. package/bin/cmk-inject-context.mjs +69 -0
  9. package/bin/cmk-observe-edit.mjs +57 -0
  10. package/bin/cmk-weekly-curate.mjs +0 -0
  11. package/bin/cmk.mjs +11 -11
  12. package/package.json +10 -2
  13. package/src/audit-log.mjs +1 -0
  14. package/src/claude-md.mjs +212 -212
  15. package/src/compressor.mjs +18 -18
  16. package/src/doctor.mjs +21 -8
  17. package/src/frontmatter.mjs +73 -73
  18. package/src/index-rebuild.mjs +26 -4
  19. package/src/inject-context.mjs +150 -10
  20. package/src/install.mjs +49 -1
  21. package/src/mcp-server.mjs +17 -0
  22. package/src/memory-write.mjs +18 -5
  23. package/src/merge-facts.mjs +213 -213
  24. package/src/provenance.mjs +217 -217
  25. package/src/reindex.mjs +134 -134
  26. package/src/repair.mjs +26 -96
  27. package/src/sanitize.mjs +39 -0
  28. package/src/settings-hooks.mjs +186 -0
  29. package/src/spawn-bin.mjs +83 -0
  30. package/src/subcommands.mjs +144 -10
  31. package/src/write-fact.mjs +46 -3
  32. package/template/.gitignore.fragment +12 -12
  33. package/template/CLAUDE.md.template +53 -49
  34. package/template/docs/journey/journey-log.md.template +292 -292
  35. package/template/project/memory/INDEX.md.template +47 -47
  36. package/template/support/cron-jobs/daily-memory-distill.md +15 -15
  37. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -17
  38. package/template/support/cron-jobs/weekly-memory-curator.md +15 -15
  39. package/template/support/milvus-deploy/README.md +57 -57
  40. package/template/support/milvus-deploy/docker-compose.yml +66 -66
  41. package/template/support/scripts/auto-extract-memory.sh +102 -102
  42. package/template/support/scripts/memsearch-index-with-flush.sh +59 -59
  43. package/template/support/scripts/refresh-distill-timestamp.py +35 -35
  44. package/template/support/scripts/register-crons.py +242 -242
  45. package/template/support/scripts/run-daily-distill.sh +67 -67
  46. package/template/support/scripts/run-weekly-curate.sh +58 -58
@@ -1,73 +1,73 @@
1
- // Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
2
- // every kit module uses to read and write per-fact frontmatter + scratchpad
3
- // HTML-comment provenance (Layer 3+ will join).
4
- //
5
- // Per the Layer-2 review's I2 finding, the previous code had THREE different
6
- // naive parsers across four modules (split-on-first-colon read; verbatim
7
- // stringify write). Output and input weren't symmetric: booleans round-tripped
8
- // as strings, arrays didn't round-trip at all, strings with `:` truncated on
9
- // read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
10
- // that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
11
- //
12
- // Public surface:
13
- // parse(text) → {frontmatter, body, parseError?}
14
- // - text: full file contents (with or without `---` markers)
15
- // - returns frontmatter as a typed object (string/number/bool/array/etc.)
16
- // - returns body as the markdown after the closing `---\n` (or empty)
17
- // - if no frontmatter block: frontmatter is null, body is the full text
18
- // - if YAML parse fails: frontmatter is null, parseError carries the message
19
- //
20
- // format({frontmatter, body}) → text
21
- // - frontmatter: typed object; key order preserved per insertion
22
- // - body: markdown; written verbatim after the closing `---\n`
23
- // - if frontmatter is null/empty: just returns body
24
- //
25
- // js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
26
- // ISO strings stay as strings). Output uses flowLevel: 1 — top-level
27
- // mapping is block style; nested arrays render as `[a, b]` (matches the
28
- // pre-refactor visual format).
29
-
30
- import yaml from 'js-yaml';
31
-
32
- const DUMP_OPTIONS = Object.freeze({
33
- schema: yaml.CORE_SCHEMA,
34
- flowLevel: 1,
35
- lineWidth: -1, // no line wrapping
36
- noRefs: true, // never emit YAML anchors / refs
37
- sortKeys: false, // preserve insertion order
38
- });
39
-
40
- const LOAD_OPTIONS = Object.freeze({
41
- schema: yaml.CORE_SCHEMA,
42
- });
43
-
44
- export function parse(text) {
45
- if (typeof text !== 'string') return { frontmatter: null, body: '' };
46
- const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
47
- if (!m) return { frontmatter: null, body: text };
48
- let frontmatter;
49
- try {
50
- frontmatter = yaml.load(m[1], LOAD_OPTIONS);
51
- } catch (e) {
52
- return { frontmatter: null, body: text, parseError: e.message };
53
- }
54
- if (frontmatter === undefined || frontmatter === null) {
55
- return { frontmatter: null, body: m[2] ?? '' };
56
- }
57
- if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
58
- return {
59
- frontmatter: null,
60
- body: text,
61
- parseError: 'frontmatter is not a mapping',
62
- };
63
- }
64
- return { frontmatter, body: m[2] ?? '' };
65
- }
66
-
67
- export function format({ frontmatter, body }) {
68
- if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
69
- return body ?? '';
70
- }
71
- const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
72
- return `---\n${yamlBody}---\n${body ?? ''}`;
73
- }
1
+ // Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
2
+ // every kit module uses to read and write per-fact frontmatter + scratchpad
3
+ // HTML-comment provenance (Layer 3+ will join).
4
+ //
5
+ // Per the Layer-2 review's I2 finding, the previous code had THREE different
6
+ // naive parsers across four modules (split-on-first-colon read; verbatim
7
+ // stringify write). Output and input weren't symmetric: booleans round-tripped
8
+ // as strings, arrays didn't round-trip at all, strings with `:` truncated on
9
+ // read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
10
+ // that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
11
+ //
12
+ // Public surface:
13
+ // parse(text) → {frontmatter, body, parseError?}
14
+ // - text: full file contents (with or without `---` markers)
15
+ // - returns frontmatter as a typed object (string/number/bool/array/etc.)
16
+ // - returns body as the markdown after the closing `---\n` (or empty)
17
+ // - if no frontmatter block: frontmatter is null, body is the full text
18
+ // - if YAML parse fails: frontmatter is null, parseError carries the message
19
+ //
20
+ // format({frontmatter, body}) → text
21
+ // - frontmatter: typed object; key order preserved per insertion
22
+ // - body: markdown; written verbatim after the closing `---\n`
23
+ // - if frontmatter is null/empty: just returns body
24
+ //
25
+ // js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
26
+ // ISO strings stay as strings). Output uses flowLevel: 1 — top-level
27
+ // mapping is block style; nested arrays render as `[a, b]` (matches the
28
+ // pre-refactor visual format).
29
+
30
+ import yaml from 'js-yaml';
31
+
32
+ const DUMP_OPTIONS = Object.freeze({
33
+ schema: yaml.CORE_SCHEMA,
34
+ flowLevel: 1,
35
+ lineWidth: -1, // no line wrapping
36
+ noRefs: true, // never emit YAML anchors / refs
37
+ sortKeys: false, // preserve insertion order
38
+ });
39
+
40
+ const LOAD_OPTIONS = Object.freeze({
41
+ schema: yaml.CORE_SCHEMA,
42
+ });
43
+
44
+ export function parse(text) {
45
+ if (typeof text !== 'string') return { frontmatter: null, body: '' };
46
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
47
+ if (!m) return { frontmatter: null, body: text };
48
+ let frontmatter;
49
+ try {
50
+ frontmatter = yaml.load(m[1], LOAD_OPTIONS);
51
+ } catch (e) {
52
+ return { frontmatter: null, body: text, parseError: e.message };
53
+ }
54
+ if (frontmatter === undefined || frontmatter === null) {
55
+ return { frontmatter: null, body: m[2] ?? '' };
56
+ }
57
+ if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
58
+ return {
59
+ frontmatter: null,
60
+ body: text,
61
+ parseError: 'frontmatter is not a mapping',
62
+ };
63
+ }
64
+ return { frontmatter, body: m[2] ?? '' };
65
+ }
66
+
67
+ export function format({ frontmatter, body }) {
68
+ if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
69
+ return body ?? '';
70
+ }
71
+ const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
72
+ return `---\n${yamlBody}---\n${body ?? ''}`;
73
+ }
@@ -367,14 +367,36 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
367
367
 
368
368
  for (const source of sources) {
369
369
  filesScanned++;
370
- const content = readFileSync(source.path, 'utf8');
371
- const sha1 = sha1OfContent(content);
372
370
  const relPath = relativeSource(source.path, { projectRoot, userDir });
373
371
  const existing = db
374
- .prepare('SELECT sha1 FROM files WHERE path = ?')
372
+ .prepare('SELECT mtime, sha1 FROM files WHERE path = ?')
375
373
  .get(relPath);
374
+ // Fast path: if the file's mtime matches the checkpoint, the content is
375
+ // unchanged — skip the read + sha1 entirely. This realizes design §9.2's
376
+ // "mtime+sha1 diff" intent (the prior impl sha1'd every file on every
377
+ // call) and is what makes reindexBoot cheap enough to run before every
378
+ // `cmk search` (finding #0) even as the memory corpus grows.
379
+ let mtime = null;
380
+ try {
381
+ mtime = Math.floor(statSync(source.path).mtimeMs);
382
+ } catch {
383
+ // stat failed (file vanished mid-walk); fall through to the read,
384
+ // which surfaces the error naturally.
385
+ }
386
+ if (existing && mtime !== null && existing.mtime === mtime) {
387
+ continue; // unchanged (mtime match — no read needed)
388
+ }
389
+ // Caveat: a content change that PRESERVES the old mtime (e.g. a restore
390
+ // tool that sets --times) is missed until the next real change or a
391
+ // `reindex --full`. Negligible in practice — the kit always writes a
392
+ // fresh mtime after the indexed one — and standard for mtime-based diffs.
393
+ //
394
+ // mtime differs (or no checkpoint) — confirm via sha1 so a mere mtime
395
+ // touch (content identical) doesn't trigger a needless reindex.
396
+ const content = readFileSync(source.path, 'utf8');
397
+ const sha1 = sha1OfContent(content);
376
398
  if (existing && existing.sha1 === sha1) {
377
- continue; // unchanged
399
+ continue; // content unchanged despite mtime touch
378
400
  }
379
401
  const n = txn(source);
380
402
  filesReindexed++;
@@ -84,17 +84,20 @@ const TIER_BUDGETS = Object.freeze({
84
84
  });
85
85
 
86
86
  // Per-tier reading plan. The hook reads the scratchpads allowed at that
87
- // tier (per SCRATCHPADS_BY_TIER) plus the tier's INDEX file, plusfor
88
- // the project tier — the most recent rolling-window day file.
87
+ // tier (per SCRATCHPADS_BY_TIER) plus for the project tierthe most
88
+ // recent rolling-window day file.
89
+ //
90
+ // INDEX.md is deliberately NOT in the snapshot (#R, 2026-05-30). It is a
91
+ // pointer/reference doc that self-declares "NOT auto-loaded at session
92
+ // start" in its own template body — injecting it both violated that
93
+ // contract and pushed ~2 KB of reference prose into Claude's context,
94
+ // crowding out real facts. It stays on disk for lookup via `cmk search` /
95
+ // the granular archive; it is not session-start content.
89
96
  function plannedFilesForTier(tier, tierRoot) {
90
97
  const files = [];
91
98
  for (const name of SCRATCHPADS_BY_TIER[tier]) {
92
99
  files.push(join(tierRoot, name));
93
100
  }
94
- // INDEX: P/L use memory/INDEX.md; U uses fragments/INDEX.md (per
95
- // resolveFactDir asymmetry in tier-paths.mjs).
96
- const indexDir = tier === 'U' ? 'fragments' : 'memory';
97
- files.push(join(tierRoot, indexDir, 'INDEX.md'));
98
101
  if (tier === 'P') {
99
102
  const sessionsDir = join(tierRoot, 'sessions');
100
103
  const latest = latestDaySession(sessionsDir);
@@ -138,10 +141,145 @@ function tierDirExists(tier, tierRoot) {
138
141
  return existsSync(tierRoot) && statSync(tierRoot).isDirectory();
139
142
  }
140
143
 
144
+ // The all-zero sha1 is the kit's template-seed sentinel: every scaffolded
145
+ // placeholder bullet (in machine-paths/overrides/SOUL/USER/HABITS/LESSONS)
146
+ // carries `sha1: 0000…0000` + `at: 2020-01-01T…`. A real captured fact
147
+ // always has a real content sha1. We use this to distinguish "scaffolding
148
+ // the user never replaced" from "a fact worth injecting".
149
+ const SEED_SHA1_RE = /sha1:\s*0{40}/;
150
+
151
+ // All HTML-comment handling below uses STRING SCANNING (indexOf/startsWith),
152
+ // never a regex tag-filter. Regex-based HTML-comment stripping is fragile by
153
+ // nature (it can't see newlines, leaves partial `<!--`, etc. — flagged by
154
+ // CodeQL's js/bad-tag-filter). String scanning is both more robust and not a
155
+ // tag-filter, so it sidesteps that whole class.
156
+
157
+ // True if `line`, ignoring surrounding whitespace, is exactly one self-
158
+ // contained HTML comment (`<!-- … -->`) — e.g. a per-bullet provenance line.
159
+ function isCommentOnlyLine(line) {
160
+ if (typeof line !== 'string') return false;
161
+ const t = line.trim();
162
+ return t.startsWith('<!--') && t.endsWith('-->') && t.length >= 7;
163
+ }
164
+
165
+ // Remove every self-contained `<!-- … -->` span WITHIN a single line, by
166
+ // scanning for delimiter pairs. An unterminated `<!--` (no `-->` on this
167
+ // line) is left in place for the multi-line state machine to handle.
168
+ function stripInlineComments(line) {
169
+ let out = '';
170
+ let i = 0;
171
+ for (;;) {
172
+ const open = line.indexOf('<!--', i);
173
+ if (open === -1) return out + line.slice(i);
174
+ const close = line.indexOf('-->', open + 4);
175
+ if (close === -1) return out + line.slice(i); // unterminated; leave it
176
+ out += line.slice(i, open);
177
+ i = close + 3;
178
+ }
179
+ }
180
+
181
+ // Is `bulletLine` a placeholder/seed bullet that should NOT be injected?
182
+ // Primary signal: a following provenance comment carrying the all-zero seed
183
+ // sha1 (every scaffolded template bullet has it; a real captured fact never
184
+ // does). Secondary: the `(example)` marker — but ONLY in the template's
185
+ // exact `(P-XXXXXXXX) (example) …` shape (right after the citation id), so a
186
+ // real fact whose text merely mentions "(example)" is not mis-dropped.
187
+ function isSeedBullet(bulletLine, nextLine) {
188
+ if (/^\s*-\s+\([PUL]-[A-Za-z0-9]{8}\)\s+\(example\)/.test(bulletLine)) {
189
+ return true;
190
+ }
191
+ const prov = isCommentOnlyLine(nextLine) ? nextLine : '';
192
+ return SEED_SHA1_RE.test(prov);
193
+ }
194
+
195
+ // Remove HTML comments robustly, including the kit templates' multi-line
196
+ // format-explanation headers that ILLUSTRATIVELY embed a single-line
197
+ // `<!-- source… -->` example inside the outer `<!-- … -->` block (a naive
198
+ // "first <!-- to first -->" pass closes on that inner `-->` and orphans the
199
+ // tail). We strip inline comments first (killing the nested one) and only
200
+ // then walk the now-cleanly-delimited multi-line blocks. All string-scan.
201
+ function stripHtmlComments(text) {
202
+ // Pass 1 — remove every self-contained `<!-- … -->` on a single line.
203
+ const lines = text.split('\n').map(stripInlineComments);
204
+ // Pass 2 — remove multi-line blocks (each now free of any inner `-->`).
205
+ const out = [];
206
+ let inBlock = false;
207
+ for (let line of lines) {
208
+ if (inBlock) {
209
+ const close = line.indexOf('-->');
210
+ if (close === -1) continue; // still inside the block; drop the line
211
+ inBlock = false;
212
+ line = line.slice(close + 3);
213
+ }
214
+ const open = line.indexOf('<!--');
215
+ if (open !== -1) {
216
+ inBlock = true;
217
+ line = line.slice(0, open);
218
+ }
219
+ if (line.trim() !== '' || out.length === 0 || out[out.length - 1] !== '') {
220
+ out.push(line.replace(/[ \t]+$/, ''));
221
+ }
222
+ }
223
+ return out.join('\n');
224
+ }
225
+
226
+ // Clean a scratchpad body for INJECTION (not for on-disk storage — the
227
+ // files keep their human-editing headers). Self-test finding #R: the raw
228
+ // bodies are ~70% template-comment noise + placeholder seed bullets that
229
+ // bury (and crowd out) the real captured facts, so the model concludes
230
+ // "no real facts populated yet". This strips:
231
+ // 1. placeholder seed bullets (all-zero sha1 / `(example)`) + their
232
+ // provenance comment line, and
233
+ // 2. ALL remaining `<!-- -->` comments (multi-line format-explanation
234
+ // headers AND per-bullet provenance — the fact text + its `(P-…)`
235
+ // citation id carry everything the model needs to read & cite).
236
+ // Whitespace is normalized so stripped regions don't leave holes.
237
+ //
238
+ // Known limitation (rare): a captured fact whose TEXT contains a literal
239
+ // `<!--`/`-->` (e.g. a note about HTML/templating) has that fragment
240
+ // stripped from the INJECTED view. The on-disk fact and the search index
241
+ // are unaffected — only the session-start snapshot loses the literal
242
+ // comment markers. Accepted as a rare edge vs. the cost of distinguishing
243
+ // real comments from comment-shaped fact text.
244
+ function cleanScratchpadBody(body) {
245
+ // Normalize CRLF so user-edited (Windows) scratchpads don't leave stray
246
+ // \r after comment/seed stripping.
247
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
248
+ const kept = [];
249
+ for (let i = 0; i < lines.length; i++) {
250
+ const line = lines[i];
251
+ if (
252
+ /^\s*-\s/.test(line) &&
253
+ ID_TOKEN_RE.test(line) &&
254
+ isSeedBullet(line, lines[i + 1])
255
+ ) {
256
+ if (isCommentOnlyLine(lines[i + 1])) i++;
257
+ continue;
258
+ }
259
+ kept.push(line);
260
+ }
261
+ // Step 2 — strip all remaining comments (format headers + real-bullet
262
+ // provenance), then normalize whitespace.
263
+ return stripHtmlComments(kept.join('\n'))
264
+ .replace(/\n{3,}/g, '\n\n')
265
+ .replace(/^\n+|\n+$/g, '');
266
+ }
267
+
268
+ // After cleaning, does a body carry any real content — i.e. a non-blank
269
+ // line that isn't a markdown heading? A body of only headings (every
270
+ // bullet was a stripped seed) is pure scaffolding and must NOT contribute
271
+ // a tier block (otherwise the model sees an empty "## …" skeleton).
272
+ function hasRealContent(cleaned) {
273
+ return cleaned
274
+ .split('\n')
275
+ .some((l) => l.trim() !== '' && !/^#{1,6}\s/.test(l));
276
+ }
277
+
141
278
  // Read the snapshot-eligible content for one tier as a single string. If
142
- // no tier files exist (or the tier dir itself is absent), returns ''. The
143
- // per-file content is wrapped in a fenced header so the snapshot is
144
- // self-describing to whoever reads Claude's context window.
279
+ // no tier files exist (or the tier dir itself is absent), returns ''. Each
280
+ // file body is cleaned for injection (see cleanScratchpadBody); files that
281
+ // reduce to scaffolding-only contribute nothing, and a tier whose every
282
+ // file is scaffolding-only is excluded entirely (no header, no skeleton).
145
283
  function readTierBlock(tier, tierRoot) {
146
284
  if (!tierDirExists(tier, tierRoot)) return '';
147
285
  const sections = [];
@@ -154,7 +292,9 @@ function readTierBlock(tier, tierRoot) {
154
292
  continue;
155
293
  }
156
294
  if (body.trim() === '') continue;
157
- sections.push(body);
295
+ const cleaned = cleanScratchpadBody(body);
296
+ if (!hasRealContent(cleaned)) continue;
297
+ sections.push(cleaned);
158
298
  }
159
299
  if (sections.length === 0) return '';
160
300
  const header = `<!-- cmk: ${TIER_LABELS[tier]} tier (${tier}) -->`;
package/src/install.mjs CHANGED
@@ -43,6 +43,8 @@ import { homedir } from 'node:os';
43
43
  import { dirname, join, relative, resolve } from 'node:path';
44
44
  import { fileURLToPath } from 'node:url';
45
45
  import { injectClaudeMdBlock } from './claude-md.mjs';
46
+ import { writeKitHooks } from './settings-hooks.mjs';
47
+ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
46
48
 
47
49
  const __filename = fileURLToPath(import.meta.url);
48
50
  const CLI_SRC_DIR = dirname(__filename);
@@ -295,7 +297,53 @@ export async function install(options = {}) {
295
297
  });
296
298
  }
297
299
 
298
- return { projectRoot, userTier, created, skipped, gitignore, claudeMd, errors };
300
+ // Hook wiring Task 49. This is what makes `npm install -g
301
+ // @lh8ppl/claude-memory-kit` + `cmk install` a COMPLETE entry point
302
+ // (no separate `/plugin install` step needed). Writes the npm-route
303
+ // hooks block (PATH-resolved bare bin names, shell form) into
304
+ // <projectRoot>/.claude/settings.json via the shared writeKitHooks
305
+ // boundary — same boundary `cmk repair --hooks` uses, so install and
306
+ // repair never drift. Idempotent: a re-run with already-canonical
307
+ // hooks is a no-op. Opt out with {noHooks:true} (CLI: --no-hooks) for
308
+ // scaffold-only installs.
309
+ let hooks = { action: 'skipped', path: join(projectRoot, '.claude', 'settings.json') };
310
+ if (!options.noHooks) {
311
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
312
+ const r = writeKitHooks(settingsPath);
313
+ if (r.error) {
314
+ errors.push({ path: settingsPath, error: r.error });
315
+ hooks = { action: 'error', path: settingsPath, error: r.error };
316
+ } else {
317
+ hooks = {
318
+ action: r.changed ? 'wired' : 'unchanged',
319
+ path: settingsPath,
320
+ events: r.events,
321
+ };
322
+ // Door-4 audit entry — install wires user-visible Claude Code
323
+ // config; a "cmk install changed my settings.json" report needs a
324
+ // trail. Emitted ONLY when something actually changed: a no-op
325
+ // re-install has nothing to audit, and emitting on no-op would make
326
+ // the append-only audit.log grow on every run, breaking install's
327
+ // idempotency guarantee (re-run = byte-identical project tree).
328
+ // Best-effort: never block install on an audit-log failure.
329
+ if (r.changed) {
330
+ try {
331
+ appendAuditEntry(join(projectRoot, 'context'), {
332
+ ts: nowIso(),
333
+ action: 'install',
334
+ tier: 'P',
335
+ id: 'P-NSTLHKWR', // synthetic stable id for install-hooks events (base32 alphabet)
336
+ reasonCode: REASON_CODES.INSTALL_HOOKS_WIRED,
337
+ extra: { settingsPath, events: r.events },
338
+ });
339
+ } catch {
340
+ // best-effort
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, errors };
299
347
  }
300
348
 
301
349
  /**
@@ -34,6 +34,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
34
34
  import { z } from 'zod';
35
35
  import { resolve as resolvePath, isAbsolute } from 'node:path';
36
36
  import { openIndexDb } from './index-db.mjs';
37
+ import { reindexBoot } from './index-rebuild.mjs';
37
38
  import { search, SEARCH_MODES } from './search.mjs';
38
39
  import { memoryWrite } from './memory-write.mjs';
39
40
  import { ID_PATTERN, resolveTierRoot } from './tier-paths.mjs';
@@ -451,6 +452,22 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
451
452
  */
452
453
  export async function runMcpServer({ projectRoot, userDir, db: dbOverride, semanticBackend } = {}) {
453
454
  const db = dbOverride ?? openIndexDb({ projectRoot });
455
+ // Refresh the index at server startup so mk_search sees facts already on
456
+ // disk — same fresh-install gap as `cmk search` (self-test finding #0):
457
+ // nothing reindexes for a just-installed project, so without this the
458
+ // model's first mk_search returns empty for facts sitting in the
459
+ // scratchpads. Incremental (mtime/sha1 diff) + best-effort; in-session
460
+ // freshness for facts written AFTER startup is the runtime watcher's job
461
+ // (future). The in-process buildMcpServer tests bypass this path.
462
+ if (projectRoot) {
463
+ try {
464
+ reindexBoot({ projectRoot, userDir, db });
465
+ } catch (err) {
466
+ process.stderr.write(
467
+ `cmk-mcp-server: startup index refresh failed: ${err?.message ?? err}\n`,
468
+ );
469
+ }
470
+ }
454
471
  const server = buildMcpServer({ projectRoot, userDir, db, semanticBackend });
455
472
  const transport = new StdioServerTransport();
456
473
 
@@ -57,6 +57,7 @@ import { appendScratchpadBullet } from './scratchpad.mjs';
57
57
  import { parseBulletProvenance } from './provenance.mjs';
58
58
  import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
59
59
  import { detectConflicts, writeConflictEntry } from './conflict-queue.mjs';
60
+ import { sanitizeHomePaths } from './sanitize.mjs';
60
61
 
61
62
  const VALID_ACTIONS = new Set(['add', 'replace', 'remove']);
62
63
 
@@ -252,8 +253,20 @@ function doAdd(opts) {
252
253
  if (errors.length > 0) {
253
254
  return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
254
255
  }
256
+ // Privacy (write-path fix #1): abstract home-dir paths to `~` for
257
+ // committed/shared tiers (P/U) BEFORE the bullet is screened, conflict-
258
+ // checked, dedup-keyed, and written — so a captured fact never ships the
259
+ // local username and stays portable. Local tier (L) keeps machine paths
260
+ // verbatim (its purpose). Everything downstream uses `addOpts`.
261
+ const sanitizedText =
262
+ opts.tier === 'P' || opts.tier === 'U'
263
+ ? sanitizeHomePaths(opts.text)
264
+ : opts.text;
265
+ const addOpts =
266
+ sanitizedText === opts.text ? opts : { ...opts, text: sanitizedText };
267
+
255
268
  const poisonResult = runPoisonGuard({
256
- text: opts.text,
269
+ text: addOpts.text,
257
270
  projectRoot: opts.projectRoot,
258
271
  source: opts.source,
259
272
  sessionId: opts.sessionId,
@@ -276,7 +289,7 @@ function doAdd(opts) {
276
289
  userDir: opts.userDir,
277
290
  });
278
291
  const conflict = detectConflicts({
279
- newText: opts.text,
292
+ newText: addOpts.text,
280
293
  newTrust,
281
294
  scratchpadPath,
282
295
  sectionTitle: opts.section,
@@ -296,14 +309,14 @@ function doAdd(opts) {
296
309
  // appendScratchpadBullet would have used, then route to the queue.
297
310
  // (Task 25b fix: generateId is positional `(tier, text)`, not
298
311
  // named-args — Task 25 originally called it as an object.)
299
- const proposedId = generateId(opts.tier, opts.text);
312
+ const proposedId = generateId(addOpts.tier, addOpts.text);
300
313
  const ts = opts.now ?? nowIso();
301
314
  return writeConflictEntry({
302
315
  tier: opts.tier,
303
316
  projectRoot: opts.projectRoot,
304
317
  userDir: opts.userDir,
305
318
  newId: proposedId,
306
- newText: opts.text,
319
+ newText: addOpts.text,
307
320
  newTrust,
308
321
  existingId: conflict.existingId,
309
322
  existingText: conflict.existingText,
@@ -313,7 +326,7 @@ function doAdd(opts) {
313
326
  detectedAt: ts,
314
327
  });
315
328
  }
316
- return appendBulletGuarded(opts);
329
+ return appendBulletGuarded(addOpts);
317
330
  }
318
331
 
319
332
  function appendBulletGuarded(opts) {