vigiles 4.0.2 → 5.0.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.
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CACHE_FORMAT_VERSION = void 0;
3
4
  exports.cacheKey = cacheKey;
4
5
  exports.readCache = readCache;
5
6
  exports.writeCache = writeCache;
6
7
  exports.snapshotDir = snapshotDir;
7
8
  exports.restoreDir = restoreDir;
9
+ exports.hashDir = hashDir;
8
10
  /**
9
11
  * vigiles — record/replay cache for the eval tier.
10
12
  *
@@ -43,20 +45,70 @@ function canonical(value) {
43
45
  }
44
46
  return value;
45
47
  }
48
+ /**
49
+ * Cache record-format version, SALTED into every key (Jest `CACHE_VERSION` /
50
+ * webpack `cache.version` pattern). Bump when the `CacheRecord` shape — or how a
51
+ * record is produced in a way the key can't otherwise see — changes, so old
52
+ * entries become *unreachable* rather than deserializing into a stale shape (no
53
+ * brittle read-time version gate needed). A major bump means orphaned files on
54
+ * disk; reclaim them by deleting the cache dir.
55
+ */
56
+ exports.CACHE_FORMAT_VERSION = 2;
57
+ /**
58
+ * Per-run env keys that are PURE NOISE for the cache key — a fresh random path
59
+ * every run, never model-affecting. The opt-in ephemeral run env
60
+ * ({@link ephemeralRunEnv} in `eval.ts`) points `HOME`/`TMPDIR` at a throwaway
61
+ * dir generated per trial, so folding them into the key would make every
62
+ * ephemeral run unique → the cache could NEVER hit. We drop them here so the
63
+ * exclusion holds however the env was assembled. A non-ephemeral run normally
64
+ * carries neither in its per-run `env` (it's an overlay over `process.env`, not
65
+ * a complete env), so dropping them is a no-op there.
66
+ */
67
+ const CACHE_KEY_ENV_EXCLUDE = ["HOME", "TMPDIR"];
68
+ /** Strip the per-run-noise env keys ({@link CACHE_KEY_ENV_EXCLUDE}) from a keyed
69
+ * env, returning `undefined` when nothing model-affecting remains (so the key is
70
+ * byte-identical to a run that had no env at all). */
71
+ function keyedEnv(env) {
72
+ if (env === undefined)
73
+ return undefined;
74
+ const out = {};
75
+ for (const [k, v] of Object.entries(env)) {
76
+ if (CACHE_KEY_ENV_EXCLUDE.includes(k))
77
+ continue;
78
+ out[k] = v;
79
+ }
80
+ return Object.keys(out).length > 0 ? out : undefined;
81
+ }
46
82
  /** Deterministic content hash of the key inputs (order-independent). */
47
83
  function cacheKey(input) {
48
- return (0, hash_js_1.sha256short)(JSON.stringify(canonical(input)));
84
+ const normalized = {
85
+ ...input,
86
+ // The tool list is logically a SET, so ["Read","Bash"] and ["Bash","Read"]
87
+ // must hash the same — sort it to avoid phantom-distinct keys. (canonical()
88
+ // already sorts object keys; it deliberately keeps other array order.)
89
+ tools: [...input.tools].sort(),
90
+ // Drop the throwaway ephemeral HOME/TMPDIR — per-run noise, not model input.
91
+ env: keyedEnv(input.env),
92
+ cacheFormatVersion: exports.CACHE_FORMAT_VERSION,
93
+ };
94
+ return (0, hash_js_1.sha256short)(JSON.stringify(canonical(normalized)));
49
95
  }
50
- /** Read a cached record by key, or null on miss / unreadable / malformed. */
96
+ /**
97
+ * Read a cached record by key. A MISS (no file) returns `null` — normal, the run
98
+ * proceeds. A CORRUPT record (file present but not valid JSON) **throws** instead
99
+ * of silently degrading to a re-run: a broken cassette is a real failure the CI
100
+ * gate must surface, not mask. The message tells you how to recover.
101
+ */
51
102
  function readCache(dir, key) {
52
103
  const path = (0, node_path_1.join)(dir, `${key}.json`);
53
104
  if (!(0, node_fs_1.existsSync)(path))
54
105
  return null;
106
+ const raw = (0, node_fs_1.readFileSync)(path, "utf-8");
55
107
  try {
56
- return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
108
+ return JSON.parse(raw);
57
109
  }
58
110
  catch {
59
- return null;
111
+ throw new Error(`eval cache: corrupt record ${path} (invalid JSON) — delete it or clear the cache dir`);
60
112
  }
61
113
  }
