@lh8ppl/claude-memory-kit 0.3.5 → 0.4.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 (53) hide show
  1. package/README.md +137 -50
  2. package/bin/cmk-approve-permission.mjs +62 -0
  3. package/bin/cmk-daily-distill.mjs +14 -0
  4. package/bin/cmk-guard-memory.mjs +57 -0
  5. package/bin/cmk-inject-context.mjs +12 -0
  6. package/bin/cmk-weekly-curate.mjs +12 -0
  7. package/package.json +4 -2
  8. package/src/agent-profile.mjs +115 -0
  9. package/src/agent-profiles.mjs +118 -0
  10. package/src/approve-permission.mjs +92 -0
  11. package/src/auto-extract.mjs +17 -10
  12. package/src/auto-persona.mjs +11 -4
  13. package/src/compaction-state.mjs +204 -0
  14. package/src/compress-session.mjs +13 -1
  15. package/src/config-core.mjs +7 -9
  16. package/src/decisions-journal.mjs +71 -3
  17. package/src/doctor.mjs +128 -5
  18. package/src/guard-memory.mjs +151 -0
  19. package/src/import-anthropic-memory.mjs +15 -1
  20. package/src/inject-context.mjs +42 -18
  21. package/src/install-agent.mjs +220 -0
  22. package/src/install-kiro.mjs +287 -0
  23. package/src/install.mjs +53 -7
  24. package/src/kiro-cli-agent.mjs +270 -0
  25. package/src/kiro-constants.mjs +19 -0
  26. package/src/kiro-hook-bin.mjs +105 -0
  27. package/src/kiro-hook-command.mjs +67 -0
  28. package/src/kiro-hook-dispatch.mjs +115 -0
  29. package/src/kiro-ide-hooks.mjs +219 -0
  30. package/src/kiro-permissions.mjs +175 -0
  31. package/src/kiro-skills.mjs +96 -0
  32. package/src/kiro-transcript.mjs +366 -0
  33. package/src/kiro-trusted-commands.mjs +130 -0
  34. package/src/lazy-compress.mjs +43 -110
  35. package/src/managed-block.mjs +138 -0
  36. package/src/memory-write.mjs +23 -8
  37. package/src/mutate-agent-config.mjs +243 -0
  38. package/src/read-json.mjs +43 -0
  39. package/src/register-crons.mjs +31 -0
  40. package/src/reindex.mjs +15 -2
  41. package/src/repair.mjs +39 -3
  42. package/src/result-shapes.mjs +8 -0
  43. package/src/review-queue.mjs +3 -0
  44. package/src/scratchpad.mjs +12 -2
  45. package/src/search.mjs +12 -5
  46. package/src/semantic-backend.mjs +7 -9
  47. package/src/settings-hooks.mjs +70 -3
  48. package/src/subcommands.mjs +360 -27
  49. package/src/tier-paths.mjs +82 -1
  50. package/src/weekly-curate.mjs +6 -2
  51. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  52. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  53. package/template/project/memory/INDEX.md.template +1 -1
@@ -29,9 +29,7 @@ import {
29
29
  existsSync,
30
30
  mkdirSync,
31
31
  readdirSync,
32
- readFileSync,
33
32
  statSync,
34
- writeFileSync,
35
33
  unlinkSync,
36
34
  } from 'node:fs';
37
35
  import { join } from 'node:path';
@@ -46,44 +44,58 @@ import { weeklyCurate } from './weekly-curate.mjs';
46
44
  import { compressSession } from './compress-session.mjs';
47
45
  import { CEILING_FREE_TIMEOUT_MS, CEILING_FREE_BACKOFF_MS } from './compress-retry.mjs';
48
46
  import { syncDecisionsJournal } from './decisions-journal.mjs';
47
+ import {
48
+ isCompactionNeeded,
49
+ recordCronHeartbeat,
50
+ cronHeartbeatPath,
51
+ } from './compaction-state.mjs';
49
52
 
50
53
  const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
51
54
  const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
52
- const SESSIONS_REL = ['context', 'sessions'];
53
55
  const LOCKS_REL = ['context', '.locks'];
54
- const NOW_MD_REL = ['context', 'sessions', 'now.md'];
55
- const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
56
- const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
57
56
  const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
58
57
 
