vigiles 5.2.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +99 -48
  2. package/dist/action-gate.js +1 -1
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +64 -4
  4. package/dist/adapters/claude-code/agent-runtime.js +131 -17
  5. package/dist/adapters/claude-code/dialect.d.ts +34 -0
  6. package/dist/adapters/claude-code/dialect.js +46 -33
  7. package/dist/adapters/claude-code/effect-region.js +1 -1
  8. package/dist/adapters/claude-code/skill-runtime.d.ts +1 -1
  9. package/dist/adapters/claude-code/skill-runtime.js +1 -9
  10. package/dist/adapters/claude-code/typed-spec.d.ts +58 -0
  11. package/dist/adapters/claude-code/typed-spec.js +55 -0
  12. package/dist/adapters/codex/hook-protocol.js +3 -0
  13. package/dist/adapters/codex/mock-model.js +1 -1
  14. package/dist/claude-code.d.ts +1 -0
  15. package/dist/claude-code.js +8 -1
  16. package/dist/cli-commands.d.ts +19 -0
  17. package/dist/cli-commands.js +51 -0
  18. package/dist/cli.js +735 -76
  19. package/dist/core/bash-effects.d.ts +12 -0
  20. package/dist/core/bash-effects.js +31 -0
  21. package/dist/core/capability-diff.d.ts +46 -0
  22. package/dist/core/capability-diff.js +97 -0
  23. package/dist/core/compile.d.ts +1 -1
  24. package/dist/core/compile.js +14 -0
  25. package/dist/core/generate-harness.d.ts +187 -0
  26. package/dist/core/generate-harness.js +337 -0
  27. package/dist/core/guards.d.ts +126 -0
  28. package/dist/core/guards.js +309 -0
  29. package/dist/core/harness-driver.d.ts +1 -1
  30. package/dist/core/hook-program.d.ts +459 -0
  31. package/dist/core/hook-program.js +468 -0
  32. package/dist/core/hook-protocol.d.ts +7 -0
  33. package/dist/core/hook-providers.d.ts +138 -0
  34. package/dist/core/hook-providers.js +155 -0
  35. package/dist/core/hook-spec.d.ts +74 -0
  36. package/dist/core/hook-spec.js +130 -0
  37. package/dist/core/inline.js +1 -1
  38. package/dist/core/mcp-tool.d.ts +12 -0
  39. package/dist/core/mcp-tool.js +20 -0
  40. package/dist/core/mcp.d.ts +13 -0
  41. package/dist/core/mcp.js +67 -0
  42. package/dist/core/spec.d.ts +290 -8
  43. package/dist/core/spec.js +118 -3
  44. package/dist/core/types.d.ts +8 -0
  45. package/dist/dialect-drift.d.ts +65 -0
  46. package/dist/dialect-drift.js +216 -0
  47. package/dist/eval.d.ts +40 -5
  48. package/dist/eval.js +59 -5
  49. package/dist/guardrail-check.d.ts +85 -0
  50. package/dist/guardrail-check.js +152 -0
  51. package/dist/harness-assert.d.ts +10 -0
  52. package/dist/harness-assert.js +30 -0
  53. package/dist/hook-install.d.ts +43 -0
  54. package/dist/hook-install.js +91 -0
  55. package/dist/hook.d.ts +52 -0
  56. package/dist/hook.js +98 -0
  57. package/dist/leaderboard.d.ts +6 -0
  58. package/dist/leaderboard.js +43 -1
  59. package/dist/linting.d.ts +9 -5
  60. package/dist/linting.js +17 -5
  61. package/dist/optimize.js +1 -1
  62. package/dist/scaffold-test.d.ts +28 -0
  63. package/dist/scaffold-test.js +134 -15
  64. package/dist/scan-behavioral.d.ts +60 -0
  65. package/dist/scan-behavioral.js +239 -1
  66. package/dist/scan.d.ts +14 -0
  67. package/dist/scan.js +33 -1
  68. package/dist/score-explainer.js +1 -1
  69. package/dist/self-command-refs.d.ts +21 -0
  70. package/dist/self-command-refs.js +125 -0
  71. package/dist/testing.d.ts +5 -3
  72. package/dist/testing.js +37 -23
  73. package/dist/tool-intercept.d.ts +4 -4
  74. package/dist/tool-intercept.js +5 -5
  75. package/dist/unit.d.ts +2 -0
  76. package/dist/unit.js +8 -1
  77. package/hooks/refs-nudge.sh +1 -1
  78. package/package.json +5 -3
@@ -45,21 +45,35 @@ function header(title, run) {
45
45
  function hookScaffold(input) {
46
46
  const cmd = input.hookCommand ?? `bash ${input.path}`;
47
47
  return `${header(`Starter unit test for the \`${input.name}\` hook.`, `npx vigiles test ${suggestedPath(input)}`)}
