@evomap/evolver 1.84.0 → 1.84.2

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 (85) hide show
  1. package/assets/gep/genes.seed.json +17 -15
  2. package/index.js +52 -16
  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/atp/hubClient.js +3 -1
  14. package/src/config.js +20 -1
  15. package/src/evolve/guards.js +1 -1
  16. package/src/evolve/pipeline/collect.js +1 -1
  17. package/src/evolve/pipeline/dispatch.js +1 -1
  18. package/src/evolve/pipeline/enrich.js +1 -1
  19. package/src/evolve/pipeline/hub.js +1 -1
  20. package/src/evolve/pipeline/select.js +1 -1
  21. package/src/evolve/pipeline/signals.js +1 -1
  22. package/src/evolve/utils.js +1 -1
  23. package/src/evolve.js +1 -1
  24. package/src/forceUpdate.js +5 -21
  25. package/src/gep/a2aProtocol.js +1 -1
  26. package/src/gep/assetStore.js +27 -6
  27. package/src/gep/candidateEval.js +1 -1
  28. package/src/gep/candidates.js +1 -1
  29. package/src/gep/contentHash.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/directoryClient.js +4 -3
  34. package/src/gep/envFingerprint.js +1 -1
  35. package/src/gep/epigenetics.js +1 -1
  36. package/src/gep/explore.js +1 -1
  37. package/src/gep/gitOps.js +0 -5
  38. package/src/gep/hash.js +1 -1
  39. package/src/gep/hubFetch.js +1 -0
  40. package/src/gep/hubReview.js +1 -1
  41. package/src/gep/hubSearch.js +1 -1
  42. package/src/gep/hubVerify.js +1 -1
  43. package/src/gep/learningSignals.js +1 -1
  44. package/src/gep/mailboxTransport.js +8 -5
  45. package/src/gep/memoryGraph.js +1 -1
  46. package/src/gep/memoryGraphAdapter.js +1 -1
  47. package/src/gep/mutation.js +1 -1
  48. package/src/gep/narrativeMemory.js +1 -1
  49. package/src/gep/openPRRegistry.js +1 -1
  50. package/src/gep/personality.js +1 -1
  51. package/src/gep/policyCheck.js +1 -1
  52. package/src/gep/prompt.js +1 -1
  53. package/src/gep/recallVerifier.js +1 -1
  54. package/src/gep/reflection.js +1 -1
  55. package/src/gep/sanitize.js +2 -1
  56. package/src/gep/schemas/gene.js +70 -1
  57. package/src/gep/schemas/protocol.js +9 -1
  58. package/src/gep/selector.js +1 -1
  59. package/src/gep/selfPR.js +62 -34
  60. package/src/gep/skillDistiller.js +1 -1
  61. package/src/gep/skillPublisher.js +3 -2
  62. package/src/gep/solidify.js +1 -1
  63. package/src/gep/strategy.js +1 -1
  64. package/src/gep/taskReceiver.js +6 -5
  65. package/src/gep/validator/index.js +10 -6
  66. package/src/gep/validator/reporter.js +2 -1
  67. package/src/gep/validator/stakeBootstrap.js +2 -1
  68. package/src/ops/health_check.js +1 -11
  69. package/src/ops/lifecycle.js +1 -3
  70. package/src/proxy/index.js +69 -0
  71. package/src/proxy/lifecycle/manager.js +3 -2
  72. package/src/proxy/router/cache_passthrough.js +26 -0
  73. package/src/proxy/router/features.js +84 -0
  74. package/src/proxy/router/messages_route.js +242 -0
  75. package/src/proxy/router/model_router.js +113 -0
  76. package/src/proxy/server/http.js +108 -6
  77. package/src/proxy/server/routes.js +12 -2
  78. package/src/proxy/server/settings.js +43 -10
  79. package/src/proxy/sync/inbound.js +3 -2
  80. package/src/proxy/sync/outbound.js +2 -1
  81. package/src/webui/observer/interactions.js +22 -16
  82. package/scripts/check_wrapper_compat.js +0 -113
  83. package/src/gep/.integrity +0 -0
  84. package/src/gep/integrityCheck.js +0 -1
  85. package/src/gep/shield.js +0 -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
@@ -129,9 +129,9 @@ class CycleTimeoutError extends Error {
129
129
  // in detached mode. So suicide-respawn (cycles >= max, RSS over budget, or the
130
130
  // new cycle hard-timeout) opens a new cmd popup on every restart. We now skip
131
131
  // the in-process detached spawn on Windows by default and rely on an external
132
- // supervisor (feishu-evolver-wrapper >= 1.10.0, NSSM, pm2-windows, etc.) to
133
- // respawn the daemon on non-zero exit. Users who insist can opt back in with
134
- // EVOLVER_SUICIDE_WINDOWS=true (and accept the popups).
132
+ // supervisor (NSSM, pm2-windows, etc.) to respawn the daemon on non-zero exit.
133
+ // Users who insist can opt back in with EVOLVER_SUICIDE_WINDOWS=true (and accept
134
+ // the popups).
135
135
  function spawnReplacementProcess({ reason, args, logPath }) {
136
136
  const isWindows = process.platform === 'win32';
137
137
  const allowOnWindows = parseBoolEnv(process.env.EVOLVER_SUICIDE_WINDOWS, false);
@@ -140,7 +140,7 @@ function spawnReplacementProcess({ reason, args, logPath }) {
140
140
  '[Daemon] Skipping in-process respawn on Windows (' + reason + '). ' +
141
141
  'Native Node spawn(detached, windowsHide) opens a cmd popup on every restart (Issue #528). ' +
142
142
  'Set EVOLVER_SUICIDE_WINDOWS=true to opt back in. ' +
143
- 'Recommended: run evolver under feishu-evolver-wrapper >= 1.10.0, NSSM, or pm2-windows so the supervisor restarts on exit.'
143
+ 'Recommended: run evolver under an external supervisor (NSSM, pm2-windows, etc.) so it restarts on exit.'
144
144
  );
145
145
  return { spawned: false, reason: 'windows_default_skip' };
146
146
  }
@@ -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
@@ -372,8 +409,7 @@ async function main() {
372
409
  const idleThresholdMs = parseMs(process.env.EVOLVER_IDLE_THRESHOLD_MS, 500);
373
410
  const pendingSleepMs = parseMs(
374
411
  process.env.EVOLVE_PENDING_SLEEP_MS ||
375
- process.env.EVOLVE_MIN_INTERVAL ||
376
- process.env.FEISHU_EVOLVER_INTERVAL,
412
+ process.env.EVOLVE_MIN_INTERVAL,
377
413
  120000
378
414
  );
379
415
 
@@ -732,7 +768,7 @@ async function main() {
732
768
  const summary = summaryFlag ? summaryFlag.slice('--summary='.length) : null;
733
769
 
734
770
  try {
735
- const res = solidify({
771
+ const res = await solidify({
736
772
  intent: intent || undefined,
737
773
  summary: summary || undefined,
738
774
  dryRun,
@@ -982,7 +1018,7 @@ async function main() {
982
1018
  if (args.includes('--approve')) {
983
1019
  console.log('\n[Review] Approved. Running solidify...\n');
984
1020
  try {
985
- const res = solidify({
1021
+ const res = await solidify({
986
1022
  intent: lastRun.intent || undefined,
987
1023
  rollbackOnFailure: true,
988
1024
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.84.0",
3
+ "version": "1.84.2",
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);