vigiles 4.0.2 → 5.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 +40 -8
- package/dist/adapter-registry.d.ts +45 -1
- package/dist/adapter-registry.js +78 -3
- package/dist/adapters/claude-code/agent-runtime.d.ts +1 -1
- package/dist/adapters/claude-code/agent-runtime.js +1 -1
- package/dist/arg-match.d.ts +28 -0
- package/dist/arg-match.js +61 -0
- package/dist/check.d.ts +52 -1
- package/dist/check.js +121 -0
- package/dist/claude-code.d.ts +1 -0
- package/dist/claude-code.js +11 -0
- package/dist/cli.js +211 -35
- package/dist/core/types.d.ts +27 -12
- package/dist/core/validate.js +14 -3
- package/dist/eval-cache.d.ts +49 -1
- package/dist/eval-cache.js +84 -4
- package/dist/eval.d.ts +243 -14
- package/dist/eval.js +540 -28
- package/dist/integration.d.ts +15 -10
- package/dist/integration.js +27 -10
- package/dist/linting.d.ts +4 -3
- package/dist/linting.js +4 -3
- package/dist/setup-plan.d.ts +11 -0
- package/dist/setup-plan.js +29 -0
- package/dist/skill-harness.d.ts +25 -0
- package/dist/skill-harness.js +40 -0
- package/dist/test-coverage.d.ts +11 -12
- package/dist/test-coverage.js +14 -19
- package/dist/testing.d.ts +4 -1
- package/dist/testing.js +24 -2
- package/dist/tool-intercept.d.ts +101 -0
- package/dist/tool-intercept.js +165 -0
- package/dist/tool-stub.d.ts +35 -0
- package/dist/tool-stub.js +92 -0
- package/package.json +11 -17
- package/skills/test-harness/SKILL.md +56 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_INTERCEPT_REASON = exports.INTERCEPT_TOOLS_ENV = void 0;
|
|
4
|
+
exports.decideIntercept = decideIntercept;
|
|
5
|
+
exports.interceptHookDecision = interceptHookDecision;
|
|
6
|
+
exports.buildInterceptSettings = buildInterceptSettings;
|
|
7
|
+
exports.serializeIntercepts = serializeIntercepts;
|
|
8
|
+
exports.parseIntercepts = parseIntercepts;
|
|
9
|
+
/**
|
|
10
|
+
* Tool interception — the eval-tier half of the tool-call spy.
|
|
11
|
+
*
|
|
12
|
+
* The eval tier drives the REAL model, so the agent's tool decisions are genuine.
|
|
13
|
+
* But letting a side-effecting tool actually run — a paid image API, `git push`,
|
|
14
|
+
* spawning a paid subagent — makes the eval expensive and dangerous. An
|
|
15
|
+
* **intercept** lets the model emit the `tool_use` (so its arguments are captured
|
|
16
|
+
* in the `Trace`, where `toolWith` / `notTool` assert on them) while a PreToolUse
|
|
17
|
+
* hook **denies the real execution** with a block message. The run stays cheap and
|
|
18
|
+
* side-effect-free, but the agent's *decision* — the thing a completion grader
|
|
19
|
+
* can't see — is fully observable.
|
|
20
|
+
*
|
|
21
|
+
* This module is the PURE decision + wiring core, mirroring the agent-contract
|
|
22
|
+
* rail (`src/adapters/claude-code/agent-runtime.ts`):
|
|
23
|
+
*
|
|
24
|
+
* - `decideIntercept` — does this call get intercepted, and with what deny reason;
|
|
25
|
+
* - `buildInterceptSettings` — the PreToolUse hook fragment routing matched tools
|
|
26
|
+
* through `vigiles intercept-tool-hook`;
|
|
27
|
+
* - `serializeIntercepts` / `parseIntercepts` — the env round-trip (incl. RegExp
|
|
28
|
+
* matchers) the hook subprocess reads back.
|
|
29
|
+
*
|
|
30
|
+
* The hook denies via exit 2 + a stderr message (the same block mechanism the
|
|
31
|
+
* agent rail uses). IMPORTANT — Claude Code surfaces that to the model as a
|
|
32
|
+
* *blocked* call: the tool is intercepted (prevented), NOT executed, and the model
|
|
33
|
+
* is NOT handed a faked successful return. So this is **intercept-and-prevent +
|
|
34
|
+
* observe the attempt**, not a faithful tool mock: it's sound for "did the agent
|
|
35
|
+
* ATTEMPT X / call it with these args / push to the wrong branch" (first-attempt
|
|
36
|
+
* questions, where what happens after doesn't matter), and unsound for "stub the
|
|
37
|
+
* tool and let the trajectory continue as if it succeeded" (the model is told it
|
|
38
|
+
* was blocked, so a multi-step flow that needs the real result will derail). CC
|
|
39
|
+
* exposes no "skip execution but return this as success" primitive for arbitrary
|
|
40
|
+
* tools, so deny+reason is the closest available — with that ceiling.
|
|
41
|
+
*/
|
|
42
|
+
const arg_match_js_1 = require("./arg-match.js");
|
|
43
|
+
/** Env var the spawned `vigiles intercept-tool-hook` reads its intercept list from. */
|
|
44
|
+
exports.INTERCEPT_TOOLS_ENV = "VIGILES_INTERCEPT_TOOLS";
|
|
45
|
+
/** The default denial reason — honest that the call was intercepted (prevented), NOT executed. */
|
|
46
|
+
exports.DEFAULT_INTERCEPT_REASON = "vigiles intercepted this tool call for testing — it was NOT executed. " +
|
|
47
|
+
"Do not retry it; treat the tool as unavailable and continue.";
|
|
48
|
+
/**
|
|
49
|
+
* Decide whether a tool call should be intercepted. Returns the first matching
|
|
50
|
+
* intercept's denial reason (preventing real execution), or `{ intercept: false }`
|
|
51
|
+
* to let the call run for real. Pure — the same logic `vigiles intercept-tool-hook`
|
|
52
|
+
* runs.
|
|
53
|
+
*/
|
|
54
|
+
function decideIntercept(toolName, input, intercepts) {
|
|
55
|
+
for (const i of intercepts) {
|
|
56
|
+
if (i.tool !== toolName)
|
|
57
|
+
continue;
|
|
58
|
+
if (i.when && !(0, arg_match_js_1.matchesArgs)(input, i.when))
|
|
59
|
+
continue;
|
|
60
|
+
return {
|
|
61
|
+
intercept: true,
|
|
62
|
+
denyReason: i.denyReason ?? exports.DEFAULT_INTERCEPT_REASON,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { intercept: false };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Decide from a raw PreToolUse event JSON (the hook's stdin). Parses `tool_name`
|
|
69
|
+
* + `tool_input`, then defers to {@link decideIntercept}. Malformed input or a
|
|
70
|
+
* missing tool name is a no-op (let it run) — never fail closed on a parse error.
|
|
71
|
+
*/
|
|
72
|
+
function interceptHookDecision(rawEvent, intercepts) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(rawEvent);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { intercept: false };
|
|
79
|
+
}
|
|
80
|
+
const tool = parsed.tool_name ?? "";
|
|
81
|
+
if (!tool)
|
|
82
|
+
return { intercept: false };
|
|
83
|
+
return decideIntercept(tool, parsed.tool_input ?? {}, intercepts);
|
|
84
|
+
}
|
|
85
|
+
function escapeRegex(s) {
|
|
86
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
87
|
+
}
|
|
88
|
+
function uniqueToolNames(intercepts) {
|
|
89
|
+
return [...new Set(intercepts.map((i) => i.tool))];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the PreToolUse hook fragment that routes every intercepted tool through
|
|
93
|
+
* `vigiles intercept-tool-hook`. The `matcher` is a CC tool-name regex over the
|
|
94
|
+
* union of intercepted tool names (each escaped), so unrelated tools are never
|
|
95
|
+
* intercepted. Merge the result into an arm's `settings`; the intercept list
|
|
96
|
+
* itself travels in the {@link INTERCEPT_TOOLS_ENV} env var (see
|
|
97
|
+
* {@link serializeIntercepts}).
|
|
98
|
+
*/
|
|
99
|
+
function buildInterceptSettings(intercepts, opts = {}) {
|
|
100
|
+
const command = opts.command ?? "npx vigiles intercept-tool-hook";
|
|
101
|
+
const matcher = uniqueToolNames(intercepts).map(escapeRegex).join("|");
|
|
102
|
+
return {
|
|
103
|
+
hooks: {
|
|
104
|
+
PreToolUse: [{ matcher, hooks: [{ type: "command", command }] }],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function isWireRegex(v) {
|
|
109
|
+
return typeof v === "object" && v !== null && "re" in v;
|
|
110
|
+
}
|
|
111
|
+
function encodeMatcher(m) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [k, v] of Object.entries(m)) {
|
|
114
|
+
out[k] = v instanceof RegExp ? { re: v.source, flags: v.flags } : v;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function decodeMatcher(w) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [k, v] of Object.entries(w)) {
|
|
121
|
+
out[k] = isWireRegex(v) ? new RegExp(v.re, v.flags) : v;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Serialize an intercept list to a JSON string for {@link INTERCEPT_TOOLS_ENV}.
|
|
127
|
+
* RegExp matchers are encoded as `{ re, flags }` so they round-trip exactly (a
|
|
128
|
+
* plain `JSON.stringify` would drop them to `{}`).
|
|
129
|
+
*/
|
|
130
|
+
function serializeIntercepts(intercepts) {
|
|
131
|
+
return JSON.stringify(intercepts.map((i) => ({
|
|
132
|
+
tool: i.tool,
|
|
133
|
+
denyReason: i.denyReason,
|
|
134
|
+
when: i.when ? encodeMatcher(i.when) : undefined,
|
|
135
|
+
})));
|
|
136
|
+
}
|
|
137
|
+
/** Parse an intercept list from the env JSON (tolerant — a bad entry is skipped). */
|
|
138
|
+
function parseIntercepts(json) {
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = JSON.parse(json);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(data))
|
|
147
|
+
return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const item of data) {
|
|
150
|
+
if (item === null || typeof item !== "object")
|
|
151
|
+
continue;
|
|
152
|
+
const o = item;
|
|
153
|
+
if (typeof o.tool !== "string")
|
|
154
|
+
continue;
|
|
155
|
+
out.push({
|
|
156
|
+
tool: o.tool,
|
|
157
|
+
denyReason: typeof o.denyReason === "string" ? o.denyReason : undefined,
|
|
158
|
+
when: o.when !== null && typeof o.when === "object"
|
|
159
|
+
? decodeMatcher(o.when)
|
|
160
|
+
: undefined,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=tool-intercept.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single fake binary: shadow the tool named `name` on PATH so that any
|
|
3
|
+
* invocation prints `stdout` (default `""`), writes `stderr` (default none) to
|
|
4
|
+
* fd 2, and exits with `exitCode` (default 0). Argv is ignored in the MVP.
|
|
5
|
+
*/
|
|
6
|
+
export interface ToolStub {
|
|
7
|
+
/** The binary name to shadow on PATH (e.g. `"gh"`, `"psql"`). */
|
|
8
|
+
readonly name: string;
|
|
9
|
+
/** Canned stdout the fake prints. Default `""`. */
|
|
10
|
+
readonly stdout?: string;
|
|
11
|
+
/** Canned stderr the fake writes to fd 2. Default: none. */
|
|
12
|
+
readonly stderr?: string;
|
|
13
|
+
/** Exit code the fake returns. Default `0`. */
|
|
14
|
+
readonly exitCode?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render the POSIX shell script for one stub. `printf '%s'` (not `echo`) prints
|
|
18
|
+
* the decoded bytes with no added trailing newline and no backslash/`-n`
|
|
19
|
+
* surprises, so the fixture round-trips byte-for-byte.
|
|
20
|
+
*/
|
|
21
|
+
export declare function renderToolStub(stub: ToolStub): string;
|
|
22
|
+
/**
|
|
23
|
+
* Write one executable POSIX shell stub per entry into `binDir` (which must
|
|
24
|
+
* already exist). Each file is named exactly `stub.name` and `chmod 0o755` so it
|
|
25
|
+
* is directly executable once `binDir` is on PATH. Pure-ish — fs only, no spawn.
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeToolStubs(binDir: string, stubs: readonly ToolStub[]): void;
|
|
28
|
+
/**
|
|
29
|
+
* Convenience: mkdtemp a fresh bin dir under `parentDir`, write `stubs` into it,
|
|
30
|
+
* and return its absolute path. Caller PREPENDS this dir to PATH so the fakes win
|
|
31
|
+
* over the real binaries, and removes it when done (it lives under `parentDir`,
|
|
32
|
+
* so a `parentDir` cleanup also clears it).
|
|
33
|
+
*/
|
|
34
|
+
export declare function stubBinDir(stubs: readonly ToolStub[], parentDir: string): string;
|
|
35
|
+
//# sourceMappingURL=tool-stub.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderToolStub = renderToolStub;
|
|
4
|
+
exports.writeToolStubs = writeToolStubs;
|
|
5
|
+
exports.stubBinDir = stubBinDir;
|
|
6
|
+
/**
|
|
7
|
+
* vigiles — **tool stubs on PATH** (rung R2 of the eval coverage model).
|
|
8
|
+
*
|
|
9
|
+
* A skill/hook/agent often calls a CLI tool (`psql`, `redis-cli`, `gh`, `git`,
|
|
10
|
+
* `z3`, …) and then works with the RESULT. To test that downstream logic without
|
|
11
|
+
* a live service, you SHADOW the real binary on PATH with a fake that emits a
|
|
12
|
+
* **recorded / author-provided canned result** — a standard PATH-shim +
|
|
13
|
+
* VCR-style record/replay technique, not a novel invention. See
|
|
14
|
+
* `research/eval-coverage-and-isolation.md` (the three-rung model).
|
|
15
|
+
*
|
|
16
|
+
* This module is the REPLAY half: write a fake executable per tool that prints a
|
|
17
|
+
* canned stdout/stderr and exits a canned code. A record-from-real-tool half (run
|
|
18
|
+
* the real binary once at a known version, capture its output as the fixture) is
|
|
19
|
+
* a documented follow-on — NOT implemented here.
|
|
20
|
+
*
|
|
21
|
+
* CRITICAL: the canned outputs are author/recorded fixtures, **never**
|
|
22
|
+
* model-synthesized — a synthesized `gh`/`git` output looks plausible but
|
|
23
|
+
* diverges from the real tool/version, producing false confidence (a green test
|
|
24
|
+
* against a fiction).
|
|
25
|
+
*
|
|
26
|
+
* MVP scope: one canned result per binary; argv is IGNORED (every invocation of
|
|
27
|
+
* the stub returns the same result). Argv-matching (different output per
|
|
28
|
+
* sub-command / flags) is a follow-on.
|
|
29
|
+
*/
|
|
30
|
+
const node_fs_1 = require("node:fs");
|
|
31
|
+
const node_path_1 = require("node:path");
|
|
32
|
+
/**
|
|
33
|
+
* Encode a string as a single-line base64 literal safe to embed verbatim inside a
|
|
34
|
+
* POSIX shell script.
|
|
35
|
+
*
|
|
36
|
+
* We base64-encode-and-decode the canned content (rather than interpolating it
|
|
37
|
+
* raw, or `cat`ing a sibling data file) because base64's alphabet is a strict
|
|
38
|
+
* subset of `[A-Za-z0-9+/=]` — it can never contain a quote, `$`, backtick,
|
|
39
|
+
* newline, `;`, or any other shell metacharacter, so the embedded literal is
|
|
40
|
+
* injection-proof regardless of the fixture's bytes. The script decodes it back
|
|
41
|
+
* with `base64 -d` at run time, so the original bytes round-trip exactly. This is
|
|
42
|
+
* simpler than a sibling data file (one self-contained script, nothing else to
|
|
43
|
+
* write/clean up) and strictly safer than quoting.
|
|
44
|
+
*/
|
|
45
|
+
function b64(s) {
|
|
46
|
+
return Buffer.from(s, "utf-8").toString("base64");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Render the POSIX shell script for one stub. `printf '%s'` (not `echo`) prints
|
|
50
|
+
* the decoded bytes with no added trailing newline and no backslash/`-n`
|
|
51
|
+
* surprises, so the fixture round-trips byte-for-byte.
|
|
52
|
+
*/
|
|
53
|
+
function renderToolStub(stub) {
|
|
54
|
+
const lines = ["#!/bin/sh"];
|
|
55
|
+
// stdout: decode the base64 literal straight to stdout. We pipe `base64 -d`'s
|
|
56
|
+
// output directly (NOT through `printf '%s' "$(...)"`) because command
|
|
57
|
+
// substitution strips trailing newlines — piping preserves the bytes exactly.
|
|
58
|
+
if (stub.stdout !== undefined && stub.stdout !== "") {
|
|
59
|
+
lines.push(`printf '%s' '${b64(stub.stdout)}' | base64 -d`);
|
|
60
|
+
}
|
|
61
|
+
// stderr: same, redirected to fd 2.
|
|
62
|
+
if (stub.stderr !== undefined && stub.stderr !== "") {
|
|
63
|
+
lines.push(`printf '%s' '${b64(stub.stderr)}' | base64 -d >&2`);
|
|
64
|
+
}
|
|
65
|
+
lines.push(`exit ${String(stub.exitCode ?? 0)}`);
|
|
66
|
+
return lines.join("\n") + "\n";
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Write one executable POSIX shell stub per entry into `binDir` (which must
|
|
70
|
+
* already exist). Each file is named exactly `stub.name` and `chmod 0o755` so it
|
|
71
|
+
* is directly executable once `binDir` is on PATH. Pure-ish — fs only, no spawn.
|
|
72
|
+
*/
|
|
73
|
+
function writeToolStubs(binDir, stubs) {
|
|
74
|
+
for (const stub of stubs) {
|
|
75
|
+
const file = (0, node_path_1.join)(binDir, stub.name);
|
|
76
|
+
(0, node_fs_1.writeFileSync)(file, renderToolStub(stub), "utf-8");
|
|
77
|
+
(0, node_fs_1.chmodSync)(file, 0o755);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Convenience: mkdtemp a fresh bin dir under `parentDir`, write `stubs` into it,
|
|
82
|
+
* and return its absolute path. Caller PREPENDS this dir to PATH so the fakes win
|
|
83
|
+
* over the real binaries, and removes it when done (it lives under `parentDir`,
|
|
84
|
+
* so a `parentDir` cleanup also clears it).
|
|
85
|
+
*/
|
|
86
|
+
function stubBinDir(stubs, parentDir) {
|
|
87
|
+
(0, node_fs_1.mkdirSync)(parentDir, { recursive: true });
|
|
88
|
+
const binDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(parentDir, "vigiles-stub-bin-"));
|
|
89
|
+
writeToolStubs(binDir, stubs);
|
|
90
|
+
return binDir;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=tool-stub.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Compile .spec.ts files to instruction files (CLAUDE.md, AGENTS.md) with linter cross-referencing",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vigiles": "dist/cli.js"
|
|
@@ -9,31 +9,20 @@
|
|
|
9
9
|
"types": "./dist/core/spec.d.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./dist/core/spec.js",
|
|
12
|
+
"./spec": "./dist/core/spec.js",
|
|
13
|
+
"./linting": "./dist/linting.js",
|
|
14
|
+
"./testing": "./dist/testing.js",
|
|
12
15
|
"./unit": "./dist/unit.js",
|
|
13
16
|
"./integration": "./dist/integration.js",
|
|
14
17
|
"./e2e": "./dist/e2e.js",
|
|
15
|
-
"./linting": "./dist/linting.js",
|
|
16
|
-
"./testing": "./dist/testing.js",
|
|
17
18
|
"./claude-code": "./dist/claude-code.js",
|
|
18
19
|
"./codex": "./dist/codex.js",
|
|
19
20
|
"./adapter": "./dist/adapter.js",
|
|
20
|
-
"./spec": "./dist/core/spec.js",
|
|
21
|
-
"./compile": "./dist/core/compile.js",
|
|
22
|
-
"./linters": "./dist/core/linters.js",
|
|
23
|
-
"./eval": "./dist/eval.js",
|
|
24
|
-
"./harness-test": "./dist/harness-test.js",
|
|
25
|
-
"./harness-assert": "./dist/harness-assert.js",
|
|
26
|
-
"./run-hook": "./dist/run-hook.js",
|
|
27
|
-
"./plugin-loader": "./dist/adapters/claude-code/plugin-loader.js",
|
|
28
|
-
"./mcp": "./dist/core/mcp.js",
|
|
29
|
-
"./judge": "./dist/judge.js",
|
|
30
|
-
"./mock-model": "./dist/mock-model.js",
|
|
31
21
|
"./vitest": {
|
|
32
22
|
"types": "./dist/vitest.d.mts",
|
|
33
23
|
"default": "./dist/vitest.mjs"
|
|
34
24
|
},
|
|
35
|
-
"./jest": "./dist/jest.js"
|
|
36
|
-
"./check": "./dist/check.js"
|
|
25
|
+
"./jest": "./dist/jest.js"
|
|
37
26
|
},
|
|
38
27
|
"files": [
|
|
39
28
|
"dist/**/*.js",
|
|
@@ -67,10 +56,15 @@
|
|
|
67
56
|
"test:vitest": "npm run build && vitest run --project runners",
|
|
68
57
|
"test:jest": "npm run build && jest",
|
|
69
58
|
"test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json",
|
|
70
|
-
"demo:plugin": "npm run build && node examples/plugin-test-demo.mjs"
|
|
59
|
+
"demo:plugin": "npm run build && node examples/plugin-test-demo.mjs",
|
|
60
|
+
"api:report": "npm run build && node scripts/api-extractor.mjs --local",
|
|
61
|
+
"api:check": "npm run build && node scripts/api-extractor.mjs",
|
|
62
|
+
"docs:api": "npm run api:report && api-documenter markdown -i temp -o api-reference"
|
|
71
63
|
},
|
|
72
64
|
"devDependencies": {
|
|
73
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
+
"@microsoft/api-documenter": "^7.30.7",
|
|
67
|
+
"@microsoft/api-extractor": "^7.58.9",
|
|
74
68
|
"@types/js-yaml": "^4.0.9",
|
|
75
69
|
"@types/minimatch": "^5.1.2",
|
|
76
70
|
"@types/node": "^20.19.39",
|
|
@@ -22,7 +22,8 @@ Match what you're testing to the cheapest tier that can answer it:
|
|
|
22
22
|
| "Is the hook actually **wired into** the assembled plugin and does it fire in a real session?" | **Deterministic** | free, no API key (real `claude` + scripted mock) | `runHarnessTest` + `scriptModel` |
|
|
23
23
|
| "Did the injected context (a SessionStart hook, a `/command`) actually **reach the model**?" | **Deterministic** | free, no API key | `runHarnessTest` → `trace.modelRequests` / `assertRequestContains` |
|
|
24
24
|
| "Does this skill's **description trigger** when it should (recall) **and stay quiet** when it shouldn't (precision)?" | **Eval** | **paid** (real model) | `measureTriggerRate` (+ `irrelevantPrompts`) → `assertTriggerRate({ min, maxFalsePositive })` |
|
|
25
|
-
| "
|
|
25
|
+
| "Is this exact skill's **output any good**?" — absolute quality, no on/off baseline (the default for testing one skill) | **Eval** | **paid** (real model) | `measure({ checks: [judged(rubric)] })` → `assertRates({ min })` |
|
|
26
|
+
| "Does this harness change **move what the agent does**, _relative_ to off?" — A/B lift, regression, signal vs noise | **Eval** | **paid** (real model) | `runEval` (arms) + `assertSignificant` |
|
|
26
27
|
|
|
27
28
|
Most harness questions — block/allow, wired-in, context-landed — never need a
|
|
28
29
|
model. Only "does the model trigger / behave differently" needs the eval tier.
|
|
@@ -30,6 +31,37 @@ model. Only "does the model trigger / behave differently" needs the eval tier.
|
|
|
30
31
|
If the unit and deterministic tiers can both answer it, **prefer unit**: it's
|
|
31
32
|
faster and reaches events the deterministic mock can't drive.
|
|
32
33
|
|
|
34
|
+
## Step 0.5 — Set honest expectations (what's testable, and at what cost)
|
|
35
|
+
|
|
36
|
+
Be explicit with the user about which bucket each surface falls into — never let
|
|
37
|
+
"we'll test it" hide whether that's free, sub-priced, or needs a container. Every
|
|
38
|
+
surface sorts into one of three buckets:
|
|
39
|
+
|
|
40
|
+
- **A — Free & deterministic** (no model, runs in CI on every commit): a hook's
|
|
41
|
+
block/allow decision (`runHook`), a tool-contract / "did NOT call the forbidden
|
|
42
|
+
tool" check, structural facts (`vigiles scan`), and **record-replay** of any tool
|
|
43
|
+
a skill shells out to (record the real result once, replay it via a PATH stub).
|
|
44
|
+
- **B — Model-gated, on your subscription** (real model, **no metered API**): does a
|
|
45
|
+
skill's description **fire** (`measureTriggerRate`, recall + precision) **and**
|
|
46
|
+
does its guidance actually **produce good output** (score it directly:
|
|
47
|
+
`measure({ checks: [judged(rubric)] })` + `assertRates` — the absolute oracle;
|
|
48
|
+
use a `runEval` A/B on-vs-off only when you need the _relative_ lift). This is
|
|
49
|
+
the half a **prose / guidance skill** lives in —
|
|
50
|
+
its worth is behavioral, so only a model can judge it. That is **not** "uncovered"
|
|
51
|
+
and **not** free: it's fully testable on the sub. State it that way.
|
|
52
|
+
- **C — Needs a real service** (a real browser / DB / redis / a11y runtime): vigiles
|
|
53
|
+
**composes with a container** here; it does not fake real semantics. Name the
|
|
54
|
+
service and hand off — don't pretend a cheap tier substitutes for it.
|
|
55
|
+
|
|
56
|
+
So a prose-skill library is roughly **~100% testable (some free, most on your sub),
|
|
57
|
+
~0% needs-a-container** — not "poorly covered." An accessibility/browser plugin is
|
|
58
|
+
the worst case, with a large bucket C. When you report coverage, give **two
|
|
59
|
+
numbers**: "% testable at all (free + sub)" vs "% that needs a container", and say
|
|
60
|
+
which surfaces are free vs sub-priced. The model-gated half is the **point** of the
|
|
61
|
+
eval pillar (affordable on the sub), not a gap — and testing a prose skill's
|
|
62
|
+
_behavior_ requires a real model for **everyone** (promptfoo, the SDKs, all of it);
|
|
63
|
+
vigiles just does it on your subscription instead of metered API.
|
|
64
|
+
|
|
33
65
|
## Step 1 — Ensure vigiles is installed
|
|
34
66
|
|
|
35
67
|
Check whether `vigiles` is a dependency (`package.json`), and install it as a
|
|
@@ -103,8 +135,29 @@ assertHookFired(r, "SessionStart");
|
|
|
103
135
|
assertRequestContains(r, "expected injected text"); // did it actually land?
|
|
104
136
|
```
|
|
105
137
|
|
|
106
|
-
**Eval (`
|
|
107
|
-
|
|
138
|
+
**Eval — absolute (`measure` + `judged`)** — testing _one_ skill, the usual case:
|
|
139
|
+
score its output directly against a rubric. No on/off baseline — this is the
|
|
140
|
+
"is it any good?" oracle (what promptfoo/DeepEval lead with), and the right
|
|
141
|
+
default when there's nothing to compare against:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { measure, judged, skill, assertRates } from "vigiles/testing";
|
|
145
|
+
|
|
146
|
+
const report = await measure({
|
|
147
|
+
pluginDir: "./",
|
|
148
|
+
task: "…a task the skill should handle…",
|
|
149
|
+
checks: [
|
|
150
|
+
skill("my-plugin:my-skill"), // it fired
|
|
151
|
+
judged("the answer correctly does X and avoids Y"), // …and the output is good
|
|
152
|
+
],
|
|
153
|
+
trials: 6,
|
|
154
|
+
});
|
|
155
|
+
assertRates(report, { min: 0.8 }); // each check passes ≥ 80% of trials
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Eval — relative (`runEval` + `assertSignificant`)** — when the question is
|
|
159
|
+
_lift over no-skill_ (regression, or proving a change isn't noise): A/B the
|
|
160
|
+
change on vs off and gate on significance, not eyeballing:
|
|
108
161
|
|
|
109
162
|
```ts
|
|
110
163
|
import { runEval, assertSignificant } from "vigiles/testing";
|