vigiles 4.0.1 → 4.0.2
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/adapter-conformance.js +1 -1
- package/dist/check.d.ts +132 -0
- package/dist/check.js +318 -0
- package/dist/cli.js +130 -52
- package/dist/core/compile.d.ts +1 -1
- package/dist/core/compile.js +1 -1
- package/dist/core/compose.d.ts +1 -1
- package/dist/core/compose.js +1 -1
- package/dist/core/generate-schema.d.ts +1 -1
- package/dist/core/generate-schema.js +4 -4
- package/dist/core/linters.js +2 -2
- package/dist/core/orphans.js +57 -14
- package/dist/core/proofs.js +1 -1
- package/dist/core/refs.d.ts +1 -1
- package/dist/core/refs.js +2 -2
- package/dist/core/sidecar.d.ts +1 -1
- package/dist/core/sidecar.js +1 -1
- package/dist/core/spec.d.ts +1 -1
- package/dist/core/spec.js +1 -1
- package/dist/core/types.d.ts +1 -1
- package/dist/core/validate.js +2 -2
- package/dist/e2e.d.ts +10 -13
- package/dist/e2e.js +10 -17
- package/dist/eval.d.ts +217 -2
- package/dist/eval.js +428 -18
- package/dist/harness-assert.d.ts +3 -0
- package/dist/harness-assert.js +16 -0
- package/dist/harness-test.d.ts +46 -0
- package/dist/harness-test.js +102 -0
- package/dist/integration.d.ts +8 -0
- package/dist/integration.js +10 -0
- package/dist/jest.d.ts +3 -1
- package/dist/jest.js +3 -2
- package/dist/run-hook.d.ts +22 -0
- package/dist/run-hook.js +28 -0
- package/dist/scan.d.ts +1 -1
- package/dist/scan.js +1 -1
- package/dist/setup-plan.d.ts +5 -1
- package/dist/setup-plan.js +11 -1
- package/dist/test-coverage.js +8 -1
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +7 -0
- package/dist/unit.d.ts +4 -2
- package/dist/unit.js +7 -1
- package/dist/vitest.d.mts +3 -1
- package/hooks/refs-nudge.sh +1 -1
- package/package.json +3 -2
- package/skills/edit-spec/SKILL.md +21 -10
- package/skills/linter-docs/SKILL.md +23 -0
- package/skills/migrate-to-spec/SKILL.md +1 -1
- package/skills/strengthen/SKILL.md +1 -2
- package/skills/generate-rule/SKILL.md +0 -64
package/dist/eval.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runEval = runEval;
|
|
4
|
+
exports.measureWith = measureWith;
|
|
5
|
+
exports.measure = measure;
|
|
6
|
+
exports.measureArmsWith = measureArmsWith;
|
|
7
|
+
exports.measureArms = measureArms;
|
|
8
|
+
exports.compareCheck = compareCheck;
|
|
9
|
+
exports.formatCheckReport = formatCheckReport;
|
|
10
|
+
exports.assertRates = assertRates;
|
|
11
|
+
exports.checkReportToJUnit = checkReportToJUnit;
|
|
4
12
|
exports.parseUsage = parseUsage;
|
|
5
13
|
exports.aggregate = aggregate;
|
|
6
14
|
exports.aggregateStats = aggregateStats;
|
|
@@ -9,6 +17,12 @@ exports.isRateLimited = isRateLimited;
|
|
|
9
17
|
exports.runPool = runPool;
|
|
10
18
|
exports.runEvalWith = runEvalWith;
|
|
11
19
|
exports.formatEvalReport = formatEvalReport;
|
|
20
|
+
exports.packageSkillsDir = packageSkillsDir;
|
|
21
|
+
exports.stubSkillBody = stubSkillBody;
|
|
22
|
+
exports.stubbedPluginDir = stubbedPluginDir;
|
|
23
|
+
exports.promptDistance = promptDistance;
|
|
24
|
+
exports.checkPromptDiversity = checkPromptDiversity;
|
|
25
|
+
exports.assertPromptDiversity = assertPromptDiversity;
|
|
12
26
|
exports.measureTriggerRateWith = measureTriggerRateWith;
|
|
13
27
|
exports.measureTriggerRate = measureTriggerRate;
|
|
14
28
|
exports.formatTriggerRateReport = formatTriggerRateReport;
|
|
@@ -41,8 +55,10 @@ const node_os_1 = require("node:os");
|
|
|
41
55
|
const node_path_1 = require("node:path");
|
|
42
56
|
const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
|
|
43
57
|
const runtime_js_1 = require("./adapters/claude-code/runtime.js");
|
|
58
|
+
const proofs_js_1 = require("./core/proofs.js");
|
|
44
59
|
const harness_test_js_1 = require("./harness-test.js");
|
|
45
60
|
const eval_cache_js_1 = require("./eval-cache.js");
|
|
61
|
+
const stats_js_1 = require("./stats.js");
|
|
46
62
|
function writeFiles(cwd, files) {
|
|
47
63
|
for (const [p, content] of Object.entries(files)) {
|
|
48
64
|
const full = (0, node_path_1.resolve)(cwd, p);
|
|
@@ -98,7 +114,207 @@ function spawnAgent(a) {
|
|
|
98
114
|
async function runEval(spec) {
|
|
99
115
|
return runEvalWith(spec, spawnAgent);
|
|
100
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Score a check vocabulary across trials — the scored counterpart to
|
|
119
|
+
* `assertChecks` (strict). Each check yields a `rate ± se` and `pass^k` over `n`
|
|
120
|
+
* runs. Reuses the tested `runEvalWith` aggregation (one arm), so the loop,
|
|
121
|
+
* cache, concurrency, and stats come for free. Exported with an injectable
|
|
122
|
+
* `runner` so the orchestration is unit-testable without a model.
|
|
123
|
+
*/
|
|
124
|
+
async function measureWith(spec, runner) {
|
|
125
|
+
if (spec.stubSkillBodies && !spec.pluginDir)
|
|
126
|
+
throw new Error("measure: `stubSkillBodies` requires `pluginDir`.");
|
|
127
|
+
const stubbed = spec.stubSkillBodies
|
|
128
|
+
? stubbedPluginDir(spec.pluginDir)
|
|
129
|
+
: undefined;
|
|
130
|
+
const pluginDir = stubbed ?? spec.pluginDir;
|
|
131
|
+
try {
|
|
132
|
+
const keyed = spec.checks.map((c, i) => [`c${String(i)}`, c]);
|
|
133
|
+
const report = await runEvalWith({
|
|
134
|
+
fixture: spec.fixture,
|
|
135
|
+
arms: {
|
|
136
|
+
run: {
|
|
137
|
+
settings: spec.settings,
|
|
138
|
+
plugin: spec.plugin,
|
|
139
|
+
pluginDir,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
task: spec.task,
|
|
143
|
+
trials: spec.trials ?? 5,
|
|
144
|
+
model: spec.model ?? "sonnet",
|
|
145
|
+
allowedTools: spec.allowedTools,
|
|
146
|
+
timeoutMs: spec.timeoutMs,
|
|
147
|
+
spacingSec: spec.spacingSec,
|
|
148
|
+
measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
|
|
149
|
+
}, runner);
|
|
150
|
+
const arm = report.arms.run;
|
|
151
|
+
return {
|
|
152
|
+
n: arm?.runs ?? 0,
|
|
153
|
+
perCheck: keyed.map(([k, c]) => {
|
|
154
|
+
const s = arm?.stats[k];
|
|
155
|
+
return {
|
|
156
|
+
check: c.toJSON(),
|
|
157
|
+
rate: s?.mean ?? 0,
|
|
158
|
+
se: s?.se ?? 0,
|
|
159
|
+
passK: s?.passK ?? 0,
|
|
160
|
+
n: s?.n ?? 0,
|
|
161
|
+
};
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (stubbed)
|
|
167
|
+
(0, node_fs_1.rmSync)(stubbed, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/* v8 ignore start -- real claude subprocess; thin wrapper over measureWith */
|
|
171
|
+
/** Score a check vocabulary across trials against the real `claude` CLI. */
|
|
172
|
+
async function measure(spec) {
|
|
173
|
+
return measureWith(spec, spawnAgent);
|
|
174
|
+
}
|
|
175
|
+
/** Score checks across arms (injectable runner). Reuses `runEvalWith`. */
|
|
176
|
+
async function measureArmsWith(spec, runner) {
|
|
177
|
+
const keyed = spec.checks.map((c, i) => [`c${String(i)}`, c]);
|
|
178
|
+
const { arms: runArms, temps } = spec.stubSkillBodies
|
|
179
|
+
? stubArmPluginDirs(spec.arms)
|
|
180
|
+
: { arms: spec.arms, temps: [] };
|
|
181
|
+
try {
|
|
182
|
+
const report = await runEvalWith({
|
|
183
|
+
fixture: spec.fixture,
|
|
184
|
+
arms: runArms,
|
|
185
|
+
task: spec.task,
|
|
186
|
+
trials: spec.trials ?? 5,
|
|
187
|
+
model: spec.model ?? "sonnet",
|
|
188
|
+
allowedTools: spec.allowedTools,
|
|
189
|
+
timeoutMs: spec.timeoutMs,
|
|
190
|
+
spacingSec: spec.spacingSec,
|
|
191
|
+
measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
|
|
192
|
+
}, runner);
|
|
193
|
+
const arms = {};
|
|
194
|
+
for (const [armName, arm] of Object.entries(report.arms)) {
|
|
195
|
+
arms[armName] = {
|
|
196
|
+
n: arm.runs,
|
|
197
|
+
perCheck: keyed.map(([k, c]) => {
|
|
198
|
+
const s = arm.stats[k];
|
|
199
|
+
return {
|
|
200
|
+
check: c.toJSON(),
|
|
201
|
+
rate: s?.mean ?? 0,
|
|
202
|
+
se: s?.se ?? 0,
|
|
203
|
+
passK: s?.passK ?? 0,
|
|
204
|
+
n: s?.n ?? 0,
|
|
205
|
+
};
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return { arms };
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
for (const t of temps)
|
|
213
|
+
(0, node_fs_1.rmSync)(t, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Repackage every arm that sets a `pluginDir` with its skill bodies stubbed
|
|
218
|
+
* (frontmatter kept), for an A/B firing comparison. Returns the rewritten arms
|
|
219
|
+
* plus the throwaway dirs the caller must remove. Arms without a `pluginDir` pass
|
|
220
|
+
* through unchanged. See {@link stubbedPluginDir}.
|
|
221
|
+
*/
|
|
222
|
+
function stubArmPluginDirs(arms) {
|
|
223
|
+
const out = {};
|
|
224
|
+
const temps = [];
|
|
225
|
+
for (const [name, arm] of Object.entries(arms)) {
|
|
226
|
+
if (arm.pluginDir) {
|
|
227
|
+
const stubbed = stubbedPluginDir(arm.pluginDir);
|
|
228
|
+
temps.push(stubbed);
|
|
229
|
+
out[name] = { ...arm, pluginDir: stubbed };
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
out[name] = arm;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { arms: out, temps };
|
|
236
|
+
}
|
|
237
|
+
/* v8 ignore start -- real claude subprocess; thin wrapper over measureArmsWith */
|
|
238
|
+
/** Score checks across arms against the real `claude` CLI. */
|
|
239
|
+
async function measureArms(spec) {
|
|
240
|
+
return measureArmsWith(spec, spawnAgent);
|
|
241
|
+
}
|
|
101
242
|
/* v8 ignore stop */
|
|
243
|
+
/**
|
|
244
|
+
* Welch significance on one check's rate between two arms (`arm` vs `baseline`),
|
|
245
|
+
* by index in `perCheck`. So "the gated arm resolves the skill significantly more
|
|
246
|
+
* than vanilla" is a p-value, not a vibe. Reuses `welchTTest` from stats.ts.
|
|
247
|
+
*/
|
|
248
|
+
function compareCheck(report, baseline, arm, checkIndex) {
|
|
249
|
+
const b = report.arms[baseline]?.perCheck[checkIndex];
|
|
250
|
+
const a = report.arms[arm]?.perCheck[checkIndex];
|
|
251
|
+
if (!a || !b) {
|
|
252
|
+
throw new Error(`compareCheck: unknown arm or check index (baseline="${baseline}", arm="${arm}", i=${String(checkIndex)})`);
|
|
253
|
+
}
|
|
254
|
+
return (0, stats_js_1.welchTTest)({ mean: a.rate, se: a.se, n: a.n }, { mean: b.rate, se: b.se, n: b.n });
|
|
255
|
+
}
|
|
256
|
+
/** A readable label for a check from its serialized form, e.g. `tool(Bash)`. */
|
|
257
|
+
function checkLabel(json) {
|
|
258
|
+
const arg = json.name ?? json.id ?? json.event ?? json.path ?? json.matcher;
|
|
259
|
+
if (typeof arg === "string" ||
|
|
260
|
+
typeof arg === "number" ||
|
|
261
|
+
typeof arg === "boolean") {
|
|
262
|
+
return `${json.kind}(${String(arg)})`;
|
|
263
|
+
}
|
|
264
|
+
return json.kind;
|
|
265
|
+
}
|
|
266
|
+
/** Format a {@link CheckReport}: one line per check with its rate ± se and pass^k. */
|
|
267
|
+
function formatCheckReport(report) {
|
|
268
|
+
const lines = [`measured ${String(report.n)} run(s):`];
|
|
269
|
+
for (const c of report.perCheck) {
|
|
270
|
+
lines.push(` ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}% ${checkLabel(c.check)}` +
|
|
271
|
+
` (pass^k ${String(c.passK)})`);
|
|
272
|
+
}
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The scored gate (Phase 4): throw if any check's measured rate is below `min` —
|
|
277
|
+
* the `measure` counterpart to `assertChecks` (strict). Reads the rate, not a
|
|
278
|
+
* single run, so it never trips on one noisy trial.
|
|
279
|
+
*/
|
|
280
|
+
function assertRates(report, opts) {
|
|
281
|
+
const below = report.perCheck.filter((c) => c.rate < opts.min);
|
|
282
|
+
if (below.length > 0) {
|
|
283
|
+
throw new Error(`${String(below.length)} check(s) below the ${(opts.min * 100).toFixed(0)}% min rate:\n` +
|
|
284
|
+
below
|
|
285
|
+
.map((c) => ` ✗ ${checkLabel(c.check)}: ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}%`)
|
|
286
|
+
.join("\n"));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function escapeXml(s) {
|
|
290
|
+
return s
|
|
291
|
+
.replace(/&/g, "&")
|
|
292
|
+
.replace(/</g, "<")
|
|
293
|
+
.replace(/>/g, ">")
|
|
294
|
+
.replace(/"/g, """);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Serialize a {@link CheckReport} to JUnit XML (Phase 4) — each check a
|
|
298
|
+
* `<testcase>`, failing when its rate is below `min`. Because a check is *data*,
|
|
299
|
+
* this falls out for free: CI test reporters, regression baselines, and a
|
|
300
|
+
* promptfoo bridge all consume the same shape.
|
|
301
|
+
*/
|
|
302
|
+
function checkReportToJUnit(report, opts = {}) {
|
|
303
|
+
const min = opts.min ?? 0;
|
|
304
|
+
const failures = report.perCheck.filter((c) => c.rate < min).length;
|
|
305
|
+
const cases = report.perCheck
|
|
306
|
+
.map((c) => {
|
|
307
|
+
const name = escapeXml(checkLabel(c.check));
|
|
308
|
+
const body = c.rate < min
|
|
309
|
+
? `\n <failure message="rate ${(c.rate * 100).toFixed(0)}% below min ${(min * 100).toFixed(0)}% (n=${String(c.n)})"/>\n `
|
|
310
|
+
: "";
|
|
311
|
+
return ` <testcase classname="vigiles.checks" name="${name}">${body}</testcase>`;
|
|
312
|
+
})
|
|
313
|
+
.join("\n");
|
|
314
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
315
|
+
`<testsuite name="${escapeXml(opts.name ?? "vigiles measure")}" tests="${String(report.perCheck.length)}" failures="${String(failures)}">\n` +
|
|
316
|
+
`${cases}\n</testsuite>\n`);
|
|
317
|
+
}
|
|
102
318
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
103
319
|
/** Pull cost / latency / tokens out of a parsed `result` event (0 when absent). */
|
|
104
320
|
function usageFrom(result) {
|
|
@@ -127,6 +343,7 @@ function makeContext(cwd, out) {
|
|
|
127
343
|
toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
|
|
128
344
|
hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
|
|
129
345
|
output,
|
|
346
|
+
subagents: (0, harness_test_js_1.parseSubagents)(out.stdout),
|
|
130
347
|
usage: usageFrom(result),
|
|
131
348
|
// The eval tier drives the real API (no mock between claude and the model),
|
|
132
349
|
// so the requests can't be captured here — modelRequests is harness-tier only.
|
|
@@ -144,7 +361,7 @@ function makeContext(cwd, out) {
|
|
|
144
361
|
}).trim();
|
|
145
362
|
}
|
|
146
363
|
catch (e) {
|
|
147
|
-
// Return captured stdout even on a non-zero exit (e.g. `
|
|
364
|
+
// Return captured stdout even on a non-zero exit (e.g. `lint` exits 2
|
|
148
365
|
// but still prints its findings), rather than swallowing it.
|
|
149
366
|
const out = e.stdout;
|
|
150
367
|
return typeof out === "string" ? out.trim() : "";
|
|
@@ -402,6 +619,178 @@ function formatEvalReport(report) {
|
|
|
402
619
|
}
|
|
403
620
|
return lines.join("\n");
|
|
404
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
|
|
624
|
+
* that `claude --plugin-dir` accepts — so repo-local skills (e.g. `.claude/skills`)
|
|
625
|
+
* can be trigger-tested without hand-rolling a `plugin.json`. Writes a minimal
|
|
626
|
+
* `.claude-plugin/plugin.json` and copies each `<name>/` (recursively, so
|
|
627
|
+
* `references/` etc. come along) under `skills/<name>/`. Returns the temp plugin
|
|
628
|
+
* dir; the caller removes it (`measureTriggerRate` does). Throws if the directory
|
|
629
|
+
* is missing or holds no `<name>/SKILL.md`.
|
|
630
|
+
*/
|
|
631
|
+
function packageSkillsDir(skillsDir, opts = {}) {
|
|
632
|
+
const abs = (0, node_path_1.resolve)(skillsDir);
|
|
633
|
+
if (!(0, node_fs_1.existsSync)(abs))
|
|
634
|
+
throw new Error(`skillsDir not found: ${skillsDir} (resolved ${abs})`);
|
|
635
|
+
const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-skills-"));
|
|
636
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
|
|
637
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name ?? "vigiles-loose-skills", version: "0.0.0" }, null, 2));
|
|
638
|
+
const skillsOut = (0, node_path_1.join)(root, "skills");
|
|
639
|
+
(0, node_fs_1.mkdirSync)(skillsOut, { recursive: true });
|
|
640
|
+
let copied = 0;
|
|
641
|
+
for (const entry of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
|
|
642
|
+
if (!entry.isDirectory())
|
|
643
|
+
continue;
|
|
644
|
+
const srcSkill = (0, node_path_1.join)(abs, entry.name, "SKILL.md");
|
|
645
|
+
if (!(0, node_fs_1.existsSync)(srcSkill))
|
|
646
|
+
continue;
|
|
647
|
+
const destDir = (0, node_path_1.join)(skillsOut, entry.name);
|
|
648
|
+
if (opts.stub) {
|
|
649
|
+
// Frontmatter-only: keep the trigger surface (name + description), drop the
|
|
650
|
+
// body so a selected skill stops instead of running its procedure.
|
|
651
|
+
(0, node_fs_1.mkdirSync)(destDir, { recursive: true });
|
|
652
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(destDir, "SKILL.md"), stubSkillBody((0, node_fs_1.readFileSync)(srcSkill, "utf-8")));
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
(0, node_fs_1.cpSync)((0, node_path_1.join)(abs, entry.name), destDir, { recursive: true });
|
|
656
|
+
}
|
|
657
|
+
copied++;
|
|
658
|
+
}
|
|
659
|
+
if (copied === 0) {
|
|
660
|
+
(0, node_fs_1.rmSync)(root, { recursive: true, force: true });
|
|
661
|
+
throw new Error(`No <name>/SKILL.md skills found under ${skillsDir}`);
|
|
662
|
+
}
|
|
663
|
+
return root;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Rewrite a SKILL.md to keep its YAML frontmatter (the trigger surface — name +
|
|
667
|
+
* description) but replace the body with a no-op stub. Trigger-rate is a property
|
|
668
|
+
* of the frontmatter ONLY: the model picks a skill from its name + description
|
|
669
|
+
* before the body is ever loaded, so the body is causally downstream of selection
|
|
670
|
+
* and irrelevant to whether the skill fires. Stubbing it lets a trigger run stop
|
|
671
|
+
* AT selection instead of executing an expensive multi-step procedure — cheaper,
|
|
672
|
+
* faster, and side-effect-free, without changing what's measured. Pure.
|
|
673
|
+
*/
|
|
674
|
+
function stubSkillBody(skillMd) {
|
|
675
|
+
const m = /^(---\n[\s\S]*?\n---\n)/.exec(skillMd);
|
|
676
|
+
const frontmatter = m ? m[1] : "";
|
|
677
|
+
return `${frontmatter}\nThis skill was selected (trigger-test stub). Acknowledge and stop — do not perform any actions.\n`;
|
|
678
|
+
}
|
|
679
|
+
/** The skills directory inside a plugin (`skills/` or `.claude/skills/`). */
|
|
680
|
+
function skillsDirOf(pluginDir) {
|
|
681
|
+
const direct = (0, node_path_1.join)(pluginDir, "skills");
|
|
682
|
+
if ((0, node_fs_1.existsSync)(direct))
|
|
683
|
+
return direct;
|
|
684
|
+
return (0, node_path_1.join)(pluginDir, ".claude", "skills");
|
|
685
|
+
}
|
|
686
|
+
/** A plugin's declared name (from `.claude-plugin/plugin.json`), for the skill
|
|
687
|
+
* id the `fired` predicate matches (`<name>:<skill>`). */
|
|
688
|
+
function pluginName(pluginDir) {
|
|
689
|
+
const manifest = (0, node_path_1.join)(pluginDir, ".claude-plugin", "plugin.json");
|
|
690
|
+
try {
|
|
691
|
+
return JSON.parse((0, node_fs_1.readFileSync)(manifest, "utf-8"))
|
|
692
|
+
.name;
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Build a throwaway plugin dir mirroring `pluginDir`'s skills with their BODIES
|
|
700
|
+
* stripped (frontmatter kept) — the trigger surface a description/firing check
|
|
701
|
+
* needs, without paying to run each skill's procedure. Keeps the original plugin
|
|
702
|
+
* NAME so `<name>:<skill>` ids still match. The caller removes the returned dir.
|
|
703
|
+
* See {@link stubSkillBody} for why the body is irrelevant to selection.
|
|
704
|
+
*/
|
|
705
|
+
function stubbedPluginDir(pluginDir) {
|
|
706
|
+
return packageSkillsDir(skillsDirOf(pluginDir), {
|
|
707
|
+
stub: true,
|
|
708
|
+
name: pluginName(pluginDir),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
// Prompt-set diversity (deterministic, pre-eval) — a trigger rate is only
|
|
713
|
+
// meaningful over ENOUGH and DIFFERENT prompts. Catch a too-small or
|
|
714
|
+
// near-duplicate set before spending a single model token. We measure
|
|
715
|
+
// "different" with Normalized Compression Distance (gzip) — the SAME engine
|
|
716
|
+
// `findSimilarRules` uses for near-duplicate rule detection — not edit
|
|
717
|
+
// distance: NCD scores shared structure/redundancy (a templated prompt with one
|
|
718
|
+
// word swapped compresses together), which is exactly the lazy-copy-paste set
|
|
719
|
+
// we want to reject, and it's the project's house algorithm for "are these two
|
|
720
|
+
// texts basically the same".
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
/** Normalize for comparison: lowercase, trim, collapse whitespace. */
|
|
723
|
+
function normalizePrompt(s) {
|
|
724
|
+
return s.toLowerCase().trim().replace(/\s+/g, " ");
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Distance between two prompts in ~0..1 (0 = identical, higher = more
|
|
728
|
+
* different) via Normalized Compression Distance over the normalized text.
|
|
729
|
+
* Reuses {@link ncd} from the proof engine.
|
|
730
|
+
*/
|
|
731
|
+
function promptDistance(a, b) {
|
|
732
|
+
return (0, proofs_js_1.ncd)(normalizePrompt(a), normalizePrompt(b));
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Deterministically check a prompt set is big and varied enough to measure a
|
|
736
|
+
* trigger rate: at least `minPrompts` entries, and no two closer than
|
|
737
|
+
* `minDistance` in NCD. Pure — no model. `label` names the set in messages.
|
|
738
|
+
*/
|
|
739
|
+
function checkPromptDiversity(prompts, opts = {}) {
|
|
740
|
+
const minPrompts = opts.minPrompts ?? 10;
|
|
741
|
+
const minDistance = opts.minDistance ?? 0.3;
|
|
742
|
+
const label = opts.label ?? "prompts";
|
|
743
|
+
const issues = [];
|
|
744
|
+
if (prompts.length < minPrompts) {
|
|
745
|
+
issues.push({
|
|
746
|
+
kind: "too-few",
|
|
747
|
+
message: `${label}: ${String(prompts.length)} prompt(s), need at least ${String(minPrompts)} to measure a rate (set minPrompts to override).`,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
751
|
+
for (let j = i + 1; j < prompts.length; j++) {
|
|
752
|
+
const dist = promptDistance(prompts[i], prompts[j]);
|
|
753
|
+
if (dist < minDistance) {
|
|
754
|
+
issues.push({
|
|
755
|
+
kind: "too-similar",
|
|
756
|
+
message: `${label}: prompts #${String(i + 1)} and #${String(j + 1)} are near-duplicates (NCD ${dist.toFixed(2)} < ${String(minDistance)}) — vary the phrasing:\n - ${prompts[i]}\n - ${prompts[j]}`,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return issues;
|
|
762
|
+
}
|
|
763
|
+
/** Throw if a prompt set isn't big/varied enough. See {@link checkPromptDiversity}. */
|
|
764
|
+
function assertPromptDiversity(prompts, opts = {}) {
|
|
765
|
+
const issues = checkPromptDiversity(prompts, opts);
|
|
766
|
+
if (issues.length > 0) {
|
|
767
|
+
throw new Error(`Prompt set is not eval-ready:\n ${issues.map((i) => i.message).join("\n ")}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Resolve the effective `--plugin-dir` for a trigger run: a caller's `pluginDir`
|
|
772
|
+
* as-is, or a throwaway package built from a loose `skillsDir`. Exactly one must
|
|
773
|
+
* be set. `packaged` is present only when vigiles built it, so the caller knows
|
|
774
|
+
* to remove it afterward.
|
|
775
|
+
*/
|
|
776
|
+
function resolveTriggerPluginDir(spec) {
|
|
777
|
+
if (spec.pluginDir && spec.skillsDir)
|
|
778
|
+
throw new Error("measureTriggerRate: set `pluginDir` OR `skillsDir`, not both.");
|
|
779
|
+
const stub = spec.stubSkillBodies ?? false;
|
|
780
|
+
if (spec.skillsDir) {
|
|
781
|
+
const packaged = packageSkillsDir(spec.skillsDir, { stub });
|
|
782
|
+
return { pluginDir: packaged, packaged };
|
|
783
|
+
}
|
|
784
|
+
if (spec.pluginDir) {
|
|
785
|
+
if (!stub)
|
|
786
|
+
return { pluginDir: spec.pluginDir };
|
|
787
|
+
// Stub a real plugin: build a minimal plugin from its skills/ with bodies
|
|
788
|
+
// stripped — keep the original plugin NAME so `<name>:<skill>` still matches.
|
|
789
|
+
const packaged = stubbedPluginDir(spec.pluginDir);
|
|
790
|
+
return { pluginDir: packaged, packaged };
|
|
791
|
+
}
|
|
792
|
+
throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
|
|
793
|
+
}
|
|
405
794
|
/** Run one prompt set × trials through `runner`, aggregating fired counts. */
|
|
406
795
|
async function runTriggerSet(prompts, cfg, runner) {
|
|
407
796
|
const perPrompt = [];
|
|
@@ -449,31 +838,52 @@ async function runTriggerSet(prompts, cfg, runner) {
|
|
|
449
838
|
* `measureTriggerRate` is this with the real agent runner.
|
|
450
839
|
*/
|
|
451
840
|
async function measureTriggerRateWith(spec, runner) {
|
|
841
|
+
// Deterministic gate FIRST — reject a too-small / near-duplicate prompt set
|
|
842
|
+
// before spending a token (and before packaging a skillsDir).
|
|
843
|
+
const diversity = {
|
|
844
|
+
minPrompts: spec.minPrompts,
|
|
845
|
+
minDistance: spec.minDistance,
|
|
846
|
+
};
|
|
847
|
+
assertPromptDiversity(spec.prompts, { ...diversity, label: "prompts" });
|
|
848
|
+
if (spec.irrelevantPrompts && spec.irrelevantPrompts.length > 0) {
|
|
849
|
+
assertPromptDiversity(spec.irrelevantPrompts, {
|
|
850
|
+
...diversity,
|
|
851
|
+
label: "irrelevantPrompts",
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
const { pluginDir, packaged } = resolveTriggerPluginDir(spec);
|
|
452
855
|
const cfg = {
|
|
453
856
|
trials: spec.trials ?? 1,
|
|
454
857
|
model: spec.model ?? "haiku",
|
|
455
858
|
tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"],
|
|
456
859
|
timeoutMs: spec.timeoutMs ?? 240000,
|
|
457
860
|
spacing: (spec.spacingSec ?? 4) * 1000,
|
|
458
|
-
pluginDir
|
|
861
|
+
pluginDir,
|
|
459
862
|
fired: spec.fired,
|
|
460
863
|
};
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
864
|
+
try {
|
|
865
|
+
const relevant = await runTriggerSet(spec.prompts, cfg, runner);
|
|
866
|
+
const base = {
|
|
867
|
+
rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
|
|
868
|
+
n: relevant.n,
|
|
869
|
+
perPrompt: relevant.perPrompt,
|
|
870
|
+
};
|
|
871
|
+
if ((spec.irrelevantPrompts?.length ?? 0) === 0)
|
|
872
|
+
return base;
|
|
873
|
+
const irrelevant = await runTriggerSet(spec.irrelevantPrompts ?? [], cfg, runner);
|
|
874
|
+
const fires = relevant.fired + irrelevant.fired;
|
|
875
|
+
return {
|
|
876
|
+
...base,
|
|
877
|
+
falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
|
|
878
|
+
precision: fires > 0 ? relevant.fired / fires : undefined,
|
|
879
|
+
perIrrelevant: irrelevant.perPrompt,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
finally {
|
|
883
|
+
// Remove the throwaway plugin dir we built from a loose `skillsDir`.
|
|
884
|
+
if (packaged)
|
|
885
|
+
(0, node_fs_1.rmSync)(packaged, { recursive: true, force: true });
|
|
886
|
+
}
|
|
477
887
|
}
|
|
478
888
|
/* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
|
|
479
889
|
/**
|
package/dist/harness-assert.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { type HarnessTestSpec, type HarnessTestResult, type ToolCall, type Trace } from "./harness-test.js";
|
|
17
17
|
import type { EvalReport, TriggerRateReport } from "./eval.js";
|
|
18
18
|
import type { HookRunResult, EgressAttempt } from "./run-hook.js";
|
|
19
|
+
import { type Check } from "./check.js";
|
|
19
20
|
import type { OutputContract } from "./core/spec.js";
|
|
20
21
|
import { type ParsedAgentResult } from "./adapters/claude-code/agent-result.js";
|
|
21
22
|
import { type BaselineFile, type DiffOptions } from "./eval-baseline.js";
|
|
@@ -282,5 +283,7 @@ export declare const vigilesMatchers: {
|
|
|
282
283
|
toHaveCreated(received: HarnessTestResult, path: string): MatcherOutput;
|
|
283
284
|
toBlock(received: HookRunResult): MatcherOutput;
|
|
284
285
|
toBeatBaseline(received: EvalReport, baseline: string, arm: string, metric: string, by?: number): MatcherOutput;
|
|
286
|
+
toPass<T>(received: T, check: Check<T>): MatcherOutput;
|
|
287
|
+
toPassAll<T>(received: T, checks: readonly Check<T>[]): MatcherOutput;
|
|
285
288
|
};
|
|
286
289
|
//# sourceMappingURL=harness-assert.d.ts.map
|
package/dist/harness-assert.js
CHANGED
|
@@ -58,6 +58,7 @@ exports.assertTriggerRate = assertTriggerRate;
|
|
|
58
58
|
* vitest and jest, so the same object supports both.
|
|
59
59
|
*/
|
|
60
60
|
const harness_test_js_1 = require("./harness-test.js");
|
|
61
|
+
const check_js_1 = require("./check.js");
|
|
61
62
|
const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
|
|
62
63
|
const stats_js_1 = require("./stats.js");
|
|
63
64
|
const eval_baseline_js_1 = require("./eval-baseline.js");
|
|
@@ -576,5 +577,20 @@ exports.vigilesMatchers = {
|
|
|
576
577
|
message: () => `expected ${arm} ${pass ? "not " : ""}to beat ${baseline} on ${metric} by > ${String(by)} (got ${delta.toFixed(3)})`,
|
|
577
578
|
};
|
|
578
579
|
},
|
|
580
|
+
// The check-vocabulary veneer (Phase 1): ONE matcher works for EVERY check,
|
|
581
|
+
// carrying the check's own failure message. `expect(r).toPass(tool("Bash"))`.
|
|
582
|
+
toPass(received, check) {
|
|
583
|
+
const r = check.eval(received);
|
|
584
|
+
return { pass: r.pass, message: () => r.message };
|
|
585
|
+
},
|
|
586
|
+
toPassAll(received, checks) {
|
|
587
|
+
const failed = (0, check_js_1.evalChecks)(received, checks).filter((x) => !x.pass);
|
|
588
|
+
return {
|
|
589
|
+
pass: failed.length === 0,
|
|
590
|
+
message: () => failed.length === 0
|
|
591
|
+
? "all checks passed"
|
|
592
|
+
: failed.map((f) => ` ✗ ${f.message}`).join("\n"),
|
|
593
|
+
};
|
|
594
|
+
},
|
|
579
595
|
};
|
|
580
596
|
//# sourceMappingURL=harness-assert.js.map
|
package/dist/harness-test.d.ts
CHANGED
|
@@ -93,9 +93,24 @@ export interface Trace {
|
|
|
93
93
|
readonly modelRequests: readonly ModelRequest[];
|
|
94
94
|
/** Number of model turns. */
|
|
95
95
|
readonly turns: number;
|
|
96
|
+
/**
|
|
97
|
+
* Sub-agent (`Task`) runs as nested traces, keyed by `subagent_type`. A
|
|
98
|
+
* subagent runs its own session; CC tags its events with `parent_tool_use_id`
|
|
99
|
+
* (= the `Task` tool call) so its tool calls are recovered into a sub-trace
|
|
100
|
+
* here, lettng a test assert what the subagent DID (not just that `Task` fired).
|
|
101
|
+
* Empty unless the stream was captured / the harness emits subagent events.
|
|
102
|
+
*/
|
|
103
|
+
readonly subagents?: readonly SubagentTrace[];
|
|
96
104
|
/** Final contents of a file under the working dir, or null if absent. */
|
|
97
105
|
file(path: string): string | null;
|
|
98
106
|
}
|
|
107
|
+
/** A sub-agent (`Task`) run as a nested trace: its name + the tools it used. */
|
|
108
|
+
export interface SubagentTrace {
|
|
109
|
+
/** The `subagent_type` from the `Task` tool input. */
|
|
110
|
+
readonly name: string;
|
|
111
|
+
/** The tools the subagent invoked (events tagged with the Task's id). */
|
|
112
|
+
readonly toolCalls: readonly ToolCall[];
|
|
113
|
+
}
|
|
99
114
|
export interface HarnessTestResult extends Trace {
|
|
100
115
|
readonly exitCode: number;
|
|
101
116
|
readonly stdout: string;
|
|
@@ -115,6 +130,19 @@ export interface HarnessTestResult extends Trace {
|
|
|
115
130
|
* actions, not a brittle stdout substring.
|
|
116
131
|
*/
|
|
117
132
|
export declare function parseToolCalls(streamJson: string): ToolCall[];
|
|
133
|
+
/**
|
|
134
|
+
* Recover sub-agent runs as nested traces. A subagent-dispatch tool call (the
|
|
135
|
+
* `Agent` tool on the live CLI — older docs say `Task` — carrying an
|
|
136
|
+
* `input.subagent_type`) spawns a subagent whose own events the CLI tags with a
|
|
137
|
+
* top-level `parent_tool_use_id` = the dispatch tool-use id. We group those
|
|
138
|
+
* tagged tool calls under their dispatch, keyed by `subagent_type`. **Schema
|
|
139
|
+
* verified against real claude output** (`parent_tool_use_id` sibling of
|
|
140
|
+
* `message`, `subagent_type` in the dispatch input; tool named `Agent`) — the
|
|
141
|
+
* same `message.content` line shape `parseToolCalls` consumes, and we match the
|
|
142
|
+
* input field NOT the tool name so a future rename can't break it. Pure; empty
|
|
143
|
+
* for a harness that doesn't emit `parent_tool_use_id` (e.g. Codex).
|
|
144
|
+
*/
|
|
145
|
+
export declare function parseSubagents(streamJson: string): SubagentTrace[];
|
|
118
146
|
/**
|
|
119
147
|
* The terminal `result` event — present in BOTH `--output-format` shapes (a
|
|
120
148
|
* `{type:"result", …}` line in stream-json, the single object in `json`), or
|
|
@@ -163,4 +191,22 @@ export interface RunHarnessTestOptions {
|
|
|
163
191
|
* only — requesting confinement for another harness throws.
|
|
164
192
|
*/
|
|
165
193
|
export declare function runHarnessTest(spec: HarnessTestSpec, opts?: RunHarnessTestOptions): Promise<HarnessTestResult>;
|
|
194
|
+
/**
|
|
195
|
+
* `runHarness` — the harness-scope entry of the revamped API (Phase 2 of
|
|
196
|
+
* `research/testing-api-design.md`). The harness has two execution scopes, `hook`
|
|
197
|
+
* (`runHook`) and `harness` (the whole assembled agent); today's `integration` /
|
|
198
|
+
* `e2e` / `eval` are all the **harness** scope under realness flags. This entry is
|
|
199
|
+
* the **deterministic** harness run (`model: "mock"`, the default) — the
|
|
200
|
+
* workhorse you gate every commit, with no key. A **real-model** harness run is
|
|
201
|
+
* non-deterministic by definition, so you don't *assert* a single one — you
|
|
202
|
+
* `measure()` it across trials (the eval scope). `egress` is a capability of this
|
|
203
|
+
* scope (the e2e tier), not a separate tier.
|
|
204
|
+
*
|
|
205
|
+
* Behaviour is identical to `runHarnessTest` (which it wraps); the new name +
|
|
206
|
+
* `model` flag make the scope/realness explicit and steer real-model runs to the
|
|
207
|
+
* right tool.
|
|
208
|
+
*/
|
|
209
|
+
export declare function runHarness(spec: HarnessTestSpec, opts?: RunHarnessTestOptions & {
|
|
210
|
+
model?: "mock" | "real";
|
|
211
|
+
}): Promise<HarnessTestResult>;
|
|
166
212
|
//# sourceMappingURL=harness-test.d.ts.map
|