59
- const TODAY_RE = /^today-(\d{4}-\d{2}-\d{2})\.md$/;
58
+ // Task 167 (D-206/D-207): the cron-liveness gate moved from a presence-only
59
+ // `cron-registered` sentinel to an anacron-style `cron-heartbeat` (in
60
+ // compaction-state.mjs), gated on AGE not existence — a registered-but-dead cron
61
+ // no longer disables the lazy roll. These three exports are kept as thin
62
+ // back-compat shims so existing callers (register-crons / doctor / subcommands /
63
+ // tests) keep working; they now operate on the heartbeat.
64
+ //
65
+ // **Decision-trail (the retired sentinel):** the old `cron-registered` marker
66
+ // recorded "a cron was REGISTERED" (written once at register-crons time, never
67
+ // updated). detectStaleness short-circuited to 'cron-active' on its mere
68
+ // existence — so a cron that never actually fired (laptop asleep at 23:00)
69
+ // disabled the lazy fallback forever and now.md grew to 410 KB (the v0.4.0
70
+ // dogfood). The heartbeat records "a run HAPPENED" and is re-stamped each fire.
60
71
 
61
72
  /**
62
- * Path helper for the cron-registered sentinel marker file. Public so
63
- * register-crons.mjs can write/remove it without re-deriving the path.
73
+ * Back-compat alias for the cron-liveness marker path. Now points at the
74
+ * `cron-heartbeat` stamp (was `cron-registered`). Retained for callers that still
75
+ * import it; prefer `cronHeartbeatPath` from compaction-state.mjs in new code.
64
76
  */
65
77
  export function cronSentinelPath(projectRoot) {
66
- return join(projectRoot, ...CRON_SENTINEL_REL);
78
+ return cronHeartbeatPath(projectRoot);
67
79
  }
68
80
 
69
81
  /**
70
- * Write the cron-registered sentinel marker. Called by registerCron
71
- * after a successful host-scheduler registration.
82
+ * Record that the cron is alive. Now writes the anacron-style heartbeat (was: a
83
+ * one-time registration sentinel). `register-crons` calls it on registration so a
84
+ * just-registered cron reads alive until its first real run re-stamps it; the
85
+ * cron BINS also call `recordCronHeartbeat` on each fire (the durable liveness
86
+ * signal). Both go to the same stamp.
72
87
  */
73
88
  export function markCronRegistered({ projectRoot }) {
74
- if (!projectRoot) return;
75
- const locksDir = join(projectRoot, ...LOCKS_REL);
76
- mkdirSync(locksDir, { recursive: true });
77
- writeFileSync(cronSentinelPath(projectRoot), nowIso() + '\n', 'utf8');
89
+ recordCronHeartbeat({ projectRoot });
78
90
  }
79
91
 
80
92
  /**
81
- * Remove the cron-registered sentinel marker. Called by unregisterCron.
82
- * Best-effort if the marker is missing, that's already the desired state.
93
+ * Remove the cron-liveness heartbeat (on unregister). Best-effort a missing
94
+ * stamp is already the desired "no live cron" state.
83
95
  */
