@evomap/evolver 1.90.0 → 2.0.0-beta.1

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 (231) hide show
  1. package/README.md +27 -564
  2. package/bin/evolver-llm-proxy.js +3 -0
  3. package/bin/evolver-mcp.js +2 -0
  4. package/bin/evolver-proxy.js +3 -0
  5. package/bin/evolver.js +4 -0
  6. package/index.js +1 -3670
  7. package/package.json +32 -62
  8. package/CONTRIBUTING.md +0 -19
  9. package/LICENSE +0 -641
  10. package/README.ja-JP.md +0 -521
  11. package/README.ko-KR.md +0 -520
  12. package/README.zh-CN.md +0 -531
  13. package/SKILL.md +0 -365
  14. package/assets/cover.png +0 -0
  15. package/assets/gep/genes.seed.json +0 -496
  16. package/conformance/savings-core/constants.json +0 -30
  17. package/conformance/savings-core/golden-vectors.json +0 -333
  18. package/scripts/a2a_export.js +0 -63
  19. package/scripts/a2a_ingest.js +0 -79
  20. package/scripts/a2a_promote.js +0 -118
  21. package/scripts/analyze_by_skill.js +0 -121
  22. package/scripts/build_binaries.js +0 -479
  23. package/scripts/check-changelog.js +0 -166
  24. package/scripts/extract_log.js +0 -85
  25. package/scripts/generate_history.js +0 -75
  26. package/scripts/gep_append_event.js +0 -96
  27. package/scripts/gep_personality_report.js +0 -234
  28. package/scripts/human_report.js +0 -147
  29. package/scripts/recall-verify-report.js +0 -234
  30. package/scripts/recover_loop.js +0 -61
  31. package/scripts/refresh_stars_badge.js +0 -168
  32. package/scripts/seed-merchants.js +0 -91
  33. package/scripts/skill2recipes.js +0 -118
  34. package/scripts/suggest_version.js +0 -89
  35. package/scripts/validate-modules.js +0 -38
  36. package/scripts/validate-suite.js +0 -78
  37. package/src/adapters/claudeCode.js +0 -194
  38. package/src/adapters/codex.js +0 -216
  39. package/src/adapters/cursor.js +0 -91
  40. package/src/adapters/hookAdapter.js +0 -469
  41. package/src/adapters/kiro.js +0 -195
  42. package/src/adapters/opencode.js +0 -326
  43. package/src/adapters/scripts/_lockPaths.js +0 -74
  44. package/src/adapters/scripts/_memoryFiltering.js +0 -35
  45. package/src/adapters/scripts/_runtimePaths.js +0 -440
  46. package/src/adapters/scripts/evolver-session-end.js +0 -321
  47. package/src/adapters/scripts/evolver-session-start.js +0 -587
  48. package/src/adapters/scripts/evolver-signal-detect.js +0 -98
  49. package/src/adapters/scripts/evolver-task-recall.js +0 -173
  50. package/src/atp/atpExecute.js +0 -283
  51. package/src/atp/atpTaskPickup.js +0 -233
  52. package/src/atp/autoBuyer.js +0 -382
  53. package/src/atp/autoDeliver.js +0 -215
  54. package/src/atp/cli.js +0 -354
  55. package/src/atp/cliAutobuyPrompt.js +0 -154
  56. package/src/atp/consumerAgent.js +0 -157
  57. package/src/atp/defaultHandler.js +0 -69
  58. package/src/atp/heartbeatSignalsHandler.js +0 -254
  59. package/src/atp/hubClient.js +0 -317
  60. package/src/atp/index.js +0 -38
  61. package/src/atp/merchantAgent.js +0 -118
  62. package/src/atp/protocol.js +0 -41
  63. package/src/atp/questionComposer.js +0 -133
  64. package/src/atp/serviceHelper.js +0 -92
  65. package/src/canary.js +0 -13
  66. package/src/config.js +0 -351
  67. package/src/evolve/guards.js +0 -1
  68. package/src/evolve/pipeline/collect.js +0 -1
  69. package/src/evolve/pipeline/dispatch.js +0 -1
  70. package/src/evolve/pipeline/enrich.js +0 -1
  71. package/src/evolve/pipeline/hub.js +0 -1
  72. package/src/evolve/pipeline/select.js +0 -1
  73. package/src/evolve/pipeline/signals.js +0 -1
  74. package/src/evolve/utils.js +0 -1
  75. package/src/evolve.js +0 -1
  76. package/src/experiment/agentRunner.js +0 -229
  77. package/src/experiment/cli.js +0 -159
  78. package/src/experiment/comparison.js +0 -233
  79. package/src/experiment/metrics.js +0 -75
  80. package/src/forceUpdate.js +0 -991
  81. package/src/gep/a2a.js +0 -173
  82. package/src/gep/a2aProtocol.js +0 -1
  83. package/src/gep/analyzer.js +0 -35
  84. package/src/gep/antiAbuseTelemetry.js +0 -1
  85. package/src/gep/assetCallLog.js +0 -197
  86. package/src/gep/assetStore.js +0 -723
  87. package/src/gep/assets.js +0 -36
  88. package/src/gep/autoDistillConv.js +0 -1
  89. package/src/gep/autoDistillLlm.js +0 -1
  90. package/src/gep/bridge.js +0 -138
  91. package/src/gep/candidateEval.js +0 -1
  92. package/src/gep/candidates.js +0 -1
  93. package/src/gep/claimNudge.js +0 -121
  94. package/src/gep/cliContracts.js +0 -1190
  95. package/src/gep/contentHash.js +0 -1
  96. package/src/gep/conversationDistiller.js +0 -1
  97. package/src/gep/conversationSniffer.js +0 -1
  98. package/src/gep/crypto.js +0 -1
  99. package/src/gep/curriculum.js +0 -1
  100. package/src/gep/deviceId.js +0 -1
  101. package/src/gep/directoryClient.js +0 -115
  102. package/src/gep/envFingerprint.js +0 -1
  103. package/src/gep/epigenetics.js +0 -1
  104. package/src/gep/execBridge.js +0 -1
  105. package/src/gep/executionTrace.js +0 -291
  106. package/src/gep/explore.js +0 -1
  107. package/src/gep/featureFlags.js +0 -121
  108. package/src/gep/gitOps.js +0 -265
  109. package/src/gep/hash.js +0 -1
  110. package/src/gep/hostErrorClassifier.js +0 -34
  111. package/src/gep/hubFetch.js +0 -1
  112. package/src/gep/hubReview.js +0 -1
  113. package/src/gep/hubSearch.js +0 -1
  114. package/src/gep/hubVerify.js +0 -1
  115. package/src/gep/idleScheduler.js +0 -400
  116. package/src/gep/issueReporter.js +0 -416
  117. package/src/gep/learningSignals.js +0 -1
  118. package/src/gep/llmReview.js +0 -92
  119. package/src/gep/localStateAwareness.js +0 -243
  120. package/src/gep/mailboxTransport.js +0 -119
  121. package/src/gep/memoryGraph.js +0 -1
  122. package/src/gep/memoryGraphAdapter.js +0 -1
  123. package/src/gep/mutation.js +0 -1
  124. package/src/gep/narrativeMemory.js +0 -1
  125. package/src/gep/oauthLogin.js +0 -181
  126. package/src/gep/openPRRegistry.js +0 -1
  127. package/src/gep/paths.js +0 -522
  128. package/src/gep/personality.js +0 -1
  129. package/src/gep/policyCheck.js +0 -1
  130. package/src/gep/portable.js +0 -103
  131. package/src/gep/privacyClient.js +0 -235
  132. package/src/gep/prompt.js +0 -1
  133. package/src/gep/questionGenerator.js +0 -518
  134. package/src/gep/recallInject.js +0 -1
  135. package/src/gep/recallVerifier.js +0 -1
  136. package/src/gep/reflection.js +0 -1
  137. package/src/gep/sanitize.js +0 -264
  138. package/src/gep/savingsCore.js +0 -1
  139. package/src/gep/schemas/capsule.js +0 -170
  140. package/src/gep/schemas/gene.js +0 -154
  141. package/src/gep/schemas/index.js +0 -8
  142. package/src/gep/schemas/protocol.js +0 -51
  143. package/src/gep/schemas/task.js +0 -74
  144. package/src/gep/selector.js +0 -1
  145. package/src/gep/selfPR.js +0 -469
  146. package/src/gep/signals.js +0 -776
  147. package/src/gep/skill2gep.js +0 -1056
  148. package/src/gep/skill2gepAudit.js +0 -303
  149. package/src/gep/skill2recipes.js +0 -511
  150. package/src/gep/skillDistiller.js +0 -1
  151. package/src/gep/skillPublisher.js +0 -358
  152. package/src/gep/solidify.js +0 -1
  153. package/src/gep/strategy.js +0 -1
  154. package/src/gep/taskReceiver.js +0 -575
  155. package/src/gep/tokenSavings.js +0 -1
  156. package/src/gep/trajectoryExport.js +0 -1
  157. package/src/gep/validationReport.js +0 -55
  158. package/src/gep/validator/index.js +0 -411
  159. package/src/gep/validator/reporter.js +0 -210
  160. package/src/gep/validator/sandboxExecutor.js +0 -480
  161. package/src/gep/validator/stakeBootstrap.js +0 -357
  162. package/src/gep/workspaceKeychain.js +0 -1
  163. package/src/ops/cleanup.js +0 -80
  164. package/src/ops/commentary.js +0 -60
  165. package/src/ops/health_check.js +0 -104
  166. package/src/ops/index.js +0 -11
  167. package/src/ops/innovation.js +0 -67
  168. package/src/ops/lifecycle.js +0 -798
  169. package/src/ops/self_repair.js +0 -76
  170. package/src/ops/skills_monitor.js +0 -147
  171. package/src/ops/trigger.js +0 -33
  172. package/src/proxy/clientSettings.js +0 -405
  173. package/src/proxy/envelope.js +0 -59
  174. package/src/proxy/extensions/dmHandler.js +0 -45
  175. package/src/proxy/extensions/sessionHandler.js +0 -141
  176. package/src/proxy/extensions/skillUpdater.js +0 -64
  177. package/src/proxy/extensions/traceControl.js +0 -1
  178. package/src/proxy/index.js +0 -1395
  179. package/src/proxy/inject.js +0 -1
  180. package/src/proxy/lifecycle/manager.js +0 -1568
  181. package/src/proxy/mailbox/state.js +0 -207
  182. package/src/proxy/mailbox/store.js +0 -602
  183. package/src/proxy/router/cache_passthrough.js +0 -26
  184. package/src/proxy/router/features.js +0 -84
  185. package/src/proxy/router/gemini_route.js +0 -154
  186. package/src/proxy/router/messages_route.js +0 -535
  187. package/src/proxy/router/model_router.js +0 -113
  188. package/src/proxy/router/models_route.js +0 -52
  189. package/src/proxy/router/ollama_route.js +0 -103
  190. package/src/proxy/router/responses_route.js +0 -170
  191. package/src/proxy/router/vertex_route.js +0 -110
  192. package/src/proxy/server/http.js +0 -363
  193. package/src/proxy/server/routes.js +0 -558
  194. package/src/proxy/server/settings.js +0 -115
  195. package/src/proxy/sync/engine.js +0 -179
  196. package/src/proxy/sync/inbound.js +0 -211
  197. package/src/proxy/sync/outbound.js +0 -361
  198. package/src/proxy/task/monitor.js +0 -131
  199. package/src/proxy/trace/extractor.js +0 -1
  200. package/src/proxy/trace/usage.js +0 -1
  201. package/src/solo/breaker.js +0 -25
  202. package/src/solo/gitGuard.js +0 -65
  203. package/src/webui/client/clientJs/assets.js +0 -111
  204. package/src/webui/client/clientJs/bootstrap.js +0 -92
  205. package/src/webui/client/clientJs/common.js +0 -77
  206. package/src/webui/client/clientJs/i18n.js +0 -366
  207. package/src/webui/client/clientJs/index.js +0 -35
  208. package/src/webui/client/clientJs/interactions.js +0 -351
  209. package/src/webui/client/clientJs/overview.js +0 -152
  210. package/src/webui/client/clientJs/personality.js +0 -285
  211. package/src/webui/client/clientJs/pipelines.js +0 -330
  212. package/src/webui/client/indexHtml.js +0 -221
  213. package/src/webui/client/static.js +0 -23
  214. package/src/webui/client/stylesCss.js +0 -639
  215. package/src/webui/client/vendor/README.md +0 -15
  216. package/src/webui/client/vendor/echarts.min.js +0 -45
  217. package/src/webui/index.js +0 -14
  218. package/src/webui/observer/assets.js +0 -146
  219. package/src/webui/observer/index.js +0 -37
  220. package/src/webui/observer/interactions.js +0 -127
  221. package/src/webui/observer/jsonl.js +0 -75
  222. package/src/webui/observer/paths.js +0 -46
  223. package/src/webui/observer/personality.js +0 -43
  224. package/src/webui/observer/pipelineEvents.js +0 -58
  225. package/src/webui/observer/redact.js +0 -63
  226. package/src/webui/observer/runs.js +0 -356
  227. package/src/webui/observer/safety.js +0 -57
  228. package/src/webui/observer/skills.js +0 -70
  229. package/src/webui/observer/status.js +0 -71
  230. package/src/webui/server/http.js +0 -138
  231. package/src/webui/server/routes.js +0 -41
