@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,321 @@
1
+ /**
2
+ * @hmharness/evolution - skills
3
+ * The skill library with a three-state lifecycle:
4
+ * skills/draft/<name>/ drafted by the evolution loop, never injected
5
+ * skills/active/<name>/ promoted skills, injected into the system prompt
6
+ * skills/archive/<ts>_<name>/ snapshots taken before each promotion
7
+ * Root-level skills/<name>/ from Phase 0 still counts as active (compat).
8
+ * Promotion is move-based and each promote snapshots the incumbent so a
9
+ * bench regression can roll back - the DGM/GDPevo lesson: no promotion
10
+ * without a gate, no gate without a rollback path.
11
+ */
12
+ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { execFile } from 'node:child_process';
15
+ import { join } from 'node:path';
16
+ import { promisify } from 'node:util';
17
+ const execCb = promisify(execFile);
18
+ /** Skills the user pinned: evolution never moves, merges or decays them.
19
+ * Marked by a .pin file next to SKILL.md (cheap, visible, git-friendly). */
20
+ export async function pinSkill(home, name) {
21
+ for (const dir of [join(home, 'skills', 'active', sanitize(name)), join(home, 'skills', sanitize(name)), join(home, 'skills', 'canary', sanitize(name))]) {
22
+ try {
23
+ await writeFile(join(dir, '.pin'), String(new Date().toISOString()), 'utf8');
24
+ return true;
25
+ }
26
+ catch { /* try next location */ }
27
+ }
28
+ return false;
29
+ }
30
+ export async function unpinSkill(home, name) {
31
+ for (const dir of [join(home, 'skills', 'active', sanitize(name)), join(home, 'skills', sanitize(name)), join(home, 'skills', 'canary', sanitize(name))]) {
32
+ try {
33
+ await rm(join(dir, '.pin'), { force: true });
34
+ return true;
35
+ }
36
+ catch { /* try next */ }
37
+ }
38
+ return false;
39
+ }
40
+ export async function isPinned(file) {
41
+ try {
42
+ await readFile(join(dirname(file), '.pin'), 'utf8');
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ function dirname(p) {
50
+ const m = /^(.*)[\\/][^\\/]+$/.exec(p);
51
+ return m?.[1] ?? p;
52
+ }
53
+ /**
54
+ * Install skills from a git URL or a local directory (`hmh skills add <src>`).
55
+ * Handles the three common repo layouts: SKILL.md at the root (single skill),
56
+ * skills/<name>/SKILL.md (multi-skill pack, e.g. greensock/gsap-skills), and
57
+ * <name>/SKILL.md one level down. Existing skill names are never overwritten.
58
+ */
59
+ export async function installSkills(src, home) {
60
+ let rootDir;
61
+ let tmpDir = null;
62
+ const isUrl = /^https?:\/\/|git@/.test(src);
63
+ if (isUrl) {
64
+ tmpDir = join(tmpdir(), `hmh-skill-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
65
+ await execCb('git', ['clone', '--depth', '1', src, tmpDir], { timeout: 120_000, windowsHide: true });
66
+ rootDir = tmpDir;
67
+ }
68
+ else {
69
+ rootDir = src;
70
+ }
71
+ try {
72
+ // locate SKILL.md directories
73
+ const found = [];
74
+ if (await exists(join(rootDir, 'SKILL.md'))) {
75
+ found.push({ name: basename(rootDir), dir: rootDir });
76
+ }
77
+ for (const sub of ['skills', '.claude/skills', '.agents/skills']) {
78
+ const packDir = join(rootDir, sub);
79
+ for (const d of await dirs(packDir)) {
80
+ if (await exists(join(d.path, 'SKILL.md')))
81
+ found.push({ name: d.name, dir: d.path });
82
+ }
83
+ }
84
+ if (!found.length) {
85
+ for (const d of await dirs(rootDir)) {
86
+ if (['.git', 'node_modules', '.github', 'examples', 'assets'].includes(d.name))
87
+ continue;
88
+ if (await exists(join(d.path, 'SKILL.md')))
89
+ found.push({ name: d.name, dir: d.path });
90
+ }
91
+ }
92
+ const installed = [];
93
+ const skipped = [];
94
+ const dest = join(home, 'skills');
95
+ await mkdir(dest, { recursive: true });
96
+ const taken = new Set((await readdir(dest, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name));
97
+ for (const f of found) {
98
+ const name = f.name.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
99
+ if (taken.has(name) || (await listSkills(home)).some((s) => s.name === name)) {
100
+ skipped.push(name);
101
+ continue;
102
+ }
103
+ await cp(f.dir, join(dest, name), { recursive: true });
104
+ taken.add(name);
105
+ installed.push(name);
106
+ }
107
+ return { installed, skipped };
108
+ }
109
+ finally {
110
+ if (tmpDir)
111
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
112
+ }
113
+ }
114
+ async function exists(p) {
115
+ try {
116
+ await readFile(p, 'utf8');
117
+ return true;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ async function dirs(p) {
124
+ try {
125
+ return (await readdir(p, { withFileTypes: true }))
126
+ .filter((d) => d.isDirectory())
127
+ .map((d) => ({ name: d.name, path: join(p, d.name) }));
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }
133
+ function basename(p) {
134
+ const m = /[\\/]?([^\\/]+)[\\/]?$/.exec(p.replace(/[\\/]+$/, ''));
135
+ return m?.[1] ?? 'skill';
136
+ }
137
+ export async function listSkills(home) {
138
+ const root = join(home, 'skills');
139
+ // Phase 0 layout: skills/<name>/SKILL.md directly under root (excluding lifecycle dirs)
140
+ const legacy = await scan(join(root), ['draft', 'active', 'canary', 'archive'], 'active');
141
+ const active = await scan(join(root, 'active'), [], 'active');
142
+ return [...legacy, ...active].sort((a, b) => a.name.localeCompare(b.name));
143
+ }
144
+ export async function listDrafts(home) {
145
+ return scan(join(home, 'skills', 'draft'), [], 'draft');
146
+ }
147
+ /** Canary-state skills: promoted through the bench gates but still under
148
+ * impact evaluation - injected into a sample of sessions, watermarked. */
149
+ export async function listCanary(home) {
150
+ return scan(join(home, 'skills', 'canary'), [], 'canary');
151
+ }
152
+ async function scan(dir, exclude, state) {
153
+ let dirs;
154
+ try {
155
+ dirs = (await readdir(dir, { withFileTypes: true })).filter((d) => d.isDirectory() && !exclude.includes(d.name));
156
+ }
157
+ catch {
158
+ return [];
159
+ }
160
+ const entries = [];
161
+ for (const d of dirs) {
162
+ const file = join(dir, d.name, 'SKILL.md');
163
+ try {
164
+ entries.push({ name: d.name, description: parseDescription(await readFile(file, 'utf8')), file, state });
165
+ }
166
+ catch {
167
+ /* directory without SKILL.md - not a skill */
168
+ }
169
+ }
170
+ return entries;
171
+ }
172
+ export function skillsToPrompt(entries) {
173
+ if (entries.length === 0)
174
+ return '';
175
+ return entries.map((s) => `- ${s.name}: ${s.description || '(no description)'}`).join('\n');
176
+ }
177
+ /** Write (or overwrite) a draft; drafts are cheap and reversible by deletion. */
178
+ export async function writeDraft(home, name, skillMd) {
179
+ const dir = join(home, 'skills', 'draft', sanitize(name));
180
+ await mkdir(dir, { recursive: true });
181
+ const file = join(dir, 'SKILL.md');
182
+ await writeFile(file, skillMd, 'utf8');
183
+ return file;
184
+ }
185
+ /**
186
+ * Promote a draft. Default target is `canary` (P0: bench-passing skills
187
+ * earn a canary slot first; full activation happens through the impact
188
+ * loop, not on the gate alone). `{canary: false}` promotes straight to
189
+ * active (used by the impact loop once evidence clears, and by `hmh skills
190
+ * promote`). If a same-name skill exists at the destination it is archived
191
+ * first (timestamped snapshot) so promotion is always reversible.
192
+ */
193
+ export async function promoteSkill(home, name, opts = {}) {
194
+ const safe = sanitize(name);
195
+ const draftDir = join(home, 'skills', 'draft', safe);
196
+ const activeDir = join(home, 'skills', 'active', safe);
197
+ const canaryDir = join(home, 'skills', 'canary', safe);
198
+ const legacyDir = join(home, 'skills', safe);
199
+ await mkdir(join(home, 'skills', 'active'), { recursive: true });
200
+ await mkdir(join(home, 'skills', 'archive'), { recursive: true });
201
+ if (opts.canary !== false)
202
+ await mkdir(join(home, 'skills', 'canary'), { recursive: true });
203
+ let archivedPrevious = false;
204
+ // archive incumbents at both destinations: active/legacy AND an existing
205
+ // canary slot (re-promoting a canary skill must not rename onto an
206
+ // occupied directory - that is EPERM on Windows)
207
+ for (const existing of [activeDir, legacyDir, join(home, 'skills', 'canary', safe)]) {
208
+ try {
209
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
210
+ await rename(existing, join(home, 'skills', 'archive', `${stamp}_${safe}`));
211
+ archivedPrevious = true;
212
+ }
213
+ catch {
214
+ /* nothing at this path */
215
+ }
216
+ }
217
+ await rename(draftDir, opts.canary === false ? activeDir : canaryDir);
218
+ return { file: join(opts.canary === false ? activeDir : canaryDir, 'SKILL.md'), archivedPrevious };
219
+ }
220
+ /** Graduate a canary skill to full active (the impact loop's decision). */
221
+ export async function promoteCanary(home, name) {
222
+ const safe = sanitize(name);
223
+ await mkdir(join(home, 'skills', 'active'), { recursive: true });
224
+ try {
225
+ await rename(join(home, 'skills', 'canary', safe), join(home, 'skills', 'active', safe));
226
+ return true;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ /** Retire a canary skill back to draft (impact loop's rejection path) -
233
+ * nothing is ever deleted (append-only red line). */
234
+ export async function retireCanary(home, name) {
235
+ const safe = sanitize(name);
236
+ await mkdir(join(home, 'skills', 'draft'), { recursive: true });
237
+ try {
238
+ const dest = join(home, 'skills', 'draft', safe);
239
+ try {
240
+ await rm(dest, { recursive: true, force: true });
241
+ }
242
+ catch { /* not there */ }
243
+ await rename(join(home, 'skills', 'canary', safe), dest);
244
+ return true;
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ }
250
+ /** Restore the most recent archived snapshot of a skill back to active. */
251
+ export async function rollbackSkill(home, name) {
252
+ const safe = sanitize(name);
253
+ const archiveRoot = join(home, 'skills', 'archive');
254
+ let snapshots;
255
+ try {
256
+ snapshots = (await readdir(archiveRoot)).filter((d) => d.endsWith(`_${safe}`)).sort();
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ const latest = snapshots.pop();
262
+ if (!latest)
263
+ return false;
264
+ await mkdir(join(home, 'skills', 'active'), { recursive: true });
265
+ // Move the regressed incumbent out of the way first (rename onto an
266
+ // existing directory fails on Windows), keeping it as a rejected snapshot.
267
+ for (const cur of [join(home, 'skills', 'active', safe), join(home, 'skills', safe)]) {
268
+ try {
269
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
270
+ await rename(cur, join(archiveRoot, `rejected-${stamp}_${safe}`));
271
+ break;
272
+ }
273
+ catch {
274
+ /* nothing at this path */
275
+ }
276
+ }
277
+ await rename(join(archiveRoot, latest), join(home, 'skills', 'active', safe));
278
+ return true;
279
+ }
280
+ /** Demote an active skill back to draft without deleting anything. */
281
+ export async function unpromoteSkill(home, name) {
282
+ const safe = sanitize(name);
283
+ for (const from of [join(home, 'skills', 'active', safe), join(home, 'skills', safe)]) {
284
+ try {
285
+ await mkdir(join(home, 'skills', 'draft'), { recursive: true });
286
+ await rename(from, join(home, 'skills', 'draft', safe));
287
+ return true;
288
+ }
289
+ catch {
290
+ /* try next */
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+ export async function deleteDraft(home, name) {
296
+ try {
297
+ await rm(join(home, 'skills', 'draft', sanitize(name)), { recursive: true, force: true });
298
+ return true;
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ }
304
+ function sanitize(name) {
305
+ return name.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 60) || 'skill';
306
+ }
307
+ function parseDescription(text) {
308
+ // frontmatter "description:" line, or first non-heading non-empty line
309
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
310
+ if (fm) {
311
+ const m = fm[1].match(/^description:\s*(.+)$/m);
312
+ if (m)
313
+ return m[1].trim().slice(0, 120);
314
+ }
315
+ for (const line of text.split('\n')) {
316
+ const t = line.trim();
317
+ if (t && !t.startsWith('#') && !t.startsWith('---'))
318
+ return t.slice(0, 120);
319
+ }
320
+ return '';
321
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @hmharness/evolution - workflows (AWM: Agent Workflow Memory, arxiv 2409.07429)
3
+ * Verified lesson from the paper: inducing commonly REUSED routines from
4
+ * past task trajectories and injecting them on demand beats stacking raw
5
+ * trajectories or per-mistake notes. Here the "repeated trajectory" signal
6
+ * is insight clustering: the same task archetype occurring 3+ times
7
+ * triggers one meta-model call that distills a PARAMETERIZED workflow
8
+ * template ({{placeholders}}), which then flows through the EXISTING
9
+ * draft -> poison-screen -> bench-gate pipeline. No new gate is opened -
10
+ * a workflow is just a skill with a shape.
11
+ */
12
+ import { type ProviderConfig } from '@hmharness/kernel';
13
+ import { type SkillProposal } from './evolve.ts';
14
+ export interface WorkflowCluster {
15
+ key: string;
16
+ tasks: string[];
17
+ outcomes: string[];
18
+ }
19
+ /** Find task archetypes that occurred >= minRepeat times in the recent
20
+ * insight history - the AWM reuse signal. */
21
+ export declare function findWorkflowClusters(home: string, minRepeat?: number, lookback?: number): Promise<WorkflowCluster[]>;
22
+ /** Induce a parameterized workflow proposal from a cluster (one meta-model
23
+ * call; falls back to null on any refusal/parse failure/poison hit). */
24
+ export declare function induceWorkflow(provider: ProviderConfig, cluster: WorkflowCluster): Promise<SkillProposal | null>;
25
+ /** The full AWM step for one evolution cycle: find fresh clusters and
26
+ * induce at most one workflow proposal (budget-conscious; the existing
27
+ * gate pipeline does the actual accept/reject). */
28
+ export declare function workflowProposals(provider: ProviderConfig, home: string, say: (l: string) => void): Promise<SkillProposal[]>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @hmharness/evolution - workflows (AWM: Agent Workflow Memory, arxiv 2409.07429)
3
+ * Verified lesson from the paper: inducing commonly REUSED routines from
4
+ * past task trajectories and injecting them on demand beats stacking raw
5
+ * trajectories or per-mistake notes. Here the "repeated trajectory" signal
6
+ * is insight clustering: the same task archetype occurring 3+ times
7
+ * triggers one meta-model call that distills a PARAMETERIZED workflow
8
+ * template ({{placeholders}}), which then flows through the EXISTING
9
+ * draft -> poison-screen -> bench-gate pipeline. No new gate is opened -
10
+ * a workflow is just a skill with a shape.
11
+ */
12
+ import { chat } from '@hmharness/kernel';
13
+ import { readInsights } from "./insights.js";
14
+ import { screenForPoison } from "./evolve.js";
15
+ /** Cluster insights by task archetype: normalized prefix (first ~24 chars,
16
+ * alphanumerics lowercased) groups "fix build error in module X" tasks. */
17
+ function archetypeKey(task) {
18
+ const norm = task.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff ]+/g, ' ').replace(/\s+/g, ' ').trim();
19
+ return norm.split(' ').slice(0, 6).join(' ').slice(0, 32) || norm.slice(0, 24);
20
+ }
21
+ /** Find task archetypes that occurred >= minRepeat times in the recent
22
+ * insight history - the AWM reuse signal. */
23
+ export async function findWorkflowClusters(home, minRepeat = 3, lookback = 120) {
24
+ const insights = await readInsights(home, lookback);
25
+ const groups = new Map();
26
+ for (const i of insights) {
27
+ const key = archetypeKey(i.task);
28
+ const g = groups.get(key) ?? { key, tasks: [], outcomes: [] };
29
+ g.tasks.push(i.task.slice(0, 100));
30
+ g.outcomes.push(i.outcome);
31
+ groups.set(key, g);
32
+ }
33
+ return [...groups.values()].filter((g) => g.tasks.length >= minRepeat);
34
+ }
35
+ /** Induce a parameterized workflow proposal from a cluster (one meta-model
36
+ * call; falls back to null on any refusal/parse failure/poison hit). */
37
+ export async function induceWorkflow(provider, cluster) {
38
+ const system = [
39
+ 'You induce reusable workflow templates for a coding agent, from repeated task instances (Agent Workflow Memory).',
40
+ 'Output ONE skill in the standard format. The body must be a parameterized routine: use {{placeholders}} for the varying parts (module names, error strings, targets) so the agent fills them per task.',
41
+ 'Rules: name is kebab-case ending in "-workflow"; description is one line starting with the trigger condition ("when ..."); skill_md max 50 lines, concrete steps with real commands; no security/approval topics.',
42
+ 'If the instances do not actually share a reusable routine, output exactly: NONE',
43
+ 'Respond with ONLY JSON: {"name":"...","description":"...","skill_md":"..."} - no prose, no fences.',
44
+ ].join('\n');
45
+ const user = `Task archetype "${cluster.key}" occurred ${cluster.tasks.length}x:\n${cluster.tasks.map((t) => `- ${t}`).join('\n')}\n\nInduce the parameterized workflow.`;
46
+ try {
47
+ const r = await chat(provider, [
48
+ { role: 'system', content: system },
49
+ { role: 'user', content: user },
50
+ ]);
51
+ const raw = (r.message.content ?? '').trim();
52
+ if (!raw || raw === 'NONE')
53
+ return null;
54
+ const m = raw.match(/\{[\s\S]*\}/);
55
+ if (!m)
56
+ return null;
57
+ const o = JSON.parse(m[0]);
58
+ if (typeof o.name !== 'string' || typeof o.skill_md !== 'string' || !o.name || !o.skill_md)
59
+ return null;
60
+ if (screenForPoison(o.skill_md))
61
+ return null;
62
+ return { name: o.name, description: o.description ?? '', skill_md: o.skill_md };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** The full AWM step for one evolution cycle: find fresh clusters and
69
+ * induce at most one workflow proposal (budget-conscious; the existing
70
+ * gate pipeline does the actual accept/reject). */
71
+ export async function workflowProposals(provider, home, say) {
72
+ const clusters = await findWorkflowClusters(home, 3, 120);
73
+ if (clusters.length === 0)
74
+ return [];
75
+ // already-proposed archetypes (by name convention <arch>-workflow) need no re-run
76
+ say(`awm: ${clusters.length} repeated task archetype(s)`);
77
+ const cluster = clusters.sort((a, b) => b.tasks.length - a.tasks.length)[0];
78
+ const p = await induceWorkflow(provider, cluster);
79
+ if (!p) {
80
+ say('awm: no reusable routine induced');
81
+ return [];
82
+ }
83
+ return [p];
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/evolution",
3
- "version": "0.14.7",
3
+ "version": "0.14.9",
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",