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.
- package/README.md +40 -8
- package/dist/adapter-registry.d.ts +45 -1
- package/dist/adapter-registry.js +78 -3
- package/dist/adapters/claude-code/agent-runtime.d.ts +1 -1
- package/dist/adapters/claude-code/agent-runtime.js +1 -1
- package/dist/arg-match.d.ts +28 -0
- package/dist/arg-match.js +61 -0
- package/dist/check.d.ts +52 -1
- package/dist/check.js +121 -0
- package/dist/claude-code.d.ts +1 -0
- package/dist/claude-code.js +11 -0
- package/dist/cli.js +211 -35
- package/dist/core/types.d.ts +27 -12
- package/dist/core/validate.js +14 -3
- package/dist/eval-cache.d.ts +49 -1
- package/dist/eval-cache.js +84 -4
- package/dist/eval.d.ts +243 -14
- package/dist/eval.js +540 -28
- package/dist/integration.d.ts +15 -10
- package/dist/integration.js +27 -10
- package/dist/linting.d.ts +4 -3
- package/dist/linting.js +4 -3
- package/dist/setup-plan.d.ts +11 -0
- package/dist/setup-plan.js +29 -0
- package/dist/skill-harness.d.ts +25 -0
- package/dist/skill-harness.js +40 -0
- package/dist/test-coverage.d.ts +11 -12
- package/dist/test-coverage.js +14 -19
- package/dist/testing.d.ts +4 -1
- package/dist/testing.js +24 -2
- package/dist/tool-intercept.d.ts +101 -0
- package/dist/tool-intercept.js +165 -0
- package/dist/tool-stub.d.ts +35 -0
- package/dist/tool-stub.js +92 -0
- package/package.json +11 -17
- package/skills/test-harness/SKILL.md +56 -3
package/dist/eval-cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
108
|
+
return JSON.parse(raw);
|
|
57
109
|
}
|
|
58
110
|
catch {
|
|
59
|
-
|
|
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
|
|
282
|
-
* the `measure` counterpart to `assertChecks` (strict). Reads the
|
|
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
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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)
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
-
/**
|
|
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.
|