@evomap/evolver-mcp 2.0.0-beta.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.
@@ -0,0 +1,513 @@
1
+ // Injection INSTALLER — the executor for planInjection (ported from v1 adapters/hookAdapter + claudeCode).
2
+ // planInjection() decides WHAT to wire (the .mcp.json config + that a SessionStart hook is needed); this
3
+ // module actually WRITES it to a runtime's config dir and can cleanly remove it. The marquee gap A#2: v2 had
4
+ // the plan but no executor, so "attach evolver to a real agent runtime" wasn't a thing you could run.
5
+ //
6
+ // MVP target = Claude Code (the hybrid the operator chose): register evolver as an MCP server in .mcp.json so
7
+ // the agent discovers evolver's tools, AND merge a SessionStart hook into .claude/settings.json so memory is
8
+ // pushed at session start (MCP alone can't push — the agent must pull). Hardened like v1: atomic writes
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,
11
+ // 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';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { util } from '@evomap/evolver-core';
17
+ import { planInjection } from './injection.js';
18
+ // codex installer lives in its own module (TOML, different config path) but plugs into the same install/uninstall
19
+ // dispatch below. The import cycle (codexInstaller imports SymlinkRefusedError + types back) is ESM-safe: both
20
+ // sides only reference the imported values inside function bodies, never at module-evaluation time.
21
+ import { installCodex, uninstallCodex } from './codexInstaller.js';
22
+ // cursor injection is a different mechanism again (a project rules file, not a config/MCP writer): it renders
23
+ // top genes into .cursor/rules/evolver.mdc. It plugs into the same install/uninstall dispatch below.
24
+ import { installCursorRules, uninstallCursorRules } from './cursorRulesInstaller.js';
25
+ /** Marks a config file as containing evolver-managed entries, so uninstall only removes what we added. */
26
+ export const MANAGED_MARKER = '_evolver_managed';
27
+ /** A hook entry is evolver-owned if any of its commands mention this — used to replace-not-duplicate on reinstall. */
28
+ const EVOLVER_HOOK_TAG = 'evolver';
29
+ /** Default command the SessionStart hook runs to render + print the memory injection. The `--hook-stdin` flag opts
30
+ * the entrypoint into reading the runtime's SessionStart JSON from stdin (to capture session_id, #205); only the
31
+ * installed hook sets it, so a manual `evolver inject session-start` never reads stdin. */
32
+ export const DEFAULT_HOOK_COMMAND = 'evolver inject session-start --hook-stdin';
33
+ const SHARED_USER_CONFIG_MODE = 0o600;
34
+ const SHARED_USER_DIR_MODE = 0o700;
35
+ const SHARED_USER_CONFIG_WRITE_RETRIES = 5;
36
+ export class SymlinkRefusedError extends Error {
37
+ constructor(label, path) {
38
+ 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.`);
39
+ this.name = 'SymlinkRefusedError';
40
+ }
41
+ }
42
+ /**
43
+ * Thrown when a SHARED user config (~/.claude.json or ~/.claude/settings.json) exists but does not parse as JSON.
44
+ * These files are Claude Code's own state (projects/oauthAccount/userID/history/settings),
45
+ * and user-scope install merges into them via a full-file atomic replace. The lenient readJson() returns {} on
46
+ * a parse failure, which would make the merge emit ONLY evolver's entry and silently WIPE the whole file — a
47
+ * realistic data-loss path because Claude Code writes these files non-atomically (a concurrent session can leave
48
+ * one truncated). For the shared-config read we therefore refuse instead of clobbering. Project-scoped
49
+ * .mcp.json/.claude/settings.json are evolver-owned, so their lenient fresh-start behavior stays unchanged.
50
+ */
51
+ export class UnparseableConfigError extends Error {
52
+ constructor(label, path) {
53
+ super(`[setup-hooks] refusing to overwrite ${label} (${path}): the file exists and is non-empty but is not valid JSON. This is Claude Code's own shared config; merging into it would replace the whole file and could wipe its contents (projects/oauthAccount/userID/history/settings). Fix or remove the corrupt file, then rerun.`);
54
+ this.name = 'UnparseableConfigError';
55
+ }
56
+ }
57
+ /**
58
+ * Thrown when a SHARED user config exists but is empty or whitespace-only. Claude Code writes these files with a
59
+ * truncating write, so present-empty can be a concurrent-write window rather than a fresh config.
60
+ */
61
+ export class EmptySharedConfigError extends Error {
62
+ constructor(label, path) {
63
+ super(`[setup-hooks] refusing to overwrite ${label} (${path}): the file exists but is empty or contains only whitespace. Claude Code 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 Claude Code finishes writing it.`);
64
+ this.name = 'EmptySharedConfigError';
65
+ }
66
+ }
67
+ // ── fs hardening ────────────────────────────────────────────────────────────
68
+ /** Refuse to read/write through a symlink at an adapter-owned path. Missing path is fine (install creates it). */
69
+ function assertNotSymlink(path, label) {
70
+ let st;
71
+ try {
72
+ st = lstatSync(path);
73
+ }
74
+ catch (e) {
75
+ if (e.code === 'ENOENT')
76
+ return;
77
+ throw e;
78
+ }
79
+ if (st.isSymbolicLink())
80
+ throw new SymlinkRefusedError(label, path);
81
+ }
82
+ function readJson(path) {
83
+ try {
84
+ if (!existsSync(path))
85
+ return {};
86
+ const raw = readFileSync(path, 'utf8').trim();
87
+ return raw ? JSON.parse(raw) : {};
88
+ }
89
+ catch {
90
+ return {}; // unparseable → start fresh (merge will re-add evolver entries)
91
+ }
92
+ }
93
+ /**
94
+ * Strict variant for SHARED user configs (Claude Code's own ~/.claude.json / ~/.claude/settings.json). Only
95
+ * ENOENT is treated as {} so a fresh user-scope install works. A present empty/whitespace file is refused because
96
+ * it can be Claude Code's truncating-write window; a present non-empty parse failure is refused because returning
97
+ * {} would make the subsequent full-file atomic write clobber Claude Code's state. Use this only for the
98
+ * shared-config read; project-scoped evolver-owned files keep the lenient readJson() above.
99
+ */
100
+ function readJsonStrictShared(path, label) {
101
+ return readJsonStrictSharedSnapshot(path, label).data;
102
+ }
103
+ function readJsonStrictSharedSnapshot(path, label) {
104
+ let raw;
105
+ try {
106
+ raw = readFileSync(path, 'utf8');
107
+ }
108
+ catch (e) {
109
+ if (e.code === 'ENOENT')
110
+ return { data: {}, raw: null };
111
+ throw e;
112
+ }
113
+ const trimmed = raw.trim();
114
+ if (!trimmed)
115
+ throw new EmptySharedConfigError(label, path);
116
+ try {
117
+ return { data: JSON.parse(trimmed), raw };
118
+ }
119
+ catch {
120
+ throw new UnparseableConfigError(label, path);
121
+ }
122
+ }
123
+ function readRawIfExists(path) {
124
+ try {
125
+ return readFileSync(path, 'utf8');
126
+ }
127
+ catch (e) {
128
+ if (e.code === 'ENOENT')
129
+ return null;
130
+ throw e;
131
+ }
132
+ }
133
+ function existingFileMode(path) {
134
+ try {
135
+ return statSync(path).mode & 0o777;
136
+ }
137
+ catch (e) {
138
+ if (e.code === 'ENOENT')
139
+ return undefined;
140
+ throw e;
141
+ }
142
+ }
143
+ function sharedUserConfigWriteMode(path) {
144
+ const existingMode = existingFileMode(path);
145
+ return existingMode === undefined ? SHARED_USER_CONFIG_MODE : existingMode & 0o700;
146
+ }
147
+ function ensureSharedUserClaudeDir(path) {
148
+ let st;
149
+ try {
150
+ st = lstatSync(path);
151
+ }
152
+ catch (e) {
153
+ if (e.code !== 'ENOENT')
154
+ throw e;
155
+ mkdirSync(path, { recursive: true, mode: SHARED_USER_DIR_MODE });
156
+ chmodSync(path, SHARED_USER_DIR_MODE);
157
+ return;
158
+ }
159
+ if (st.isSymbolicLink())
160
+ throw new SymlinkRefusedError('~/.claude', path);
161
+ if (!st.isDirectory()) {
162
+ mkdirSync(path, { recursive: true, mode: SHARED_USER_DIR_MODE });
163
+ return;
164
+ }
165
+ const hardenedMode = (st.mode & 0o777) & 0o700;
166
+ if (hardenedMode !== (st.mode & 0o777))
167
+ chmodSync(path, hardenedMode);
168
+ }
169
+ function hardenSharedUserConfigFile(path, label) {
170
+ let st;
171
+ try {
172
+ st = lstatSync(path);
173
+ }
174
+ catch (e) {
175
+ if (e.code === 'ENOENT')
176
+ return;
177
+ throw e;
178
+ }
179
+ if (st.isSymbolicLink())
180
+ throw new SymlinkRefusedError(label, path);
181
+ if (!st.isFile())
182
+ return;
183
+ const currentMode = st.mode & 0o777;
184
+ const hardenedMode = currentMode & 0o700;
185
+ if (hardenedMode !== currentMode)
186
+ chmodSync(path, hardenedMode);
187
+ }
188
+ function writeJsonAtomic(path, data, options = {}) {
189
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
190
+ const content = `${JSON.stringify(data, null, 2)}\n`;
191
+ const mode = options.mode ?? existingFileMode(path);
192
+ const restoreMode = process.platform === 'win32' ? mode : undefined;
193
+ try {
194
+ writeFileSync(tmp, content, mode === undefined
195
+ ? { encoding: 'utf8', flag: 'wx' }
196
+ : { encoding: 'utf8', flag: 'wx', mode });
197
+ if (mode !== undefined)
198
+ chmodSync(tmp, mode);
199
+ if (process.platform === 'win32' && mode !== undefined && existsSync(path)) {
200
+ chmodSync(path, mode | 0o200);
201
+ }
202
+ renameSync(tmp, path);
203
+ if (mode !== undefined)
204
+ chmodSync(path, mode);
205
+ }
206
+ catch (e) {
207
+ rmSync(tmp, { force: true });
208
+ if (restoreMode !== undefined) {
209
+ try {
210
+ if (existsSync(path))
211
+ chmodSync(path, restoreMode);
212
+ }
213
+ catch (rollbackError) {
214
+ e.rollbackError = rollbackError;
215
+ }
216
+ }
217
+ throw e;
218
+ }
219
+ }
220
+ let sharedConfigRaceHookForTest;
221
+ export function _setSharedConfigRaceHookForTest(hook) {
222
+ sharedConfigRaceHookForTest = hook;
223
+ }
224
+ function writeSharedJsonWithRetry(path, label, update) {
225
+ const lockPath = `${path}.evolver.lock`;
226
+ util.acquireLock(lockPath);
227
+ try {
228
+ for (let attempt = 1; attempt <= SHARED_USER_CONFIG_WRITE_RETRIES; attempt++) {
229
+ const snapshot = readJsonStrictSharedSnapshot(path, label);
230
+ const next = update(snapshot.data);
231
+ if (!next.changed)
232
+ return false;
233
+ sharedConfigRaceHookForTest?.(path, attempt);
234
+ if (readRawIfExists(path) !== snapshot.raw)
235
+ continue;
236
+ writeJsonAtomic(path, next.data, { mode: sharedUserConfigWriteMode(path) });
237
+ return true;
238
+ }
239
+ }
240
+ finally {
241
+ util.releaseLock(lockPath);
242
+ }
243
+ 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.`);
244
+ }
245
+ // ── pure merge (exported for tests) ──────────────────────────────────────────
246
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
247
+ // Prototype-pollution guard (#201): keys that, if merged via bracket assignment, can poison Object.prototype or a
248
+ // constructor. deepMerge writes config files and is an exported util — skip these regardless of caller trust so a
249
+ // future deepMerge(trusted, untrustedSource) can never pollute. JSON.parse/toml-parse surface __proto__ as an own key.
250
+ const POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
251
+ export function deepMerge(target, source) {
252
+ const out = { ...target };
253
+ for (const k of Object.keys(source)) {
254
+ if (POLLUTION_KEYS.has(k))
255
+ continue; // never merge a prototype-pollution key
256
+ const s = source[k];
257
+ const t = out[k];
258
+ out[k] = isObj(s) && isObj(t) ? deepMerge(t, s) : s;
259
+ }
260
+ return out;
261
+ }
262
+ function collectCommands(entry) {
263
+ if (!isObj(entry))
264
+ return [];
265
+ const out = [];
266
+ if (typeof entry['command'] === 'string')
267
+ out.push(entry['command']);
268
+ const inner = entry['hooks'];
269
+ if (Array.isArray(inner))
270
+ for (const h of inner)
271
+ if (isObj(h) && typeof h['command'] === 'string')
272
+ out.push(h['command']);
273
+ return out;
274
+ }
275
+ const isEvolverOwned = (entry) => collectCommands(entry).some((c) => c.includes(EVOLVER_HOOK_TAG));
276
+ /**
277
+ * deepMerge, but for `hooks.<event>` arrays keep the user's existing entries and only replace evolver-owned
278
+ * ones — so reinstalling refreshes evolver's hook without clobbering a user's own SessionStart/Stop hooks.
279
+ */
280
+ export function mergeHooksUnion(target, source) {
281
+ const result = deepMerge(target, source);
282
+ const tHooks = target['hooks'];
283
+ const sHooks = source['hooks'];
284
+ if (isObj(tHooks) && isObj(sHooks)) {
285
+ const merged = { ...(isObj(result['hooks']) ? result['hooks'] : {}) };
286
+ for (const event of Object.keys(sHooks)) {
287
+ if (POLLUTION_KEYS.has(event))
288
+ continue; // same guard for the hooks-union branch
289
+ const tArr = tHooks[event];
290
+ const sArr = sHooks[event];
291
+ if (Array.isArray(tArr) && Array.isArray(sArr)) {
292
+ merged[event] = [...tArr.filter((e) => !isEvolverOwned(e)), ...sArr];
293
+ }
294
+ }
295
+ result['hooks'] = merged;
296
+ }
297
+ return result;
298
+ }
299
+ /** Strip evolver-owned hook entries + the marker from a parsed config (uninstall). Returns [changed, data]. */
300
+ export function stripManaged(data) {
301
+ let changed = false;
302
+ const out = { ...data };
303
+ const hooks = out['hooks'];
304
+ if (isObj(hooks)) {
305
+ const nextHooks = {};
306
+ for (const event of Object.keys(hooks)) {
307
+ const arr = hooks[event];
308
+ if (Array.isArray(arr)) {
309
+ const kept = arr.filter((e) => !isEvolverOwned(e));
310
+ if (kept.length !== arr.length)
311
+ changed = true;
312
+ if (kept.length > 0)
313
+ nextHooks[event] = kept;
314
+ }
315
+ else {
316
+ nextHooks[event] = arr;
317
+ }
318
+ }
319
+ if (Object.keys(nextHooks).length > 0)
320
+ out['hooks'] = nextHooks;
321
+ else {
322
+ delete out['hooks'];
323
+ }
324
+ }
325
+ // remove evolver MCP server registration
326
+ const mcp = out['mcpServers'];
327
+ if (isObj(mcp) && 'evolver' in mcp) {
328
+ const next = { ...mcp };
329
+ delete next['evolver'];
330
+ changed = true;
331
+ if (Object.keys(next).length > 0)
332
+ out['mcpServers'] = next;
333
+ else {
334
+ delete out['mcpServers'];
335
+ }
336
+ }
337
+ if (MANAGED_MARKER in out) {
338
+ delete out[MANAGED_MARKER];
339
+ changed = changed || true;
340
+ }
341
+ return { changed, data: out };
342
+ }
343
+ function sessionStartHookPatch(hookCommand) {
344
+ return { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: hookCommand }] }] } };
345
+ }
346
+ /** True when a parsed config already carries evolver's MCP registration (mcpServers.evolver) — the same entry
347
+ * stripManaged removes on uninstall. The "already installed" short-circuit checks this in addition to the hook
348
+ * marker so a user-scope upgrade is not declared complete off the settings marker alone while the MCP entry is
349
+ * still missing (the hook and the MCP live in DIFFERENT files for user scope, so a legacy global install left
350
+ * ~/.claude/settings.json marked but ~/.claude.json without mcpServers.evolver — #290 would stay unfixed). */
351
+ function hasEvolverMcpRegistration(config) {
352
+ const mcp = config['mcpServers'];
353
+ return isObj(mcp) && 'evolver' in mcp;
354
+ }
355
+ /**
356
+ * Resolve the claude-code config targets for a scope. USER scope anchors at the home dir and is self-contained
357
+ * (it does NOT depend on configRoot): the MCP goes to ~/.claude.json's top-level mcpServers — Claude Code's real
358
+ * user scope — NOT ~/.mcp.json (a `.mcp.json` only loads when its dir is the launch cwd, so `~/.mcp.json` would
359
+ * silently do nothing outside $HOME). The SessionStart hook lands in ~/.claude/settings.json (already user-level).
360
+ * PROJECT scope keeps the legacy paths under configRoot.
361
+ */
362
+ function claudeCodeTargets(scope, configRoot) {
363
+ if (scope === 'user') {
364
+ const claudeDir = join(homedir(), '.claude');
365
+ return {
366
+ mcpConfigPath: join(homedir(), '.claude.json'),
367
+ mcpIsSharedUserConfig: true,
368
+ claudeDir,
369
+ settingsPath: join(claudeDir, 'settings.json'),
370
+ };
371
+ }
372
+ const claudeDir = join(configRoot, '.claude');
373
+ return {
374
+ mcpConfigPath: join(configRoot, '.mcp.json'),
375
+ mcpIsSharedUserConfig: false,
376
+ claudeDir,
377
+ settingsPath: join(claudeDir, 'settings.json'),
378
+ };
379
+ }
380
+ // ── install / uninstall ───────────────────────────────────────────────────────
381
+ /**
382
+ * Execute an InjectionPlan against a runtime config root. Active runtimes:
383
+ * - claude-code (mcp-hooks): scope 'project' (default) writes/merges <root>/.mcp.json + <root>/.claude/settings.json;
384
+ * scope 'user' registers the MCP in ~/.claude.json (real user scope) + the SessionStart hook in ~/.claude/settings.json.
385
+ * - codex (mcp-plugin): writes/merges <root>/.codex/config.toml — [mcp_servers.evolver] + [[hooks.SessionStart]]
386
+ * (delegated to codexInstaller; TOML, not JSON). Same hybrid value (tool discovery + session-start injection).
387
+ * - cursor (cursor-rules): renders top genes into <root>/.cursor/rules/evolver.mdc (alwaysApply:true) — gene
388
+ * memory injection, not MCP tool discovery (delegated to cursorRulesInstaller). The daemon keeps it fresh.
389
+ * Idempotent + symlink-safe; passive runtimes (kiro/opencode) return ok:false (nothing to inject).
390
+ */
391
+ export function installInjection(plan, opts) {
392
+ if (plan.mode === 'passive') {
393
+ return { ok: false, runtime: plan.runtime, mode: plan.mode, files: [], error: `passive runtime ${plan.runtime}: no tool injection` };
394
+ }
395
+ if (plan.runtime === 'codex') {
396
+ return installCodex(plan, opts);
397
+ }
398
+ if (plan.runtime === 'cursor') {
399
+ return installCursorRules({ configRoot: opts.configRoot, genes: opts.genes ?? [], ...(opts.maxGenes !== undefined ? { maxGenes: opts.maxGenes } : {}) });
400
+ }
401
+ if (plan.runtime !== 'claude-code') {
402
+ // kiro/opencode are passive (handled above); any other active runtime is not yet ported.
403
+ return { ok: false, runtime: plan.runtime, mode: plan.mode, files: [], error: `installer not yet implemented for ${plan.runtime} (supported: claude-code, codex, cursor)` };
404
+ }
405
+ const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
406
+ const scope = opts.scope ?? 'project';
407
+ const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
408
+ const mcpLabel = mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json';
409
+ const settingsLabel = mcpIsSharedUserConfig ? '~/.claude/settings.json' : '.claude/settings.json';
410
+ // project scope owns configRoot; user scope writes only home-anchored paths, so configRoot is irrelevant there.
411
+ if (scope === 'project')
412
+ assertNotSymlink(opts.configRoot, 'config root');
413
+ assertNotSymlink(mcpConfigPath, mcpLabel);
414
+ assertNotSymlink(claudeDir, mcpIsSharedUserConfig ? '~/.claude' : '.claude');
415
+ assertNotSymlink(settingsPath, settingsLabel);
416
+ // For the SHARED user config (~/.claude.json + ~/.claude/settings.json) read strictly: a present, non-empty,
417
+ // unparseable file aborts the install (UnparseableConfigError) instead of being treated as {} and clobbered by
418
+ // the full-file atomic write below. Project-scoped evolver-owned files keep the lenient readJson fresh-start.
419
+ const readConfig = mcpIsSharedUserConfig
420
+ ? (p, label) => readJsonStrictShared(p, label)
421
+ : (p, _label) => readJson(p);
422
+ if (mcpIsSharedUserConfig) {
423
+ ensureSharedUserClaudeDir(claudeDir);
424
+ hardenSharedUserConfigFile(mcpConfigPath, mcpLabel);
425
+ hardenSharedUserConfigFile(settingsPath, settingsLabel);
426
+ }
427
+ const existingSettings = readConfig(settingsPath, settingsLabel);
428
+ const existingMcp = readConfig(mcpConfigPath, mcpLabel);
429
+ // "Already installed" requires BOTH the SessionStart hook marker AND evolver's MCP registration. Keying off the
430
+ // settings marker alone missed user-scope upgrades: a legacy global install stamped ~/.claude/settings.json but
431
+ // registered the MCP in ~/.mcp.json (never ~/.claude.json), so a non-force reinstall returned alreadyInstalled
432
+ // and left #290 unfixed. The hook and the MCP live in different files for user scope, so check both.
433
+ if (!opts.force && existingSettings[MANAGED_MARKER] === true && hasEvolverMcpRegistration(existingMcp)) {
434
+ return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
435
+ }
436
+ // MCP server registration. project → <root>/.mcp.json (stamped _evolver_managed). user → ~/.claude.json's
437
+ // top-level mcpServers (Claude Code's real user scope); we do NOT stamp the marker into ~/.claude.json because
438
+ // it's Claude Code's own shared config, so uninstall keys off the mcpServers.evolver entry there instead.
439
+ if (mcpIsSharedUserConfig) {
440
+ writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => ({
441
+ changed: true,
442
+ data: deepMerge(current, plan.config),
443
+ }));
444
+ }
445
+ else {
446
+ const mcpMerged = deepMerge(existingMcp, plan.config);
447
+ mcpMerged[MANAGED_MARKER] = true;
448
+ writeJsonAtomic(mcpConfigPath, mcpMerged);
449
+ }
450
+ // .claude/settings.json ← SessionStart hook (hooks-union preserves the user's own hooks). For user scope this
451
+ // is ~/.claude/settings.json, which is already Claude Code's user-level hook config.
452
+ if (mcpIsSharedUserConfig)
453
+ ensureSharedUserClaudeDir(claudeDir);
454
+ else
455
+ mkdirSync(claudeDir, { recursive: true });
456
+ if (mcpIsSharedUserConfig) {
457
+ writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
458
+ const settingsMerged = mergeHooksUnion(current, sessionStartHookPatch(hookCommand));
459
+ settingsMerged[MANAGED_MARKER] = true;
460
+ return { changed: true, data: settingsMerged };
461
+ });
462
+ }
463
+ else {
464
+ const settingsMerged = mergeHooksUnion(existingSettings, sessionStartHookPatch(hookCommand));
465
+ settingsMerged[MANAGED_MARKER] = true;
466
+ writeJsonAtomic(settingsPath, settingsMerged);
467
+ }
468
+ return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [mcpConfigPath, settingsPath] };
469
+ }
470
+ /** Remove evolver's MCP registration + SessionStart hook from a CC config root (leaves user content intact).
471
+ * Pass the SAME scope used at install: 'user' cleans ~/.claude.json + ~/.claude/settings.json; 'project'
472
+ * (default) cleans <configRoot>/.mcp.json + <configRoot>/.claude/settings.json. stripManaged only removes
473
+ * the mcpServers.evolver entry (and any evolver-owned hooks/marker), so it is safe on the shared ~/.claude.json. */
474
+ export function uninstallInjection(runtime, opts) {
475
+ if (runtime === 'codex') {
476
+ return uninstallCodex(runtime, opts);
477
+ }
478
+ if (runtime === 'cursor') {
479
+ return uninstallCursorRules(opts);
480
+ }
481
+ if (runtime !== 'claude-code') {
482
+ return { ok: false, runtime, mode: 'n/a', files: [], error: `uninstall not implemented for ${runtime}` };
483
+ }
484
+ const scope = opts.scope ?? 'project';
485
+ const { mcpConfigPath, mcpIsSharedUserConfig, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
486
+ const cleaned = [];
487
+ const targets = [
488
+ [mcpConfigPath, mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json'],
489
+ [settingsPath, mcpIsSharedUserConfig ? '~/.claude/settings.json' : '.claude/settings.json'],
490
+ ];
491
+ for (const [path, label] of targets) {
492
+ assertNotSymlink(path, label);
493
+ if (!existsSync(path))
494
+ continue;
495
+ if (mcpIsSharedUserConfig) {
496
+ const changed = writeSharedJsonWithRetry(path, label, (current) => stripManaged(current));
497
+ if (changed)
498
+ cleaned.push(path);
499
+ }
500
+ else {
501
+ const { changed, data } = stripManaged(readJson(path));
502
+ if (changed) {
503
+ writeJsonAtomic(path, data);
504
+ cleaned.push(path);
505
+ }
506
+ }
507
+ }
508
+ return { ok: true, runtime, mode: 'uninstall', files: cleaned };
509
+ }
510
+ /** Convenience: plan + install in one call for a runtime. */
511
+ export function setupRuntime(runtime, opts) {
512
+ return installInjection(planInjection(runtime, opts.server), opts);
513
+ }
@@ -0,0 +1,14 @@
1
+ import type { McpServerCmd, SetupRuntime } from './injection.js';
2
+ export interface ManualWiringContext {
3
+ /** The evolver MCP stdio server launch command — the SAME one the installed runtimes register. Its `env` may
4
+ * carry an `EVOLVER_ENV_FILE` pointer (when the operator passed --env-file); no secrets ever live here. */
5
+ server: McpServerCmd;
6
+ /** Adapter-supplied wiring lines (e.g. the PrivateHub HTTP/A2A endpoint for http-agent). v2 prints them
7
+ * verbatim and never hardcodes hub policy itself. */
8
+ hints?: readonly string[];
9
+ }
10
+ /**
11
+ * Render the manual wiring instructions for a `manual`-class runtime. Returns a multi-line, copy-pasteable block.
12
+ * Pure given its inputs. The caller (setup-hooks) only invokes this when runtimeSupport(...) === 'manual'.
13
+ */
14
+ export declare function renderManualWiring(runtime: SetupRuntime, ctx: ManualWiringContext): string;
@@ -0,0 +1,91 @@
1
+ const ENV_FILE_KEY = 'EVOLVER_ENV_FILE';
2
+ /** The `.mcp.json`-shaped registration block a generic MCP client needs — the same shape planInjection writes for
3
+ * claude-code, rendered as a copyable JSON snippet. Only the EVOLVER_ENV_FILE pointer ever appears under env. */
4
+ function mcpServerSnippet(server) {
5
+ const reg = { command: server.command, args: server.args ?? [] };
6
+ if (server.env && Object.keys(server.env).length > 0)
7
+ reg['env'] = server.env;
8
+ return JSON.stringify({ mcpServers: { evolver: reg } }, null, 2);
9
+ }
10
+ /** A one-line note about how credentials are referenced — either "already pointed at <path>" or a tip to pass
11
+ * --env-file. Never prints a secret, only the pointer key / path. */
12
+ function envNote(server) {
13
+ // Wording is snippet-agnostic: http-agent / server render NO MCP snippet, so the note must not say "the snippet"
14
+ // (Bugbot #266). It only states how credentials are referenced — via the EVOLVER_ENV_FILE pointer, never a token.
15
+ const path = server.env?.[ENV_FILE_KEY];
16
+ return path
17
+ ? `Credentials are referenced via ${ENV_FILE_KEY}=${path} (a pointer; the secret value is never exposed here).`
18
+ : `Tip: pass --env-file=<path> to reference your private credential store via ${ENV_FILE_KEY} (never inline tokens).`;
19
+ }
20
+ function hintBlock(hints) {
21
+ return hints && hints.length > 0 ? `\nAdapter notes:\n${hints.map((h) => ` - ${h}`).join('\n')}` : '';
22
+ }
23
+ /**
24
+ * Render the manual wiring instructions for a `manual`-class runtime. Returns a multi-line, copy-pasteable block.
25
+ * Pure given its inputs. The caller (setup-hooks) only invokes this when runtimeSupport(...) === 'manual'.
26
+ */
27
+ export function renderManualWiring(runtime, ctx) {
28
+ const snippet = mcpServerSnippet(ctx.server);
29
+ const env = envNote(ctx.server);
30
+ const hints = hintBlock(ctx.hints);
31
+ switch (runtime) {
32
+ case 'mcp-generic':
33
+ return [
34
+ 'manual: mcp-generic — evolver does not write this client config. Two steps wire the FULL self-learning loop:',
35
+ '',
36
+ "1. TOOLS. Add the evolver server to your MCP client's server map (exposes evolver_recall / search / distill / reuse-result):",
37
+ snippet,
38
+ '',
39
+ '2. LOOP. So evolver also LEARNS from this agent (not just serves it), close the observe side:',
40
+ ' - point the evolver daemon at a transcript dir and run it:',
41
+ ' EVOLVER_SESSION_DIRS=<dir> EVOLVER_AUTO_RECALL=1 evolver autoexec',
42
+ ' - have the agent write its session transcript into <dir> as <name>.chat.jsonl (standard OpenAI/Anthropic',
43
+ ' messages — the generic-chat adapter reads it; auto-distill turns it into reusable genes);',
44
+ ' - when approved local memory is likely to help, call evolver_recall with sessionId set to the transcript filename WITHOUT the .jsonl suffix',
45
+ ' (e.g. "run-1.chat" for run-1.chat.jsonl — the basename evolver derives), so the primed genes tie to that',
46
+ ' transcript and auto-recall can observe which ones you actually used.',
47
+ '',
48
+ env,
49
+ hints,
50
+ ].filter(Boolean).join('\n');
51
+ case 'opencode':
52
+ case 'kiro':
53
+ return [
54
+ `manual: ${runtime} — evolver consumes ${runtime} sessions passively today and does not write its config.`,
55
+ 'For MCP tool discovery, register the evolver MCP server by hand:',
56
+ snippet,
57
+ '',
58
+ `${runtime} has no SessionStart-hook hybrid, so this wires tool discovery only (no automatic memory injection).`,
59
+ env,
60
+ hints,
61
+ ].filter(Boolean).join('\n');
62
+ case 'openclaw':
63
+ return [
64
+ 'manual: openclaw — no v2 auto-installer yet. Preferred: register the evolver MCP server:',
65
+ snippet,
66
+ '',
67
+ 'Alternatively, point an HTTP/A2A client at PrivateHub (endpoint supplied by your enterprise adapter).',
68
+ env,
69
+ hints,
70
+ ].filter(Boolean).join('\n');
71
+ case 'http-agent':
72
+ return [
73
+ 'manual: http-agent — evolver does not configure an HTTP/API-only agent. Wire it to evolver/PrivateHub over HTTP/A2A.',
74
+ `Keep credentials in your private env file and reference them via ${ENV_FILE_KEY}; never inline tokens in the agent config.`,
75
+ ctx.hints && ctx.hints.length > 0 ? '' : 'The specific endpoint/headers are supplied by your enterprise adapter.',
76
+ env,
77
+ hints,
78
+ ].filter(Boolean).join('\n');
79
+ case 'server':
80
+ return [
81
+ 'manual: server — evolver does not manage service lifecycle.',
82
+ `Run the evolver process under your service manager with ${ENV_FILE_KEY} set to your credential store.`,
83
+ 'For a ready-to-edit template, run: evolver setup-hooks --runtime=server --service=launchd|systemd|windows|compose|k8s',
84
+ env,
85
+ hints,
86
+ ].filter(Boolean).join('\n');
87
+ default:
88
+ // claude-code / codex / cursor are installed-class — they should never reach here, but stay honest if they do.
89
+ return `manual wiring is not applicable to ${runtime} (it is auto-installed; run setup-hooks without treating it as manual).`;
90
+ }
91
+ }
@@ -0,0 +1,12 @@
1
+ export interface PrimerOptions {
2
+ /** Whether a local evolver-proxy → PrivateHub link is wired (mirrors how the tool descriptions branch). When
3
+ * true the loop includes the hub-only steps (reuse-result reporting, pre-publish dry-run validate). */
4
+ proxy?: boolean;
5
+ }
6
+ /**
7
+ * Build the evolver mechanism primer: a short, quiet-by-default description of the recall/search -> reuse -> report -> capture
8
+ * loop, anchored to the exact tool names so the model can map each step onto a tool in tools/list. Adapts to the
9
+ * wired capabilities so it never tells the agent to call a tool that is not present (reuse-result / validate are
10
+ * proxy-only). Deterministic given its options.
11
+ */
12
+ export declare function buildEvolverPrimer(opts?: PrimerOptions): string;
package/dist/primer.js ADDED
@@ -0,0 +1,32 @@
1
+ // Mechanism primer (#mcp-onboarding) — the runtime-agnostic explanation of how to reuse Evolver memory without
2
+ // turning routine checks into user-visible chatter. It is surfaced through initialize.instructions and the explicit
3
+ // evolver_guide tool. PURE: a function of the wired capabilities, no IO.
4
+ /**
5
+ * Build the evolver mechanism primer: a short, quiet-by-default description of the recall/search -> reuse -> report -> capture
6
+ * loop, anchored to the exact tool names so the model can map each step onto a tool in tools/list. Adapts to the
7
+ * wired capabilities so it never tells the agent to call a tool that is not present (reuse-result / validate are
8
+ * proxy-only). Deterministic given its options.
9
+ */
10
+ export function buildEvolverPrimer(opts = {}) {
11
+ const proxy = opts.proxy === true;
12
+ const searchWhere = proxy ? 'shared experience on the hub' : 'your local experience store';
13
+ const publishStep = proxy
14
+ ? 'dry-run validate it (evolver_asset_validate), then publish (evolver_asset_publish).'
15
+ : 'then publish it (evolver_asset_publish).';
16
+ const lines = [
17
+ 'Evolver gives this agent reusable memory of past solutions (genes and capsules). Use it quietly when prior experience is likely to help:',
18
+ '',
19
+ '1. PRIME OR SEARCH WHEN USEFUL. For clear error text, repeated workflows, or substantial tasks, look for prior experience:',
20
+ ' - call evolver_recall when approved local genes are likely to help;',
21
+ ` - call evolver_asset_search with concise key signals or error text to search ${searchWhere};`,
22
+ ' - if a candidate fits, call evolver_asset_fetch and reuse only the parts that apply.',
23
+ ];
24
+ if (proxy) {
25
+ lines.push('2. REPORT REAL REUSE. After a fetched asset materially affects the solution, call evolver_asset_reuse_result', ' (success / failed / mismatched / stale / unsafe) so the memory learns what is worth keeping.', '3. CAPTURE VERIFIED LEARNING. When you solve something non-trivial and have VERIFIED it, distill it for the next agent:');
26
+ }
27
+ else {
28
+ lines.push('2. CAPTURE VERIFIED LEARNING. When you solve something non-trivial and have VERIFIED it, distill it for the next agent:');
29
+ }
30
+ lines.push(' - evolver_distill_conversation with a concrete summary + strategy + evidence + validation (weak signals are rejected);', ` - or build it yourself (evolver_gep_build), ${publishStep}`, '', 'The mechanism is: recall/search -> reuse -> capture. Do not narrate routine Evolver status, preflight, or empty search results to the user; mention Evolver only when the user asks, a reused asset materially changes the answer, or a blocker matters. Only capture what you actually verified; never publish secrets.');
31
+ return lines.join('\n');
32
+ }