@evomap/evolver 1.89.2 → 1.89.4

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 (110) hide show
  1. package/.cursor/BUGBOT.md +182 -0
  2. package/.env.example +68 -0
  3. package/.git-commit-guard-token +1 -0
  4. package/.github/CODEOWNERS +63 -0
  5. package/.github/ISSUE_TEMPLATE/good_first_issue.md +23 -0
  6. package/.github/pull_request_template.md +45 -0
  7. package/.github/workflows/test.yml +75 -0
  8. package/CHANGELOG.md +1237 -0
  9. package/README.ja-JP.md +1 -3
  10. package/README.ko-KR.md +1 -3
  11. package/README.md +86 -530
  12. package/README.public.md +569 -0
  13. package/README.zh-CN.md +1 -3
  14. package/SECURITY.md +108 -0
  15. package/assets/gep/events.jsonl +3 -0
  16. package/assets/gep/genes.json +496 -0
  17. package/examples/atp-consumer-quickstart.md +100 -0
  18. package/examples/hello-world.md +38 -0
  19. package/index.js +44 -48
  20. package/package.json +6 -17
  21. package/proxy-package.json +39 -0
  22. package/public.manifest.json +143 -0
  23. package/src/adapters/hookAdapter.js +2 -0
  24. package/src/adapters/scripts/_lockPaths.js +74 -0
  25. package/src/adapters/scripts/evolver-session-start.js +19 -27
  26. package/src/config.js +23 -0
  27. package/src/evolve/guards.js +721 -1
  28. package/src/evolve/pipeline/collect.js +1283 -1
  29. package/src/evolve/pipeline/dispatch.js +421 -1
  30. package/src/evolve/pipeline/enrich.js +440 -1
  31. package/src/evolve/pipeline/hub.js +319 -1
  32. package/src/evolve/pipeline/select.js +274 -1
  33. package/src/evolve/pipeline/signals.js +206 -1
  34. package/src/evolve/utils.js +264 -1
  35. package/src/evolve.js +350 -1
  36. package/src/experiment/agentRunner.js +229 -0
  37. package/src/experiment/cli.js +159 -0
  38. package/src/experiment/comparison.js +233 -0
  39. package/src/experiment/metrics.js +75 -0
  40. package/src/forceUpdate.js +311 -30
  41. package/src/gep/a2aProtocol.js +4455 -1
  42. package/src/gep/antiAbuseTelemetry.js +233 -0
  43. package/src/gep/autoDistillConv.js +205 -1
  44. package/src/gep/autoDistillLlm.js +315 -1
  45. package/src/gep/candidateEval.js +92 -1
  46. package/src/gep/candidates.js +198 -1
  47. package/src/gep/contentHash.js +30 -1
  48. package/src/gep/conversationSniffer.js +266 -1
  49. package/src/gep/crypto.js +89 -1
  50. package/src/gep/curriculum.js +163 -1
  51. package/src/gep/deviceId.js +218 -1
  52. package/src/gep/envFingerprint.js +118 -1
  53. package/src/gep/epigenetics.js +31 -1
  54. package/src/gep/execBridge.js +711 -1
  55. package/src/gep/explore.js +289 -1
  56. package/src/gep/hash.js +15 -1
  57. package/src/gep/hubFetch.js +359 -1
  58. package/src/gep/hubReview.js +207 -1
  59. package/src/gep/hubSearch.js +526 -1
  60. package/src/gep/hubVerify.js +306 -1
  61. package/src/gep/learningSignals.js +89 -1
  62. package/src/gep/memoryGraph.js +1374 -1
  63. package/src/gep/memoryGraphAdapter.js +203 -1
  64. package/src/gep/mutation.js +203 -1
  65. package/src/gep/narrativeMemory.js +108 -1
  66. package/src/gep/openPRRegistry.js +205 -1
  67. package/src/gep/personality.js +423 -1
  68. package/src/gep/policyCheck.js +599 -1
  69. package/src/gep/prompt.js +836 -1
  70. package/src/gep/recallInject.js +409 -1
  71. package/src/gep/recallVerifier.js +318 -1
  72. package/src/gep/reflection.js +177 -1
  73. package/src/gep/sanitize.js +9 -0
  74. package/src/gep/selector.js +602 -1
  75. package/src/gep/skillDistiller.js +1294 -1
  76. package/src/gep/solidify.js +1699 -1
  77. package/src/gep/strategy.js +136 -1
  78. package/src/gep/tokenSavings.js +88 -1
  79. package/src/gep/validator/sandboxExecutor.js +29 -1
  80. package/src/gep/workspaceKeychain.js +174 -1
  81. package/src/proxy/extensions/traceControl.js +99 -1
  82. package/src/proxy/index.js +14 -5
  83. package/src/proxy/inject.js +52 -1
  84. package/src/proxy/lifecycle/manager.js +30 -0
  85. package/src/proxy/mailbox/store.js +2 -1
  86. package/src/proxy/router/messages_route.js +13 -2
  87. package/src/proxy/trace/extractor.js +646 -1
  88. package/src/proxy/trace/usage.js +105 -1
  89. package/CONTRIBUTING.md +0 -19
  90. package/assets/cover.png +0 -0
  91. package/assets/gep/genes.seed.json +0 -245
  92. package/scripts/a2a_export.js +0 -63
  93. package/scripts/a2a_ingest.js +0 -79
  94. package/scripts/a2a_promote.js +0 -118
  95. package/scripts/analyze_by_skill.js +0 -121
  96. package/scripts/build_binaries.js +0 -479
  97. package/scripts/check-changelog.js +0 -166
  98. package/scripts/extract_log.js +0 -85
  99. package/scripts/generate_history.js +0 -75
  100. package/scripts/gep_append_event.js +0 -96
  101. package/scripts/gep_personality_report.js +0 -234
  102. package/scripts/human_report.js +0 -147
  103. package/scripts/recall-verify-report.js +0 -234
  104. package/scripts/recover_loop.js +0 -61
  105. package/scripts/seed-merchants.js +0 -91
  106. package/scripts/suggest_version.js +0 -89
  107. package/scripts/validate-modules.js +0 -38
  108. package/scripts/validate-suite.js +0 -78
  109. package/skills/index.json +0 -14
  110. /package/{skills → bundled-skills}/_meta/SKILL.md +0 -0
