vigiles 4.0.0 → 4.0.2

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 (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -18
  3. package/action.yml +1 -1
  4. package/dist/adapter-conformance.js +1 -1
  5. package/dist/adapters/claude-code/adapter.js +1 -1
  6. package/dist/check.d.ts +132 -0
  7. package/dist/check.js +318 -0
  8. package/dist/claude-code.d.ts +1 -1
  9. package/dist/claude-code.js +1 -1
  10. package/dist/cli.js +133 -55
  11. package/dist/core/compile.d.ts +1 -1
  12. package/dist/core/compile.js +1 -1
  13. package/dist/core/compose.d.ts +1 -1
  14. package/dist/core/compose.js +1 -1
  15. package/dist/core/generate-schema.d.ts +1 -1
  16. package/dist/core/generate-schema.js +4 -4
  17. package/dist/core/linters.js +2 -2
  18. package/dist/core/orphans.js +57 -14
  19. package/dist/core/proofs.js +1 -1
  20. package/dist/core/refs.d.ts +1 -1
  21. package/dist/core/refs.js +2 -2
  22. package/dist/core/sidecar.d.ts +1 -1
  23. package/dist/core/sidecar.js +1 -1
  24. package/dist/core/spec.d.ts +1 -1
  25. package/dist/core/spec.js +1 -1
  26. package/dist/core/types.d.ts +1 -1
  27. package/dist/core/validate.js +2 -2
  28. package/dist/e2e.d.ts +10 -13
  29. package/dist/e2e.js +10 -17
  30. package/dist/{adapters/claude-code/eval-cache.d.ts → eval-cache.d.ts} +1 -1
  31. package/dist/{adapters/claude-code/eval-cache.js → eval-cache.js} +1 -1
  32. package/dist/{adapters/claude-code/eval.d.ts → eval.d.ts} +217 -2
  33. package/dist/{adapters/claude-code/eval.js → eval.js} +430 -20
  34. package/dist/harness-assert.d.ts +11 -8
  35. package/dist/harness-assert.js +21 -5
  36. package/dist/{adapters/claude-code/harness-test.d.ts → harness-test.d.ts} +50 -4
  37. package/dist/{adapters/claude-code/harness-test.js → harness-test.js} +106 -4
  38. package/dist/integration.d.ts +11 -3
  39. package/dist/integration.js +12 -3
  40. package/dist/jest.d.ts +3 -1
  41. package/dist/jest.js +3 -2
  42. package/dist/{adapters/claude-code/mock-model.d.ts → mock-model.d.ts} +2 -2
  43. package/dist/{adapters/claude-code/run-hook.d.ts → run-hook.d.ts} +23 -1
  44. package/dist/{adapters/claude-code/run-hook.js → run-hook.js} +29 -1
  45. package/dist/{adapters/claude-code/sandbox.js → sandbox.js} +1 -1
  46. package/dist/scan.d.ts +1 -1
  47. package/dist/scan.js +1 -1
  48. package/dist/setup-plan.d.ts +5 -1
  49. package/dist/setup-plan.js +11 -1
  50. package/dist/test-coverage.js +8 -1
  51. package/dist/testing.d.ts +10 -3
  52. package/dist/testing.js +15 -3
  53. package/dist/unit.d.ts +4 -2
  54. package/dist/unit.js +8 -2
  55. package/dist/vitest.d.mts +3 -1
  56. package/hooks/refs-nudge.sh +1 -1
  57. package/hooks/session-start.sh +3 -3
  58. package/package.json +8 -7
  59. package/skills/edit-spec/SKILL.md +21 -10
  60. package/skills/linter-docs/SKILL.md +23 -0
  61. package/skills/migrate-to-spec/SKILL.md +1 -1
  62. package/skills/strengthen/SKILL.md +1 -2
  63. package/skills/generate-rule/SKILL.md +0 -64
  64. /package/dist/{adapters/claude-code/egress-entry.d.ts → egress-entry.d.ts} +0 -0
  65. /package/dist/{adapters/claude-code/egress-entry.js → egress-entry.js} +0 -0
  66. /package/dist/{adapters/claude-code/egress-proxy.d.ts → egress-proxy.d.ts} +0 -0
  67. /package/dist/{adapters/claude-code/egress-proxy.js → egress-proxy.js} +0 -0
  68. /package/dist/{adapters/claude-code/egress.d.ts → egress.d.ts} +0 -0
  69. /package/dist/{adapters/claude-code/egress.js → egress.js} +0 -0
  70. /package/dist/{adapters/claude-code/eval-baseline.d.ts → eval-baseline.d.ts} +0 -0
  71. /package/dist/{adapters/claude-code/eval-baseline.js → eval-baseline.js} +0 -0
  72. /package/dist/{adapters/claude-code/judge.d.ts → judge.d.ts} +0 -0
  73. /package/dist/{adapters/claude-code/judge.js → judge.js} +0 -0
  74. /package/dist/{adapters/claude-code/mock-entry.d.ts → mock-entry.d.ts} +0 -0
  75. /package/dist/{adapters/claude-code/mock-entry.js → mock-entry.js} +0 -0
  76. /package/dist/{adapters/claude-code/mock-model.js → mock-model.js} +0 -0
  77. /package/dist/{adapters/claude-code/sandbox.d.ts → sandbox.d.ts} +0 -0
  78. /package/dist/{adapters/claude-code/stats.d.ts → stats.d.ts} +0 -0
  79. /package/dist/{adapters/claude-code/stats.js → stats.js} +0 -0
@@ -1,6 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runEval = runEval;
4
+ exports.measureWith = measureWith;
5
+ exports.measure = measure;
6
+ exports.measureArmsWith = measureArmsWith;
7
+ exports.measureArms = measureArms;
8
+ exports.compareCheck = compareCheck;
9
+ exports.formatCheckReport = formatCheckReport;
10
+ exports.assertRates = assertRates;
11
+ exports.checkReportToJUnit = checkReportToJUnit;
4
12
  exports.parseUsage = parseUsage;
5
13
  exports.aggregate = aggregate;
6
14
  exports.aggregateStats = aggregateStats;
@@ -9,6 +17,12 @@ exports.isRateLimited = isRateLimited;
9
17
  exports.runPool = runPool;
10
18
  exports.runEvalWith = runEvalWith;
11
19
  exports.formatEvalReport = formatEvalReport;
20
+ exports.packageSkillsDir = packageSkillsDir;
21
+ exports.stubSkillBody = stubSkillBody;
22
+ exports.stubbedPluginDir = stubbedPluginDir;
23
+ exports.promptDistance = promptDistance;
24
+ exports.checkPromptDiversity = checkPromptDiversity;
25
+ exports.assertPromptDiversity = assertPromptDiversity;
12
26
  exports.measureTriggerRateWith = measureTriggerRateWith;
13
27
  exports.measureTriggerRate = measureTriggerRate;
14
28
  exports.formatTriggerRateReport = formatTriggerRateReport;
@@ -39,10 +53,12 @@ const node_child_process_1 = require("node:child_process");
39
53
  const node_fs_1 = require("node:fs");
40
54
  const node_os_1 = require("node:os");
41
55
  const node_path_1 = require("node:path");
42
- const plugin_loader_js_1 = require("./plugin-loader.js");
43
- const runtime_js_1 = require("./runtime.js");
56
+ const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
57
+ const runtime_js_1 = require("./adapters/claude-code/runtime.js");
58
+ const proofs_js_1 = require("./core/proofs.js");
44
59
  const harness_test_js_1 = require("./harness-test.js");
45
60
  const eval_cache_js_1 = require("./eval-cache.js");
61
+ const stats_js_1 = require("./stats.js");
46
62
  function writeFiles(cwd, files) {
47
63
  for (const [p, content] of Object.entries(files)) {
48
64
  const full = (0, node_path_1.resolve)(cwd, p);
@@ -98,7 +114,207 @@ function spawnAgent(a) {
98
114
  async function runEval(spec) {
99
115
  return runEvalWith(spec, spawnAgent);
100
116
  }
117
+ /**
118
+ * Score a check vocabulary across trials — the scored counterpart to
119
+ * `assertChecks` (strict). Each check yields a `rate ± se` and `pass^k` over `n`
120
+ * runs. Reuses the tested `runEvalWith` aggregation (one arm), so the loop,
121
+ * cache, concurrency, and stats come for free. Exported with an injectable
122
+ * `runner` so the orchestration is unit-testable without a model.
123
+ */
124
+ async function measureWith(spec, runner) {
125
+ if (spec.stubSkillBodies && !spec.pluginDir)
126
+ throw new Error("measure: `stubSkillBodies` requires `pluginDir`.");
127
+ const stubbed = spec.stubSkillBodies
128
+ ? stubbedPluginDir(spec.pluginDir)
129
+ : undefined;
130
+ const pluginDir = stubbed ?? spec.pluginDir;
131
+ try {
132
+ const keyed = spec.checks.map((c, i) => [`c${String(i)}`, c]);
133
+ const report = await runEvalWith({
134
+ fixture: spec.fixture,
135
+ arms: {
136
+ run: {
137
+ settings: spec.settings,
138
+ plugin: spec.plugin,
139
+ pluginDir,
140
+ },
141
+ },
142
+ task: spec.task,
143
+ trials: spec.trials ?? 5,
144
+ model: spec.model ?? "sonnet",
145
+ allowedTools: spec.allowedTools,
146
+ timeoutMs: spec.timeoutMs,
147
+ spacingSec: spec.spacingSec,
148
+ measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
149
+ }, runner);
150
+ const arm = report.arms.run;
151
+ return {
152
+ n: arm?.runs ?? 0,
153
+ perCheck: keyed.map(([k, c]) => {
154
+ const s = arm?.stats[k];
155
+ return {
156
+ check: c.toJSON(),
157
+ rate: s?.mean ?? 0,
158
+ se: s?.se ?? 0,
159
+ passK: s?.passK ?? 0,
160
+ n: s?.n ?? 0,
161
+ };
162
+ }),
163
+ };
164
+ }
165
+ finally {
166
+ if (stubbed)
167
+ (0, node_fs_1.rmSync)(stubbed, { recursive: true, force: true });
168
+ }
169
+ }
170
+ /* v8 ignore start -- real claude subprocess; thin wrapper over measureWith */
171
+ /** Score a check vocabulary across trials against the real `claude` CLI. */
172
+ async function measure(spec) {
173
+ return measureWith(spec, spawnAgent);
174
+ }
175
+ /** Score checks across arms (injectable runner). Reuses `runEvalWith`. */
176
+ async function measureArmsWith(spec, runner) {
177
+ const keyed = spec.checks.map((c, i) => [`c${String(i)}`, c]);
178
+ const { arms: runArms, temps } = spec.stubSkillBodies
179
+ ? stubArmPluginDirs(spec.arms)
180
+ : { arms: spec.arms, temps: [] };
181
+ try {
182
+ const report = await runEvalWith({
183
+ fixture: spec.fixture,
184
+ arms: runArms,
185
+ task: spec.task,
186
+ trials: spec.trials ?? 5,
187
+ model: spec.model ?? "sonnet",
188
+ allowedTools: spec.allowedTools,
189
+ timeoutMs: spec.timeoutMs,
190
+ spacingSec: spec.spacingSec,
191
+ measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
192
+ }, runner);
193
+ const arms = {};
194
+ for (const [armName, arm] of Object.entries(report.arms)) {
195
+ arms[armName] = {
196
+ n: arm.runs,
197
+ perCheck: keyed.map(([k, c]) => {
198
+ const s = arm.stats[k];
199
+ return {
200
+ check: c.toJSON(),
201
+ rate: s?.mean ?? 0,
202
+ se: s?.se ?? 0,
203
+ passK: s?.passK ?? 0,
204
+ n: s?.n ?? 0,
205
+ };
206
+ }),
207
+ };
208
+ }
209
+ return { arms };
210
+ }
211
+ finally {
212
+ for (const t of temps)
213
+ (0, node_fs_1.rmSync)(t, { recursive: true, force: true });
214
+ }
215
+ }
216
+ /**
217
+ * Repackage every arm that sets a `pluginDir` with its skill bodies stubbed
218
+ * (frontmatter kept), for an A/B firing comparison. Returns the rewritten arms
219
+ * plus the throwaway dirs the caller must remove. Arms without a `pluginDir` pass
220
+ * through unchanged. See {@link stubbedPluginDir}.
221
+ */
222
+ function stubArmPluginDirs(arms) {
223
+ const out = {};
224
+ const temps = [];
225
+ for (const [name, arm] of Object.entries(arms)) {
226
+ if (arm.pluginDir) {
227
+ const stubbed = stubbedPluginDir(arm.pluginDir);
228
+ temps.push(stubbed);
229
+ out[name] = { ...arm, pluginDir: stubbed };
230
+ }
231
+ else {
232
+ out[name] = arm;
233
+ }
234
+ }
235
+ return { arms: out, temps };
236
+ }
237
+ /* v8 ignore start -- real claude subprocess; thin wrapper over measureArmsWith */
238
+ /** Score checks across arms against the real `claude` CLI. */
239
+ async function measureArms(spec) {
240
+ return measureArmsWith(spec, spawnAgent);
241
+ }
101
242
  /* v8 ignore stop */
243
+ /**
244
+ * Welch significance on one check's rate between two arms (`arm` vs `baseline`),
245
+ * by index in `perCheck`. So "the gated arm resolves the skill significantly more
246
+ * than vanilla" is a p-value, not a vibe. Reuses `welchTTest` from stats.ts.
247
+ */
248
+ function compareCheck(report, baseline, arm, checkIndex) {
249
+ const b = report.arms[baseline]?.perCheck[checkIndex];
250
+ const a = report.arms[arm]?.perCheck[checkIndex];
251
+ if (!a || !b) {
252
+ throw new Error(`compareCheck: unknown arm or check index (baseline="${baseline}", arm="${arm}", i=${String(checkIndex)})`);
253
+ }
254
+ return (0, stats_js_1.welchTTest)({ mean: a.rate, se: a.se, n: a.n }, { mean: b.rate, se: b.se, n: b.n });
255
+ }
256
+ /** A readable label for a check from its serialized form, e.g. `tool(Bash)`. */
257
+ function checkLabel(json) {
258
+ const arg = json.name ?? json.id ?? json.event ?? json.path ?? json.matcher;
259
+ if (typeof arg === "string" ||
260
+ typeof arg === "number" ||
261
+ typeof arg === "boolean") {
262
+ return `${json.kind}(${String(arg)})`;
263
+ }
264
+ return json.kind;
265
+ }
266
+ /** Format a {@link CheckReport}: one line per check with its rate ± se and pass^k. */
267
+ function formatCheckReport(report) {
268
+ const lines = [`measured ${String(report.n)} run(s):`];
269
+ for (const c of report.perCheck) {
270
+ lines.push(` ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}% ${checkLabel(c.check)}` +
271
+ ` (pass^k ${String(c.passK)})`);
272
+ }
273
+ return lines.join("\n");
274
+ }
275
+ /**
276
+ * The scored gate (Phase 4): throw if any check's measured rate is below `min` —
277
+ * the `measure` counterpart to `assertChecks` (strict). Reads the rate, not a
278
+ * single run, so it never trips on one noisy trial.
279
+ */
280
+ function assertRates(report, opts) {
281
+ const below = report.perCheck.filter((c) => c.rate < opts.min);
282
+ if (below.length > 0) {
283
+ throw new Error(`${String(below.length)} check(s) below the ${(opts.min * 100).toFixed(0)}% min rate:\n` +
284
+ below
285
+ .map((c) => ` ✗ ${checkLabel(c.check)}: ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}%`)
286
+ .join("\n"));
287
+ }
288
+ }
289
+ function escapeXml(s) {
290
+ return s
291
+ .replace(/&/g, "&amp;")
292
+ .replace(/</g, "&lt;")
293
+ .replace(/>/g, "&gt;")
294
+ .replace(/"/g, "&quot;");
295
+ }
296
+ /**
297
+ * Serialize a {@link CheckReport} to JUnit XML (Phase 4) — each check a
298
+ * `<testcase>`, failing when its rate is below `min`. Because a check is *data*,
299
+ * this falls out for free: CI test reporters, regression baselines, and a
300
+ * promptfoo bridge all consume the same shape.
301
+ */
302
+ function checkReportToJUnit(report, opts = {}) {
303
+ const min = opts.min ?? 0;
304
+ const failures = report.perCheck.filter((c) => c.rate < min).length;
305
+ const cases = report.perCheck
306
+ .map((c) => {
307
+ const name = escapeXml(checkLabel(c.check));
308
+ const body = c.rate < min
309
+ ? `\n <failure message="rate ${(c.rate * 100).toFixed(0)}% below min ${(min * 100).toFixed(0)}% (n=${String(c.n)})"/>\n `
310
+ : "";
311
+ return ` <testcase classname="vigiles.checks" name="${name}">${body}</testcase>`;
312
+ })
313
+ .join("\n");
314
+ return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
315
+ `<testsuite name="${escapeXml(opts.name ?? "vigiles measure")}" tests="${String(report.perCheck.length)}" failures="${String(failures)}">\n` +
316
+ `${cases}\n</testsuite>\n`);
317
+ }
102
318
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
103
319
  /** Pull cost / latency / tokens out of a parsed `result` event (0 when absent). */
104
320
  function usageFrom(result) {
@@ -127,6 +343,7 @@ function makeContext(cwd, out) {
127
343
  toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
128
344
  hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
129
345
  output,
346
+ subagents: (0, harness_test_js_1.parseSubagents)(out.stdout),
130
347
  usage: usageFrom(result),
131
348
  // The eval tier drives the real API (no mock between claude and the model),
132
349
  // so the requests can't be captured here — modelRequests is harness-tier only.
@@ -144,7 +361,7 @@ function makeContext(cwd, out) {
144
361
  }).trim();
145
362
  }
146
363
  catch (e) {
147
- // Return captured stdout even on a non-zero exit (e.g. `audit` exits 2
364
+ // Return captured stdout even on a non-zero exit (e.g. `lint` exits 2
148
365
  // but still prints its findings), rather than swallowing it.
149
366
  const out = e.stdout;
150
367
  return typeof out === "string" ? out.trim() : "";
@@ -402,6 +619,178 @@ function formatEvalReport(report) {
402
619
  }
403
620
  return lines.join("\n");
404
621
  }
622
+ /**
623
+ * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
624
+ * that `claude --plugin-dir` accepts — so repo-local skills (e.g. `.claude/skills`)
625
+ * can be trigger-tested without hand-rolling a `plugin.json`. Writes a minimal
626
+ * `.claude-plugin/plugin.json` and copies each `<name>/` (recursively, so
627
+ * `references/` etc. come along) under `skills/<name>/`. Returns the temp plugin
628
+ * dir; the caller removes it (`measureTriggerRate` does). Throws if the directory
629
+ * is missing or holds no `<name>/SKILL.md`.
630
+ */
631
+ function packageSkillsDir(skillsDir, opts = {}) {
632
+ const abs = (0, node_path_1.resolve)(skillsDir);
633
+ if (!(0, node_fs_1.existsSync)(abs))
634
+ throw new Error(`skillsDir not found: ${skillsDir} (resolved ${abs})`);
635
+ const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-skills-"));
636
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
637
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name ?? "vigiles-loose-skills", version: "0.0.0" }, null, 2));
638
+ const skillsOut = (0, node_path_1.join)(root, "skills");
639
+ (0, node_fs_1.mkdirSync)(skillsOut, { recursive: true });
640
+ let copied = 0;
641
+ for (const entry of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
642
+ if (!entry.isDirectory())
643
+ continue;
644
+ const srcSkill = (0, node_path_1.join)(abs, entry.name, "SKILL.md");
645
+ if (!(0, node_fs_1.existsSync)(srcSkill))
646
+ continue;
647
+ const destDir = (0, node_path_1.join)(skillsOut, entry.name);
648
+ if (opts.stub) {
649
+ // Frontmatter-only: keep the trigger surface (name + description), drop the
650
+ // body so a selected skill stops instead of running its procedure.
651
+ (0, node_fs_1.mkdirSync)(destDir, { recursive: true });
652
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(destDir, "SKILL.md"), stubSkillBody((0, node_fs_1.readFileSync)(srcSkill, "utf-8")));
653
+ }
654
+ else {
655
+ (0, node_fs_1.cpSync)((0, node_path_1.join)(abs, entry.name), destDir, { recursive: true });
656
+ }
657
+ copied++;
658
+ }
659
+ if (copied === 0) {
660
+ (0, node_fs_1.rmSync)(root, { recursive: true, force: true });
661
+ throw new Error(`No <name>/SKILL.md skills found under ${skillsDir}`);
662
+ }
663
+ return root;
664
+ }
665
+ /**
666
+ * Rewrite a SKILL.md to keep its YAML frontmatter (the trigger surface — name +
667
+ * description) but replace the body with a no-op stub. Trigger-rate is a property
668
+ * of the frontmatter ONLY: the model picks a skill from its name + description
669
+ * before the body is ever loaded, so the body is causally downstream of selection
670
+ * and irrelevant to whether the skill fires. Stubbing it lets a trigger run stop
671
+ * AT selection instead of executing an expensive multi-step procedure — cheaper,
672
+ * faster, and side-effect-free, without changing what's measured. Pure.
673
+ */
674
+ function stubSkillBody(skillMd) {
675
+ const m = /^(---\n[\s\S]*?\n---\n)/.exec(skillMd);
676
+ const frontmatter = m ? m[1] : "";
677
+ return `${frontmatter}\nThis skill was selected (trigger-test stub). Acknowledge and stop — do not perform any actions.\n`;
678
+ }
679
+ /** The skills directory inside a plugin (`skills/` or `.claude/skills/`). */
680
+ function skillsDirOf(pluginDir) {
681
+ const direct = (0, node_path_1.join)(pluginDir, "skills");
682
+ if ((0, node_fs_1.existsSync)(direct))
683
+ return direct;
684
+ return (0, node_path_1.join)(pluginDir, ".claude", "skills");
685
+ }
686
+ /** A plugin's declared name (from `.claude-plugin/plugin.json`), for the skill
687
+ * id the `fired` predicate matches (`<name>:<skill>`). */
688
+ function pluginName(pluginDir) {
689
+ const manifest = (0, node_path_1.join)(pluginDir, ".claude-plugin", "plugin.json");
690
+ try {
691
+ return JSON.parse((0, node_fs_1.readFileSync)(manifest, "utf-8"))
692
+ .name;
693
+ }
694
+ catch {
695
+ return undefined;
696
+ }
697
+ }
698
+ /**
699
+ * Build a throwaway plugin dir mirroring `pluginDir`'s skills with their BODIES
700
+ * stripped (frontmatter kept) — the trigger surface a description/firing check
701
+ * needs, without paying to run each skill's procedure. Keeps the original plugin
702
+ * NAME so `<name>:<skill>` ids still match. The caller removes the returned dir.
703
+ * See {@link stubSkillBody} for why the body is irrelevant to selection.
704
+ */
705
+ function stubbedPluginDir(pluginDir) {
706
+ return packageSkillsDir(skillsDirOf(pluginDir), {
707
+ stub: true,
708
+ name: pluginName(pluginDir),
709
+ });
710
+ }
711
+ // ---------------------------------------------------------------------------
712
+ // Prompt-set diversity (deterministic, pre-eval) — a trigger rate is only
713
+ // meaningful over ENOUGH and DIFFERENT prompts. Catch a too-small or
714
+ // near-duplicate set before spending a single model token. We measure
715
+ // "different" with Normalized Compression Distance (gzip) — the SAME engine
716
+ // `findSimilarRules` uses for near-duplicate rule detection — not edit
717
+ // distance: NCD scores shared structure/redundancy (a templated prompt with one
718
+ // word swapped compresses together), which is exactly the lazy-copy-paste set
719
+ // we want to reject, and it's the project's house algorithm for "are these two
720
+ // texts basically the same".
721
+ // ---------------------------------------------------------------------------
722
+ /** Normalize for comparison: lowercase, trim, collapse whitespace. */
723
+ function normalizePrompt(s) {
724
+ return s.toLowerCase().trim().replace(/\s+/g, " ");
725
+ }
726
+ /**
727
+ * Distance between two prompts in ~0..1 (0 = identical, higher = more
728
+ * different) via Normalized Compression Distance over the normalized text.
729
+ * Reuses {@link ncd} from the proof engine.
730
+ */
731
+ function promptDistance(a, b) {
732
+ return (0, proofs_js_1.ncd)(normalizePrompt(a), normalizePrompt(b));
733
+ }
734
+ /**
735
+ * Deterministically check a prompt set is big and varied enough to measure a
736
+ * trigger rate: at least `minPrompts` entries, and no two closer than
737
+ * `minDistance` in NCD. Pure — no model. `label` names the set in messages.
738
+ */
739
+ function checkPromptDiversity(prompts, opts = {}) {
740
+ const minPrompts = opts.minPrompts ?? 10;
741
+ const minDistance = opts.minDistance ?? 0.3;
742
+ const label = opts.label ?? "prompts";
743
+ const issues = [];
744
+ if (prompts.length < minPrompts) {
745
+ issues.push({
746
+ kind: "too-few",
747
+ message: `${label}: ${String(prompts.length)} prompt(s), need at least ${String(minPrompts)} to measure a rate (set minPrompts to override).`,
748
+ });
749
+ }
750
+ for (let i = 0; i < prompts.length; i++) {
751
+ for (let j = i + 1; j < prompts.length; j++) {
752
+ const dist = promptDistance(prompts[i], prompts[j]);
753
+ if (dist < minDistance) {
754
+ issues.push({
755
+ kind: "too-similar",
756
+ message: `${label}: prompts #${String(i + 1)} and #${String(j + 1)} are near-duplicates (NCD ${dist.toFixed(2)} < ${String(minDistance)}) — vary the phrasing:\n - ${prompts[i]}\n - ${prompts[j]}`,
757
+ });
758
+ }
759
+ }
760
+ }
761
+ return issues;
762
+ }
763
+ /** Throw if a prompt set isn't big/varied enough. See {@link checkPromptDiversity}. */
764
+ function assertPromptDiversity(prompts, opts = {}) {
765
+ const issues = checkPromptDiversity(prompts, opts);
766
+ if (issues.length > 0) {
767
+ throw new Error(`Prompt set is not eval-ready:\n ${issues.map((i) => i.message).join("\n ")}`);
768
+ }
769
+ }
770
+ /**
771
+ * Resolve the effective `--plugin-dir` for a trigger run: a caller's `pluginDir`
772
+ * as-is, or a throwaway package built from a loose `skillsDir`. Exactly one must
773
+ * be set. `packaged` is present only when vigiles built it, so the caller knows
774
+ * to remove it afterward.
775
+ */
776
+ function resolveTriggerPluginDir(spec) {
777
+ if (spec.pluginDir && spec.skillsDir)
778
+ throw new Error("measureTriggerRate: set `pluginDir` OR `skillsDir`, not both.");
779
+ const stub = spec.stubSkillBodies ?? false;
780
+ if (spec.skillsDir) {
781
+ const packaged = packageSkillsDir(spec.skillsDir, { stub });
782
+ return { pluginDir: packaged, packaged };
783
+ }
784
+ if (spec.pluginDir) {
785
+ if (!stub)
786
+ return { pluginDir: spec.pluginDir };
787
+ // Stub a real plugin: build a minimal plugin from its skills/ with bodies
788
+ // stripped — keep the original plugin NAME so `<name>:<skill>` still matches.
789
+ const packaged = stubbedPluginDir(spec.pluginDir);
790
+ return { pluginDir: packaged, packaged };
791
+ }
792
+ throw new Error("measureTriggerRate: provide `pluginDir` or `skillsDir`.");
793
+ }
405
794
  /** Run one prompt set × trials through `runner`, aggregating fired counts. */
