@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
package/src/reindex.mjs CHANGED
@@ -1,134 +1,134 @@
1
- // Granular-archive pointer-index writer (Task 8, refactored in
2
- // cleanup-layer-2-cross-module-drift). Single public boundary:
3
- // reindex(opts) → result. See design §2.3.
4
- //
5
- // Uses shared modules: tier-paths (path resolution), frontmatter (js-yaml
6
- // parse). See CLAUDE.md "Shared modules" rule.
7
-
8
- import {
9
- existsSync,
10
- mkdirSync,
11
- readdirSync,
12
- readFileSync,
13
- writeFileSync,
14
- } from 'node:fs';
15
- import { join } from 'node:path';
16
- import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
17
- import { parse } from './frontmatter.mjs';
18
-
19
- const INDEX_SIZE_WARN_BYTES = 25 * 1024;
20
- const HOOK_MAX_LEN = 80;
21
-
22
- const TIER_LABEL = {
23
- P: 'project tier',
24
- L: 'local tier',
25
- U: 'user tier',
26
- };
27
-
28
- function extractHook(body) {
29
- for (const raw of body.split('\n')) {
30
- const line = raw.trim();
31
- if (!line) continue;
32
- if (line.startsWith('#')) continue;
33
- if (line.length > HOOK_MAX_LEN) {
34
- return line.slice(0, HOOK_MAX_LEN).trimEnd() + '...';
35
- }
36
- return line;
37
- }
38
- return '';
39
- }
40
-
41
- function formatIndexLine({ id, type, title, filename, hook }) {
42
- const head = `- (${id}) [${type}] [${title}](${filename})`;
43
- return hook ? `${head} — ${hook}` : head;
44
- }
45
-
46
- function listFactFiles(factDir) {
47
- if (!existsSync(factDir)) return [];
48
- const out = [];
49
- for (const entry of readdirSync(factDir, { withFileTypes: true })) {
50
- if (!entry.isFile()) continue;
51
- if (!entry.name.endsWith('.md')) continue;
52
- if (entry.name === 'INDEX.md') continue;
53
- out.push(entry.name);
54
- }
55
- return out.sort();
56
- }
57
-
58
- export function reindex(opts = {}) {
59
- const { tier, projectRoot, userDir, warn } = opts;
60
- if (!tier || !VALID_TIERS.has(tier)) {
61
- throw new Error(
62
- `reindex: invalid tier ${JSON.stringify(tier)}. Must be 'U', 'P', or 'L'.`,
63
- );
64
- }
65
- const emit = warn ?? ((msg) => process.stderr.write(msg + '\n'));
66
- const warnings = [];
67
- function pushWarning(msg) {
68
- warnings.push(msg);
69
- emit(msg);
70
- }
71
-
72
- const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
73
- const factDir = resolveFactDir(tier, tierRoot);
74
- mkdirSync(factDir, { recursive: true });
75
-
76
- const entries = [];
77
- for (const filename of listFactFiles(factDir)) {
78
- const path = join(factDir, filename);
79
- let text;
80
- try {
81
- text = readFileSync(path, 'utf8');
82
- } catch (e) {
83
- pushWarning(`reindex: failed to read ${filename}: ${e.message}`);
84
- continue;
85
- }
86
- const { frontmatter, body, parseError } = parse(text);
87
- if (!frontmatter) {
88
- pushWarning(
89
- `reindex: ${filename} skipped — ${parseError ?? 'no YAML frontmatter'}`,
90
- );
91
- continue;
92
- }
93
- if (!frontmatter.id || !frontmatter.type || !frontmatter.title) {
94
- pushWarning(
95
- `reindex: ${filename} skipped — missing required frontmatter field(s) (id/type/title)`,
96
- );
97
- continue;
98
- }
99
- if (frontmatter.deleted_at) continue;
100
- entries.push({
101
- id: frontmatter.id,
102
- type: frontmatter.type,
103
- title: frontmatter.title,
104
- filename,
105
- hook: extractHook(body),
106
- });
107
- }
108
-
109
- entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
110
-
111
- const header = `# Granular memory index — ${TIER_LABEL[tier]}\n\n## Files\n`;
112
- const bodyLines = entries.map(formatIndexLine).join('\n');
113
- const content = entries.length
114
- ? `${header}\n${bodyLines}\n`
115
- : `${header}\n`;
116
-
117
- const indexPath = join(factDir, 'INDEX.md');
118
- writeFileSync(indexPath, content, 'utf8');
119
-
120
- const bytes = Buffer.byteLength(content, 'utf8');
121
- if (bytes > INDEX_SIZE_WARN_BYTES) {
122
- pushWarning(
123
- `reindex: ${indexPath} is ${(bytes / 1024).toFixed(1)} KB (>25 KB); consider consolidation`,
124
- );
125
- }
126
-
127
- return {
128
- tier,
129
- indexPath,
130
- factCount: entries.length,
131
- bytes,
132
- warnings,
133
- };
134
- }
1
+ // Granular-archive pointer-index writer (Task 8, refactored in
2
+ // cleanup-layer-2-cross-module-drift). Single public boundary:
3
+ // reindex(opts) → result. See design §2.3.
4
+ //
5
+ // Uses shared modules: tier-paths (path resolution), frontmatter (js-yaml
6
+ // parse). See CLAUDE.md "Shared modules" rule.
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
17
+ import { parse } from './frontmatter.mjs';
18
+
19
+ const INDEX_SIZE_WARN_BYTES = 25 * 1024;
20
+ const HOOK_MAX_LEN = 80;
21
+
22
+ const TIER_LABEL = {
23
+ P: 'project tier',
24
+ L: 'local tier',
25
+ U: 'user tier',
26
+ };
27
+
28
+ function extractHook(body) {
29
+ for (const raw of body.split('\n')) {
30
+ const line = raw.trim();
31
+ if (!line) continue;
32
+ if (line.startsWith('#')) continue;
33
+ if (line.length > HOOK_MAX_LEN) {
34
+ return line.slice(0, HOOK_MAX_LEN).trimEnd() + '...';
35
+ }
36
+ return line;
37
+ }
38
+ return '';
39
+ }
40
+
41
+ function formatIndexLine({ id, type, title, filename, hook }) {
42
+ const head = `- (${id}) [${type}] [${title}](${filename})`;
43
+ return hook ? `${head} — ${hook}` : head;
44
+ }
45
+
46
+ function listFactFiles(factDir) {
47
+ if (!existsSync(factDir)) return [];
48
+ const out = [];
49
+ for (const entry of readdirSync(factDir, { withFileTypes: true })) {
50
+ if (!entry.isFile()) continue;
51
+ if (!entry.name.endsWith('.md')) continue;
52
+ if (entry.name === 'INDEX.md') continue;
53
+ out.push(entry.name);
54
+ }
55
+ return out.sort();
56
+ }
57
+
58
+ export function reindex(opts = {}) {
59
+ const { tier, projectRoot, userDir, warn } = opts;
60
+ if (!tier || !VALID_TIERS.has(tier)) {
61
+ throw new Error(
62
+ `reindex: invalid tier ${JSON.stringify(tier)}. Must be 'U', 'P', or 'L'.`,
63
+ );
64
+ }
65
+ const emit = warn ?? ((msg) => process.stderr.write(msg + '\n'));
66
+ const warnings = [];
67
+ function pushWarning(msg) {
68
+ warnings.push(msg);
69
+ emit(msg);
70
+ }
71
+
72
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
73
+ const factDir = resolveFactDir(tier, tierRoot);
74
+ mkdirSync(factDir, { recursive: true });
75
+
76
+ const entries = [];
77
+ for (const filename of listFactFiles(factDir)) {
78
+ const path = join(factDir, filename);
79
+ let text;
80
+ try {
81
+ text = readFileSync(path, 'utf8');
82
+ } catch (e) {
83
+ pushWarning(`reindex: failed to read ${filename}: ${e.message}`);
84
+ continue;
85
+ }
86
+ const { frontmatter, body, parseError } = parse(text);
87
+ if (!frontmatter) {
88
+ pushWarning(
89
+ `reindex: ${filename} skipped — ${parseError ?? 'no YAML frontmatter'}`,
90
+ );
91
+ continue;
92
+ }
93
+ if (!frontmatter.id || !frontmatter.type || !frontmatter.title) {
94
+ pushWarning(
95
+ `reindex: ${filename} skipped — missing required frontmatter field(s) (id/type/title)`,
96
+ );
97
+ continue;
98
+ }
99
+ if (frontmatter.deleted_at) continue;
100
+ entries.push({
101
+ id: frontmatter.id,
102
+ type: frontmatter.type,
103
+ title: frontmatter.title,
104
+ filename,
105
+ hook: extractHook(body),
106
+ });
107
+ }
108
+
109
+ entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
110
+
111
+ const header = `# Granular memory index — ${TIER_LABEL[tier]}\n\n## Files\n`;
112
+ const bodyLines = entries.map(formatIndexLine).join('\n');
113
+ const content = entries.length
114
+ ? `${header}\n${bodyLines}\n`
115
+ : `${header}\n`;
116
+
117
+ const indexPath = join(factDir, 'INDEX.md');
118
+ writeFileSync(indexPath, content, 'utf8');
119
+
120
+ const bytes = Buffer.byteLength(content, 'utf8');
121
+ if (bytes > INDEX_SIZE_WARN_BYTES) {
122
+ pushWarning(
123
+ `reindex: ${indexPath} is ${(bytes / 1024).toFixed(1)} KB (>25 KB); consider consolidation`,
124
+ );
125
+ }
126
+
127
+ return {
128
+ tier,
129
+ indexPath,
130
+ factCount: entries.length,
131
+ bytes,
132
+ warnings,
133
+ };
134
+ }
package/src/repair.mjs CHANGED
@@ -16,14 +16,10 @@
16
16
  // Per design §14 + tasks.md 39 (39.1–39.3).
