@lh8ppl/claude-memory-kit 0.3.4 → 0.4.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 (47) hide show
  1. package/README.md +6 -0
  2. package/bin/cmk-guard-memory.mjs +57 -0
  3. package/package.json +3 -2
  4. package/src/agent-profile.mjs +115 -0
  5. package/src/agent-profiles.mjs +118 -0
  6. package/src/auto-persona.mjs +4 -1
  7. package/src/compress-retry.mjs +25 -0
  8. package/src/compress-session.mjs +27 -3
  9. package/src/config-core.mjs +7 -9
  10. package/src/daily-distill.mjs +7 -3
  11. package/src/decisions-journal.mjs +71 -3
  12. package/src/doctor.mjs +86 -4
  13. package/src/guard-memory.mjs +151 -0
  14. package/src/import-anthropic-memory.mjs +15 -1
  15. package/src/inject-context.mjs +34 -3
  16. package/src/install-agent.mjs +220 -0
  17. package/src/install-kiro.mjs +287 -0
  18. package/src/install.mjs +16 -3
  19. package/src/kiro-cli-agent.mjs +270 -0
  20. package/src/kiro-constants.mjs +19 -0
  21. package/src/kiro-hook-bin.mjs +105 -0
  22. package/src/kiro-hook-command.mjs +67 -0
  23. package/src/kiro-hook-dispatch.mjs +115 -0
  24. package/src/kiro-ide-hooks.mjs +219 -0
  25. package/src/kiro-permissions.mjs +175 -0
  26. package/src/kiro-skills.mjs +96 -0
  27. package/src/kiro-transcript.mjs +366 -0
  28. package/src/kiro-trusted-commands.mjs +130 -0
  29. package/src/lazy-compress.mjs +6 -0
  30. package/src/managed-block.mjs +138 -0
  31. package/src/memory-write.mjs +23 -8
  32. package/src/mutate-agent-config.mjs +243 -0
  33. package/src/read-json.mjs +43 -0
  34. package/src/reindex.mjs +15 -2
  35. package/src/repair.mjs +39 -3
  36. package/src/result-shapes.mjs +8 -0
  37. package/src/review-queue.mjs +3 -0
  38. package/src/scratchpad.mjs +12 -2
  39. package/src/search.mjs +12 -5
  40. package/src/semantic-backend.mjs +7 -9
  41. package/src/settings-hooks.mjs +12 -2
  42. package/src/subcommands.mjs +360 -27
  43. package/src/tier-paths.mjs +48 -1
  44. package/src/weekly-curate.mjs +13 -6
  45. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  46. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  47. package/template/project/memory/INDEX.md.template +1 -1
package/src/doctor.mjs CHANGED
@@ -48,6 +48,8 @@ import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
48
48
  import { resolveDefaultSearchMode } from './semantic-backend.mjs';
49
49
  import { checkVersionDrift } from './version-drift.mjs';
50
50
  import { getKitVersion } from './install.mjs';
51
+ import { hasOurCliAgent } from './kiro-cli-agent.mjs';
52
+ import { stripBom } from './read-json.mjs';
51
53
 
52
54
  const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
53
55
  const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@@ -59,9 +61,41 @@ const MEMORY_DIR_REL = ['context', 'memory'];
59
61
  const LOCKS_REL = ['context', '.locks'];
60
62
  const NATIVE_MEMORY_LOG_REL = ['context', '.locks', 'native-memory-status.log'];
61
63
 
64
+ // Which agent was this project installed for? A `--ide kiro` install wires
65
+ // .kiro/ surfaces (hooks + steering/cmk.md + skills + settings/mcp.json); a
66
+ // Claude Code install wires .claude/settings.json. HC-1 must check the RIGHT
67
+ // surface — before v0.4.0 it hard-checked .claude/settings.json and false-FAILed
68
+ // on every Kiro install (the cut-gate-kiro live-test find, Task 50).
69
+ //
70
+ // Detection precedence:
71
+ // 1. .claude/settings.json present → Claude Code.
72
+ // 2. a CMK-OWNED Kiro marker present (.kiro/steering/cmk.md — written by every
73
+ // installKiro run) → Kiro. We key on OUR marker, not a bare `.kiro/` dir, so
74
+ // a stray/unrelated .kiro/ (another tool's, or a partial non-cmk dir) does
75
+ // NOT flip the project to the Kiro path (I2).
76
+ // 3. neither → Claude Code (historical default — a not-yet-installed project
77
+ // still reports the Claude-shaped repair hint).
78
+ //
79
+ // NOTE (I1, deliberate punt): a project installed for BOTH agents (.claude AND a
80
+ // cmk .kiro marker) resolves to Claude Code by precedence — HC-1 checks only the
81
+ // Claude surface. Dual-install is rare; the single-surface check is intentional
82
+ // for v0.4.0, not an oversight. Revisit if dual-install becomes common.
83
+ function detectInstallKind(projectRoot) {
84
+ if (existsSync(join(projectRoot, '.claude', 'settings.json'))) return 'claude-code';
85
+ if (existsSync(join(projectRoot, '.kiro', 'steering', 'cmk.md'))) return 'kiro';
86
+ return 'claude-code';
87
+ }
88
+
62
89
  // --- HC-1: Stop + SessionStart hooks registered -----------------------
