vigiles 11.0.0 → 12.1.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/README.md +10 -5
  3. package/action.yml +13 -2
  4. package/dist/adapter-conformance.js +6 -0
  5. package/dist/adapter-registry.d.ts +20 -0
  6. package/dist/adapter-registry.js +27 -0
  7. package/dist/adapters/claude-code/hook-protocol.js +4 -0
  8. package/dist/adapters/claude-code/runtime.js +12 -0
  9. package/dist/adapters/codex/eval.js +3 -0
  10. package/dist/adapters/codex/hook-protocol.d.ts +9 -1
  11. package/dist/adapters/codex/hook-protocol.js +10 -0
  12. package/dist/adapters/codex/runtime.js +10 -0
  13. package/dist/adapters/opencode/runtime.js +4 -0
  14. package/dist/claude-code.d.ts +2 -0
  15. package/dist/claude-code.js +9 -1
  16. package/dist/cli-commands.d.ts +1 -1
  17. package/dist/cli-commands.js +1 -0
  18. package/dist/cli.js +245 -29
  19. package/dist/core/hook-protocol.d.ts +15 -0
  20. package/dist/core/rule-meta.js +8 -0
  21. package/dist/core/runtime.d.ts +20 -0
  22. package/dist/core/skill-description-budget.d.ts +42 -0
  23. package/dist/core/skill-description-budget.js +47 -0
  24. package/dist/core/types.d.ts +21 -0
  25. package/dist/core/validate.js +3 -0
  26. package/dist/doc-command-coverage.d.ts +20 -0
  27. package/dist/doc-command-coverage.js +60 -0
  28. package/dist/eval-cache.d.ts +6 -0
  29. package/dist/eval-cache.js +2 -0
  30. package/dist/eval-cost.d.ts +75 -0
  31. package/dist/eval-cost.js +134 -0
  32. package/dist/eval-lock.d.ts +192 -0
  33. package/dist/eval-lock.js +286 -0
  34. package/dist/eval.d.ts +37 -20
  35. package/dist/eval.js +227 -56
  36. package/dist/research-index.d.ts +31 -0
  37. package/dist/research-index.js +48 -0
  38. package/dist/scan-behavioral.d.ts +42 -0
  39. package/dist/scan-behavioral.js +67 -0
  40. package/dist/scan.d.ts +3 -23
  41. package/dist/scan.js +18 -69
  42. package/dist/setup-plan.d.ts +38 -1
  43. package/dist/setup-plan.js +67 -4
  44. package/hooks/eval-lock-nudge.sh +21 -0
  45. package/package.json +1 -1
  46. package/skills/adopt-spec/SKILL.md +10 -1
  47. package/skills/edit-spec/SKILL.md +1 -0
  48. package/skills/strengthen/SKILL.md +4 -0
  49. package/skills/test-harness/SKILL.md +44 -0
package/dist/eval.js CHANGED
@@ -22,7 +22,6 @@ exports.aggregateUsage = aggregateUsage;
22
22
  exports.isDatedModel = isDatedModel;
23
23
  exports.modelTier = modelTier;
24
24
  exports.belowModelFloor = belowModelFloor;
25
- exports.harnessVersionKey = harnessVersionKey;
26
25
  exports.ephemeralRunEnv = ephemeralRunEnv;
27
26
  exports.seedEphemeralHome = seedEphemeralHome;
28
27
  exports.isRateLimited = isRateLimited;
@@ -68,9 +67,11 @@ const node_os_1 = require("node:os");
68
67
  const node_path_1 = require("node:path");
69
68
  const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
70
69
  const runtime_js_1 = require("./adapters/claude-code/runtime.js");
70
+ const eval_cost_js_1 = require("./eval-cost.js");
71
71
  const proofs_js_1 = require("./core/proofs.js");
72
72
  const harness_test_js_1 = require("./harness-test.js");
73
73
  const eval_cache_js_1 = require("./eval-cache.js");
74
+ const eval_lock_js_1 = require("./eval-lock.js");
74
75
  const stats_js_1 = require("./stats.js");
75
76
  const tool_intercept_js_1 = require("./tool-intercept.js");
76
77
  const tool_stub_js_1 = require("./tool-stub.js");
@@ -144,7 +145,11 @@ function spawnAgent(a) {
144
145
  * `runEvalWith` with the real agent runner.
145
146
  */
146
147
  async function runEval(spec) {
147
- return runEvalWith(spec, spawnAgent);
148
+ const report = await runEvalWith(spec, spawnAgent);
149
+ // Surface what the run spent — tokens + API-equivalent $, and a LOUD warning if
150
+ // it was billed to a metered API key instead of the subscription. See eval-cost.ts.
151
+ (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromEvalReport)(report));
152
+ return report;
148
153
  }
149
154
  /**
150
155
  * Score a check vocabulary across trials — the scored counterpart to
@@ -201,6 +206,7 @@ async function measureWith(spec, runner) {
201
206
  n: s?.n ?? 0,
202
207
  };
203
208
  }),
209
+ usage: arm?.usage ?? aggregateUsage([]),
204
210
  };
205
211
  }
206
212
  finally {
@@ -211,7 +217,9 @@ async function measureWith(spec, runner) {
211
217
  /* v8 ignore start -- real claude subprocess; thin wrapper over measureWith */