package/index.js CHANGED
@@ -1,3670 +1 @@
1
- #!/usr/bin/env node
2
- function _parseBootstrapSemver(version) {
3
- const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(version || ''));
4
- if (!match) return null;
5
- return {
6
- major: match[1],
7
- minor: match[2],
8
- patch: match[3],
9
- prerelease: match[4] ? match[4].split('.') : [],
10
- };
11
- }
12
-
13
- function _compareBootstrapNumeric(left, right) {
14
- if (left.length !== right.length) return left.length - right.length;
15
- if (left < right) return -1;
16
- if (left > right) return 1;
17
- return 0;
18
- }
19
-
20
- function _compareBootstrapPrerelease(left, right) {
21
- const leftNumeric = /^\d+$/.test(left);
22
- const rightNumeric = /^\d+$/.test(right);
23
- if (leftNumeric && rightNumeric) return _compareBootstrapNumeric(left, right);
24
- if (leftNumeric) return -1;
25
- if (rightNumeric) return 1;
26
- if (left < right) return -1;
27
- if (left > right) return 1;
28
- return 0;
29
- }
30
-
31
- function _compareBootstrapSemver(left, right) {
32
- const a = _parseBootstrapSemver(left);
33
- const b = _parseBootstrapSemver(right);
34
- if (!a || !b) return null;
35
- for (const key of ['major', 'minor', 'patch']) {
36
- const cmp = _compareBootstrapNumeric(a[key], b[key]);
37
- if (cmp !== 0) return cmp;
38
- }
39
- if (!a.prerelease.length && !b.prerelease.length) return 0;
40
- if (!a.prerelease.length) return 1;
41
- if (!b.prerelease.length) return -1;
42
- const max = Math.max(a.prerelease.length, b.prerelease.length);
43
- for (let i = 0; i < max; i++) {
44
- if (a.prerelease[i] === undefined) return -1;
45
- if (b.prerelease[i] === undefined) return 1;
46
- const cmp = _compareBootstrapPrerelease(a.prerelease[i], b.prerelease[i]);
47
- if (cmp !== 0) return cmp;
48
- }
49
- return 0;
50
- }
51
-
52
- function _bootstrapVersionSatisfies(currentVersion, requiredVersion) {
53
- if (String(currentVersion || '') === String(requiredVersion || '')) return true;
54
- const cmp = _compareBootstrapSemver(currentVersion, requiredVersion);
55
- return cmp !== null && cmp >= 0;
56
- }
57
-
58
- function _failClosedForceUpdateBootstrap(backupName, entryName, error) {
59
- const detail = error && error.message ? error.message : String(error || 'unknown error');
60
- console.error('[ForceUpdate] Bootstrap recovery failed for ' + backupName +
61
- (entryName ? ' while restoring ' + entryName : '') + ': ' + detail);
62
- console.error('[ForceUpdate] Refusing to continue startup; recovery backup and journal were left in place.');
63
- process.exit(1);
64
- }
65
-
66
- function _recoverInterruptedForceUpdateBootstrap() {
67
- const fs = require('fs');
68
- const path = require('path');
69
- const installRoot = __dirname;
70
- const backupPrefix = '.evolver-force-update-backup-';
71
- const journalName = '.evolver-force-update-journal.json';
72
- let backups = [];
73
- try {
74
- backups = fs.readdirSync(installRoot)
75
- .filter((name) => name.startsWith(backupPrefix))
76
- .sort()
77
- .reverse();
78
- } catch (_) {
79
- return false;
80
- }
81
- for (const backupName of backups) {
82
- const backupRoot = path.join(installRoot, backupName);
83
- const journalPath = path.join(backupRoot, journalName);
84
- let journal = null;
85
- try {
86
- journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
87
- } catch (_) {
88
- // Not a force-update recovery journal; leave unrelated directories alone.
89
- continue;
90
- }
91
- if (!journal || journal.state !== 'precommit' || !journal.requiredVersion) continue;
92
- let currentVersion = '';
93
- try {
94
- const pkg = JSON.parse(fs.readFileSync(path.join(installRoot, 'package.json'), 'utf8'));
95
- currentVersion = pkg && pkg.version ? String(pkg.version) : '';
96
- } catch (_) {
97
- // If the package marker is unreadable, prefer restoring the old payload.
98
- }
99
- if (_bootstrapVersionSatisfies(currentVersion, String(journal.requiredVersion))) {
100
- try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch (_) {
101
- // Cleanup failure is non-fatal; normal startup can continue.
102
- }
103
- continue;
104
- }
105
- let entries = [];
106
- try {
107
- entries = fs.readdirSync(backupRoot, { withFileTypes: true });
108
- } catch (readErr) {
109
- _failClosedForceUpdateBootstrap(backupName, '', readErr);
110
- }
111
- for (const entry of entries) {
112
- if (entry.name === journalName) continue;
113
- const livePath = path.join(installRoot, entry.name);
114
- const backupPath = path.join(backupRoot, entry.name);
115
- try {
116
- if (entry.name === 'index.js') {
117
- const tmpPath = livePath + '.' + process.pid + '.recover-tmp';
118
- try { fs.rmSync(tmpPath, { force: true }); } catch (_) {}
119
- fs.copyFileSync(backupPath, tmpPath);
120
- try {
121
- const backupStat = fs.statSync(backupPath);
122
- fs.chmodSync(tmpPath, backupStat.mode & 0o777);
123
- } catch (_) {
124
- // Recovery can proceed without mode restoration; npm/service launches
125
- // normally use `node index.js` after an interrupted update.
126
- }
127
- fs.renameSync(tmpPath, livePath);
128
- fs.rmSync(backupPath, { force: true });
129
- } else {
130
- fs.rmSync(livePath, { recursive: true, force: true });
131
- fs.renameSync(backupPath, livePath);
132
- }
133
- } catch (restoreErr) {
134
- _failClosedForceUpdateBootstrap(backupName, entry.name, restoreErr);
135
- }
136
- }
137
- try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch (_) {
138
- // Best-effort cleanup; recovery already restored the payload.
139
- }
140
- console.warn('[ForceUpdate] Recovered interrupted install from ' + backupName);
141
- return true;
142
- }
143
- return false;
144
- }
145
-
146
- _recoverInterruptedForceUpdateBootstrap();
147
-
148
- function _printProxyTokenUsage(out = process.stderr) {
149
- out.write('Usage: node index.js proxy-token [--settings FILE]\n');
150
- }
151
-
152
- function _readProxyTokenFromSettingsFile(fs, settingsFile) {
153
- try {
154
- const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
155
- return parsed && parsed.proxy && typeof parsed.proxy.token === 'string'
156
- ? parsed.proxy.token
157
- : '';
158
- } catch {
159
- return '';
160
- }
161
- }
162
-
163
- // `proxy-token` is a credential helper for Codex. Handle it before loading any
164
- // project .env so a workspace cannot change EVOLVER_SETTINGS_DIR or other local
165
- // state used to find the proxy token.
166
- if (process.argv[2] === 'proxy-token') {
167
- try {
168
- const _fs = require('fs');
169
- const _os = require('os');
170
- const _path = require('path');
171
- let settingsFile = '';
172
- for (let i = 3; i < process.argv.length; i++) {
173
- const arg = process.argv[i];
174
- if (arg === '-h' || arg === '--help') {
175
- _printProxyTokenUsage(process.stdout);
176
- process.exit(0);
177
- }
178
- if (arg === '--settings') {
179
- if (!process.argv[i + 1]) {
180
- _printProxyTokenUsage();
181
- console.error('[proxy-token] missing value for --settings');
182
- process.exit(2);
183
- }
184
- settingsFile = process.argv[i + 1];
185
- i++;
186
- continue;
187
- }
188
- _printProxyTokenUsage();
189
- console.error('[proxy-token] unknown argument');
190
- process.exit(2);
191
- }
192
- const defaultSettingsFile = _path.join(
193
- process.env.EVOLVER_SETTINGS_DIR || _path.join(_os.homedir(), '.evolver'),
194
- 'settings.json',
195
- );
196
- const token = _readProxyTokenFromSettingsFile(_fs, settingsFile || defaultSettingsFile);
197
- if (!token) {
198
- console.error('[proxy-token] no active proxy token found; start evolver with EVOMAP_PROXY=1 first');
199
- process.exit(1);
200
- }
201
- process.stdout.write(token + '\n');
202
- process.exit(0);
203
- } catch (e) {
204
- console.error('[proxy-token] Failed:', e && e.message || e);
205
- process.exit(1);
206
- }
207
- }
208
-
209
- // Load .env BEFORE any internal require so that a2aProtocol and ATP
210
- // modules see A2A_NODE_SECRET / A2A_NODE_ID / A2A_HUB_URL at first
211
- // access and never fall back to a stale persisted/cached secret.
212
- // Reported in #460.
213
- //
214
- // Load order matters (see #526): we must not call getRepoRoot() before
215
- // .env is loaded, otherwise EVOLVER_REPO_ROOT set in .env is silently
216
- // ignored because getRepoRoot() caches the .git-walk result on first
217
- // call. Strategy:
218
- // 1. Try .env at process.cwd() first. This is where a user running
219
- // `evolver` from their project root expects the file, and it is
220
- // independent of getRepoRoot() caching.
221
- // 2. Read EVOLVER_REPO_ROOT from process.env (dotenv just populated it
222
- // if set in cwd/.env).
223
- // 3. Only now call getRepoRoot(), which will honor EVOLVER_REPO_ROOT
224
- // if present; then try .env at that root as well (dotenv never
225
- // overwrites already-set keys, so step 1 wins when both exist).
226
- try {
227
- const _path = require('path');
228
- // Step 1: load .env from process.cwd() before any internal require.
229
- // Matches the regression test for #460 which asserts
230
- // `require('dotenv').config` appears before any ./src/* require other
231
- // than ./src/gep/paths.
232
- require('dotenv').config({ path: _path.join(process.cwd(), '.env') });
233
- // Suppress the "Using host git repository at" banner during bootstrap.
234
- // If .env at the discovered root overrides EVOLVER_REPO_ROOT, the
235
- // initial banner would point at the wrong path and mislead users
236
- // debugging the very chicken-and-egg problem #526 reported. The banner
237
- // prints for real when getRepoRoot() is called later by application code.
238
- const _prevQuiet = process.env.EVOLVER_QUIET_PARENT_GIT;
239
- process.env.EVOLVER_QUIET_PARENT_GIT = '1';
240
- const { getRepoRoot: _getRepoRoot } = require('./src/gep/paths');
241
- const _root = _getRepoRoot();
242
- if (_root && _root !== process.cwd()) {
243
- require('dotenv').config({ path: _path.join(_root, '.env') });
244
- }
245
- if (_prevQuiet === undefined) delete process.env.EVOLVER_QUIET_PARENT_GIT;
246
- else process.env.EVOLVER_QUIET_PARENT_GIT = _prevQuiet;
247
- } catch (e) { /* dotenv is optional */ }
248
-
249
- const evolve = require('./src/evolve');
250
- const { solidify } = require('./src/gep/solidify');
251
- const path = require('path');
252
- const os = require('os');
253
- const { getRepoRoot } = require('./src/gep/paths');
254
- const fs = require('fs');
255
- const { spawn } = require('child_process');
256
-
257
- // Interruptible sleep: SIGCONT (and any future wake hook) can short-circuit
258
- // pending sleeps so a daemon that just woke from macOS sleep doesn't sit
259
- // out the rest of its pre-sleep adaptive-sleep window on the resumed
260
- // monotonic clock. Without this, the heartbeat side recovers via the
261
- // drift detector but the outer evolve cycle stays paused up to maxSleepMs
262
- // (default 5 min) after wake. Each call tracks its own resolver in
263
- // _activeSleeps so the wake hook can resolve all of them.
264
- const _activeSleeps = new Set();
265
- function sleepMs(ms) {
266
- const n = parseInt(String(ms), 10);
267
- const t = Number.isFinite(n) ? Math.max(0, n) : 0;
268
- return new Promise(resolve => {
269
- let done = false;
270
- const finish = () => {
271
- if (done) return;
272
- done = true;
273
- clearTimeout(timer);
274
- _activeSleeps.delete(finish);
275
- resolve();
276
- };
277
- const timer = setTimeout(finish, t);
278
- // NOTE: intentionally NOT calling timer.unref() here. When the daemon is in
279
- // a long adaptive sleep (up to maxSleepMs = 5 min by default), this timer is
280
- // often the ONLY ref'd handle keeping the event loop alive. All other timers
281
- // (_heartbeatTimer, _heartbeatDriftInterval, _selfDrivingPollTimer, etc.) are
282
- // unref'd, so once the evolve loop's sleepMs timer was also unref'd, Node.js
283
- // could see zero ref'd handles and silently exit the process mid-sleep. That
284
- // was the root cause of "first launch ok, idle for a while, then evolver dead
285
- // with no log trace" on macOS. A ref'd sleep timer is the load-bearing event-
286
- // loop anchor during idle periods; it fires within maxSleepMs and the daemon
287
- // then reschedules itself normally. Leaving it ref'd has no observable cost.
288
- _activeSleeps.add(finish);
289
- });
290
- }
291
- function _interruptAllSleeps() {
292
- if (_activeSleeps.size === 0) return;
293
- // Snapshot first because resolvers mutate the set as they run.
294
- const finishers = Array.from(_activeSleeps);
295
- for (const fn of finishers) {
296
- try { fn(); } catch (_) {}
297
- }
298
- }
299
-
300
- // Round-6 (§19.5): heartbeat-internal wake recovery (drainPool +
301
- // pokeHeartbeat + SSE restart + self-driving-poll re-arm) lives in
302
- // a2aProtocol so the drift detector can drive it directly. Process-
303
- // level wake hooks (sleepMs interrupter, validator daemon poke) are
304
- // registered with a2aProtocol so both the SIGCONT handler and the
305
- // drift detector long-sleep branch run them. Lazy-register so requires
306
- // resolve cleanly under test (single Set of registered hooks; cheap to
307
- // re-register idempotently).
308
- let _wakeHooksRegistered = false;
309
- function _registerProcessWakeHooks() {
310
- if (_wakeHooksRegistered) return;
311
- try {
312
- const a2a = require('./src/gep/a2aProtocol.js');
313
- if (typeof a2a.registerWakeHook !== 'function') return;
314
- a2a.registerWakeHook(function () {
315
- try { _interruptAllSleeps(); } catch (_) {}
316
- });
317
- // R13: guards.sleepMs is a separate private helper used for 60-120s
318
- // backoffs inside evolve.run() arms (active-sessions, system-load,
319
- // pending-solidify). Without this hook, a guard sleep that spans
320
- // macOS suspend would block the cycle for the full window on the
321
- // resumed monotonic clock even though the outer sleep was interrupted.
322
- a2a.registerWakeHook(function () {
323
- try {
324
- const guards = require('./src/evolve/guards');
325
- if (guards && typeof guards._interruptGuardSleeps === 'function') {
326
- guards._interruptGuardSleeps();
327
- }
328
- } catch (_) {}
329
- });
330
- a2a.registerWakeHook(function () {
331
- try {
332
- const v = require('./src/gep/validator');
333
- if (v && typeof v.pokeValidatorDaemon === 'function') {
334
- v.pokeValidatorDaemon();
335
- }
336
- } catch (_) {}
337
- });
338
- _wakeHooksRegistered = true;
339
- } catch (_) {}
340
- }
341
-
342
- function readJsonSafe(p) {
343
- try {
344
- if (!fs.existsSync(p)) return null;
345
- const raw = fs.readFileSync(p, 'utf8');
346
- if (!raw.trim()) return null;
347
- return JSON.parse(raw);
348
- } catch (e) {
349
- return null;
350
- }
351
- }
352
-
353
- /**
354
- * Mark a pending evolution run as rejected (state-only, no git rollback).
355
- * @param {string} statePath - Path to evolution_solidify_state.json
356
- * @returns {boolean} true if a pending run was found and rejected
357
- */
358
- function rejectPendingRun(statePath) {
359
- try {
360
- const state = readJsonSafe(statePath);
361
- if (state && state.last_run && state.last_run.run_id) {
362
- state.last_solidify = {
363
- run_id: state.last_run.run_id,
364
- rejected: true,
365
- reason: 'loop_bridge_disabled_autoreject_no_rollback',
366
- timestamp: new Date().toISOString(),
367
- };
368
- const tmp = `${statePath}.tmp`;
369
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
370
- fs.renameSync(tmp, statePath);
371
- return true;
372
- }
373
- } catch (e) {
374
- console.warn('[Loop] Failed to clear pending run state: ' + (e.message || e));
375
- }
376
-
377
- return false;
378
- }
379
-
380
- function isPendingSolidify(state) {
381
- const lastRun = state && state.last_run ? state.last_run : null;
382
- const lastSolid = state && state.last_solidify ? state.last_solidify : null;
383
- if (!lastRun || !lastRun.run_id) return false;
384
- if (!lastSolid || !lastSolid.run_id) return true;
385
- return String(lastSolid.run_id) !== String(lastRun.run_id);
386
- }
387
-
388
- function parseMs(v, fallback) {
389
- const n = parseInt(String(v == null ? '' : v), 10);
390
- if (Number.isFinite(n)) return Math.max(0, n);
391
- return fallback;
392
- }
393
-
394
- function parseBoolEnv(v, fallback) {
395
- if (v == null) return fallback;
396
- const s = String(v).toLowerCase().trim();
397
- if (s === '' ) return fallback;
398
- if (s === 'false' || s === '0' || s === 'off' || s === 'no') return false;
399
- if (s === 'true' || s === '1' || s === 'on' || s === 'yes') return true;
400
- return fallback;
401
- }
402
-
403
- class CycleTimeoutError extends Error {
404
- constructor(timeoutMs, phase, cycleNum) {
405
- super('Cycle hard-timeout exceeded after ' + timeoutMs + 'ms (cycle=' + cycleNum + ', phase=' + phase + ')');
406
- this.name = 'CycleTimeoutError';
407
- this.code = 'CYCLE_TIMEOUT';
408
- this.timeoutMs = timeoutMs;
409
- this.phase = phase;
410
- this.cycleNum = cycleNum;
411
- }
412
- }
413
-
414
- // Issue #528: on Windows, child_process.spawn(detached: true, windowsHide: true)
415
- // allocates a new conhost window every time -- windowsHide is silently ignored
416
- // in detached mode. So suicide-respawn (cycles >= max, RSS over budget, or the
417
- // new cycle hard-timeout) opens a new cmd popup on every restart. We now skip
418
- // the in-process detached spawn on Windows by default and rely on an external
419
- // supervisor (NSSM, pm2-windows, etc.) to respawn the daemon on non-zero exit.
420
- // Users who insist can opt back in with EVOLVER_SUICIDE_WINDOWS=true (and accept
421
- // the popups).
422
- function spawnReplacementProcess({ reason, args, logPath }) {
423
- const isWindows = process.platform === 'win32';
424
- const allowOnWindows = parseBoolEnv(process.env.EVOLVER_SUICIDE_WINDOWS, false);
425
- if (isWindows && !allowOnWindows) {
426
- console.log(
427
- '[Daemon] Skipping in-process respawn on Windows (' + reason + '). ' +
428
- 'Native Node spawn(detached, windowsHide) opens a cmd popup on every restart (Issue #528). ' +
429
- 'Set EVOLVER_SUICIDE_WINDOWS=true to opt back in. ' +
430
- 'Recommended: run evolver under an external supervisor (NSSM, pm2-windows, etc.) so it restarts on exit.'
431
- );
432
- return { spawned: false, reason: 'windows_default_skip' };
433
- }
434
- try {
435
- const logFd = fs.openSync(logPath, 'a');
436
- const spawnOpts = {
437
- detached: true,
438
- stdio: ['ignore', logFd, logFd],
439
- env: process.env,
440
- windowsHide: true,
441
- };
442
- const child = spawn(process.execPath, [__filename, ...args], spawnOpts);
443
- child.unref();
444
- return { spawned: true };
445
- } catch (e) {
446
- console.error('[Daemon] Spawn-replacement failed (' + reason + '): ' + (e && e.message || e));
447
- return { spawned: false, reason: 'spawn_error', error: e };
448
- }
449
- }
450
-
451
- // Atomic write of the cycle_progress.json file. Wrapper polls this file every
452
- // 60s; if updated_at goes stale beyond EVOLVE_INNER_STUCK_TIMEOUT_SEC the
453
- // wrapper treats the inner core as zombie and SIGKILLs it. See Issue #19 (the
454
- // 22-day stuck-cycle incident) and the cross-repo timeout plan for context.
455
- function writeCycleProgressAtomic(progressPath, fields) {
456
- try {
457
- const data = Object.assign({}, fields, { updated_at: Date.now() });
458
- const tmp = progressPath + '.tmp.' + process.pid;
459
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
460
- fs.renameSync(tmp, progressPath);
461
- return true;
462
- } catch (e) {
463
- return false;
464
- }
465
- }
466
-
467
- function handleCycleTimeout({ error, cycleProgressPath, progressFields, suicideEnabled, args, logPath, spawnReplacementFn }) {
468
- const msg = error && error.message ? String(error.message) : String(error);
469
- console.error('[Daemon] ' + msg);
470
-
471
- if (!suicideEnabled) {
472
- console.warn('[Daemon] Cycle hard-timeout treated as non-fatal because EVOLVER_SUICIDE=false.');
473
- writeCycleProgressAtomic(cycleProgressPath, Object.assign({}, progressFields, {
474
- phase: 'cycle_timeout_nonfatal',
475
- }));
476
- return { action: 'continue' };
477
- }
478
-
479
- writeCycleProgressAtomic(cycleProgressPath, Object.assign({}, progressFields, {
480
- phase: 'cycle_timeout_respawn',
481
- }));
482
- spawnReplacementFn({
483
- reason: 'cycle_hard_timeout',
484
- args: args,
485
- logPath: logPath,
486
- });
487
- return { action: 'respawn' };
488
- }
489
-
490
- function logTimedOutEvolveRejection(error, logFn) {
491
- const lateMsg = error && error.message ? String(error.message) : String(error);
492
- try {
493
- logFn('[Daemon] Timed-out evolve.run() eventually rejected: ' + lateMsg);
494
- } catch (logError) {
495
- const fallbackMsg = logError && logError.message ? String(logError.message) : String(logError);
496
- console.error('[Daemon] Failed to log timed-out evolve.run() rejection: ' + fallbackMsg);
497
- }
498
- }
499
-
500
- function observeTimedOutEvolvePromise(evolvePromise, logFn = console.error) {
501
- if (!evolvePromise || typeof evolvePromise.then !== 'function') {
502
- return { status: 'ignored' };
503
- }
504
-
505
- Promise.resolve(evolvePromise).then(
506
- function () {},
507
- function (error) {
508
- logTimedOutEvolveRejection(error, logFn);
509
- }
510
- );
511
-
512
- return { status: 'observing' };
513
- }
514
-
515
- async function waitForTimedOutEvolvePromise(evolvePromise, logFn = console.error) {
516
- if (!evolvePromise || typeof evolvePromise.then !== 'function') {
517
- return { status: 'ignored' };
518
- }
519
-
520
- try {
521
- await evolvePromise;
522
- return { status: 'resolved' };
523
- } catch (error) {
524
- logTimedOutEvolveRejection(error, logFn);
525
- return { status: 'rejected' };
526
- }
527
- }
528
-
529
- function getLastSignals(statePath) {
530
- try {
531
- const st = readJsonSafe(statePath);
532
- return (st && st.last_run && Array.isArray(st.last_run.signals)) ? st.last_run.signals : [];
533
- } catch (e) {
534
- return [];
535
- }
536
- }
537
-
538
- // Singleton Guard - prevent multiple evolver daemon instances.
539
- //
540
- // Lock location + lease tunables live in src/adapters/scripts/_lockPaths.js
541
- // (issue #176): the session-start hook's auto-restart guard needs the exact
542
- // same resolution, and inlining it in both places drifted. The Round-4
543
- // (per-install-mode pidfile convergence) and Round-9 (lease staleness)
544
- // history notes moved there with the code.
545
- const {
546
- getLockFilePath,
547
- lockIsStaleByLease: _lockIsStaleByLease,
548
- STALE_LOCK_TTL_MS,
549
- LOCK_REFRESH_MS,
550
- } = require('./src/adapters/scripts/_lockPaths');
551
-
552
- function _writeLockAtomic(lockFile, payload) {
553
- // Round-6 (§19.8): the previous implementation used tmp + rename, which
554
- // makes the WRITE atomic but not the OWNERSHIP claim. Two processes
555
- // could both rename their own tmp file over the same lockFile (rename
556
- // is atomic per call but successive renames overwrite each other), then
557
- // each read it back and -- if the second rename happened between the
558
- // first process's rename and its read-back -- see the OTHER process's
559
- // PID. Each then concludes "I lost the race" and exits, leaving the
560
- // lockFile owned by no live process. Symmetrically, two processes can
561
- // each see their own PID if the reads happen between their respective
562
- // renames, and both conclude they won.
563
- //
564
- // The proper primitive is link(2): given a unique tmp file, link to the
565
- // target path fails atomically with EEXIST if the target already
566
- // exists. Only one of N concurrent linkers succeeds.
567
- // NOTE(windows): mode 0o700 / 0o600 are silently ignored on Windows.
568
- // The lock directory and tmp file will NOT be owner-only on Windows.
569
- // Isolation relies solely on the user-profile directory ACLs.
570
- const dir = path.dirname(lockFile);
571
- try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (_) {}
572
- const tmp = lockFile + '.' + process.pid + '.tmp';
573
- fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
574
- // link() requires the target NOT to exist. The caller in the takeover
575
- // path has already unlinked the stale lockFile via fs.unlinkSync
576
- // (ignoring ENOENT). If a concurrent process beat us to the link, our
577
- // linkSync below throws EEXIST -- we surface that to the caller and
578
- // clean up our tmp.
579
- //
580
- // EXDEV: fs.link() fails with EXDEV when tmp and lockFile are on different
581
- // volumes (can happen on Windows when EVOLVER_LOCK_DIR points to a drive
582
- // other than the tmp dir). Fall back to renameSync, which Node.js handles
583
- // cross-device by copying + deleting. rename is not atomic in this path,
584
- // so the EEXIST guard is lost, but this is an unusual configuration and
585
- // the result is still safe (worst case: two daemons both think they won,
586
- // the second write wins, the first will exit on its next tick when it
587
- // reads back a foreign PID via the heartbeat).
588
- try {
589
- fs.linkSync(tmp, lockFile);
590
- } catch (err) {
591
- if (err && err.code === 'EXDEV') {
592
- // Cross-device: rename falls back to copy+delete inside Node.js; this
593
- // loses the atomic-EEXIST guarantee but is better than hard-failing.
594
- try {
595
- fs.renameSync(tmp, lockFile);
596
- } catch (renameErr) {
597
- try { fs.unlinkSync(tmp); } catch (_) {}
598
- throw renameErr;
599
- }
600
- return; // tmp has been consumed by renameSync, skip unlinkSync below
601
- }
602
- try { fs.unlinkSync(tmp); } catch (_) {}
603
- throw err;
604
- }
605
- try { fs.unlinkSync(tmp); } catch (_) {}
606
- }
607
-
608
- function _readLockPayload(lockFile) {
609
- try {
610
- const raw = fs.readFileSync(lockFile, 'utf8').trim();
611
- if (!raw) return null;
612
- // Backward-compat: older lock files contained only the pid as text.
613
- // Newer payloads are JSON {pid, uid, startedAt}.
614
- if (raw[0] === '{') {
615
- try { return JSON.parse(raw); } catch (_) { return null; }
616
- }
617
- const pid = parseInt(raw, 10);
618
- return Number.isFinite(pid) && pid > 0 ? { pid: pid } : null;
619
- } catch (_) { return null; }
620
- }
621
-
622
- function _lockPayload() {
623
- return JSON.stringify({
624
- pid: process.pid,
625
- uid: typeof process.getuid === 'function' ? process.getuid() : null,
626
- startedAt: new Date().toISOString(),
627
- // Round-9: marks a daemon that refreshes this lock file's mtime on a
628
- // lease (see startLockRefresh). Only when this flag is present do
629
- // acquireLock / refuseHelloIfDaemonRunning trust mtime-staleness to
630
- // reclaim a lock whose PID is alive -- the PID-reuse / SIGKILL-stale
631
- // guard. A lock written by an OLDER daemon (no flag) keeps the legacy
632
- // kill(0)-only behavior so a new binary can never falsely steal a
633
- // still-running old daemon's lock (which would run two daemons).
634
- lease: true,
635
- });
636
- }
637
-
638
- // STALE_LOCK_TTL_MS / LOCK_REFRESH_MS / _lockIsStaleByLease come from
639
- // src/adapters/scripts/_lockPaths.js (required next to getLockFilePath
640
- // above) — see issue #176 and the Round-9 history note in that module.
641
- let _lockRefreshTimer = null;
642
-
643
- // Start refreshing the lock file's mtime so other processes can tell this
644
- // daemon is alive without trusting a (recyclable) PID. unref'd: it never
645
- // keeps the event loop open on its own, but fires for as long as the daemon
646
- // is otherwise alive.
647
- function startLockRefresh() {
648
- if (_lockRefreshTimer) return;
649
- const lockFile = getLockFilePath();
650
- _lockRefreshTimer = setInterval(function () {
651
- try {
652
- const now = new Date();
653
- fs.utimesSync(lockFile, now, now);
654
- } catch (_) { /* lock gone / FS error: nothing we can do here */ }
655
- }, LOCK_REFRESH_MS);
656
- if (_lockRefreshTimer && typeof _lockRefreshTimer.unref === 'function') {
657
- _lockRefreshTimer.unref();
658
- }
659
- }
660
-
661
- function stopLockRefresh() {
662
- if (_lockRefreshTimer) {
663
- clearInterval(_lockRefreshTimer);
664
- _lockRefreshTimer = null;
665
- }
666
- }
667
-
668
- function acquireLock() {
669
- const lockFile = getLockFilePath();
670
- // NOTE(windows): mode 0o700 / 0o600 are silently ignored on Windows.
671
- // Lock directory and file permissions provide no OS-level isolation on
672
- // Windows; rely on user-profile directory ACLs (%USERPROFILE%\.evomap).
673
- try {
674
- try { fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 }); } catch (_) {}
675
- try {
676
- fs.writeFileSync(lockFile, _lockPayload(), { flag: 'wx', mode: 0o600 });
677
- return true;
678
- } catch (exclErr) {
679
- if (exclErr.code !== 'EEXIST') throw exclErr;
680
- }
681
- const payload = _readLockPayload(lockFile);
682
- if (!payload || !Number.isFinite(payload.pid) || payload.pid <= 0) {
683
- console.log('[Singleton] Corrupt lock file. Taking over.');
684
- } else if (_lockIsStaleByLease(lockFile, payload)) {
685
- // Round-9: a lease-aware daemon has not refreshed this lock's mtime
686
- // within the stale TTL. Either it was SIGKILLed/crashed, or its PID
687
- // has since been reused by an unrelated process (kill(0) below would
688
- // then falsely report it alive and we would refuse to start forever).
689
- // The expired lease is authoritative: take over.
690
- console.log('[Singleton] Lock lease expired (PID ' + payload.pid + ', no mtime refresh for > ' +
691
- Math.round(STALE_LOCK_TTL_MS / 60_000) + 'min). Taking over.');
692
- } else {
693
- try {
694
- process.kill(payload.pid, 0);
695
- // Process exists. Distinguish "alive, our user" (refuse) from
696
- // "alive, different uid" (also refuse -- never barge into a root
697
- // daemon under a user-launched evolver, etc.).
698
- console.log(`[Singleton] Evolver loop already running (PID ${payload.pid}). Exiting.`);
699
- return false;
700
- } catch (e) {
701
- if (e && e.code === 'EPERM') {
702
- // PID exists but belongs to another user. Conservatively
703
- // refuse: barging in would race the existing daemon for
704
- // secret/heartbeat ownership.
705
- console.warn(`[Singleton] Lock owned by PID ${payload.pid} (different user). Refusing to take over. ` +
706
- `Remove ${lockFile} manually if the PID is actually dead.`);
707
- return false;
708
- }
709
- console.log(`[Singleton] Stale lock found (PID ${payload.pid}). Taking over.`);
710
- }
711
- }
712
- // Atomic takeover so two daemons that both observe the same stale PID
713
- // and pass the kill(0) check cannot both end up "owning" the lock.
714
- //
715
- // Bug it fixes: the previous "unconditional unlinkSync then linkSync"
716
- // pattern was NOT atomic across acquirers. Interleaving where P1 wins
717
- // the linkSync but P2's unlinkSync then deletes P1's freshly-linked
718
- // file (P2 never re-verifies it's deleting the same stale lock it
719
- // observed) lets P2's subsequent linkSync also succeed. Both processes
720
- // then return true and start a daemon, racing each other on the
721
- // shared singleton secret store.
722
- //
723
- // renameSync is atomic at the filesystem level: only one of N racing
724
- // acquirers can move the stale lockFile to a unique claim name, the
725
- // rest see ENOENT and abort. After the claim succeeds, _writeLockAtomic
726
- // installs the fresh lock; the claim file is unlinked in every exit
727
- // path so it doesn't accumulate.
728
- const claimFile = lockFile + '.' + process.pid + '.' + Date.now() + '.takeover';
729
- try {
730
- fs.renameSync(lockFile, claimFile);
731
- } catch (e) {
732
- if (e && e.code === 'ENOENT') {
733
- // Another concurrent acquirer already claimed the stale lock.
734
- // They'll race us on _writeLockAtomic below; the EEXIST branch
735
- // handles the loser case correctly.
736
- } else {
737
- console.warn('[Singleton] Cannot claim stale lock at ' + lockFile + ': ' + e.message);
738
- return false;
739
- }
740
- }
741
- try {
742
- _writeLockAtomic(lockFile, _lockPayload());
743
- } catch (linkErr) {
744
- try { fs.unlinkSync(claimFile); } catch (_) {}
745
- if (linkErr && linkErr.code === 'EEXIST') {
746
- // Lost the link race to another concurrent acquirer. Read who
747
- // won (best-effort) for the log line.
748
- const winner = _readLockPayload(lockFile);
749
- console.log('[Singleton] Lost takeover race to PID ' + (winner && winner.pid) + '. Exiting.');
750
- return false;
751
- }
752
- throw linkErr;
753
- }
754
- try { fs.unlinkSync(claimFile); } catch (_) {}
755
- return true;
756
- } catch (err) {
757
- console.error('[Singleton] Lock acquisition failed:', err);
758
- return false;
759
- }
760
- }
761
-
762
- function releaseLock() {
763
- const lockFile = getLockFilePath();
764
- try {
765
- if (fs.existsSync(lockFile)) {
766
- const payload = _readLockPayload(lockFile);
767
- if (payload && payload.pid === process.pid) fs.unlinkSync(lockFile);
768
- }
769
- } catch (e) { /* ignore */ }
770
- }
771
-
772
- // Round-7 (§20.7): the daemon-lock acquireLock() only fires for `--loop`
773
- // mode; CLI subcommands like `evolver fetch` and `evolver sync` run
774
- // without acquiring the lock and freely call sendHelloToHub when
775
- // node_secret is missing. The hub-side hello-with-rotate rewrites the
776
- // node_secret on disk, so two writers (the daemon's heartbeat path
777
- // rotating one secret + this CLI's sendHelloToHub writing a different
778
- // one) race to be "last writer." Whichever wrote second silences the
779
- // other -- the daemon then 401-loops -> enters reauth backoff -> goes
780
- // silent for 30 min..4 h. The original §6 "instance lock" scenario.
781
- //
782
- // This helper does NOT take the lock (the daemon legitimately owns it);
783
- // it only refuses to proceed if a LIVE daemon owns the lock AND we are
784
- // about to send a fresh hello. If the daemon is alive it already has a
785
- // valid secret in ~/.evomap/node_secret, so the right thing for the CLI
786
- // is to wait briefly for the secret to appear (newly registered daemon)
787
- // or exit with an actionable error.
788
- //
789
- // Callers: every CLI subcommand whose runner could call sendHelloToHub()
790
- // when getHubNodeSecret() returns empty. Currently: fetch, sync
791
- // (round-7 §20.7), plus atp-complete, buy, orders, verify (round-8
792
- // §21.8 -- the ATP runners hit the same vector via consumerAgent /
793
- // merchantAgent / atpExecute paths).
794
- function refuseHelloIfDaemonRunning(toolLabel) {
795
- try {
796
- const lockFile = getLockFilePath();
797
- if (!fs.existsSync(lockFile)) return; // no daemon
798
- const payload = _readLockPayload(lockFile);
799
- if (!payload || !Number.isFinite(payload.pid) || payload.pid <= 0) return;
800
- if (payload.pid === process.pid) return; // shouldn't happen for CLI
801
- // Round-9: a lease-aware lock whose mtime has gone stale means the
802
- // daemon is dead (or its PID was reused). Do NOT refuse on it -- that
803
- // was the "CLI hard-exits because it trusts a recyclable PID" hole.
804
- if (_lockIsStaleByLease(lockFile, payload)) return;
805
- try {
806
- process.kill(payload.pid, 0);
807
- } catch (e) {
808
- if (e && e.code === 'ESRCH') return; // stale lock, daemon is gone
809
- // EPERM = alive under a different user; still a real daemon. Fall
810
- // through to refuse.
811
- }
812
- console.error(
813
- '[' + toolLabel + '] Refusing to send hello: an evolver daemon ' +
814
- '(PID ' + payload.pid + ') is running and owns ~/.evomap/instance.lock.'
815
- );
816
- console.error(
817
- ' Two concurrent hello calls would rotate node_secret against ' +
818
- 'each other and silence the daemon for hours.'
819
- );
820
- console.error(
821
- ' Either wait for the daemon to register (the secret will ' +
822
- 'appear at ~/.evomap/node_secret), or stop the daemon and retry.'
823
- );
824
- process.exit(1);
825
- } catch (_) {
826
- // Never let the lock-check helper itself escape; if the helper
827
- // throws (FS permission, etc.) we fall through to the original code
828
- // path. The race we're protecting against is rare; failing closed
829
- // here would block legitimate CLI use.
830
- }
831
- }
832
-
833
- async function main() {
834
- const args = process.argv.slice(2);
835
- const command = args[0];
836
- // Solo mode (--solo): the "constrained wild" profile. Implies loop (a solo
837
- // run IS a Mad Dog loop) but hard-cuts the network + ATP, snapshots the
838
- // target repo each cycle and rolls back on failure, and circuit-breaks on
839
- // repeated failures instead of blind-respawning. It is a NEW side path — the
840
- // wild --mad-dog / controlled-daemon behavior below is untouched when isSolo
841
- // is false.
842
- const isSolo = args.includes('--solo');
843
- const isLoop = args.includes('--loop') || args.includes('--mad-dog') || isSolo;
844
- // Solo lockdown: hard-cut network + autonomous spend at the SOURCE, in-process,
845
- // before any module reads these envs. The block-level `if (!isSolo)` gates
846
- // below are belt-and-suspenders for the startup path; THIS is what also closes
847
- // the in-cycle paths (src/evolve/guards.js starts autoBuyer/autoDeliver from
848
- // inside evolve.run()) and the validator daemon (network + staked credits).
849
- // These are overwrites, not defaults — a user cannot re-enable ATP/hub/validator
850
- // under --solo by setting the env themselves. That is the "no escape valve" the
851
- // boss pinned. Numeric knobs (e.g. EVOLVER_SOLO_MAX_FAILS) stay tunable; the
852
- // four safety cuts do not.
853
- if (isSolo) {
854
- process.env.A2A_HUB_URL = '';
855
- process.env.EVOMAP_HUB_URL = '';
856
- process.env.EVOLVER_ATP = 'off'; // getAtpMode() → 'off'
857
- process.env.EVOLVER_ATP_AUTOBUY = 'off'; // getConsent() → disabled (env wins over ack)
858
- process.env.EVOLVER_ATP_AUTODELIVER = 'off';
859
- process.env.EVOLVER_VALIDATOR_ENABLED = 'false';
860
- process.env.EVOMAP_PROXY = '0';
861
- }
862
- const isVerbose = args.includes('--verbose') || args.includes('-v') ||
863
- String(process.env.EVOLVER_VERBOSE || '').toLowerCase() === 'true';
864
- if (isVerbose) process.env.EVOLVER_VERBOSE = 'true';
865
-
866
- if (!command || command === 'run' || command === '/evolve' || isLoop) {
867
- if (isLoop) {
868
- // EPIPE protection. The daemon may outlive the controlling
869
- // terminal (user closes the iTerm tab, ssh session drops, parent
870
- // shell exits). The SIGHUP handler below covers the signal side,
871
- // but the underlying pty fd is gone and the FIRST subsequent
872
- // console.log writes to a closed pipe -> stdout emits 'error'
873
- // with EPIPE. Without a listener attached, Node escalates EPIPE
874
- // to uncaughtException, which our handler then turns into
875
- // process.exit(1). Net result: daemon silently dies the next
876
- // time it tries to log, with no useful trace. Swallow EPIPE
877
- // explicitly so the daemon stays alive when its terminal goes
878
- // away (matching standard daemonization practice).
879
- try {
880
- // EPIPE: swallow (daemon must outlive its controlling terminal).
881
- // Non-EPIPE (EIO, ENOSPC on redirected log, etc.): the listener
882
- // already prevents 'error' from escalating to uncaughtException,
883
- // so write a one-line trace to the *other* stream so operators
884
- // can see the failure mode instead of finding a silent daemon.
885
- process.stdout.on('error', function (err) {
886
- if (err && err.code === 'EPIPE') return;
887
- try { process.stderr.write('[evolver] stdout error: ' + (err && (err.code || err.message) || err) + '\n'); } catch (_) {}
888
- });
889
- process.stderr.on('error', function (err) {
890
- if (err && err.code === 'EPIPE') return;
891
- try { process.stdout.write('[evolver] stderr error: ' + (err && (err.code || err.message) || err) + '\n'); } catch (_) {}
892
- });
893
- } catch (_) {}
894
-
895
- const originalLog = console.log;
896
- const originalWarn = console.warn;
897
- const originalError = console.error;
898
- function ts() { return '[' + new Date().toISOString() + ']'; }
899
- // Wrap originals in try/catch so a broken transport (closed pty,
900
- // disk full on a redirected log file) cannot escape and trip
901
- // unhandledException -> exit(1) the next time we log.
902
- console.log = (...args) => {
903
- try { originalLog.call(console, ts(), ...args); } catch (_) {}
904
- };
905
- console.warn = (...args) => {
906
- try { originalWarn.call(console, ts(), ...args); } catch (_) {}
907
- };
908
- console.error = (...args) => {
909
- try { originalError.call(console, ts(), ...args); } catch (_) {}
910
- };
911
- }
912
-
913
- console.log('Starting evolver...');
914
-
915
- // Preflight: fail fast if git is not on PATH. On Windows in particular
916
- // a missing git binary can cause evolver to hang silently (see #394),
917
- // because several cycle-critical steps shell out to git early (repo
918
- // resolution, diff, blast-radius). Catching this up front makes the
919
- // failure mode obvious.
920
- try {
921
- const { execSync } = require('child_process');
922
- execSync('git --version', { stdio: 'ignore', timeout: 5000, windowsHide: true });
923
- } catch (_gitErr) {
924
- console.error('');
925
- console.error('[Preflight] Could not run "git --version". Evolver requires git to be installed and available on PATH.');
926
- console.error('[Preflight] On Windows: install Git from https://git-scm.com/download/win and make sure `git --version` works in a fresh terminal.');
927
- console.error('[Preflight] On macOS: xcode-select --install (or `brew install git`)');
928
- console.error('[Preflight] On Linux: sudo apt-get install -y git (or your distro equivalent)');
929
- console.error('');
930
- process.exit(1);
931
- }
932
-
933
- if (isLoop) {
934
- // Internal daemon loop (no wrapper required).
935
- if (!acquireLock()) process.exit(0);
936
- // Round-9: refresh the lock lease so other processes can detect a
937
- // crash / PID reuse via stale mtime instead of trusting kill(0).
938
- startLockRefresh();
939
-
940
- // Linux OOM score adjustment: lower oom_score_adj so the kernel
941
- // deprioritises evolver when choosing an OOM victim. This is a
942
- // best-effort hint -- the kernel can still kill us under extreme
943
- // memory pressure, but we will not be the first target.
944
- //
945
- // Value -500 (range -1000..1000; -1000 = never kill, 0 = default,
946
- // +1000 = kill first). -500 gives meaningful protection without
947
- // reserving the slot for truly critical system services.
948
- //
949
- // Requires the process to be either root or to have CAP_SYS_RESOURCE.
950
- // On most Docker/k8s images running as non-root this write will fail
951
- // with EACCES -- that is expected and harmless; we log a one-liner so
952
- // operators know to pass --oom-score-adj=-500 via their container spec,
953
- // or to set /proc/<pid>/oom_score_adj from the supervising process.
954
- //
955
- // Users who want to set this from outside the process (safer, no CAP):
956
- // echo -500 > /proc/$(pgrep -f "node.*evolver.*--loop")/oom_score_adj
957
- //
958
- // Opt-out: EVOLVER_DISABLE_OOM_ADJUST=1
959
- if (process.platform === 'linux' &&
960
- String(process.env.EVOLVER_DISABLE_OOM_ADJUST || '') !== '1') {
961
- try {
962
- const _oomPath = '/proc/self/oom_score_adj';
963
- const _oomTarget = '-500';
964
- require('fs').writeFileSync(_oomPath, _oomTarget + '\n', 'utf8');
965
- console.log('[evolver] Set Linux oom_score_adj=' + _oomTarget +
966
- ' to reduce OOM-kill priority.');
967
- } catch (oomErr) {
968
- // EACCES under non-root / no CAP_SYS_RESOURCE is expected; EPERM
969
- // inside stricter seccomp/apparmor profiles. Both are non-fatal.
970
- const oomCode = oomErr && oomErr.code ? oomErr.code : 'unknown';
971
- console.log('[evolver] Could not set oom_score_adj (' + oomCode +
972
- '). To protect evolver from OOM kill, run as root, add ' +
973
- 'CAP_SYS_RESOURCE, or set oom_score_adj externally via your ' +
974
- 'container spec (e.g. resources.requests + oom_score_adj in k8s).');
975
- }
976
- }
977
-
978
- // Round-4: macOS App Nap / QoS demotion mitigation. Without this,
979
- // a backgrounded `evolver --loop` running in an iTerm tab gets its
980
- // process QoS demoted to UTILITY/BACKGROUND once the parent app
981
- // is no longer focused. CPU runtime caps to ~5% of one core,
982
- // setTimeout resolution drops toward 1 Hz, disk I/O is throttled.
983
- // The drift detector cannot rescue this because the demotion does
984
- // NOT cause Date.now() to jump -- only the inter-tick interval
985
- // dilates, which the detector samples through its own (also
986
- // demoted) setInterval. Net result: heartbeat appears alive but
987
- // ticks fire so slowly that the hub marks the node offline,
988
- // matching the user-reported "first launch ok -> idle -> dead
989
- // forever" pattern.
990
- //
991
- // os.setPriority() raises BSD process priority; macOS bridges that
992
- // to Mach thread QoS via the priority bridge so the demotion does
993
- // not engage. -10 is the most negative value raisable without
994
- // root. Failures are logged but non-fatal (e.g. EPERM under a
995
- // restrictive sandbox -- the daemon continues, just unprotected).
996
- // Opt-out via EVOLVER_DISABLE_PRIORITY_BOOST=1 for users on
997
- // power-constrained battery profiles who would rather accept
998
- // the throttle than the extra wake-time.
999
- if (process.platform === 'darwin' &&
1000
- String(process.env.EVOLVER_DISABLE_PRIORITY_BOOST || '') !== '1') {
1001
- let priorityBoostOk = false;
1002
- try {
1003
- const os = require('os');
1004
- os.setPriority(0, -10);
1005
- // Round-5: actually verify the boost landed. macOS silently
1006
- // returns success from setPriority(2) under some sandboxes
1007
- // even when the underlying syscall was rejected by the
1008
- // Mach thread-policy bridge. Read it back; if the value is
1009
- // still 0 (or worse), App Nap will engage and the user
1010
- // sees the "first launch -> idle -> dead" symptom from
1011
- // round-3 with NO log evidence to RCA from.
1012
- const observed = os.getPriority();
1013
- if (observed <= -10) {
1014
- priorityBoostOk = true;
1015
- console.log('[evolver] Raised process priority on macOS to ' + observed +
1016
- ' to prevent App Nap / QoS demotion.');
1017
- } else {
1018
- console.warn('[evolver] setPriority(-10) reported success but observed priority is ' +
1019
- observed + '; App Nap protection NOT in effect. ' +
1020
- 'Run with EVOLVER_CAFFEINATE=1 or via `caffeinate -is node index.js --loop`.');
1021
- }
1022
- } catch (e) {
1023
- console.warn('[evolver] setPriority(-10) refused (' + (e && e.code || 'unknown') +
1024
- '): ' + (e && e.message || e) + '. App Nap protection NOT in effect. ' +
1025
- 'Run with EVOLVER_CAFFEINATE=1 or via `caffeinate -is node index.js --loop`.');
1026
- }
1027
- // Round-5: caffeinate side-child. Round-4 made this opt-in via
1028
- // EVOLVER_CAFFEINATE=1 to avoid the extra Activity-Monitor row;
1029
- // the round-5 audit found that 99% of users never set the env
1030
- // var, so the App Nap fallback was effectively unused. Promote
1031
- // to default-on when the priority boost did NOT land (so we
1032
- // either have priority or have caffeinate, never neither),
1033
- // unless the user has explicitly opted out via
1034
- // EVOLVER_CAFFEINATE=0. The combined effect: a fresh laptop
1035
- // user gets at least one layer of throttle protection without
1036
- // having to learn about either env var.
1037
- const caffeinateRaw = String(process.env.EVOLVER_CAFFEINATE || '').toLowerCase().trim();
1038
- const caffeinateOptedIn = caffeinateRaw === '1' || caffeinateRaw === 'true';
1039
- const caffeinateOptedOut = caffeinateRaw === '0' || caffeinateRaw === 'false';
1040
- const caffeinateFallback = !priorityBoostOk && !caffeinateOptedOut;
1041
- if (caffeinateOptedIn || caffeinateFallback) {
1042
- try {
1043
- const child = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
1044
- detached: true,
1045
- stdio: 'ignore',
1046
- });
1047
- child.unref();
1048
- console.log('[evolver] Spawned caffeinate -i -w ' + process.pid +
1049
- ' to block App Nap (pid ' + child.pid + ').' +
1050
- (caffeinateFallback ? ' (fallback because priority boost was refused)' : ''));
1051
- } catch (e) {
1052
- console.warn('[evolver] caffeinate spawn failed: ' +
1053
- (e && e.message || e) + '. App Nap may throttle the heartbeat. ' +
1054
- 'Install caffeinate (Xcode CLT) or run under a launchd plist with NSAppSleepDisabled=1.');
1055
- }
1056
- }
1057
- }
1058
-
1059
- // Event-loop keep-alive anchor (defense-in-depth for the sleepMs fix).
1060
- //
1061
- // All timers in a2aProtocol.js (heartbeat, drift detector, self-driving
1062
- // poll, SSE reconnect) are unref'd so they never prevent a clean exit.
1063
- // The sleepMs() timer above is now ref'd (the primary fix), but as an
1064
- // additional safety net we install one ref'd setInterval here that fires
1065
- // every 10 minutes. Its only job is to emit a lightweight log line so
1066
- // the evolver_loop.log gets touched even when the daemon is completely
1067
- // idle (no session signals, evolve cycle sleeping at maxSleepMs). This
1068
- // guarantees the event loop has at least one ref'd handle at all times
1069
- // while the daemon is running, and provides a heartbeat-on-disk so
1070
- // lifecycle.checkHealth() (MAX_SILENCE_MS = 30 min default) does not
1071
- // wrongly declare the process stagnant during legitimate long idle windows.
1072
- // Cleared in shutdown() so it does not outlive the daemon.
1073
- const _KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000;
1074
- let _keepAliveTimer = setInterval(function () {
1075
- try {
1076
- // Inline append that mirrors a2aProtocol._appendHeartbeatLog's
1077
- // ENOENT-retry (that helper is not exported).
1078
- const a2aKA = require('./src/gep/a2aProtocol');
1079
- if (typeof a2aKA.getHeartbeatStats === 'function') {
1080
- const s = a2aKA.getHeartbeatStats();
1081
- const { getEvolverLogPath } = require('./src/gep/paths');
1082
- const fsKA = require('fs');
1083
- const pathKA = require('path');
1084
- try {
1085
- const logPath = getEvolverLogPath();
1086
- fsKA.mkdirSync(pathKA.dirname(logPath), { recursive: true });
1087
- const line = JSON.stringify({
1088
- ts: new Date().toISOString(),
1089
- type: 'keepalive_tick',
1090
- hb_running: s.running,
1091
- hb_last_tick_ago_s: s.lastTickAt ? Math.round((Date.now() - s.lastTickAt) / 1000) : null,
1092
- }) + '\n';
1093
- try {
1094
- fsKA.appendFileSync(logPath, line, { encoding: 'utf8' });
1095
- } catch (e) {
1096
- if (e && e.code === 'ENOENT') {
1097
- try {
1098
- fsKA.mkdirSync(pathKA.dirname(logPath), { recursive: true });
1099
- fsKA.appendFileSync(logPath, line, { encoding: 'utf8' });
1100
- } catch (_) { /* log destination broken; do not throw out */ }
1101
- }
1102
- }
1103
- } catch (_) { /* never let the log write kill the timer */ }
1104
- }
1105
- } catch (_) { /* never let any error kill the keep-alive timer */ }
1106
- }, _KEEPALIVE_INTERVAL_MS);
1107
- // Intentionally ref'd: this is the explicit event-loop anchor.
1108
- // Do NOT add .unref() here -- that would defeat the purpose.
1109
-
1110
- function shutdown() {
1111
- if (_keepAliveTimer) { clearInterval(_keepAliveTimer); _keepAliveTimer = null; }
1112
- stopLockRefresh();
1113
- releaseLock();
1114
- // stopHeartbeat() clears the drift detector interval and the heartbeat
1115
- // timer, preventing "ghost tick" log noise after exit and ensuring a
1116
- // clean state if the process is somehow continued (test harness, etc.).
1117
- try { require('./src/gep/a2aProtocol').stopHeartbeat(); } catch (e) {}
1118
- try { require('./src/gep/a2aProtocol').stopEventStream(); } catch (e) {}
1119
- }
1120
- process.on('exit', shutdown);
1121
- process.on('SIGINT', () => { shutdown(); process.exit(); });
1122
- process.on('SIGTERM', () => { shutdown(); process.exit(); });
1123
- // SIGHUP: two meanings depending on platform and how the daemon was started.
1124
- //
1125
- // macOS / interactive terminal: closing the iTerm/Terminal tab sends
1126
- // SIGHUP to the controlling process, and Node's default action is to
1127
- // terminate. That is the most common "first-launch, then idle, then
1128
- // evolver dead" path on macOS. As a daemon we intentionally ignore it.
1129
- //
1130
- // Linux systemd: `systemctl reload evolver` delivers SIGHUP to signal
1131
- // configuration reload. The socket / connection state may be stale (e.g.
1132
- // the hub URL changed in .env, or the admin wants a fresh hello after a
1133
- // manual secret rotation). We treat reload as a soft wake-recovery: drain
1134
- // the undici pool, poke the heartbeat, and restart the SSE stream, which
1135
- // is identical to what SIGCONT / the drift detector do on system resume.
1136
- // We also emit sd_notify RELOADING=1 / READY=1 so systemd can track the
1137
- // reload state (required for Type=notify units that call systemctl reload).
1138
- //
1139
- // A one-shot (non --loop) invocation keeps the default behavior because
1140
- // this branch is gated on `isLoop`.
1141
- process.on('SIGHUP', () => {
1142
- try {
1143
- if (process.platform === 'linux') {
1144
- // On Linux, SIGHUP from systemd means reload, not terminal close.
1145
- // Announce reload state to the service manager first so systemd
1146
- // does not time out waiting, then perform the recovery, then signal
1147
- // READY=1 again to confirm we are back in steady state.
1148
- try {
1149
- const a2aForSd = require('./src/gep/a2aProtocol.js');
1150
- if (typeof a2aForSd._sdNotify === 'function') {
1151
- // MONOTONIC_USEC requires microseconds from the monotonic clock.
1152
- // process.hrtime() returns [sec, nsec] from a fixed epoch;
1153
- // avoids BigInt literals for Node <10.3 compatibility.
1154
- const _hrt = process.hrtime();
1155
- const _monUsec = _hrt[0] * 1000000 + Math.floor(_hrt[1] / 1000);
1156
- a2aForSd._sdNotify('RELOADING=1\nMONOTONIC_USEC=' + _monUsec);
1157
- }
1158
- } catch (_) {}
1159
- console.warn('[evolver] Received SIGHUP on Linux (systemctl reload?). ' +
1160
- 'Running wake recovery (drain pool + poke heartbeat + restart SSE). ' +
1161
- 'To stop the daemon use SIGINT/SIGTERM.');
1162
- try {
1163
- const a2a = require('./src/gep/a2aProtocol.js');
1164
- if (typeof a2a._runWakeRecovery === 'function') a2a._runWakeRecovery();
1165
- } catch (_) {}
1166
- // Interrupt any pending sleepMs so the evolve loop picks up
1167
- // immediately after the reload rather than sitting out its window.
1168
- try { _interruptAllSleeps(); } catch (_) {}
1169
- // Signal READY=1 to close the RELOADING window. systemd will mark
1170
- // the reload complete once it sees this notification.
1171
- try {
1172
- const a2aForSd2 = require('./src/gep/a2aProtocol.js');
1173
- if (typeof a2aForSd2._sdNotify === 'function') {
1174
- a2aForSd2._sdNotify('READY=1');
1175
- }
1176
- } catch (_) {}
1177
- } else {
1178
- // macOS / non-systemd: terminal-close semantics, ignore the signal.
1179
- console.warn('[evolver] Received SIGHUP (controlling terminal closed?). ' +
1180
- 'Daemon ignoring -- heartbeat loop continues. To stop the daemon use SIGINT/SIGTERM.');
1181
- }
1182
- } catch (_) {}
1183
- });
1184
- // SIGCONT fires on `kill -CONT`, debugger detach, and some VM/sleep
1185
- // resume paths. Nudge the heartbeat loop so it doesn't sit waiting for
1186
- // its next scheduled tick (which could be up to 30 min away under
1187
- // backoff) before reconnecting after a wake event. Also restart the
1188
- // SSE stream: the underlying TCP socket almost certainly died during
1189
- // the SIGSTOP window without a FIN reaching us, and the existing
1190
- // exponential reconnect could be up to 120s away on the resumed
1191
- // monotonic clock.
1192
- // Round-6 (§19.5): register process-level wake hooks so both the
1193
- // SIGCONT handler and the drift detector's long-sleep branch
1194
- // (a2aProtocol) interrupt the outer evolve sleepMs and poke the
1195
- // validator daemon, not just the heartbeat-internal recovery.
1196
- _registerProcessWakeHooks();
1197
- // SIGCONT is not supported on Windows (process.on() throws ERR_UNKNOWN_SIGNAL).
1198
- // Wake recovery on Windows is handled exclusively by the drift detector.
1199
- if (process.platform !== 'win32') {
1200
- process.on('SIGCONT', () => {
1201
- // Real recovery delegates to a2aProtocol._runWakeRecovery so
1202
- // SIGCONT and the drift detector share one code path. NOTE:
1203
- // per followups §18.2, SIGCONT is never sent by the macOS
1204
- // kernel on system wake; this handler primarily covers:
1205
- // - hypervisor/docker resume (container unpause)
1206
- // - `kill -CONT <pid>` from operators or supervisors
1207
- // - Linux debugger attach/detach (ptrace SIGSTOP+SIGCONT;
1208
- // on Linux this is a true job-control signal unlike macOS)
1209
- // - `docker unpause` (sends SIGCONT to all cgroup processes)
1210
- // Bare-metal macOS wake recovery is driven by the drift
1211
- // detector only. _runWakeRecovery() has a 1s debounce gate so
1212
- // a rapid burst (e.g. gdb repeatedly attaching) collapses into
1213
- // one recovery without leaking undici agents or SSE connections.
1214
- try {
1215
- const a2a = require('./src/gep/a2aProtocol.js');
1216
- if (typeof a2a._runWakeRecovery === 'function') a2a._runWakeRecovery();
1217
- } catch (_) {}
1218
- });
1219
- }
1220
- process.on('uncaughtException', (err) => {
1221
- console.error('[FATAL] Uncaught exception:', err && err.stack ? err.stack : String(err));
1222
- releaseLock();
1223
- process.exit(1);
1224
- });
1225
- // Sliding window: only exit if many rejections cluster in a short
1226
- // period AND the daemon shows no other signs of life. A daemon
1227
- // running for weeks can accumulate harmless, unrelated rejections
1228
- // (transient network blips, hub timeouts); the original cumulative
1229
- // counter would eventually kill the process for noise. Cluster =
1230
- // real failure cascade. But macOS wake bursts also synthesize
1231
- // clusters: heartbeat / SSE / validator / merchantAgent / ATP all
1232
- // fire near-simultaneously on resume and any subsystem with an
1233
- // unhandled async-callback throw can blow past 5 rejections in
1234
- // seconds. We add a liveness gate so an actively-recovering
1235
- // daemon doesn't kill itself in the middle of a wake-recovery
1236
- // storm. Threshold and window widened to match the macOS-wake
1237
- // amplification observed in round-2 testing.
1238
- const REJECTION_WINDOW_MS = 5 * 60 * 1000;
1239
- const REJECTION_THRESHOLD = 10;
1240
- const RECENT_LIVENESS_MS = 60 * 1000;
1241
- let _rejectionTimestamps = [];
1242
- function _heartbeatLooksAlive() {
1243
- // Round-6 (§19.8): the previous implementation reached into
1244
- // the `_testing` namespace and returned false (= "treat as
1245
- // dead, exit on cluster") if that test-only accessor was
1246
- // unavailable. Under bundling / minification / a future
1247
- // refactor that drops the `_testing` export, this turned a
1248
- // recovery storm into a guaranteed exit -- the OPPOSITE of
1249
- // what the gate exists to do. Switched to the public
1250
- // getHeartbeatStats() API (which surfaces `running` and
1251
- // `lastTickAt` for exactly this purpose) and made the
1252
- // require failure path "fail open" -- assume alive so we
1253
- // don't kill an actively-recovering daemon just because the
1254
- // module load failed on this turn.
1255
- //
1256
- // Round-10: `running` + recent `lastTickAt` alone are not
1257
- // enough to claim "alive." `lastTickAt` is stamped at the
1258
- // TOP of every heartbeat tick, regardless of whether the
1259
- // tick actually makes progress -- including ticks that
1260
- // immediately bail out because the loop is spinning in a
1261
- // reauth backoff window (see a2aProtocol.js getHeartbeatStats
1262
- // comment near :2940, which acknowledges that the loop
1263
- // showed `running: true, lastTickAt: <recent>` even when
1264
- // silent for 30 min waiting on a reauth backoff). In that
1265
- // state a rejection cascade originating OUTSIDE the
1266
- // heartbeat would be repeatedly forgiven while the loop is
1267
- // not actually making forward progress. Require additionally
1268
- // that `consecutiveFailures === 0` and that we are not
1269
- // currently inside a reauth backoff window, so "alive" means
1270
- // "making progress," not just "ticking."
1271
- //
1272
- // Trade-off: a transient hub blip that bumps
1273
- // `consecutiveFailures` to 1 will now NOT forgive a
1274
- // concurrent rejection cascade. That is intentional --
1275
- // cascade-forgiveness exists to avoid flapping during a
1276
- // healthy loop; during an unhealthy loop we should not keep
1277
- // absorbing rejections silently.
1278
- try {
1279
- const a2a = require('./src/gep/a2aProtocol.js');
1280
- if (!a2a || typeof a2a.getHeartbeatStats !== 'function') {
1281
- // Cannot read state -- fail open. A real wedged daemon
1282
- // will be caught by the next rejection if/when stats
1283
- // become available, or by other watchdogs.
1284
- return true;
1285
- }
1286
- const s = a2a.getHeartbeatStats();
1287
- if (!s || !s.running) return false;
1288
- const last = s.lastTickAt || 0;
1289
- if (!(last > 0 && (Date.now() - last) < RECENT_LIVENESS_MS)) return false;
1290
- // Round-10: gate on success state, not just tick freshness.
1291
- if ((s.consecutiveFailures || 0) > 0) return false;
1292
- if ((s.reauthBackoffUntil || 0) > Date.now()) return false;
1293
- return true;
1294
- } catch (_) {
1295
- // Module load threw -- fail open for the same reason as
1296
- // above. A genuinely broken require would surface via
1297
- // uncaughtException long before this gate matters.
1298
- return true;
1299
- }
1300
- }
1301
- process.on('unhandledRejection', (reason) => {
1302
- const now = Date.now();
1303
- _rejectionTimestamps.push(now);
1304
- _rejectionTimestamps = _rejectionTimestamps.filter(function (t) {
1305
- return now - t < REJECTION_WINDOW_MS;
1306
- });
1307
- console.error('[FATAL] Unhandled promise rejection (' + _rejectionTimestamps.length + ' in window):', reason && reason.stack ? reason.stack : String(reason));
1308
- if (_rejectionTimestamps.length >= REJECTION_THRESHOLD) {
1309
- if (_heartbeatLooksAlive()) {
1310
- console.warn('[FATAL] ' + _rejectionTimestamps.length + ' rejections within ' +
1311
- (REJECTION_WINDOW_MS / 1000) + 's BUT heartbeat ticked in the last ' +
1312
- (RECENT_LIVENESS_MS / 1000) + 's. Treating as recovery storm, not exiting. ' +
1313
- 'Resetting rejection window so a real subsequent cascade can still trip the trap.');
1314
- _rejectionTimestamps = [];
1315
- return;
1316
- }
1317
- console.error('[FATAL] ' + _rejectionTimestamps.length + ' unhandled rejections within ' + (REJECTION_WINDOW_MS / 1000) + 's and no recent heartbeat activity. Exiting to avoid corrupt state.');
1318
- releaseLock();
1319
- process.exit(1);
1320
- }
1321
- });
1322
-
1323
- process.env.EVOLVE_LOOP = 'true';
1324
- // Issue #96: from v1.85.0, --loop defaults EVOLVE_BRIDGE=true so the
1325
- // daemon actually evolves the working tree. The previous default of
1326
- // 'false' caused 33 days of empty cycling on Aurora — every cycle
1327
- // hit rejectPendingRun(reason=loop_bridge_disabled_autoreject_no_rollback)
1328
- // and produced no EvolutionEvent. Failed cycles still recover safely
1329
- // via rollbackTracked (src/gep/gitOps.js#rollbackTracked, mode=stash
1330
- // by default since v1.81.0): the daemon's changes get pushed to a
1331
- // stash entry the user can recover with `git stash pop`.
1332
- // Set EVOLVE_BRIDGE=false explicitly to opt back into observe-only.
1333
- if (!process.env.EVOLVE_BRIDGE) {
1334
- process.env.EVOLVE_BRIDGE = 'true';
1335
- }
1336
- const bridgeEnabled = String(process.env.EVOLVE_BRIDGE).toLowerCase() !== 'false';
1337
- console.log(`Loop mode enabled (internal daemon, bridge=${process.env.EVOLVE_BRIDGE}, verbose=${isVerbose}).`);
1338
- if (bridgeEnabled) {
1339
- console.warn('[Daemon] EVOLVE_BRIDGE=true (default since v1.85.0).');
1340
- console.warn('[Daemon] evolver may modify your working tree.');
1341
- console.warn('[Daemon] Failed cycles auto-stash via "git stash push --include-untracked".');
1342
- console.warn('[Daemon] Recover: git stash list | grep evolver-rollback');
1343
- console.warn('[Daemon] Set EVOLVE_BRIDGE=false to opt out (observe-only mode).');
1344
- } else {
1345
- console.warn('[Daemon] EVOLVE_BRIDGE=false: evolver will NOT modify your working tree (observe-only).');
1346
- console.warn('[Daemon] To enable real evolution: unset EVOLVE_BRIDGE or set it to "true".');
1347
- }
1348
-
1349
- // Startup diagnostic: in daemon mode evolver consumes its own stdout
1350
- // instead of handing `sessions_spawn(...)` directives to a host
1351
- // runtime (OpenClaw). If the operator expects real-time agent assist
1352
- // they are likely using the wrong mode; if they intend daemon mode
1353
- // they still need AGENT_NAME / AGENT_SESSIONS_DIR pointing at a live
1354
- // agent or the loop will just cycle on its own logs. Emit a single
1355
- // warning at startup so "empty cycling" has a visible breadcrumb.
1356
- try {
1357
- const { diagnoseSessionSourceEmpty } = require('./src/evolve');
1358
- const diag = diagnoseSessionSourceEmpty();
1359
- const hasAnySource = diag.agentSessionsDirExists ||
1360
- diag.cursorDirExists || diag.claudeDirExists || diag.codexDirExists ||
1361
- Boolean(diag.cursorTranscriptsDir);
1362
- if (!hasAnySource) {
1363
- console.warn('[Daemon] No session sources detected at startup. Loop mode runs background self-maintenance but cannot observe a live agent without at least one of:');
1364
- console.warn(` - ~/.openclaw/agents/<AGENT_NAME>/sessions/ (current AGENT_NAME=${diag.agentName}, exists=${diag.agentSessionsDirExists})`);
1365
- console.warn(' - ~/.cursor / ~/.claude / ~/.codex (IDE transcripts)');
1366
- console.warn(' - EVOLVER_CURSOR_TRANSCRIPTS_DIR (explicit override)');
1367
- if (diag.availableOpenClawAgents.length > 0) {
1368
- console.warn(` Available OpenClaw agents under ~/.openclaw/agents/: ${diag.availableOpenClawAgents.join(', ')}`);
1369
- console.warn(' Set AGENT_NAME=<agent> or AGENT_SESSIONS_DIR=<abs path> to the one actually doing work.');
1370
- }
1371
- for (const hint of diag.hints) {
1372
- console.warn(` HINT: ${hint}`);
1373
- }
1374
- console.warn(' If you want real-time agent assist (not background self-maintenance), run `evolver run` from inside the agent session instead of `evolver --loop`.');
1375
- }
1376
- } catch (_diagErr) { /* diagnostics must never block startup */ }
1377
-
1378
- // Hub outcome mirror diagnostic. memoryGraph.syncEventToHub posts every
1379
- // outcome/attempt/solidify/skill_emit event to <hub>/a2a/memory/event
1380
- // by default, which is what populates this node's recall stream from
1381
- // the Hub side (consumed by gep-mcp-server's gep_recall). It is silent
1382
- // best-effort: failures don't crash the daemon but also don't surface,
1383
- // so users get a "why does gep_recall return 0 matches" puzzle. A
1384
- // single startup line says explicitly whether the mirror is on, what
1385
- // node it would post as, and what to flip if you want it off.
1386
- try {
1387
- const a2a = require('./src/gep/a2aProtocol');
1388
- const mirrorOff = process.env.MEMORY_GRAPH_SYNC_HUB === '0';
1389
- const hubUrl = typeof a2a.getHubUrl === 'function' ? a2a.getHubUrl() : '';
1390
- const nodeId = typeof a2a.getNodeId === 'function' ? a2a.getNodeId() : '';
1391
- const hasSecret = typeof a2a.getHubNodeSecret === 'function' && !!a2a.getHubNodeSecret();
1392
- if (mirrorOff) {
1393
- console.log('[HubMirror] DISABLED — set MEMORY_GRAPH_SYNC_HUB=1 (or unset it) to mirror outcome events to <hub>/a2a/memory/event.');
1394
- } else if (!hubUrl || !nodeId || !hasSecret) {
1395
- console.log(`[HubMirror] inactive — missing one of: hub=${hubUrl ? 'OK' : 'MISSING'} node_id=${nodeId ? 'OK' : 'MISSING'} secret=${hasSecret ? 'OK' : 'MISSING'}. Local memory graph is unaffected.`);
1396
- } else {
1397
- console.log(`[HubMirror] ENABLED — outcome/attempt/solidify/skill_emit events mirror to ${hubUrl}/a2a/memory/event as ${nodeId}. Set MEMORY_GRAPH_SYNC_HUB=0 to disable.`);
1398
- }
1399
- } catch (_mirrorDiagErr) { /* diagnostics must never block startup */ }
1400
-
1401
- // RecallVerify diagnostic banner: parallel to HubMirror but reads its
1402
- // own env, since verification can run with HubMirror off (verifier
1403
- // events are local-only on first ship).
1404
- try {
1405
- const enabled = String(process.env.EVOLVE_RECALL_VERIFY || '0') === '1';
1406
- const sampleRateRaw = Number(process.env.EVOLVE_RECALL_VERIFY_SAMPLE_RATE);
1407
- const sampleRate = Number.isFinite(sampleRateRaw) && sampleRateRaw >= 0 && sampleRateRaw <= 1 ? sampleRateRaw : 1.0;
1408
- if (!enabled) {
1409
- console.log('[RecallVerify] DISABLED (default) — opt-in observability only. Set EVOLVE_RECALL_VERIFY=1 to verify published assets round-trip via Hub Phase 2 lookup.');
1410
- } else {
1411
- console.log(`[RecallVerify] ENABLED — verifying published assets via Hub Phase 2 lookup, sample_rate=${sampleRate}. Set EVOLVE_RECALL_VERIFY=0 to disable.`);
1412
- }
1413
- } catch (_rvDiagErr) { /* diagnostics must never block startup */ }
1414
-
1415
- const { getEvolutionDir, getEvolverLogPath } = require('./src/gep/paths');
1416
- const solidifyStatePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json');
1417
- const cycleProgressPath = path.join(getEvolutionDir(), 'cycle_progress.json');
1418
-
1419
- const minSleepMs = parseMs(process.env.EVOLVER_MIN_SLEEP_MS, 2000);
1420
- const maxSleepMs = parseMs(process.env.EVOLVER_MAX_SLEEP_MS, 300000);
1421
- const idleThresholdMs = parseMs(process.env.EVOLVER_IDLE_THRESHOLD_MS, 500);
1422
- const pendingSleepMs = parseMs(
1423
- process.env.EVOLVE_PENDING_SLEEP_MS ||
1424
- process.env.EVOLVE_MIN_INTERVAL,
1425
- 120000
1426
- );
1427
-
1428
- const maxCyclesPerProcess = parseMs(process.env.EVOLVER_MAX_CYCLES_PER_PROCESS, 100) || 100;
1429
- const maxRssMb = parseMs(process.env.EVOLVER_MAX_RSS_MB, 500) || 500;
1430
- const suicideEnabled = String(process.env.EVOLVER_SUICIDE || '').toLowerCase() !== 'false';
1431
-
1432
- // Issue #19: hard timeout around evolve.run() to break out of zombie
1433
- // cycles (e.g. unclosed socket / stuck LLM call). On timeout we throw
1434
- // CycleTimeoutError, log diagnostic stderr, and force suicide-respawn
1435
- // so the wrapper sees a fresh PID + cycle. Also write cycle_progress
1436
- // every progressUpdateMs so the wrapper has a true heartbeat to poll.
1437
- const cycleTimeoutEnabled = parseBoolEnv(process.env.EVOLVER_CYCLE_TIMEOUT_ENABLED, true);
1438
- const cycleTimeoutMs = parseMs(process.env.EVOLVER_CYCLE_TIMEOUT_MS, 2700000); // 45 min default
1439
- const progressUpdateMs = parseMs(process.env.EVOLVER_PROGRESS_UPDATE_MS, 60000); // 1 min default
1440
-
1441
- // Start hub heartbeat (keeps node alive independently of evolution cycles)
1442
- if (isSolo) {
1443
- console.log('[Solo] 断网模式:不连 hub,不启心跳/SSE/proxy/validator,仅本地自进化。');
1444
- } else {
1445
- try {
1446
- if (process.env.EVOMAP_PROXY === '1' || process.env.A2A_TRANSPORT === 'mailbox') {
1447
- const { startProxy } = require('./src/proxy');
1448
- const proxyInfo = await startProxy({
1449
- hubUrl: process.env.A2A_HUB_URL,
1450
- clientSettings: {},
1451
- });
1452
- console.log('[Proxy] Started on ' + proxyInfo.url);
1453
- try {
1454
- const { injectProxyEnv } = require('./src/proxy/inject');
1455
- const injected = injectProxyEnv(proxyInfo);
1456
- if (injected.injected) {
1457
- console.log('[Proxy] Auto-injected client env for Claude Code/Codex/Cursor. Set EVOMAP_PROXY_AUTO_INJECT=off to disable.');
1458
- } else {
1459
- console.log('[Proxy] Auto-inject skipped: ' + injected.reason);
1460
- }
1461
- } catch (injectErr) {
1462
- console.warn('[Proxy] Auto-inject failed: ' + (injectErr && injectErr.message || injectErr));
1463
- }
1464
- const { registerMailboxTransport } = require('./src/gep/mailboxTransport');
1465
- registerMailboxTransport();
1466
- process.env.A2A_TRANSPORT = 'mailbox';
1467
- try {
1468
- const a2a = require('./src/gep/a2aProtocol');
1469
- a2a.startSystemdNotifyWatchdog(function () {
1470
- try {
1471
- const proxy = proxyInfo && proxyInfo.proxy;
1472
- const lifecycle = proxy && proxy.lifecycle;
1473
- if (lifecycle && typeof lifecycle.getHeartbeatStats === 'function') {
1474
- const stats = lifecycle.getHeartbeatStats();
1475
- // Hub-backed lifecycle stats are authoritative even when stopped;
1476
- // systemd should starve and restart instead of seeing a false ping.
1477
- if (stats && (stats.running || proxy.hubUrl)) return stats;
1478
- }
1479
- } catch (_) {}
1480
- return { running: true, consecutiveFailures: 0, lastTickAt: Date.now() };
1481
- });
1482
- } catch (sdErr) {
1483
- console.warn('[Heartbeat] systemd notify/watchdog setup failed: ' + (sdErr && sdErr.message || sdErr));
1484
- }
1485
- } else {
1486
- const a2a = require('./src/gep/a2aProtocol');
1487
- try { a2a.startHeartbeat(); }
1488
- catch (hbErr) { console.warn('[Heartbeat] startHeartbeat failed: ' + (hbErr && hbErr.message || hbErr)); }
1489
- try { a2a.startEventStream(); }
1490
- catch (ssErr) { console.warn('[SSE] startEventStream failed: ' + (ssErr && ssErr.message || ssErr)); }
1491
- }
1492
- } catch (e) {
1493
- console.warn('[Heartbeat] Failed to start: ' + (e.message || e));
1494
- }
1495
- }
1496
-
1497
- // RecallVerify worker: starts once per process; drains the publish-
1498
- // verification queue with backoff. unref'd so it never blocks exit.
1499
- try {
1500
- if (String(process.env.EVOLVE_RECALL_VERIFY || '0') === '1') {
1501
- require('./src/gep/recallVerifier').startWorker();
1502
- }
1503
- } catch (rvStartErr) {
1504
- console.warn('[RecallVerify] startWorker failed: ' + (rvStartErr && rvStartErr.message || rvStartErr));
1505
- }
1506
-
1507
- // Validator daemon: independent timer that fetches and executes
1508
- // validation tasks regardless of the main evolve loop's idle gating.
1509
- // Honors EVOLVER_VALIDATOR_ENABLED and the persisted feature flag.
1510
- // Solo: skip entirely — the validator uses network + staked credits, and
1511
- // its daemon timer arms even when disabled, so don't even construct it.
1512
- if (!isSolo) {
1513
- try {
1514
- const { startValidatorDaemon } = require('./src/gep/validator');
1515
- if (startValidatorDaemon()) {
1516
- console.log('[ValidatorDaemon] started.');
1517
- }
1518
- } catch (vdErr) {
1519
- console.warn('[ValidatorDaemon] failed to start: ' + (vdErr && vdErr.message || vdErr));
1520
- }
1521
- }
1522
-
1523
- // OAuth token auto-refresh: if a device-flow OAuth token is present,
1524
- // keep it fresh in the background so long-running `evolver run` loops
1525
- // never hit an expired a2a token mid-request. No-op for node_secret nodes.
1526
- try {
1527
- const { loadOAuthToken, startTokenAutoRefresh } = require('./src/gep/oauthLogin');
1528
- if (loadOAuthToken()) {
1529
- startTokenAutoRefresh();
1530
- console.log('[OAuth] token auto-refresh scheduled.');
1531
- }
1532
- } catch (oauthRefreshErr) {
1533
- console.warn('[OAuth] auto-refresh setup failed: ' + (oauthRefreshErr && oauthRefreshErr.message || oauthRefreshErr));
1534
- }
1535
-
1536
- // ATP: auto-start merchant agent if enabled
1537
- if (isSolo) {
1538
- console.log('[Solo] 禁 ATP:不加载自治交易模块,不碰花钱通道。');
1539
- } else {
1540
- try {
1541
- const { defaultHandler, merchantAgent } = require('./src/atp');
1542
- const atpMode = defaultHandler.getAtpMode();
1543
- if (atpMode === 'auto' || atpMode === 'on') {
1544
- const hubUrl = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
1545
- if (hubUrl) {
1546
- const services = defaultHandler.resolveAtpServices();
1547
- merchantAgent.start({
1548
- services: services,
1549
- onOrder: defaultHandler.defaultOrderHandler,
1550
- pollMs: 30000,
1551
- }).catch(function (atpErr) {
1552
- console.warn('[ATP] merchantAgent.start failed: ' + (atpErr && atpErr.message || atpErr));
1553
- });
1554
- }
1555
- }
1556
- } catch (atpInitErr) {
1557
- console.warn('[ATP] Auto-init failed: ' + (atpInitErr && atpInitErr.message || atpInitErr));
1558
- }
1559
-
1560
- // ATP: capability-gap auto-buyer. OPT-IN as of consent-required
1561
- // change — new installs do not auto-spend until the user explicitly
1562
- // runs `evolver atp enable` or answers `y` at the first-run prompt.
1563
- // Also starts the merchant-side auto-deliver daemon so claimed ATP
1564
- // tasks actually call submitDelivery and settle instead of expiring.
1565
- try {
1566
- try {
1567
- const { runPrompt } = require('./src/atp/cliAutobuyPrompt');
1568
- await runPrompt();
1569
- } catch (promptErr) {
1570
- console.warn('[ATP-AutoBuyer] first-run prompt failed: ' + (promptErr && promptErr.message || promptErr));
1571
- }
1572
- const { autoBuyer } = require('./src/atp');
1573
- const consent = autoBuyer.getConsent();
1574
- if (consent.enabled) {
1575
- const hubUrl = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
1576
- if (hubUrl) {
1577
- // Round-5: previously this bare start() call was a true
1578
- // fire-and-forget. If autoBuyer.start returned a rejected
1579
- // promise (transient hub error, bad config, mid-wake DNS
1580
- // flap), the unhandledRejection escaped to the
1581
- // process-level handler -- which, post round-3, only
1582
- // exits if heartbeat is also dead. Net effect: daemon
1583
- // stays alive but the autobuyer is half-initialized and
1584
- // silently ignores claims. Attach a catch so the
1585
- // operator can see the failure and the daemon-survival
1586
- // gate is not relied on.
1587
- try {
1588
- const _autoBuyerPromise = autoBuyer.start({
1589
- dailyCap: Number(process.env.ATP_AUTOBUY_DAILY_CAP_CREDITS) || undefined,
1590
- perOrderCap: Number(process.env.ATP_AUTOBUY_PER_ORDER_CAP_CREDITS) || undefined,
1591
- });
1592
- if (_autoBuyerPromise && typeof _autoBuyerPromise.catch === 'function') {
1593
- _autoBuyerPromise.catch(function (abErr) {
1594
- console.warn('[ATP-AutoBuyer] start() rejected: ' + (abErr && abErr.message || abErr));
1595
- });
1596
- }
1597
- } catch (abSyncErr) {
1598
- console.warn('[ATP-AutoBuyer] start() threw synchronously: ' + (abSyncErr && abSyncErr.message || abSyncErr));
1599
- }
1600
- if (consent.source === 'default') {
1601
- // First-run on a non-TTY (daemon, hook, CI) where the prompt
1602
- // could not fire AND no env override + no ack file. autoBuyer
1603
- // is starting with the default-on policy — surface a single
1604
- // WARN per process so users see what is happening and how to
1605
- // opt out, instead of discovering it via a credit balance dip.
1606
- let safeHubUrl;
1607
- try { safeHubUrl = new URL(hubUrl).origin; }
1608
- catch { safeHubUrl = '(configured)'; }
1609
- console.warn('[ATP-AutoBuyer] ATP auto-spend is ON (default for new installs).');
1610
- console.warn(' Hub: ' + safeHubUrl + ' Caps: ' +
1611
- (process.env.ATP_AUTOBUY_DAILY_CAP_CREDITS || '50') + ' credits/day, ' +
1612
- (process.env.ATP_AUTOBUY_PER_ORDER_CAP_CREDITS || '10') + '/order' +
1613
- ' (cold-start half-cap for the first 5 min).');
1614
- console.warn(' To opt out: evolver atp disable (or EVOLVER_ATP_AUTOBUY=off)');
1615
- }
1616
- } else {
1617
- console.warn('[ATP-AutoBuyer] autobuy enabled but no hub URL configured, skipping.');
1618
- }
1619
- }
1620
- const autoDeliverRaw = (process.env.EVOLVER_ATP_AUTODELIVER || 'on').toLowerCase().trim();
1621
- const autoDeliverOn = autoDeliverRaw !== 'off' && autoDeliverRaw !== '0' && autoDeliverRaw !== 'false';
1622
- if (autoDeliverOn) {
1623
- const hubUrl = process.env.A2A_HUB_URL || process.env.EVOMAP_HUB_URL || '';
1624
- if (hubUrl) {
1625
- const autoDeliver = require('./src/atp/autoDeliver');
1626
- // Round-5: same fire-and-forget hardening as autoBuyer above.
1627
- try {
1628
- const _autoDeliverPromise = autoDeliver.start({
1629
- pollMs: Number(process.env.ATP_AUTODELIVER_POLL_MS) || undefined,
1630
- });
1631
- if (_autoDeliverPromise && typeof _autoDeliverPromise.catch === 'function') {
1632
- _autoDeliverPromise.catch(function (adErr) {
1633
- console.warn('[ATP-AutoDeliver] start() rejected: ' + (adErr && adErr.message || adErr));
1634
- });
1635
- }
1636
- } catch (adSyncErr) {
1637
- console.warn('[ATP-AutoDeliver] start() threw synchronously: ' + (adSyncErr && adSyncErr.message || adSyncErr));
1638
- }
1639
- } else {
1640
- console.warn('[ATP-AutoDeliver] autodeliver enabled but no hub URL configured, skipping.');
1641
- }
1642
- }
1643
- } catch (autoBuyInitErr) {
1644
- console.warn('[ATP-AutoBuyer] Init failed: ' + (autoBuyInitErr && autoBuyInitErr.message || autoBuyInitErr));
1645
- }
1646
- }
1647
-
1648
- // Hoist module refs used inside the loop to avoid repeated module lookups per cycle
1649
- const idleScheduler = require('./src/gep/idleScheduler');
1650
- const { shouldDistillFromFailures: shouldDF, autoDistillFromFailures: autoDF } = require('./src/gep/skillDistiller');
1651
- const { autoDistillLlm } = require('./src/gep/autoDistillLlm'); // P3: autonomous LLM distillation (shadow-first, off by default)
1652
- const { tryExplore } = require('./src/gep/explore');
1653
-
1654
- // Solo-mode setup (--solo): git safety net + circuit breaker. repoRoot is
1655
- // the TARGET project being evolved (getRepoRoot), NOT evolver's own source.
1656
- const gitGuard = isSolo ? require('./src/solo/gitGuard') : null;
1657
- const soloBreaker = isSolo ? require('./src/solo/breaker') : null;
1658
- const soloRepoRoot = isSolo ? require('./src/gep/paths').getRepoRoot() : null;
1659
- const soloMaxConsecutiveFailures = Math.max(1, parseMs(process.env.EVOLVER_SOLO_MAX_FAILS, 5) || 5);
1660
- let soloBreakerState = { consecutiveFailures: 0 };
1661
- if (isSolo) {
1662
- console.log('════════════════════════════════════════════════════════');
1663
- console.log('[Solo] Mad Dog · 受约束的野性模式已启动');
1664
- console.log('[Solo] 兜底四道:断网 · 禁ATP · 每 cycle git 快照/失败回滚 · 连续失败熔断');
1665
- console.log('[Solo] 默认免人审。目标仓:' + soloRepoRoot);
1666
- console.log('[Solo] 无 hub 审计,仅本地 git 可追溯。');
1667
- console.log('════════════════════════════════════════════════════════');
1668
- }
1669
-
1670
- let currentSleepMs = minSleepMs;
1671
- let cycleCount = 0;
1672
-
1673
- while (true) {
1674
- try {
1675
- cycleCount += 1;
1676
-
1677
- // Ralph-loop gating: do not run a new cycle while previous run is pending solidify.
1678
- const st0 = readJsonSafe(solidifyStatePath);
1679
- if (isPendingSolidify(st0)) {
1680
- await sleepMs(Math.max(pendingSleepMs, minSleepMs));
1681
- continue;
1682
- }
1683
-
1684
- const t0 = Date.now();
1685
- let ok = false;
1686
- // Solo: snapshot the target repo before the cycle self-modifies it, so
1687
- // a failed cycle can be rolled back to this exact point.
1688
- const soloSnap = isSolo ? gitGuard.snapshot(soloRepoRoot) : null;
1689
- // Issue #19: write progress at cycle start, refresh it every
1690
- // progressUpdateMs (default 60s) while evolve.run() is active, and
1691
- // wrap evolve.run() with Promise.race(timeout) so a hung internal
1692
- // call cannot freeze the daemon for days.
1693
- writeCycleProgressAtomic(cycleProgressPath, {
1694
- pid: process.pid,
1695
- outer_cycle: cycleCount,
1696
- inner_cycle: cycleCount,
1697
- started_at: t0,
1698
- phase: 'evolve.run',
1699
- });
1700
- let progressTicker = null;
1701
- if (progressUpdateMs > 0) {
1702
- progressTicker = setInterval(function () {
1703
- writeCycleProgressAtomic(cycleProgressPath, {
1704
- pid: process.pid,
1705
- outer_cycle: cycleCount,
1706
- inner_cycle: cycleCount,
1707
- started_at: t0,
1708
- phase: 'evolve.run',
1709
- });
1710
- }, progressUpdateMs);
1711
- if (typeof progressTicker.unref === 'function') progressTicker.unref();
1712
- }
1713
- let cycleTimeoutHandle = null;
1714
- let evolvePromise = null;
1715
- try {
1716
- evolvePromise = evolve.run();
1717
- if (cycleTimeoutEnabled && cycleTimeoutMs > 0) {
1718
- const timeoutPromise = new Promise(function (_, reject) {
1719
- cycleTimeoutHandle = setTimeout(function () {
1720
- reject(new CycleTimeoutError(cycleTimeoutMs, 'evolve.run', cycleCount));
1721
- }, cycleTimeoutMs);
1722
- if (cycleTimeoutHandle && typeof cycleTimeoutHandle.unref === 'function') cycleTimeoutHandle.unref();
1723
- });
1724
- await Promise.race([evolvePromise, timeoutPromise]);
1725
- } else {
1726
- await evolvePromise;
1727
- }
1728
- ok = true;
1729
-
1730
- if (String(process.env.EVOLVE_BRIDGE || '').toLowerCase() === 'false') {
1731
- const stAfterRun = readJsonSafe(solidifyStatePath);
1732
- if (isPendingSolidify(stAfterRun)) {
1733
- const cleared = rejectPendingRun(solidifyStatePath);
1734
- if (cleared) {
1735
- console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode (state only, no rollback).');
1736
- }
1737
- }
1738
- }
1739
- } catch (error) {
1740
- const msg = error && error.message ? String(error.message) : String(error);
1741
- if (error && error.code === 'CYCLE_TIMEOUT') {
1742
- if (progressTicker) { clearInterval(progressTicker); progressTicker = null; }
1743
- if (cycleTimeoutHandle) { clearTimeout(cycleTimeoutHandle); cycleTimeoutHandle = null; }
1744
- const timeoutAction = handleCycleTimeout({
1745
- error,
1746
- cycleProgressPath,
1747
- progressFields: {
1748
- pid: process.pid,
1749
- outer_cycle: cycleCount,
1750
- inner_cycle: cycleCount,
1751
- started_at: t0,
1752
- },
1753
- suicideEnabled,
1754
- args,
1755
- logPath: getEvolverLogPath(),
1756
- spawnReplacementFn: spawnReplacementProcess,
1757
- });
1758
- if (timeoutAction.action === 'respawn') {
1759
- releaseLock();
1760
- process.exit(1);
1761
- }
1762
- if (timeoutAction.action === 'continue') {
1763
- await waitForTimedOutEvolvePromise(evolvePromise);
1764
- }
1765
- } else {
1766
- console.error(`Evolution cycle failed: ${msg}`);
1767
- }
1768
- } finally {
1769
- if (progressTicker) { clearInterval(progressTicker); progressTicker = null; }
1770
- if (cycleTimeoutHandle) { clearTimeout(cycleTimeoutHandle); cycleTimeoutHandle = null; }
1771
- }
1772
- const dt = Date.now() - t0;
1773
-
1774
- // Solo: on a failed cycle, roll the target repo back to the pre-cycle
1775
- // snapshot (undo the broken self-edit) and advance the circuit breaker;
1776
- // a good cycle resets it. The breaker trip (exit) is enforced just
1777
- // before the suicide block below.
1778
- if (isSolo) {
1779
- const b = soloBreaker.step(soloBreakerState, ok, soloMaxConsecutiveFailures);
1780
- soloBreakerState = b.state;
1781
- if (!ok) {
1782
- const rolledBack = gitGuard.rollbackTo(soloRepoRoot, soloSnap);
1783
- if (rolledBack) {
1784
- console.warn('[Solo] cycle 失败,已 git 回滚目标仓到 ' + String(soloSnap).slice(0, 12) + ' (连续失败 ' + soloBreakerState.consecutiveFailures + '/' + soloMaxConsecutiveFailures + ')');
1785
- } else {
1786
- console.error('[Solo] cycle 失败且回滚未成功(无快照或 git 出错)。连续失败 ' + soloBreakerState.consecutiveFailures + '/' + soloMaxConsecutiveFailures);
1787
- }
1788
- }
1789
- }
1790
-
1791
- // Adaptive sleep: treat very fast cycles as "idle", backoff; otherwise reset to min.
1792
- if (!ok || dt < idleThresholdMs) {
1793
- currentSleepMs = Math.min(maxSleepMs, Math.max(minSleepMs, currentSleepMs * 2));
1794
- } else {
1795
- currentSleepMs = minSleepMs;
1796
- }
1797
-
1798
- // OMLS-inspired idle scheduling: adjust sleep and trigger aggressive
1799
- // operations (distillation, reflection) during detected idle windows.
1800
- let omlsMultiplier = 1;
1801
- try {
1802
- const schedule = idleScheduler.getScheduleRecommendation();
1803
- if (schedule.enabled && schedule.sleep_multiplier > 0) {
1804
- omlsMultiplier = schedule.sleep_multiplier;
1805
- if (schedule.should_distill) {
1806
- try {
1807
- if (shouldDF()) {
1808
- const dfResult = autoDF();
1809
- if (dfResult && dfResult.ok) {
1810
- console.log('[OMLS] Idle-window failure distillation: ' + dfResult.gene.id);
1811
- }
1812
- }
1813
- } catch (e) {
1814
- if (isVerbose) console.warn('[OMLS] Distill error: ' + (e.message || e));
1815
- }
1816
- // P3: autonomous LLM-quality distillation of SUCCESS capsules.
1817
- // Default off; shadow logs a candidate; enforce upserts (after a
1818
- // real run-green gate). Reuses the P1 exec bridge under the hood.
1819
- if ((process.env.EVOLVER_AUTO_DISTILL_LLM || 'off') !== 'off') {
1820
- try {
1821
- const llmRes = await autoDistillLlm();
1822
- if (llmRes && llmRes.ok && llmRes.gene) {
1823
- console.log('[OMLS] Idle-window LLM distillation enforced gene: ' + llmRes.gene.id);
1824
- } else if (llmRes && llmRes.reason === 'shadow_logged') {
1825
- console.log('[OMLS] LLM distillation shadow candidate: ' + (llmRes.candidate && llmRes.candidate.id));
1826
- }
1827
- } catch (e) {
1828
- if (isVerbose) console.warn('[OMLS] LLM distill error (non-fatal): ' + (e.message || e));
1829
- }
1830
- }
1831
- }
1832
- if (schedule.should_explore) {
1833
- try {
1834
- const exploreResult = await tryExplore([], schedule, getRepoRoot());
1835
- if (exploreResult && exploreResult.signals && exploreResult.signals.length > 0) {
1836
- console.log('[OMLS] Explore discovered ' + exploreResult.signals.length + ' signals: ' + exploreResult.signals.slice(0, 5).join(', '));
1837
- }
1838
- } catch (e) {
1839
- if (isVerbose) console.warn('[OMLS] Explore error: ' + (e.message || e));
1840
- }
1841
- }
1842
- // P2: conversation capability -> distilled gene (shadow-only v1).
1843
- // Deliberately OUTSIDE the should_distill guard: should_distill is
1844
- // true only at aggressive/deep intensity, but headless/air-gapped
1845
- // hosts fall back to 'normal', which would make P2 a dead feature.
1846
- // A freshly-discovered capability is time-relevant; gate it solely on
1847
- // its own flag + the per-slug cooldown + a non-empty queue (all of
1848
- // which already bound spend). Default off => zero behavior change.
1849
- if ((process.env.EVOLVER_CONV_DISTILL_ENABLED || 'off') !== 'off') {
1850
- try {
1851
- const { autoDistillConversation } = require('./src/gep/autoDistillConv');
1852
- const convRes = await autoDistillConversation();
1853
- if (convRes && convRes.ok) console.log('[P2] conv-distill ' + convRes.mode + ' candidate: ' + (convRes.gene_id || convRes.reason));
1854
- } catch (e) {
1855
- if (isVerbose) console.warn('[P2] conv-distill error (non-fatal): ' + (e.message || e));
1856
- }
1857
- }
1858
- if (isVerbose && schedule.idle_seconds >= 0) {
1859
- console.log(`[OMLS] idle=${schedule.idle_seconds}s intensity=${schedule.intensity} multiplier=${omlsMultiplier}`);
1860
- }
1861
- }
1862
- } catch (e) {
1863
- if (isVerbose) console.warn('[OMLS] Scheduler error: ' + (e.message || e));
1864
- }
1865
-
1866
- // Solo circuit breaker: replace the wild loop's blind sleep-and-retry
1867
- // with a hard stop after N consecutive failures. Stopping (exit 1) —
1868
- // rather than respawning — is the point: a solo run that keeps failing
1869
- // must not thrash forever. An external supervisor may restart it, but
1870
- // the process itself refuses to blind-loop.
1871
- if (isSolo && soloBreakerState.consecutiveFailures >= soloMaxConsecutiveFailures) {
1872
- console.error('[Solo] 连续失败 ' + soloBreakerState.consecutiveFailures + ' 次达阈值,熔断停机(非盲重生)。');
1873
- releaseLock();
1874
- process.exit(1);
1875
- }
1876
-
1877
- // Suicide check (memory leak protection). On Windows the
1878
- // in-process respawn opens a cmd popup (Issue #528), so by default
1879
- // we delegate to an external supervisor by exiting with a non-zero
1880
- // code instead. See spawnReplacementProcess() for the policy.
1881
- if (suicideEnabled) {
1882
- const memMb = process.memoryUsage().rss / 1024 / 1024;
1883
- if (cycleCount >= maxCyclesPerProcess || memMb > maxRssMb) {
1884
- console.log(`[Daemon] Restarting self (cycles=${cycleCount}, rssMb=${memMb.toFixed(0)})`);
1885
- const result = spawnReplacementProcess({
1886
- reason: 'max_cycles_or_rss',
1887
- args: args,
1888
- logPath: getEvolverLogPath(),
1889
- });
1890
- if (result.spawned) {
1891
- releaseLock();
1892
- process.exit(0);
1893
- } else if (result.reason === 'windows_default_skip') {
1894
- console.log('[Daemon] Exiting with code 1 to let external supervisor respawn.');
1895
- releaseLock();
1896
- process.exit(1);
1897
- } else {
1898
- // Non-Windows spawn error: keep the lock and fall through to
1899
- // the next iteration of the loop instead of leaving the daemon
1900
- // dead. This matches the pre-1.79.1 behavior where a failed
1901
- // spawn was logged and the process continued running.
1902
- console.error('[Daemon] Spawn failed, continuing current process.');
1903
- }
1904
- }
1905
- }
1906
-
1907
- let saturationMultiplier = 1;
1908
- try {
1909
- const lastSignals = getLastSignals(solidifyStatePath);
1910
- if (lastSignals.includes('force_steady_state')) {
1911
- saturationMultiplier = 4;
1912
- console.log('[Daemon] Saturation detected. Entering steady-state mode (4x sleep).');
1913
- } else if (lastSignals.includes('evolution_saturation')) {
1914
- saturationMultiplier = 2;
1915
- console.log('[Daemon] Approaching saturation. Reducing evolution frequency (2x sleep).');
1916
- }
1917
- } catch (e) {
1918
- if (isVerbose) console.warn('[Daemon] Saturation check error: ' + (e.message || e));
1919
- }
1920
-
1921
- // Jitter to avoid lockstep restarts.
1922
- const jitter = Math.floor(Math.random() * 250);
1923
- const totalSleepMs = Math.max(minSleepMs, (currentSleepMs + jitter) * saturationMultiplier * omlsMultiplier);
1924
- if (isVerbose) {
1925
- const memMb = (process.memoryUsage().rss / 1024 / 1024).toFixed(1);
1926
- const signals = getLastSignals(solidifyStatePath).join(',');
1927
- console.log(`[Verbose] cycle=${cycleCount} ok=${ok} dt=${dt}ms sleep=${totalSleepMs}ms (base=${currentSleepMs} jitter=${jitter} sat=${saturationMultiplier}x) rss=${memMb}MB signals=[${signals}]`);
1928
- }
1929
- writeCycleProgressAtomic(cycleProgressPath, {
1930
- pid: process.pid,
1931
- outer_cycle: cycleCount,
1932
- inner_cycle: cycleCount,
1933
- started_at: t0,
1934
- phase: 'sleep',
1935
- });
1936
- await sleepMs(totalSleepMs);
1937
-
1938
- } catch (loopErr) {
1939
- console.error('[Daemon] Unexpected loop error (recovering): ' + (loopErr && loopErr.message ? loopErr.message : String(loopErr)));
1940
- await sleepMs(Math.max(minSleepMs, 10000));
1941
- }
1942
- }
1943
- } else {
1944
- // Normal Single Run
1945
- try {
1946
- await evolve.run();
1947
- } catch (error) {
1948
- console.error('Evolution failed:', error);
1949
- process.exit(1);
1950
- }
1951
- }
1952
-
1953
- // Post-run hint
1954
- console.log('\n' + '=======================================================');
1955
- console.log('Evolver finished. If you use this project, consider starring the upstream repository.');
1956
- console.log('Upstream: https://github.com/EvoMap/evolver');
1957
- console.log('=======================================================\n');
1958
-
1959
- } else if (command === 'solidify') {
1960
- const dryRun = args.includes('--dry-run');
1961
- const noRollback = args.includes('--no-rollback');
1962
- const intentFlag = args.find(a => typeof a === 'string' && a.startsWith('--intent='));
1963
- const summaryFlag = args.find(a => typeof a === 'string' && a.startsWith('--summary='));
1964
- const intent = intentFlag ? intentFlag.slice('--intent='.length) : null;
1965
- const summary = summaryFlag ? summaryFlag.slice('--summary='.length) : null;
1966
-
1967
- try {
1968
- const res = await solidify({
1969
- intent: intent || undefined,
1970
- summary: summary || undefined,
1971
- dryRun,
1972
- rollbackOnFailure: !noRollback,
1973
- });
1974
- const st = res && res.ok ? 'SUCCESS' : 'FAILED';
1975
- console.log(`[SOLIDIFY] ${st}`);
1976
- if (res && res.gene) console.log(JSON.stringify(res.gene, null, 2));
1977
- if (res && res.event) console.log(JSON.stringify(res.event, null, 2));
1978
- if (res && res.capsule) console.log(JSON.stringify(res.capsule, null, 2));
1979
-
1980
- if (res && res.ok && !dryRun) {
1981
- try {
1982
- const { shouldDistill, prepareDistillation, autoDistill, shouldDistillFromFailures, autoDistillFromFailures } = require('./src/gep/skillDistiller');
1983
- const { readStateForSolidify } = require('./src/gep/solidify');
1984
- const solidifyState = readStateForSolidify();
1985
- const count = solidifyState.solidify_count || 0;
1986
- const autoDistillInterval = 5;
1987
- const autoTrigger = count > 0 && count % autoDistillInterval === 0;
1988
-
1989
- if (autoTrigger || shouldDistill()) {
1990
- const auto = autoDistill();
1991
- if (auto && auto.ok && auto.gene) {
1992
- console.log('[Distiller] Auto-distilled gene: ' + auto.gene.id);
1993
- } else {
1994
- const dr = prepareDistillation();
1995
- if (dr && dr.ok && dr.promptPath) {
1996
- const trigger = autoTrigger ? `auto (every ${autoDistillInterval} solidifies, count=${count})` : 'threshold';
1997
- console.log('\n[DISTILL_REQUEST]');
1998
- console.log(`Distillation triggered: ${trigger}`);
1999
- console.log('Read the prompt file, process it with your LLM,');
2000
- console.log('save the LLM response to a file, then run:');
2001
- console.log(' node index.js distill --response-file=<path_to_llm_response>');
2002
- console.log('Prompt file: ' + dr.promptPath);
2003
- console.log('[/DISTILL_REQUEST]');
2004
- }
2005
- }
2006
- }
2007
-
2008
- if (shouldDistillFromFailures()) {
2009
- const failureResult = autoDistillFromFailures();
2010
- if (failureResult && failureResult.ok && failureResult.gene) {
2011
- console.log('[Distiller] Repair gene distilled from failures: ' + failureResult.gene.id);
2012
- }
2013
- }
2014
- } catch (e) {
2015
- console.warn('[Distiller] Init failed (non-fatal): ' + (e.message || e));
2016
- }
2017
- }
2018
-
2019
- if (res && res.hubReviewPromise) {
2020
- await res.hubReviewPromise;
2021
- }
2022
-
2023
- // Post-solidify urgent questions: when solidify fails or produces a
2024
- // low-quality outcome, generate questions and send them to Hub immediately.
2025
- if (!dryRun) {
2026
- try {
2027
- const { generateUrgentQuestions } = require('./src/gep/questionGenerator');
2028
- const { fetchTasks } = require('./src/gep/taskReceiver');
2029
- const urgentOpts = {};
2030
-
2031
- if (!res || !res.ok) {
2032
- if (res && res.validation && !res.validation.ok) {
2033
- urgentOpts.validationFailed = true;
2034
- const failedStep = res.validation.results && res.validation.results.find(function (r) { return !r.ok; });
2035
- urgentOpts.validationErrors = failedStep ? (failedStep.err || failedStep.cmd || '') : '';
2036
- }
2037
- urgentOpts.geneId = res && res.gene ? res.gene.id : undefined;
2038
- const evtOutcome = res && res.event && res.event.outcome;
2039
- if (evtOutcome && typeof evtOutcome.score === 'number' && evtOutcome.score < 0.3) {
2040
- urgentOpts.lowConfidence = true;
2041
- urgentOpts.confidenceScore = evtOutcome.score;
2042
- urgentOpts.intent = res.event.intent;
2043
- }
2044
- if (res && res.blast && res.blast.files === 0 && res.blast.lines === 0) {
2045
- urgentOpts.zeroBlastRadius = true;
2046
- urgentOpts.hadSignals = true;
2047
- urgentOpts.signals = res.event && Array.isArray(res.event.signals) ? res.event.signals : [];
2048
- }
2049
- if (res && res.constraintCheck && Array.isArray(res.constraintCheck.violations)) {
2050
- const llmRejectV = res.constraintCheck.violations.find(function (v) { return String(v).startsWith('llm_review_rejected'); });
2051
- if (llmRejectV) {
2052
- urgentOpts.llmReviewRejected = true;
2053
- urgentOpts.llmReviewReason = String(llmRejectV).replace('llm_review_rejected: ', '');
2054
- }
2055
- }
2056
- const lr = readJsonSafe(path.join(require('./src/gep/paths').getEvolutionDir(), 'evolution_solidify_state.json'));
2057
- if (lr && lr.last_run && lr.last_run.active_task_id) {
2058
- urgentOpts.taskCompletionFailed = true;
2059
- urgentOpts.taskTitle = lr.last_run.active_task_title || '';
2060
- urgentOpts.taskSignals = Array.isArray(lr.last_run.task_signals) ? lr.last_run.task_signals.join(', ') : '';
2061
- }
2062
- } else if (res.event && res.event.outcome && res.event.outcome.score < 0.3) {
2063
- urgentOpts.lowConfidence = true;
2064
- urgentOpts.confidenceScore = res.event.outcome.score;
2065
- urgentOpts.intent = res.event.intent;
2066
- }
2067
-
2068
- if (Object.keys(urgentOpts).length > 0) {
2069
- const urgentQs = generateUrgentQuestions(urgentOpts);
2070
- if (urgentQs.length > 0) {
2071
- console.log('[UrgentQ] Generated ' + urgentQs.length + ' urgent question(s) from solidify outcome.');
2072
- try {
2073
- const fetchRes = await fetchTasks({ questions: urgentQs });
2074
- if (fetchRes.questions_created) {
2075
- const accepted = fetchRes.questions_created.filter(function (q) { return !q.error; });
2076
- if (accepted.length > 0) {
2077
- console.log('[UrgentQ] Hub accepted ' + accepted.length + ' urgent question(s) as bounties.');
2078
- }
2079
- }
2080
- } catch (err) {
2081
- console.log('[UrgentQ] Send failed (non-fatal): ' + (err && err.message ? err.message : err));
2082
- }
2083
- }
2084
- }
2085
- } catch (e) {
2086
- console.log('[UrgentQ] Init failed (non-fatal): ' + (e && e.message ? e.message : e));
2087
- }
2088
- }
2089
-
2090
- process.exit(res && res.ok ? 0 : 2);
2091
- } catch (error) {
2092
- console.error('[SOLIDIFY] Error:', error);
2093
- process.exit(2);
2094
- }
2095
- } else if (command === 'exec') {
2096
- // node index.js exec --harness=claude-code [--once] [--max-cycles N]
2097
- // P1 auto-exec bridge: run the Brain, scrape its sessions_spawn(...), spawn
2098
- // the Hand (headless claude) to apply + solidify. Shadow-first opt-in.
2099
- if (String(process.env.EVOLVE_EXEC_BRIDGE || '').toLowerCase() !== 'true') {
2100
- console.error('[exec] EVOLVE_EXEC_BRIDGE is not "true". The auto-exec bridge is opt-in. Refusing.');
2101
- process.exit(2);
2102
- }
2103
- const getFlag = (n) => {
2104
- const i = args.findIndex(a => a === `--${n}` || a.startsWith(`--${n}=`));
2105
- if (i === -1) return undefined;
2106
- const h = args[i];
2107
- if (h.includes('=')) return h.split('=').slice(1).join('='); // --n=value
2108
- // bare --n: if the next token is a value (not another --flag), consume it
2109
- // (#179 r6: support `--max-cycles N` space-separated, not just =N). A
2110
- // trailing bare flag with no following value stays boolean true (e.g. --once).
2111
- const next = args[i + 1];
2112
- return (next !== undefined && !next.startsWith('--')) ? next : true;
2113
- };
2114
- const harness = String(getFlag('harness') || 'claude-code');
2115
- const once = getFlag('once') === true;
2116
- // #179 r7: validate --max-cycles. Number('foo')||0 silently became 0 =
2117
- // unbounded daemon — a typo must fail fast, not run forever. Absent flag =>
2118
- // 0 (intentional unbounded). A present value must be a non-negative integer.
2119
- const rawMaxCycles = getFlag('max-cycles');
2120
- let maxCycles = 0;
2121
- if (rawMaxCycles !== undefined && rawMaxCycles !== true) {
2122
- const n = Number(rawMaxCycles);
2123
- if (!Number.isInteger(n) || n < 0) {
2124
- console.error(`[exec] invalid --max-cycles '${rawMaxCycles}' (expected a non-negative integer; 0 or omit = unbounded)`);
2125
- process.exit(2);
2126
- }
2127
- maxCycles = n;
2128
- } else if (rawMaxCycles === true) {
2129
- console.error('[exec] --max-cycles requires a value (e.g. --max-cycles 5 or --max-cycles=5)');
2130
- process.exit(2);
2131
- }
2132
- if (!['claude-code', 'openclaw', 'codex', 'opencode'].includes(harness)) {
2133
- console.error(`[exec] unknown --harness '${harness}' (expected claude-code | openclaw | codex | opencode)`);
2134
- process.exit(2);
2135
- }
2136
- try {
2137
- const { runExecBridge } = require('./src/gep/execBridge');
2138
- const res = await runExecBridge({ harness, once, maxCycles });
2139
- console.log(`[exec] done: cycles=${res.cycles} lastOutcome=${res.lastOutcome}`);
2140
- // Exit 0 only on a genuine success. A bounded/daemon run that ended in
2141
- // hand_failed/brain_failed/no_spawn must report non-zero to shells & CI
2142
- // (Bugbot #179: do not exit 0 on failure just because cycles>0).
2143
- process.exit(res.lastOutcome === 'success' ? 0 : 1);
2144
- } catch (error) {
2145
- console.error('[exec] bridge error:', error && error.message ? error.message : error);
2146
- process.exit(1);
2147
- }
2148
-
2149
- } else if (command === 'distill') {
2150
- const responseFileFlag = args.find(a => typeof a === 'string' && a.startsWith('--response-file='));
2151
- if (!responseFileFlag) {
2152
- console.error('Usage: node index.js distill --response-file=<path>');
2153
- process.exit(1);
2154
- }
2155
- const responseFilePath = responseFileFlag.slice('--response-file='.length);
2156
- {
2157
- const { getRepoRoot } = require('./src/gep/paths');
2158
- const resolvedResponsePath = path.resolve(responseFilePath);
2159
- const resolvedRepoRoot = path.resolve(getRepoRoot());
2160
- if (responseFilePath.includes('..') || !resolvedResponsePath.startsWith(resolvedRepoRoot)) {
2161
- console.error('[Distill] ERROR: Invalid response-file path "' + responseFilePath + '" - path traversal detected or path is outside the repository.');
2162
- process.exit(2);
2163
- }
2164
- }
2165
- try {
2166
- const responseText = fs.readFileSync(responseFilePath, 'utf8');
2167
- const { completeDistillation } = require('./src/gep/skillDistiller');
2168
- const result = completeDistillation(responseText);
2169
- if (result && result.ok) {
2170
- console.log('[Distiller] Gene produced: ' + result.gene.id);
2171
- console.log(JSON.stringify(result.gene, null, 2));
2172
- } else {
2173
- console.warn('[Distiller] Distillation did not produce a gene: ' + (result && result.reason || 'unknown'));
2174
- }
2175
- process.exit(result && result.ok ? 0 : 2);
2176
- } catch (error) {
2177
- console.error('[DISTILL] Error:', error);
2178
- process.exit(2);
2179
- }
2180
-
2181
- } else if (command === 'review' || command === '--review') {
2182
- const { getEvolutionDir, getRepoRoot } = require('./src/gep/paths');
2183
- const { loadGenes } = require('./src/gep/assetStore');
2184
- const { execSync } = require('child_process');
2185
- const MAX_EXEC_BUFFER = 10 * 1024 * 1024; // 10MB; see GHSA reports / #451
2186
-
2187
- const statePath = path.join(getEvolutionDir(), 'evolution_solidify_state.json');
2188
- const state = readJsonSafe(statePath);
2189
- const lastRun = state && state.last_run ? state.last_run : null;
2190
-
2191
- if (!lastRun || !lastRun.run_id) {
2192
- console.log('[Review] No pending evolution run to review.');
2193
- console.log('Run "node index.js run" first to produce changes, then review before solidifying.');
2194
- process.exit(0);
2195
- }
2196
-
2197
- const lastSolid = state && state.last_solidify ? state.last_solidify : null;
2198
- if (lastSolid && String(lastSolid.run_id) === String(lastRun.run_id)) {
2199
- console.log('[Review] Last run has already been solidified. Nothing to review.');
2200
- process.exit(0);
2201
- }
2202
-
2203
- const repoRoot = getRepoRoot();
2204
- let diff = '';
2205
- try {
2206
- const unstaged = execSync('git diff', { cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true }).trim();
2207
- const staged = execSync('git diff --cached', { cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true }).trim();
2208
- const untracked = execSync('git ls-files --others --exclude-standard', { cwd: repoRoot, encoding: 'utf8', timeout: 10000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true }).trim();
2209
- if (staged) diff += '=== Staged Changes ===\n' + staged + '\n\n';
2210
- if (unstaged) diff += '=== Unstaged Changes ===\n' + unstaged + '\n\n';
2211
- if (untracked) diff += '=== Untracked Files ===\n' + untracked + '\n';
2212
- } catch (e) {
2213
- diff = '(failed to capture diff: ' + (e.message || e) + ')';
2214
- }
2215
-
2216
- const genes = loadGenes();
2217
- const geneId = lastRun.selected_gene_id ? String(lastRun.selected_gene_id) : null;
2218
- const gene = geneId ? genes.find(g => g && g.type === 'Gene' && g.id === geneId) : null;
2219
- const signals = Array.isArray(lastRun.signals) ? lastRun.signals : [];
2220
- const mutation = lastRun.mutation || null;
2221
-
2222
- console.log('\n' + '='.repeat(60));
2223
- console.log('[Review] Pending evolution run: ' + lastRun.run_id);
2224
- console.log('='.repeat(60));
2225
- console.log('\n--- Gene ---');
2226
- if (gene) {
2227
- console.log(' ID: ' + gene.id);
2228
- console.log(' Category: ' + (gene.category || '?'));
2229
- console.log(' Summary: ' + (gene.summary || '?'));
2230
- if (Array.isArray(gene.strategy) && gene.strategy.length > 0) {
2231
- console.log(' Strategy:');
2232
- gene.strategy.forEach((s, i) => console.log(' ' + (i + 1) + '. ' + s));
2233
- }
2234
- } else {
2235
- console.log(' (no gene selected or gene not found: ' + (geneId || 'none') + ')');
2236
- }
2237
-
2238
- console.log('\n--- Signals ---');
2239
- if (signals.length > 0) {
2240
- signals.forEach(s => console.log(' - ' + s));
2241
- } else {
2242
- console.log(' (no signals)');
2243
- }
2244
-
2245
- console.log('\n--- Mutation ---');
2246
- if (mutation) {
2247
- console.log(' Category: ' + (mutation.category || '?'));
2248
- console.log(' Risk Level: ' + (mutation.risk_level || '?'));
2249
- if (mutation.rationale) console.log(' Rationale: ' + mutation.rationale);
2250
- } else {
2251
- console.log(' (no mutation data)');
2252
- }
2253
-
2254
- if (lastRun.blast_radius_estimate) {
2255
- console.log('\n--- Blast Radius Estimate ---');
2256
- const br = lastRun.blast_radius_estimate;
2257
- console.log(' Files changed: ' + (br.files_changed || '?'));
2258
- console.log(' Lines changed: ' + (br.lines_changed || '?'));
2259
- }
2260
-
2261
- console.log('\n--- Diff ---');
2262
- if (diff.trim()) {
2263
- console.log(diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, ' + diff.length + ' chars total)' : diff);
2264
- } else {
2265
- console.log(' (no changes detected)');
2266
- }
2267
- console.log('='.repeat(60));
2268
-
2269
- if (args.includes('--approve')) {
2270
- console.log('\n[Review] Approved. Running solidify...\n');
2271
- try {
2272
- const res = await solidify({
2273
- intent: lastRun.intent || undefined,
2274
- rollbackOnFailure: true,
2275
- });
2276
- const st = res && res.ok ? 'SUCCESS' : 'FAILED';
2277
- console.log(`[SOLIDIFY] ${st}`);
2278
- if (res && res.gene) console.log(JSON.stringify(res.gene, null, 2));
2279
- if (res && res.hubReviewPromise) {
2280
- await res.hubReviewPromise;
2281
- }
2282
- process.exit(res && res.ok ? 0 : 2);
2283
- } catch (error) {
2284
- console.error('[SOLIDIFY] Error:', error);
2285
- process.exit(2);
2286
- }
2287
- } else if (args.includes('--reject')) {
2288
- console.log('\n[Review] Rejected. Rolling back changes...');
2289
- try {
2290
- execSync('git checkout -- .', { cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true });
2291
- // Preserve user state on reject: .env files, node_modules, runtime
2292
- // PID files, and a dedicated workspace/ dir (if one exists) MUST NOT
2293
- // be wiped by an automated rollback. Users have reported losing
2294
- // secrets and runtime caches to an aggressive git clean.
2295
- execSync('git clean -fd -e node_modules -e workspace -e .env -e ".env.*" -e "*.pid"', {
2296
- cwd: repoRoot, encoding: 'utf8', timeout: 30000, maxBuffer: MAX_EXEC_BUFFER, windowsHide: true,
2297
- });
2298
- const evolDir = getEvolutionDir();
2299
- const sp = path.join(evolDir, 'evolution_solidify_state.json');
2300
- if (fs.existsSync(sp)) {
2301
- const s = readJsonSafe(sp);
2302
- if (s && s.last_run) {
2303
- s.last_solidify = { run_id: s.last_run.run_id, rejected: true, timestamp: new Date().toISOString() };
2304
- const tmpReject = `${sp}.tmp`;
2305
- fs.writeFileSync(tmpReject, JSON.stringify(s, null, 2) + '\n', 'utf8');
2306
- fs.renameSync(tmpReject, sp);
2307
- }
2308
- }
2309
- console.log('[Review] Changes rolled back.');
2310
- } catch (e) {
2311
- console.error('[Review] Rollback failed:', e.message || e);
2312
- process.exit(2);
2313
- }
2314
- } else {
2315
- console.log('\nTo approve and solidify: node index.js review --approve');
2316
- console.log('To reject and rollback: node index.js review --reject');
2317
- }
2318
-
2319
- } else if (command === 'fetch') {
2320
- let skillId = null;
2321
- const eqFlag = args.find(a => typeof a === 'string' && (a.startsWith('--skill=') || a.startsWith('-s=')));
2322
- if (eqFlag) {
2323
- skillId = eqFlag.split('=').slice(1).join('=');
2324
- } else {
2325
- const sIdx = args.indexOf('-s');
2326
- const longIdx = args.indexOf('--skill');
2327
- const flagIdx = sIdx !== -1 ? sIdx : longIdx;
2328
- if (flagIdx !== -1 && args[flagIdx + 1] && !String(args[flagIdx + 1]).startsWith('-')) {
2329
- skillId = args[flagIdx + 1];
2330
- }
2331
- }
2332
- if (!skillId) {
2333
- const positional = args[1];
2334
- if (positional && !String(positional).startsWith('-')) skillId = positional;
2335
- }
2336
-
2337
- if (!skillId) {
2338
- console.error('Usage: evolver fetch --skill <skill_id>');
2339
- console.error(' evolver fetch -s <skill_id>');
2340
- process.exit(1);
2341
- }
2342
-
2343
- const { getHubUrl, getNodeId, buildHubHeaders, sendHelloToHub, getHubNodeSecret } = require('./src/gep/a2aProtocol');
2344
- const { hubFetch } = require('./src/gep/hubFetch');
2345
-
2346
- const hubUrl = getHubUrl();
2347
- if (!hubUrl) {
2348
- console.error('[fetch] A2A_HUB_URL is not configured.');
2349
- console.error('Set it via environment variable or .env file:');
2350
- console.error(' export A2A_HUB_URL=https://evomap.ai');
2351
- process.exit(1);
2352
- }
2353
-
2354
- try {
2355
- if (!getHubNodeSecret()) {
2356
- // Round-7 (§20.7): if a daemon is up and we have no secret, we
2357
- // would race the daemon's hello and silently corrupt its
2358
- // node_secret. Refuse cleanly with a hint instead.
2359
- refuseHelloIfDaemonRunning('fetch');
2360
- console.log('[fetch] No node_secret found. Sending hello to Hub to register...');
2361
- const helloResult = await sendHelloToHub();
2362
- if (!helloResult || !helloResult.ok) {
2363
- console.error('[fetch] Failed to register with Hub:', helloResult && helloResult.error || 'unknown');
2364
- process.exit(1);
2365
- }
2366
- console.log('[fetch] Registered as ' + getNodeId());
2367
- }
2368
-
2369
- const endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/skill/store/' + encodeURIComponent(skillId) + '/download';
2370
- const nodeId = getNodeId();
2371
-
2372
- console.log('[fetch] Downloading skill: ' + skillId);
2373
-
2374
- const resp = await hubFetch(endpoint, {
2375
- method: 'POST',
2376
- headers: buildHubHeaders(),
2377
- body: JSON.stringify({ sender_id: nodeId }),
2378
- signal: AbortSignal.timeout(30000),
2379
- });
2380
-
2381
- if (!resp.ok) {
2382
- const body = await resp.text().catch(() => '');
2383
- let errorDetail = '';
2384
- let errorCode = '';
2385
- try {
2386
- const j = JSON.parse(body);
2387
- errorDetail = j.detail || j.message || j.error || '';
2388
- errorCode = j.error || j.code || '';
2389
- } catch (_) {
2390
- errorDetail = body ? body.slice(0, 500) : '';
2391
- }
2392
- console.error('[fetch] Download failed (HTTP ' + resp.status + ')' + (errorCode ? ': ' + errorCode : ''));
2393
- if (errorDetail && errorDetail !== errorCode) {
2394
- console.error(' Detail: ' + errorDetail);
2395
- }
2396
- if (resp.status === 404) {
2397
- console.error(' Skill "' + skillId + '" not found or not publicly available.');
2398
- console.error(' Check the skill ID spelling, or browse available skills at https://evomap.ai');
2399
- } else if (resp.status === 401 || resp.status === 403) {
2400
- console.error(' Authentication failed. Try:');
2401
- console.error(' 1. Delete ~/.evomap/node_secret and retry');
2402
- console.error(' 2. Re-register: set A2A_NODE_ID and run fetch again');
2403
- } else if (resp.status === 402) {
2404
- console.error(' Insufficient credits. Check your balance at https://evomap.ai');
2405
- } else if (resp.status >= 500) {
2406
- console.error(' Server error. The Hub may be temporarily unavailable.');
2407
- console.error(' Try again in a few minutes. If the issue persists, report at:');
2408
- console.error(' https://github.com/EvoMap/evolver/issues');
2409
- }
2410
- if (isVerbose) {
2411
- console.error('[Verbose] Endpoint: ' + endpoint);
2412
- console.error('[Verbose] Status: ' + resp.status + ' ' + (resp.statusText || ''));
2413
- console.error('[Verbose] Response body: ' + (body || '(empty)').slice(0, 2000));
2414
- }
2415
- process.exit(1);
2416
- }
2417
-
2418
- const data = await resp.json();
2419
- const outFlag = args.find(a => typeof a === 'string' && a.startsWith('--out='));
2420
- const safeId = String(data.skill_id || skillId).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
2421
- // Reject safeId values that would either stay inside cwd instead of
2422
- // descending into skills/, or escape cwd entirely. The sanitizing regex
2423
- // above permits `.`, so `..` / `.` / empty survive it; `path.join('.',
2424
- // 'skills', '..')` collapses to `.` which turns the download directory
2425
- // into the user's working directory and lets Hub-supplied bundled_files
2426
- // overwrite `index.js`, `package.json`, etc. See GHSA-cfcj-hqpf-hccf.
2427
- if (
2428
- safeId === '' ||
2429
- safeId === '.' ||
2430
- safeId === '..' ||
2431
- safeId.includes('/') ||
2432
- safeId.includes('\\') ||
2433
- safeId.includes('\0')
2434
- ) {
2435
- console.error('[fetch] Hub returned an invalid skill_id: ' + JSON.stringify(safeId));
2436
- process.exit(1);
2437
- }
2438
- let outDir;
2439
- if (outFlag) {
2440
- const rawOut = outFlag.slice('--out='.length);
2441
- if (!rawOut || rawOut.trim() === '') {
2442
- console.error('[fetch] --out= value cannot be empty');
2443
- process.exit(1);
2444
- }
2445
- const resolvedOut = path.resolve(process.cwd(), rawOut);
2446
- const cwd = path.resolve(process.cwd());
2447
- const rel = path.relative(cwd, resolvedOut);
2448
- // Reject paths that escape the current working directory or are
2449
- // absolute on a different volume/root. This prevents --out=../../etc
2450
- // from writing outside the project tree.
2451
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
2452
- console.error('[fetch] --out= must resolve to a path inside the current working directory');
2453
- console.error(' Provided: ' + rawOut);
2454
- console.error(' Resolved: ' + resolvedOut);
2455
- console.error(' Workdir: ' + cwd);
2456
- process.exit(1);
2457
- }
2458
- outDir = resolvedOut;
2459
- } else {
2460
- // Defense in depth: apply the same traversal check to the default
2461
- // branch so any remaining path-smuggling shape in `safeId` is caught.
2462
- const candidate = path.resolve(process.cwd(), 'skills', safeId);
2463
- const skillsRoot = path.resolve(process.cwd(), 'skills');
2464
- const rel = path.relative(skillsRoot, candidate);
2465
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
2466
- console.error('[fetch] Hub-provided skill_id escapes skills/ directory: ' + JSON.stringify(safeId));
2467
- process.exit(1);
2468
- }
2469
- outDir = candidate;
2470
- }
2471
-
2472
- if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
2473
-
2474
- if (data.content) {
2475
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), data.content, 'utf8');
2476
- }
2477
-
2478
- const ALLOWED_SKILL_EXTENSIONS = new Set([
2479
- '.js', '.mjs', '.cjs', '.ts',
2480
- '.json', '.md', '.txt',
2481
- '.sh', '.py',
2482
- '.yml', '.yaml',
2483
- ]);
2484
- const MAX_SKILL_FILE_BYTES = 512 * 1024;
2485
- // Even with outDir locked to skills/, a legitimate-looking skill can
2486
- // ship a bundled file named `package.json`, `index.js`, or any other
2487
- // top-level project artifact whose name collides with something the
2488
- // user may later copy back up. Prefix-guard the resolved path so every
2489
- // write stays strictly within the resolved outDir (no trailing `/..`
2490
- // in basename, no absolute path smuggling) and never points at cwd.
2491
- const resolvedOutDir = path.resolve(outDir);
2492
- const resolvedCwd = path.resolve(process.cwd());
2493
-
2494
- const bundled = Array.isArray(data.bundled_files) ? data.bundled_files : [];
2495
- const skippedFiles = [];
2496
- for (const file of bundled) {
2497
- if (!file || !file.name || typeof file.content !== 'string') continue;
2498
- const safeName = path.basename(file.name);
2499
- if (!safeName || safeName === '.' || safeName === '..') {
2500
- skippedFiles.push(String(file.name));
2501
- continue;
2502
- }
2503
- const ext = path.extname(safeName).toLowerCase();
2504
- if (!ALLOWED_SKILL_EXTENSIONS.has(ext)) {
2505
- console.warn('[fetch] Skipped skill file with disallowed extension: ' + safeName);
2506
- skippedFiles.push(safeName);
2507
- continue;
2508
- }
2509
- if (Buffer.byteLength(file.content, 'utf8') > MAX_SKILL_FILE_BYTES) {
2510
- console.warn('[fetch] Skipped skill file exceeding ' + MAX_SKILL_FILE_BYTES + ' bytes: ' + safeName);
2511
- skippedFiles.push(safeName);
2512
- continue;
2513
- }
2514
- const destPath = path.resolve(resolvedOutDir, safeName);
2515
- const relToOut = path.relative(resolvedOutDir, destPath);
2516
- if (relToOut.startsWith('..') || path.isAbsolute(relToOut)) {
2517
- console.warn('[fetch] Skipped bundled file whose resolved path escapes outDir: ' + safeName);
2518
- skippedFiles.push(safeName);
2519
- continue;
2520
- }
2521
- // Never let a bundled write touch the evolver's own cwd -- this is
2522
- // the concrete attack shape from GHSA-cfcj-hqpf-hccf (fetch default
2523
- // branch writing to `./index.js`). outDir should always be under
2524
- // skills/ now, but belt-and-braces keep the guarantee explicit.
2525
- if (path.dirname(destPath) === resolvedCwd) {
2526
- console.warn('[fetch] Skipped bundled file that would land in cwd: ' + safeName);
2527
- skippedFiles.push(safeName);
2528
- continue;
2529
- }
2530
- fs.writeFileSync(destPath, file.content, 'utf8');
2531
- }
2532
-
2533
- console.log('[fetch] Skill downloaded to: ' + outDir);
2534
- console.log(' Name: ' + (data.name || skillId));
2535
- console.log(' Version: ' + (data.version || '?'));
2536
- console.log(' Files: SKILL.md' + (bundled.length > 0 ? ', ' + bundled.map(f => f.name).join(', ') : ''));
2537
- if (data.already_purchased) {
2538
- console.log(' Fetch cost: free (already purchased)');
2539
- } else {
2540
- console.log(' Fetch cost: ' + (data.credit_cost || 0) + ' credits');
2541
- }
2542
- } catch (error) {
2543
- if (error && error.name === 'TimeoutError') {
2544
- console.error('[fetch] Request timed out (30s). Check your network and A2A_HUB_URL.');
2545
- console.error(' Hub URL: ' + hubUrl);
2546
- } else {
2547
- console.error('[fetch] Error: ' + (error && error.message || error));
2548
- if (error && error.cause) console.error(' Cause: ' + (error.cause.message || error.cause.code || error.cause));
2549
- if (isVerbose && error && error.stack) console.error('[Verbose] Stack:\n' + error.stack);
2550
- }
2551
- process.exit(1);
2552
- }
2553
-
2554
- } else if (command === 'sync') {
2555
- const { getHubUrl, getNodeId, buildHubHeaders, sendHelloToHub, getHubNodeSecret } = require('./src/gep/a2aProtocol');
2556
- const { hubFetch } = require('./src/gep/hubFetch');
2557
- const { upsertGene, upsertCapsule, loadGenes, loadCapsules } = require('./src/gep/assetStore');
2558
- const { getGepAssetsDir, getMemoryDir } = require('./src/gep/paths');
2559
-
2560
- const hubUrl = getHubUrl();
2561
- if (!hubUrl) {
2562
- console.error('[sync] A2A_HUB_URL is not configured.');
2563
- process.exit(1);
2564
- }
2565
-
2566
- try {
2567
- if (!getHubNodeSecret()) {
2568
- // Round-7 (§20.7): refuse a fresh hello if a live daemon owns
2569
- // the lock; the daemon's secret will appear shortly.
2570
- refuseHelloIfDaemonRunning('sync');
2571
- console.log('[sync] No node_secret found. Sending hello to Hub to register...');
2572
- const helloResult = await sendHelloToHub();
2573
- if (!helloResult || !helloResult.ok) {
2574
- console.error('[sync] Failed to register with Hub:', helloResult && helloResult.error || 'unknown');
2575
- process.exit(1);
2576
- }
2577
- console.log('[sync] Registered as ' + getNodeId());
2578
- }
2579
-
2580
- const nodeId = getNodeId();
2581
- const baseUrl = hubUrl.replace(/\/+$/, '');
2582
- const typeFilter = (function () {
2583
- const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--type='); });
2584
- return f ? f.slice('--type='.length) : null;
2585
- })();
2586
- const scopeArg = (function () {
2587
- const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--scope='); });
2588
- return f ? f.slice('--scope='.length) : 'all';
2589
- })();
2590
- const statusFilter = (function () {
2591
- const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--status='); });
2592
- return f ? f.slice('--status='.length) : null;
2593
- })();
2594
- const exportPath = (function () {
2595
- const f = args.find(function (a) { return typeof a === 'string' && a.startsWith('--export='); });
2596
- return f ? f.slice('--export='.length) : null;
2597
- })();
2598
- const dryRun = args.includes('--dry-run');
2599
- const listUnpublished = !args.includes('--no-unpublished-list');
2600
- const force = args.includes('--force');
2601
- const limitPerPage = 100;
2602
-
2603
- const validScopes = new Set(['all', 'purchased', 'published']);
2604
- if (!validScopes.has(scopeArg)) {
2605
- console.error('[sync] Invalid --scope=' + scopeArg + '. Expected: all, purchased, published.');
2606
- process.exit(1);
2607
- }
2608
- const doPurchased = scopeArg === 'all' || scopeArg === 'purchased';
2609
- const doPublished = scopeArg === 'all' || scopeArg === 'published';
2610
-
2611
- async function fetchAllPages(endpoint, extraParams) {
2612
- const out = [];
2613
- let cursor = null;
2614
- let page = 0;
2615
- while (true) {
2616
- page++;
2617
- let url = baseUrl + endpoint + '?node_id=' + encodeURIComponent(nodeId) + '&limit=' + limitPerPage;
2618
- if (cursor) url += '&cursor=' + encodeURIComponent(cursor);
2619
- if (typeFilter) url += '&type=' + encodeURIComponent(typeFilter);
2620
- if (extraParams) {
2621
- for (const [k, v] of Object.entries(extraParams)) {
2622
- if (v != null) url += '&' + k + '=' + encodeURIComponent(v);
2623
- }
2624
- }
2625
- const resp = await hubFetch(url, {
2626
- method: 'GET',
2627
- headers: buildHubHeaders(),
2628
- signal: AbortSignal.timeout(30000),
2629
- });
2630
- if (!resp.ok) {
2631
- const body = await resp.text().catch(function () { return ''; });
2632
- throw new Error('Hub HTTP ' + resp.status + ' on ' + endpoint + ': ' + body.slice(0, 500));
2633
- }
2634
- const data = await resp.json();
2635
- if (Array.isArray(data.assets)) out.push.apply(out, data.assets);
2636
- if (isVerbose) console.log('[sync] ' + endpoint + ' page ' + page + ': ' + (data.count || 0) + ' (total ' + out.length + ')');
2637
- if (data.has_more && data.next_cursor) cursor = data.next_cursor;
2638
- else break;
2639
- }
2640
- return out;
2641
- }
2642
-
2643
- let purchasedAssets = [];
2644
- let publishedAssets = [];
2645
-
2646
- if (doPurchased) {
2647
- console.log('[sync] Fetching purchased assets from Hub...');
2648
- purchasedAssets = await fetchAllPages('/a2a/assets/purchased');
2649
- console.log('[sync] purchased: ' + purchasedAssets.length + ' asset(s)');
2650
- }
2651
- if (doPublished) {
2652
- console.log('[sync] Fetching published-by-me assets from Hub (includes drafts)...');
2653
- publishedAssets = await fetchAllPages('/a2a/assets/published-by-me', { status: statusFilter });
2654
- console.log('[sync] published: ' + publishedAssets.length + ' asset(s)');
2655
- }
2656
-
2657
- const seen = new Set();
2658
- const allAssets = [];
2659
- for (const src of [purchasedAssets, publishedAssets]) {
2660
- for (const asset of src) {
2661
- if (!asset || !asset.asset_id) continue;
2662
- if (seen.has(asset.asset_id)) continue;
2663
- seen.add(asset.asset_id);
2664
- allAssets.push(asset);
2665
- }
2666
- }
2667
-
2668
- if (allAssets.length === 0) {
2669
- console.log('[sync] No remote assets to sync.');
2670
- if (!exportPath && !(listUnpublished && doPublished)) {
2671
- process.exit(0);
2672
- }
2673
- }
2674
-
2675
- const existingGenes = loadGenes();
2676
- const existingCapsules = loadCapsules();
2677
- // Dedup by Hub asset_id is the only safe key. Local-facing `id` (e.g.
2678
- // `gene_gep_repair_from_errors`) collides between bundled default seed
2679
- // genes and identically-named assets that the user later published, so
2680
- // dedup-by-id silently skips legitimate Hub copies on first sync. Track
2681
- // hub_asset_id (set by previous syncs / publishes) and only skip when
2682
- // we've already seen the same Hub-side identity.
2683
- const localHubAssetIds = new Set();
2684
- for (const g of existingGenes) {
2685
- if (g && g.hub_asset_id) localHubAssetIds.add(String(g.hub_asset_id));
2686
- }
2687
- for (const c of existingCapsules) {
2688
- if (c && c.hub_asset_id) localHubAssetIds.add(String(c.hub_asset_id));
2689
- }
2690
- const localGeneIds = new Set(existingGenes.filter(function (g) { return g && g.id; }).map(function (g) { return g.id; }));
2691
- const localCapsuleIds = new Set(existingCapsules.filter(function (c) { return c && c.id; }).map(function (c) { return c.id; }));
2692
-
2693
- let synced = 0;
2694
- let skippedAlreadySynced = 0;
2695
- let skippedIdCollision = 0;
2696
- let fetchErrors = 0;
2697
-
2698
- for (const asset of allAssets) {
2699
- const assetId = asset.asset_id;
2700
- const assetType = asset.asset_type;
2701
- const localId = asset.local_id || assetId;
2702
-
2703
- if (assetType !== 'Gene' && assetType !== 'Capsule') {
2704
- skippedAlreadySynced++;
2705
- continue;
2706
- }
2707
-
2708
- // Already-synced check: same Hub asset_id is already in our local
2709
- // store. Idempotent skip; safe to no-op even with --force because
2710
- // re-fetching the same payload would only rewrite identical bytes.
2711
- if (!force && localHubAssetIds.has(String(assetId))) {
2712
- skippedAlreadySynced++;
2713
- continue;
2714
- }
2715
-
2716
- // Local-id collision: a local entry with the same user-facing id
2717
- // already exists but has no hub_asset_id (e.g. bundled default seed
2718
- // gene, or a hand-edited entry). Without --force we keep the
2719
- // user-owned entry and warn so the user can decide.
2720
- if (!force) {
2721
- if (assetType === 'Gene' && localGeneIds.has(localId)) {
2722
- if (isVerbose) console.warn(' [sync] Skipping ' + localId + ' (local id collision; pass --force to overwrite with Hub copy)');
2723
- skippedIdCollision++;
2724
- continue;
2725
- }
2726
- if (assetType === 'Capsule' && localCapsuleIds.has(localId)) {
2727
- if (isVerbose) console.warn(' [sync] Skipping ' + localId + ' (local id collision; pass --force to overwrite with Hub copy)');
2728
- skippedIdCollision++;
2729
- continue;
2730
- }
2731
- }
2732
-
2733
- if (dryRun) {
2734
- console.log(' [dry-run] Would sync: ' + assetType + ' ' + assetId + (force ? ' (force)' : ''));
2735
- synced++;
2736
- continue;
2737
- }
2738
-
2739
- try {
2740
- let payload = asset.payload;
2741
- if (!payload) {
2742
- const detailResp = await hubFetch(baseUrl + '/a2a/assets/' + encodeURIComponent(assetId) + '?detailed=true', {
2743
- method: 'GET',
2744
- headers: buildHubHeaders(),
2745
- signal: AbortSignal.timeout(15000),
2746
- });
2747
- if (!detailResp.ok) {
2748
- if (isVerbose) console.warn(' [sync] Failed to fetch detail for ' + assetId + ' (HTTP ' + detailResp.status + ')');
2749
- fetchErrors++;
2750
- continue;
2751
- }
2752
- const detail = await detailResp.json();
2753
- payload = detail.payload || {};
2754
- }
2755
-
2756
- if (assetType === 'Gene') {
2757
- const geneObj = {
2758
- type: 'Gene',
2759
- id: payload.id || localId,
2760
- category: payload.category || 'unknown',
2761
- signals: Array.isArray(payload.signals) ? payload.signals : [],
2762
- strategy: Array.isArray(payload.strategy) ? payload.strategy : [],
2763
- avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
2764
- validation: payload.validation || {},
2765
- summary: payload.summary || asset.summary || '',
2766
- hub_asset_id: assetId,
2767
- synced_at: new Date().toISOString(),
2768
- };
2769
- upsertGene(geneObj);
2770
- localGeneIds.add(geneObj.id);
2771
- localHubAssetIds.add(String(assetId));
2772
- } else {
2773
- const capsuleObj = {
2774
- type: 'Capsule',
2775
- id: payload.id || localId,
2776
- gene: payload.gene || null,
2777
- genes_used: Array.isArray(payload.genes_used) ? payload.genes_used : [],
2778
- outcome: payload.outcome || {},
2779
- execution_trace: payload.execution_trace || {},
2780
- summary: payload.summary || asset.summary || '',
2781
- hub_asset_id: assetId,
2782
- synced_at: new Date().toISOString(),
2783
- };
2784
- upsertCapsule(capsuleObj);
2785
- localCapsuleIds.add(capsuleObj.id);
2786
- localHubAssetIds.add(String(assetId));
2787
- }
2788
- synced++;
2789
- } catch (fetchErr) {
2790
- if (isVerbose) console.warn(' [sync] Error fetching ' + assetId + ': ' + (fetchErr && fetchErr.message || fetchErr));
2791
- fetchErrors++;
2792
- }
2793
- }
2794
-
2795
- const skippedTotal = skippedAlreadySynced + skippedIdCollision;
2796
- console.log('[sync] Done. scope=' + scopeArg + ' synced=' + synced + ' skipped=' + skippedTotal + ' (already_synced=' + skippedAlreadySynced + ', id_collision=' + skippedIdCollision + ') errors=' + fetchErrors);
2797
- if (skippedIdCollision > 0 && !force) {
2798
- console.log('[sync] ' + skippedIdCollision + ' Hub asset(s) share a local id with an existing local entry that has no hub_asset_id.');
2799
- console.log('[sync] Re-run with --force to overwrite those local entries with the Hub copies.');
2800
- }
2801
- if (dryRun) console.log('[sync] (dry-run mode: no files were modified)');
2802
-
2803
- if (listUnpublished && doPublished) {
2804
- const hubGeneIds = new Set();
2805
- const hubCapsuleIds = new Set();
2806
- for (const a of publishedAssets) {
2807
- const lid = a.local_id || a.asset_id;
2808
- if (a.asset_type === 'Gene') hubGeneIds.add(lid);
2809
- else if (a.asset_type === 'Capsule') hubCapsuleIds.add(lid);
2810
- }
2811
- const unpublishedGenes = existingGenes.filter(function (g) {
2812
- return g && g.id && !hubGeneIds.has(g.id) && !g.hub_asset_id;
2813
- });
2814
- const unpublishedCapsules = existingCapsules.filter(function (c) {
2815
- return c && c.id && !hubCapsuleIds.has(c.id) && !c.hub_asset_id;
2816
- });
2817
- if (unpublishedGenes.length || unpublishedCapsules.length) {
2818
- console.log('[sync] Local-only (not on Hub): genes=' + unpublishedGenes.length + ' capsules=' + unpublishedCapsules.length);
2819
- if (isVerbose) {
2820
- for (const g of unpublishedGenes.slice(0, 20)) console.log(' gene: ' + g.id);
2821
- for (const c of unpublishedCapsules.slice(0, 20)) console.log(' capsule: ' + c.id);
2822
- if (unpublishedGenes.length + unpublishedCapsules.length > 40) {
2823
- console.log(' ... (truncated; use --export=<path>.gepx to bundle all)');
2824
- }
2825
- }
2826
- }
2827
- }
2828
-
2829
- if (exportPath) {
2830
- if (dryRun) {
2831
- console.log('[sync] [dry-run] Would export to ' + exportPath);
2832
- } else {
2833
- const { exportGepx } = require('./src/gep/portable');
2834
- const assetsDir = getGepAssetsDir();
2835
- const memoryGraphPath = require('path').join(getMemoryDir(), 'memory_graph.jsonl');
2836
- try {
2837
- const result = exportGepx({
2838
- assetsDir,
2839
- memoryGraphPath,
2840
- outputPath: exportPath,
2841
- agentId: nodeId,
2842
- agentName: process.env.AGENT_NAME || 'evolver',
2843
- });
2844
- console.log('[sync] Exported .gepx -> ' + result.outputPath);
2845
- console.log('[sync] stats: ' + JSON.stringify(result.manifest.statistics));
2846
- } catch (exportErr) {
2847
- console.error('[sync] Export failed: ' + (exportErr && exportErr.message || exportErr));
2848
- process.exit(1);
2849
- }
2850
- }
2851
- }
2852
- } catch (error) {
2853
- if (error && error.name === 'TimeoutError') {
2854
- console.error('[sync] Request timed out. Check your network and A2A_HUB_URL.');
2855
- } else {
2856
- console.error('[sync] Error: ' + (error && error.message || error));
2857
- }
2858
- process.exit(1);
2859
- }
2860
-
2861
- } else if (command === 'asset-log') {
2862
- const { summarizeCallLog, readCallLog, getLogPath } = require('./src/gep/assetCallLog');
2863
-
2864
- const runIdFlag = args.find(a => typeof a === 'string' && a.startsWith('--run='));
2865
- const actionFlag = args.find(a => typeof a === 'string' && a.startsWith('--action='));
2866
- const lastFlag = args.find(a => typeof a === 'string' && a.startsWith('--last='));
2867
- const sinceFlag = args.find(a => typeof a === 'string' && a.startsWith('--since='));
2868
- const jsonMode = args.includes('--json');
2869
-
2870
- const opts = {};
2871
- if (runIdFlag) opts.run_id = runIdFlag.slice('--run='.length);
2872
- if (actionFlag) opts.action = actionFlag.slice('--action='.length);
2873
- if (lastFlag) opts.last = parseInt(lastFlag.slice('--last='.length), 10);
2874
- if (sinceFlag) opts.since = sinceFlag.slice('--since='.length);
2875
-
2876
- if (jsonMode) {
2877
- const entries = readCallLog(opts);
2878
- console.log(JSON.stringify(entries, null, 2));
2879
- } else {
2880
- const summary = summarizeCallLog(opts);
2881
- console.log(`\n[Asset Call Log] ${getLogPath()}`);
2882
- console.log(` Total entries: ${summary.total_entries}`);
2883
- console.log(` Unique assets: ${summary.unique_assets}`);
2884
- console.log(` Unique runs: ${summary.unique_runs}`);
2885
- console.log(` By action:`);
2886
- for (const [action, count] of Object.entries(summary.by_action)) {
2887
- console.log(` ${action}: ${count}`);
2888
- }
2889
- if (summary.entries.length > 0) {
2890
- console.log(`\n Recent entries:`);
2891
- const show = summary.entries.slice(-10);
2892
- for (const e of show) {
2893
- const ts = e.timestamp ? e.timestamp.slice(0, 19) : '?';
2894
- const assetShort = e.asset_id ? e.asset_id.slice(0, 20) + '...' : '(none)';
2895
- const sigPreview = Array.isArray(e.signals) ? e.signals.slice(0, 3).join(', ') : '';
2896
- console.log(` [${ts}] ${e.action || '?'} asset=${assetShort} score=${e.score || '-'} mode=${e.mode || '-'} signals=[${sigPreview}] run=${e.run_id || '-'}`);
2897
- }
2898
- } else {
2899
- console.log('\n No entries found.');
2900
- }
2901
- console.log('');
2902
- }
2903
-
2904
- } else if (command === 'trajectory-export') {
2905
- const allowPartial = args.includes('--allow-partial');
2906
- const runtimeSessions = args.includes('--runtime-sessions');
2907
- // Strict-by-default marking gate (v1): runtime-session discovery only
2908
- // collects sessions evolver actively marked (session-start hook), minus
2909
- // those the gateway already captured. These flags open each gate back up.
2910
- const includeUnmarked = args.includes('--include-unmarked');
2911
- const includeGatewayCaptured = args.includes('--include-gateway-captured');
2912
- try {
2913
- const optionValue = (name) => {
2914
- let value;
2915
- for (let i = 0; i < args.length; i += 1) {
2916
- const arg = args[i];
2917
- if (arg === name) {
2918
- if (i + 1 >= args.length || String(args[i + 1]).startsWith('--')) {
2919
- throw new Error('missing value for ' + name);
2920
- }
2921
- value = String(args[i + 1]);
2922
- } else if (typeof arg === 'string' && arg.startsWith(name + '=')) {
2923
- value = arg.slice(name.length + 1);
2924
- }
2925
- }
2926
- return value;
2927
- };
2928
- const optionValues = (name) => {
2929
- const values = [];
2930
- for (let i = 0; i < args.length; i += 1) {
2931
- const arg = args[i];
2932
- if (arg === name) {
2933
- if (i + 1 >= args.length || String(args[i + 1]).startsWith('--')) {
2934
- throw new Error('missing value for ' + name);
2935
- }
2936
- values.push(String(args[i + 1]));
2937
- } else if (typeof arg === 'string' && arg.startsWith(name + '=')) {
2938
- values.push(arg.slice(name.length + 1));
2939
- }
2940
- }
2941
- return values;
2942
- };
2943
- const input = optionValue('--input');
2944
- const output = optionValue('--output');
2945
- const runtimeSessionDirs = optionValues('--runtime-session-dir');
2946
- const nodeSecretInline = optionValue('--node-secret');
2947
- const nodeSecretFile = optionValue('--node-secret-file');
2948
- const nodeSecretEnv = optionValue('--node-secret-env');
2949
- const hubPrivateKeyFile = optionValue('--hub-private-key');
2950
- const nodeSecretKeyringFile = optionValue('--node-secret-keyring');
2951
- const markedSessionsFile = optionValue('--marked-sessions-file');
2952
- let nodeSecret;
2953
- let hubPrivateKey;
2954
- let nodeSecretKeyring;
2955
- const nodeSecretSourceCount = [nodeSecretInline, nodeSecretFile, nodeSecretEnv]
2956
- .filter((value) => value !== undefined).length;
2957
- if (nodeSecretSourceCount > 1) {
2958
- throw new Error('use only one node secret source');
2959
- }
2960
- if (nodeSecretInline !== undefined) {
2961
- if (!nodeSecretInline) throw new Error('missing value for --node-secret');
2962
- if (fs.existsSync(nodeSecretInline)) {
2963
- try {
2964
- nodeSecret = fs.readFileSync(nodeSecretInline, 'utf8').trim();
2965
- } catch (readErr) {
2966
- const err = new Error('node secret file is not readable: ' + nodeSecretInline);
2967
- err.cause = readErr;
2968
- throw err;
2969
- }
2970
- if (!nodeSecret) throw new Error('node secret file is empty');
2971
- } else {
2972
- nodeSecret = String(nodeSecretInline).trim();
2973
- if (!nodeSecret) throw new Error('node secret is empty');
2974
- }
2975
- }
2976
- if (nodeSecretFile !== undefined) {
2977
- if (!nodeSecretFile) throw new Error('missing path for --node-secret-file');
2978
- try {
2979
- nodeSecret = fs.readFileSync(nodeSecretFile, 'utf8').trim();
2980
- } catch (readErr) {
2981
- const err = new Error('node secret file is not readable: ' + nodeSecretFile);
2982
- err.cause = readErr;
2983
- throw err;
2984
- }
2985
- if (!nodeSecret) throw new Error('node secret file is empty');
2986
- }
2987
- if (nodeSecretEnv !== undefined) {
2988
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(nodeSecretEnv)) {
2989
- throw new Error('invalid environment variable name for --node-secret-env');
2990
- }
2991
- nodeSecret = String(process.env[nodeSecretEnv] || '').trim();
2992
- if (!nodeSecret) throw new Error('node secret environment variable is empty or unset');
2993
- }
2994
- if (hubPrivateKeyFile !== undefined) {
2995
- if (!hubPrivateKeyFile) throw new Error('missing path for --hub-private-key');
2996
- try {
2997
- hubPrivateKey = fs.readFileSync(hubPrivateKeyFile, 'utf8');
2998
- } catch (readErr) {
2999
- const err = new Error('hub private key file is not readable: ' + hubPrivateKeyFile);
3000
- err.cause = readErr;
3001
- throw err;
3002
- }
3003
- if (!String(hubPrivateKey || '').trim()) throw new Error('hub private key file is empty');
3004
- }
3005
- if (nodeSecretKeyringFile !== undefined) {
3006
- if (!nodeSecretKeyringFile) throw new Error('missing path for --node-secret-keyring');
3007
- try {
3008
- nodeSecretKeyring = JSON.parse(fs.readFileSync(nodeSecretKeyringFile, 'utf8'));
3009
- } catch (readErr) {
3010
- const err = new Error('node secret keyring file is not readable or invalid JSON: ' + nodeSecretKeyringFile);
3011
- err.cause = readErr;
3012
- throw err;
3013
- }
3014
- }
3015
- const { writeTrajectories } = require('./src/gep/trajectoryExport');
3016
- const result = writeTrajectories({
3017
- input,
3018
- output,
3019
- nodeSecret,
3020
- nodeSecretKeyring,
3021
- hubPrivateKey,
3022
- allowPartial,
3023
- runtimeSessions: runtimeSessions ? true : undefined,
3024
- runtimeSessionDirs,
3025
- markedSessionsFile,
3026
- includeUnmarked: includeUnmarked ? true : undefined,
3027
- includeGatewayCaptured: includeGatewayCaptured ? true : undefined,
3028
- });
3029
- console.log('[trajectory-export] Wrote ' + result.trajectories.length + ' trajector' + (result.trajectories.length === 1 ? 'y' : 'ies') + ' to ' + result.outputPath);
3030
- console.log('[trajectory-export] Read ' + result.rowsRead + ' trace row(s) and ' + (result.sessionTurnsRead || 0) + ' session turn(s) from ' + (result.filesRead || 0) + ' file(s).');
3031
- if (result.sessionFilesRead > 0) {
3032
- console.log('[trajectory-export] Converted ' + result.sessionFilesRead + ' runtime session file(s).');
3033
- }
3034
- if (result.runtimeSessionDiscovery && result.runtimeSessionDiscovery.enabled) {
3035
- console.log('[trajectory-export] Runtime session discovery is local-only; no Hub upload is performed.');
3036
- console.log('[trajectory-export] Runtime discovery scanned '
3037
- + result.runtimeSessionDiscovery.dirsScanned + ' Codex/Claude dir(s), matched '
3038
- + result.runtimeSessionDiscovery.filesMatched + ' workspace session file(s).');
3039
- const mg = result.runtimeSessionDiscovery.markGate;
3040
- if (mg) {
3041
- console.log('[trajectory-export] Marking gate: enforce_marked=' + mg.enforceMarked
3042
- + ', exclude_gateway_captured=' + mg.excludeGatewayCaptured
3043
- + '; marked_registry=' + mg.markedSessionCount
3044
- + ', gateway_captured=' + mg.gatewayCapturedCount
3045
- + ', excluded_unmarked=' + mg.excludedByMark
3046
- + ', excluded_gateway=' + mg.excludedByGateway + '.');
3047
- }
3048
- }
3049
- if (result.stats) {
3050
- console.log('[trajectory-export] Scanned ' + result.stats.rowsScanned
3051
- + ' row(s); encrypted=' + result.stats.encryptedRows
3052
- + ', skipped_missing_secret=' + result.stats.skippedMissingSecret
3053
- + ', decrypt_failures=' + result.stats.decryptFailures
3054
- + ', invalid_json=' + result.stats.invalidJson
3055
- + ', session_invalid_json=' + (result.stats.sessionInvalidJson || 0)
3056
- + ', non_prism=' + result.stats.nonPrismSkipped + '.');
3057
- }
3058
- } catch (error) {
3059
- console.error('[trajectory-export] Failed: ' + (error && error.message || error));
3060
- process.exit(1);
3061
- }
3062
-
3063
- } else if (command === 'webui') {
3064
- const portFlag = args.find(a => typeof a === 'string' && a.startsWith('--port='));
3065
- const port = portFlag ? Number(portFlag.slice('--port='.length)) : undefined;
3066
- const { startWebUi } = require('./src/webui');
3067
- try {
3068
- const info = await startWebUi({ port });
3069
- console.log('[webui] Open ' + info.url);
3070
- const shutdown = async () => {
3071
- try { await info.server.stop(); } catch (_) {}
3072
- process.exit(0);
3073
- };
3074
- process.on('SIGINT', shutdown);
3075
- process.on('SIGTERM', shutdown);
3076
- await new Promise(() => {});
3077
- } catch (error) {
3078
- console.error('[webui] Failed: ' + (error && error.message || error));
3079
- process.exit(1);
3080
- }
3081
-
3082
- } else if (command === 'login') {
3083
- const { deviceLogin, resolveHubUrl, tokenFile } = require('./src/gep/oauthLogin');
3084
- const hubUrl = resolveHubUrl();
3085
- try {
3086
- console.log('Logging in to ' + hubUrl + ' ...');
3087
- const tok = await deviceLogin({
3088
- hubUrl,
3089
- onCode: ({ userCode, verificationUri }) => {
3090
- console.log('\nTo authorize this device:');
3091
- console.log(' 1. open ' + verificationUri);
3092
- console.log(' 2. enter code: ' + userCode);
3093
- console.log('\nWaiting for approval (Ctrl-C to cancel)...');
3094
- },
3095
- });
3096
- console.log('\n✓ Logged in. Token stored at ' + tokenFile() + ' (expires ' + new Date(tok.expires_at).toISOString() + ').');
3097
- process.exit(0);
3098
- } catch (error) {
3099
- console.error('login failed: ' + (error && error.message || error));
3100
- process.exit(1);
3101
- }
3102
-
3103
- } else if (command === 'logout') {
3104
- const { clearOAuthToken, tokenFile } = require('./src/gep/oauthLogin');
3105
- const removed = clearOAuthToken();
3106
- console.log(removed ? ('Logged out (removed ' + tokenFile() + ').') : 'No OAuth token to remove.');
3107
- process.exit(0);
3108
-
3109
- } else if (command === 'setup-hooks') {
3110
- const hookAdapter = require('./src/adapters/hookAdapter');
3111
- const { setupHooks, resolveConfigRoot, detectPlatform, loadAdapter } = hookAdapter;
3112
-
3113
- const platformFlag = args.find(a => typeof a === 'string' && a.startsWith('--platform='));
3114
- const platform = platformFlag ? platformFlag.slice('--platform='.length) : undefined;
3115
- const force = args.includes('--force');
3116
- const uninstall = args.includes('--uninstall');
3117
- const verifyOnly = args.includes('--verify');
3118
-
3119
- if (verifyOnly) {
3120
- // Read-only verification: do not touch any files, just report whether
3121
- // the previously-installed hooks/plugin look healthy. Lets users answer
3122
- // "is the plugin actually loaded?" without grepping opencode logs.
3123
- try {
3124
- const platformId = platform || detectPlatform(process.cwd());
3125
- if (!platformId) {
3126
- console.error('[setup-hooks] --verify: could not detect platform. Pass --platform=opencode|cursor|claude-code|codex|kiro');
3127
- process.exit(2);
3128
- }
3129
- const adapter = loadAdapter(platformId);
3130
- if (!adapter || typeof adapter.verify !== 'function') {
3131
- console.error('[setup-hooks] --verify: platform ' + platformId + ' does not support verification yet.');
3132
- process.exit(2);
3133
- }
3134
- const configRoot = resolveConfigRoot(platformId, process.cwd());
3135
- const report = adapter.verify({ configRoot });
3136
- if (typeof adapter.printVerifyReport === 'function') {
3137
- adapter.printVerifyReport(report);
3138
- } else {
3139
- console.log(JSON.stringify(report, null, 2));
3140
- }
3141
- process.exit(report.ok ? 0 : 1);
3142
- } catch (verifyErr) {
3143
- console.error('[setup-hooks] --verify error:', verifyErr && verifyErr.message || verifyErr);
3144
- process.exit(1);
3145
- }
3146
- }
3147
-
3148
- try {
3149
- const result = await setupHooks({
3150
- platform,
3151
- cwd: process.cwd(),
3152
- force,
3153
- uninstall,
3154
- evolverRoot: __dirname,
3155
- });
3156
- if (result && result.ok) {
3157
- if (!uninstall && result.files) {
3158
- console.log('\n[setup-hooks] Files created/updated:');
3159
- for (const f of result.files) {
3160
- console.log(' ' + f);
3161
- }
3162
- }
3163
- process.exit(0);
3164
- } else {
3165
- console.error('[setup-hooks] Failed: ' + (result && result.error || 'unknown'));
3166
- process.exit(1);
3167
- }
3168
- } catch (error) {
3169
- console.error('[setup-hooks] Error:', error && error.message || error);
3170
- process.exit(1);
3171
- }
3172
-
3173
- } else if (command === 'reset-local-secret') {
3174
- // Wipe every local store of node_secret in one shot, so a daemon stuck
3175
- // after a manual web reset (https://evomap.ai/account -> Reset Secret)
3176
- // can boot clean. Local stores involved:
3177
- // - MailboxStore: ~/.evomap/mailbox/state.json node_secret state keys
3178
- // - Legacy files: ~/.evomap/node_secret, node_secret_version,
3179
- // node_secret_source, and node_secret_env_suppressed
3180
- // - Shell env: A2A_NODE_SECRET / EVOMAP_NODE_SECRET and matching
3181
- // version vars (we cannot mutate the parent shell; we
3182
- // just print the unset hint)
3183
- const path = require('path');
3184
- const fs = require('fs');
3185
- // Honor an explicit HOME override (used by tests to redirect to a fake
3186
- // home) before falling back to os.homedir(). On POSIX, os.homedir() also
3187
- // reads $HOME first, so this is a no-op in practice on macOS/Linux. On
3188
- // Windows, os.homedir() reads %USERPROFILE% and ignores HOME -- without
3189
- // this fallback, test/resetLocalSecret.test.js cannot inject a fake home
3190
- // and the reset operates on the real user dir.
3191
- const home = process.env.HOME || os.homedir();
3192
- const evomapDirs = [];
3193
- const seenEvomapDirs = new Set();
3194
- const addEvomapDir = (dir) => {
3195
- if (!dir) return;
3196
- const resolved = path.resolve(dir);
3197
- if (seenEvomapDirs.has(resolved)) return;
3198
- seenEvomapDirs.add(resolved);
3199
- evomapDirs.push(dir);
3200
- };
3201
- addEvomapDir(path.join(home, '.evomap'));
3202
- addEvomapDir(process.env.EVOLVER_HOME);
3203
- let cleared = 0;
3204
- try {
3205
- for (const evomapDir of evomapDirs) {
3206
- const stateFile = path.join(evomapDir, 'mailbox', 'state.json');
3207
- if (fs.existsSync(stateFile)) {
3208
- const raw = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
3209
- let mutated = false;
3210
- for (const k of ['node_secret', 'node_secret_source', 'node_secret_version', 'node_secret_env_suppressed']) {
3211
- if (raw[k] !== undefined && raw[k] !== '') {
3212
- raw[k] = '';
3213
- mutated = true;
3214
- }
3215
- }
3216
- if (mutated) {
3217
- fs.writeFileSync(stateFile, JSON.stringify(raw, null, 2) + '\n', 'utf8');
3218
- cleared += 1;
3219
- console.log('[reset-local-secret] cleared MailboxStore at ' + stateFile);
3220
- } else {
3221
- console.log('[reset-local-secret] MailboxStore had no node_secret to clear at ' + stateFile);
3222
- }
3223
- }
3224
- for (const legacyName of ['node_secret', 'node_secret_version', 'node_secret_source', 'node_secret_env_suppressed']) {
3225
- const legacyFile = path.join(evomapDir, legacyName);
3226
- if (fs.existsSync(legacyFile)) {
3227
- fs.unlinkSync(legacyFile);
3228
- cleared += 1;
3229
- console.log('[reset-local-secret] removed legacy file ' + legacyFile);
3230
- }
3231
- }
3232
- }
3233
- } catch (err) {
3234
- console.error('[reset-local-secret] error:', err && err.message || err);
3235
- process.exit(1);
3236
- }
3237
- const stillSetEnv = [
3238
- 'A2A_NODE_SECRET',
3239
- 'A2A_NODE_SECRET_VERSION',
3240
- 'EVOMAP_NODE_SECRET',
3241
- 'EVOMAP_NODE_SECRET_VERSION',
3242
- ].filter((key) => process.env[key]);
3243
- if (stillSetEnv.length > 0) {
3244
- console.log('');
3245
- console.log('[reset-local-secret] Node secret env vars are still set in this shell: ' + stillSetEnv.join(', '));
3246
- console.log('[reset-local-secret] Run: unset A2A_NODE_SECRET A2A_NODE_SECRET_VERSION EVOMAP_NODE_SECRET EVOMAP_NODE_SECRET_VERSION');
3247
- console.log('[reset-local-secret] Or update secret and version as a matched pair before restarting the daemon.');
3248
- } else {
3249
- console.log('[reset-local-secret] Node secret env vars are not set in env -- good.');
3250
- }
3251
- console.log('[reset-local-secret] ' + cleared + ' location(s) cleared. Restart the daemon to pick a fresh secret from the hub.');
3252
- process.exit(0);
3253
-
3254
- } else if (command === 'atp-complete') {
3255
- // Invoked by a spawned Cursor sub-session after it has written the ATP
3256
- // task answer to a file. Drives publish -> task/complete -> atp/deliver.
3257
- try {
3258
- // Round-8 (§21.8): if a daemon is up and the spawned subsession
3259
- // somehow has no secret on disk, the inner completeAtpTask ->
3260
- // _ensureNodeSecret -> sendHelloToHub call would race the
3261
- // daemon's hello and silently corrupt the daemon's node_secret
3262
- // (same vector round-7 §20.7 closed for fetch/sync). In the
3263
- // common happy path the daemon already registered, the secret
3264
- // exists, the guard is a no-op. Imported lazily so the helper
3265
- // resolution does not slow down unrelated subcommands.
3266
- try {
3267
- const { getHubNodeSecret } = require('./src/gep/a2aProtocol');
3268
- if (!getHubNodeSecret()) refuseHelloIfDaemonRunning('atp-complete');
3269
- } catch (_) { /* never block ATP completion on a guard error */ }
3270
- const subArgs = args.slice(1);
3271
- function flag(name) {
3272
- const pref = '--' + name + '=';
3273
- const hit = subArgs.find(function (a) { return typeof a === 'string' && a.startsWith(pref); });
3274
- return hit ? hit.slice(pref.length) : null;
3275
- }
3276
- function list(name) {
3277
- const raw = flag(name);
3278
- if (!raw) return null;
3279
- return raw.split(',').map(function (s) { return String(s).trim(); }).filter(Boolean);
3280
- }
3281
- const taskId = flag('task-id');
3282
- const orderId = flag('order-id');
3283
- const answerFile = flag('answer-file');
3284
- const summary = flag('summary');
3285
- const capabilities = list('capabilities');
3286
- const signals = list('signals');
3287
- if (!taskId || !orderId || !answerFile) {
3288
- console.error('[ATP-Complete] Missing required flags: --task-id, --order-id, --answer-file');
3289
- console.error('Usage: node index.js atp-complete --task-id=<tid> --order-id=<oid> --answer-file=<path> [--summary="..."] [--capabilities=cap1,cap2] [--signals=sig1,sig2]');
3290
- process.exit(2);
3291
- }
3292
- const { completeAtpTask } = require('./src/atp/atpExecute');
3293
- const res = await completeAtpTask({ taskId, orderId, answerFile, summary, capabilities, signals });
3294
- if (res && res.ok) {
3295
- console.log('[ATP-Complete] OK asset_id=' + res.assetId + (res.deliveryId ? ' delivery_id=' + res.deliveryId : ''));
3296
- process.exit(0);
3297
- }
3298
- console.error('[ATP-Complete] FAILED stage=' + (res && res.stage) + ' error=' + (res && res.error));
3299
- process.exit(1);
3300
- } catch (atpCompleteErr) {
3301
- console.error('[ATP-Complete] Error:', atpCompleteErr && atpCompleteErr.message || atpCompleteErr);
3302
- process.exit(1);
3303
- }
3304
-
3305
- } else if (command === 'buy' || command === 'orders' || command === 'verify' || command === 'atp') {
3306
- try {
3307
- // Round-8 (§21.8): same daemon-vs-CLI race protection as fetch/sync
3308
- // and atp-complete. The ATP runners (consumerAgent / merchantAgent
3309
- // / atpExecute) all call sendHelloToHub when getHubNodeSecret() is
3310
- // empty, which clobbers a running daemon's secret and silences it
3311
- // for 30 min..4 h. The check is a no-op when a secret already
3312
- // exists (the common case once the daemon has registered).
3313
- try {
3314
- const { getHubNodeSecret } = require('./src/gep/a2aProtocol');
3315
- if (!getHubNodeSecret()) refuseHelloIfDaemonRunning(command);
3316
- } catch (_) { /* never block ATP CLI on a guard error */ }
3317
- const atpCli = require('./src/atp/cli');
3318
- const subArgs = args.slice(1); // drop the command token (e.g. "buy") itself
3319
- let parsed;
3320
- let runner;
3321
- if (command === 'buy') {
3322
- parsed = atpCli.parseBuyArgs(subArgs);
3323
- runner = atpCli.runBuy;
3324
- } else if (command === 'orders') {
3325
- parsed = atpCli.parseOrdersArgs(subArgs);
3326
- runner = atpCli.runOrders;
3327
- } else if (command === 'verify') {
3328
- parsed = atpCli.parseVerifyArgs(subArgs);
3329
- runner = atpCli.runVerify;
3330
- } else {
3331
- parsed = atpCli.parseAtpArgs(subArgs);
3332
- runner = atpCli.runAtp;
3333
- }
3334
- if (!parsed.ok) {
3335
- console.error('[ATP] ' + parsed.error);
3336
- console.error(atpCli.printUsage());
3337
- process.exit(2);
3338
- }
3339
- const res = await runner(parsed.opts);
3340
- process.exit(res && typeof res.exitCode === 'number' ? res.exitCode : 0);
3341
- } catch (atpCliErr) {
3342
- console.error('[ATP] CLI error:', atpCliErr && atpCliErr.message || atpCliErr);
3343
- process.exit(1);
3344
- }
3345
-
3346
- } else if (command === 'reuse') {
3347
- try {
3348
- const { runReuseCommand } = require('./src/gep/cliContracts');
3349
- process.exit(await runReuseCommand(args.slice(1)));
3350
- } catch (e) {
3351
- process.stdout.write(JSON.stringify({
3352
- ok: false,
3353
- contract: 'reuse.v1',
3354
- reason: 'internal_error',
3355
- message: 'evolver reuse failed',
3356
- }) + '\n');
3357
- process.exit(1);
3358
- }
3359
-
3360
- } else if (command === 'publish') {
3361
- try {
3362
- const { runPublishCommand } = require('./src/gep/cliContracts');
3363
- process.exit(await runPublishCommand(args.slice(1)));
3364
- } catch (e) {
3365
- process.stdout.write(JSON.stringify({
3366
- ok: false,
3367
- contract: 'publish.v1',
3368
- reason: 'internal_error',
3369
- retryable: false,
3370
- message: 'evolver publish failed',
3371
- }) + '\n');
3372
- process.exit(1);
3373
- }
3374
-
3375
- } else if (command === 'recipe') {
3376
- // recipe build — assemble a DNA blueprint from owned Gene/Capsule assets
3377
- // recipe reuse — fetch + express an existing recipe into an organism
3378
- const sub = args[1];
3379
- const {
3380
- getHubUrl, getNodeId, getHubNodeSecret, sendHelloToHub, rotateNodeSecret,
3381
- hubCreateRecipe, hubPublishRecipe, hubGetRecipe, hubExpressRecipe,
3382
- } = require('./src/gep/a2aProtocol');
3383
-
3384
- const hubUrl = getHubUrl();
3385
- if (!hubUrl) {
3386
- console.error('[recipe] A2A_HUB_URL is not configured. Set A2A_HUB_URL (e.g. https://evomap.ai).');
3387
- process.exit(1);
3388
- }
3389
-
3390
- function flagVal(name) {
3391
- const eq = args.find(a => typeof a === 'string' && a.startsWith('--' + name + '='));
3392
- return eq ? eq.split('=').slice(1).join('=') : null;
3393
- }
3394
- async function ensureRegistered(tag) {
3395
- if (!getHubNodeSecret()) {
3396
- console.log('[' + tag + '] No node_secret found. Registering with Hub...');
3397
- const hello = await sendHelloToHub();
3398
- if (!hello || !hello.ok) {
3399
- console.error('[' + tag + '] Failed to register with Hub:', (hello && hello.error) || 'unknown');
3400
- process.exit(1);
3401
- }
3402
- console.log('[' + tag + '] Registered as ' + getNodeId());
3403
- }
3404
- }
3405
- // True when the hub rejected our node_secret as stale/invalid — the one
3406
- // case where a rotate-and-retry is the documented recovery.
3407
- function isStaleSecret(result) {
3408
- if (!result || result.ok) return false;
3409
- if (result.status !== 401 && result.status !== 403) return false;
3410
- const e = String(result.error || '');
3411
- return e.includes('node_secret_invalid') || e.includes('node_secret_not_set');
3412
- }
3413
- // Run a hub call; if it fails because our node_secret is stale, rotate
3414
- // once and retry. Rotation only works when the CURRENT secret is still
3415
- // server-valid (the hub authenticates the rotate with it). If the secret
3416
- // has fully diverged from the server, rotation cannot recover it — that
3417
- // requires re-registering, so we surface the actionable recovery path
3418
- // instead of silently looping.
3419
- let _authRecoveryFailed = false;
3420
- async function callWithAuthRetry(tag, fn) {
3421
- let result = await fn();
3422
- if (isStaleSecret(result) && typeof rotateNodeSecret === 'function' && !_authRecoveryFailed) {
3423
- console.log('[' + tag + '] node_secret stale; rotating via /a2a/hello and retrying...');
3424
- const rot = await rotateNodeSecret();
3425
- if (rot && rot.ok) {
3426
- result = await fn();
3427
- } else {
3428
- _authRecoveryFailed = true;
3429
- console.error('[' + tag + '] Could not auto-rotate: the local node_secret has diverged from the Hub and can no longer authenticate a rotate.');
3430
- console.error(' Recover by either:');
3431
- console.error(' 1. Reset Secret on the web (Account -> Reset Secret), then run: node index.js reset-local-secret');
3432
- console.error(' 2. Or register a fresh node: set a new A2A_NODE_ID and retry (auto-provisions).');
3433
- }
3434
- }
3435
- return result;
3436
- }
3437
- function reportHubError(tag, result) {
3438
- console.error('[' + tag + '] Hub call failed' + (result.status ? ' (HTTP ' + result.status + ')' : '') + ': ' + (result.error || 'unknown'));
3439
- if (result.status === 401 || result.status === 403) console.error(' Auth failed. If this persists, delete ~/.evomap/node_secret and retry.');
3440
- else if (result.status === 402) console.error(' Insufficient credits. Check your balance at ' + hubUrl);
3441
- }
3442
-
3443
- if (sub === 'build') {
3444
- // --genes=<asset_id,...> ordered; types resolved from the local asset store.
3445
- const genesArg = flagVal('genes');
3446
- const title = flagVal('title');
3447
- const description = flagVal('description');
3448
- const doPublish = args.includes('--publish');
3449
- if (!genesArg || !title) {
3450
- console.error('Usage: node index.js recipe build --title="..." --genes=<asset_id,...> [--description="..."] [--price=N] [--publish]');
3451
- console.error(' Builds a DRAFT recipe by default. --publish is opt-in and pushes it live.');
3452
- process.exit(1);
3453
- }
3454
- const { loadGenes, loadCapsules } = require('./src/gep/assetStore');
3455
- const typeById = new Map();
3456
- try {
3457
- for (const g of (loadGenes() || [])) if (g && g.asset_id) typeById.set(g.asset_id, 'Gene');
3458
- for (const c of (loadCapsules() || [])) if (c && c.asset_id) typeById.set(c.asset_id, 'Capsule');
3459
- } catch (e) { /* fall back to Gene below */ }
3460
-
3461
- const ids = genesArg.split(',').map(s => s.trim()).filter(Boolean);
3462
- if (ids.length === 0) { console.error('[recipe build] --genes is empty.'); process.exit(1); }
3463
- if (ids.length > 20) { console.error('[recipe build] at most 20 steps per recipe.'); process.exit(1); }
3464
- const steps = ids.map((asset_id, i) => ({
3465
- asset_id,
3466
- asset_type: typeById.get(asset_id) || 'Gene',
3467
- position: i,
3468
- }));
3469
-
3470
- await ensureRegistered('recipe build');
3471
- const priceVal = flagVal('price');
3472
- const createRes = await callWithAuthRetry('recipe build', () => hubCreateRecipe({
3473
- title, steps, description: description || undefined,
3474
- pricePerExecution: priceVal ? Number(priceVal) : undefined,
3475
- }));
3476
- if (!createRes.ok) { reportHubError('recipe build', createRes); process.exit(1); }
3477
- const recipe = (createRes.data && (createRes.data.recipe || createRes.data)) || {};
3478
- const recipeId = recipe.id;
3479
- console.log('[recipe build] Created DRAFT recipe ' + recipeId + ' ("' + title + '", ' + steps.length + ' steps).');
3480
-
3481
- if (doPublish && recipeId) {
3482
- const pubRes = await callWithAuthRetry('recipe build', () => hubPublishRecipe(recipeId));
3483
- if (!pubRes.ok) { reportHubError('recipe build', pubRes); process.exit(1); }
3484
- console.log('[recipe build] Published recipe ' + recipeId + ' to the marketplace.');
3485
- } else if (!doPublish) {
3486
- console.log('[recipe build] Left as draft. Re-run with --publish to make it live.');
3487
- }
3488
- process.exit(0);
3489
- } else if (sub === 'reuse') {
3490
- const recipeId = flagVal('id') || (args[2] && !String(args[2]).startsWith('-') ? args[2] : null);
3491
- if (!recipeId) {
3492
- console.error('Usage: node index.js recipe reuse --id=<recipe_id> [--input=<json>]');
3493
- process.exit(1);
3494
- }
3495
- await ensureRegistered('recipe reuse');
3496
- const getRes = await hubGetRecipe(recipeId);
3497
- if (!getRes.ok) { reportHubError('recipe reuse', getRes); process.exit(1); }
3498
- let inputPayload = {};
3499
- const inputArg = flagVal('input');
3500
- if (inputArg) {
3501
- try { inputPayload = JSON.parse(inputArg); }
3502
- catch (e) { console.error('[recipe reuse] --input must be valid JSON.'); process.exit(1); }
3503
- }
3504
- const expRes = await callWithAuthRetry('recipe reuse', () => hubExpressRecipe(recipeId, inputPayload));
3505
- if (!expRes.ok) { reportHubError('recipe reuse', expRes); process.exit(1); }
3506
- console.log('[recipe reuse] Expressed recipe ' + recipeId + '.');
3507
- if (isVerbose) console.log(JSON.stringify(expRes.data, null, 2));
3508
- process.exit(0);
3509
- } else {
3510
- console.error('Usage: node index.js recipe <build|reuse> [flags]');
3511
- console.error(' build --title="..." --genes=<asset_id,...> [--publish] (draft unless --publish)');
3512
- console.error(' reuse --id=<recipe_id> [--input=<json>]');
3513
- process.exit(1);
3514
- }
3515
-
3516
- } else if (command === 'experiment') {
3517
- // Comparative experiment runner: run the SAME task twice -- a baseline arm
3518
- // and a variant arm that reuses a gene's strategy -- via a headless agent
3519
- // CLI, collect duration/rounds/tokens/pass-rate, and print a comparison
3520
- // JSON to stdout. Consumed by EvoMap Desktop's ExperimentsAPI.Run, which
3521
- // spawns `node index.js experiment --request-file=<json>` and parses stdout.
3522
- try {
3523
- const expCli = require('./src/experiment/cli');
3524
- const parsed = expCli.parseExperimentArgs(args.slice(1));
3525
- if (!parsed.ok) {
3526
- console.error('[Experiment] ' + parsed.error);
3527
- console.error(expCli.printExperimentUsage());
3528
- process.exit(2);
3529
- }
3530
- const res = await expCli.runExperiment(parsed.opts, { err: (...a) => console.error(...a) });
3531
- // stdout carries ONLY the structured JSON so the Go caller can JSON.parse
3532
- // it without log contamination; all logging above went to stderr. res.data
3533
- // is already secret-redacted by runExperiment (sanitizePayload).
3534
- if (res && res.data) process.stdout.write(JSON.stringify(res.data) + '\n');
3535
- process.exit(res && typeof res.exitCode === 'number' ? res.exitCode : (res && res.ok ? 0 : 1));
3536
- } catch (expErr) {
3537
- console.error('[Experiment] CLI error:', expErr && expErr.message || expErr);
3538
- process.exit(1);
3539
- }
3540
-
3541
- } else {
3542
- console.log(`Usage: node index.js [run|/evolve|login|logout|proxy-token|solidify|review|distill|fetch|sync|asset-log|trajectory-export|webui|setup-hooks|reuse|publish|recipe|buy|orders|verify|atp|atp-complete|experiment] [--loop]
3543
- - login (authorize this device via the hub, gh-auth-login style; stores an OAuth token used instead of node_secret)
3544
- - logout (remove the stored OAuth token)
3545
- - proxy-token (print the local proxy bearer token for command-backed client auth)
3546
- - experiment flags:
3547
- - --task="..." --metric="..." (required; same task, baseline vs variant)
3548
- - --gene=<geneId> (variant arm reuses this gene's strategy)
3549
- - --baseline="..." --variant="..." --validation="c1;;c2" --request-file=<json>
3550
- - recipe flags:
3551
- - build --title="..." --genes=<asset_id,...> [--description] [--price=N] [--publish]
3552
- (builds a DRAFT DNA blueprint; --publish is opt-in)
3553
- - reuse --id=<recipe_id> [--input=<json>] (express a recipe into an organism)
3554
- - reuse flags:
3555
- - --id=<asset_id> --json (reuse a Hub asset into the local library; stdout JSON contract reuse.v1)
3556
- - publish flags:
3557
- - --asset=<id|path> [--asset ...] [--dry-run] --json
3558
- (publish.v1 stdout JSON contract for Desktop)
3559
- - fetch flags:
3560
- - --skill=<id> | -s <id> (skill ID to download)
3561
- - --out=<dir> (output directory, default: ./skills/<skill_id>)
3562
- - sync flags:
3563
- - --scope=all|purchased|published (default: all)
3564
- - --type=Gene|Capsule (filter by asset type)
3565
- - --status=draft,promoted,all (only for published scope; default promoted+draft)
3566
- - --export=<path.gepx> (also bundle local assets into a .gepx archive)
3567
- - --no-unpublished-list (suppress local-only asset list)
3568
- - --force (overwrite local entries that share an id with a Hub asset; bypasses default-seed dedup)
3569
- - --dry-run (preview without writing to local store)
3570
- - solidify flags:
3571
- - --dry-run
3572
- - --no-rollback
3573
- - --intent=repair|optimize|innovate
3574
- - --summary=...
3575
- - review flags:
3576
- - --approve (approve and solidify the pending changes)
3577
- - --reject (reject and rollback the pending changes)
3578
- - distill flags:
3579
- - --response-file=<path> (LLM response file for skill distillation)
3580
- - setup-hooks flags:
3581
- - --platform=cursor|claude-code|codex|kiro|opencode (auto-detect if omitted)
3582
- - --force (overwrite existing config)
3583
- - --uninstall (remove evolver hooks)
3584
- - --verify (read-only: print install health for the chosen platform)
3585
- - asset-log flags:
3586
- - --run=<run_id> (filter by run ID)
3587
- - --action=<action> (filter: hub_search_hit, hub_search_miss, asset_reuse, asset_reference, asset_publish, asset_publish_skip)
3588
- - --last=<N> (show last N entries)
3589
- - --since=<ISO_date> (entries after date)
3590
- - --json (raw JSON output)
3591
- - trajectory-export flags:
3592
- - --input=<path>|--input <path>
3593
- (proxy trace JSONL, Claude/Codex/Cursor session JSONL, or directory; default: platform trace file)
3594
- - --output=<path>|--output <path>
3595
- (output JSONL; default: ./coding-trajectories.jsonl)
3596
- - --runtime-sessions (local-only: also scan current-workspace Codex/Claude JSONL from ~/.codex/sessions and ~/.claude/projects; env: EVOLVER_TRAJECTORY_RUNTIME_SESSIONS=1)
3597
- - --runtime-session-dir=<path>|--runtime-session-dir <path>
3598
- (local-only: extra Codex/Claude runtime session directory; may be repeated)
3599
- - --include-unmarked (collect runtime sessions even if evolver did not actively mark them; default: strict — only marked sessions are collected; env: EVOLVER_TRAJECTORY_INCLUDE_UNMARKED=1)
3600
- - --include-gateway-captured (collect runtime sessions even if the proxy gateway already captured them; default: strict — gateway-captured sessions are skipped; env: EVOLVER_TRAJECTORY_INCLUDE_GATEWAY_CAPTURED=1)
3601
- - --marked-sessions-file=<path>|--marked-sessions-file <path>
3602
- (override the evolver-marked-sessions registry path; default: <trace-dir>/marked-sessions.jsonl; env: EVOLVER_MARKED_SESSIONS_FILE)
3603
- - --allow-partial (skip unreadable encrypted rows instead of failing the whole export)
3604
- - --node-secret=<hex|path>|--node-secret <hex|path>
3605
- (read node secret from an existing local file, otherwise use the literal value)
3606
- - --node-secret-file=<path>|--node-secret-file <path>
3607
- (read node secret from a local file)
3608
- - --node-secret-env=<name>|--node-secret-env <name>
3609
- (read node secret from an environment variable)
3610
- - --node-secret-keyring=<path>|--node-secret-keyring <path>
3611
- (read versioned node secrets from a JSON file)
3612
- - --hub-private-key=<path>|--hub-private-key <path>
3613
- (decrypt hub_key_envelope trace rows with a local private key)
3614
- - webui flags:
3615
- - --port=<N> (local Web UI port, default 19821)
3616
-
3617
- ATP (Agent Transaction Protocol) subcommands:
3618
- - buy <caps> (place an ATP order; caps is comma-separated)
3619
- - --budget=<N> (credits to spend, default 10)
3620
- - --question="..." (order description)
3621
- - --routing=<mode> (fastest|cheapest|auction|swarm, default fastest)
3622
- - --verify=<mode> (auto|ai_judge|bilateral, default auto)
3623
- - --no-wait (return immediately after placing)
3624
- - --timeout=<seconds> (lifecycle timeout, default 300)
3625
- - orders (list your recent ATP orders / deliveries)
3626
- - --role=consumer|merchant (default consumer)
3627
- - --status=pending|verified|disputed|settled
3628
- - --limit=<N> (1..100, default 20)
3629
- - --json (raw JSON)
3630
- - verify <orderId> (confirm delivery or trigger AI judge)
3631
- - --action=confirm|ai_judge (default confirm)
3632
- - atp-complete (internal: spawned Cursor sub-session uses this to settle an ATP task)
3633
- - --task-id=<tid> (Hub task id, required)
3634
- - --order-id=<oid> (ATP DeliveryProof id, required)
3635
- - --answer-file=<path> (file containing the merchant answer, required)
3636
- - --summary="..." (capsule summary, optional)
3637
- - --capabilities=a,b (listing capabilities, optional)
3638
- - --signals=s1,s2 (task signals, optional)
3639
-
3640
- Validator role (decentralized validation, default ON since v1.69.0):
3641
- - EVOLVER_VALIDATOR_ENABLED=0 opt out (env beats persisted flag and default)
3642
- - EVOLVER_VALIDATOR_ENABLED=1 explicitly opt in
3643
- - unset honor persisted flag from ~/.evomap/feature_flags.json,
3644
- else default ON. The hub may push a flag update via
3645
- the mailbox (event type: feature_flag_update).
3646
- - Earnings: validators earn credits + reputation from successful consensus.
3647
- See docs/validator.md for details.`);
3648
- }
3649
- }
3650
-
3651
- if (require.main === module) {
3652
- main().catch(function (err) {
3653
- console.error('[FATAL] Top-level error:', err && err.stack ? err.stack : String(err));
3654
- process.exitCode = 1;
3655
- });
3656
- }
3657
-
3658
- module.exports = {
3659
- main,
3660
- readJsonSafe,
3661
- rejectPendingRun,
3662
- isPendingSolidify,
3663
- parseBoolEnv,
3664
- CycleTimeoutError,
3665
- writeCycleProgressAtomic,
3666
- handleCycleTimeout,
3667
- observeTimedOutEvolvePromise,
3668
- waitForTimedOutEvolvePromise,
3669
- spawnReplacementProcess,
3670
- };
1
+ export * from '@evomap/evolver-cli';