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 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 &amp; 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 two levels.
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 hooks**; tool-event hooks
394
- (Edit/Write) are headless-gated, so test those via the eval tier. Our own
395
- findings from this harness live in [`research/benchmarks-runtime-gates.md`](research/benchmarks-runtime-gates.md).
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
- let sum = 0;
113
- let n = 0;
134
+ const values = [];
114
135
  for (const r of rows) {
115
- const v = r[k];
116
- if (typeof v === "number") {
117
- sum += v;
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
- out[k] = n > 0 ? sum / n : 0;
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
- writeFiles(cwd, { ...spec.fixture, ...arm.files });
147
- const hasSettings = arm.settings !== undefined;
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(arm.settings, null, 2).replaceAll("{cwd}", cwd));
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] = { runs: rows.length, metrics: aggregate(rows) };
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]) => `${k}=${v.toFixed(2)}`)
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
@@ -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". */
@@ -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, spec) {
52
- for (const [p, content] of Object.entries(spec.files ?? {})) {
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 (spec.settings !== undefined) {
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(spec.settings, null, 2).replaceAll("{cwd}", cwd);
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
- writeFixture(cwd, spec);
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
- ...(spec.settings !== undefined ? ["--settings", "settings.json"] : []),
114
+ ...(settings !== undefined ? ["--settings", "settings.json"] : []),
106
115
  "--allowedTools",
107
116
  ...tools,
108
117
  ];
package/dist/jest.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ declare module "@jest/expect" {
2
+ interface Matchers<R> {
3
+ toHaveCreated(path: string): R;
4
+ toBlock(): R;
5
+ toBeatBaseline(baseline: string, arm: string, metric: string, by?: number): R;
6
+ }
7
+ }
8
+ export {};
9
+ //# sourceMappingURL=jest.d.ts.map
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