212
218
  /** Score a check vocabulary across trials against the real `claude` CLI. */
213
219
  async function measure(spec) {
214
- return measureWith(spec, spawnAgent);
220
+ const report = await measureWith(spec, spawnAgent);
221
+ (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromArm)(report.usage));
222
+ return report;
215
223
  }
216
224
  /** Score checks across arms (injectable runner). Reuses `runEvalWith`. */
217
225
  async function measureArmsWith(spec, runner) {
@@ -245,6 +253,7 @@ async function measureArmsWith(spec, runner) {
245
253
  n: s?.n ?? 0,
246
254
  };
247
255
  }),
256
+ usage: arm.usage,
248
257
  };
249
258
  }
250
259
  return { arms };
@@ -278,7 +287,10 @@ function stubArmPluginDirs(arms) {
278
287
  /* v8 ignore start -- real claude subprocess; thin wrapper over measureArmsWith */
279
288
  /** Score checks across arms against the real `claude` CLI. */
280
289
  async function measureArms(spec) {
281
- return measureArmsWith(spec, spawnAgent);
290
+ const report = await measureArmsWith(spec, spawnAgent);
291
+ // Sum every arm's spend — an A/B run pays for both arms.
292
+ (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.sumCosts)(Object.values(report.arms).map((a) => (0, eval_cost_js_1.costFromArm)(a.usage))));
293
+ return report;
282
294
  }
283
295
  /* v8 ignore stop */
284
296
  /**
@@ -640,32 +652,22 @@ function belowModelFloor(model, floor) {
640
652
  const f = modelTier(floor);
641
653
  return m !== null && f !== null && m < f;
642
654
  }
643
- /**
644
- * Reduce a raw `--version` string to the **major.minor** cache-key token. We key
645
- * the cache on major.minor, NOT the patch: a patch release rarely changes agent
646
- * behaviour, so keying patches would churn the cache on every release for no
647
- * signal; a minor/major bump is where the system prompt / tool defs actually move.
648
- * (If a specific patch is known to matter, clear the cache or bump
649
- * `CACHE_FORMAT_VERSION`.) Falls back to the trimmed raw string when no semver is
650
- * found. Pure + tested.
651
- */
652
- function harnessVersionKey(raw) {
653
- const m = /(\d+)\.(\d+)\.\d+/.exec(raw);
654
- return m ? `${m[1]}.${m[2]}` : raw.trim();
655
- }
656
655
  /* v8 ignore start -- spawns the real harness binary; memoized, cache-path only */
657
656
  let cachedHarnessVersion;
658
657
  /**
659
- * The harness binary version (`claude --version`) reduced to major.minor, for the
660
- * cache key — so a CLI **minor/major** upgrade (new system prompt / tool defs)
661
- * invalidates a stale replay, while patches don't churn it. Memoized (one spawn
662
- * per process), resolved only on the cache path, "unknown" if the binary isn't
663
- * found (then it doesn't partition the key).
658
+ * The harness binary version (`claude --version`), reduced to its
659
+ * behaviorally-significant token by the runtime port's
660
+ * {@link HarnessRuntime.versionKey} — so a Claude Code **minor/major** upgrade
661
+ * (new system prompt / tool defs) invalidates a stale replay while patches don't
662
+ * churn it. The reduction is per-harness (CC → `major.minor`, Codex → `""`) and
663
+ * lives on the adapter, not here. Memoized (one spawn per process), resolved only
664
+ * on the cache path, "unknown" if the binary isn't found (then it doesn't
665
+ * partition the key).
664
666
  */
