@hmharness/evolution 0.14.7 → 0.14.9

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 (40) hide show
  1. package/dist/bench.d.ts.v2bak.v2bak +44 -0
  2. package/dist/bench.js.v2bak.v2bak +156 -0
  3. package/dist/candidates.d.ts.v2bak.v2bak +114 -0
  4. package/dist/candidates.js.v2bak.v2bak +232 -0
  5. package/dist/dataset.d.ts.v2bak.v2bak +64 -0
  6. package/dist/dataset.js.v2bak.v2bak +184 -0
  7. package/dist/evolve.d.ts +14 -0
  8. package/dist/evolve.d.ts.v2bak.v2bak +98 -0
  9. package/dist/evolve.js +24 -7
  10. package/dist/evolve.js.v2bak.v2bak +573 -0
  11. package/dist/impact.d.ts.v2bak.v2bak +77 -0
  12. package/dist/impact.js.v2bak.v2bak +214 -0
  13. package/dist/index.d.ts.v2bak.v2bak +16 -0
  14. package/dist/index.js.v2bak.v2bak +16 -0
  15. package/dist/insights.d.ts.v2bak.v2bak +18 -0
  16. package/dist/insights.js.v2bak.v2bak +80 -0
  17. package/dist/knowledge.d.ts.v2bak.v2bak +15 -0
  18. package/dist/knowledge.js.v2bak.v2bak +145 -0
  19. package/dist/labels.d.ts.v2bak.v2bak +20 -0
  20. package/dist/labels.js.v2bak.v2bak +57 -0
  21. package/dist/memory.d.ts.v2bak.v2bak +32 -0
  22. package/dist/memory.js.v2bak.v2bak +233 -0
  23. package/dist/patches.d.ts.v2bak.v2bak +83 -0
  24. package/dist/patches.js +6 -2
  25. package/dist/patches.js.v2bak.v2bak +253 -0
  26. package/dist/radar.d.ts.v2bak.v2bak +3 -0
  27. package/dist/radar.js.v2bak.v2bak +40 -0
  28. package/dist/ranker.d.ts.v2bak.v2bak +44 -0
  29. package/dist/ranker.js.v2bak.v2bak +55 -0
  30. package/dist/readiness.d.ts.v2bak.v2bak +15 -0
  31. package/dist/readiness.js +14 -2
  32. package/dist/readiness.js.v2bak.v2bak +167 -0
  33. package/dist/skillpayload.d.ts.v2bak.v2bak +1 -0
  34. package/dist/skillpayload.js.v2bak +28 -0
  35. package/dist/skillpayload.js.v2bak.v2bak.v2bak +28 -0
  36. package/dist/skills.d.ts.v2bak.v2bak +54 -0
  37. package/dist/skills.js.v2bak.v2bak +321 -0
  38. package/dist/workflows.d.ts.v2bak.v2bak +28 -0
  39. package/dist/workflows.js.v2bak.v2bak +84 -0
  40. package/package.json +1 -1
