@evo-hq/pi-evo 0.4.5 → 0.5.0-alpha.10

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.
@@ -0,0 +1,672 @@
1
+ /*
2
+ * evo-optimize.js — Claude Code dynamic-workflow driver for the /evo:optimize round loop.
3
+ *
4
+ * This is the CODE form of plugins/evo/skills/optimize/SKILL.md "The Loop". It is an
5
+ * opt-in, Claude-Code-only driver; the prose skill remains the canonical, host-agnostic
6
+ * default. The workflow encodes the loop CONTROL: while/stall, mandatory scan + cross-history
7
+ * axis check, research escalation (ideators on stall / every ~5 commits), brief + diversity,
8
+ * fan-out + verify, collect + frontier-select. A concurrent ANALYST thread (Opus, self-paced,
9
+ * read-only) runs alongside the round loop via Promise.all — host + cross-history checks during
10
+ * rounds, feeding hints into the next brief. All domain work goes through the `evo` CLI inside
11
+ * agents — the script itself never touches the filesystem/shell.
12
+ *
13
+ * Treat this as a TEMPLATE: launch it as-is for the standard loop, or adapt the prompts /
14
+ * batch sizing / model routing per repo. The firm parts (mandatory scan, verify-before-
15
+ * count, stall, no-budget-in-condition) are the structure; brief content is adjustable.
16
+ *
17
+ * args (passed by optimize/SKILL.md Step 0.2):
18
+ * { pluginRoot, subagents, budget, stall }
19
+ * - pluginRoot : absolute path of the evo plugin (${CLAUDE_PLUGIN_ROOT}); used so nodes
20
+ * can Read the subagent skill by path (deterministic protocol loading).
21
+ * - subagents : round width N
22
+ * - budget : per-subagent iteration budget
23
+ * - stall : consecutive no-improve rounds before stopping
24
+ *
25
+ * Schemas are inlined (the workflow runtime is not relied on to resolve relative imports).
26
+ */
27
+
28
+ export const meta = {
29
+ name: 'evo-optimize',
30
+ description: 'Deterministic evo tree-search loop over the evo CLI (orient, scan, ideate-on-stall, brief, fan-out, verify, collect).',
31
+ phases: [
32
+ { title: 'Orient', detail: 'read state + select frontier parents to extend' },
33
+ { title: 'Scan', detail: 'mandatory parallel cross-cutting scan + structural aggregation (incl. cross-history axis check)' },
34
+ { title: 'Ideate', detail: 'research escalation: parallel ideators on stall / every ~5 commits' },
35
+ { title: 'Brief', detail: 'write one non-overlapping brief per subagent (reconciling ideator proposals)' },
36
+ { title: 'Optimize', detail: 'parallel optimization subagents (evo new/run)' },
37
+ { title: 'Verify', detail: 'validity audit + benchmark-noise confirm' },
38
+ { title: 'Collect', detail: 'prune dead lineages, record cross-cutting notes' },
39
+ { title: 'Analyst', detail: 'concurrent independent observer (Opus) — host + cross-history checks during rounds' },
40
+ ],
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Schemas (inlined)
45
+ // ---------------------------------------------------------------------------
46
+ const STATE = {
47
+ type: 'object',
48
+ // direction + evaluatedIds are required: the stall comparator needs direction (min vs max),
49
+ // and the mandatory scan keys off evaluatedIds (the state node must return [] when there are none).
50
+ required: ['bestScore', 'ceiling', 'frontier', 'direction', 'evaluatedIds'],
51
+ properties: {
52
+ bestScore: { type: 'number' },
53
+ bestExpId: { type: 'string' },
54
+ ceiling: { type: 'number' },
55
+ direction: { enum: ['max', 'min'] },
56
+ frontier: {
57
+ type: 'array',
58
+ items: {
59
+ type: 'object',
60
+ properties: { id: { type: 'string' }, score: { type: 'number' }, rank: { type: 'integer' } },
61
+ required: ['id'],
62
+ },
63
+ },
64
+ evaluatedIds: { type: 'array', items: { type: 'string' } },
65
+ committedCount: { type: 'integer' }, // total committed experiments (drives the periodic ideator trigger)
66
+ verifyRepeats: { type: 'integer' }, // benchmark noise profile (1 = deterministic, no confirm-loop)
67
+ summary: { type: 'string' }, // short scratchpad summary for subagent context
68
+ },
69
+ }
70
+
71
+ const FINDINGS = {
72
+ type: 'object',
73
+ required: ['findings'],
74
+ properties: {
75
+ findings: {
76
+ type: 'array',
77
+ items: {
78
+ type: 'object',
79
+ required: ['description', 'experiment_ids'],
80
+ properties: {
81
+ description: { type: 'string' },
82
+ experiment_ids: { type: 'array', items: { type: 'string' } },
83
+ evidence: { type: 'array', items: { type: 'string' } },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ }
89
+
90
+ const PATTERNS = {
91
+ type: 'object',
92
+ required: ['patterns'],
93
+ properties: {
94
+ patterns: {
95
+ type: 'array',
96
+ items: {
97
+ type: 'object',
98
+ required: ['kind', 'label'],
99
+ properties: {
100
+ kind: { type: 'string', enum: ['single', 'intersection', 'improver', 'axis-warning'] },
101
+ label: { type: 'string' },
102
+ experiment_ids: { type: 'array', items: { type: 'string' } },
103
+ },
104
+ },
105
+ },
106
+ },
107
+ }
108
+
109
+ const BRIEFS = {
110
+ type: 'object',
111
+ required: ['briefs'],
112
+ properties: {
113
+ briefs: {
114
+ type: 'array',
115
+ items: {
116
+ type: 'object',
117
+ required: ['objective', 'parent', 'boundaries', 'pointerTraces'],
118
+ properties: {
119
+ objective: { type: 'string' },
120
+ parent: { type: 'string' },
121
+ boundaries: { type: 'string' },
122
+ pointerTraces: { type: 'array', items: { type: 'string' } },
123
+ hard: { type: 'boolean' },
124
+ },
125
+ },
126
+ },
127
+ },
128
+ }
129
+
130
+ const SUBAGENT_RESULT = {
131
+ type: 'object',
132
+ required: ['expIds', 'status', 'committedImprover'],
133
+ properties: {
134
+ expIds: { type: 'array', items: { type: 'string', pattern: '^exp_[0-9]+$' } }, // proof the protocol ran
135
+ bestExpId: { type: 'string' },
136
+ bestScore: { type: 'number' },
137
+ status: { type: 'string', enum: ['committed', 'evaluated', 'failed', 'none'] },
138
+ committedImprover: { type: 'boolean' },
139
+ gatesAdded: { type: 'array', items: { type: 'string' } },
140
+ learnings: { type: 'array', items: { type: 'string' } },
141
+ suggestions: { type: 'array', items: { type: 'string' } },
142
+ },
143
+ // NOTE: "a committed improver must carry bestExpId + a numeric bestScore" is intentionally NOT
144
+ // expressed as a JSON-Schema allOf/if/then here. The workflow StructuredOutput validator runs in
145
+ // strict mode and REJECTS allOf/if/then sub-schemas (verified empirically: the schema fails to
146
+ // compile and every agent call errors with "subagent completed without calling StructuredOutput").
147
+ // The improver requirement is enforced in the verify stage instead (the bestExpId / numeric
148
+ // bestScore guard), which is functionally equivalent for stall accounting and auditing.
149
+ }
150
+
151
+ const VERDICT = {
152
+ type: 'object',
153
+ required: ['valid'],
154
+ properties: {
155
+ valid: { type: 'boolean' },
156
+ reasons: { type: 'array', items: { type: 'string' } },
157
+ },
158
+ }
159
+
160
+ // Implement stage output: experiment allocated + edited in its worktree, NOT yet run.
161
+ const IMPL_RESULT = {
162
+ type: 'object',
163
+ required: ['expId', 'worktree'],
164
+ properties: {
165
+ expId: { type: 'string', pattern: '^exp_[0-9]+$' },
166
+ worktree: { type: 'string' },
167
+ summary: { type: 'string' },
168
+ },
169
+ }
170
+
171
+ // Pre-run verifier verdict (design-time cheating gate).
172
+ const PREVERDICT = {
173
+ type: 'object',
174
+ required: ['pass'],
175
+ properties: {
176
+ pass: { type: 'boolean' },
177
+ findings: { type: 'array', items: { type: 'string' } },
178
+ },
179
+ }
180
+
181
+ // Analyst tick output: work-quality hints (fed into the next brief) + runtime/host alerts (surfaced).
182
+ const ANALYST_FINDINGS = {
183
+ type: 'object',
184
+ required: ['briefHints', 'alerts'],
185
+ properties: {
186
+ briefHints: { type: 'array', items: { type: 'string' } },
187
+ alerts: { type: 'array', items: { type: 'string' } },
188
+ },
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Helpers (pure JS — control-plane only)
193
+ // ---------------------------------------------------------------------------
194
+ // `args` may arrive as an object OR as a JSON STRING — the Workflow `args` param is frequently
195
+ // threaded to the script verbatim as a string (confirmed empirically). Coerce so the four knobs
196
+ // resolve either way; then Number() so even stringified numbers ("1") coerce, with NaN || 5 -> 5.
197
+ const A = typeof args === 'string'
198
+ ? (() => { try { return JSON.parse(args) } catch (_) { return {} } })()
199
+ : (args || {})
200
+ const pr = A.pluginRoot || ''
201
+ const WIDTH = Number(A.subagents) || 5
202
+ const ITER = Number(A.budget) || 5
203
+ const LIMIT = Number(A.stall) || 5
204
+ // Fire ideators (research escalation) once the stall counter reaches this — strictly BELOW the hard
205
+ // stall limit, so the loop researches its way toward a new direction before it gives up.
206
+ const IDEATE_STALL = Math.max(1, Math.min(3, LIMIT - 1))
207
+ const IDEATE_EVERY_COMMITS = 5 // periodic research cadence (matches prose step 6b)
208
+ const PREVERIFY_MAX = 3 // pre-run verify <-> revise attempts before discarding a rigged edit
209
+ // Concurrent analyst thread (runs alongside the round loop, NOT per-round).
210
+ const ANALYST_ENABLED = true
211
+ const ANALYST_MODEL = 'opus' // the analyst always reasons with Opus (judgment-heavy)
212
+ const ANALYST_INTERVAL_S = 300 // self-pace: observe ~every 5 min, during rounds
213
+ const ANALYST_HOP_S = 15 // the wait is INTERRUPTIBLE in hops of this size: when the optimize loop
214
+ // ends mid-wait it drops a sentinel the analyst polls, so the in-flight
215
+ // tick exits within ~ANALYST_HOP_S instead of stalling the run for the
216
+ // full interval (the script can't interrupt an agent's `sleep` directly).
217
+ const DONE_SENTINEL = '.evo/.wf_optimize_done' // optimize -> analyst "loop is over" signal (a file,
218
+ // since the in-memory `done` flag isn't visible to the agent's process)
219
+ const ANALYST_MAX_FAILS = 3 // consecutive failed ticks before the advisory analyst self-disables
220
+ // (guards against a hot-spin when ticks fail instantly, e.g. a bad schema)
221
+ // Experiments per scan agent. Heuristic for the prose "small enough to read in one pass" rule —
222
+ // the workflow can't recursively self-partition like the prose loop, so this is fixed up front.
223
+ // Lower it for heavy traces (many tasks / long messages); raise it for tiny traces.
224
+ const SCAN_BATCH = 6
225
+
226
+ function betterResult(a, b, direction) {
227
+ if (!a) return b
228
+ if (!b) return a
229
+ const sa = typeof a.bestScore === 'number' ? a.bestScore : null
230
+ const sb = typeof b.bestScore === 'number' ? b.bestScore : null
231
+ if (sa === null) return b
232
+ if (sb === null) return a
233
+ return (direction === 'min' ? sb < sa : sb > sa) ? b : a
234
+ }
235
+
236
+ function chunk(arr, n) {
237
+ const out = []
238
+ for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n))
239
+ return out
240
+ }
241
+
242
+ // Compact label for a batch of ids: factor out the shared leading chars.
243
+ // e.g. ["exp_0003","exp_0004","exp_0005"] -> "exp_000[3,4,5]"
244
+ function commonPrefix(strs) {
245
+ if (!strs.length) return ''
246
+ let p = strs[0]
247
+ for (const s of strs) {
248
+ while (p && !s.startsWith(p)) p = p.slice(0, -1)
249
+ if (!p) break
250
+ }
251
+ return p
252
+ }
253
+ function batchLabel(b) {
254
+ if (!b.length) return 'frontier'
255
+ if (b.length === 1) return b[0]
256
+ const p = commonPrefix(b)
257
+ return p.length > 1 ? `${p}[${b.map((x) => x.slice(p.length)).join(',')}]` : b.join(',')
258
+ }
259
+
260
+ // Diversity check: drop briefs whose pointer-trace sets overlap heavily with an earlier one.
261
+ function dedupeBriefs(briefs) {
262
+ const kept = []
263
+ for (const b of briefs) {
264
+ const ptr = new Set(b.pointerTraces || [])
265
+ const clash = kept.some((k) => {
266
+ const o = new Set(k.pointerTraces || [])
267
+ const inter = [...ptr].filter((x) => o.has(x)).length
268
+ const overlap = inter / Math.max(1, Math.min(ptr.size, o.size))
269
+ return k.parent === b.parent && overlap >= 0.6
270
+ })
271
+ if (!clash) kept.push(b)
272
+ }
273
+ return kept
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Node prompt builders
278
+ // ---------------------------------------------------------------------------
279
+ function statePrompt() {
280
+ return [
281
+ 'Read-only. Do NOT edit files or run experiments. Run these and parse their output:',
282
+ '`evo scratchpad`, `evo frontier` (already prints a JSON envelope), `evo status`, `evo awaiting`.',
283
+ 'Also read `.evo/project.md` for the metric goal, direction, and the benchmark-determinism line.',
284
+ 'Return: bestScore + bestExpId; the theoretical ceiling (1.0 for max metric, 0.0 for min)',
285
+ 'and direction; the frontier nodes ALREADY ranked by the configured strategy',
286
+ '(id, score, rank) — preserve evo\'s ordering, do not re-rank; the list of evaluated-but-',
287
+ 'undecided experiment ids; committedCount (number of committed experiments, from `evo status`);',
288
+ 'verifyRepeats (from project.md: 1 if deterministic, 3 if sampling-based / variance-expected);',
289
+ 'and a 2-3 sentence scratchpad summary for subagent context.',
290
+ ].join(' ')
291
+ }
292
+
293
+ // Verbatim scan brief from optimize/SKILL.md step 3.
294
+ function scanBrief(batch) {
295
+ return [
296
+ 'You are a read-only evo scan sub-agent. Do not run experiments or edit code.',
297
+ '',
298
+ 'Start by reading `.evo/project.md` to understand the optimization goal and metric. All your findings should be relevant to this goal.',
299
+ '',
300
+ `Your batch: ${JSON.stringify(batch)}.`,
301
+ '',
302
+ 'For each experiment, read its `outcome.json` and `traces/task_*.json` under `.evo/run_*/experiments/<id>/attempts/NNN/`. Also consider `hypothesis` and prose `error` text.',
303
+ '',
304
+ 'Find patterns that will populate the next round\'s subagent briefs:',
305
+ '- Shared failure causes -- root-cause reasons recurring across 2+ experiments (the *why*, not the surface gate name).',
306
+ '- Wall patterns -- approaches or gates multiple experiments consistently fail on.',
307
+ '- Compound-failure standouts -- single experiments hitting multiple failure modes.',
308
+ '',
309
+ 'Evidence must be VERBATIM quotes from outcome.json fields, trace messages, or error text -- not paraphrases. If you cannot cite verbatim evidence for a finding, drop it. Evidence: short quotes (<200 chars), max 3 per finding.',
310
+ 'Return JSON only: {"findings":[{"description","experiment_ids":[],"evidence":[]}]}.',
311
+ ].join('\n')
312
+ }
313
+
314
+ function aggregatePrompt(ids) {
315
+ return [
316
+ 'Read-only. These experiments', JSON.stringify(ids), 'are a MIX of evaluated-but-undecided nodes',
317
+ 'and committed frontier nodes. Load each outcome.json under the active run dir',
318
+ '(`.evo/run_*/experiments/<id>/attempts/NNN/outcome.json`) in Python; the `outcome` field tells you which is which.',
319
+ 'From the EVALUATED ones aggregate: co-occurring gate_failures; shared zero-score task ids in',
320
+ 'benchmark.result.tasks; recurring substrings in error — emit each single-pattern set AND every',
321
+ 'pairwise intersection where >=2 experiments exhibit both (kind:"intersection").',
322
+ 'From the COMMITTED ones enumerate improvers (outcome=committed — evo already applied the metric',
323
+ 'direction when it committed; do NOT re-derive improvement from a raw score>parent comparison) as kind:"improver".',
324
+ 'CROSS-HISTORY AXIS CHECK (look beyond this batch): run `evo tree` and read the `hypothesis` of ALL committed',
325
+ 'experiments in the run. If 3+ STRUCTURALLY DISTINCT hypotheses (not parameter sweeps of one idea) committed at',
326
+ '~the same score (a plateau), the bottleneck is not where those hypotheses aimed — emit kind:"axis-warning" whose',
327
+ 'label names the saturated axis AND suggests the orthogonal axis (harness, score definition, input data, or a',
328
+ 'different mechanism) the next briefs should pivot to. At most one axis-warning.',
329
+ 'Return JSON only.',
330
+ ].join(' ')
331
+ }
332
+
333
+ function briefPrompt(state, findings, patterns, parents, ideated, analystHints) {
334
+ return [
335
+ 'You are the evo orchestrator\'s brief writer.',
336
+ 'State summary:', state.summary || '',
337
+ '\nVerified scan findings:', JSON.stringify(findings),
338
+ '\nStructural patterns (incl. intersections, improvers, and any axis-warning):', JSON.stringify(patterns),
339
+ '\nSelected parent nodes:', JSON.stringify(parents.map((p) => p.id)),
340
+ ideated
341
+ ? '\nFRESH IDEATOR PROPOSALS may be available — read `.evo/run_*/ideator/proposals.jsonl` and reconcile BEFORE writing: skip any whose technique was already tried (`evo discards --like "<keyword>"`); score the rest by expected_score_uplift x confidence (frontier_extrapolation > failure_analysis > literature, all else equal); let the top 1-2 become brief objectives, citing the proposal\'s hypothesis/technique. Proposals are advisory — if none beat the in-graph scan findings, ignore them.'
342
+ : '',
343
+ '\nIf the patterns include an "axis-warning", the current axis is saturated — target the ORTHOGONAL axis it names rather than iterating the plateaued one.',
344
+ (analystHints && analystHints.length)
345
+ ? '\nLIVE ANALYST SIGNALS (from the concurrent observer — fold relevant ones into objectives/boundaries, e.g. switch off a saturated axis, avoid a flagged dead direction): ' + JSON.stringify(analystHints)
346
+ : '',
347
+ `\nWrite up to ${WIDTH} briefs (use the full round width of ${WIDTH} whenever you can find that many genuinely DISTINCT objectives — multiple briefs MAY branch from the SAME parent when fewer than ${WIDTH} frontier parents exist, as long as each attacks a different surface; do not pad with redundant briefs). One per subagent, each with four fields:`,
348
+ '1. objective -- one sentence naming WHERE in system behavior the gain hides, with evidence; NO file/function/edit names.',
349
+ '2. parent -- which experiment id to branch from (choose from the selected parents).',
350
+ '3. boundaries -- what NOT to try and why (discarded approaches, gates not to regress, what adjacent briefs this round do).',
351
+ '4. pointerTraces -- task ids to study first, one-line reason each.',
352
+ 'Mark hard:true on any brief needing deep trace analysis.',
353
+ 'The briefs MUST NOT collapse onto each other -- distinct objectives, non-overlapping pointer traces, different surfaces.',
354
+ 'Return JSON only.',
355
+ ].join(' ')
356
+ }
357
+
358
+ // IMPLEMENT — allocate + edit, but do NOT run (a pre-run verifier audits the edit first).
359
+ function implementPrompt(brief, parent, state) {
360
+ return [
361
+ `First, load and follow the evo subagent skill: Read ${pr}/skills/subagent/SKILL.md IN FULL and follow it as your operating protocol — do not skip it even if the brief looks simple.`,
362
+ `Allocate your experiment via \`evo new --parent ${parent}\`, then edit inside the returned worktree to implement the brief.`,
363
+ 'IMPORTANT: do NOT run `evo run` yet — a pre-run verifier audits your change first. Stop once the edit is complete.',
364
+ 'Do NOT edit benchmark, gate, or framework code; do NOT weaken/bypass any gate.',
365
+ '',
366
+ 'Your brief:',
367
+ `- Objective: ${brief.objective}`,
368
+ `- Parent node: ${parent}`,
369
+ `- Boundaries / anti-patterns: ${brief.boundaries}`,
370
+ `- Pointer traces: ${(brief.pointerTraces || []).join('; ')}`,
371
+ '',
372
+ 'Context (current state):', state.summary || '',
373
+ '',
374
+ 'Return the experiment id you created (exp_NNNN), its worktree path, and a 1-2 sentence change summary.',
375
+ ].join('\n')
376
+ }
377
+
378
+ // PRE-VERIFY — independent design-time cheating audit of the uncommitted edit (evo:verifier, pre-phase).
379
+ function preVerifyPrompt(expId, worktree) {
380
+ return [
381
+ `Read-only PRE-RUN verifier audit (design-time cheating) for experiment ${expId}.`,
382
+ `Inspect the UNCOMMITTED change in its worktree (\`git -C ${worktree} diff\` plus the edited files).`,
383
+ 'Set pass:false if the change does ANY of: leaks held-out / validation / gold answers into what the target sees;',
384
+ 'subsets or narrows the eval/benchmark command to only easy cases; adds a new artifact that needs a gate but has none;',
385
+ 'rests on a generic/empty hypothesis; short-circuits via a cache; fabricates artifacts; or edits benchmark/gate/framework code.',
386
+ 'Otherwise pass:true. Return {pass, findings:[]}; findings are concrete, fixable statements. Default pass:false if you cannot confirm it is clean.',
387
+ ].join(' ')
388
+ }
389
+
390
+ // REVISE — feed the verifier findings back to fix the edit (still no run).
391
+ function revisePrompt(expId, worktree, findings) {
392
+ return [
393
+ `The pre-run verifier FAILED experiment ${expId} for these design-time issues:`, JSON.stringify(findings || []),
394
+ `Revise the edit in its worktree (${worktree}) to address EVERY finding WITHOUT weakening the brief's objective or gaming the metric.`,
395
+ 'Do NOT run `evo run`. Do NOT edit benchmark/gate/framework code. Return a 1-2 sentence summary of the fix.',
396
+ ].join(' ')
397
+ }
398
+
399
+ // RUN — evaluate + commit the (pre-verified) experiment.
400
+ function runPrompt(expId) {
401
+ return [
402
+ `Run \`evo run ${expId}\` to evaluate and (if it improves and passes gates) commit it.`,
403
+ 'If it exits GATE_FAILED, do not fight the gate — report status=evaluated.',
404
+ `Return: expIds:["${expId}"]; status (committed|evaluated|failed|none); committedImprover = true ONLY if evo printed COMMITTED;`,
405
+ 'bestExpId + bestScore (required when committedImprover is true); any gates added; learnings.',
406
+ ].join(' ')
407
+ }
408
+
409
+ // DISCARD — pre-verify never satisfied; do not run a rigged experiment.
410
+ function discardPrompt(expId, findings) {
411
+ return [
412
+ `Pre-run verification could not be satisfied for ${expId} after ${PREVERIFY_MAX} revision attempts:`, JSON.stringify(findings || []),
413
+ `Annotate the experiment (\`evo annotate ${expId} "pre-verify failed: ..."\`), then run`,
414
+ `\`evo discard ${expId} --reason "pre-verify: design-time cheating not resolved"\` so a rigged experiment is never run or committed.`,
415
+ 'Return a one-line confirmation.',
416
+ ].join(' ')
417
+ }
418
+
419
+ // One ideator brief (failure_analysis | literature | frontier_extrapolation). Dispatched via
420
+ // agentType 'evo:ideator' so the agent gets the ideator system prompt + its tool set (incl.
421
+ // WebSearch/WebFetch for literature). It appends proposals to .evo/run_*/ideator/proposals.jsonl.
422
+ function ideatorPrompt(brief) {
423
+ return [
424
+ `brief=${brief}`,
425
+ '(workspace: infer from the current directory by walking up until you find `.evo/`.)',
426
+ 'Follow your ideator protocol: produce proposals for this brief and append them as JSONL to',
427
+ '`.evo/run_*/ideator/proposals.jsonl` in a single final write, then return a short JSON summary.',
428
+ ].join('\n')
429
+ }
430
+
431
+ function auditPrompt(expId) {
432
+ return [
433
+ `Read-only validity audit of experiment ${expId}.`,
434
+ 'Read its artifacts under `.evo/run_*/experiments/<id>/attempts/NNN/` (diff.patch, outcome.json, benchmark.log).',
435
+ 'Check: did it edit benchmark / gate / framework code? does the held-out slice still pass?',
436
+ 'is the score reproducible / not short-circuited by a cache? any constant-return or metric-gaming pattern?',
437
+ 'Return {valid:bool, reasons:[]}. Default valid:false if you cannot confirm.',
438
+ ].join(' ')
439
+ }
440
+
441
+ function collectPrompt(results, round) {
442
+ return [
443
+ `Round ${round} results:`, JSON.stringify(results.map((r) => ({ expIds: r.expIds, status: r.status, improver: r.committedImprover }))),
444
+ '\nRead each evaluated node\'s outcome.json (`.evo/run_*/experiments/<id>/attempts/NNN/outcome.json`) and spot shared failure modes the per-subagent summaries glossed over.',
445
+ 'Where a committed node has 3+ children that all regressed, run `evo prune <id> --reason "exhausted: ..."` (never `evo discard` a committed node).',
446
+ 'Record cross-cutting learnings with `evo set <id> --note "..."` and any workspace insight with `evo note "..."`.',
447
+ 'Return a one-line summary of what you pruned and noted.',
448
+ ].join(' ')
449
+ }
450
+
451
+ // One analyst tick (a FRESH Opus agent each call — no memory across ticks, so `reported` carries
452
+ // the dedup state in the loop's closure). Read-only: observes host + cross-history signals DURING
453
+ // rounds, returns work-quality briefHints (folded into the next brief) + runtime alerts (surfaced).
454
+ function analystPrompt(ctx, intervalS, reported) {
455
+ return [
456
+ 'You are the evo ANALYST — an independent observer running CONCURRENTLY with the optimize loop.',
457
+ 'Read-only: do NOT edit code, run experiments, or mutate evo state.',
458
+ `FIRST pace yourself with an INTERRUPTIBLE wait, so you stop promptly when the optimize loop ends. Run this single Bash command with a tool timeout of at least ${(intervalS + 30) * 1000} ms:`,
459
+ ` \`if [ -f ${DONE_SENTINEL} ]; then echo OPTIMIZE_DONE; else for i in $(seq 1 ${Math.ceil(intervalS / ANALYST_HOP_S)}); do sleep ${ANALYST_HOP_S}; [ -f ${DONE_SENTINEL} ] && { echo OPTIMIZE_DONE; break; }; done; fi\``,
460
+ `If that prints OPTIMIZE_DONE, the optimize loop has finished — return {"briefHints":[],"alerts":[]} immediately WITHOUT gathering any signals. Otherwise the full interval elapsed: now gather signals and report.`,
461
+ `Current loop state: round=${ctx.round}, stall=${ctx.stall}/${LIMIT}, best=${ctx.bestScore}.`,
462
+ `Already reported (do NOT repeat — only emit findings NEW since these): ${JSON.stringify(reported || [])}.`,
463
+ 'Walk these checks (skip any whose inputs are unavailable; cite evidence; nothing speculative):',
464
+ '- Zombie GPU: `nvidia-smi --query-compute-apps=pid,used_memory,process_name --format=csv,noheader` + `ps` — a PID holding >=4GB not tied to an active `evo run`. ALERT with a verify clause (do NOT kill).',
465
+ '- Buried stderr warning: tail recent experiment stderr under `.evo/run_*/experiments/*/attempts/*/` for tokenizer / EOS / chat_template / parity-mismatch lines not already annotated. ALERT.',
466
+ '- Stuck experiment / time-budget overrun: from `evo status`/`evo show`, an experiment active far longer than its peers, or a round overrunning the others. ALERT.',
467
+ '- Stuck axis: from `evo tree`, 3+ structurally-distinct committed hypotheses plateaued at ~the same score → name the saturated axis + one orthogonal axis. BRIEF HINT.',
468
+ '- Dead direction / ignored mechanism: annotations repeatedly naming a mechanism the recent work ignores, or a direction that keeps regressing. BRIEF HINT.',
469
+ 'Return {briefHints:[...], alerts:[...]}. briefHints feed the NEXT round\'s briefs (work-quality redirections); alerts surface to the user (runtime/host issues). Empty arrays are fine — most ticks should be quiet.',
470
+ ].join('\n')
471
+ }
472
+
473
+ // Per-brief lane: implement -> pre-verify <-> revise loop -> run -> post-audit, repeated up to the
474
+ // iteration budget (deepening the branch each time a committed improver lands). The independent
475
+ // evo:verifier gates EACH run for design-time cheating BEFORE the experiment is evaluated; its
476
+ // findings are fed back to a revise agent on the same experiment until it passes or is discarded.
477
+ async function runBrief(brief, state) {
478
+ let parent = brief.parent
479
+ let best = null
480
+ for (let depth = 0; depth < ITER; depth++) {
481
+ const impl = await agent(implementPrompt(brief, parent, state), {
482
+ schema: IMPL_RESULT, model: brief.hard ? 'opus' : 'sonnet', phase: 'Optimize', label: `impl:${parent}#${depth}`,
483
+ })
484
+ if (!impl || !impl.expId) break
485
+
486
+ // pre-verify <-> revise feedback loop (design-time cheating gate)
487
+ let pv = null
488
+ for (let v = 0; v < PREVERIFY_MAX; v++) {
489
+ pv = await agent(preVerifyPrompt(impl.expId, impl.worktree), {
490
+ schema: PREVERDICT, agentType: 'evo:verifier', phase: 'Verify', label: `preverify:${impl.expId}#${v}`,
491
+ })
492
+ if (pv && pv.pass) break
493
+ if (v < PREVERIFY_MAX - 1) {
494
+ await agent(revisePrompt(impl.expId, impl.worktree, pv && pv.findings), {
495
+ model: brief.hard ? 'opus' : 'sonnet', phase: 'Optimize', label: `revise:${impl.expId}#${v}`,
496
+ })
497
+ }
498
+ }
499
+ if (!pv || !pv.pass) {
500
+ await agent(discardPrompt(impl.expId, pv && pv.findings), { phase: 'Verify', label: `discard:${impl.expId}` })
501
+ break // couldn't produce a clean edit on this branch — stop spending budget here
502
+ }
503
+
504
+ // run -> evaluate + commit
505
+ const r = await agent(runPrompt(impl.expId), { schema: SUBAGENT_RESULT, phase: 'Optimize', label: `run:${impl.expId}` })
506
+ if (!r) break
507
+
508
+ // post-run validity audit (evo:verifier, post-phase) on committed improvers
509
+ let valid = true
510
+ if (r.committedImprover) {
511
+ if (!r.bestExpId || typeof r.bestScore !== 'number') {
512
+ valid = false
513
+ } else {
514
+ const audit = await agent(auditPrompt(r.bestExpId), { schema: VERDICT, agentType: 'evo:verifier', phase: 'Verify', label: `audit:${r.bestExpId}` })
515
+ valid = !!(audit && audit.valid)
516
+ if (valid && (Number(state.verifyRepeats) || 1) > 1) {
517
+ log(`note: ${r.bestExpId} on a noisy benchmark (repeats=${state.verifyRepeats}); confirm-loop pending the evo rescore affordance — relying on the held-out gate`)
518
+ }
519
+ }
520
+ }
521
+ const scored = { ...r, valid }
522
+ best = betterResult(best, scored, state.direction)
523
+
524
+ if (valid && r.committedImprover && r.bestExpId) {
525
+ parent = r.bestExpId // deepen: extend the new commit on the next budget iteration
526
+ } else {
527
+ break // evaluated / failed / invalid — stop deepening this branch
528
+ }
529
+ }
530
+ return best
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // The loop
535
+ // ---------------------------------------------------------------------------
536
+ let stall = 0
537
+ let round = 0
538
+ let lastIdeatedCommit = 0 // committedCount at the last ideator dispatch (periodic cadence)
539
+ let ideatedThisStall = false // fire ideators once per stall episode, not every stalled round
540
+ let lastBestScore = null // latest best score, surfaced to the concurrent analyst thread
541
+ let done = false // set when the optimize loop ends -> stops the analyst thread
542
+ const analystSignals = [] // briefHints the analyst pushes; drained into the next round's brief
543
+
544
+ log(`evo-optimize start: subagents=${WIDTH} budget=${ITER} stall=${LIMIT} analyst=${ANALYST_ENABLED ? ANALYST_MODEL : 'off'} | argsType=${typeof args} A.subagents=${A.subagents} A.budget=${A.budget} A.stall=${A.stall}`)
545
+
546
+ // The optimize round loop (runs concurrently with analystLoop via Promise.all).
547
+ async function optimizeLoop() {
548
+ while (stall < LIMIT) {
549
+ round += 1
550
+
551
+ phase('Orient')
552
+ const state = await agent(statePrompt(), { schema: STATE, agentType: 'Explore', model: 'sonnet', phase: 'Orient', label: `state:r${round}` })
553
+ lastBestScore = state.bestScore
554
+ if (state.bestScore === state.ceiling) { log(`ceiling reached (best=${state.bestScore}) — stopping`); break }
555
+ const parents = (state.frontier || []).slice(0, WIDTH)
556
+ if (parents.length === 0) { log('no explorable frontier nodes — stopping'); break }
557
+
558
+ // N1 + N1.5 — mandatory parallel scan + structural aggregation (barrier). Scan runs EVERY round
559
+ // (hard rule); when there are no evaluated-undecided nodes yet (round 1) it falls back to the
560
+ // committed frontier so at least one scan agent still runs before briefs.
561
+ phase('Scan')
562
+ const evaluatedIds = state.evaluatedIds || []
563
+ const frontierIds = (state.frontier || []).map((f) => f.id).filter(Boolean)
564
+ const scanTargets = evaluatedIds.length ? evaluatedIds : frontierIds
565
+ const batches = chunk(scanTargets, SCAN_BATCH)
566
+ const scanThunks = batches.map((b) => () => agent(scanBrief(b), { schema: FINDINGS, agentType: 'Explore', phase: 'Scan', label: `scan ${b.length}: ${batchLabel(b)}` }))
567
+ const aggregateIds = [...new Set([...evaluatedIds, ...frontierIds])]
568
+ const aggThunk = aggregateIds.length
569
+ ? [() => agent(aggregatePrompt(aggregateIds), { schema: PATTERNS, agentType: 'Explore', phase: 'Scan', label: 'aggregate' })]
570
+ : []
571
+ const scanResults = (await parallel([...scanThunks, ...aggThunk])).filter(Boolean)
572
+ const findings = scanResults.flatMap((r) => (r && r.findings) ? r.findings : [])
573
+ const patterns = scanResults.flatMap((r) => (r && r.patterns) ? r.patterns : [])
574
+
575
+ // N1.7 — research escalation (6b): on stall (before the hard limit) or every ~5 commits, fire the
576
+ // three ideators in parallel. parallel() blocks until all return (proposals land before briefing).
577
+ const commits = Number(state.committedCount) || 0
578
+ const stalledTrigger = stall >= IDEATE_STALL && !ideatedThisStall
579
+ const periodicTrigger = commits - lastIdeatedCommit >= IDEATE_EVERY_COMMITS
580
+ let ideated = false
581
+ if (stalledTrigger || periodicTrigger) {
582
+ phase('Ideate')
583
+ await parallel(['frontier_extrapolation', 'failure_analysis', 'literature'].map((b) => () =>
584
+ agent(ideatorPrompt(b), { agentType: 'evo:ideator', phase: 'Ideate', label: `ideate:${b}` })))
585
+ lastIdeatedCommit = commits
586
+ if (stalledTrigger) ideatedThisStall = true
587
+ ideated = true
588
+ log(`ideators fired (trigger: ${stalledTrigger ? 'stall' : 'periodic'}, stall=${stall}, commits=${commits})`)
589
+ }
590
+
591
+ // N2 — brief writer: reconciles ideator proposals (6c), acts on axis-warning, and folds in any
592
+ // live analyst hints accumulated since the last round; JS diversity dedupe afterwards.
593
+ phase('Brief')
594
+ const analystHints = analystSignals.splice(0)
595
+ const briefOut = await agent(briefPrompt(state, findings, patterns, parents, ideated, analystHints), { schema: BRIEFS, phase: 'Brief', label: `briefs:r${round}` })
596
+ const briefs = dedupeBriefs((briefOut && briefOut.briefs) || [])
597
+ if (briefs.length === 0) { log('no briefs produced — stopping'); break }
598
+
599
+ // N3..N4 — fan out one lane per brief; each lane: implement -> pre-verify<->revise -> run -> post-audit.
600
+ const results = (await parallel(briefs.map((b) => () => runBrief(b, state)))).filter(Boolean)
601
+
602
+ // N5 — collect: prune dead lineages, record notes.
603
+ phase('Collect')
604
+ await agent(collectPrompt(results, round), { phase: 'Collect', label: `collect:r${round}` })
605
+
606
+ // Loop control: stall resets only when this round produced a VERIFIED committed score that beats
607
+ // the PRIOR BEST in the metric direction (a beat-its-own-parent commit is branch progress, not a
608
+ // new best, and does NOT reset stall). No budget in the condition.
609
+ const dir = state.direction || 'max'
610
+ const gains = results
611
+ .filter((r) => r.committedImprover && r.valid !== false && typeof r.bestScore === 'number')
612
+ .map((r) => r.bestScore)
613
+ const roundBest = gains.length ? (dir === 'min' ? Math.min(...gains) : Math.max(...gains)) : null
614
+ const improved = roundBest !== null && (dir === 'min' ? roundBest < state.bestScore : roundBest > state.bestScore)
615
+ stall = improved ? 0 : stall + 1
616
+ if (improved) ideatedThisStall = false
617
+ log(`round ${round}: improved=${improved} roundBest=${roundBest} prevBest=${state.bestScore} stall=${stall}/${LIMIT} spent=${budget.spent()}`)
618
+ }
619
+ done = true
620
+ // Wake any in-flight analyst tick now (its `sleep` can't see the in-memory `done`): the sentinel
621
+ // makes the tick's interruptible wait exit within ~ANALYST_HOP_S instead of running the full interval.
622
+ if (ANALYST_ENABLED) await agent(`mkdir -p .evo && : > ${DONE_SENTINEL} && echo signalled`, { phase: 'Collect', label: 'signal:optimize-done' })
623
+ log(`optimize loop finished after ${round} round(s), final stall=${stall}/${LIMIT}`)
624
+ return { rounds: round, finalStall: stall }
625
+ }
626
+
627
+ // Concurrent analyst thread (P1-sliver/P2-P5/P7): an independent, self-paced Opus observer that runs
628
+ // DURING rounds (not per-round). Each tick is a FRESH agent (no cross-tick memory), so `reported`
629
+ // holds the dedup state in this closure. Work-quality findings -> analystSignals (next brief);
630
+ // runtime/host alerts -> the run log. Stops when optimizeLoop sets `done`.
631
+ async function analystLoop() {
632
+ if (!ANALYST_ENABLED) return
633
+ const reported = [] // closure memory across the stateless ticks (caps re-alerting)
634
+ let t = 0
635
+ let fails = 0 // consecutive tick failures; trips the self-disable below
636
+ while (!done) {
637
+ t += 1
638
+ // The analyst is purely advisory and read-only: a failed tick must NEVER reject this loop and
639
+ // abort the optimizer. Swallow any tick error, log it, and continue (or exit if `done` flipped).
640
+ let tick = null
641
+ try {
642
+ tick = await agent(analystPrompt({ round, stall, bestScore: lastBestScore }, ANALYST_INTERVAL_S, reported.slice(-30)), {
643
+ agentType: 'Explore', model: ANALYST_MODEL, schema: ANALYST_FINDINGS, phase: 'Analyst', label: `analyst#${t}`,
644
+ })
645
+ } catch (e) {
646
+ log(`ANALYST tick #${t} errored (ignored, optimize unaffected): ${(e && e.message) || e}`)
647
+ }
648
+ if (tick) {
649
+ fails = 0 // a real tick resets the failure streak
650
+ for (const h of (tick.briefHints || [])) { analystSignals.push(h); reported.push(h) }
651
+ for (const a of (tick.alerts || [])) { log(`ANALYST ALERT: ${a}`); reported.push(a) }
652
+ } else if (++fails >= ANALYST_MAX_FAILS) {
653
+ // The pacing wait lives INSIDE the agent, so a tick that fails before sleeping (e.g. a schema
654
+ // reject) leaves nothing to pace the retry — left unchecked the loop hot-spins agents. The
655
+ // analyst is optional, so after a short streak of failures, disable it for the rest of the run.
656
+ log(`ANALYST disabled after ${fails} consecutive failed ticks — optimize continues without it.`)
657
+ return
658
+ }
659
+ }
660
+ }
661
+
662
+ // Clear any stale sentinel from a prior run BEFORE the threads start, else the analyst's first wait
663
+ // would see it and exit instantly. The script can't touch the filesystem itself, so an agent does it.
664
+ if (ANALYST_ENABLED) await agent(`rm -f ${DONE_SENTINEL}; echo cleared`, { phase: 'Orient', label: 'init:clear-sentinel' })
665
+
666
+ // optimizeLoop is the run's result; analystLoop is advisory. The `.catch` is the definitive guard that
667
+ // the observer thread can NEVER reject the combined promise and fail an otherwise-good optimize run.
668
+ const [optimizeResult] = await Promise.all([
669
+ optimizeLoop(),
670
+ analystLoop().catch((e) => log(`ANALYST thread exited abnormally (ignored): ${(e && e.message) || e}`)),
671
+ ])
672
+ return optimizeResult