62
114
  /** Write a cached record by key (creating the cache dir as needed). */
@@ -91,4 +143,32 @@ function restoreDir(cwd, files) {
91
143
  (0, node_fs_1.writeFileSync)(full, content);
92
144
  }
93
145
  }
146
+ /**
147
+ * Content digest of a directory: a lexicographically-sorted list of
148
+ * `relativePath:contentHash` for every file, hashed to one value. Editing,
149
+ * adding, removing, or moving any file changes the digest. It hashes file
150
+ * CONTENT (not mtime — CI checkouts reset mtimes, the classic stale-cache
151
+ * anti-pattern) and includes the relative path (so a rename invalidates and two
152
+ * files can't swap contents undetected). A flat sorted list, NOT a Merkle tree —
153
+ * sufficient at plugin-dir scale; the tree's incremental-recompute payoff isn't
154
+ * worth the complexity here (cf. Bazel/Turborepo hash content per file).
155
+ */
156
+ function hashDir(dir) {
157
+ const root = (0, node_path_1.resolve)(dir);
158
+ const parts = [];
159
+ const walk = (d) => {
160
+ for (const entry of (0, node_fs_1.readdirSync)(d).sort()) {
161
+ if (SKIP_DIRS.has(entry))
162
+ continue;
163
+ const full = (0, node_path_1.join)(d, entry);
164
+ const st = (0, node_fs_1.statSync)(full);
165
+ if (st.isDirectory())
166
+ walk(full);
167
+ else if (st.isFile())
168
+ parts.push(`${(0, node_path_1.relative)(root, full)}:${(0, hash_js_1.sha256short)((0, node_fs_1.readFileSync)(full))}`);
169
+ }
170
+ };
171
+ walk(root);
172
+ return (0, hash_js_1.sha256short)(parts.join("\n"));
173
+ }
94
174
  //# sourceMappingURL=eval-cache.js.map
package/dist/eval.d.ts CHANGED
@@ -2,6 +2,8 @@ import { type ToolCall, type Trace } from "./harness-test.js";
2
2
  import { type CacheMode } from "./eval-cache.js";
3
3
  import type { Check, CheckJSON } from "./check.js";
4
4
  import { type Comparison } from "./stats.js";
5
+ import { type ToolIntercept } from "./tool-intercept.js";
6
+ import { type ToolStub } from "./tool-stub.js";
5
7
  /** One arm of the comparison: fixture overrides + settings (hooks) for this arm. */
6
8
  export interface EvalArm {
7
9
  /** Files written on top of the base fixture for this arm. */
@@ -22,6 +24,27 @@ export interface EvalArm {
22
24
  * an arm be "skill installed" vs "off" to measure real activation.
23
25
  */
24
26
  readonly pluginDir?: string;
27
+ /**
28
+ * Tools to intercept for this arm (the tool-call spy). Each
29
+ * {@link ToolIntercept} is denied its real execution by an auto-wired PreToolUse
30
+ * hook — the model still emits the `tool_use` (so its arguments land in the
31
+ * `Trace` for `toolWith` / `notTool`), but the side effect (a paid API call, a
32
+ * `git push`, a paid subagent) never happens: the call is intercepted
33
+ * (prevented), NOT executed. This makes a real-model eval **side-effect-free and
34
+ * safe** (it does NOT cut the model-call cost); and because CC surfaces the
35
+ * denial as a *blocked* call, it's for asserting the agent's ATTEMPT, not for
36
+ * stubbing a tool to continue a multi-step flow. See `src/tool-intercept.ts`.
37
+ */
38
+ readonly interceptTools?: readonly ToolIntercept[];
39
+ /**
40
+ * Model alias/id for THIS arm, overriding the eval-level `model`. A model
41
+ * comparison IS a harness A/B — `arms: { sonnet: { model: "claude-sonnet-4-6" },
42
+ * opus: { model: "claude-opus-4-8" } }` — so model-as-an-arm answers "does my
43
+ * harness still hold on the cheaper tier / after a model upgrade?" through the
44
+ * same significance machinery, with no separate model-matrix runner. Omit to
45
+ * use the eval-level model. See `docs/eval-architecture.md` (model strategy).
46
+ */
47
+ readonly model?: string;
25
48
  }
26
49
  /** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
27
50
  export interface EvalUsage {
@@ -29,8 +52,13 @@ export interface EvalUsage {
29
52
  readonly costUsd: number;
30
53
  /** Wall-clock `duration_ms` of the run. */
31
54
  readonly durationMs: number;
55
+ /** Fresh (uncached) input tokens, billed at full input price. */
32
56
  readonly inputTokens: number;
33
57
  readonly outputTokens: number;
58
+ /** Tokens written to the prompt cache this run (~1.25× input price). */
59
+ readonly cacheCreationTokens: number;
60
+ /** Tokens served from the prompt cache this run (~0.1× input price). */
61
+ readonly cacheReadTokens: number;
34
62
  }
