@evomap/evolver 1.88.1 → 1.88.3

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 (66) hide show
  1. package/index.js +159 -3
  2. package/package.json +2 -1
  3. package/src/adapters/claudeCode.js +21 -1
  4. package/src/adapters/hookAdapter.js +4 -2
  5. package/src/adapters/scripts/_runtimePaths.js +160 -36
  6. package/src/adapters/scripts/evolver-session-start.js +14 -10
  7. package/src/adapters/scripts/evolver-task-recall.js +173 -0
  8. package/src/atp/atpExecute.js +20 -7
  9. package/src/atp/cli.js +17 -9
  10. package/src/atp/protocol.js +41 -0
  11. package/src/config.js +29 -0
  12. package/src/evolve/guards.js +1 -1
  13. package/src/evolve/pipeline/collect.js +1 -1
  14. package/src/evolve/pipeline/dispatch.js +1 -1
  15. package/src/evolve/pipeline/enrich.js +1 -1
  16. package/src/evolve/pipeline/hub.js +1 -1
  17. package/src/evolve/pipeline/select.js +1 -1
  18. package/src/evolve/pipeline/signals.js +1 -1
  19. package/src/evolve/utils.js +1 -1
  20. package/src/evolve.js +1 -1
  21. package/src/forceUpdate.js +108 -3
  22. package/src/gep/a2aProtocol.js +1 -1
  23. package/src/gep/assetCallLog.js +40 -1
  24. package/src/gep/autoDistillConv.js +1 -1
  25. package/src/gep/autoDistillLlm.js +1 -1
  26. package/src/gep/candidateEval.js +1 -1
  27. package/src/gep/candidates.js +1 -1
  28. package/src/gep/contentHash.js +1 -1
  29. package/src/gep/conversationSniffer.js +1 -1
  30. package/src/gep/crypto.js +1 -1
  31. package/src/gep/curriculum.js +1 -1
  32. package/src/gep/deviceId.js +1 -1
  33. package/src/gep/envFingerprint.js +1 -1
  34. package/src/gep/epigenetics.js +1 -1
  35. package/src/gep/execBridge.js +1 -1
  36. package/src/gep/explore.js +1 -1
  37. package/src/gep/hash.js +1 -1
  38. package/src/gep/hubFetch.js +1 -1
  39. package/src/gep/hubReview.js +1 -1
  40. package/src/gep/hubSearch.js +1 -1
  41. package/src/gep/hubVerify.js +1 -1
  42. package/src/gep/learningSignals.js +1 -1
  43. package/src/gep/memoryGraph.js +1 -1
  44. package/src/gep/memoryGraphAdapter.js +1 -1
  45. package/src/gep/mutation.js +1 -1
  46. package/src/gep/narrativeMemory.js +1 -1
  47. package/src/gep/openPRRegistry.js +1 -1
  48. package/src/gep/personality.js +1 -1
  49. package/src/gep/policyCheck.js +1 -1
  50. package/src/gep/prompt.js +1 -1
  51. package/src/gep/recallInject.js +1 -0
  52. package/src/gep/recallVerifier.js +1 -1
  53. package/src/gep/reflection.js +1 -1
  54. package/src/gep/sanitize.js +5 -0
  55. package/src/gep/selector.js +1 -1
  56. package/src/gep/skillDistiller.js +1 -1
  57. package/src/gep/solidify.js +1 -1
  58. package/src/gep/strategy.js +1 -1
  59. package/src/gep/workspaceKeychain.js +1 -1
  60. package/src/proxy/extensions/traceControl.js +1 -0
  61. package/src/proxy/index.js +46 -4
  62. package/src/proxy/inject.js +1 -0
  63. package/src/proxy/lifecycle/manager.js +457 -2
  64. package/src/proxy/mailbox/store.js +1 -0
  65. package/src/proxy/router/messages_route.js +57 -8
  66. package/src/proxy/trace/extractor.js +1 -0
package/index.js CHANGED
@@ -1185,6 +1185,17 @@ async function main() {
1185
1185
  hubUrl: process.env.A2A_HUB_URL,
1186
1186
  });
1187
1187
  console.log('[Proxy] Started on ' + proxyInfo.url);
1188
+ try {
1189
+ const { injectProxyEnv } = require('./src/proxy/inject');
1190
+ const injected = injectProxyEnv(proxyInfo);
1191
+ if (injected.injected) {
1192
+ console.log('[Proxy] Auto-injected client env for Claude Code/Codex/Cursor. Set EVOMAP_PROXY_AUTO_INJECT=off to disable.');
1193
+ } else {
1194
+ console.log('[Proxy] Auto-inject skipped: ' + injected.reason);
1195
+ }
1196
+ } catch (injectErr) {
1197
+ console.warn('[Proxy] Auto-inject failed: ' + (injectErr && injectErr.message || injectErr));
1198
+ }
1188
1199
  const { registerMailboxTransport } = require('./src/gep/mailboxTransport');
