vigiles 2.2.0 → 2.4.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,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);
@@ -37,7 +39,7 @@ function writeFiles(cwd, files) {
37
39
  (0, node_fs_1.writeFileSync)(full, content);
38
40
  }
39
41
  }
40
- function spawnAgent(task, cwd, model, tools, hasSettings, timeoutMs) {
42
+ function spawnAgent(task, cwd, model, tools, hasSettings, pluginDir, timeoutMs) {
41
43
  return new Promise((resolvePromise) => {
42
44
  const args = [
43
45
  "-p",
@@ -48,6 +50,7 @@ function spawnAgent(task, cwd, model, tools, hasSettings, timeoutMs) {
48
50
  model,
49
51
  "--permission-mode",
50
52
  "acceptEdits",
53
+ ...(pluginDir !== undefined ? ["--plugin-dir", (0, node_path_1.resolve)(pluginDir)] : []),
51
54
  ...(hasSettings ? ["--settings", "settings.json"] : []),
52
55
  "--allowedTools",
53
56
  ...tools,
@@ -101,28 +104,46 @@ function makeContext(cwd, out) {
101
104
  },
102
105
  };
103
106
  }
107
+ /** Coerce a metric value to a number (booleans → 0/1), or null if absent. */
108
+ function numeric(v) {
109
+ if (typeof v === "number")
110
+ return v;
111
+ if (typeof v === "boolean")
112
+ return v ? 1 : 0;
113
+ return null;
114
+ }
104
115
  /** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
105
116
  function aggregate(rows) {
117
+ const stats = aggregateStats(rows);
118
+ const out = {};
119
+ for (const [k, s] of Object.entries(stats))
120
+ out[k] = s.mean;
121
+ return out;
122
+ }
123
+ /**
124
+ * Aggregate per-run metrics with spread: mean, sample std, standard error, and
125
+ * n. The se/std let you judge whether an A/B gap between arms is real or noise —
126
+ * a difference smaller than the combined se is not yet significant.
127
+ */
128
+ function aggregateStats(rows) {
106
129
  const keys = new Set();
107
130
  for (const r of rows)
108
131
  for (const k of Object.keys(r))
109
132
  keys.add(k);
110
133
  const out = {};
111
134
  for (const k of keys) {
112
- let sum = 0;
113
- let n = 0;
135
+ const values = [];
114
136
  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
- }
137
+ const v = numeric(r[k]);
138
+ if (v !== null)
139
+ values.push(v);
124
140
  }
125
- out[k] = n > 0 ? sum / n : 0;
141
+ const n = values.length;
142
+ const mean = n > 0 ? values.reduce((a, b) => a + b, 0) / n : 0;
143
+ const std = n > 1
144
+ ? Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
145
+ : 0;
146
+ out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n };
126
147
  }
127
148
  return out;
128
149
  }
@@ -143,12 +164,17 @@ async function runEval(spec) {
143
164
  for (let t = 0; t < trials; t++) {
144
165
  const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
145
166
  try {
146
- writeFiles(cwd, { ...spec.fixture, ...arm.files });
147
- const hasSettings = arm.settings !== undefined;
167
+ const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
168
+ plugin: arm.plugin,
169
+ settings: arm.settings,
170
+ files: { ...spec.fixture, ...arm.files },
171
+ });
172
+ writeFiles(cwd, files);
173
+ const hasSettings = settings !== undefined;
148
174
  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));
175
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
150
176
  }
151
- const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, timeoutMs);
177
+ const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, arm.pluginDir, timeoutMs);
152
178
  rows.push(spec.measure(makeContext(cwd, out)));
153
179
  }
154
180
  finally {
@@ -156,16 +182,25 @@ async function runEval(spec) {
156
182
  await sleep(spacing);
157
183
  }
158
184
  }
159
- arms[armName] = { runs: rows.length, metrics: aggregate(rows) };
185
+ arms[armName] = {
186
+ runs: rows.length,
187
+ metrics: aggregate(rows),
188
+ stats: aggregateStats(rows),
189
+ };
160
190
  }
161
191
  return { name: spec.name ?? "eval", trials, arms };
162
192
  }