406
795
  async function runTriggerSet(prompts, cfg, runner) {
407
796
  const perPrompt = [];
@@ -449,31 +838,52 @@ async function runTriggerSet(prompts, cfg, runner) {
449
838
  * `measureTriggerRate` is this with the real agent runner.
450
839
  */
451
840
  async function measureTriggerRateWith(spec, runner) {
841
+ // Deterministic gate FIRST — reject a too-small / near-duplicate prompt set
842
+ // before spending a token (and before packaging a skillsDir).
843
+ const diversity = {
844
+ minPrompts: spec.minPrompts,
845
+ minDistance: spec.minDistance,
846
+ };
847
+ assertPromptDiversity(spec.prompts, { ...diversity, label: "prompts" });
848
+ if (spec.irrelevantPrompts && spec.irrelevantPrompts.length > 0) {
849
+ assertPromptDiversity(spec.irrelevantPrompts, {
850
+ ...diversity,
851
+ label: "irrelevantPrompts",
852
+ });
853
+ }
854
+ const { pluginDir, packaged } = resolveTriggerPluginDir(spec);
452
855
  const cfg = {
453
856
  trials: spec.trials ?? 1,
454
857
  model: spec.model ?? "haiku",
455
858
  tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"],
456
859
  timeoutMs: spec.timeoutMs ?? 240000,
457
860
  spacing: (spec.spacingSec ?? 4) * 1000,
458
- pluginDir: spec.pluginDir,
861
+ pluginDir,
459
862
  fired: spec.fired,
460
863
  };
461
- const relevant = await runTriggerSet(spec.prompts, cfg, runner);
462
- const base = {
463
- rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
464
- n: relevant.n,
465
- perPrompt: relevant.perPrompt,
466
- };
467
- if ((spec.irrelevantPrompts?.length ?? 0) === 0)
468
- return base;
469
- const irrelevant = await runTriggerSet(spec.irrelevantPrompts ?? [], cfg, runner);
470
- const fires = relevant.fired + irrelevant.fired;
471
- return {
472
- ...base,
473
- falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
474
- precision: fires > 0 ? relevant.fired / fires : undefined,
475
- perIrrelevant: irrelevant.perPrompt,
476
- };
864
+ try {
865
+ const relevant = await runTriggerSet(spec.prompts, cfg, runner);
866
+ const base = {
867
+ rate: relevant.n > 0 ? relevant.fired / relevant.n : 0,
868
+ n: relevant.n,
869
+ perPrompt: relevant.perPrompt,
870
+ };
871
+ if ((spec.irrelevantPrompts?.length ?? 0) === 0)
872
+ return base;
873
+ const irrelevant = await runTriggerSet(spec.irrelevantPrompts ?? [], cfg, runner);
874
+ const fires = relevant.fired + irrelevant.fired;
875
+ return {
876
+ ...base,
877
+ falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
878
+ precision: fires > 0 ? relevant.fired / fires : undefined,
879
+ perIrrelevant: irrelevant.perPrompt,
880
+ };
881
+ }
882
+ finally {
883
+ // Remove the throwaway plugin dir we built from a loose `skillsDir`.
884
+ if (packaged)
885
+ (0, node_fs_1.rmSync)(packaged, { recursive: true, force: true });
886
+ }
477
887
  }
478
888
  /* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
479
889
  /**
@@ -13,16 +13,17 @@
13
13
  * `expect(...).toHaveCreated(...)` sugar. The signature is identical for
14
14
  * vitest and jest, so the same object supports both.
15
15
  */
16
- import { type HarnessTestSpec, type HarnessTestResult, type ToolCall, type Trace } from "./adapters/claude-code/harness-test.js";
17
- import type { EvalReport, TriggerRateReport } from "./adapters/claude-code/eval.js";
18
- import type { HookRunResult, EgressAttempt } from "./adapters/claude-code/run-hook.js";
16
+ import { type HarnessTestSpec, type HarnessTestResult, type ToolCall, type Trace } from "./harness-test.js";
17
+ import type { EvalReport, TriggerRateReport } from "./eval.js";
18
+ import type { HookRunResult, EgressAttempt } from "./run-hook.js";
19
+ import { type Check } from "./check.js";
19
20
  import type { OutputContract } from "./core/spec.js";
20
21
  import { type ParsedAgentResult } from "./adapters/claude-code/agent-result.js";
21
- import { type BaselineFile, type DiffOptions } from "./adapters/claude-code/eval-baseline.js";
22
- export { compareArms } from "./adapters/claude-code/stats.js";
23
- export type { Comparison } from "./adapters/claude-code/stats.js";
24
- export { diffReports, toBaselineFile, parseBaselineFile, readBaseline, writeBaseline, formatBaselineDiff, diffToJUnit, } from "./adapters/claude-code/eval-baseline.js";
25
- export type { BaselineFile, BaselineDiff, MetricDiff, DiffStatus, DiffOptions, } from "./adapters/claude-code/eval-baseline.js";
22
+ import { type BaselineFile, type DiffOptions } from "./eval-baseline.js";
23
+ export { compareArms } from "./stats.js";
24
+ export type { Comparison } from "./stats.js";
25
+ export { diffReports, toBaselineFile, parseBaselineFile, readBaseline, writeBaseline, formatBaselineDiff, diffToJUnit, } from "./eval-baseline.js";
26
+ export type { BaselineFile, BaselineDiff, MetricDiff, DiffStatus, DiffOptions, } from "./eval-baseline.js";
26
27
  /**
27
28
  * Run a harness test, hand the result to `fn`, and always clean up the sandbox.
28
29
  * Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
@@ -282,5 +283,7 @@ export declare const vigilesMatchers: {
282
283
  toHaveCreated(received: HarnessTestResult, path: string): MatcherOutput;
283
284
  toBlock(received: HookRunResult): MatcherOutput;
284
285
  toBeatBaseline(received: EvalReport, baseline: string, arm: string, metric: string, by?: number): MatcherOutput;
286
+ toPass<T>(received: T, check: Check<T>): MatcherOutput;
287
+ toPassAll<T>(received: T, checks: readonly Check<T>[]): MatcherOutput;
285
288
  };
286
289
  //# sourceMappingURL=harness-assert.d.ts.map
@@ -57,15 +57,16 @@ exports.assertTriggerRate = assertTriggerRate;
57
57
  * `expect(...).toHaveCreated(...)` sugar. The signature is identical for
58
58
  * vitest and jest, so the same object supports both.
59
59
  */
60
- const harness_test_js_1 = require("./adapters/claude-code/harness-test.js");
60
+ const harness_test_js_1 = require("./harness-test.js");
61
+ const check_js_1 = require("./check.js");
61
62
  const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
62
- const stats_js_1 = require("./adapters/claude-code/stats.js");
63
- const eval_baseline_js_1 = require("./adapters/claude-code/eval-baseline.js");
63
+ const stats_js_1 = require("./stats.js");
64
+ const eval_baseline_js_1 = require("./eval-baseline.js");
64
65
  // Re-export the significance primitives so the whole eval-analysis surface lives
65
66
  // behind `vigiles/harness-assert` (no separate entry point).
66
- var stats_js_2 = require("./adapters/claude-code/stats.js");
67
+ var stats_js_2 = require("./stats.js");
67
68
  Object.defineProperty(exports, "compareArms", { enumerable: true, get: function () { return stats_js_2.compareArms; } });
68
- var eval_baseline_js_2 = require("./adapters/claude-code/eval-baseline.js");
69
+ var eval_baseline_js_2 = require("./eval-baseline.js");
69
70
  Object.defineProperty(exports, "diffReports", { enumerable: true, get: function () { return eval_baseline_js_2.diffReports; } });
70
71
  Object.defineProperty(exports, "toBaselineFile", { enumerable: true, get: function () { return eval_baseline_js_2.toBaselineFile; } });
71
72
  Object.defineProperty(exports, "parseBaselineFile", { enumerable: true, get: function () { return eval_baseline_js_2.parseBaselineFile; } });
@@ -576,5 +577,20 @@ exports.vigilesMatchers = {
576
577
  message: () => `expected ${arm} ${pass ? "not " : ""}to beat ${baseline} on ${metric} by > ${String(by)} (got ${delta.toFixed(3)})`,
577
578
  };
578
579
  },
580
+ // The check-vocabulary veneer (Phase 1): ONE matcher works for EVERY check,
581
+ // carrying the check's own failure message. `expect(r).toPass(tool("Bash"))`.
582
+ toPass(received, check) {
583
+ const r = check.eval(received);
584
+ return { pass: r.pass, message: () => r.message };
585
+ },
586
+ toPassAll(received, checks) {
587
+ const failed = (0, check_js_1.evalChecks)(received, checks).filter((x) => !x.pass);
588
+ return {
589
+ pass: failed.length === 0,
590
+ message: () => failed.length === 0
591
+ ? "all checks passed"
592
+ : failed.map((f) => ` ✗ ${f.message}`).join("\n"),
593
+ };
594
+ },
579
595
  };