63
- function hc1Hooks({ projectRoot }) {
64
- // Per design §5 the kit's hooks live in .claude/settings.json
90
+ function hc1Hooks({ projectRoot, awsDir }) {
91
+ // Agent-aware (v0.4.0): a Kiro install keeps its hooks in .kiro/hooks/ (IDE)
92
+ // and/or ~/.kiro/agents/ (kiro-cli), so route to the Kiro check
93
+ // rather than false-failing on a missing .claude/settings.json with a
94
+ // Claude-Code repair hint.
95
+ if (detectInstallKind(projectRoot) === 'kiro') {
96
+ return hc1KiroHooks({ projectRoot, awsDir });
97
+ }
98
+ // Per design §5 — the Claude Code hooks live in .claude/settings.json
65
99
  // alongside its plugin manifest. Required for auto-extract +
66
100
  // session-end compression to fire.
67
101
  const settingsPath = join(projectRoot, '.claude', 'settings.json');
@@ -76,7 +110,9 @@ function hc1Hooks({ projectRoot }) {
76
110
  }
77
111
  let settings;
78
112
  try {
79
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
113
+ // stripBom: a Windows-editor BOM on .claude/settings.json must not make a
114
+ // valid file read as a parse error → false HC-1 FAIL (D-187).
115
+ settings = JSON.parse(stripBom(readFileSync(settingsPath, 'utf8')));
80
116
  } catch (err) {
81
117
  return {
82
118
  id: 'HC-1',
@@ -141,6 +177,51 @@ function hc1Hooks({ projectRoot }) {
141
177
  };
142
178
  }
143
179
 
180
+ // --- HC-1 (Kiro variant): capture + inject can fire via EITHER Kiro surface ----
181
+ // Kiro wires capture/inject through TWO independent surfaces (D-186):
182
+ // • IDE hooks → .kiro/hooks/{cmk-capture,cmk-inject}.kiro.hook (the GUI user)
183
+ // • CLI agent → ~/.kiro/agents/cmk.json with
184
+ // agentSpawn(inject)+stop(capture) hooks (the kiro-cli user)
185
+ // HC-1 is a CAPABILITY check ("can capture/inject fire?"), not a single-file
186
+ // check — so it PASSES if EITHER surface is present, and FAILs only when NEITHER
187
+ // is. The original v0.4.0 fix checked only the IDE hooks, which false-FAILed a
188
+ // working kiro-cli-only install (the surface lives in ~/.aws, which is also
189
+ // machine-local and doesn't travel with a clone) — the same separately-correct-
190
+ // jointly-broken class as D-184/D-185, one level down (skill-review B1).
191
+ // `awsDir` is injectable so tests can sandbox the ~/.aws probe.
192
+ function hc1KiroHooks({ projectRoot, awsDir }) {
193
+ const hooksDir = join(projectRoot, '.kiro', 'hooks');
194
+ const ideHooks = ['cmk-capture.kiro.hook', 'cmk-inject.kiro.hook'];
195
+ const ideMissing = ideHooks.filter((f) => !existsSync(join(hooksDir, f)));
196
+ const ideComplete = ideMissing.length === 0;
197
+ const cliAgent = hasOurCliAgent({ awsDir });
198
+
199
+ if (ideComplete || cliAgent) {
200
+ const surfaces = [];
201
+ if (ideComplete) surfaces.push('IDE hooks (.kiro/hooks/)');
202
+ if (cliAgent) surfaces.push('CLI agent (~/.kiro/agents/cmk.json)');
203
+ return {
204
+ id: 'HC-1',
205
+ name: 'Stop + SessionStart hooks registered',
206
+ status: 'pass',
207
+ message: `Kiro capture/inject wired via ${surfaces.join(' + ')}`,
208
+ };
209
+ }
210
+
211
+ // Neither surface present → genuinely not wired. Name both so a kiro-cli user
212
+ // isn't pushed at the IDE-only repair.
213
+ return {
214
+ id: 'HC-1',
215
+ name: 'Stop + SessionStart hooks registered',
216
+ status: 'fail',
217
+ message:
218
+ `Kiro install: no capture/inject surface found — neither the IDE hooks ` +
219
+ `(${ideMissing.join(', ')} in .kiro/hooks/) nor a cmk CLI agent in ` +
220
+ `~/.kiro/agents/`,
221
+ recoveryCommand: 'cmk install --ide kiro',
222
+ };
223
+ }
224
+
144
225
  // --- HC-2: distill freshness (≤2 days) --------------------------------
145
226
  function hc2DistillFreshness({ projectRoot, now }) {
146
227
  const recentPath = join(projectRoot, ...RECENT_MD_REL);
@@ -566,6 +647,7 @@ export async function runDoctor({
566
647
  kitVersion,
567
648
  kitBindingProbe,
568
649
  embedderBindingProbe,
650
+ awsDir, // injectable: sandboxes the HC-1 Kiro CLI-agent (~/.aws) probe in tests
569
651
  } = {}) {
570
652
  const t0 = Date.now();
571
653
  if (!projectRoot) {
@@ -580,7 +662,7 @@ export async function runDoctor({
580
662
  const resolvedUserDir = userDir ?? join(homedir(), '.claude-memory-kit');
581
663
 
582
664
  // Run all checks in order.
583
- const c1 = hc1Hooks({ projectRoot });
665
+ const c1 = hc1Hooks({ projectRoot, awsDir });
584
666
  const c2 = hc2DistillFreshness({ projectRoot, now: ts });
585
667
  const c3 = hc3Transcripts({ projectRoot, now: ts });
586
668
  const c4 = hc4IndexConsistency({ projectRoot });
@@ -0,0 +1,151 @@
1
+ // guard-memory.mjs — the memory delete-guardrail core (D-192).
2
+ //
3
+ // A Claude Code `PreToolUse` hook (wired by `cmk install`) calls the
4
+ // `cmk-guard-memory` bin before every Bash/PowerShell tool call. This module
5
+ // is the pure decision: given a shell command, should it be BLOCKED because it
6
+ // would delete a claude-memory-kit memory path?
7
+ //
8
+ // Why this exists: 2026-06-22 (D-192), a `cd` that silently failed left a
9
+ // following `rm -f context/sessions/* context/transcripts/*` running in the
10
+ // wrong repo, deleting gitignored (non-recoverable) memory. A prose rule relies
11
+ // on the agent remembering; this hook enforces it structurally — the command
12
+ // never runs. Recovered only via an off-machine backup; this prevents the next.
13
+ //
14
+ // Design: BROAD by intent. A false block is recoverable (the user rephrases or
15
+ // deletes by hand); a false allow is the data loss we're preventing. So the
16
+ // predicates favor over-blocking a memory delete over under-blocking one.
17
+
18
+ // A command is destructive if it invokes a delete/destroy verb OR an equivalent
19
+ // data-destroying mechanism that carries no `rm` token (find -delete, truncate,
20
+ // a `>`/`:>` redirection that truncates a file). Skill-review I2: a memory file
21
+ // can be destroyed with no `rm` at all.
22
+ const DESTRUCTIVE = [
23
+ /\brm\b/i, // unix rm
24
+ /\bRemove-Item\b/i, // PowerShell
25
+ /\brmdir\b/i,
26
+ /\brd\b/i, // cmd rd
27
+ /\bdel\b/i, // cmd del
28
+ /\bunlink\b/i,
29
+ /\bgit\s+clean\b/i,
30
+ /\bgit\s+reset\s+--hard\b/i,
31
+ /\bfind\b[^|]*-delete\b/i, // find … -delete
32
+ /\btruncate\b/i, // truncate -s0 file
33
+ /(^|[\s;&|])>\s*\S/, // a `>`/`> file` redirection (truncates the target to empty)
34
+ ];
35
+
36
+ // A path is a MEMORY path if the command mentions any of these. The bare
37
+ // `context` segment matcher requires a path-ish boundary so the WORD "context"
38
+ // (e.g. `grep context`, `rm contextual.md`) is not a false positive.
39
+ const MEMORY_TOKENS = [
40
+ /context\/sessions/i,
41
+ /context\/transcripts/i,
42
+ /context\/memory/i,
43
+ /context\.local/i,
44
+ // a `context` path segment: `context/`, `context\`, `./context`, `repo/context`,
45
+ // or a bare ` context` argument (e.g. `git clean -fd context`).
46
+ /(^|[\s'"./\\])context([\s'"/\\]|$)/i,
47
+ /\.claude-memory-kit/i, // the cross-project user tier
48
+ /MEMORY\.md/i,
49
+ /DECISIONS\.md/i,
50
+ ];
51
+
52
+ /** True if the command invokes a delete/destroy verb. */
53
+ export function isDestructive(cmd) {
54
+ return typeof cmd === 'string' && DESTRUCTIVE.some((re) => re.test(cmd));
55
+ }
56
+
57
+ /** True if the command references a claude-memory-kit memory path. */
58
+ export function touchesMemory(cmd) {
59
+ return typeof cmd === 'string' && MEMORY_TOKENS.some((re) => re.test(cmd));
60
+ }
61
+
62
+ // A SEGMENT (not the whole command) may be exempt: it only MENTIONS a delete in
63
+ // its text and can't itself execute one — a `git commit` whose message describes
64
+ // a delete, an `echo`/`grep`/`cat` of text about a delete. (oh-my-kiro's
65
+ // block-dangerous.sh exempts `git commit` for the same reason.) CRITICAL: this
66
+ // is applied PER SEGMENT, never to a whole compound command — `echo x && rm -rf
67
+ // context/memory` must NOT be exempted by its leading `echo` (skill-review B1).
68
+ const SEGMENT_EXEMPT = [
69
+ /^\s*git\s+commit\b/i,
70
+ /^\s*git\s+log\b/i,
71
+ /^\s*echo\b/i,
72
+ /^\s*(grep|rg|cat|less|head|tail|sed -n|awk)\b/i,
73
+ ];
74
+
75
+ // Split a shell command into its sequenced segments on the separators that start
76
+ // a NEW command: && || ; | and newlines. (A `>` redirection is NOT a separator —
77
+ // it's handled as a destructive mechanism on its own segment.) Also surface any
78
+ // $(...) / `...` command substitution as its own segment so a delete hidden in a
79
+ // commit-message arg (`git commit -m "$(rm -rf context/memory)"`) is evaluated
80
+ // on its own and can't ride the outer command's exemption (skill-review I1).
81
+ function splitSegments(cmd) {
82
+ const segments = [];
83
+ // pull out command substitutions first, add them as standalone segments
84
+ const subRe = /\$\(([^)]*)\)|`([^`]*)`/g;
85
+ let m;
86
+ while ((m = subRe.exec(cmd)) !== null) {
87
+ const inner = m[1] ?? m[2];
88
+ if (inner && inner.trim()) segments.push(inner);
89
+ }
90
+ const stripped = cmd.replace(subRe, ' '); // remove subs so they don't leak into outer segments
91
+ for (const seg of stripped.split(/&&|\|\||;|\||\r?\n/)) {
92
+ if (seg.trim()) segments.push(seg);
93
+ }
94
+ return segments;
95
+ }
96
+
97
+ function isSegmentExempt(seg) {
98
+ return SEGMENT_EXEMPT.some((re) => re.test(seg));
99
+ }
100
+
101
+ /**
102
+ * The pure decision. Returns { block: boolean, reason?: string }.
103
+ * Splits the command into segments and BLOCKS if ANY non-exempt segment is both
104
+ * destructive AND aimed at a memory path. Per-segment so an exempt verb in front
105
+ * (`echo …`, `git commit …`) cannot launder a chained real delete (B1/I1).
106
+ */
107
+ export function decideGuard(cmd) {
108
+ if (typeof cmd !== 'string' || cmd.trim() === '') return { block: false };
109
+ const offending = splitSegments(cmd).some(
110
+ (seg) => !isSegmentExempt(seg) && isDestructive(seg) && touchesMemory(seg),
111
+ );
112
+ if (offending) {
113
+ return {
114
+ block: true,
115
+ reason:
116
+ 'BLOCKED by the claude-memory-kit delete-guardrail: this command deletes a ' +
117
+ 'memory path (context/ , the persona tier ~/.claude-memory-kit, or a memory ' +
118
+ 'file). Memory is precious and a delete here is often non-recoverable. If you ' +
119
+ 'REALLY mean to delete memory, do it by hand after a backup — or ask the user. ' +
120
+ 'NEVER run a delete after a `cd` you have not verified with `pwd`.',
121
+ };
122
+ }
123
+ return { block: false };
124
+ }
125
+
126
+ // The shell-tool names across agents. Claude Code: `Bash` / `PowerShell`.
127
+ // Kiro / Amazon-Q: `execute_bash` (+ legacy alias `executeBash`); kiro-cli V3
128
+ // (2.9.0) RENAMED it to `execute_command` (D-198, observed live in the cut-gate).
129
+ // Payload `.tool_name` + `.tool_input.command` on STDIN — the SAME shape as
130
+ // Claude Code, so one bin guards every agent. D-192.
131
+ const SHELL_TOOLS = new Set([
132
+ 'Bash',
133
+ 'PowerShell',
134
+ 'execute_bash',
135
+ 'executeBash',
136
+ 'execute_command', // kiro-cli V3 (2.9.0) rename — D-198
137
+ 'shell',
138
+ ]);
139
+
140
+ /**
141
+ * Evaluate a PreToolUse payload (already parsed) from EITHER Claude Code or Kiro
142
+ * — both deliver `{ tool_name, tool_input: { command } }` on stdin. Only shell
143
+ * tools are inspected; everything else is allowed. Returns { block, reason? }.
144
+ */
145
+ export function evaluatePayload(payload) {
146
+ const tool = payload?.tool_name;
147
+ if (!SHELL_TOOLS.has(tool)) return { block: false };
148
+ const cmd = payload?.tool_input?.command;
149
+ if (typeof cmd !== 'string' || cmd === '') return { block: false };
150
+ return decideGuard(cmd);
151
+ }
@@ -253,7 +253,21 @@ export async function importAnthropicMemory({
253
253
  })
254
254
  .join('\n');
255
255
  mkdirSync(join(projectRoot, 'context'), { recursive: true });
256
- appendFileSync(targetPath, sectionHeader + '\n' + bulletLines + '\n', 'utf8');
256
+ // Lint-clean append (MD022): guarantee exactly one blank line ABOVE the
257
+ // `## Imported …` heading when the target file already has content (the
258
+ // leading `\n` in sectionHeader assumes the file ends in `\n` — fragile if it
259
+ // doesn't). The blank below the heading is the `+ '\n'` after sectionHeader.
260
+ let prefix = '';
261
+ if (existsSync(targetPath)) {
262
+ const existing = readFileSync(targetPath, 'utf8');
263
+ if (existing.trim() !== '') {
264
+ prefix = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '' : '\n';
265
+ }
266
+ }
267
+ // sectionHeader already starts with `\n`, so a file ending in `\n` yields the
268
+ // needed blank line; prefix adds the missing one only when the file lacks a
269
+ // trailing newline.
270
+ appendFileSync(targetPath, prefix + sectionHeader + '\n' + bulletLines + '\n', 'utf8');
257
271
 
258
272
  let accepted = 0;
259
273
  for (const p of proposals) {
@@ -31,7 +31,8 @@ import {
31
31
  closeSync,
32
32
  } from 'node:fs';
33
33
  import { spawn } from 'node:child_process';
34
- import { join } from 'node:path';
34
+ import { join, dirname } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
35
36
  import { homedir } from 'node:os';
36
37
  import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
37
38
  import { nowIso } from './audit-log.mjs';
@@ -617,6 +618,32 @@ function enforceCap(orderedBlocks, capBytes, ts, reportCapBytes = capBytes) {
617
618
  * Exposed so injectContext can override via dependency injection in tests
618
619
  * (testSpawnLazy parameter) — production callers pass nothing.
619
620
  */
621
+ /**
622
+ * Resolve the path to `bin/cmk-compress-lazy.mjs` from THIS module's location
623
+ * (`src/` → `../bin/`), honoring the $CMK_COMPRESS_LAZY_PATH override. Returns
624
+ * null if it can't be found (→ the shell:true fallback).
625
+ *
626
+ * Why this exists (the cross-agent console-popup fix): the no-popup spawn (Task
627
+ * 81) only kicks in when `injectContext` receives a real `compressLazyPath` —
628
+ * otherwise `lazyCompressSpawnDescriptor` falls to the `shell:true` `.cmd` shim,
629
+ * which flashes a `node` console window on Windows. The Claude Code bin
630
+ * (`cmk-inject-context.mjs`) passed the path; the Kiro `cmk hook agentSpawn`
631
+ * path did NOT, so a real Kiro user got the popup (the cut-gate-kiro live find).
632
+ * Resolving it HERE (in injectContext's default) fixes EVERY caller — Claude
633
+ * bin, Kiro hook, any future agent — not just the ones that remember to pass it.
634
+ */
635
+ export function resolveCompressLazyPath() {
636
+ const fromEnv = process.env.CMK_COMPRESS_LAZY_PATH;
637
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
638
+ try {
639
+ const here = dirname(fileURLToPath(import.meta.url)); // .../packages/cli/src
640
+ const candidate = join(here, '..', 'bin', 'cmk-compress-lazy.mjs');
641
+ return existsSync(candidate) ? candidate : null;
642
+ } catch {
643
+ return null;
644
+ }
645
+ }
646
+
620
647
  /**
621
648
  * Pure spawn descriptor for the lazy-compress child (Task 81). Separated so the
622
649
  * Door-3 contract (node-direct + windowsHide, no shell, when the path is known)
@@ -706,8 +733,12 @@ export function injectContext({
706
733
  // Resolved path to cmk-compress-lazy.mjs (passed by the bin wrapper, which
707
734
  // knows the install layout). Lets spawnLazyCompress run `node <path>`
708
735
  // directly instead of the shell:true `.cmd` shim — the Windows
709
- // console-popup fix (Task 81). Absent → graceful shell:true fallback.
710
- compressLazyPath,
736
+ // console-popup fix (Task 81). Absent → self-resolve from this module's
737
+ // location (resolveCompressLazyPath), so EVERY caller gets the no-popup
738
+ // node-direct spawn — not just the Claude bin that passed it explicitly. A
739
+ // caller may still override (e.g. tests). Only a genuinely-unfindable bin
740
+ // (corrupt install) falls to the shell:true descriptor.
741
+ compressLazyPath = resolveCompressLazyPath(),
711
742
  } = {}) {
712
743
  const ts = now ?? nowIso();
713
744
  const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
@@ -0,0 +1,220 @@
1
+ // install-agent.mjs — wire a per-agent profile's legs into a project (Task 50.E/50.F).
2
+ //
3
+ // Given a profile (DATA from agent-profiles.mjs) + a project root, this lands the
4
+ // profile's three legs in its DECLARED paths, reusing the kit's shared primitives:
5
+ // - MCP registration → mutateAgentConfig (touch-only-our-keys, refuse-on-parse-error)
6
+ // - hook entry → mutateAgentConfig (the agent's hook-config file)
7
+ // - instruction file → a managed marker block (byte-preserving install/uninstall)
8
+ //
9
+ // This is the per-agent path. install.mjs keeps its existing Claude-Code wiring
10
+ // for the default `--ide claude-code` route (regression-proof, D-180 / 50.E); this
11
+ // module handles the OTHER agents (Kiro first). The kit's core — store, compression,
12
+ // search, CLI, MCP server — is identical across agents; only these legs differ.
13
+ //
14
+ // Public surface:
15
+ // installAgent({ projectRoot, profile }) → { action, agent, changed, legs, errors? }
16
+ // uninstallAgent({ projectRoot, profile }) → { action, agent, changed }
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { mutateAgentConfig, atomicWrite } from './mutate-agent-config.mjs';
21
+ import {
22
+ removeJsonKey,
23
+ pruneEmptyParent,
24
+ escapeRe,
25
+ trimLeadingNewlines,
26
+ trimTrailingNewlines,
27
+ } from './managed-block.mjs';
28
+
29
+ // The kit's MCP server entry — same shape settings-hooks.mjs writes for Claude
30
+ // Code (`cmk mcp serve` over stdio). Agent-neutral: every agent registers the
31
+ // same server; only the FILE it goes in differs (profile.mcp.path).
32
+ const MCP_ENTRY = Object.freeze({ type: 'stdio', command: 'cmk', args: ['mcp', 'serve'] });
33
+ const MCP_SERVER_NAME = 'claude-memory-kit';
34
+
35
+ // The kit's lifecycle-hook commands, keyed by the ABSTRACT event the profile's
36
+ // eventMap translates to the agent's concrete event name. Only the events an
37
+ // agent supports (present in its eventMap) get wired.
38
+ const HOOK_COMMANDS = Object.freeze({
39
+ sessionStart: 'cmk-inject-context',
40
+ promptSubmit: 'cmk-capture-prompt',
41
+ postEdit: 'cmk-observe-edit',
42
+ turnEnd: 'cmk-capture-turn',
43
+ sessionEnd: 'cmk-compress-session',
44
+ });
45
+
46
+ // Steering / instruction-file body. Kiro reads `inclusion: always` frontmatter
47
+ // to keep this in context every session (kiro.dev primary-verified). The body is
48
+ // intentionally short — it points the agent at the kit's recall surface; the rich
49
+ // memory lives in context/.
50
+ const INSTRUCTION_BODY = [
51
+ '# claude-memory-kit',
52
+ '',
53
+ 'This project uses claude-memory-kit for durable, in-repo memory across sessions.',
54
+ 'Recall before re-deriving: run `cmk search "<topic>"` for prior decisions, preferences,',
55
+ 'and project facts; the curated tiers live under `context/`. Capture durable facts with',
56
+ '`cmk remember` — never hand-edit the memory files.',
57
+ ].join('\n');
58
+
59
+ const MARK_START = '<!-- claude-memory-kit:start -->';
60
+ const MARK_END = '<!-- claude-memory-kit:end -->';
61
+
62
+ export function installAgent({ projectRoot, profile }) {
63
+ if (!projectRoot) throw new Error('installAgent: projectRoot is required');
64
+ if (!profile || !profile.name) throw new Error('installAgent: a profile is required');
65
+
66
+ const legs = {};
67
+ const errors = [];
68
+ let changed = false;
69
+
70
+ // ── MCP leg ───────────────────────────────────────────────────────────────
71
+ if (profile.mcp) {
72
+ const r = mutateAgentConfig({
73
+ path: join(projectRoot, profile.mcp.path),
74
+ format: 'json',
75
+ keyPath: [profile.mcp.serversKey, MCP_SERVER_NAME],
76
+ entry: MCP_ENTRY,
77
+ });
78
+ legs.mcp = r.action;
79
+ if (r.action === 'error') errors.push({ leg: 'mcp', ...r });
80
+ else if (r.changed) changed = true;
81
+ }
82
+
83
+ // ── hooks leg ───────────────────────────────────────────────────────────────
84
+ // Only if MCP didn't already error (refuse-to-clobber means a corrupt config
85
+ // should halt this agent's install — report, don't push past it).
86
+ if (profile.hooks && errors.length === 0) {
87
+ const hookEntry = buildHookEntry(profile);
88
+ const r = mutateAgentConfig({
89
+ path: join(projectRoot, profile.hooks.path),
90
+ format: 'json',
91
+ keyPath: ['hooks'],
92
+ entry: hookEntry,
93
+ });
94
+ legs.hooks = r.action;
95
+ if (r.action === 'error') errors.push({ leg: 'hooks', ...r });
96
+ else if (r.changed) changed = true;
97
+ }
98
+
99
+ // ── instruction leg (managed marker block) ──────────────────────────────────
100
+ if (errors.length === 0) {
101
+ const instrPath = join(projectRoot, profile.instructionFile);
102
+ const r = writeInstructionFile(instrPath, profile);
103
+ legs.instruction = r.action;
104
+ if (r.changed) changed = true;
105
+ }
106
+
107
+ if (errors.length > 0) {
108
+ return { action: 'error', agent: profile.name, changed, legs, errors };
109
+ }
110
+ return { action: 'installed', agent: profile.name, changed, legs };
111
+ }
112
+
113
+ export function uninstallAgent({ projectRoot, profile }) {
114
+ if (!projectRoot) throw new Error('uninstallAgent: projectRoot is required');
115
+ if (!profile || !profile.name) throw new Error('uninstallAgent: a profile is required');
116
+
117
+ let changed = false;
118
+
119
+ // MCP: remove only our server key, preserve siblings.
120
+ if (profile.mcp) {
121
+ const p = join(projectRoot, profile.mcp.path);
122
+ if (removeJsonKey(p, [profile.mcp.serversKey, MCP_SERVER_NAME])) changed = true;
123
+ // prune an emptied servers object we leave behind (no kit-shaped residue).
124
+ pruneEmptyParent(p, [profile.mcp.serversKey]);
125
+ }
126
+ // hooks: remove ONLY the event keys WE wrote (symmetry with the MCP leg —
127
+ // remove our keys, preserve any the user added to the same `hooks` object).
128
+ // This is safe even if a future profile points hooks.path at a shared file.
129
+ if (profile.hooks) {
130
+ const p = join(projectRoot, profile.hooks.path);
131
+ const ourEvents = Object.keys(buildHookEntry(profile));
132
+ for (const ev of ourEvents) {
133
+ if (removeJsonKey(p, ['hooks', ev])) changed = true;
134
+ }
135
+ // prune an emptied `hooks` object we leave behind (no residue).
136
+ pruneEmptyParent(p, ['hooks']);
137
+ }
138
+ // instruction: strip our marker block, byte-preserve the rest.
139
+ const instrPath = join(projectRoot, profile.instructionFile);
140
+ if (removeInstructionBlock(instrPath)) changed = true;
141
+
142
+ return { action: 'uninstalled', agent: profile.name, changed };
143
+ }
144
+
145
+ // ── internal helpers ───────────────────────────────────────────────────────
146
+
147
+ // Build the agent's hook object: { <concreteEvent>: [ {command} ] } for each
148
+ // abstract event the profile maps + the kit has a command for.
149
+ function buildHookEntry(profile) {
150
+ const hooks = {};
151
+ for (const [abstractEvent, concreteEvent] of Object.entries(profile.hooks.eventMap)) {
152
+ const command = HOOK_COMMANDS[abstractEvent];
153
+ if (command) hooks[concreteEvent] = [{ command }];
154
+ }
155
+ return hooks;
156
+ }
157
+
158
+ // Write the instruction file with a managed marker block + agent-specific
159
+ // frontmatter (Kiro wants `inclusion: always`). Idempotent: re-writing identical
160
+ // content reports changed:false.
161
+ function writeInstructionFile(path, profile) {
162
+ const frontmatter = needsInclusionFrontmatter(profile) ? '---\ninclusion: always\n---\n\n' : '';
163
+ const block = `${MARK_START}\n${INSTRUCTION_BODY}\n${MARK_END}`;
164
+ const desired = `${frontmatter}${block}\n`;
165
+
166
+ let existing = '';
167
+ if (existsSync(path)) existing = readFileSync(path, 'utf8');
168
+
169
+ let next;
170
+ if (existing === '') {
171
+ next = desired;
172
+ } else if (existing.includes(MARK_START) && existing.includes(MARK_END)) {
173
+ // refresh in place — replace only the managed block, byte-preserve the rest.
174
+ next = existing.replace(
175
+ new RegExp(`${escapeRe(MARK_START)}[\\s\\S]*?${escapeRe(MARK_END)}`),
176
+ block,
177
+ );
178
+ } else {
179
+ // append our block to the user's existing file.
180
+ next = `${trimTrailingNewlines(existing)}\n\n${block}\n`;
181
+ }
182
+
183
+ if (next === existing) return { action: 'unchanged', changed: false };
184
+ atomicWrite(path, next);
185
+ return { action: existing === '' ? 'created' : 'updated', changed: true };
186
+ }
187
+
188
+ function removeInstructionBlock(path) {
189
+ if (!existsSync(path)) return false;
190
+ const existing = readFileSync(path, 'utf8');
191
+ if (!existing.includes(MARK_START)) return false;
192
+ // Strip our block (+ surrounding blank lines). The inner [\s\S]*? is lazy +
193
+ // bounded by two fixed literal delimiters (no nested quantifier) → linear, not
194
+ // ReDoS-prone. The newline trims are done WITHOUT regex (no `\n*$`/`^\n+`
195
+ // super-linear shapes) — see trimLeadingNewlines/trimTrailingNewlines.
196
+ const blockRe = new RegExp(`${escapeRe(MARK_START)}[\\s\\S]*?${escapeRe(MARK_END)}`);
197
+ const withoutBlock = existing.replace(blockRe, '');
198
+ const stripped = trimLeadingNewlines(collapseBlankRun(withoutBlock));
199
+ atomicWrite(path, stripped);
200
+ return true;
201
+ }
202
+
203
+ // removeJsonKey / pruneEmptyParent / escapeRe / trim{Leading,Trailing}Newlines
204
+ // are now shared from managed-block.mjs (deduped — the kit's shared-module
205
+ // discipline; install-kiro.mjs uses the same source).
206
+
207
+ function needsInclusionFrontmatter(profile) {
208
+ // Kiro steering files use `inclusion: always`. Driven by the profile's
209
+ // instruction path living under a steering dir — kept simple for v0.4.0
210
+ // (only Kiro needs it); generalize when a second steering-style agent lands.
211
+ return profile.instructionFile.includes('/steering/') || profile.name === 'kiro';
212
+ }
213
+
214
+ // Collapse a run of 2+ blank lines left where our block was removed into a
215
+ // single newline, so an uninstall doesn't leave a widening gap.
216
+ function collapseBlankRun(s) {
217
+ // split on newlines, drop empty segments created by the removed block's
218
+ // surrounding blanks, rejoin — no regex, no backtracking.
219
+ return s.replace(/\n{3,}/g, '\n\n');
220
+ }