vigiles 36.0.0 → 37.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/dist/eval.js CHANGED
@@ -16,6 +16,7 @@ exports.measureArmsWith = measureArmsWith;
16
16
  exports.measureArms = measureArms;
17
17
  exports.compareCheck = compareCheck;
18
18
  exports.formatCheckReport = formatCheckReport;
19
+ exports.formatArmsCheckReport = formatArmsCheckReport;
19
20
  exports.assertRates = assertRates;
20
21
  exports.checkReportToJUnit = checkReportToJUnit;
21
22
  exports.parseUsage = parseUsage;
@@ -32,6 +33,8 @@ exports.ephemeralRunEnv = ephemeralRunEnv;
32
33
  exports.seedEphemeralHome = seedEphemeralHome;
33
34
  exports.isRateLimited = isRateLimited;
34
35
  exports.runPool = runPool;
36
+ exports.unansweredInReport = unansweredInReport;
37
+ exports.runEvalReporting = runEvalReporting;
35
38
  exports.runEvalWith = runEvalWith;
36
39
  exports.formatEvalReport = formatEvalReport;
37
40
  exports.packageSkillsDir = packageSkillsDir;
@@ -54,6 +57,7 @@ exports.formatTriggerRateReport = formatTriggerRateReport;
54
57
  * benchmark harness under `bench/` — the empirical half of testing your harness.
55
58
  *