580
596
  //# sourceMappingURL=harness-assert.js.map
@@ -1,9 +1,9 @@
1
- import type { HarnessAdapter } from "../../core/adapter.js";
2
- import type { HarnessTestDriver, ToolCall, HookFire, ParsedRun, ModelTurn, ModelRequest } from "../../core/harness-driver.js";
1
+ import type { HarnessAdapter } from "./core/adapter.js";
2
+ import type { HarnessTestDriver, ToolCall, HookFire, ParsedRun, ModelTurn, ModelRequest } from "./core/harness-driver.js";
3
3
  import { type SandboxMode } from "./sandbox.js";
4
4
  export { scriptModel } from "./mock-model.js";
5
- export type { ModelTurn, ModelRequest, ToolCall, HookFire, HarnessTestDriver, } from "../../core/harness-driver.js";
6
- export { loadPlugin, resolveHarness } from "./plugin-loader.js";
5
+ export type { ModelTurn, ModelRequest, ToolCall, HookFire, HarnessTestDriver, } from "./core/harness-driver.js";
6
+ export { loadPlugin, resolveHarness, } from "./adapters/claude-code/plugin-loader.js";
7
7
  export { decideSandbox, specTrusted, sandboxAvailable, type SandboxMode, } from "./sandbox.js";
