@lh8ppl/claude-memory-kit 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) 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/doctor.mjs +16 -5
  16. package/src/frontmatter.mjs +73 -73
  17. package/src/install.mjs +49 -1
  18. package/src/merge-facts.mjs +213 -213
  19. package/src/provenance.mjs +217 -217
  20. package/src/reindex.mjs +134 -134
  21. package/src/repair.mjs +26 -96
  22. package/src/settings-hooks.mjs +186 -0
  23. package/src/subcommands.mjs +13 -2
  24. package/template/.gitignore.fragment +12 -12
  25. package/template/CLAUDE.md.template +49 -49
  26. package/template/docs/journey/journey-log.md.template +292 -292
  27. package/template/project/memory/INDEX.md.template +47 -47
  28. package/template/support/cron-jobs/daily-memory-distill.md +15 -15
  29. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -17
  30. package/template/support/cron-jobs/weekly-memory-curator.md +15 -15
  31. package/template/support/milvus-deploy/README.md +57 -57
  32. package/template/support/milvus-deploy/docker-compose.yml +66 -66
  33. package/template/support/scripts/auto-extract-memory.sh +102 -102
  34. package/template/support/scripts/memsearch-index-with-flush.sh +59 -59
  35. package/template/support/scripts/refresh-distill-timestamp.py +35 -35
  36. package/template/support/scripts/register-crons.py +242 -242
  37. package/template/support/scripts/run-daily-distill.sh +67 -67
  38. package/template/support/scripts/run-weekly-curate.sh +58 -58
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
  /**
@@ -1,213 +1,213 @@
1
- // Fact consolidation (Task 10, refactored in cleanup-layer-2-cross-module-drift).
2
- // Single public boundary: mergeFacts(opts) → result. See design §3.4.
3
- //
4
- // Uses shared modules: tier-paths, frontmatter, audit-log, result-shapes.
5
- // Composes writeFact() to create the new merged fact, then moves A + B into
6
- // archive/superseded/ with superseded_by injected. See CLAUDE.md "Shared
7
- // modules" rule.
8
-
9
- import {
10
- existsSync,
11
- mkdirSync,
12
- readdirSync,
13
- readFileSync,
14
- statSync,
15
- unlinkSync,
16
- writeFileSync,
17
- } from 'node:fs';
18
- import { join } from 'node:path';
19
- import {
20
- VALID_TIERS,
21
- ID_PATTERN,
22
- resolveTierRoot,
23
- resolveFactDir,
24
- } from './tier-paths.mjs';
25
- import { parse, format } from './frontmatter.mjs';
26
- import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
27
- import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
28
- import { writeFact } from './write-fact.mjs';
29
-
30
- function listLiveFactFiles(factDir) {
31
- if (!existsSync(factDir)) return [];
32
- const out = [];
33
- for (const entry of readdirSync(factDir, { withFileTypes: true })) {
34
- if (!entry.isFile()) continue;
35
- if (!entry.name.endsWith('.md')) continue;
36
- if (entry.name === 'INDEX.md') continue;
37
- out.push(entry.name);
38
- }
39
- return out;
40
- }
41
-
42
- function findLiveFactById(factDir, id) {
43
- if (!existsSync(factDir)) return null;
44
- for (const filename of listLiveFactFiles(factDir)) {
45
- const p = join(factDir, filename);
46
- if (!statSync(p).isFile()) continue;
47
- const { frontmatter, body } = parse(readFileSync(p, 'utf8'));
48
- if (frontmatter?.id === id && !frontmatter.deleted_at) {
49
- return { id, path: p, frontmatter, body };
50
- }
51
- }
52
- return null;
53
- }
54
-
55
- function moveToSuperseded(match, supersededBy) {
56
- const supersededDir = join(match.factDir, 'archive', 'superseded');
57
- mkdirSync(supersededDir, { recursive: true });
58
- const newPath = join(supersededDir, `${match.id}.md`);
59
- const { frontmatter, body } = parse(readFileSync(match.path, 'utf8'));
60
- const updated = {
61
- superseded_by: supersededBy,
62
- ...(frontmatter ?? {}),
63
- };
64
- writeFileSync(newPath, format({ frontmatter: updated, body }), 'utf8');
65
- unlinkSync(match.path);
66
- return newPath;
67
- }
68
-
69
- export function mergeFacts(opts = {}) {
70
- const {
71
- idA,
72
- idB,
73
- mergedBody,
74
- mergedTitle,
75
- mergedSlug,
76
- mergedType,
77
- writeSource,
78
- trust,
79
- sourceFile,
80
- sourceLine,
81
- sourceSha1,
82
- mergedTags,
83
- projectRoot,
84
- userDir,
85
- now,
86
- } = opts;
87
-
88
- const errors = [];
89
- if (!idA || !ID_PATTERN.test(idA)) errors.push('idA: must be a valid citation ID');
90
- if (!idB || !ID_PATTERN.test(idB)) errors.push('idB: must be a valid citation ID');
91
- if (idA && idB && idA === idB) {
92
- return errorResult({
93
- category: ERROR_CATEGORIES.SCHEMA,
94
- errors: [`idA and idB are the same (${idA}); cannot merge a fact with itself`],
95
- });
96
- }
97
- if (!mergedBody || typeof mergedBody !== 'string' || !mergedBody.length) {
98
- errors.push('mergedBody: required, non-empty string');
99
- }
100
- if (!mergedTitle || typeof mergedTitle !== 'string') {
101
- errors.push('mergedTitle: required, non-empty string');
102
- }
103
- // Layer-2 review S4: removed the redundant `mergedSlug` truthy check. The
104
- // downstream writeFact owns all slug validation (pattern + presence).
105
- // Inconsistent layering disappears; bad slugs surface from writeFact with
106
- // a clear schema error.
107
- //
108
- // Layer-2 review S3: writeSource is now REQUIRED (no compressor default).
109
- // Compressor was the most-suspicious default — Task 23 auto-extract and
110
- // Task 24 memory-write are NOT compressor-driven. Forcing the caller to
111
- // pick avoids accidentally tagging human-curated merges as 'compressor'.
112
- if (!writeSource || typeof writeSource !== 'string') {
113
- errors.push('writeSource: required (no default). Pick one of user-explicit/auto-extract/compressor/manual-edit/imported.');
114
- }
115
- if (errors.length > 0) {
116
- return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
117
- }
118
-
119
- const tierA = idA[0];
120
- const tierB = idB[0];
121
- if (tierA !== tierB) {
122
- return errorResult({
123
- category: ERROR_CATEGORIES.SCHEMA,
124
- errors: [
125
- `cross-tier merge not supported: idA tier (${tierA}) ≠ idB tier (${tierB}). Promote one side to the same tier first.`,
126
- ],
127
- });
128
- }
129
- const tier = tierA;
130
- if (!VALID_TIERS.has(tier)) {
131
- return errorResult({
132
- category: ERROR_CATEGORIES.SCHEMA,
133
- errors: [`invalid tier prefix on ids: ${tier}`],
134
- });
135
- }
136
-
137
- const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
138
- const factDir = resolveFactDir(tier, tierRoot);
139
-
140
- const matchA = findLiveFactById(factDir, idA);
141
- const matchB = findLiveFactById(factDir, idB);
142
- if (!matchA || !matchB) {
143
- const missing = [];
144
- if (!matchA) missing.push(idA);
145
- if (!matchB) missing.push(idB);
146
- return notFoundResult({
147
- errors: [`no live fact found for ${missing.join(', ')}`],
148
- });
149
- }
150
- matchA.factDir = factDir;
151
- matchB.factDir = factDir;
152
-
153
- const typeC =
154
- mergedType ?? matchA.frontmatter.type ?? matchB.frontmatter.type;
155
-
156
- const writeResult = writeFact({
157
- tier,
158
- type: typeC,
159
- slug: mergedSlug,
160
- title: mergedTitle,
161
- body: mergedBody,
162
- writeSource,
163
- trust: trust ?? 'high',
164
- sourceFile: sourceFile ?? matchA.frontmatter.source_file ?? 'merge',
165
- sourceLine: sourceLine ?? 1,
166
- sourceSha1: sourceSha1 ?? matchA.frontmatter.source_sha1 ?? 'merged',
167
- mergedFrom: [idA, idB],
168
- tags: mergedTags,
169
- projectRoot,
170
- userDir,
171
- });
172
- if (writeResult.action === 'error') {
173
- return errorResult({
174
- category: writeResult.errorCategory,
175
- errors: writeResult.errors,
176
- });
177
- }
178
- // PR-1 blocker B1 fix preserved: writeFact dedup'd to an existing unrelated
179
- // fact → return collision error rather than silently retargeting A and B.
180
- if (writeResult.action !== 'created') {
181
- return errorResult({
182
- category: ERROR_CATEGORIES.COLLISION,
183
- errors: [
184
- `merged body collides with existing fact ${writeResult.id} (writeFact returned ${writeResult.action}${writeResult.skipReason ? ': ' + writeResult.skipReason : ''}); choose a different mergedBody`,
185
- ],
186
- });
187
- }
188
-
189
- const supersededA = moveToSuperseded(matchA, writeResult.id);
190
- const supersededB = moveToSuperseded(matchB, writeResult.id);
191
-
192
- const ts = now ?? nowIso();
193
- appendAuditEntry(tierRoot, {
194
- ts,
195
- action: 'merged',
196
- tier,
197
- id: writeResult.id,
198
- reasonCode: REASON_CODES.CURATED_MERGE,
199
- paths: {
200
- after: writeResult.path,
201
- archive: [supersededA, supersededB],
202
- },
203
- extra: { mergedFrom: [idA, idB] },
204
- });
205
-
206
- return {
207
- action: 'merged',
208
- id: writeResult.id,
209
- tier,
210
- path: writeResult.path,
211
- supersededPaths: [supersededA, supersededB],
212
- };
213
- }
1
+ // Fact consolidation (Task 10, refactored in cleanup-layer-2-cross-module-drift).
2
+ // Single public boundary: mergeFacts(opts) → result. See design §3.4.
3
+ //
4
+ // Uses shared modules: tier-paths, frontmatter, audit-log, result-shapes.
5
+ // Composes writeFact() to create the new merged fact, then moves A + B into
6
+ // archive/superseded/ with superseded_by injected. See CLAUDE.md "Shared
7
+ // modules" rule.
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ statSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import {
20
+ VALID_TIERS,
21
+ ID_PATTERN,
22
+ resolveTierRoot,
23
+ resolveFactDir,
24
+ } from './tier-paths.mjs';
25
+ import { parse, format } from './frontmatter.mjs';
26
+ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
27
+ import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
28
+ import { writeFact } from './write-fact.mjs';
29
+
30
+ function listLiveFactFiles(factDir) {
31
+ if (!existsSync(factDir)) return [];
32
+ const out = [];
33
+ for (const entry of readdirSync(factDir, { withFileTypes: true })) {
34
+ if (!entry.isFile()) continue;
35
+ if (!entry.name.endsWith('.md')) continue;
36
+ if (entry.name === 'INDEX.md') continue;
37
+ out.push(entry.name);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function findLiveFactById(factDir, id) {
43
+ if (!existsSync(factDir)) return null;
44
+ for (const filename of listLiveFactFiles(factDir)) {
45
+ const p = join(factDir, filename);
46
+ if (!statSync(p).isFile()) continue;
47
+ const { frontmatter, body } = parse(readFileSync(p, 'utf8'));
48
+ if (frontmatter?.id === id && !frontmatter.deleted_at) {
49
+ return { id, path: p, frontmatter, body };
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function moveToSuperseded(match, supersededBy) {
56
+ const supersededDir = join(match.factDir, 'archive', 'superseded');
57
+ mkdirSync(supersededDir, { recursive: true });
58
+ const newPath = join(supersededDir, `${match.id}.md`);
59
+ const { frontmatter, body } = parse(readFileSync(match.path, 'utf8'));
60
+ const updated = {
61
+ superseded_by: supersededBy,
62
+ ...(frontmatter ?? {}),
63
+ };
64
+ writeFileSync(newPath, format({ frontmatter: updated, body }), 'utf8');
65
+ unlinkSync(match.path);
66
+ return newPath;
67
+ }
68
+
69
+ export function mergeFacts(opts = {}) {
70
+ const {
71
+ idA,
72
+ idB,
73
+ mergedBody,
74
+ mergedTitle,
75
+ mergedSlug,
76
+ mergedType,
77
+ writeSource,
78
+ trust,
79
+ sourceFile,
80
+ sourceLine,
81
+ sourceSha1,
82
+ mergedTags,
83
+ projectRoot,
84
+ userDir,
85
+ now,
86
+ } = opts;
87
+
88
+ const errors = [];
89
+ if (!idA || !ID_PATTERN.test(idA)) errors.push('idA: must be a valid citation ID');
90
+ if (!idB || !ID_PATTERN.test(idB)) errors.push('idB: must be a valid citation ID');
91
+ if (idA && idB && idA === idB) {
92
+ return errorResult({
93
+ category: ERROR_CATEGORIES.SCHEMA,
94
+ errors: [`idA and idB are the same (${idA}); cannot merge a fact with itself`],
95
+ });
96
+ }
97
+ if (!mergedBody || typeof mergedBody !== 'string' || !mergedBody.length) {
98
+ errors.push('mergedBody: required, non-empty string');
99
+ }
100
+ if (!mergedTitle || typeof mergedTitle !== 'string') {
101
+ errors.push('mergedTitle: required, non-empty string');
102
+ }
103
+ // Layer-2 review S4: removed the redundant `mergedSlug` truthy check. The
104
+ // downstream writeFact owns all slug validation (pattern + presence).
105
+ // Inconsistent layering disappears; bad slugs surface from writeFact with
106
+ // a clear schema error.
107
+ //
108
+ // Layer-2 review S3: writeSource is now REQUIRED (no compressor default).
109
+ // Compressor was the most-suspicious default — Task 23 auto-extract and
110
+ // Task 24 memory-write are NOT compressor-driven. Forcing the caller to
111
+ // pick avoids accidentally tagging human-curated merges as 'compressor'.
112
+ if (!writeSource || typeof writeSource !== 'string') {
113
+ errors.push('writeSource: required (no default). Pick one of user-explicit/auto-extract/compressor/manual-edit/imported.');
114
+ }
115
+ if (errors.length > 0) {
116
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
117
+ }
118
+
119
+ const tierA = idA[0];
120
+ const tierB = idB[0];
121
+ if (tierA !== tierB) {
122
+ return errorResult({
123
+ category: ERROR_CATEGORIES.SCHEMA,
124
+ errors: [
125
+ `cross-tier merge not supported: idA tier (${tierA}) ≠ idB tier (${tierB}). Promote one side to the same tier first.`,
126
+ ],
127
+ });
128
+ }
129
+ const tier = tierA;
130
+ if (!VALID_TIERS.has(tier)) {
131
+ return errorResult({
132
+ category: ERROR_CATEGORIES.SCHEMA,
133
+ errors: [`invalid tier prefix on ids: ${tier}`],
134
+ });
135
+ }
136
+
137
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
138
+ const factDir = resolveFactDir(tier, tierRoot);
139
+
140
+ const matchA = findLiveFactById(factDir, idA);
141
+ const matchB = findLiveFactById(factDir, idB);
142
+ if (!matchA || !matchB) {
143
+ const missing = [];
144
+ if (!matchA) missing.push(idA);
145
+ if (!matchB) missing.push(idB);
146
+ return notFoundResult({
147
+ errors: [`no live fact found for ${missing.join(', ')}`],
148
+ });
149
+ }
150
+ matchA.factDir = factDir;
151
+ matchB.factDir = factDir;
152
+
153
+ const typeC =
154
+ mergedType ?? matchA.frontmatter.type ?? matchB.frontmatter.type;
155
+
156
+ const writeResult = writeFact({
157
+ tier,
158
+ type: typeC,
159
+ slug: mergedSlug,
160
+ title: mergedTitle,
161
+ body: mergedBody,
162
+ writeSource,
163
+ trust: trust ?? 'high',
164
+ sourceFile: sourceFile ?? matchA.frontmatter.source_file ?? 'merge',
165
+ sourceLine: sourceLine ?? 1,
166
+ sourceSha1: sourceSha1 ?? matchA.frontmatter.source_sha1 ?? 'merged',
167
+ mergedFrom: [idA, idB],
168
+ tags: mergedTags,
169
+ projectRoot,
170
+ userDir,
171
+ });
172
+ if (writeResult.action === 'error') {
173
+ return errorResult({
174
+ category: writeResult.errorCategory,
175
+ errors: writeResult.errors,
176
+ });
177
+ }
178
+ // PR-1 blocker B1 fix preserved: writeFact dedup'd to an existing unrelated
179
+ // fact → return collision error rather than silently retargeting A and B.
180
+ if (writeResult.action !== 'created') {
181
+ return errorResult({
182
+ category: ERROR_CATEGORIES.COLLISION,
183
+ errors: [
184
+ `merged body collides with existing fact ${writeResult.id} (writeFact returned ${writeResult.action}${writeResult.skipReason ? ': ' + writeResult.skipReason : ''}); choose a different mergedBody`,
185
+ ],
186
+ });
187
+ }
188
+
189
+ const supersededA = moveToSuperseded(matchA, writeResult.id);
190
+ const supersededB = moveToSuperseded(matchB, writeResult.id);
191
+
192
+ const ts = now ?? nowIso();
193
+ appendAuditEntry(tierRoot, {
194
+ ts,
195
+ action: 'merged',
196
+ tier,
197
+ id: writeResult.id,
198
+ reasonCode: REASON_CODES.CURATED_MERGE,
199
+ paths: {
200
+ after: writeResult.path,
201
+ archive: [supersededA, supersededB],
202
+ },
203
+ extra: { mergedFrom: [idA, idB] },
204
+ });
205
+
206
+ return {
207
+ action: 'merged',
208
+ id: writeResult.id,
209
+ tier,
210
+ path: writeResult.path,
211
+ supersededPaths: [supersededA, supersededB],
212
+ };
213
+ }