vigiles 5.0.1 → 5.2.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 (72) hide show
  1. package/README.md +15 -9
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +66 -37
  5. package/dist/adapters/claude-code/dialect.js +37 -2
  6. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  7. package/dist/adapters/claude-code/effect-region.js +45 -0
  8. package/dist/adapters/claude-code/layout.js +3 -0
  9. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  10. package/dist/adapters/claude-code/skill-runtime.js +48 -0
  11. package/dist/adapters/codex/adapter.js +3 -0
  12. package/dist/adapters/codex/eval.d.ts +94 -0
  13. package/dist/adapters/codex/eval.js +227 -0
  14. package/dist/adapters/codex/layout.js +3 -0
  15. package/dist/adapters/opencode/adapter.js +1 -0
  16. package/dist/adapters/opencode/layout.js +3 -0
  17. package/dist/check.d.ts +8 -0
  18. package/dist/check.js +27 -3
  19. package/dist/cli.js +712 -21
  20. package/dist/codex.d.ts +1 -0
  21. package/dist/codex.js +3 -0
  22. package/dist/core/adapter.d.ts +10 -0
  23. package/dist/core/bash-effects.d.ts +41 -0
  24. package/dist/core/bash-effects.js +405 -0
  25. package/dist/core/compile.d.ts +3 -1
  26. package/dist/core/compile.js +169 -74
  27. package/dist/core/description-overlap.d.ts +27 -0
  28. package/dist/core/description-overlap.js +53 -0
  29. package/dist/core/dialect.d.ts +18 -0
  30. package/dist/core/effects.d.ts +172 -0
  31. package/dist/core/effects.js +245 -0
  32. package/dist/core/frontmatter-read.d.ts +25 -0
  33. package/dist/core/frontmatter-read.js +138 -0
  34. package/dist/core/hook-events.d.ts +34 -0
  35. package/dist/core/hook-events.js +48 -0
  36. package/dist/core/layout.d.ts +6 -0
  37. package/dist/core/mcp-config.d.ts +20 -0
  38. package/dist/core/mcp-config.js +40 -0
  39. package/dist/core/mcp-hook.d.ts +35 -0
  40. package/dist/core/mcp-hook.js +70 -0
  41. package/dist/core/mcp-tool.d.ts +50 -0
  42. package/dist/core/mcp-tool.js +61 -0
  43. package/dist/core/orphans.js +21 -0
  44. package/dist/core/spec.d.ts +142 -3
  45. package/dist/core/spec.js +48 -0
  46. package/dist/core/tool-contract.d.ts +68 -0
  47. package/dist/core/tool-contract.js +113 -0
  48. package/dist/core/types.d.ts +91 -2
  49. package/dist/core/validate.js +23 -1
  50. package/dist/eval.d.ts +69 -13
  51. package/dist/eval.js +106 -51
  52. package/dist/harness-test.d.ts +7 -0
  53. package/dist/harness-test.js +19 -7
  54. package/dist/leaderboard.d.ts +2 -0
  55. package/dist/leaderboard.js +63 -3
  56. package/dist/optimize.d.ts +74 -0
  57. package/dist/optimize.js +94 -0
  58. package/dist/plugin-loader.d.ts +1 -0
  59. package/dist/plugin-loader.js +71 -18
  60. package/dist/scaffold-test.d.ts +30 -0
  61. package/dist/scaffold-test.js +158 -0
  62. package/dist/scan-behavioral.d.ts +73 -0
  63. package/dist/scan-behavioral.js +150 -0
  64. package/dist/scan.d.ts +166 -1
  65. package/dist/scan.js +622 -55
  66. package/dist/score-explainer.d.ts +69 -0
  67. package/dist/score-explainer.js +169 -0
  68. package/dist/test-coverage.d.ts +7 -0
  69. package/dist/test-coverage.js +39 -24
  70. package/package.json +2 -1
  71. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
  72. package/skills/edit-spec/SKILL.md +1 -1
@@ -71,7 +71,7 @@ export interface OrphansConfig {
71
71
  }
72
72
  /**
73
73
  * Shared options for the per-kind untested-* rules (`untested-skill` /
74
- * `untested-agent` / `untested-hook`). Which kinds are scanned is controlled by
74
+ * `untested-subagent` / `untested-hook`). Which kinds are scanned is controlled by
75
75
  * each rule's severity (set a rule to `false` to skip that kind), so only the
76
76
  * test-discovery knobs live here.
77
77
  */
