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.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EPHEMERAL_HOME_KEEP = void 0;
|
|
4
|
+
exports.resolveSpawnEnv = resolveSpawnEnv;
|
|
3
5
|
exports.runEval = runEval;
|
|
4
6
|
exports.measureWith = measureWith;
|
|
5
7
|
exports.measure = measure;
|
|
@@ -13,6 +15,12 @@ exports.parseUsage = parseUsage;
|
|
|
13
15
|
exports.aggregate = aggregate;
|
|
14
16
|
exports.aggregateStats = aggregateStats;
|
|
15
17
|
exports.aggregateUsage = aggregateUsage;
|
|
18
|
+
exports.isDatedModel = isDatedModel;
|
|
19
|
+
exports.modelTier = modelTier;
|
|
20
|
+
exports.belowModelFloor = belowModelFloor;
|
|
21
|
+
exports.harnessVersionKey = harnessVersionKey;
|
|
22
|
+
exports.ephemeralRunEnv = ephemeralRunEnv;
|
|
23
|
+
exports.seedEphemeralHome = seedEphemeralHome;
|
|
16
24
|
exports.isRateLimited = isRateLimited;
|
|
17
25
|
exports.runPool = runPool;
|
|
18
26
|
exports.runEvalWith = runEvalWith;
|
|
@@ -23,6 +31,7 @@ exports.stubbedPluginDir = stubbedPluginDir;
|
|
|
23
31
|
exports.promptDistance = promptDistance;
|
|
24
32
|
exports.checkPromptDiversity = checkPromptDiversity;
|
|
25
33
|
exports.assertPromptDiversity = assertPromptDiversity;
|
|
34
|
+
exports.packageInstallSet = packageInstallSet;
|
|
26
35
|
exports.measureTriggerRateWith = measureTriggerRateWith;
|
|
27
36
|
exports.measureTriggerRate = measureTriggerRate;
|
|
28
37
|
exports.formatTriggerRateReport = formatTriggerRateReport;
|
|
@@ -59,6 +68,8 @@ const proofs_js_1 = require("./core/proofs.js");
|
|
|
59
68
|
const harness_test_js_1 = require("./harness-test.js");
|
|
60
69
|
const eval_cache_js_1 = require("./eval-cache.js");
|
|
61
70
|
const stats_js_1 = require("./stats.js");
|
|
71
|
+
const tool_intercept_js_1 = require("./tool-intercept.js");
|
|
72
|
+
const tool_stub_js_1 = require("./tool-stub.js");
|
|
62
73
|
function writeFiles(cwd, files) {
|
|
63
74
|
for (const [p, content] of Object.entries(files)) {
|
|
64
75
|
const full = (0, node_path_1.resolve)(cwd, p);
|
|
@@ -66,6 +77,19 @@ function writeFiles(cwd, files) {
|
|
|
66
77
|
(0, node_fs_1.writeFileSync)(full, content);
|
|
67
78
|
}
|
|
68
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the environment a trial's subprocess actually runs with — the
|
|
82
|
+
* SECURITY-CRITICAL decision behind `ephemeralEnv`. When `replaceEnv` is set, the
|
|
83
|
+
* scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
|
|
84
|
+
* secrets are DROPPED; otherwise `env` is an overlay on `base` (byte-identical to
|
|
85
|
+
* the pre-ephemeral behaviour). Extracted from the `v8 ignore`d `spawnAgent` so
|
|
86
|
+
* the one line that enforces the scrub is both unit- and behaviourally-tested — a
|
|
87
|
+
* regression to an always-merge would otherwise silently defeat ephemerality and
|
|
88
|
+
* leak the host environment into an untrusted, model-driven run.
|
|
89
|
+
*/
|
|
90
|
+
function resolveSpawnEnv(a, base = process.env) {
|
|
91
|
+
return a.replaceEnv ? (a.env ?? {}) : { ...base, ...a.env };
|
|
92
|
+
}
|
|
69
93
|
/* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
|
|
70
94
|
function spawnAgent(a) {
|
|
71
95
|
return new Promise((resolvePromise) => {
|
|
@@ -91,7 +115,9 @@ function spawnAgent(a) {
|
|
|
91
115
|
];
|
|
92
116
|
const child = (0, node_child_process_1.spawn)(runtime_js_1.claudeCodeRuntime.agentBinary, args, {
|
|
93
117
|
cwd: a.cwd,
|
|
94
|
-
env
|
|
118
|
+
// The security-critical env resolution (overlay vs. scrubbed replacement)
|
|
119
|
+
// lives in the tested `resolveSpawnEnv` seam above, not inline here.
|
|
120
|
+
env: resolveSpawnEnv(a),
|
|
95
121
|
stdio: ["ignore", "pipe", "pipe"],
|
|
96
122
|
});
|
|
97
123
|
let stdout = "";
|
|
@@ -124,6 +150,14 @@ async function runEval(spec) {
|
|
|
124
150
|
async function measureWith(spec, runner) {
|
|
125
151
|
if (spec.stubSkillBodies && !spec.pluginDir)
|
|
126
152
|
throw new Error("measure: `stubSkillBodies` requires `pluginDir`.");
|
|
153
|
+
// stubSkillBodies replaces each skill BODY with a no-op (the run stops at
|
|
154
|
+
// selection), so there is no output to grade — a `judged` check would score an
|
|
155
|
+
// empty body and mislead. The docs warn against this pairing; enforce it.
|
|
156
|
+
if (spec.stubSkillBodies &&
|
|
157
|
+
spec.checks.some((c) => c.toJSON().kind === "judged"))
|
|
158
|
+
throw new Error("measure: `stubSkillBodies` is for firing/`skill()` checks only — it stubs " +
|
|
159
|
+
"the skill body, so there's no output for a `judged` check to grade. Drop " +
|
|
160
|
+
"`stubSkillBodies`, or remove the `judged` check.");
|
|
127
161
|
const stubbed = spec.stubSkillBodies
|
|
128
162
|
? stubbedPluginDir(spec.pluginDir)
|
|
129
163
|
: undefined;
|
|
@@ -137,6 +171,7 @@ async function measureWith(spec, runner) {
|
|
|
137
171
|
settings: spec.settings,
|
|
138
172
|
plugin: spec.plugin,
|
|
139
173
|
pluginDir,
|
|
174
|
+
interceptTools: spec.interceptTools,
|
|
140
175
|
},
|
|
141
176
|
},
|
|
142
177
|
task: spec.task,
|
|
@@ -273,16 +308,37 @@ function formatCheckReport(report) {
|
|
|
273
308
|
return lines.join("\n");
|
|
274
309
|
}
|
|
275
310
|
/**
|
|
276
|
-
* The
|
|
277
|
-
*
|
|
278
|
-
*
|
|
311
|
+
* The min rate a check must clear: its per-KIND override in `per`, else the
|
|
312
|
+
* default `min`. Shared by `assertRates` (the throwing gate) and
|
|
313
|
+
* `checkReportToJUnit` (the XML report) so the two never diverge on thresholds.
|
|
314
|
+
*/
|
|
315
|
+
function checkRateThreshold(check, min, per) {
|
|
316
|
+
return per?.[check.kind] ?? min;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The scored gate (Phase 4): throw if any check's measured rate is below its
|
|
320
|
+
* threshold — the `measure` counterpart to `assertChecks` (strict). Reads the
|
|
321
|
+
* rate, not a single run, so it never trips on one noisy trial.
|
|
322
|
+
*
|
|
323
|
+
* `min` is the default threshold for every check. `per` overrides it for a check
|
|
324
|
+
* KIND (e.g. `{ min: 0.8, per: { skill: 1.0 } }` — "every check ≥ 80%, but the
|
|
325
|
+
* skill must FIRE on every trial"), so a strict firing/safety check and a
|
|
326
|
+
* lenient quality check gate in one call — the single-skill absolute oracle
|
|
327
|
+
* (`measure({ checks: [skill(), judged()] }) + assertRates`) needs exactly this.
|
|
279
328
|
*/
|
|
280
329
|
function assertRates(report, opts) {
|
|
281
|
-
|
|
330
|
+
// An empty report gates nothing — a green that tested NOTHING (the silent-pass
|
|
331
|
+
// the no-silent-skips rule forbids). Fail loudly instead of vacuously passing.
|
|
332
|
+
if (report.perCheck.length === 0) {
|
|
333
|
+
throw new Error("assertRates: the report has no checks to gate — did you call " +
|
|
334
|
+
"`measure({ checks: [...] })` with an empty list? An empty gate is a silent pass.");
|
|
335
|
+
}
|
|
336
|
+
const thresholdFor = (c) => checkRateThreshold(c.check, opts.min, opts.per);
|
|
337
|
+
const below = report.perCheck.filter((c) => c.rate < thresholdFor(c));
|
|
282
338
|
if (below.length > 0) {
|
|
283
|
-
throw new Error(`${String(below.length)} check(s) below
|
|
339
|
+
throw new Error(`${String(below.length)} check(s) below their min rate:\n` +
|
|
284
340
|
below
|
|
285
|
-
.map((c) => ` ✗ ${checkLabel(c.check)}: ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}
|
|
341
|
+
.map((c) => ` ✗ ${checkLabel(c.check)}: ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}% (min ${(thresholdFor(c) * 100).toFixed(0)}%)`)
|
|
286
342
|
.join("\n"));
|
|
287
343
|
}
|
|
288
344
|
}
|
|
@@ -295,18 +351,21 @@ function escapeXml(s) {
|
|
|
295
351
|
}
|
|
296
352
|
/**
|
|
297
353
|
* Serialize a {@link CheckReport} to JUnit XML (Phase 4) — each check a
|
|
298
|
-
* `<testcase>`, failing when its rate is below `min
|
|
299
|
-
*
|
|
300
|
-
*
|
|
354
|
+
* `<testcase>`, failing when its rate is below its threshold. `min` is the
|
|
355
|
+
* default; `per` overrides it by check KIND, matching `assertRates` exactly (one
|
|
356
|
+
* shared threshold helper) so the gate and the report can never disagree about
|
|
357
|
+
* which checks failed. Because a check is *data*, this falls out for free: CI
|
|
358
|
+
* test reporters, regression baselines, and a promptfoo bridge all consume it.
|
|
301
359
|
*/
|
|
302
360
|
function checkReportToJUnit(report, opts = {}) {
|
|
303
361
|
const min = opts.min ?? 0;
|
|
304
|
-
const
|
|
362
|
+
const thr = (c) => checkRateThreshold(c.check, min, opts.per);
|
|
363
|
+
const failures = report.perCheck.filter((c) => c.rate < thr(c)).length;
|
|
305
364
|
const cases = report.perCheck
|
|
306
365
|
.map((c) => {
|
|
307
366
|
const name = escapeXml(checkLabel(c.check));
|
|
308
|
-
const body = c.rate <
|
|
309
|
-
? `\n <failure message="rate ${(c.rate * 100).toFixed(0)}% below min ${(
|
|
367
|
+
const body = c.rate < thr(c)
|
|
368
|
+
? `\n <failure message="rate ${(c.rate * 100).toFixed(0)}% below min ${(thr(c) * 100).toFixed(0)}% (n=${String(c.n)})"/>\n `
|
|
310
369
|
: "";
|
|
311
370
|
return ` <testcase classname="vigiles.checks" name="${name}">${body}</testcase>`;
|
|
312
371
|
})
|
|
@@ -325,6 +384,8 @@ function usageFrom(result) {
|
|
|
325
384
|
durationMs: num(result?.duration_ms),
|
|
326
385
|
inputTokens: num(usage.input_tokens),
|
|
327
386
|
outputTokens: num(usage.output_tokens),
|
|
387
|
+
cacheCreationTokens: num(usage.cache_creation_input_tokens),
|
|
388
|
+
cacheReadTokens: num(usage.cache_read_input_tokens),
|
|
328
389
|
};
|
|
329
390
|
}
|
|
330
391
|
/** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
|
|
@@ -424,6 +485,8 @@ function aggregateUsage(usages) {
|
|
|
424
485
|
meanDurationMs: n > 0 ? sum((u) => u.durationMs) / n : 0,
|
|
425
486
|
totalInputTokens: sum((u) => u.inputTokens),
|
|
426
487
|
totalOutputTokens: sum((u) => u.outputTokens),
|
|
488
|
+
totalCacheCreationTokens: sum((u) => u.cacheCreationTokens),
|
|
489
|
+
totalCacheReadTokens: sum((u) => u.cacheReadTokens),
|
|
427
490
|
};
|
|
428
491
|
}
|
|
429
492
|
/**
|
|
@@ -441,6 +504,13 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
|
441
504
|
tools: runArgs.tools,
|
|
442
505
|
files: keyParts.files,
|
|
443
506
|
settings: keyParts.settings,
|
|
507
|
+
env: runArgs.env,
|
|
508
|
+
// A native --plugin-dir install isn't in `files`, so hash its CONTENTS into
|
|
509
|
+
// the key — otherwise editing a skill in it would false-replay.
|
|
510
|
+
pluginDirHash: runArgs.pluginDir ? (0, eval_cache_js_1.hashDir)(runArgs.pluginDir) : undefined,
|
|
511
|
+
// The harness binary evolves fast; a CLI upgrade must invalidate (stale
|
|
512
|
+
// replay otherwise serves a result from a different system prompt).
|
|
513
|
+
harnessVersion: harnessVersion(),
|
|
444
514
|
trialIndex: keyParts.trialIndex,
|
|
445
515
|
});
|
|
446
516
|
const hit = (0, eval_cache_js_1.readCache)(cfg.cacheDir, key);
|
|
@@ -454,15 +524,313 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
|
454
524
|
}
|
|
455
525
|
return out;
|
|
456
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* The `vigiles intercept-tool-hook` command, as an absolute `node <cli> …`
|
|
529
|
+
* invocation — the eval runs in a throwaway cwd where `npx vigiles` wouldn't
|
|
530
|
+
* resolve, so the auto-wired PreToolUse hook must point at this CLI's own `cli.js`
|
|
531
|
+
* (resolved from `__dirname`, the same way `run-hook.ts`/`sandbox.ts` locate their
|
|
532
|
+
* entries).
|
|
533
|
+
*/
|
|
534
|
+
const INTERCEPT_TOOL_HOOK_CLI = [(0, node_path_1.join)(__dirname, "cli.js"), (0, node_path_1.join)(__dirname, "..", "dist", "cli.js")].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "cli.js");
|
|
535
|
+
const INTERCEPT_TOOL_HOOK_CMD = `"${process.execPath}" "${INTERCEPT_TOOL_HOOK_CLI}" intercept-tool-hook`;
|
|
536
|
+
function isRecord(v) {
|
|
537
|
+
return v !== null && typeof v === "object";
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* A model id is "dated" (honestly pinned) when it ends in an 8-digit date stamp,
|
|
541
|
+
* e.g. `claude-haiku-4-5-20251001`. A floating alias (`haiku`, `sonnet`, or even
|
|
542
|
+
* `claude-sonnet-4-6` with no date) can change underneath you — so a cached or
|
|
543
|
+
* baselined result pinned to it can silently hide model drift. See
|
|
544
|
+
* `docs/eval-architecture.md` (honest model pinning).
|
|
545
|
+
*/
|
|
546
|
+
function isDatedModel(model) {
|
|
547
|
+
return /\d{8}$/.test(model);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Capability tier of a model by FAMILY: haiku=1 < sonnet=2 < opus=3 (version is
|
|
551
|
+
* ignored, so `claude-sonnet-4-6` and a dated Sonnet rank equal). An unrecognized
|
|
552
|
+
* family returns `null` — unrankable, so the floor never blocks a model we can't
|
|
553
|
+
* judge (fail-open on ranking). Used by the model floor; aliases and full/dated
|
|
554
|
+
* ids both work.
|
|
555
|
+
*/
|
|
556
|
+
function modelTier(id) {
|
|
557
|
+
const s = id.toLowerCase();
|
|
558
|
+
if (s.includes("haiku"))
|
|
559
|
+
return 1;
|
|
560
|
+
if (s.includes("sonnet"))
|
|
561
|
+
return 2;
|
|
562
|
+
if (s.includes("opus"))
|
|
563
|
+
return 3;
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Is `model` a weaker tier than `floor`? Both must be rankable (see
|
|
568
|
+
* {@link modelTier}); an unrankable model/floor is never "below" (fail-open).
|
|
569
|
+
*/
|
|
570
|
+
function belowModelFloor(model, floor) {
|
|
571
|
+
const m = modelTier(model);
|
|
572
|
+
const f = modelTier(floor);
|
|
573
|
+
return m !== null && f !== null && m < f;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Reduce a raw `--version` string to the **major.minor** cache-key token. We key
|
|
577
|
+
* the cache on major.minor, NOT the patch: a patch release rarely changes agent
|
|
578
|
+
* behaviour, so keying patches would churn the cache on every release for no
|
|
579
|
+
* signal; a minor/major bump is where the system prompt / tool defs actually move.
|
|
580
|
+
* (If a specific patch is known to matter, clear the cache or bump
|
|
581
|
+
* `CACHE_FORMAT_VERSION`.) Falls back to the trimmed raw string when no semver is
|
|
582
|
+
* found. Pure + tested.
|
|
583
|
+
*/
|
|
584
|
+
function harnessVersionKey(raw) {
|
|
585
|
+
const m = /(\d+)\.(\d+)\.\d+/.exec(raw);
|
|
586
|
+
return m ? `${m[1]}.${m[2]}` : raw.trim();
|
|
587
|
+
}
|
|
588
|
+
/* v8 ignore start -- spawns the real harness binary; memoized, cache-path only */
|
|
589
|
+
let cachedHarnessVersion;
|
|
590
|
+
/**
|
|
591
|
+
* The harness binary version (`claude --version`) reduced to major.minor, for the
|
|
592
|
+
* cache key — so a CLI **minor/major** upgrade (new system prompt / tool defs)
|
|
593
|
+
* invalidates a stale replay, while patches don't churn it. Memoized (one spawn
|
|
594
|
+
* per process), resolved only on the cache path, "unknown" if the binary isn't
|
|
595
|
+
* found (then it doesn't partition the key).
|
|
596
|
+
*/
|
|
597
|
+
function harnessVersion() {
|
|
598
|
+
if (cachedHarnessVersion === undefined) {
|
|
599
|
+
try {
|
|
600
|
+
cachedHarnessVersion = harnessVersionKey((0, node_child_process_1.execSync)(`${runtime_js_1.claudeCodeRuntime.agentBinary} --version`, {
|
|
601
|
+
encoding: "utf-8",
|
|
602
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
cachedHarnessVersion = "unknown";
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return cachedHarnessVersion;
|
|
610
|
+
}
|
|
611
|
+
/* v8 ignore stop */
|
|
612
|
+
/** Warn (once per run) that replaying/recording a cache on a floating alias hides drift. */
|
|
613
|
+
function warnFloatingModel(model) {
|
|
614
|
+
const msg = `vigiles: eval cache is on but the model "${model}" is a floating alias — ` +
|
|
615
|
+
`a replay can serve a result computed against a since-changed model, hiding ` +
|
|
616
|
+
`drift. Pin a dated id (e.g. ...-20251001) for honest replay.`;
|
|
617
|
+
if (process.env.GITHUB_ACTIONS)
|
|
618
|
+
console.log(`::warning::${msg}`);
|
|
619
|
+
else
|
|
620
|
+
console.warn(msg);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Merge the tool-intercept PreToolUse hook into an arm's resolved settings
|
|
624
|
+
* (appending to any existing `PreToolUse` list). Returns the settings unchanged
|
|
625
|
+
* when there are no intercepts. The intercept list itself rides the
|
|
626
|
+
* `VIGILES_INTERCEPT_TOOLS` env, not the settings — see {@link executeTrial}.
|
|
627
|
+
*/
|
|
628
|
+
function withInterceptToolHook(settings, intercepts) {
|
|
629
|
+
if (intercepts.length === 0)
|
|
630
|
+
return settings;
|
|
631
|
+
const intercept = (0, tool_intercept_js_1.buildInterceptSettings)(intercepts, {
|
|
632
|
+
command: INTERCEPT_TOOL_HOOK_CMD,
|
|
633
|
+
});
|
|
634
|
+
const base = isRecord(settings) ? settings : {};
|
|
635
|
+
const baseHooks = isRecord(base.hooks) ? base.hooks : {};
|
|
636
|
+
const basePre = Array.isArray(baseHooks.PreToolUse)
|
|
637
|
+
? baseHooks.PreToolUse
|
|
638
|
+
: [];
|
|
639
|
+
return {
|
|
640
|
+
...base,
|
|
641
|
+
hooks: {
|
|
642
|
+
...baseHooks,
|
|
643
|
+
PreToolUse: [...basePre, ...intercept.hooks.PreToolUse],
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The default allowlist {@link ephemeralRunEnv} passes through from the real
|
|
649
|
+
* environment. Two groups, both load-bearing for a real-model `claude` run:
|
|
650
|
+
*
|
|
651
|
+
* - **Auth** — the harness's OWN credentials. The eval drives the real `claude`
|
|
652
|
+
* CLI, which authenticates via the user's subscription (`~/.claude`, reached
|
|
653
|
+
* through the fresh HOME's allowed config — see below) OR via these env vars.
|
|
654
|
+
* We mirror the auth surface the runtime port already names
|
|
655
|
+
* (`ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`, see
|
|
656
|
+
* `adapters/claude-code/runtime.ts`) plus the OAuth/token + region variants the
|
|
657
|
+
* CLI accepts, so a token-authed user is not broken by a too-narrow list.
|
|
658
|
+
* - **Runtime** — what any spawned process needs to *function*: `PATH` (resolve
|
|
659
|
+
* `node` / `claude`), the locale/terminal vars (`LANG` / `LC_*` / `TERM`), and
|
|
660
|
+
* `TMPDIR` (which we override to the fresh HOME). Mirrors what `bwrapArgs` /
|
|
661
|
+
* `setenvArgs` in `src/sandbox.ts` set back after `--clearenv`.
|
|
662
|
+
*
|
|
663
|
+
* Notably it does NOT pass through `GIT_*`, `GH_TOKEN`, `SSH_*`, `AWS_*`, or any
|
|
664
|
+
* other non-allowlisted secret-shaped var — those are exactly what an ephemeral
|
|
665
|
+
* run must not see. `CLAUDE_*` is allowlisted by prefix because the CLI reads
|
|
666
|
+
* several `CLAUDE_*` knobs (config dir, etc.) and omitting one is the failure
|
|
667
|
+
* mode this whole guard is conservative against.
|
|
668
|
+
*
|
|
669
|
+
* Conservative by design: a too-broad allowlist is safe (it just leaks a benign
|
|
670
|
+
* var); a too-narrow one silently breaks auth — which is why the feature ships
|
|
671
|
+
* default-OFF until validated against a real run.
|
|
672
|
+
*/
|
|
673
|
+
const EPHEMERAL_ALLOW = [
|
|
674
|
+
// Runtime essentials (mirror sandbox.ts setenv-after-clearenv).
|
|
675
|
+
"PATH",
|
|
676
|
+
"LANG",
|
|
677
|
+
"TERM",
|
|
678
|
+
// Anthropic / Claude Code auth + endpoint (mirror runtime.ts + CLI auth vars).
|
|
679
|
+
runtime_js_1.claudeCodeRuntime.modelApiKeyEnv, // ANTHROPIC_API_KEY
|
|
680
|
+
runtime_js_1.claudeCodeRuntime.modelBaseUrlEnv, // ANTHROPIC_BASE_URL
|
|
681
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
682
|
+
"ANTHROPIC_API_URL",
|
|
683
|
+
"ANTHROPIC_MODEL",
|
|
684
|
+
"ANTHROPIC_DEFAULT_HEADERS",
|
|
685
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
686
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
687
|
+
// Cloud-provider auth the CLI uses for Bedrock/Vertex backends (region/profile
|
|
688
|
+
// only — NOT the secret-shaped AWS_* access keys, which stay dropped).
|
|
689
|
+
"AWS_REGION",
|
|
690
|
+
"AWS_DEFAULT_REGION",
|
|
691
|
+
"AWS_PROFILE",
|
|
692
|
+
"CLOUD_ML_REGION",
|
|
693
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
694
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
695
|
+
];
|
|
696
|
+
/** Prefixes passed through wholesale — the CLI reads several `CLAUDE_*` knobs and
|
|
697
|
+
* a `LC_*` locale family; allowlist by prefix so omitting one isn't the silent
|
|
698
|
+
* auth/locale break this guard exists to avoid. */
|
|
699
|
+
const EPHEMERAL_ALLOW_PREFIXES = ["CLAUDE_", "LC_"];
|
|
700
|
+
/**
|
|
701
|
+
* Build an **ephemeral run environment** for a model-driven run: a NEW env object
|
|
702
|
+
* with a *fresh* `HOME` (and `TMPDIR`) pointed at the throwaway `opts.home`, only
|
|
703
|
+
* an allowlist of auth + runtime vars passed through from `base`, and everything
|
|
704
|
+
* else DROPPED. Pure — no fs, no spawn.
|
|
705
|
+
*
|
|
706
|
+
* The rationale is "fresh HOME + only the harness credential injected, **not** a
|
|
707
|
+
* blanket wipe": running a model-driven skill/agent is itself a side effect (the
|
|
708
|
+
* *model*, not the author, chose the actions), so it should not be able to read
|
|
709
|
+
* the real `~/.gitconfig` / `~/.ssh` / `~/.aws` or write to the real `~`. But the
|
|
710
|
+
* real `claude` CLI must still AUTHENTICATE, so the harness's own credentials
|
|
711
|
+
* ({@link EPHEMERAL_ALLOW} — `ANTHROPIC_*`, `CLAUDE_*`, locale/PATH) are
|
|
712
|
+
* re-injected; a blanket `--clearenv`-style wipe would break every eval. Because
|
|
713
|
+
* this needs no kernel features, it is the cross-platform STATE-protection floor
|
|
714
|
+
* (lands on macOS immediately), orthogonal to the Linux bubblewrap HOST
|
|
715
|
+
* confinement in `src/sandbox.ts`.
|
|
716
|
+
*
|
|
717
|
+
* @param base the source environment to filter (usually `process.env`).
|
|
718
|
+
* @param opts.home the throwaway dir to set as `HOME`/`TMPDIR`.
|
|
719
|
+
* @param opts.allow extra var NAMES to pass through (e.g. the `VIGILES_*` keys
|
|
720
|
+
* the eval already injects). Layered ON TOP of the default allowlist.
|
|
721
|
+
*/
|
|
722
|
+
function ephemeralRunEnv(base, opts) {
|
|
723
|
+
const out = {};
|
|
724
|
+
const allowExact = new Set([
|
|
725
|
+
...EPHEMERAL_ALLOW,
|
|
726
|
+
...(opts.allow ?? []),
|
|
727
|
+
]);
|
|
728
|
+
for (const [k, v] of Object.entries(base)) {
|
|
729
|
+
if (v === undefined)
|
|
730
|
+
continue;
|
|
731
|
+
const allowed = allowExact.has(k) ||
|
|
732
|
+
EPHEMERAL_ALLOW_PREFIXES.some((p) => k.startsWith(p));
|
|
733
|
+
if (allowed)
|
|
734
|
+
out[k] = v;
|
|
735
|
+
}
|
|
736
|
+
// Fresh HOME + TMPDIR last so they always win over anything passed through.
|
|
737
|
+
out.HOME = opts.home;
|
|
738
|
+
out.TMPDIR = opts.home;
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Home-relative AUTH files to carry from the real HOME into the throwaway one.
|
|
743
|
+
*
|
|
744
|
+
* A local subscription credential (OAuth token) often lives in a FILE under HOME,
|
|
745
|
+
* not an env var — so scrubbing HOME would lose it and break a local-authed run.
|
|
746
|
+
* This is the file half of the auth allowlist; {@link EPHEMERAL_ALLOW} covers the
|
|
747
|
+
* env-var / host-brokered half. Kept a named constant so it's easy to extend, and
|
|
748
|
+
* deliberately NARROW — only the explicit auth files, never `.gitconfig` / `.ssh`
|
|
749
|
+
* / `.aws`, which are exactly what an ephemeral run must not see.
|
|
750
|
+
*/
|
|
751
|
+
exports.EPHEMERAL_HOME_KEEP = [
|
|
752
|
+
".claude/.credentials.json", // the Claude Code OAuth token
|
|
753
|
+
];
|
|
754
|
+
/**
|
|
755
|
+
* Seed the throwaway HOME with the harness's own auth FILE(s) — best-effort;
|
|
756
|
+
* covers local file-based OAuth; the env-var/host-brokered path is covered by the
|
|
757
|
+
* allowlist in {@link ephemeralRunEnv}.
|
|
758
|
+
*
|
|
759
|
+
* COPIES (never symlinks) each {@link EPHEMERAL_HOME_KEEP} path from `realHome`
|
|
760
|
+
* into `throwawayHome`, creating parent dirs as needed; a symlink would let the
|
|
761
|
+
* model-driven run write back to the real credential file, defeating ephemerality.
|
|
762
|
+
* A path that doesn't exist in the real HOME is skipped silently (that user auths
|
|
763
|
+
* via env-var / host broker instead). Pure fs — no env, no spawn.
|
|
764
|
+
*/
|
|
765
|
+
function seedEphemeralHome(throwawayHome, realHome, keep = exports.EPHEMERAL_HOME_KEEP) {
|
|
766
|
+
for (const rel of keep) {
|
|
767
|
+
const src = (0, node_path_1.join)(realHome, rel);
|
|
768
|
+
if (!(0, node_fs_1.existsSync)(src))
|
|
769
|
+
continue; // env-var / host-brokered auth covers this.
|
|
770
|
+
const dest = (0, node_path_1.join)(throwawayHome, rel);
|
|
771
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
|
|
772
|
+
(0, node_fs_1.cpSync)(src, dest); // copy, not symlink — keep the real credential read-only.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
457
775
|
/** Execute one trial in a fresh sandbox; returns its metric row + usage. */
|
|
458
776
|
async function executeTrial(spec, arm, trialIndex, runner, cfg) {
|
|
459
777
|
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
|
|
460
778
|
try {
|
|
461
|
-
const
|
|
779
|
+
const resolved = (0, plugin_loader_js_1.resolveHarness)({
|
|
462
780
|
plugin: arm.plugin,
|
|
463
781
|
settings: arm.settings,
|
|
464
782
|
files: { ...spec.fixture, ...arm.files },
|
|
465
783
|
});
|
|
784
|
+
const { files } = resolved;
|
|
785
|
+
const intercepts = arm.interceptTools ?? [];
|
|
786
|
+
const settings = withInterceptToolHook(resolved.settings, intercepts);
|
|
787
|
+
// The eval-injected overlay (VIGILES_INTERCEPT_TOOLS), if any.
|
|
788
|
+
const overlay = intercepts.length > 0
|
|
789
|
+
? { [tool_intercept_js_1.INTERCEPT_TOOLS_ENV]: (0, tool_intercept_js_1.serializeIntercepts)(intercepts) }
|
|
790
|
+
: undefined;
|
|
791
|
+
// Opt-in (default OFF): an ephemeral run env — a throwaway HOME under the
|
|
792
|
+
// trial's own temp cwd + a scrubbed, auth-only allowlist. The eval's injected
|
|
793
|
+
// keys (e.g. VIGILES_INTERCEPT_TOOLS) are allowlisted through so interception
|
|
794
|
+
// still works. When OFF, `env`/`replaceEnv` are exactly as before.
|
|
795
|
+
// Tool stubs on PATH (rung R2): write the fake binaries into a bin dir under
|
|
796
|
+
// this trial's cwd; it is PREPENDED to whatever PATH the run uses below, so
|
|
797
|
+
// the fakes win over the real binaries. Absent → no PATH change.
|
|
798
|
+
const stubs = spec.stubs ?? [];
|
|
799
|
+
const stubDir = stubs.length > 0
|
|
800
|
+
? (0, tool_stub_js_1.stubBinDir)(stubs, (0, node_path_1.join)(cwd, ".vigiles-stubs"))
|
|
801
|
+
: undefined;
|
|
802
|
+
const prependPath = (path) => stubDir === undefined
|
|
803
|
+
? (path ?? "")
|
|
804
|
+
: path === undefined || path === ""
|
|
805
|
+
? stubDir
|
|
806
|
+
: `${stubDir}${node_path_1.delimiter}${path}`;
|
|
807
|
+
const ephemeral = spec.ephemeralEnv === true;
|
|
808
|
+
let env;
|
|
809
|
+
let replaceEnv = false;
|
|
810
|
+
if (ephemeral) {
|
|
811
|
+
const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(cwd, "home-"));
|
|
812
|
+
// Carry the harness's own auth FILE (local OAuth) into the fresh HOME —
|
|
813
|
+
// env-var/host-brokered auth is covered by ephemeralRunEnv's allowlist.
|
|
814
|
+
seedEphemeralHome(home, process.env.HOME ?? (0, node_os_1.homedir)());
|
|
815
|
+
env = ephemeralRunEnv(process.env, {
|
|
816
|
+
home,
|
|
817
|
+
allow: overlay ? Object.keys(overlay) : [],
|
|
818
|
+
});
|
|
819
|
+
if (overlay)
|
|
820
|
+
Object.assign(env, overlay);
|
|
821
|
+
// ephemeralRunEnv passes PATH through; prepend the stub dir over it.
|
|
822
|
+
if (stubDir !== undefined)
|
|
823
|
+
env.PATH = prependPath(env.PATH);
|
|
824
|
+
replaceEnv = true;
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
// Legacy overlay path: `spawnAgent` spreads `{ ...process.env, ...env }`, so
|
|
828
|
+
// set PATH in the overlay to the stub dir prepended over process.env.PATH.
|
|
829
|
+
env =
|
|
830
|
+
stubDir !== undefined
|
|
831
|
+
? { ...overlay, PATH: prependPath(process.env.PATH) }
|
|
832
|
+
: overlay;
|
|
833
|
+
}
|
|
466
834
|
writeFiles(cwd, files);
|
|
467
835
|
const hasSettings = settings !== undefined;
|
|
468
836
|
if (hasSettings) {
|
|
@@ -471,11 +839,14 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
|
|
|
471
839
|
const out = await runWithCache({
|
|
472
840
|
task: spec.task,
|
|
473
841
|
cwd,
|
|
474
|
-
model:
|
|
842
|
+
// A model comparison is a harness A/B: an arm may override the model.
|
|
843
|
+
model: arm.model ?? cfg.model,
|
|
475
844
|
tools: cfg.tools,
|
|
476
845
|
hasSettings,
|
|
477
846
|
pluginDir: arm.pluginDir,
|
|
478
847
|
timeoutMs: cfg.timeoutMs,
|
|
848
|
+
env,
|
|
849
|
+
replaceEnv,
|
|
479
850
|
}, { files, settings, trialIndex }, runner, cfg);
|
|
480
851
|
const ctx = makeContext(cwd, out);
|
|
481
852
|
return { row: spec.measure(ctx), usage: ctx.usage };
|
|
@@ -566,6 +937,9 @@ async function runEvalWith(spec, runner) {
|
|
|
566
937
|
cache: spec.cache ?? "off",
|
|
567
938
|
cacheDir: spec.cacheDir ?? (0, node_path_1.resolve)(process.cwd(), ".vigiles", "eval-cache"),
|
|
568
939
|
};
|
|
940
|
+
if (cfg.cache !== "off" && !isDatedModel(cfg.model)) {
|
|
941
|
+
warnFloatingModel(cfg.model);
|
|
942
|
+
}
|
|
569
943
|
const retrying = (a) => runWithRetry(a, runner, retries, backoffMs);
|
|
570
944
|
const units = buildUnits(spec.arms, trials);
|
|
571
945
|
let spent = 0;
|
|
@@ -773,23 +1147,141 @@ function assertPromptDiversity(prompts, opts = {}) {
|
|
|
773
1147
|
* be set. `packaged` is present only when vigiles built it, so the caller knows
|
|
774
1148
|
* to remove it afterward.
|
|
775
1149
|
*/
|
|
1150
|
+
/** The dir holding `<name>/SKILL.md` for a source that may be a plugin (`skills/`
|
|
1151
|
+
* or `.claude/skills/`) or already a loose skills dir. */
|
|
1152
|
+
function collectSkillsSource(dir) {
|
|
1153
|
+
const abs = (0, node_path_1.resolve)(dir);
|
|
1154
|
+
const pluginSkills = (0, node_path_1.join)(abs, "skills");
|
|
1155
|
+
if ((0, node_fs_1.existsSync)(pluginSkills))
|
|
1156
|
+
return pluginSkills;
|
|
1157
|
+
const ccSkills = (0, node_path_1.join)(abs, ".claude", "skills");
|
|
1158
|
+
if ((0, node_fs_1.existsSync)(ccSkills))
|
|
1159
|
+
return ccSkills;
|
|
1160
|
+
return abs;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Copy each `<name>/SKILL.md` skill from `src` into `skillsOut` (stubbing the body
|
|
1164
|
+
* when asked); skip a skill whose name is already `present` so the under-test
|
|
1165
|
+
* skill wins a collision. Returns how many were newly copied.
|
|
1166
|
+
*/
|
|
1167
|
+
function copySkillsInto(src, skillsOut, stub, present) {
|
|
1168
|
+
const abs = (0, node_path_1.resolve)(src);
|
|
1169
|
+
if (!(0, node_fs_1.existsSync)(abs))
|
|
1170
|
+
throw new Error(`installSet source not found: ${src} (resolved ${abs})`);
|
|
1171
|
+
let copied = 0;
|
|
1172
|
+
for (const entry of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
|
|
1173
|
+
if (!entry.isDirectory() || present.has(entry.name))
|
|
1174
|
+
continue;
|
|
1175
|
+
const srcSkill = (0, node_path_1.join)(abs, entry.name, "SKILL.md");
|
|
1176
|
+
if (!(0, node_fs_1.existsSync)(srcSkill))
|
|
1177
|
+
continue;
|
|
1178
|
+
const destDir = (0, node_path_1.join)(skillsOut, entry.name);
|
|
1179
|
+
if (stub) {
|
|
1180
|
+
(0, node_fs_1.mkdirSync)(destDir, { recursive: true });
|
|
1181
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(destDir, "SKILL.md"), stubSkillBody((0, node_fs_1.readFileSync)(srcSkill, "utf-8")));
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
(0, node_fs_1.cpSync)((0, node_path_1.join)(abs, entry.name), destDir, { recursive: true });
|
|
1185
|
+
}
|
|
1186
|
+
present.add(entry.name);
|
|
1187
|
+
copied++;
|
|
1188
|
+
}
|
|
1189
|
+
return copied;
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Build a combined plugin: the under-test skills PLUS every `installSet` source's
|
|
1193
|
+
* skills, so the skill-under-test competes for selection as in the real harness.
|
|
1194
|
+
* Named after the under-test plugin so `<name>:<skill>` ids still match; the
|
|
1195
|
+
* under-test skills win a name collision. Returns the dir + `added` = how many
|
|
1196
|
+
* installSet skills were merged in (excludes collisions). The report's
|
|
1197
|
+
* `competitors` is derived separately from the FULL pool (see `countSkills`), so
|
|
1198
|
+
* sibling skills already in the under-test source count too. Caller removes the
|
|
1199
|
+
* dir. Pure (filesystem only).
|
|
1200
|
+
*/
|
|
1201
|
+
function packageInstallSet(opts) {
|
|
1202
|
+
const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
|
|
1203
|
+
try {
|
|
1204
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
|
|
1205
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name, version: "0.0.0" }, null, 2));
|
|
1206
|
+
const skillsOut = (0, node_path_1.join)(root, "skills");
|
|
1207
|
+
(0, node_fs_1.mkdirSync)(skillsOut, { recursive: true });
|
|
1208
|
+
const present = new Set();
|
|
1209
|
+
const underTest = copySkillsInto(opts.underTestSrc, skillsOut, opts.stub, present);
|
|
1210
|
+
if (underTest === 0)
|
|
1211
|
+
throw new Error(`No <name>/SKILL.md skills under the skill-under-test source ${opts.underTestSrc}`);
|
|
1212
|
+
let added = 0;
|
|
1213
|
+
for (const src of opts.installSet)
|
|
1214
|
+
added += copySkillsInto(collectSkillsSource(src), skillsOut, opts.stub, present);
|
|
1215
|
+
return { dir: root, added };
|
|
1216
|
+
}
|
|
1217
|
+
catch (e) {
|
|
1218
|
+
(0, node_fs_1.rmSync)(root, { recursive: true, force: true }); // don't leak the temp dir
|
|
1219
|
+
throw e;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/** The under-test skills source + plugin name (the namespace `fired` matches). */
|
|
1223
|
+
function underTestSource(spec) {
|
|
1224
|
+
if (spec.skillsDir)
|
|
1225
|
+
return { src: spec.skillsDir, name: "vigiles-loose-skills" };
|
|
1226
|
+
if (spec.pluginDir)
|
|
1227
|
+
return {
|
|
1228
|
+
src: skillsDirOf(spec.pluginDir),
|
|
1229
|
+
name: pluginName(spec.pluginDir) ?? "vigiles-loose-skills",
|
|
1230
|
+
};
|
|
1231
|
+
throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
|
|
1232
|
+
}
|
|
1233
|
+
/** Number of `<name>/SKILL.md` skills installed in a plugin — the selection pool. */
|
|
1234
|
+
function countSkills(pluginDir) {
|
|
1235
|
+
const dir = skillsDirOf(pluginDir);
|
|
1236
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
1237
|
+
return 0;
|
|
1238
|
+
let n = 0;
|
|
1239
|
+
for (const e of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }))
|
|
1240
|
+
if (e.isDirectory() && (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, e.name, "SKILL.md")))
|
|
1241
|
+
n++;
|
|
1242
|
+
return n;
|
|
1243
|
+
}
|
|
776
1244
|
function resolveTriggerPluginDir(spec) {
|
|
777
1245
|
if (spec.pluginDir && spec.skillsDir)
|
|
778
1246
|
throw new Error("measureTriggerRate: set `pluginDir` OR `skillsDir`, not both.");
|
|
779
|
-
const stub = spec.stubSkillBodies ??
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1247
|
+
const stub = spec.stubSkillBodies ?? true; // trigger = frontmatter; body never needed
|
|
1248
|
+
const installSet = spec.installSet ?? [];
|
|
1249
|
+
let pluginDir;
|
|
1250
|
+
let packaged;
|
|
1251
|
+
if (installSet.length > 0) {
|
|
1252
|
+
// Whole-harness tier: merge the under-test skills with the install set so
|
|
1253
|
+
// selection is competitive (the realistic, differentiated measurement).
|
|
1254
|
+
const { src, name } = underTestSource(spec);
|
|
1255
|
+
({ dir: pluginDir } = packageInstallSet({
|
|
1256
|
+
underTestSrc: src,
|
|
1257
|
+
name,
|
|
1258
|
+
installSet,
|
|
1259
|
+
stub,
|
|
1260
|
+
}));
|
|
1261
|
+
packaged = pluginDir;
|
|
1262
|
+
}
|
|
1263
|
+
else if (spec.skillsDir) {
|
|
1264
|
+
packaged = packageSkillsDir(spec.skillsDir, { stub });
|
|
1265
|
+
pluginDir = packaged;
|
|
1266
|
+
}
|
|
1267
|
+
else if (spec.pluginDir) {
|
|
787
1268
|
// Stub a real plugin: build a minimal plugin from its skills/ with bodies
|
|
788
1269
|
// stripped — keep the original plugin NAME so `<name>:<skill>` still matches.
|
|
789
|
-
|
|
790
|
-
|
|
1270
|
+
packaged = stub ? stubbedPluginDir(spec.pluginDir) : undefined;
|
|
1271
|
+
pluginDir = packaged ?? spec.pluginDir;
|
|
791
1272
|
}
|
|
792
|
-
|
|
1273
|
+
else {
|
|
1274
|
+
throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
|
|
1275
|
+
}
|
|
1276
|
+
// `competitors` is the REAL selection pressure: every OTHER skill installed in
|
|
1277
|
+
// the resolved plugin (siblings already in the source + any installSet), not
|
|
1278
|
+
// just the installSet delta — so a multi-skill plugin is never mislabeled
|
|
1279
|
+
// "isolated". `max(0, …)` guards a 0-skill pool.
|
|
1280
|
+
return {
|
|
1281
|
+
pluginDir,
|
|
1282
|
+
packaged,
|
|
1283
|
+
competitors: Math.max(0, countSkills(pluginDir) - 1),
|
|
1284
|
+
};
|
|
793
1285
|
}
|
|
794
1286
|
/** Run one prompt set × trials through `runner`, aggregating fired counts. */
|
|
795
1287
|
async function runTriggerSet(prompts, cfg, runner) {
|
|
@@ -851,10 +1343,23 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
851
1343
|
label: "irrelevantPrompts",
|
|
852
1344
|
});
|
|
853
1345
|
}
|
|
854
|
-
|
|
1346
|
+
// Model floor (default Sonnet): trigger-rate under-measures selection on a
|
|
1347
|
+
// weaker model, so FAIL before spending a token rather than report a
|
|
1348
|
+
// false-negative recall. The floor lives in the spec (`minModel`), not an env
|
|
1349
|
+
// override — model choice is part of the measurement definition. Lower it
|
|
1350
|
+
// deliberately for a cheap run.
|
|
1351
|
+
const model = spec.model ?? "sonnet";
|
|
1352
|
+
const minModel = spec.minModel ?? "sonnet";
|
|
1353
|
+
if (belowModelFloor(model, minModel))
|
|
1354
|
+
throw new Error(`measureTriggerRate: model "${model}" is below the minimum "${minModel}" — ` +
|
|
1355
|
+
"trigger-rate under-measures selection on a weaker model " +
|
|
1356
|
+
"(raise the model, or lower `minModel` for a deliberately cheap run).");
|
|
1357
|
+
const { pluginDir, packaged, competitors } = resolveTriggerPluginDir(spec);
|
|
855
1358
|
const cfg = {
|
|
856
1359
|
trials: spec.trials ?? 1,
|
|
857
|
-
|
|
1360
|
+
// Sonnet, not haiku: trigger-rate is a selection measurement and haiku
|
|
1361
|
+
// under-selects, producing false-negative recall (see TriggerRateSpec.model).
|
|
1362
|
+
model,
|
|
858
1363
|
tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"],
|
|
859
1364
|
timeoutMs: spec.timeoutMs ?? 240000,
|
|
860
1365
|
spacing: (spec.spacingSec ?? 4) * 1000,
|
|
@@ -867,6 +1372,7 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
867
1372
|
rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
|
|
868
1373
|
n: relevant.n,
|
|
869
1374
|
perPrompt: relevant.perPrompt,
|
|
1375
|
+
competitors,
|
|
870
1376
|
};
|
|
871
1377
|
if ((spec.irrelevantPrompts?.length ?? 0) === 0)
|
|
872
1378
|
return base;
|
|
@@ -911,6 +1417,12 @@ function formatTriggerRateReport(report) {
|
|
|
911
1417
|
lines.push(` ${p.rate.toFixed(2)} [irrelevant] ${p.prompt.slice(0, 48)}`);
|
|
912
1418
|
}
|
|
913
1419
|
}
|
|
1420
|
+
// Honest labelling: an isolated run measures the skill alone, with no competing
|
|
1421
|
+
// skills to evict or out-compete its description — so recall is an UPPER bound
|
|
1422
|
+
// and false-positive a LOWER bound. Say so, or point at the whole-harness count.
|
|
1423
|
+
lines.push(report.competitors > 0
|
|
1424
|
+
? `whole-harness: measured against ${String(report.competitors)} competing skill(s)`
|
|
1425
|
+
: "isolated: no competing skills — recall is an upper bound, false-positive a lower bound (populate `installSet` for a release-gate measurement)");
|
|
914
1426
|
return lines.join("\n");
|
|
915
1427
|
}
|
|
916
1428
|
//# sourceMappingURL=eval.js.map
|