8
8
  export interface HarnessTestSpec {
9
9
  /** Fixture files to write in a fresh temp working dir (path → contents). */
@@ -93,9 +93,24 @@ export interface Trace {
93
93
  readonly modelRequests: readonly ModelRequest[];
94
94
  /** Number of model turns. */
95
95
  readonly turns: number;
96
+ /**
97
+ * Sub-agent (`Task`) runs as nested traces, keyed by `subagent_type`. A
98
+ * subagent runs its own session; CC tags its events with `parent_tool_use_id`
99
+ * (= the `Task` tool call) so its tool calls are recovered into a sub-trace
100
+ * here, lettng a test assert what the subagent DID (not just that `Task` fired).
101
+ * Empty unless the stream was captured / the harness emits subagent events.
102
+ */
103
+ readonly subagents?: readonly SubagentTrace[];
96
104
  /** Final contents of a file under the working dir, or null if absent. */
97
105
  file(path: string): string | null;
98
106
  }
107
+ /** A sub-agent (`Task`) run as a nested trace: its name + the tools it used. */
108
+ export interface SubagentTrace {
109
+ /** The `subagent_type` from the `Task` tool input. */
110
+ readonly name: string;
111
+ /** The tools the subagent invoked (events tagged with the Task's id). */
112
+ readonly toolCalls: readonly ToolCall[];
113
+ }
99
114
  export interface HarnessTestResult extends Trace {
100
115
  readonly exitCode: number;
101
116
  readonly stdout: string;
@@ -115,6 +130,19 @@ export interface HarnessTestResult extends Trace {
115
130
  * actions, not a brittle stdout substring.
116
131
  */
117
132
  export declare function parseToolCalls(streamJson: string): ToolCall[];
133
+ /**
134
+ * Recover sub-agent runs as nested traces. A subagent-dispatch tool call (the
135
+ * `Agent` tool on the live CLI — older docs say `Task` — carrying an
136
+ * `input.subagent_type`) spawns a subagent whose own events the CLI tags with a
137
+ * top-level `parent_tool_use_id` = the dispatch tool-use id. We group those
138
+ * tagged tool calls under their dispatch, keyed by `subagent_type`. **Schema
139
+ * verified against real claude output** (`parent_tool_use_id` sibling of
140
+ * `message`, `subagent_type` in the dispatch input; tool named `Agent`) — the
141
+ * same `message.content` line shape `parseToolCalls` consumes, and we match the
142
+ * input field NOT the tool name so a future rename can't break it. Pure; empty
143
+ * for a harness that doesn't emit `parent_tool_use_id` (e.g. Codex).
144
+ */
145
+ export declare function parseSubagents(streamJson: string): SubagentTrace[];
118
146
  /**
119
147
  * The terminal `result` event — present in BOTH `--output-format` shapes (a
120
148
  * `{type:"result", …}` line in stream-json, the single object in `json`), or
@@ -163,4 +191,22 @@ export interface RunHarnessTestOptions {
163
191
  * only — requesting confinement for another harness throws.
164
192
  */
165
193
  export declare function runHarnessTest(spec: HarnessTestSpec, opts?: RunHarnessTestOptions): Promise<HarnessTestResult>;
194
+ /**
195
+ * `runHarness` — the harness-scope entry of the revamped API (Phase 2 of
196
+ * `research/testing-api-design.md`). The harness has two execution scopes, `hook`
197
+ * (`runHook`) and `harness` (the whole assembled agent); today's `integration` /
198
+ * `e2e` / `eval` are all the **harness** scope under realness flags. This entry is
199
+ * the **deterministic** harness run (`model: "mock"`, the default) — the
200
+ * workhorse you gate every commit, with no key. A **real-model** harness run is
201
+ * non-deterministic by definition, so you don't *assert* a single one — you
202
+ * `measure()` it across trials (the eval scope). `egress` is a capability of this
203
+ * scope (the e2e tier), not a separate tier.
204
+ *
205
+ * Behaviour is identical to `runHarnessTest` (which it wraps); the new name +
206
+ * `model` flag make the scope/realness explicit and steer real-model runs to the
207
+ * right tool.
208
+ */
209
+ export declare function runHarness(spec: HarnessTestSpec, opts?: RunHarnessTestOptions & {
210
+ model?: "mock" | "real";
211
+ }): Promise<HarnessTestResult>;
166
212
  //# sourceMappingURL=harness-test.d.ts.map