vigiles 4.1.0 → 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/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 +86 -14
- package/dist/core/types.d.ts +17 -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.js +3 -1
- 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,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";
|