@hmharness/evolution 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/evolve.d.ts CHANGED
@@ -74,6 +74,9 @@ export interface EvolveReport {
74
74
  cyclesToday: number;
75
75
  maxCyclesPerDay?: number;
76
76
  };
77
+ /** chars/4 estimate of this cycle's meta-call traffic; feeds the daily
78
+ * token budget gate (readBudget sums today's entries) */
79
+ estTokens?: number;
77
80
  }
78
81
  /** Runs one bench case with the given skills block injected. */
79
82
  export type CaseRunner = (c: BenchCase, skillsPrompt: string) => Promise<string>;
package/dist/evolve.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { appendFile, mkdir, readFile } from 'node:fs/promises';
17
17
  import { join } from 'node:path';
18
- import { chat } from '@hmharness/kernel';
18
+ import { chat, loadConfig } from '@hmharness/kernel';
19
19
  import { listCases, matchCase, seedCases } from "./bench.js";
20
20
  import { deleteDraft, listCanary, listDrafts, listSkills, promoteSkill, rollbackSkill, skillsToPrompt, unpromoteSkill, writeDraft } from "./skills.js";
21
21
  import { appendMemory, readNotes } from "./memory.js";
@@ -52,14 +52,24 @@ export async function runEvolution(opts) {
52
52
  memoryDistilled: null,
53
53
  };
54
54
  // P0 evolution budget gate (AZR "safety alarms" + cost control): a day's
55
- // cycle count and a per-cycle token ceiling live in config; overspending
56
- // skips the cycle instead of burning money unattended.
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).
57
60
  const budget = await readBudget(home);
58
61
  const today = new Date().toISOString().slice(0, 10);
59
62
  if (budget.maxCyclesPerDay && budget.cyclesToday >= budget.maxCyclesPerDay) {
60
63
  report.outcomes.push({ name: '(budget)', action: 'error', reason: `daily cycle limit reached (${budget.cyclesToday}/${budget.maxCyclesPerDay} today) - skipped` });
61
64
  return report;
62
65
  }
66
+ if (budget.maxCyclesPerDay && budget.maxTokensPerCycle) {
67
+ const dailyCap = budget.maxCyclesPerDay * budget.maxTokensPerCycle;
68
+ if ((budget.tokensToday ?? 0) >= dailyCap) {
69
+ report.outcomes.push({ name: '(budget)', action: 'error', reason: `daily token limit reached (~${budget.tokensToday}/${dailyCap} est-tokens today) - skipped` });
70
+ return report;
71
+ }
72
+ }
63
73
  // 1. Seed bench cases on a fresh home so the gate always has a signal.
64
74
  report.seededCases = await seedCases(home);
65
75
  const cases = await listCases(home);
