@evomap/evolver 1.29.8 → 1.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -25,24 +25,13 @@ function readJsonSafe(p) {
25
25
  }
26
26
 
27
27
  function rejectPendingRun(statePath) {
28
- try {
29
- const { getRepoRoot } = require('./src/gep/paths');
30
- const { execSync } = require('child_process');
31
- const repoRoot = getRepoRoot();
32
-
33
- execSync('git checkout -- .', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 });
34
- execSync('git clean -fd', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 });
35
- } catch (e) {
36
- console.warn('[Loop] Pending run rollback failed: ' + (e.message || e));
37
- }
38
-
39
28
  try {
40
29
  const state = readJsonSafe(statePath);
41
30
  if (state && state.last_run && state.last_run.run_id) {
42
31
  state.last_solidify = {
43
32
  run_id: state.last_run.run_id,
44
33
  rejected: true,
45
- reason: 'loop_bridge_disabled_autoreject',
34
+ reason: 'loop_bridge_disabled_autoreject_no_rollback',
46
35
  timestamp: new Date().toISOString(),
47
36
  };
48
37
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf8');
@@ -184,7 +173,7 @@ async function main() {
184
173
  if (isPendingSolidify(stAfterRun)) {
185
174
  const cleared = rejectPendingRun(solidifyStatePath);
186
175
  if (cleared) {
187
- console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode.');
176
+ console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode (state only, no rollback).');
188
177
  }
189
178
  }
190
179
  }
@@ -285,11 +274,19 @@ async function main() {
285
274
  if (res && res.ok && !dryRun) {
286
275
  try {
287
276
  const { shouldDistill, prepareDistillation } = require('./src/gep/skillDistiller');
288
- if (shouldDistill()) {
277
+ const { readStateForSolidify } = require('./src/gep/solidify');
278
+ const solidifyState = readStateForSolidify();
279
+ const count = solidifyState.solidify_count || 0;
280
+ const autoDistillInterval = 5;
281
+ const autoTrigger = count > 0 && count % autoDistillInterval === 0;
282
+
283
+ if (autoTrigger || shouldDistill()) {
289
284
  const dr = prepareDistillation();
290
285
  if (dr && dr.ok && dr.promptPath) {
286
+ const trigger = autoTrigger ? `auto (every ${autoDistillInterval} solidifies, count=${count})` : 'threshold';
291
287
  console.log('\n[DISTILL_REQUEST]');
292
- console.log('Distillation prompt ready. Read the prompt file, process it with your LLM,');
288
+ console.log(`Distillation triggered: ${trigger}`);
289
+ console.log('Read the prompt file, process it with your LLM,');
293
290
  console.log('save the LLM response to a file, then run:');
294
291
  console.log(' node index.js distill --response-file=<path_to_llm_response>');
295
292
  console.log('Prompt file: ' + dr.promptPath);
@@ -528,3 +525,10 @@ async function main() {
528
525
  if (require.main === module) {
529
526
  main();
530
527
  }
528
+
529
+ module.exports = {
530
+ main,
531
+ readJsonSafe,
532
+ rejectPendingRun,
533
+ isPendingSolidify,
534
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.29.8",
3
+ "version": "1.30.2",
4
4
  "description": "A GEP-powered self-evolution engine for AI agents. Features automated log analysis and Genome Evolution Protocol (GEP) for auditable, reusable evolution assets.",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/evolve.js CHANGED
@@ -59,6 +59,7 @@ const IS_RANDOM_DRIFT = ARGS.includes('--drift') || String(process.env.RANDOM_DR
59
59
  const MEMORY_DIR = getMemoryDir();
60
60
  const AGENT_NAME = process.env.AGENT_NAME || 'main';
61
61
  const AGENT_SESSIONS_DIR = path.join(os.homedir(), `.openclaw/agents/${AGENT_NAME}/sessions`);
62
+ const CURSOR_TRANSCRIPTS_DIR = process.env.EVOLVER_CURSOR_TRANSCRIPTS_DIR || '';
62
63
  const TODAY_LOG = path.join(MEMORY_DIR, new Date().toISOString().split('T')[0] + '.md');
63
64
 
64
65
  // Ensure memory directory exists so state/cache writes work.
@@ -160,77 +161,177 @@ function formatSessionLog(jsonlContent) {
160
161
  return result.join('\n');
161
162
  }
162
163
 
163
- function readRealSessionLog() {
164
+ function formatCursorTranscript(raw) {
165
+ const lines = raw.split('\n');
166
+ const result = [];
167
+ let skipUntilNextBlock = false;
168
+
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i];
171
+ const trimmed = line.trim();
172
+
173
+ // Keep user messages and assistant text responses
174
+ if (trimmed === 'user:' || trimmed.startsWith('A:')) {
175
+ skipUntilNextBlock = false;
176
+ result.push(trimmed);
177
+ continue;
178
+ }
179
+
180
+ // Tool call lines: keep as compact markers, skip their parameter block
181
+ if (trimmed.startsWith('[Tool call]')) {
182
+ skipUntilNextBlock = true;
183
+ result.push(`[Tool call] ${trimmed.replace('[Tool call]', '').trim()}`);
184
+ continue;
185
+ }
186
+
187
+ // Tool result markers: skip their content (usually large and noisy)
188
+ if (trimmed.startsWith('[Tool result]')) {
189
+ skipUntilNextBlock = true;
190
+ continue;
191
+ }
192
+
193
+ if (skipUntilNextBlock) continue;
194
+
195
+ // Keep user query content and assistant text (skip XML tags like <user_query>)
196
+ if (trimmed.startsWith('<') && trimmed.endsWith('>')) continue;
197
+ if (trimmed) {
198
+ result.push(trimmed.slice(0, 300));
199
+ }
200
+ }
201
+
202
+ return result.join('\n');
203
+ }
204
+
205
+ function readCursorTranscripts() {
206
+ if (!CURSOR_TRANSCRIPTS_DIR) return '';
164
207
  try {
165
- if (!fs.existsSync(AGENT_SESSIONS_DIR)) return '[NO SESSION LOGS FOUND]';
208
+ if (!fs.existsSync(CURSOR_TRANSCRIPTS_DIR)) return '';
166
209
 
167
210
  const now = Date.now();
168
- const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
211
+ const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000;
169
212
  const TARGET_BYTES = 120000;
170
- const PER_SESSION_BYTES = 20000; // Read tail of each active session
171
-
172
- // Session scope isolation: when EVOLVER_SESSION_SCOPE is set,
173
- // only read sessions whose filenames contain the scope identifier.
174
- // This prevents cross-channel/cross-project memory contamination.
175
- const sessionScope = getSessionScope();
213
+ const PER_FILE_BYTES = 20000;
214
+ const RECENCY_GUARD_MS = 30 * 1000;
176
215
 
177
- // Find ALL active sessions (modified in last 24h), sorted newest first
178
216
  let files = fs
179
- .readdirSync(AGENT_SESSIONS_DIR)
180
- .filter(f => f.endsWith('.jsonl') && !f.includes('.lock'))
217
+ .readdirSync(CURSOR_TRANSCRIPTS_DIR)
218
+ .filter(f => f.endsWith('.txt') || f.endsWith('.jsonl'))
181
219
  .map(f => {
182
220
  try {
183
- const st = fs.statSync(path.join(AGENT_SESSIONS_DIR, f));
221
+ const st = fs.statSync(path.join(CURSOR_TRANSCRIPTS_DIR, f));
184
222
  return { name: f, time: st.mtime.getTime(), size: st.size };
185
223
  } catch (e) {
186
224
  return null;
187
225
  }
188
226
  })
189
227
  .filter(f => f && (now - f.time) < ACTIVE_WINDOW_MS)
190
- .sort((a, b) => b.time - a.time); // Newest first
191
-
192
- if (files.length === 0) return '[NO JSONL FILES]';
193
-
194
- // Skip evolver's own sessions to avoid self-reference loops
195
- let nonEvolverFiles = files.filter(f => !f.name.startsWith('evolver_hand_'));
196
-
197
- // Session scope filter: when scope is active, only include sessions
198
- // whose filename contains the scope string (e.g., channel_123456.jsonl).
199
- // If no sessions match the scope, fall back to all non-evolver sessions
200
- // (graceful degradation -- better to evolve with global context than not at all).
201
- if (sessionScope && nonEvolverFiles.length > 0) {
202
- const scopeLower = sessionScope.toLowerCase();
203
- const scopedFiles = nonEvolverFiles.filter(f => f.name.toLowerCase().includes(scopeLower));
204
- if (scopedFiles.length > 0) {
205
- nonEvolverFiles = scopedFiles;
206
- console.log(`[SessionScope] Filtered to ${scopedFiles.length} session(s) matching scope "${sessionScope}".`);
207
- } else {
208
- console.log(`[SessionScope] No sessions match scope "${sessionScope}". Using all ${nonEvolverFiles.length} session(s) (fallback).`);
209
- }
210
- }
228
+ .sort((a, b) => b.time - a.time);
229
+
230
+ if (files.length === 0) return '';
211
231
 
212
- const activeFiles = nonEvolverFiles.length > 0 ? nonEvolverFiles : files.slice(0, 1);
232
+ // Skip the most recently modified file if it was touched in the last 30s --
233
+ // it is likely the current active session that triggered this evolver run,
234
+ // reading it would cause self-referencing signal noise.
235
+ if (files.length > 1 && (now - files[0].time) < RECENCY_GUARD_MS) {
236
+ files = files.slice(1);
237
+ }
213
238
 
214
- // Read from multiple active sessions (up to 6) to get a full picture
215
- const maxSessions = Math.min(activeFiles.length, 6);
239
+ const maxFiles = Math.min(files.length, 6);
216
240
  const sections = [];
217
241
  let totalBytes = 0;
218
242
 
219
- for (let i = 0; i < maxSessions && totalBytes < TARGET_BYTES; i++) {
220
- const f = activeFiles[i];
243
+ for (let i = 0; i < maxFiles && totalBytes < TARGET_BYTES; i++) {
244
+ const f = files[i];
221
245
  const bytesLeft = TARGET_BYTES - totalBytes;
222
- const readSize = Math.min(PER_SESSION_BYTES, bytesLeft);
223
- const raw = readRecentLog(path.join(AGENT_SESSIONS_DIR, f.name), readSize);
224
- const formatted = formatSessionLog(raw);
225
- if (formatted.trim()) {
226
- sections.push(`--- SESSION (${f.name}) ---\n${formatted}`);
227
- totalBytes += formatted.length;
246
+ const readSize = Math.min(PER_FILE_BYTES, bytesLeft);
247
+ const raw = readRecentLog(path.join(CURSOR_TRANSCRIPTS_DIR, f.name), readSize);
248
+ if (raw.trim() && !raw.startsWith('[MISSING]')) {
249
+ const formatted = formatCursorTranscript(raw);
250
+ if (formatted.trim()) {
251
+ sections.push(`--- CURSOR SESSION (${f.name}) ---\n${formatted}`);
252
+ totalBytes += formatted.length;
253
+ }
228
254
  }
229
255
  }
230
256
 
231
- let content = sections.join('\n\n');
257
+ return sections.join('\n\n');
258
+ } catch (e) {
259
+ console.warn(`[CursorTranscripts] Read failed: ${e.message}`);
260
+ return '';
261
+ }
262
+ }
263
+
264
+ function readRealSessionLog() {
265
+ try {
266
+ // Primary source: OpenClaw session logs (.jsonl)
267
+ if (fs.existsSync(AGENT_SESSIONS_DIR)) {
268
+ const now = Date.now();
269
+ const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
270
+ const TARGET_BYTES = 120000;
271
+ const PER_SESSION_BYTES = 20000;
272
+
273
+ const sessionScope = getSessionScope();
274
+
275
+ let files = fs
276
+ .readdirSync(AGENT_SESSIONS_DIR)
277
+ .filter(f => f.endsWith('.jsonl') && !f.includes('.lock'))
278
+ .map(f => {
279
+ try {
280
+ const st = fs.statSync(path.join(AGENT_SESSIONS_DIR, f));
281
+ return { name: f, time: st.mtime.getTime(), size: st.size };
282
+ } catch (e) {
283
+ return null;
284
+ }
285
+ })
286
+ .filter(f => f && (now - f.time) < ACTIVE_WINDOW_MS)
287
+ .sort((a, b) => b.time - a.time);
288
+
289
+ if (files.length > 0) {
290
+ let nonEvolverFiles = files.filter(f => !f.name.startsWith('evolver_hand_'));
291
+
292
+ if (sessionScope && nonEvolverFiles.length > 0) {
293
+ const scopeLower = sessionScope.toLowerCase();
294
+ const scopedFiles = nonEvolverFiles.filter(f => f.name.toLowerCase().includes(scopeLower));
295
+ if (scopedFiles.length > 0) {
296
+ nonEvolverFiles = scopedFiles;
297
+ console.log(`[SessionScope] Filtered to ${scopedFiles.length} session(s) matching scope "${sessionScope}".`);
298
+ } else {
299
+ console.log(`[SessionScope] No sessions match scope "${sessionScope}". Using all ${nonEvolverFiles.length} session(s) (fallback).`);
300
+ }
301
+ }
232
302
 
233
- return content;
303
+ const activeFiles = nonEvolverFiles.length > 0 ? nonEvolverFiles : files.slice(0, 1);
304
+
305
+ const maxSessions = Math.min(activeFiles.length, 6);
306
+ const sections = [];
307
+ let totalBytes = 0;
308
+
309
+ for (let i = 0; i < maxSessions && totalBytes < TARGET_BYTES; i++) {
310
+ const f = activeFiles[i];
311
+ const bytesLeft = TARGET_BYTES - totalBytes;
312
+ const readSize = Math.min(PER_SESSION_BYTES, bytesLeft);
313
+ const raw = readRecentLog(path.join(AGENT_SESSIONS_DIR, f.name), readSize);
314
+ const formatted = formatSessionLog(raw);
315
+ if (formatted.trim()) {
316
+ sections.push(`--- SESSION (${f.name}) ---\n${formatted}`);
317
+ totalBytes += formatted.length;
318
+ }
319
+ }
320
+
321
+ if (sections.length > 0) {
322
+ return sections.join('\n\n');
323
+ }
324
+ }
325
+ }
326
+
327
+ // Fallback: Cursor agent-transcripts (.txt)
328
+ const cursorContent = readCursorTranscripts();
329
+ if (cursorContent) {
330
+ console.log('[SessionFallback] Using Cursor agent-transcripts as session source.');
331
+ return cursorContent;
332
+ }
333
+
334
+ return '[NO SESSION LOGS FOUND]';
234
335
  } catch (e) {
235
336
  return `[ERROR READING SESSION LOGS: ${e.message}]`;
236
337
  }
@@ -1307,6 +1408,15 @@ async function run() {
1307
1408
  console.log('[FailedCapsules] Read failed (non-fatal): ' + e.message);
1308
1409
  }
1309
1410
 
1411
+ // Heartbeat hints: novelty score and capability gaps for diversity-directed drift
1412
+ var heartbeatNovelty = null;
1413
+ var heartbeatCapGaps = [];
1414
+ try {
1415
+ var { getNoveltyHint, getCapabilityGaps: getCapGaps } = require('./gep/a2aProtocol');
1416
+ heartbeatNovelty = getNoveltyHint();
1417
+ heartbeatCapGaps = getCapGaps() || [];
1418
+ } catch (e) {}
1419
+
1310
1420
  const { selectedGene, capsuleCandidates, selector } = selectGeneAndCapsule({
1311
1421
  genes,
1312
1422
  capsules,
@@ -1314,6 +1424,8 @@ async function run() {
1314
1424
  memoryAdvice,
1315
1425
  driftEnabled: IS_RANDOM_DRIFT,
1316
1426
  failedCapsules: recentFailedCapsules,
1427
+ capabilityGaps: heartbeatCapGaps,
1428
+ noveltyScore: heartbeatNovelty && Number.isFinite(heartbeatNovelty.score) ? heartbeatNovelty.score : null,
1317
1429
  });
1318
1430
 
1319
1431
  const selectedBy = memoryAdvice && memoryAdvice.preferredGeneId ? 'memory_graph+selector' : 'selector';
@@ -402,6 +402,9 @@ var _heartbeatTotalFailed = 0;
402
402
  var _heartbeatFpSent = false;
403
403
  var _latestAvailableWork = [];
404
404
  var _latestOverdueTasks = [];
405
+ var _latestSkillStoreHint = null;
406
+ var _latestNoveltyHint = null;
407
+ var _latestCapabilityGaps = [];
405
408
  var _pendingCommitmentUpdates = [];
406
409
  var _cachedHubNodeSecret = null;
407
410
  var _heartbeatIntervalMs = 0;
@@ -576,6 +579,21 @@ function sendHeartbeat() {
576
579
  _latestOverdueTasks = data.overdue_tasks;
577
580
  console.warn('[Commitment] ' + data.overdue_tasks.length + ' overdue task(s) detected via heartbeat.');
578
581
  }
582
+ if (data.skill_store) {
583
+ _latestSkillStoreHint = data.skill_store;
584
+ if (data.skill_store.eligible && data.skill_store.published_skills === 0) {
585
+ console.log('[Skill Store] ' + data.skill_store.hint);
586
+ }
587
+ }
588
+ if (data.novelty && typeof data.novelty === 'object') {
589
+ _latestNoveltyHint = data.novelty;
590
+ }
591
+ if (Array.isArray(data.capability_gaps) && data.capability_gaps.length > 0) {
592
+ _latestCapabilityGaps = data.capability_gaps;
593
+ }
594
+ if (data.circle_experience && typeof data.circle_experience === 'object') {
595
+ console.log('[EvolutionCircle] Active circle: ' + (data.circle_experience.circle_id || '?') + ' (' + (data.circle_experience.member_count || 0) + ' members)');
596
+ }
579
597
  _heartbeatConsecutiveFailures = 0;
580
598
  try {
581
599
  var logPath = getEvolverLogPath();
@@ -629,12 +647,24 @@ function getOverdueTasks() {
629
647
  return _latestOverdueTasks;
630
648
  }
631
649
 
650
+ function getSkillStoreHint() {
651
+ return _latestSkillStoreHint;
652
+ }
653
+
632
654
  function consumeOverdueTasks() {
633
655
  var tasks = _latestOverdueTasks;
634
656
  _latestOverdueTasks = [];
635
657
  return tasks;
636
658
  }
637
659
 
660
+ function getNoveltyHint() {
661
+ return _latestNoveltyHint;
662
+ }
663
+
664
+ function getCapabilityGaps() {
665
+ return _latestCapabilityGaps;
666
+ }
667
+
638
668
  /**
639
669
  * Queue a commitment deadline update to be sent with the next heartbeat.
640
670
  * @param {string} taskId
@@ -746,7 +776,10 @@ module.exports = {
746
776
  consumeAvailableWork,
747
777
  getOverdueTasks,
748
778
  consumeOverdueTasks,
779
+ getSkillStoreHint,
749
780
  queueCommitmentUpdate,
750
781
  getHubNodeSecret,
751
782
  buildHubHeaders,
783
+ getNoveltyHint,
784
+ getCapabilityGaps,
752
785
  };
@@ -26,8 +26,12 @@ function extractToolCalls(transcript) {
26
26
  const lines = toLines(transcript);
27
27
  const calls = [];
28
28
  for (const line of lines) {
29
+ // OpenClaw format: [TOOL: Shell]
29
30
  const m = line.match(/\[TOOL:\s*([^\]]+)\]/i);
30
- if (m && m[1]) calls.push(m[1].trim());
31
+ if (m && m[1]) { calls.push(m[1].trim()); continue; }
32
+ // Cursor transcript format: [Tool call] Shell
33
+ const m2 = line.match(/\[Tool call\]\s+(\S+)/i);
34
+ if (m2 && m2[1]) calls.push(m2[1].trim());
31
35
  }
32
36
  return calls;
33
37
  }
@@ -0,0 +1,201 @@
1
+ // Execution Trace: structured, desensitized evolution execution summary.
2
+ // Built during solidify and optionally shared with Hub via EvolutionEvent payload.
3
+ //
4
+ // Desensitization rules (applied locally, never on Hub):
5
+ // - File paths: basename + extension only (src/utils/retry.js -> retry.js)
6
+ // - Code content: never sent, only statistical metrics (lines, files)
7
+ // - Error messages: type signature only (TypeError: x is not a function -> TypeError)
8
+ // - Environment variables, secrets, user data: stripped entirely
9
+ // - Configurable via EVOLVER_TRACE_LEVEL: none | minimal | standard (default: minimal)
10
+
11
+ const path = require('path');
12
+
13
+ const TRACE_LEVELS = { none: 0, minimal: 1, standard: 2 };
14
+
15
+ function getTraceLevel() {
16
+ const raw = String(process.env.EVOLVER_TRACE_LEVEL || 'minimal').toLowerCase().trim();
17
+ return TRACE_LEVELS[raw] != null ? raw : 'minimal';
18
+ }
19
+
20
+ function desensitizeFilePath(filePath) {
21
+ if (!filePath || typeof filePath !== 'string') return null;
22
+ const ext = path.extname(filePath);
23
+ const base = path.basename(filePath);
24
+ return base || ext || 'unknown';
25
+ }
26
+
27
+ function extractErrorSignature(errorText) {
28
+ if (!errorText || typeof errorText !== 'string') return null;
29
+ const text = errorText.trim();
30
+
31
+ // Match common error type patterns: TypeError, ReferenceError, SyntaxError, etc.
32
+ const jsError = text.match(/^((?:[A-Z][a-zA-Z]*)?Error)\b/);
33
+ if (jsError) return jsError[1];
34
+
35
+ // Match errno-style: ECONNRESET, ENOENT, EPERM, etc.
36
+ const errno = text.match(/\b(E[A-Z]{2,})\b/);
37
+ if (errno) return errno[1];
38
+
39
+ // Match HTTP status codes
40
+ const http = text.match(/\b((?:4|5)\d{2})\b/);
41
+ if (http) return 'HTTP_' + http[1];
42
+
43
+ // Fallback: first word if it looks like an error type
44
+ const firstWord = text.split(/[\s:]/)[0];
45
+ if (firstWord && firstWord.length <= 40 && /^[A-Z]/.test(firstWord)) return firstWord;
46
+
47
+ return 'UnknownError';
48
+ }
49
+
50
+ function inferToolChain(validationResults, blast) {
51
+ const tools = new Set();
52
+
53
+ if (blast && blast.files > 0) tools.add('file_edit');
54
+
55
+ if (Array.isArray(validationResults)) {
56
+ for (const r of validationResults) {
57
+ const cmd = String(r.cmd || '').trim();
58
+ if (cmd.startsWith('npm test') || cmd.includes('jest') || cmd.includes('mocha')) {
59
+ tools.add('test_run');
60
+ } else if (cmd.includes('lint') || cmd.includes('eslint')) {
61
+ tools.add('lint_check');
62
+ } else if (cmd.includes('validate') || cmd.includes('check')) {
63
+ tools.add('validation_run');
64
+ } else if (cmd.startsWith('node ')) {
65
+ tools.add('node_exec');
66
+ }
67
+ }
68
+ }
69
+
70
+ return Array.from(tools);
71
+ }
72
+
73
+ function classifyBlastLevel(blast) {
74
+ if (!blast) return 'unknown';
75
+ const files = Number(blast.files) || 0;
76
+ const lines = Number(blast.lines) || 0;
77
+ if (files <= 3 && lines <= 50) return 'low';
78
+ if (files <= 10 && lines <= 200) return 'medium';
79
+ return 'high';
80
+ }
81
+
82
+ function buildExecutionTrace({
83
+ gene,
84
+ mutation,
85
+ signals,
86
+ blast,
87
+ constraintCheck,
88
+ validation,
89
+ canary,
90
+ outcomeStatus,
91
+ startedAt,
92
+ }) {
93
+ const level = getTraceLevel();
94
+ if (level === 'none') return null;
95
+
96
+ const trace = {
97
+ gene_id: gene && gene.id ? String(gene.id) : null,
98
+ mutation_category: (mutation && mutation.category) || (gene && gene.category) || null,
99
+ signals_matched: Array.isArray(signals) ? signals.slice(0, 10) : [],
100
+ outcome: outcomeStatus || 'unknown',
101
+ };
102
+
103
+ // Minimal level: core metrics only
104
+ trace.files_changed_count = blast ? Number(blast.files) || 0 : 0;
105
+ trace.lines_added = 0;
106
+ trace.lines_removed = 0;
107
+
108
+ // Compute added/removed from blast if available
109
+ if (blast && blast.lines) {
110
+ // blast.lines is total churn (added + deleted); split heuristically
111
+ const total = Number(blast.lines) || 0;
112
+ if (outcomeStatus === 'success') {
113
+ trace.lines_added = Math.round(total * 0.6);
114
+ trace.lines_removed = total - trace.lines_added;
115
+ } else {
116
+ trace.lines_added = Math.round(total * 0.5);
117
+ trace.lines_removed = total - trace.lines_added;
118
+ }
119
+ }
120
+
121
+ trace.validation_result = validation && validation.ok ? 'pass' : 'fail';
122
+ trace.blast_radius = classifyBlastLevel(blast);
123
+
124
+ // Standard level: richer context
125
+ if (level === 'standard') {
126
+ // Desensitized file list (basenames only)
127
+ if (blast && Array.isArray(blast.changed_files)) {
128
+ trace.file_types = {};
129
+ for (const f of blast.changed_files) {
130
+ const ext = path.extname(f) || '.unknown';
131
+ trace.file_types[ext] = (trace.file_types[ext] || 0) + 1;
132
+ }
133
+ }
134
+
135
+ // Validation commands (already safe -- node/npm/npx only)
136
+ if (validation && Array.isArray(validation.results)) {
137
+ trace.validation_commands = validation.results.map(r => String(r.cmd || '').slice(0, 100));
138
+ }
139
+
140
+ // Error signatures (desensitized)
141
+ trace.error_signatures = [];
142
+ if (constraintCheck && Array.isArray(constraintCheck.violations)) {
143
+ for (const v of constraintCheck.violations) {
144
+ // Constraint violations have known prefixes; classify directly
145
+ const vStr = String(v);
146
+ if (vStr.startsWith('max_files')) trace.error_signatures.push('max_files_exceeded');
147
+ else if (vStr.startsWith('forbidden_path')) trace.error_signatures.push('forbidden_path');
148
+ else if (vStr.startsWith('HARD CAP')) trace.error_signatures.push('hard_cap_breach');
149
+ else if (vStr.startsWith('CRITICAL')) trace.error_signatures.push('critical_overrun');
150
+ else if (vStr.startsWith('critical_path')) trace.error_signatures.push('critical_path_modified');
151
+ else if (vStr.startsWith('canary_failed')) trace.error_signatures.push('canary_failed');
152
+ else if (vStr.startsWith('ethics:')) trace.error_signatures.push('ethics_violation');
153
+ else {
154
+ const sig = extractErrorSignature(v);
155
+ if (sig) trace.error_signatures.push(sig);
156
+ }
157
+ }
158
+ }
159
+ if (validation && Array.isArray(validation.results)) {
160
+ for (const r of validation.results) {
161
+ if (!r.ok && r.err) {
162
+ const sig = extractErrorSignature(r.err);
163
+ if (sig && !trace.error_signatures.includes(sig)) {
164
+ trace.error_signatures.push(sig);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ trace.error_signatures = trace.error_signatures.slice(0, 10);
170
+
171
+ // Tool chain inference
172
+ trace.tool_chain = inferToolChain(
173
+ validation && validation.results ? validation.results : [],
174
+ blast
175
+ );
176
+
177
+ // Duration
178
+ if (validation && validation.startedAt && validation.finishedAt) {
179
+ trace.validation_duration_ms = validation.finishedAt - validation.startedAt;
180
+ }
181
+
182
+ // Canary result
183
+ if (canary && !canary.skipped) {
184
+ trace.canary_ok = !!canary.ok;
185
+ }
186
+ }
187
+
188
+ // Timestamp
189
+ trace.created_at = new Date().toISOString();
190
+
191
+ return trace;
192
+ }
193
+
194
+ module.exports = {
195
+ buildExecutionTrace,
196
+ desensitizeFilePath,
197
+ extractErrorSignature,
198
+ inferToolChain,
199
+ classifyBlastLevel,
200
+ getTraceLevel,
201
+ };