@@ -0,0 +1,75 @@
1
+ // src/experiment/metrics.js
2
+ //
3
+ // Pure, table-driven mapping from a human metric label (e.g. "完成耗时 (s)",
4
+ // "轮次", "token", "通过率") onto a per-arm field + comparison direction.
5
+ // No I/O, no side effects -- safe to unit-test in isolation.
6
+ 'use strict';
7
+
8
+ function num(v, fallback) {
9
+ const n = Number(v);
10
+ return Number.isFinite(n) ? n : (fallback === undefined ? 0 : fallback);
11
+ }
12
+
13
+ function round(n, digits) {
14
+ const f = Math.pow(10, digits);
15
+ return Math.round((num(n) + Number.EPSILON) * f) / f;
16
+ }
17
+
18
+ // Ordered rules. The FIRST rule whose any keyword is a (case-insensitive)
19
+ // substring of the metric label wins. Order matters: pass-rate / rounds /
20
+ // tokens / cost are checked before duration so a label like "通过率" is not
21
+ // swallowed by a looser rule.
22
+ const METRIC_RULES = [
23
+ { keys: ['通过率', 'pass', 'success', 'accuracy', '准确', '正确率'], field: 'passRate', lowerIsBetter: false },
24
+ { keys: ['轮次', 'turn', 'round', 'step', 'iteration', '迭代'], field: 'rounds', lowerIsBetter: true },
25
+ { keys: ['token', '令牌'], field: 'tokensTotal', lowerIsBetter: true },
26
+ { keys: ['成本', 'cost', 'usd', '价格', '费用'], field: 'costUsd', lowerIsBetter: true },
27
+ { keys: ['耗时', 'duration', 'latency', '延迟', '秒', 'second', '(s)', 'time'], field: 'durationMs', lowerIsBetter: true },
28
+ ];
29
+
30
+ /**
31
+ * Resolve a metric label to the per-arm field used for scoring, the
32
+ * comparison direction, and the display unit.
33
+ *
34
+ * @param {string} metricStr
35
+ * @returns {{ metricField: string, lowerIsBetter: boolean, scoreUnit: string, recognized: boolean }}
36
+ */
37
+ function deriveMetric(metricStr) {
38
+ const m = String(metricStr || '').toLowerCase();
39
+ for (const rule of METRIC_RULES) {
40
+ if (rule.keys.some((k) => m.includes(String(k).toLowerCase()))) {
41
+ if (rule.field === 'durationMs') {
42
+ // Seconds-flavoured labels ("(s)", "秒", "seconds") -> report in seconds.
43
+ if (/\(s\)|秒|second/.test(m)) {
44
+ return { metricField: 'durationSec', lowerIsBetter: true, scoreUnit: 'seconds', recognized: true };
45
+ }
46
+ return { metricField: 'durationMs', lowerIsBetter: true, scoreUnit: 'ms', recognized: true };
47
+ }
48
+ return { metricField: rule.field, lowerIsBetter: rule.lowerIsBetter, scoreUnit: 'raw', recognized: true };
49
+ }
50
+ }
51
+ // Unrecognized -> degrade to pass-rate (higher is better). Caller records a warning.
52
+ return { metricField: 'passRate', lowerIsBetter: false, scoreUnit: 'raw', recognized: false };
53
+ }
54
+
55
+ /**
56
+ * Pull the scalar score for one arm given the resolved metric field.
57
+ *
58
+ * @param {object} arm a normalized arm (see comparison.normalizeArm)
59
+ * @param {string} metricField
60
+ * @returns {number}
61
+ */
62
+ function scoreArm(arm, metricField) {
63
+ if (!arm) return 0;
64
+ switch (metricField) {
65
+ case 'durationSec': return round(num(arm.durationMs) / 1000, 2);
66
+ case 'durationMs': return num(arm.durationMs);
67
+ case 'rounds': return num(arm.rounds);
68
+ case 'tokensTotal': return num(arm.tokensTotal);
69
+ case 'costUsd': return round(num(arm.costUsd), 4);
70
+ case 'passRate': return round(num(arm.passRate), 4);
71
+ default: return round(num(arm.passRate), 4);
72
+ }
73
+ }
74
+
75
+ module.exports = { deriveMetric, scoreArm, METRIC_RULES, round, num };
@@ -38,6 +38,173 @@ const FORCE_UPDATE_NOOP = Symbol('FORCE_UPDATE_NOOP');
38
38
  // fire its own reportForceUpdateOutcome. See test/forceUpdateConcurrencyGuard.test.js.