163
- /** Format an eval report as a compact table for the console. */
193
+ /** Format an eval report as a compact table for the console (mean ± se). */
164
194
  function formatEvalReport(report) {
165
195
  const lines = [`${report.name} (${String(report.trials)} trials/arm)`];
166
196
  for (const [arm, r] of Object.entries(report.arms)) {
167
197
  const parts = Object.entries(r.metrics)
168
- .map(([k, v]) => `${k}=${v.toFixed(2)}`)
198
+ .map(([k, v]) => {
199
+ const se = r.stats[k]?.se ?? 0;
200
+ return se > 0
201
+ ? `${k}=${v.toFixed(2)}±${se.toFixed(2)}`
202
+ : `${k}=${v.toFixed(2)}`;
203
+ })
169
204
  .join(" ");
170
205
  lines.push(` ${arm.padEnd(10)} ${parts}`);
171
206
  }
@@ -0,0 +1,109 @@
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, type ToolCall } 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
+ /**
36
+ * Assert the agent invoked a tool whose name matches `name` (string = exact,
37
+ * RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
38
+ * a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
39
+ * skill/MCP/command surfaces are really about.
40
+ */
41
+ export declare function assertToolUsed(r: HarnessTestResult, name: string | RegExp): void;
42
+ /**
43
+ * Assert the agent did NOT invoke any tool matching `name` — the safety negative
44
+ * (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
45
+ * accident; "the tool was never used" is the real invariant. Needs `transcript`.
46
+ */
47
+ export declare function assertToolNotUsed(r: HarnessTestResult, name: string | RegExp): void;
48
+ /**
49
+ * Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
50
+ * without error — the correct skill-activation invariant, vs. grepping the body.
51
+ */
52
+ export declare function assertSkillResolved(r: HarnessTestResult, skill: string): void;
53
+ /**
54
+ * Assert how many tools matching `name` the agent invoked is within bounds — a
55
+ * budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
56
+ * "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
57
+ */
58
+ export declare function assertToolCount(r: HarnessTestResult, name: string | RegExp, bounds: {
59
+ min?: number;
60
+ max?: number;
61
+ exactly?: number;
62
+ }): void;
63
+ /**
64
+ * Assert the named tools occurred in this order (as a subsequence — gaps allowed)
65
+ * — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
66
+ * Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
67
+ * Needs `transcript`.
68
+ */
69
+ export declare function assertToolSequence(r: HarnessTestResult, names: ReadonlyArray<string | RegExp>): void;
70
+ /**
71
+ * The escape hatch: assert any custom invariant over the full list of tool calls
72
+ * the agent made — for rules the helpers above don't express, e.g. "every Edit
73
+ * was preceded by a Read of that file". Needs `transcript`.
74
+ */
75
+ export declare function assertToolCalls(r: HarnessTestResult, predicate: (calls: readonly ToolCall[]) => boolean, message?: string): void;
76
+ /** The gap on `metric` between two arms (arm − baseline). */
77
+ export declare function improvement(report: EvalReport, baseline: string, arm: string, metric: string): number;
78
+ /**
79
+ * Assert `arm` beats `baseline` on `metric` by more than `by`. With `by` left at
80
+ * 0 this just asserts a positive gap; pass the combined se to demand the gap
81
+ * clear the noise floor.
82
+ */
83
+ export declare function assertImproves(report: EvalReport, opts: {
84
+ baseline: string;
85
+ arm: string;
86
+ metric: string;
87
+ by?: number;
88
+ }): void;
89
+ interface MatcherOutput {
90
+ pass: boolean;
91
+ message: () => string;
92
+ }
93
+ /**
94
+ * Custom matchers compatible with both vitest and jest. Register once:
95
+ *
96
+ * import { expect } from "vitest"; // or "@jest/globals"
97
+ * import { vigilesMatchers } from "vigiles/harness-assert";
98
+ * expect.extend(vigilesMatchers);
99
+ *
100
+ * expect(result).toHaveCreated("RESULT");
101
+ * expect(report).toBeatBaseline("vanilla", "gated", "caught");
102
+ */
103
+ export declare const vigilesMatchers: {
104
+ toHaveCreated(received: HarnessTestResult, path: string): MatcherOutput;
105
+ toBlock(received: HookRunResult): MatcherOutput;
106
+ toBeatBaseline(received: EvalReport, baseline: string, arm: string, metric: string, by?: number): MatcherOutput;
107
+ };
108
+ export {};
109
+ //# sourceMappingURL=harness-assert.d.ts.map
@@ -0,0 +1,220 @@
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.assertToolUsed = assertToolUsed;
11
+ exports.assertToolNotUsed = assertToolNotUsed;
12
+ exports.assertSkillResolved = assertSkillResolved;
13
+ exports.assertToolCount = assertToolCount;
14
+ exports.assertToolSequence = assertToolSequence;
15
+ exports.assertToolCalls = assertToolCalls;
16
+ exports.improvement = improvement;
17
+ exports.assertImproves = assertImproves;
18
+ /**
19
+ * vigiles — runner-agnostic helpers for harness tests / evals.
20
+ *
21
+ * `runHarnessTest` and `runEval` are plain async functions that return data, so
22
+ * they already work inside any runner (node:test, vitest, jest, mocha). These
23
+ * helpers remove the last bit of boilerplate without coupling to a runner:
24
+ *
25
+ * - `withHarness` — run a harness test and auto-clean the sandbox (try/finally),
26
+ * so you don't leak temp dirs in `afterEach`.
27
+ * - plain `assert*` helpers that throw — usable in every runner, including
28
+ * node:test which has no `expect.extend`.
29
+ * - `vigilesMatchers` — register with `expect.extend(vigilesMatchers)` for
30
+ * `expect(...).toHaveCreated(...)` sugar. The signature is identical for
31
+ * vitest and jest, so the same object supports both.
32
+ */
33
+ const harness_test_js_1 = require("./harness-test.js");
34
+ /**
35
+ * Run a harness test, hand the result to `fn`, and always clean up the sandbox.
36
+ * Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
37
+ * hand — it survives assertion failures.
38
+ */
39
+ async function withHarness(spec, fn) {
40
+ const r = await (0, harness_test_js_1.runHarnessTest)(spec);
41
+ try {
42
+ return await fn(r);
43
+ }
44
+ finally {
45
+ r.cleanup();
46
+ }
47
+ }
48
+ // --- Plain throwing assertions (any runner) --------------------------------
49
+ function fail(message) {
50
+ throw new Error(message);
51
+ }
52
+ /** Assert the sandbox contains `path` (a hook/agent side-effect file). */
53
+ function assertCreated(r, path) {
54
+ if (r.file(path) === null)
55
+ fail(`expected the run to create ${path}`);
56
+ }
57
+ /** Assert the sandbox does NOT contain `path` (e.g. a blocked action's output). */
58
+ function assertNotCreated(r, path) {
59
+ if (r.file(path) !== null)
60
+ fail(`expected the run NOT to create ${path}`);
61
+ }
62
+ /** Assert the scripted model served at least `n` turns (e.g. a Stop hook forced more). */
63
+ function assertServedTurns(r, n) {
64
+ if (r.turns < n) {
65
+ fail(`expected ≥ ${String(n)} model turns, got ${String(r.turns)}`);
66
+ }
67
+ }
68
+ /** Assert a `runHook` result blocked (exit 2 / decision:block / permission:deny). */
69
+ function assertHookBlocked(r) {
70
+ if (!r.blocked) {
71
+ fail(`expected the hook to block, but it allowed (exit ${String(r.exitCode)})`);
72
+ }
73
+ }
74
+ /** Assert a `runHook` result allowed (did not block). */
75
+ function assertHookAllowed(r) {
76
+ if (r.blocked) {
77
+ fail(`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`);
78
+ }
79
+ }
80
+ function nameMatches(name, pat) {
81
+ return typeof pat === "string" ? name === pat : pat.test(name);
82
+ }
83
+ function toolNames(r) {
84
+ return r.toolCalls.map((c) => c.name).join(", ") || "none";
85
+ }
86
+ /**
87
+ * Assert the agent invoked a tool whose name matches `name` (string = exact,
88
+ * RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
89
+ * a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
90
+ * skill/MCP/command surfaces are really about.
91
+ */
92
+ function assertToolUsed(r, name) {
93
+ if (!r.toolCalls.some((c) => nameMatches(c.name, name))) {
94
+ fail(`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(r)}] (did you set transcript:true?)`);
95
+ }
96
+ }
97
+ /**
98
+ * Assert the agent did NOT invoke any tool matching `name` — the safety negative
99
+ * (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
100
+ * accident; "the tool was never used" is the real invariant. Needs `transcript`.
101
+ */
102
+ function assertToolNotUsed(r, name) {
103
+ const hit = r.toolCalls.find((c) => nameMatches(c.name, name));
104
+ if (hit) {
105
+ fail(`expected no tool matching ${String(name)} to be used, but ${hit.name} was`);
106
+ }
107
+ }
108
+ /**
109
+ * Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
110
+ * without error — the correct skill-activation invariant, vs. grepping the body.
111
+ */
112
+ function assertSkillResolved(r, skill) {
113
+ const call = r.toolCalls.find((c) => c.name === "Skill" && c.input?.skill === skill);
114
+ if (!call) {
115
+ const seen = r.toolCalls
116
+ .filter((c) => c.name === "Skill")
117
+ .map((c) => c.input?.skill ?? "?")
118
+ .join(", ");
119
+ fail(`expected the Skill tool to resolve "${skill}"; Skill calls: [${seen || "none"}]`);
120
+ }
121
+ if (call.isError) {
122
+ fail(`the Skill "${skill}" was invoked but errored: ${call.resultText.slice(0, 200)}`);
123
+ }
124
+ }
125
+ // --- sequence / budget invariants over the agent's actions -----------------
126
+ /**
127
+ * Assert how many tools matching `name` the agent invoked is within bounds — a
128
+ * budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
129
+ * "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
130
+ */
131
+ function assertToolCount(r, name, bounds) {
132
+ const n = r.toolCalls.filter((c) => nameMatches(c.name, name)).length;
133
+ const ok = (bounds.exactly === undefined || n === bounds.exactly) &&
134
+ (bounds.min === undefined || n >= bounds.min) &&
135
+ (bounds.max === undefined || n <= bounds.max);
136
+ if (!ok) {
137
+ fail(`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(r)}])`);
138
+ }
139
+ }
140
+ /**
141
+ * Assert the named tools occurred in this order (as a subsequence — gaps allowed)
142
+ * — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
143
+ * Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
144
+ * Needs `transcript`.
145
+ */
146
+ function assertToolSequence(r, names) {
147
+ let i = 0;
148
+ for (const c of r.toolCalls) {
149
+ const want = names[i];
150
+ if (want !== undefined && nameMatches(c.name, want))
151
+ i++;
152
+ }
153
+ if (i < names.length) {
154
+ fail(`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(r)}]`);
155
+ }
156
+ }
157
+ /**
158
+ * The escape hatch: assert any custom invariant over the full list of tool calls
159
+ * the agent made — for rules the helpers above don't express, e.g. "every Edit
160
+ * was preceded by a Read of that file". Needs `transcript`.
161
+ */
162
+ function assertToolCalls(r, predicate, message = "tool-call invariant failed") {
163
+ if (!predicate(r.toolCalls)) {
164
+ fail(`${message}; tools used: [${toolNames(r)}]`);
165
+ }
166
+ }
167
+ /** The gap on `metric` between two arms (arm − baseline). */
168
+ function improvement(report, baseline, arm, metric) {
169
+ const a = report.arms[arm]?.metrics[metric] ?? 0;
170
+ const b = report.arms[baseline]?.metrics[metric] ?? 0;
171
+ return a - b;
172
+ }
173
+ /**
174
+ * Assert `arm` beats `baseline` on `metric` by more than `by`. With `by` left at
175
+ * 0 this just asserts a positive gap; pass the combined se to demand the gap
176
+ * clear the noise floor.
177
+ */
178
+ function assertImproves(report, opts) {
179
+ const by = opts.by ?? 0;
180
+ const delta = improvement(report, opts.baseline, opts.arm, opts.metric);
181
+ if (delta <= by) {
182
+ fail(`expected ${opts.arm} to beat ${opts.baseline} on ${opts.metric} by > ${String(by)}, got ${delta.toFixed(3)}`);
183
+ }
184
+ }
185
+ /**
186
+ * Custom matchers compatible with both vitest and jest. Register once:
187
+ *
188
+ * import { expect } from "vitest"; // or "@jest/globals"
189
+ * import { vigilesMatchers } from "vigiles/harness-assert";
190
+ * expect.extend(vigilesMatchers);
191
+ *
192
+ * expect(result).toHaveCreated("RESULT");
193
+ * expect(report).toBeatBaseline("vanilla", "gated", "caught");
194
+ */
195
+ exports.vigilesMatchers = {
196
+ toHaveCreated(received, path) {
197
+ const pass = received.file(path) !== null;
198
+ return {
199
+ pass,
200
+ message: () => `expected the run ${pass ? "not " : ""}to create ${path}`,
201
+ };
202
+ },
203
+ toBlock(received) {
204
+ const pass = received.blocked;
205
+ return {
206
+ pass,
207
+ message: () => `expected the hook ${pass ? "not " : ""}to block (exit ${String(received.exitCode)}, decision ${String(received.decision)})`,
208
+ };
209
+ },
210
+ // eslint-disable-next-line max-params -- jest/vitest matchers take positional args
211
+ toBeatBaseline(received, baseline, arm, metric, by = 0) {
212
+ const delta = improvement(received, baseline, arm, metric);
213
+ const pass = delta > by;
214
+ return {
215
+ pass,
216
+ message: () => `expected ${arm} ${pass ? "not " : ""}to beat ${baseline} on ${metric} by > ${String(by)} (got ${delta.toFixed(3)})`,
217
+ };
218
+ },
219
+ };
220
+ //# sourceMappingURL=harness-assert.js.map
@@ -1,16 +1,40 @@
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;
15
+ /**
16
+ * Path to a plugin dir to install NATIVELY via `claude --plugin-dir`, so its
17
+ * skills / commands / agents / hooks register and ACTIVATE the real way — a
18
+ * scripted `Skill` tool_use resolves, and the real model can trigger them.
19
+ * Unlike `plugin` (which materializes a file subset that does NOT register
20
+ * skills for the `Skill` tool), this is the real install path, so point it at a
21
+ * COMPLETE plugin (internal references resolve). Inline `settings`/`files` and
22
+ * `plugin` still layer on top. Resolved to an absolute path.
23
+ */
24
+ readonly pluginDir?: string;
8
25
  /** The scripted model turns the agent will take. */