@@ -97,7 +97,7 @@ export interface RulesConfig {
97
97
  /** Flag a skill (SKILL.md) that ships with no test or eval. Default: "warn". */
98
98
  "untested-skill"?: RuleWithOptions<TestCoverageConfig>;
99
99
  /** Flag a subagent (agents/*.md) that ships with no test or eval. Default: "warn". */
100
- "untested-agent"?: RuleWithOptions<TestCoverageConfig>;
100
+ "untested-subagent"?: RuleWithOptions<TestCoverageConfig>;
101
101
  /** Flag a hook script that ships with no test or eval. Default: "warn". */
102
102
  "untested-hook"?: RuleWithOptions<TestCoverageConfig>;
103
103
  /**
@@ -108,6 +108,95 @@ export interface RulesConfig {
108
108
  * block the edit, false → off.
109
109
  */
110
110
  "unmarked-refs"?: RuleSeverity;
111
+ /**
112
+ * Cross-reference each subagent's `tools:` rail against the harness tool
113
+ * catalog — flag a never-available tool or a close typo (the moat). Only
114
+ * high-confidence issues are reported (a bare unrecognized tool is likely
115
+ * plugin/MCP-provided, never flagged). Off unless set; "warn" surfaces,
116
+ * "error" gates CI. Same detector as `scan` + `compileAgent`.
117
+ */
118
+ "subagent-tool-contract"?: RuleSeverity;
119
+ /**
120
+ * Flag a hook registered under an event name the harness doesn't define (a
121
+ * typo → the hook never fires). High-precision: close typos only, never a
122
+ * framework/custom event. Default "warn"; "error" gates CI. Same detector as
123
+ * `scan`.
124
+ */
125
+ "hook-events"?: RuleSeverity;
126
+ /**
127
+ * Flag a skill/agent missing a required frontmatter field — a skill needs
128
+ * `name` (to load), an agent needs `name` + `description`. A broken surface
129
+ * that won't register. Default "warn"; "error" gates CI. Same detector as `scan`.
130
+ */
131
+ "subagent-frontmatter"?: RuleSeverity;
132
+ /**
133
+ * Flag a declared MCP server that can't start — neither a `command` (stdio)
134
+ * nor a `url` (http/sse). Default "warn"; "error" gates CI. Same detector as
135
+ * `scan`. (JSON `.mcp.json`/manifest `mcpServers`; Codex TOML not yet parsed.)
136
+ */
137
+ "mcp-config"?: RuleSeverity;
138
+ /**
139
+ * RECOMMEND (not require) that a SKILL.md declares an explicit `name` +
140
+ * `description` rather than relying on the dir-name / first-paragraph
141
+ * fallbacks — a more reliable trigger surface. The skill still loads without
142
+ * them, so this is a best-practice nudge: default "warn"; set "error" to
143
+ * enforce on your own skills. Same detector as `scan` (skillMetaIssues).
144
+ */
145
+ "skill-frontmatter"?: RuleSeverity;
146
+ /**
147
+ * Cross-reference an `mcp__server__tool` in a subagent's contract against the
148
+ * plugin's declared `mcpServers` — flag a server the plugin doesn't declare
149
+ * (the MCP half of the tool moat; `subagent-tool-contract` checks the built-in
150
+ * half). High-precision: only flags when the plugin SHIPS a declared set,
151
+ * allowlists harness built-ins (`ide`), and skips the plugin-namespaced
152
+ * `mcp__plugin_…` form. Default "warn"; "error" gates CI. Same detector as
153
+ * `scan` (mcpToolIssues).
154
+ */
155
+ "mcp-tool-resolves"?: RuleSeverity;
156
+ /**
157
+ * Flag a hook command that references a script file which doesn't exist on
158
+ * disk (with `${CLAUDE_PLUGIN_ROOT}` resolved) — the hook silently never runs.
159
+ * FP-safe: skips unresolved `$VAR` paths, existence-guarded one-liners, and
160
+ * inline commands. Matches Anthropic's own `claude plugin validate`. Default
161
+ * "warn"; "error" gates CI. Same detector as `scan` (hooks status "missing").
162
+ */
163
+ "hook-script-exists"?: RuleSeverity;
164
+ /**
165
+ * Cross-reference a subagent's `disallowedTools:` block-list against the
166
+ * catalog — the deny-side mirror of `subagent-tool-contract`. A close typo there
167
+ * blocks NOTHING (you meant to deny `Bash`, wrote `Bsh`), leaving the tool
168
+ * available. High-precision: close-typo only (a never-available tool is
169
+ * harmless to list, a bare unknown is likely a plugin tool). Default "warn";
170
+ * "error" gates CI. Same detector as `scan` (disallowedToolIssues).
171
+ */
172
+ "disallowed-tools-contract"?: RuleSeverity;
173
+ /**
174
+ * Flag two model-invocable skills whose descriptions are near-identical — the
175
+ * selector can't tell them apart, so the wrong one fires (a precision
176
+ * collision). A DETERMINISTIC NCD proxy for a `--trigger`-class behavioral bug;
177
+ * calibrated FP-safe (only basically-identical text, below the sweep's
178
+ * most-similar distinct pair). Default "warn"; "error" gates CI. Same detector
179
+ * as `scan` (descriptionOverlaps).
180
+ */
181
+ "description-overlap"?: RuleSeverity;
182
+ /**
183
+ * Flag a skill/agent whose `---` frontmatter block EXISTS but isn't valid YAML
184
+ * — fields may not parse as intended. CAVEAT: a real YAML parser (js-yaml) is
185
+ * stricter than some loaders, so a one-line `description:` containing a `: `
186
+ * colon or an `<example>` block is flagged even though it may still load.
187
+ * Hence default "warn" (a nudge), not "error" — verify before enforcing. Same
188
+ * detector as `scan` (malformedFrontmatter).
189
+ */
190
+ "frontmatter-valid"?: RuleSeverity;
191
+ /**
192
+ * Flag a `type: "mcp_tool"` hook action that's incomplete (missing `server` /
193
+ * `tool`) or targets a server the plugin doesn't declare in `mcpServers` — the
194
+ * hook silently never dispatches. High-precision: the undeclared-server half is
195
+ * gated on the plugin shipping a declared set and allowlists built-ins (`ide`),
196
+ * mirroring `mcp-tool-resolves`. Default "warn"; "error" gates CI. Same detector
197
+ * as `scan` (mcpHookIssues).
198
+ */
199
+ "mcp-hook-target-resolves"?: RuleSeverity;
111
200
  }
