vigiles 2.3.0 → 2.5.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/dist/eval.js CHANGED
@@ -1,9 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runEval = runEval;
4
+ exports.parseUsage = parseUsage;
3
5
  exports.aggregate = aggregate;
4
6
  exports.aggregateStats = aggregateStats;
5
- exports.runEval = runEval;
7
+ exports.aggregateUsage = aggregateUsage;
8
+ exports.isRateLimited = isRateLimited;
9
+ exports.runPool = runPool;
10
+ exports.runEvalWith = runEvalWith;
6
11
  exports.formatEvalReport = formatEvalReport;
12
+ exports.measureTriggerRateWith = measureTriggerRateWith;
13
+ exports.measureTriggerRate = measureTriggerRate;
14
+ exports.formatTriggerRateReport = formatTriggerRateReport;
7
15
  /**
8
16
  * vigiles — Claude Code harness *evals*.
9
17
  *
@@ -32,6 +40,8 @@ const node_fs_1 = require("node:fs");
32
40
  const node_os_1 = require("node:os");
33
41
  const node_path_1 = require("node:path");
34
42
  const plugin_loader_js_1 = require("./plugin-loader.js");
43
+ const harness_test_js_1 = require("./harness-test.js");
44
+ const eval_cache_js_1 = require("./eval-cache.js");
35
45
  function writeFiles(cwd, files) {
36
46
  for (const [p, content] of Object.entries(files)) {
37
47
  const full = (0, node_path_1.resolve)(cwd, p);
@@ -39,49 +49,87 @@ function writeFiles(cwd, files) {
39
49
  (0, node_fs_1.writeFileSync)(full, content);
40
50
  }
41
51
  }
42
- function spawnAgent(task, cwd, model, tools, hasSettings, timeoutMs) {
52
+ /* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
53
+ function spawnAgent(a) {
43
54
  return new Promise((resolvePromise) => {
44
55
  const args = [
45
56
  "-p",
46
- task,
57
+ a.task,
58
+ // stream-json (+ --verbose, required with -p) so the per-turn tool_use
59
+ // events survive into `ctx.toolCalls` — the unified Trace, same as the
60
+ // harness tier. The terminal `result` event still carries num_turns/output.
47
61
  "--output-format",
48
- "json",
62
+ "stream-json",
63
+ "--verbose",
49
64
  "--model",
50
- model,
65
+ a.model,
51
66
  "--permission-mode",
52
67
  "acceptEdits",
53
- ...(hasSettings ? ["--settings", "settings.json"] : []),
68
+ ...(a.pluginDir !== undefined
69
+ ? ["--plugin-dir", (0, node_path_1.resolve)(a.pluginDir)]
70
+ : []),
71
+ ...(a.hasSettings ? ["--settings", "settings.json"] : []),
54
72
  "--allowedTools",
55
- ...tools,
73
+ ...a.tools,
56
74
  ];
57
75
  const child = (0, node_child_process_1.spawn)("claude", args, {
58
- cwd,
76
+ cwd: a.cwd,
59
77
  env: process.env,
60
78
  stdio: ["ignore", "pipe", "pipe"],
61
79
  });
62
80
  let stdout = "";
81
+ let stderr = "";
63
82
  child.stdout.on("data", (d) => (stdout += d.toString()));
64
- const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
83
+ child.stderr.on("data", (d) => (stderr += d.toString()));
84
+ const timer = setTimeout(() => child.kill("SIGKILL"), a.timeoutMs);
65
85
  child.on("close", (code) => {
66
86
  clearTimeout(timer);
67
- resolvePromise({ code: code ?? 0, stdout });
87
+ resolvePromise({ code: code ?? 0, stdout, stderr });
68
88
  });
69
89
  });
70
90
  }
91
+ /**
92
+ * Run the eval: every arm × every trial against the real `claude` CLI, with the
93
+ * metric computed per run and aggregated per arm. Requires `claude` on PATH and
94
+ * working model auth (e.g. `ANTHROPIC_API_KEY`). Thin wrapper over
95
+ * {@link runEvalWith} with the real agent runner.
96
+ */
97
+ async function runEval(spec) {
98
+ return runEvalWith(spec, spawnAgent);
99
+ }
100
+ /* v8 ignore stop */
71
101
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
102
+ /** Pull cost / latency / tokens out of a parsed `result` event (0 when absent). */
103
+ function usageFrom(result) {
104
+ const num = (v) => (typeof v === "number" ? v : 0);
105
+ const usage = (result?.usage ?? {});
106
+ return {
107
+ costUsd: num(result?.total_cost_usd),
108
+ durationMs: num(result?.duration_ms),
109
+ inputTokens: num(usage.input_tokens),
110
+ outputTokens: num(usage.output_tokens),
111
+ };
112
+ }
113
+ /** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
114
+ function parseUsage(stdout) {
115
+ return usageFrom((0, harness_test_js_1.parseResultEvent)(stdout));
116
+ }
72
117
  function makeContext(cwd, out) {
73
- let turns = 0;
74
- try {
75
- turns = JSON.parse(out.stdout).num_turns ?? 0;
76
- }
77
- catch {
78
- /* non-JSON output */
79
- }
118
+ const result = (0, harness_test_js_1.parseResultEvent)(out.stdout);
119
+ const turns = typeof result?.num_turns === "number" ? result.num_turns : 0;
120
+ const output = typeof result?.result === "string" ? result.result : "";
80
121
  return {
81
122
  cwd,
82
123
  exitCode: out.code,
83
124
  stdout: out.stdout,
84
125
  turns,
126
+ toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
127
+ hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
128
+ output,
129
+ usage: usageFrom(result),
130
+ // The eval tier drives the real API (no mock between claude and the model),
131
+ // so the requests can't be captured here — modelRequests is harness-tier only.
132
+ modelRequests: [],
85
133
  file: (p) => {
86
134
  const f = (0, node_path_1.resolve)(cwd, p);
87
135
  return (0, node_fs_1.existsSync)(f) ? (0, node_fs_1.readFileSync)(f, "utf-8") : null;
@@ -142,66 +190,280 @@ function aggregateStats(rows) {
142
190
  const std = n > 1
143
191
  ? Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
144
192
  : 0;
145
- out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n };
193
+ const passK = n > 0 && values.every((v) => v > 0) ? 1 : 0;
194
+ out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n, passK };
146
195
  }