9
26
  readonly model: readonly ModelTurn[];
10
27
  /** The user prompt. Default: "go". */
11
28
  readonly prompt?: string;
12
29
  /** Tools the agent may use. Default: Read Edit Write Bash. */
13
30
  readonly allowedTools?: readonly string[];
31
+ /**
32
+ * Capture the full event transcript (`--output-format stream-json`) into
33
+ * `stdout`, instead of just the final result object, so you can assert on what
34
+ * the agent's tools returned — e.g. the body a `Skill` tool_use resolved. With
35
+ * this on, `stdout` is newline-delimited JSON events, not a single object.
36
+ */
37
+ readonly transcript?: boolean;
14
38
  /** Per-run wall-clock timeout in ms. Default 60000. */
15
39
  readonly timeoutMs?: number;
16
40
  }
@@ -23,11 +47,33 @@ export interface HarnessTestResult {
23
47
  readonly cwd: string;
24
48
  /** Number of model turns the agent took (mock turns served). */
25
49
  readonly turns: number;
50
+ /**
51
+ * The tools the agent invoked, each paired with its result — parsed from the
52
+ * transcript. Empty unless `transcript: true`. Lets a test assert on the
53
+ * agent's *actions* (skills, MCP tools, subagents) instead of grepping stdout.
54
+ */
55
+ readonly toolCalls: readonly ToolCall[];
26
56
  /** Final contents of a file under the working dir, or null if absent. */
27
57
  file(path: string): string | null;
28
58
  /** Remove the temp working dir. */
29
59
  cleanup(): void;
30
60
  }
61
+ /** A tool the agent invoked, paired with its result (transcript mode only). */
62
+ export interface ToolCall {
63
+ readonly name: string;
64
+ readonly input: unknown;
65
+ /** The tool_result text ("" if none / not captured). */
66
+ readonly resultText: string;
67
+ /** Whether the tool_result came back flagged as an error. */
68
+ readonly isError: boolean;
69
+ }
70
+ /**
71
+ * Parse `--output-format stream-json` (the `transcript: true` output) into the
72
+ * tools the agent invoked, each joined to its result by id. Returns [] for the
73
+ * non-stream `json` output. The seam that lets a test assert on the agent's
74
+ * actions, not a brittle stdout substring.
75
+ */
76
+ export declare function parseToolCalls(streamJson: string): ToolCall[];
31
77
  /** Whether the `claude` CLI is available — harness tests need it. */
32
78
  export declare function claudeAvailable(): boolean;
33
79
  /**