112
201
  /** Extract severity from a rule value (handles both simple and tuple forms). */
113
202
  export declare function ruleSeverity<T>(rule: RuleWithOptions<T> | undefined): RuleSeverity;
@@ -43,9 +43,31 @@ const DEFAULT_RULES = {
43
43
  coverage: false,
44
44
  // Per-kind surface-coverage: a skill/agent/hook must ship with a test or eval.
45
45
  "untested-skill": "warn",
46
- "untested-agent": "warn",
46
+ "untested-subagent": "warn",
47
47
  "untested-hook": "warn",
48
48
  "unmarked-refs": "warn",
49
+ // High-precision (never-available + close typos only), so on by default at warn.
50
+ "subagent-tool-contract": "warn",
51
+ // High-precision (close typos only), on by default at warn.
52
+ "hook-events": "warn",
53
+ // Missing required frontmatter (name/description) — on by default at warn.
54
+ "subagent-frontmatter": "warn",
55
+ // A declared MCP server with no command/url can't start — on by default at warn.
56
+ "mcp-config": "warn",
57
+ // Best-practice nudge (skills load without frontmatter) — warn, not error.
58
+ "skill-frontmatter": "warn",
59
+ // High-precision (gated on a declared MCP set; built-ins allowlisted) — warn.
60
+ "mcp-tool-resolves": "warn",
61
+ // A hook script referenced but missing never runs — on by default at warn.
62
+ "hook-script-exists": "warn",
63
+ // High-precision (close-typo only) deny-list mirror of subagent-tool-contract.
64
+ "disallowed-tools-contract": "warn",
65
+ // Deterministic NCD precision proxy (near-identical skill descriptions) — warn.
66
+ "description-overlap": "warn",
67
+ // Malformed-YAML frontmatter — WARN only (js-yaml is stricter than some loaders).
68
+ "frontmatter-valid": "warn",
69
+ // A mcp_tool hook incomplete / targeting an undeclared server — on by default at warn.
70
+ "mcp-hook-target-resolves": "warn",
49
71
  };
50
72
  const DEFAULT_CONFIG = {
51
73
  ruleMarkers: ["headings", "checkboxes"],
package/dist/eval.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ToolCall, type Trace } from "./harness-test.js";
1
+ import { parseToolCalls, parseHooks, parseSubagents, type ToolCall, type Trace } from "./harness-test.js";
2
2
  import { type CacheMode } from "./eval-cache.js";
3
3
  import type { Check, CheckJSON } from "./check.js";
4
4
  import { type Comparison } from "./stats.js";
@@ -250,6 +250,9 @@ export type AgentRunner = (args: AgentRunArgs) => Promise<RunOut>;
250
250
  * leak the host environment into an untrusted, model-driven run.
251
251
  */
