@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/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,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
+ }
@@ -62,15 +62,25 @@ const NOTICE_PREFIX = 'not yet implemented in v0.1.0';
62
62
  * replaced / upgraded / downgrade-blocked / forced-downgrade / unchanged).
63
63
  */
64
64
  async function runInstall(options /* , command */) {
65
- const result = await installAction({ force: !!(options && options.force) });
65
+ // commander maps `--no-hooks` to options.hooks === false.
66
+ const noHooks = !!(options && options.hooks === false);
67
+ const result = await installAction({ force: !!(options && options.force), noHooks });
66
68
  const parts = [
67
69
  `scaffolded ${result.created.length} file(s)`,
68
70
  result.skipped.length ? `skipped ${result.skipped.length} existing` : null,
69
71
  `.gitignore=${result.gitignore.action}`,
70
72
  `CLAUDE.md=${result.claudeMd.action}`,
73
+ `hooks=${result.hooks.action}`,
71
74
  ].filter(Boolean);
72
75
  console.log('cmk install: ' + parts.join(', '));
73
76
 
77
+ if (result.hooks.action === 'wired' || result.hooks.action === 'unchanged') {
78
+ console.log(
79
+ ' hooks wired into .claude/settings.json — restart Claude Code to activate. ' +
80
+ 'This is a COMPLETE install; no separate /plugin step is needed.',
81
+ );
82
+ }
83
+
74
84
  if (result.claudeMd.action === 'downgrade-blocked') {
75
85
  console.error(
76
86
  ` warning: CLAUDE.md already has a newer kit block (v${result.claudeMd.oldVersion}). ` +
@@ -1007,10 +1017,11 @@ function stub(name, milestone, extra) {
1007
1017
  export const subcommands = [
1008
1018
  {
1009
1019
  name: 'install',
1010
- description: 'cross-OS one-shot install — scaffold 3-tier dirs + inject .gitignore + drop kit CLAUDE.md block',
1020
+ description: 'cross-OS one-shot install — scaffold 3-tier dirs + inject .gitignore + drop kit CLAUDE.md block + wire Claude Code hooks',
1011
1021
  milestone: 3,
1012
1022
  optionSpec: [
1013
1023
  { flags: '--force', description: 'allow downgrade of an existing newer-version CLAUDE.md block' },
1024
+ { flags: '--no-hooks', description: 'scaffold only; do NOT wire hooks into .claude/settings.json' },
1014
1025
  ],
1015
1026
  action: runInstall,
1016
1027
  },