mcp-castor 2026.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +487 -0
  2. package/bin/castor.js +706 -0
  3. package/index.js +206 -0
  4. package/package.json +97 -0
  5. package/skills/canary-test-staging/SKILL.md +24 -0
  6. package/skills/evo-mutation-rollback/SKILL.md +29 -0
  7. package/skills/hypothesis-generation/SKILL.md +26 -0
  8. package/skills/traceback-condensing/SKILL.md +26 -0
  9. package/src/castor_runner.js +469 -0
  10. package/src/config.js +1204 -0
  11. package/src/env.js +10 -0
  12. package/src/evo_engine.js +214 -0
  13. package/src/harness/core/events.js +75 -0
  14. package/src/harness/core/kernel.js +209 -0
  15. package/src/harness/evo/evaluator.js +156 -0
  16. package/src/harness/evo/evo_operator.js +550 -0
  17. package/src/harness/evo/lineage_dag.js +383 -0
  18. package/src/harness/evo/trace_repair.js +173 -0
  19. package/src/harness/evo/watchdog.js +72 -0
  20. package/src/harness/loop_detector.js +135 -0
  21. package/src/harness/runner.js +1216 -0
  22. package/src/harness/services/ast_service.js +1813 -0
  23. package/src/harness/services/event_logger.js +275 -0
  24. package/src/harness/services/mcp_bridge.js +408 -0
  25. package/src/harness/services/provider_vllm.js +728 -0
  26. package/src/harness/services/sandbox_fs.js +1238 -0
  27. package/src/harness/services/searxng_lifecycle.js +254 -0
  28. package/src/harness/services/shell_executor.js +264 -0
  29. package/src/harness/services/shell_validator.js +506 -0
  30. package/src/harness/services/web_service.js +828 -0
  31. package/src/platform.js +344 -0
  32. package/src/repetition_detector.js +139 -0
  33. package/src/semaphore.js +373 -0
  34. package/src/server_lifecycle.js +781 -0
  35. package/src/skills.js +400 -0
  36. package/src/state_pruner.js +392 -0
  37. package/src/task_registry.js +1357 -0
  38. package/src/telemetry.js +638 -0
  39. package/src/tools.js +997 -0
  40. package/src/wsl_bridge.js +629 -0
  41. package/src/wsl_env.js +171 -0
  42. package/stream_proxy.js +453 -0
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Evo Operator (Evolutionary Variation Operators Core Engine)
3
+ *
4
+ * Implements the autonomous evolutionary loop:
5
+ * propose -> mutate -> execute -> evaluate -> select / revert
6
+ *
7
+ * Features:
8
+ * - Deterministic file snapshotting and rollback
9
+ * - Closed-loop fitness scoring and Lineage DAG tracking
10
+ * - Stagnation circuit breaker
11
+ * - Castor plugin exposing Evo tools
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { LineageDag } from "./lineage_dag.js";
17
+ import { ClosedLoopEvaluator } from "./evaluator.js";
18
+ import { EvoWatchdog } from "./watchdog.js";
19
+ import { IS_WINDOWS } from "../../config.js";
20
+ import { toPosixWslPath, toWindowsPath, canonicalizePath } from "../../wsl_bridge.js";
21
+ import { skillsDir, splitFrontmatter, parseFrontmatter } from "../../skills.js";
22
+
23
+ export class EvoOperator {
24
+ constructor(options = {}) {
25
+ // Canonicalize the Evo workspace root through the OS symlink/junction
26
+ // resolution layer so a junction/symlink cwd is stored as its real path.
27
+ // assertWithinWorkspace then compares realpath against a real root,
28
+ // eliminating false containment errors while still catching real escapes.
29
+ this.workspaceRoot = canonicalizePath(options.workspaceRoot || process.cwd());
30
+ this.shell = options.shell;
31
+ this.snapshotsDir = path.join(this.workspaceRoot, ".evo", "snapshots");
32
+
33
+ // Share the per-workspace singleton DAG with EvoLineageEngine so both
34
+ // writers merge into one graph.
35
+ this.dag = LineageDag.forWorkspace(this.workspaceRoot);
36
+ this.evaluator = new ClosedLoopEvaluator({ shell: this.shell });
37
+ this.watchdog = new EvoWatchdog(options.watchdogOptions || {});
38
+
39
+ this.activeCandidate = null;
40
+ this._ensureDirs();
41
+ }
42
+
43
+ _ensureDirs() {
44
+ try {
45
+ fs.mkdirSync(this.snapshotsDir, { recursive: true });
46
+ } catch {}
47
+ }
48
+
49
+ assertWithinWorkspace(targetPath) {
50
+ let p = String(targetPath || "").trim();
51
+ if (!IS_WINDOWS && (p.includes("\\") || /^[a-zA-Z]:/.test(p))) {
52
+ p = toPosixWslPath(p);
53
+ } else if (IS_WINDOWS && (p.startsWith("/mnt/") || p.startsWith("/"))) {
54
+ p = toWindowsPath(p);
55
+ }
56
+ const normTarget = path.normalize(p);
57
+ const normRoot = path.normalize(this.workspaceRoot);
58
+
59
+ // Canonicalize both sides of the containment comparison through the OS
60
+ // symlink/junction resolution layer so a junction-form target is compared
61
+ // against the real root in the same "real" path space. Real escapes
62
+ // (../outside, symlink-to-outside) still resolve outside the real root
63
+ // and are caught.
64
+ const realTarget = canonicalizePath(normTarget);
65
+ const realRoot = canonicalizePath(normRoot);
66
+ const rel = path.relative(realRoot, realTarget);
67
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
68
+ throw new Error(`SecurityContainmentError: Refusing file operation outside workspace root: '${targetPath}'`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolves the target SKILL.md for a given skill name.
74
+ * Searches workspace skills, workspace .agents skills, and repo-level skills.
75
+ * If creating a new skill, defaults to <workspaceRoot>/skills/<name>/SKILL.md
76
+ * (or <workspaceRoot>/.agents/skills/<name>/SKILL.md if .agents exists).
77
+ * @param {string} skillName
78
+ * @returns {string} full path
79
+ */
80
+ resolveSkillPath(skillName) {
81
+ const cleanName = String(skillName || "").trim().replace(/\.md$/i, "");
82
+ if (!cleanName || cleanName.includes("..") || cleanName.includes("/") || cleanName.includes("\\")) {
83
+ throw new Error(`Invalid skill name '${skillName}': must be a simple identifier`);
84
+ }
85
+
86
+ const candidates = [
87
+ path.join(this.workspaceRoot, ".agents", "skills", cleanName, "SKILL.md"),
88
+ path.join(this.workspaceRoot, "skills", cleanName, "SKILL.md"),
89
+ ];
90
+
91
+ try {
92
+ const repoSkills = path.join(skillsDir(), cleanName, "SKILL.md");
93
+ candidates.push(repoSkills);
94
+ } catch {}
95
+
96
+ for (const cand of candidates) {
97
+ if (fs.existsSync(cand)) {
98
+ return canonicalizePath(cand);
99
+ }
100
+ }
101
+
102
+ const agentsDir = path.join(this.workspaceRoot, ".agents", "skills");
103
+ if (fs.existsSync(agentsDir)) {
104
+ return path.join(agentsDir, cleanName, "SKILL.md");
105
+ }
106
+ return path.join(this.workspaceRoot, "skills", cleanName, "SKILL.md");
107
+ }
108
+
109
+ assertWithinWorkspaceOrSkills(targetPath) {
110
+ let isInside = false;
111
+ try {
112
+ this.assertWithinWorkspace(targetPath);
113
+ isInside = true;
114
+ } catch {}
115
+ if (isInside) return;
116
+
117
+ try {
118
+ let p = String(targetPath || "").trim();
119
+ if (!IS_WINDOWS && (p.includes("\\") || /^[a-zA-Z]:/.test(p))) {
120
+ p = toPosixWslPath(p);
121
+ } else if (IS_WINDOWS && (p.startsWith("/mnt/") || p.startsWith("/"))) {
122
+ p = toWindowsPath(p);
123
+ }
124
+ const sDir = canonicalizePath(skillsDir());
125
+ const realTarget = canonicalizePath(path.normalize(p));
126
+ const rel = path.relative(sDir, realTarget);
127
+ if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
128
+ return;
129
+ }
130
+ } catch {}
131
+
132
+ throw new Error(`SecurityContainmentError: Refusing skill operation outside workspace and skills root: '${targetPath}'`);
133
+ }
134
+
135
+ /**
136
+ * Validates frontmatter syntax and required fields on any modified skills.
137
+ * @param {Array<object>} snapshotManifest
138
+ * @returns {{ valid: boolean, errors: Array<string> }}
139
+ */
140
+ validateSkillFrontmatter(snapshotManifest) {
141
+ const errors = [];
142
+ const skillItems = (snapshotManifest || []).filter((m) => m.isSkill);
143
+ for (const item of skillItems) {
144
+ if (!fs.existsSync(item.fullPath)) {
145
+ errors.push(`Skill file '${item.fullPath}' does not exist on disk after mutation`);
146
+ continue;
147
+ }
148
+ try {
149
+ const content = fs.readFileSync(item.fullPath, "utf8");
150
+ const { frontmatter, body } = splitFrontmatter(content);
151
+ if (!frontmatter || !frontmatter.trim()) {
152
+ errors.push(`Skill '${item.skillName}' is missing required YAML frontmatter (--- markers)`);
153
+ continue;
154
+ }
155
+ const meta = parseFrontmatter(frontmatter);
156
+ if (!meta.name || !meta.name.trim()) {
157
+ errors.push(`Skill '${item.skillName}' frontmatter is missing required 'name' field`);
158
+ }
159
+ if (!meta.description || !meta.description.trim()) {
160
+ errors.push(`Skill '${item.skillName}' frontmatter is missing required 'description' field`);
161
+ }
162
+ if (!body || !body.trim()) {
163
+ errors.push(`Skill '${item.skillName}' markdown workflow body is empty`);
164
+ }
165
+ } catch (err) {
166
+ errors.push(`Skill '${item.skillName}' error parsing frontmatter: ${err.message}`);
167
+ }
168
+ }
169
+ return { valid: errors.length === 0, errors };
170
+ }
171
+
172
+ /**
173
+ * Proposes a new mutation candidate and snapshots target files and/or skills for deterministic rollback.
174
+ *
175
+ * @param {object} params
176
+ * @param {string} params.hypothesis Description of the code variation
177
+ * @param {Array<string>} [params.files_to_modify] List of file paths to be mutated
178
+ * @param {Array<string>} [params.skills_to_modify] Optional list of skill names to evolve
179
+ * @returns {object} The created candidate node
180
+ */
181
+ async proposeCandidate({ hypothesis, files_to_modify = [], skills_to_modify = [] }) {
182
+ if (!hypothesis || typeof hypothesis !== "string") {
183
+ throw new Error("Evo hypothesis is required to propose a candidate.");
184
+ }
185
+ const candidateId = `cand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
186
+ const snapshotDir = path.join(this.snapshotsDir, candidateId);
187
+ fs.mkdirSync(snapshotDir, { recursive: true });
188
+
189
+ // Snapshot existing versions of files and record manifest
190
+ const snapshotManifest = [];
191
+ for (const relPath of files_to_modify) {
192
+ const fullPath = path.isAbsolute(relPath) ? relPath : path.resolve(this.workspaceRoot, relPath);
193
+ this.assertWithinWorkspace(fullPath);
194
+ const existed = fs.existsSync(fullPath) && fs.statSync(fullPath).isFile();
195
+ let targetBackup = null;
196
+ if (existed) {
197
+ targetBackup = path.join(snapshotDir, encodeURIComponent(relPath));
198
+ fs.copyFileSync(fullPath, targetBackup);
199
+ }
200
+ snapshotManifest.push({ relPath, fullPath, targetBackup, existed, isSkill: false });
201
+ }
202
+
203
+ // Snapshot procedural skills
204
+ for (const skillName of skills_to_modify) {
205
+ const skillPath = this.resolveSkillPath(skillName);
206
+ this.assertWithinWorkspaceOrSkills(skillPath);
207
+ const existed = fs.existsSync(skillPath) && fs.statSync(skillPath).isFile();
208
+ const relPath = path.relative(this.workspaceRoot, skillPath);
209
+ let targetBackup = null;
210
+ if (existed) {
211
+ targetBackup = path.join(snapshotDir, `skill_${encodeURIComponent(skillName)}.md`);
212
+ fs.copyFileSync(skillPath, targetBackup);
213
+ }
214
+ snapshotManifest.push({
215
+ relPath,
216
+ fullPath: skillPath,
217
+ targetBackup,
218
+ existed,
219
+ isSkill: true,
220
+ skillName,
221
+ });
222
+ }
223
+
224
+ fs.writeFileSync(path.join(snapshotDir, "manifest.json"), JSON.stringify(snapshotManifest, null, 2), "utf8");
225
+
226
+ const node = this.dag.addCandidate({
227
+ id: candidateId,
228
+ hypothesis,
229
+ filesModified: files_to_modify,
230
+ skillsModified: skills_to_modify,
231
+ metrics: {
232
+ snapshotDir,
233
+ snapshotCount: snapshotManifest.filter((m) => m.existed).length,
234
+ },
235
+ });
236
+
237
+ this.activeCandidate = {
238
+ node,
239
+ snapshotDir,
240
+ snapshotManifest,
241
+ skillsModified: skills_to_modify,
242
+ };
243
+
244
+ const snapshottedCount = snapshotManifest.filter((m) => m.existed).length;
245
+ return {
246
+ candidate_id: candidateId,
247
+ parent_id: node.parentId,
248
+ hypothesis,
249
+ files_snapshotted: snapshottedCount,
250
+ skills_snapshotted: snapshotManifest.filter((m) => m.isSkill && m.existed).length,
251
+ message: `Candidate ${candidateId} registered. ${snapshottedCount} target artifact(s) safely snapshotted for rollback. You may now perform mutations.`,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Evaluates the candidate using closed-loop execution.
257
+ *
258
+ * @param {object} params
259
+ * @param {string} params.command Test, lint, or benchmark command
260
+ * @param {string} [params.primary_metric] Metric to prioritize ("throughput", "latency_ms", "tests_passed")
261
+ * @param {number} [params.timeout_ms]
262
+ */
263
+ async evaluateCandidate({ command, primary_metric = "tests_passed", timeout_ms = 120_000 }) {
264
+ if (!this.activeCandidate) {
265
+ throw new Error("No active Evo candidate to evaluate. Call evo_propose_candidate first.");
266
+ }
267
+
268
+ const candidateId = this.activeCandidate.node.id;
269
+
270
+ // Validate skill frontmatter before running evaluation command
271
+ const skillValidation = this.validateSkillFrontmatter(this.activeCandidate.snapshotManifest);
272
+ if (!skillValidation.valid) {
273
+ this.dag.updateMetrics(candidateId, {
274
+ fitness: 0,
275
+ evalCommand: command,
276
+ evalMetrics: { tests_passed: 0, skill_validation: "failed" },
277
+ exitCode: 1,
278
+ passed: false,
279
+ });
280
+ return {
281
+ candidate_id: candidateId,
282
+ passed: false,
283
+ fitness: 0,
284
+ best_known_fitness: this.dag.getBestCandidate()?.metrics?.fitness ?? 0,
285
+ is_improvement: false,
286
+ metrics: { skill_errors: skillValidation.errors },
287
+ failure_digest: `SkillFrontmatterValidationError: ${skillValidation.errors.join("; ")}`,
288
+ failure_detail: { errors: skillValidation.errors },
289
+ stdout: "",
290
+ stderr: skillValidation.errors.join("\n"),
291
+ recommendation: "Candidate skill has malformed frontmatter. Fix formatting or call evo_revert_candidate.",
292
+ };
293
+ }
294
+
295
+ const evalResult = await this.evaluator.evaluate({
296
+ command,
297
+ cwd: this.workspaceRoot,
298
+ primaryMetric: primary_metric,
299
+ timeout_ms,
300
+ });
301
+ this.dag.updateMetrics(candidateId, {
302
+ fitness: evalResult.fitness,
303
+ evalCommand: command,
304
+ evalMetrics: evalResult.metrics,
305
+ exitCode: evalResult.exitCode,
306
+ passed: evalResult.passed,
307
+ });
308
+
309
+ const best = this.dag.getBestCandidate();
310
+ const isImprovement = evalResult.passed && evalResult.fitness >= (best?.metrics?.fitness ?? -Infinity);
311
+
312
+ return {
313
+ candidate_id: candidateId,
314
+ passed: evalResult.passed,
315
+ fitness: evalResult.fitness,
316
+ best_known_fitness: best?.metrics?.fitness ?? 0,
317
+ is_improvement: isImprovement,
318
+ metrics: evalResult.metrics,
319
+ failure_digest: evalResult.failureDigest?.summary || null,
320
+ failure_detail: evalResult.failureDigest || null,
321
+ stdout: evalResult.stdout.slice(0, 1000),
322
+ stderr: evalResult.stderr.slice(0, 1000),
323
+ recommendation: isImprovement
324
+ ? "Fitness improved or verified. Call evo_select_candidate to accept."
325
+ : "Candidate failed or degraded fitness. Call evo_revert_candidate to cleanly rollback.",
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Accepts the candidate and advances the lineage head.
331
+ *
332
+ * @param {object} params
333
+ * @param {string} [params.candidate_id]
334
+ */
335
+ async selectCandidate({ candidate_id } = {}) {
336
+ const id = candidate_id || this.activeCandidate?.node?.id;
337
+ if (!id) {
338
+ throw new Error("No candidate specified to select");
339
+ }
340
+
341
+ if (this.activeCandidate && this.activeCandidate.snapshotManifest) {
342
+ const skillValidation = this.validateSkillFrontmatter(this.activeCandidate.snapshotManifest);
343
+ if (!skillValidation.valid) {
344
+ throw new Error(`Cannot select candidate: Skill frontmatter validation failed: ${skillValidation.errors.join("; ")}`);
345
+ }
346
+ }
347
+
348
+ this.dag.updateMetrics(id, {}, "accepted");
349
+ this.watchdog.recordCandidateOutcome("accepted");
350
+ this.activeCandidate = null;
351
+
352
+ return {
353
+ candidate_id: id,
354
+ status: "accepted",
355
+ current_head: id,
356
+ message: `Candidate ${id} accepted into lineage DAG. Workspace changes preserved as new baseline.`,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Deterministically rolls back files to pre-mutation snapshot.
362
+ *
363
+ * @param {object} params
364
+ * @param {string} [params.candidate_id]
365
+ */
366
+ async revertCandidate({ candidate_id } = {}) {
367
+ const id = candidate_id || this.activeCandidate?.node?.id;
368
+ if (!id) {
369
+ throw new Error("No candidate specified to revert");
370
+ }
371
+
372
+ // Validate the candidate in the DAG before touching any files.
373
+ const node = this.dag.nodes.get(id);
374
+ if (!node) {
375
+ throw new Error(`Candidate '${id}' does not exist in lineage DAG`);
376
+ }
377
+ if (node.metrics?.status === "accepted") {
378
+ throw new Error(`Candidate '${id}' is already accepted into baseline and cannot be reverted.`);
379
+ }
380
+
381
+ const snapshotDir = path.join(this.snapshotsDir, id);
382
+ const manifestFile = path.join(snapshotDir, "manifest.json");
383
+ let revertedCount = 0;
384
+
385
+ if (fs.existsSync(manifestFile)) {
386
+ try {
387
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
388
+ for (const item of manifest) {
389
+ if (!item.existed) {
390
+ // Newly created file that did not exist before mutation: delete it.
391
+ if (fs.existsSync(item.fullPath)) {
392
+ try {
393
+ if (item.isSkill) {
394
+ this.assertWithinWorkspaceOrSkills(item.fullPath);
395
+ } else {
396
+ this.assertWithinWorkspace(item.fullPath);
397
+ }
398
+ fs.unlinkSync(item.fullPath);
399
+ revertedCount++;
400
+ if (item.isSkill) {
401
+ const parentDir = path.dirname(item.fullPath);
402
+ try {
403
+ if (fs.existsSync(parentDir) && fs.readdirSync(parentDir).length === 0) {
404
+ fs.rmdirSync(parentDir);
405
+ }
406
+ } catch {}
407
+ }
408
+ } catch (err) {
409
+ console.error(`[EvoOperator] Error deleting new file ${item.fullPath}:`, err.message);
410
+ }
411
+ }
412
+ } else if (item.targetBackup && fs.existsSync(item.targetBackup)) {
413
+ try {
414
+ if (item.isSkill) {
415
+ this.assertWithinWorkspaceOrSkills(item.fullPath);
416
+ } else {
417
+ this.assertWithinWorkspace(item.fullPath);
418
+ }
419
+ fs.copyFileSync(item.targetBackup, item.fullPath);
420
+ revertedCount++;
421
+ } catch (err) {
422
+ console.error(`[EvoOperator] Error reverting ${item.fullPath}:`, err.message);
423
+ }
424
+ }
425
+ }
426
+ } catch (err) {
427
+ console.error(`[EvoOperator] Manifest-based rollback failed (${err.message}); falling back to snapshot dir.`);
428
+ }
429
+ } else if (fs.existsSync(snapshotDir)) {
430
+ const files = fs.readdirSync(snapshotDir);
431
+ for (const f of files) {
432
+ if (f === "manifest.json") continue;
433
+ const relPath = decodeURIComponent(f);
434
+ const fullPath = path.resolve(this.workspaceRoot, relPath);
435
+ const backupPath = path.join(snapshotDir, f);
436
+ try {
437
+ fs.copyFileSync(backupPath, fullPath);
438
+ revertedCount++;
439
+ } catch (err) {
440
+ console.error(`[EvoOperator] Error reverting ${fullPath}:`, err.message);
441
+ }
442
+ }
443
+ }
444
+
445
+ this.dag.updateMetrics(id, {}, "rejected");
446
+ const watchdogStatus = this.watchdog.recordCandidateOutcome("rejected");
447
+ this.activeCandidate = null;
448
+
449
+ return {
450
+ candidate_id: id,
451
+ status: "rejected",
452
+ files_reverted: revertedCount,
453
+ watchdog: watchdogStatus,
454
+ message: `Candidate ${id} reverted. Workspace restored cleanly to parent snapshot.`,
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Returns complete overview of evolutionary lineage and current best score.
460
+ */
461
+ getStatus() {
462
+ return {
463
+ summary: this.dag.toSummary(),
464
+ activeCandidate: this.activeCandidate?.node?.id || null,
465
+ watchdogTripped: this.watchdog.stagnationTripped,
466
+ };
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Castor Plugin to mount EvoOperator into Context.
472
+ */
473
+ export function evoPlugin(ctx, options = {}) {
474
+ const shell = ctx.get("shell");
475
+ const evo = new EvoOperator({
476
+ workspaceRoot: options.workspaceRoot || process.cwd(),
477
+ shell,
478
+ watchdogOptions: options.watchdogOptions,
479
+ });
480
+
481
+ ctx.provide("evo", evo);
482
+
483
+ ctx.registerTool("evo_propose_candidate", {
484
+ description: "Evo: Propose an evolutionary code mutation hypothesis and snapshot target files for rollback",
485
+ parameters: {
486
+ type: "object",
487
+ properties: {
488
+ hypothesis: { type: "string", description: "The rationale and objective of this code variation" },
489
+ files_to_modify: {
490
+ type: "array",
491
+ items: { type: "string" },
492
+ description: "List of file paths that will be mutated in this variation",
493
+ },
494
+ skills_to_modify: {
495
+ type: "array",
496
+ items: { type: "string" },
497
+ description: "Optional list of skill names (from skills/<name>/SKILL.md or .agents/skills/<name>/SKILL.md) to evolve",
498
+ },
499
+ },
500
+ required: ["hypothesis"],
501
+ },
502
+ execute: (args) => evo.proposeCandidate(args),
503
+ });
504
+
505
+ ctx.registerTool("evo_evaluate_candidate", {
506
+ description: "Evo: Execute closed-loop verification command and compute fitness score",
507
+ parameters: {
508
+ type: "object",
509
+ properties: {
510
+ command: { type: "string", description: "Verification command (e.g. npm test, pytest, node test.js)" },
511
+ primary_metric: {
512
+ type: "string",
513
+ enum: ["tests_passed", "throughput", "latency_ms"],
514
+ default: "tests_passed",
515
+ },
516
+ timeout_ms: { type: "integer", default: 120000 },
517
+ },
518
+ required: ["command"],
519
+ },
520
+ execute: (args) => evo.evaluateCandidate(args),
521
+ });
522
+
523
+ ctx.registerTool("evo_select_candidate", {
524
+ description: "Evo: Accept candidate mutation into lineage DAG when tests pass or fitness improves",
525
+ parameters: {
526
+ type: "object",
527
+ properties: {
528
+ candidate_id: { type: "string", description: "Candidate ID to accept (optional, defaults to active)" },
529
+ },
530
+ },
531
+ execute: (args) => evo.selectCandidate(args),
532
+ });
533
+
534
+ ctx.registerTool("evo_revert_candidate", {
535
+ description: "Evo: Deterministically rollback workspace files to pre-mutation snapshot when variation fails",
536
+ parameters: {
537
+ type: "object",
538
+ properties: {
539
+ candidate_id: { type: "string", description: "Candidate ID to revert (optional, defaults to active)" },
540
+ },
541
+ },
542
+ execute: (args) => evo.revertCandidate(args),
543
+ });
544
+
545
+ ctx.registerTool("evo_status", {
546
+ description: "Evo: Query the evolutionary candidate lineage DAG and current Pareto frontier",
547
+ parameters: { type: "object", properties: {} },
548
+ execute: () => evo.getStatus(),
549
+ });
550
+ }