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.
- package/README.md +99 -48
- package/dist/action-gate.js +1 -1
- package/dist/adapters/claude-code/agent-runtime.d.ts +64 -4
- package/dist/adapters/claude-code/agent-runtime.js +131 -17
- package/dist/adapters/claude-code/dialect.d.ts +34 -0
- package/dist/adapters/claude-code/dialect.js +46 -33
- package/dist/adapters/claude-code/effect-region.js +1 -1
- package/dist/adapters/claude-code/skill-runtime.d.ts +1 -1
- package/dist/adapters/claude-code/skill-runtime.js +1 -9
- package/dist/adapters/claude-code/typed-spec.d.ts +58 -0
- package/dist/adapters/claude-code/typed-spec.js +55 -0
- package/dist/adapters/codex/hook-protocol.js +3 -0
- package/dist/adapters/codex/mock-model.js +1 -1
- package/dist/claude-code.d.ts +1 -0
- package/dist/claude-code.js +8 -1
- package/dist/cli-commands.d.ts +19 -0
- package/dist/cli-commands.js +51 -0
- package/dist/cli.js +735 -76
- package/dist/core/bash-effects.d.ts +12 -0
- package/dist/core/bash-effects.js +31 -0
- package/dist/core/capability-diff.d.ts +46 -0
- package/dist/core/capability-diff.js +97 -0
- package/dist/core/compile.d.ts +1 -1
- package/dist/core/compile.js +14 -0
- package/dist/core/generate-harness.d.ts +187 -0
- package/dist/core/generate-harness.js +337 -0
- package/dist/core/guards.d.ts +126 -0
- package/dist/core/guards.js +309 -0
- package/dist/core/harness-driver.d.ts +1 -1
- package/dist/core/hook-program.d.ts +459 -0
- package/dist/core/hook-program.js +468 -0
- package/dist/core/hook-protocol.d.ts +7 -0
- package/dist/core/hook-providers.d.ts +138 -0
- package/dist/core/hook-providers.js +155 -0
- package/dist/core/hook-spec.d.ts +74 -0
- package/dist/core/hook-spec.js +130 -0
- package/dist/core/inline.js +1 -1
- package/dist/core/mcp-tool.d.ts +12 -0
- package/dist/core/mcp-tool.js +20 -0
- package/dist/core/mcp.d.ts +13 -0
- package/dist/core/mcp.js +67 -0
- package/dist/core/spec.d.ts +290 -8
- package/dist/core/spec.js +118 -3
- package/dist/core/types.d.ts +8 -0
- package/dist/dialect-drift.d.ts +65 -0
- package/dist/dialect-drift.js +216 -0
- package/dist/eval.d.ts +40 -5
- package/dist/eval.js +59 -5
- package/dist/guardrail-check.d.ts +85 -0
- package/dist/guardrail-check.js +152 -0
- package/dist/harness-assert.d.ts +10 -0
- package/dist/harness-assert.js +30 -0
- package/dist/hook-install.d.ts +43 -0
- package/dist/hook-install.js +91 -0
- package/dist/hook.d.ts +52 -0
- package/dist/hook.js +98 -0
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +43 -1
- package/dist/linting.d.ts +9 -5
- package/dist/linting.js +17 -5
- package/dist/optimize.js +1 -1
- package/dist/scaffold-test.d.ts +28 -0
- package/dist/scaffold-test.js +134 -15
- package/dist/scan-behavioral.d.ts +60 -0
- package/dist/scan-behavioral.js +239 -1
- package/dist/scan.d.ts +14 -0
- package/dist/scan.js +33 -1
- package/dist/score-explainer.js +1 -1
- package/dist/self-command-refs.d.ts +21 -0
- package/dist/self-command-refs.js +125 -0
- package/dist/testing.d.ts +5 -3
- package/dist/testing.js +37 -23
- package/dist/tool-intercept.d.ts +4 -4
- package/dist/tool-intercept.js +5 -5
- package/dist/unit.d.ts +2 -0
- package/dist/unit.js +8 -1
- package/hooks/refs-nudge.sh +1 -1
- package/package.json +5 -3
package/dist/scaffold-test.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
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
|
|
104
|
-
function
|
|
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
|
|
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
|
package/dist/scan-behavioral.js
CHANGED
|
@@ -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 {
|
|
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 \`
|
|
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.
|
package/dist/score-explainer.js
CHANGED
|
@@ -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 `
|
|
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
|