56
59
  * const report = await runEval({
60
+ * env: { kind: "ephemeral" },
57
61
  * fixture: { "src/billing.ts": "export function chargeCard(){}" },
58
62
  * arms: {
59
63
  * vanilla: {},
@@ -89,6 +93,8 @@ const check_count_js_1 = require("./check-count.js");
89
93
  const coverage_probe_js_1 = require("./coverage-probe.js");
90
94
  const tool_intercept_js_1 = require("./tool-intercept.js");
91
95
  const tool_stub_js_1 = require("./tool-stub.js");
96
+ const stub_rules_js_1 = require("./core/stub-rules.js");
97
+ const eval_spec_parse_js_1 = require("./core/eval-spec-parse.js");
92
98
  const tmp_root_js_1 = require("./core/tmp-root.js");
93
99
  const edit_distance_js_1 = require("./core/edit-distance.js");
94
100
  /**
@@ -108,7 +114,7 @@ function writeFiles(cwd, files) {
108
114
  }
109
115
  /**
110
116
  * Resolve the environment a trial's subprocess actually runs with — the
111
- * SECURITY-CRITICAL decision behind `ephemeralEnv`. When `replaceEnv` is set, the
117
+ * SECURITY-CRITICAL decision behind `env: { kind: "ephemeral" }`. When `replaceEnv` is set, the
112
118
  * scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
113
119
  * secrets are DROPPED; otherwise `env` is an overlay on `base`. In BOTH, the
114
120
  * harness's declared session identity (`runEnv.sessionIdentity`) is removed: a
@@ -345,13 +351,14 @@ function warnUnregisteredSkillArms(arms) {
345
351
  }
346
352
  }
347
353
  async function runEval(spec) {
348
- warnUnregisteredSkillArms(spec.arms);
349
- const report = await runEvalWith(spec, exports.spawnAgent);
350
- // Surface what the run spent — tokens + API-equivalent $, and a LOUD warning if
351
- // it was billed to a metered API key instead of the subscription. See eval-cost.ts.
352
- (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromEvalReport)(report));
353
- return report;
354
+ return runEvalReporting(spec, exports.spawnAgent);
354
355
  }
356
+ /** The env `measure` / `measureArms` pass on: they have no `env` field yet. */
357
+ const MEASURE_INHERIT_REASON = "measure and measureArms have no `env` field; they run with your HOME and environment (docs/safety.md)";
358
+ const INHERITED_BY_MEASURE = {
359
+ kind: "inherit",
360
+ reason: MEASURE_INHERIT_REASON,
361
+ };
355
362
  /**
356
363
  * Score a check vocabulary across trials — the scored counterpart to
357
364
  * `assertChecks` (strict). Each check yields a `rate ± se` and `pass^k` over `n`
@@ -396,6 +403,9 @@ async function measureWith(spec, runner) {
396
403
  allowedTools: spec.allowedTools,
397
404
  timeoutMs: spec.timeoutMs,
398
405
  spacingSec: spec.spacingSec,
406
+ // measure / measureArms have no `env` field (yet): they run with your
407
+ // HOME and environment, as before — said here, not defaulted silently.
408
+ env: INHERITED_BY_MEASURE,
399
409
  measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
400
410
  }, runner);
401
411
  return checkReportOf(report.arms.run, keyed, installNamespace(arm));
@@ -450,6 +460,9 @@ async function measureArmsWith(spec, runner) {
450
460
  allowedTools: spec.allowedTools,
451
461
  timeoutMs: spec.timeoutMs,
452
462
  spacingSec: spec.spacingSec,
463
+ // measure / measureArms have no `env` field (yet): they run with your
464
+ // HOME and environment, as before — said here, not defaulted silently.
465
+ env: INHERITED_BY_MEASURE,
453
466
  measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
454
467
  }, runner);
455
468
  const arms = {};
@@ -491,8 +504,25 @@ function checkLabel(json) {
491
504
  }
492
505
  return json.kind;
493
506
  }
494
- /** Format a {@link CheckReport}: one line per check with its rate ± se and pass^k. */
507
+ /** The report line that says a run used your real HOME and environment, and why. */
508
+ const inheritLine = (reason) => ` env: inherited your HOME and environment — ${reason}`;
509
+ /**
510
+ * Format a {@link CheckReport}: one line per check with its rate ± se and
511
+ * pass^k, and the line saying `measure` ran with your HOME and environment
512
+ * (it has no `env` field; `INHERITED_BY_MEASURE`).
513
+ */
495
514
  function formatCheckReport(report) {
515
+ return [formatChecks(report), inheritLine(MEASURE_INHERIT_REASON)].join("\n");
516
+ }
517
+ /**
518
+ * Format an {@link ArmsCheckReport}: each arm's checks under `[arm: name]`, and
519
+ * the inherit line ONCE at the end — every arm ran the same way.
520
+ */
521
+ function formatArmsCheckReport(report) {
522
+ const arms = Object.entries(report.arms).map(([name, arm]) => `\n[arm: ${name}]\n${formatChecks(arm)}`);
523
+ return [...arms, inheritLine(MEASURE_INHERIT_REASON)].join("\n");
524
+ }
525
+ function formatChecks(report) {
496
526
  const lines = [`measured ${String(report.n)} run(s):`];
497
527
  for (const c of report.perCheck) {
498
528
  lines.push(` ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}% ${checkLabel(c.check)}` +
@@ -781,9 +811,11 @@ function aggregateUsage(usages) {
781
811
  * miss, run the agent and (in `readwrite`) record output + cwd snapshot. The
782
812
  * cache key excludes `measure`, so editing the metric still replays.
783
813
  */
784
- async function runWithCache(runArgs, keyParts, runner, cfg) {
785
- if (cfg.cache === "off")
786
- return runner(runArgs);
814
+ async function runWithCache(runArgs, keyParts, runner, cfg, collectStubCalls) {
815
+ if (cfg.cache === "off") {
816
+ const out = await runner(runArgs);
817
+ return { out, stubCalls: collectStubCalls() };
818
+ }
787
819
  const key = (0, eval_cache_js_1.cacheKey)({
788
820
  task: runArgs.task,
789
821
  model: runArgs.model,
@@ -791,7 +823,9 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
791
823
  tools: runArgs.tools,
792
824
  files: keyParts.files,
793
825
  settings: keyParts.settings,
794
- env: runArgs.env,
826
+ env: keyParts.env,
827
+ stubs: keyParts.stubs.length > 0 ? keyParts.stubs : undefined,
828
+ runEnv: keyParts.runEnv,
795
829
  // A native --plugin-dir install isn't in `files`, so hash its CONTENTS into
796
830
  // the key — otherwise editing a skill in it would false-replay.
797
831
  pluginDirHash: runArgs.pluginDir ? (0, eval_cache_js_1.hashDir)(runArgs.pluginDir) : undefined,
@@ -806,13 +840,20 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
806
840
  // otherwise be scored as a trial on every later run.
807
841
  if (hit && startFailure(hit.out) === null) {
808
842
  (0, eval_cache_js_1.restoreDir)(runArgs.cwd, hit.files);
809
- return hit.out;
843
+ // The stub log lives beside the work dir, so the record carries it: a
844
+ // replayed trial reports the same calls, unanswered ones included.
845
+ return { out: hit.out, stubCalls: hit.stubCalls };
810
846
  }
811
847
  const out = await runner(runArgs);
848
+ const stubCalls = collectStubCalls();
812
849
  if (cfg.cache === "readwrite") {
813
- (0, eval_cache_js_1.writeCache)(cfg.cacheDir, key, { out, files: (0, eval_cache_js_1.snapshotDir)(runArgs.cwd) });
850
+ (0, eval_cache_js_1.writeCache)(cfg.cacheDir, key, {
851
+ out,
852
+ files: (0, eval_cache_js_1.snapshotDir)(runArgs.cwd),
853
+ ...(stubCalls === undefined ? {} : { stubCalls }),
854
+ });
814
855
  }
815
- return out;
856
+ return { out, stubCalls };
816
857
  }
817
858
  /**
818
859
  * The `vigiles hook-runtime intercept-tool` command, as an absolute `node <cli> …`
@@ -822,7 +863,15 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
822
863
  * entries).
823
864
  */
824
865
  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");
825
- const INTERCEPT_TOOL_HOOK_CMD = `"${process.execPath}" "${INTERCEPT_TOOL_HOOK_CLI}" hook-runtime intercept-tool`;
866
+ /** This node binary — what every command the eval writes for a child calls back into. */
867
+ const NODE_BIN = process.execPath;
868
+ const INTERCEPT_TOOL_HOOK_CMD = `"${NODE_BIN}" "${INTERCEPT_TOOL_HOOK_CLI}" hook-runtime intercept-tool`;
869
+ /**
870
+ * How a stub shim reaches the stub runtime: the same node and `cli.js` as the
871
+ * intercept hook, for the same reason (`npx vigiles` does not resolve from a
872
+ * throwaway dir). The shim runs `cli.js hook-runtime stub`.
873
+ */
874
+ const STUB_LAUNCHER = { node: NODE_BIN, cli: INTERCEPT_TOOL_HOOK_CLI };
826
875
  function isRecord(v) {
827
876
  return v !== null && typeof v === "object";
828
877
  }
@@ -988,9 +1037,91 @@ function seedEphemeralHome(throwawayHome, realHome, keep = EVAL_RUNTIME.runEnv?.
988
1037
  (0, node_fs_1.copyFileSync)(src, dest);
989
1038
  }
990
1039
  }
1040
+ /**
1041
+ * The parts of a {@link RunEnv} that move behaviour — the kind and the seed —
1042
+ * for the cache key and the lock. Never the per-trial HOME path, and never an
1043
+ * `inherit` reason (prose, not an input).
1044
+ */
1045
+ function runEnvKeyView(env) {
1046
+ return env.kind === "ephemeral"
1047
+ ? { kind: env.kind, home: env.home }
1048
+ : { kind: env.kind };
1049
+ }
1050
+ /** The run environment as a report carries it: the kind, and an inherit's reason. */
1051
+ function runEnvReportView(env) {
1052
+ return env.kind === "ephemeral"
1053
+ ? { kind: "ephemeral" }
1054
+ : { kind: "inherit", reason: env.reason };
1055
+ }
1056
+ /** Write a seed's files under a throwaway HOME, parents created. Effects only. */
1057
+ function materializeHome(home, files) {
1058
+ Object.entries(files).forEach(([path, contents]) => {
1059
+ const dest = (0, node_path_1.join)(home, path);
1060
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
1061
+ (0, node_fs_1.writeFileSync)(dest, contents);
1062
+ });
1063
+ }
1064
+ /**
1065
+ * Build a trial's environment from the spec's {@link RunEnv}. `ephemeral`: a
1066
+ * throwaway HOME (`home`, a directory BESIDE the trial cwd — never in it, or a
1067
+ * cache snapshot of the cwd would carry the credentials and the seed), seeded
1068
+ * (the seed first, then the harness's own auth file), and a scrubbed allowlist env — the eval's injected
1069
+ * keys (`VIGILES_INTERCEPT_TOOLS`) allowlisted through so interception works.
1070
+ * `inherit`: an overlay on the caller's env. Either way the stub bin dir, when
1071
+ * there is one, goes first on PATH.
1072
+ */
1073
+ function trialEnv(runEnv, home, overlay, stubBin) {
1074
+ const withStubs = (path) => stubBin === undefined
1075
+ ? (path ?? "")
1076
+ : path === undefined || path === ""
1077
+ ? stubBin
1078
+ : `${stubBin}${node_path_1.delimiter}${path}`;
1079
+ switch (runEnv.kind) {
1080
+ case "ephemeral": {
1081
+ // The seed was validated at the spec boundary (`parseRunEnv`): every
1082
+ // path HOME-relative, inside HOME, and not the harness's auth file.
1083
+ materializeHome(home, runEnv.home?.files ?? {});
1084
+ // Carry the harness's own auth FILE (local OAuth) into the fresh HOME —
1085
+ // env-var/host-brokered auth is covered by ephemeralRunEnv's allowlist.
1086
+ seedEphemeralHome(home, process.env.HOME ?? (0, node_os_1.homedir)());
1087
+ const base = {
1088
+ ...ephemeralRunEnv(process.env, {
1089
+ home,
1090
+ allow: overlay ? Object.keys(overlay) : [],
1091
+ }),
1092
+ ...overlay,
1093
+ };
1094
+ return {
1095
+ env: stubBin === undefined
1096
+ ? base
1097
+ : { ...base, PATH: withStubs(base.PATH) },
1098
+ replaceEnv: true,
1099
+ keyEnv: base,
1100
+ };
1101
+ }
1102
+ case "inherit":
1103
+ // `spawnAgent` spreads `{ ...process.env, ...env }`, so a stub PATH is
1104
+ // set in the overlay as the stub dir prepended over process.env.PATH.
1105
+ return {
1106
+ env: stubBin === undefined
1107
+ ? overlay
1108
+ : { ...overlay, PATH: withStubs(process.env.PATH) },
1109
+ replaceEnv: false,
1110
+ keyEnv: overlay,
1111
+ };
1112
+ }
1113
+ }
991
1114
  /** Execute one trial in a fresh sandbox; returns its metric row + usage. */
992
- async function executeTrial(spec, arm, trialIndex, runner, cfg) {
1115
+ async function executeTrial(spec, arm, trialIndex, retrying, cfg) {
993
1116
  const cwd = (0, tmp_root_js_1.makeTmpDir)("eval");
1117
+ // The ephemeral env's HOME: beside the work dir, so `snapshotDir(cwd)` (the
1118
+ // cache record) never sees the credentials file or the seed, and the agent's
1119
+ // `ls -a` of its project does not show it. An `inherit` run leaves it empty.
1120
+ const home = (0, tmp_root_js_1.makeTmpDir)("home");
1121
+ // Tool stubs (rung R2): a shim per stub in a temp dir BESIDE the work dir —
1122
+ // not in it, so the agent does not find vigiles plumbing in its project.
1123
+ const stubs = spec.stubs;
1124
+ const stubDir = stubs.length > 0 ? (0, tool_stub_js_1.writeStubDir)(stubs, STUB_LAUNCHER) : undefined;
994
1125
  try {
995
1126
  const resolved = (0, plugin_loader_js_1.resolveHarness)({
996
1127
  plugin: arm.plugin,
@@ -1004,55 +1135,13 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
1004
1135
  const overlay = intercepts.length > 0
1005
1136
  ? { [tool_intercept_js_1.INTERCEPT_TOOLS_ENV]: (0, tool_intercept_js_1.serializeIntercepts)(intercepts) }
1006
1137
  : undefined;
1007
- // Opt-in (default OFF): an ephemeral run env — a throwaway HOME under the
1008
- // trial's own temp cwd + a scrubbed, auth-only allowlist. The eval's injected
1009
- // keys (e.g. VIGILES_INTERCEPT_TOOLS) are allowlisted through so interception
1010
- // still works. When OFF, `env`/`replaceEnv` are exactly as before.
1011
- // Tool stubs on PATH (rung R2): write the fake binaries into a bin dir under
1012
- // this trial's cwd; it is PREPENDED to whatever PATH the run uses below, so
1013
- // the fakes win over the real binaries. Absent → no PATH change.
1014
- const stubs = spec.stubs ?? [];
1015
- const stubDir = stubs.length > 0
1016
- ? (0, tool_stub_js_1.stubBinDir)(stubs, (0, node_path_1.join)(cwd, ".vigiles-stubs"))
1017
- : undefined;
1018
- const prependPath = (path) => stubDir === undefined
1019
- ? (path ?? "")
1020
- : path === undefined || path === ""
1021
- ? stubDir
1022
- : `${stubDir}${node_path_1.delimiter}${path}`;
1023
- const ephemeral = spec.ephemeralEnv === true;
1024
- let env;
1025
- let replaceEnv = false;
1026
- if (ephemeral) {
1027
- const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(cwd, "home-"));
1028
- // Carry the harness's own auth FILE (local OAuth) into the fresh HOME —
1029
- // env-var/host-brokered auth is covered by ephemeralRunEnv's allowlist.
1030
- seedEphemeralHome(home, process.env.HOME ?? (0, node_os_1.homedir)());
1031
- env = ephemeralRunEnv(process.env, {
1032
- home,
1033
- allow: overlay ? Object.keys(overlay) : [],
1034
- });
1035
- if (overlay)
1036
- Object.assign(env, overlay);
1037
- // ephemeralRunEnv passes PATH through; prepend the stub dir over it.
1038
- if (stubDir !== undefined)
1039
- env.PATH = prependPath(env.PATH);
1040
- replaceEnv = true;
1041
- }
1042
- else {
1043
- // Legacy overlay path: `spawnAgent` spreads `{ ...process.env, ...env }`, so
1044
- // set PATH in the overlay to the stub dir prepended over process.env.PATH.
1045
- env =
1046
- stubDir !== undefined
1047
- ? { ...overlay, PATH: prependPath(process.env.PATH) }
1048
- : overlay;
1049
- }
1138
+ const run = trialEnv(spec.env, home, overlay, stubDir?.binDir);
1050
1139
  writeFiles(cwd, files);
1051
1140
  const hasSettings = settings !== undefined;
1052
1141
  if (hasSettings) {
1053
1142
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
1054
1143
  }
1055
- const out = await runWithCache({
1144
+ const { out, stubCalls } = await runWithCache({
1056
1145
  task: spec.task,
1057
1146
  cwd,
1058
1147
  // A model comparison is a harness A/B: an arm may override the model.
@@ -1062,14 +1151,43 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
1062
1151
  hasSettings,
1063
1152
  pluginDir: arm.pluginDir,
1064
1153
  timeoutMs: cfg.timeoutMs,
1065
- env,
1066
- replaceEnv,
1067
- }, { files, settings, trialIndex }, runner, cfg);
1068
- const ctx = makeContext(cwd, out);
1069
- return { row: spec.measure(ctx), usage: ctx.usage };
1154
+ env: run.env,
1155
+ replaceEnv: run.replaceEnv,
1156
+ }, {
1157
+ files,
1158
+ settings,
1159
+ trialIndex,
1160
+ env: run.keyEnv,
1161
+ stubs: stubs.map(stub_rules_js_1.encodeStub),
1162
+ runEnv: runEnvKeyView(spec.env),
1163
+ },
1164
+ // A rate-limited attempt is discarded and retried; its stub calls must
1165
+ // go with it, or the retry inherits a used-up `inOrder` answer and the
1166
+ // report counts calls from a run nobody scores.
1167
+ retrying(stubDir === undefined
1168
+ ? undefined
1169
+ : () => {
1170
+ (0, tool_stub_js_1.resetStubCalls)(stubDir);
1171
+ }), cfg, () => (stubDir === undefined ? undefined : (0, tool_stub_js_1.readStubCalls)(stubDir)));
1172
+ const base = makeContext(cwd, out);
1173
+ const ctx = stubCalls === undefined ? base : { ...base, stubCalls };
1174
+ return {
1175
+ row: spec.measure(ctx),
1176
+ usage: ctx.usage,
1177
+ unanswered: [
1178
+ ...(stubCalls ?? []),
1179
+ // A stub that could not write its log still printed its neutral line
1180
+ // into the agent's tool results: count those too, or a confined shell
1181
+ // would leave the report clean.
1182
+ ...(0, stub_rules_js_1.unloggedMisses)(ctx.toolCalls.map((c) => c.resultText), stubCalls ?? [], stubs.map((s) => s.name)),
1183
+ ].filter((c) => (0, stub_rules_js_1.isUnanswered)(c.outcome)),
1184
+ };
1070
1185
  }
1071
1186
  finally {
1072
1187
  (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
1188
+ (0, node_fs_1.rmSync)(home, { recursive: true, force: true });
1189
+ if (stubDir !== undefined)
1190
+ (0, tool_stub_js_1.removeStubDir)(stubDir);
1073
1191
  }
1074
1192
  }
1075
1193
  // A signal in the captured streams that the model call was rate-limited /
@@ -1095,9 +1213,14 @@ const RATE_LIMIT_RE = /rate[ -]?limit(?:ed)?\b|rate_limit_error|\b429\b|overload
1095
1213
  function isRateLimited(out) {
1096
1214
  return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
1097
1215
  }
1098
- /** Call `runner`, retrying with exponential backoff while it looks rate-limited. */
1099
- async function runWithRetry(runArgs, runner, retries, baseMs) {
1216
+ /**
1217
+ * Call `runner`, retrying with exponential backoff while it looks rate-limited.
1218
+ * `beforeAttempt` runs before every attempt, the first included: it resets the
1219
+ * state a discarded attempt would otherwise leave to the next.
1220
+ */
1221
+ async function runWithRetry(runArgs, runner, retries, baseMs, beforeAttempt) {
1100
1222
  for (let attempt = 0;; attempt++) {
1223
+ beforeAttempt?.();
1101
1224
  const out = await runner(runArgs);
1102
1225
  if (!isRateLimited(out) || attempt >= retries)
1103
1226
  return out;
@@ -1155,6 +1278,7 @@ function aggregateArms(armNames, results) {
1155
1278
  metrics: aggregate(rows),
1156
1279
  stats: aggregateStats(rows),
1157
1280
  usage,
1281
+ unansweredStubCalls: done.flatMap((d) => d.unanswered),
1158
1282
  };
1159
1283
  }
1160
1284
  return { arms, totalCostUsd };
@@ -1222,6 +1346,11 @@ async function withEvalLock(args, produce) {
1222
1346
  if (decision.kind === "replay")
1223
1347
  return decision.report;
1224
1348
  const report = await produce();
1349
+ const refusal = args.refuse?.(report);
1350
+ if (refusal !== undefined) {
1351
+ emitLockMessage(`vigiles eval --${lock.mode}: the lock for "${args.name}" was NOT written — ${refusal}`, true);
1352
+ return report;
1353
+ }
1225
1354
  const builtLock = (0, eval_lock_js_1.buildLock)({
1226
1355
  name: args.name,
1227
1356
  inputsHash,
@@ -1290,21 +1419,22 @@ function evalArmsInputs(spec, cfg) {
1290
1419
  : undefined,
1291
1420
  };
1292
1421
  }
1293
- // Tool stubs (`spec.stubs`) are written onto PATH before each trial, so a
1294
- // change to a canned CLI output IS a model-facing input change — fold a
1295
- // canonical (name-sorted) view into the hash so `--check` catches it. Sorted
1296
- // for a stable key regardless of declaration order; an empty list is the
1297
- // byte-identical-to-before default. Each ToolStub is plain serializable data.
1298
- const stubs = [...(spec.stubs ?? [])].sort((a, b) => a.name.localeCompare(b.name));
1299
- // `ephemeralEnv` swaps the trial's environment (scrubbed env + throwaway HOME
1300
- // vs the inherited process env), which can move tool/hook/agent behavior — a
1301
- // model-facing input, so it belongs in the hash. Normalize to a bool so a
1302
- // record under one mode can't be replayed under the other with the same hash.
1422
+ // Tool stubs are a model-facing input: a changed answer changes what the agent
1423
+ // sees. Folded in ENCODED (`encodeStub`), never as the raw rules —
1424
+ // `JSON.stringify(/^repos/)` is `{}`, so two specs differing only in a RegExp
1425
+ // token would hash the same and `--check` would pass a stale lock. Sorted by
1426
+ // name for a key that ignores declaration order.
1427
+ const stubs = [...spec.stubs]
1428
+ .sort((a, b) => a.name.localeCompare(b.name))
1429
+ .map(stub_rules_js_1.encodeStub);
1430
+ // Where the trials run (ephemeral + its seed, or inherit) moves behaviour, so
1431
+ // it is an input. The seed's CONTENTS are hashed — the author's input — never
1432
+ // the per-trial HOME path, which differs every run.
1303
1433
  return {
1304
1434
  task: spec.task,
1305
1435
  arms,
1306
1436
  stubs,
1307
- ephemeralEnv: spec.ephemeralEnv === true,
1437
+ env: runEnvKeyView(spec.env),
1308
1438
  };
1309
1439
  }
1310
1440
  // ---------------------------------------------------------------------------
@@ -1349,7 +1479,7 @@ const EVAL_SPEC_KEYS = {
1349
1479
  maxCostUsd: true,
1350
1480
  rateLimitRetries: true,
1351
1481
  retryBackoffMs: true,
1352
- ephemeralEnv: true,
1482
+ env: true,
1353
1483
  stubs: true,
1354
1484
  lock: true,
1355
1485
  };
@@ -1443,7 +1573,45 @@ function assertKnownKeys(spec, known, at) {
1443
1573
  " An unknown field is refused rather than ignored: a dropped field would " +
1444
1574
  "make the run measure a setup you did not ask for.");
1445
1575
  }
1576
+ /**
1577
+ * The author-facing failure for a report whose stubs left a call unanswered, or
1578
+ * undefined. One owner for the rule — `vigiles eval`'s exit 2 and the lock's
1579
+ * refusal both read it — so a consumer whose `assert` calls no helper still
1580
+ * fails. Pure.
1581
+ */
1582
+ function unansweredInReport(report, stubs) {
1583
+ const calls = Object.values(report.arms).flatMap((a) => a.unansweredStubCalls);
1584
+ const message = (0, stub_rules_js_1.unansweredMessage)(calls, stubs);
1585
+ return message === undefined
1586
+ ? undefined
1587
+ : `${message}\n The report is not a measurement: no lock is written, and \`vigiles eval\` exits 2.`;
1588
+ }
1589
+ /**
1590
+ * `runEval` with its author-facing output, over any runner — so the real one and
1591
+ * a test's fake share one path. Besides the run it prints, to stderr, what the
1592
+ * caller must not miss: a skill arm that never activates, a stub call no rule
1593
+ * answered (the same text `vigiles eval` fails with — a script that calls
1594
+ * `runEval` itself, as the `withServices` examples do, gets the report back and
1595
+ * nothing else, so the library is not silent about it either), and what the
1596
+ * run spent.
1597
+ */
1598
+ async function runEvalReporting(spec, runner) {
1599
+ warnUnregisteredSkillArms(spec.arms);
1600
+ const report = await runEvalWith(spec, runner);
1601
+ const unanswered = unansweredInReport(report, (0, eval_spec_parse_js_1.parseToolStubs)(spec.stubs, "runEval"));
1602
+ if (unanswered !== undefined)
1603
+ console.error(`⚠ runEval: ${unanswered}`);
1604
+ // Surface what the run spent — tokens + API-equivalent $, and a LOUD warning if
1605
+ // it was billed to a metered API key instead of the subscription. See eval-cost.ts.
1606
+ (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromEvalReport)(report));
1607
+ return report;
1608
+ }
1446
1609
  async function runEvalWith(input, runner) {
1610
+ // A REMOVED field gets its rewrite, not "unknown field … did you mean".
1611
+ if (Object.hasOwn(input, "ephemeralEnv"))
1612
+ throw new Error("runEval: `ephemeralEnv` was replaced by `env`, which is required:\n" +
1613
+ ' ephemeralEnv: true → env: { kind: "ephemeral" }\n' +
1614
+ ' ephemeralEnv: false / absent → env: { kind: "inherit", reason: "…" } (your real HOME; the reason is printed with the report)');
1447
1615
  // Refuse a stray field BEFORE spending a token: an eval file is plain JS, so a
1448
1616
  // typo'd or misplaced key would otherwise vanish and the run would report a
1449
1617
  // confident number about the wrong setup (issue #307).
@@ -1464,8 +1632,18 @@ async function runEvalWith(input, runner) {
1464
1632
  // `skillsDir` packaged into a throwaway plugin) is resolved HERE, once — the
1465
1633
  // one place that decides what `--plugin-dir` receives, so measure / measureArms
1466
1634
  // / runEval cannot disagree about it. The throwaways are removed afterward.
1635
+ // `stubs` and `env` are parsed HERE, once: the old argv-blind stub shape, a
1636
+ // stateful RegExp, a missing `env`, a seed path outside HOME — each refused
1637
+ // with its rewrite before a token is spent. The runner trusts the result.
1638
+ const stubs = (0, eval_spec_parse_js_1.parseToolStubs)(input.stubs, "runEval");
1639
+ const env = (0, eval_spec_parse_js_1.parseRunEnv)(input.env, "runEval", EVAL_RUNTIME.runEnv?.keepHomeFiles ?? []);
1467
1640
  const { arms: resolvedArms, packaged } = resolveArmInstalls(input.arms, input.stubSkillBodies ?? false);
1468
- const spec = { ...input, arms: resolvedArms };
1641
+ const spec = {
1642
+ ...input,
1643
+ arms: resolvedArms,
1644
+ stubs,
1645
+ env,
1646
+ };
1469
1647
  try {
1470
1648
  return await runResolvedEval(spec, runner);
1471
1649
  }
@@ -1492,31 +1670,49 @@ async function runResolvedEval(spec, runner) {
1492
1670
  if (cfg.cache !== "off" && !isDatedModel(cfg.model)) {
1493
1671
  warnFloatingModel(cfg.model);
1494
1672
  }
1495
- const retrying = (a) => runWithRetry(a, runner, retries, backoffMs);
1673
+ const retrying = (beforeAttempt) => (a) => runWithRetry(a, runner, retries, backoffMs, beforeAttempt);
1496
1674
  const units = buildUnits(spec.arms, trials);
1497
1675
  let spent = 0;
1498
- let aborted = false;
1676
+ // Why no NEW trial starts: the budget cap, or a stub call nobody scripted —
1677
+ // the report will be refused, so later trials would only spend. In-flight
1678
+ // trials finish either way.
1679
+ let stopped;
1499
1680
  const worker = async (unit) => {
1500
- if (aborted)
1681
+ if (stopped !== undefined)
1501
1682
  return { armName: unit.armName, skipped: true };
1502
- const { row, usage } = await executeTrial(spec, unit.arm, unit.trialIndex, retrying, cfg);
1683
+ const { row, usage, unanswered } = await executeTrial(spec, unit.arm, unit.trialIndex, retrying, cfg);
1503
1684
  spent += usage.costUsd;
1504
- if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd) {
1505
- aborted = true;
1506
- }
1685
+ if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd)
1686
+ stopped = "budget";
1687
+ else if (unanswered.length > 0)
1688
+ stopped = stopped ?? "unanswered";
1507
1689
  if (spacing > 0)
1508
1690
  await sleep(spacing);
1509
- return { armName: unit.armName, skipped: false, row, usage };
1691
+ return { armName: unit.armName, skipped: false, row, usage, unanswered };
1510
1692
  };
1511
1693
  const lock = resolveLock(spec.lock);
1512
1694
  // Resolve the lock inputs only when the lock is active (off skips the
1513
1695
  // resolveHarness/hashDir work). `check` replays the committed report below
1514
1696
  // without ever entering the run pool — so no model is driven in CI.
1515
1697
  const inputs = lock.mode === "off" ? undefined : evalArmsInputs(spec, cfg);
1516
- return withEvalLock({ name: spec.name, inputs, model: cfg.model, effort: cfg.effort, lock }, async () => {
1698
+ return withEvalLock({
1699
+ name: spec.name,
1700
+ inputs,
1701
+ model: cfg.model,
1702
+ effort: cfg.effort,
1703
+ lock,
1704
+ refuse: (report) => unansweredInReport(report, spec.stubs),
1705
+ }, async () => {
1517
1706
  const results = await runPool(units, concurrency, worker);
1518
1707
  const { arms, totalCostUsd } = aggregateArms(Object.keys(spec.arms), results);
1519
- return { name: spec.name ?? "eval", trials, arms, totalCostUsd, aborted };
1708
+ return {
1709
+ name: spec.name ?? "eval",
1710
+ trials,
1711
+ arms,
1712
+ totalCostUsd,
1713
+ aborted: stopped === "budget",
1714
+ env: runEnvReportView(spec.env),
1715
+ };
1520
1716
  });
1521
1717
  }
1522
1718
  /** Render one metric: `name=mean±se pass^k=…` (se/pass^k shown when measured). */
@@ -1549,7 +1745,13 @@ function formatEvalReport(report) {
1549
1745
  .map(([k, v]) => formatMetric(k, v, r.stats[k]))
1550
1746
  .join(" ");
1551
1747
  lines.push(` ${arm.padEnd(10)} ${parts}${formatUsage(r.usage)}`);
1748
+ // One line: the calls themselves are printed once, by `runEval`
1749
+ // (`unansweredInReport`), not again under every arm of the table.
1750
+ if (r.unansweredStubCalls.length > 0)
1751
+ lines.push(` ⚠ ${arm}: ${String(r.unansweredStubCalls.length)} stub call(s) went unanswered — the report is not a measurement`);
1552
1752
  }
1753
+ if (report.env?.kind === "inherit")
1754
+ lines.push(inheritLine(report.env.reason));
1553
1755
  return lines.join("\n");
1554
1756
  }
1555
1757
  /**
@@ -204,7 +204,7 @@ export declare function parseClaudeRun(stdout: string): ParsedRun;
204
204
  * else from the caller: no harness auth (the scripted mock needs none, and a
205
205
  * config-dir variable would point the run back at the real config), no
206
206
  * identity, no secrets. The same scrubbed shape as the eval tier's
207
- * `ephemeralEnv` (`scrubbedRunEnv`), without the auth.
207
+ * `env: { kind: "ephemeral" }` (`scrubbedRunEnv`), without the auth.
208
208
  */
209
209
  export type HarnessRunHome = {
210
210
  readonly home: "inherit";
@@ -29,7 +29,7 @@
29
29
  * a dev box with NO production access;
30
30
  * - point the task at the disposable service's connection string ONLY;
31
31
  * - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
32
- * `~/.ssh`) — pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
32
+ * `~/.ssh`) — pair it with the eval tier's `env: { kind: "ephemeral" }` (throwaway HOME +
33
33
  * cleared env) to scrub them so the model has no real keys to misuse.
34
34
  * Treat it like running an untrusted script. A future increment adds an egress
35
35
  * wall (the skill reaches only the model + the service); until then that job is
package/dist/services.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * a dev box with NO production access;
31
31
  * - point the task at the disposable service's connection string ONLY;
32
32
  * - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
33
- * `~/.ssh`) — pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
33
+ * `~/.ssh`) — pair it with the eval tier's `env: { kind: "ephemeral" }` (throwaway HOME +
34
34
  * cleared env) to scrub them so the model has no real keys to misuse.
35
35
  * Treat it like running an untrusted script. A future increment adds an egress
36
36
  * wall (the skill reaches only the model + the service); until then that job is