vigiles 2.2.0 → 2.3.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/README.md +55 -4
- package/dist/cli.js +42 -0
- package/dist/eval.d.ts +26 -1
- package/dist/eval.js +52 -18
- package/dist/harness-assert.d.ts +68 -0
- package/dist/harness-assert.js +127 -0
- package/dist/harness-test.d.ts +7 -0
- package/dist/harness-test.js +16 -7
- package/dist/jest.d.ts +9 -0
- package/dist/jest.js +23 -0
- package/dist/judge.d.ts +29 -0
- package/dist/judge.js +88 -0
- package/dist/plugin-loader.d.ts +37 -0
- package/dist/plugin-loader.js +195 -0
- package/dist/run-hook.d.ts +77 -0
- package/dist/run-hook.js +80 -0
- package/dist/run-scripts.d.ts +20 -0
- package/dist/run-scripts.js +70 -0
- package/dist/vitest.d.mts +9 -0
- package/dist/vitest.mjs +22 -0
- package/package.json +35 -5
package/README.md
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
<em>Quis custodiet ipsos custodes?</em> — Who watches the watchmen?
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
+
<p align="center">
|
|
12
|
+
<strong>Test & verify your Claude Code harness.</strong><br />
|
|
13
|
+
vigiles <strong>verifies the references</strong> your instruction files make — linter rules, file paths, scripts, code symbols — and <strong>evals</strong> whether your hooks, skills, and CLAUDE.md actually change what the agent does.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
11
16
|
<p align="center">
|
|
12
17
|
<a href="https://www.npmjs.com/package/vigiles"><img src="https://img.shields.io/npm/v/vigiles?color=orange" alt="npm version" /></a>
|
|
13
18
|
<a href="https://github.com/zernie/vigiles/actions"><img src="https://img.shields.io/github/actions/workflow/status/zernie/vigiles/ci.yml?branch=main" alt="CI" /></a>
|
|
@@ -258,6 +263,8 @@ npx vigiles init [--target=X.md] # Scaffold a spec (runs full setup wizard by
|
|
|
258
263
|
npx vigiles compile [files...] # Compile .spec.ts → .md
|
|
259
264
|
npx vigiles audit [files...] # Verify hashes + inline/frontmatter/spec rules + symbols + coverage
|
|
260
265
|
npx vigiles refs <file.md> # Check the symbol references in an instruction file
|
|
266
|
+
npx vigiles test [files...] # Run *.harness.mjs deterministic harness tests (no API key)
|
|
267
|
+
npx vigiles eval [files...] # Run *.eval.mjs real-model harness evals (--trials=N)
|
|
261
268
|
npx vigiles generate-types # Emit .d.ts from project state (for spec mode)
|
|
262
269
|
npx vigiles generate-types --check # Verify .d.ts is up to date
|
|
263
270
|
npx vigiles generate-schema # Emit JSON Schema for vigiles: frontmatter (Level 1)
|
|
@@ -335,7 +342,7 @@ Install with [Vercel Skills](https://github.com/vercel-labs/skills): `npx skills
|
|
|
335
342
|
|
|
336
343
|
vigiles also ships a library for **testing the harness itself** — your hooks,
|
|
337
344
|
settings, skills, and instruction files. `Agent = Model + Harness`; this tests
|
|
338
|
-
the harness, at
|
|
345
|
+
the harness, at three levels.
|
|
339
346
|
|
|
340
347
|
**Evals — does my change actually move agent behaviour?** Define a fixture, a set
|
|
341
348
|
of **arms** (a hook on vs off, with/without a CLAUDE.md rule), a task, and a
|
|
@@ -390,9 +397,53 @@ const r = await runHarnessTest({
|
|
|
390
397
|
assert(JSON.parse(r.stdout).num_turns > 1); // the Stop hook forced more work
|
|
391
398
|
```
|
|
392
399
|
|
|
393
|
-
The deterministic tier is reliable for **Stop
|
|
394
|
-
|
|
395
|
-
|
|
400
|
+
The deterministic tier is reliable for **SessionStart, Stop, UserPromptSubmit,
|
|
401
|
+
and Bash PreToolUse/PostToolUse** hooks — the governance/policy shapes most real
|
|
402
|
+
plugins use; Edit/Write tool-event hooks are headless-gated, so test those at the
|
|
403
|
+
unit tier or via the eval tier.
|
|
404
|
+
|
|
405
|
+
**Unit-test a hook — no `claude` at all.** A hook is just a process: `runHook`
|
|
406
|
+
pipes an event JSON to its stdin and reports the block/allow decision —
|
|
407
|
+
milliseconds, and the only tier that reaches **every** event (incl. Edit/Write,
|
|
408
|
+
PreCompact, SessionEnd, which the deterministic mock can't trigger).
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
import { runHook } from "vigiles/run-hook";
|
|
412
|
+
|
|
413
|
+
const r = runHook(guardCommand, {
|
|
414
|
+
hook_event_name: "PreToolUse",
|
|
415
|
+
tool_name: "Bash",
|
|
416
|
+
tool_input: { command: "git commit --no-verify" },
|
|
417
|
+
});
|
|
418
|
+
assert(r.blocked); // exit 2, decision:"block", or permissionDecision:"deny"
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**Run them as a CI command.** `vigiles test` discovers `*.harness.mjs` files
|
|
422
|
+
(deterministic, no API key) and `vigiles eval` discovers `*.eval.mjs` files
|
|
423
|
+
(real model). Canonical, real-plugin-shaped examples to copy:
|
|
424
|
+
|
|
425
|
+
- [`examples/harness/policy-gate.harness.mjs`](examples/harness/policy-gate.harness.mjs) — a `PreToolUse` Bash policy gate (block `git commit --no-verify`) and a `SessionStart` setup hook, deterministic.
|
|
426
|
+
- [`examples/harness/skill-outcome.eval.mjs`](examples/harness/skill-outcome.eval.mjs) — does a skill change the agent's output? (the question you ask of any `SKILL.md`).
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
npx vigiles test examples/harness/policy-gate.harness.mjs
|
|
430
|
+
npx vigiles eval --trials=6 examples/harness/skill-outcome.eval.mjs
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**Test the whole machine.** Point `plugin` at a plugin (or `"./"` for your repo)
|
|
434
|
+
and the real harness — hooks (with `${CLAUDE_PLUGIN_ROOT}` resolved), CLAUDE.md,
|
|
435
|
+
skills, subagents and commands — is loaded into the sandbox, so you test what
|
|
436
|
+
ships, not a retyped subset. `loadPlugin(...).warnings` flags surfaces only a
|
|
437
|
+
real model can drive (subagents, slash commands, MCP), so loading a whole plugin
|
|
438
|
+
never silently tests an empty machine. The library is plain async functions, so
|
|
439
|
+
it runs in **node:test, vitest, or jest** unchanged (shared `expect.extend`
|
|
440
|
+
matchers for the latter two).
|
|
441
|
+
|
|
442
|
+
[Full guide → `docs/harness-testing.md`](docs/harness-testing.md). The design
|
|
443
|
+
rationale and a coverage assessment against real plugins (protect-mcp,
|
|
444
|
+
obra/superpowers, block-no-verify, 156 wshobson skills) are in
|
|
445
|
+
[`research/harness-testing.md`](research/harness-testing.md); findings from
|
|
446
|
+
running this harness in anger live in [`research/benchmarks-runtime-gates.md`](research/benchmarks-runtime-gates.md).
|
|
396
447
|
|
|
397
448
|
## Maturity Levels
|
|
398
449
|
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,8 @@ const action_gate_js_1 = require("./action-gate.js");
|
|
|
26
26
|
const refs_js_1 = require("./refs.js");
|
|
27
27
|
const skill_runtime_js_1 = require("./skill-runtime.js");
|
|
28
28
|
const linters_js_1 = require("./linters.js");
|
|
29
|
+
const harness_test_js_1 = require("./harness-test.js");
|
|
30
|
+
const run_scripts_js_1 = require("./run-scripts.js");
|
|
29
31
|
const integrity_js_1 = require("./integrity.js");
|
|
30
32
|
const coverage_js_1 = require("./coverage.js");
|
|
31
33
|
const orphans_js_1 = require("./orphans.js");
|
|
@@ -1413,6 +1415,38 @@ function handleGenerateSchema(args, restArgs) {
|
|
|
1413
1415
|
console.log(" Add to your markdown frontmatter:\n" +
|
|
1414
1416
|
` # yaml-language-server: $schema=./${outPath}`);
|
|
1415
1417
|
}
|
|
1418
|
+
/**
|
|
1419
|
+
* `vigiles test` / `vigiles eval` — discover and run the two-tier harness
|
|
1420
|
+
* scripts (deterministic `*.harness.mjs` / real-model `*.eval.mjs`) as child
|
|
1421
|
+
* `node` processes, aggregating exit codes so they work as a CI command. See
|
|
1422
|
+
* src/run-scripts.ts.
|
|
1423
|
+
*
|
|
1424
|
+
* `vigiles test` skips clean when the `claude` CLI is absent (the deterministic
|
|
1425
|
+
* tier needs it, just like the node:test suite). `--trials=N` is forwarded to
|
|
1426
|
+
* eval scripts via the `VIGILES_TRIALS` env var.
|
|
1427
|
+
*/
|
|
1428
|
+
function handleRunScripts(kind, args, restArgs) {
|
|
1429
|
+
const cwd = process.cwd();
|
|
1430
|
+
const defaultGlob = kind === "test" ? "**/*.harness.mjs" : "**/*.eval.mjs";
|
|
1431
|
+
if (kind === "test" && !(0, harness_test_js_1.claudeAvailable)()) {
|
|
1432
|
+
console.log("vigiles test: `claude` CLI not found — skipping harness tests.");
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
|
|
1436
|
+
if (files.length === 0) {
|
|
1437
|
+
console.log(`No ${defaultGlob} files found.`);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const trialsFlag = args.find((a) => a.startsWith("--trials="));
|
|
1441
|
+
const env = {};
|
|
1442
|
+
if (trialsFlag)
|
|
1443
|
+
env.VIGILES_TRIALS = trialsFlag.split("=")[1];
|
|
1444
|
+
console.log(`Running ${String(files.length)} ${kind} file(s):\n`);
|
|
1445
|
+
const results = (0, run_scripts_js_1.runScripts)(files, cwd, env);
|
|
1446
|
+
console.log("\n" + (0, run_scripts_js_1.formatScriptSummary)(results));
|
|
1447
|
+
if (results.some((r) => r.code !== 0))
|
|
1448
|
+
process.exit(1);
|
|
1449
|
+
}
|
|
1416
1450
|
function printUsage(command) {
|
|
1417
1451
|
console.log("vigiles — compile typed specs to instruction files");
|
|
1418
1452
|
console.log("");
|
|
@@ -1420,6 +1454,8 @@ function printUsage(command) {
|
|
|
1420
1454
|
console.log(" vigiles init [flags] Setup project (--target=X.md, --strict, --no-gha)");
|
|
1421
1455
|
console.log(" vigiles compile [files...] Compile .spec.ts → .md");
|
|
1422
1456
|
console.log(" vigiles audit [files...] Verify, find gaps, suggest improvements");
|
|
1457
|
+
console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
|
|
1458
|
+
console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N)");
|
|
1423
1459
|
console.log("");
|
|
1424
1460
|
console.log("Examples:");
|
|
1425
1461
|
console.log(" vigiles init Auto-detect project, create specs, wire CI");
|
|
@@ -1725,6 +1761,12 @@ async function main() {
|
|
|
1725
1761
|
}
|
|
1726
1762
|
break;
|
|
1727
1763
|
}
|
|
1764
|
+
case "test":
|
|
1765
|
+
handleRunScripts("test", args, restArgs);
|
|
1766
|
+
break;
|
|
1767
|
+
case "eval":
|
|
1768
|
+
handleRunScripts("eval", args, restArgs);
|
|
1769
|
+
break;
|
|
1728
1770
|
// --- Plumbing ---
|
|
1729
1771
|
case "generate-types":
|
|
1730
1772
|
handleGenerateTypes(args, restArgs);
|
package/dist/eval.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export interface EvalArm {
|
|
|
4
4
|
readonly files?: Record<string, string>;
|
|
5
5
|
/** `.claude/settings.json` (hooks/permissions) for this arm; omit for none. */
|
|
6
6
|
readonly settings?: unknown;
|
|
7
|
+
/**
|
|
8
|
+
* Path to a real plugin/repo to load for this arm (hooks + CLAUDE.md +
|
|
9
|
+
* skills). Lets an arm be "the whole plugin on" vs "off". See
|
|
10
|
+
* src/plugin-loader.ts.
|
|
11
|
+
*/
|
|
12
|
+
readonly plugin?: string;
|
|
7
13
|
}
|
|
8
14
|
/** Context handed to `measure` after a run, to compute that run's metrics. */
|
|
9
15
|
export interface RunContext {
|
|
@@ -39,10 +45,23 @@ export interface EvalSpec<M extends Metrics> {
|
|
|
39
45
|
/** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
|
|
40
46
|
readonly spacingSec?: number;
|
|
41
47
|
}
|
|
48
|
+
/** Per-metric summary statistics across an arm's runs. */
|
|
49
|
+
export interface MetricStat {
|
|
50
|
+
/** Mean (numbers) / fraction-true (booleans). */
|
|
51
|
+
readonly mean: number;
|
|
52
|
+
/** Sample standard deviation (0 when n < 2). */
|
|
53
|
+
readonly std: number;
|
|
54
|
+
/** Standard error of the mean (std / √n). */
|
|
55
|
+
readonly se: number;
|
|
56
|
+
/** Number of runs the metric was observed in. */
|
|
57
|
+
readonly n: number;
|
|
58
|
+
}
|
|
42
59
|
export interface ArmReport {
|
|
43
60
|
readonly runs: number;
|
|
44
61
|
/** Aggregated metrics: mean for numbers, fraction-true (0..1) for booleans. */
|
|
45
62
|
readonly metrics: Record<string, number>;
|
|
63
|
+
/** Per-metric mean / std / se / n, so an A/B gap can be read for significance. */
|
|
64
|
+
readonly stats: Record<string, MetricStat>;
|
|
46
65
|
}
|
|
47
66
|
export interface EvalReport {
|
|
48
67
|
readonly name: string;
|
|
@@ -51,12 +70,18 @@ export interface EvalReport {
|
|
|
51
70
|
}
|
|
52
71
|
/** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
|
|
53
72
|
export declare function aggregate(rows: readonly Metrics[]): Record<string, number>;
|
|
73
|
+
/**
|
|
74
|
+
* Aggregate per-run metrics with spread: mean, sample std, standard error, and
|
|
75
|
+
* n. The se/std let you judge whether an A/B gap between arms is real or noise —
|
|
76
|
+
* a difference smaller than the combined se is not yet significant.
|
|
77
|
+
*/
|
|
78
|
+
export declare function aggregateStats(rows: readonly Metrics[]): Record<string, MetricStat>;
|
|
54
79
|
/**
|
|
55
80
|
* Run the eval: every arm × every trial against the real `claude` CLI, with the
|
|
56
81
|
* metric computed per run and aggregated per arm. Requires `claude` on PATH and
|
|
57
82
|
* working model auth (e.g. `ANTHROPIC_API_KEY`).
|
|
58
83
|
*/
|
|
59
84
|
export declare function runEval<M extends Metrics>(spec: EvalSpec<M>): Promise<EvalReport>;
|
|
60
|
-
/** Format an eval report as a compact table for the console. */
|
|
85
|
+
/** Format an eval report as a compact table for the console (mean ± se). */
|
|
61
86
|
export declare function formatEvalReport(report: EvalReport): string;
|
|
62
87
|
//# sourceMappingURL=eval.d.ts.map
|
package/dist/eval.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.aggregate = aggregate;
|
|
4
|
+
exports.aggregateStats = aggregateStats;
|
|
4
5
|
exports.runEval = runEval;
|
|
5
6
|
exports.formatEvalReport = formatEvalReport;
|
|
6
7
|
/**
|
|
@@ -30,6 +31,7 @@ const node_child_process_1 = require("node:child_process");
|
|
|
30
31
|
const node_fs_1 = require("node:fs");
|
|
31
32
|
const node_os_1 = require("node:os");
|
|
32
33
|
const node_path_1 = require("node:path");
|
|
34
|
+
const plugin_loader_js_1 = require("./plugin-loader.js");
|
|
33
35
|
function writeFiles(cwd, files) {
|
|
34
36
|
for (const [p, content] of Object.entries(files)) {
|
|
35
37
|
const full = (0, node_path_1.resolve)(cwd, p);
|
|
@@ -101,28 +103,46 @@ function makeContext(cwd, out) {
|
|
|
101
103
|
},
|
|
102
104
|
};
|
|
103
105
|
}
|
|
106
|
+
/** Coerce a metric value to a number (booleans → 0/1), or null if absent. */
|
|
107
|
+
function numeric(v) {
|
|
108
|
+
if (typeof v === "number")
|
|
109
|
+
return v;
|
|
110
|
+
if (typeof v === "boolean")
|
|
111
|
+
return v ? 1 : 0;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
104
114
|
/** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
|
|
105
115
|
function aggregate(rows) {
|
|
116
|
+
const stats = aggregateStats(rows);
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [k, s] of Object.entries(stats))
|
|
119
|
+
out[k] = s.mean;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Aggregate per-run metrics with spread: mean, sample std, standard error, and
|
|
124
|
+
* n. The se/std let you judge whether an A/B gap between arms is real or noise —
|
|
125
|
+
* a difference smaller than the combined se is not yet significant.
|
|
126
|
+
*/
|
|
127
|
+
function aggregateStats(rows) {
|
|
106
128
|
const keys = new Set();
|
|
107
129
|
for (const r of rows)
|
|
108
130
|
for (const k of Object.keys(r))
|
|
109
131
|
keys.add(k);
|
|
110
132
|
const out = {};
|
|
111
133
|
for (const k of keys) {
|
|
112
|
-
|
|
113
|
-
let n = 0;
|
|
134
|
+
const values = [];
|
|
114
135
|
for (const r of rows) {
|
|
115
|
-
const v = r[k];
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
n++;
|
|
119
|
-
}
|
|
120
|
-
else if (typeof v === "boolean") {
|
|
121
|
-
sum += v ? 1 : 0;
|
|
122
|
-
n++;
|
|
123
|
-
}
|
|
136
|
+
const v = numeric(r[k]);
|
|
137
|
+
if (v !== null)
|
|
138
|
+
values.push(v);
|
|
124
139
|
}
|
|
125
|
-
|
|
140
|
+
const n = values.length;
|
|
141
|
+
const mean = n > 0 ? values.reduce((a, b) => a + b, 0) / n : 0;
|
|
142
|
+
const std = n > 1
|
|
143
|
+
? Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
|
|
144
|
+
: 0;
|
|
145
|
+
out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n };
|
|
126
146
|
}
|
|
127
147
|
return out;
|
|
128
148
|
}
|
|
@@ -143,10 +163,15 @@ async function runEval(spec) {
|
|
|
143
163
|
for (let t = 0; t < trials; t++) {
|
|
144
164
|
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
|
|
145
165
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
166
|
+
const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
|
|
167
|
+
plugin: arm.plugin,
|
|
168
|
+
settings: arm.settings,
|
|
169
|
+
files: { ...spec.fixture, ...arm.files },
|
|
170
|
+
});
|
|
171
|
+
writeFiles(cwd, files);
|
|
172
|
+
const hasSettings = settings !== undefined;
|
|
148
173
|
if (hasSettings) {
|
|
149
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(
|
|
174
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
|
|
150
175
|
}
|
|
151
176
|
const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, timeoutMs);
|
|
152
177
|
rows.push(spec.measure(makeContext(cwd, out)));
|
|
@@ -156,16 +181,25 @@ async function runEval(spec) {
|
|
|
156
181
|
await sleep(spacing);
|
|
157
182
|
}
|
|
158
183
|
}
|
|
159
|
-
arms[armName] = {
|
|
184
|
+
arms[armName] = {
|
|
185
|
+
runs: rows.length,
|
|
186
|
+
metrics: aggregate(rows),
|
|
187
|
+
stats: aggregateStats(rows),
|
|
188
|
+
};
|
|
160
189
|
}
|
|
161
190
|
return { name: spec.name ?? "eval", trials, arms };
|
|
162
191
|
}
|
|
163
|
-
/** Format an eval report as a compact table for the console. */
|
|
192
|
+
/** Format an eval report as a compact table for the console (mean ± se). */
|
|
164
193
|
function formatEvalReport(report) {
|
|
165
194
|
const lines = [`${report.name} (${String(report.trials)} trials/arm)`];
|
|
166
195
|
for (const [arm, r] of Object.entries(report.arms)) {
|
|
167
196
|
const parts = Object.entries(r.metrics)
|
|
168
|
-
.map(([k, v]) =>
|
|
197
|
+
.map(([k, v]) => {
|
|
198
|
+
const se = r.stats[k]?.se ?? 0;
|
|
199
|
+
return se > 0
|
|
200
|
+
? `${k}=${v.toFixed(2)}±${se.toFixed(2)}`
|
|
201
|
+
: `${k}=${v.toFixed(2)}`;
|
|
202
|
+
})
|
|
169
203
|
.join(" ");
|
|
170
204
|
lines.push(` ${arm.padEnd(10)} ${parts}`);
|
|
171
205
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vigiles — runner-agnostic helpers for harness tests / evals.
|
|
3
|
+
*
|
|
4
|
+
* `runHarnessTest` and `runEval` are plain async functions that return data, so
|
|
5
|
+
* they already work inside any runner (node:test, vitest, jest, mocha). These
|
|
6
|
+
* helpers remove the last bit of boilerplate without coupling to a runner:
|
|
7
|
+
*
|
|
8
|
+
* - `withHarness` — run a harness test and auto-clean the sandbox (try/finally),
|
|
9
|
+
* so you don't leak temp dirs in `afterEach`.
|
|
10
|
+
* - plain `assert*` helpers that throw — usable in every runner, including
|
|
11
|
+
* node:test which has no `expect.extend`.
|
|
12
|
+
* - `vigilesMatchers` — register with `expect.extend(vigilesMatchers)` for
|
|
13
|
+
* `expect(...).toHaveCreated(...)` sugar. The signature is identical for
|
|
14
|
+
* vitest and jest, so the same object supports both.
|
|
15
|
+
*/
|
|
16
|
+
import { type HarnessTestSpec, type HarnessTestResult } from "./harness-test.js";
|
|
17
|
+
import type { EvalReport } from "./eval.js";
|
|
18
|
+
import type { HookRunResult } from "./run-hook.js";
|
|
19
|
+
/**
|
|
20
|
+
* Run a harness test, hand the result to `fn`, and always clean up the sandbox.
|
|
21
|
+
* Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
|
|
22
|
+
* hand — it survives assertion failures.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withHarness<T>(spec: HarnessTestSpec, fn: (r: HarnessTestResult) => T | Promise<T>): Promise<T>;
|
|
25
|
+
/** Assert the sandbox contains `path` (a hook/agent side-effect file). */
|
|
26
|
+
export declare function assertCreated(r: HarnessTestResult, path: string): void;
|
|
27
|
+
/** Assert the sandbox does NOT contain `path` (e.g. a blocked action's output). */
|
|
28
|
+
export declare function assertNotCreated(r: HarnessTestResult, path: string): void;
|
|
29
|
+
/** Assert the scripted model served at least `n` turns (e.g. a Stop hook forced more). */
|
|
30
|
+
export declare function assertServedTurns(r: HarnessTestResult, n: number): void;
|
|
31
|
+
/** Assert a `runHook` result blocked (exit 2 / decision:block / permission:deny). */
|
|
32
|
+
export declare function assertHookBlocked(r: HookRunResult): void;
|
|
33
|
+
/** Assert a `runHook` result allowed (did not block). */
|
|
34
|
+
export declare function assertHookAllowed(r: HookRunResult): void;
|
|
35
|
+
/** The gap on `metric` between two arms (arm − baseline). */
|
|
36
|
+
export declare function improvement(report: EvalReport, baseline: string, arm: string, metric: string): number;
|
|
37
|
+
/**
|
|
38
|
+
* Assert `arm` beats `baseline` on `metric` by more than `by`. With `by` left at
|
|
39
|
+
* 0 this just asserts a positive gap; pass the combined se to demand the gap
|
|
40
|
+
* clear the noise floor.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertImproves(report: EvalReport, opts: {
|
|
43
|
+
baseline: string;
|
|
44
|
+
arm: string;
|
|
45
|
+
metric: string;
|
|
46
|
+
by?: number;
|
|
47
|
+
}): void;
|
|
48
|
+
interface MatcherOutput {
|
|
49
|
+
pass: boolean;
|
|
50
|
+
message: () => string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Custom matchers compatible with both vitest and jest. Register once:
|
|
54
|
+
*
|
|
55
|
+
* import { expect } from "vitest"; // or "@jest/globals"
|
|
56
|
+
* import { vigilesMatchers } from "vigiles/harness-assert";
|
|
57
|
+
* expect.extend(vigilesMatchers);
|
|
58
|
+
*
|
|
59
|
+
* expect(result).toHaveCreated("RESULT");
|
|
60
|
+
* expect(report).toBeatBaseline("vanilla", "gated", "caught");
|
|
61
|
+
*/
|
|
62
|
+
export declare const vigilesMatchers: {
|
|
63
|
+
toHaveCreated(received: HarnessTestResult, path: string): MatcherOutput;
|
|
64
|
+
toBlock(received: HookRunResult): MatcherOutput;
|
|
65
|
+
toBeatBaseline(received: EvalReport, baseline: string, arm: string, metric: string, by?: number): MatcherOutput;
|
|
66
|
+
};
|
|
67
|
+
export {};
|
|
68
|
+
//# sourceMappingURL=harness-assert.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.vigilesMatchers = void 0;
|
|
4
|
+
exports.withHarness = withHarness;
|
|
5
|
+
exports.assertCreated = assertCreated;
|
|
6
|
+
exports.assertNotCreated = assertNotCreated;
|
|
7
|
+
exports.assertServedTurns = assertServedTurns;
|
|
8
|
+
exports.assertHookBlocked = assertHookBlocked;
|
|
9
|
+
exports.assertHookAllowed = assertHookAllowed;
|
|
10
|
+
exports.improvement = improvement;
|
|
11
|
+
exports.assertImproves = assertImproves;
|
|
12
|
+
/**
|
|
13
|
+
* vigiles — runner-agnostic helpers for harness tests / evals.
|
|
14
|
+
*
|
|
15
|
+
* `runHarnessTest` and `runEval` are plain async functions that return data, so
|
|
16
|
+
* they already work inside any runner (node:test, vitest, jest, mocha). These
|
|
17
|
+
* helpers remove the last bit of boilerplate without coupling to a runner:
|
|
18
|
+
*
|
|
19
|
+
* - `withHarness` — run a harness test and auto-clean the sandbox (try/finally),
|
|
20
|
+
* so you don't leak temp dirs in `afterEach`.
|
|
21
|
+
* - plain `assert*` helpers that throw — usable in every runner, including
|
|
22
|
+
* node:test which has no `expect.extend`.
|
|
23
|
+
* - `vigilesMatchers` — register with `expect.extend(vigilesMatchers)` for
|
|
24
|
+
* `expect(...).toHaveCreated(...)` sugar. The signature is identical for
|
|
25
|
+
* vitest and jest, so the same object supports both.
|
|
26
|
+
*/
|
|
27
|
+
const harness_test_js_1 = require("./harness-test.js");
|
|
28
|
+
/**
|
|
29
|
+
* Run a harness test, hand the result to `fn`, and always clean up the sandbox.
|
|
30
|
+
* Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
|
|
31
|
+
* hand — it survives assertion failures.
|
|
32
|
+
*/
|
|
33
|
+
async function withHarness(spec, fn) {
|
|
34
|
+
const r = await (0, harness_test_js_1.runHarnessTest)(spec);
|
|
35
|
+
try {
|
|
36
|
+
return await fn(r);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
r.cleanup();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// --- Plain throwing assertions (any runner) --------------------------------
|
|
43
|
+
function fail(message) {
|
|
44
|
+
throw new Error(message);
|
|
45
|
+
}
|
|
46
|
+
/** Assert the sandbox contains `path` (a hook/agent side-effect file). */
|
|
47
|
+
function assertCreated(r, path) {
|
|
48
|
+
if (r.file(path) === null)
|
|
49
|
+
fail(`expected the run to create ${path}`);
|
|
50
|
+
}
|
|
51
|
+
/** Assert the sandbox does NOT contain `path` (e.g. a blocked action's output). */
|
|
52
|
+
function assertNotCreated(r, path) {
|
|
53
|
+
if (r.file(path) !== null)
|
|
54
|
+
fail(`expected the run NOT to create ${path}`);
|
|
55
|
+
}
|
|
56
|
+
/** Assert the scripted model served at least `n` turns (e.g. a Stop hook forced more). */
|
|
57
|
+
function assertServedTurns(r, n) {
|
|
58
|
+
if (r.turns < n) {
|
|
59
|
+
fail(`expected ≥ ${String(n)} model turns, got ${String(r.turns)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Assert a `runHook` result blocked (exit 2 / decision:block / permission:deny). */
|
|
63
|
+
function assertHookBlocked(r) {
|
|
64
|
+
if (!r.blocked) {
|
|
65
|
+
fail(`expected the hook to block, but it allowed (exit ${String(r.exitCode)})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Assert a `runHook` result allowed (did not block). */
|
|
69
|
+
function assertHookAllowed(r) {
|
|
70
|
+
if (r.blocked) {
|
|
71
|
+
fail(`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The gap on `metric` between two arms (arm − baseline). */
|
|
75
|
+
function improvement(report, baseline, arm, metric) {
|
|
76
|
+
const a = report.arms[arm]?.metrics[metric] ?? 0;
|
|
77
|
+
const b = report.arms[baseline]?.metrics[metric] ?? 0;
|
|
78
|
+
return a - b;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Assert `arm` beats `baseline` on `metric` by more than `by`. With `by` left at
|
|
82
|
+
* 0 this just asserts a positive gap; pass the combined se to demand the gap
|
|
83
|
+
* clear the noise floor.
|
|
84
|
+
*/
|
|
85
|
+
function assertImproves(report, opts) {
|
|
86
|
+
const by = opts.by ?? 0;
|
|
87
|
+
const delta = improvement(report, opts.baseline, opts.arm, opts.metric);
|
|
88
|
+
if (delta <= by) {
|
|
89
|
+
fail(`expected ${opts.arm} to beat ${opts.baseline} on ${opts.metric} by > ${String(by)}, got ${delta.toFixed(3)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Custom matchers compatible with both vitest and jest. Register once:
|
|
94
|
+
*
|
|
95
|
+
* import { expect } from "vitest"; // or "@jest/globals"
|
|
96
|
+
* import { vigilesMatchers } from "vigiles/harness-assert";
|
|
97
|
+
* expect.extend(vigilesMatchers);
|
|
98
|
+
*
|
|
99
|
+
* expect(result).toHaveCreated("RESULT");
|
|
100
|
+
* expect(report).toBeatBaseline("vanilla", "gated", "caught");
|
|
101
|
+
*/
|
|
102
|
+
exports.vigilesMatchers = {
|
|
103
|
+
toHaveCreated(received, path) {
|
|
104
|
+
const pass = received.file(path) !== null;
|
|
105
|
+
return {
|
|
106
|
+
pass,
|
|
107
|
+
message: () => `expected the run ${pass ? "not " : ""}to create ${path}`,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
toBlock(received) {
|
|
111
|
+
const pass = received.blocked;
|
|
112
|
+
return {
|
|
113
|
+
pass,
|
|
114
|
+
message: () => `expected the hook ${pass ? "not " : ""}to block (exit ${String(received.exitCode)}, decision ${String(received.decision)})`,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
// eslint-disable-next-line max-params -- jest/vitest matchers take positional args
|
|
118
|
+
toBeatBaseline(received, baseline, arm, metric, by = 0) {
|
|
119
|
+
const delta = improvement(received, baseline, arm, metric);
|
|
120
|
+
const pass = delta > by;
|
|
121
|
+
return {
|
|
122
|
+
pass,
|
|
123
|
+
message: () => `expected ${arm} ${pass ? "not " : ""}to beat ${baseline} on ${metric} by > ${String(by)} (got ${delta.toFixed(3)})`,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
//# sourceMappingURL=harness-assert.js.map
|
package/dist/harness-test.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { type ModelTurn } from "./mock-model.js";
|
|
2
2
|
export { scriptModel, type ModelTurn } from "./mock-model.js";
|
|
3
|
+
export { loadPlugin, resolveHarness } from "./plugin-loader.js";
|
|
3
4
|
export interface HarnessTestSpec {
|
|
4
5
|
/** Fixture files to write in a fresh temp working dir (path → contents). */
|
|
5
6
|
readonly files?: Record<string, string>;
|
|
6
7
|
/** `.claude/settings.json` contents — the hooks/permissions under test. */
|
|
7
8
|
readonly settings?: unknown;
|
|
9
|
+
/**
|
|
10
|
+
* Path to a real plugin/repo whose harness (hooks + CLAUDE.md + skills) is
|
|
11
|
+
* loaded into the sandbox, so you test the assembled machine, not a retyped
|
|
12
|
+
* subset. Inline `settings`/`files` layer on top. See src/plugin-loader.ts.
|
|
13
|
+
*/
|
|
14
|
+
readonly plugin?: string;
|
|
8
15
|
/** The scripted model turns the agent will take. */
|
|
9
16
|
readonly model: readonly ModelTurn[];
|
|
10
17
|
/** The user prompt. Default: "go". */
|
package/dist/harness-test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.scriptModel = void 0;
|
|
3
|
+
exports.resolveHarness = exports.loadPlugin = exports.scriptModel = void 0;
|
|
4
4
|
exports.claudeAvailable = claudeAvailable;
|
|
5
5
|
exports.runHarnessTest = runHarnessTest;
|
|
6
6
|
/**
|
|
@@ -37,8 +37,12 @@ const node_fs_1 = require("node:fs");
|
|
|
37
37
|
const node_os_1 = require("node:os");
|
|
38
38
|
const node_path_1 = require("node:path");
|
|
39
39
|
const mock_model_js_1 = require("./mock-model.js");
|
|
40
|
+
const plugin_loader_js_1 = require("./plugin-loader.js");
|
|
40
41
|
var mock_model_js_2 = require("./mock-model.js");
|
|
41
42
|
Object.defineProperty(exports, "scriptModel", { enumerable: true, get: function () { return mock_model_js_2.scriptModel; } });
|
|
43
|
+
var plugin_loader_js_2 = require("./plugin-loader.js");
|
|
44
|
+
Object.defineProperty(exports, "loadPlugin", { enumerable: true, get: function () { return plugin_loader_js_2.loadPlugin; } });
|
|
45
|
+
Object.defineProperty(exports, "resolveHarness", { enumerable: true, get: function () { return plugin_loader_js_2.resolveHarness; } });
|
|
42
46
|
/** Whether the `claude` CLI is available — harness tests need it. */
|
|
43
47
|
function claudeAvailable() {
|
|
44
48
|
try {
|
|
@@ -48,17 +52,17 @@ function claudeAvailable() {
|
|
|
48
52
|
return false;
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
|
-
function writeFixture(cwd,
|
|
52
|
-
for (const [p, content] of Object.entries(
|
|
55
|
+
function writeFixture(cwd, files, settings) {
|
|
56
|
+
for (const [p, content] of Object.entries(files)) {
|
|
53
57
|
const full = (0, node_path_1.resolve)(cwd, p);
|
|
54
58
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
|
|
55
59
|
(0, node_fs_1.writeFileSync)(full, content);
|
|
56
60
|
}
|
|
57
|
-
if (
|
|
61
|
+
if (settings !== undefined) {
|
|
58
62
|
// `{cwd}` in any hook command is substituted with the working dir, so a
|
|
59
63
|
// hook can reference an absolute path inside it (hooks don't run with the
|
|
60
64
|
// project dir as cwd).
|
|
61
|
-
const json = JSON.stringify(
|
|
65
|
+
const json = JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd);
|
|
62
66
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), json);
|
|
63
67
|
}
|
|
64
68
|
}
|
|
@@ -91,7 +95,12 @@ function spawnClaude(args, cwd, baseUrl, timeoutMs) {
|
|
|
91
95
|
*/
|
|
92
96
|
async function runHarnessTest(spec) {
|
|
93
97
|
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
|
|
94
|
-
|
|
98
|
+
const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
|
|
99
|
+
plugin: spec.plugin,
|
|
100
|
+
settings: spec.settings,
|
|
101
|
+
files: spec.files,
|
|
102
|
+
});
|
|
103
|
+
writeFixture(cwd, files, settings);
|
|
95
104
|
const mock = await (0, mock_model_js_1.startMock)(spec.model);
|
|
96
105
|
try {
|
|
97
106
|
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
|
|
@@ -102,7 +111,7 @@ async function runHarnessTest(spec) {
|
|
|
102
111
|
"json",
|
|
103
112
|
"--model",
|
|
104
113
|
"claude-sonnet-4-5",
|
|
105
|
-
...(
|
|
114
|
+
...(settings !== undefined ? ["--settings", "settings.json"] : []),
|
|
106
115
|
"--allowedTools",
|
|
107
116
|
...tools,
|
|
108
117
|
];
|
package/dist/jest.d.ts
ADDED
package/dist/jest.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/* eslint-disable max-params --
|
|
4
|
+
The matcher signatures mirror the runtime vigilesMatchers (positional args). */
|
|
5
|
+
/**
|
|
6
|
+
* vigiles — jest integration (opt-in).
|
|
7
|
+
*
|
|
8
|
+
* Importing this entry registers the vigiles matchers AND augments jest's types
|
|
9
|
+
* so `toHaveCreated` / `toBeatBaseline` type-check.
|
|
10
|
+
*
|
|
11
|
+
* // jest.config.js → setupFilesAfterEnv: ["vigiles/jest"]
|
|
12
|
+
* // …or at the top of a test file:
|
|
13
|
+
* import "vigiles/jest";
|
|
14
|
+
*
|
|
15
|
+
* expect(result).toHaveCreated("DONE");
|
|
16
|
+
* expect(report).toBeatBaseline("vanilla", "gated", "caught");
|
|
17
|
+
*
|
|
18
|
+
* jest is an optional peer dependency — only jest users load this entry.
|
|
19
|
+
*/
|
|
20
|
+
const globals_1 = require("@jest/globals");
|
|
21
|
+
const harness_assert_js_1 = require("./harness-assert.js");
|
|
22
|
+
globals_1.expect.extend(harness_assert_js_1.vigilesMatchers);
|
|
23
|
+
//# sourceMappingURL=jest.js.map
|