vigiles 2.4.0 → 2.6.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 +72 -327
- package/dist/agent-result.d.ts +40 -0
- package/dist/agent-result.js +97 -0
- package/dist/agent-runtime.d.ts +64 -0
- package/dist/agent-runtime.js +147 -0
- package/dist/claude-code.d.ts +10 -0
- package/dist/claude-code.js +26 -0
- package/dist/cli.js +106 -0
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/egress-proxy.d.ts +2 -0
- package/dist/egress-proxy.js +60 -0
- package/dist/eval-baseline.d.ts +68 -0
- package/dist/eval-baseline.js +173 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +172 -9
- package/dist/eval.js +319 -58
- package/dist/harness-assert.d.ts +175 -13
- package/dist/harness-assert.js +358 -25
- package/dist/harness-test.d.ts +97 -11
- package/dist/harness-test.js +147 -37
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/linting.d.ts +9 -0
- package/dist/linting.js +25 -0
- package/dist/mock-entry.d.ts +2 -0
- package/dist/mock-entry.js +36 -0
- package/dist/mock-model.d.ts +29 -0
- package/dist/mock-model.js +40 -0
- package/dist/plugin-loader.js +51 -17
- package/dist/run-hook.d.ts +81 -1
- package/dist/run-hook.js +189 -11
- package/dist/sandbox.d.ts +107 -0
- package/dist/sandbox.js +307 -0
- package/dist/spec.d.ts +130 -0
- package/dist/spec.js +55 -0
- package/dist/stats.d.ts +49 -0
- package/dist/stats.js +109 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +28 -0
- package/package.json +10 -4
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sandboxAvailable = sandboxAvailable;
|
|
4
|
+
exports.specTrusted = specTrusted;
|
|
5
|
+
exports.decideSandbox = decideSandbox;
|
|
6
|
+
exports.bwrapArgs = bwrapArgs;
|
|
7
|
+
exports.setenvArgs = setenvArgs;
|
|
8
|
+
exports.parseRequestLog = parseRequestLog;
|
|
9
|
+
exports.parseEgressLog = parseEgressLog;
|
|
10
|
+
exports.diffTrees = diffTrees;
|
|
11
|
+
exports.runSandboxed = runSandboxed;
|
|
12
|
+
/**
|
|
13
|
+
* vigiles — safe-by-default confinement for executing untrusted harness code.
|
|
14
|
+
*
|
|
15
|
+
* `runHarnessTest` runs the real `claude` CLI, which runs the real hooks of
|
|
16
|
+
* whatever plugin you load. For code YOU authored (inline `settings`/`files`)
|
|
17
|
+
* that's fine — trust is implicit. But pointing it at someone else's `plugin` /
|
|
18
|
+
* `pluginDir` executes THEIR hooks with your privileges. This module makes that
|
|
19
|
+
* safe by default: untrusted code is confined under bubblewrap, or — if no
|
|
20
|
+
* sandbox is available — the run refuses rather than executing unconfined.
|
|
21
|
+
*
|
|
22
|
+
* Confinement (proven on bwrap 0.9): `--unshare-all` gives a fresh network
|
|
23
|
+
* namespace whose loopback is auto-up but has NO external route — so the
|
|
24
|
+
* scripted mock, co-launched INSIDE the namespace, is reachable over 127.0.0.1
|
|
25
|
+
* while a malicious hook cannot phone home. The filesystem is `--ro-bind`
|
|
26
|
+
* read-only except the throwaway work dir, a fresh empty `$HOME`, and an IO dir
|
|
27
|
+
* used to hand the script in and stream captured requests back out.
|
|
28
|
+
*
|
|
29
|
+
* The policy (`decideSandbox`), trust test (`specTrusted`), and bwrap argv
|
|
30
|
+
* (`bwrapArgs`) are pure and unit-tested; the executor (`runSandboxed`) needs a
|
|
31
|
+
* real bwrap and is covered by the integration test, which skips where bwrap is
|
|
32
|
+
* absent — the same pattern as the real-`claude` paths.
|
|
33
|
+
*/
|
|
34
|
+
const node_child_process_1 = require("node:child_process");
|
|
35
|
+
const node_fs_1 = require("node:fs");
|
|
36
|
+
const node_os_1 = require("node:os");
|
|
37
|
+
const node_path_1 = require("node:path");
|
|
38
|
+
let cachedAvailable;
|
|
39
|
+
/**
|
|
40
|
+
* Whether this environment can ACTUALLY confine untrusted code under bubblewrap.
|
|
41
|
+
* **Linux only.** Critically, `bwrap --version` succeeding is NOT enough: many CI
|
|
42
|
+
* runners and hardened hosts ship bubblewrap but disable the **unprivileged user
|
|
43
|
+
* namespaces** it depends on, so a real confined exec fails even though the binary
|
|
44
|
+
* is present. We probe that real capability — a throwaway `bwrap --unshare-all …
|
|
45
|
+
* true` — and cache it, so we never *claim* confinement we can't deliver.
|
|
46
|
+
* `decideSandbox` then correctly refuses untrusted code in such an environment
|
|
47
|
+
* (rather than running it in a "sandbox" that doesn't actually sandbox), and the
|
|
48
|
+
* sandbox-gated tests skip instead of failing. The result is cached because the
|
|
49
|
+
* probe spawns a process and the answer can't change within a run.
|
|
50
|
+
*/
|
|
51
|
+
function sandboxAvailable() {
|
|
52
|
+
if (cachedAvailable === undefined)
|
|
53
|
+
cachedAvailable = probeSandbox();
|
|
54
|
+
return cachedAvailable;
|
|
55
|
+
}
|
|
56
|
+
function probeSandbox() {
|
|
57
|
+
/* v8 ignore next -- non-Linux has no bwrap; CI/coverage runs on Linux */
|
|
58
|
+
if (process.platform !== "linux")
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
// The capability that fails when user namespaces are disabled is the
|
|
62
|
+
// namespace creation itself (`--unshare-all`), so probe exactly that.
|
|
63
|
+
return ((0, node_child_process_1.spawnSync)("bwrap", ["--unshare-all", "--ro-bind", "/", "/", "true"], {
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
timeout: 10_000,
|
|
66
|
+
}).status === 0);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* v8 ignore next -- defensive: spawnSync only throws on a fork failure */
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Is this spec's executed code trusted? Inline `settings`/`files` you authored
|
|
75
|
+
* are trusted; any external `plugin` / `pluginDir` brings in third-party hooks
|
|
76
|
+
* and is NOT — committing it to your repo is the same trust decision as a
|
|
77
|
+
* dependency, so the trust boundary follows provenance: foreign = confined.
|
|
78
|
+
*/
|
|
79
|
+
function specTrusted(spec) {
|
|
80
|
+
return spec.plugin === undefined && spec.pluginDir === undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The pure safe-by-default policy. Untrusted code NEVER runs unconfined unless
|
|
84
|
+
* the caller explicitly opted out (`mode: false`). This is the whole security
|
|
85
|
+
* contract, isolated as a pure function so it is exhaustively unit-tested.
|
|
86
|
+
*/
|
|
87
|
+
function decideSandbox(opts) {
|
|
88
|
+
// Explicit dangerous opt-out: run unconfined, trusted or not.
|
|
89
|
+
if (opts.mode === false)
|
|
90
|
+
return { action: "direct" };
|
|
91
|
+
// Force confinement regardless of trust; refuse if we can't.
|
|
92
|
+
if (opts.mode === "strict") {
|
|
93
|
+
return opts.available
|
|
94
|
+
? { action: "sandbox" }
|
|
95
|
+
: {
|
|
96
|
+
action: "throw",
|
|
97
|
+
reason: "sandbox: 'strict' requires Linux + bubblewrap (bwrap), which was not available",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// auto: trusted code runs directly; untrusted must be confined or refused.
|
|
101
|
+
if (opts.trusted)
|
|
102
|
+
return { action: "direct" };
|
|
103
|
+
return opts.available
|
|
104
|
+
? { action: "sandbox" }
|
|
105
|
+
: {
|
|
106
|
+
action: "throw",
|
|
107
|
+
reason: "refusing to execute an untrusted plugin's hooks without a sandbox: " +
|
|
108
|
+
"the sandbox needs Linux + bubblewrap (bwrap) — install it to run " +
|
|
109
|
+
"confined, or pass sandbox: false to run unconfined if you trust this " +
|
|
110
|
+
"code / the outer container",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The bubblewrap confinement argv (everything before the command): a fresh
|
|
115
|
+
* network namespace (`--unshare-all`, loopback-only, no egress), a read-only
|
|
116
|
+
* system, writable mounts limited to the work dir, the IO dir, and a fresh empty
|
|
117
|
+
* HOME (inside the IO dir so it needs no mountpoint on the read-only root, and so
|
|
118
|
+
* no host credentials/config leak in), and a **cleared environment** —
|
|
119
|
+
* `--clearenv` drops every host variable (API keys, cloud creds) and only PATH /
|
|
120
|
+
* HOME / TMPDIR are set back, so untrusted code can't even read your secrets.
|
|
121
|
+
* Pure, so the confinement shape is asserted in a unit test.
|
|
122
|
+
*/
|
|
123
|
+
function bwrapArgs(opts) {
|
|
124
|
+
return [
|
|
125
|
+
// New user/net/pid/ipc/uts/cgroup namespaces. The net namespace has only a
|
|
126
|
+
// loopback route, so the in-sandbox mock is reachable but egress is blocked.
|
|
127
|
+
"--unshare-all",
|
|
128
|
+
// Drop ALL inherited env (host secrets); only the essentials are set back.
|
|
129
|
+
"--clearenv",
|
|
130
|
+
"--ro-bind",
|
|
131
|
+
"/",
|
|
132
|
+
"/",
|
|
133
|
+
"--dev",
|
|
134
|
+
"/dev",
|
|
135
|
+
"--proc",
|
|
136
|
+
"/proc",
|
|
137
|
+
// Writable: the work dir and the IO dir (later binds override the ro-bind).
|
|
138
|
+
"--bind",
|
|
139
|
+
opts.cwd,
|
|
140
|
+
opts.cwd,
|
|
141
|
+
"--bind",
|
|
142
|
+
opts.ioDir,
|
|
143
|
+
opts.ioDir,
|
|
144
|
+
// A fresh empty HOME so no host credentials/config are visible.
|
|
145
|
+
"--setenv",
|
|
146
|
+
"HOME",
|
|
147
|
+
opts.home,
|
|
148
|
+
"--setenv",
|
|
149
|
+
"TMPDIR",
|
|
150
|
+
opts.ioDir,
|
|
151
|
+
// PATH must be set back explicitly (cleared above) so node/claude resolve.
|
|
152
|
+
"--setenv",
|
|
153
|
+
"PATH",
|
|
154
|
+
opts.path,
|
|
155
|
+
"--chdir",
|
|
156
|
+
opts.cwd,
|
|
157
|
+
"--die-with-parent",
|
|
158
|
+
"--new-session",
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `--setenv K V` pairs to add back specific variables after `--clearenv` — e.g.
|
|
163
|
+
* a hook's configured env (the `GUARD=path` a plugin's command relies on), which
|
|
164
|
+
* `bwrapArgs`' `--clearenv` would otherwise drop. Pure, so it's unit-tested.
|
|
165
|
+
*/
|
|
166
|
+
function setenvArgs(env) {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const [k, v] of Object.entries(env))
|
|
169
|
+
out.push("--setenv", k, v);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
|
|
173
|
+
function parseRequestLog(ndjson) {
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const line of ndjson.split("\n")) {
|
|
176
|
+
if (!line.trim())
|
|
177
|
+
continue;
|
|
178
|
+
try {
|
|
179
|
+
out.push(JSON.parse(line));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* a partially-written final line — skip */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse the egress recorder's ndjson log into {@link EgressAttempt}s. Pure, so
|
|
189
|
+
* the record-shape and the malformed-line tolerance are unit-tested without a
|
|
190
|
+
* sandbox. A line missing host/port is skipped (a partially-flushed final line).
|
|
191
|
+
*/
|
|
192
|
+
function parseEgressLog(ndjson) {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const line of ndjson.split("\n")) {
|
|
195
|
+
if (!line.trim())
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
const o = JSON.parse(line);
|
|
199
|
+
if (typeof o.host === "string" && typeof o.port === "number") {
|
|
200
|
+
out.push({ host: o.host, port: o.port, ts: Number(o.ts) || 0 });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* a partially-written final line — skip */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The files in `after` that are new or changed vs `before` — i.e. what a confined
|
|
211
|
+
* run wrote to its work dir. Each tree maps a relative path to a content
|
|
212
|
+
* signature (size + mtime). Pure, so the diff is unit-tested without a sandbox.
|
|
213
|
+
*/
|
|
214
|
+
function diffTrees(before, after) {
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const [path, sig] of Object.entries(after)) {
|
|
217
|
+
if (before[path] !== sig)
|
|
218
|
+
out.push(path);
|
|
219
|
+
}
|
|
220
|
+
return out.sort();
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Co-launch the scripted mock and `claude` inside ONE bubblewrap network
|
|
224
|
+
* namespace: the mock serves on the sandbox's loopback (reachable), egress is
|
|
225
|
+
* blocked, and captured requests stream out through the bound IO dir. Paths come
|
|
226
|
+
* in via env so the wrapper needs no escaping; `claude`'s args are the wrapper's
|
|
227
|
+
* positional params (`"$@"`).
|
|
228
|
+
*/
|
|
229
|
+
const WRAPPER = [
|
|
230
|
+
// start the in-sandbox mock; it writes its port to $VIG_PORT when ready
|
|
231
|
+
'node "$VIG_MOCKENTRY" "$VIG_SCRIPT" "$VIG_REQS" "$VIG_PORT" &',
|
|
232
|
+
"MOCKPID=$!",
|
|
233
|
+
"i=0",
|
|
234
|
+
'while [ ! -s "$VIG_PORT" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i+1)); done',
|
|
235
|
+
'export ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$VIG_PORT")"',
|
|
236
|
+
"export ANTHROPIC_API_KEY=sk-vigiles-mock",
|
|
237
|
+
'claude "$@"',
|
|
238
|
+
"code=$?",
|
|
239
|
+
'kill "$MOCKPID" 2>/dev/null',
|
|
240
|
+
'exit "$code"',
|
|
241
|
+
].join("\n");
|
|
242
|
+
/* v8 ignore start -- spawns bwrap + the real claude CLI; exercised by the
|
|
243
|
+
bwrap-backed integration test (skipped without bwrap), not the unit gate —
|
|
244
|
+
the pure policy/args/parse helpers above carry the testable logic. */
|
|
245
|
+
function runSandboxed(opts) {
|
|
246
|
+
const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-sbx-"));
|
|
247
|
+
const home = (0, node_path_1.join)(ioDir, "home");
|
|
248
|
+
(0, node_fs_1.mkdirSync)(home);
|
|
249
|
+
const scriptF = (0, node_path_1.join)(ioDir, "script.json");
|
|
250
|
+
const reqsF = (0, node_path_1.join)(ioDir, "requests.ndjson");
|
|
251
|
+
const portF = (0, node_path_1.join)(ioDir, "port");
|
|
252
|
+
(0, node_fs_1.writeFileSync)(scriptF, JSON.stringify(opts.script));
|
|
253
|
+
(0, node_fs_1.writeFileSync)(reqsF, "");
|
|
254
|
+
// The mock entry is only runnable as built JS. In production __dirname is
|
|
255
|
+
// dist/ (sibling); under vitest the source runs from src/, so fall back to
|
|
256
|
+
// the built dist/ copy.
|
|
257
|
+
const mockEntry = [
|
|
258
|
+
(0, node_path_1.join)(__dirname, "mock-entry.js"),
|
|
259
|
+
(0, node_path_1.join)(__dirname, "..", "dist", "mock-entry.js"),
|
|
260
|
+
].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "mock-entry.js");
|
|
261
|
+
const args = [
|
|
262
|
+
...bwrapArgs({
|
|
263
|
+
cwd: opts.cwd,
|
|
264
|
+
ioDir,
|
|
265
|
+
home,
|
|
266
|
+
path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
267
|
+
}),
|
|
268
|
+
"--setenv",
|
|
269
|
+
"VIG_MOCKENTRY",
|
|
270
|
+
mockEntry,
|
|
271
|
+
"--setenv",
|
|
272
|
+
"VIG_SCRIPT",
|
|
273
|
+
scriptF,
|
|
274
|
+
"--setenv",
|
|
275
|
+
"VIG_REQS",
|
|
276
|
+
reqsF,
|
|
277
|
+
"--setenv",
|
|
278
|
+
"VIG_PORT",
|
|
279
|
+
portF,
|
|
280
|
+
"sh",
|
|
281
|
+
"-c",
|
|
282
|
+
WRAPPER,
|
|
283
|
+
"sh",
|
|
284
|
+
...opts.claudeArgs,
|
|
285
|
+
];
|
|
286
|
+
return new Promise((resolvePromise) => {
|
|
287
|
+
const child = (0, node_child_process_1.spawn)("bwrap", args, {
|
|
288
|
+
cwd: opts.cwd,
|
|
289
|
+
env: process.env,
|
|
290
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
291
|
+
});
|
|
292
|
+
let stdout = "";
|
|
293
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
294
|
+
child.stderr.on("data", () => {
|
|
295
|
+
/* hook diagnostics — not needed for the captured result */
|
|
296
|
+
});
|
|
297
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
|
|
298
|
+
child.on("close", (code) => {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
const requests = parseRequestLog((0, node_fs_1.existsSync)(reqsF) ? (0, node_fs_1.readFileSync)(reqsF, "utf-8") : "");
|
|
301
|
+
(0, node_fs_1.rmSync)(ioDir, { recursive: true, force: true });
|
|
302
|
+
resolvePromise({ code: code ?? 0, stdout, requests });
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/* v8 ignore stop */
|
|
307
|
+
//# sourceMappingURL=sandbox.js.map
|
package/dist/spec.d.ts
CHANGED
|
@@ -320,6 +320,136 @@ export interface SkillSpec {
|
|
|
320
320
|
* export default skill({ name: "my-skill", description: "...", body: "..." });
|
|
321
321
|
*/
|
|
322
322
|
export declare function skill(spec: Omit<SkillSpec, "_specType">): SkillSpec;
|
|
323
|
+
/**
|
|
324
|
+
* A subagent definition (compiles to `agents/<name>.md`). Unlike a skill —
|
|
325
|
+
* reference material the model reads on activation — a subagent is a *delegated
|
|
326
|
+
* worker with a contract*: a dispatch `description`, an allowed-`tools` rail, an
|
|
327
|
+
* optional `model`, a system-prompt `body`, and the `rules` it must follow. That
|
|
328
|
+
* tool contract + those rules are the "railway" a subagent runs on, and they're
|
|
329
|
+
* exactly the compile-time-verifiable surface vigiles owns: the body's
|
|
330
|
+
* `file()`/`cmd()`/`symbol()` marks are checked like any instruction file, and
|
|
331
|
+
* the tools list is verified against the real tool set.
|
|
332
|
+
*/
|
|
333
|
+
export interface AgentSpec {
|
|
334
|
+
readonly _specType: "agent";
|
|
335
|
+
/** Subagent name (frontmatter + dispatch handle). */
|
|
336
|
+
readonly name: string;
|
|
337
|
+
/** When to dispatch this subagent — the trigger (frontmatter). */
|
|
338
|
+
readonly description: string;
|
|
339
|
+
/** Model alias (e.g. "sonnet", "opus", "haiku", "inherit"). Optional. */
|
|
340
|
+
readonly model?: string;
|
|
341
|
+
/**
|
|
342
|
+
* The allowed-tools contract — the rails the worker runs on. Each entry must be
|
|
343
|
+
* a known built-in tool (Read/Write/Edit/Bash/Grep/Glob/WebSearch/WebFetch/
|
|
344
|
+
* NotebookEdit/TodoWrite/Task/Skill) or an MCP tool (`mcp__server__tool`).
|
|
345
|
+
* Omit to inherit all tools. Verified at compile time.
|
|
346
|
+
*/
|
|
347
|
+
readonly tools?: readonly string[];
|
|
348
|
+
/**
|
|
349
|
+
* The lead/intro prose of the system prompt (the "You are…" opener), before any
|
|
350
|
+
* sections. Carries verified `file()`/`cmd()`/`symbol()`/`ref()` marks. No
|
|
351
|
+
* markdown headers — use `sections` for those.
|
|
352
|
+
*/
|
|
353
|
+
readonly body?: string | InstructionFragment[];
|
|
354
|
+
/**
|
|
355
|
+
* Named `##` sections of the system prompt (e.g. Purpose, Core Principles,
|
|
356
|
+
* Capabilities) — the shape real subagents actually take. Same verified-ref +
|
|
357
|
+
* no-nested-`##` rules as a CLAUDE.md spec's sections. Use `body` for the intro
|
|
358
|
+
* and `sections` for the structured rest.
|
|
359
|
+
*/
|
|
360
|
+
readonly sections?: Record<string, string | InstructionFragment[]>;
|
|
361
|
+
/** Rules the worker must follow — rendered as a `## Rules` section. */
|
|
362
|
+
readonly rules?: Record<string, Rule>;
|
|
363
|
+
/**
|
|
364
|
+
* The typed result contract — what this worker returns on success/error. When
|
|
365
|
+
* set, compiles to an `## Output contract` section instructing the worker to
|
|
366
|
+
* end with a `vigiles:ok` / `vigiles:err` block, so its outcome is parseable
|
|
367
|
+
* and testable (see `result()`, `parseAgentResult`, `assertAgentOk`).
|
|
368
|
+
*/
|
|
369
|
+
readonly output?: OutputContract;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Define a subagent specification (compiles to `agents/<name>.md`).
|
|
373
|
+
*
|
|
374
|
+
* // agents/reviewer.md.spec.ts
|
|
375
|
+
* export default agent({
|
|
376
|
+
* name: "reviewer",
|
|
377
|
+
* description: "Review a diff for correctness. Dispatch PROACTIVELY after edits.",
|
|
378
|
+
* model: "sonnet",
|
|
379
|
+
* tools: ["Read", "Grep", "Bash"],
|
|
380
|
+
* body: instructions`Review the diff. Run ${cmd("npm test")} first.`,
|
|
381
|
+
* rules: {
|
|
382
|
+
* "no-floating": enforce("@typescript-eslint/no-floating-promises", "Await promises."),
|
|
383
|
+
* },
|
|
384
|
+
* });
|
|
385
|
+
*/
|
|
386
|
+
export declare function agent(spec: Omit<AgentSpec, "_specType">): AgentSpec;
|
|
387
|
+
/** The field types a result contract can declare (kept tiny + dependency-free). */
|
|
388
|
+
export type OutputFieldType = "string" | "number" | "boolean" | "string[]";
|
|
389
|
+
/**
|
|
390
|
+
* A subagent's typed result contract: the shape it must return on success
|
|
391
|
+
* (`ok`) and on failure (`err`). Rich on both tracks — an error is structured
|
|
392
|
+
* detail, not a bare pass/fail bit. Compiles into the worker's system prompt
|
|
393
|
+
* (the `vigiles:ok` / `vigiles:err` block it must emit) and is the schema the
|
|
394
|
+
* `parseAgentResult` parser + the `assertAgentOk/Err` test helpers validate.
|
|
395
|
+
*/
|
|
396
|
+
export interface OutputContract {
|
|
397
|
+
readonly _ref: "output";
|
|
398
|
+
readonly ok: Readonly<Record<string, OutputFieldType>>;
|
|
399
|
+
readonly err: Readonly<Record<string, OutputFieldType>>;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Declare a subagent's success/error result contract.
|
|
403
|
+
*
|
|
404
|
+
* result(
|
|
405
|
+
* { files: "string[]", summary: "string" }, // rich success
|
|
406
|
+
* { reason: "string", retryable: "boolean" }, // rich error
|
|
407
|
+
* )
|
|
408
|
+
*
|
|
409
|
+
* (Distinct from a skill's `result:` postcondition gate — this types a
|
|
410
|
+
* subagent's *return value*, the success/error tracks of the railway.)
|
|
411
|
+
*/
|
|
412
|
+
export declare function result(ok: Record<string, OutputFieldType>, err: Record<string, OutputFieldType>): OutputContract;
|
|
413
|
+
/** One step on a railway: dispatch a flat subagent (the "activity"). */
|
|
414
|
+
export interface RailwayStep {
|
|
415
|
+
readonly _step: "delegate";
|
|
416
|
+
/** The subagent to dispatch — resolved against compiled agent names. */
|
|
417
|
+
readonly agent: string;
|
|
418
|
+
/** Optional task hint passed to the worker. */
|
|
419
|
+
readonly task?: string;
|
|
420
|
+
}
|
|
421
|
+
/** Build a railway step that dispatches `agent` (optionally with a task hint). */
|
|
422
|
+
export declare function delegate(agent: string, task?: string): RailwayStep;
|
|
423
|
+
/**
|
|
424
|
+
* A railway over flat subagents. `steps` run in order on the success track; the
|
|
425
|
+
* first step that returns an error short-circuits to `onError`. `recover`
|
|
426
|
+
* optionally retries the failing step a *bounded* number of times before the
|
|
427
|
+
* error track. There is intentionally no loop combinator — the value is a finite
|
|
428
|
+
* tree, so it always terminates and is fully verifiable at compile time.
|
|
429
|
+
*/
|
|
430
|
+
export interface Railway {
|
|
431
|
+
readonly _specType: "railway";
|
|
432
|
+
readonly name: string;
|
|
433
|
+
readonly steps: readonly RailwayStep[];
|
|
434
|
+
/** Error track — runs with the failing step's error payload. */
|
|
435
|
+
readonly onError?: RailwayStep;
|
|
436
|
+
/** Bounded recovery: retry the failing step up to `max` times (finite). */
|
|
437
|
+
readonly recover?: {
|
|
438
|
+
readonly step: RailwayStep;
|
|
439
|
+
readonly max: number;
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Compose flat subagents into a railway (compiles to an orchestrator command).
|
|
444
|
+
*
|
|
445
|
+
* railway({
|
|
446
|
+
* name: "ship",
|
|
447
|
+
* steps: [delegate("planner"), delegate("coder"), delegate("reviewer")],
|
|
448
|
+
* onError: delegate("reporter"),
|
|
449
|
+
* recover: { step: delegate("fixer"), max: 2 },
|
|
450
|
+
* })
|
|
451
|
+
*/
|
|
452
|
+
export declare function railway(spec: Omit<Railway, "_specType">): Railway;
|
|
323
453
|
/** Derive the spec filename from an output filename. */
|
|
324
454
|
export type SpecPath<Output extends `${string}.md`> = `${Output}.spec.ts`;
|
|
325
455
|
/** Extract the output filename from a spec filename. */
|
package/dist/spec.js
CHANGED
|
@@ -23,6 +23,10 @@ exports.project = project;
|
|
|
23
23
|
exports.input = input;
|
|
24
24
|
exports.step = step;
|
|
25
25
|
exports.skill = skill;
|
|
26
|
+
exports.agent = agent;
|
|
27
|
+
exports.result = result;
|
|
28
|
+
exports.delegate = delegate;
|
|
29
|
+
exports.railway = railway;
|
|
26
30
|
exports.defineConfig = defineConfig;
|
|
27
31
|
// ---------------------------------------------------------------------------
|
|
28
32
|
// Builder functions
|
|
@@ -154,6 +158,57 @@ function step(instr, opts = {}) {
|
|
|
154
158
|
function skill(spec) {
|
|
155
159
|
return { _specType: "skill", ...spec };
|
|
156
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Define a subagent specification (compiles to `agents/<name>.md`).
|
|
163
|
+
*
|
|
164
|
+
* // agents/reviewer.md.spec.ts
|
|
165
|
+
* export default agent({
|
|
166
|
+
* name: "reviewer",
|
|
167
|
+
* description: "Review a diff for correctness. Dispatch PROACTIVELY after edits.",
|
|
168
|
+
* model: "sonnet",
|
|
169
|
+
* tools: ["Read", "Grep", "Bash"],
|
|
170
|
+
* body: instructions`Review the diff. Run ${cmd("npm test")} first.`,
|
|
171
|
+
* rules: {
|
|
172
|
+
* "no-floating": enforce("@typescript-eslint/no-floating-promises", "Await promises."),
|
|
173
|
+
* },
|
|
174
|
+
* });
|
|
175
|
+
*/
|
|
176
|
+
function agent(spec) {
|
|
177
|
+
return { _specType: "agent", ...spec };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Declare a subagent's success/error result contract.
|
|
181
|
+
*
|
|
182
|
+
* result(
|
|
183
|
+
* { files: "string[]", summary: "string" }, // rich success
|
|
184
|
+
* { reason: "string", retryable: "boolean" }, // rich error
|
|
185
|
+
* )
|
|
186
|
+
*
|
|
187
|
+
* (Distinct from a skill's `result:` postcondition gate — this types a
|
|
188
|
+
* subagent's *return value*, the success/error tracks of the railway.)
|
|
189
|
+
*/
|
|
190
|
+
function result(ok, err) {
|
|
191
|
+
return { _ref: "output", ok, err };
|
|
192
|
+
}
|
|
193
|
+
/** Build a railway step that dispatches `agent` (optionally with a task hint). */
|
|
194
|
+
function delegate(agent, task) {
|
|
195
|
+
return task === undefined
|
|
196
|
+
? { _step: "delegate", agent }
|
|
197
|
+
: { _step: "delegate", agent, task };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Compose flat subagents into a railway (compiles to an orchestrator command).
|
|
201
|
+
*
|
|
202
|
+
* railway({
|
|
203
|
+
* name: "ship",
|
|
204
|
+
* steps: [delegate("planner"), delegate("coder"), delegate("reviewer")],
|
|
205
|
+
* onError: delegate("reporter"),
|
|
206
|
+
* recover: { step: delegate("fixer"), max: 2 },
|
|
207
|
+
* })
|
|
208
|
+
*/
|
|
209
|
+
function railway(spec) {
|
|
210
|
+
return { _specType: "railway", ...spec };
|
|
211
|
+
}
|
|
157
212
|
function defineConfig(config) {
|
|
158
213
|
return config;
|
|
159
214
|
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vigiles — significance testing for eval A/B arms.
|
|
3
|
+
*
|
|
4
|
+
* The eval tier already reports mean ± se per arm; this answers the question that
|
|
5
|
+
* `assertImproves(..., { by: se })` punted to the user: is the gap between two
|
|
6
|
+
* arms real, or noise? A Welch's t-test over the per-arm summary stats (mean, se,
|
|
7
|
+
* n) — no raw rows needed — yields a two-sided p-value and a significance verdict.
|
|
8
|
+
* Pure + model-free, so it's fully unit-tested against known t-table values.
|
|
9
|
+
*
|
|
10
|
+
* For 0/1 (proportion) metrics this is the t approximation to the two-proportion
|
|
11
|
+
* test — close at the trial counts evals use, and one code path for any metric.
|
|
12
|
+
* The numerics (log-gamma, incomplete beta) are specialized to the argument range
|
|
13
|
+
* these tests produce (a, b ≥ 0.5; x ∈ (0,1)); they are not a general library.
|
|
14
|
+
*/
|
|
15
|
+
import type { EvalReport } from "./eval.js";
|
|
16
|
+
/** Regularized incomplete beta I_x(a, b) ∈ [0, 1]. */
|
|
17
|
+
export declare function regularizedIncompleteBeta(a: number, b: number, x: number): number;
|
|
18
|
+
/** Two-sided p-value for Student's t with `df` degrees of freedom. */
|
|
19
|
+
export declare function tPValueTwoSided(t: number, df: number): number;
|
|
20
|
+
/** The verdict on one arm-vs-baseline comparison for a single metric. */
|
|
21
|
+
export interface Comparison {
|
|
22
|
+
/** mean(arm) − mean(baseline). */
|
|
23
|
+
readonly delta: number;
|
|
24
|
+
/** Combined standard error of the difference. */
|
|
25
|
+
readonly seDelta: number;
|
|
26
|
+
/** Welch t statistic (delta / seDelta). */
|
|
27
|
+
readonly t: number;
|
|
28
|
+
/** Welch–Satterthwaite degrees of freedom. */
|
|
29
|
+
readonly df: number;
|
|
30
|
+
/** Two-sided p-value for the difference. */
|
|
31
|
+
readonly pValue: number;
|
|
32
|
+
/** p < alpha — the difference is unlikely to be noise. */
|
|
33
|
+
readonly significant: boolean;
|
|
34
|
+
}
|
|
35
|
+
type Summary = {
|
|
36
|
+
readonly mean: number;
|
|
37
|
+
readonly se: number;
|
|
38
|
+
readonly n: number;
|
|
39
|
+
};
|
|
40
|
+
/** Welch's unequal-variance t-test between two arms' summary stats. */
|
|
41
|
+
export declare function welchTTest(arm: Summary, baseline: Summary, alpha?: number): Comparison;
|
|
42
|
+
/**
|
|
43
|
+
* Compare two arms on a metric using their reported summary stats, or null if
|
|
44
|
+
* either arm/metric is absent. The grounded form of `assertImproves`'s `by`: it
|
|
45
|
+
* computes the noise floor instead of asking the caller to supply it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function compareArms(report: EvalReport, baseline: string, arm: string, metric: string, alpha?: number): Comparison | null;
|
|
48
|
+
export {};
|
|
49
|
+
//# sourceMappingURL=stats.d.ts.map
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.regularizedIncompleteBeta = regularizedIncompleteBeta;
|
|
4
|
+
exports.tPValueTwoSided = tPValueTwoSided;
|
|
5
|
+
exports.welchTTest = welchTTest;
|
|
6
|
+
exports.compareArms = compareArms;
|
|
7
|
+
// Lanczos coefficients (g = 7) for log-gamma; sufficient for the beta args here.
|
|
8
|
+
const LANCZOS = [
|
|
9
|
+
676.5203681218851, -1259.1392167224028, 771.32342877765313,
|
|
10
|
+
-176.61502916214059, 12.507343278686905, -0.13857109526572012,
|
|
11
|
+
9.9843695780195716e-6, 1.5056327351493116e-7,
|
|
12
|
+
];
|
|
13
|
+
/** Log-gamma via Lanczos. Valid for x ≥ 0.5 (all args used below satisfy this). */
|
|
14
|
+
function lgamma(x) {
|
|
15
|
+
const g = 7;
|
|
16
|
+
const xm1 = x - 1;
|
|
17
|
+
const base = LANCZOS.reduce((acc, c, i) => acc + c / (xm1 + i + 1), 0.99999999999980993);
|
|
18
|
+
const tt = xm1 + g + 0.5;
|
|
19
|
+
return (0.5 * Math.log(2 * Math.PI) +
|
|
20
|
+
(xm1 + 0.5) * Math.log(tt) -
|
|
21
|
+
tt +
|
|
22
|
+
Math.log(base));
|
|
23
|
+
}
|
|
24
|
+
/** Continued fraction for the incomplete beta (Numerical Recipes betacf). */
|
|
25
|
+
function betacf(a, b, x) {
|
|
26
|
+
const MAXIT = 200;
|
|
27
|
+
const EPS = 1e-12;
|
|
28
|
+
const qab = a + b;
|
|
29
|
+
const qap = a + 1;
|
|
30
|
+
const qam = a - 1;
|
|
31
|
+
let c = 1;
|
|
32
|
+
let d = 1 / (1 - (qab * x) / qap);
|
|
33
|
+
let h = d;
|
|
34
|
+
for (let m = 1; m <= MAXIT; m++) {
|
|
35
|
+
const m2 = 2 * m;
|
|
36
|
+
let aa = (m * (b - m) * x) / ((qam + m2) * (a + m2));
|
|
37
|
+
d = 1 / (1 + aa * d);
|
|
38
|
+
c = 1 + aa / c;
|
|
39
|
+
h *= d * c;
|
|
40
|
+
aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2));
|
|
41
|
+
d = 1 / (1 + aa * d);
|
|
42
|
+
c = 1 + aa / c;
|
|
43
|
+
const del = d * c;
|
|
44
|
+
h *= del;
|
|
45
|
+
if (Math.abs(del - 1) < EPS)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return h;
|
|
49
|
+
}
|
|
50
|
+
/** Regularized incomplete beta I_x(a, b) ∈ [0, 1]. */
|
|
51
|
+
function regularizedIncompleteBeta(a, b, x) {
|
|
52
|
+
if (x <= 0)
|
|
53
|
+
return 0;
|
|
54
|
+
if (x >= 1)
|
|
55
|
+
return 1;
|
|
56
|
+
const front = Math.exp(lgamma(a + b) -
|
|
57
|
+
lgamma(a) -
|
|
58
|
+
lgamma(b) +
|
|
59
|
+
a * Math.log(x) +
|
|
60
|
+
b * Math.log(1 - x));
|
|
61
|
+
return x < (a + 1) / (a + b + 2)
|
|
62
|
+
? (front * betacf(a, b, x)) / a
|
|
63
|
+
: 1 - (front * betacf(b, a, 1 - x)) / b;
|
|
64
|
+
}
|
|
65
|
+
/** Two-sided p-value for Student's t with `df` degrees of freedom. */
|
|
66
|
+
function tPValueTwoSided(t, df) {
|
|
67
|
+
if (df <= 0)
|
|
68
|
+
return 1;
|
|
69
|
+
return regularizedIncompleteBeta(df / 2, 0.5, df / (df + t * t));
|
|
70
|
+
}
|
|
71
|
+
// Variance contribution of one arm to the Welch df denominator. Guarded by v > 0
|
|
72
|
+
// (se > 0 ⇒ n ≥ 2, so n − 1 ≥ 1); a deterministic arm (se = 0) contributes 0.
|
|
73
|
+
const dfTerm = (v, n) => v > 0 ? (v * v) / (n - 1) : 0;
|
|
74
|
+
/** Welch's unequal-variance t-test between two arms' summary stats. */
|
|
75
|
+
function welchTTest(arm, baseline, alpha = 0.05) {
|
|
76
|
+
const delta = arm.mean - baseline.mean;
|
|
77
|
+
const va = arm.se ** 2;
|
|
78
|
+
const vb = baseline.se ** 2;
|
|
79
|
+
const seDelta = Math.sqrt(va + vb);
|
|
80
|
+
if (seDelta === 0) {
|
|
81
|
+
// Both arms are deterministic: significant iff they differ at all.
|
|
82
|
+
const significant = delta !== 0;
|
|
83
|
+
return {
|
|
84
|
+
delta,
|
|
85
|
+
seDelta,
|
|
86
|
+
t: 0,
|
|
87
|
+
df: 0,
|
|
88
|
+
pValue: significant ? 0 : 1,
|
|
89
|
+
significant,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const t = delta / seDelta;
|
|
93
|
+
const df = (va + vb) ** 2 / (dfTerm(va, arm.n) + dfTerm(vb, baseline.n));
|
|
94
|
+
const pValue = tPValueTwoSided(t, df);
|
|
95
|
+
return { delta, seDelta, t, df, pValue, significant: pValue < alpha };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Compare two arms on a metric using their reported summary stats, or null if
|
|
99
|
+
* either arm/metric is absent. The grounded form of `assertImproves`'s `by`: it
|
|
100
|
+
* computes the noise floor instead of asking the caller to supply it.
|
|
101
|
+
*/
|
|
102
|
+
function compareArms(report, baseline, arm, metric, alpha = 0.05) {
|
|
103
|
+
const a = report.arms[arm]?.stats[metric];
|
|
104
|
+
const b = report.arms[baseline]?.stats[metric];
|
|
105
|
+
if (!a || !b)
|
|
106
|
+
return null;
|
|
107
|
+
return welchTTest(a, b, alpha);
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=stats.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vigiles/testing` — Pillar 2 entry point: the **harness-testing** API. Re-exports
|
|
3
|
+
* the three tiers — `runHook` (unit), `runHarnessTest` (deterministic), `runEval`
|
|
4
|
+
* (eval) — plus the runner-agnostic predicates/assertions. Kept deliberately
|
|
5
|
+
* separate from `vigiles/claude-code` so this surface can stay harness-agnostic as
|
|
6
|
+
* more harnesses are added. Granular paths (`vigiles/run-hook`, etc.) still work.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./run-hook.js";
|
|
9
|
+
export * from "./harness-test.js";
|
|
10
|
+
export * from "./eval.js";
|
|
11
|
+
export * from "./harness-assert.js";
|
|
12
|
+
//# sourceMappingURL=testing.d.ts.map
|