@hmharness/evolution 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bench.d.ts +38 -0
- package/dist/bench.js +130 -0
- package/dist/evolve.d.ts +89 -0
- package/dist/evolve.js +479 -0
- package/dist/impact.d.ts +71 -0
- package/dist/impact.js +200 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/insights.d.ts +17 -0
- package/dist/insights.js +59 -0
- package/dist/knowledge.d.ts +15 -0
- package/dist/knowledge.js +139 -0
- package/dist/memory.d.ts +21 -0
- package/dist/memory.js +98 -0
- package/dist/patches.d.ts +75 -0
- package/dist/patches.d.ts.bad-1788466569598 +75 -0
- package/dist/patches.js +238 -0
- package/dist/patches.js.bad-1788466569598 +238 -0
- package/dist/radar.d.ts +3 -0
- package/dist/radar.js +40 -0
- package/dist/skills.d.ts +54 -0
- package/dist/skills.js +321 -0
- package/dist/workflows.d.ts +28 -0
- package/dist/workflows.js +84 -0
- package/package.json +31 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmh/evolution - patches
|
|
3
|
+
* CODE-LEVEL self-evolution: the evolution loop can now propose source-code
|
|
4
|
+
* patches (not just prompt-level skills), test them on an isolated git
|
|
5
|
+
* branch, and merge or revert based on the benchmark gate.
|
|
6
|
+
*
|
|
7
|
+
* The Darwin Gödel Machine insight: an agent that can only take notes
|
|
8
|
+
* (skills/memory) learns WHAT to do; an agent that can patch its own code
|
|
9
|
+
* learns WHAT IT CAN DO. This module is the bridge.
|
|
10
|
+
*
|
|
11
|
+
* Safety model (same shape as the skill gate, extended to code):
|
|
12
|
+
* 1. patches may only modify files under packages/.../src/ (never config,
|
|
13
|
+
* never security code, never the kernel loop itself)
|
|
14
|
+
* 2. every patch runs on a git branch (sandbox), never on main
|
|
15
|
+
* 3. the bench gate must show no regression (same double-sample rule)
|
|
16
|
+
* 4. revert = git checkout main + branch delete (zero residue)
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
/** A proposed code change: find-and-replace in one source file. */
|
|
20
|
+
export interface CodePatch {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
/** relative path from repo root, must match packages/.../src/.../*.ts */
|
|
24
|
+
file: string;
|
|
25
|
+
/** exact string to find in the file (must be unique) */
|
|
26
|
+
find: string;
|
|
27
|
+
/** replacement string */
|
|
28
|
+
replace: string;
|
|
29
|
+
/** why this change helps (for the audit log) */
|
|
30
|
+
reason: string;
|
|
31
|
+
}
|
|
32
|
+
export interface PatchOutcome {
|
|
33
|
+
name: string;
|
|
34
|
+
action: 'merged' | 'reverted' | 'error';
|
|
35
|
+
reason: string;
|
|
36
|
+
branch?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Guard: only source files inside packages, never kernel internals. */
|
|
39
|
+
export declare function isPatchableFile(file: string): boolean;
|
|
40
|
+
/** Validate + apply a patch to a file on disk. Returns error if find-string
|
|
41
|
+
* is not found or not unique. */
|
|
42
|
+
export declare function applyPatch(repoRoot: string, patch: CodePatch): Promise<string>;
|
|
43
|
+
/** Create a sandbox git branch for testing a patch. */
|
|
44
|
+
export declare function createSandbox(repoRoot: string, name: string): Promise<string>;
|
|
45
|
+
/** Commit the patch on the sandbox branch so it is fully isolated from main. */
|
|
46
|
+
export declare function commitOnSandbox(repoRoot: string, patchName: string): Promise<void>;
|
|
47
|
+
/** Merge the sandbox branch back to main (patch promoted). */
|
|
48
|
+
export declare function mergeSandbox(repoRoot: string, branch: string): Promise<void>;
|
|
49
|
+
/** Run the bench gate on the current branch. Returns pass rate (0-1) or -1 on build failure. */
|
|
50
|
+
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). */
|
|
52
|
+
export declare function revertSandbox(repoRoot: string, branch: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Full sandbox cycle: apply patch on a branch, rebuild, bench, merge or revert.
|
|
55
|
+
* This is the CODE-LEVEL equivalent of the skill A/B gate.
|
|
56
|
+
*/
|
|
57
|
+
export declare function runPatchSandbox(opts: {
|
|
58
|
+
repoRoot: string;
|
|
59
|
+
patch: CodePatch;
|
|
60
|
+
baselineRate: number;
|
|
61
|
+
runCase: (c: import('./bench.ts').BenchCase, skillsPrompt: string) => Promise<string>;
|
|
62
|
+
benchCases: import('./bench.ts').BenchCase[];
|
|
63
|
+
log?: (line: string) => void;
|
|
64
|
+
}): Promise<PatchOutcome>;
|
|
65
|
+
/**
|
|
66
|
+
* Meta-model call: propose code patches from session signals.
|
|
67
|
+
* The model sees the signals + the target file's current source and
|
|
68
|
+
* outputs find/replace pairs. Constrained to optimization prompts:
|
|
69
|
+
* fix a bug, speed up a hot path, improve error messages.
|
|
70
|
+
*/
|
|
71
|
+
export declare function proposePatches(provider: {
|
|
72
|
+
baseUrl: string;
|
|
73
|
+
apiKey: string;
|
|
74
|
+
model: string;
|
|
75
|
+
}, signals: Record<string, unknown>, readFileFn: typeof readFile, repoRoot: string, say: (l: string) => void): Promise<CodePatch[]>;
|
package/dist/patches.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - patches
|
|
3
|
+
* CODE-LEVEL self-evolution: the evolution loop can now propose source-code
|
|
4
|
+
* patches (not just prompt-level skills), test them on an isolated git
|
|
5
|
+
* branch, and merge or revert based on the benchmark gate.
|
|
6
|
+
*
|
|
7
|
+
* The Darwin Gödel Machine insight: an agent that can only take notes
|
|
8
|
+
* (skills/memory) learns WHAT to do; an agent that can patch its own code
|
|
9
|
+
* learns WHAT IT CAN DO. This module is the bridge.
|
|
10
|
+
*
|
|
11
|
+
* Safety model (same shape as the skill gate, extended to code):
|
|
12
|
+
* 1. patches may only modify files under packages/.../src/ (never config,
|
|
13
|
+
* never security code, never the kernel loop itself)
|
|
14
|
+
* 2. every patch runs on a git branch (sandbox), never on main
|
|
15
|
+
* 3. the bench gate must show no regression (same double-sample rule)
|
|
16
|
+
* 4. revert = git checkout main + branch delete (zero residue)
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
const execCb = promisify(execFile);
|
|
23
|
+
/** git executable: PATH first, then known Windows locations (this repo's
|
|
24
|
+
* dev machine has git only outside the test-process PATH). */
|
|
25
|
+
async function git(args, opts) {
|
|
26
|
+
const { homedir } = await import('node:os');
|
|
27
|
+
// .cmd shims can't execFile-spawn on modern Node (EINVAL) - real .exe only
|
|
28
|
+
const candidates = [
|
|
29
|
+
'git',
|
|
30
|
+
'C:\\Program Files\\Git\\cmd\\git.exe',
|
|
31
|
+
'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
|
|
32
|
+
'C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise\\Common7\\IDE\\CommonExtensions\\Microsoft\\TeamFoundation\\Team Explorer\\Git\\cmd\\git.exe',
|
|
33
|
+
];
|
|
34
|
+
let lastErr;
|
|
35
|
+
for (const exe of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
return await execCb(exe, args, { ...opts, windowsHide: true });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
lastErr = err;
|
|
41
|
+
const msg = String(err);
|
|
42
|
+
if (!/ENOENT|not found|EINVAL/i.test(msg))
|
|
43
|
+
throw err; // real failure, not missing-exe
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw lastErr;
|
|
47
|
+
}
|
|
48
|
+
/** Guard: only source files inside packages, never kernel internals. */
|
|
49
|
+
export function isPatchableFile(file) {
|
|
50
|
+
if (!/^packages\/[a-z-]+\/src\/[a-zA-Z0-9_/.-]+\.ts$/.test(file))
|
|
51
|
+
return false;
|
|
52
|
+
// never allow patching the kernel loop itself (bootstrapping paradox)
|
|
53
|
+
if (file.startsWith('packages/kernel/src/loop'))
|
|
54
|
+
return false;
|
|
55
|
+
if (file.startsWith('packages/kernel/src/provider'))
|
|
56
|
+
return false;
|
|
57
|
+
// never config or security
|
|
58
|
+
if (/config|security|mcp/.test(file))
|
|
59
|
+
return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/** Validate + apply a patch to a file on disk. Returns error if find-string
|
|
63
|
+
* is not found or not unique. */
|
|
64
|
+
export async function applyPatch(repoRoot, patch) {
|
|
65
|
+
if (!isPatchableFile(patch.file)) {
|
|
66
|
+
return `refused: ${patch.file} is not a patchable source file (must be packages/*/src/*.ts, not kernel loop/provider/config)`;
|
|
67
|
+
}
|
|
68
|
+
const filePath = join(repoRoot, patch.file);
|
|
69
|
+
let content;
|
|
70
|
+
try {
|
|
71
|
+
content = await readFile(filePath, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return `file not found: ${patch.file}`;
|
|
75
|
+
}
|
|
76
|
+
const count = content.split(patch.find).length - 1;
|
|
77
|
+
if (count === 0)
|
|
78
|
+
return `find-string not found in ${patch.file}`;
|
|
79
|
+
if (count > 1)
|
|
80
|
+
return `find-string appears ${count}x in ${patch.file} (must be unique)`;
|
|
81
|
+
const patched = content.replace(patch.find, patch.replace);
|
|
82
|
+
await writeFile(filePath, patched, 'utf8');
|
|
83
|
+
return 'applied';
|
|
84
|
+
}
|
|
85
|
+
/** Create a sandbox git branch for testing a patch. */
|
|
86
|
+
export async function createSandbox(repoRoot, name) {
|
|
87
|
+
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
|
+
await git(['checkout', '-b', branch], { cwd: repoRoot, timeout: 10_000 });
|
|
91
|
+
return branch;
|
|
92
|
+
}
|
|
93
|
+
/** Commit the patch on the sandbox branch so it is fully isolated from main. */
|
|
94
|
+
export async function commitOnSandbox(repoRoot, patchName) {
|
|
95
|
+
await git(['add', '-A'], { cwd: repoRoot, timeout: 10_000 });
|
|
96
|
+
await git(['commit', '-m', `evolve(code-patch): ${patchName}`, '--no-verify'], { cwd: repoRoot, timeout: 15_000 });
|
|
97
|
+
}
|
|
98
|
+
/** Merge the sandbox branch back to main (patch promoted). */
|
|
99
|
+
export async function mergeSandbox(repoRoot, branch) {
|
|
100
|
+
await git(['checkout', 'main'], { cwd: repoRoot, timeout: 10_000 });
|
|
101
|
+
await git(['merge', '--no-edit', branch], { cwd: repoRoot, timeout: 15_000 });
|
|
102
|
+
await git(['branch', '-D', branch], { cwd: repoRoot, timeout: 5000 });
|
|
103
|
+
}
|
|
104
|
+
/** Run the bench gate on the current branch. Returns pass rate (0-1) or -1 on build failure. */
|
|
105
|
+
export async function sandboxBench(repoRoot, runCase, cases) {
|
|
106
|
+
// rebuild all packages (the patch may affect any layer)
|
|
107
|
+
try {
|
|
108
|
+
await execCb('npm', ['run', 'build'], { cwd: repoRoot, timeout: 120_000, windowsHide: true });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return -1; // build failed = automatic reject
|
|
112
|
+
}
|
|
113
|
+
// double-sample rule: each case must pass BOTH runs
|
|
114
|
+
let pass = 0;
|
|
115
|
+
for (const c of cases) {
|
|
116
|
+
let ok = true;
|
|
117
|
+
for (let i = 0; i < 2 && ok; i++) {
|
|
118
|
+
try {
|
|
119
|
+
const out = await runCase(c, '');
|
|
120
|
+
ok = c.expect.every((e) => out.toLowerCase().includes(e.toLowerCase()));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
ok = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (ok)
|
|
127
|
+
pass++;
|
|
128
|
+
}
|
|
129
|
+
return cases.length > 0 ? pass / cases.length : -1;
|
|
130
|
+
}
|
|
131
|
+
/** Revert: go back to main, delete the sandbox branch (zero residue). */
|
|
132
|
+
export async function revertSandbox(repoRoot, branch) {
|
|
133
|
+
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
|
+
await git(['branch', '-D', branch], { cwd: repoRoot, timeout: 5000 });
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Full sandbox cycle: apply patch on a branch, rebuild, bench, merge or revert.
|
|
140
|
+
* This is the CODE-LEVEL equivalent of the skill A/B gate.
|
|
141
|
+
*/
|
|
142
|
+
export async function runPatchSandbox(opts) {
|
|
143
|
+
const say = opts.log ?? (() => undefined);
|
|
144
|
+
const { patch } = opts;
|
|
145
|
+
say(` code-patch "${patch.name}": creating sandbox branch`);
|
|
146
|
+
let branch = '';
|
|
147
|
+
try {
|
|
148
|
+
branch = await createSandbox(opts.repoRoot, patch.name);
|
|
149
|
+
const applyResult = await applyPatch(opts.repoRoot, patch);
|
|
150
|
+
if (applyResult !== 'applied') {
|
|
151
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
152
|
+
return { name: patch.name, action: 'error', reason: applyResult, branch };
|
|
153
|
+
}
|
|
154
|
+
// commit on the branch BEFORE benching: the patch must be fully isolated
|
|
155
|
+
// so a later checkout of main can never carry uncommitted patch changes
|
|
156
|
+
await commitOnSandbox(opts.repoRoot, patch.name);
|
|
157
|
+
say(` code-patch "${patch.name}": applied + committed on branch, rebuilding + benching`);
|
|
158
|
+
const rate = await sandboxBench(opts.repoRoot, opts.runCase, opts.benchCases);
|
|
159
|
+
if (rate < 0) {
|
|
160
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
161
|
+
return { name: patch.name, action: 'reverted', reason: 'build failed in sandbox', branch };
|
|
162
|
+
}
|
|
163
|
+
if (rate < opts.baselineRate) {
|
|
164
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
165
|
+
return { name: patch.name, action: 'reverted', reason: `bench ${rate} < baseline ${opts.baselineRate}`, branch };
|
|
166
|
+
}
|
|
167
|
+
say(` code-patch "${patch.name}": bench ${rate} >= baseline ${opts.baselineRate}, merging`);
|
|
168
|
+
await mergeSandbox(opts.repoRoot, branch);
|
|
169
|
+
return { name: patch.name, action: 'merged', reason: `bench ${rate} >= baseline (merged to main)`, branch };
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (branch) {
|
|
173
|
+
try {
|
|
174
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
175
|
+
}
|
|
176
|
+
catch { /* best effort */ }
|
|
177
|
+
}
|
|
178
|
+
return { name: patch.name, action: 'error', reason: String(err).slice(0, 200), branch };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Meta-model call: propose code patches from session signals.
|
|
183
|
+
* The model sees the signals + the target file's current source and
|
|
184
|
+
* outputs find/replace pairs. Constrained to optimization prompts:
|
|
185
|
+
* fix a bug, speed up a hot path, improve error messages.
|
|
186
|
+
*/
|
|
187
|
+
export async function proposePatches(provider, signals, readFileFn, repoRoot, say) {
|
|
188
|
+
// pick candidate files: the most-used tools from insights
|
|
189
|
+
const toolFiles = {
|
|
190
|
+
run_command: 'packages/agent/src/tools.ts',
|
|
191
|
+
web_search: 'packages/agent/src/tools.ts',
|
|
192
|
+
web_fetch: 'packages/agent/src/tools.ts',
|
|
193
|
+
harmony_build: 'packages/domain-harmony/src/index.ts',
|
|
194
|
+
harmony_devices: 'packages/domain-harmony/src/devices.ts',
|
|
195
|
+
// add more as insights reveal hot paths
|
|
196
|
+
};
|
|
197
|
+
const toolsUsed = (signals.toolUsage ?? {});
|
|
198
|
+
const hotTools = Object.entries(toolsUsed)
|
|
199
|
+
.filter(([t]) => toolFiles[t])
|
|
200
|
+
.sort((a, b) => b[1] - a[1])
|
|
201
|
+
.slice(0, 2)
|
|
202
|
+
.map(([t]) => t);
|
|
203
|
+
if (hotTools.length === 0)
|
|
204
|
+
return [];
|
|
205
|
+
const fileContents = {};
|
|
206
|
+
for (const t of hotTools) {
|
|
207
|
+
const f = toolFiles[t];
|
|
208
|
+
try {
|
|
209
|
+
fileContents[f] = (await readFileFn(join(repoRoot, f), 'utf8')).slice(0, 6000);
|
|
210
|
+
}
|
|
211
|
+
catch { /* skip */ }
|
|
212
|
+
}
|
|
213
|
+
const system = [
|
|
214
|
+
'You are the code-evolution module of hmharness. Given session signals and the CURRENT source of the most-used tool files, propose at most 1 code patch as a JSON object.',
|
|
215
|
+
'A patch is: {"name":"kebab-case","description":"one line","file":"packages/.../src/...ts","find":"exact string from the source (must be unique)","replace":"the improved code","reason":"why"}',
|
|
216
|
+
'Rules: ONLY optimize what the signals show is slow/broken/verbose. Keep patches SMALL (under 20 lines of change). Do NOT restructure. Do NOT touch security, config, or the agent loop. If nothing genuinely needs a code fix, return [].',
|
|
217
|
+
'Respond with ONLY a JSON array.',
|
|
218
|
+
].join('\n');
|
|
219
|
+
const user = `Session signals:\n${JSON.stringify(signals, null, 2)}\n\nHot tool source files:\n${JSON.stringify(fileContents, null, 2)}\n\nPropose at most 1 patch (or []).`;
|
|
220
|
+
say(' code-evolution: asking meta-model for patch proposals');
|
|
221
|
+
try {
|
|
222
|
+
const { chat } = await import('@hmharness/kernel');
|
|
223
|
+
const r = await chat(provider, [
|
|
224
|
+
{ role: 'system', content: system },
|
|
225
|
+
{ role: 'user', content: user },
|
|
226
|
+
]);
|
|
227
|
+
const text = r.message.content ?? '[]';
|
|
228
|
+
const start = text.indexOf('[');
|
|
229
|
+
const end = text.lastIndexOf(']');
|
|
230
|
+
if (start < 0 || end < 0)
|
|
231
|
+
return [];
|
|
232
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
233
|
+
return parsed.filter((p) => p.file && p.find && p.replace && isPatchableFile(p.file));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmh/evolution - patches
|
|
3
|
+
* CODE-LEVEL self-evolution: the evolution loop can now propose source-code
|
|
4
|
+
* patches (not just prompt-level skills), test them on an isolated git
|
|
5
|
+
* branch, and merge or revert based on the benchmark gate.
|
|
6
|
+
*
|
|
7
|
+
* The Darwin Gödel Machine insight: an agent that can only take notes
|
|
8
|
+
* (skills/memory) learns WHAT to do; an agent that can patch its own code
|
|
9
|
+
* learns WHAT IT CAN DO. This module is the bridge.
|
|
10
|
+
*
|
|
11
|
+
* Safety model (same shape as the skill gate, extended to code):
|
|
12
|
+
* 1. patches may only modify files under packages/.../src/ (never config,
|
|
13
|
+
* never security code, never the kernel loop itself)
|
|
14
|
+
* 2. every patch runs on a git branch (sandbox), never on main
|
|
15
|
+
* 3. the bench gate must show no regression (same double-sample rule)
|
|
16
|
+
* 4. revert = git checkout main + branch delete (zero residue)
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
const execCb = promisify(execFile);
|
|
23
|
+
/** git executable: PATH first, then known Windows locations (this repo's
|
|
24
|
+
* dev machine has git only outside the test-process PATH). */
|
|
25
|
+
async function git(args, opts) {
|
|
26
|
+
const { homedir } = await import('node:os');
|
|
27
|
+
// .cmd shims can't execFile-spawn on modern Node (EINVAL) - real .exe only
|
|
28
|
+
const candidates = [
|
|
29
|
+
'git',
|
|
30
|
+
'C:\\Program Files\\Git\\cmd\\git.exe',
|
|
31
|
+
'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
|
|
32
|
+
'C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise\\Common7\\IDE\\CommonExtensions\\Microsoft\\TeamFoundation\\Team Explorer\\Git\\cmd\\git.exe',
|
|
33
|
+
];
|
|
34
|
+
let lastErr;
|
|
35
|
+
for (const exe of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
return await execCb(exe, args, { ...opts, windowsHide: true });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
lastErr = err;
|
|
41
|
+
const msg = String(err);
|
|
42
|
+
if (!/ENOENT|not found|EINVAL/i.test(msg))
|
|
43
|
+
throw err; // real failure, not missing-exe
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw lastErr;
|
|
47
|
+
}
|
|
48
|
+
/** Guard: only source files inside packages, never kernel internals. */
|
|
49
|
+
export function isPatchableFile(file) {
|
|
50
|
+
if (!/^packages\/[a-z-]+\/src\/[a-zA-Z0-9_/.-]+\.ts$/.test(file))
|
|
51
|
+
return false;
|
|
52
|
+
// never allow patching the kernel loop itself (bootstrapping paradox)
|
|
53
|
+
if (file.startsWith('packages/kernel/src/loop'))
|
|
54
|
+
return false;
|
|
55
|
+
if (file.startsWith('packages/kernel/src/provider'))
|
|
56
|
+
return false;
|
|
57
|
+
// never config or security
|
|
58
|
+
if (/config|security|mcp/.test(file))
|
|
59
|
+
return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/** Validate + apply a patch to a file on disk. Returns error if find-string
|
|
63
|
+
* is not found or not unique. */
|
|
64
|
+
export async function applyPatch(repoRoot, patch) {
|
|
65
|
+
if (!isPatchableFile(patch.file)) {
|
|
66
|
+
return `refused: ${patch.file} is not a patchable source file (must be packages/*/src/*.ts, not kernel loop/provider/config)`;
|
|
67
|
+
}
|
|
68
|
+
const filePath = join(repoRoot, patch.file);
|
|
69
|
+
let content;
|
|
70
|
+
try {
|
|
71
|
+
content = await readFile(filePath, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return `file not found: ${patch.file}`;
|
|
75
|
+
}
|
|
76
|
+
const count = content.split(patch.find).length - 1;
|
|
77
|
+
if (count === 0)
|
|
78
|
+
return `find-string not found in ${patch.file}`;
|
|
79
|
+
if (count > 1)
|
|
80
|
+
return `find-string appears ${count}x in ${patch.file} (must be unique)`;
|
|
81
|
+
const patched = content.replace(patch.find, patch.replace);
|
|
82
|
+
await writeFile(filePath, patched, 'utf8');
|
|
83
|
+
return 'applied';
|
|
84
|
+
}
|
|
85
|
+
/** Create a sandbox git branch for testing a patch. */
|
|
86
|
+
export async function createSandbox(repoRoot, name) {
|
|
87
|
+
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
|
+
await git(['checkout', '-b', branch], { cwd: repoRoot, timeout: 10_000 });
|
|
91
|
+
return branch;
|
|
92
|
+
}
|
|
93
|
+
/** Commit the patch on the sandbox branch so it is fully isolated from main. */
|
|
94
|
+
export async function commitOnSandbox(repoRoot, patchName) {
|
|
95
|
+
await git(['add', '-A'], { cwd: repoRoot, timeout: 10_000 });
|
|
96
|
+
await git(['commit', '-m', `evolve(code-patch): ${patchName}`, '--no-verify'], { cwd: repoRoot, timeout: 15_000 });
|
|
97
|
+
}
|
|
98
|
+
/** Merge the sandbox branch back to main (patch promoted). */
|
|
99
|
+
export async function mergeSandbox(repoRoot, branch) {
|
|
100
|
+
await git(['checkout', 'main'], { cwd: repoRoot, timeout: 10_000 });
|
|
101
|
+
await git(['merge', '--no-edit', branch], { cwd: repoRoot, timeout: 15_000 });
|
|
102
|
+
await git(['branch', '-D', branch], { cwd: repoRoot, timeout: 5000 });
|
|
103
|
+
}
|
|
104
|
+
/** Run the bench gate on the current branch. Returns pass rate (0-1) or -1 on build failure. */
|
|
105
|
+
export async function sandboxBench(repoRoot, runCase, cases) {
|
|
106
|
+
// rebuild all packages (the patch may affect any layer)
|
|
107
|
+
try {
|
|
108
|
+
await execCb('npm', ['run', 'build'], { cwd: repoRoot, timeout: 120_000, windowsHide: true });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return -1; // build failed = automatic reject
|
|
112
|
+
}
|
|
113
|
+
// double-sample rule: each case must pass BOTH runs
|
|
114
|
+
let pass = 0;
|
|
115
|
+
for (const c of cases) {
|
|
116
|
+
let ok = true;
|
|
117
|
+
for (let i = 0; i < 2 && ok; i++) {
|
|
118
|
+
try {
|
|
119
|
+
const out = await runCase(c, '');
|
|
120
|
+
ok = c.expect.every((e) => out.toLowerCase().includes(e.toLowerCase()));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
ok = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (ok)
|
|
127
|
+
pass++;
|
|
128
|
+
}
|
|
129
|
+
return cases.length > 0 ? pass / cases.length : -1;
|
|
130
|
+
}
|
|
131
|
+
/** Revert: go back to main, delete the sandbox branch (zero residue). */
|
|
132
|
+
export async function revertSandbox(repoRoot, branch) {
|
|
133
|
+
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
|
+
await git(['branch', '-D', branch], { cwd: repoRoot, timeout: 5000 });
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Full sandbox cycle: apply patch on a branch, rebuild, bench, merge or revert.
|
|
140
|
+
* This is the CODE-LEVEL equivalent of the skill A/B gate.
|
|
141
|
+
*/
|
|
142
|
+
export async function runPatchSandbox(opts) {
|
|
143
|
+
const say = opts.log ?? (() => undefined);
|
|
144
|
+
const { patch } = opts;
|
|
145
|
+
say(` code-patch "${patch.name}": creating sandbox branch`);
|
|
146
|
+
let branch = '';
|
|
147
|
+
try {
|
|
148
|
+
branch = await createSandbox(opts.repoRoot, patch.name);
|
|
149
|
+
const applyResult = await applyPatch(opts.repoRoot, patch);
|
|
150
|
+
if (applyResult !== 'applied') {
|
|
151
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
152
|
+
return { name: patch.name, action: 'error', reason: applyResult, branch };
|
|
153
|
+
}
|
|
154
|
+
// commit on the branch BEFORE benching: the patch must be fully isolated
|
|
155
|
+
// so a later checkout of main can never carry uncommitted patch changes
|
|
156
|
+
await commitOnSandbox(opts.repoRoot, patch.name);
|
|
157
|
+
say(` code-patch "${patch.name}": applied + committed on branch, rebuilding + benching`);
|
|
158
|
+
const rate = await sandboxBench(opts.repoRoot, opts.runCase, opts.benchCases);
|
|
159
|
+
if (rate < 0) {
|
|
160
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
161
|
+
return { name: patch.name, action: 'reverted', reason: 'build failed in sandbox', branch };
|
|
162
|
+
}
|
|
163
|
+
if (rate < opts.baselineRate) {
|
|
164
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
165
|
+
return { name: patch.name, action: 'reverted', reason: `bench ${rate} < baseline ${opts.baselineRate}`, branch };
|
|
166
|
+
}
|
|
167
|
+
say(` code-patch "${patch.name}": bench ${rate} >= baseline ${opts.baselineRate}, merging`);
|
|
168
|
+
await mergeSandbox(opts.repoRoot, branch);
|
|
169
|
+
return { name: patch.name, action: 'merged', reason: `bench ${rate} >= baseline (merged to main)`, branch };
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (branch) {
|
|
173
|
+
try {
|
|
174
|
+
await revertSandbox(opts.repoRoot, branch);
|
|
175
|
+
}
|
|
176
|
+
catch { /* best effort */ }
|
|
177
|
+
}
|
|
178
|
+
return { name: patch.name, action: 'error', reason: String(err).slice(0, 200), branch };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Meta-model call: propose code patches from session signals.
|
|
183
|
+
* The model sees the signals + the target file's current source and
|
|
184
|
+
* outputs find/replace pairs. Constrained to optimization prompts:
|
|
185
|
+
* fix a bug, speed up a hot path, improve error messages.
|
|
186
|
+
*/
|
|
187
|
+
export async function proposePatches(provider, signals, readFileFn, repoRoot, say) {
|
|
188
|
+
// pick candidate files: the most-used tools from insights
|
|
189
|
+
const toolFiles = {
|
|
190
|
+
run_command: 'packages/agent/src/tools.ts',
|
|
191
|
+
web_search: 'packages/agent/src/tools.ts',
|
|
192
|
+
web_fetch: 'packages/agent/src/tools.ts',
|
|
193
|
+
harmony_build: 'packages/domain-harmony/src/index.ts',
|
|
194
|
+
harmony_devices: 'packages/domain-harmony/src/devices.ts',
|
|
195
|
+
// add more as insights reveal hot paths
|
|
196
|
+
};
|
|
197
|
+
const toolsUsed = (signals.toolUsage ?? {});
|
|
198
|
+
const hotTools = Object.entries(toolsUsed)
|
|
199
|
+
.filter(([t]) => toolFiles[t])
|
|
200
|
+
.sort((a, b) => b[1] - a[1])
|
|
201
|
+
.slice(0, 2)
|
|
202
|
+
.map(([t]) => t);
|
|
203
|
+
if (hotTools.length === 0)
|
|
204
|
+
return [];
|
|
205
|
+
const fileContents = {};
|
|
206
|
+
for (const t of hotTools) {
|
|
207
|
+
const f = toolFiles[t];
|
|
208
|
+
try {
|
|
209
|
+
fileContents[f] = (await readFileFn(join(repoRoot, f), 'utf8')).slice(0, 6000);
|
|
210
|
+
}
|
|
211
|
+
catch { /* skip */ }
|
|
212
|
+
}
|
|
213
|
+
const system = [
|
|
214
|
+
'You are the code-evolution module of hmharness. Given session signals and the CURRENT source of the most-used tool files, propose at most 1 code patch as a JSON object.',
|
|
215
|
+
'A patch is: {"name":"kebab-case","description":"one line","file":"packages/.../src/...ts","find":"exact string from the source (must be unique)","replace":"the improved code","reason":"why"}',
|
|
216
|
+
'Rules: ONLY optimize what the signals show is slow/broken/verbose. Keep patches SMALL (under 20 lines of change). Do NOT restructure. Do NOT touch security, config, or the agent loop. If nothing genuinely needs a code fix, return [].',
|
|
217
|
+
'Respond with ONLY a JSON array.',
|
|
218
|
+
].join('\n');
|
|
219
|
+
const user = `Session signals:\n${JSON.stringify(signals, null, 2)}\n\nHot tool source files:\n${JSON.stringify(fileContents, null, 2)}\n\nPropose at most 1 patch (or []).`;
|
|
220
|
+
say(' code-evolution: asking meta-model for patch proposals');
|
|
221
|
+
try {
|
|
222
|
+
const { chat } = await import('@hmh/kernel');
|
|
223
|
+
const r = await chat(provider, [
|
|
224
|
+
{ role: 'system', content: system },
|
|
225
|
+
{ role: 'user', content: user },
|
|
226
|
+
]);
|
|
227
|
+
const text = r.message.content ?? '[]';
|
|
228
|
+
const start = text.indexOf('[');
|
|
229
|
+
const end = text.lastIndexOf(']');
|
|
230
|
+
if (start < 0 || end < 0)
|
|
231
|
+
return [];
|
|
232
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
233
|
+
return parsed.filter((p) => p.file && p.find && p.replace && isPatchableFile(p.file));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
package/dist/radar.d.ts
ADDED
package/dist/radar.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - radar (ops-signal feed)
|
|
3
|
+
* The ops keeper's ecosystem radar scans OpenHarmony release sources and
|
|
4
|
+
* writes dated briefs (home/ops/briefs/YYYY-MM-DD.md). This module hands
|
|
5
|
+
* the NEWEST brief text to the evolution loop as context: toolchain flags
|
|
6
|
+
* and API surfaces move with releases, and a skill proposal that doesn't
|
|
7
|
+
* know "ArkUI 5.1.2 shipped last week" proposes stale advice.
|
|
8
|
+
*
|
|
9
|
+
* Read-only and best-effort: evolution never triggers a scan (that's
|
|
10
|
+
* `hmh ops scan`'s own budgeted job) - it reads what the keeper already
|
|
11
|
+
* published, and an absent brief simply means no ecosystem signal.
|
|
12
|
+
*/
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
/** The newest radar brief, capped (the meta-model needs a headline, not
|
|
16
|
+
* the whole document; stale-brief protection via the filename date). */
|
|
17
|
+
export async function latestRadarBrief(home, maxChars = 1200, maxAgeDays = 14) {
|
|
18
|
+
const dir = join(home, 'ops', 'briefs');
|
|
19
|
+
let files;
|
|
20
|
+
try {
|
|
21
|
+
files = (await readdir(dir)).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const latest = files[files.length - 1];
|
|
27
|
+
if (!latest)
|
|
28
|
+
return null;
|
|
29
|
+
const date = latest.replace(/\.md$/, '');
|
|
30
|
+
if (Date.now() - new Date(date + 'T00:00:00Z').getTime() > maxAgeDays * 86400_000) {
|
|
31
|
+
return null; // too old to be ecosystem "news"
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const text = (await readFile(join(dir, latest), 'utf8')).trim();
|
|
35
|
+
return text.length > 20 ? text.slice(0, maxChars) : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type SkillState = 'draft' | 'canary' | 'active' | 'archived';
|
|
2
|
+
export interface SkillEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
file: string;
|
|
6
|
+
state: SkillState;
|
|
7
|
+
}
|
|
8
|
+
/** Skills the user pinned: evolution never moves, merges or decays them.
|
|
9
|
+
* Marked by a .pin file next to SKILL.md (cheap, visible, git-friendly). */
|
|
10
|
+
export declare function pinSkill(home: string, name: string): Promise<boolean>;
|
|
11
|
+
export declare function unpinSkill(home: string, name: string): Promise<boolean>;
|
|
12
|
+
export declare function isPinned(file: string): Promise<boolean>;
|
|
13
|
+
/**
|
|
14
|
+
* Install skills from a git URL or a local directory (`hmh skills add <src>`).
|
|
15
|
+
* Handles the three common repo layouts: SKILL.md at the root (single skill),
|
|
16
|
+
* skills/<name>/SKILL.md (multi-skill pack, e.g. greensock/gsap-skills), and
|
|
17
|
+
* <name>/SKILL.md one level down. Existing skill names are never overwritten.
|
|
18
|
+
*/
|
|
19
|
+
export declare function installSkills(src: string, home: string): Promise<{
|
|
20
|
+
installed: string[];
|
|
21
|
+
skipped: string[];
|
|
22
|
+
}>;
|
|
23
|
+
export declare function listSkills(home: string): Promise<SkillEntry[]>;
|
|
24
|
+
export declare function listDrafts(home: string): Promise<SkillEntry[]>;
|
|
25
|
+
/** Canary-state skills: promoted through the bench gates but still under
|
|
26
|
+
* impact evaluation - injected into a sample of sessions, watermarked. */
|
|
27
|
+
export declare function listCanary(home: string): Promise<SkillEntry[]>;
|
|
28
|
+
export declare function skillsToPrompt(entries: SkillEntry[]): string;
|
|
29
|
+
/** Write (or overwrite) a draft; drafts are cheap and reversible by deletion. */
|
|
30
|
+
export declare function writeDraft(home: string, name: string, skillMd: string): Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Promote a draft. Default target is `canary` (P0: bench-passing skills
|
|
33
|
+
* earn a canary slot first; full activation happens through the impact
|
|
34
|
+
* loop, not on the gate alone). `{canary: false}` promotes straight to
|
|
35
|
+
* active (used by the impact loop once evidence clears, and by `hmh skills
|
|
36
|
+
* promote`). If a same-name skill exists at the destination it is archived
|
|
37
|
+
* first (timestamped snapshot) so promotion is always reversible.
|
|
38
|
+
*/
|
|
39
|
+
export declare function promoteSkill(home: string, name: string, opts?: {
|
|
40
|
+
canary?: boolean;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
file: string;
|
|
43
|
+
archivedPrevious: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
/** Graduate a canary skill to full active (the impact loop's decision). */
|
|
46
|
+
export declare function promoteCanary(home: string, name: string): Promise<boolean>;
|
|
47
|
+
/** Retire a canary skill back to draft (impact loop's rejection path) -
|
|
48
|
+
* nothing is ever deleted (append-only red line). */
|
|
49
|
+
export declare function retireCanary(home: string, name: string): Promise<boolean>;
|
|
50
|
+
/** Restore the most recent archived snapshot of a skill back to active. */
|
|
51
|
+
export declare function rollbackSkill(home: string, name: string): Promise<boolean>;
|
|
52
|
+
/** Demote an active skill back to draft without deleting anything. */
|
|
53
|
+
export declare function unpromoteSkill(home: string, name: string): Promise<boolean>;
|
|
54
|
+
export declare function deleteDraft(home: string, name: string): Promise<boolean>;
|