252
252
  export declare function resolveSpawnEnv(a: Pick<AgentRunArgs, "env" | "replaceEnv">, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
253
+ /** The real `claude`-spawning runner (composition root). Exported so other
254
+ * real-model entries (e.g. `scan --trigger`) bind the same runner. */
255
+ export declare function spawnAgent(a: AgentRunArgs): Promise<RunOut>;
253
256
  /**
254
257
  * Run the eval: every arm × every trial against the real `claude` CLI, with the
255
258
  * metric computed per run and aggregated per arm. Requires `claude` on PATH and
@@ -392,6 +395,24 @@ export declare function checkReportToJUnit(report: CheckReport, opts?: {
392
395
  }): string;
393
396
  /** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
394
397
  export declare function parseUsage(stdout: string): EvalUsage;
398
+ /**
399
+ * The harness-specific half of a run trace: how a real model's raw stdout maps
400
+ * to the common fields. Claude Code's `parseClaudeRun` reads its stream-json; a
401
+ * second harness (Codex) supplies its own parser of `codex exec --json` JSONL, so
402
+ * the eval tier (`measureTriggerRate`/`runEval`) isn't bound to Claude's format.
403
+ * The non-harness fields (cwd/exitCode/stdout/file/sh) stay in `makeContext`.
404
+ */
405
+ export interface ParsedModelRun {
406
+ readonly turns: number;
407
+ readonly output: string;
408
+ readonly toolCalls: ReturnType<typeof parseToolCalls>;
409
+ readonly hooks: ReturnType<typeof parseHooks>;
410
+ readonly subagents: ReturnType<typeof parseSubagents>;
411
+ readonly usage: EvalUsage;
412
+ }
413
+ export type ModelOutputParser = (out: RunOut) => ParsedModelRun;
414
+ /** Parse Claude Code's stream-json stdout into the common trace fields. */
415
+ export declare function parseClaudeRun(out: RunOut): ParsedModelRun;
395
416
  /** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
396
417
  export declare function aggregate(rows: readonly Metrics[]): Record<string, number>;
397
418
  /**
@@ -593,6 +614,22 @@ export interface TriggerRateSpec {
593
614
  readonly timeoutMs?: number;
594
615
  /** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
595
616
  readonly spacingSec?: number;
617
+ /**
618
+ * Files (path → contents) seeded into every run's cwd before the prompt — the
619
+ * filesystem CONTEXT the skill is measured in. The default empty cwd is faithful
620
+ * for opening-move skills ("describe a feature", "debug this") but biased-low for
621
+ * skills whose trigger is a repo STATE ("in a git repo", "dirty tree"); seed that
622
+ * state here so recall is honest instead of an artifact of the cold start. Mirrors
623
+ * `MeasureSpec.fixture`. See `research/plugin-behavioral-findings.md`.
624
+ */
625
+ readonly fixture?: Record<string, string>;
626
+ /**
627
+ * How many runs to execute in parallel across the whole prompts × trials grid.
628
+ * Default 1 (serial, the politest to rate limits). Raise it to cut wall-clock on
629
+ * a large prompt set or roster sweep — the `spacingSec` pause still applies per
630
+ * run, so it stays best-effort polite. Mirrors `EvalSpec.concurrency`.
631
+ */
632
+ readonly concurrency?: number;
596
633
  }
597
634
  /** Per-prompt trigger result: how many of its trials fired. */
598
635
  export interface PromptTriggerStat {
@@ -630,7 +667,29 @@ export interface TriggerRateReport {
630
667
  * description). A non-zero count is the whole-harness measurement.
631
668
  */
632
669
  readonly competitors: number;
670
+ /**
671
+ * Runs EXCLUDED because the turn errored / was rate-limited (detected by the
672
+ * driver's `runError`), present only when > 0. These are NOT counted in `n` or
673
+ * as misses — so `rate` reflects only valid runs. A large `errored` relative to
674
+ * `n` means the measurement is thin (e.g. a Codex usage limit was hit); re-run.
675
+ */
676
+ readonly errored?: number;
633
677
  }
