@evomap/evolver 1.84.1 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/assets/gep/genes.seed.json +17 -15
  2. package/index.js +45 -8
  3. package/package.json +4 -3
  4. package/src/adapters/claudeCode.js +44 -31
  5. package/src/adapters/codex.js +70 -26
  6. package/src/adapters/cursor.js +3 -1
  7. package/src/adapters/hookAdapter.js +142 -2
  8. package/src/adapters/kiro.js +6 -14
  9. package/src/adapters/opencode.js +6 -14
  10. package/src/adapters/scripts/_runtimePaths.js +114 -0
  11. package/src/adapters/scripts/evolver-session-end.js +37 -61
  12. package/src/adapters/scripts/evolver-session-start.js +1 -31
  13. package/src/config.js +20 -1
  14. package/src/evolve/guards.js +1 -1
  15. package/src/evolve/pipeline/collect.js +1 -1
  16. package/src/evolve/pipeline/dispatch.js +1 -1
  17. package/src/evolve/pipeline/enrich.js +1 -1
  18. package/src/evolve/pipeline/hub.js +1 -1
  19. package/src/evolve/pipeline/select.js +1 -1
  20. package/src/evolve/pipeline/signals.js +1 -1
  21. package/src/evolve/utils.js +1 -1
  22. package/src/evolve.js +1 -1
  23. package/src/gep/a2aProtocol.js +1 -1
  24. package/src/gep/assetStore.js +27 -6
  25. package/src/gep/candidateEval.js +1 -1
  26. package/src/gep/candidates.js +1 -1
  27. package/src/gep/contentHash.js +1 -1
  28. package/src/gep/crypto.js +1 -1
  29. package/src/gep/curriculum.js +1 -1
  30. package/src/gep/deviceId.js +1 -1
  31. package/src/gep/directoryClient.js +4 -3
  32. package/src/gep/envFingerprint.js +1 -1
  33. package/src/gep/epigenetics.js +1 -1
  34. package/src/gep/explore.js +1 -1
  35. package/src/gep/hash.js +1 -1
  36. package/src/gep/hubFetch.js +1 -0
  37. package/src/gep/hubReview.js +1 -1
  38. package/src/gep/hubSearch.js +1 -1
  39. package/src/gep/hubVerify.js +1 -1
  40. package/src/gep/learningSignals.js +1 -1
  41. package/src/gep/memoryGraph.js +1 -1
  42. package/src/gep/memoryGraphAdapter.js +1 -1
  43. package/src/gep/mutation.js +1 -1
  44. package/src/gep/narrativeMemory.js +1 -1
  45. package/src/gep/openPRRegistry.js +1 -1
  46. package/src/gep/personality.js +1 -1
  47. package/src/gep/policyCheck.js +1 -1
  48. package/src/gep/prompt.js +1 -1
  49. package/src/gep/recallVerifier.js +1 -1
  50. package/src/gep/reflection.js +1 -1
  51. package/src/gep/schemas/gene.js +70 -1
  52. package/src/gep/schemas/protocol.js +9 -1
  53. package/src/gep/selector.js +1 -1
  54. package/src/gep/selfPR.js +62 -32
  55. package/src/gep/skillDistiller.js +1 -1
  56. package/src/gep/skillPublisher.js +3 -2
  57. package/src/gep/solidify.js +1 -1
  58. package/src/gep/strategy.js +1 -1
  59. package/src/gep/taskReceiver.js +6 -5
  60. package/src/gep/validator/index.js +10 -6
  61. package/src/gep/validator/reporter.js +2 -1
  62. package/src/gep/validator/stakeBootstrap.js +2 -1
  63. package/src/proxy/index.js +69 -0
  64. package/src/proxy/lifecycle/manager.js +3 -2
  65. package/src/proxy/router/cache_passthrough.js +26 -0
  66. package/src/proxy/router/features.js +84 -0
  67. package/src/proxy/router/messages_route.js +242 -0
  68. package/src/proxy/router/model_router.js +113 -0
  69. package/src/proxy/server/http.js +92 -5
  70. package/src/proxy/server/routes.js +12 -2
  71. package/src/proxy/server/settings.js +37 -11
  72. package/src/proxy/sync/inbound.js +3 -2
  73. package/src/proxy/sync/outbound.js +2 -1
@@ -6,10 +6,12 @@
6
6
  "id": "gene_gep_repair_from_errors",
7
7
  "category": "repair",
8
8
  "signals_match": [
9
- "error",
10
- "exception",
11
- "failed",
12
- "unstable"
9
+ "error|错误|异常|エラー|오류",
10
+ "exception|异常|例外|예외",
11
+ "failed|失败|失敗|실패|fail",
12
+ "unstable|不稳定|不安定|불안정",
13
+ "log_error",
14
+ "test_failure"
13
15
  ],
