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
package/README.md
CHANGED
|
@@ -27,10 +27,10 @@ deterministic layer for the harness: it **lints** the references your instructio
|
|
|
27
27
|
files make and **tests** that your hooks and skills actually fire. Two independent
|
|
28
28
|
pillars — adopt either, or both:
|
|
29
29
|
|
|
30
|
-
| | Pillar | What it does
|
|
31
|
-
| ----- | ------------------------------- |
|
|
32
|
-
| **①** | **Lint your instruction files** | Every linter rule, file path, script, and code symbol your CLAUDE.md cites is checked against reality, so stale references can't silently mislead the agent. → [guide](docs/verifying-instruction-files.md)
|
|
33
|
-
| **②** | **Test your harness** | Your hooks and skills are code — vigiles tests they actually fire, **deterministically and free** (no model, no API key)
|
|
30
|
+
| | Pillar | What it does |
|
|
31
|
+
| ----- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| **①** | **Lint your instruction files** | Every linter rule, file path, script, and code symbol your CLAUDE.md cites is checked against reality, so stale references can't silently mislead the agent. → [guide](docs/verifying-instruction-files.md) |
|
|
33
|
+
| **②** | **Test your harness** | Your hooks and skills are code — vigiles tests they actually fire, **deterministically and free** (no model, no API key); and when a question _does_ need a real-model eval, it runs on your **Claude subscription**, not metered API. → [guide](docs/harness-testing.md) |
|
|
34
34
|
|
|
35
35
|
Neither pillar depends on the other — pick the one that hurts today. **Works with
|
|
36
36
|
Claude Code and Codex** ([`vigiles/codex`](docs/harnesses.md)) behind a five-port
|
|
@@ -93,10 +93,42 @@ assert(r.blocked); // a red ✗ here means your hook silently lets it through
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
Three tiers, cheapest first: **`runHook`** (a hook's logic), **`runHarnessTest`**
|
|
96
|
-
(the real agent CLI against a scripted mock model),
|
|
97
|
-
|
|
98
|
-
its description
|
|
99
|
-
hijacking unrelated ones
|
|
96
|
+
(the real agent CLI against a scripted mock model), and the real-model scored tier
|
|
97
|
+
(**`measure`** / **`runEval`**). **Testing a skill?** Two questions, both covered:
|
|
98
|
+
does its description **fire** (`measureTriggerRate` — recall across varied prompts
|
|
99
|
+
without hijacking unrelated ones, precision), **and** does its guidance actually
|
|
100
|
+
**work**. For "is this exact skill any good?" score the output directly —
|
|
101
|
+
`measure({ checks: [judged(rubric)] })` + `assertRates` (the **absolute** oracle,
|
|
102
|
+
what promptfoo/DeepEval lead with; no on/off baseline needed). When you need the
|
|
103
|
+
**relative** lift over no-skill — regression, or proving the change isn't noise —
|
|
104
|
+
A/B it on-vs-off with `runEval` + `assertSignificant`. Description _and_ behavior,
|
|
105
|
+
not just one. **Need a safety property** — that the agent
|
|
106
|
+
**didn't** push to the wrong branch or call a paid API? `notTool` + `interceptTools`
|
|
107
|
+
intercept the tool in the real hook layer, so the attempt is caught and the side
|
|
108
|
+
effect never happens. **[Full guide →](docs/harness-testing.md)** · vigiles runs
|
|
109
|
+
foreign code (and a real model) safely by default — **[safety model →](docs/safety.md)**
|
|
110
|
+
|
|
111
|
+
Most of what real plugins do is testable cheaply — fire / trigger / contract /
|
|
112
|
+
safety, plus **record-replay** for the tool/API results a skill consumes (recorded
|
|
113
|
+
once from the real tool, replayed deterministically — no live service, no Docker).
|
|
114
|
+
That covers ~90%+ of real plugin surface on your subscription; the rare case that
|
|
115
|
+
needs a real browser or database **composes with Docker** rather than us
|
|
116
|
+
reinventing the sandbox. **[What we test, how →](research/eval-coverage-and-isolation.md)**
|
|
117
|
+
|
|
118
|
+
**Affordable by design — the eval you can actually run.** Almost nobody evals
|
|
119
|
+
their harness, because the usual tools (promptfoo, DeepEval, …) hit the API SDK
|
|
120
|
+
and bill **per token on every run**. vigiles inverts that: most questions are
|
|
121
|
+
answered with **no model at all** (free, every commit), and when you do reach for
|
|
122
|
+
a real-model eval, vigiles drives your `claude` CLI — so it runs on the **Pro/Max
|
|
123
|
+
subscription you already pay for**, not metered API billing. CI runs only the free
|
|
124
|
+
deterministic tiers; you run the real-model eval where the subscription already is
|
|
125
|
+
— a Claude Code session or locally — when it's worth it, not on every PR.
|
|
126
|
+
|
|
127
|
+
This affordability story is **ToS-clean**: vigiles drives _your own_ `claude` CLI
|
|
128
|
+
to test _your own_ harness on _your own_ subscription — the same thing you do when
|
|
129
|
+
you run Claude Code. (The Claude Agent SDK's ToS restricts _productizing_ claude.ai
|
|
130
|
+
login/limits in a third-party offering; running your own tests on your own sub is
|
|
131
|
+
exactly the supported posture, not that.)
|
|
100
132
|
|
|
101
133
|
## Quick start
|
|
102
134
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A subagent declares an allowed-tools contract in its frontmatter (`tools:`).
|
|
5
5
|
* But that field is documentation, not a hard runtime boundary (Claude Code
|
|
6
|
-
*
|
|
6
|
+
* #4740/#21460, SDK #172): permissions are session-wide, a subagent inherits the parent
|
|
7
7
|
* session's grants, and `tools:` only filters what's *offered* — it can't deny
|
|
8
8
|
* what the session allows. The deterministic layer that actually closes the gap
|
|
9
9
|
* is a **PreToolUse hook** that blocks any tool the active agent's contract
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A subagent declares an allowed-tools contract in its frontmatter (`tools:`).
|
|
6
6
|
* But that field is documentation, not a hard runtime boundary (Claude Code
|
|
7
|
-
*
|
|
7
|
+
* #4740/#21460, SDK #172): permissions are session-wide, a subagent inherits the parent
|
|
8
8
|
* session's grants, and `tools:` only filters what's *offered* — it can't deny
|
|
9
9
|
* what the session allows. The deterministic layer that actually closes the gap
|
|
10
10
|
* is a **PreToolUse hook** that blocks any tool the active agent's contract
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ArgMatcher` — a small, serializable matcher over a tool call's `input`.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the check vocabulary (`toolWith` / `notTool` in `src/check.ts`) and
|
|
5
|
+
* the tool-interception seam (`src/tool-intercept.ts`), so "did the agent call
|
|
6
|
+
* this tool with these arguments?" means the same thing whether you're *asserting*
|
|
7
|
+
* on a captured call or *intercepting* one before it runs. Pure + model-free.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A declarative matcher over a tool call's `input`, keyed by **dot-path** (e.g.
|
|
11
|
+
* `"body.prompt"`). Each value is matched against the value at that path: a
|
|
12
|
+
* `RegExp` is a pattern over the stringified value (use this for "contains"), and
|
|
13
|
+
* a `string`/`number`/`boolean` is an **exact** match (use this for "equals", e.g.
|
|
14
|
+
* a push target). All keys must match (AND). Serializable, so anything carrying
|
|
15
|
+
* one still round-trips through `toJSON`.
|
|
16
|
+
*/
|
|
17
|
+
export type ArgMatcher = Record<string, string | number | boolean | RegExp>;
|
|
18
|
+
/** Render any tool-input value as a string for matching / messages. */
|
|
19
|
+
export declare function stringifyValue(v: unknown): string;
|
|
20
|
+
/** Resolve a dot-path (`"a.b.c"`) within an arbitrary value, or undefined. */
|
|
21
|
+
export declare function getPath(obj: unknown, path: string): unknown;
|
|
22
|
+
/** Does `input` satisfy every entry of `matcher`? (RegExp = pattern, else exact.) */
|
|
23
|
+
export declare function matchesArgs(input: unknown, matcher: ArgMatcher): boolean;
|
|
24
|
+
/** A human-readable form of a matcher for failure messages. */
|
|
25
|
+
export declare function describeArgs(matcher: ArgMatcher): string;
|
|
26
|
+
/** Serialize a matcher for `toJSON` (RegExp → its string form). */
|
|
27
|
+
export declare function serializeArgs(matcher: ArgMatcher): Record<string, string | number | boolean>;
|
|
28
|
+
//# sourceMappingURL=arg-match.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `ArgMatcher` — a small, serializable matcher over a tool call's `input`.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the check vocabulary (`toolWith` / `notTool` in `src/check.ts`) and
|
|
6
|
+
* the tool-interception seam (`src/tool-intercept.ts`), so "did the agent call
|
|
7
|
+
* this tool with these arguments?" means the same thing whether you're *asserting*
|
|
8
|
+
* on a captured call or *intercepting* one before it runs. Pure + model-free.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.stringifyValue = stringifyValue;
|
|
12
|
+
exports.getPath = getPath;
|
|
13
|
+
exports.matchesArgs = matchesArgs;
|
|
14
|
+
exports.describeArgs = describeArgs;
|
|
15
|
+
exports.serializeArgs = serializeArgs;
|
|
16
|
+
/** Render any tool-input value as a string for matching / messages. */
|
|
17
|
+
function stringifyValue(v) {
|
|
18
|
+
if (typeof v === "string")
|
|
19
|
+
return v;
|
|
20
|
+
if (typeof v === "object" && v !== null) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.stringify(v) ?? "[object]";
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return "[object]";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return String(v);
|
|
29
|
+
}
|
|
30
|
+
/** Resolve a dot-path (`"a.b.c"`) within an arbitrary value, or undefined. */
|
|
31
|
+
function getPath(obj, path) {
|
|
32
|
+
let cur = obj;
|
|
33
|
+
for (const part of path.split(".")) {
|
|
34
|
+
if (cur === null || typeof cur !== "object")
|
|
35
|
+
return undefined;
|
|
36
|
+
cur = cur[part];
|
|
37
|
+
}
|
|
38
|
+
return cur;
|
|
39
|
+
}
|
|
40
|
+
/** Does `input` satisfy every entry of `matcher`? (RegExp = pattern, else exact.) */
|
|
41
|
+
function matchesArgs(input, matcher) {
|
|
42
|
+
return Object.entries(matcher).every(([key, m]) => {
|
|
43
|
+
const value = getPath(input, key);
|
|
44
|
+
return m instanceof RegExp ? m.test(stringifyValue(value)) : value === m;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** A human-readable form of a matcher for failure messages. */
|
|
48
|
+
function describeArgs(matcher) {
|
|
49
|
+
return Object.entries(matcher)
|
|
50
|
+
.map(([k, m]) => `${k}=${m instanceof RegExp ? String(m) : JSON.stringify(m)}`)
|
|
51
|
+
.join(", ");
|
|
52
|
+
}
|
|
53
|
+
/** Serialize a matcher for `toJSON` (RegExp → its string form). */
|
|
54
|
+
function serializeArgs(matcher) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [k, m] of Object.entries(matcher)) {
|
|
57
|
+
out[k] = m instanceof RegExp ? String(m) : m;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=arg-match.js.map
|
package/dist/check.d.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { Trace } from "./harness-test.js";
|
|
20
20
|
import type { HookRunResult } from "./run-hook.js";
|
|
21
|
+
import { type ArgMatcher } from "./arg-match.js";
|
|
22
|
+
export type { ArgMatcher };
|
|
21
23
|
/** The outcome of evaluating one check against one result. */
|
|
22
24
|
export interface CheckResult {
|
|
23
25
|
/** Did the check hold? */
|
|
@@ -54,6 +56,28 @@ export declare function evalChecks<T>(target: T, checks: readonly Check<T>[]): C
|
|
|
54
56
|
export declare function assertChecks<T>(target: T, checks: readonly Check<T>[]): void;
|
|
55
57
|
/** The agent invoked a tool by this name (regardless of result). */
|
|
56
58
|
export declare function tool(name: string): Check<Trace>;
|
|
59
|
+
/**
|
|
60
|
+
* The agent used tool `name` with at least one call whose `input` matches `args`
|
|
61
|
+
* — the **argument** half of the tool-call spy. Asserts not just *that* a tool was
|
|
62
|
+
* reached but *how* it was called (the image-request body carried the style suffix,
|
|
63
|
+
* the panel spawn requested a non-expert), which a completion grader can't see.
|
|
64
|
+
*
|
|
65
|
+
* Cross-reference: ≈ promptfoo `is-valid-function-call` / DeepEval `ToolCorrectnessMetric`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function toolWith(name: string, args: ArgMatcher): Check<Trace>;
|
|
68
|
+
/**
|
|
69
|
+
* The agent did NOT use tool `name` — the **safety / negative** assertion. With
|
|
70
|
+
* `args`, only calls whose `input` matches are forbidden (so "did not push to
|
|
71
|
+
* `main`" still allows pushing elsewhere; "no paid API call" forbids it outright).
|
|
72
|
+
* This is the highest-value, most-overlooked check, and the one a
|
|
73
|
+
* completion-grading eval structurally cannot make: it sees the agent's *decision*
|
|
74
|
+
* to act, not just its final text.
|
|
75
|
+
*
|
|
76
|
+
* Cross-reference: this negative/safety assertion of a *decision not to act* has
|
|
77
|
+
* no promptfoo / DeepEval / Inspect equivalent — they grade what the agent DID,
|
|
78
|
+
* not what it correctly refrained from doing.
|
|
79
|
+
*/
|
|
80
|
+
export declare function notTool(name: string, args?: ArgMatcher): Check<Trace>;
|
|
57
81
|
/** A skill resolved to this id (`<plugin>:<skill>`) without erroring. */
|
|
58
82
|
export declare function skill(id: string): Check<Trace>;
|
|
59
83
|
/** The agent's final output contains a substring / matches a RegExp. */
|
|
@@ -112,8 +136,13 @@ interface UsageTrace {
|
|
|
112
136
|
readonly usage: {
|
|
113
137
|
readonly costUsd: number;
|
|
114
138
|
readonly durationMs: number;
|
|
139
|
+
/** Fresh (uncached) input tokens, billed at full input price. */
|
|
115
140
|
readonly inputTokens: number;
|
|
116
141
|
readonly outputTokens: number;
|
|
142
|
+
/** Tokens written to the prompt cache this run (~1.25× input price). */
|
|
143
|
+
readonly cacheCreationTokens: number;
|
|
144
|
+
/** Tokens served from the prompt cache this run (~0.1× input price). */
|
|
145
|
+
readonly cacheReadTokens: number;
|
|
117
146
|
};
|
|
118
147
|
}
|
|
119
148
|
/** The run cost at most `maxUsd`. */
|
|
@@ -128,5 +157,27 @@ export declare function latency(opts: {
|
|
|
128
157
|
export declare function tokens(opts: {
|
|
129
158
|
max: number;
|
|
130
159
|
}): Check<UsageTrace>;
|
|
131
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The run used at most `max` **fresh (uncached) input** tokens. The honest input
|
|
162
|
+
* side of a cost claim: a skill or CLAUDE.md injection adds input every turn, so
|
|
163
|
+
* a "compression" win on output can be erased here. (Cache reads are separate —
|
|
164
|
+
* see `cacheTokens`.)
|
|
165
|
+
*/
|
|
166
|
+
export declare function inputTokens(opts: {
|
|
167
|
+
max: number;
|
|
168
|
+
}): Check<UsageTrace>;
|
|
169
|
+
/** The run used at most `max` **output** tokens — the generation side. */
|
|
170
|
+
export declare function outputTokens(opts: {
|
|
171
|
+
max: number;
|
|
172
|
+
}): Check<UsageTrace>;
|
|
173
|
+
/**
|
|
174
|
+
* Bound the prompt-cache token classes a run uses. `maxCreation` caps tokens
|
|
175
|
+
* **written** to the cache (the ~1.25× write premium — a fresh/cold prompt);
|
|
176
|
+
* `maxRead` caps tokens **served** from cache (~0.1× input). Each constraint is
|
|
177
|
+
* checked only when provided; the check passes when every provided bound holds.
|
|
178
|
+
*/
|
|
179
|
+
export declare function cacheTokens(opts: {
|
|
180
|
+
maxCreation?: number;
|
|
181
|
+
maxRead?: number;
|
|
182
|
+
}): Check<UsageTrace>;
|
|
132
183
|
//# sourceMappingURL=check.d.ts.map
|
package/dist/check.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.evalChecks = evalChecks;
|
|
4
4
|
exports.assertChecks = assertChecks;
|
|
5
5
|
exports.tool = tool;
|
|
6
|
+
exports.toolWith = toolWith;
|
|
7
|
+
exports.notTool = notTool;
|
|
6
8
|
exports.skill = skill;
|
|
7
9
|
exports.output = output;
|
|
8
10
|
exports.hookFired = hookFired;
|
|
@@ -17,7 +19,11 @@ exports.judged = judged;
|
|
|
17
19
|
exports.cost = cost;
|
|
18
20
|
exports.latency = latency;
|
|
19
21
|
exports.tokens = tokens;
|
|
22
|
+
exports.inputTokens = inputTokens;
|
|
23
|
+
exports.outputTokens = outputTokens;
|
|
24
|
+
exports.cacheTokens = cacheTokens;
|
|
20
25
|
const judge_js_1 = require("./judge.js");
|
|
26
|
+
const arg_match_js_1 = require("./arg-match.js");
|
|
21
27
|
/** Evaluate every check against a target. Pure — the shared core of `expect`
|
|
22
28
|
* (strict) and `measure` (scored). */
|
|
23
29
|
function evalChecks(target, checks) {
|
|
@@ -71,6 +77,63 @@ function tool(name) {
|
|
|
71
77
|
toJSON: () => ({ kind: "tool", name }),
|
|
72
78
|
};
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The agent used tool `name` with at least one call whose `input` matches `args`
|
|
82
|
+
* — the **argument** half of the tool-call spy. Asserts not just *that* a tool was
|
|
83
|
+
* reached but *how* it was called (the image-request body carried the style suffix,
|
|
84
|
+
* the panel spawn requested a non-expert), which a completion grader can't see.
|
|
85
|
+
*
|
|
86
|
+
* Cross-reference: ≈ promptfoo `is-valid-function-call` / DeepEval `ToolCorrectnessMetric`.
|
|
87
|
+
*/
|
|
88
|
+
function toolWith(name, args) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "toolWith",
|
|
91
|
+
eval: (t) => {
|
|
92
|
+
const calls = t.toolCalls.filter((c) => c.name === name);
|
|
93
|
+
const want = (0, arg_match_js_1.describeArgs)(args);
|
|
94
|
+
if (calls.length === 0) {
|
|
95
|
+
return no(`expected the agent to use tool "${name}" (with ${want}), but it used ${distinctToolNames(t.toolCalls)}`);
|
|
96
|
+
}
|
|
97
|
+
return calls.some((c) => (0, arg_match_js_1.matchesArgs)(c.input, args))
|
|
98
|
+
? ok(`agent used "${name}" with ${want}`)
|
|
99
|
+
: no(`agent used "${name}" but never with ${want} (saw ${calls
|
|
100
|
+
.map((c) => truncate((0, arg_match_js_1.stringifyValue)(c.input), 60))
|
|
101
|
+
.join("; ")})`);
|
|
102
|
+
},
|
|
103
|
+
toJSON: () => ({ kind: "toolWith", name, args: (0, arg_match_js_1.serializeArgs)(args) }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The agent did NOT use tool `name` — the **safety / negative** assertion. With
|
|
108
|
+
* `args`, only calls whose `input` matches are forbidden (so "did not push to
|
|
109
|
+
* `main`" still allows pushing elsewhere; "no paid API call" forbids it outright).
|
|
110
|
+
* This is the highest-value, most-overlooked check, and the one a
|
|
111
|
+
* completion-grading eval structurally cannot make: it sees the agent's *decision*
|
|
112
|
+
* to act, not just its final text.
|
|
113
|
+
*
|
|
114
|
+
* Cross-reference: this negative/safety assertion of a *decision not to act* has
|
|
115
|
+
* no promptfoo / DeepEval / Inspect equivalent — they grade what the agent DID,
|
|
116
|
+
* not what it correctly refrained from doing.
|
|
117
|
+
*/
|
|
118
|
+
function notTool(name, args) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "notTool",
|
|
121
|
+
eval: (t) => {
|
|
122
|
+
const calls = t.toolCalls.filter((c) => c.name === name);
|
|
123
|
+
const offending = args
|
|
124
|
+
? calls.filter((c) => (0, arg_match_js_1.matchesArgs)(c.input, args))
|
|
125
|
+
: calls;
|
|
126
|
+
const what = args ? `"${name}" with ${(0, arg_match_js_1.describeArgs)(args)}` : `"${name}"`;
|
|
127
|
+
const first = offending[0];
|
|
128
|
+
if (!first)
|
|
129
|
+
return ok(`agent never used ${what}`);
|
|
130
|
+
return no(`expected the agent NOT to use ${what}, but it did (${truncate((0, arg_match_js_1.stringifyValue)(first.input), 60)})`);
|
|
131
|
+
},
|
|
132
|
+
toJSON: () => args
|
|
133
|
+
? { kind: "notTool", name, args: (0, arg_match_js_1.serializeArgs)(args) }
|
|
134
|
+
: { kind: "notTool", name },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
74
137
|
/** A skill resolved to this id (`<plugin>:<skill>`) without erroring. */
|
|
75
138
|
function skill(id) {
|
|
76
139
|
return {
|
|
@@ -315,4 +378,62 @@ function tokens(opts) {
|
|
|
315
378
|
toJSON: () => ({ kind: "tokens", max: opts.max }),
|
|
316
379
|
};
|
|
317
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* The run used at most `max` **fresh (uncached) input** tokens. The honest input
|
|
383
|
+
* side of a cost claim: a skill or CLAUDE.md injection adds input every turn, so
|
|
384
|
+
* a "compression" win on output can be erased here. (Cache reads are separate —
|
|
385
|
+
* see `cacheTokens`.)
|
|
386
|
+
*/
|
|
387
|
+
function inputTokens(opts) {
|
|
388
|
+
return {
|
|
389
|
+
kind: "inputTokens",
|
|
390
|
+
eval: (t) => {
|
|
391
|
+
const v = t.usage.inputTokens;
|
|
392
|
+
return v <= opts.max
|
|
393
|
+
? ok(`${String(v)} input tokens ≤ ${String(opts.max)}`)
|
|
394
|
+
: no(`expected ≤ ${String(opts.max)} input tokens, got ${String(v)}`);
|
|
395
|
+
},
|
|
396
|
+
toJSON: () => ({ kind: "inputTokens", max: opts.max }),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** The run used at most `max` **output** tokens — the generation side. */
|
|
400
|
+
function outputTokens(opts) {
|
|
401
|
+
return {
|
|
402
|
+
kind: "outputTokens",
|
|
403
|
+
eval: (t) => {
|
|
404
|
+
const v = t.usage.outputTokens;
|
|
405
|
+
return v <= opts.max
|
|
406
|
+
? ok(`${String(v)} output tokens ≤ ${String(opts.max)}`)
|
|
407
|
+
: no(`expected ≤ ${String(opts.max)} output tokens, got ${String(v)}`);
|
|
408
|
+
},
|
|
409
|
+
toJSON: () => ({ kind: "outputTokens", max: opts.max }),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Bound the prompt-cache token classes a run uses. `maxCreation` caps tokens
|
|
414
|
+
* **written** to the cache (the ~1.25× write premium — a fresh/cold prompt);
|
|
415
|
+
* `maxRead` caps tokens **served** from cache (~0.1× input). Each constraint is
|
|
416
|
+
* checked only when provided; the check passes when every provided bound holds.
|
|
417
|
+
*/
|
|
418
|
+
function cacheTokens(opts) {
|
|
419
|
+
return {
|
|
420
|
+
kind: "cacheTokens",
|
|
421
|
+
eval: (t) => {
|
|
422
|
+
const created = t.usage.cacheCreationTokens;
|
|
423
|
+
const read = t.usage.cacheReadTokens;
|
|
424
|
+
if (opts.maxCreation !== undefined && created > opts.maxCreation) {
|
|
425
|
+
return no(`expected ≤ ${String(opts.maxCreation)} cache-creation tokens, got ${String(created)}`);
|
|
426
|
+
}
|
|
427
|
+
if (opts.maxRead !== undefined && read > opts.maxRead) {
|
|
428
|
+
return no(`expected ≤ ${String(opts.maxRead)} cache-read tokens, got ${String(read)}`);
|
|
429
|
+
}
|
|
430
|
+
return ok(`cache tokens within bounds (created ${String(created)}, read ${String(read)})`);
|
|
431
|
+
},
|
|
432
|
+
toJSON: () => ({
|
|
433
|
+
kind: "cacheTokens",
|
|
434
|
+
...(opts.maxCreation !== undefined && { maxCreation: opts.maxCreation }),
|
|
435
|
+
...(opts.maxRead !== undefined && { maxRead: opts.maxRead }),
|
|
436
|
+
}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
318
439
|
//# sourceMappingURL=check.js.map
|
package/dist/claude-code.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export * from "./adapters/claude-code/plugin-loader.js";
|
|
9
9
|
export * from "./mock-model.js";
|
|
10
|
+
export { claudeCodeDriver, buildClaudeArgs, parseClaudeRun, claudeAvailable, } from "./harness-test.js";
|
|
10
11
|
export * from "./adapters/claude-code/dialect.js";
|
|
11
12
|
export * from "./adapters/claude-code/layout.js";
|
|
12
13
|
export * from "./adapters/claude-code/runtime.js";
|
package/dist/claude-code.js
CHANGED
|
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.claudeAvailable = exports.parseClaudeRun = exports.buildClaudeArgs = exports.claudeCodeDriver = void 0;
|
|
17
18
|
/**
|
|
18
19
|
* `vigiles/claude-code` — the Claude Code-specific harness pieces a *different*
|
|
19
20
|
* harness would swap out: the plugin/repo loader (reads real Claude Code plugin
|
|
@@ -23,6 +24,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
23
24
|
*/
|
|
24
25
|
__exportStar(require("./adapters/claude-code/plugin-loader.js"), exports);
|
|
25
26
|
__exportStar(require("./mock-model.js"), exports);
|
|
27
|
+
// The Claude-Code harness-test transport — the default driver + its argv/parse
|
|
28
|
+
// helpers + the `claude` capability probe. Agnostic users never need these
|
|
29
|
+
// (`runHarnessTest` defaults to the CC driver), but they're exposed here — beside
|
|
30
|
+
// the Codex driver in `vigiles/codex` — for CC-specific tests/tooling. They are
|
|
31
|
+
// deliberately NOT on the agnostic `vigiles/testing` surface.
|
|
32
|
+
var harness_test_js_1 = require("./harness-test.js");
|
|
33
|
+
Object.defineProperty(exports, "claudeCodeDriver", { enumerable: true, get: function () { return harness_test_js_1.claudeCodeDriver; } });
|
|
34
|
+
Object.defineProperty(exports, "buildClaudeArgs", { enumerable: true, get: function () { return harness_test_js_1.buildClaudeArgs; } });
|
|
35
|
+
Object.defineProperty(exports, "parseClaudeRun", { enumerable: true, get: function () { return harness_test_js_1.parseClaudeRun; } });
|
|
36
|
+
Object.defineProperty(exports, "claudeAvailable", { enumerable: true, get: function () { return harness_test_js_1.claudeAvailable; } });
|
|
26
37
|
__exportStar(require("./adapters/claude-code/dialect.js"), exports);
|
|
27
38
|
__exportStar(require("./adapters/claude-code/layout.js"), exports);
|
|
28
39
|
__exportStar(require("./adapters/claude-code/runtime.js"), exports);
|
package/dist/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ const compose_js_1 = require("./core/compose.js");
|
|
|
32
32
|
const compile_generator_js_1 = require("./core/compile-generator.js");
|
|
33
33
|
const action_gate_js_1 = require("./action-gate.js");
|
|
34
34
|
const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
|
|
35
|
+
const tool_intercept_js_1 = require("./tool-intercept.js");
|
|
35
36
|
const refs_js_1 = require("./core/refs.js");
|
|
36
37
|
const mcp_js_1 = require("./core/mcp.js");
|
|
37
38
|
const skill_runtime_js_1 = require("./adapters/claude-code/skill-runtime.js");
|
|
@@ -909,8 +910,8 @@ async function runLint(restArgs, flags, config) {
|
|
|
909
910
|
}
|
|
910
911
|
}
|
|
911
912
|
// 7b. Untested-surface check — skills/agents/hooks shipping without a test or
|
|
912
|
-
// eval. Warning by default (a nudge, exit 0); set rules.untested-
|
|
913
|
-
// "error" to gate CI. See src/test-coverage.ts and docs/rules
|
|
913
|
+
// eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
|
|
914
|
+
// hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
|
|
914
915
|
const untested = checkUntestedSurfaces(config, silent);
|
|
915
916
|
// 8. Validate vigiles builder calls inside markdown code blocks. Default
|
|
916
917
|
// is to validate every ref; illustrative blocks opt out via
|
|
@@ -924,6 +925,16 @@ async function runLint(restArgs, flags, config) {
|
|
|
924
925
|
console.log(` ${line}`);
|
|
925
926
|
}
|
|
926
927
|
}
|
|
928
|
+
// Per-line GitHub annotations for each broken doc ref — each carries file+line,
|
|
929
|
+
// so GitHub renders it INLINE on the PR diff (not just in the summary blob).
|
|
930
|
+
// Previously this check reported to stdout only; the inline/spec checks already
|
|
931
|
+
// annotate per-line, so this closes the gap that left doc-ref findings invisible
|
|
932
|
+
// on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
|
|
933
|
+
if (isGitHubActions() && !silent) {
|
|
934
|
+
for (const e of docRefReport.errors) {
|
|
935
|
+
ghAnnotate("error", `${e.kind}("${e.value}") — ${e.message}`, e.file, e.line);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
927
938
|
// 9. Verify code-shaped symbol references live (see src/refs.ts).
|
|
928
939
|
const symbolRefErrors = verifyMarkdownSymbols(files, silent);
|
|
929
940
|
// 10. Verify `vigiles:mcp server#tool` marks against live MCP servers
|
|
@@ -1877,29 +1888,47 @@ function checkIntegrityForFiles(files, severity, silent) {
|
|
|
1877
1888
|
return severity === "error" ? errorCount : 0;
|
|
1878
1889
|
}
|
|
1879
1890
|
/**
|
|
1880
|
-
* Apply the `untested-
|
|
1881
|
-
* eval (see src/test-coverage.ts).
|
|
1882
|
-
*
|
|
1883
|
-
* "error" fails (exit 2)
|
|
1891
|
+
* Apply the per-kind `untested-skill` / `untested-agent` / `untested-hook` rules:
|
|
1892
|
+
* find skills/agents/hooks with no test or eval (see src/test-coverage.ts). Each
|
|
1893
|
+
* kind is gated by its OWN rule severity — a kind set to `false` is not scanned;
|
|
1894
|
+
* "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
|
|
1895
|
+
* untested count plus the severity-gated error count.
|
|
1884
1896
|
*/
|
|
1885
1897
|
function checkUntestedSurfaces(config, silent) {
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1898
|
+
const rules = config?.rules;
|
|
1899
|
+
const skillSev = (0, types_js_1.ruleSeverity)(rules?.["untested-skill"]);
|
|
1900
|
+
const agentSev = (0, types_js_1.ruleSeverity)(rules?.["untested-agent"]);
|
|
1901
|
+
const hookSev = (0, types_js_1.ruleSeverity)(rules?.["untested-hook"]);
|
|
1902
|
+
if (!skillSev && !agentSev && !hookSev)
|
|
1888
1903
|
return { untested: 0, errors: 0 };
|
|
1889
|
-
const
|
|
1890
|
-
|
|
1904
|
+
const sevFor = (kind) => kind === "skill" ? skillSev : kind === "agent" ? agentSev : hookSev;
|
|
1905
|
+
// Test-discovery options (testGlobs/exclude) are shared; merge them from
|
|
1906
|
+
// whichever of the three rules carries them.
|
|
1907
|
+
const opts = {
|
|
1908
|
+
...(0, types_js_1.ruleOptions)(rules?.["untested-skill"]),
|
|
1909
|
+
...(0, types_js_1.ruleOptions)(rules?.["untested-agent"]),
|
|
1910
|
+
...(0, types_js_1.ruleOptions)(rules?.["untested-hook"]),
|
|
1911
|
+
};
|
|
1912
|
+
const report = (0, test_coverage_js_1.findUntestedSurfaces)({
|
|
1913
|
+
basePath: process.cwd(),
|
|
1914
|
+
skills: skillSev !== false,
|
|
1915
|
+
agents: agentSev !== false,
|
|
1916
|
+
hooks: hookSev !== false,
|
|
1917
|
+
testGlobs: opts.testGlobs,
|
|
1918
|
+
exclude: opts.exclude,
|
|
1919
|
+
});
|
|
1891
1920
|
if (!silent) {
|
|
1892
1921
|
console.log("\nUntested surfaces:\n");
|
|
1893
1922
|
for (const line of (0, test_coverage_js_1.formatUntestedReport)(report).split("\n")) {
|
|
1894
1923
|
console.log(` ${line}`);
|
|
1895
1924
|
}
|
|
1896
1925
|
for (const s of report.untested) {
|
|
1897
|
-
ghAnnotate(
|
|
1926
|
+
ghAnnotate(sevFor(s.kind) === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, s.path);
|
|
1898
1927
|
}
|
|
1899
1928
|
}
|
|
1900
1929
|
return {
|
|
1901
1930
|
untested: report.untested.length,
|
|
1902
|
-
errors:
|
|
1931
|
+
errors: report.untested.filter((s) => sevFor(s.kind) === "error").length,
|
|
1903
1932
|
};
|
|
1904
1933
|
}
|
|
1905
1934
|
/**
|
|
@@ -2081,6 +2110,18 @@ function handleRunScripts(kind, args, restArgs) {
|
|
|
2081
2110
|
// Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
|
|
2082
2111
|
const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
|
|
2083
2112
|
const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
|
|
2113
|
+
// `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
|
|
2114
|
+
// a renamed file, or a glob that matched nothing fails LOUD instead of passing
|
|
2115
|
+
// green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
|
|
2116
|
+
const minFlag = args.find((a) => a.startsWith("--min="));
|
|
2117
|
+
const minRequired = minFlag
|
|
2118
|
+
? Math.max(0, Number.parseInt(minFlag.split("=")[1] ?? "", 10) || 0)
|
|
2119
|
+
: 0;
|
|
2120
|
+
if (files.length < minRequired) {
|
|
2121
|
+
console.error(`✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(files.length)} ${kind} file(s) matched — ` +
|
|
2122
|
+
"evals never executed (check the paths/globs, or that the run was reached).");
|
|
2123
|
+
process.exit(1);
|
|
2124
|
+
}
|
|
2084
2125
|
if (files.length === 0) {
|
|
2085
2126
|
console.log(`No ${defaultGlob} files found.`);
|
|
2086
2127
|
return;
|
|
@@ -2091,6 +2132,10 @@ function handleRunScripts(kind, args, restArgs) {
|
|
|
2091
2132
|
if (kind === "test" && !(0, harness_test_js_1.claudeAvailable)()) {
|
|
2092
2133
|
console.log("ℹ `claude` CLI not found — unit-tier tests run; tests that need it report SKIPPED.\n");
|
|
2093
2134
|
}
|
|
2135
|
+
// `--trials=N` (a run knob: cost/precision, doesn't change WHAT is measured) is
|
|
2136
|
+
// forwarded to scripts via env. The MODEL is deliberately NOT a CLI/env knob —
|
|
2137
|
+
// it's part of the measurement definition, so it belongs in the spec
|
|
2138
|
+
// (`model` / `minModel`), version-controlled, not a hidden override.
|
|
2094
2139
|
const trialsFlag = args.find((a) => a.startsWith("--trials="));
|
|
2095
2140
|
const env = {};
|
|
2096
2141
|
if (trialsFlag)
|
|
@@ -2117,7 +2162,7 @@ function printUsage(command) {
|
|
|
2117
2162
|
console.log(" vigiles compile [files...] Compile .spec.ts → .md");
|
|
2118
2163
|
console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
|
|
2119
2164
|
console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
|
|
2120
|
-
console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N)");
|
|
2165
|
+
console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N, --min=N, --no-skip)");
|
|
2121
2166
|
console.log("");
|
|
2122
2167
|
console.log("Examples:");
|
|
2123
2168
|
console.log(" vigiles init Auto-detect project, create specs, wire CI");
|
|
@@ -2231,7 +2276,7 @@ function skillStartCommand(target) {
|
|
|
2231
2276
|
* PreToolUse-hook entrypoint: enforce the active subagent's allowed-tools
|
|
2232
2277
|
* contract. Reads the tool event on stdin, parses the active agent's compiled
|
|
2233
2278
|
* `.md` tool rail, and blocks (exit 2 + reason on stderr) any tool outside it —
|
|
2234
|
-
* the deterministic boundary `tools:` alone can't provide (Claude Code #
|
|
2279
|
+
* the deterministic boundary `tools:` alone can't provide (Claude Code #4740/#21460, SDK #172).
|
|
2235
2280
|
*/
|
|
2236
2281
|
function agentHookCommand() {
|
|
2237
2282
|
let raw = "";
|
|
@@ -2256,6 +2301,30 @@ function agentHookCommand() {
|
|
|
2256
2301
|
process.exit(2);
|
|
2257
2302
|
}
|
|
2258
2303
|
}
|
|
2304
|
+
/**
|
|
2305
|
+
* `vigiles intercept-tool-hook` — the PreToolUse interception hook for the
|
|
2306
|
+
* tool-call spy. Reads the intercept list from `VIGILES_INTERCEPT_TOOLS`, decides
|
|
2307
|
+
* whether the called tool should be intercepted, and if so denies the real
|
|
2308
|
+
* execution (exit 2) with a block message — the call is intercepted (prevented),
|
|
2309
|
+
* NOT executed. Allowing (return) lets the tool run for real. The model still
|
|
2310
|
+
* emits the `tool_use`, so its arguments land in the Trace for `toolWith` /
|
|
2311
|
+
* `notTool` to assert on. See src/tool-intercept.ts.
|
|
2312
|
+
*/
|
|
2313
|
+
function interceptToolHookCommand() {
|
|
2314
|
+
let raw = "";
|
|
2315
|
+
try {
|
|
2316
|
+
raw = (0, node_fs_1.readFileSync)(0, "utf-8");
|
|
2317
|
+
}
|
|
2318
|
+
catch {
|
|
2319
|
+
/* no stdin */
|
|
2320
|
+
}
|
|
2321
|
+
const intercepts = (0, tool_intercept_js_1.parseIntercepts)(process.env[tool_intercept_js_1.INTERCEPT_TOOLS_ENV] ?? "");
|
|
2322
|
+
const decision = (0, tool_intercept_js_1.interceptHookDecision)(raw, intercepts);
|
|
2323
|
+
if (decision.intercept) {
|
|
2324
|
+
console.error(decision.denyReason);
|
|
2325
|
+
process.exit(2);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2259
2328
|
/** Mark a subagent active so the PreToolUse hook enforces its tool contract. */
|
|
2260
2329
|
function agentStartCommand(target) {
|
|
2261
2330
|
if (!target) {
|
|
@@ -2289,6 +2358,9 @@ function handleSkillCommand(command, restArgs) {
|
|
|
2289
2358
|
case "agent-hook":
|
|
2290
2359
|
agentHookCommand();
|
|
2291
2360
|
return true;
|
|
2361
|
+
case "intercept-tool-hook":
|
|
2362
|
+
interceptToolHookCommand();
|
|
2363
|
+
return true;
|
|
2292
2364
|
case "action-hook":
|
|
2293
2365
|
actionHookCommand();
|
|
2294
2366
|
return true;
|