17
17
 
18
18
  import {
19
- existsSync,
20
- mkdirSync,
21
- readFileSync,
22
19
  statSync,
23
20
  unlinkSync,
24
- writeFileSync,
25
21
  } from 'node:fs';
26
- import { dirname, join } from 'node:path';
22
+ import { join } from 'node:path';
27
23
  import {
28
24
  appendAuditEntry,
29
25
  nowIso,
@@ -31,104 +27,38 @@ import {
31
27
  } from './audit-log.mjs';
32
28
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
33
29
  import { detectStaleLocks } from './lock-discipline.mjs';
30
+ import { writeKitHooks } from './settings-hooks.mjs';
34
31
 
35
32
  const DEFAULT_STALE_LOCK_MS = 60 * 60 * 1000; // 1 hour
36
33
  const SETTINGS_REL = ['.claude', 'settings.json'];
37
34
 
38
- // I1 fix (Task 39 skill-review 2026-05-28): the canonical hooks block
39
- // is embedded INLINE as a JS constant instead of being read from
40
- // plugin/hooks/hooks.json. Rationale: packages/cli/package.json `files`
41
- // lists ['bin/', 'src/', 'README.md'] the plugin/ tree is OUTSIDE the
42
- // published @lh8ppl/claude-memory-kit tarball. Reading from plugin/ works
43
- // in-repo (where __dirname resolves up to the repo root) but breaks
44
- // post-`npm install -g` where plugin/ doesn't exist.
35
+ // Task 49 (2026-05-29): the canonical hooks block + the read-merge-write
36
+ // logic moved to settings-hooks.mjs (single source of truth, shared with
37
+ // install.mjs). It also switched from the PLUGIN form (`bash
38
+ // "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup) to the
39
+ // npm-route form (PATH-resolved bare bin names, 5 events) so that
40
+ // `cmk repair --hooks` produces hooks that work with NO plugin loaded —
41
+ // matching `cmk install`'s new posture as a complete entry point. The
42
+ // full decision trail (incl. why Setup/cmk-version-check is dropped and
43
+ // where the plugin form still lives) is documented in settings-hooks.mjs.
45
44
  //
46
- // Same Task-33-B1 class of bug: cron-emission paths were also broken
47
- // post-npm-install-g until they embedded absolute paths at registration
48
- // time. The embed-the-canonical-constant pattern is the durable fix.
49
- //
50
- // Source of truth: keep this in sync with plugin/hooks/hooks.json. A
51
- // future validator (scripts/validate-hooks-block-sync.mjs) would catch
52
- // drift automatically; v0.1.x candidate.
53
- const KIT_HOOKS_BLOCK = Object.freeze({
54
- Setup: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-version-check"', timeout: 30 }] }],
55
- SessionStart: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-inject-context"', timeout: 30 }] }],
56
- UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-prompt"', timeout: 10 }] }],
57
- PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-observe-edit"', async: true, timeout: 120 }] }],
58
- Stop: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-turn"', timeout: 30 }] }],
59
- SessionEnd: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-compress-session"', timeout: 60 }] }],
60
- });
45
+ // Pre-Task-49 history (kept for the trail): the block was previously
46
+ // embedded inline here as a fix for the I1 finding (Task 39 skill-review,
47
+ // 2026-05-28) reading from plugin/hooks/hooks.json broke post-npm-install-g
48
+ // because the plugin/ tree is outside the published tarball. The inline
49
+ // constant fixed that; Task 49 then extracted it to the shared module.
61
50
 
62
51
  /**
63
52
  * Repair `<projectRoot>/.claude/settings.json` by merging in the kit's
64
- * canonical hooks block. Preserves any other top-level keys + non-kit
65
- * hook entries (e.g., the user's own PreToolUse hooks under different
66
- * matchers).
53
+ * canonical hooks block (via the shared writeKitHooks boundary).
54
+ * Preserves any other top-level keys + non-kit hook entries.
67
55
  */
68
56
  function repairHooks({ projectRoot, ts }) {
69
- // I1 fix: use the inlined KIT_HOOKS_BLOCK constant; no file read,
70
- // no npm-install-g brittleness.
71
- const kitHooks = KIT_HOOKS_BLOCK;
72
-
73
57
  const settingsPath = join(projectRoot, ...SETTINGS_REL);
74
- let settings = {};
75
- if (existsSync(settingsPath)) {
76
- try {
77
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
78
- } catch (err) {
79
- return {
80
- kind: 'hooks',
81
- changed: false,
82
- error: `${settingsPath} parse error: ${err?.message ?? err}`,
83
- };
84
- }
85
- }
86
-
87
- const before = JSON.stringify(settings);
88
- if (!settings.hooks || typeof settings.hooks !== 'object') {
89
- settings.hooks = {};
90
- }
91
-
92
- // Merge each event array: replace the kit's hook entries (matched by
93
- // command substring) with the canonical version; keep any user-added
94
- // entries that don't reference the kit.
95
- const KIT_COMMAND_TOKENS = [
96
- 'cmk-version-check',
97
- 'cmk-inject-context',
98
- 'cmk-capture-prompt',
99
- 'cmk-observe-edit',
100
- 'cmk-capture-turn',
101
- 'cmk-compress-session',
102
- ];
103
- const isKitEntry = (entry) => {
104
- if (!entry || typeof entry !== 'object') return false;
105
- // Entry shape varies: {command} or {hooks: [{command}]}.
106
- const collectCommands = (e) => {
107
- const cmds = [];
108
- if (typeof e.command === 'string') cmds.push(e.command);
109
- if (Array.isArray(e.hooks)) {
110
- for (const h of e.hooks) if (typeof h.command === 'string') cmds.push(h.command);
111
- }
112
- return cmds;
113
- };
114
- const cmds = collectCommands(entry);
115
- return cmds.some((c) => KIT_COMMAND_TOKENS.some((t) => c.includes(t)));
116
- };
117
-
118
- for (const [eventName, kitEntries] of Object.entries(kitHooks)) {
119
- const existing = Array.isArray(settings.hooks[eventName])
120
- ? settings.hooks[eventName]
121
- : [];
122
- const userEntries = existing.filter((e) => !isKitEntry(e));
123
- settings.hooks[eventName] = [...userEntries, ...kitEntries];
124
- }
125
-
126
- const after = JSON.stringify(settings);
127
- const changed = before !== after;
58
+ const r = writeKitHooks(settingsPath);
128
59
 
129
- if (changed) {
130
- mkdirSync(dirname(settingsPath), { recursive: true });
131
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
60
+ if (r.error) {
61
+ return { kind: 'hooks', changed: false, error: r.error };
132
62
  }
133
63
 
134
64
  // I3 fix (Task 39 skill-review 2026-05-28): emit a Door-4 audit
@@ -141,8 +71,8 @@ function repairHooks({ projectRoot, ts }) {
141
71
  action: 'repair',
142
72
  tier: 'P',
143
73
  id: 'P-RPHKAPLD', // synthetic stable id for hooks-repair events (base32 alphabet)
144
- reasonCode: changed ? REASON_CODES.REPAIR_HOOKS_APPLIED : REASON_CODES.REPAIR_HOOKS_NOOP,
145
- extra: { settingsPath, events: Object.keys(kitHooks) },
74
+ reasonCode: r.changed ? REASON_CODES.REPAIR_HOOKS_APPLIED : REASON_CODES.REPAIR_HOOKS_NOOP,
75
+ extra: { settingsPath: r.settingsPath, events: r.events },
146
76
  });
147
77
  } catch {
148
78
  // best-effort — never block repair on audit-log failure
@@ -150,9 +80,9 @@ function repairHooks({ projectRoot, ts }) {
150
80
 
151
81
  return {
152
82
  kind: 'hooks',
153
- changed,
154
- settingsPath,
155
- events: Object.keys(kitHooks),
83
+ changed: r.changed,
84
+ settingsPath: r.settingsPath,
85
+ events: r.events,
156
86
  };
157
87
  }
158
88
 
@@ -0,0 +1,39 @@
1
+ // sanitize.mjs — privacy sanitizers applied before durable writes to a
2
+ // committed/shared tier. Sibling to poison-guard.mjs, but where Poison_Guard
3
+ // REJECTS a write (secrets/poison), these REWRITE it (privacy abstraction).
4
+ //
5
+ // Write-path fix #1 (the self-test privacy leak): a durable fact written to a
6
+ // committed project tier carried the local username inside an absolute
7
+ // interpreter path (C:\Users\<you>\...\python.exe), shipping it to git and
8
+ // making the fact non-portable. sanitizeHomePaths abstracts the home-dir
9
+ // prefix to `~` — killing the username leak AND making the fact portable
10
+ // across machines — while preserving everything after the home dir.
11
+ //
12
+ // Applied to P (committed) and U (cross-project) tier writes. NOT to L
13
+ // (local, gitignored) — machine-specific absolute paths are the whole point
14
+ // of the local tier, so they stay verbatim there.
15
+
16
+ // Each pattern matches an absolute home-directory prefix up to (but not
17
+ // including) the next path separator / whitespace / quote, so the remainder
18
+ // of the path is preserved. Username char class excludes separators, spaces,
19
+ // quotes, and shell/redirect metacharacters.
20
+ const USER = "[^\\\\/\\s\"'`<>|]+";
21
+ // Case-INSENSITIVE: Windows + macOS filesystems are case-insensitive, so a
22
+ // fact may carry `c:\users\you\…` or `/users/you`; the `i` flag keeps the
23
+ // privacy abstraction from being bypassed by lowercasing.
24
+ const HOME_PATH_PATTERNS = [
25
+ new RegExp(`[A-Za-z]:[\\\\/]Users[\\\\/]${USER}`, 'gi'), // Windows C:\Users\name (either slash)
26
+ new RegExp(`/Users/${USER}`, 'gi'), // macOS /Users/name
27
+ new RegExp(`/home/${USER}`, 'gi'), // Linux /home/name
28
+ ];
29
+
30
+ /**
31
+ * Abstract absolute home-directory prefixes to `~`. Returns non-string input
32
+ * unchanged (callers may pass undefined for optional fields).
33
+ */
34
+ export function sanitizeHomePaths(text) {
35
+ if (typeof text !== 'string') return text;
36
+ let out = text;
37
+ for (const re of HOME_PATH_PATTERNS) out = out.replace(re, '~');
38
+ return out;
39
+ }
@@ -0,0 +1,186 @@
1
+ // settings-hooks.mjs — the canonical npm-route hooks block + the
2
+ // read-merge-write logic that wires it into a project's
3
+ // <projectRoot>/.claude/settings.json. (Task 49, T-037.)
4
+ //
5
+ // SINGLE SOURCE OF TRUTH for the npm-route hook wiring. Both
6
+ // `cmk install` (install.mjs — the complete npm entry point) and
7
+ // `cmk repair --hooks` (repair.mjs) import from here. Before Task 49
8
+ // the block lived inline in repair.mjs as `KIT_HOOKS_BLOCK`; it was
9
+ // extracted + de-plugin-ified here so install can share it.
10
+ //
11
+ // ─────────────────────────────────────────────────────────────────────
12
+ // Command form: SHELL form (no `args`), bare bin name. WHY (verified
13
+ // against Anthropic's hooks docs, https://code.claude.com/docs/en/hooks,
14
+ // 2026-05-29):
15
+ //
16
+ // - Hook commands with `args` present run in EXEC form (no shell). On
17
+ // Windows, exec form requires `command` to resolve to a REAL
18
+ // executable (.exe) — the docs explicitly warn that the `.cmd`/`.ps1`
19
+ // shims npm installs for `bin` entries "cannot be spawned without a
20
+ // shell". So exec form + a bare `cmk-inject-context` would FAIL on
21
+ // Windows.
22
+ // - Hook commands WITHOUT `args` run in SHELL form: `sh -c "<command>"`
23
+ // on macOS/Linux, Git Bash (or PowerShell) on Windows. The shell
24
+ // resolves the bare name on PATH — picking up npm's global shim on
25
+ // every OS. So we deliberately OMIT `args` and emit a bare bin name.
26
+ //
27
+ // This is why the block below has no `args` and a bare command string.
28
+ // `npm install -g @lh8ppl/claude-memory-kit` puts these 5 bins on PATH
29
+ // (declared in packages/cli/package.json `bin`); the hook commands then
30
+ // resolve the same way `cmk` itself does.
31
+ //
32
+ // ─────────────────────────────────────────────────────────────────────
33
+ // Decision trail (CLAUDE.md "Decision-trail preservation"):
34
+ //
35
+ // **Original block (pre-2026-05-29, repair.mjs)**: the PLUGIN form,
36
+ // `bash "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup →
37
+ // cmk-version-check. That form is correct ONLY when the plugin is
38
+ // loaded (CLAUDE_PLUGIN_ROOT is set + bash is present). It still lives
39
+ // in plugin/hooks/hooks.json for the PLUGIN route (Route B), unchanged.
40
+ //
41
+ // **Task 49 (2026-05-29)**: the npm route (Route A) needs hooks that
42
+ // work with NO plugin loaded. This block is that form. It drops the
43
+ // Setup → cmk-version-check hook: version-check is a not-yet-implemented
44
+ // bash stub (no node bin ships for it — see tasks.md 49.1, which lists
45
+ // exactly the 5 functional hooks), and `Setup` is not in Anthropic's
46
+ // documented common-events set. Porting version-check to a node bin is
47
+ // a v0.1.x item if a real Setup-time check is wanted. The 5 functional
48
+ // hooks below are the complete auto-memory loop.
49
+
50
+ import {
51
+ existsSync,
52
+ mkdirSync,
53
+ readFileSync,
54
+ writeFileSync,
55
+ } from 'node:fs';
56
+ import { dirname } from 'node:path';
57
+
58
+ /**
59
+ * Canonical npm-route hooks block. Shell form (no `args`), PATH-resolved
60
+ * bare bin names. Keep in sync with packages/cli/package.json `bin` and
61
+ * (modulo command form) plugin/hooks/hooks.json.
62
+ */
63
+ export const KIT_HOOKS_BLOCK = Object.freeze({
64
+ SessionStart: [{ hooks: [{ type: 'command', command: 'cmk-inject-context', timeout: 30 }] }],
65
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'cmk-capture-prompt', timeout: 10 }] }],
66
+ PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'cmk-observe-edit', async: true, timeout: 120 }] }],
67
+ Stop: [{ hooks: [{ type: 'command', command: 'cmk-capture-turn', timeout: 30 }] }],
68
+ SessionEnd: [{ hooks: [{ type: 'command', command: 'cmk-compress-session', timeout: 60 }] }],
69
+ });
70
+
71
+ /**
72
+ * Substrings that identify a kit-owned hook entry, so re-runs replace the
73
+ * kit's entries in place while preserving the user's own hooks. Covers
74
+ * BOTH command forms (npm-route bare names AND the plugin-route
75
+ * `${CLAUDE_PLUGIN_ROOT}/bin/<name>` form) so a project that previously
76
+ * had plugin-form kit hooks gets them cleanly upgraded to npm-form rather
77
+ * than duplicated.
78
+ */
79
+ export const KIT_COMMAND_TOKENS = Object.freeze([
80
+ 'cmk-version-check',
81
+ 'cmk-inject-context',
82
+ 'cmk-capture-prompt',
83
+ 'cmk-observe-edit',
84
+ 'cmk-capture-turn',
85
+ 'cmk-compress-session',
86
+ ]);
87
+
88
+ /** True if a hooks-array entry references any kit bin. */
89
+ function isKitEntry(entry) {
90
+ if (!entry || typeof entry !== 'object') return false;
91
+ const cmds = [];
92
+ if (typeof entry.command === 'string') cmds.push(entry.command);
93
+ if (Array.isArray(entry.hooks)) {
94
+ for (const h of entry.hooks) if (typeof h.command === 'string') cmds.push(h.command);
95
+ }
96
+ return cmds.some((c) => KIT_COMMAND_TOKENS.some((t) => c.includes(t)));
97
+ }
98
+
99
+ /**
100
+ * Read-merge-write the canonical kit hooks block into the settings.json
101
+ * at `settingsPath`. Idempotent. Preserves any non-kit top-level keys and
102
+ * any non-kit hook entries under the same events.
103
+ *
104
+ * Public boundary (install.mjs + repair.mjs depend on this shape):
105
+ * writeKitHooks(settingsPath) → {
106
+ * changed: boolean, // did the file content change?
107
+ * settingsPath: string,
108
+ * events: string[], // kit events written
109
+ * error?: string, // present iff the existing file was unparseable
110
+ * }
111
+ *
112
+ * On a JSON parse error of an existing settings.json, returns
113
+ * {changed:false, error} WITHOUT overwriting — never clobber a file the
114
+ * user may have hand-broken; surface it so they can fix it.
115
+ */
116
+ export function writeKitHooks(settingsPath) {
117
+ const events = Object.keys(KIT_HOOKS_BLOCK);
118
+
119
+ let settings = {};
120
+ if (existsSync(settingsPath)) {
121
+ try {
122
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
123
+ } catch (err) {
124
+ return {
125
+ changed: false,
126
+ settingsPath,
127
+ events,
128
+ error: `${settingsPath} parse error: ${err?.message ?? err}`,
129
+ };
130
+ }
131
+ }
132
+
133
+ const before = JSON.stringify(settings);
134
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
135
+ settings.hooks = {};
136
+ }
137
+
138
+ // Walk the UNION of existing events + kit events. For each event, strip
139
+ // any kit-owned entries (matched by KIT_COMMAND_TOKENS), preserving the
140
+ // user's own entries; then re-add the canonical kit entries for events
141
+ // the kit manages. Walking existing events too (not just the 5 kit
142
+ // events) is what PRUNES a stale plugin-form hook the kit no longer
143
+ // emits — e.g. a leftover `Setup → cmk-version-check` written by a
144
+ // pre-0.1.1 `cmk repair --hooks`: it's a kit entry under an event NOT in
145
+ // KIT_HOOKS_BLOCK, so it gets removed rather than left to fail on the
146
+ // npm route (no ${CLAUDE_PLUGIN_ROOT}, no bash).
147
+ const allEvents = new Set([
148
+ ...Object.keys(settings.hooks),
149
+ ...Object.keys(KIT_HOOKS_BLOCK),
150
+ ]);
151
+ for (const eventName of allEvents) {
152
+ const existing = Array.isArray(settings.hooks[eventName])
153
+ ? settings.hooks[eventName]
154
+ : [];
155
+ const isKitEvent = Object.prototype.hasOwnProperty.call(KIT_HOOKS_BLOCK, eventName);
156
+ const hadKitEntry = existing.some(isKitEntry);
157
+ // Leave purely-user events untouched (don't even rewrite an empty
158
+ // array the user authored) — only manage events the kit owns OR that
159
+ // currently carry a stale kit entry to prune.
160
+ if (!isKitEvent && !hadKitEntry) continue;
161
+ const userEntries = existing.filter((e) => !isKitEntry(e));
162
+ // Deep-clone the kit entries before inserting: KIT_HOOKS_BLOCK is only
163
+ // shallow-frozen, so inserting its nested objects by reference would let
164
+ // a later mutation of `settings` leak back into the shared constant.
165
+ const kitEntries = isKitEvent ? structuredClone(KIT_HOOKS_BLOCK[eventName]) : [];
166
+ const next = [...userEntries, ...kitEntries];
167
+ if (next.length > 0) {
168
+ settings.hooks[eventName] = next;
169
+ } else {
170
+ // Event held only kit entries the kit no longer emits (e.g. a stale
171
+ // plugin-form Setup → cmk-version-check): drop the now-empty array
172
+ // instead of leaving `"Setup": []` behind.
173
+ delete settings.hooks[eventName];
174
+ }
175
+ }
176
+
177
+ const after = JSON.stringify(settings);
178
+ const changed = before !== after;
179
+
180
+ if (changed) {
181
+ mkdirSync(dirname(settingsPath), { recursive: true });
182
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
183
+ }
184
+
185
+ return { changed, settingsPath, events };
186
+ }