665
667
  function harnessVersion() {
666
668
  if (cachedHarnessVersion === undefined) {
667
669
  try {
668
- cachedHarnessVersion = harnessVersionKey((0, node_child_process_1.execSync)(`${runtime_js_1.claudeCodeRuntime.agentBinary} --version`, {
670
+ cachedHarnessVersion = runtime_js_1.claudeCodeRuntime.versionKey((0, node_child_process_1.execSync)(`${runtime_js_1.claudeCodeRuntime.agentBinary} --version`, {
669
671
  encoding: "utf-8",
670
672
  stdio: ["ignore", "pipe", "ignore"],
671
673
  }));
@@ -983,15 +985,141 @@ function aggregateArms(armNames, results) {
983
985
  }
984
986
  return { arms, totalCostUsd };
985
987
  }
988
+ function resolveLock(over) {
989
+ return {
990
+ mode: over?.mode ?? (0, eval_lock_js_1.lockModeFromEnv)(),
991
+ dir: over?.dir ?? (0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR),
992
+ evalApiVersion: over?.evalApiVersion ?? (0, eval_lock_js_1.evalApiVersionFromEnv)(),
993
+ };
994
+ }
995
+ /** Emit a vigiles message (GitHub annotation under Actions, else stderr/stdout). */
996
+ function emitLockMessage(msg, warn) {
997
+ if (process.env.GITHUB_ACTIONS)
998
+ console.log(`::${warn ? "warning" : "notice"}::${msg}`);
999
+ else if (warn)
1000
+ console.warn(msg);
1001
+ else
1002
+ console.log(msg);
1003
+ }
1004
+ /**
1005
+ * Run a named eval through the lock. `off` → just `produce()`. `check` → replay a
1006
+ * matching committed lock (NO model call) or throw "stale". `update` → `produce()`,
1007
+ * write the lock, print the human-facing delta. The model is driven ONLY on the
1008
+ * run path — never on a clean `check`, which is what keeps the CI gate binary-free.
1009
+ * A lock-on run with no `name` is a LOUD skip (the lock needs a name to key the file).
1010
+ */
1011
+ async function withEvalLock(args, produce) {
1012
+ const { lock } = args;
1013
+ if (lock.mode === "off")
1014
+ return produce();
1015
+ if (!args.name) {
1016
+ // An unnamed eval can't be keyed to a lock. In `check` (the CI gate) running
1017
+ // it would call the model — violating the no-model-in-CI contract and failing
1018
+ // for missing auth — so FAIL LOUDLY instead of silently hitting the model.
1019
+ // `update` (local, model available) keeps producing: the eval just isn't gated.
1020
+ if (lock.mode === "check") {
1021
+ throw new Error(`vigiles eval --check: an unnamed eval cannot run in CI — it has no ` +
1022
+ `committed lock to replay, and running it would call the model. Add a ` +
1023
+ `\`name\` to the spec to gate it, or exclude it from the --check run.`);
1024
+ }
1025
+ emitLockMessage(`vigiles eval --${lock.mode}: skipped the lock for an unnamed eval — set ` +
1026
+ `\`name\` on the spec to enable the staleness gate for it.`, true);
1027
+ return produce();
1028
+ }
1029
+ if (!isDatedModel(args.model))
1030
+ warnFloatingModel(args.model);
1031
+ const inputsHash = (0, eval_lock_js_1.evalInputsHash)({
1032
+ model: args.model,
1033
+ evalApiVersion: lock.evalApiVersion,
1034
+ inputs: args.inputs,
1035
+ });
1036
+ const existing = (0, eval_lock_js_1.readLock)(lock.dir, args.name);
1037
+ const decision = (0, eval_lock_js_1.decideLock)(lock.mode, args.name, inputsHash, existing);
1038
+ if (decision.kind === "stale")
1039
+ throw new Error(decision.reason);
1040
+ if (decision.kind === "replay")
1041
+ return decision.report;
1042
+ const report = await produce();
1043
+ const builtLock = (0, eval_lock_js_1.buildLock)({
1044
+ name: args.name,
1045
+ inputsHash,
1046
+ model: args.model,
1047
+ harnessVersionKey: harnessVersion(),
1048
+ evalApiVersion: lock.evalApiVersion,
1049
+ builtAt: new Date().toISOString(),
1050
+ report,
1051
+ });
1052
+ const deltas = existing ? (0, eval_lock_js_1.diffReportNumbers)(existing.report, report) : [];
1053
+ (0, eval_lock_js_1.writeLock)(lock.dir, builtLock);
1054
+ emitLockMessage((0, eval_lock_js_1.formatLockUpdate)(args.name, deltas, existing === null), false);
1055
+ return report;
1056
+ }
1057
+ /**
1058
+ * Strip the machine-specific plugin-root prefix from a resolved value before it
1059
+ * enters the lock hash. `resolveHarness` expands `${PLUGIN_ROOT}` in a plugin's
1060
+ * hook commands to the checkout's ABSOLUTE path, so a lock recorded at
1061
+ * `/home/dev/...` would be falsely STALE when `--check` recomputes it at
1062
+ * `/home/runner/...` in CI (or any other machine). Normalizing the prefix back to
1063
+ * a token makes the hash location-independent. No-op when there's no plugin root.
1064
+ */
1065
+ function stripPluginRoot(value, absRoot) {
1066
+ if (absRoot === "")
1067
+ return value;
1068
+ const json = JSON.stringify(value);
1069
+ // A hookless plugin resolves to `settings: undefined`, which `JSON.stringify`
1070
+ // returns as `undefined` (not a string) — pass it through rather than `.split`
1071
+ // a non-string (which would throw before the eval can run).
1072
+ if (json === undefined)
1073
+ return value;
1074
+ return JSON.parse(json.split(absRoot).join("${PLUGIN_ROOT}"));
1075
+ }
986
1076
  /**
987
- * The eval orchestration — every arm × trial via `runner`, run through the cache
988
- * and a rate-limit retry, with at most `concurrency` in flight and an optional
989
- * `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
990
- * arm. Exported with an injectable `runner` so the loop, `measure` context,
991
- * caching, pooling, and aggregation are unit-testable without spawning a model
992
- * (pass a fake returning canned stream-json). `runEval` is this with the real
993
- * agent runner.
1077
+ * Resolve each arm's model-affecting inputs into a canonical object for the lock
1078
+ * hash — WITHOUT running the model (it reads files + hashes plugin dirs only). The
1079
+ * trial count is excluded (a sample-size knob, not a behavior input); `measure` is
1080
+ * excluded by design (the script re-asserts against the replayed report).
994
1081
  */
1082
+ function evalArmsInputs(spec, cfg) {
1083
+ const arms = {};
1084
+ for (const [name, arm] of Object.entries(spec.arms)) {
1085
+ const resolved = (0, plugin_loader_js_1.resolveHarness)({
1086
+ plugin: arm.plugin,
1087
+ settings: arm.settings,
1088
+ files: { ...spec.fixture, ...arm.files },
1089
+ });
1090
+ // The plugin root `resolveHarness` expanded into the resolved files/settings
1091
+ // is this checkout's absolute path — normalize it out so the hash is the same
1092
+ // on the dev's machine and in CI (else every plugin-with-root-hooks eval is
1093
+ // falsely stale across machines).
1094
+ const absRoot = arm.plugin ? (0, node_path_1.resolve)(process.cwd(), arm.plugin) : "";
1095
+ arms[name] = {
1096
+ model: arm.model ?? cfg.model,
1097
+ tools: [...cfg.tools].sort(),
1098
+ files: stripPluginRoot(resolved.files, absRoot),
1099
+ settings: stripPluginRoot(resolved.settings, absRoot),
1100
+ pluginDirHash: arm.pluginDir ? (0, eval_cache_js_1.hashDir)(arm.pluginDir) : undefined,
1101
+ interceptTools: arm.interceptTools
1102
+ ? (0, tool_intercept_js_1.serializeIntercepts)(arm.interceptTools)
1103
+ : undefined,
1104
+ };
1105
+ }
1106
+ // Tool stubs (`spec.stubs`) are written onto PATH before each trial, so a
1107
+ // change to a canned CLI output IS a model-facing input change — fold a
1108
+ // canonical (name-sorted) view into the hash so `--check` catches it. Sorted
1109
+ // for a stable key regardless of declaration order; an empty list is the
1110
+ // byte-identical-to-before default. Each ToolStub is plain serializable data.
1111
+ const stubs = [...(spec.stubs ?? [])].sort((a, b) => a.name.localeCompare(b.name));
1112
+ // `ephemeralEnv` swaps the trial's environment (scrubbed env + throwaway HOME
1113
+ // vs the inherited process env), which can move tool/hook/agent behavior — a
1114
+ // model-facing input, so it belongs in the hash. Normalize to a bool so a
1115
+ // record under one mode can't be replayed under the other with the same hash.
1116
+ return {
1117
+ task: spec.task,
1118
+ arms,
1119
+ stubs,
1120
+ ephemeralEnv: spec.ephemeralEnv === true,
1121
+ };
1122
+ }
995
1123
  async function runEvalWith(spec, runner) {
996
1124
  const trials = spec.trials ?? 5;
997
1125
  const spacing = (spec.spacingSec ?? 4) * 1000;
@@ -1024,9 +1152,16 @@ async function runEvalWith(spec, runner) {
1024
1152
  await sleep(spacing);
1025
1153
  return { armName: unit.armName, skipped: false, row, usage };
1026
1154
  };
1027
- const results = await runPool(units, concurrency, worker);
1028
- const { arms, totalCostUsd } = aggregateArms(Object.keys(spec.arms), results);
1029
- return { name: spec.name ?? "eval", trials, arms, totalCostUsd, aborted };
1155
+ const lock = resolveLock(spec.lock);
1156
+ // Resolve the lock inputs only when the lock is active (off skips the
1157
+ // resolveHarness/hashDir work). `check` replays the committed report below
1158
+ // without ever entering the run pool — so no model is driven in CI.
1159
+ const inputs = lock.mode === "off" ? undefined : evalArmsInputs(spec, cfg);
1160
+ return withEvalLock({ name: spec.name, inputs, model: cfg.model, lock }, async () => {
1161
+ const results = await runPool(units, concurrency, worker);
1162
+ const { arms, totalCostUsd } = aggregateArms(Object.keys(spec.arms), results);
1163
+ return { name: spec.name ?? "eval", trials, arms, totalCostUsd, aborted };
1164
+ });
1030
1165
  }
1031
1166
  /** Render one metric: `name=mean±se pass^k=…` (se/pass^k shown when measured). */
1032
1167
  function formatMetric(name, mean, stat) {
@@ -1065,6 +1200,7 @@ function formatEvalReport(report) {
1065
1200
  exports.claudeEvalDriver = {
1066
1201
  runner: spawnAgent,
1067
1202
  parse: parseClaudeRun,
1203
+ harness: "claude-code",
1068
1204
  };
1069
1205
  /**
1070
1206
  * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
@@ -1372,13 +1508,17 @@ async function runTriggerTrial(prompt, cfg, runner) {
1372
1508
  pluginDir: cfg.pluginDir,
1373
1509
  timeoutMs: cfg.timeoutMs,
1374
1510
  });
1511
+ // Usage comes from the parser (harness-neutral: Claude + Codex both fill it),
1512
+ // and a run costs tokens even when it errors — so accumulate it either way.
1513
+ const ctx = makeContext(cwd, out, cfg.parse);
1375
1514
  // An errored/rate-limited turn is NOT a "skill didn't fire" miss — it's
1376
1515
  // excluded from the rate, so e.g. a Codex usage limit can't read as recall 0.
1377
1516
  if (cfg.runError?.(out))
1378
- return { fired: 0, errored: true };
1517
+ return { fired: 0, errored: true, usage: ctx.usage };
1379
1518
  return {
1380
- fired: cfg.fired(makeContext(cwd, out, cfg.parse)) ? 1 : 0,
1519
+ fired: cfg.fired(ctx) ? 1 : 0,
1381
1520
  errored: false,
1521
+ usage: ctx.usage,
1382
1522
  };
1383
1523
  }
1384
1524
  finally {
@@ -1415,6 +1555,7 @@ async function runTriggerSet(prompts, cfg, runner) {
1415
1555
  fired: firedBy.reduce((a, b) => a + b, 0),
1416
1556
  n: trialsBy.reduce((a, b) => a + b, 0),
1417
1557
  errored,
1558
+ usages: outcomes.map((o) => o.usage),
1418
1559
  };
1419
1560
  }
1420
1561
  /**
@@ -1439,7 +1580,7 @@ function assertTriggerDiversity(spec) {
1439
1580
  });
1440
1581
  }
1441
1582
  }
1442
- async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runError) {
1583
+ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runError, harness = "claude-code") {
1443
1584
  // Deterministic gate FIRST — before spending a token (or packaging a skillsDir).
1444
1585
  assertTriggerDiversity(spec);
1445
1586
  // Model floor (default Sonnet): trigger-rate under-measures selection on a
@@ -1469,26 +1610,53 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
1469
1610
  parse,
1470
1611
  runError,
1471
1612
  };
1613
+ const lock = resolveLock(spec.lock);
1472
1614
  try {
1473
- const relevant = await runTriggerSet(spec.prompts, cfg, runner);
1474
- const base = {
1475
- rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
1476
- n: relevant.n,
1477
- perPrompt: relevant.perPrompt,
1478
- competitors,
1479
- errored: positiveOrUndefined(relevant.errored),
1480
- };
1481
- if ((spec.irrelevantPrompts?.length ?? 0) === 0)
1482
- return base;
1483
- const irrelevant = await runTriggerSet(spec.irrelevantPrompts ?? [], cfg, runner);
1484
- const fires = relevant.fired + irrelevant.fired;
1485
- return {
1486
- ...base,
1487
- errored: positiveOrUndefined(relevant.errored + irrelevant.errored),
1488
- falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
1489
- precision: fires > 0 ? relevant.fired / fires : undefined,
1490
- perIrrelevant: irrelevant.perPrompt,
1491
- };
1615
+ // The lock hashes the skill UNDER TEST (its dir contents) + the prompts +
1616
+ // model + tools — the inputs that steer whether it fires. `--check` replays
1617
+ // the committed report with no model; `--update` records it. Resolved only
1618
+ // when the lock is active. `trials` is excluded (a sample-size knob).
1619
+ const triggerInputs = lock.mode === "off"
1620
+ ? undefined
1621
+ : {
1622
+ pluginDirHash: (0, eval_cache_js_1.hashDir)(pluginDir),
1623
+ prompts: [...spec.prompts],
1624
+ irrelevantPrompts: spec.irrelevantPrompts
1625
+ ? [...spec.irrelevantPrompts]
1626
+ : undefined,
1627
+ model: cfg.model,
1628
+ tools: [...cfg.tools].sort(),
1629
+ fixture: spec.fixture,
1630
+ competitors,
1631
+ // The harness the driver runs is a model-facing input — a Claude vs
1632
+ // Codex run can fire a skill differently — so a recorded report is
1633
+ // STALE if the eval is switched to another harness.
1634
+ harness,
1635
+ };
1636
+ return await withEvalLock({ name: spec.name, inputs: triggerInputs, model: cfg.model, lock }, async () => {
1637
+ const relevant = await runTriggerSet(spec.prompts, cfg, runner);
1638
+ const base = {
1639
+ rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
1640
+ n: relevant.n,
1641
+ perPrompt: relevant.perPrompt,
1642
+ competitors,
1643
+ errored: positiveOrUndefined(relevant.errored),
1644
+ usage: aggregateUsage(relevant.usages),
1645
+ };
1646
+ if ((spec.irrelevantPrompts?.length ?? 0) === 0)
1647
+ return base;
1648
+ const irrelevant = await runTriggerSet(spec.irrelevantPrompts ?? [], cfg, runner);
1649
+ const fires = relevant.fired + irrelevant.fired;
1650
+ return {
1651
+ ...base,
1652
+ errored: positiveOrUndefined(relevant.errored + irrelevant.errored),
1653
+ falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
1654
+ precision: fires > 0 ? relevant.fired / fires : undefined,
1655
+ perIrrelevant: irrelevant.perPrompt,
1656
+ // Total cost across BOTH sets (the precision runs cost tokens too).
1657
+ usage: aggregateUsage([...relevant.usages, ...irrelevant.usages]),
1658
+ };
1659
+ });
1492
1660
  }
1493
1661
  finally {
1494
1662
  // Remove the throwaway plugin dir we built from a loose `skillsDir`.
@@ -1506,7 +1674,10 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
1506
1674
  */
1507
1675
  async function measureTriggerRate(spec, opts = {}) {
1508
1676
  const d = opts.evalDriver ?? exports.claudeEvalDriver;
1509
- return measureTriggerRateWith(spec, d.runner, d.parse, d.runError);
1677
+ const report = await measureTriggerRateWith(spec, d.runner, d.parse, d.runError, d.harness ?? "claude-code");
1678
+ // Surface what the run spent (tokens + API-equivalent $ + metered warning).
1679
+ (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromArm)(report.usage));
1680
+ return report;
1510
1681
  }
1511
1682
  /* v8 ignore stop */
1512
1683
  /** Format a trigger-rate report: overall %, then each prompt's rate. */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Research-index completeness — the deterministic FLOOR keeping the `research/`
3
+ * corpus and its index (`research/CLAUDE.md.spec.ts`) in sync. The spec's
4
+ * `keyFiles` map is the AGENT-FACING index of every research doc; the compiler
5
+ * already verifies the OTHER direction (every indexed path EXISTS, else
6
+ * `vigiles compile` fails), so the only open gap is a doc that was ADDED but
7
+ * never indexed. This check closes it: every `research/*.md` (except the
8
+ * human-facing `README.md`) must appear in the index, or the dogfood test fails.
9
+ *
10
+ * Pure — the caller supplies the doc filenames and the index content (the spec
11
+ * source, where an entry is AUTHORED), so it runs over the real `research/` dir
12
+ * in a test or over any file set. Bidirectional sync = compiler (index ⊆ docs)
13
+ * + this check (docs ⊆ index).
14
+ */
15
+ /** Docs that are the index itself / human front-door, not indexed entries. */
16
+ export declare const INDEX_EXEMPT: readonly ["README.md"];
17
+ /**
18
+ * Research doc basenames (e.g. `roadmap.md`) NOT referenced anywhere in
19
+ * `indexContent`. A doc counts as indexed if its repo-relative path
20
+ * (`research/<name>.md`) appears in the index — the exact form the spec's
21
+ * `keyFiles` keys use. Exempt docs (the README) are never flagged.
22
+ */
23
+ export declare function unindexedResearchDocs(docFilenames: readonly string[], indexContent: string, exempt?: readonly string[]): string[];
24
+ /**
25
+ * Index entries pointing at a `research/<name>.md` that no longer exists on
26
+ * disk. The compiler catches this at compile time (a missing `keyFiles` path is
27
+ * a compile error), so this is a belt-and-suspenders reader for a test that
28
+ * wants to assert it directly without invoking the compiler.
29
+ */
30
+ export declare function deadIndexEntries(docFilenames: readonly string[], indexContent: string): string[];
31
+ //# sourceMappingURL=research-index.d.ts.map
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ /**
3
+ * Research-index completeness — the deterministic FLOOR keeping the `research/`
4
+ * corpus and its index (`research/CLAUDE.md.spec.ts`) in sync. The spec's
5
+ * `keyFiles` map is the AGENT-FACING index of every research doc; the compiler
6
+ * already verifies the OTHER direction (every indexed path EXISTS, else
7
+ * `vigiles compile` fails), so the only open gap is a doc that was ADDED but
8
+ * never indexed. This check closes it: every `research/*.md` (except the
9
+ * human-facing `README.md`) must appear in the index, or the dogfood test fails.
10
+ *
11
+ * Pure — the caller supplies the doc filenames and the index content (the spec
12
+ * source, where an entry is AUTHORED), so it runs over the real `research/` dir
13
+ * in a test or over any file set. Bidirectional sync = compiler (index ⊆ docs)
14
+ * + this check (docs ⊆ index).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.INDEX_EXEMPT = void 0;
18
+ exports.unindexedResearchDocs = unindexedResearchDocs;
19
+ exports.deadIndexEntries = deadIndexEntries;
20
+ /** Docs that are the index itself / human front-door, not indexed entries. */
21
+ exports.INDEX_EXEMPT = ["README.md"];
22
+ /**
23
+ * Research doc basenames (e.g. `roadmap.md`) NOT referenced anywhere in
24
+ * `indexContent`. A doc counts as indexed if its repo-relative path
25
+ * (`research/<name>.md`) appears in the index — the exact form the spec's
26
+ * `keyFiles` keys use. Exempt docs (the README) are never flagged.
27
+ */
28
+ function unindexedResearchDocs(docFilenames, indexContent, exempt = exports.INDEX_EXEMPT) {
29
+ return docFilenames.filter((name) => !exempt.includes(name) && !indexContent.includes(`research/${name}`));
30
+ }
31
+ /**
32
+ * Index entries pointing at a `research/<name>.md` that no longer exists on
33
+ * disk. The compiler catches this at compile time (a missing `keyFiles` path is
34
+ * a compile error), so this is a belt-and-suspenders reader for a test that
35
+ * wants to assert it directly without invoking the compiler.
36
+ */
37
+ function deadIndexEntries(docFilenames, indexContent) {
38
+ const present = new Set(docFilenames);
39
+ const refs = indexContent.matchAll(/research\/([\w.-]+\.md)/g);
40
+ const dead = new Set();
41
+ for (const m of refs) {
42
+ const name = m[1];
43
+ if (!present.has(name))
44
+ dead.add(name);
45
+ }
46
+ return [...dead];
47
+ }
48
+ //# sourceMappingURL=research-index.js.map
@@ -135,6 +135,48 @@ export declare function measurePluginSelectionWith(dir: string, promptSet: Trigg
135
135
  export declare function measurePluginSelection(dir: string, promptSet: TriggerPromptSet, opts?: SelectionOptions): Promise<SelectionReport>;
136
136
  /** Format the selection-collision matrix as a scan-report section. */
137
137
  export declare function formatSelectionReport(r: SelectionReport): string;
138
+ /** Options for {@link measureSelectionMatrix}: {@link SelectionOptions} plus an
139
+ * optional explicit prompt set (auto-derived from descriptions when omitted). */
140
+ export interface SelectionMatrixOptions extends SelectionOptions {
141
+ /**
142
+ * Per-skill recall prompts. Omit for ZERO-SETUP — prompts are auto-derived
143
+ * from each skill's description (the same generator the audit trigger tier
144
+ * uses). Supply your own for a curated collision benchmark; only the `prompts`
145
+ * array per skill is read (any `irrelevant` bank is ignored here).
146
+ */
147
+ readonly prompts?: TriggerPromptSet;
148
+ }
149
+ /**
150
+ * Measure a plugin's skill-SELECTION collision matrix — "when I ask for skill i's
151
+ * job, does ONLY skill i fire?" The first-class, assertable form of the cross-skill
152
+ * collision measurement (pair with {@link assertNoCollision}). The matrix diagonal
153
+ * is recall; off-diagonal mass is collision — skill j hijacking skill i's prompt,
154
+ * the failure that breaks a multi-skill plugin and that per-skill trigger-rate
155
+ * (each skill in ISOLATION) structurally can't see.
156
+ *
157
+ * ZERO-SETUP: with no `prompts`, they're derived from each model-invocable skill's
158
+ * description. Claude Code only (Codex has no skill-selection event to read); needs
159
+ * the `claude` CLI + model auth, else `available: false`. Thin promotion of
160
+ * {@link measurePluginSelection}. See research/plugin-selection-collision.md.
161
+ */
162
+ export declare function measureSelectionMatrix(dir: string, opts?: SelectionMatrixOptions): Promise<SelectionReport>;
163
+ /**
164
+ * Injectable core of {@link measureSelectionMatrix} (for tests): auto-derive the
165
+ * prompts (unless supplied) and drive the matrix via a fake/real probe.
166
+ */
167
+ export declare function measureSelectionMatrixWith(dir: string, probe: HarnessProbe, opts?: SelectionMatrixOptions): Promise<SelectionReport>;
168
+ /**
169
+ * Assert a plugin's skills don't hijack each other — the gate over a
170
+ * {@link SelectionReport} from {@link measureSelectionMatrix}. `maxOffDiagonal`
171
+ * caps EACH skill's collision rate (fraction of its own prompts on which a SIBLING
172
+ * fired); `maxPluginCollision` caps the plugin-wide rate. With neither set it
173
+ * demands ZERO collision. THROWS (never a silent green) when nothing was measured
174
+ * — an unavailable harness or a zero-run report is a gap, not a pass.
175
+ */
176
+ export declare function assertNoCollision(report: SelectionReport, opts?: {
177
+ maxOffDiagonal?: number;
178
+ maxPluginCollision?: number;
179
+ }): void;
138
180
  /** Does a skill description assert a hard constraint (→ an adversarial-gate candidate)? */
139
181
  export declare function isGateDescription(description: string): boolean;
140
182
  /** A skill considered for gate detection — name + its (model-visible) description. */
@@ -22,6 +22,9 @@ exports.buildSelectionReport = buildSelectionReport;
22
22
  exports.measurePluginSelectionWith = measurePluginSelectionWith;
23
23
  exports.measurePluginSelection = measurePluginSelection;
24
24
  exports.formatSelectionReport = formatSelectionReport;
25
+ exports.measureSelectionMatrix = measureSelectionMatrix;
26
+ exports.measureSelectionMatrixWith = measureSelectionMatrixWith;
27
+ exports.assertNoCollision = assertNoCollision;
25
28
  exports.isGateDescription = isGateDescription;
26
29
  exports.detectGateSkills = detectGateSkills;
27
30
  exports.gateRubric = gateRubric;
@@ -33,6 +36,7 @@ const node_os_1 = require("node:os");
33
36
  const node_path_1 = require("node:path");
34
37
  const node_child_process_1 = require("node:child_process");
35
38
  const scan_js_1 = require("./scan.js");
39
+ const audit_prompts_js_1 = require("./audit-prompts.js");
36
40
  const judge_js_1 = require("./judge.js");
37
41
  const eval_js_1 = require("./eval.js");
38
42
  const harness_assert_js_1 = require("./harness-assert.js");
@@ -397,6 +401,69 @@ function formatSelectionReport(r) {
397
401
  }
398
402
  return lines.join("\n");
399
403
  }
404
+ /**
405
+ * Measure a plugin's skill-SELECTION collision matrix — "when I ask for skill i's
406
+ * job, does ONLY skill i fire?" The first-class, assertable form of the cross-skill
407
+ * collision measurement (pair with {@link assertNoCollision}). The matrix diagonal
408
+ * is recall; off-diagonal mass is collision — skill j hijacking skill i's prompt,
409
+ * the failure that breaks a multi-skill plugin and that per-skill trigger-rate
410
+ * (each skill in ISOLATION) structurally can't see.
411
+ *
412
+ * ZERO-SETUP: with no `prompts`, they're derived from each model-invocable skill's
413
+ * description. Claude Code only (Codex has no skill-selection event to read); needs
414
+ * the `claude` CLI + model auth, else `available: false`. Thin promotion of
415
+ * {@link measurePluginSelection}. See research/plugin-selection-collision.md.
416
+ */
417
+ async function measureSelectionMatrix(dir, opts = {}) {
418
+ return measurePluginSelection(dir, resolveSelectionPrompts(dir, opts), opts);
419
+ }
420
+ /**
421
+ * Injectable core of {@link measureSelectionMatrix} (for tests): auto-derive the
422
+ * prompts (unless supplied) and drive the matrix via a fake/real probe.
423
+ */
424
+ async function measureSelectionMatrixWith(dir, probe, opts = {}) {
425
+ return measurePluginSelectionWith(dir, resolveSelectionPrompts(dir, opts), probe, opts);
426
+ }
427
+ /** The prompts for a selection run: explicit if given, else auto-derived from the
428
+ * model-invocable skills' descriptions (the zero-setup path). */
429
+ function resolveSelectionPrompts(dir, opts) {
430
+ return (opts.prompts ??
431
+ (0, audit_prompts_js_1.autoTriggerPrompts)((0, scan_js_1.scanPlugin)(dir)
432
+ .skills.filter((s) => !s.userInvoked && s.hasDescription)
433
+ .map((s) => ({ name: s.name, description: s.description ?? "" }))));
434
+ }
435
+ /**
436
+ * Assert a plugin's skills don't hijack each other — the gate over a
437
+ * {@link SelectionReport} from {@link measureSelectionMatrix}. `maxOffDiagonal`
438
+ * caps EACH skill's collision rate (fraction of its own prompts on which a SIBLING
439
+ * fired); `maxPluginCollision` caps the plugin-wide rate. With neither set it
440
+ * demands ZERO collision. THROWS (never a silent green) when nothing was measured
441
+ * — an unavailable harness or a zero-run report is a gap, not a pass.
442
+ */
443
+ function assertNoCollision(report, opts = {}) {
444
+ if (!report.available)
445
+ throw new Error(`selection matrix unavailable — ${report.note ?? "n/a"}`);
446
+ if (report.n === 0)
447
+ throw new Error(`selection matrix measured nothing — ${report.note ?? "no runs"}`);
448
+ // Enforce the per-skill ceiling when asked, OR by default (name = NoCollision);
449
+ // if only the plugin-wide cap is given, don't also silently demand zero per-skill.
450
+ const maxOff = opts.maxOffDiagonal ??
451
+ (opts.maxPluginCollision === undefined ? 0 : undefined);
452
+ if (maxOff !== undefined) {
453
+ const worst = report.perSkill
454
+ .filter((s) => s.n > 0)
455
+ .reduce((w, s) => (w && w.collisionRate >= s.collisionRate ? w : s), undefined);
456
+ if (worst && worst.collisionRate > maxOff) {
457
+ const top = worst.collidesWith[0];
458
+ const tail = top ? ` (top collider: ${top.skill} ${pct(top.rate)})` : "";
459
+ throw new Error(`expected each skill's collision rate ≤ ${String(maxOff)}, but ${worst.skill} = ${worst.collisionRate.toFixed(2)}${tail}`);
460
+ }
461
+ }
462
+ if (opts.maxPluginCollision !== undefined &&
463
+ report.collisionRate > opts.maxPluginCollision) {
464
+ throw new Error(`expected plugin collision rate ≤ ${String(opts.maxPluginCollision)}, got ${report.collisionRate.toFixed(2)}`);
465
+ }
466
+ }
400
467
  // ─── Enforcement-gate detection (for the adversarial-gate eval) ───────────────
401
468
  //
402
469
  // A skill whose description states a HARD CONSTRAINT ("always write tests first",