678
+ /**
679
+ * An eval-tier transport: how to RUN a real harness turn and PARSE its output.
680
+ * The default is Claude Code (`claudeEvalDriver`); a second harness supplies its
681
+ * own (e.g. `codexEvalDriver` from `vigiles/codex`) and passes it as
682
+ * `measureTriggerRate(spec, { evalDriver })` — the eval-tier analog of
683
+ * `runHarnessTest`'s `{ adapter }`. `runError` lets the loop drop an
684
+ * errored/rate-limited turn instead of scoring it as a miss.
685
+ */
686
+ export interface EvalDriver {
687
+ readonly runner: AgentRunner;
688
+ readonly parse: ModelOutputParser;
689
+ readonly runError?: (out: RunOut) => string | null;
690
+ }
691
+ /** The default (Claude Code) eval driver: real `claude` + stream-json parsing. */
692
+ export declare const claudeEvalDriver: EvalDriver;
634
693
  /**
635
694
  * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
636
695
  * that `claude --plugin-dir` accepts — so repo-local skills (e.g. `.claude/skills`)
@@ -707,20 +766,17 @@ export declare function packageInstallSet(opts: {
707
766
  dir: string;
708
767
  added: number;
709
768
  };
769
+ export declare function measureTriggerRateWith(spec: TriggerRateSpec, runner: AgentRunner, parse?: ModelOutputParser, runError?: (out: RunOut) => string | null): Promise<TriggerRateReport>;
710
770
  /**
711
- * Trigger-rate orchestration every prompt × trial via `runner`, the `fired`
712
- * predicate evaluated per run and aggregated into an overall + per-prompt rate.
713
- * With `irrelevantPrompts`, also runs the precision side (firing there is a false
714
- * positive) and adds `falsePositiveRate` + `precision`. Exported with an
715
- * injectable `runner` so the loop is unit-testable without a model;
716
- * `measureTriggerRate` is this with the real agent runner.
717
- */
718
- export declare function measureTriggerRateWith(spec: TriggerRateSpec, runner: AgentRunner): Promise<TriggerRateReport>;
719
- /**
720
- * Measure a skill/behaviour's real trigger rate across prompts × trials against
721
- * the real `claude` CLI. Requires `claude` + model auth.
771
+ * Measure a skill/behaviour's real trigger rate across prompts × trials. Defaults
772
+ * to the real `claude` CLI (`claudeEvalDriver`); pass `{ evalDriver }` to drive a
773
+ * second harness e.g. `measureTriggerRate(spec, { evalDriver: codexEvalDriver })`
774
+ * from `vigiles/codex` (the eval-tier analog of `runHarnessTest`'s `{ adapter }`).
775
+ * Requires that harness's binary + auth.
722
776
  */
723
- export declare function measureTriggerRate(spec: TriggerRateSpec): Promise<TriggerRateReport>;
777
+ export declare function measureTriggerRate(spec: TriggerRateSpec, opts?: {
778
+ evalDriver?: EvalDriver;
779
+ }): Promise<TriggerRateReport>;
724
780
  /** Format a trigger-rate report: overall %, then each prompt's rate. */
725
781
  export declare function formatTriggerRateReport(report: TriggerRateReport): string;
726
782
  //# sourceMappingURL=eval.d.ts.map
package/dist/eval.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EPHEMERAL_HOME_KEEP = void 0;
3
+ exports.claudeEvalDriver = exports.EPHEMERAL_HOME_KEEP = void 0;
4
4
  exports.resolveSpawnEnv = resolveSpawnEnv;
5
+ exports.spawnAgent = spawnAgent;
5
6
  exports.runEval = runEval;
6
7
  exports.measureWith = measureWith;
7
8
  exports.measure = measure;
@@ -12,6 +13,7 @@ exports.formatCheckReport = formatCheckReport;
12
13
  exports.assertRates = assertRates;
13
14
  exports.checkReportToJUnit = checkReportToJUnit;
14
15
  exports.parseUsage = parseUsage;
16
+ exports.parseClaudeRun = parseClaudeRun;
15
17
  exports.aggregate = aggregate;
16
18
  exports.aggregateStats = aggregateStats;
17
19
  exports.aggregateUsage = aggregateUsage;
@@ -91,6 +93,8 @@ function resolveSpawnEnv(a, base = process.env) {
91
93
  return a.replaceEnv ? (a.env ?? {}) : { ...base, ...a.env };
92
94
  }