@@ -297,29 +307,42 @@ export async function runEvolution(opts) {
297
307
  // 6. CODE-LEVEL evolution: propose + sandbox-bench + merge/revert patches.
298
308
  // This is the DGM bridge - the agent can now modify its own tool code,
299
309
  // gated by the same bench discipline as skill promotion.
300
- try {
301
- const { proposePatches, runPatchSandbox } = await import("./patches.js");
302
- const repoRoot = process.cwd();
303
- const patches = await proposePatches(provider, signals, readFile, repoRoot, say);
304
- if (patches.length > 0) {
305
- report.codePatches = patches.map((p) => ({ name: p.name, file: p.file, reason: p.reason }));
306
- for (const patch of patches.slice(0, 1)) { // at most 1 patch per cycle
307
- const outcome = await runPatchSandbox({
308
- repoRoot,
309
- patch,
310
- baselineRate: baseRate,
311
- runCase: async (c) => runCase(c, skillsToPrompt(active)),
312
- benchCases: train,
313
- log: say,
314
- });
315
- report.patchOutcomes = report.patchOutcomes ?? [];
316
- report.patchOutcomes.push(outcome);
317
- say(` code-patch ${outcome.action}: ${outcome.reason}`);
318
- }
310
+ // OFF BY DEFAULT (DGM's own paper calls self-modifying systems "unsafe by
311
+ // default"; AlphaEvolve evolves external programs in isolation, never the
312
+ // live repo). Opt in with evolution.autoPatch=true in config.json - and
313
+ // even then it only ever touches the repo the evolution runs in, with
314
+ // human-reviewed sandbox benches before any merge.
315
+ const cfg = await loadConfig();
316
+ if (cfg.evolution?.autoPatch !== true) {
317
+ if (insights.length > 0 || notes.length > 0) {
318
+ say(' code-evolution: off (set evolution.autoPatch=true in config.json to enable)');
319
319
  }
320
320
  }
321
- catch (err) {
322
- say(` code-evolution skipped: ${String(err).slice(0, 120)}`);
321
+ else {
322
+ try {
323
+ const { proposePatches, runPatchSandbox } = await import("./patches.js");
324
+ const repoRoot = process.cwd();
325
+ const patches = await proposePatches(provider, signals, readFile, repoRoot, say);
326
+ if (patches.length > 0) {
327
+ report.codePatches = patches.map((p) => ({ name: p.name, file: p.file, reason: p.reason }));
328
+ for (const patch of patches.slice(0, 1)) { // at most 1 patch per cycle
329
+ const outcome = await runPatchSandbox({
330
+ repoRoot,
331
+ patch,
332
+ baselineRate: baseRate,
333
+ runCase: async (c) => runCase(c, skillsToPrompt(active)),
334
+ benchCases: train,
335
+ log: say,
336
+ });
337
+ report.patchOutcomes = report.patchOutcomes ?? [];
338
+ report.patchOutcomes.push(outcome);
339
+ say(` code-patch ${outcome.action}: ${outcome.reason}`);
340
+ }
341
+ }
342
+ }
343
+ catch (err) {
344
+ say(` code-evolution skipped: ${String(err).slice(0, 120)}`);
345
+ }
323
346
  }
324
347
  // 7. Append-only memory distillation.
325
348
  if (notes.length >= 4) {
@@ -356,7 +379,14 @@ export async function runEvolution(opts) {
356
379
  catch (err) {
357
380
  say(` impact/decay skipped: ${String(err).slice(0, 100)}`);
358
381
  }
359
- // 7. Durable evolution log.
382
+ // 7. Durable evolution log. estTokens feeds the daily token budget gate:
383
+ // a chars/4 estimate of this cycle's meta-call traffic (proposals + bench
384
+ // outputs + distilled notes) - coarse on purpose, the gate only needs an
385
+ // order of magnitude to stop unattended spend.
386
+ const estTokensSpent = (proposals ?? []).reduce((n, p) => n + estTokens(p.skill_md ?? '') + estTokens(p.description ?? ''), 0)
387
+ + (report.memoryDistilled ? estTokens(report.memoryDistilled) : 0)
388
+ + (report.patchOutcomes ?? []).length * 4_000;
389
+ report.estTokens = estTokensSpent;
360
390
  const logDir = join(home, 'evolution');
361
391
  await mkdir(logDir, { recursive: true });
362
392
  await appendFile(join(logDir, 'log.jsonl'), JSON.stringify(report) + '\n', 'utf8');
package/dist/impact.d.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  export interface EvolutionBudget {
2
2
  maxCyclesPerDay?: number;
3
3
  maxTokensPerCycle?: number;
4
+ /** summed estTokens of today's logged cycles (filled by readBudget) */
5
+ tokensToday?: number;
4
6
  }
5
- /** How many evolve cycles already ran today (counts the log.jsonl). */
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. */
6
12
  export declare function readBudget(home: string): Promise<EvolutionBudget & {
7
13
  cyclesToday: number;
8
14
  }>;
package/dist/impact.js CHANGED
@@ -20,26 +20,40 @@
20
20
  */
21
21
  import { appendFile, mkdir, readFile, rename } from 'node:fs/promises';
22
22
  import { join } from 'node:path';
23
- /** How many evolve cycles already ran today (counts the log.jsonl). */
23
+ /** How many evolve cycles already ran today (counts the log.jsonl).
24
+ * Accepts BOTH key spellings: the code's maxCyclesPerDay/maxTokensPerCycle
25
+ * and the documented-in-SELFFEED.md cyclesPerDay/tokensPerCycle - the docs
26
+ * shipped with the short names, so users who configured by the book were
27
+ * silently unlimited. */
24
28
  export async function readBudget(home) {
25
29
  const budget = { cyclesToday: 0 };
26
30
  try {
27
31
  const cfg = JSON.parse(await readFile(join(home, 'config.json'), 'utf8'));
28
- budget.maxCyclesPerDay = cfg.evolutionBudget?.maxCyclesPerDay;
29
- budget.maxTokensPerCycle = cfg.evolutionBudget?.maxTokensPerCycle;
32
+ budget.maxCyclesPerDay = cfg.evolutionBudget?.maxCyclesPerDay ?? cfg.evolutionBudget?.cyclesPerDay;
33
+ budget.maxTokensPerCycle = cfg.evolutionBudget?.maxTokensPerCycle ?? cfg.evolutionBudget?.tokensPerCycle;
30
34
  }
31
35
  catch { /* no config / no budget - unlimited */ }
32
36
  const today = new Date().toISOString().slice(0, 10);
33
37
  try {
34
38
  const text = await readFile(join(home, 'evolution', 'log.jsonl'), 'utf8');
35
- budget.cyclesToday = text.trim().split('\n')
39
+ const todays = text.trim().split('\n')
36
40
  .filter((l) => { try {
37
41
  return JSON.parse(l).time.startsWith(today);
38
42
  }
39
43
  catch {
40
44
  return false;
41
- } })
42
- .length;
45
+ } });
46
+ budget.cyclesToday = todays.length;
47
+ // token budget uses each cycle's logged estTokens (chars/4 estimate; the
48
+ // precise per-call usage is not threaded through the meta-call helpers)
49
+ budget.tokensToday = todays.reduce((n, l) => {
50
+ try {
51
+ return n + (JSON.parse(l).estTokens ?? 0);
52
+ }
53
+ catch {
54
+ return n;
55
+ }
56
+ }, 0);
43
57
  }
44
58
  catch { /* no log yet */ }
45
59
  return budget;
package/dist/patches.d.ts CHANGED
@@ -40,7 +40,11 @@ export declare function isPatchableFile(file: string): boolean;
40
40
  /** Validate + apply a patch to a file on disk. Returns error if find-string
41
41
  * is not found or not unique. */
42
42
  export declare function applyPatch(repoRoot: string, patch: CodePatch): Promise<string>;
43
- /** Create a sandbox git branch for testing a patch. */
43
+ /** Create a sandbox git branch for testing a patch.
44
+ * Precondition: the working tree is CLEAN (runPatchSandbox enforces this).
45
+ * The old version stashed uncommitted user work here and never popped it -
46
+ * a failed cycle silently swallowed the user's changes into a stash. Now a
47
+ * dirty tree refuses the cycle outright instead of hiding the work. */
44
48
  export declare function createSandbox(repoRoot: string, name: string): Promise<string>;
45
49
  /** Commit the patch on the sandbox branch so it is fully isolated from main. */
46
50
  export declare function commitOnSandbox(repoRoot: string, patchName: string): Promise<void>;
@@ -48,7 +52,11 @@ export declare function commitOnSandbox(repoRoot: string, patchName: string): Pr
48
52
  export declare function mergeSandbox(repoRoot: string, branch: string): Promise<void>;
49
53
  /** Run the bench gate on the current branch. Returns pass rate (0-1) or -1 on build failure. */
50
54
  export declare function sandboxBench(repoRoot: string, runCase: (c: import('./bench.ts').BenchCase, skillsPrompt: string) => Promise<string>, cases: import('./bench.ts').BenchCase[]): Promise<number>;
51
- /** Revert: go back to main, delete the sandbox branch (zero residue). */
55
+ /** Revert: go back to main, delete the sandbox branch (zero residue).
56
+ * No `reset --hard` anymore: the tree is guaranteed clean at entry (clean-
57
+ * tree precondition) and the patch is committed on the sandbox branch, so
58
+ * there is nothing to hard-reset - and a hard reset on main is exactly the
59
+ * operation that can destroy a user's uncommitted work. */
52
60
  export declare function revertSandbox(repoRoot: string, branch: string): Promise<void>;
53
61
  /**
54
62
  * Full sandbox cycle: apply patch on a branch, rebuild, bench, merge or revert.
package/dist/patches.js CHANGED
@@ -82,11 +82,13 @@ export async function applyPatch(repoRoot, patch) {
82
82
  await writeFile(filePath, patched, 'utf8');
83
83
  return 'applied';
84
84
  }
85
- /** Create a sandbox git branch for testing a patch. */
85
+ /** Create a sandbox git branch for testing a patch.
86
+ * Precondition: the working tree is CLEAN (runPatchSandbox enforces this).
87
+ * The old version stashed uncommitted user work here and never popped it -
88
+ * a failed cycle silently swallowed the user's changes into a stash. Now a
89
+ * dirty tree refuses the cycle outright instead of hiding the work. */
86
90
  export async function createSandbox(repoRoot, name) {
87
91
  const branch = `evolve/${name}-${Date.now().toString(36)}`;
88
- // stash any stray working-tree changes first so the branch starts clean
89
- await git(['stash', '--include-untracked'], { cwd: repoRoot, timeout: 10_000 }).catch(() => undefined);
90
92
  await git(['checkout', '-b', branch], { cwd: repoRoot, timeout: 10_000 });
91
93
  return branch;
92
94
  }
@@ -128,11 +130,13 @@ export async function sandboxBench(repoRoot, runCase, cases) {
128
130
  }
129
131
  return cases.length > 0 ? pass / cases.length : -1;
130
132
  }
131
- /** Revert: go back to main, delete the sandbox branch (zero residue). */
133
+ /** Revert: go back to main, delete the sandbox branch (zero residue).
134
+ * No `reset --hard` anymore: the tree is guaranteed clean at entry (clean-
135
+ * tree precondition) and the patch is committed on the sandbox branch, so
136
+ * there is nothing to hard-reset - and a hard reset on main is exactly the
137
+ * operation that can destroy a user's uncommitted work. */
132
138
  export async function revertSandbox(repoRoot, branch) {
133
139
  await git(['checkout', 'main'], { cwd: repoRoot, timeout: 10_000 });
134
- // discard any uncommitted changes on the sandbox branch
135
- await git(['reset', '--hard', 'HEAD'], { cwd: repoRoot, timeout: 5000 });
136
140
  await git(['branch', '-D', branch], { cwd: repoRoot, timeout: 5000 });
137
141
  }
138
142
  /**
@@ -142,6 +146,17 @@ export async function revertSandbox(repoRoot, branch) {
142
146
  export async function runPatchSandbox(opts) {
143
147
  const say = opts.log ?? (() => undefined);
144
148
  const { patch } = opts;
149
+ // clean-tree precondition: a dirty working tree means a human is mid-work
150
+ // in this repo - refuse rather than stash/reset around their changes
151
+ try {
152
+ const { stdout } = await git(['status', '--porcelain'], { cwd: opts.repoRoot, timeout: 10_000 });
153
+ if (stdout.trim()) {
154
+ return { name: patch.name, action: 'error', reason: 'working tree not clean - refusing to run a patch cycle in a repo with uncommitted changes (commit or stash them first)', branch: '' };
155
+ }
156
+ }
157
+ catch (err) {
158
+ return { name: patch.name, action: 'error', reason: `git status failed: ${String(err).slice(0, 120)}`, branch: '' };
159
+ }
145
160
  say(` code-patch "${patch.name}": creating sandbox branch`);
146
161
  let branch = '';
147
162
  try {
@@ -191,7 +206,7 @@ export async function proposePatches(provider, signals, readFileFn, repoRoot, sa
191
206
  web_search: 'packages/agent/src/tools.ts',
192
207
  web_fetch: 'packages/agent/src/tools.ts',
193
208
  harmony_build: 'packages/domain-harmony/src/index.ts',
194
- harmony_devices: 'packages/domain-harmony/src/devices.ts',
209
+ harmony_devices: 'packages/domain-harmony/src/index.ts',
195
210
  // add more as insights reveal hot paths
196
211
  };
197
212
  const toolsUsed = (signals.toolUsage ?? {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/evolution",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "hmharness evolution subsystem: persistent memory, insight capture, skill library, and the bench that gives evolution its fitness signal. First-class kernel citizen, not a plugin.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "build": "tsc -p tsconfig.build.json"
16
16
  },
17
17
  "dependencies": {
18
- "@hmharness/kernel": "0.6.6"
18
+ "@hmharness/kernel": "0.6.8"
19
19
  },
20
20
  "files": [
21
21
  "dist"