1189
1200
  registerMailboxTransport();
1190
1201
  process.env.A2A_TRANSPORT = 'mailbox';
@@ -1760,8 +1771,8 @@ async function main() {
1760
1771
  console.error('[exec] --max-cycles requires a value (e.g. --max-cycles 5 or --max-cycles=5)');
1761
1772
  process.exit(2);
1762
1773
  }
1763
- if (!['claude-code', 'openclaw'].includes(harness)) {
1764
- console.error(`[exec] unknown --harness '${harness}' (expected claude-code | openclaw)`);
1774
+ if (!['claude-code', 'openclaw', 'codex', 'opencode'].includes(harness)) {
1775
+ console.error(`[exec] unknown --harness '${harness}' (expected claude-code | openclaw | codex | opencode)`);
1765
1776
  process.exit(2);
1766
1777
  }
1767
1778
  try {
@@ -2763,8 +2774,153 @@ async function main() {
2763
2774
  process.exit(1);
2764
2775
  }
2765
2776
 
2777
+ } else if (command === 'recipe') {
2778
+ // recipe build — assemble a DNA blueprint from owned Gene/Capsule assets
2779
+ // recipe reuse — fetch + express an existing recipe into an organism
2780
+ const sub = args[1];
2781
+ const {
2782
+ getHubUrl, getNodeId, getHubNodeSecret, sendHelloToHub, rotateNodeSecret,
2783
+ hubCreateRecipe, hubPublishRecipe, hubGetRecipe, hubExpressRecipe,
2784
+ } = require('./src/gep/a2aProtocol');
2785
+
2786
+ const hubUrl = getHubUrl();
2787
+ if (!hubUrl) {
2788
+ console.error('[recipe] A2A_HUB_URL is not configured. Set A2A_HUB_URL (e.g. https://evomap.ai).');
2789
+ process.exit(1);
2790
+ }
2791
+
2792
+ function flagVal(name) {
2793
+ const eq = args.find(a => typeof a === 'string' && a.startsWith('--' + name + '='));
2794
+ return eq ? eq.split('=').slice(1).join('=') : null;
2795
+ }
2796
+ async function ensureRegistered(tag) {
2797
+ if (!getHubNodeSecret()) {
2798
+ console.log('[' + tag + '] No node_secret found. Registering with Hub...');
2799
+ const hello = await sendHelloToHub();
2800
+ if (!hello || !hello.ok) {
2801
+ console.error('[' + tag + '] Failed to register with Hub:', (hello && hello.error) || 'unknown');
2802
+ process.exit(1);
2803
+ }
2804
+ console.log('[' + tag + '] Registered as ' + getNodeId());
2805
+ }
2806
+ }
2807
+ // True when the hub rejected our node_secret as stale/invalid — the one
2808
+ // case where a rotate-and-retry is the documented recovery.
2809
+ function isStaleSecret(result) {
2810
+ if (!result || result.ok) return false;
2811
+ if (result.status !== 401 && result.status !== 403) return false;
2812
+ const e = String(result.error || '');
2813
+ return e.includes('node_secret_invalid') || e.includes('node_secret_not_set');
2814
+ }
2815
+ // Run a hub call; if it fails because our node_secret is stale, rotate
2816
+ // once and retry. Rotation only works when the CURRENT secret is still
2817
+ // server-valid (the hub authenticates the rotate with it). If the secret
2818
+ // has fully diverged from the server, rotation cannot recover it — that
2819
+ // requires re-registering, so we surface the actionable recovery path
2820
+ // instead of silently looping.
2821
+ let _authRecoveryFailed = false;
2822
+ async function callWithAuthRetry(tag, fn) {
2823
+ let result = await fn();
2824
+ if (isStaleSecret(result) && typeof rotateNodeSecret === 'function' && !_authRecoveryFailed) {
2825
+ console.log('[' + tag + '] node_secret stale; rotating via /a2a/hello and retrying...');
2826
+ const rot = await rotateNodeSecret();
2827
+ if (rot && rot.ok) {
2828
+ result = await fn();
2829
+ } else {
2830
+ _authRecoveryFailed = true;
2831
+ console.error('[' + tag + '] Could not auto-rotate: the local node_secret has diverged from the Hub and can no longer authenticate a rotate.');
2832
+ console.error(' Recover by either:');
2833
+ console.error(' 1. Reset Secret on the web (Account -> Reset Secret), then run: node index.js reset-local-secret');
2834
+ console.error(' 2. Or register a fresh node: set a new A2A_NODE_ID and retry (auto-provisions).');
2835
+ }
2836
+ }
2837
+ return result;
2838
+ }
2839
+ function reportHubError(tag, result) {
2840
+ console.error('[' + tag + '] Hub call failed' + (result.status ? ' (HTTP ' + result.status + ')' : '') + ': ' + (result.error || 'unknown'));
2841
+ if (result.status === 401 || result.status === 403) console.error(' Auth failed. If this persists, delete ~/.evomap/node_secret and retry.');
2842
+ else if (result.status === 402) console.error(' Insufficient credits. Check your balance at ' + hubUrl);
2843
+ }
2844
+
2845
+ if (sub === 'build') {
2846
+ // --genes=<asset_id,...> ordered; types resolved from the local asset store.
2847
+ const genesArg = flagVal('genes');
2848
+ const title = flagVal('title');
2849
+ const description = flagVal('description');
2850
+ const doPublish = args.includes('--publish');
2851
+ if (!genesArg || !title) {
2852
+ console.error('Usage: node index.js recipe build --title="..." --genes=<asset_id,...> [--description="..."] [--price=N] [--publish]');
2853
+ console.error(' Builds a DRAFT recipe by default. --publish is opt-in and pushes it live.');
2854
+ process.exit(1);
2855
+ }
2856
+ const { loadGenes, loadCapsules } = require('./src/gep/assetStore');
2857
+ const typeById = new Map();
2858
+ try {
2859
+ for (const g of (loadGenes() || [])) if (g && g.asset_id) typeById.set(g.asset_id, 'Gene');
2860
+ for (const c of (loadCapsules() || [])) if (c && c.asset_id) typeById.set(c.asset_id, 'Capsule');
2861
+ } catch (e) { /* fall back to Gene below */ }
2862
+
2863
+ const ids = genesArg.split(',').map(s => s.trim()).filter(Boolean);
2864
+ if (ids.length === 0) { console.error('[recipe build] --genes is empty.'); process.exit(1); }
2865
+ if (ids.length > 20) { console.error('[recipe build] at most 20 steps per recipe.'); process.exit(1); }
2866
+ const steps = ids.map((asset_id, i) => ({
2867
+ asset_id,
2868
+ asset_type: typeById.get(asset_id) || 'Gene',
2869
+ position: i,
2870
+ }));
2871
+
2872
+ await ensureRegistered('recipe build');
2873
+ const priceVal = flagVal('price');
2874
+ const createRes = await callWithAuthRetry('recipe build', () => hubCreateRecipe({
2875
+ title, steps, description: description || undefined,
2876
+ pricePerExecution: priceVal ? Number(priceVal) : undefined,
2877
+ }));
2878
+ if (!createRes.ok) { reportHubError('recipe build', createRes); process.exit(1); }
2879
+ const recipe = (createRes.data && (createRes.data.recipe || createRes.data)) || {};
2880
+ const recipeId = recipe.id;
2881
+ console.log('[recipe build] Created DRAFT recipe ' + recipeId + ' ("' + title + '", ' + steps.length + ' steps).');
2882
+
2883
+ if (doPublish && recipeId) {
2884
+ const pubRes = await callWithAuthRetry('recipe build', () => hubPublishRecipe(recipeId));
2885
+ if (!pubRes.ok) { reportHubError('recipe build', pubRes); process.exit(1); }
2886
+ console.log('[recipe build] Published recipe ' + recipeId + ' to the marketplace.');
2887
+ } else if (!doPublish) {
2888
+ console.log('[recipe build] Left as draft. Re-run with --publish to make it live.');
2889
+ }
2890
+ process.exit(0);
2891
+ } else if (sub === 'reuse') {
2892
+ const recipeId = flagVal('id') || (args[2] && !String(args[2]).startsWith('-') ? args[2] : null);
2893
+ if (!recipeId) {
2894
+ console.error('Usage: node index.js recipe reuse --id=<recipe_id> [--input=<json>]');
2895
+ process.exit(1);
2896
+ }
2897
+ await ensureRegistered('recipe reuse');
2898
+ const getRes = await hubGetRecipe(recipeId);
2899
+ if (!getRes.ok) { reportHubError('recipe reuse', getRes); process.exit(1); }
2900
+ let inputPayload = {};
2901
+ const inputArg = flagVal('input');
2902
+ if (inputArg) {
2903
+ try { inputPayload = JSON.parse(inputArg); }
2904
+ catch (e) { console.error('[recipe reuse] --input must be valid JSON.'); process.exit(1); }
2905
+ }
2906
+ const expRes = await callWithAuthRetry('recipe reuse', () => hubExpressRecipe(recipeId, inputPayload));
2907
+ if (!expRes.ok) { reportHubError('recipe reuse', expRes); process.exit(1); }
2908
+ console.log('[recipe reuse] Expressed recipe ' + recipeId + '.');
2909
+ if (isVerbose) console.log(JSON.stringify(expRes.data, null, 2));
2910
+ process.exit(0);
2911
+ } else {
2912
+ console.error('Usage: node index.js recipe <build|reuse> [flags]');
2913
+ console.error(' build --title="..." --genes=<asset_id,...> [--publish] (draft unless --publish)');
2914
+ console.error(' reuse --id=<recipe_id> [--input=<json>]');
2915
+ process.exit(1);
2916
+ }
2917
+
2766
2918
  } else {
2767
- console.log(`Usage: node index.js [run|/evolve|solidify|review|distill|fetch|sync|asset-log|webui|setup-hooks|buy|orders|verify|atp|atp-complete] [--loop]
2919
+ console.log(`Usage: node index.js [run|/evolve|solidify|review|distill|fetch|sync|asset-log|webui|setup-hooks|recipe|buy|orders|verify|atp|atp-complete] [--loop]
2920
+ - recipe flags:
2921
+ - build --title="..." --genes=<asset_id,...> [--description] [--price=N] [--publish]
2922
+ (builds a DRAFT DNA blueprint; --publish is opt-in)
2923
+ - reuse --id=<recipe_id> [--input=<json>] (express a recipe into an organism)
2768
2924
  - fetch flags:
2769
2925
  - --skill=<id> | -s <id> (skill ID to download)
2770
2926
  - --out=<dir> (output directory, default: ./skills/<skill_id>)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.88.1",
3
+ "version": "1.88.3",
4
4
  "description": "A GEP-powered self-evolution engine for AI agents. Features automated log analysis and Genome Evolution Protocol (GEP) for auditable, reusable evolution assets.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@aws-sdk/client-bedrock-runtime": "^3.1053.0",
40
+ "@evomap/atp-sdk": "^0.1.0",
40
41
  "@evomap/gep-sdk": "^1.5.0",
41
42
  "dotenv": "^16.4.7",
42
43
  "undici": "^7.0.0"
@@ -20,6 +20,24 @@ function buildClaudeHooks(evolverRoot) {
20
20
  ],
21
21
  },
22
22
  ],
23
+ UserPromptSubmit: [
24
+ {
25
+ hooks: [
26
+ {
27
+ type: 'command',
28
+ // Runtime asset injection (P4-c). DEFAULT off (EVOLVER_RECALL_MODE
29
+ // unset/off -> emits {} without reading the prompt). The script
30
+ // owns an absolute 3.3s watchdog that always exits 0 with valid
31
+ // JSON (fail-open); this host timeout (5s) is a strict backstop
32
+ // ABOVE that watchdog, so the host never kills the script
33
+ // mid-write. A stuck/slow recall can never block or erase the
34
+ // user's prompt.
35
+ command: `node ${scriptsBase}/evolver-task-recall.js`,
36
+ timeout: 5,
37
+ },
38
+ ],
39
+ },
40
+ ],
23
41
  PostToolUse: [
24
42
  {
25
43
  matcher: 'Write',
@@ -55,6 +73,8 @@ This project uses evolver for self-evolution. Hooks automatically:
55
73
  1. Inject recent evolution memory at session start
56
74
  2. Detect evolution signals during file edits
57
75
  3. Record outcomes at session end
76
+ 4. (Opt-in) Surface matching distilled capabilities for each prompt — set
77
+ \`EVOLVER_RECALL_MODE=shadow\` to preview, \`enforce\` to inject (default off).
58
78
 
59
79
  For substantive tasks, call \`gep_recall\` before work and \`gep_record_outcome\` after.
60
80
  Signals: log_error, perf_bottleneck, user_feature_request, capability_gap, deployment_issue, test_failure.`;
@@ -126,7 +146,7 @@ function uninstall({ configRoot }) {
126
146
  const innerBefore = matcher.hooks.length;
127
147
  const filtered = matcher.hooks.filter(h => {
128
148
  const cmd = (h && h.command) || '';
129
- return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal');
149
+ return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal') && !cmd.includes('evolver-task-recall');
130
150
  });
131
151
  // A matcher containing both evolver and user hooks shrinks
132
152
  // its inner array without changing the outer matcher count.
@@ -76,7 +76,7 @@ function mergeWithHooksUnion(target, source) {
76
76
  if (tArr && sArr) {
77
77
  const isEvolverOwned = (entry) => {
78
78
  const cmds = collectCommands(entry);
79
- return cmds.some(c => c.includes('evolver-session') || c.includes('evolver-signal'));
79
+ return cmds.some(c => c.includes('evolver-session') || c.includes('evolver-signal') || c.includes('evolver-task-recall'));
80
80
  };
81
81
  const userEntries = tArr.filter(e => !isEvolverOwned(e));
82
82
  result.hooks[event] = [...userEntries, ...sArr];
@@ -171,6 +171,7 @@ function copyHookScripts(destDir, evolverRoot) {
171
171
  'evolver-session-start.js',
172
172
  'evolver-signal-detect.js',
173
173
  'evolver-session-end.js',
174
+ 'evolver-task-recall.js',
174
175
  ];
175
176
  fs.mkdirSync(destDir, { recursive: true });
176
177
  const copied = [];
@@ -224,7 +225,7 @@ function removeEvolverHooks(filePath, { markerKey = '_evolver_managed' } = {}) {
224
225
  const before = data.hooks[event].length;
225
226
  data.hooks[event] = data.hooks[event].filter(h => {
226
227
  const cmd = h.command || '';
227
- return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal');
228
+ return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal') && !cmd.includes('evolver-task-recall');
228
229
  });
229
230
  if (data.hooks[event].length !== before) changed = true;
230
231
  if (data.hooks[event].length === 0) delete data.hooks[event];
@@ -257,6 +258,7 @@ function removeHookScripts(hooksDir) {
257
258
  'evolver-session-start.js',
258
259
  'evolver-signal-detect.js',
259
260
  'evolver-session-end.js',
261
+ 'evolver-task-recall.js',
260
262
  ];
261
263
  let removed = 0;
262
264
  for (const name of scripts) {
@@ -31,6 +31,137 @@ function isEvolverPackageJson(filePath) {
31
31
  }
32
32
  }
33
33
 
34
+ // Scan a "versions dir" used by Node version managers (NVM, fnm, Volta, asdf)
35
+ // and append each `<versions-dir>/<version>/<subdir>/node_modules` to `out`.
36
+ // Skips silently when the versions dir does not exist (typical case — most
37
+ // users have at most one version manager). Most-recent-mtime first so the
38
+ // active version is preferred when a user has multiple Node versions
39
+ // installed.
40
+ function _scanVersionedNodeModules(versionsDir, subdir, out) {
41
+ let entries;
42
+ try {
43
+ entries = fs.readdirSync(versionsDir, { withFileTypes: true });
44
+ } catch {
45
+ return;
46
+ }
47
+ const dirs = entries
48
+ .filter((e) => e.isDirectory && e.isDirectory())
49
+ .map((e) => {
50
+ const full = path.join(versionsDir, e.name);
51
+ let mtime = 0;
52
+ try { mtime = fs.statSync(full).mtimeMs; } catch {}
53
+ return { full, mtime };
54
+ })
55
+ .sort((a, b) => b.mtime - a.mtime);
56
+ for (const d of dirs) {
57
+ out.push(path.join(d.full, subdir, 'node_modules'));
58
+ }
59
+ }
60
+
61
+ // Build the require.resolve paths array. All entries are user/system-scoped
62
+ // install roots — process.cwd() is intentionally excluded for the same
63
+ // prompt-injection reason as the original allowlist (see comment in
64
+ // findEvolverRoot below).
65
+ function _buildInstallSearchPaths() {
66
+ const home = os.homedir();
67
+ // Env-derived bases must be ABSOLUTE. A relative override (e.g. NVM_DIR='.nvm')
68
+ // or empty value would resolve against process.cwd() and let a hostile
69
+ // workspace plant a fake @evomap/evolver in the require.resolve allowlist —
70
+ // the prompt-injection surface PR #94 closed. isAbsolute is platform-matched
71
+ // (path.win32 recognises C:\ ...) so the guard holds on Windows too; a
72
+ // non-absolute override falls through to the trusted home/system default.
73
+ const _pathFlavor = process.platform === 'win32' ? path.win32 : path.posix;
74
+ const absEnv = (v) => (v && _pathFlavor.isAbsolute(v)) ? v : null;
75
+ const paths = [
76
+ // npm global with `npm config set prefix` overrides
77
+ path.join(home, '.npm-global', 'lib', 'node_modules'),
78
+ path.join(home, '.local', 'lib', 'node_modules'),
79
+ // System-wide (apt/yum nodejs, Intel Mac Homebrew)
80
+ '/usr/lib/node_modules',
81
+ '/usr/local/lib/node_modules',
82
+ // Apple Silicon Homebrew (default since macOS Big Sur on M1/M2/M3/M4 —
83
+ // the majority of Mac dev hardware sold since 2021). Without this
84
+ // entry, `npm install -g @evomap/evolver` on an Apple Silicon Mac
85
+ // lands at /opt/homebrew/lib/node_modules/@evomap/evolver and the
86
+ // hook scripts cannot find the package -> additionalContext is empty
87
+ // -> evolution memory never reaches the LLM.
88
+ '/opt/homebrew/lib/node_modules',
89
+ // Linuxbrew (Homebrew on Linux — niche but real).
90
+ '/home/linuxbrew/.linuxbrew/lib/node_modules',
91
+ ];
92
+ // Per-user Node version managers. Each manager has its own on-disk layout
93
+ // and its own base-dir env override; the version subdirectory is dynamic
94
+ // (e.g. `~/.nvm/versions/node/v22.15.0`) so we scan and append each
95
+ // version's node_modules. These were missing from the original hard-coded
96
+ // list even though NVM in particular is extremely common across all OSes.
97
+
98
+ // NVM. Globals are per-version under `<NVM_DIR>/versions/node/<ver>/lib`.
99
+ // NVM_DIR defaults to ~/.nvm but is frequently relocated.
100
+ const nvmDir = absEnv(process.env.NVM_DIR) || path.join(home, '.nvm');
101
+ _scanVersionedNodeModules(path.join(nvmDir, 'versions', 'node'), 'lib', paths);
102
+
103
+ // fnm. Each version lives under `<base>/node-versions/<ver>/installation/`,
104
+ // and fnm does NOT override the npm prefix, so globals are at
105
+ // `installation/lib/node_modules`. The base dir is XDG-first
106
+ // (`$XDG_DATA_HOME/fnm`, i.e. ~/.local/share/fnm on Linux and
107
+ // ~/Library/Application Support/fnm on macOS); `~/.fnm` is only the legacy
108
+ // fallback. `$FNM_DIR` overrides everything. Scan all candidate bases;
109
+ // _scanVersionedNodeModules silently skips the ones that don't exist.
110
+ const fnmSub = path.join('installation', 'lib');
111
+ const fnmBases = [];
112
+ if (absEnv(process.env.FNM_DIR)) fnmBases.push(process.env.FNM_DIR);
113
+ if (absEnv(process.env.XDG_DATA_HOME)) fnmBases.push(path.join(process.env.XDG_DATA_HOME, 'fnm'));
114
+ fnmBases.push(path.join(home, '.local', 'share', 'fnm')); // Linux XDG default
115
+ fnmBases.push(path.join(home, 'Library', 'Application Support', 'fnm')); // macOS default
116
+ fnmBases.push(path.join(home, '.fnm')); // legacy
117
+ for (const base of fnmBases) {
118
+ _scanVersionedNodeModules(path.join(base, 'node-versions'), fnmSub, paths);
119
+ }
120
+
121
+ // Volta does NOT store global packages alongside the Node image. It
122
+ // sandboxes each `npm install -g`'d package under
123
+ // `<VOLTA_HOME>/tools/image/packages/<name>/lib/node_modules` (the scope
124
+ // becomes a real nested directory). Because we know the package name, this
125
+ // is a single fixed path rather than a version scan. VOLTA_HOME defaults to
126
+ // ~/.volta on macOS/Linux but to %LOCALAPPDATA%\Volta on Windows (where the
127
+ // globals actually live, and hook processes often don't inherit VOLTA_HOME).
128
+ // Verified against volta-cli/volta `volta-layout` v4 (`package_image_dir`) +
129
+ // `package/manager.rs` (`source_dir` = lib/node_modules).
130
+ const voltaHome = absEnv(process.env.VOLTA_HOME)
131
+ || (process.platform === 'win32'
132
+ ? path.join(absEnv(process.env.LOCALAPPDATA) || path.join(home, 'AppData', 'Local'), 'Volta')
133
+ : path.join(home, '.volta'));
134
+ paths.push(path.join(voltaHome, 'tools', 'image', 'packages', '@evomap', 'evolver', 'lib', 'node_modules'));
135
+
136
+ // asdf. Globals are per-version under `<data>/installs/nodejs/<ver>/`.
137
+ // asdf-nodejs dropped the `.npm` prefix override in PR #228 (Sept 2022),
138
+ // so modern installs use plain `lib/node_modules`; older installs (never
139
+ // re-created) still use `.npm/lib/node_modules`. Scan both. `$ASDF_DATA_DIR`
140
+ // overrides the ~/.asdf default (asdf 0.16+ Go rewrite).
141
+ const asdfData = absEnv(process.env.ASDF_DATA_DIR) || path.join(home, '.asdf');
142
+ const asdfVersions = path.join(asdfData, 'installs', 'nodejs');
143
+ _scanVersionedNodeModules(asdfVersions, 'lib', paths); // modern (post-#228)
144
+ _scanVersionedNodeModules(asdfVersions, path.join('.npm', 'lib'), paths); // legacy (pre-#228)
145
+
146
+ // Windows: `npm install -g` puts packages under %APPDATA%\npm\node_modules
147
+ // (most common; same convention as `npm config get prefix` default on win32),
148
+ // %ProgramFiles%\nodejs\node_modules (system-wide installer), or
149
+ // %ProgramFiles(x86)%\nodejs\node_modules (32-bit Node on a 64-bit host).
150
+ // Conditional expansion keeps the POSIX base list untouched on Linux/macOS.
151
+ if (process.platform === 'win32') {
152
+ const appdata = absEnv(process.env.APPDATA) || path.join(home, 'AppData', 'Roaming');
153
+ paths.push(path.join(appdata, 'npm', 'node_modules'));
154
+ if (absEnv(process.env.ProgramFiles)) {
155
+ paths.push(path.join(process.env.ProgramFiles, 'nodejs', 'node_modules'));
156
+ }
157
+ if (absEnv(process.env['ProgramFiles(x86)'])) {
158
+ paths.push(path.join(process.env['ProgramFiles(x86)'], 'nodejs', 'node_modules'));
159
+ }
160
+ }
161
+
162
+ return paths;
163
+ }
164
+
34
165
  function findEvolverRoot() {
35
166
  if (process.env.EVOLVER_ROOT) {
36
167
  const explicit = process.env.EVOLVER_ROOT;
@@ -57,39 +188,18 @@ function findEvolverRoot() {
57
188
  // be selected here and control `findMemoryGraph()` -> the memory graph
58
189
  // contents become attacker-controlled prompt-injection material in
59
190
  // `evolver-session-start.js`'s `additionalContext`. Restrict to trusted,
60
- // user/system-scoped install roots.
191
+ // user/system-scoped install roots (built in `_buildInstallSearchPaths`).
61
192
  try {
62
- // Windows: `npm install -g` puts packages under %APPDATA%\npm\node_modules
63
- // (most common), %ProgramFiles%\nodejs\node_modules (system-wide installer),
64
- // or %ProgramFiles(x86)%\nodejs\node_modules. Build the extra Windows paths
65
- // conditionally so the POSIX base list stays intact.
66
- const _winPaths = process.platform === 'win32'
67
- ? [
68
- path.join(
69
- process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
70
- 'npm', 'node_modules'
71
- ),
72
- ...(process.env.ProgramFiles
73
- ? [path.join(process.env.ProgramFiles, 'nodejs', 'node_modules')]
74
- : []),
75
- ...(process.env['ProgramFiles(x86)']
76
- ? [path.join(process.env['ProgramFiles(x86)'], 'nodejs', 'node_modules')]
77
- : []),
78
- ]
79
- : [];
80
-
193
+ // Allowlist of trusted user/system-scoped install roots. Built by
194
+ // _buildInstallSearchPaths() above so the list is one source of truth
195
+ // (Apple Silicon Homebrew, Linuxbrew, NVM / fnm / Volta / asdf,
196
+ // and Windows %APPDATA%\npm + %ProgramFiles%\nodejs install layouts).
197
+ // process.cwd() is intentionally excluded: a hostile workspace can plant
198
+ // its own node_modules/@evomap/evolver/package.json which would then
199
+ // control findMemoryGraph() and feed attacker-controlled content into
200
+ // evolver-session-start.js's additionalContext.
81
201
  const pkgJson = require.resolve('@evomap/evolver/package.json', {
82
- // Do NOT include process.cwd() — a hostile workspace can plant its own
83
- // node_modules/@evomap/evolver to gain control over the memory graph path
84
- // (prompt-injection surface: evolver-session-start.js additionalContext).
85
- // Only trust user/system-scoped install roots.
86
- paths: [
87
- path.join(os.homedir(), '.npm-global', 'lib', 'node_modules'),
88
- path.join(os.homedir(), '.local', 'lib', 'node_modules'),
89
- '/usr/lib/node_modules',
90
- '/usr/local/lib/node_modules',
91
- ..._winPaths,
92
- ],
202
+ paths: _buildInstallSearchPaths(),
93
203
  });
94
204
  if (pkgJson && isEvolverPackageJson(pkgJson)) {
95
205
  return path.dirname(pkgJson);
@@ -293,10 +403,10 @@ function findMemoryGraph(evolverRoot) {
293
403
  }
294
404
 
295
405
  // Is `dir` inside a git work tree? Cheap, no-shell `git rev-parse`. Returns
296
- // false on any error (git missing, not a repo, timeout) and never throws — the
297
- // session-start hook uses this only to decide whether to surface a one-line
298
- // "evolver needs a git workspace" notice, so a false negative just suppresses
299
- // the notice rather than breaking anything.
406
+ // false on any error (git missing, not a repo, timeout) and never throws.
407
+ // The session-start hook uses this only to decide whether to surface a
408
+ // one-line "evolver needs a git workspace" notice, so a false negative just
409
+ // suppresses the notice rather than breaking anything.
300
410
  function isGitWorkspace(dir) {
301
411
  try {
302
412
  const res = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
@@ -312,4 +422,18 @@ function isGitWorkspace(dir) {
312
422
  }
313
423
  }
314
424
 
315
- module.exports = { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace };
425
+ module.exports = {
426
+ findEvolverRoot,
427
+ findMemoryGraph,
428
+ resolveProjectDir,
429
+ resolveWorkspaceId,
430
+ isGitWorkspace,
431
+ // Test-only: exposes the install-path builder so the test suite can
432
+ // verify Apple Silicon Homebrew + version-manager (NVM/fnm/Volta/asdf)
433
+ // + Windows %APPDATA%\npm paths are included without going through the
434
+ // full require.resolve chain (which depends on a real filesystem layout).
435
+ __internals: {
436
+ buildInstallSearchPaths: _buildInstallSearchPaths,
437
+ scanVersionedNodeModules: _scanVersionedNodeModules,
438
+ },
439
+ };
@@ -199,14 +199,18 @@ function getDedupStatePath() {
199
199
  return path.join(dir, 'session-start-state.json');
200
200
  }
201
201
 
202
- // TTL throttle keyed by an arbitrary string, persisted in session-start-state
203
- // .json. Returns true if `key` fired within the last `ttlMs` (caller should
204
- // suppress); otherwise records "now" for `key` and returns false. Best-effort:
205
- // a state read/write failure just means no throttling (fail open). Shared by
206
- // the Kiro per-prompt dedup and the non-git notice so both age out of the same
207
- // file (entries older than 24h are pruned on write).
208
- function throttled(key, ttlMs) {
209
- const statePath = getDedupStatePath();
202
+ function getNoticeStatePath() {
203
+ const dir = process.env.EVOLVER_SESSION_STATE_DIR
204
+ || path.join(os.homedir(), '.evolver');
205
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
206
+ return path.join(dir, 'session-start-notice-state.json');
207
+ }
208
+
209
+ // TTL throttle keyed by an arbitrary string. Returns true if `key` fired within
210
+ // the last `ttlMs` (caller should suppress); otherwise records "now" for `key`
211
+ // and returns false. Best-effort: a state read/write failure just means no
212
+ // throttling (fail open). Entries older than 24h are pruned on write.
213
+ function throttled(key, ttlMs, statePath) {
210
214
  let state = {};
211
215
  try {
212
216
  if (fs.existsSync(statePath)) {
@@ -241,7 +245,7 @@ function shouldSkipInjection() {
241
245
  if (!dedupEnabled) return false;
242
246
 
243
247
  const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
244
- return throttled(process.cwd(), ttlMs);
248
+ return throttled(process.cwd(), ttlMs, getDedupStatePath());
245
249
  }
246
250
 
247
251
  function main() {
@@ -256,7 +260,7 @@ function main() {
256
260
  // from git diffs), so tell the user — once per folder per TTL — instead of
257
261
  // failing silently. Emitted regardless of whether any memory exists below.
258
262
  const parts = [];
259
- if (!isGitWorkspace(currentDir) && !throttled('nongit:' + currentDir, NON_GIT_NOTICE_TTL_MS)) {
263
+ if (!isGitWorkspace(currentDir) && !throttled('nongit:' + currentDir, NON_GIT_NOTICE_TTL_MS, getNoticeStatePath())) {
260
264
  parts.push(NON_GIT_NOTICE);
261
265
  }
262
266