harness-evolve 1.0.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/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/cli.js +1685 -0
- package/dist/cli.js.map +1 -0
- package/dist/delivery/run-evolve.d.ts +2 -0
- package/dist/delivery/run-evolve.js +2069 -0
- package/dist/delivery/run-evolve.js.map +1 -0
- package/dist/hooks/permission-request.d.ts +8 -0
- package/dist/hooks/permission-request.js +405 -0
- package/dist/hooks/permission-request.js.map +1 -0
- package/dist/hooks/post-tool-use-failure.d.ts +9 -0
- package/dist/hooks/post-tool-use-failure.js +437 -0
- package/dist/hooks/post-tool-use-failure.js.map +1 -0
- package/dist/hooks/post-tool-use.d.ts +9 -0
- package/dist/hooks/post-tool-use.js +441 -0
- package/dist/hooks/post-tool-use.js.map +1 -0
- package/dist/hooks/pre-tool-use.d.ts +8 -0
- package/dist/hooks/pre-tool-use.js +434 -0
- package/dist/hooks/pre-tool-use.js.map +1 -0
- package/dist/hooks/stop.d.ts +8 -0
- package/dist/hooks/stop.js +1609 -0
- package/dist/hooks/stop.js.map +1 -0
- package/dist/hooks/user-prompt-submit.d.ts +8 -0
- package/dist/hooks/user-prompt-submit.js +442 -0
- package/dist/hooks/user-prompt-submit.js.map +1 -0
- package/dist/index.d.ts +1029 -0
- package/dist/index.js +3131 -0
- package/dist/index.js.map +1 -0
- package/package.json +104 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1685 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "@commander-js/extra-typings";
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { join as join10 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/cli/init.ts
|
|
9
|
+
import { copyFile, mkdir, access as access2, writeFile } from "fs/promises";
|
|
10
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
11
|
+
|
|
12
|
+
// src/cli/utils.ts
|
|
13
|
+
import { readFile } from "fs/promises";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { createInterface } from "readline/promises";
|
|
16
|
+
import writeFileAtomic from "write-file-atomic";
|
|
17
|
+
var HARNESS_EVOLVE_MARKER = "harness-evolve";
|
|
18
|
+
var SETTINGS_PATH = join(
|
|
19
|
+
process.env.HOME ?? "",
|
|
20
|
+
".claude",
|
|
21
|
+
"settings.json"
|
|
22
|
+
);
|
|
23
|
+
var HOOK_REGISTRATIONS = [
|
|
24
|
+
{
|
|
25
|
+
event: "UserPromptSubmit",
|
|
26
|
+
hookFile: "user-prompt-submit.js",
|
|
27
|
+
timeout: 10,
|
|
28
|
+
async: false,
|
|
29
|
+
description: "Captures prompts and delivers optimization notifications"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
event: "PreToolUse",
|
|
33
|
+
hookFile: "pre-tool-use.js",
|
|
34
|
+
timeout: 10,
|
|
35
|
+
async: true,
|
|
36
|
+
description: "Tracks tool usage patterns before execution"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
event: "PostToolUse",
|
|
40
|
+
hookFile: "post-tool-use.js",
|
|
41
|
+
timeout: 10,
|
|
42
|
+
async: true,
|
|
43
|
+
description: "Records tool outcomes for pattern analysis"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
event: "PostToolUseFailure",
|
|
47
|
+
hookFile: "post-tool-use-failure.js",
|
|
48
|
+
timeout: 10,
|
|
49
|
+
async: true,
|
|
50
|
+
description: "Logs tool failures to detect correction patterns"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
event: "PermissionRequest",
|
|
54
|
+
hookFile: "permission-request.js",
|
|
55
|
+
timeout: 10,
|
|
56
|
+
async: true,
|
|
57
|
+
description: "Monitors permission decisions for auto-approval suggestions"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
event: "Stop",
|
|
61
|
+
hookFile: "stop.js",
|
|
62
|
+
timeout: 10,
|
|
63
|
+
async: true,
|
|
64
|
+
description: "Triggers analysis when interaction threshold is reached"
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
function resolveHookPath(hookFile, baseDirOverride) {
|
|
68
|
+
const baseDir = baseDirOverride ?? import.meta.dirname;
|
|
69
|
+
return join(baseDir, "hooks", hookFile);
|
|
70
|
+
}
|
|
71
|
+
async function readSettings(settingsPath) {
|
|
72
|
+
const filePath = settingsPath ?? SETTINGS_PATH;
|
|
73
|
+
try {
|
|
74
|
+
const raw = await readFile(filePath, "utf-8");
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
} catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function writeSettings(settings, settingsPath) {
|
|
81
|
+
const filePath = settingsPath ?? SETTINGS_PATH;
|
|
82
|
+
await writeFileAtomic(filePath, JSON.stringify(settings, null, 2));
|
|
83
|
+
}
|
|
84
|
+
function mergeHooks(existing, hookCommands) {
|
|
85
|
+
const hooks = existing.hooks != null ? { ...existing.hooks } : {};
|
|
86
|
+
for (const hc of hookCommands) {
|
|
87
|
+
const eventArray = Array.isArray(hooks[hc.event]) ? [...hooks[hc.event]] : [];
|
|
88
|
+
const alreadyRegistered = eventArray.some((entry) => {
|
|
89
|
+
const innerHooks = entry.hooks;
|
|
90
|
+
if (!Array.isArray(innerHooks)) return false;
|
|
91
|
+
return innerHooks.some(
|
|
92
|
+
(h) => String(h.command ?? "").includes(HARNESS_EVOLVE_MARKER)
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
if (!alreadyRegistered) {
|
|
96
|
+
const hookEntry = {
|
|
97
|
+
type: "command",
|
|
98
|
+
command: hc.command,
|
|
99
|
+
timeout: hc.timeout
|
|
100
|
+
};
|
|
101
|
+
if (hc.async) {
|
|
102
|
+
hookEntry.async = true;
|
|
103
|
+
}
|
|
104
|
+
eventArray.push({
|
|
105
|
+
matcher: "*",
|
|
106
|
+
hooks: [hookEntry]
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
hooks[hc.event] = eventArray;
|
|
110
|
+
}
|
|
111
|
+
return { ...existing, hooks };
|
|
112
|
+
}
|
|
113
|
+
async function confirm(message) {
|
|
114
|
+
const rl = createInterface({
|
|
115
|
+
input: process.stdin,
|
|
116
|
+
output: process.stdout
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
const answer = await rl.question(`${message} [y/N] `);
|
|
120
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
121
|
+
} finally {
|
|
122
|
+
rl.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/scan/context-builder.ts
|
|
127
|
+
import { readFile as readFile2, readdir } from "fs/promises";
|
|
128
|
+
import { join as join2, basename } from "path";
|
|
129
|
+
|
|
130
|
+
// src/scan/schemas.ts
|
|
131
|
+
import { z } from "zod/v4";
|
|
132
|
+
var scanContextSchema = z.object({
|
|
133
|
+
generated_at: z.iso.datetime(),
|
|
134
|
+
project_root: z.string(),
|
|
135
|
+
claude_md_files: z.array(
|
|
136
|
+
z.object({
|
|
137
|
+
path: z.string(),
|
|
138
|
+
scope: z.enum(["user", "project", "local"]),
|
|
139
|
+
content: z.string(),
|
|
140
|
+
line_count: z.number(),
|
|
141
|
+
headings: z.array(z.string()),
|
|
142
|
+
references: z.array(z.string())
|
|
143
|
+
})
|
|
144
|
+
),
|
|
145
|
+
rules: z.array(
|
|
146
|
+
z.object({
|
|
147
|
+
path: z.string(),
|
|
148
|
+
filename: z.string(),
|
|
149
|
+
content: z.string(),
|
|
150
|
+
frontmatter: z.object({
|
|
151
|
+
paths: z.array(z.string()).optional()
|
|
152
|
+
}).optional(),
|
|
153
|
+
headings: z.array(z.string())
|
|
154
|
+
})
|
|
155
|
+
),
|
|
156
|
+
settings: z.object({
|
|
157
|
+
user: z.unknown().nullable(),
|
|
158
|
+
project: z.unknown().nullable(),
|
|
159
|
+
local: z.unknown().nullable()
|
|
160
|
+
}),
|
|
161
|
+
commands: z.array(
|
|
162
|
+
z.object({
|
|
163
|
+
path: z.string(),
|
|
164
|
+
name: z.string(),
|
|
165
|
+
content: z.string()
|
|
166
|
+
})
|
|
167
|
+
),
|
|
168
|
+
hooks_registered: z.array(
|
|
169
|
+
z.object({
|
|
170
|
+
event: z.string(),
|
|
171
|
+
scope: z.enum(["user", "project", "local"]),
|
|
172
|
+
type: z.string(),
|
|
173
|
+
command: z.string().optional()
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// src/scan/context-builder.ts
|
|
179
|
+
async function readFileSafe(path) {
|
|
180
|
+
try {
|
|
181
|
+
return await readFile2(path, "utf-8");
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function extractHeadings(content) {
|
|
187
|
+
const headings = [];
|
|
188
|
+
const regex = /^#{1,6}\s+(.+)$/gm;
|
|
189
|
+
let match;
|
|
190
|
+
while ((match = regex.exec(content)) !== null) {
|
|
191
|
+
headings.push(match[1].trim());
|
|
192
|
+
}
|
|
193
|
+
return headings;
|
|
194
|
+
}
|
|
195
|
+
function extractReferences(content) {
|
|
196
|
+
const refs = [];
|
|
197
|
+
const regex = /@([\w./-]+)/g;
|
|
198
|
+
let match;
|
|
199
|
+
while ((match = regex.exec(content)) !== null) {
|
|
200
|
+
const ref = match[1];
|
|
201
|
+
const idx = match.index;
|
|
202
|
+
if (idx > 0 && /\w/.test(content[idx - 1])) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const cleaned = ref.replace(/\.$/, "");
|
|
206
|
+
refs.push(cleaned);
|
|
207
|
+
}
|
|
208
|
+
return refs;
|
|
209
|
+
}
|
|
210
|
+
async function readClaudeMdFiles(cwd, home) {
|
|
211
|
+
const locations = [
|
|
212
|
+
{ path: join2(cwd, "CLAUDE.md"), scope: "project" },
|
|
213
|
+
{ path: join2(cwd, ".claude", "CLAUDE.md"), scope: "local" },
|
|
214
|
+
{ path: join2(home, ".claude", "CLAUDE.md"), scope: "user" }
|
|
215
|
+
];
|
|
216
|
+
const files = [];
|
|
217
|
+
for (const loc of locations) {
|
|
218
|
+
const content = await readFileSafe(loc.path);
|
|
219
|
+
if (content !== null) {
|
|
220
|
+
files.push({
|
|
221
|
+
path: loc.path,
|
|
222
|
+
scope: loc.scope,
|
|
223
|
+
content,
|
|
224
|
+
line_count: content.split("\n").length,
|
|
225
|
+
headings: extractHeadings(content),
|
|
226
|
+
references: extractReferences(content)
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return files;
|
|
231
|
+
}
|
|
232
|
+
async function collectMdFiles(dir) {
|
|
233
|
+
const results = [];
|
|
234
|
+
try {
|
|
235
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
const fullPath = join2(dir, entry.name);
|
|
238
|
+
if (entry.isDirectory()) {
|
|
239
|
+
const nested = await collectMdFiles(fullPath);
|
|
240
|
+
results.push(...nested);
|
|
241
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
242
|
+
results.push(fullPath);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
return results;
|
|
248
|
+
}
|
|
249
|
+
function parseFrontmatter(content) {
|
|
250
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
251
|
+
if (!match) return void 0;
|
|
252
|
+
const frontmatter = match[1];
|
|
253
|
+
const pathsMatch = frontmatter.match(
|
|
254
|
+
/paths:\s*\n((?:\s*-\s*.+\n?)*)/
|
|
255
|
+
);
|
|
256
|
+
if (!pathsMatch) return {};
|
|
257
|
+
const paths2 = pathsMatch[1].split("\n").map((line) => line.replace(/^\s*-\s*/, "").trim()).filter((line) => line.length > 0);
|
|
258
|
+
return paths2.length > 0 ? { paths: paths2 } : {};
|
|
259
|
+
}
|
|
260
|
+
async function readRuleFiles(cwd) {
|
|
261
|
+
const rulesDir = join2(cwd, ".claude", "rules");
|
|
262
|
+
const mdFiles = await collectMdFiles(rulesDir);
|
|
263
|
+
const rules = [];
|
|
264
|
+
for (const filePath of mdFiles) {
|
|
265
|
+
const content = await readFileSafe(filePath);
|
|
266
|
+
if (content !== null) {
|
|
267
|
+
rules.push({
|
|
268
|
+
path: filePath,
|
|
269
|
+
filename: basename(filePath),
|
|
270
|
+
content,
|
|
271
|
+
frontmatter: parseFrontmatter(content),
|
|
272
|
+
headings: extractHeadings(content)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return rules;
|
|
277
|
+
}
|
|
278
|
+
async function readAllSettings(cwd, home) {
|
|
279
|
+
const settingsPaths = {
|
|
280
|
+
user: join2(home, ".claude", "settings.json"),
|
|
281
|
+
project: join2(cwd, ".claude", "settings.json"),
|
|
282
|
+
local: join2(cwd, ".claude", "settings.local.json")
|
|
283
|
+
};
|
|
284
|
+
const readJsonSafe = async (path) => {
|
|
285
|
+
try {
|
|
286
|
+
const raw = await readFile2(path, "utf-8");
|
|
287
|
+
return JSON.parse(raw);
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const [user, project, local] = await Promise.all([
|
|
293
|
+
readJsonSafe(settingsPaths.user),
|
|
294
|
+
readJsonSafe(settingsPaths.project),
|
|
295
|
+
readJsonSafe(settingsPaths.local)
|
|
296
|
+
]);
|
|
297
|
+
return { user, project, local };
|
|
298
|
+
}
|
|
299
|
+
async function readCommandFiles(cwd) {
|
|
300
|
+
const commandsDir = join2(cwd, ".claude", "commands");
|
|
301
|
+
const commands = [];
|
|
302
|
+
try {
|
|
303
|
+
const entries = await readdir(commandsDir, { withFileTypes: true });
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
306
|
+
const filePath = join2(commandsDir, entry.name);
|
|
307
|
+
const content = await readFileSafe(filePath);
|
|
308
|
+
if (content !== null) {
|
|
309
|
+
commands.push({
|
|
310
|
+
path: filePath,
|
|
311
|
+
name: entry.name.replace(/\.md$/, ""),
|
|
312
|
+
content
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
}
|
|
319
|
+
return commands;
|
|
320
|
+
}
|
|
321
|
+
function extractHooksFromAllSettings(settings) {
|
|
322
|
+
const hooks = [];
|
|
323
|
+
const extractFromScope = (settingsObj, scope) => {
|
|
324
|
+
if (!settingsObj || typeof settingsObj !== "object") return;
|
|
325
|
+
const obj = settingsObj;
|
|
326
|
+
if (!obj.hooks || typeof obj.hooks !== "object") return;
|
|
327
|
+
const hooksConfig = obj.hooks;
|
|
328
|
+
for (const [event, defs] of Object.entries(hooksConfig)) {
|
|
329
|
+
if (!Array.isArray(defs)) continue;
|
|
330
|
+
for (const def of defs) {
|
|
331
|
+
if (!def || typeof def !== "object") continue;
|
|
332
|
+
const hookDef = def;
|
|
333
|
+
const type = String(hookDef.type ?? "command");
|
|
334
|
+
const command = typeof hookDef.command === "string" ? hookDef.command : void 0;
|
|
335
|
+
hooks.push({ event, scope, type, command });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
extractFromScope(settings.user, "user");
|
|
340
|
+
extractFromScope(settings.project, "project");
|
|
341
|
+
extractFromScope(settings.local, "local");
|
|
342
|
+
return hooks;
|
|
343
|
+
}
|
|
344
|
+
async function buildScanContext(cwd, home) {
|
|
345
|
+
const homeDir = home ?? process.env.HOME ?? "";
|
|
346
|
+
const [claudeMdFiles, rules, settings, commands] = await Promise.all([
|
|
347
|
+
readClaudeMdFiles(cwd, homeDir),
|
|
348
|
+
readRuleFiles(cwd),
|
|
349
|
+
readAllSettings(cwd, homeDir),
|
|
350
|
+
readCommandFiles(cwd)
|
|
351
|
+
]);
|
|
352
|
+
const hooksRegistered = extractHooksFromAllSettings(settings);
|
|
353
|
+
const ctx = {
|
|
354
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
355
|
+
project_root: cwd,
|
|
356
|
+
claude_md_files: claudeMdFiles,
|
|
357
|
+
rules,
|
|
358
|
+
settings,
|
|
359
|
+
commands,
|
|
360
|
+
hooks_registered: hooksRegistered
|
|
361
|
+
};
|
|
362
|
+
return scanContextSchema.parse(ctx);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/scan/scanners/redundancy.ts
|
|
366
|
+
function normalizeText(text) {
|
|
367
|
+
return text.toLowerCase().trim().replace(/\s+/g, " ");
|
|
368
|
+
}
|
|
369
|
+
function scanRedundancy(context) {
|
|
370
|
+
const recommendations = [];
|
|
371
|
+
let index = 0;
|
|
372
|
+
const claudeMdHeadings = context.claude_md_files.flatMap(
|
|
373
|
+
(f) => f.headings.map((h) => ({ heading: normalizeText(h), source: f.path }))
|
|
374
|
+
);
|
|
375
|
+
const ruleHeadings = context.rules.flatMap(
|
|
376
|
+
(r) => r.headings.map((h) => ({ heading: normalizeText(h), source: r.path }))
|
|
377
|
+
);
|
|
378
|
+
for (const cmdH of claudeMdHeadings) {
|
|
379
|
+
const match = ruleHeadings.find((rH) => rH.heading === cmdH.heading);
|
|
380
|
+
if (match) {
|
|
381
|
+
recommendations.push({
|
|
382
|
+
id: `rec-scan-redundancy-${index++}`,
|
|
383
|
+
target: "RULE",
|
|
384
|
+
confidence: "MEDIUM",
|
|
385
|
+
pattern_type: "scan_redundancy",
|
|
386
|
+
title: `Redundant section: "${cmdH.heading}"`,
|
|
387
|
+
description: `The heading "${cmdH.heading}" appears in both ${cmdH.source} and ${match.source}. This may indicate duplicated instructions.`,
|
|
388
|
+
evidence: {
|
|
389
|
+
count: 2,
|
|
390
|
+
examples: [cmdH.source, match.source]
|
|
391
|
+
},
|
|
392
|
+
suggested_action: "Consolidate into one location. If it belongs in rules, remove from CLAUDE.md. If it belongs in CLAUDE.md, remove the rule file."
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const rulesByHeadingSet = /* @__PURE__ */ new Map();
|
|
397
|
+
for (const rule of context.rules) {
|
|
398
|
+
const key = rule.headings.map((h) => normalizeText(h)).sort().join("||");
|
|
399
|
+
if (!key) continue;
|
|
400
|
+
const existing = rulesByHeadingSet.get(key) ?? [];
|
|
401
|
+
existing.push(rule.path);
|
|
402
|
+
rulesByHeadingSet.set(key, existing);
|
|
403
|
+
}
|
|
404
|
+
for (const [, paths2] of rulesByHeadingSet) {
|
|
405
|
+
if (paths2.length < 2) continue;
|
|
406
|
+
recommendations.push({
|
|
407
|
+
id: `rec-scan-redundancy-${index++}`,
|
|
408
|
+
target: "RULE",
|
|
409
|
+
confidence: "MEDIUM",
|
|
410
|
+
pattern_type: "scan_redundancy",
|
|
411
|
+
title: `Duplicate rule files detected (${paths2.length} files with same headings)`,
|
|
412
|
+
description: `${paths2.length} rule files share the same heading structure: ${paths2.join(", ")}. They may contain redundant content.`,
|
|
413
|
+
evidence: {
|
|
414
|
+
count: paths2.length,
|
|
415
|
+
examples: paths2.slice(0, 3)
|
|
416
|
+
},
|
|
417
|
+
suggested_action: "Review these rule files and merge them into a single file, or differentiate their content."
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return recommendations;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/scan/scanners/mechanization.ts
|
|
424
|
+
var MECHANIZATION_INDICATORS = [
|
|
425
|
+
{ regex: /always\s+run\s+["`']?(\S+)/i, hookEvent: "PreToolUse", label: "always run" },
|
|
426
|
+
{
|
|
427
|
+
regex: /before\s+committing?,?\s+run\s+["`']?(\S+)/i,
|
|
428
|
+
hookEvent: "PreToolUse",
|
|
429
|
+
label: "pre-commit check"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
regex: /after\s+every\s+(?:edit|change|write)/i,
|
|
433
|
+
hookEvent: "PostToolUse",
|
|
434
|
+
label: "post-edit action"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
regex: /must\s+(?:always\s+)?check\s+["`']?(\S+)/i,
|
|
438
|
+
hookEvent: "PreToolUse",
|
|
439
|
+
label: "mandatory check"
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
regex: /never\s+(?:allow|permit|run)\s+["`']?(\S+)/i,
|
|
443
|
+
hookEvent: "PreToolUse",
|
|
444
|
+
label: "forbidden operation"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
regex: /forbidden.*(?:rm\s+-rf|drop\s+|delete\s+|truncate)/i,
|
|
448
|
+
hookEvent: "PreToolUse",
|
|
449
|
+
label: "dangerous command guard"
|
|
450
|
+
}
|
|
451
|
+
];
|
|
452
|
+
function scanMechanization(context) {
|
|
453
|
+
const recommendations = [];
|
|
454
|
+
let index = 0;
|
|
455
|
+
const allTextSources = [
|
|
456
|
+
...context.claude_md_files.map((f) => ({ content: f.content, source: f.path })),
|
|
457
|
+
...context.rules.map((r) => ({ content: r.content, source: r.path }))
|
|
458
|
+
];
|
|
459
|
+
for (const source of allTextSources) {
|
|
460
|
+
for (const indicator of MECHANIZATION_INDICATORS) {
|
|
461
|
+
const match = source.content.match(indicator.regex);
|
|
462
|
+
if (!match) continue;
|
|
463
|
+
const alreadyCovered = context.hooks_registered.some(
|
|
464
|
+
(h) => h.event === indicator.hookEvent
|
|
465
|
+
);
|
|
466
|
+
if (alreadyCovered) continue;
|
|
467
|
+
recommendations.push({
|
|
468
|
+
id: `rec-scan-mechanize-${index++}`,
|
|
469
|
+
target: "HOOK",
|
|
470
|
+
confidence: "MEDIUM",
|
|
471
|
+
pattern_type: "scan_missing_mechanization",
|
|
472
|
+
title: `Mechanizable rule: "${match[0].substring(0, 60)}"`,
|
|
473
|
+
description: `Found a rule in ${source.source} that describes an operation suitable for a ${indicator.hookEvent} hook: "${match[0]}". Hooks provide 100% reliable execution, while rules depend on Claude's probabilistic compliance.`,
|
|
474
|
+
evidence: {
|
|
475
|
+
count: 1,
|
|
476
|
+
examples: [match[0].substring(0, 100)]
|
|
477
|
+
},
|
|
478
|
+
suggested_action: `Create a ${indicator.hookEvent} hook to enforce this rule automatically. See Claude Code hooks docs for ${indicator.hookEvent} event.`
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return recommendations;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/scan/scanners/staleness.ts
|
|
486
|
+
import { access } from "fs/promises";
|
|
487
|
+
import { constants } from "fs";
|
|
488
|
+
import { resolve, dirname as dirname2 } from "path";
|
|
489
|
+
async function fileExistsOnDisk(filePath) {
|
|
490
|
+
try {
|
|
491
|
+
await access(filePath, constants.F_OK);
|
|
492
|
+
return true;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function extractPathFromCommand(command) {
|
|
498
|
+
const quotedMatch = command.match(/(?:node|sh|bash|python)\s+["']([^"']+)["']/i);
|
|
499
|
+
if (quotedMatch) return quotedMatch[1];
|
|
500
|
+
const unquotedMatch = command.match(
|
|
501
|
+
/(?:node|sh|bash|python)\s+(\S+\.(?:js|ts|sh|py|mjs|cjs))/i
|
|
502
|
+
);
|
|
503
|
+
if (unquotedMatch) return unquotedMatch[1];
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
async function scanStaleness(context) {
|
|
507
|
+
const recommendations = [];
|
|
508
|
+
let index = 0;
|
|
509
|
+
for (const claudeMd of context.claude_md_files) {
|
|
510
|
+
for (const ref of claudeMd.references) {
|
|
511
|
+
const resolved = resolve(dirname2(claudeMd.path), ref);
|
|
512
|
+
const inContext = context.rules.some((r) => r.path === resolved) || context.claude_md_files.some((f) => f.path === resolved) || context.commands.some((c) => c.path === resolved);
|
|
513
|
+
if (inContext) continue;
|
|
514
|
+
const existsOnDisk = await fileExistsOnDisk(resolved);
|
|
515
|
+
if (existsOnDisk) continue;
|
|
516
|
+
recommendations.push({
|
|
517
|
+
id: `rec-scan-stale-${index++}`,
|
|
518
|
+
target: "CLAUDE_MD",
|
|
519
|
+
confidence: "HIGH",
|
|
520
|
+
pattern_type: "scan_stale_reference",
|
|
521
|
+
title: `Stale reference: @${ref}`,
|
|
522
|
+
description: `${claudeMd.path} references @${ref}, but this file does not exist.`,
|
|
523
|
+
evidence: {
|
|
524
|
+
count: 1,
|
|
525
|
+
examples: [`@${ref} in ${claudeMd.path}`]
|
|
526
|
+
},
|
|
527
|
+
suggested_action: `Remove the @${ref} reference from ${claudeMd.path}, or create the missing file.`
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
for (const hook of context.hooks_registered) {
|
|
532
|
+
if (!hook.command) continue;
|
|
533
|
+
const scriptPath = extractPathFromCommand(hook.command);
|
|
534
|
+
if (!scriptPath) continue;
|
|
535
|
+
const exists = await fileExistsOnDisk(scriptPath);
|
|
536
|
+
if (exists) continue;
|
|
537
|
+
recommendations.push({
|
|
538
|
+
id: `rec-scan-stale-${index++}`,
|
|
539
|
+
target: "SETTINGS",
|
|
540
|
+
confidence: "HIGH",
|
|
541
|
+
pattern_type: "scan_stale_reference",
|
|
542
|
+
title: `Stale hook script: ${scriptPath}`,
|
|
543
|
+
description: `Hook (${hook.event}, ${hook.scope}) references script "${scriptPath}", but this file does not exist. The hook will fail when triggered.`,
|
|
544
|
+
evidence: {
|
|
545
|
+
count: 1,
|
|
546
|
+
examples: [`${hook.event} hook command: ${hook.command}`]
|
|
547
|
+
},
|
|
548
|
+
suggested_action: `Create the missing script at "${scriptPath}", or update the hook command in settings.json.`
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
return recommendations;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/scan/scanners/index.ts
|
|
555
|
+
var scanners = [scanRedundancy, scanMechanization, scanStaleness];
|
|
556
|
+
|
|
557
|
+
// src/scan/index.ts
|
|
558
|
+
async function runDeepScan(cwd, home) {
|
|
559
|
+
const scanContext = await buildScanContext(cwd, home);
|
|
560
|
+
const recommendations = [];
|
|
561
|
+
for (const scanner of scanners) {
|
|
562
|
+
try {
|
|
563
|
+
const result = await scanner(scanContext);
|
|
564
|
+
recommendations.push(...result);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
console.error(
|
|
567
|
+
`Scanner error: ${err instanceof Error ? err.message : String(err)}`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
573
|
+
scan_context: scanContext,
|
|
574
|
+
recommendations
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/commands/evolve-scan.ts
|
|
579
|
+
function generateScanCommand() {
|
|
580
|
+
return `---
|
|
581
|
+
name: scan
|
|
582
|
+
description: Run a deep harness-evolve configuration scan to detect quality issues
|
|
583
|
+
disable-model-invocation: true
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
# Evolve Scan
|
|
587
|
+
|
|
588
|
+
Run a deep scan of the current project's Claude Code configuration to detect quality issues and optimization opportunities.
|
|
589
|
+
|
|
590
|
+
## What This Does
|
|
591
|
+
|
|
592
|
+
Analyzes your Claude Code configuration files to detect:
|
|
593
|
+
- **Redundant rules** -- same constraint defined in multiple files (CLAUDE.md, .claude/rules/, settings.json)
|
|
594
|
+
- **Missing mechanization** -- operations in rules or CLAUDE.md that should be hooks for 100% reliability
|
|
595
|
+
- **Stale config** -- references to non-existent files, outdated commands, or unused settings
|
|
596
|
+
- **Configuration drift** -- inconsistencies between .claude/commands/, rules, and settings
|
|
597
|
+
|
|
598
|
+
Files scanned: CLAUDE.md, .claude/rules/, settings.json, .claude/commands/
|
|
599
|
+
|
|
600
|
+
## Instructions
|
|
601
|
+
|
|
602
|
+
Run the scan CLI command:
|
|
603
|
+
|
|
604
|
+
\`\`\`bash
|
|
605
|
+
npx harness-evolve scan
|
|
606
|
+
\`\`\`
|
|
607
|
+
|
|
608
|
+
Or if globally installed:
|
|
609
|
+
|
|
610
|
+
\`\`\`bash
|
|
611
|
+
harness-evolve scan
|
|
612
|
+
\`\`\`
|
|
613
|
+
|
|
614
|
+
## Presenting Results
|
|
615
|
+
|
|
616
|
+
Present the results grouped by confidence level (HIGH first, then MEDIUM, then LOW):
|
|
617
|
+
|
|
618
|
+
1. **HIGH confidence** -- Issues that are very likely real problems. Recommend immediate action.
|
|
619
|
+
2. **MEDIUM confidence** -- Probable issues worth reviewing. Present with context for user to decide.
|
|
620
|
+
3. **LOW confidence** -- Possible improvements. Mention briefly and let user prioritize.
|
|
621
|
+
|
|
622
|
+
For each issue, show:
|
|
623
|
+
- Confidence level and category
|
|
624
|
+
- Description of the problem
|
|
625
|
+
- Affected file(s)
|
|
626
|
+
- Suggested fix
|
|
627
|
+
|
|
628
|
+
If issues are found, suggest running \`/evolve:apply\` to review and apply the recommendations interactively.
|
|
629
|
+
|
|
630
|
+
If no issues are found, congratulate the user on a clean configuration.
|
|
631
|
+
`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/commands/evolve-apply.ts
|
|
635
|
+
function generateApplyCommand() {
|
|
636
|
+
return `---
|
|
637
|
+
name: apply
|
|
638
|
+
description: Review and apply pending harness-evolve recommendations one by one
|
|
639
|
+
disable-model-invocation: true
|
|
640
|
+
argument-hint: "[filter: all|high|medium|low]"
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
# Evolve Apply
|
|
644
|
+
|
|
645
|
+
Review pending harness-evolve recommendations interactively. For each recommendation, you can choose to apply it, skip it for later, or permanently dismiss it.
|
|
646
|
+
|
|
647
|
+
## Arguments
|
|
648
|
+
|
|
649
|
+
If \`$ARGUMENTS\` is provided (e.g., "high", "medium", or "low"), filter recommendations to only show that confidence level. If empty or "all", show all pending recommendations.
|
|
650
|
+
|
|
651
|
+
## Instructions
|
|
652
|
+
|
|
653
|
+
### Step 1: Read Pending Recommendations
|
|
654
|
+
|
|
655
|
+
\`\`\`bash
|
|
656
|
+
npx harness-evolve pending
|
|
657
|
+
\`\`\`
|
|
658
|
+
|
|
659
|
+
If the command outputs no pending recommendations, inform the user:
|
|
660
|
+
> No pending recommendations. Run \`/evolve:scan\` to analyze your configuration and generate new recommendations.
|
|
661
|
+
|
|
662
|
+
### Step 2: Present Each Recommendation
|
|
663
|
+
|
|
664
|
+
For each pending recommendation, present the following:
|
|
665
|
+
|
|
666
|
+
- **Confidence level** (HIGH / MEDIUM / LOW)
|
|
667
|
+
- **Title** -- what the recommendation is about
|
|
668
|
+
- **Description** -- detailed explanation of the issue
|
|
669
|
+
- **Evidence** -- what data or pattern triggered this recommendation
|
|
670
|
+
- **Suggested action** -- what change is proposed
|
|
671
|
+
|
|
672
|
+
### Step 3: Ask User to Choose
|
|
673
|
+
|
|
674
|
+
For each recommendation, ask the user to choose one of three actions:
|
|
675
|
+
|
|
676
|
+
1. **Apply** -- Execute the recommendation. Run:
|
|
677
|
+
\`\`\`bash
|
|
678
|
+
npx harness-evolve apply-one <id>
|
|
679
|
+
\`\`\`
|
|
680
|
+
Report the result (success or failure) and show what changed.
|
|
681
|
+
|
|
682
|
+
2. **Skip** -- Do nothing for now. The recommendation stays pending for future review. No command needed.
|
|
683
|
+
|
|
684
|
+
3. **Ignore** -- Permanently dismiss this recommendation. Run:
|
|
685
|
+
\`\`\`bash
|
|
686
|
+
npx harness-evolve dismiss <id>
|
|
687
|
+
\`\`\`
|
|
688
|
+
Confirm the recommendation has been dismissed.
|
|
689
|
+
|
|
690
|
+
### Step 4: Continue or Finish
|
|
691
|
+
|
|
692
|
+
After processing each recommendation, move to the next one. When all recommendations have been processed, summarize what was done:
|
|
693
|
+
- How many applied
|
|
694
|
+
- How many skipped
|
|
695
|
+
- How many ignored
|
|
696
|
+
|
|
697
|
+
## Notes
|
|
698
|
+
|
|
699
|
+
- Recommendations are generated by \`harness-evolve scan\` or automatic background analysis
|
|
700
|
+
- Applied recommendations modify configuration files (settings.json, CLAUDE.md, .claude/rules/, etc.)
|
|
701
|
+
- Ignored recommendations will not appear in future \`/evolve:apply\` sessions
|
|
702
|
+
- Skipped recommendations remain pending and will reappear next time
|
|
703
|
+
`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/cli/init.ts
|
|
707
|
+
async function fileExists(path) {
|
|
708
|
+
try {
|
|
709
|
+
await access2(path);
|
|
710
|
+
return true;
|
|
711
|
+
} catch {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function installSlashCommands(projectDir) {
|
|
716
|
+
const commandsDir = join3(projectDir, ".claude", "commands", "evolve");
|
|
717
|
+
await mkdir(commandsDir, { recursive: true });
|
|
718
|
+
const commands = [
|
|
719
|
+
{ name: "scan", generate: generateScanCommand, path: join3(commandsDir, "scan.md") },
|
|
720
|
+
{ name: "apply", generate: generateApplyCommand, path: join3(commandsDir, "apply.md") }
|
|
721
|
+
];
|
|
722
|
+
for (const cmd of commands) {
|
|
723
|
+
if (await fileExists(cmd.path)) {
|
|
724
|
+
console.log(` /evolve:${cmd.name} already installed, skipping`);
|
|
725
|
+
} else {
|
|
726
|
+
await writeFile(cmd.path, cmd.generate(), "utf-8");
|
|
727
|
+
console.log(` /evolve:${cmd.name} installed`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async function runInit(options) {
|
|
732
|
+
const settingsPath = options.settingsPath ?? SETTINGS_PATH;
|
|
733
|
+
const hookCommands = HOOK_REGISTRATIONS.map((reg) => {
|
|
734
|
+
const absolutePath = resolveHookPath(reg.hookFile, options.baseDirOverride);
|
|
735
|
+
return {
|
|
736
|
+
event: reg.event,
|
|
737
|
+
command: `node "${absolutePath}"`,
|
|
738
|
+
timeout: reg.timeout,
|
|
739
|
+
async: reg.async,
|
|
740
|
+
description: reg.description
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
console.log("\nPlanned hook registrations:\n");
|
|
744
|
+
for (const hc of hookCommands) {
|
|
745
|
+
const asyncLabel = hc.async ? " (async)" : "";
|
|
746
|
+
console.log(` ${hc.event}${asyncLabel} -- ${hc.description}`);
|
|
747
|
+
console.log(` -> ${hc.command}`);
|
|
748
|
+
}
|
|
749
|
+
console.log("");
|
|
750
|
+
const samplePath = hookCommands[0].command;
|
|
751
|
+
if (samplePath.includes(".npm/_npx/")) {
|
|
752
|
+
console.log(
|
|
753
|
+
"WARNING: Detected npx installation. Hook paths may break when the"
|
|
754
|
+
);
|
|
755
|
+
console.log(
|
|
756
|
+
"npx cache is cleared. For persistent installation, use: npm i -g harness-evolve\n"
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
if (!options.yes) {
|
|
760
|
+
const accepted = await confirm("Register hooks in settings.json?");
|
|
761
|
+
if (!accepted) {
|
|
762
|
+
console.log("Aborted.");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
await mkdir(dirname3(settingsPath), { recursive: true });
|
|
767
|
+
const exists = await fileExists(settingsPath);
|
|
768
|
+
if (exists) {
|
|
769
|
+
await copyFile(settingsPath, settingsPath + ".backup");
|
|
770
|
+
console.log(`Backup created: ${settingsPath}.backup`);
|
|
771
|
+
}
|
|
772
|
+
const settings = await readSettings(settingsPath);
|
|
773
|
+
const merged = mergeHooks(settings, hookCommands);
|
|
774
|
+
await writeSettings(merged, settingsPath);
|
|
775
|
+
console.log(
|
|
776
|
+
`Hooks registered successfully! (${hookCommands.length} events)`
|
|
777
|
+
);
|
|
778
|
+
console.log("\nInstalling slash commands...\n");
|
|
779
|
+
await installSlashCommands(options.projectDir ?? process.cwd());
|
|
780
|
+
try {
|
|
781
|
+
console.log("\nScanning configuration...\n");
|
|
782
|
+
const scanResult = await runDeepScan(process.cwd());
|
|
783
|
+
if (scanResult.recommendations.length > 0) {
|
|
784
|
+
console.log(
|
|
785
|
+
`Found ${scanResult.recommendations.length} configuration suggestion(s):
|
|
786
|
+
`
|
|
787
|
+
);
|
|
788
|
+
for (const rec of scanResult.recommendations) {
|
|
789
|
+
console.log(` [${rec.confidence}] ${rec.title}`);
|
|
790
|
+
console.log(` ${rec.description}`);
|
|
791
|
+
console.log(` Suggested: ${rec.suggested_action}
|
|
792
|
+
`);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
console.log("Configuration looks clean -- no issues detected.\n");
|
|
796
|
+
}
|
|
797
|
+
} catch (err) {
|
|
798
|
+
console.error(
|
|
799
|
+
`Warning: Configuration scan failed: ${err instanceof Error ? err.message : String(err)}`
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function registerInitCommand(program2) {
|
|
804
|
+
program2.command("init").description("Register harness-evolve hooks in Claude Code settings").option("--yes", "Skip confirmation prompt").action(async (opts) => {
|
|
805
|
+
await runInit({ yes: opts.yes ?? false });
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/storage/counter.ts
|
|
810
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
811
|
+
import { lock } from "proper-lockfile";
|
|
812
|
+
import writeFileAtomic2 from "write-file-atomic";
|
|
813
|
+
|
|
814
|
+
// src/schemas/counter.ts
|
|
815
|
+
import { z as z2 } from "zod/v4";
|
|
816
|
+
var counterSchema = z2.object({
|
|
817
|
+
total: z2.number().default(0),
|
|
818
|
+
session: z2.record(z2.string(), z2.number()).default({}),
|
|
819
|
+
last_analysis: z2.iso.datetime().optional(),
|
|
820
|
+
last_updated: z2.iso.datetime()
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// src/storage/dirs.ts
|
|
824
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
825
|
+
import { join as join4 } from "path";
|
|
826
|
+
var BASE_DIR = join4(process.env.HOME ?? "", ".harness-evolve");
|
|
827
|
+
var paths = {
|
|
828
|
+
base: BASE_DIR,
|
|
829
|
+
logs: {
|
|
830
|
+
prompts: join4(BASE_DIR, "logs", "prompts"),
|
|
831
|
+
tools: join4(BASE_DIR, "logs", "tools"),
|
|
832
|
+
permissions: join4(BASE_DIR, "logs", "permissions"),
|
|
833
|
+
sessions: join4(BASE_DIR, "logs", "sessions")
|
|
834
|
+
},
|
|
835
|
+
analysis: join4(BASE_DIR, "analysis"),
|
|
836
|
+
analysisPreProcessed: join4(BASE_DIR, "analysis", "pre-processed"),
|
|
837
|
+
summary: join4(BASE_DIR, "analysis", "pre-processed", "summary.json"),
|
|
838
|
+
environmentSnapshot: join4(BASE_DIR, "analysis", "environment-snapshot.json"),
|
|
839
|
+
analysisResult: join4(BASE_DIR, "analysis", "analysis-result.json"),
|
|
840
|
+
pending: join4(BASE_DIR, "pending"),
|
|
841
|
+
config: join4(BASE_DIR, "config.json"),
|
|
842
|
+
counter: join4(BASE_DIR, "counter.json"),
|
|
843
|
+
recommendations: join4(BASE_DIR, "recommendations.md"),
|
|
844
|
+
recommendationState: join4(BASE_DIR, "analysis", "recommendation-state.json"),
|
|
845
|
+
recommendationArchive: join4(BASE_DIR, "analysis", "recommendations-archive"),
|
|
846
|
+
notificationFlag: join4(BASE_DIR, "analysis", "has-pending-notifications"),
|
|
847
|
+
autoApplyLog: join4(BASE_DIR, "analysis", "auto-apply-log.jsonl"),
|
|
848
|
+
outcomeHistory: join4(BASE_DIR, "analysis", "outcome-history.jsonl")
|
|
849
|
+
};
|
|
850
|
+
var initialized = false;
|
|
851
|
+
async function ensureInit() {
|
|
852
|
+
if (initialized) return;
|
|
853
|
+
await mkdir2(paths.logs.prompts, { recursive: true });
|
|
854
|
+
await mkdir2(paths.logs.tools, { recursive: true });
|
|
855
|
+
await mkdir2(paths.logs.permissions, { recursive: true });
|
|
856
|
+
await mkdir2(paths.logs.sessions, { recursive: true });
|
|
857
|
+
await mkdir2(paths.analysis, { recursive: true });
|
|
858
|
+
await mkdir2(paths.analysisPreProcessed, { recursive: true });
|
|
859
|
+
await mkdir2(paths.pending, { recursive: true });
|
|
860
|
+
await mkdir2(paths.recommendationArchive, { recursive: true });
|
|
861
|
+
initialized = true;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/storage/counter.ts
|
|
865
|
+
async function readCounter() {
|
|
866
|
+
await ensureInit();
|
|
867
|
+
try {
|
|
868
|
+
const raw = await readFile3(paths.counter, "utf-8");
|
|
869
|
+
return counterSchema.parse(JSON.parse(raw));
|
|
870
|
+
} catch {
|
|
871
|
+
return {
|
|
872
|
+
total: 0,
|
|
873
|
+
session: {},
|
|
874
|
+
last_updated: (/* @__PURE__ */ new Date()).toISOString()
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// src/delivery/state.ts
|
|
880
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
881
|
+
import writeFileAtomic3 from "write-file-atomic";
|
|
882
|
+
|
|
883
|
+
// src/schemas/delivery.ts
|
|
884
|
+
import { z as z3 } from "zod/v4";
|
|
885
|
+
var recommendationStatusSchema = z3.enum(["pending", "applied", "dismissed"]);
|
|
886
|
+
var recommendationStateEntrySchema = z3.object({
|
|
887
|
+
id: z3.string(),
|
|
888
|
+
status: recommendationStatusSchema,
|
|
889
|
+
updated_at: z3.iso.datetime(),
|
|
890
|
+
applied_details: z3.string().optional()
|
|
891
|
+
});
|
|
892
|
+
var recommendationStateSchema = z3.object({
|
|
893
|
+
entries: z3.array(recommendationStateEntrySchema),
|
|
894
|
+
last_updated: z3.iso.datetime()
|
|
895
|
+
});
|
|
896
|
+
var autoApplyLogEntrySchema = z3.object({
|
|
897
|
+
timestamp: z3.iso.datetime(),
|
|
898
|
+
recommendation_id: z3.string(),
|
|
899
|
+
target: z3.string(),
|
|
900
|
+
action: z3.string(),
|
|
901
|
+
success: z3.boolean(),
|
|
902
|
+
details: z3.string().optional(),
|
|
903
|
+
backup_path: z3.string().optional()
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// src/delivery/state.ts
|
|
907
|
+
async function loadState() {
|
|
908
|
+
try {
|
|
909
|
+
const raw = await readFile4(paths.recommendationState, "utf-8");
|
|
910
|
+
return recommendationStateSchema.parse(JSON.parse(raw));
|
|
911
|
+
} catch (err) {
|
|
912
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
913
|
+
return { entries: [], last_updated: (/* @__PURE__ */ new Date()).toISOString() };
|
|
914
|
+
}
|
|
915
|
+
throw err;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async function saveState(state) {
|
|
919
|
+
await writeFileAtomic3(
|
|
920
|
+
paths.recommendationState,
|
|
921
|
+
JSON.stringify(state, null, 2)
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
async function updateStatus(id, status, details) {
|
|
925
|
+
const state = await loadState();
|
|
926
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
927
|
+
const existing = state.entries.find((e) => e.id === id);
|
|
928
|
+
if (existing) {
|
|
929
|
+
existing.status = status;
|
|
930
|
+
existing.updated_at = now;
|
|
931
|
+
if (status === "applied" && details !== void 0) {
|
|
932
|
+
existing.applied_details = details;
|
|
933
|
+
} else if (status !== "applied") {
|
|
934
|
+
existing.applied_details = void 0;
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
state.entries.push({
|
|
938
|
+
id,
|
|
939
|
+
status,
|
|
940
|
+
updated_at: now,
|
|
941
|
+
...status === "applied" && details !== void 0 ? { applied_details: details } : {}
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
state.last_updated = now;
|
|
945
|
+
await saveState(state);
|
|
946
|
+
}
|
|
947
|
+
function isNodeError(err) {
|
|
948
|
+
return err instanceof Error && "code" in err;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// src/cli/status.ts
|
|
952
|
+
async function runStatus(options) {
|
|
953
|
+
const settingsPath = options.settingsPath ?? SETTINGS_PATH;
|
|
954
|
+
const counter = await readCounter();
|
|
955
|
+
const state = await loadState();
|
|
956
|
+
const pendingCount = state.entries.filter(
|
|
957
|
+
(e) => e.status === "pending"
|
|
958
|
+
).length;
|
|
959
|
+
const settings = await readSettings(settingsPath);
|
|
960
|
+
const hooksRegistered = JSON.stringify(settings.hooks ?? {}).includes(
|
|
961
|
+
HARNESS_EVOLVE_MARKER
|
|
962
|
+
);
|
|
963
|
+
console.log("");
|
|
964
|
+
console.log("harness-evolve status");
|
|
965
|
+
console.log("=====================");
|
|
966
|
+
console.log(`Interactions: ${counter.total}`);
|
|
967
|
+
console.log(`Last analysis: ${counter.last_analysis ?? "never"}`);
|
|
968
|
+
console.log(`Pending recs: ${pendingCount}`);
|
|
969
|
+
console.log(`Hooks registered: ${hooksRegistered ? "Yes" : "No"}`);
|
|
970
|
+
console.log("");
|
|
971
|
+
}
|
|
972
|
+
function registerStatusCommand(program2) {
|
|
973
|
+
program2.command("status").description("Show harness-evolve status and statistics").action(async () => {
|
|
974
|
+
await runStatus({});
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// src/cli/uninstall.ts
|
|
979
|
+
import { copyFile as copyFile2, rm, rmdir, access as access3 } from "fs/promises";
|
|
980
|
+
import { constants as constants2 } from "fs";
|
|
981
|
+
import { join as join5 } from "path";
|
|
982
|
+
async function removeSlashCommands(projectDir) {
|
|
983
|
+
const commandsDir = join5(projectDir, ".claude", "commands", "evolve");
|
|
984
|
+
for (const file of ["scan.md", "apply.md"]) {
|
|
985
|
+
try {
|
|
986
|
+
await rm(join5(commandsDir, file));
|
|
987
|
+
console.log(` Removed /evolve:${file.replace(".md", "")}`);
|
|
988
|
+
} catch {
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
await rmdir(commandsDir);
|
|
993
|
+
} catch {
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async function runUninstall(options) {
|
|
997
|
+
const settingsPath = options.settingsPath ?? SETTINGS_PATH;
|
|
998
|
+
const settings = await readSettings(settingsPath);
|
|
999
|
+
const hooks = settings.hooks;
|
|
1000
|
+
if (!hooks || Object.keys(hooks).length === 0) {
|
|
1001
|
+
console.log("No harness-evolve hooks found in settings.json");
|
|
1002
|
+
} else {
|
|
1003
|
+
let removedCount = 0;
|
|
1004
|
+
const filteredHooks = {};
|
|
1005
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
1006
|
+
if (!Array.isArray(entries)) {
|
|
1007
|
+
filteredHooks[event] = entries;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
const kept = entries.filter((entry) => {
|
|
1011
|
+
const innerHooks = entry.hooks;
|
|
1012
|
+
if (!Array.isArray(innerHooks)) return true;
|
|
1013
|
+
const isHarnessEvolve = innerHooks.some(
|
|
1014
|
+
(h) => String(h.command ?? "").includes(HARNESS_EVOLVE_MARKER)
|
|
1015
|
+
);
|
|
1016
|
+
if (isHarnessEvolve) removedCount++;
|
|
1017
|
+
return !isHarnessEvolve;
|
|
1018
|
+
});
|
|
1019
|
+
if (kept.length > 0) {
|
|
1020
|
+
filteredHooks[event] = kept;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (removedCount > 0) {
|
|
1024
|
+
await copyFile2(settingsPath, settingsPath + ".backup");
|
|
1025
|
+
console.log(`Backup created: ${settingsPath}.backup`);
|
|
1026
|
+
settings.hooks = filteredHooks;
|
|
1027
|
+
await writeSettings(settings, settingsPath);
|
|
1028
|
+
console.log("Removed harness-evolve hooks from settings.json");
|
|
1029
|
+
} else {
|
|
1030
|
+
console.log("No harness-evolve hooks found in settings.json");
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
console.log("\nRemoving slash commands...");
|
|
1034
|
+
await removeSlashCommands(options.projectDir ?? process.cwd());
|
|
1035
|
+
if (options.purge) {
|
|
1036
|
+
if (!options.yes) {
|
|
1037
|
+
const accepted = await confirm(
|
|
1038
|
+
"Delete all harness-evolve data (~/.harness-evolve/)?"
|
|
1039
|
+
);
|
|
1040
|
+
if (!accepted) {
|
|
1041
|
+
console.log("Data directory preserved.");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
try {
|
|
1046
|
+
await access3(paths.base, constants2.F_OK);
|
|
1047
|
+
await rm(paths.base, { recursive: true, force: true });
|
|
1048
|
+
console.log(`Deleted data directory: ${paths.base}`);
|
|
1049
|
+
} catch {
|
|
1050
|
+
console.log(`Data directory not found: ${paths.base}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function registerUninstallCommand(program2) {
|
|
1055
|
+
program2.command("uninstall").description("Remove harness-evolve hooks and optionally delete data").option("--purge", "Also delete ~/.harness-evolve/ data directory").option("--yes", "Skip confirmation prompt").action(async (opts) => {
|
|
1056
|
+
await runUninstall({
|
|
1057
|
+
purge: opts.purge ?? false,
|
|
1058
|
+
yes: opts.yes ?? false
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// src/cli/scan.ts
|
|
1064
|
+
var CONFIDENCE_ORDER = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
1065
|
+
function registerScanCommand(program2) {
|
|
1066
|
+
program2.command("scan").description("Run deep configuration scan and output results as JSON").action(async () => {
|
|
1067
|
+
try {
|
|
1068
|
+
const result = await runDeepScan(process.cwd());
|
|
1069
|
+
const sorted = [...result.recommendations].sort(
|
|
1070
|
+
(a, b) => (CONFIDENCE_ORDER[a.confidence] ?? 3) - (CONFIDENCE_ORDER[b.confidence] ?? 3)
|
|
1071
|
+
);
|
|
1072
|
+
const output = {
|
|
1073
|
+
generated_at: result.generated_at,
|
|
1074
|
+
recommendation_count: sorted.length,
|
|
1075
|
+
recommendations: sorted
|
|
1076
|
+
};
|
|
1077
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.log(JSON.stringify({
|
|
1080
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1081
|
+
recommendations: []
|
|
1082
|
+
}, null, 2));
|
|
1083
|
+
process.exitCode = 1;
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// src/cli/apply.ts
|
|
1089
|
+
import { readFile as readFile8, appendFile as appendFile2 } from "fs/promises";
|
|
1090
|
+
|
|
1091
|
+
// src/schemas/recommendation.ts
|
|
1092
|
+
import { z as z4 } from "zod/v4";
|
|
1093
|
+
var routingTargetSchema = z4.enum([
|
|
1094
|
+
"HOOK",
|
|
1095
|
+
"SKILL",
|
|
1096
|
+
"RULE",
|
|
1097
|
+
"CLAUDE_MD",
|
|
1098
|
+
"MEMORY",
|
|
1099
|
+
"SETTINGS"
|
|
1100
|
+
]);
|
|
1101
|
+
var confidenceSchema = z4.enum(["HIGH", "MEDIUM", "LOW"]);
|
|
1102
|
+
var patternTypeSchema = z4.enum([
|
|
1103
|
+
"repeated_prompt",
|
|
1104
|
+
"long_prompt",
|
|
1105
|
+
"permission-always-approved",
|
|
1106
|
+
"code_correction",
|
|
1107
|
+
"personal_info",
|
|
1108
|
+
"config_drift",
|
|
1109
|
+
"version_update",
|
|
1110
|
+
"ecosystem_gsd",
|
|
1111
|
+
"ecosystem_cog",
|
|
1112
|
+
"onboarding_start_hooks",
|
|
1113
|
+
"onboarding_start_rules",
|
|
1114
|
+
"onboarding_start_claudemd",
|
|
1115
|
+
"onboarding_optimize",
|
|
1116
|
+
"scan_redundancy",
|
|
1117
|
+
"scan_missing_mechanization",
|
|
1118
|
+
"scan_stale_reference"
|
|
1119
|
+
]);
|
|
1120
|
+
var recommendationSchema = z4.object({
|
|
1121
|
+
id: z4.string(),
|
|
1122
|
+
target: routingTargetSchema,
|
|
1123
|
+
confidence: confidenceSchema,
|
|
1124
|
+
pattern_type: patternTypeSchema,
|
|
1125
|
+
title: z4.string(),
|
|
1126
|
+
description: z4.string(),
|
|
1127
|
+
evidence: z4.object({
|
|
1128
|
+
count: z4.number(),
|
|
1129
|
+
sessions: z4.number().optional(),
|
|
1130
|
+
examples: z4.array(z4.string()).max(3)
|
|
1131
|
+
}),
|
|
1132
|
+
suggested_action: z4.string(),
|
|
1133
|
+
ecosystem_context: z4.string().optional()
|
|
1134
|
+
});
|
|
1135
|
+
var DEFAULT_THRESHOLDS = {
|
|
1136
|
+
repeated_prompt_min_count: 5,
|
|
1137
|
+
repeated_prompt_high_count: 10,
|
|
1138
|
+
repeated_prompt_high_sessions: 3,
|
|
1139
|
+
repeated_prompt_medium_sessions: 2,
|
|
1140
|
+
long_prompt_min_words: 200,
|
|
1141
|
+
long_prompt_min_count: 2,
|
|
1142
|
+
long_prompt_high_words: 300,
|
|
1143
|
+
long_prompt_high_count: 3,
|
|
1144
|
+
permission_approval_min_count: 10,
|
|
1145
|
+
permission_approval_min_sessions: 3,
|
|
1146
|
+
permission_approval_high_count: 15,
|
|
1147
|
+
permission_approval_high_sessions: 4,
|
|
1148
|
+
code_correction_min_failure_rate: 0.3,
|
|
1149
|
+
code_correction_min_failures: 3
|
|
1150
|
+
};
|
|
1151
|
+
var analysisConfigSchema = z4.object({
|
|
1152
|
+
thresholds: z4.object({
|
|
1153
|
+
repeated_prompt_min_count: z4.number().default(DEFAULT_THRESHOLDS.repeated_prompt_min_count),
|
|
1154
|
+
repeated_prompt_high_count: z4.number().default(DEFAULT_THRESHOLDS.repeated_prompt_high_count),
|
|
1155
|
+
repeated_prompt_high_sessions: z4.number().default(DEFAULT_THRESHOLDS.repeated_prompt_high_sessions),
|
|
1156
|
+
repeated_prompt_medium_sessions: z4.number().default(DEFAULT_THRESHOLDS.repeated_prompt_medium_sessions),
|
|
1157
|
+
long_prompt_min_words: z4.number().default(DEFAULT_THRESHOLDS.long_prompt_min_words),
|
|
1158
|
+
long_prompt_min_count: z4.number().default(DEFAULT_THRESHOLDS.long_prompt_min_count),
|
|
1159
|
+
long_prompt_high_words: z4.number().default(DEFAULT_THRESHOLDS.long_prompt_high_words),
|
|
1160
|
+
long_prompt_high_count: z4.number().default(DEFAULT_THRESHOLDS.long_prompt_high_count),
|
|
1161
|
+
permission_approval_min_count: z4.number().default(DEFAULT_THRESHOLDS.permission_approval_min_count),
|
|
1162
|
+
permission_approval_min_sessions: z4.number().default(DEFAULT_THRESHOLDS.permission_approval_min_sessions),
|
|
1163
|
+
permission_approval_high_count: z4.number().default(DEFAULT_THRESHOLDS.permission_approval_high_count),
|
|
1164
|
+
permission_approval_high_sessions: z4.number().default(DEFAULT_THRESHOLDS.permission_approval_high_sessions),
|
|
1165
|
+
code_correction_min_failure_rate: z4.number().default(DEFAULT_THRESHOLDS.code_correction_min_failure_rate),
|
|
1166
|
+
code_correction_min_failures: z4.number().default(DEFAULT_THRESHOLDS.code_correction_min_failures)
|
|
1167
|
+
}).default(() => ({ ...DEFAULT_THRESHOLDS })),
|
|
1168
|
+
max_recommendations: z4.number().default(20)
|
|
1169
|
+
}).default(() => ({
|
|
1170
|
+
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
1171
|
+
max_recommendations: 20
|
|
1172
|
+
}));
|
|
1173
|
+
var analysisResultSchema = z4.object({
|
|
1174
|
+
generated_at: z4.iso.datetime(),
|
|
1175
|
+
summary_period: z4.object({
|
|
1176
|
+
since: z4.string(),
|
|
1177
|
+
until: z4.string(),
|
|
1178
|
+
days: z4.number()
|
|
1179
|
+
}),
|
|
1180
|
+
recommendations: z4.array(recommendationSchema),
|
|
1181
|
+
metadata: z4.object({
|
|
1182
|
+
classifier_count: z4.number(),
|
|
1183
|
+
patterns_evaluated: z4.number(),
|
|
1184
|
+
environment_ecosystems: z4.array(z4.string()),
|
|
1185
|
+
claude_code_version: z4.string()
|
|
1186
|
+
})
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// src/delivery/auto-apply.ts
|
|
1190
|
+
import { appendFile } from "fs/promises";
|
|
1191
|
+
|
|
1192
|
+
// src/storage/config.ts
|
|
1193
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1194
|
+
import writeFileAtomic4 from "write-file-atomic";
|
|
1195
|
+
|
|
1196
|
+
// src/schemas/config.ts
|
|
1197
|
+
import { z as z5 } from "zod/v4";
|
|
1198
|
+
var configSchema = z5.object({
|
|
1199
|
+
version: z5.number().default(1),
|
|
1200
|
+
analysis: z5.object({
|
|
1201
|
+
threshold: z5.number().min(1).default(50),
|
|
1202
|
+
enabled: z5.boolean().default(true),
|
|
1203
|
+
classifierThresholds: z5.record(z5.string(), z5.number()).default({})
|
|
1204
|
+
}).default({ threshold: 50, enabled: true, classifierThresholds: {} }),
|
|
1205
|
+
hooks: z5.object({
|
|
1206
|
+
capturePrompts: z5.boolean().default(true),
|
|
1207
|
+
captureTools: z5.boolean().default(true),
|
|
1208
|
+
capturePermissions: z5.boolean().default(true),
|
|
1209
|
+
captureSessions: z5.boolean().default(true)
|
|
1210
|
+
}).default({
|
|
1211
|
+
capturePrompts: true,
|
|
1212
|
+
captureTools: true,
|
|
1213
|
+
capturePermissions: true,
|
|
1214
|
+
captureSessions: true
|
|
1215
|
+
}),
|
|
1216
|
+
scrubbing: z5.object({
|
|
1217
|
+
enabled: z5.boolean().default(true),
|
|
1218
|
+
highEntropyDetection: z5.boolean().default(false),
|
|
1219
|
+
customPatterns: z5.array(z5.object({
|
|
1220
|
+
name: z5.string(),
|
|
1221
|
+
regex: z5.string(),
|
|
1222
|
+
replacement: z5.string()
|
|
1223
|
+
})).default([])
|
|
1224
|
+
}).default({
|
|
1225
|
+
enabled: true,
|
|
1226
|
+
highEntropyDetection: false,
|
|
1227
|
+
customPatterns: []
|
|
1228
|
+
}),
|
|
1229
|
+
delivery: z5.object({
|
|
1230
|
+
stdoutInjection: z5.boolean().default(true),
|
|
1231
|
+
maxTokens: z5.number().default(200),
|
|
1232
|
+
fullAuto: z5.boolean().default(false),
|
|
1233
|
+
maxRecommendationsInFile: z5.number().default(20),
|
|
1234
|
+
archiveAfterDays: z5.number().default(7)
|
|
1235
|
+
}).default({
|
|
1236
|
+
stdoutInjection: true,
|
|
1237
|
+
maxTokens: 200,
|
|
1238
|
+
fullAuto: false,
|
|
1239
|
+
maxRecommendationsInFile: 20,
|
|
1240
|
+
archiveAfterDays: 7
|
|
1241
|
+
})
|
|
1242
|
+
}).strict();
|
|
1243
|
+
|
|
1244
|
+
// src/delivery/appliers/index.ts
|
|
1245
|
+
var registry = /* @__PURE__ */ new Map();
|
|
1246
|
+
function registerApplier(applier) {
|
|
1247
|
+
registry.set(applier.target, applier);
|
|
1248
|
+
}
|
|
1249
|
+
function getApplier(target) {
|
|
1250
|
+
return registry.get(target);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// src/delivery/appliers/settings-applier.ts
|
|
1254
|
+
import { readFile as readFile6, copyFile as copyFile3, mkdir as mkdir3 } from "fs/promises";
|
|
1255
|
+
import { join as join6, dirname as dirname4 } from "path";
|
|
1256
|
+
import writeFileAtomic5 from "write-file-atomic";
|
|
1257
|
+
var SettingsApplier = class {
|
|
1258
|
+
target = "SETTINGS";
|
|
1259
|
+
canApply(rec) {
|
|
1260
|
+
return rec.confidence === "HIGH" && rec.target === "SETTINGS" && rec.pattern_type === "permission-always-approved";
|
|
1261
|
+
}
|
|
1262
|
+
async apply(rec, options) {
|
|
1263
|
+
try {
|
|
1264
|
+
if (rec.pattern_type !== "permission-always-approved") {
|
|
1265
|
+
return {
|
|
1266
|
+
recommendation_id: rec.id,
|
|
1267
|
+
success: false,
|
|
1268
|
+
details: `Skipped: pattern_type '${rec.pattern_type}' not supported for auto-apply in v1`
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
const settingsFilePath = options?.settingsPath ?? join6(process.env.HOME ?? "", ".claude", "settings.json");
|
|
1272
|
+
const raw = await readFile6(settingsFilePath, "utf-8");
|
|
1273
|
+
const settings = JSON.parse(raw);
|
|
1274
|
+
const backup = join6(
|
|
1275
|
+
paths.analysis,
|
|
1276
|
+
"backups",
|
|
1277
|
+
`settings-backup-${rec.id}.json`
|
|
1278
|
+
);
|
|
1279
|
+
await mkdir3(dirname4(backup), { recursive: true });
|
|
1280
|
+
await copyFile3(settingsFilePath, backup);
|
|
1281
|
+
const toolName = extractToolName(rec);
|
|
1282
|
+
if (!toolName) {
|
|
1283
|
+
return {
|
|
1284
|
+
recommendation_id: rec.id,
|
|
1285
|
+
success: false,
|
|
1286
|
+
details: "Could not extract tool name from recommendation evidence"
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
const allowedTools = Array.isArray(settings.allowedTools) ? settings.allowedTools : [];
|
|
1290
|
+
if (!allowedTools.includes(toolName)) {
|
|
1291
|
+
allowedTools.push(toolName);
|
|
1292
|
+
}
|
|
1293
|
+
settings.allowedTools = allowedTools;
|
|
1294
|
+
await writeFileAtomic5(
|
|
1295
|
+
settingsFilePath,
|
|
1296
|
+
JSON.stringify(settings, null, 2)
|
|
1297
|
+
);
|
|
1298
|
+
return {
|
|
1299
|
+
recommendation_id: rec.id,
|
|
1300
|
+
success: true,
|
|
1301
|
+
details: `Added ${toolName} to allowedTools`
|
|
1302
|
+
};
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1305
|
+
return {
|
|
1306
|
+
recommendation_id: rec.id,
|
|
1307
|
+
success: false,
|
|
1308
|
+
details: message
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
function extractToolName(rec) {
|
|
1314
|
+
for (const example of rec.evidence.examples) {
|
|
1315
|
+
const match = example.match(/^(\w+)\(/);
|
|
1316
|
+
if (match) return match[1];
|
|
1317
|
+
}
|
|
1318
|
+
return void 0;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/delivery/appliers/rule-applier.ts
|
|
1322
|
+
import { writeFile as writeFile2, access as access4, mkdir as mkdir4 } from "fs/promises";
|
|
1323
|
+
import { join as join7 } from "path";
|
|
1324
|
+
var RuleApplier = class {
|
|
1325
|
+
target = "RULE";
|
|
1326
|
+
canApply(rec) {
|
|
1327
|
+
return rec.confidence === "HIGH" && rec.target === "RULE";
|
|
1328
|
+
}
|
|
1329
|
+
async apply(rec, options) {
|
|
1330
|
+
const rulesDir = options?.rulesDir ?? join7(process.env.HOME ?? "", ".claude", "rules");
|
|
1331
|
+
const fileName = `evolve-${rec.pattern_type}.md`;
|
|
1332
|
+
const filePath = join7(rulesDir, fileName);
|
|
1333
|
+
try {
|
|
1334
|
+
await access4(filePath);
|
|
1335
|
+
return {
|
|
1336
|
+
recommendation_id: rec.id,
|
|
1337
|
+
success: false,
|
|
1338
|
+
details: `Rule file already exists: ${fileName}`
|
|
1339
|
+
};
|
|
1340
|
+
} catch {
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
await mkdir4(rulesDir, { recursive: true });
|
|
1344
|
+
const content = [
|
|
1345
|
+
`# ${rec.title}`,
|
|
1346
|
+
"",
|
|
1347
|
+
rec.description,
|
|
1348
|
+
"",
|
|
1349
|
+
"## Action",
|
|
1350
|
+
"",
|
|
1351
|
+
rec.suggested_action,
|
|
1352
|
+
"",
|
|
1353
|
+
"---",
|
|
1354
|
+
`*Auto-generated by harness-evolve (${rec.id})*`
|
|
1355
|
+
].join("\n");
|
|
1356
|
+
await writeFile2(filePath, content, "utf-8");
|
|
1357
|
+
return {
|
|
1358
|
+
recommendation_id: rec.id,
|
|
1359
|
+
success: true,
|
|
1360
|
+
details: `Created rule file: ${fileName}`
|
|
1361
|
+
};
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1364
|
+
return {
|
|
1365
|
+
recommendation_id: rec.id,
|
|
1366
|
+
success: false,
|
|
1367
|
+
details: message
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
// src/delivery/appliers/hook-applier.ts
|
|
1374
|
+
import { writeFile as writeFile3, access as access5, mkdir as mkdir5, chmod, copyFile as copyFile4 } from "fs/promises";
|
|
1375
|
+
import { join as join8, basename as basename2 } from "path";
|
|
1376
|
+
|
|
1377
|
+
// src/generators/schemas.ts
|
|
1378
|
+
import { z as z6 } from "zod/v4";
|
|
1379
|
+
var GENERATOR_VERSION = "1.0.0";
|
|
1380
|
+
function nowISO() {
|
|
1381
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1382
|
+
}
|
|
1383
|
+
function toSlug(text) {
|
|
1384
|
+
if (!text) return "";
|
|
1385
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
|
|
1386
|
+
}
|
|
1387
|
+
var generatedArtifactSchema = z6.object({
|
|
1388
|
+
type: z6.enum(["skill", "hook", "claude_md_patch"]),
|
|
1389
|
+
filename: z6.string(),
|
|
1390
|
+
content: z6.string(),
|
|
1391
|
+
source_recommendation_id: z6.string(),
|
|
1392
|
+
metadata: z6.object({
|
|
1393
|
+
generated_at: z6.iso.datetime(),
|
|
1394
|
+
generator_version: z6.string(),
|
|
1395
|
+
pattern_type: z6.string()
|
|
1396
|
+
})
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
// src/generators/hook-generator.ts
|
|
1400
|
+
function extractHookEvent(rec) {
|
|
1401
|
+
const descMatch = rec.description.match(/suitable for a (\w+) hook/i);
|
|
1402
|
+
if (descMatch) return descMatch[1];
|
|
1403
|
+
const actionMatch = rec.suggested_action.match(/Create a (\w+) hook/i);
|
|
1404
|
+
if (actionMatch) return actionMatch[1];
|
|
1405
|
+
return "PreToolUse";
|
|
1406
|
+
}
|
|
1407
|
+
function generateHook(rec) {
|
|
1408
|
+
if (rec.target !== "HOOK") return null;
|
|
1409
|
+
const hookEvent = extractHookEvent(rec);
|
|
1410
|
+
const slugName = toSlug(rec.title);
|
|
1411
|
+
const content = [
|
|
1412
|
+
"#!/usr/bin/env bash",
|
|
1413
|
+
`# Auto-generated hook for: ${rec.title}`,
|
|
1414
|
+
`# Hook event: ${hookEvent}`,
|
|
1415
|
+
`# Source: harness-evolve (${rec.id})`,
|
|
1416
|
+
"#",
|
|
1417
|
+
"# TODO: Review and customize this script before use.",
|
|
1418
|
+
"",
|
|
1419
|
+
"# Read hook input from stdin",
|
|
1420
|
+
"INPUT=$(cat)",
|
|
1421
|
+
"",
|
|
1422
|
+
"# Extract relevant fields",
|
|
1423
|
+
`# Adjust jq path based on your ${hookEvent} event schema`,
|
|
1424
|
+
"",
|
|
1425
|
+
`# ${rec.suggested_action}`,
|
|
1426
|
+
"",
|
|
1427
|
+
"# Exit 0 to allow, exit 2 to block",
|
|
1428
|
+
"exit 0"
|
|
1429
|
+
].join("\n");
|
|
1430
|
+
return {
|
|
1431
|
+
type: "hook",
|
|
1432
|
+
filename: `.claude/hooks/evolve-${slugName}.sh`,
|
|
1433
|
+
content,
|
|
1434
|
+
source_recommendation_id: rec.id,
|
|
1435
|
+
metadata: {
|
|
1436
|
+
generated_at: nowISO(),
|
|
1437
|
+
generator_version: GENERATOR_VERSION,
|
|
1438
|
+
pattern_type: rec.pattern_type
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/delivery/appliers/hook-applier.ts
|
|
1444
|
+
var HookApplier = class {
|
|
1445
|
+
target = "HOOK";
|
|
1446
|
+
canApply(rec) {
|
|
1447
|
+
return rec.confidence === "HIGH" && rec.target === "HOOK";
|
|
1448
|
+
}
|
|
1449
|
+
async apply(rec, options) {
|
|
1450
|
+
try {
|
|
1451
|
+
const artifact = generateHook(rec);
|
|
1452
|
+
if (!artifact) {
|
|
1453
|
+
return {
|
|
1454
|
+
recommendation_id: rec.id,
|
|
1455
|
+
success: false,
|
|
1456
|
+
details: "Generator returned null \u2014 recommendation not applicable for hook generation"
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
const hooksDir = options?.hooksDir ?? join8(process.env.HOME ?? "", ".claude", "hooks");
|
|
1460
|
+
const scriptFilename = basename2(artifact.filename);
|
|
1461
|
+
const scriptPath = join8(hooksDir, scriptFilename);
|
|
1462
|
+
try {
|
|
1463
|
+
await access5(scriptPath);
|
|
1464
|
+
return {
|
|
1465
|
+
recommendation_id: rec.id,
|
|
1466
|
+
success: false,
|
|
1467
|
+
details: `Hook file already exists: ${scriptFilename}`
|
|
1468
|
+
};
|
|
1469
|
+
} catch {
|
|
1470
|
+
}
|
|
1471
|
+
await mkdir5(hooksDir, { recursive: true });
|
|
1472
|
+
await writeFile3(scriptPath, artifact.content, "utf-8");
|
|
1473
|
+
await chmod(scriptPath, 493);
|
|
1474
|
+
const settingsPath = options?.settingsPath ?? join8(process.env.HOME ?? "", ".claude", "settings.json");
|
|
1475
|
+
const settings = await readSettings(settingsPath);
|
|
1476
|
+
const backupDir = join8(paths.analysis, "backups");
|
|
1477
|
+
await mkdir5(backupDir, { recursive: true });
|
|
1478
|
+
const backupFile = join8(backupDir, `settings-backup-${rec.id}.json`);
|
|
1479
|
+
try {
|
|
1480
|
+
await copyFile4(settingsPath, backupFile);
|
|
1481
|
+
} catch {
|
|
1482
|
+
await writeFile3(backupFile, JSON.stringify(settings, null, 2), "utf-8");
|
|
1483
|
+
}
|
|
1484
|
+
const eventMatch = artifact.content.match(/# Hook event: (\w+)/);
|
|
1485
|
+
const hookEvent = eventMatch?.[1] ?? "PreToolUse";
|
|
1486
|
+
const merged = mergeHooks(settings, [
|
|
1487
|
+
{
|
|
1488
|
+
event: hookEvent,
|
|
1489
|
+
command: `bash "${scriptPath}"`,
|
|
1490
|
+
timeout: 10,
|
|
1491
|
+
async: true
|
|
1492
|
+
}
|
|
1493
|
+
]);
|
|
1494
|
+
await writeSettings(merged, settingsPath);
|
|
1495
|
+
return {
|
|
1496
|
+
recommendation_id: rec.id,
|
|
1497
|
+
success: true,
|
|
1498
|
+
details: `Created hook script: ${scriptFilename} and registered under ${hookEvent}`
|
|
1499
|
+
};
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1502
|
+
return {
|
|
1503
|
+
recommendation_id: rec.id,
|
|
1504
|
+
success: false,
|
|
1505
|
+
details: message
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
// src/delivery/appliers/claude-md-applier.ts
|
|
1512
|
+
import { readFile as readFile7, mkdir as mkdir6 } from "fs/promises";
|
|
1513
|
+
import { join as join9, dirname as dirname6 } from "path";
|
|
1514
|
+
import writeFileAtomic6 from "write-file-atomic";
|
|
1515
|
+
var DESTRUCTIVE_PATTERNS = /* @__PURE__ */ new Set([
|
|
1516
|
+
"scan_stale_reference",
|
|
1517
|
+
"scan_redundancy"
|
|
1518
|
+
]);
|
|
1519
|
+
var ClaudeMdApplier = class {
|
|
1520
|
+
target = "CLAUDE_MD";
|
|
1521
|
+
canApply(rec) {
|
|
1522
|
+
return rec.confidence === "HIGH" && rec.target === "CLAUDE_MD";
|
|
1523
|
+
}
|
|
1524
|
+
async apply(rec, options) {
|
|
1525
|
+
try {
|
|
1526
|
+
if (DESTRUCTIVE_PATTERNS.has(rec.pattern_type)) {
|
|
1527
|
+
return {
|
|
1528
|
+
recommendation_id: rec.id,
|
|
1529
|
+
success: false,
|
|
1530
|
+
details: `Pattern type '${rec.pattern_type}' requires manual review \u2014 cannot safely auto-apply`
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
const claudeMdPath = options?.claudeMdPath ?? join9(process.cwd(), "CLAUDE.md");
|
|
1534
|
+
let existingContent = "";
|
|
1535
|
+
try {
|
|
1536
|
+
existingContent = await readFile7(claudeMdPath, "utf-8");
|
|
1537
|
+
} catch {
|
|
1538
|
+
}
|
|
1539
|
+
if (existingContent) {
|
|
1540
|
+
const backupDir = join9(paths.analysis, "backups");
|
|
1541
|
+
await mkdir6(backupDir, { recursive: true });
|
|
1542
|
+
const backupFile = join9(backupDir, `claudemd-backup-${rec.id}.md`);
|
|
1543
|
+
await writeFileAtomic6(backupFile, existingContent);
|
|
1544
|
+
}
|
|
1545
|
+
const newSection = [
|
|
1546
|
+
"",
|
|
1547
|
+
"",
|
|
1548
|
+
`## ${rec.title}`,
|
|
1549
|
+
"",
|
|
1550
|
+
rec.suggested_action,
|
|
1551
|
+
"",
|
|
1552
|
+
"---",
|
|
1553
|
+
`*Auto-generated by harness-evolve (${rec.id})*`,
|
|
1554
|
+
""
|
|
1555
|
+
].join("\n");
|
|
1556
|
+
const updatedContent = existingContent + newSection;
|
|
1557
|
+
await mkdir6(dirname6(claudeMdPath), { recursive: true });
|
|
1558
|
+
await writeFileAtomic6(claudeMdPath, updatedContent);
|
|
1559
|
+
return {
|
|
1560
|
+
recommendation_id: rec.id,
|
|
1561
|
+
success: true,
|
|
1562
|
+
details: `Appended section: ${rec.title}`
|
|
1563
|
+
};
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1566
|
+
return {
|
|
1567
|
+
recommendation_id: rec.id,
|
|
1568
|
+
success: false,
|
|
1569
|
+
details: message
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
// src/delivery/auto-apply.ts
|
|
1576
|
+
registerApplier(new SettingsApplier());
|
|
1577
|
+
registerApplier(new RuleApplier());
|
|
1578
|
+
registerApplier(new HookApplier());
|
|
1579
|
+
registerApplier(new ClaudeMdApplier());
|
|
1580
|
+
|
|
1581
|
+
// src/cli/apply.ts
|
|
1582
|
+
var CONFIDENCE_ORDER2 = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
1583
|
+
async function loadAnalysisResult() {
|
|
1584
|
+
try {
|
|
1585
|
+
const raw = await readFile8(paths.analysisResult, "utf-8");
|
|
1586
|
+
const parsed = analysisResultSchema.parse(JSON.parse(raw));
|
|
1587
|
+
return parsed.recommendations;
|
|
1588
|
+
} catch {
|
|
1589
|
+
return [];
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
function registerPendingCommand(program2) {
|
|
1593
|
+
program2.command("pending").description("List pending recommendations as JSON").action(async () => {
|
|
1594
|
+
const allRecs = await loadAnalysisResult();
|
|
1595
|
+
const state = await loadState();
|
|
1596
|
+
const statusMap = new Map(state.entries.map((e) => [e.id, e.status]));
|
|
1597
|
+
const pending = allRecs.filter((rec) => {
|
|
1598
|
+
const status = statusMap.get(rec.id);
|
|
1599
|
+
return status === void 0 || status === "pending";
|
|
1600
|
+
}).sort(
|
|
1601
|
+
(a, b) => (CONFIDENCE_ORDER2[a.confidence] ?? 3) - (CONFIDENCE_ORDER2[b.confidence] ?? 3)
|
|
1602
|
+
);
|
|
1603
|
+
console.log(JSON.stringify({ pending, count: pending.length }, null, 2));
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
function registerApplyOneCommand(program2) {
|
|
1607
|
+
program2.command("apply-one").description("Apply a single recommendation by ID").argument("<id>", "Recommendation ID to apply").action(async (id) => {
|
|
1608
|
+
try {
|
|
1609
|
+
const allRecs = await loadAnalysisResult();
|
|
1610
|
+
const rec = allRecs.find((r) => r.id === id);
|
|
1611
|
+
if (!rec) {
|
|
1612
|
+
console.log(JSON.stringify({
|
|
1613
|
+
recommendation_id: id,
|
|
1614
|
+
success: false,
|
|
1615
|
+
details: `Recommendation '${id}' not found`
|
|
1616
|
+
}));
|
|
1617
|
+
process.exitCode = 1;
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
const applier = getApplier(rec.target);
|
|
1621
|
+
if (!applier || !applier.canApply(rec)) {
|
|
1622
|
+
console.log(JSON.stringify({
|
|
1623
|
+
recommendation_id: id,
|
|
1624
|
+
success: false,
|
|
1625
|
+
details: `No applicable applier for target '${rec.target}'`
|
|
1626
|
+
}));
|
|
1627
|
+
process.exitCode = 1;
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const result = await applier.apply(rec);
|
|
1631
|
+
const logEntry = {
|
|
1632
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1633
|
+
recommendation_id: rec.id,
|
|
1634
|
+
target: rec.target,
|
|
1635
|
+
action: rec.suggested_action,
|
|
1636
|
+
success: result.success,
|
|
1637
|
+
details: result.details
|
|
1638
|
+
};
|
|
1639
|
+
try {
|
|
1640
|
+
await appendFile2(paths.autoApplyLog, JSON.stringify(logEntry) + "\n", "utf-8");
|
|
1641
|
+
} catch {
|
|
1642
|
+
}
|
|
1643
|
+
if (result.success) {
|
|
1644
|
+
await updateStatus(id, "applied", `Applied via /evolve:apply: ${result.details}`);
|
|
1645
|
+
}
|
|
1646
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
console.log(JSON.stringify({
|
|
1649
|
+
recommendation_id: id,
|
|
1650
|
+
success: false,
|
|
1651
|
+
details: err instanceof Error ? err.message : String(err)
|
|
1652
|
+
}));
|
|
1653
|
+
process.exitCode = 1;
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
function registerDismissCommand(program2) {
|
|
1658
|
+
program2.command("dismiss").description("Permanently dismiss a recommendation by ID").argument("<id>", "Recommendation ID to dismiss").action(async (id) => {
|
|
1659
|
+
try {
|
|
1660
|
+
await updateStatus(id, "dismissed", "Dismissed by user via /evolve:apply");
|
|
1661
|
+
console.log(JSON.stringify({ id, status: "dismissed" }, null, 2));
|
|
1662
|
+
} catch (err) {
|
|
1663
|
+
console.log(JSON.stringify({
|
|
1664
|
+
id,
|
|
1665
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1666
|
+
}));
|
|
1667
|
+
process.exitCode = 1;
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// src/cli.ts
|
|
1673
|
+
var pkg = JSON.parse(
|
|
1674
|
+
readFileSync(join10(import.meta.dirname, "..", "package.json"), "utf-8")
|
|
1675
|
+
);
|
|
1676
|
+
var program = new Command().name("harness-evolve").description("Self-iteration engine for Claude Code").version(pkg.version);
|
|
1677
|
+
registerInitCommand(program);
|
|
1678
|
+
registerStatusCommand(program);
|
|
1679
|
+
registerUninstallCommand(program);
|
|
1680
|
+
registerScanCommand(program);
|
|
1681
|
+
registerPendingCommand(program);
|
|
1682
|
+
registerApplyOneCommand(program);
|
|
1683
|
+
registerDismissCommand(program);
|
|
1684
|
+
program.parse();
|
|
1685
|
+
//# sourceMappingURL=cli.js.map
|