147
196
  return out;
148
197
  }
198
+ /** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
199
+ function aggregateUsage(usages) {
200
+ const n = usages.length;
201
+ const sum = (f) => usages.reduce((a, u) => a + f(u), 0);
202
+ const totalCostUsd = sum((u) => u.costUsd);
203
+ return {
204
+ totalCostUsd,
205
+ meanCostUsd: n > 0 ? totalCostUsd / n : 0,
206
+ meanDurationMs: n > 0 ? sum((u) => u.durationMs) / n : 0,
207
+ totalInputTokens: sum((u) => u.inputTokens),
208
+ totalOutputTokens: sum((u) => u.outputTokens),
209
+ };
210
+ }
149
211
  /**
150
- * Run the eval: every arm × every trial against the real `claude` CLI, with the
151
- * metric computed per run and aggregated per arm. Requires `claude` on PATH and
152
- * working model auth (e.g. `ANTHROPIC_API_KEY`).
212
+ * Run one trial through the cache: on a hit, restore the recorded post-run
213
+ * filesystem into `cwd` and return the recorded output (no model call); on a
214
+ * miss, run the agent and (in `readwrite`) record output + cwd snapshot. The
215
+ * cache key excludes `measure`, so editing the metric still replays.
153
216
  */
154
- async function runEval(spec) {
217
+ async function runWithCache(runArgs, keyParts, runner, cfg) {
218
+ if (cfg.cache === "off")
219
+ return runner(runArgs);
220
+ const key = (0, eval_cache_js_1.cacheKey)({
221
+ task: runArgs.task,
222
+ model: runArgs.model,
223
+ tools: runArgs.tools,
224
+ files: keyParts.files,
225
+ settings: keyParts.settings,
226
+ trialIndex: keyParts.trialIndex,
227
+ });
228
+ const hit = (0, eval_cache_js_1.readCache)(cfg.cacheDir, key);
229
+ if (hit) {
230
+ (0, eval_cache_js_1.restoreDir)(runArgs.cwd, hit.files);
231
+ return hit.out;
232
+ }
233
+ const out = await runner(runArgs);
234
+ if (cfg.cache === "readwrite") {
235
+ (0, eval_cache_js_1.writeCache)(cfg.cacheDir, key, { out, files: (0, eval_cache_js_1.snapshotDir)(runArgs.cwd) });
236
+ }
237
+ return out;
238
+ }
239
+ /** Execute one trial in a fresh sandbox; returns its metric row + usage. */
240
+ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
241
+ const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
242
+ try {
243
+ const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
244
+ plugin: arm.plugin,
245
+ settings: arm.settings,
246
+ files: { ...spec.fixture, ...arm.files },
247
+ });
248
+ writeFiles(cwd, files);
249
+ const hasSettings = settings !== undefined;
250
+ if (hasSettings) {
251
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
252
+ }
253
+ const out = await runWithCache({
254
+ task: spec.task,
255
+ cwd,
256
+ model: cfg.model,
257
+ tools: cfg.tools,
258
+ hasSettings,
259
+ pluginDir: arm.pluginDir,
260
+ timeoutMs: cfg.timeoutMs,
261
+ }, { files, settings, trialIndex }, runner, cfg);
262
+ const ctx = makeContext(cwd, out);
263
+ return { row: spec.measure(ctx), usage: ctx.usage };
264
+ }
265
+ finally {
266
+ (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
267
+ }
268
+ }
269
+ // A signal in the captured streams that the model call was rate-limited /
270
+ // overloaded — worth a backoff + retry rather than counting as a real sample.
271
+ const RATE_LIMIT_RE = /rate.?limit|\b429\b|overloaded|too many requests/i;
272
+ /** Whether a run's captured output looks like a rate-limit / overload. Pure. */
273
+ function isRateLimited(out) {
274
+ return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
275
+ }
276
+ /** Call `runner`, retrying with exponential backoff while it looks rate-limited. */
277
+ async function runWithRetry(runArgs, runner, retries, baseMs) {
278
+ for (let attempt = 0;; attempt++) {
279
+ const out = await runner(runArgs);
280
+ if (!isRateLimited(out) || attempt >= retries)
281
+ return out;
282
+ await sleep(baseMs * 2 ** attempt);
283
+ }
284
+ }
285
+ /** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
286
+ async function runPool(items, concurrency, worker) {
287
+ const results = new Array(items.length);
288
+ let next = 0;
289
+ const drain = async () => {
290
+ for (;;) {
291
+ const i = next++;
292
+ const item = items[i];
293
+ if (i >= items.length || item === undefined)
294
+ return;
295
+ results[i] = await worker(item);
296
+ }
297
+ };
298
+ const workers = Math.max(1, Math.min(concurrency, items.length || 1));
299
+ await Promise.all(Array.from({ length: workers }, drain));
300
+ return results;
301
+ }
302
+ /** Flatten arms × trials into a single work list (so concurrency spans both). */
303
+ function buildUnits(arms, trials) {
304
+ const units = [];
305
+ for (const [armName, arm] of Object.entries(arms)) {
306
+ for (let t = 0; t < trials; t++)
307
+ units.push({ armName, arm, trialIndex: t });
308
+ }
309
+ return units;
310
+ }
311
+ /** Group completed (non-skipped) unit results by arm and aggregate each. */
312
+ function aggregateArms(armNames, results) {
313
+ const arms = {};
314
+ let totalCostUsd = 0;
315
+ for (const armName of armNames) {
316
+ const done = results.filter((r) => !r.skipped && r.armName === armName);
317
+ const rows = done.map((d) => d.row);
318
+ const usage = aggregateUsage(done.map((d) => d.usage));
319
+ totalCostUsd += usage.totalCostUsd;
320
+ arms[armName] = {
321
+ runs: rows.length,
322
+ metrics: aggregate(rows),
323
+ stats: aggregateStats(rows),
324
+ usage,
325
+ };
326
+ }
327
+ return { arms, totalCostUsd };
328
+ }
329
+ /**
330
+ * The eval orchestration — every arm × trial via `runner`, run through the cache
331
+ * and a rate-limit retry, with at most `concurrency` in flight and an optional
332
+ * `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
333
+ * arm. Exported with an injectable `runner` so the loop, `measure` context,
334
+ * caching, pooling, and aggregation are unit-testable without spawning a model
335
+ * (pass a fake returning canned stream-json). `runEval` is this with the real
336
+ * agent runner.
337
+ */
338
+ async function runEvalWith(spec, runner) {
155
339
  const trials = spec.trials ?? 5;
340
+ const spacing = (spec.spacingSec ?? 4) * 1000;
341
+ const concurrency = spec.concurrency ?? 1;
342
+ const retries = spec.rateLimitRetries ?? 3;
343
+ const backoffMs = spec.retryBackoffMs ?? 1000;
344
+ const cfg = {
345
+ model: spec.model ?? "haiku",
346
+ tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"],
347
+ timeoutMs: spec.timeoutMs ?? 240000,
348
+ cache: spec.cache ?? "off",
349
+ cacheDir: spec.cacheDir ?? (0, node_path_1.resolve)(process.cwd(), ".vigiles", "eval-cache"),
350
+ };
351
+ const retrying = (a) => runWithRetry(a, runner, retries, backoffMs);
352
+ const units = buildUnits(spec.arms, trials);
353
+ let spent = 0;
354
+ let aborted = false;
355
+ const worker = async (unit) => {
356
+ if (aborted)
357
+ return { armName: unit.armName, skipped: true };
358
+ const { row, usage } = await executeTrial(spec, unit.arm, unit.trialIndex, retrying, cfg);
359
+ spent += usage.costUsd;
360
+ if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd) {
361
+ aborted = true;
362
+ }
363
+ if (spacing > 0)
364
+ await sleep(spacing);
365
+ return { armName: unit.armName, skipped: false, row, usage };
366
+ };
367
+ const results = await runPool(units, concurrency, worker);
368
+ const { arms, totalCostUsd } = aggregateArms(Object.keys(spec.arms), results);
369
+ return { name: spec.name ?? "eval", trials, arms, totalCostUsd, aborted };
370
+ }
371
+ /** Render one metric: `name=mean±se pass^k=…` (se/pass^k shown when measured). */
372
+ function formatMetric(name, mean, stat) {
373
+ const base = stat && stat.se > 0
374
+ ? `${name}=${mean.toFixed(2)}±${stat.se.toFixed(2)}`
375
+ : `${name}=${mean.toFixed(2)}`;
376
+ return stat && stat.n > 0 ? `${base} pass^k=${String(stat.passK)}` : base;
377
+ }
378
+ /** Compact tokens like `3.4k`; whole numbers under 1000 stay as-is. */
379
+ function fmtTokens(n) {
380
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
381
+ }
382
+ /** A `($0.0123 · 1.2s/run · 3.4k tok)` suffix, or "" when no usage was reported. */
383
+ function formatUsage(u) {
384
+ if (u.totalCostUsd === 0 && u.totalInputTokens + u.totalOutputTokens === 0) {
385
+ return "";
386
+ }
387
+ const tok = fmtTokens(u.totalInputTokens + u.totalOutputTokens);
388
+ return ` ($${u.totalCostUsd.toFixed(4)} · ${(u.meanDurationMs / 1000).toFixed(1)}s/run · ${tok} tok)`;
389
+ }
390
+ /** Format an eval report as a compact table for the console (mean ± se, pass^k). */
391
+ function formatEvalReport(report) {
392
+ const header = report.totalCostUsd > 0
393
+ ? `${report.name} (${String(report.trials)} trials/arm) — $${report.totalCostUsd.toFixed(4)} total`
394
+ : `${report.name} (${String(report.trials)} trials/arm)`;
395
+ const lines = [header];
396
+ for (const [arm, r] of Object.entries(report.arms)) {
397
+ const parts = Object.entries(r.metrics)
398
+ .map(([k, v]) => formatMetric(k, v, r.stats[k]))
399
+ .join(" ");
400
+ lines.push(` ${arm.padEnd(10)} ${parts}${formatUsage(r.usage)}`);
401
+ }
402
+ return lines.join("\n");
403
+ }
404
+ /**
405
+ * Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
406
+ * predicate evaluated per run and aggregated into an overall + per-prompt rate.
407
+ * Exported with an injectable `runner` so the loop is unit-testable without a
408
+ * model; `measureTriggerRate` is this with the real agent runner.
409
+ */
410
+ async function measureTriggerRateWith(spec, runner) {
411
+ const trials = spec.trials ?? 1;
156
412
  const model = spec.model ?? "haiku";
157
- const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
413
+ const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"];
158
414
  const timeoutMs = spec.timeoutMs ?? 240000;
159
415
  const spacing = (spec.spacingSec ?? 4) * 1000;
160
- const arms = {};
161
- for (const [armName, arm] of Object.entries(spec.arms)) {
162
- const rows = [];
416
+ const perPrompt = [];
417
+ let firedTotal = 0;
418
+ let n = 0;
419
+ for (const prompt of spec.prompts) {
420
+ let fired = 0;
163
421
  for (let t = 0; t < trials; t++) {
164
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
422
+ const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
165
423
  try {
166
- const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
167
- plugin: arm.plugin,
168
- settings: arm.settings,
169
- files: { ...spec.fixture, ...arm.files },
424
+ const out = await runner({
425
+ task: prompt,
426
+ cwd,
427
+ model,
428
+ tools,
429
+ hasSettings: false,
430
+ pluginDir: spec.pluginDir,
431
+ timeoutMs,
170
432
  });
171
- writeFiles(cwd, files);
172
- const hasSettings = settings !== undefined;
173
- if (hasSettings) {
174
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
175
- }
176
- const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, timeoutMs);
177
- rows.push(spec.measure(makeContext(cwd, out)));
433
+ if (spec.fired(makeContext(cwd, out)))
434
+ fired++;
178
435
  }
179
436
  finally {
180
437
  (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
181
438
  await sleep(spacing);
182
439
  }
183
440
  }
184
- arms[armName] = {
185
- runs: rows.length,
186
- metrics: aggregate(rows),
187
- stats: aggregateStats(rows),
188
- };
441
+ perPrompt.push({
442
+ prompt,
443
+ fired,
444
+ trials,
445
+ rate: trials > 0 ? fired / trials : 0,
446
+ });
447
+ firedTotal += fired;
448
+ n += trials;
189
449
  }
190
- return { name: spec.name ?? "eval", trials, arms };
450
+ return { rate: n > 0 ? firedTotal / n : 0, n, perPrompt };
191
451
  }
192
- /** Format an eval report as a compact table for the console (mean ± se). */
193
- function formatEvalReport(report) {
194
- const lines = [`${report.name} (${String(report.trials)} trials/arm)`];
195
- for (const [arm, r] of Object.entries(report.arms)) {
196
- const parts = Object.entries(r.metrics)
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
- })
203
- .join(" ");
204
- lines.push(` ${arm.padEnd(10)} ${parts}`);
452
+ /* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
453
+ /**
454
+ * Measure a skill/behaviour's real trigger rate across prompts × trials against
455
+ * the real `claude` CLI. Requires `claude` + model auth.
456
+ */
457
+ async function measureTriggerRate(spec) {
458
+ return measureTriggerRateWith(spec, spawnAgent);
459
+ }
460
+ /* v8 ignore stop */
461
+ /** Format a trigger-rate report: overall %, then each prompt's rate. */
462
+ function formatTriggerRateReport(report) {
463
+ const pct = (report.rate * 100).toFixed(0);
464
+ const lines = [`trigger-rate: ${pct}% (${String(report.n)} runs)`];
465
+ for (const p of report.perPrompt) {
466
+ lines.push(` ${p.rate.toFixed(2)} ${p.prompt.slice(0, 60)}`);
205
467
  }
206
468
  return lines.join("\n");
207
469
  }
@@ -13,9 +13,13 @@
13
13
  * `expect(...).toHaveCreated(...)` sugar. The signature is identical for
14
14
  * vitest and jest, so the same object supports both.
15
15
  */
16
- import { type HarnessTestSpec, type HarnessTestResult } from "./harness-test.js";
17
- import type { EvalReport } from "./eval.js";
16
+ import { type HarnessTestSpec, type HarnessTestResult, type ToolCall, type Trace } from "./harness-test.js";
17
+ import type { EvalReport, TriggerRateReport } from "./eval.js";
18
18
  import type { HookRunResult } from "./run-hook.js";
19
+ import type { OutputContract } from "./spec.js";
20
+ import { type ParsedAgentResult } from "./agent-result.js";
21
+ export { compareArms } from "./stats.js";
22
+ export type { Comparison } from "./stats.js";
19
23
  /**
20
24
  * Run a harness test, hand the result to `fn`, and always clean up the sandbox.
21
25
  * Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
@@ -32,18 +36,184 @@ export declare function assertServedTurns(r: HarnessTestResult, n: number): void
32
36
  export declare function assertHookBlocked(r: HookRunResult): void;
33
37
  /** Assert a `runHook` result allowed (did not block). */
34
38
  export declare function assertHookAllowed(r: HookRunResult): void;
39
+ /**
40
+ * Assert the worker's output is a success result, and return its `value`. With a
41
+ * `contract`, the value is validated against the success shape (a wrong/missing
42
+ * field fails the assertion). A malformed or error result throws.
43
+ */
44
+ export declare function assertAgentOk(output: string, contract?: OutputContract): Record<string, unknown>;
45
+ /**
46
+ * Assert the worker's output is an error result, and return its `error`. The
47
+ * railway's error track — proves the worker reported failure with rich detail
48
+ * (not that it crashed or returned prose). A malformed or success result throws.
49
+ */
50
+ export declare function assertAgentErr(output: string, contract?: OutputContract): Record<string, unknown>;
51
+ /**
52
+ * Assert the parsed result satisfies `predicate` — the general form, for
53
+ * checking rich detail (e.g. `(r) => r.kind === "ok" && r.value.files.length > 0`).
54
+ */
55
+ export declare function assertAgentResult(output: string, predicate: (r: ParsedAgentResult) => boolean, contract?: OutputContract): void;
56
+ /**
57
+ * Did the agent invoke a tool whose name matches `name` (string = exact,
58
+ * RegExp = test)? The predicate behind `assertToolUsed` / `assertToolNotUsed`.
59
+ */
60
+ export declare function usedTool(trace: Trace, name: string | RegExp): boolean;
61
+ /** How many tools matching `name` the agent invoked. Behind `assertToolCount`. */
62
+ export declare function toolCount(trace: Trace, name: string | RegExp): number;
63
+ /**
64
+ * Did the `Skill` tool resolve `skill` (e.g. `"superpowers:test-driven-development"`)
65
+ * without error? The skill-activation predicate behind `assertSkillResolved`.
66
+ */
67
+ export declare function skillResolved(trace: Trace, skill: string): boolean;
68
+ /**
69
+ * Did the agent invoke a tool matching `name` whose INPUT satisfies
70
+ * `inputMatcher` — a tool-ARGUMENT predicate (DeepEval-style), e.g. an `Edit`
71
+ * that targeted the right file. The predicate behind `assertToolUsedWith`.
72
+ */
73
+ export declare function toolUsedWith(trace: Trace, name: string | RegExp, inputMatcher: (input: unknown) => boolean): boolean;
74
+ /**
75
+ * Does the agent's final answer (`trace.output`) contain `needle` (string =
76
+ * substring, RegExp = test)? The output predicate behind `assertOutputContains`
77
+ * — the DeepEval-style "what did the agent actually say" check.
78
+ */
79
+ export declare function outputContains(trace: Trace, needle: string | RegExp): boolean;
80
+ /**
81
+ * Did ANY request the model received contain `needle` — searching the system
82
+ * prompt and every message across all requests? The predicate that proves
83
+ * injected context *reached the model*: a SessionStart hook's `additionalContext`
84
+ * or a slash command's expansion. Harness tier only — the eval tier drives the
85
+ * real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
86
+ */
87
+ export declare function requestContains(trace: Trace, needle: string | RegExp): boolean;
88
+ /**
89
+ * Did a hook matching `name` fire? Matches against both the hook label
90
+ * (`"PreToolUse:Edit"`) and the bare event (`"PreToolUse"`), so `/PreToolUse/`
91
+ * or `"PreToolUse:Edit"` both work. The predicate behind `assertHookFired`.
92
+ */
93
+ export declare function hookFired(trace: Trace, name: string | RegExp): boolean;
94
+ /** Did a hook matching `name` fire AND block (exit ≠ 0 / outcome "error")? */
95
+ export declare function hookBlocked(trace: Trace, name: string | RegExp): boolean;
96
+ /**
97
+ * Assert the agent invoked a tool whose name matches `name` (string = exact,
98
+ * RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
99
+ * a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
100
+ * skill/MCP/command surfaces are really about.
101
+ */
102
+ export declare function assertToolUsed(trace: Trace, name: string | RegExp): void;
103
+ /**
104
+ * Assert the agent did NOT invoke any tool matching `name` — the safety negative
105
+ * (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
106
+ * accident; "the tool was never used" is the real invariant. Needs `transcript`.
107
+ */
108
+ export declare function assertToolNotUsed(trace: Trace, name: string | RegExp): void;
109
+ /**
110
+ * Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
111
+ * without error — the correct skill-activation invariant, vs. grepping the body.
112
+ */
113
+ export declare function assertSkillResolved(trace: Trace, skill: string): void;
114
+ /**
115
+ * Assert the agent invoked a tool matching `name` whose INPUT satisfies
116
+ * `inputMatcher` — a tool-ARGUMENT invariant (DeepEval-style). Asserts not just
117
+ * *that* a tool ran but *with what args*, e.g. an `Edit` that targeted the right
118
+ * file: `assertToolUsedWith(r, "Edit", (i) => (i as { file_path?: string })
119
+ * .file_path === "src/x.ts")`. Needs `transcript`.
120
+ */
121
+ export declare function assertToolUsedWith(trace: Trace, name: string | RegExp, inputMatcher: (input: unknown) => boolean, message?: string): void;
122
+ /** Assert the agent's final answer contains `needle` (string substring / RegExp). */
123
+ export declare function assertOutputContains(trace: Trace, needle: string | RegExp): void;
124
+ /**
125
+ * Assert some request the model received contained `needle` — the "did the
126
+ * injected context land" invariant (SessionStart `additionalContext`, slash
127
+ * command expansion). Harness tier only; a zero-request trace fails with a hint
128
+ * that the eval tier can't capture requests.
129
+ */
130
+ export declare function assertRequestContains(trace: Trace, needle: string | RegExp): void;
131
+ /**
132
+ * Assert a hook matching `name` fired (and, with `{ blocked: true }`, that it
133
+ * blocked) — the honest hook-firing check, recorded from the run's stream rather
134
+ * than inferred from a marker file the hook had to write. Needs `transcript`.
135
+ */
136
+ export declare function assertHookFired(trace: Trace, name: string | RegExp, opts?: {
137
+ blocked?: boolean;
138
+ }): void;
139
+ /**
140
+ * Assert how many tools matching `name` the agent invoked is within bounds — a
141
+ * budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
142
+ * "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
143
+ */
144
+ export declare function assertToolCount(trace: Trace, name: string | RegExp, bounds: {
145
+ min?: number;
146
+ max?: number;
147
+ exactly?: number;
148
+ }): void;
149
+ /**
150
+ * Assert the named tools occurred in this order (as a subsequence — gaps allowed)
151
+ * — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
152
+ * Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
153
+ * Needs `transcript`.
154
+ */
155
+ export declare function assertToolSequence(trace: Trace, names: ReadonlyArray<string | RegExp>): void;
156
+ /**
157
+ * The escape hatch: assert any custom invariant over the full list of tool calls
158
+ * the agent made — for rules the helpers above don't express, e.g. "every Edit
159
+ * was preceded by a Read of that file". Needs `transcript`.
160
+ */
161
+ export declare function assertToolCalls(trace: Trace, predicate: (calls: readonly ToolCall[]) => boolean, message?: string): void;
162
+ /**
163
+ * Did `arm` succeed on EVERY trial for `metric` — τ-bench pass^k = 1? The
164
+ * reliability predicate over an eval report (vs. `improvement`, which reads the
165
+ * mean gap). Reads `report.arms[arm].stats[metric].passK`.
166
+ */
167
+ export declare function reliable(report: EvalReport, arm: string, metric: string): boolean;
168
+ /**
169
+ * Assert `arm` passed `metric` on every trial (pass^k = 1) — the reliability
170
+ * gate for a non-deterministic harness ("worked every time", not "on average").
171
+ */
172
+ export declare function assertReliable(report: EvalReport, opts: {
173
+ arm: string;
174
+ metric: string;
175
+ }): void;
35
176
  /** The gap on `metric` between two arms (arm − baseline). */
36
177
  export declare function improvement(report: EvalReport, baseline: string, arm: string, metric: string): number;
37
178
  /**
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.
179
+ * Did `arm` *significantly* beat `baseline` on `metric` a positive gap whose
180
+ * two-sided Welch t-test p-value is below `alpha` (default 0.05)? The grounded
181
+ * upgrade over `improvement`: the noise floor is computed from the arms' spread,
182
+ * not hand-fed. False when either arm/metric is missing. See `src/stats.ts`.
183
+ */
184
+ export declare function significantlyBeats(report: EvalReport, baseline: string, arm: string, metric: string, alpha?: number): boolean;
185
+ /**
186
+ * Assert `arm` significantly beats `baseline` on `metric` (positive gap, p < α).
187
+ * The statistical gate for a non-deterministic A/B — "the gap clears the noise",
188
+ * with the noise floor computed, not supplied. The honest version of
189
+ * `assertImproves(..., { by: se })`.
190
+ */
191
+ export declare function assertSignificant(report: EvalReport, opts: {
192
+ baseline: string;
193
+ arm: string;
194
+ metric: string;
195
+ alpha?: number;
196
+ }): void;
197
+ /**
198
+ * Assert `arm` beats `baseline` on `metric`. By default just a positive gap > `by`
199
+ * (pass the combined se to clear the noise floor by hand). Pass `{ significant:
200
+ * true }` to demand a Welch t-test at `alpha` instead — the computed noise floor.
41
201
  */
42
202
  export declare function assertImproves(report: EvalReport, opts: {
43
203
  baseline: string;
44
204
  arm: string;
45
205
  metric: string;
46
206
  by?: number;
207
+ significant?: boolean;
208
+ alpha?: number;
209
+ }): void;
210
+ /**
211
+ * Assert a skill/behaviour triggered on at least `min` (0..1) of its runs — the
212
+ * reliability gate for a skill's *activation* (does its description fire on the
213
+ * task), over a {@link TriggerRateReport} from `measureTriggerRate`.
214
+ */
215
+ export declare function assertTriggerRate(report: TriggerRateReport, opts: {
216
+ min: number;
47
217
  }): void;
48
218
  interface MatcherOutput {
49
219
  pass: boolean;
@@ -64,5 +234,4 @@ export declare const vigilesMatchers: {
64
234
  toBlock(received: HookRunResult): MatcherOutput;
65
235
  toBeatBaseline(received: EvalReport, baseline: string, arm: string, metric: string, by?: number): MatcherOutput;
66
236
  };
67
- export {};
68
237
  //# sourceMappingURL=harness-assert.d.ts.map