35
63
  /**
36
64
  * Context handed to `measure` after a run, to compute that run's metrics. It is
@@ -98,6 +126,39 @@ export interface EvalSpec<M extends Metrics> {
98
126
  readonly rateLimitRetries?: number;
99
127
  /** Base backoff ms (doubled each retry). Default 1000. */
100
128
  readonly retryBackoffMs?: number;
129
+ /**
130
+ * **Opt-in, default OFF.** Run each trial in an *ephemeral run environment* — a
131
+ * throwaway `$HOME` + scrubbed env, re-injecting only the harness's own auth (see
132
+ * {@link ephemeralRunEnv}). Running a model-driven skill/agent is itself a side
133
+ * effect (the *model*, not the author, chose the actions), so a `git push` /
134
+ * write to `~` should land in a disposable HOME, not the real `~/.gitconfig` /
135
+ * `~/.ssh` / `~/.aws`. This is the cross-platform STATE-protection floor (no
136
+ * kernel features), orthogonal to the bubblewrap host-confinement in
137
+ * `src/sandbox.ts`.
138
+ *
139
+ * **Ships default-OFF** because a too-narrow auth allowlist would silently break
140
+ * the real `claude` CLI's authentication; leaving it off keeps every existing
141
+ * eval (including one running right now) authenticating exactly as before. When
142
+ * absent / `false`, the per-trial env is byte-identical to today
143
+ * (`{ ...process.env, ...arm.env }`). See `docs/safety.md` (ephemerality) and
144
+ * `research/cross-platform-sandboxing.md`.
145
+ */
146
+ readonly ephemeralEnv?: boolean;
147
+ /**
148
+ * **Tool stubs on PATH (rung R2).** A list of fake binaries to shadow on PATH
149
+ * for every trial, so a skill/hook/agent that calls a CLI tool (`gh`, `psql`,
150
+ * `redis-cli`, `z3`, …) and works with its RESULT can be tested against a
151
+ * **recorded / author-provided canned output** — no live service. vigiles writes
152
+ * one executable stub per {@link ToolStub} into a bin dir under the trial cwd and
153
+ * PREPENDS that dir to the run's PATH (both the default and the ephemeral env
154
+ * path), so the fake wins over the real binary.
155
+ *
156
+ * The stubs are author/recorded fixtures, **never** model-synthesized — a
157
+ * synthesized tool output looks plausible but diverges from the real
158
+ * tool/version (false confidence). Absent → no change (the PATH is byte-identical
159
+ * to today). See {@link ToolStub} and `research/eval-coverage-and-isolation.md`.
160
+ */
161
+ readonly stubs?: readonly ToolStub[];
101
162
  }
102
163
  /** Per-metric summary statistics across an arm's runs. */