@@ -0,0 +1,573 @@
1
+ /**
2
+ * @hmharness/evolution - evolve
3
+ * The evolution loop, one cycle per invocation (schedule it however you
4
+ * like). Pipeline: mine insights -> propose skill drafts via a meta-model
5
+ * call -> bench A/B (baseline vs candidate injection) -> promote or reject
6
+ * -> append-only memory distillation -> everything logged to
7
+ * evolution/log.jsonl.
8
+ *
9
+ * Guardrails baked in (DGM/GDPevo/ICLR-misevolve lessons):
10
+ * - drafts are never injected into real sessions; only promotion changes
11
+ * behavior, and only after the bench shows no regression
12
+ * - the loop writes only under skills/ and memory/ - it cannot touch
13
+ * config, security settings, or code
14
+ * - memory is append-only (ACE: rewriting is how context gets lost)
15
+ */
16
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { chat, loadConfig } from '@hmharness/kernel';
19
+ import { listCases, matchCase, seedCases } from "./bench.js";
20
+ import { deleteDraft, listCanary, listDrafts, listSkills, promoteSkill, rollbackSkill, skillsToPrompt, unpromoteSkill, writeDraft } from "./skills.js";
21
+ import { appendMemory, readNotes } from "./memory.js";
22
+ import { readInsights } from "./insights.js";
23
+ import { readBudget, recordParetoEntry, readParetoEntries, sampleAncestor, impactReport, decayUnusedSkills } from "./impact.js";
24
+ /** One bench run through the structured assertion (upgraded gate: exact/
25
+ * regex/none/any modes, not just substrings). Also returns the raw output
26
+ * so callers can cost-cap (verbose-but-passing candidates). */
27
+ async function runAndAssert(runCase, c, injection) {
28
+ const output = await runCase(c, injection);
29
+ return { pass: matchCase(output, c).pass, output };
30
+ }
31
+ /** Rough token estimate, language-aware: ASCII runs ~4 chars/token, CJK
32
+ * ~1 char/token. A flat chars/4 undercounted Chinese 2-4x, letting verbose
33
+ * zh candidates dodge the cost cap. Used on BOTH sides of every comparison
34
+ * (baseline and candidate), never as the only rejection reason (the
35
+ * pass-rate gate decides; cost-cap only vetoes pass-by-rambling). */
36
+ export function estTokens(text) {
37
+ const cjk = (text.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/g) ?? []).length;
38
+ return Math.ceil((text.length - cjk) / 4 + cjk);
39
+ }
40
+ export async function runEvolution(opts) {
41
+ const { home, provider, runCase } = opts;
42
+ const maxProposals = opts.maxProposals ?? 2;
43
+ const say = opts.log ?? (() => undefined);
44
+ const report = {
45
+ time: new Date().toISOString(),
46
+ model: provider.model,
47
+ seededCases: [],
48
+ insightCount: 0,
49
+ noteCount: 0,
50
+ proposals: [],
51
+ outcomes: [],
52
+ memoryDistilled: null,
53
+ };
54
+ // P0 evolution budget gate (AZR "safety alarms" + cost control): a day's
55
+ // cycle count and a token ceiling live in config; overspending skips the
56
+ // cycle instead of burning money unattended. The token gate compares the
57
+ // summed estTokens of today's logged cycles against maxCyclesPerDay *
58
+ // maxTokensPerCycle (the old code read maxTokensPerCycle but never checked
59
+ // anything but cycles - the documented budget was decorative).
60
+ const budget = await readBudget(home);
61
+ const today = new Date().toISOString().slice(0, 10);
62
+ // Skipped cycles ARE data (SELFFEED honesty rule #2: absent/empty days must
63
+ // be visible). The early returns used to bypass the durable log entirely -
64
+ // a day's skips vanished without a trace and "0 skips" was unauditable
65
+ // (caught by the day-16 meta-audit). Persist before returning.
66
+ const persistSkip = async () => {
67
+ report.estTokens = 0;
68
+ const logDir = join(home, 'evolution');
69
+ await mkdir(logDir, { recursive: true });
70
+ await appendFile(join(logDir, 'log.jsonl'), JSON.stringify(report) + '\n', 'utf8');
71
+ };
72
+ if (budget.maxCyclesPerDay && budget.cyclesToday >= budget.maxCyclesPerDay) {
73
+ report.outcomes.push({ name: '(budget)', action: 'error', reason: `daily cycle limit reached (${budget.cyclesToday}/${budget.maxCyclesPerDay} today) - skipped` });
74
+ await persistSkip();
75
+ return report;
76
+ }
77
+ if (budget.maxCyclesPerDay && budget.maxTokensPerCycle) {
78
+ const dailyCap = budget.maxCyclesPerDay * budget.maxTokensPerCycle;
79
+ if ((budget.tokensToday ?? 0) >= dailyCap) {
80
+ report.outcomes.push({ name: '(budget)', action: 'error', reason: `daily token limit reached (~${budget.tokensToday}/${dailyCap} est-tokens today) - skipped` });
81
+ await persistSkip();
82
+ return report;
83
+ }
84
+ }
85
+ // 1. Seed bench cases on a fresh home so the gate always has a signal.
86
+ report.seededCases = await seedCases(home);
87
+ const cases = await listCases(home);
88
+ const train = cases.filter((c) => !c.holdout);
89
+ const holdout = cases.filter((c) => c.holdout);
90
+ if (train.length === 0) {
91
+ report.outcomes.push({ name: '(bench)', action: 'error', reason: 'no train bench cases available' });
92
+ return report;
93
+ }
94
+ // 2. Gather evolution signals.
95
+ const insights = await readInsights(home, 40);
96
+ const notes = await readNotes(home);
97
+ const active = await listSkills(home);
98
+ const drafts = await listDrafts(home);
99
+ const canary = await listCanary(home);
100
+ report.insightCount = insights.length;
101
+ report.noteCount = notes.length;
102
+ const insightIds = insights.map((i) => `${i.time.slice(0, 16)}:${i.session.slice(-6)}`);
103
+ const toolCounts = {};
104
+ for (const i of insights)
105
+ for (const t of i.toolsUsed)
106
+ toolCounts[t] = (toolCounts[t] ?? 0) + 1;
107
+ // Radar feed (ops keeper): the newest ecosystem brief as context for
108
+ // proposals - toolchain advice must know what shipped recently.
109
+ // Read-only, best-effort; no brief = no signal.
110
+ let radarBrief = null;
111
+ try {
112
+ const { latestRadarBrief } = await import("./radar.js");
113
+ radarBrief = await latestRadarBrief(home);
114
+ }
115
+ catch { /* radar feed absent = no ecosystem signal this cycle */ }
116
+ const signals = {
117
+ sessions: insights.length,
118
+ failures: insights.filter((i) => i.outcome !== 'ok').map((i) => ({ task: i.task, outcome: i.outcome })),
119
+ toolUsage: toolCounts,
120
+ activeSkills: active.map((s) => s.name),
121
+ canarySkills: canary.map((s) => s.name),
122
+ existingDrafts: drafts.map((s) => s.name),
123
+ recentNotes: notes.slice(-10).map((n) => n.text),
124
+ ecosystemNews: radarBrief ?? '(no recent ecosystem brief - run hmh ops scan)',
125
+ };
126
+ // 3. Baseline bench (train gates promotion; holdout re-verifies after).
127
+ // Per-case cost captured too - the dual-metric gate (P0 methodology):
128
+ // a candidate that passes only by RAMBLING (cost > baseline x cost-cap)
129
+ // gets vetoed even at a green pass rate.
130
+ say(`baseline bench: ${train.length} train + ${holdout.length} holdout cases`);
131
+ const baseResults = [];
132
+ const baseCost = {};
133
+ for (const c of train) {
134
+ try {
135
+ const r = await runAndAssert(runCase, c, skillsToPrompt(active));
136
+ baseResults.push({ name: c.name, pass: r.pass });
137
+ baseCost[c.name] = estTokens(r.output);
138
+ }
139
+ catch (err) {
140
+ baseResults.push({ name: c.name, pass: false });
141
+ say(` case ${c.name} threw: ${String(err).slice(0, 100)}`);
142
+ }
143
+ }
144
+ const baseRate = baseResults.filter((r) => r.pass).length / baseResults.length;
145
+ const holdoutBase = [];
146
+ for (const c of holdout) {
147
+ try {
148
+ holdoutBase.push({ name: c.name, pass: (await runAndAssert(runCase, c, skillsToPrompt(active))).pass });
149
+ }
150
+ catch {
151
+ holdoutBase.push({ name: c.name, pass: false });
152
+ }
153
+ }
154
+ const holdoutBaseRate = holdoutBase.length === 0 ? 1 : holdoutBase.filter((r) => r.pass).length / holdoutBase.length;
155
+ say(`baseline: train ${(baseRate * 100).toFixed(0)}%, holdout ${(holdoutBaseRate * 100).toFixed(0)}%`);
156
+ // 4. Proposals: preset (tests/UI) or meta-model call. The GEPA population
157
+ // loop: ONE random ancestor from the rejected-candidate pool (Pareto
158
+ // front) feeds the prompt so evolution varies around the archive, not
159
+ // around the single best; a complementary reject gets merged in (cross).
160
+ // AWM adds workflow-level induction from repeated task archetypes - a
161
+ // higher abstraction than per-mistake reflection (paper-verified).
162
+ const pool = await readParetoEntries(home, 60);
163
+ const { ancestor, mergeWith } = sampleAncestor(pool);
164
+ let proposals;
165
+ if (opts.presetProposals) {
166
+ proposals = opts.presetProposals;
167
+ }
168
+ else {
169
+ proposals = await proposeSkills(provider, signals, say, ancestor ?? undefined, mergeWith ?? undefined);
170
+ // AWM is ADDITIVE, not fallback-only (design intent: "higher abstraction
171
+ // than per-mistake reflection"). With batch-produced task archetypes
172
+ // repeating 100+ times/day, the workflow path must get its turn even
173
+ // when the meta-model also proposes - the -workflow naming + gate still
174
+ // decides promotion. Guard: only append when clusters are real (>=5).
175
+ try {
176
+ const { workflowProposals } = await import("./workflows.js");
177
+ const wf = await workflowProposals(provider, home, say);
178
+ proposals = [...proposals, ...wf];
179
+ }
180
+ catch (err) {
181
+ say(` awm skipped: ${String(err).slice(0, 80)}`);
182
+ }
183
+ }
184
+ report.proposals = proposals;
185
+ // 4b. Promotion quality floor (day-16 meta-audit): "no regression" alone
186
+ // can promote a candidate that is still terrible when the BASELINE itself
187
+ // is weak (baseline 20% -> candidate 25% passes the old gate). An absolute
188
+ // minimum train pass rate closes that hole. Configurable via
189
+ // config.json evolution.minPassRate (default 0.6).
190
+ let minPassRate = 0.6;
191
+ try {
192
+ const cfg = JSON.parse(await readFile(join(home, 'config.json'), 'utf8'));
193
+ if (typeof cfg.evolution?.minPassRate === 'number' && cfg.evolution.minPassRate >= 0 && cfg.evolution.minPassRate <= 1) {
194
+ minPassRate = cfg.evolution.minPassRate;
195
+ }
196
+ }
197
+ catch { /* default floor */ }
198
+ // 5. A/B gate each proposal on the train set.
199
+ for (const p of proposals.slice(0, maxProposals)) {
200
+ say(`candidate "${p.name}": drafting + candidate bench`);
201
+ try {
202
+ // Write-channel anti-poisoning (Misevolve lesson): the behavior gate
203
+ // only sees bench output, so instructions the model merely IGNORES
204
+ // slip through. Screen drafted content for attempts to suppress tool
205
+ // use, rename toolchain identifiers, or bypass the approval gate.
206
+ const poison = screenForPoison(p.skill_md);
207
+ if (poison) {
208
+ report.outcomes.push({ name: p.name, action: 'rejected', reason: `poisoning screen: ${poison}` });
209
+ say(` rejected by poisoning screen (${poison.slice(0, 60)})`);
210
+ continue;
211
+ }
212
+ await writeDraft(home, p.name, p.skill_md);
213
+ const draftBlock = `## Draft skill under evaluation: ${p.name}\n\n${p.skill_md.slice(0, 4000)}`;
214
+ const candidateInjection = `${skillsToPrompt(active)}\n${draftBlock}`;
215
+ const candResults = [];
216
+ const candCost = {};
217
+ for (const c of train) {
218
+ try {
219
+ // two independent samples: a candidate passes only if it passes
220
+ // BOTH runs - a single lucky output must not clear the gate
221
+ const a = await runAndAssert(runCase, c, candidateInjection);
222
+ const b = a.pass ? await runAndAssert(runCase, c, candidateInjection) : { pass: false, output: '' };
223
+ candResults.push({ name: c.name, pass: a.pass && b.pass });
224
+ candCost[c.name] = Math.max(estTokens(a.output), estTokens(b.output));
225
+ }
226
+ catch {
227
+ candResults.push({ name: c.name, pass: false });
228
+ }
229
+ }
230
+ const candRate = candResults.filter((r) => r.pass).length / candResults.length;
231
+ const regression = baseResults.some((b) => b.pass && !candResults.find((c) => c.name === b.name)?.pass);
232
+ // dual-metric veto: a passing case that costs > cost-cap x its
233
+ // baseline counts as a cost regression (the candidate passed by
234
+ // rambling) - only enforced where the case declares a cost-cap
235
+ const costRegressions = train.filter((c) => {
236
+ const cap = c.costCap ?? 1.3; // default 1.3x for all cases
237
+ const base = baseCost[c.name] ?? 0;
238
+ const cand = candCost[c.name] ?? 0;
239
+ return base > 0 && cand > base * cap;
240
+ }).map((c) => c.name);
241
+ const summary = (rs) => rs.map((r) => `${r.name}:${r.pass ? 'pass' : 'FAIL'}`).join(' ');
242
+ if (regression || candRate < baseRate) {
243
+ await deleteDraft(home, p.name);
244
+ await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: regression ? 'bench regression' : `pass rate ${candRate.toFixed(2)} < baseline ${baseRate.toFixed(2)}`, scores: { train: candRate }, metaModel: provider.model, at: new Date().toISOString() });
245
+ report.outcomes.push({
246
+ name: p.name,
247
+ action: 'rejected',
248
+ reason: regression ? 'bench regression on a previously passing case' : `pass rate ${candRate} < baseline ${baseRate}`,
249
+ baseline: { passRate: baseRate, cases: summary(baseResults) },
250
+ candidate: { passRate: candRate, cases: summary(candResults) },
251
+ lineage: { parentInsights: insightIds, scores: { train: candRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
252
+ });
253
+ say(` rejected (${regression ? 'regression' : 'lower pass rate'})`);
254
+ continue;
255
+ }
256
+ if (candRate < minPassRate) {
257
+ await deleteDraft(home, p.name);
258
+ await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: `below quality floor (${candRate.toFixed(2)} < ${minPassRate})`, scores: { train: candRate }, metaModel: provider.model, at: new Date().toISOString() });
259
+ report.outcomes.push({
260
+ name: p.name,
261
+ action: 'rejected',
262
+ reason: `below quality floor: pass rate ${candRate} < min ${minPassRate} (non-regression vs a weak baseline ${baseRate} is not good enough)`,
263
+ baseline: { passRate: baseRate, cases: summary(baseResults) },
264
+ candidate: { passRate: candRate, cases: summary(candResults) },
265
+ lineage: { parentInsights: insightIds, scores: { train: candRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
266
+ });
267
+ say(` rejected (below quality floor ${minPassRate})`);
268
+ continue;
269
+ }
270
+ if (costRegressions.length > 0) {
271
+ await deleteDraft(home, p.name);
272
+ await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: `cost regression on ${costRegressions.join(', ')}`, scores: { train: candRate }, metaModel: provider.model, at: new Date().toISOString() });
273
+ report.outcomes.push({
274
+ name: p.name,
275
+ action: 'rejected',
276
+ reason: `passed the bench but by rambling: output cost exceeded the baseline cap on ${costRegressions.join(', ')}`,
277
+ baseline: { passRate: baseRate, cases: summary(baseResults) },
278
+ candidate: { passRate: candRate, cases: summary(candResults) },
279
+ lineage: { parentInsights: insightIds, scores: { train: candRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
280
+ });
281
+ say(` rejected (cost regression on ${costRegressions.join(', ')})`);
282
+ continue;
283
+ }
284
+ // P0 canary promotion: passing the train+holdout gates earns a
285
+ // CANARY slot (injected into ~20% of sessions, watermarked as
286
+ // experimental), not immediate full-active. The impact loop
287
+ // (bench --impact) compares canary vs control sessions and promotes
288
+ // to active only on evidence - the objective-hacking defense:
289
+ // never trust only the metric the evolution system can see.
290
+ const { archivedPrevious } = await promoteSkill(home, p.name, { canary: true });
291
+ // Holdout re-verification (GDPevo anti-memorization): the gate saw the
292
+ // train cases; holdout cases check the skill generalizes. Regression
293
+ // here rolls the promotion back.
294
+ let holdoutRate = 1;
295
+ if (holdout.length > 0) {
296
+ const holdoutCand = [];
297
+ for (const c of holdout) {
298
+ try {
299
+ // same double-sample rule as the training gate
300
+ const a = await runAndAssert(runCase, c, candidateInjection);
301
+ const b = a.pass ? await runAndAssert(runCase, c, candidateInjection) : { pass: false, output: '' };
302
+ holdoutCand.push({ name: c.name, pass: a.pass && b.pass });
303
+ }
304
+ catch {
305
+ holdoutCand.push({ name: c.name, pass: false });
306
+ }
307
+ }
308
+ holdoutRate = holdoutCand.filter((r) => r.pass).length / holdoutCand.length;
309
+ if (holdoutRate < holdoutBaseRate) {
310
+ // Restore the previous version; when there is none (first-time
311
+ // promotion), demote the new skill back out of active and delete.
312
+ const restored = await rollbackSkill(home, p.name);
313
+ if (!restored) {
314
+ await unpromoteSkill(home, p.name);
315
+ await deleteDraft(home, p.name);
316
+ }
317
+ await recordParetoEntry(home, { name: p.name, parentInsights: insightIds, rejectedReason: `holdout regression (${holdoutRate.toFixed(2)} < ${holdoutBaseRate.toFixed(2)})`, scores: { train: candRate, holdout: holdoutRate }, metaModel: provider.model, at: new Date().toISOString() });
318
+ report.outcomes.push({
319
+ name: p.name,
320
+ action: 'rejected',
321
+ reason: `holdout regression after promotion (train ${candRate} vs ${baseRate}, holdout ${holdoutRate} vs ${holdoutBaseRate}) - rolled back`,
322
+ baseline: { passRate: baseRate, cases: summary(baseResults) },
323
+ candidate: { passRate: candRate, cases: summary(candResults) },
324
+ holdout: { baselineRate: holdoutBaseRate, candidateRate: holdoutRate },
325
+ lineage: { parentInsights: insightIds, scores: { train: candRate, holdout: holdoutRate }, metaModel: provider.model, decidedAt: new Date().toISOString() },
326
+ });
327
+ say(` rolled back (holdout regression: ${(holdoutRate * 100).toFixed(0)}% < ${(holdoutBaseRate * 100).toFixed(0)}%)`);
328
+ continue;
329
+ }
330
+ }
331
+ // A promotion without any holdout case is a WEAK gate (behavior-only
332
+ // signal); surfaced in the log so weakly-gated skills are auditable.
333
+ const weakGate = holdout.length === 0;
334
+ report.outcomes.push({
335
+ name: p.name,
336
+ action: 'promoted',
337
+ reason: `no regression (train ${(candRate * 100).toFixed(0)}% vs ${(baseRate * 100).toFixed(0)}%${holdout.length ? `, holdout ${(holdoutRate * 100).toFixed(0)}%` : ''}; floor ≥${(minPassRate * 100).toFixed(0)}%) - promoted to CANARY (20% sessions, impact-gated full promotion)${archivedPrevious ? '; previous version archived' : ''}${weakGate ? ' [WEAK GATE: no holdout cases defined]' : ''}`,
338
+ baseline: { passRate: baseRate, cases: summary(baseResults) },
339
+ candidate: { passRate: candRate, cases: summary(candResults) },
340
+ ...(holdout.length ? { holdout: { baselineRate: holdoutBaseRate, candidateRate: holdoutRate } } : {}),
341
+ lineage: { parentInsights: insightIds, scores: { train: candRate, holdout: holdout.length ? holdoutRate : undefined }, metaModel: provider.model, decidedAt: new Date().toISOString() },
342
+ });
343
+ say(` promoted to canary${holdout.length ? ` (holdout ${(holdoutRate * 100).toFixed(0)}%)` : ' [weak gate: no holdout]'}`);
344
+ // AWM workflows are versioned artifacts: a promoted *-workflow skill is
345
+ // ALSO recorded under evolution/workflows/<name>.json - the readiness
346
+ // "version-provenance" condition scans that dir (it was permanently
347
+ // empty before this, making condition 5 structurally unmeetable).
348
+ if (/workflow$/.test(p.name)) {
349
+ try {
350
+ const wfDir = join(home, 'evolution', 'workflows');
351
+ await mkdir(wfDir, { recursive: true });
352
+ await writeFile(join(wfDir, `${p.name}.json`), JSON.stringify({
353
+ name: p.name,
354
+ version: candRate.toFixed(2),
355
+ skillMd: p.skill_md,
356
+ promotedAt: new Date().toISOString(),
357
+ trainPassRate: candRate,
358
+ }, null, 2) + '\n', 'utf8');
359
+ }
360
+ catch { /* versioning is best-effort bookkeeping */ }
361
+ }
362
+ }
363
+ catch (err) {
364
+ report.outcomes.push({ name: p.name, action: 'error', reason: String(err).slice(0, 200) });
365
+ say(` error: ${String(err).slice(0, 120)}`);
366
+ }
367
+ }
368
+ // 6. CODE-LEVEL evolution: propose + sandbox-bench + merge/revert patches.
369
+ // This is the DGM bridge - the agent can now modify its own tool code,
370
+ // gated by the same bench discipline as skill promotion.
371
+ // OFF BY DEFAULT (DGM's own paper calls self-modifying systems "unsafe by
372
+ // default"; AlphaEvolve evolves external programs in isolation, never the
373
+ // live repo). Opt in with evolution.autoPatch=true in config.json - and
374
+ // even then it only ever touches the repo the evolution runs in, with
375
+ // human-reviewed sandbox benches before any merge.
376
+ const cfg = await loadConfig();
377
+ if (cfg.evolution?.autoPatch !== true) {
378
+ if (insights.length > 0 || notes.length > 0) {
379
+ say(' code-evolution: off (set evolution.autoPatch=true in config.json to enable)');
380
+ }
381
+ }
382
+ else {
383
+ try {
384
+ const { proposePatches, runPatchSandbox } = await import("./patches.js");
385
+ const repoRoot = process.cwd();
386
+ const patches = await proposePatches(provider, signals, readFile, repoRoot, say);
387
+ if (patches.length > 0) {
388
+ report.codePatches = patches.map((p) => ({ name: p.name, file: p.file, reason: p.reason }));
389
+ for (const patch of patches.slice(0, 1)) { // at most 1 patch per cycle
390
+ const outcome = await runPatchSandbox({
391
+ repoRoot,
392
+ patch,
393
+ baselineRate: baseRate,
394
+ runCase: async (c) => runCase(c, skillsToPrompt(active)),
395
+ benchCases: train,
396
+ log: say,
397
+ });
398
+ report.patchOutcomes = report.patchOutcomes ?? [];
399
+ report.patchOutcomes.push(outcome);
400
+ say(` code-patch ${outcome.action}: ${outcome.reason}`);
401
+ }
402
+ }
403
+ }
404
+ catch (err) {
405
+ say(` code-evolution skipped: ${String(err).slice(0, 120)}`);
406
+ }
407
+ }
408
+ // 7. Append-only memory distillation.
409
+ if (notes.length >= 4) {
410
+ const distilled = await distillMemory(provider, notes.map((n) => n.text).slice(-20), say);
411
+ const distillPoison = distilled ? screenForPoison(distilled) : null;
412
+ if (distillPoison) {
413
+ say(`memory distillation rejected by poisoning screen (${distillPoison})`);
414
+ }
415
+ else if (distilled) {
416
+ await appendMemory(home, `(distilled) ${distilled}`);
417
+ report.memoryDistilled = distilled;
418
+ }
419
+ }
420
+ // 7.5. P0 impact loop: graduate/retire canaries on evidence; P1 decay:
421
+ // quiet skills leave the injection set (never deleted). Both are
422
+ // best-effort - measurement must never fail the cycle.
423
+ try {
424
+ report.budget = { cyclesToday: budget.cyclesToday, maxCyclesPerDay: budget.maxCyclesPerDay };
425
+ const impact = await impactReport(home);
426
+ if (impact.rows.length > 0) {
427
+ report.impact = {
428
+ rows: impact.rows.map((r) => ({ skill: r.skill, exposed: `${r.exposed.sessions}s/${(r.exposed.okRate * 100).toFixed(0)}%`, control: `${r.control.sessions}s/${(r.control.okRate * 100).toFixed(0)}%`, verdict: r.verdict })),
429
+ applied: impact.applied,
430
+ };
431
+ for (const a of impact.applied)
432
+ say(` impact: ${a}`);
433
+ }
434
+ const decayed = await decayUnusedSkills(home);
435
+ if (decayed.length) {
436
+ report.decayed = decayed;
437
+ say(` decayed to dormant: ${decayed.join(', ')}`);
438
+ }
439
+ }
440
+ catch (err) {
441
+ say(` impact/decay skipped: ${String(err).slice(0, 100)}`);
442
+ }
443
+ // 7. Durable evolution log. estTokens feeds the daily token budget gate:
444
+ // a chars/4 estimate of this cycle's meta-call traffic (proposals + bench
445
+ // outputs + distilled notes) - coarse on purpose, the gate only needs an
446
+ // order of magnitude to stop unattended spend.
447
+ const estTokensSpent = (proposals ?? []).reduce((n, p) => n + estTokens(p.skill_md ?? '') + estTokens(p.description ?? ''), 0)
448
+ + (report.memoryDistilled ? estTokens(report.memoryDistilled) : 0)
449
+ + (report.patchOutcomes ?? []).length * 4_000;
450
+ report.estTokens = estTokensSpent;
451
+ const logDir = join(home, 'evolution');
452
+ await mkdir(logDir, { recursive: true });
453
+ await appendFile(join(logDir, 'log.jsonl'), JSON.stringify(report) + '\n', 'utf8');
454
+ return report;
455
+ }
456
+ async function proposeSkills(provider, signals, say, ancestor, mergeWith) {
457
+ const system = [
458
+ 'You are the evolution module of hmharness, a self-evolving agent framework for HarmonyOS development.',
459
+ 'Your job: read session signals and decide whether any repeatable procedure is worth crystallizing into a skill.',
460
+ 'A skill is a markdown how-to document the agent reads on demand. Topics must be limited to: HarmonyOS toolchain usage (hdc/hvigorw/ohpm/DevEco), this framework\'s tools (list_dir/read_file/write_file/run_command/remember/harmony_*), and reusable task workflows observed in the signals.',
461
+ 'Rules: name is kebab-case; description is one line; skill_md is at most 60 lines with concrete steps and example commands; do NOT propose skills about security config, approval policy, or anything outside the topics; if nothing is genuinely reusable, return an empty array.',
462
+ 'Respond with ONLY a JSON array: [{"name":"...","description":"...","skill_md":"..."}] - no prose, no code fences.',
463
+ ].join('\n');
464
+ // GEPA ancestor context: vary around a past rejection (its reason is the
465
+ // lesson), optionally crossing with a complementary one - steady-state
466
+ // genetic sampling instead of always restarting from scratch.
467
+ let lineage = '';
468
+ if (ancestor) {
469
+ lineage = `\nPast rejected candidate (sampled from the archive - learn from why it failed, propose a VARIATION that fixes it):\nname: ${ancestor.name}\nrejected because: ${ancestor.rejectedReason ?? 'unknown'}\n${ancestor.skillMd ? `its content (first 1500 chars):\n${ancestor.skillMd.slice(0, 1500)}\n` : ''}`;
470
+ if (mergeWith) {
471
+ lineage += `\nA second rejected candidate with a DIFFERENT failure mode - consider merging their complementary angles:\nname: ${mergeWith.name}\nrejected because: ${mergeWith.rejectedReason ?? 'unknown'}\n${mergeWith.skillMd ? `content (first 1000 chars):\n${mergeWith.skillMd.slice(0, 1000)}\n` : ''}`;
472
+ }
473
+ }
474
+ const user = `Session signals:\n${JSON.stringify(signals, null, 2)}${lineage}\n\nIf signals.ecosystemNews mentions recent OpenHarmony releases, prefer proposals that account for them over stale toolchain advice.\n\nPropose at most 2 skills (or []).`;
475
+ const r = await chat(provider, [
476
+ { role: 'system', content: system },
477
+ { role: 'user', content: user },
478
+ ]);
479
+ const raw = r.message.content ?? '[]';
480
+ const parsed = parseJsonArray(raw);
481
+ const out = [];
482
+ for (const item of parsed) {
483
+ const o = item;
484
+ if (typeof o.name === 'string' && typeof o.skill_md === 'string' && o.name && o.skill_md) {
485
+ out.push({ name: o.name, description: typeof o.description === 'string' ? o.description : '', skill_md: o.skill_md });
486
+ }
487
+ }
488
+ say(`proposals: ${out.length ? out.map((p) => p.name).join(', ') : '(none)'}${ancestor ? ` (ancestor: ${ancestor.name})` : ''}`);
489
+ return out;
490
+ }
491
+ async function distillMemory(provider, recentNotes, say) {
492
+ const system = [
493
+ 'You distill long-term memory notes for an agent. Given recent notes, write ONE new note (max 200 chars, Chinese or English matching the notes) that captures a repeated lesson or stable fact not yet obvious from any single note.',
494
+ 'If the notes are too scattered to synthesize anything genuinely new, output exactly: NONE',
495
+ 'Output only the note text, nothing else.',
496
+ ].join('\n');
497
+ try {
498
+ const r = await chat(provider, [
499
+ { role: 'system', content: system },
500
+ { role: 'user', content: recentNotes.join('\n') },
501
+ ]);
502
+ const text = (r.message.content ?? '').trim();
503
+ if (!text || text === 'NONE' || text.length > 300) {
504
+ say('memory distillation: none');
505
+ return null;
506
+ }
507
+ say(`memory distillation: ${text.slice(0, 80)}`);
508
+ return text;
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
514
+ /** First balanced JSON array in the text; tolerates fences and prose around it. */
515
+ function parseJsonArray(text) {
516
+ const cleaned = text.replace(/```(?:json)?/g, '');
517
+ const start = cleaned.indexOf('[');
518
+ if (start < 0)
519
+ return [];
520
+ let depth = 0;
521
+ let inStr = false;
522
+ let esc = false;
523
+ for (let i = start; i < cleaned.length; i++) {
524
+ const ch = cleaned[i];
525
+ if (esc) {
526
+ esc = false;
527
+ continue;
528
+ }
529
+ if (ch === '\\') {
530
+ esc = true;
531
+ continue;
532
+ }
533
+ if (ch === '"')
534
+ inStr = !inStr;
535
+ if (inStr)
536
+ continue;
537
+ if (ch === '[' || ch === '{')
538
+ depth++;
539
+ else if (ch === ']' || ch === '}') {
540
+ depth--;
541
+ if (depth === 0 && ch === ']') {
542
+ try {
543
+ return JSON.parse(cleaned.slice(start, i + 1));
544
+ }
545
+ catch {
546
+ return [];
547
+ }
548
+ }
549
+ }
550
+ }
551
+ return [];
552
+ }
553
+ /**
554
+ * Write-channel poisoning screen. Conservative pattern set; a hit rejects
555
+ * the draft outright (the bench cannot see instructions the model ignores).
556
+ * Deliberately窄: catches suppression/renaming/bypass directives, not
557
+ * ordinary technical content.
558
+ */
559
+ const POISON_PATTERNS = [
560
+ { re: /(不要|别|never|don'?t)\s*(调用|使用|执行|call|use|run)[^。\n]{0,20}(工具|tool)/i, why: 'suppresses tool usage' },
561
+ { re: /(凭记忆|from memory|without (calling|using))[^。\n]{0,20}(回答|工具|tool)/i, why: 'answers from memory instead of tools' },
562
+ { re: /(一律|always|永远|never)[^。\n]{0,30}(写成|写作|rename|称作|call it)\s*["'"]?[a-z_]+["'"]?/i, why: 'renames a toolchain identifier' },
563
+ { re: /(跳过|绕过|忽略|bypass|skip|ignore)[^。\n]{0,20}(审批|批准|门禁|approval|confirm|guard|deny)/i, why: 'attempts to bypass the approval gate' },
564
+ { re: /(自动批准|auto[- ]?approve|always approve)/i, why: 'attempts to force auto-approval' },
565
+ { re: /(删除|disable|remove)[^。\n]{0,20}(护栏|防护|safety|deny|guard)/i, why: 'attempts to disable safety guards' },
566
+ ];
567
+ export function screenForPoison(text) {
568
+ for (const p of POISON_PATTERNS) {
569
+ if (p.re.test(text))
570
+ return p.why;
571
+ }
572
+ return null;
573
+ }
@@ -0,0 +1,77 @@
1
+ export interface EvolutionBudget {
2
+ maxCyclesPerDay?: number;
3
+ maxTokensPerCycle?: number;
4
+ /** summed estTokens of today's logged cycles (filled by readBudget) */
5
+ tokensToday?: number;
6
+ }
7
+ /** How many evolve cycles already ran today (counts the log.jsonl).
8
+ * Accepts BOTH key spellings: the code's maxCyclesPerDay/maxTokensPerCycle
9
+ * and the documented-in-SELFFEED.md cyclesPerDay/tokensPerCycle - the docs
10
+ * shipped with the short names, so users who configured by the book were
11
+ * silently unlimited. */
12
+ export declare function readBudget(home: string): Promise<EvolutionBudget & {
13
+ cyclesToday: number;
14
+ }>;
15
+ export interface ParetoEntry {
16
+ name: string;
17
+ parentInsights: string[];
18
+ rejectedReason?: string;
19
+ scores: {
20
+ train: number;
21
+ holdout?: number;
22
+ };
23
+ metaModel: string;
24
+ at: string;
25
+ /** skill_md snapshot for merge-crossing and ancestor re-proposal */
26
+ skillMd?: string;
27
+ }
28
+ /** Rejected proposals are never garbage - they are the population's
29
+ * diversity (GEPA's Pareto front). Kept under evolution/pareto/. */
30
+ export declare function recordParetoEntry(home: string, entry: ParetoEntry): Promise<void>;
31
+ export declare function readParetoEntries(home: string, limit?: number): Promise<ParetoEntry[]>;
32
+ /** GEPA's ancestor selection: ONE random entry from the pool feeds the
33
+ * next proposal prompt (steady-state genetic loop - the pool exists so
34
+ * evolution does not collapse onto the single global best). Two entries
35
+ * with complementary rejection reasons are returned for a Merge cross. */
36
+ export declare function sampleAncestor(entries: ParetoEntry[]): {
37
+ ancestor: ParetoEntry | null;
38
+ mergeWith: ParetoEntry | null;
39
+ };
40
+ /** Deterministic per-session canary assignment: a session gets the canary
41
+ * block if hash(sessionId) % 100 < 20 - the same session always resolves
42
+ * the same way (stable attribution), different sessions split ~20/80. */
43
+ export declare function sessionGetsCanary(sessionId: string): boolean;
44
+ /** The watermark every canary injection carries (Misevolve's mitigation:
45
+ * experimental knowledge must read as a REFERENCE to weigh, not a rule
46
+ * to obey - memory-as-rules is what decays safety alignment). */
47
+ export declare function canaryWatermark(names: string[]): string;
48
+ export interface ImpactRow {
49
+ skill: string;
50
+ window: 'canary-period';
51
+ exposed: {
52
+ sessions: number;
53
+ okRate: number;
54
+ };
55
+ control: {
56
+ sessions: number;
57
+ okRate: number;
58
+ };
59
+ verdict: 'insufficient-data' | 'keep' | 'promote' | 'retire';
60
+ }
61
+ /**
62
+ * The impact loop: for each canary skill, compare sessions where it was
63
+ * injected (Insight.skillsInjected contains it) against sessions where it
64
+ * was not. Promote on evidence, retire on harm, and say so honestly when
65
+ * the data is too thin (never let a small sample auto-graduate anything).
66
+ * This is the attribution loop that makes "越用越聪明" falsifiable.
67
+ */
68
+ export declare function impactReport(home: string): Promise<{
69
+ rows: ImpactRow[];
70
+ applied: string[];
71
+ }>;
72
+ /** Move active skills with zero injections in 30 days to skills/dormant/
73
+ * - not deleted (append-only red line), just out of the injection set and
74
+ * the prompt budget. The Voyager lesson (reversed): its ever-growing
75
+ * library was a selling point in the paper but is a retrieval-quality
76
+ * debt in production; decay is the missing lifecycle operator. */
77
+ export declare function decayUnusedSkills(home: string, days?: number): Promise<string[]>;