14
16
  "preconditions": [
15
17
  "signals contains error-related indicators"
@@ -39,11 +41,11 @@
39
41
  "id": "gene_gep_optimize_prompt_and_assets",
40
42
  "category": "optimize",
41
43
  "signals_match": [
42
- "protocol",
44
+ "protocol|协议|プロトコル|프로토콜",
43
45
  "gep",
44
- "prompt",
45
- "audit",
46
- "reusable"
46
+ "prompt|提示词|提示|プロンプト|프롬프트",
47
+ "audit|审计|監査|감사",
48
+ "reusable|可复用|再利用|재사용"
47
49
  ],
48
50
  "preconditions": [
49
51
  "need stricter, auditable evolution protocol outputs"
@@ -73,12 +75,12 @@
73
75
  "id": "gene_gep_innovate_from_opportunity",
74
76
  "category": "innovate",
75
77
  "signals_match": [
76
- "user_feature_request",
77
- "user_improvement_suggestion",
78
- "perf_bottleneck",
79
- "capability_gap",
78
+ "user_feature_request|功能请求|機能リクエスト|기능요청",
79
+ "user_improvement_suggestion|改进建议|改善提案|개선제안",
80
+ "perf_bottleneck|性能瓶颈|パフォーマンス|성능병목",
81
+ "capability_gap|能力缺口|機能ギャップ|역량공백",
80
82
  "stable_success_plateau",
81
- "external_opportunity",
83
+ "external_opportunity|外部机会|外部機会|외부기회",
82
84
  "bounty_task"
83
85
  ],
84
86
  "preconditions": [
@@ -114,8 +116,8 @@
114
116
  "signals_match": [
115
117
  "high_tool_usage:exec",
116
118
  "repeated_tool_usage:exec",
117
- "tool_bypass",
118
- "tool_loop",
119
+ "tool_bypass|工具绕过|ツール迂回|도구우회",
120
+ "tool_loop|工具循环|ツールループ|도구반복",
119
121
  "high_tool_usage"
120
122
  ],
121
123
  "preconditions": [
package/index.js CHANGED
@@ -187,8 +187,14 @@ function getLastSignals(statePath) {
187
187
  }
188
188
 
189
189
  // Singleton Guard - prevent multiple evolver daemon instances
190
+ function getLockFilePath() {
191
+ // Allow tests / sandboxed runs to override the pid-file location so they
192
+ // do not collide with a real daemon's lock at the source-dir default.
193
+ const dir = process.env.EVOLVER_LOCK_DIR || __dirname;
194
+ return path.join(dir, 'evolver.pid');
195
+ }
190
196
  function acquireLock() {
191
- const lockFile = path.join(__dirname, 'evolver.pid');
197
+ const lockFile = getLockFilePath();
192
198
  try {
193
199
  try {
194
200
  fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
@@ -217,7 +223,7 @@ function acquireLock() {
217
223
  }
218
224
 
219
225
  function releaseLock() {
220
- const lockFile = path.join(__dirname, 'evolver.pid');
226
+ const lockFile = getLockFilePath();
221
227
  try {
222
228
  if (fs.existsSync(lockFile)) {
223
229
  const pid = parseInt(fs.readFileSync(lockFile, 'utf8').trim(), 10);
@@ -280,22 +286,53 @@ async function main() {
280
286
  releaseLock();
281
287
  process.exit(1);
282
288
  });
283
- let _unhandledRejectionCount = 0;
289
+ // Sliding window: only exit if many rejections cluster in a short
290
+ // period. A daemon running for weeks can accumulate harmless,
291
+ // unrelated rejections (transient network blips, hub timeouts);
292
+ // the original cumulative counter would eventually kill the
293
+ // process for noise. Cluster = real failure cascade.
294
+ const REJECTION_WINDOW_MS = 5 * 60 * 1000;
295
+ const REJECTION_THRESHOLD = 5;
296
+ let _rejectionTimestamps = [];
284
297
  process.on('unhandledRejection', (reason) => {
285
- _unhandledRejectionCount++;
286
- console.error('[FATAL] Unhandled promise rejection (' + _unhandledRejectionCount + '):', reason && reason.stack ? reason.stack : String(reason));
287
- if (_unhandledRejectionCount >= 5) {
288
- console.error('[FATAL] Too many unhandled rejections (' + _unhandledRejectionCount + '). Exiting to avoid corrupt state.');
298
+ const now = Date.now();
299
+ _rejectionTimestamps.push(now);
300
+ _rejectionTimestamps = _rejectionTimestamps.filter(function (t) {
301
+ return now - t < REJECTION_WINDOW_MS;
302
+ });
303
+ console.error('[FATAL] Unhandled promise rejection (' + _rejectionTimestamps.length + ' in window):', reason && reason.stack ? reason.stack : String(reason));
304
+ if (_rejectionTimestamps.length >= REJECTION_THRESHOLD) {
305
+ console.error('[FATAL] ' + _rejectionTimestamps.length + ' unhandled rejections within ' + (REJECTION_WINDOW_MS / 1000) + 's. Exiting to avoid corrupt state.');
289
306
  releaseLock();
290
307
  process.exit(1);
291
308
  }
292
309
  });
293
310
 
294
311
  process.env.EVOLVE_LOOP = 'true';
312
+ // Issue #96: from v1.85.0, --loop defaults EVOLVE_BRIDGE=true so the
313
+ // daemon actually evolves the working tree. The previous default of
314
+ // 'false' caused 33 days of empty cycling on Aurora — every cycle
315
+ // hit rejectPendingRun(reason=loop_bridge_disabled_autoreject_no_rollback)
316
+ // and produced no EvolutionEvent. Failed cycles still recover safely
317
+ // via rollbackTracked (src/gep/gitOps.js#rollbackTracked, mode=stash
318
+ // by default since v1.81.0): the daemon's changes get pushed to a
319
+ // stash entry the user can recover with `git stash pop`.
320
+ // Set EVOLVE_BRIDGE=false explicitly to opt back into observe-only.
295
321
  if (!process.env.EVOLVE_BRIDGE) {
296
- process.env.EVOLVE_BRIDGE = 'false';
322
+ process.env.EVOLVE_BRIDGE = 'true';
297
323
  }
324
+ const bridgeEnabled = String(process.env.EVOLVE_BRIDGE).toLowerCase() !== 'false';
298
325
  console.log(`Loop mode enabled (internal daemon, bridge=${process.env.EVOLVE_BRIDGE}, verbose=${isVerbose}).`);
326
+ if (bridgeEnabled) {
327
+ console.warn('[Daemon] EVOLVE_BRIDGE=true (default since v1.85.0).');
328
+ console.warn('[Daemon] evolver may modify your working tree.');
329
+ console.warn('[Daemon] Failed cycles auto-stash via "git stash push --include-untracked".');
330
+ console.warn('[Daemon] Recover: git stash list | grep evolver-rollback');
331
+ console.warn('[Daemon] Set EVOLVE_BRIDGE=false to opt out (observe-only mode).');
332
+ } else {
333
+ console.warn('[Daemon] EVOLVE_BRIDGE=false: evolver will NOT modify your working tree (observe-only).');
334
+ console.warn('[Daemon] To enable real evolution: unset EVOLVE_BRIDGE or set it to "true".');
335
+ }
299
336
 
300
337
  // Startup diagnostic: in daemon mode evolver consumes its own stdout
301
338
  // instead of handing `sessions_spawn(...)` directives to a host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.84.1",
3
+ "version": "1.85.0",
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": {
@@ -36,8 +36,9 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@evomap/gep-sdk": "^1.2.0",
40
- "dotenv": "^16.4.7"
39
+ "@evomap/gep-sdk": "^1.3.0",
40
+ "dotenv": "^16.4.7",
41
+ "undici": "^7.0.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "javascript-obfuscator": "^5.4.1"
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { mergeJsonFile, copyHookScripts, appendSectionToFile, removeHookScripts } = require('./hookAdapter');
3
+ const { mergeJsonFile, copyHookScripts, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir } = require('./hookAdapter');
4
4
 
5
5
  const HOOK_SCRIPTS_DIR_NAME = 'hooks';
6
6
  const EVOLVER_MARKER = '<!-- evolver-evolution-memory -->';
@@ -65,6 +65,7 @@ function install({ configRoot, evolverRoot, force }) {
65
65
  const settingsPath = path.join(claudeDir, 'settings.json');
66
66
  const hooksDir = path.join(claudeDir, HOOK_SCRIPTS_DIR_NAME);
67
67
  const claudeMdPath = path.join(configRoot, 'CLAUDE.md');
68
+ assertSafeConfigDir(claudeDir, '.claude', { subdirs: [HOOK_SCRIPTS_DIR_NAME] });
68
69
 
69
70
  if (!force && fs.existsSync(settingsPath)) {
70
71
  try {
@@ -104,54 +105,66 @@ function uninstall({ configRoot }) {
104
105
  const settingsPath = path.join(claudeDir, 'settings.json');
105
106
  const hooksDir = path.join(claudeDir, HOOK_SCRIPTS_DIR_NAME);
106
107
  const claudeMdPath = path.join(configRoot, 'CLAUDE.md');
108
+ assertSafeConfigDir(claudeDir, '.claude', { subdirs: [HOOK_SCRIPTS_DIR_NAME] });
107
109
 
108
110
  let changed = false;
109
111
 
112
+ // Strip evolver entries from settings.json. Even without the marker we
113
+ // still try to filter by command — a missing/dropped marker should not
114
+ // strand obvious evolver-owned entries (#538).
110
115
  try {
111
116
  if (fs.existsSync(settingsPath)) {
112
117
  const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
113
- if (data._evolver_managed) {
114
- if (data.hooks) {
115
- for (const event of Object.keys(data.hooks)) {
116
- if (Array.isArray(data.hooks[event])) {
117
- data.hooks[event] = data.hooks[event]
118
- .map(matcher => {
119
- if (!matcher || !Array.isArray(matcher.hooks)) return matcher;
120
- const filtered = matcher.hooks.filter(h => {
121
- const cmd = (h && h.command) || '';
122
- return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal');
123
- });
124
- return { ...matcher, hooks: filtered };
125
- })
126
- .filter(matcher => matcher && Array.isArray(matcher.hooks) && matcher.hooks.length > 0);
127
- if (data.hooks[event].length === 0) delete data.hooks[event];
128
- }
118
+ let touched = false;
119
+ if (data.hooks) {
120
+ for (const event of Object.keys(data.hooks)) {
121
+ if (Array.isArray(data.hooks[event])) {
122
+ const beforeLen = data.hooks[event].length;
123
+ data.hooks[event] = data.hooks[event]
124
+ .map(matcher => {
125
+ if (!matcher || !Array.isArray(matcher.hooks)) return matcher;
126
+ const innerBefore = matcher.hooks.length;
127
+ const filtered = matcher.hooks.filter(h => {
128
+ const cmd = (h && h.command) || '';
129
+ return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal');
130
+ });
131
+ // A matcher containing both evolver and user hooks shrinks
132
+ // its inner array without changing the outer matcher count.
133
+ // Track the inner-array shrink so `touched` reflects it.
134
+ if (filtered.length !== innerBefore) touched = true;
135
+ return { ...matcher, hooks: filtered };
136
+ })
137
+ .filter(matcher => matcher && Array.isArray(matcher.hooks) && matcher.hooks.length > 0);
138
+ if (data.hooks[event].length !== beforeLen) touched = true;
139
+ if (data.hooks[event].length === 0) delete data.hooks[event];
129
140
  }
130
- if (Object.keys(data.hooks).length === 0) delete data.hooks;
131
141
  }
142
+ if (Object.keys(data.hooks).length === 0) delete data.hooks;
143
+ }
144
+ if (data._evolver_managed) {
132
145
  delete data._evolver_managed;
146
+ touched = true;
147
+ }
148
+ if (touched) {
133
149
  fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
134
150
  changed = true;
135
151
  }
136
152
  }
137
- } catch { /* ignore */ }
153
+ } catch (e) {
154
+ console.warn(`[claude-code] Failed to clean ${settingsPath}: ${e.message || e}`);
155
+ }
138
156
 
139
157
  const scripts = removeHookScripts(hooksDir);
140
158
  if (scripts > 0) changed = true;
141
-
142
159
  try {
143
- if (fs.existsSync(claudeMdPath)) {
144
- let content = fs.readFileSync(claudeMdPath, 'utf8');
145
- if (content.includes(EVOLVER_MARKER)) {
146
- const idx = content.indexOf(EVOLVER_MARKER);
147
- const nextSection = content.indexOf('\n## ', idx + EVOLVER_MARKER.length);
148
- const endIdx = nextSection !== -1 ? nextSection : content.length;
149
- content = content.slice(0, idx).trimEnd() + (nextSection !== -1 ? content.slice(endIdx) : '');
150
- fs.writeFileSync(claudeMdPath, content.trimEnd() + '\n', 'utf8');
151
- changed = true;
152
- }
160
+ if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
161
+ fs.rmdirSync(hooksDir);
153
162
  }
154
- } catch { /* ignore */ }
163
+ } catch { /* best-effort */ }
164
+
165
+ if (removeMarkedSection(claudeMdPath, EVOLVER_MARKER)) {
166
+ changed = true;
167
+ }
155
168
 
156
169
  console.log(changed
157
170
  ? '[claude-code] Uninstalled evolver hooks.'
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { mergeJsonFile, copyHookScripts, appendSectionToFile, removeHookScripts } = require('./hookAdapter');
3
+ const { mergeJsonFile, copyHookScripts, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir } = require('./hookAdapter');
4
4
 
5
5
  const HOOK_SCRIPTS_DIR_NAME = 'hooks';
6
6
  const EVOLVER_MARKER = '<!-- evolver-evolution-memory -->';
@@ -57,6 +57,34 @@ function ensureConfigToml(codexDir) {
57
57
  return true;
58
58
  }
59
59
 
60
+ // Reverse of `ensureConfigToml`: drop the `codex_hooks = true` line and, if
61
+ // the surrounding `[features]` block becomes empty as a result, drop that
62
+ // header too. Other unrelated entries under `[features]` are preserved.
63
+ // Returns true when the file changed.
64
+ function cleanConfigToml(codexDir) {
65
+ const tomlPath = path.join(codexDir, 'config.toml');
66
+ let content;
67
+ try { content = fs.readFileSync(tomlPath, 'utf8'); } catch { return false; }
68
+ if (!/codex_hooks\s*=\s*true/i.test(content)) return false;
69
+
70
+ // Drop the `codex_hooks = true` line. The greedy `\s*` after `true`
71
+ // consumes the trailing newline plus any blank lines so the
72
+ // empty-`[features]` check below cannot be fooled by a stray blank
73
+ // line into treating the section as empty while user entries still
74
+ // follow.
75
+ let next = content.replace(/^\s*codex_hooks\s*=\s*true\s*\n?/im, '');
76
+ // Drop a now-empty `[features]` block. Two strict patterns avoid
77
+ // `$` with the /m flag — multiline `$` matches before any `\n`, so
78
+ // a single `(?=\s*$)` lookahead can succeed mid-file and strand
79
+ // user entries below the removed header (PR #94 round-3).
80
+ next = next.replace(/(^|\n)\[features\]\s*\n(?=\s*\[)/, '$1');
81
+ next = next.replace(/(^|\n)\[features\]\s*$/, '$1');
82
+ next = next.replace(/\n{3,}/g, '\n\n').trimEnd();
83
+ if (next.length > 0) next += '\n';
84
+ fs.writeFileSync(tomlPath, next, 'utf8');
85
+ return true;
86
+ }
87
+
60
88
  function buildAgentsMdSection() {
61
89
  return `${EVOLVER_MARKER}
62
90
  ## Evolution Memory (Evolver)
@@ -75,6 +103,7 @@ function install({ configRoot, evolverRoot, force }) {
75
103
  const hooksJsonPath = path.join(codexDir, 'hooks.json');
76
104
  const hooksDir = path.join(codexDir, HOOK_SCRIPTS_DIR_NAME);
77
105
  const agentsMdPath = path.join(configRoot, 'AGENTS.md');
106
+ assertSafeConfigDir(codexDir, '.codex', { subdirs: [HOOK_SCRIPTS_DIR_NAME] });
78
107
 
79
108
  if (!force && fs.existsSync(hooksJsonPath)) {
80
109
  try {
@@ -119,48 +148,63 @@ function uninstall({ configRoot }) {
119
148
  const hooksJsonPath = path.join(codexDir, 'hooks.json');
120
149
  const hooksDir = path.join(codexDir, HOOK_SCRIPTS_DIR_NAME);
121
150
  const agentsMdPath = path.join(configRoot, 'AGENTS.md');
151
+ assertSafeConfigDir(codexDir, '.codex', { subdirs: [HOOK_SCRIPTS_DIR_NAME] });
122
152
 
123
153
  let changed = false;
124
154
 
155
+ // Strip evolver entries from hooks.json. Even when the
156
+ // `_evolver_managed` marker is missing (older install, hand-edited
157
+ // file), we still try to filter by command — a missing marker should
158
+ // not strand obvious evolver-owned entries (#538).
125
159
  try {
126
160
  if (fs.existsSync(hooksJsonPath)) {
127
161
  const data = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
128
- if (data._evolver_managed) {
129
- if (data.hooks) {
130
- for (const event of Object.keys(data.hooks)) {
131
- if (Array.isArray(data.hooks[event])) {
132
- data.hooks[event] = data.hooks[event].filter(h => {
133
- const cmd = h.command || '';
134
- return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal');
135
- });
136
- if (data.hooks[event].length === 0) delete data.hooks[event];
137
- }
162
+ let touched = false;
163
+ if (data.hooks) {
164
+ for (const event of Object.keys(data.hooks)) {
165
+ if (Array.isArray(data.hooks[event])) {
166
+ const before = data.hooks[event].length;
167
+ data.hooks[event] = data.hooks[event].filter(h => {
168
+ const cmd = (h && h.command) || '';
169
+ return !cmd.includes('evolver-session') && !cmd.includes('evolver-signal');
170
+ });
171
+ if (data.hooks[event].length !== before) touched = true;
172
+ if (data.hooks[event].length === 0) delete data.hooks[event];
138
173
  }
139
- if (Object.keys(data.hooks).length === 0) delete data.hooks;
140
174
  }
175
+ if (Object.keys(data.hooks).length === 0) delete data.hooks;
176
+ }
177
+ if (data._evolver_managed) {
141
178
  delete data._evolver_managed;
179
+ touched = true;
180
+ }
181
+ if (touched) {
142
182
  fs.writeFileSync(hooksJsonPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
143
183
  changed = true;
144
184
  }
145
185
  }
146
- } catch { /* ignore */ }
186
+ } catch (e) {
187
+ console.warn(`[codex] Failed to clean ${hooksJsonPath}: ${e.message || e}`);
188
+ }
147
189
 
148
190
  const scripts = removeHookScripts(hooksDir);
149
191
  if (scripts > 0) changed = true;
150
-
192
+ // If hooks dir is now empty (only evolver scripts lived there), remove it
193
+ // so a subsequent install starts from a clean slate.
151
194
  try {
152
- if (fs.existsSync(agentsMdPath)) {
153
- let content = fs.readFileSync(agentsMdPath, 'utf8');
154
- if (content.includes(EVOLVER_MARKER)) {
155
- const idx = content.indexOf(EVOLVER_MARKER);
156
- const nextSection = content.indexOf('\n## ', idx + EVOLVER_MARKER.length);
157
- const endIdx = nextSection !== -1 ? nextSection : content.length;
158
- content = content.slice(0, idx).trimEnd() + (nextSection !== -1 ? content.slice(endIdx) : '');
159
- fs.writeFileSync(agentsMdPath, content.trimEnd() + '\n', 'utf8');
160
- changed = true;
161
- }
195
+ if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
196
+ fs.rmdirSync(hooksDir);
162
197
  }
163
- } catch { /* ignore */ }
198
+ } catch { /* best-effort */ }
199
+
200
+ if (cleanConfigToml(codexDir)) {
201
+ console.log('[codex] Removed codex_hooks flag from config.toml');
202
+ changed = true;
203
+ }
204
+
205
+ if (removeMarkedSection(agentsMdPath, EVOLVER_MARKER)) {
206
+ changed = true;
207
+ }
164
208
 
165
209
  console.log(changed
166
210
  ? '[codex] Uninstalled evolver hooks.'
@@ -169,4 +213,4 @@ function uninstall({ configRoot }) {
169
213
  return { ok: true, removed: changed };
170
214
  }
171
215
 
172
- module.exports = { install, uninstall, buildCodexHooksJson, ensureConfigToml };
216
+ module.exports = { install, uninstall, buildCodexHooksJson, ensureConfigToml, cleanConfigToml };
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { mergeJsonFile, copyHookScripts, removeEvolverHooks, removeHookScripts } = require('./hookAdapter');
3
+ const { mergeJsonFile, copyHookScripts, removeEvolverHooks, removeHookScripts, assertSafeConfigDir } = require('./hookAdapter');
4
4
 
5
5
  const HOOK_SCRIPTS_DIR_NAME = 'hooks';
6
6
 
@@ -39,6 +39,7 @@ function install({ configRoot, evolverRoot, force }) {
39
39
  const cursorDir = path.join(configRoot, '.cursor');
40
40
  const hooksJsonPath = path.join(cursorDir, 'hooks.json');
41
41
  const hooksDir = path.join(cursorDir, HOOK_SCRIPTS_DIR_NAME);
42
+ assertSafeConfigDir(cursorDir, '.cursor', { subdirs: [HOOK_SCRIPTS_DIR_NAME] });
42
43
 
43
44
  if (!force && fs.existsSync(hooksJsonPath)) {
44
45
  try {
@@ -73,6 +74,7 @@ function uninstall({ configRoot, evolverRoot }) {
73
74
  const cursorDir = path.join(configRoot, '.cursor');
74
75
  const hooksJsonPath = path.join(cursorDir, 'hooks.json');
75
76
  const hooksDir = path.join(cursorDir, HOOK_SCRIPTS_DIR_NAME);
77
+ assertSafeConfigDir(cursorDir, '.cursor', { subdirs: [HOOK_SCRIPTS_DIR_NAME] });
76
78
 
77
79
  const removed = removeEvolverHooks(hooksJsonPath);
78
80
  const scripts = removeHookScripts(hooksDir);
@@ -51,7 +51,7 @@ function mergeJsonFile(filePath, patch, { markerKey = '_evolver_managed' } = {})
51
51
  if (raw) existing = JSON.parse(raw);
52
52
  }
53
53
  } catch { /* start fresh */ }
54
- const merged = deepMerge(existing, patch);
54
+ const merged = mergeWithHooksUnion(existing, patch);
55
55
  merged[markerKey] = true;
56
56
  const tmp = filePath + '.tmp';
57
57
  fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf8');
@@ -59,6 +59,48 @@ function mergeJsonFile(filePath, patch, { markerKey = '_evolver_managed' } = {})
59
59
  return merged;
60
60
  }
61
61
 
62
+ // Like deepMerge, but for `hooks.<event>` arrays specifically: instead of
63
+ // replacing the user's existing entries, keep them and append/refresh evolver-
64
+ // owned entries (matched by command containing `evolver-session/-signal`).
65
+ // This preserves user-installed Stop/SessionStart hooks (#539) while still
66
+ // updating evolver hooks across reinstalls.
67
+ function mergeWithHooksUnion(target, source) {
68
+ const result = deepMerge(target, source);
69
+ if (
70
+ target && target.hooks && typeof target.hooks === 'object' &&
71
+ source && source.hooks && typeof source.hooks === 'object'
72
+ ) {
73
+ for (const event of Object.keys(source.hooks)) {
74
+ const tArr = Array.isArray(target.hooks[event]) ? target.hooks[event] : null;
75
+ const sArr = Array.isArray(source.hooks[event]) ? source.hooks[event] : null;
76
+ if (tArr && sArr) {
77
+ const isEvolverOwned = (entry) => {
78
+ const cmds = collectCommands(entry);
79
+ return cmds.some(c => c.includes('evolver-session') || c.includes('evolver-signal'));
80
+ };
81
+ const userEntries = tArr.filter(e => !isEvolverOwned(e));
82
+ result.hooks[event] = [...userEntries, ...sArr];
83
+ }
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+
89
+ // Pull all `command` strings out of an event entry, supporting both flat
90
+ // shape (Codex: `{type, command}`) and Claude Code matcher shape
91
+ // (`{matcher, hooks: [{type, command}]}`). Returns [] when neither applies.
92
+ function collectCommands(entry) {
93
+ if (!entry || typeof entry !== 'object') return [];
94
+ const out = [];
95
+ if (typeof entry.command === 'string') out.push(entry.command);
96
+ if (Array.isArray(entry.hooks)) {
97
+ for (const h of entry.hooks) {
98
+ if (h && typeof h.command === 'string') out.push(h.command);
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
62
104
  function deepMerge(target, source) {
63
105
  const result = { ...target };
64
106
  for (const key of Object.keys(source)) {
@@ -74,9 +116,49 @@ function deepMerge(target, source) {
74
116
  return result;
75
117
  }
76
118
 
119
+ // Refuse to write/read through a symbolic link at the adapter's
120
+ // platform config dir (`<root>/.codex`, `<root>/.claude`, …) or any
121
+ // nested adapter-owned subdir (`hooks/`, `plugins/`, …). A
122
+ // repository-controlled symlink at any of these paths would let
123
+ // install/uninstall writes land on attacker-chosen files outside the
124
+ // workspace (PR #94 round-4 surfaced the top-level case; round-5
125
+ // surfaced that a hostile repo can keep `.codex` real and only
126
+ // symlink `.codex/hooks`). Missing dirs are fine — install will
127
+ // create them.
128
+ function assertSafeConfigDir(dir, label, { subdirs = [] } = {}) {
129
+ assertNotSymlink(dir, label || 'config dir');
130
+ for (const sub of subdirs) {
131
+ assertNotSymlink(path.join(dir, sub), `${label || 'config dir'}/${sub}`);
132
+ }
133
+ }
134
+
135
+ function assertNotSymlink(p, label) {
136
+ let st;
137
+ try {
138
+ st = fs.lstatSync(p);
139
+ } catch (e) {
140
+ if (e && e.code === 'ENOENT') return;
141
+ throw e;
142
+ }
143
+ if (st.isSymbolicLink()) {
144
+ throw new Error(
145
+ `[setup-hooks] Refusing to operate: ${label} ${p} is a ` +
146
+ `symbolic link. evolver will not follow symlinks for ` +
147
+ `adapter-owned dirs — a hostile workspace could redirect ` +
148
+ `writes/unlinks outside the project root. Replace it with a ` +
149
+ `real directory and rerun.`
150
+ );
151
+ }
152
+ }
153
+
77
154
  function copyHookScripts(destDir, evolverRoot) {
78
155
  const scriptsDir = path.join(evolverRoot || __dirname, 'scripts');
156
+ // _runtimePaths.js is required by the two session-* scripts via
157
+ // `require('./_runtimePaths')`, which resolves relative to the *destination*
158
+ // (__dirname after copy). It MUST be copied alongside or both hooks crash
159
+ // with MODULE_NOT_FOUND at runtime. Caught in PR #94 review.
79
160
  const scripts = [
161
+ '_runtimePaths.js',
80
162
  'evolver-session-start.js',
81
163
  'evolver-signal-detect.js',
82
164
  'evolver-session-end.js',
@@ -90,6 +172,13 @@ function copyHookScripts(destDir, evolverRoot) {
90
172
  console.warn(`[setup-hooks] Warning: script not found: ${src}`);
91
173
  continue;
92
174
  }
175
+ // PR #94 round-6 HIGH: reject if the destination is a pre-planted
176
+ // symlink. fs.copyFileSync follows symlinks at the destination, so
177
+ // a hostile repo that pre-creates `.codex/hooks/evolver-session-end.js`
178
+ // pointing at e.g. `~/.bashrc` would have its target overwritten with
179
+ // evolver script content. Round-5 closed the directory hole; this
180
+ // closes the per-file hole.
181
+ assertNotSymlink(dest, `hook destination ${name}`);
93
182
  fs.copyFileSync(src, dest);
94
183
  try { fs.chmodSync(dest, 0o755); } catch { /* windows */ }
95
184
  copied.push(dest);
@@ -147,6 +236,7 @@ function removeEvolverHooks(filePath, { markerKey = '_evolver_managed' } = {}) {
147
236
 
148
237
  function removeHookScripts(hooksDir) {
149
238
  const scripts = [
239
+ '_runtimePaths.js',
150
240
  'evolver-session-start.js',
151
241
  'evolver-signal-detect.js',
152
242
  'evolver-session-end.js',
@@ -156,11 +246,56 @@ function removeHookScripts(hooksDir) {
156
246
  const p = path.join(hooksDir, name);
157
247
  try {
158
248
  if (fs.existsSync(p)) { fs.unlinkSync(p); removed++; }
159
- } catch { /* ignore */ }
249
+ } catch (e) {
250
+ // Surface unlink failures so users can see why a "successful"
251
+ // uninstall left files behind (Windows file-locking, perms, …).
252
+ console.warn(`[setup-hooks] Failed to remove ${p}: ${e.message || e}`);
253
+ }
160
254
  }
161
255
  return removed;
162
256
  }
163
257
 
258
+ // Remove a marker-bracketed section from a markdown file. Used by adapter
259
+ // uninstall to clean up CLAUDE.md / AGENTS.md without nuking surrounding
260
+ // user content.
261
+ //
262
+ // The previous inline implementations (codex/claude/kiro/opencode) searched
263
+ // for the *next* `\n## ` after the marker, which matched evolver's own
264
+ // `## Evolution Memory` heading and left the entire injected section in
265
+ // place (#538). This helper skips any `## ` heading on the same line as the
266
+ // marker, then looks for the next H2 to know where the user's content
267
+ // resumes.
268
+ function removeMarkedSection(filePath, marker) {
269
+ try {
270
+ if (!fs.existsSync(filePath)) return false;
271
+ const raw = fs.readFileSync(filePath, 'utf8');
272
+ const idx = raw.indexOf(marker);
273
+ if (idx === -1) return false;
274
+
275
+ // Skip past the marker line (and any heading on the same line).
276
+ let scanFrom = idx + marker.length;
277
+ const eol = raw.indexOf('\n', scanFrom);
278
+ if (eol !== -1) scanFrom = eol + 1;
279
+
280
+ // Skip past evolver's own `## ...` heading line if present.
281
+ if (raw.startsWith('## ', scanFrom)) {
282
+ const eol2 = raw.indexOf('\n', scanFrom);
283
+ scanFrom = eol2 !== -1 ? eol2 + 1 : raw.length;
284
+ }
285
+
286
+ const nextSection = raw.indexOf('\n## ', scanFrom);
287
+ const endIdx = nextSection !== -1 ? nextSection : raw.length;
288
+ const before = raw.slice(0, idx).trimEnd();
289
+ const after = nextSection !== -1 ? raw.slice(endIdx) : '';
290
+ const next = (before ? before + (after.startsWith('\n') ? '' : '\n') : '') + after;
291
+ fs.writeFileSync(filePath, next.trimEnd() + '\n', 'utf8');
292
+ return true;
293
+ } catch (e) {
294
+ console.warn(`[setup-hooks] Failed to clean section in ${filePath}: ${e.message || e}`);
295
+ return false;
296
+ }
297
+ }
298
+
164
299
  async function setupHooks({ platform, cwd, force, uninstall, evolverRoot } = {}) {
165
300
  const effectiveCwd = cwd || process.cwd();
166
301
  const effectiveEvolverRoot = evolverRoot || path.resolve(__dirname, '..');
@@ -200,10 +335,15 @@ module.exports = {
200
335
  loadAdapter,
201
336
  mergeJsonFile,
202
337
  deepMerge,
338
+ mergeWithHooksUnion,
339
+ collectCommands,
203
340
  copyHookScripts,
204
341
  appendSectionToFile,
342
+ assertSafeConfigDir,
343
+ assertNotSymlink,
205
344
  removeEvolverHooks,
206
345
  removeHookScripts,
346
+ removeMarkedSection,
207
347
  setupHooks,
208
348
  PLATFORMS,
209
349
  };