@evomap/evolver-mcp 2.0.0-beta.17 → 2.0.0-beta.19

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.
package/dist/installer.js CHANGED
@@ -7,12 +7,13 @@
7
7
  // the agent discovers evolver's tools, AND merge a SessionStart hook into .claude/settings.json so memory is
8
8
  // pushed at session start (MCP alone can't push — the agent must pull). Hardened like v1: atomic writes
9
9
  // (tmp+rename), refusal to follow a symlink at any adapter-owned path (a hostile workspace could redirect
10
- // writes/unlinks outside the project), marker-managed so reinstall/uninstall only touch evolver's own entries,
10
+ // writes/unlinks outside the project), conditionally committed so concurrent runtime updates are preserved,
11
+ // marker-managed so reinstall/uninstall only touch evolver's own entries,
11
12
  // and a hooks-UNION merge that preserves the user's existing hooks.
12
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
- import { randomUUID } from 'node:crypto';
13
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs';
14
+ import { createHash } from 'node:crypto';
14
15
  import { homedir } from 'node:os';
15
- import { join } from 'node:path';
16
+ import { dirname, join } from 'node:path';
16
17
  import { util } from '@evomap/evolver-core';
17
18
  import { planInjection } from './injection.js';
18
19
  // codex installer lives in its own module (TOML, different config path) but plugs into the same install/uninstall
@@ -25,48 +26,22 @@ import { installCursorRules, uninstallCursorRules } from './cursorRulesInstaller
25
26
  import { installAntigravity, uninstallAntigravity } from './antigravityInstaller.js';
26
27
  import { installOpenCode, uninstallOpenCode } from './opencodeInstaller.js';
27
28
  import { installKiro, uninstallKiro } from './kiroInstaller.js';
