@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
@@ -0,0 +1,597 @@
1
+ // SQLite index rebuild + runtime file-watcher (Task 29, T-025).
2
+ //
3
+ // Composes on top of:
4
+ // - index-db.mjs (Task 28) — schema + openIndexDb
5
+ // - provenance.mjs (Task 13) — readBullet (parses bullet+comment pairs)
6
+ // - frontmatter.mjs (Task 7) — parse (YAML frontmatter for fact files)
7
+ // - tier-paths.mjs — resolveTierRoot, resolveFactDir, ID_PATTERN
8
+ //
9
+ // Public surface:
10
+ // listObservationSources({projectRoot, userDir})
11
+ // Returns absolute paths of every markdown file the kit treats as a
12
+ // source of observations: <tier>/MEMORY.md + <tier>/memory/*.md
13
+ // across the P / L / U tiers. Caller-skipped: today-{date}.md
14
+ // compression archives (Haiku output isn't kit-canonical bullet+comment
15
+ // shape — see design §16.x as a v0.1.x candidate to index session
16
+ // compressions as observations once Haiku's output schema is pinned).
17
+ //
18
+ // reindexBoot({projectRoot, userDir, db})
19
+ // Walk every source file. For each: compute sha1 of file content;
20
+ // compare against the `files` checkpoint table. Skip unchanged.
21
+ // Reindex changed: DELETE all rows where source_file = path, parse
22
+ // observations, INSERT, UPSERT files row. Atomic per-file via SQLite
23
+ // transaction so a partial reindex never leaves a half-written file.
24
+ //
25
+ // reindexFull({projectRoot, userDir, db})
26
+ // DROP observations / observations_fts / files tables; re-apply
27
+ // INDEX_DB_SCHEMA; walk + reindex every source unconditionally.
28
+ // Faster than DELETE FROM observations for large indexes because
29
+ // the FTS5 delete trigger doesn't fire per row.
30
+ //
31
+ // startRuntimeWatcher({projectRoot, userDir, db, debounceMs})
32
+ // chokidar watcher over the same source paths as listObservationSources.
33
+ // Debounced 500ms by default per design §9.2. Returns {close()} so the
34
+ // caller can shut down cleanly (tests, hook handlers).
35
+ //
36
+ // Design §9.2 reindex strategy:
37
+ // - Boot: walk + diff mtime+sha1 vs files table → reindex changed only
38
+ // - Runtime: chokidar 500ms debounce → reindex on FS event
39
+ // - Recovery: drop DB + rebuild from markdown
40
+ //
41
+ // Per CLAUDE.md "Shared modules" rule, this module imports from the
42
+ // established sources of truth and does NOT re-implement bullet/frontmatter
43
+ // parsing or path resolution.
44
+
45
+ import { createHash } from 'node:crypto';
46
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
47
+ import { basename, join, relative } from 'node:path';
48
+ import chokidar from 'chokidar';
49
+ import { INDEX_DB_SCHEMA } from './index-db.mjs';
50
+ import { readBullet, parseBulletProvenance } from './provenance.mjs';
51
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
52
+ import {
53
+ VALID_TIERS,
54
+ resolveTierRoot,
55
+ resolveFactDir,
56
+ ID_PATTERN,
57
+ } from './tier-paths.mjs';
58
+
59
+ // --- File listing -----------------------------------------------------
60
+
61
+ /**
62
+ * Enumerate the observation-source files across all three tiers.
63
+ * Returns objects with absolute path + the tier it belongs to + the
64
+ * file kind ('scratchpad' or 'fact') so callers don't have to
65
+ * re-derive the parsing strategy.
66
+ */
67
+ export function listObservationSources({ projectRoot, userDir }) {
68
+ const sources = [];
69
+ for (const tier of ['P', 'L', 'U']) {
70
+ const root = resolveTierRoot({ tier, projectRoot, userDir });
71
+ if (!existsSync(root)) continue;
72
+ // Scratchpad: <tier>/MEMORY.md
73
+ const scratchpad = join(root, 'MEMORY.md');
74
+ if (existsSync(scratchpad)) {
75
+ sources.push({ path: scratchpad, tier, kind: 'scratchpad' });
76
+ }
77
+ // Granular fact files: <tier>/memory/*.md (excluding INDEX.md)
78
+ const factDir = resolveFactDir(tier, root);
79
+ if (existsSync(factDir)) {
80
+ for (const entry of readdirSync(factDir, { withFileTypes: true })) {
81
+ if (!entry.isFile()) continue;
82
+ if (!entry.name.endsWith('.md')) continue;
83
+ if (entry.name === 'INDEX.md') continue;
84
+ sources.push({
85
+ path: join(factDir, entry.name),
86
+ tier,
87
+ kind: 'fact',
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return sources;
93
+ }
94
+
95
+ // --- Helpers ----------------------------------------------------------
96
+
97
+ function sha1OfContent(content) {
98
+ return createHash('sha1').update(content, 'utf8').digest('hex');
99
+ }
100
+
101
+ function isoToEpochMs(iso) {
102
+ const t = Date.parse(iso);
103
+ return Number.isFinite(t) ? t : 0;
104
+ }
105
+
106
+ function relativeSource(absPath, { projectRoot, userDir }) {
107
+ // Sibling-prefix guard: a naive startsWith() check would misclassify
108
+ // "/foo-other/x.md" as inside "/foo". The path-separator suffix
109
+ // ensures we only match true descendants. Surfaced as Important
110
+ // finding I2 by the Task 29 code-review.
111
+ const sep = process.platform === 'win32' ? /[\\/]/ : '/';
112
+ function isInside(parent, child) {
113
+ if (!parent) return false;
114
+ if (!child.startsWith(parent)) return false;
115
+ if (child.length === parent.length) return false;
116
+ const next = child.charAt(parent.length);
117
+ return process.platform === 'win32'
118
+ ? next === '\\' || next === '/'
119
+ : next === '/';
120
+ }
121
+ if (isInside(userDir, absPath)) {
122
+ return relative(userDir, absPath).replaceAll('\\', '/');
123
+ }
124
+ return relative(projectRoot, absPath).replaceAll('\\', '/');
125
+ }
126
+
127
+ // --- Parsing ----------------------------------------------------------
128
+
129
+ /**
130
+ * Parse a scratchpad MEMORY.md into observations.
131
+ *
132
+ * Walks line-by-line tracking the most recent h2 heading. For each
133
+ * bullet+comment pair, calls readBullet() to extract id/text/provenance.
134
+ * Returns one row per bullet conforming to the observations schema.
135
+ *
136
+ * Tolerant: bullets without a following provenance comment are skipped
137
+ * (the kit's writeBullet always emits both). Bullets whose readBullet()
138
+ * returns null (malformed id, missing required provenance fields) are
139
+ * skipped — the broader markdown file still indexes its valid bullets.
140
+ */
141
+ export function parseObservationsFromScratchpad({
142
+ path,
143
+ content,
144
+ tier,
145
+ projectRoot,
146
+ userDir,
147
+ }) {
148
+ const lines = content.split('\n');
149
+ const sha1 = sha1OfContent(content);
150
+ const source_file = relativeSource(path, { projectRoot, userDir });
151
+ const baseName = basename(path);
152
+
153
+ const observations = [];
154
+ let currentHeading = null;
155
+
156
+ for (let i = 0; i < lines.length; i++) {
157
+ const line = lines[i];
158
+ const headingMatch = /^##\s+(.+)$/.exec(line);
159
+ if (headingMatch) {
160
+ currentHeading = headingMatch[1].trim();
161
+ continue;
162
+ }
163
+ // Try to parse this line as a bullet, with line i+1 as the
164
+ // provenance comment.
165
+ const next = lines[i + 1] ?? '';
166
+ const bullet = readBullet({ bulletLine: line, commentLine: next });
167
+ if (!bullet) continue;
168
+ const { id, text, provenance } = bullet;
169
+ const heading_path = currentHeading
170
+ ? `${baseName} > ${currentHeading}`
171
+ : baseName;
172
+ observations.push({
173
+ id,
174
+ tier,
175
+ source_file,
176
+ source_line: i + 1,
177
+ source_sha1: sha1,
178
+ heading_path,
179
+ body: text,
180
+ write_source: provenance.write,
181
+ trust: provenance.trust,
182
+ created_at: isoToEpochMs(provenance.at),
183
+ superseded_by: provenance.superseded_by ?? null,
184
+ deleted_at: null, // scratchpads don't tombstone in place
185
+ });
186
+ // Skip the comment line so we don't try to parse it as a bullet.
187
+ i++;
188
+ }
189
+ return { observations, sha1 };
190
+ }
191
+
192
+ /**
193
+ * Parse a granular fact file into a single observation.
194
+ *
195
+ * Per-fact files have YAML frontmatter (id, type, title, source, sha1,
196
+ * write_source, trust, at, optional deleted_at + superseded_by) and a
197
+ * markdown body. The whole file = one observation row.
198
+ */
199
+ export function parseObservationsFromFactFile({
200
+ path,
201
+ content,
202
+ tier,
203
+ projectRoot,
204
+ userDir,
205
+ }) {
206
+ const sha1 = sha1OfContent(content);
207
+ const source_file = relativeSource(path, { projectRoot, userDir });
208
+ const baseName = basename(path);
209
+ const { frontmatter, body, parseError } = parseFrontmatter(content);
210
+ if (!frontmatter || parseError) {
211
+ return { observations: [], sha1, skipped: parseError ?? 'no frontmatter' };
212
+ }
213
+ if (!frontmatter.id || !ID_PATTERN.test(frontmatter.id)) {
214
+ return { observations: [], sha1, skipped: 'invalid or missing id' };
215
+ }
216
+ // Kit's writeFact (see packages/cli/src/write-fact.mjs:96-115) writes
217
+ // these field names: `created_at` (not `at`), `source_file` (not
218
+ // `source`), `source_sha1` (not `sha1`). An earlier draft of this
219
+ // parser used the shorter `at`/`source`/`sha1` names — surfaced by
220
+ // Task 29's code-review-excellence pass as a separately-correct-
221
+ // jointly-broken composition gap that would have made reindex a
222
+ // no-op for every kit-produced fact file. The fix here reads the
223
+ // canonical writer-emitted names; the test helper (seedFactFile)
224
+ // now uses writeFact() directly so this kind of drift surfaces at
225
+ // TDD time. Per CLAUDE.md "Integration-test coverage for cross-
226
+ // module flows".
227
+ if (!frontmatter.write_source || !frontmatter.trust || !frontmatter.created_at) {
228
+ return {
229
+ observations: [],
230
+ sha1,
231
+ skipped: 'missing write_source / trust / created_at',
232
+ };
233
+ }
234
+ // The kit's "type" field becomes the heading_path qualifier.
235
+ const heading_path = frontmatter.type
236
+ ? `${baseName} > ${frontmatter.type}`
237
+ : baseName;
238
+ // Important: the observations table's `source_file` field means
239
+ // "on-disk location of the markdown that holds this observation"
240
+ // (e.g., `memory/<fact>.md` for per-fact files, `MEMORY.md` for
241
+ // scratchpad bullets). It is NOT the frontmatter's `source_file`
242
+ // field, which is provenance — "where did this fact ORIGINATE
243
+ // from" (e.g., a MEMORY.md bullet that was promoted to a fact via
244
+ // `cmk promote`). The two concepts share a field name but have
245
+ // different semantics. The DELETE-then-INSERT pattern in
246
+ // replaceObservationsForFile keys on the on-disk location, so the
247
+ // index must use that interpretation. The provenance lineage is
248
+ // retrievable by reading the fact file's frontmatter when needed.
249
+ // source_sha1 is similarly the sha1 of the file being indexed —
250
+ // used as the diff key in reindexBoot's mtime+sha1 checkpoint.
251
+ const observation = {
252
+ id: frontmatter.id,
253
+ tier,
254
+ source_file,
255
+ source_line: 1, // frontmatter starts at line 1
256
+ source_sha1: sha1,
257
+ heading_path,
258
+ body: (body ?? '').trim() || (frontmatter.title ?? ''),
259
+ write_source: frontmatter.write_source,
260
+ trust: frontmatter.trust,
261
+ created_at: isoToEpochMs(frontmatter.created_at),
262
+ superseded_by: frontmatter.superseded_by ?? null,
263
+ deleted_at: frontmatter.deleted_at ? isoToEpochMs(frontmatter.deleted_at) : null,
264
+ };
265
+ return { observations: [observation], sha1 };
266
+ }
267
+
268
+ function parseSource(source, { projectRoot, userDir }) {
269
+ const content = readFileSync(source.path, 'utf8');
270
+ if (source.kind === 'scratchpad') {
271
+ return parseObservationsFromScratchpad({
272
+ path: source.path,
273
+ content,
274
+ tier: source.tier,
275
+ projectRoot,
276
+ userDir,
277
+ });
278
+ }
279
+ return parseObservationsFromFactFile({
280
+ path: source.path,
281
+ content,
282
+ tier: source.tier,
283
+ projectRoot,
284
+ userDir,
285
+ });
286
+ }
287
+
288
+ // --- DB write helpers -------------------------------------------------
289
+
290
+ const INSERT_OBSERVATION_SQL = `
291
+ INSERT INTO observations
292
+ (id, tier, source_file, source_line, source_sha1, heading_path, body,
293
+ write_source, trust, created_at, superseded_by, deleted_at)
294
+ VALUES
295
+ (@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
296
+ @write_source, @trust, @created_at, @superseded_by, @deleted_at)
297
+ `;
298
+
299
+ const UPSERT_FILE_SQL = `
300
+ INSERT INTO files (path, mtime, sha1, indexed_at)
301
+ VALUES (@path, @mtime, @sha1, @indexed_at)
302
+ ON CONFLICT(path) DO UPDATE SET
303
+ mtime = excluded.mtime,
304
+ sha1 = excluded.sha1,
305
+ indexed_at = excluded.indexed_at
306
+ `;
307
+
308
+ const DELETE_OBSERVATIONS_FOR_PATH_SQL = `DELETE FROM observations WHERE source_file = ?`;
309
+
310
+ /**
311
+ * Replace all observations for a single source file. Caller-wrapped
312
+ * in a transaction. The FTS5 delete-then-insert pattern fires the
313
+ * documented sync triggers (external-content sentinel + new insert).
314
+ */
315
+ function replaceObservationsForFile(db, { source, observations, mtime, sha1, projectRoot, userDir, now }) {
316
+ const source_file = relativeSource(source.path, { projectRoot, userDir });
317
+ db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
318
+ const insert = db.prepare(INSERT_OBSERVATION_SQL);
319
+ for (const obs of observations) {
320
+ insert.run(obs);
321
+ }
322
+ db.prepare(UPSERT_FILE_SQL).run({
323
+ path: source_file,
324
+ mtime,
325
+ sha1,
326
+ indexed_at: now,
327
+ });
328
+ }
329
+
330
+ // --- Public API: boot / full / watcher --------------------------------
331
+
332
+ /**
333
+ * Boot reindex: walk source files; reindex only those whose sha1
334
+ * differs from the `files` checkpoint.
335
+ *
336
+ * @returns {object} {filesScanned, filesReindexed, observationsAffected,
337
+ * durationMs, skipped: [{path, reason}]}
338
+ */
339
+ export function reindexBoot({ projectRoot, userDir, db, now }) {
340
+ const t0 = Date.now();
341
+ const ts = now ?? t0;
342
+ const sources = listObservationSources({ projectRoot, userDir });
343
+ const skipped = [];
344
+ let filesScanned = 0;
345
+ let filesReindexed = 0;
346
+ let observationsAffected = 0;
347
+
348
+ const txn = db.transaction((source) => {
349
+ const stat = statSync(source.path);
350
+ const mtime = Math.floor(stat.mtimeMs);
351
+ const result = parseSource(source, { projectRoot, userDir });
352
+ if (result.skipped) {
353
+ skipped.push({ path: source.path, reason: result.skipped });
354
+ return 0;
355
+ }
356
+ replaceObservationsForFile(db, {
357
+ source,
358
+ observations: result.observations,
359
+ mtime,
360
+ sha1: result.sha1,
361
+ projectRoot,
362
+ userDir,
363
+ now: ts,
364
+ });
365
+ return result.observations.length;
366
+ });
367
+
368
+ for (const source of sources) {
369
+ filesScanned++;
370
+ const content = readFileSync(source.path, 'utf8');
371
+ const sha1 = sha1OfContent(content);
372
+ const relPath = relativeSource(source.path, { projectRoot, userDir });
373
+ const existing = db
374
+ .prepare('SELECT sha1 FROM files WHERE path = ?')
375
+ .get(relPath);
376
+ if (existing && existing.sha1 === sha1) {
377
+ continue; // unchanged
378
+ }
379
+ const n = txn(source);
380
+ filesReindexed++;
381
+ observationsAffected += n;
382
+ }
383
+
384
+ return {
385
+ filesScanned,
386
+ filesReindexed,
387
+ observationsAffected,
388
+ durationMs: Date.now() - t0,
389
+ skipped,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Full reindex: drop observations + observations_fts + files tables,
395
+ * re-apply the schema, then walk + reindex every source.
396
+ *
397
+ * Faster than DELETE FROM observations for large indexes because the
398
+ * FTS5 sentinel triggers don't fire per row.
399
+ */
400
+ export function reindexFull({ projectRoot, userDir, db, now }) {
401
+ const t0 = Date.now();
402
+ const ts = now ?? t0;
403
+ // Drop + recreate (faster than per-row DELETE).
404
+ db.exec(`
405
+ DROP TABLE IF EXISTS observations_fts;
406
+ DROP TRIGGER IF EXISTS obs_after_insert;
407
+ DROP TRIGGER IF EXISTS obs_after_update;
408
+ DROP TRIGGER IF EXISTS obs_after_delete;
409
+ DROP TABLE IF EXISTS observations;
410
+ DROP TABLE IF EXISTS files;
411
+ `);
412
+ db.exec(INDEX_DB_SCHEMA);
413
+
414
+ const sources = listObservationSources({ projectRoot, userDir });
415
+ const skipped = [];
416
+ let filesScanned = 0;
417
+ let observationsAffected = 0;
418
+
419
+ const txn = db.transaction((source, sha1) => {
420
+ // sha1 is passed in (not recomputed) so the file-read for sha1
421
+ // matches the content parseSource will read again inside this txn.
422
+ // Tiny TOCTOU window: if the file changes between the outer read
423
+ // (sha1) and parseSource's read, the next reindex picks up the
424
+ // newest content — acceptable for a regenerable read-cache.
425
+ // Surfaced as Important finding I5 (dead `content` arg) in the
426
+ // Task 29 code-review; removed in this commit.
427
+ const stat = statSync(source.path);
428
+ const mtime = Math.floor(stat.mtimeMs);
429
+ const result = parseSource(source, { projectRoot, userDir });
430
+ if (result.skipped) {
431
+ skipped.push({ path: source.path, reason: result.skipped });
432
+ return 0;
433
+ }
434
+ replaceObservationsForFile(db, {
435
+ source,
436
+ observations: result.observations,
437
+ mtime,
438
+ sha1,
439
+ projectRoot,
440
+ userDir,
441
+ now: ts,
442
+ });
443
+ return result.observations.length;
444
+ });
445
+
446
+ for (const source of sources) {
447
+ filesScanned++;
448
+ const content = readFileSync(source.path, 'utf8');
449
+ const sha1 = sha1OfContent(content);
450
+ observationsAffected += txn(source, sha1);
451
+ }
452
+
453
+ return {
454
+ filesScanned,
455
+ observationsAffected,
456
+ durationMs: Date.now() - t0,
457
+ skipped,
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Runtime watcher. Returns a handle with .close() so the caller (tests,
463
+ * future hook handler) can shut it down cleanly.
464
+ *
465
+ * Debounce via chokidar's awaitWriteFinish (stability threshold = the
466
+ * caller's debounceMs, default 500ms per design §9.2). On `add` /
467
+ * `change` events: re-parse the touched file + replace its observations.
468
+ * On `unlink`: delete the file's observations (FTS5 sentinel trigger
469
+ * fires for each).
470
+ *
471
+ * Tier inference: paths are matched against the resolved tier roots —
472
+ * a path starting with the projectRoot's context/ is P; context.local/
473
+ * is L; userDir is U.
474
+ */
475
+ export function startRuntimeWatcher({
476
+ projectRoot,
477
+ userDir,
478
+ db,
479
+ debounceMs = 500,
480
+ }) {
481
+ // chokidar v5 dropped glob support (breaking change from v3). Watch
482
+ // the DIRECTORIES that contain observation-source files; filter events
483
+ // by extension + filename in the handlers. The MEMORY.md scratchpad
484
+ // is a single file so it can be watched directly; the memory/ fact
485
+ // directory is watched as a folder so chokidar receives 'add' events
486
+ // for newly-created per-fact files (e.g., from auto-extract's
487
+ // routeHigh writing a new fact).
488
+ const watchPaths = [];
489
+ const tierRoots = [];
490
+ for (const tier of ['P', 'L', 'U']) {
491
+ const root = resolveTierRoot({ tier, projectRoot, userDir });
492
+ if (!existsSync(root)) continue;
493
+ tierRoots.push({ tier, root });
494
+ const scratchpad = join(root, 'MEMORY.md');
495
+ if (existsSync(scratchpad)) watchPaths.push(scratchpad);
496
+ const factDir = resolveFactDir(tier, root);
497
+ if (existsSync(factDir)) watchPaths.push(factDir);
498
+ }
499
+ if (watchPaths.length === 0) {
500
+ return { close: async () => {}, watcher: null };
501
+ }
502
+
503
+ const watcher = chokidar.watch(watchPaths, {
504
+ ignoreInitial: true,
505
+ persistent: true,
506
+ awaitWriteFinish: {
507
+ stabilityThreshold: debounceMs,
508
+ pollInterval: 100,
509
+ },
510
+ });
511
+
512
+ function tierForPath(p) {
513
+ const np = p.replaceAll('\\', '/');
514
+ for (const { tier, root } of tierRoots) {
515
+ const nr = root.replaceAll('\\', '/');
516
+ // Sibling-prefix guard (I2): require a `/` after the root prefix
517
+ // so "/foo-other/..." doesn't match "/foo". Same logic as
518
+ // relativeSource's isInside helper.
519
+ if (np === nr || np.startsWith(nr + '/')) return tier;
520
+ }
521
+ return null;
522
+ }
523
+
524
+ function kindForPath(p) {
525
+ const np = p.replaceAll('\\', '/');
526
+ return /\/memory\/[^/]+\.md$/.test(np) ? 'fact' : 'scratchpad';
527
+ }
528
+
529
+ function isObservationSource(absPath) {
530
+ // Filter chokidar events. Watch is over directories; this filter
531
+ // drops non-.md files, INDEX.md (Task 8's pointer index — not an
532
+ // observation source), and anything outside the kit's tier roots.
533
+ if (!absPath.endsWith('.md')) return false;
534
+ if (basename(absPath) === 'INDEX.md') return false;
535
+ return VALID_TIERS.has(tierForPath(absPath));
536
+ }
537
+
538
+ function handleChange(absPath) {
539
+ if (!isObservationSource(absPath)) return;
540
+ const tier = tierForPath(absPath);
541
+ const kind = kindForPath(absPath);
542
+ const source = { path: absPath, tier, kind };
543
+ try {
544
+ const content = readFileSync(absPath, 'utf8');
545
+ const sha1 = sha1OfContent(content);
546
+ const result = parseSource(source, { projectRoot, userDir });
547
+ if (result.skipped) return;
548
+ const stat = statSync(absPath);
549
+ const mtime = Math.floor(stat.mtimeMs);
550
+ const txn = db.transaction(() => {
551
+ replaceObservationsForFile(db, {
552
+ source,
553
+ observations: result.observations,
554
+ mtime,
555
+ sha1,
556
+ projectRoot,
557
+ userDir,
558
+ now: Date.now(),
559
+ });
560
+ });
561
+ txn();
562
+ } catch (err) {
563
+ // Best-effort: a partial write or temp-file might trigger an event
564
+ // for a file that's already been replaced. Re-fire on the next event.
565
+ // Log to stderr with the file path so a poison-pill file doesn't
566
+ // fail silently — surfaced as Minor finding M4 by the Task 29
567
+ // code-review.
568
+ process.stderr.write(
569
+ `cmk runtime-watcher: skipped ${absPath}: ${err?.message ?? err}\n`,
570
+ );
571
+ }
572
+ }
573
+
574
+ function handleUnlink(absPath) {
575
+ if (!isObservationSource(absPath)) return;
576
+ const source_file = relativeSource(absPath, { projectRoot, userDir });
577
+ const txn = db.transaction(() => {
578
+ db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
579
+ db.prepare('DELETE FROM files WHERE path = ?').run(source_file);
580
+ });
581
+ txn();
582
+ }
583
+
584
+ watcher.on('add', handleChange);
585
+ watcher.on('change', handleChange);
586
+ watcher.on('unlink', handleUnlink);
587
+
588
+ return {
589
+ watcher,
590
+ close: () => watcher.close(),
591
+ };
592
+ }
593
+
594
+ // `parseBulletProvenance` re-export so a future test can probe a comment
595
+ // in isolation without re-importing from provenance.mjs. Kept narrow to
596
+ // avoid widening the module's API beyond what callers need.
597
+ export { parseBulletProvenance };
package/src/index.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // cmk CLI — top-level commander wiring.
2
+ // Task 2 (T-002) ships ONLY stubs. Each subcommand prints a
3
+ // "not yet implemented in v0.1.0 milestone N" message identifying
4
+ // the tasks.md task that lights it up. Every stub exits 0 — they are
5
+ // valid invocations of a CLI that doesn't do anything yet.
6
+ //
7
+ // Per tasks.md "Engineering discipline":
8
+ // - Deep modules: the subcommand registry is one module here.
9
+ // - Boundary testing: tests assert what `cmk --help` lists, what
10
+ // stubs output, and that exit codes are 0 — NOT how commander
11
+ // happens to format help text internally.
12
+
13
+ import { Command, Option } from 'commander';
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { subcommands } from './subcommands.mjs';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const PKG_ROOT = join(dirname(__filename), '..');
21
+
22
+ function readPackageVersion() {
23
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
24
+ return pkg.version;
25
+ }
26
+
27
+ /**
28
+ * Build the Commander program with every documented subcommand wired in
29
+ * as a stub. Exported separately so tests can introspect the program
30
+ * without invoking it.
31
+ */
32
+ export function buildProgram() {
33
+ const program = new Command();
34
+
35
+ program
36
+ .name('cmk')
37
+ .description(
38
+ 'claude-memory-kit — per-project, in-repo memory system for Claude Code. ' +
39
+ 'Run `cmk install` to scaffold a project, `cmk doctor` to verify health.'
40
+ )
41
+ .version(readPackageVersion(), '-V, --version', 'print the cmk version + exit');
42
+
43
+ for (const sub of subcommands) {
44
+ const cmd = program.command(sub.name).description(sub.description);
45
+
46
+ // Attach positional + flag declarations if the stub declares them.
47
+ if (sub.argSpec) {
48
+ for (const a of sub.argSpec) cmd.argument(a.flags, a.description);
49
+ }
50
+ if (sub.optionSpec) {
51
+ for (const o of sub.optionSpec) cmd.addOption(new Option(o.flags, o.description));
52
+ }
53
+ if (sub.children) {
54
+ for (const child of sub.children) {
55
+ const childCmd = cmd
56
+ .command(child.name)
57
+ .description(child.description);
58
+ if (child.argSpec) for (const a of child.argSpec) childCmd.argument(a.flags, a.description);
59
+ if (child.optionSpec) for (const o of child.optionSpec) childCmd.addOption(new Option(o.flags, o.description));
60
+ // Task 42 B3 fix (skill-review 2026-05-28): when a child has
61
+ // its OWN action (Task 38 transcripts/extract is the precedent),
62
+ // wire that directly so its options propagate to its handler.
63
+ // Falling back to `sub.action(child.name)` was a stub-era
64
+ // pattern from Task 2 that broke once children grew real
65
+ // logic — `transcripts` has no parent action and `cmk
66
+ // transcripts extract` crashed with TypeError.
67
+ if (typeof child.action === 'function') {
68
+ childCmd.action((...cmdArgs) => child.action(...cmdArgs));
69
+ } else {
70
+ childCmd.action(() => sub.action(child.name));
71
+ }
72
+ }
73
+ } else {
74
+ cmd.action((...cmdArgs) => sub.action(...cmdArgs));
75
+ }
76
+ }
77
+
78
+ return program;
79
+ }
80
+
81
+ /**
82
+ * Parse argv and dispatch. Returns a Promise that resolves after the
83
+ * matched subcommand action returns. Exits 0 on stub success.
84
+ *
85
+ * @param {string[]} argv - typically process.argv
86
+ */
87
+ export async function run(argv) {
88
+ const program = buildProgram();
89
+ await program.parseAsync(argv);
90
+ }