103
164
  export interface MetricStat {
@@ -124,6 +185,8 @@ export interface ArmUsage {
124
185
  readonly meanDurationMs: number;
125
186
  readonly totalInputTokens: number;
126
187
  readonly totalOutputTokens: number;
188
+ readonly totalCacheCreationTokens: number;
189
+ readonly totalCacheReadTokens: number;
127
190
  }
128
191
  export interface ArmReport {
129
192
  readonly runs: number;
@@ -159,6 +222,15 @@ export interface AgentRunArgs {
159
222
  readonly hasSettings: boolean;
160
223
  readonly pluginDir: string | undefined;
161
224
  readonly timeoutMs: number;
225
+ /** Extra env layered over `process.env` for this run (e.g. `VIGILES_INTERCEPT_TOOLS`). */
226
+ readonly env?: Record<string, string>;
227
+ /**
228
+ * When true, `env` is the COMPLETE spawn environment (an ephemeral run env from
229
+ * {@link ephemeralRunEnv}) — the runner does NOT prepend `process.env`, so the
230
+ * real `$HOME` / secrets are scrubbed. Default false: `env` is an overlay over
231
+ * `process.env` (the byte-identical-to-today path). Set only by `ephemeralEnv`.
232
+ */
233
+ readonly replaceEnv?: boolean;
162
234
  }
163
235
  /**
164
236
  * Runs one trial and returns its raw output. The default ({@link spawnAgent})
@@ -167,6 +239,17 @@ export interface AgentRunArgs {
167
239
  * stream-json) and a custom runtime can be plugged in.
168
240
  */
169
241
  export type AgentRunner = (args: AgentRunArgs) => Promise<RunOut>;
242
+ /**
243
+ * Resolve the environment a trial's subprocess actually runs with — the
244
+ * SECURITY-CRITICAL decision behind `ephemeralEnv`. When `replaceEnv` is set, the
245
+ * scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
246
+ * secrets are DROPPED; otherwise `env` is an overlay on `base` (byte-identical to
247
+ * the pre-ephemeral behaviour). Extracted from the `v8 ignore`d `spawnAgent` so
248
+ * the one line that enforces the scrub is both unit- and behaviourally-tested — a
249
+ * regression to an always-merge would otherwise silently defeat ephemerality and
250
+ * leak the host environment into an untrusted, model-driven run.
251
+ */
252
+ export declare function resolveSpawnEnv(a: Pick<AgentRunArgs, "env" | "replaceEnv">, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
170
253
  /**
171
254
  * Run the eval: every arm × every trial against the real `claude` CLI, with the
172
255
  * metric computed per run and aggregated per arm. Requires `claude` on PATH and
@@ -193,6 +276,8 @@ export interface MeasureSpec {
193
276
  * there's nothing to grade. Requires `pluginDir`. See {@link stubSkillBody}.
194
277
  */
195
278
  readonly stubSkillBodies?: boolean;
279
+ /** Tools to intercept (the tool-call spy) — see {@link EvalArm.interceptTools}. */
280
+ readonly interceptTools?: readonly ToolIntercept[];
196
281
  /** The task prompt given to the agent. */
197
282
  readonly task: string;
198
283
  /**
@@ -278,21 +363,31 @@ export declare function compareCheck(report: ArmsCheckReport, baseline: string,
278
363
  /** Format a {@link CheckReport}: one line per check with its rate ± se and pass^k. */
279
364
  export declare function formatCheckReport(report: CheckReport): string;
280
365
  /**
281
- * The scored gate (Phase 4): throw if any check's measured rate is below `min` —
282
- * the `measure` counterpart to `assertChecks` (strict). Reads the rate, not a
283
- * single run, so it never trips on one noisy trial.
366
+ * The scored gate (Phase 4): throw if any check's measured rate is below its
367
+ * threshold — the `measure` counterpart to `assertChecks` (strict). Reads the
368
+ * rate, not a single run, so it never trips on one noisy trial.
369
+ *
370
+ * `min` is the default threshold for every check. `per` overrides it for a check
371
+ * KIND (e.g. `{ min: 0.8, per: { skill: 1.0 } }` — "every check ≥ 80%, but the
372
+ * skill must FIRE on every trial"), so a strict firing/safety check and a
373
+ * lenient quality check gate in one call — the single-skill absolute oracle
374
+ * (`measure({ checks: [skill(), judged()] }) + assertRates`) needs exactly this.
284
375
  */
285
376
  export declare function assertRates(report: CheckReport, opts: {
286
377
  min: number;
378
+ per?: Readonly<Record<string, number>>;
287
379
  }): void;
288
380
  /**
289
381
  * Serialize a {@link CheckReport} to JUnit XML (Phase 4) — each check a
290
- * `<testcase>`, failing when its rate is below `min`. Because a check is *data*,
291
- * this falls out for free: CI test reporters, regression baselines, and a
292
- * promptfoo bridge all consume the same shape.
382
+ * `<testcase>`, failing when its rate is below its threshold. `min` is the
383
+ * default; `per` overrides it by check KIND, matching `assertRates` exactly (one
384
+ * shared threshold helper) so the gate and the report can never disagree about
385
+ * which checks failed. Because a check is *data*, this falls out for free: CI
386
+ * test reporters, regression baselines, and a promptfoo bridge all consume it.
293
387
  */
294
388
  export declare function checkReportToJUnit(report: CheckReport, opts?: {
295
389
  min?: number;
390
+ per?: Readonly<Record<string, number>>;
296
391
  name?: string;
297
392
  }): string;
298
393
  /** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
@@ -307,6 +402,86 @@ export declare function aggregate(rows: readonly Metrics[]): Record<string, numb
307
402
  export declare function aggregateStats(rows: readonly Metrics[]): Record<string, MetricStat>;
308
403
  /** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
309
404
  export declare function aggregateUsage(usages: readonly EvalUsage[]): ArmUsage;
405
+ /**
406
+ * A model id is "dated" (honestly pinned) when it ends in an 8-digit date stamp,
407
+ * e.g. `claude-haiku-4-5-20251001`. A floating alias (`haiku`, `sonnet`, or even
408
+ * `claude-sonnet-4-6` with no date) can change underneath you — so a cached or
409
+ * baselined result pinned to it can silently hide model drift. See
410
+ * `docs/eval-architecture.md` (honest model pinning).
411
+ */
412
+ export declare function isDatedModel(model: string): boolean;
413
+ /**
414
+ * Capability tier of a model by FAMILY: haiku=1 < sonnet=2 < opus=3 (version is
415
+ * ignored, so `claude-sonnet-4-6` and a dated Sonnet rank equal). An unrecognized
416
+ * family returns `null` — unrankable, so the floor never blocks a model we can't
417
+ * judge (fail-open on ranking). Used by the model floor; aliases and full/dated
418
+ * ids both work.
419
+ */
420
+ export declare function modelTier(id: string): number | null;
421
+ /**
422
+ * Is `model` a weaker tier than `floor`? Both must be rankable (see
423
+ * {@link modelTier}); an unrankable model/floor is never "below" (fail-open).
424
+ */
425
+ export declare function belowModelFloor(model: string, floor: string): boolean;
426
+ /**
427
+ * Reduce a raw `--version` string to the **major.minor** cache-key token. We key
428
+ * the cache on major.minor, NOT the patch: a patch release rarely changes agent
429
+ * behaviour, so keying patches would churn the cache on every release for no
430
+ * signal; a minor/major bump is where the system prompt / tool defs actually move.
431
+ * (If a specific patch is known to matter, clear the cache or bump
432
+ * `CACHE_FORMAT_VERSION`.) Falls back to the trimmed raw string when no semver is
433
+ * found. Pure + tested.
434
+ */
435
+ export declare function harnessVersionKey(raw: string): string;
436
+ /**
437
+ * Build an **ephemeral run environment** for a model-driven run: a NEW env object
438
+ * with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, only
439
+ * an allowlist of auth + runtime vars passed through from `base`, and everything
440
+ * else DROPPED. Pure — no fs, no spawn.
441
+ *
442
+ * The rationale is "fresh HOME + only the harness credential injected, **not** a
443
+ * blanket wipe": running a model-driven skill/agent is itself a side effect (the
444
+ * *model*, not the author, chose the actions), so it should not be able to read
445
+ * the real `~/.gitconfig` / `~/.ssh` / `~/.aws` or write to the real `~`. But the
446
+ * real `claude` CLI must still AUTHENTICATE, so the harness's own credentials
447
+ * ({@link EPHEMERAL_ALLOW} — `ANTHROPIC_*`, `CLAUDE_*`, locale/PATH) are
448
+ * re-injected; a blanket `--clearenv`-style wipe would break every eval. Because
449
+ * this needs no kernel features, it is the cross-platform STATE-protection floor
450
+ * (lands on macOS immediately), orthogonal to the Linux bubblewrap HOST
451
+ * confinement in `src/sandbox.ts`.
452
+ *
453
+ * @param base the source environment to filter (usually `process.env`).
454
+ * @param opts.home the throwaway dir to set as `HOME`/`TMPDIR`.
455
+ * @param opts.allow extra var NAMES to pass through (e.g. the `VIGILES_*` keys
456
+ * the eval already injects). Layered ON TOP of the default allowlist.
457
+ */
458
+ export declare function ephemeralRunEnv(base: NodeJS.ProcessEnv | Record<string, string | undefined>, opts: {
459
+ home: string;
460
+ allow?: readonly string[];
461
+ }): Record<string, string>;
462
+ /**
463
+ * Home-relative AUTH files to carry from the real HOME into the throwaway one.
464
+ *
465
+ * A local subscription credential (OAuth token) often lives in a FILE under HOME,
466
+ * not an env var — so scrubbing HOME would lose it and break a local-authed run.
467
+ * This is the file half of the auth allowlist; {@link EPHEMERAL_ALLOW} covers the
468
+ * env-var / host-brokered half. Kept a named constant so it's easy to extend, and
469
+ * deliberately NARROW — only the explicit auth files, never `.gitconfig` / `.ssh`
470
+ * / `.aws`, which are exactly what an ephemeral run must not see.
471
+ */
472
+ export declare const EPHEMERAL_HOME_KEEP: readonly string[];
473
+ /**
474
+ * Seed the throwaway HOME with the harness's own auth FILE(s) — best-effort;
475
+ * covers local file-based OAuth; the env-var/host-brokered path is covered by the
476
+ * allowlist in {@link ephemeralRunEnv}.
477
+ *
478
+ * COPIES (never symlinks) each {@link EPHEMERAL_HOME_KEEP} path from `realHome`
479
+ * into `throwawayHome`, creating parent dirs as needed; a symlink would let the
480
+ * model-driven run write back to the real credential file, defeating ephemerality.
481
+ * A path that doesn't exist in the real HOME is skipped silently (that user auths
482
+ * via env-var / host broker instead). Pure fs — no env, no spawn.
483
+ */
484
+ export declare function seedEphemeralHome(throwawayHome: string, realHome: string, keep?: readonly string[]): void;
310
485
  /** Whether a run's captured output looks like a rate-limit / overload. Pure. */
311
486
  export declare function isRateLimited(out: RunOut): boolean;
312
487
  /** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
@@ -357,15 +532,30 @@ export interface TriggerRateSpec {
357
532
  readonly irrelevantPrompts?: readonly string[];
358
533
  /** Did the behaviour fire on this run? e.g. `(t) => skillResolved(t, "x:y")`. */
359
534
  readonly fired: (trace: Trace) => boolean;
535
+ /**
536
+ * Co-install these skill sources ALONGSIDE the skill-under-test so it competes
537
+ * for selection as it does in the real harness — the **whole-harness** tier.
538
+ * Each entry is a plugin dir (`skills/` or `.claude/skills/`) or a loose skills
539
+ * dir (`<name>/SKILL.md`); their skills are merged into one install under the
540
+ * under-test plugin's name (the under-test skill wins a name collision, so its
541
+ * `<name>:<skill>` id still matches `fired`).
542
+ *
543
+ * WHY: skill selection is competitive and Claude Code evicts the least-used
544
+ * skill descriptions under a context budget, so an ISOLATED trigger-rate (the
545
+ * default, `installSet` absent/empty) **overstates recall and understates
546
+ * false-positives**. Isolated is the cheap authoring loop; populate the set for
547
+ * a release gate. See `research/isolated-vs-whole-harness-eval.md`.
548
+ */
549
+ readonly installSet?: readonly string[];
360
550
  /**
361
551
  * Replace each skill's BODY with a no-op stub (keeping its frontmatter — name +
362
- * description) before running. Trigger-rate is decided by the frontmatter alone
363
- * (the model selects a skill before its body loads), so stubbing the body can't
364
- * change what's measured but stops the run from executing an expensive
365
- * procedure once the skill fires far cheaper, faster, side-effect-free. All
366
- * skills' descriptions stay present, so the selection competition is faithful.
367
- * Default false (off) for now; recommended `true` for trigger evals. See
368
- * {@link stubSkillBody}.
552
+ * description). Trigger-rate is decided by the frontmatter ALONE (the model
553
+ * selects a skill before its body loads), so stubbing can't change what's
554
+ * measured it just stops the run executing an expensive procedure once the
555
+ * skill fires. All descriptions stay present, so selection competition is
556
+ * faithful. **Defaults to `true`** here: testing a description never needs the
557
+ * body, so it's automated, not a knob you must remember. Set `false` only in the
558
+ * rare case you want the real body to run. See {@link stubSkillBody}.
369
559
  */
370
560
  readonly stubSkillBodies?: boolean;
371
561
  /**
@@ -383,8 +573,20 @@ export interface TriggerRateSpec {
383
573
  readonly minDistance?: number;
384
574
  /** Trials per prompt. Default 1. */
385
575
  readonly trials?: number;
386
- /** Model alias. Default "haiku". */
576
+ /**
577
+ * Model alias/id. Default `"sonnet"` — the realistic selector most Claude Code
578
+ * users run. NOT haiku: trigger-rate is a *selection* measurement and haiku is a
579
+ * much weaker selector, so it under-reports recall (dogfooded: a skill scored
580
+ * 0.50 on haiku vs 0.90 on Sonnet). Override for a cheaper-but-pessimistic run.
581
+ */
387
582
  readonly model?: string;
583
+ /**
584
+ * Minimum model tier this eval may run on (haiku<sonnet<opus by family). The
585
+ * run **fails** if the resolved `model` is weaker — trigger-rate under-measures
586
+ * selection on a too-weak model, so this stops a cheap model from producing
587
+ * false-negative recall. Default `"sonnet"`. Lower it deliberately for a cheap run.
588
+ */
589
+ readonly minModel?: string;
388
590
  /** Tools the agent may use. Default: Read Edit Write Bash Skill. */
389
591
  readonly allowedTools?: readonly string[];
390
592
  /** Per-run timeout ms. Default 240000. */
@@ -420,6 +622,14 @@ export interface TriggerRateReport {
420
622
  readonly precision?: number;
421
623
  /** Per-prompt stats for the irrelevant set. Present with irrelevant prompts. */
422
624
  readonly perIrrelevant?: readonly PromptTriggerStat[];
625
+ /**
626
+ * Competitor skills co-installed via {@link TriggerRateSpec.installSet}. `0`
627
+ * (the default) means the skill was measured **ISOLATED** — so `rate` is an
628
+ * UPPER bound on real recall and `falsePositiveRate` a LOWER bound, because
629
+ * selection is competitive (a populated harness can evict or out-compete the
630
+ * description). A non-zero count is the whole-harness measurement.
631
+ */
632
+ readonly competitors: number;
423
633
  }
424
634
  /**
425
635
  * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
@@ -478,6 +688,25 @@ export declare function assertPromptDiversity(prompts: readonly string[], opts?:
478
688
  minDistance?: number;
479
689
  label?: string;
480
690
  }): void;
691
+ /**
692
+ * Build a combined plugin: the under-test skills PLUS every `installSet` source's
693
+ * skills, so the skill-under-test competes for selection as in the real harness.
694
+ * Named after the under-test plugin so `<name>:<skill>` ids still match; the
695
+ * under-test skills win a name collision. Returns the dir + `added` = how many
696
+ * installSet skills were merged in (excludes collisions). The report's
697
+ * `competitors` is derived separately from the FULL pool (see `countSkills`), so
698
+ * sibling skills already in the under-test source count too. Caller removes the
699
+ * dir. Pure (filesystem only).
700
+ */
701
+ export declare function packageInstallSet(opts: {
702
+ underTestSrc: string;
703
+ name: string;
704
+ installSet: readonly string[];
705
+ stub: boolean;
706
+ }): {
707
+ dir: string;
708
+ added: number;
709
+ };
481
710
  /**
482
711
  * Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
483
712
  * predicate evaluated per run and aggregated into an overall + per-prompt rate.