93
95
  /* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
96
+ /** The real `claude`-spawning runner (composition root). Exported so other
97
+ * real-model entries (e.g. `scan --trigger`) bind the same runner. */
94
98
  function spawnAgent(a) {
95
99
  return new Promise((resolvePromise) => {
96
100
  const args = [
@@ -392,21 +396,31 @@ function usageFrom(result) {
392
396
  function parseUsage(stdout) {
393
397
  return usageFrom((0, harness_test_js_1.parseResultEvent)(stdout));
394
398
  }
395
- function makeContext(cwd, out) {
399
+ /** Parse Claude Code's stream-json stdout into the common trace fields. */
400
+ function parseClaudeRun(out) {
396
401
  const result = (0, harness_test_js_1.parseResultEvent)(out.stdout);
397
- const turns = typeof result?.num_turns === "number" ? result.num_turns : 0;
398
- const output = typeof result?.result === "string" ? result.result : "";
399
402
  return {
400
- cwd,
401
- exitCode: out.code,
402
- stdout: out.stdout,
403
- turns,
403
+ turns: typeof result?.num_turns === "number" ? result.num_turns : 0,
404
+ output: typeof result?.result === "string" ? result.result : "",
404
405
  toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
405
406
  hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
406
- output,
407
407
  subagents: (0, harness_test_js_1.parseSubagents)(out.stdout),
408
408
  usage: usageFrom(result),
409
- // The eval tier drives the real API (no mock between claude and the model),
409
+ };
410
+ }
411
+ function makeContext(cwd, out, parse = parseClaudeRun) {
412
+ const p = parse(out);
413
+ return {
414
+ cwd,
415
+ exitCode: out.code,
416
+ stdout: out.stdout,
417
+ turns: p.turns,
418
+ toolCalls: p.toolCalls,
419
+ hooks: p.hooks,
420
+ output: p.output,
421
+ subagents: p.subagents,
422
+ usage: p.usage,
423
+ // The eval tier drives the real API (no mock between the agent and the model),
410
424
  // so the requests can't be captured here — modelRequests is harness-tier only.
411
425
  modelRequests: [],
412
426
  file: (p) => {
@@ -993,6 +1007,11 @@ function formatEvalReport(report) {
993
1007
  }
994
1008
  return lines.join("\n");
995
1009
  }
1010
+ /** The default (Claude Code) eval driver: real `claude` + stream-json parsing. */
1011
+ exports.claudeEvalDriver = {
1012
+ runner: spawnAgent,
1013
+ parse: parseClaudeRun,
1014
+ };
996
1015
  /**
997
1016
  * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
998
1017
  * that `claude --plugin-dir` accepts — so repo-local skills (e.g. `.claude/skills`)
@@ -1284,42 +1303,65 @@ function resolveTriggerPluginDir(spec) {
1284
1303
  };
1285
1304
  }
1286
1305
  /** Run one prompt set × trials through `runner`, aggregating fired counts. */
1287
- async function runTriggerSet(prompts, cfg, runner) {
1288
- const perPrompt = [];
1289
- let firedTotal = 0;
1290
- let n = 0;
1291
- for (const prompt of prompts) {
1292
- let fired = 0;
1293
- for (let t = 0; t < cfg.trials; t++) {
1294
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
1295
- try {
1296
- const out = await runner({
1297
- task: prompt,
1298
- cwd,
1299
- model: cfg.model,
1300
- tools: cfg.tools,
1301
- hasSettings: false,
1302
- pluginDir: cfg.pluginDir,
1303
- timeoutMs: cfg.timeoutMs,
1304
- });
1305
- if (cfg.fired(makeContext(cwd, out)))
1306
- fired++;
1307
- }
1308
- finally {
1309
- (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
1310
- await sleep(cfg.spacing);
1311
- }
1312
- }
1313
- perPrompt.push({
1314
- prompt,
1315
- fired,
1316
- trials: cfg.trials,
1317
- rate: cfg.trials > 0 ? fired / cfg.trials : 0,
1306
+ /** Run one trigger trial in a throwaway cwd (fixture seeded) → fired 0/1. */
1307
+ async function runTriggerTrial(prompt, cfg, runner) {
1308
+ const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
1309
+ try {
1310
+ if (cfg.fixture)
1311
+ writeFiles(cwd, cfg.fixture);
1312
+ const out = await runner({
1313
+ task: prompt,
1314
+ cwd,
1315
+ model: cfg.model,
1316
+ tools: cfg.tools,
1317
+ hasSettings: false,
1318
+ pluginDir: cfg.pluginDir,
1319
+ timeoutMs: cfg.timeoutMs,
1318
1320
  });
1319
- firedTotal += fired;
1320
- n += cfg.trials;
1321
+ // An errored/rate-limited turn is NOT a "skill didn't fire" miss — it's
1322
+ // excluded from the rate, so e.g. a Codex usage limit can't read as recall 0.
1323
+ if (cfg.runError?.(out))
1324
+ return { fired: 0, errored: true };
1325
+ return {
1326
+ fired: cfg.fired(makeContext(cwd, out, cfg.parse)) ? 1 : 0,
1327
+ errored: false,
1328
+ };
1329
+ }
1330
+ finally {
1331
+ (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
1332
+ await sleep(cfg.spacing);
1321
1333
  }
1322
- return { perPrompt, fired: firedTotal, n };
1334
+ }
1335
+ /** A count for reporting: the number if positive, else undefined (omit zero). */
1336
+ const positiveOrUndefined = (n) => n > 0 ? n : undefined;
1337
+ async function runTriggerSet(prompts, cfg, runner) {
1338
+ // Flatten prompts × trials into one work list so concurrency spans both.
1339
+ const jobs = prompts.flatMap((prompt, promptIndex) => Array.from({ length: cfg.trials }, () => ({ prompt, promptIndex })));
1340
+ const outcomes = await runPool(jobs, cfg.concurrency, (job) => runTriggerTrial(job.prompt, cfg, runner));
1341
+ // Re-aggregate per prompt, preserving input order; errored runs don't count.
1342
+ const firedBy = new Array(prompts.length).fill(0);
1343
+ const trialsBy = new Array(prompts.length).fill(0);
1344
+ let errored = 0;
1345
+ jobs.forEach((job, i) => {
1346
+ if (outcomes[i].errored) {
1347
+ errored += 1;
1348
+ return;
1349
+ }
1350
+ firedBy[job.promptIndex] += outcomes[i].fired;
1351
+ trialsBy[job.promptIndex] += 1;
1352
+ });
1353
+ const perPrompt = prompts.map((prompt, i) => ({
1354
+ prompt,
1355
+ fired: firedBy[i],
1356
+ trials: trialsBy[i],
1357
+ rate: trialsBy[i] > 0 ? firedBy[i] / trialsBy[i] : 0,
1358
+ }));
1359
+ return {
1360
+ perPrompt,
1361
+ fired: firedBy.reduce((a, b) => a + b, 0),
1362
+ n: trialsBy.reduce((a, b) => a + b, 0),
1363
+ errored,
1364
+ };
1323
1365
  }
1324
1366
  /**
1325
1367
  * Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
@@ -1329,9 +1371,8 @@ async function runTriggerSet(prompts, cfg, runner) {
1329
1371
  * injectable `runner` so the loop is unit-testable without a model;
1330
1372
  * `measureTriggerRate` is this with the real agent runner.
1331
1373
  */
1332
- async function measureTriggerRateWith(spec, runner) {
1333
- // Deterministic gate FIRST — reject a too-small / near-duplicate prompt set
1334
- // before spending a token (and before packaging a skillsDir).
1374
+ /** Diversity pre-flight: reject a too-small / near-duplicate prompt set (both sides). */
1375
+ function assertTriggerDiversity(spec) {
1335
1376
  const diversity = {
1336
1377
  minPrompts: spec.minPrompts,
1337
1378
  minDistance: spec.minDistance,
@@ -1343,6 +1384,10 @@ async function measureTriggerRateWith(spec, runner) {
1343
1384
  label: "irrelevantPrompts",
1344
1385
  });
1345
1386
  }
1387
+ }
1388
+ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runError) {
1389
+ // Deterministic gate FIRST — before spending a token (or packaging a skillsDir).
1390
+ assertTriggerDiversity(spec);
1346
1391
  // Model floor (default Sonnet): trigger-rate under-measures selection on a
1347
1392
  // weaker model, so FAIL before spending a token rather than report a
1348
1393
  // false-negative recall. The floor lives in the spec (`minModel`), not an env
@@ -1365,6 +1410,10 @@ async function measureTriggerRateWith(spec, runner) {
1365
1410
  spacing: (spec.spacingSec ?? 4) * 1000,
1366
1411
  pluginDir,
1367
1412
  fired: spec.fired,
1413
+ fixture: spec.fixture,
1414
+ concurrency: Math.max(1, spec.concurrency ?? 1),
1415
+ parse,
1416
+ runError,
1368
1417
  };
1369
1418
  try {
1370
1419
  const relevant = await runTriggerSet(spec.prompts, cfg, runner);
@@ -1373,6 +1422,7 @@ async function measureTriggerRateWith(spec, runner) {
1373
1422
  n: relevant.n,
1374
1423
  perPrompt: relevant.perPrompt,
1375
1424
  competitors,
1425
+ errored: positiveOrUndefined(relevant.errored),
1376
1426
  };
1377
1427
  if ((spec.irrelevantPrompts?.length ?? 0) === 0)
1378
1428
  return base;
@@ -1380,6 +1430,7 @@ async function measureTriggerRateWith(spec, runner) {
1380
1430
  const fires = relevant.fired + irrelevant.fired;
1381
1431
  return {
1382
1432
  ...base,
1433
+ errored: positiveOrUndefined(relevant.errored + irrelevant.errored),
1383
1434
  falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
1384
1435
  precision: fires > 0 ? relevant.fired / fires : undefined,
1385
1436
  perIrrelevant: irrelevant.perPrompt,
@@ -1393,11 +1444,15 @@ async function measureTriggerRateWith(spec, runner) {
1393
1444
  }
1394
1445
  /* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
1395
1446
  /**
1396
- * Measure a skill/behaviour's real trigger rate across prompts × trials against
1397
- * the real `claude` CLI. Requires `claude` + model auth.
1447
+ * Measure a skill/behaviour's real trigger rate across prompts × trials. Defaults
1448
+ * to the real `claude` CLI (`claudeEvalDriver`); pass `{ evalDriver }` to drive a
1449
+ * second harness — e.g. `measureTriggerRate(spec, { evalDriver: codexEvalDriver })`
1450
+ * from `vigiles/codex` (the eval-tier analog of `runHarnessTest`'s `{ adapter }`).
1451
+ * Requires that harness's binary + auth.
1398
1452
  */
1399
- async function measureTriggerRate(spec) {
1400
- return measureTriggerRateWith(spec, spawnAgent);
1453
+ async function measureTriggerRate(spec, opts = {}) {
1454
+ const d = opts.evalDriver ?? exports.claudeEvalDriver;
1455
+ return measureTriggerRateWith(spec, d.runner, d.parse, d.runError);
1401
1456
  }
1402
1457
  /* v8 ignore stop */
1403
1458
  /** Format a trigger-rate report: overall %, then each prompt's rate. */
@@ -110,6 +110,13 @@ export interface SubagentTrace {
110
110
  readonly name: string;
111
111
  /** The tools the subagent invoked (events tagged with the Task's id). */
112
112
  readonly toolCalls: readonly ToolCall[];
113
+ /**
114
+ * The subagent's RETURNED text — the dispatch tool_result the orchestrator
115
+ * receives back. This is where a `result()` contract's `vigiles:ok`/`vigiles:err`
116
+ * block lands, so `subagent(name, [output(/vigiles:ok/)])` can assert the typed
117
+ * outcome. "" if not captured.
118
+ */
119
+ readonly output: string;
113
120
  }
114
121
  export interface HarnessTestResult extends Trace {
115
122
  readonly exitCode: number;
@@ -130,6 +130,7 @@ function parseToolCalls(streamJson) {
130
130
  */
131
131
  function parseSubagents(streamJson) {
132
132
  const tasks = new Map(); // dispatch id → subagent name
133
+ const dispatchOutput = new Map(); // dispatch id → returned text
133
134
  const byParent = new Map();
134
135
  const groupFor = (parent) => {
135
136
  let g = byParent.get(parent);
@@ -162,7 +163,10 @@ function parseSubagents(streamJson) {
162
163
  // A subagent dispatch is any top-level tool_use whose input carries a
163
164
  // `subagent_type` — the dispatch tool is named "Agent" on the live CLI
164
165
  // (older docs say "Task"), so match the input field, NOT the tool name,
165
- // to survive the rename. Confirmed against real claude output.
166
+ // to survive the rename. Confirmed against real claude output. CC NOTE:
167
+ // under `--plugin-dir` the value is NAMESPACED `plugin:agent` (captured
168
+ // "reviewer-spec:code-reviewer"); the bare agent name is matched in the
169
+ // `subagent()` check (src/check.ts), so the full id is preserved here.
166
170
  const sub = b.input?.subagent_type;
167
171
  if (typeof sub === "string")
168
172
  tasks.set(id, sub);
@@ -170,12 +174,20 @@ function parseSubagents(streamJson) {
170
174
  if (parent)
171
175
  groupFor(parent).uses.push({ id, name: b.name, input: b.input });
172
176
  }
173
- else if (b.type === "tool_result" && parent) {
177
+ else if (b.type === "tool_result") {
174
178
  const id = typeof b.tool_use_id === "string" ? b.tool_use_id : "";
175
- groupFor(parent).results.set(id, {
176
- text: contentText(b.content),
177
- isError: b.is_error === true,
178
- });
179
+ if (parent) {
180
+ groupFor(parent).results.set(id, {
181
+ text: contentText(b.content),
182
+ isError: b.is_error === true,
183
+ });
184
+ }
185
+ else if (id) {
186
+ // A top-level tool_result whose id is a subagent dispatch is the SUB's
187
+ // RETURN to the orchestrator (where a result() vigiles:ok/err block
188
+ // lands). Record it; matched to its dispatch by id below.
189
+ dispatchOutput.set(id, contentText(b.content));
190
+ }
179
191
  }
180
192
  }
181
193
  }
@@ -188,7 +200,7 @@ function parseSubagents(streamJson) {
188
200
  resultText: g?.results.get(u.id)?.text ?? "",
189
201
  isError: g?.results.get(u.id)?.isError ?? false,
190
202
  }));
191
- out.push({ name, toolCalls });
203
+ out.push({ name, toolCalls, output: dispatchOutput.get(taskId) ?? "" });
192
204
  }
193
205
  return out;
194
206
  }
@@ -21,6 +21,8 @@ export interface PluginScore {
21
21
  readonly issues: readonly string[];
22
22
  readonly report: ScanReport;
23
23
  }
24
+ /** Map a 0–100 structural-health score to its letter grade (A ≥90 … F <60). */
25
+ export declare function gradeFor(score: number): PluginScore["grade"];
24
26
  /** Deterministic structural-health score for one scanned plugin. */
25
27
  export declare function scoreReport(r: ScanReport): {
26
28
  score: number;