48
- import { runHook, assertHookAllowed } from "vigiles/unit";
48
+ import {
49
+ runHook,
50
+ assertHookAllowed,
51
+ verifyGuardrail,
52
+ formatGuardrailReport,
53
+ // assertBlocksDisasters, // uncomment to gate CI on the battery (see below)
54
+ } from "vigiles/unit";
55
+
56
+ const cmd = ${JSON.stringify(cmd)};
49
57
 
58
+ // 1) A benign event should pass through.
50
59
  // TODO: set the event + input your hook actually inspects (PreToolUse/Bash shown).
51
- const event = {
60
+ const benign = {
52
61
  hook_event_name: "PreToolUse",
53
62
  tool_name: "Bash",
54
63
  tool_input: { command: "echo hello" },
55
64
  };
65
+ assertHookAllowed(runHook(cmd, benign));
66
+ console.log("✓ ${input.name}: allowed the benign event");
56
67
 
57
- const r = runHook(${JSON.stringify(cmd)}, event);
68
+ // 2) SAFETY: if this is a guard, PROVE it blocks the dangerous battery (the #1 hook
69
+ // pain is a guard that silently doesn't — exit 1 instead of 2, wrong jq path, …).
70
+ // This prints a coverage map; it does NOT fail by default (your hook may not be
71
+ // meant to block all of these).
72
+ console.log(formatGuardrailReport(cmd, verifyGuardrail(cmd)));
58
73
 
59
- // TODO: assert the decision you expect. Use assertHookBlocked(r) for the deny case
60
- // (and a second runHook with the input that SHOULD be blocked).
61
- assertHookAllowed(r);
62
- console.log("✓ ${input.name}: hook allowed the benign event");
74
+ // 3) To GATE CI: declare what this guard MUST block, then assert it. Uncomment +
75
+ // pick the categories your hook is responsible for:
76
+ // assertBlocksDisasters(cmd, { categories: ["destructive-git"] });
63
77
  `;
64
78
  }
65
79
  /** A skill → the eval tier (`measureTriggerRate`): does its description FIRE? */
@@ -100,18 +114,68 @@ console.log(formatTriggerRateReport(report));
100
114
  assertTriggerRate(report, { min: 0.8, maxFalsePositive: 0.3 });
101
115
  `;
102
116
  }
103
- /** A subagent the deterministic harness tier; point at the railway/Result path. */
104
- function agentScaffold(input) {
117
+ /** A JSON value placeholder for an `OutputFieldType`, for the `vigiles:ok` block. */
118
+ function placeholderFor(type) {
119
+ switch (type) {
120
+ case "number":
121
+ return 1;
122
+ case "boolean":
123
+ return true;
124
+ case "string[]":
125
+ return ["example"];
126
+ default:
127
+ return "example";
128
+ }
129
+ }
130
+ /** Render a `result(ok, err)` builder call reconstructed from the parsed contract. */
131
+ function renderContractBuilder(contract) {
132
+ const shape = (fields) => `{ ${fields.map((f) => `${f.name}: ${JSON.stringify(f.type)}`).join(", ")} }`;
133
+ return `result(\n ${shape(contract.ok)},\n ${shape(contract.err)},\n)`;
134
+ }
135
+ /**
136
+ * The OUTCOME test, GENERATED FROM the subagent's `result()` contract: reconstruct
137
+ * the contract, build a matching `vigiles:ok` block, and `assertAgentOk` it —
138
+ * deterministic, no LLM judge. This is the typed-spec payoff a markdown
139
+ * `description:` cannot give you: a parseable, typed outcome a test reads directly.
140
+ */
141
+ function outcomeSection(input, contract) {
142
+ const okValue = Object.fromEntries(contract.ok.map((f) => [f.name, placeholderFor(f.type)]));
143
+ const firstField = contract.ok[0]?.name;
144
+ const fieldAssertion = firstField
145
+ ? `// TODO: assert the VALUES you expect (the shape is already validated above), e.g.:\n// assert.ok(value.${firstField}, "expected a ${firstField}");`
146
+ : "";
147
+ return `import assert from "node:assert/strict";
148
+ import { result } from "vigiles/spec";
149
+ import { assertAgentOk } from "vigiles/testing";
150
+
151
+ // Reconstructed from ${input.name}'s ## Output contract (its compiled .md) — the
152
+ // typed result() the spec wrote. assertAgentOk parses + validates the outcome
153
+ // with NO model judge; swap \`okOutput\` for a real \`runHarness\` turn (Part B in
154
+ // examples/harness/railway-result.harness.mjs) to assert REAL behaviour.
155
+ const contract = ${renderContractBuilder(contract)};
156
+
157
+ const okOutput = [
158
+ "${input.name} finished its task.",
159
+ "\`\`\`vigiles:ok",
160
+ ${JSON.stringify(JSON.stringify(okValue))},
161
+ "\`\`\`",
162
+ ].join("\\n");
163
+
164
+ const value = assertAgentOk(okOutput, contract); // deterministic — no LLM judge
165
+ ${fieldAssertion}
166
+ console.log("✓ ${input.name}: result() outcome parses + validates against its typed contract");
167
+ `;
168
+ }
169
+ /** The fallback when the subagent has no `result()` contract — assert a tool use. */
170
+ function fallbackSection(input) {
105
171
  const toolHint = input.tools && input.tools.length > 0
106
172
  ? `assertToolUsed(r, ${JSON.stringify(input.tools[0])}); // its declared contract: ${input.tools.join(", ")}`
107
173
  : `assertToolUsed(r, "Task"); // TODO: assert what the subagent should do`;
108
- return `${header(`Starter harness test for the \`${input.name}\` subagent.`, `npx vigiles test ${suggestedPath(input)}`)}
109
- import { runHarnessTest, assertToolUsed } from "vigiles/testing";
110
-
111
- // A subagent's OUTCOME is best asserted via a result() contract — deterministic,
112
- // no LLM judge. If ${input.name} has one, use assertAgentOk(r.output, contract)
113
- // instead; see the railway-result example in the vigiles docs.
174
+ return `import { runHarnessTest, assertToolUsed } from "vigiles/testing";
114
175
 
176
+ // ${input.name} has no result() contract, so its outcome can't be asserted
177
+ // deterministically — add one (result() on its agent() spec) for a no-judge
178
+ // outcome test. For now, assert it reaches for the right tool.
115
179
  const r = await runHarnessTest({
116
180
  plugin: ".", // TODO: the plugin dir holding this subagent
117
181
  // TODO: a prompt that dispatches ${input.name} (via the Task tool).
@@ -124,6 +188,61 @@ ${toolHint}
124
188
  console.log("✓ ${input.name}: subagent test ran");
125
189
  `;