39
39
  const FORCE_UPDATE_BUSY = Symbol('FORCE_UPDATE_BUSY');
40
40
 
41
+ // Structured failure taxonomy. Historically every failing branch of
42
+ // _executeForceUpdateInner just `return false`, so the only thing that ever
43
+ // reached the hub (via reportForceUpdateOutcome) was the literal string
44
+ // "executeForceUpdate returned false" — degit-missing, tag-404, version
45
+ // mismatch and copy-EPERM were all indistinguishable in EvolverUpgradeAttempt.
46
+ // Each branch now returns _fail(code, detail); the reporter encodes it as
47
+ // `error = code + ': ' + detail`, so operators can GROUP BY the code prefix
48
+ // without any hub schema / DB migration. Codes are a small stable set — keep
49
+ // new ones coarse and additive so historical `error LIKE 'code%'` queries
50
+ // don't churn.
51
+ const FORCE_UPDATE_FAIL_CODES = Object.freeze({
52
+ INSTALL_GUARD_NAME_MISMATCH: 'install_guard_name_mismatch',
53
+ INSTALL_GUARD_UNREADABLE: 'install_guard_unreadable',
54
+ BAD_REQUIRED_VERSION: 'bad_required_version',
55
+ CURRENT_VERSION_UNPARSABLE: 'current_version_unparsable',
56
+ NPX_NOT_FOUND: 'npx_not_found',
57
+ DEGIT_TIMEOUT: 'degit_timeout',
58
+ DEGIT_FAILED: 'degit_failed',
59
+ DOWNLOAD_INCOMPLETE: 'download_incomplete',
60
+ DOWNLOADED_VERSION_MISMATCH: 'downloaded_version_mismatch',
61
+ DELETE_FAILED: 'delete_failed',
62
+ COPY_FAILED: 'copy_failed',
63
+ ALL_CHANNELS_EXHAUSTED: 'all_channels_exhausted',
64
+ });
65
+
66
+ // Build the structured failure result that replaces a bare `return false`.
67
+ // Shape: { ok:false, code, detail }. Distinct from `true`, FORCE_UPDATE_NOOP
68
+ // and FORCE_UPDATE_BUSY, so the three call sites' `result === true` /
69
+ // `result === SENTINEL` checks keep classifying it as "failed" unchanged —
70
+ // this is backward compatible. Frozen so a downstream consumer cannot mutate
71
+ // the code/detail before it is reported. detail is best-effort context (an
72
+ // errno, a version delta, an entry name); it is redacted + truncated to
73
+ // ERROR_MAX by the reporter before it leaves the process.
74
+ function _fail(code, detail) {
75
+ return Object.freeze({
76
+ ok: false,
77
+ code: String(code),
78
+ detail: detail == null ? '' : String(detail),
79
+ });
80
+ }
81
+
82
+ // Compact "CODE: message" rendering of a thrown error for the detail field.
83
+ function _errStr(e) {
84
+ if (!e) return 'unknown';
85
+ var code = e.code ? String(e.code) + ': ' : '';
86
+ return code + (e.message != null ? String(e.message) : String(e));
87
+ }
88
+
89
+ // Map a Channel 1 (GitHub Release / degit) throw to a structured failure.
90
+ // `phase` records how far the try block got before throwing, so a readFileSync
91
+ // ENOENT (truncated download) is not misread as an npx ENOENT (npx missing):
92
+ // 'degit' -> the npx/degit spawn itself
93
+ // 'parse' -> degit exited 0 but the downloaded package.json is missing/invalid
94
+ // 'copy' -> the staged tree downloaded fine but cpSync into INSTALL_ROOT failed
95
+ function _classifyChannel1Error(e, phase) {
96
+ if (phase === 'delete') {
97
+ var deleteEntry = e && e._evolverEntry ? String(e._evolverEntry) + ': ' : '';
98
+ return _fail(FORCE_UPDATE_FAIL_CODES.DELETE_FAILED, deleteEntry + _errStr(e));
99
+ }
100
+ if (phase === 'copy') {
101
+ var entry = e && e._evolverEntry ? String(e._evolverEntry) + ': ' : '';
102
+ return _fail(FORCE_UPDATE_FAIL_CODES.COPY_FAILED, entry + _errStr(e));
103
+ }
104
+ if (phase === 'parse') {
105
+ return _fail(FORCE_UPDATE_FAIL_CODES.DOWNLOAD_INCOMPLETE,
106
+ 'missing/invalid package.json in downloaded tree: ' + _errStr(e));
107
+ }
108
+ // phase === 'degit' (the spawn). ENOENT here is the npx binary itself, not a
109
+ // file inside the download — that distinction is exactly why `phase` exists.
110
+ if (e && e.code === 'ENOENT') {
111
+ return _fail(FORCE_UPDATE_FAIL_CODES.NPX_NOT_FOUND, _errStr(e));
112
+ }
113
+ // execFileSync timeout kills the child with SIGTERM (and sets .killed); some
114
+ // platforms surface ETIMEDOUT instead. Either way it is a 60s timeout.
115
+ if (e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT')) {
116
+ return _fail(FORCE_UPDATE_FAIL_CODES.DEGIT_TIMEOUT,
117
+ 'degit timed out after 60s' + (e.signal ? ' (signal=' + e.signal + ')' : ''));
118
+ }
119
+ // Generic degit/network/tag-not-found failure. degit prints the real reason
120
+ // ("could not find commit hash for v…", "could not resolve host") to stderr,
121
+ // so keep a tail of it. Redact + strip control chars HERE, before the tail
122
+ // slice: the downstream reporter redact (a2aProtocol.reportForceUpdateOutcome)
123
+ // runs after this, so slicing first could chop a token's prefix anchor and
124
+ // let the bare value slip past the prefix-anchored redact patterns. Stripping
125
+ // ANSI/NUL/newlines also keeps the persisted error free of terminal-injection
126
+ // sequences and log-line noise.
127
+ var detail = _errStr(e);
128
+ var stderr = '';
129
+ if (e && e.stderr != null) {
130
+ try {
131
+ var redactString = require('./gep/sanitize').redactString;
132
+ stderr = redactString(String(e.stderr)).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
133
+ } catch (_) {
134
+ // sanitize unavailable — still strip control chars so logs stay clean.
135
+ stderr = String(e.stderr).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
136
+ }
137
+ }
138
+ if (stderr) detail += ' | stderr=' + stderr.slice(-300);
139
+ return _fail(FORCE_UPDATE_FAIL_CODES.DEGIT_FAILED, detail);
140
+ }
141
+
142
+ function _isRetryableFsLockError(e) {
143
+ var code = e && e.code;
144
+ return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES' ||
145
+ code === 'ENOTEMPTY' || code === 'EMFILE' || code === 'ENFILE';
146
+ }
147
+
148
+ function _waitForFsLockRetry() {
149
+ var until = Date.now() + 200;
150
+ while (Date.now() < until) { /* spin */ }
151
+ }
152
+
153
+ function _retryFsLockOperation(fn) {
154
+ var err = null;
155
+ for (var attempt = 0; attempt < 3; attempt++) {
156
+ try {
157
+ return fn();
158
+ } catch (e) {
159
+ err = e;
160
+ if (!_isRetryableFsLockError(e)) break;
161
+ if (attempt < 2) _waitForFsLockRetry();
162
+ }
163
+ }
164
+ throw err;
165
+ }
166
+
167
+ function _recoverPackageCommitMarkerIfMissing(installRoot) {
168
+ var pkgDst = path.join(installRoot, 'package.json');
169
+ if (fs.existsSync(pkgDst)) return false;
170
+ var entries;
171
+ try {
172
+ entries = fs.readdirSync(installRoot);
173
+ } catch (_) {
174
+ return false;
175
+ }
176
+ var backups = entries
177
+ .filter(function (name) { return /^package\.json\.\d+\.evolver-old$/.test(name); })
178
+ .sort();
179
+ for (var i = backups.length - 1; i >= 0; i--) {
180
+ try {
181
+ fs.renameSync(path.join(installRoot, backups[i]), pkgDst);
182
+ console.warn('[ForceUpdate] Recovered package.json commit marker from ' + backups[i]);
183
+ return true;
184
+ } catch (_) {}
185
+ }
186
+ return false;
187
+ }
188
+
189
+ function _restorePackageBackup(pkgBackup, pkgDst) {
190
+ if (!fs.existsSync(pkgBackup)) return false;
191
+ if (fs.existsSync(pkgDst)) {
192
+ try { fs.rmSync(pkgBackup, { force: true }); } catch (_) {}
193
+ return false;
194
+ }
195
+ try {
196
+ fs.renameSync(pkgBackup, pkgDst);
197
+ return true;
198
+ } catch (_) {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ function _isForceUpdateKeepEntry(name) {
204
+ return name === 'node_modules' || name === 'memory' || name === '.git' || name === 'MEMORY.md' ||
205
+ name === '.env' || name === '.env.local' || name === 'USER.md' || name === '.evolver';
206
+ }
207
+
41
208
  // Module-level mutex: shared by every caller that requires('../forceUpdate'),
42
209
  // so the heartbeat-thread trigger in a2aProtocol.js and the evolve-tick path
43
210
  // in enrich/pipeline cannot run executeForceUpdate concurrently. This is a
@@ -163,12 +330,33 @@ function _executeForceUpdateInner(forceUpdate) {
163
330
  console.warn('[ForceUpdate] Refusing — ' + INSTALL_ROOT +
164
331
  '/package.json has name="' + (pkg && pkg.name) +
165
332
  '", expected "@evomap/evolver". Aborting to avoid data loss.');
166
- return false;
333
+ return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_NAME_MISMATCH,
334
+ 'install root package.json name="' + (pkg && pkg.name) + '", expected "@evomap/evolver"');
167
335
  }
168
336
  } catch (e) {
169
- console.warn('[ForceUpdate] Refusing — cannot read ' + INSTALL_ROOT +
170
- '/package.json: ' + (e && e.message || e));
171
- return false;
337
+ if (_recoverPackageCommitMarkerIfMissing(INSTALL_ROOT)) {
338
+ try {
339
+ const recoveredPkg = JSON.parse(fs.readFileSync(path.join(INSTALL_ROOT, 'package.json'), 'utf8'));
340
+ if (!recoveredPkg || (recoveredPkg.name !== '@evomap/evolver' && recoveredPkg.name !== 'evolver')) {
341
+ console.warn('[ForceUpdate] Refusing — recovered ' + INSTALL_ROOT +
342
+ '/package.json has name="' + (recoveredPkg && recoveredPkg.name) +
343
+ '", expected "@evomap/evolver". Aborting to avoid data loss.');
344
+ return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_NAME_MISMATCH,
345
+ 'recovered install root package.json name="' + (recoveredPkg && recoveredPkg.name) +
346
+ '", expected "@evomap/evolver"');
347
+ }
348
+ } catch (recoverReadErr) {
349
+ console.warn('[ForceUpdate] Refusing — cannot read recovered ' + INSTALL_ROOT +
350
+ '/package.json: ' + (recoverReadErr && recoverReadErr.message || recoverReadErr));
351
+ return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_UNREADABLE,
352
+ 'cannot read recovered install root package.json: ' + _errStr(recoverReadErr));
353
+ }
354
+ } else {
355
+ console.warn('[ForceUpdate] Refusing — cannot read ' + INSTALL_ROOT +
356
+ '/package.json: ' + (e && e.message || e));
357
+ return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_UNREADABLE,
358
+ 'cannot read install root package.json: ' + _errStr(e));
359
+ }
172
360
  }