29
+ import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
30
+ import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
31
+ export { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
28
32
  /** Marks a config file as containing evolver-managed entries, so uninstall only removes what we added. */
29
33
  export const MANAGED_MARKER = '_evolver_managed';
30
- /** A hook entry is evolver-owned if any of its commands mention this used to replace-not-duplicate on reinstall. */
31
- const EVOLVER_HOOK_TAG = 'evolver';
34
+ /** Official command-handler metadata, also gives custom commands an ownership marker for reinstall/uninstall. */
35
+ export const EVOLVER_HOOK_STATUS = 'Loading Evolver memory';
32
36
  /** Default command the SessionStart hook runs to render + print the memory injection. The `--hook-stdin` flag opts
33
37
  * the entrypoint into reading the runtime's SessionStart JSON from stdin (to capture session_id, #205); only the
34
38
  * installed hook sets it, so a manual `evolver inject session-start` never reads stdin. */
35
39
  export const DEFAULT_HOOK_COMMAND = 'evolver inject session-start --hook-stdin';
40
+ /** Local-only prompt recall. The handler is default-off and reads stdin only when EVOLVER_RECALL_MODE opts in. */
41
+ export const DEFAULT_PROMPT_RECALL_HOOK_COMMAND = 'evolver inject prompt-recall --hook-stdin';
36
42
  const SHARED_USER_CONFIG_MODE = 0o600;
37
43
  const SHARED_USER_DIR_MODE = 0o700;
38
44
  const SHARED_USER_CONFIG_WRITE_RETRIES = 5;
39
- export class SymlinkRefusedError extends Error {
40
- constructor(label, path) {
41
- super(`[setup-hooks] refusing to operate: ${label} ${path} is a symbolic link — evolver will not follow symlinks for adapter-owned paths (a hostile workspace could redirect writes/unlinks outside the project). Replace it with a real directory/file and rerun.`);
42
- this.name = 'SymlinkRefusedError';
43
- }
44
- }
45
- /**
46
- * Thrown when a SHARED user config (~/.claude.json or ~/.claude/settings.json) exists but does not parse as JSON.
47
- * These files are Claude Code's own state (projects/oauthAccount/userID/history/settings),
48
- * and user-scope install merges into them via a full-file atomic replace. The lenient readJson() returns {} on
49
- * a parse failure, which would make the merge emit ONLY evolver's entry and silently WIPE the whole file — a
50
- * realistic data-loss path because Claude Code writes these files non-atomically (a concurrent session can leave
51
- * one truncated). For the shared-config read we therefore refuse instead of clobbering. Project-scoped
52
- * .mcp.json/.claude/settings.json are evolver-owned, so their lenient fresh-start behavior stays unchanged.
53
- */
54
- export class UnparseableConfigError extends Error {
55
- constructor(label, path, owner = 'Claude Code') {
56
- super(`[setup-hooks] refusing to overwrite ${label} (${path}): the file exists and is non-empty but is not valid JSON. This is ${owner}'s own shared config; merging into it would replace the whole file and could wipe its contents. Fix or remove the corrupt file, then rerun.`);
57
- this.name = 'UnparseableConfigError';
58
- }
59
- }
60
- /**
61
- * Thrown when a SHARED user config exists but is empty or whitespace-only. Claude Code writes these files with a
62
- * truncating write, so present-empty can be a concurrent-write window rather than a fresh config.
63
- */
64
- export class EmptySharedConfigError extends Error {
65
- constructor(label, path, owner = 'Claude Code') {
66
- super(`[setup-hooks] refusing to overwrite ${label} (${path}): the file exists but is empty or contains only whitespace. ${owner} may be in the middle of a truncating write, and treating it as fresh config could wipe shared config data. Fix the empty file or retry after ${owner} finishes writing it.`);
67
- this.name = 'EmptySharedConfigError';
68
- }
69
- }
70
45
  // ── fs hardening ────────────────────────────────────────────────────────────
71
46
  /** Refuse to read/write through a symlink at an adapter-owned path. Missing path is fine (install creates it). */
72
47
  function assertNotSymlink(path, label) {
@@ -82,23 +57,33 @@ function assertNotSymlink(path, label) {
82
57
  if (st.isSymbolicLink())
83
58
  throw new SymlinkRefusedError(label, path);
84
59
  }
85
- function readJson(path) {
60
+ /** Validate a shared config path without following either its parent or the target. */
61
+ function validatedSharedConfigMode(path, label) {
62
+ const parentPath = dirname(path);
63
+ const parent = lstatSync(parentPath);
64
+ if (parent.isSymbolicLink())
65
+ throw new SymlinkRefusedError(`${label} parent directory`, parentPath);
66
+ if (!parent.isDirectory()) {
67
+ throw new Error(`[setup-hooks] refusing to operate: parent of ${label} (${parentPath}) is not a directory.`);
68
+ }
69
+ let target;
86
70
  try {
87
- if (!existsSync(path))
88
- return {};
89
- const raw = readFileSync(path, 'utf8').trim();
90
- return raw ? JSON.parse(raw) : {};
71
+ target = lstatSync(path);
91
72
  }
92
- catch {
93
- return {}; // unparseable → start fresh (merge will re-add evolver entries)
73
+ catch (error) {
74
+ if (error.code === 'ENOENT')
75
+ return undefined;
76
+ throw error;
77
+ }
78
+ if (target.isSymbolicLink())
79
+ throw new SymlinkRefusedError(label, path);
80
+ if (!target.isFile()) {
81
+ throw new Error(`[setup-hooks] refusing to operate: ${label} ${path} is not a regular file.`);
94
82
  }
83
+ return target.mode & 0o777;
95
84
  }
96
85
  /**
97
- * Strict variant for SHARED user configs (Claude Code's own ~/.claude.json / ~/.claude/settings.json). Only
98
- * ENOENT is treated as {} so a fresh user-scope install works. A present empty/whitespace file is refused because
99
- * it can be Claude Code's truncating-write window; a present non-empty parse failure is refused because returning
100
- * {} would make the subsequent full-file atomic write clobber Claude Code's state. Use this only for the
101
- * shared-config read; project-scoped evolver-owned files keep the lenient readJson() above.
86
+ * Only ENOENT is treated as a fresh config. Empty or malformed existing files are never replaced.
102
87
  */
103
88
  function readJsonStrictShared(path, label) {
104
89
  return readJsonStrictSharedSnapshot(path, label).data;
@@ -133,18 +118,8 @@ function readRawIfExists(path) {
133
118
  throw e;
134
119
  }
135
120
  }
136
- function existingFileMode(path) {
137
- try {
138
- return statSync(path).mode & 0o777;
139
- }
140
- catch (e) {
141
- if (e.code === 'ENOENT')
142
- return undefined;
143
- throw e;
144
- }
145
- }
146
- function sharedUserConfigWriteMode(path) {
147
- const existingMode = existingFileMode(path);
121
+ function sharedUserConfigWriteMode(path, label) {
122
+ const existingMode = validatedSharedConfigMode(path, label);
148
123
  return existingMode === undefined ? SHARED_USER_CONFIG_MODE : existingMode & 0o700;
149
124
  }
150
125
  function ensureSharedUserClaudeDir(path) {
@@ -188,38 +163,6 @@ function hardenSharedUserConfigFile(path, label) {
188
163
  if (hardenedMode !== currentMode)
189
164
  chmodSync(path, hardenedMode);
190
165
  }
191
- function writeJsonAtomic(path, data, options = {}) {
192
- const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
193
- const content = `${JSON.stringify(data, null, 2)}\n`;
194
- const mode = options.mode ?? existingFileMode(path);
195
- const restoreMode = process.platform === 'win32' ? mode : undefined;
196
- try {
197
- writeFileSync(tmp, content, mode === undefined
198
- ? { encoding: 'utf8', flag: 'wx' }
199
- : { encoding: 'utf8', flag: 'wx', mode });
200
- if (mode !== undefined)
201
- chmodSync(tmp, mode);
202
- if (process.platform === 'win32' && mode !== undefined && existsSync(path)) {
203
- chmodSync(path, mode | 0o200);
204
- }
205
- renameSync(tmp, path);
206
- if (mode !== undefined)
207
- chmodSync(path, mode);
208
- }
209
- catch (e) {
210
- rmSync(tmp, { force: true });
211
- if (restoreMode !== undefined) {
212
- try {
213
- if (existsSync(path))
214
- chmodSync(path, restoreMode);
215
- }
216
- catch (rollbackError) {
217
- e.rollbackError = rollbackError;
218
- }
219
- }
220
- throw e;
221
- }
222
- }
223
166
  let sharedConfigRaceHookForTest;
224
167
  export function _setSharedConfigRaceHookForTest(hook) {
225
168
  sharedConfigRaceHookForTest = hook;
@@ -227,23 +170,64 @@ export function _setSharedConfigRaceHookForTest(hook) {
227
170
  function writeSharedJsonWithRetry(path, label, update) {
228
171
  const lockPath = `${path}.evolver.lock`;
229
172
  util.acquireLock(lockPath);
173
+ let operationResult = false;
174
+ let operationFailed = false;
175
+ let operationError;
230
176
  try {
231
- for (let attempt = 1; attempt <= SHARED_USER_CONFIG_WRITE_RETRIES; attempt++) {
232
- const snapshot = readJsonStrictSharedSnapshot(path, label);
233
- const next = update(snapshot.data);
234
- if (!next.changed)
235
- return false;
236
- sharedConfigRaceHookForTest?.(path, attempt);
237
- if (readRawIfExists(path) !== snapshot.raw)
238
- continue;
239
- writeJsonAtomic(path, next.data, { mode: sharedUserConfigWriteMode(path) });
240
- return true;
241
- }
177
+ operationResult = (() => {
178
+ for (let attempt = 1; attempt <= SHARED_USER_CONFIG_WRITE_RETRIES; attempt++) {
179
+ validatedSharedConfigMode(path, label);
180
+ const snapshot = readJsonStrictSharedSnapshot(path, label);
181
+ validatedSharedConfigMode(path, label);
182
+ const next = update(snapshot.data);
183
+ if (!next.changed)
184
+ return false;
185
+ sharedConfigRaceHookForTest?.(path, attempt);
186
+ validatedSharedConfigMode(path, label);
187
+ const currentRaw = readRawIfExists(path);
188
+ validatedSharedConfigMode(path, label);
189
+ if (currentRaw !== snapshot.raw)
190
+ continue;
191
+ try {
192
+ commitSharedFile({
193
+ path,
194
+ expectedRaw: snapshot.raw ?? undefined,
195
+ nextRaw: `${JSON.stringify(next.data, null, 2)}\n`,
196
+ mode: sharedUserConfigWriteMode(path, label),
197
+ });
198
+ return true;
199
+ }
200
+ catch (error) {
201
+ if (error instanceof SharedFileConflictError)
202
+ continue;
203
+ throw error;
204
+ }
205
+ }
206
+ throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the file changed repeatedly while evolver was merging it. Rerun setup-hooks after Claude Code finishes writing this config.`);
207
+ })();
208
+ }
209
+ catch (error) {
210
+ operationFailed = true;
211
+ operationError = error;
242
212
  }
243
- finally {
244
- util.releaseLock(lockPath);
213
+ let releaseError;
214
+ try {
215
+ const released = util.releaseLock(lockPath);
216
+ if (!released.released)
217
+ releaseError = new util.LockReleaseError(released.reason);
218
+ }
219
+ catch (error) {
220
+ releaseError = error;
245
221
  }
246
- throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the file changed repeatedly while evolver was merging it. Rerun setup-hooks after Claude Code finishes writing this config.`);
222
+ if (operationFailed) {
223
+ if (operationError instanceof Error && releaseError !== undefined) {
224
+ operationError.lockReleaseError = releaseError;
225
+ }
226
+ throw operationError;
227
+ }
228
+ if (releaseError !== undefined)
229
+ throw releaseError;
230
+ return operationResult;
247
231
  }
248
232
  // ── pure merge (exported for tests) ──────────────────────────────────────────
249
233
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -275,7 +259,60 @@ function collectCommands(entry) {
275
259
  out.push(h['command']);
276
260
  return out;
277
261
  }
278
- const isEvolverOwned = (entry) => collectCommands(entry).some((c) => c.includes(EVOLVER_HOOK_TAG));
262
+ const LEGACY_NODE_COMMAND = /^"?node(?:\.exe)?"?\s+/i;
263
+ const LEGACY_EVOLVER_SCRIPT_BASENAME = /(?:^|[\\/'"\s])(?:evolver-session-start|evolver-session-end|evolver-signal-detect|evolver-task-recall|evolver-daemon-start)\.js(?=$|[\\/'"\s])/i;
264
+ const LEGACY_V2_CLI_HOOK_COMMAND = /^"?evolver(?:\.cmd|\.exe)?"?\s+inject\s+(?:session-start|prompt-recall)(?:\s|$)/i;
265
+ function isKnownEvolverHookCommand(command) {
266
+ // V1 invoked only these copied script basenames through node; pre-marker V2 used the two anchored CLI verbs.
267
+ // Do not use a generic `evolver` substring: user script paths and messages commonly contain that project name.
268
+ const trimmed = command.trim();
269
+ return LEGACY_V2_CLI_HOOK_COMMAND.test(trimmed)
270
+ || (LEGACY_NODE_COMMAND.test(trimmed) && LEGACY_EVOLVER_SCRIPT_BASENAME.test(trimmed));
271
+ }
272
+ /** Bind custom-command ownership to that exact command without adding undocumented hook-schema fields. */
273
+ export function evolverManagedHookStatus(command) {
274
+ if (isKnownEvolverHookCommand(command))
275
+ return EVOLVER_HOOK_STATUS;
276
+ const commandTag = createHash('sha256').update(command, 'utf8').digest('hex').slice(0, 16);
277
+ return `${EVOLVER_HOOK_STATUS} [evolver:${commandTag}]`;
278
+ }
279
+ const NO_MANAGED_HOOK_COMMANDS = new Set();
280
+ const isEvolverHandler = (handler, trustManagedStatus, managedCommands) => {
281
+ if (!isObj(handler) || typeof handler['command'] !== 'string')
282
+ return false;
283
+ const command = handler['command'];
284
+ return isKnownEvolverHookCommand(command)
285
+ || (trustManagedStatus && (managedCommands.has(command)
286
+ || handler['statusMessage'] === evolverManagedHookStatus(command)));
287
+ };
288
+ /** Remove only Evolver-owned handlers, retaining user handlers that share the same matcher group. */
289
+ export function stripEvolverHookEntries(entries, trustManagedStatus = false, managedCommands = NO_MANAGED_HOOK_COMMANDS) {
290
+ let changed = false;
291
+ const keptEntries = [];
292
+ for (const entry of entries) {
293
+ if (!isObj(entry)) {
294
+ keptEntries.push(entry);
295
+ continue;
296
+ }
297
+ const handlers = entry['hooks'];
298
+ if (!Array.isArray(handlers)) {
299
+ if (isEvolverHandler(entry, trustManagedStatus, managedCommands))
300
+ changed = true;
301
+ else
302
+ keptEntries.push(entry);
303
+ continue;
304
+ }
305
+ const keptHandlers = handlers.filter((handler) => !isEvolverHandler(handler, trustManagedStatus, managedCommands));
306
+ if (keptHandlers.length === handlers.length) {
307
+ keptEntries.push(entry);
308
+ continue;
309
+ }
310
+ changed = true;
311
+ if (keptHandlers.length > 0)
312
+ keptEntries.push({ ...entry, hooks: keptHandlers });
313
+ }
314
+ return { changed, entries: keptEntries };
315
+ }
279
316
  /**
280
317
  * deepMerge, but for `hooks.<event>` arrays keep the user's existing entries and only replace evolver-owned
281
318
  * ones — so reinstalling refreshes evolver's hook without clobbering a user's own SessionStart/Stop hooks.
@@ -284,16 +321,41 @@ export function mergeHooksUnion(target, source) {
284
321
  const result = deepMerge(target, source);
285
322
  const tHooks = target['hooks'];
286
323
  const sHooks = source['hooks'];
324
+ const trustManagedStatus = target[MANAGED_MARKER] === true;
287
325
  if (isObj(tHooks) && isObj(sHooks)) {
288
- const merged = { ...(isObj(result['hooks']) ? result['hooks'] : {}) };
326
+ const merged = {};
327
+ const managedCommands = new Set();
328
+ for (const value of Object.values(sHooks)) {
329
+ if (!Array.isArray(value))
330
+ continue;
331
+ for (const entry of value)
332
+ for (const command of collectCommands(entry))
333
+ managedCommands.add(command);
334
+ }
335
+ // First remove every stale Evolver-owned entry, including V1 events (Stop/PostToolUse) that V2 deliberately
336
+ // no longer installs. User entries and non-array hook metadata remain untouched.
337
+ for (const event of Object.keys(tHooks)) {
338
+ if (POLLUTION_KEYS.has(event))
339
+ continue;
340
+ const value = tHooks[event];
341
+ if (!Array.isArray(value)) {
342
+ merged[event] = value;
343
+ continue;
344
+ }
345
+ const kept = stripEvolverHookEntries(value, trustManagedStatus, managedCommands).entries;
346
+ if (kept.length > 0)
347
+ merged[event] = kept;
348
+ }
289
349
  for (const event of Object.keys(sHooks)) {
290
350
  if (POLLUTION_KEYS.has(event))
291
351
  continue; // same guard for the hooks-union branch
292
- const tArr = tHooks[event];
293
352
  const sArr = sHooks[event];
294
- if (Array.isArray(tArr) && Array.isArray(sArr)) {
295
- merged[event] = [...tArr.filter((e) => !isEvolverOwned(e)), ...sArr];
353
+ if (Array.isArray(sArr)) {
354
+ const prior = Array.isArray(merged[event]) ? merged[event] : [];
355
+ merged[event] = [...prior, ...sArr];
296
356
  }
357
+ else
358
+ merged[event] = sArr;
297
359
  }
298
360
  result['hooks'] = merged;
299
361
  }
@@ -303,14 +365,16 @@ export function mergeHooksUnion(target, source) {
303
365
  export function stripManaged(data) {
304
366
  let changed = false;
305
367
  const out = { ...data };
368
+ const trustManagedStatus = data[MANAGED_MARKER] === true;
306
369
  const hooks = out['hooks'];
307
370
  if (isObj(hooks)) {
308
371
  const nextHooks = {};
309
372
  for (const event of Object.keys(hooks)) {
310
373
  const arr = hooks[event];
311
374
  if (Array.isArray(arr)) {
312
- const kept = arr.filter((e) => !isEvolverOwned(e));
313
- if (kept.length !== arr.length)
375
+ const stripped = stripEvolverHookEntries(arr, trustManagedStatus);
376
+ const kept = stripped.entries;
377
+ if (stripped.changed)
314
378
  changed = true;
315
379
  if (kept.length > 0)
316
380
  nextHooks[event] = kept;
@@ -343,8 +407,26 @@ export function stripManaged(data) {
343
407
  }
344
408
  return { changed, data: out };
345
409
  }
346
- function sessionStartHookPatch(hookCommand) {
347
- return { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: hookCommand }] }] } };
410
+ function runtimeHookPatch(sessionStartCommand, promptRecallCommand) {
411
+ return {
412
+ hooks: {
413
+ SessionStart: [{ hooks: [{
414
+ type: 'command', command: sessionStartCommand, statusMessage: evolverManagedHookStatus(sessionStartCommand),
415
+ }] }],
416
+ // Claude Code documents command-hook timeouts in seconds. A timeout discards output and lets the prompt
417
+ // proceed, so this remains fail-open even if the local CLI process stalls before its own deadline.
418
+ UserPromptSubmit: [{ hooks: [{
419
+ type: 'command', command: promptRecallCommand, timeout: 5,
420
+ statusMessage: evolverManagedHookStatus(promptRecallCommand),
421
+ }] }],
422
+ },
423
+ };
424
+ }
425
+ function hasExactHookCommand(config, event, command) {
426
+ const hooks = config['hooks'];
427
+ if (!isObj(hooks) || !Array.isArray(hooks[event]))
428
+ return false;
429
+ return hooks[event].some((entry) => collectCommands(entry).includes(command));
348
430
  }
349
431
  /** True when a parsed config already carries evolver's MCP registration (mcpServers.evolver) — the same entry
350
432
  * stripManaged removes on uninstall. The "already installed" short-circuit checks this in addition to the hook
@@ -416,6 +498,7 @@ export function installInjection(plan, opts) {
416
498
  return { ok: false, runtime: plan.runtime, mode: plan.mode, files: [], error: `installer not yet implemented for ${plan.runtime} (supported: claude-code, codex, cursor, antigravity, opencode, kiro)` };
417
499
  }
418
500
  const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
501
+ const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
419
502
  const scope = opts.scope ?? 'project';
420
503
  const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
421
504
  const mcpLabel = mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json';
@@ -426,12 +509,8 @@ export function installInjection(plan, opts) {
426
509
  assertNotSymlink(mcpConfigPath, mcpLabel);
427
510
  assertNotSymlink(claudeDir, mcpIsSharedUserConfig ? '~/.claude' : '.claude');
428
511
  assertNotSymlink(settingsPath, settingsLabel);
429
- // For the SHARED user config (~/.claude.json + ~/.claude/settings.json) read strictly: a present, non-empty,
430
- // unparseable file aborts the install (UnparseableConfigError) instead of being treated as {} and clobbered by
431
- // the full-file atomic write below. Project-scoped evolver-owned files keep the lenient readJson fresh-start.
432
- const readConfig = mcpIsSharedUserConfig
433
- ? (p, label) => readJsonStrictShared(p, label)
434
- : (p, _label) => readJson(p);
512
+ // Validate both targets before either write so malformed input cannot cause a partial install.
513
+ const readConfig = (p, label) => readJsonStrictShared(p, label);
435
514
  if (mcpIsSharedUserConfig) {
436
515
  ensureSharedUserClaudeDir(claudeDir);
437
516
  hardenSharedUserConfigFile(mcpConfigPath, mcpLabel);
@@ -443,7 +522,11 @@ export function installInjection(plan, opts) {
443
522
  // settings marker alone missed user-scope upgrades: a legacy global install stamped ~/.claude/settings.json but
444
523
  // registered the MCP in ~/.mcp.json (never ~/.claude.json), so a non-force reinstall returned alreadyInstalled
445
524
  // and left #290 unfixed. The hook and the MCP live in different files for user scope, so check both.
446
- if (!opts.force && existingSettings[MANAGED_MARKER] === true && hasEvolverMcpRegistration(existingMcp)) {
525
+ if (!opts.force
526
+ && existingSettings[MANAGED_MARKER] === true
527
+ && hasEvolverMcpRegistration(existingMcp)
528
+ && hasExactHookCommand(existingSettings, 'SessionStart', hookCommand)
529
+ && hasExactHookCommand(existingSettings, 'UserPromptSubmit', promptRecallHookCommand)) {
447
530
  return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
448
531
  }
449
532
  // MCP server registration. project → <root>/.mcp.json (stamped _evolver_managed). user → ~/.claude.json's
@@ -456,9 +539,11 @@ export function installInjection(plan, opts) {
456
539
  }));
457
540
  }
458
541
  else {
459
- const mcpMerged = deepMerge(existingMcp, plan.config);
460
- mcpMerged[MANAGED_MARKER] = true;
461
- writeJsonAtomic(mcpConfigPath, mcpMerged);
542
+ writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => {
543
+ const mcpMerged = deepMerge(current, plan.config);
544
+ mcpMerged[MANAGED_MARKER] = true;
545
+ return { changed: true, data: mcpMerged };
546
+ });
462
547
  }
463
548
  // .claude/settings.json ← SessionStart hook (hooks-union preserves the user's own hooks). For user scope this
464
549
  // is ~/.claude/settings.json, which is already Claude Code's user-level hook config.
@@ -468,15 +553,17 @@ export function installInjection(plan, opts) {
468
553
  mkdirSync(claudeDir, { recursive: true });
469
554
  if (mcpIsSharedUserConfig) {
470
555
  writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
471
- const settingsMerged = mergeHooksUnion(current, sessionStartHookPatch(hookCommand));
556
+ const settingsMerged = mergeHooksUnion(current, runtimeHookPatch(hookCommand, promptRecallHookCommand));
472
557
  settingsMerged[MANAGED_MARKER] = true;
473
558
  return { changed: true, data: settingsMerged };
474
559
  });
475
560
  }
476
561
  else {
477
- const settingsMerged = mergeHooksUnion(existingSettings, sessionStartHookPatch(hookCommand));
478
- settingsMerged[MANAGED_MARKER] = true;
479
- writeJsonAtomic(settingsPath, settingsMerged);
562
+ writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
563
+ const settingsMerged = mergeHooksUnion(current, runtimeHookPatch(hookCommand, promptRecallHookCommand));
564
+ settingsMerged[MANAGED_MARKER] = true;
565
+ return { changed: true, data: settingsMerged };
566
+ });
480
567
  }
481
568
  return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [mcpConfigPath, settingsPath] };
482
569
  }
@@ -512,20 +599,15 @@ export function uninstallInjection(runtime, opts) {
512
599
  ];
513
600
  for (const [path, label] of targets) {
514
601
  assertNotSymlink(path, label);
602
+ if (existsSync(path))
603
+ readJsonStrictShared(path, label);
604
+ }
605
+ for (const [path, label] of targets) {
515
606
  if (!existsSync(path))
516
607
  continue;
517
- if (mcpIsSharedUserConfig) {
518
- const changed = writeSharedJsonWithRetry(path, label, (current) => stripManaged(current));
519
- if (changed)
520
- cleaned.push(path);
521
- }
522
- else {
523
- const { changed, data } = stripManaged(readJson(path));
524
- if (changed) {
525
- writeJsonAtomic(path, data);
526
- cleaned.push(path);
527
- }
528
- }
608
+ const changed = writeSharedJsonWithRetry(path, label, (current) => stripManaged(current));
609
+ if (changed)
610
+ cleaned.push(path);
529
611
  }
530
612
  return { ok: true, runtime, mode: 'uninstall', files: cleaned };
531
613
  }
@@ -0,0 +1,103 @@
1
+ import type { McpServerCmd, RuntimeId } from './injection.js';
2
+ /** Minimal gene projection consumed by the Cursor rules renderer. */
3
+ export interface CursorGene {
4
+ id: string;
5
+ category?: string;
6
+ hint?: string;
7
+ }
8
+ /** Default command used by runtimes that support a SessionStart hook. The flag enables session-id capture. */
9
+ export declare const DEFAULT_HOOK_COMMAND = "evolver inject session-start --hook-stdin";
10
+ export declare const DEFAULT_PROMPT_RECALL_HOOK_COMMAND = "evolver inject prompt-recall --hook-stdin";
11
+ export declare const EVOLVER_HOOK_STATUS = "evolver-managed-hook";
12
+ /** Bind custom-command ownership to that exact command without adding undocumented hook-schema fields. */
13
+ export declare function evolverManagedHookStatus(command: string): string;
14
+ /** Remove only Evolver-owned handlers, retaining user handlers that share the same matcher group. */
15
+ export declare function stripEvolverHookEntries(entries: readonly unknown[], trustManagedStatus?: boolean, managedCommands?: ReadonlySet<string>): {
16
+ changed: boolean;
17
+ entries: unknown[];
18
+ };
19
+ /** Runtime configuration scope. Project is the default when omitted. */
20
+ export type InstallScope = 'user' | 'project';
21
+ export interface InstallOptions {
22
+ /** Runtime config root. Runtime-specific user scopes may resolve their own home-anchored paths. */
23
+ configRoot: string;
24
+ /** Claude Code scope. Other runtimes may also use this to select project or user configuration. */
25
+ scope?: InstallScope;
26
+ /** The MCP server launch command registered in the runtime configuration. */
27
+ server: McpServerCmd;
28
+ /** Command the SessionStart hook runs to inject memory. */
29
+ hookCommand?: string;
30
+ /** Command the UserPromptSubmit hook runs. Default is local-only, default-off prompt recall. */
31
+ promptRecallHookCommand?: string;
32
+ /** Reinstall even if an Evolver install is already present. */
33
+ force?: boolean;
34
+ /** Plan and validate without writing config or backup files. */
35
+ dryRun?: boolean;
36
+ /** Cursor only: genes rendered into the managed project rules file. */
37
+ genes?: readonly CursorGene[];
38
+ /** Cursor only: cap on genes rendered into the always-on rules body. */
39
+ maxGenes?: number;
40
+ /** Antigravity only: override the user home used to resolve ~/.gemini config roots. */
41
+ homeDir?: string;
42
+ /** Kiro user scope only: direct replacement for ~/.kiro, matching KIRO_HOME semantics. */
43
+ kiroHome?: string;
44
+ /** OpenCode user scope only: explicit XDG_CONFIG_HOME used to resolve the global config. */
45
+ xdgConfigHome?: string;
46
+ /** OpenCode user scope only: explicit OPENCODE_CONFIG file override. */
47
+ opencodeConfig?: string;
48
+ /** OpenCode user scope only: explicit OPENCODE_CONFIG_DIR override. */
49
+ opencodeConfigDir?: string;
50
+ /** OpenCode only: inline config loaded after project/custom-directory config. */
51
+ opencodeConfigContent?: string;
52
+ /** OpenCode only: mirrors truthy OPENCODE_DISABLE_PROJECT_CONFIG handling. */
53
+ opencodeDisableProjectConfig?: boolean;
54
+ /** OpenCode only: injectable managed-config directory for hermetic tests. */
55
+ opencodeManagedConfigDir?: string;
56
+ /** OpenCode only: injectable macOS managed-preference paths for hermetic tests. */
57
+ opencodeManagedPreferencePaths?: readonly string[];
58
+ /** OpenCode only: injectable platform used to resolve system managed paths. */
59
+ opencodePlatform?: NodeJS.Platform;
60
+ /** OpenCode only: injectable ProgramData used to resolve the Windows managed path. */
61
+ opencodeProgramData?: string;
62
+ /** OpenCode only: injectable username used to resolve macOS managed preferences. */
63
+ opencodeUsername?: string;
64
+ }
65
+ export interface UninstallOptions {
66
+ configRoot: string;
67
+ scope?: InstallScope;
68
+ /** Antigravity only: override the user home used to resolve ~/.gemini config roots. */
69
+ homeDir?: string;
70
+ /** Kiro user scope only: direct replacement for ~/.kiro, matching KIRO_HOME semantics. */
71
+ kiroHome?: string;
72
+ /** Validate and report the uninstall without changing config or backup files. */
73
+ dryRun?: boolean;
74
+ /** OpenCode user scope only: explicit XDG_CONFIG_HOME used to resolve the global config. */
75
+ xdgConfigHome?: string;
76
+ /** OpenCode user scope only: explicit OPENCODE_CONFIG file override. */
77
+ opencodeConfig?: string;
78
+ /** OpenCode user scope only: explicit OPENCODE_CONFIG_DIR override. */
79
+ opencodeConfigDir?: string;
80
+ }
81
+ export interface InstallResult {
82
+ ok: boolean;
83
+ runtime: RuntimeId;
84
+ mode: string;
85
+ /** Absolute paths written (install) or cleaned (uninstall). */
86
+ files: string[];
87
+ alreadyInstalled?: boolean;
88
+ dryRun?: boolean;
89
+ verified?: boolean;
90
+ backups?: string[];
91
+ error?: string;
92
+ }
93
+ export declare class SymlinkRefusedError extends Error {
94
+ constructor(label: string, path: string);
95
+ }
96
+ export declare class UnparseableConfigError extends Error {
97
+ constructor(label: string, path: string, owner?: string);
98
+ }
99
+ export declare class EmptySharedConfigError extends Error {
100
+ constructor(label: string, path: string, owner?: string);
101
+ }
102
+ /** Refuse to read or write through a symlink at an adapter-owned path. */
103
+ export declare function assertNotSymlink(path: string, label: string): void;