84
96
  export function unmarkCronRegistered({ projectRoot }) {
85
97
  if (!projectRoot) return;
86
- const path = cronSentinelPath(projectRoot);
98
+ const path = cronHeartbeatPath(projectRoot);
87
99
  if (existsSync(path)) {
88
100
  try {
89
101
  unlinkSync(path);
@@ -93,18 +105,6 @@ export function unmarkCronRegistered({ projectRoot }) {
93
105
  }
94
106
  }
95
107
 
96
- function listTodayFiles(projectRoot) {
97
- const sessionsDir = join(projectRoot, ...SESSIONS_REL);
98
- if (!existsSync(sessionsDir)) return [];
99
- const matches = [];
100
- for (const name of readdirSync(sessionsDir)) {
101
- const m = TODAY_RE.exec(name);
102
- if (!m) continue;
103
- matches.push({ name, date: m[1], path: join(sessionsDir, name) });
104
- }
105
- return matches;
106
- }
107
-
108
108
  // Task 105 (D-75): does now.md carry prior-session content? The now→today
109
109
  // roll (compressSession) fires only at SessionEnd, and Claude Code fires
110
110
  // SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
@@ -114,25 +114,10 @@ function listTodayFiles(projectRoot) {
114
114
  // capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
115
115
  // match compressSession's own `buffer.trim() === ''` check so the spawn verdict
116
116
  // and the actual roll agree (else we'd spawn for a roll that immediately skips).
117
- function nowMdHasContent(projectRoot) {
118
- const p = join(projectRoot, ...NOW_MD_REL);
119
- if (!existsSync(p)) return false;
120
- try {
121
- return readFileSync(p, 'utf8').trim() !== '';
122
- } catch {
123
- return false;
124
- }
125
- }
126
-
127
- function recentMdMtimeMs(projectRoot) {
128
- const p = join(projectRoot, ...RECENT_MD_REL);
129
- if (!existsSync(p)) return null;
130
- try {
131
- return statSync(p).mtimeMs;
132
- } catch {
133
- return null;
134
- }
135
- }
117
+ // Task 167 (D-207): nowMdHasContent / recentMdMtimeMs / listTodayFiles + the
118
+ // now/daily/weekly derive logic moved to compaction-state.mjs (the deep module
119
+ // that owns the verdict). detectStaleness now delegates there; only isJournalStale
120
+ // (an INDEPENDENT signal, not part of the compaction verdict) stays here.
136
121
 
137
122
  const MEMORY_REL = ['context', 'memory'];
138
123
  const DECISIONS_MD_REL = ['context', 'DECISIONS.md'];
@@ -210,66 +195,14 @@ export function detectStaleness({
210
195
  dailyTtlMs = DEFAULT_DAILY_TTL_MS,
211
196
  weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
212
197
  } = {}) {
213
- if (!projectRoot) {
214
- return { action: 'no-context-dir', reason: 'missing-project-root' };
215
- }
216
- // Cron sentinel short-circuits everything.
217
- if (existsSync(cronSentinelPath(projectRoot))) {
218
- return { action: 'cron-active', reason: 'cron-sentinel-present' };
219
- }
220
- const sessionsDir = join(projectRoot, ...SESSIONS_REL);
221
- if (!existsSync(sessionsDir)) {
222
- return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
223
- }
224
-
225
- // Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
226
- // hook would have done. It takes PRECEDENCE over daily/weekly because it's
227
- // the FIRST pipeline level (now → today → recent → archive) — roll it this
228
- // SessionStart; the today→recent + weekly levels cascade on subsequent
229
- // SessionStarts once now.md is drained. (cron-active above still wins — a
230
- // registered cron owns the whole pipeline.)
231
- if (nowMdHasContent(projectRoot)) {
232
- return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
233
- }
234
-
235
- const ts = now ?? nowIso();
236
- const nowMs = new Date(ts).getTime();
237
- const files = listTodayFiles(projectRoot);
238
-
239
- // Weekly check: any today-*.md older than weeklyTtlMs by its date stamp
240
- // (NOT mtime — the file's date is the canonical age signal; mtime can
241
- // drift if someone touched the file).
242
- const weeklyCutoffMs = nowMs - weeklyTtlMs;
243
- const hasOldToday = files.some((f) => {
244
- const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
245
- return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
246
- });
247
- if (hasOldToday) {
248
- return { action: 'stale-weekly', reason: 'today-file-older-than-7d' };
249
- }
250
-
251
- // Task 36 I1 fix: if there are NO today-*.md files at all, the
252
- // pipeline has nothing to compress — return fresh regardless of
253
- // recent.md mtime. Previously this check only fired when recent.md
254
- // was MISSING; for the stale-but-no-input case (e.g., right after
255
- // weeklyCurate archived every today file), the daily-stale branch
256
- // would fire and the SessionStart hook would spawn lazy-compress
257
- // forever (no new today file means no work; dailyDistill would
258
- // return skipped:no-input but not touch recent.md, so the next
259
- // SessionStart sees the same stale verdict).
260
- if (files.length === 0) {
261
- return { action: 'fresh', reason: 'no-input' };
262
- }
263
-
264
- // Daily check: recent.md missing OR older than dailyTtlMs.
265
- const mtimeMs = recentMdMtimeMs(projectRoot);
266
- if (mtimeMs === null) {
267
- return { action: 'stale-daily', reason: 'recent-md-missing' };
268
- }
269
- if (nowMs - mtimeMs > dailyTtlMs) {
270
- return { action: 'stale-daily', reason: 'recent-md-older-than-ttl' };
271
- }
272
- return { action: 'fresh', reason: 'within-ttl' };
198
+ // Task 167 (D-207): delegate to the compaction-state deep module. This is a
199
+ // thin back-compat adapter it maps the rich `{verdict, cronStale,
200
+ // heartbeatAge}` return onto the legacy `{action, reason}` shape callers
201
+ // (inject-context, runLazyCompress) already consume. The KEY behavior change
202
+ // vs the old body: `cron-active` is now gated on heartbeat FRESHNESS (age), not
203
+ // sentinel existence, so a dead cron falls through to the stale verdicts.
204
+ const r = isCompactionNeeded({ projectRoot, now, dailyTtlMs, weeklyTtlMs });
205
+ return { action: r.verdict, reason: r.reason, cronStale: r.cronStale, heartbeatAge: r.heartbeatAge };
273
206
  }
274
207
 
275
208
  function writeLazyLogEntry({ projectRoot, entry }) {
@@ -0,0 +1,138 @@
1
+ // managed-block.mjs — shared helpers for agent-config + instruction-file writes.
2
+ //
3
+ // install-agent.mjs (the generic per-agent installer) and install-kiro.mjs (the
4
+ // Kiro 4-surface orchestrator) both need the same primitives: a managed
5
+ // marker-block instruction-file writer, JSON-key removal that preserves siblings,
6
+ // empty-parent pruning, and the non-regex newline trims (ReDoS-safe). These were
7
+ // duplicated verbatim across the two; centralizing them here removes the drift
8
+ // hazard (the kit's shared-module discipline — the same lesson the duplicated
9
+ // atomicWrite taught earlier in Task 50).
10
+ //
11
+ // Public surface:
12
+ // writeManagedBlock(path, {body, frontmatter, markStart, markEnd}) → changed:bool
13
+ // removeManagedBlock(path, {markStart, markEnd}) → changed:bool
14
+ // removeJsonKey(path, keyPath) → changed:bool (preserves siblings)
15
+ // pruneEmptyParent(path, keyPath) → void (drops an emptied {} we leave)
16
+ // escapeRe / trimLeadingNewlines / trimTrailingNewlines (ReDoS-safe utils)
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { atomicWrite } from './mutate-agent-config.mjs';
20
+
21
+ export const DEFAULT_MARK_START = '<!-- claude-memory-kit:start -->';
22
+ export const DEFAULT_MARK_END = '<!-- claude-memory-kit:end -->';
23
+
24
+ /**
25
+ * Write/refresh a managed marker block in an instruction file, byte-preserving
26
+ * everything outside the markers. `frontmatter` (e.g. Kiro's `inclusion: always`)
27
+ * is prepended only when the file is created fresh. Idempotent: identical content
28
+ * → no write, returns false.
29
+ * @returns {boolean} whether a write happened.
30
+ */
31
+ export function writeManagedBlock(path, { body, frontmatter = '', markStart = DEFAULT_MARK_START, markEnd = DEFAULT_MARK_END } = {}) {
32
+ const block = `${markStart}\n${body}\n${markEnd}`;
33
+ const desired = `${frontmatter}${block}\n`;
34
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
35
+
36
+ let next;
37
+ if (existing === '') {
38
+ next = desired;
39
+ } else if (existing.includes(markStart) && existing.includes(markEnd)) {
40
+ // refresh in place — replace only the managed block, byte-preserve the rest.
41
+ next = existing.replace(
42
+ new RegExp(`${escapeRe(markStart)}[\\s\\S]*?${escapeRe(markEnd)}`),
43
+ block,
44
+ );
45
+ } else {
46
+ // append our block to the user's existing file.
47
+ next = `${trimTrailingNewlines(existing)}\n\n${block}\n`;
48
+ }
49
+
50
+ if (next === existing) return false;
51
+ atomicWrite(path, next);
52
+ return true;
53
+ }
54
+
55
+ /** Strip our managed block (+ surrounding blank lines), byte-preserve the rest. */
56
+ export function removeManagedBlock(path, { markStart = DEFAULT_MARK_START, markEnd = DEFAULT_MARK_END } = {}) {
57
+ if (!existsSync(path)) return false;
58
+ const existing = readFileSync(path, 'utf8');
59
+ if (!existing.includes(markStart)) return false;
60
+ // The inner [\s\S]*? is lazy + bounded by two fixed literal delimiters → linear,
61
+ // not ReDoS-prone. Newline trims are non-regex (no `\n*$`/`^\n+` super-linear
62
+ // shapes); the `\n{3,}` collapse is a bounded quantifier, also linear.
63
+ const blockRe = new RegExp(`${escapeRe(markStart)}[\\s\\S]*?${escapeRe(markEnd)}`);
64
+ const stripped = trimLeadingNewlines(existing.replace(blockRe, '').replace(/\n{3,}/g, '\n\n'));
65
+ atomicWrite(path, stripped);
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Remove a nested key from a JSON file, preserving everything else. Skips
71
+ * (returns false) on a missing file / parse error (never clobbers a corrupt file).
72
+ * @returns {boolean} whether a change was written.
73
+ */
74
+ export function removeJsonKey(path, keyPath) {
75
+ if (!existsSync(path)) return false;
76
+ let root;
77
+ try {
78
+ root = JSON.parse(readFileSync(path, 'utf8'));
79
+ } catch {
80
+ return false;
81
+ }
82
+ let cur = root;
83
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
84
+ if (!cur || typeof cur !== 'object' || !(keyPath[i] in cur)) return false;
85
+ cur = cur[keyPath[i]];
86
+ }
87
+ const last = keyPath[keyPath.length - 1];
88
+ if (!cur || typeof cur !== 'object' || !(last in cur)) return false;
89
+ delete cur[last];
90
+ atomicWrite(path, `${JSON.stringify(root, null, 2)}\n`);
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * If the object at keyPath exists and is now empty ({}), remove it — so an
96
+ * uninstall doesn't leave a kit-shaped `{"mcpServers":{}}` husk. No-op on a
97
+ * missing file / parse error / non-empty target.
98
+ */
99
+ export function pruneEmptyParent(path, keyPath) {
100
+ if (!existsSync(path)) return;
101
+ let root;
102
+ try {
103
+ root = JSON.parse(readFileSync(path, 'utf8'));
104
+ } catch {
105
+ return;
106
+ }
107
+ let cur = root;
108
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
109
+ if (!cur || typeof cur !== 'object' || !(keyPath[i] in cur)) return;
110
+ cur = cur[keyPath[i]];
111
+ }
112
+ const last = keyPath[keyPath.length - 1];
113
+ const target = cur && typeof cur === 'object' ? cur[last] : undefined;
114
+ if (target && typeof target === 'object' && !Array.isArray(target) && Object.keys(target).length === 0) {
115
+ delete cur[last];
116
+ atomicWrite(path, `${JSON.stringify(root, null, 2)}\n`);
117
+ }
118
+ }
119
+
120
+ // ── ReDoS-safe string utils ──────────────────────────────────────────────────
121
+
122
+ export function escapeRe(s) {
123
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
+ }
125
+
126
+ // Non-regex newline trims — avoid the `\n*$` / `^\n+` super-linear-backtracking
127
+ // shapes a static analyzer (rightly) flags as ReDoS-prone. O(n), no backtracking.
128
+ export function trimTrailingNewlines(s) {
129
+ let end = s.length;
130
+ while (end > 0 && s[end - 1] === '\n') end -= 1;
131
+ return s.slice(0, end);
132
+ }
133
+
134
+ export function trimLeadingNewlines(s) {
135
+ let start = 0;
136
+ while (start < s.length && s[start] === '\n') start += 1;
137
+ return s.slice(start);
138
+ }
@@ -203,6 +203,27 @@ function findMatchingBullet({ lines, substring, sectionTitle }) {
203
203
  return null;
204
204
  }
205
205
 
206
+ // Strip a bullet + its provenance comment (lines bulletIdx..commentIdx) and
207
+ // collapse a resulting double blank line at the seam, so removing a bullet from
208
+ // a blank-padded scratchpad doesn't leave two adjacent blank lines (MD012
209
+ // no-multiple-blanks). Only the seam is touched — blanks elsewhere are
210
+ // preserved. The bullet↔comment pair is removed together (the adjacency
211
+ // invariant the whole kit depends on).
212
+ function stripBulletPair(lines, bulletIdx, commentIdx) {
213
+ const kept = [...lines.slice(0, bulletIdx), ...lines.slice(commentIdx + 1)];
214
+ // If the splice put a blank line directly after a blank line at the seam,
215
+ // drop one. The seam is at index `bulletIdx` in `kept`.
216
+ if (
217
+ bulletIdx > 0 &&
218
+ bulletIdx < kept.length &&
219
+ kept[bulletIdx - 1].trim() === '' &&
220
+ kept[bulletIdx].trim() === ''
221
+ ) {
222
+ kept.splice(bulletIdx, 1);
223
+ }
224
+ return kept.join('\n');
225
+ }
226
+
206
227
  // --- Tombstone writer (design §6.5) --------------------------------
207
228
 
208
229
  function writeTombstone({
@@ -438,10 +459,7 @@ function doReplace(opts) {
438
459
  });
439
460
  }
440
461
  // Strip the matched bullet + provenance comment.
441
- const stripped = [
442
- ...lines.slice(0, match.bulletIdx),
443
- ...lines.slice(match.commentIdx + 1),
444
- ].join('\n');
462
+ const stripped = stripBulletPair(lines, match.bulletIdx, match.commentIdx);
445
463
  writeFileSync(path, stripped, 'utf8');
446
464
 
447
465
  // Append the new bullet via the GUARDED inner path. We already ran
@@ -550,10 +568,7 @@ function doRemove(opts) {
550
568
  });
551
569
 
552
570
  // Strip from scratchpad.
553
- const stripped = [
554
- ...lines.slice(0, match.bulletIdx),
555
- ...lines.slice(match.commentIdx + 1),
556
- ].join('\n');
571
+ const stripped = stripBulletPair(lines, match.bulletIdx, match.commentIdx);
557
572
  writeFileSync(path, stripped, 'utf8');
558
573
 
559
574
  appendAuditEntry(tierRoot, {
@@ -0,0 +1,243 @@
1
+ // mutate-agent-config.mjs — the shared per-agent config-write PRIMITIVE.
2
+ //
3
+ // Task 50 (cross-agent install). This is the one piece of machinery the whole
4
+ // `cmk install --ide <agent>` seam rests on: a single, tested function that
5
+ // writes the kit's entry (an MCP-server registration, a hook entry) into ANY
6
+ // agent's config file WITHOUT clobbering the user's other keys.
7
+ //
8
+ // The D-180 finding (research note 2026-06-20): do NOT build a per-agent
9
+ // `Installer` base class — claude-mem proved it breaks the moment agents differ
10
+ // in format/mechanism, and its ~6 bespoke config writers drifted in rigor (one
11
+ // surgical, one whole-file-clobber, one that discards user config on a JSON
12
+ // parse error). What actually generalizes is THIS primitive. Each per-agent
13
+ // profile is then just DATA (where the file is, which key, which format).
14
+ //
15
+ // The kit's existing disciplines, applied to third-party files:
16
+ // - touch-only-our-keys = the marker-block byte-preservation invariant
17
+ // - refuse-on-parse-error = the safe-write / Poison_Guard fail-closed rule
18
+ // - atomic (tmp + rename) = the same pattern compress-session/persona use
19
+ // - idempotent changed-bool = re-running install is a no-op (Codex's pattern)
20
+ //
21
+ // Public surface (one function — a deep module, narrow interface):
22
+ //
23
+ // mutateAgentConfig({ path, format, keyPath, entry, mode? }) → result
24
+ //
25
+ // path absolute path to the agent's config file (may not exist yet)
26
+ // format 'json' (v0.4.0; 'yaml' / 'toml' deferred until an agent needs them)
27
+ // keyPath array of keys to the slot we own, e.g. ['mcpServers', 'claude-memory-kit']
28
+ // entry the object to place at keyPath
29
+ // mode 'merge' (default — deep-merge into an existing entry) | 'replace'
30
+ //
31
+ // Result shape (result-shapes.mjs conventions):
32
+ // { action: 'created'|'updated'|'skipped'|'error', changed: boolean, path,
33
+ // errorCategory?, errors? }
34
+ // created — the file did not exist; we created it with just our key
35
+ // updated — the file existed; our key was added/changed (siblings preserved)
36
+ // skipped — our key already matched exactly; nothing written (changed:false)
37
+ // error — input invalid (schema) or target unparseable (config_parse);
38
+ // NEVER writes on error
39
+
40
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
41
+ import { dirname, basename, join } from 'node:path';
42
+ import { randomUUID } from 'node:crypto';
43
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
44
+ import { stripBom } from './read-json.mjs';
45
+
46
+ const SUPPORTED_FORMATS = new Set(['json']);
47
+
48
+ /**
49
+ * Write the kit's entry into an agent config file, preserving everything else.
50
+ * @returns {{action:string, changed:boolean, path:string, errorCategory?:string, errors?:string[]}}
51
+ */
52
+ export function mutateAgentConfig({ path, format, keyPath, entry, mode = 'merge' }) {
53
+ // ── input validation (schema) ───────────────────────────────────────────
54
+ if (typeof path !== 'string' || path.length === 0) {
55
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['path must be a non-empty string'], changed: false });
56
+ }
57
+ if (!SUPPORTED_FORMATS.has(format)) {
58
+ return errorResult({
59
+ category: ERROR_CATEGORIES.SCHEMA,
60
+ errors: [`unsupported format ${JSON.stringify(format)} — supported: ${[...SUPPORTED_FORMATS].join(', ')}`],
61
+ changed: false,
62
+ path,
63
+ });
64
+ }
65
+ if (!Array.isArray(keyPath) || keyPath.length === 0 || !keyPath.every((k) => typeof k === 'string' && k.length > 0)) {
66
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['keyPath must be a non-empty array of non-empty strings'], changed: false, path });
67
+ }
68
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
69
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['entry must be a plain object'], changed: false, path });
70
+ }
71
+ if (mode !== 'merge' && mode !== 'replace') {
72
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: [`mode must be 'merge' or 'replace', got ${JSON.stringify(mode)}`], changed: false, path });
73
+ }
74
+
75
+ // ── read existing (refuse-to-clobber on parse error) ─────────────────────
76
+ const fileExists = existsSync(path);
77
+ let root;
78
+ if (fileExists) {
79
+ let raw;
80
+ try {
81
+ // stripBom: a user's config file written by a Windows editor may carry a
82
+ // leading UTF-8 BOM, which would otherwise make the JSON.parse below refuse
83
+ // a perfectly valid file as "corrupt" (D-187). Strip only the BOM; the
84
+ // refuse-to-clobber-on-real-corruption guarantee below is unchanged.
85
+ raw = stripBom(readFileSync(path, 'utf8'));
86
+ } catch (err) {
87
+ return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`could not read ${path}: ${err.message}`], changed: false, path });
88
+ }
89
+ if (raw.trim() === '') {
90
+ // an empty file is treated as an empty object (not a parse error)
91
+ root = {};
92
+ } else {
93
+ try {
94
+ root = JSON.parse(raw);
95
+ } catch (err) {
96
+ // The crucial guarantee: a corrupt target is NEVER overwritten.
97
+ return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`${path} is not valid JSON — refusing to overwrite: ${err.message}`], changed: false, path });
98
+ }
99
+ }
100
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
101
+ return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`${path} is valid JSON but not an object — refusing to overwrite`], changed: false, path });
102
+ }
103
+ } else {
104
+ root = {};
105
+ }
106
+
107
+ // ── compute the next entry at keyPath ────────────────────────────────────
108
+ const existing = getAt(root, keyPath);
109
+ const nextEntry =
110
+ mode === 'merge' && isPlainObject(existing) ? deepMerge(existing, entry) : entry;
111
+
112
+ // idempotent: if our slot already deep-equals what we'd write, do nothing.
113
+ if (existing !== undefined && deepEqual(existing, nextEntry)) {
114
+ return { action: 'skipped', changed: false, path };
115
+ }
116
+
117
+ // ── apply + atomic write ─────────────────────────────────────────────────
118
+ const next = cloneDeep(root);
119
+ setAt(next, keyPath, nextEntry);
120
+
121
+ const serialized = `${JSON.stringify(next, null, 2)}\n`;
122
+ atomicWrite(path, serialized);
123
+
124
+ return { action: fileExists ? 'updated' : 'created', changed: true, path };
125
+ }
126
+
127
+ // ── internal helpers ───────────────────────────────────────────────────────
128
+
129
+ function isPlainObject(v) {
130
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
131
+ }
132
+
133
+ function getAt(obj, keyPath) {
134
+ let cur = obj;
135
+ for (const k of keyPath) {
136
+ if (!isPlainObject(cur) || !(k in cur)) return undefined;
137
+ cur = cur[k];
138
+ }
139
+ return cur;
140
+ }
141
+
142
+ function setAt(obj, keyPath, value) {
143
+ let cur = obj;
144
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
145
+ const k = keyPath[i];
146
+ if (!isPlainObject(cur[k])) cur[k] = {};
147
+ cur = cur[k];
148
+ }
149
+ cur[keyPath[keyPath.length - 1]] = value;
150
+ }
151
+
152
+ // Deep-merge `source` onto a copy of `target`. Objects merge recursively;
153
+ // arrays + scalars from source replace target's (no array concat — replacing
154
+ // args/command is the intended semantics).
155
+ function deepMerge(target, source) {
156
+ const out = cloneDeep(target);
157
+ for (const [k, v] of Object.entries(source)) {
158
+ if (isPlainObject(v) && isPlainObject(out[k])) {
159
+ out[k] = deepMerge(out[k], v);
160
+ } else {
161
+ out[k] = cloneDeep(v);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function cloneDeep(v) {
168
+ return v === undefined ? undefined : JSON.parse(JSON.stringify(v));
169
+ }
170
+
171
+ function deepEqual(a, b) {
172
+ return JSON.stringify(a) === JSON.stringify(b);
173
+ }
174
+
175
+ // Atomic write: serialize to a temp sibling, then rename over the target.
176
+ // rename is atomic on the same filesystem, so a reader never sees a partial file.
177
+ // Exported so install-agent.mjs (the per-agent wiring) shares ONE implementation
178
+ // — the kit's shared-module discipline (no two copies to drift).
179
+ //
180
+ // Two Windows-specific hazards this guards against (BOTH real, BOTH surfaced by
181
+ // the Task-50 stress runs — not "flakes": concrete EPERM-under-load failures):
182
+ // 1. The tmp suffix is UNIQUE PER CALL (randomUUID), not `process.pid` — a
183
+ // single install sequence writes the same path several times (write →
184
+ // uninstall → prune) and vitest runs files concurrently in one worker
185
+ // (shared pid), so a pid-keyed tmp name could collide.
186
+ // 2. `renameSync(tmp, target)` ONTO an existing file intermittently throws
187
+ // EPERM/EBUSY on Windows when the target is transiently locked (AV /
188
+ // indexer / another handle) under heavy parallel FS load. The kit already
189
+ // hit + documented this in persona-portability.mjs (restoreBackup); the
190
+ // proven fix is a short bounded retry. Without it, a `cmk install` on a
191
+ // busy Windows machine could spuriously fail mid-write.
192
+ export function atomicWrite(path, contents) {
193
+ mkdirSync(dirname(path), { recursive: true });
194
+ const tmp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
195
+ try {
196
+ writeFileSync(tmp, contents, 'utf8');
197
+ renameWithRetry(tmp, path);
198
+ } finally {
199
+ if (existsSync(tmp)) {
200
+ try {
201
+ unlinkSync(tmp);
202
+ } catch {
203
+ /* best-effort cleanup; the rename already succeeded or threw */
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ // renameSync with a bounded retry + BACKOFF on transient Windows EPERM/EBUSY
210
+ // (the target briefly locked by AV/indexer/another handle under load). A
211
+ // no-delay spin doesn't work — the lock lasts milliseconds while 5 immediate
212
+ // retries finish in microseconds (the Task-50 stress proved this: retry fired
213
+ // but exhausted instantly). So we sleep between attempts with a synchronous
214
+ // backoff (Atomics.wait — real sync sleep, no busy-spin). A non-transient error
215
+ // (e.g. ENOENT on the source) reraises immediately so we don't mask a real bug.
216
+ //
217
+ // `rename` + `sleep` are injectable so the retry policy is unit-testable WITHOUT
218
+ // mocking node:fs or actually sleeping — production passes nothing.
219
+ export function renameWithRetry(from, to, attempts = 8, rename = renameSync, sleep = sleepMs) {
220
+ let lastErr;
221
+ for (let i = 0; i < attempts; i += 1) {
222
+ try {
223
+ rename(from, to);
224
+ return;
225
+ } catch (err) {
226
+ if (err && (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES')) {
227
+ lastErr = err; // transient lock under load — back off + retry
228
+ if (i < attempts - 1) sleep(Math.min(10 * 2 ** i, 250)); // 10,20,40…≤250ms
229
+ continue;
230
+ }
231
+ throw err; // anything else is a real error — don't mask it
232
+ }
233
+ }
234
+ throw lastErr;
235
+ }
236
+
237
+ // Synchronous sleep via Atomics.wait on a throwaway SharedArrayBuffer — blocks
238
+ // the thread for `ms` without a CPU busy-spin. Used only on the (rare) Windows
239
+ // transient-lock retry path, so the block is bounded + infrequent.
240
+ function sleepMs(ms) {
241
+ if (ms <= 0) return;
242
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
243
+ }