173
361
 
174
362
  const requiredVersion = normalizeRequiredVersion(forceUpdate.required_version);
@@ -176,7 +364,8 @@ function _executeForceUpdateInner(forceUpdate) {
176
364
  console.warn('[ForceUpdate] Refusing — required_version "' +
177
365
  String(forceUpdate.required_version || '').replace(/^[>=^~\s]+/, '') +
178
366
  '" is not a concrete semver (ranges not accepted).');
179
- return false;
367
+ return _fail(FORCE_UPDATE_FAIL_CODES.BAD_REQUIRED_VERSION,
368
+ 'required_version=' + JSON.stringify(forceUpdate && forceUpdate.required_version) + ' is not a concrete semver');
180
369
  }
181
370
 
182
371
  function getCurrentVersion() {
@@ -207,7 +396,8 @@ function _executeForceUpdateInner(forceUpdate) {
207
396
  if (versionCmp === null) {
208
397
  console.warn('[ForceUpdate] Refusing — current installed version "' +
209
398
  currentVersion + '" is not a concrete semver.');
210
- return false;
399
+ return _fail(FORCE_UPDATE_FAIL_CODES.CURRENT_VERSION_UNPARSABLE,
400
+ 'current installed version "' + currentVersion + '" is not a concrete semver');
211
401
  }
212
402
  if (versionCmp >= 0) {
213
403
  console.log('[ForceUpdate] already satisfies required version, no-op (current=' +
@@ -230,6 +420,14 @@ function _executeForceUpdateInner(forceUpdate) {
230
420
  const TMP_TARGET = fs.mkdtempSync(path.join(os.tmpdir(), '.evolver-update-tmp-'));
231
421
 
232
422
  // Channel 1: GitHub Release (via degit pinned to exact version tag)
423
+ //
424
+ // channel1Failure captures the structured reason this channel failed, so the
425
+ // terminal `return` can surface it instead of a bare `false`. `phase` tracks
426
+ // how far we got before any throw, so _classifyChannel1Error can tell a
427
+ // degit-spawn failure (phase 'degit') from a truncated download (phase
428
+ // 'parse') from a delete/copy-into-INSTALL_ROOT failure.
429
+ var channel1Failure = null;
430
+ var phase = 'degit';
233
431
  try {
234
432
  console.log('[ForceUpdate] Channel 1: GitHub Release download (v' + requiredVersion + ')...');
235
433
  var npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
@@ -240,6 +438,7 @@ function _executeForceUpdateInner(forceUpdate) {
240
438
  encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
241
439
  timeout: 60000, windowsHide: true, maxBuffer: MAX_EXEC_BUFFER,
242
440
  });
441
+ phase = 'parse';
243
442
  var tmpPkg = JSON.parse(fs.readFileSync(path.join(TMP_TARGET, 'package.json'), 'utf8'));
244
443
  // Require exact version match — a ">=" check would allow a compromised hub to
245
444
  // request version "0.0.1" and install any version including unreleased HEAD code.
@@ -247,44 +446,110 @@ function _executeForceUpdateInner(forceUpdate) {
247
446
  var entries = fs.readdirSync(INSTALL_ROOT, { withFileTypes: true });
248
447
  for (var ei = 0; ei < entries.length; ei++) {
249
448
  var eName = entries[ei].name;
250
- if (eName === 'node_modules' || eName === 'memory' || eName === '.git' || eName === 'MEMORY.md'
251
- || eName === '.env' || eName === '.env.local' || eName === 'USER.md' || eName === '.evolver') continue;
252
- try { fs.rmSync(path.join(INSTALL_ROOT, eName), { recursive: true, force: true }); } catch (_) {}
449
+ // package.json is the install's commit marker: keep the OLD one in
450
+ // place through the entire delete+copy below and swap in the new one
451
+ // atomically at the very end (see "commit marker" block). If it were
452
+ // deleted here and any later cpSync threw (ENOSPC, a Windows lock that
453
+ // outlasts the retries, a kill), the install root would be left with
454
+ // no package.json — and the install-guard at the top of this function
455
+ // refuses on an unreadable package.json, wedging the node in
456
+ // install_guard_unreadable on every subsequent attempt with no path
457
+ // that ever re-copies it. Deferring it keeps the install self-healing.
458
+ if (_isForceUpdateKeepEntry(eName) || eName === 'package.json') continue;
459
+ try {
460
+ (function (entryName) {
461
+ phase = 'delete';
462
+ _retryFsLockOperation(function () {
463
+ fs.rmSync(path.join(INSTALL_ROOT, entryName), {
464
+ recursive: true, force: true, maxRetries: 3, retryDelay: 200,
465
+ });
466
+ });
467
+ })(eName);
468
+ } catch (rmErr) {
469
+ console.warn('[ForceUpdate] rmSync failed for ' + eName + ': ' + (rmErr.message || rmErr));
470
+ try { rmErr._evolverEntry = eName; } catch (_) {}
471
+ throw rmErr;
472
+ }
253
473
  }
474
+ phase = 'copy';
254
475
  var newEntries = fs.readdirSync(TMP_TARGET, { withFileTypes: true });
255
476
  for (var ni = 0; ni < newEntries.length; ni++) {
477
+ // Deferred: package.json is the commit marker, written last after every
478
+ // other entry has copied successfully (see below). Keep-list entries are
479
+ // local state and must not be overwritten by the downloaded release.
480
+ if (newEntries[ni].name === 'package.json' || _isForceUpdateKeepEntry(newEntries[ni].name)) continue;
256
481
  var src = path.join(TMP_TARGET, newEntries[ni].name);
257
482
  var dst = path.join(INSTALL_ROOT, newEntries[ni].name);
258
- // On Windows, files held open by antivirus or the OS itself raise EPERM/EBUSY.
259
- // Retry up to 3 times with a short delay before propagating the error.
260
- var copyErr = null;
261
- for (var attempt = 0; attempt < 3; attempt++) {
262
- try {
263
- fs.cpSync(src, dst, { recursive: true });
264
- copyErr = null;
265
- break;
266
- } catch (cpErr) {
267
- copyErr = cpErr;
268
- var code = cpErr && cpErr.code;
269
- if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES') break;
270
- // Brief busy-wait — execFileSync has already blocked the event loop,
271
- // so a synchronous spin is acceptable here.
272
- var until = Date.now() + 200;
273
- while (Date.now() < until) { /* spin */ }
274
- }
275
- }
276
- if (copyErr) {
483
+ try {
484
+ (function (copySrc, copyDst) {
485
+ _retryFsLockOperation(function () {
486
+ fs.cpSync(copySrc, copyDst, { recursive: true });
487
+ });
488
+ })(src, dst);
489
+ } catch (copyErr) {
277
490
  console.warn('[ForceUpdate] cpSync failed for ' + newEntries[ni].name + ': ' + (copyErr.message || copyErr));
491
+ // Tag the failing entry so _classifyChannel1Error can name it in the
492
+ // copy_failed detail. phase is already 'copy' here.
493
+ try { copyErr._evolverEntry = newEntries[ni].name; } catch (_) {}
278
494
  throw copyErr;
279
495
  }
280
496
  }
497
+ // Commit marker: every other entry copied successfully, so swap in the
498
+ // new package.json LAST. POSIX gets an atomic rename-over-existing. Windows
499
+ // cannot rename over an existing destination, so it first renames the old
500
+ // package.json to a recoverable same-directory backup; the install guard
501
+ // restores that backup on the next tick if the process dies mid-commit.
502
+ var pkgSrc = path.join(TMP_TARGET, 'package.json');
503
+ var pkgDst = path.join(INSTALL_ROOT, 'package.json');
504
+ var pkgTmp = pkgDst + '.' + process.pid + '.evolver-tmp';
505
+ var pkgBackup = pkgDst + '.' + process.pid + '.evolver-old';
506
+ try {
507
+ _retryFsLockOperation(function () {
508
+ try { fs.rmSync(pkgTmp, { force: true }); } catch (_) {}
509
+ fs.cpSync(pkgSrc, pkgTmp);
510
+ if (process.platform === 'win32') {
511
+ if (!fs.existsSync(pkgDst)) _recoverPackageCommitMarkerIfMissing(INSTALL_ROOT);
512
+ try { fs.rmSync(pkgBackup, { force: true }); } catch (_) {}
513
+ fs.renameSync(pkgDst, pkgBackup);
514
+ try {
515
+ fs.renameSync(pkgTmp, pkgDst);
516
+ } catch (commitErr) {
517
+ _restorePackageBackup(pkgBackup, pkgDst);
518
+ throw commitErr;
519
+ }
520
+ try { fs.rmSync(pkgBackup, { force: true }); } catch (_) {}
521
+ } else {
522
+ fs.renameSync(pkgTmp, pkgDst);
523
+ }
524
+ });
525
+ } catch (pkgErr) {
526
+ _restorePackageBackup(pkgBackup, pkgDst);
527
+ try { fs.rmSync(pkgTmp, { force: true }); } catch (_) {}
528
+ console.warn('[ForceUpdate] package.json commit (atomic replace) failed: ' + (pkgErr.message || pkgErr));
529
+ try { pkgErr._evolverEntry = 'package.json commit'; } catch (_) {}
530
+ throw pkgErr;
531
+ }
281
532
  try { fs.rmSync(TMP_TARGET, { recursive: true, force: true }); } catch (_) {}
282
533
  console.log('[ForceUpdate] GitHub Release update successful: ' + tmpPkg.version);
283
534
  return true;
284
535
  }
536
+ // degit succeeded and produced a parseable package.json, but it did not
537
+ // satisfy the exact-version check above. Two distinct causes, two codes:
538
+ if (!tmpPkg.version) {
539
+ // degit produced a parseable package.json with no version field — a
540
+ // malformed/incomplete download, not a stale/tampered tag mismatch.
541
+ channel1Failure = _fail(FORCE_UPDATE_FAIL_CODES.DOWNLOAD_INCOMPLETE,
542
+ 'downloaded package.json has no version field');
543
+ } else {
544
+ // version present but not the exact tag we asked for (stale tag, mirror
545
+ // lag, or a tampered/redirected tag). Refuse and record the delta.
546
+ channel1Failure = _fail(FORCE_UPDATE_FAIL_CODES.DOWNLOADED_VERSION_MISMATCH,
547
+ 'downloaded version=' + JSON.stringify(tmpPkg.version) + ', expected ' + requiredVersion);
548
+ }
285
549
  try { fs.rmSync(TMP_TARGET, { recursive: true, force: true }); } catch (_) {}
286
550
  } catch (e) {
287
- console.warn('[ForceUpdate] GitHub Release failed:', e && e.message || e);
551
+ channel1Failure = _classifyChannel1Error(e, phase);
552
+ console.warn('[ForceUpdate] GitHub Release failed (' + channel1Failure.code + '):', e && e.message || e);
288
553
  try { fs.rmSync(TMP_TARGET, { recursive: true, force: true }); } catch (_) {}
289
554
  // Fall through to Channel 2 (manual download URL hint) instead of
290
555
  // returning. A Channel 1 error (degit missing, network down, tag not
@@ -301,7 +566,12 @@ function _executeForceUpdateInner(forceUpdate) {
301
566
  } catch (_) {}
302
567
 
303
568
  console.warn('[ForceUpdate] All automatic channels exhausted. Current version: ' + getCurrentVersion());
304
- return false;
569
+ // Surface the concrete Channel 1 failure when we have one (the common case:
570
+ // degit/network/copy/version-mismatch). channel1Failure is null only when
571
+ // Channel 1 was never entered, which cannot happen here — but fall back to a
572
+ // terminal code so the reporter never lands on the legacy "returned false".
573
+ return channel1Failure || _fail(FORCE_UPDATE_FAIL_CODES.ALL_CHANNELS_EXHAUSTED,
574
+ 'no automatic channel succeeded; current=' + getCurrentVersion() + ' target=' + requiredVersion);
305
575
  }
306
576
 
307
577
  // Test-only hook: re-implements the EXACT same operator-strip + semver
@@ -318,10 +588,21 @@ function _isAcceptedRequiredVersionForTesting(raw) {
318
588
  return normalizeRequiredVersion(raw) !== '';
319
589
  }
320
590
 
591
+ // Type guard: is `result` a structured failure (vs true / NOOP / BUSY)?
592
+ // Call sites use this to decide whether to forward result as opts.failure to
593
+ // reportForceUpdateOutcome. Kept tiny and dependency-free so all three
594
+ // duplicated triggers (a2aProtocol heartbeat, proxy manager, enrich tick) can
595
+ // share one definition.
596
+ function isForceUpdateFailure(result) {
597
+ return !!result && typeof result === 'object' && result.ok === false && typeof result.code === 'string';
598
+ }
599
+
321
600
  module.exports = {
322
601
  executeForceUpdate,
323
602
  FORCE_UPDATE_NOOP,
324
603
  FORCE_UPDATE_BUSY,
604
+ FORCE_UPDATE_FAIL_CODES,
605
+ isForceUpdateFailure,
325
606
  // Test-only hook: reset the in-flight mutex so unit tests do not leak state
326
607
  // across cases. Production callers must NOT touch this -- the mutex is the
327
608
  // load-bearing invariant that prevents concurrent state-file writes.