126
190
  }
191
+ /**
192
+ * The SAFETY check, GENERATED FROM the subagent's side-effecting `tools`: assert it
193
+ * stays inside its declared write surface and never reaches for a destructive op.
194
+ * The `tools` allowlist + effectSurface identify the "hole"; the check asserts it —
195
+ * a test the typed contract writes for you (markdown can declare the tools, not test them).
196
+ */
197
+ function safetySection(input, sideEffecting) {
198
+ const checks = [];
199
+ if (sideEffecting.includes("Bash")) {
200
+ checks.push(` notTool("Bash", { command: /git push|rm -rf/ }), // never a destructive op`);
201
+ }
202
+ if (sideEffecting.some((t) => t === "Write" || t === "Edit")) {
203
+ checks.push(` didNotWrite("secrets.env"), // TODO: the path(s) it must NOT write outside its surface`);
204
+ }
205
+ if (checks.length === 0) {
206
+ checks.push(` // TODO: a notTool()/didNotWrite() per side-effecting tool: ${sideEffecting.join(", ")}`);
207
+ }
208
+ return `
209
+ // --- Safety (deterministic) — generated from ${input.name}'s side-effecting tools: ${sideEffecting.join(", ")} ---
210
+ // In a real run, replace this constructed Trace with a real \`runHarness\` /
211
+ // \`measure\` turn (use interceptTools so a real model's attempt is DENIED, never
212
+ // executed — see docs/eval-architecture.md). The checks below are derived from the
213
+ // declared tools contract — the agent's "hole" asserted to stay in its lane.
214
+ {
215
+ const trace = {
216
+ output: "done",
217
+ turns: 1,
218
+ hooks: [],
219
+ toolCalls: [
220
+ // TODO: the tool calls a benign run of ${input.name} makes.
221
+ { name: "Bash", input: { command: "git status" } },
222
+ ],
223
+ file: () => null,
224
+ };
225
+ assertChecks(trace, [
226
+ ${checks.join("\n")}
227
+ ]);
228
+ console.log("✓ ${input.name}: stayed inside its declared side-effect surface");
229
+ }
230
+ `;
231
+ }
232
+ /** A subagent → deterministic outcome + safety tests, generated from its typed contract. */
233
+ function agentScaffold(input) {
234
+ const head = header(`Starter harness test for the \`${input.name}\` subagent.`, `npx vigiles test ${suggestedPath(input)}`);
235
+ const safetyImport = input.sideEffectingTools && input.sideEffectingTools.length > 0
236
+ ? `import { notTool, didNotWrite, assertChecks } from "vigiles/testing";\n`
237
+ : "";
238
+ const body = input.resultContract
239
+ ? outcomeSection(input, input.resultContract)
240
+ : fallbackSection(input);
241
+ const safety = input.sideEffectingTools && input.sideEffectingTools.length > 0
242
+ ? safetySection(input, input.sideEffectingTools)
243
+ : "";
244
+ return `${head}\n${safetyImport}${body}${safety}`;
245
+ }
127
246
  const TIER = {
128
247
  hook: "unit",
129
248
  skill: "eval",
@@ -70,4 +70,64 @@ export declare function probePluginTriggersWith(dir: string, promptSet: TriggerP
70
70
  export declare function probePluginTriggers(dir: string, promptSet: TriggerPromptSet, opts?: ProbeOptions): Promise<BehavioralReport>;
71
71
  /** Format the behavioral column as a scan-report section. */
72
72
  export declare function formatBehavioralReport(b: BehavioralReport): string;
73
+ /** Knobs for the selection-collision measurement. */
74
+ export interface SelectionOptions {
75
+ /** Repeats per prompt (default 1). */
76
+ readonly trials?: number;
77
+ /** Selector model — defaults to Sonnet (a weaker model under-selects). */
78
+ readonly model?: string;
79
+ /** Parallel runs across the prompts × trials grid (default 1). */
80
+ readonly concurrency?: number;
81
+ /** Which harness drives it (default `"claude-code"`; others report n/a). */
82
+ readonly harness?: ProbeHarness;
83
+ }
84
+ /** One run's outcome for the matrix: which of the plugin's OWN skills fired. */
85
+ interface SelectionRun {
86
+ readonly intended: string;
87
+ readonly firedBare: readonly string[];
88
+ }
89
+ export interface SkillSelectionStat {
90
+ readonly skill: string;
91
+ /** Fraction of its own prompts on which it fired (matrix diagonal). */
92
+ readonly recall: number;
93
+ /** Fraction of its own prompts on which a SIBLING skill also/instead fired. */
94
+ readonly collisionRate: number;
95
+ /** Non-errored runs measured for this skill. */
96
+ readonly n: number;
97
+ /** Sibling skills that fired on this skill's prompts, by rate (desc, rate>0). */
98
+ readonly collidesWith: readonly {
99
+ readonly skill: string;
100
+ readonly rate: number;
101
+ }[];
102
+ }
103
+ export interface SelectionReport {
104
+ /** False when the harness CLI / auth is absent, or the harness has no selector. */
105
+ readonly available: boolean;
106
+ /** Matrix axes — the plugin's model-invocable skill names (bare). */
107
+ readonly skills: readonly string[];
108
+ /** matrix[i][j] = times skill j fired when skill i's prompt was given. */
109
+ readonly matrix: readonly (readonly number[])[];
110
+ readonly perSkill: readonly SkillSelectionStat[];
111
+ /** Plugin-level: fraction of all runs where a non-intended skill fired. */
112
+ readonly collisionRate: number;
113
+ readonly n: number;
114
+ readonly note?: string;
115
+ }
116
+ /**
117
+ * Pure aggregation: fold per-run fired-skill sets into the N×N selection matrix +
118
+ * per-skill recall/collision + the plugin-level collision rate. Separated from the
119
+ * model-driving so it's unit-testable with synthetic runs (no model).
120
+ */
121
+ export declare function buildSelectionReport(skills: readonly string[], runs: readonly SelectionRun[]): SelectionReport;
122
+ /** The injectable core (for tests): drive the matrix via a fake/real probe. */
123
+ export declare function measurePluginSelectionWith(dir: string, promptSet: TriggerPromptSet, probe: HarnessProbe, opts?: SelectionOptions): Promise<SelectionReport>;
124
+ /**
125
+ * Measure a plugin's cross-skill selection-collision matrix against the real
126
+ * harness (Claude Code only — Codex has no skill-selection event). Needs the
127
+ * `claude` CLI + model auth; degrades to `available: false` otherwise.
128
+ */
129
+ export declare function measurePluginSelection(dir: string, promptSet: TriggerPromptSet, opts?: SelectionOptions): Promise<SelectionReport>;
130
+ /** Format the selection-collision matrix as a scan-report section. */
131
+ export declare function formatSelectionReport(r: SelectionReport): string;
132
+ export {};
73
133
  //# sourceMappingURL=scan-behavioral.d.ts.map
@@ -17,12 +17,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.probePluginTriggersWith = probePluginTriggersWith;
18
18
  exports.probePluginTriggers = probePluginTriggers;
19
19
  exports.formatBehavioralReport = formatBehavioralReport;
20
+ exports.buildSelectionReport = buildSelectionReport;
21
+ exports.measurePluginSelectionWith = measurePluginSelectionWith;
22
+ exports.measurePluginSelection = measurePluginSelection;
23
+ exports.formatSelectionReport = formatSelectionReport;
20
24
  const node_fs_1 = require("node:fs");
21
25
  const node_path_1 = require("node:path");
22
26
  const scan_js_1 = require("./scan.js");
23
27
  const eval_js_1 = require("./eval.js");
24
28
  const harness_assert_js_1 = require("./harness-assert.js");
25
29
  const harness_test_js_1 = require("./harness-test.js");
30
+ const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
26
31
  const eval_js_2 = require("./adapters/codex/eval.js");
27
32
  const driver_js_1 = require("./adapters/codex/driver.js");
28
33
  function buildProbe(dir, harness) {
@@ -56,6 +61,39 @@ function pluginName(dir) {
56
61
  return null;
57
62
  }
58
63
  }
64
+ /**
65
+ * Does the plugin declare a SessionStart hook? The STUBBED measurement path rebuilds
66
+ * the plugin to skills-only (`packageSkillsDir`), DROPPING `hooks/` — so a SessionStart
67
+ * hook that primes skill selection (e.g. superpowers' `using-superpowers` gateway
68
+ * injection) is silently lost, and a recall collapse to 0 under stubbing is then a
69
+ * measurement ARTIFACT, not a real miss. Detect it to LABEL honestly (Layer 1) rather
70
+ * than report a misleading 0%. See `research/plugin-selection-collision.md`.
71
+ */
72
+ function hasSessionStartHook(dir) {
73
+ try {
74
+ const hooks = (0, plugin_loader_js_1.loadPlugin)(dir).settings.hooks;
75
+ return hooks !== undefined && Object.keys(hooks).includes("SessionStart");
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ const HOOK_PRIMED_NOTE = "hook-primed — the stubbed run dropped the plugin's SessionStart hook (which can " +
82
+ "prime skill selection), so 0% recall is likely a measurement artifact; re-run " +
83
+ "against the full plugin install to measure faithfully";
84
+ /**
85
+ * Layer-1 honesty: a STUBBED run on a SessionStart-hooked plugin where EVERY measured
86
+ * skill sits at recall 0 is the dropped-hook artifact — not a real result. The
87
+ * all-zero gate keeps a genuine single-skill miss reported as real (if siblings fired,
88
+ * the hook ran or wasn't needed). Applied to both the trigger column and the matrix.
89
+ */
90
+ function isStubbedHookArtifact(dir, stub, recalls) {
91
+ if (!stub || recalls.length === 0)
92
+ return false;
93
+ if (!recalls.every((r) => r === 0))
94
+ return false;
95
+ return hasSessionStartHook(dir);
96
+ }
59
97
  /** Probe one skill via the harness probe's eval driver → result, never throwing. */
60
98
  async function probeSkill(ctx, name, ps) {
61
99
  try {
@@ -108,7 +146,19 @@ async function probePluginTriggersWith(dir, promptSet, probe, opts = {}) {
108
146
  }
109
147
  results.push(await probeSkill(ctx, s.name, ps));
110
148
  }
111
- return { available: true, results };
149
+ return {
150
+ available: true,
151
+ results: relabelTriggerArtifact(dir, probe, results),
152
+ };
153
+ }
154
+ /** Relabel an all-zero-recall stubbed run on a hooked plugin as unmeasured (Layer 1). */
155
+ function relabelTriggerArtifact(dir, probe, results) {
156
+ const recalls = results.filter((r) => r.measured).map((r) => r.recall ?? 0);
157
+ if (!isStubbedHookArtifact(dir, probe.stub, recalls))
158
+ return [...results];
159
+ return results.map((r) => r.measured && (r.recall ?? 0) === 0
160
+ ? { skill: r.skill, measured: false, note: HOOK_PRIMED_NOTE }
161
+ : r);
112
162
  }
113
163
  /**
114
164
  * Probe a plugin's skills against the real harness (default Claude Code; Codex via
@@ -147,4 +197,192 @@ function formatBehavioralReport(b) {
147
197
  }
148
198
  return lines.join("\n");
149
199
  }
200
+ /** Map a namespaced skill id (`ns:name`) to its bare name. */
201
+ function bareSkillName(id) {
202
+ const i = id.lastIndexOf(":");
203
+ return i >= 0 ? id.slice(i + 1) : id;
204
+ }
205
+ function emptySelection(skills) {
206
+ return {
207
+ available: true,
208
+ skills,
209
+ matrix: skills.map(() => []),
210
+ perSkill: [],
211
+ collisionRate: 0,
212
+ n: 0,
213
+ };
214
+ }
215
+ function buildSkillStat(a) {
216
+ const { skill, i, skills, row, n, collisions } = a;
217
+ const collidesWith = skills
218
+ .map((s, j) => ({ skill: s, rate: n > 0 ? row[j] / n : 0 }))
219
+ .filter((c) => c.skill !== skill && c.rate > 0)
220
+ .sort((x, y) => y.rate - x.rate);
221
+ return {
222
+ skill,
223
+ recall: n > 0 ? row[i] / n : 0,
224
+ collisionRate: n > 0 ? collisions / n : 0,
225
+ n,
226
+ collidesWith,
227
+ };
228
+ }
229
+ /**
230
+ * Pure aggregation: fold per-run fired-skill sets into the N×N selection matrix +
231
+ * per-skill recall/collision + the plugin-level collision rate. Separated from the
232
+ * model-driving so it's unit-testable with synthetic runs (no model).
233
+ */
234
+ function buildSelectionReport(skills, runs) {
235
+ const index = new Map(skills.map((s, i) => [s, i]));
236
+ const n = skills.length;
237
+ const matrix = skills.map(() => new Array(n).fill(0));
238
+ const nBy = new Array(n).fill(0);
239
+ const collisionBy = new Array(n).fill(0);
240
+ let collisionTotal = 0;
241
+ for (const run of runs) {
242
+ const i = index.get(run.intended);
243
+ if (i === undefined)
244
+ continue;
245
+ nBy[i] += 1;
246
+ let collided = false;
247
+ for (const fb of run.firedBare) {
248
+ const j = index.get(fb);
249
+ if (j === undefined)
250
+ continue;
251
+ matrix[i][j] += 1;
252
+ if (j !== i)
253
+ collided = true;
254
+ }
255
+ if (collided) {
256
+ collisionBy[i] += 1;
257
+ collisionTotal += 1;
258
+ }
259
+ }
260
+ const total = nBy.reduce((a, b) => a + b, 0);
261
+ return {
262
+ available: true,
263
+ skills,
264
+ matrix,
265
+ perSkill: skills.map((s, i) => buildSkillStat({
266
+ skill: s,
267
+ i,
268
+ skills,
269
+ row: matrix[i],
270
+ n: nBy[i],
271
+ collisions: collisionBy[i],
272
+ })),
273
+ collisionRate: total > 0 ? collisionTotal / total : 0,
274
+ n: total,
275
+ };
276
+ }
277
+ /** Build the prompts × trials work list across every skill that has prompts. */
278
+ function selectionJobs(candidates, promptSet, trials) {
279
+ return candidates.flatMap((c) => {
280
+ const ps = promptSet[c.name];
281
+ if (!ps || ps.prompts.length === 0)
282
+ return [];
283
+ return ps.prompts.flatMap((prompt) => Array.from({ length: trials }, () => ({ intended: c.name, prompt })));
284
+ });
285
+ }
286
+ /** The injectable core (for tests): drive the matrix via a fake/real probe. */
287
+ async function measurePluginSelectionWith(dir, promptSet, probe, opts = {}) {
288
+ const candidates = (0, scan_js_1.scanPlugin)(dir).skills.filter((s) => !s.userInvoked && s.hasDescription);
289
+ const skills = candidates.map((c) => c.name);
290
+ if (skills.length < 2) {
291
+ return {
292
+ ...emptySelection(skills),
293
+ note: "needs ≥2 model-invocable skills to measure cross-skill collision",
294
+ };
295
+ }
296
+ const own = new Set(skills);
297
+ const jobs = selectionJobs(candidates, promptSet, Math.max(1, opts.trials ?? 1));
298
+ if (jobs.length === 0) {
299
+ return {
300
+ ...emptySelection(skills),
301
+ note: "no prompts supplied for any skill",
302
+ };
303
+ }
304
+ // Selection is decided at the frontmatter (the selector picks BEFORE the body
305
+ // loads), so stub each body to a no-op: the run stops AT selection instead of
306
+ // executing the whole workflow — the same affordability trick trigger-rate uses.
307
+ const pluginDir = probe.stub ? (0, eval_js_1.stubbedPluginDir)(dir) : dir;
308
+ try {
309
+ const d = probe.evalDriver;
310
+ const outcomes = await (0, eval_js_1.runPool)(jobs, Math.max(1, opts.concurrency ?? 1), (job) => (0, eval_js_1.runSkillSelectionTrial)({
311
+ prompt: job.prompt,
312
+ pluginDir,
313
+ runner: d.runner,
314
+ parse: d.parse,
315
+ runError: d.runError,
316
+ model: opts.model ?? "sonnet",
317
+ }));
318
+ const runs = [];
319
+ jobs.forEach((job, k) => {
320
+ if (outcomes[k].errored)
321
+ return;
322
+ runs.push({
323
+ intended: job.intended,
324
+ firedBare: outcomes[k].fired
325
+ .map(bareSkillName)
326
+ .filter((b) => own.has(b)),
327
+ });
328
+ });
329
+ const report = buildSelectionReport(skills, runs);
330
+ // Layer-1 honesty: an all-zero-recall stubbed run on a hooked plugin is the
331
+ // dropped-hook artifact — flag it instead of presenting a 0%-collision result
332
+ // computed from a plugin that never fired.
333
+ const recalls = report.perSkill.filter((s) => s.n > 0).map((s) => s.recall);
334
+ return isStubbedHookArtifact(dir, probe.stub, recalls)
335
+ ? { ...report, note: HOOK_PRIMED_NOTE }
336
+ : report;
337
+ }
338
+ finally {
339
+ if (probe.stub)
340
+ (0, node_fs_1.rmSync)(pluginDir, { recursive: true, force: true });
341
+ }
342
+ }
343
+ /**
344
+ * Measure a plugin's cross-skill selection-collision matrix against the real
345
+ * harness (Claude Code only — Codex has no skill-selection event). Needs the
346
+ * `claude` CLI + model auth; degrades to `available: false` otherwise.
347
+ */
348
+ async function measurePluginSelection(dir, promptSet, opts = {}) {
349
+ const harness = opts.harness ?? "claude-code";
350
+ if (harness !== "claude-code") {
351
+ return {
352
+ ...emptySelection([]),
353
+ available: false,
354
+ note: `selection-collision is Claude Code only (no skill-selection event on ${harness})`,
355
+ };
356
+ }
357
+ const probe = buildProbe(dir, harness);
358
+ if (!probe.available()) {
359
+ return {
360
+ ...emptySelection([]),
361
+ available: false,
362
+ note: "needs the claude CLI + model auth",
363
+ };
364
+ }
365
+ return measurePluginSelectionWith(dir, promptSet, probe, opts);
366
+ }
367
+ /** Format the selection-collision matrix as a scan-report section. */
368
+ function formatSelectionReport(r) {
369
+ if (!r.available)
370
+ return `Selection-collision: unavailable — ${r.note ?? "n/a"}`;
371
+ if (r.n === 0)
372
+ return `Selection-collision: ${r.note ?? "not measured"}`;
373
+ const lines = [
374
+ `Selection-collision: ${pct(r.collisionRate)} of ${String(r.n)} runs hit a sibling skill`,
375
+ ];
376
+ if (r.note)
377
+ lines.push(` ⓘ ${r.note}`);
378
+ for (const s of r.perSkill) {
379
+ if (s.n === 0)
380
+ continue;
381
+ const mark = s.collisionRate === 0 ? "✓" : "⚠";
382
+ const top = s.collidesWith[0];
383
+ const tail = top ? ` — top collider: ${top.skill} ${pct(top.rate)}` : "";
384
+ lines.push(` ${mark} ${s.skill} — recall ${pct(s.recall)}, collision ${pct(s.collisionRate)}${tail}`);
385
+ }
386
+ return lines.join("\n");
387
+ }
150
388
  //# sourceMappingURL=scan-behavioral.js.map
package/dist/scan.d.ts CHANGED
@@ -18,6 +18,7 @@ import { type HookEventIssue } from "./core/hook-events.js";
18
18
  import { type McpIssue } from "./core/mcp-config.js";
19
19
  import { type DescriptionOverlap } from "./core/description-overlap.js";
20
20
  import { type McpToolIssue } from "./core/mcp-tool.js";
21
+ import { type McpContractToolError } from "./core/mcp.js";
21
22
  import { type McpHookIssue } from "./core/mcp-hook.js";
22
23
  import { type PurityLevel, type EffectSurface } from "./core/effects.js";
23
24
  /** A named writing system. The label `unexpectedScript` reports + the config's expectation parse into this. */
@@ -176,6 +177,19 @@ export interface SurfaceClassifier {
176
177
  export declare function unexpectedScript(text: string, expected?: Script): Script | null;
177
178
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
178
179
  export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect): ScanReport;
180
+ /**
181
+ * LIVE MCP tool resolution for a scanned plugin — the opt-in (`scan --verify-mcp`)
182
+ * dynamic check no static linter can do: it STARTS each declared MCP server and
183
+ * checks every `mcp__server__tool` the plugin's agents reference actually exists on
184
+ * it (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
185
+ * already-computed `report` (its agents' tool lists) + the declared server configs;
186
+ * returns `[]` when the plugin declares no MCP servers (nothing to start). Async +
187
+ * side-effecting (spawns servers) — which is exactly why it's opt-in, not a default
188
+ * lint rule. See `verifyMcpContractTools` (core/mcp.ts).
189
+ */
190
+ export declare function verifyLiveMcpTools(report: ScanReport, layout: PluginLayout, dialect: HarnessDialect, timeoutMs?: number): Promise<McpContractToolError[]>;
191
+ /** Render the live MCP tool-check result (human-readable). */
192
+ export declare function formatMcpContractReport(errors: readonly McpContractToolError[]): string;
179
193
  /**
180
194
  * A plugin MARKETPLACE (`.claude-plugin/marketplace.json`) decomposed into its
181
195
  * members. A marketplace either VENDORS its plugins in-tree (string `source`
package/dist/scan.js CHANGED
@@ -15,6 +15,8 @@
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.unexpectedScript = unexpectedScript;
17
17
  exports.scanPlugin = scanPlugin;
18
+ exports.verifyLiveMcpTools = verifyLiveMcpTools;
19
+ exports.formatMcpContractReport = formatMcpContractReport;
18
20
  exports.inspectMarketplace = inspectMarketplace;
19
21
  exports.expandMarketplace = expandMarketplace;
20
22
  exports.formatScanReport = formatScanReport;
@@ -31,6 +33,7 @@ const linters_js_1 = require("./core/linters.js");
31
33
  const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
32
34
  const description_overlap_js_1 = require("./core/description-overlap.js");
33
35
  const mcp_tool_js_1 = require("./core/mcp-tool.js");
36
+ const mcp_js_1 = require("./core/mcp.js");
34
37
  const mcp_hook_js_1 = require("./core/mcp-hook.js");
35
38
  const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
36
39
  const test_coverage_js_1 = require("./test-coverage.js");
@@ -535,6 +538,35 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
535
538
  puritySummary,
536
539
  };
537
540
  }
541
+ /**
542
+ * LIVE MCP tool resolution for a scanned plugin — the opt-in (`scan --verify-mcp`)
543
+ * dynamic check no static linter can do: it STARTS each declared MCP server and
544
+ * checks every `mcp__server__tool` the plugin's agents reference actually exists on
545
+ * it (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
546
+ * already-computed `report` (its agents' tool lists) + the declared server configs;
547
+ * returns `[]` when the plugin declares no MCP servers (nothing to start). Async +
548
+ * side-effecting (spawns servers) — which is exactly why it's opt-in, not a default
549
+ * lint rule. See `verifyMcpContractTools` (core/mcp.ts).
550
+ */
551
+ async function verifyLiveMcpTools(report, layout, dialect, timeoutMs = 10000) {
552
+ // collectMcpServers yields the raw JSON server entries; a malformed one (no
553
+ // command) just fails to start → server-unreachable (handled), so the cast is safe.
554
+ const servers = collectMcpServers((0, node_path_1.resolve)(report.dir), layout);
555
+ if (Object.keys(servers).length === 0)
556
+ return [];
557
+ const tools = report.agents.flatMap((a) => a.tools ?? []);
558
+ return (0, mcp_js_1.verifyMcpContractTools)(tools, servers, dialect, timeoutMs);
559
+ }
560
+ /** Render the live MCP tool-check result (human-readable). */
561
+ function formatMcpContractReport(errors) {
562
+ if (errors.length === 0) {
563
+ return "Live MCP tool check: every referenced mcp__server__tool resolves ✓";
564
+ }
565
+ const lines = [`Live MCP tool check — ${String(errors.length)} issue(s):`];
566
+ for (const e of errors)
567
+ lines.push(" ✗ " + (0, mcp_js_1.mcpContractToolMessage)(e));
568
+ return lines.join("\n");
569
+ }
538
570
  /**
539
571
  * Read a `marketplace.json` beside the layout's plugin manifest and classify its
540
572
  * members into on-disk vs external. Returns `null` when `dir` is not a
@@ -710,7 +742,7 @@ function formatScanReport(r) {
710
742
  // verdict — it points at the behavioral column, it doesn't fail the scan.
711
743
  const mismatched = r.skills.filter((s) => s.descriptionScript);
712
744
  if (mismatched.length > 0) {
713
- out.push(`⚠ ${String(mismatched.length)} skill(s) have descriptions in an unexpected script (cross-language trigger risk) — measure with \`scan --trigger\``, "");
745
+ out.push(`⚠ ${String(mismatched.length)} skill(s) have descriptions in an unexpected script (cross-language trigger risk) — measure with \`vigiles measure\``, "");
714
746
  }
715
747
  // Skill-metadata is a RECOMMENDATION, not a structural defect (the skill loads
716
748
  // via fallbacks) — reported as a soft note, never counted in the verdict.
@@ -155,7 +155,7 @@ function explainSurface(report, surface) {
155
155
  /** Render explanations for a CLI/report — grouped under the symptom, fix called out. */
156
156
  function formatExplanations(exps) {
157
157
  if (exps.length === 0) {
158
- return "No deterministic cause found — the cause is likely behavioral (measure with `scan --trigger` / an eval).";
158
+ return "No deterministic cause found — the cause is likely behavioral (measure with `vigiles measure` / an eval).";
159
159
  }
160
160
  const lines = [];
161
161
  for (const e of exps) {
@@ -0,0 +1,21 @@
1
+ export interface CommandRefIssue {
2
+ readonly file: string;
3
+ readonly line: number;
4
+ /** The offending invocation, e.g. `vigiles compile-hook`. */
5
+ readonly ref: string;
6
+ readonly reason: string;
7
+ }
8
+ export interface KnownCommands {
9
+ readonly verbs: readonly string[];
10
+ readonly kinds: readonly string[];
11
+ }
12
+ /**
13
+ * Find stale/unknown vigiles command references across the given files. Pure —
14
+ * the caller supplies file contents (so it works over the repo in a test, or any
15
+ * file set).
16
+ */
17
+ export declare function findStaleCommandRefs(files: readonly {
18
+ readonly path: string;
19
+ readonly content: string;
20
+ }[], known?: KnownCommands): CommandRefIssue[];
21
+ //# sourceMappingURL=self-command-refs.d.ts.map