vigiles 12.3.0 โ†’ 12.4.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 CHANGED
@@ -174,7 +174,15 @@ A hook that blocks nothing, a skill that hijacks unrelated prompts, context that
174
174
 
175
175
  ### ๐Ÿ“Š Eval โ€” does a skill help, or just cost more?
176
176
 
177
- _"65% fewer tokens." Says who?_ vigiles A/Bs the claim on real coding tasks and reports the token bill, whether it hit its target, and whether the code still works. promptfoo and DeepEval bill **per token, every run**; vigiles runs on your own Claude Pro/Max subscription. Evals run locally; a committed lock file โ€” like a `package-lock` โ€” records the result, so CI catches stale numbers without calling the model again. (On Claude Code today; Codex eval support is landing.)
177
+ _"Caveman Mode cuts 65% of your tokens." Says who?_ vigiles A/Bs the claim on real coding tasks and hands you three numbers: the **token bill**, whether it hit its **target**, and whether your code still **works**.
178
+
179
+ ```text
180
+ caveman vs verbose ยท haiku ยท $0 on your subscription
181
+ output tokens 762 โ†’ 842 (+11% โ€” the "saving" reversed)
182
+ correctness 1.0 โ†’ 1.0 (the fact survived)
183
+ ```
184
+
185
+ Point it at any harness change that claims a number โ€” does a compression skill pay for itself, is a subagent worth its cost, which model is cheapest here. promptfoo and DeepEval bill **per token, every run**; vigiles runs on your own Claude Pro/Max subscription, so you measure on every change, not once. A committed lock file (like `package-lock`) keeps CI honest without re-calling the model. (Claude Code today; Codex landing.)
178
186
  **[Measure a skill โ†’](docs/measuring-skills.md)**
179
187
 
180
188
  ## Quick start
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `vigiles/experimental` โ€” โš ๏ธ EXPERIMENTAL, UNSTABLE public surface.
3
+ *
4
+ * Everything re-exported here is a DRAFT. It is deliberately quarantined behind
5
+ * the `experimental` subpath (and the `experimental_` name prefix on runtime
6
+ * exports) so the import itself signals the risk at the call site:
7
+ *
8
+ * import { experimental_startServices } from "vigiles/experimental";
9
+ *
10
+ * NOT covered by the stability guarantee (STABILITY.md): the shape may change or
11
+ * be removed WITHOUT a major-version bump. Do not depend on it in production.
12
+ *
13
+ * Current contents โ€” the R3 disposable-service tier (real side-effect testing;
14
+ * see docs/measuring-skills.md ยง Experimental and src/services.ts).
15
+ *
16
+ * โš ๏ธ SAFETY: R3 runs a model-driven skill FOR REAL. The disposable container is
17
+ * the ONLY isolation vigiles provides โ€” it does not confine the skill's filesystem
18
+ * or network. Run it in a disposable environment with NO production access and
19
+ * keep real credentials out of the run. See the SAFETY note in src/services.ts and
20
+ * docs/measuring-skills.md ยง Experimental.
21
+ *
22
+ * @experimental
23
+ * @module vigiles/experimental
24
+ */
25
+ export { experimental_startServices, experimental_withServices, type ServiceSpec, type ServiceReady, type ServiceReset, type ServiceHandle, type ServiceSession, type ContainerRuntime, } from "./services.js";
26
+ export { experimental_dockerRuntime, makeDockerRuntime, type DockerExec, type NetProbe, } from "./services-docker.js";
27
+ //# sourceMappingURL=experimental.d.ts.map
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = void 0;
4
+ /**
5
+ * `vigiles/experimental` โ€” โš ๏ธ EXPERIMENTAL, UNSTABLE public surface.
6
+ *
7
+ * Everything re-exported here is a DRAFT. It is deliberately quarantined behind
8
+ * the `experimental` subpath (and the `experimental_` name prefix on runtime
9
+ * exports) so the import itself signals the risk at the call site:
10
+ *
11
+ * import { experimental_startServices } from "vigiles/experimental";
12
+ *
13
+ * NOT covered by the stability guarantee (STABILITY.md): the shape may change or
14
+ * be removed WITHOUT a major-version bump. Do not depend on it in production.
15
+ *
16
+ * Current contents โ€” the R3 disposable-service tier (real side-effect testing;
17
+ * see docs/measuring-skills.md ยง Experimental and src/services.ts).
18
+ *
19
+ * โš ๏ธ SAFETY: R3 runs a model-driven skill FOR REAL. The disposable container is
20
+ * the ONLY isolation vigiles provides โ€” it does not confine the skill's filesystem
21
+ * or network. Run it in a disposable environment with NO production access and
22
+ * keep real credentials out of the run. See the SAFETY note in src/services.ts and
23
+ * docs/measuring-skills.md ยง Experimental.
24
+ *
25
+ * @experimental
26
+ * @module vigiles/experimental
27
+ */
28
+ var services_js_1 = require("./services.js");
29
+ Object.defineProperty(exports, "experimental_startServices", { enumerable: true, get: function () { return services_js_1.experimental_startServices; } });
30
+ Object.defineProperty(exports, "experimental_withServices", { enumerable: true, get: function () { return services_js_1.experimental_withServices; } });
31
+ var services_docker_js_1 = require("./services-docker.js");
32
+ Object.defineProperty(exports, "experimental_dockerRuntime", { enumerable: true, get: function () { return services_docker_js_1.experimental_dockerRuntime; } });
33
+ Object.defineProperty(exports, "makeDockerRuntime", { enumerable: true, get: function () { return services_docker_js_1.makeDockerRuntime; } });
34
+ //# sourceMappingURL=experimental.js.map
@@ -0,0 +1,50 @@
1
+ import type { ContainerRuntime, ServiceSpec } from "./services.js";
2
+ /** A synchronous docker CLI call โ€” the injected real-IO seam. */
3
+ export type DockerExec = (args: readonly string[]) => {
4
+ readonly stdout: string;
5
+ readonly stderr: string;
6
+ readonly code: number;
7
+ };
8
+ /** A TCP readiness probe against a published host port โ€” the second IO seam. */
9
+ export type NetProbe = (port: number) => Promise<boolean>;
10
+ /**
11
+ * A docker-safe, collision-free container name for a declared service. Sanitises
12
+ * the service name to docker's `[a-zA-Z0-9_.-]` charset and appends the process
13
+ * id + a monotonic counter so parallel/repeated runs never clash. Pure given the
14
+ * injected suffix, so it's unit-tested.
15
+ */
16
+ export declare function containerNameFor(name: string, suffix: string): string;
17
+ /** The `docker run -d โ€ฆ` argv for a service. Pure. */
18
+ export declare function dockerRunArgs(spec: ServiceSpec, containerName: string): string[];
19
+ /** The `docker exec <c> sh -c <cmd>` argv. Pure. */
20
+ export declare function dockerExecArgs(containerName: string, command: string): string[];
21
+ /** The port to publish โ€” a 0-or-1 element list (v0 is single-port). Pure. */
22
+ export declare function publishedPorts(spec: ServiceSpec): number[];
23
+ /**
24
+ * Parse the published host port out of `docker port <c> <containerPort>` output,
25
+ * e.g. `0.0.0.0:49153` or `127.0.0.1:49153\n[::]:49153` โ†’ `49153`. Returns
26
+ * `undefined` when nothing is published โ€” parse-don't-validate: the caller gets
27
+ * "a port or nothing", never a magic `0` to special-case. Pure.
28
+ */
29
+ export declare function parseDockerPort(output: string): number | undefined;
30
+ /**
31
+ * Build a Docker-backed {@link ContainerRuntime}. The two real-IO seams default
32
+ * to the real docker CLI + a real TCP probe, but both are injectable so the
33
+ * lifecycle is unit-testable with fakes.
34
+ *
35
+ * @experimental
36
+ */
37
+ export declare function makeDockerRuntime(deps?: {
38
+ exec?: DockerExec;
39
+ netProbe?: NetProbe;
40
+ sleep?: (ms: number) => Promise<void>;
41
+ /** Readiness deadline; injectable so a test can cover the timeout path. */
42
+ readyTimeoutMs?: number;
43
+ }): ContainerRuntime;
44
+ /**
45
+ * The default Docker-backed runtime โ€” pass to `experimental_startServices`.
46
+ *
47
+ * @experimental
48
+ */
49
+ export declare const experimental_dockerRuntime: ContainerRuntime;
50
+ //# sourceMappingURL=services-docker.d.ts.map
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.experimental_dockerRuntime = void 0;
4
+ exports.containerNameFor = containerNameFor;
5
+ exports.dockerRunArgs = dockerRunArgs;
6
+ exports.dockerExecArgs = dockerExecArgs;
7
+ exports.publishedPorts = publishedPorts;
8
+ exports.parseDockerPort = parseDockerPort;
9
+ exports.makeDockerRuntime = makeDockerRuntime;
10
+ /**
11
+ * vigiles โ€” a Docker-backed {@link ContainerRuntime} for the R3 disposable-service
12
+ * tier (โš ๏ธ EXPERIMENTAL / UNSTABLE โ€” see src/services.ts and `vigiles/experimental`).
13
+ *
14
+ * This is the v0 backend the R3 build spec (research/r3-disposable-services.md)
15
+ * scopes: `docker run` a throwaway service, wait for it to be ready, run its seed,
16
+ * hand back a {@link ServiceHandle} whose `exec` shells into the container, and
17
+ * `docker rm -f` it on teardown.
18
+ *
19
+ * The design mirrors `src/sandbox.ts` / `src/egress.ts`: the COMMAND BUILDERS and
20
+ * output parsers are pure (unit-tested), and the two real-IO seams โ€” the
21
+ * synchronous docker CLI (`exec`) and the TCP readiness probe (`netProbe`) โ€” are
22
+ * INJECTED, so the orchestration is fully testable with fakes and only the
23
+ * end-to-end integration test needs a live daemon (it skips when absent).
24
+ *
25
+ * @experimental
26
+ * @module vigiles/experimental (docker backend)
27
+ */
28
+ const node_child_process_1 = require("node:child_process");
29
+ const node_net_1 = require("node:net");
30
+ const hash_js_1 = require("./core/hash.js");
31
+ /** How long to wait for a service to become ready before giving up. */
32
+ const DEFAULT_READY_TIMEOUT_MS = 30_000;
33
+ /** Delay between readiness polls. */
34
+ const READY_POLL_MS = 250;
35
+ let containerCounter = 0;
36
+ /**
37
+ * A docker-safe, collision-free container name for a declared service. Sanitises
38
+ * the service name to docker's `[a-zA-Z0-9_.-]` charset and appends the process
39
+ * id + a monotonic counter so parallel/repeated runs never clash. Pure given the
40
+ * injected suffix, so it's unit-tested.
41
+ */
42
+ function containerNameFor(name, suffix) {
43
+ const safe = name.replace(/[^a-zA-Z0-9_.-]/g, "-").replace(/^[-.]+/, "") || "svc";
44
+ return `vigiles-${safe}-${suffix}`;
45
+ }
46
+ /** The `docker run -d โ€ฆ` argv for a service. Pure. */
47
+ function dockerRunArgs(spec, containerName) {
48
+ const args = ["run", "-d", "--rm", "--name", containerName];
49
+ for (const [k, v] of Object.entries(spec.env ?? {})) {
50
+ args.push("-e", `${k}=${v}`);
51
+ }
52
+ for (const port of publishedPorts(spec)) {
53
+ // Publish to an ephemeral loopback host port; `docker port` discovers it.
54
+ args.push("-p", `127.0.0.1::${port}`);
55
+ }
56
+ args.push(spec.image);
57
+ return args;
58
+ }
59
+ /** The `docker exec <c> sh -c <cmd>` argv. Pure. */
60
+ function dockerExecArgs(containerName, command) {
61
+ return ["exec", containerName, "sh", "-c", command];
62
+ }
63
+ /** The port to publish โ€” a 0-or-1 element list (v0 is single-port). Pure. */
64
+ function publishedPorts(spec) {
65
+ return spec.port === undefined ? [] : [spec.port];
66
+ }
67
+ /**
68
+ * Parse the published host port out of `docker port <c> <containerPort>` output,
69
+ * e.g. `0.0.0.0:49153` or `127.0.0.1:49153\n[::]:49153` โ†’ `49153`. Returns
70
+ * `undefined` when nothing is published โ€” parse-don't-validate: the caller gets
71
+ * "a port or nothing", never a magic `0` to special-case. Pure.
72
+ */
73
+ function parseDockerPort(output) {
74
+ for (const line of output.split("\n")) {
75
+ const m = /:(\d+)\s*$/.exec(line.trim());
76
+ if (m)
77
+ return Number(m[1]);
78
+ }
79
+ return undefined;
80
+ }
81
+ /* v8 ignore start -- the real-daemon IO seams: they spawn the real `docker` CLI /
82
+ open a real socket / sleep, and are exercised by the gated integration test
83
+ (skipped without a daemon) + the real-socket unit test โ€” not the coverage gate,
84
+ which the pure builders + the fake-injected orchestration below carry. Same
85
+ pattern as sandbox.ts's `runSandboxed`. */
86
+ /** Default real docker CLI seam (synchronous). */
87
+ const realDockerExec = (args) => {
88
+ const r = (0, node_child_process_1.spawnSync)("docker", args, { encoding: "utf-8" });
89
+ return {
90
+ stdout: r.stdout ?? "",
91
+ stderr: r.stderr ?? "",
92
+ code: r.status ?? 1,
93
+ };
94
+ };
95
+ /** Default TCP probe: resolve true if a connection to 127.0.0.1:port succeeds. */
96
+ const realNetProbe = (port) => new Promise((resolve) => {
97
+ const sock = (0, node_net_1.connect)({ host: "127.0.0.1", port }, () => {
98
+ sock.destroy();
99
+ resolve(true);
100
+ });
101
+ sock.on("error", () => {
102
+ sock.destroy();
103
+ resolve(false);
104
+ });
105
+ sock.setTimeout(1000, () => {
106
+ sock.destroy();
107
+ resolve(false);
108
+ });
109
+ });
110
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
111
+ /** Parse the authoring union into a tagged {@link ReadyProbe} (exec > log > tcp). */
112
+ function parseReady(ready) {
113
+ if ("exec" in ready)
114
+ return { kind: "exec", command: ready.exec };
115
+ if ("log" in ready)
116
+ return { kind: "log", pattern: ready.log };
117
+ return { kind: "tcp", containerPort: ready.tcp }; // carry the DECLARED port
118
+ }
119
+ /** One readiness attempt for a parsed {@link ReadyProbe} โ€” exhaustive by `kind`. */
120
+ function probeReady(ctx, probe) {
121
+ switch (probe.kind) {
122
+ case "exec":
123
+ return Promise.resolve(ctx.exec(dockerExecArgs(ctx.containerName, probe.command)).code === 0);
124
+ case "log": {
125
+ const r = ctx.exec(["logs", ctx.containerName]);
126
+ return Promise.resolve(probe.pattern.test(r.stdout + r.stderr));
127
+ }
128
+ case "tcp": {
129
+ // Resolve the DECLARED tcp port's host mapping (it must be the published
130
+ // port) โ€” the value isn't discarded, so a wrong port surfaces as an error.
131
+ const hostPort = parseDockerPort(ctx.exec(["port", ctx.containerName, String(probe.containerPort)])
132
+ .stdout);
133
+ if (hostPort === undefined) {
134
+ throw new Error(`readiness { tcp: ${probe.containerPort} } โ€” container port ${probe.containerPort} is not published for "${ctx.containerName}"`);
135
+ }
136
+ return ctx.netProbe(hostPort);
137
+ }
138
+ /* v8 ignore next 2 -- exhaustiveness guard, unreachable given ReadyProbe */
139
+ default:
140
+ return (0, hash_js_1.assertNever)(probe);
141
+ }
142
+ }
143
+ /** Poll {@link probeReady} until it passes or the deadline elapses. */
144
+ async function waitReady(ctx, ready, timeoutMs) {
145
+ if (!ready)
146
+ return;
147
+ const probe = parseReady(ready); // parse ONCE, then poll
148
+ const started = numericNow();
149
+ for (;;) {
150
+ if (await probeReady(ctx, probe))
151
+ return;
152
+ if (numericNow() - started >= timeoutMs) {
153
+ throw new Error(`service "${ctx.containerName}" did not become ready within ${timeoutMs}ms`);
154
+ }
155
+ await ctx.sleep(READY_POLL_MS);
156
+ }
157
+ }
158
+ // Isolated so the deadline math is injectable-free but still centralised.
159
+ function numericNow() {
160
+ return Date.now();
161
+ }
162
+ /**
163
+ * Build a Docker-backed {@link ContainerRuntime}. The two real-IO seams default
164
+ * to the real docker CLI + a real TCP probe, but both are injectable so the
165
+ * lifecycle is unit-testable with fakes.
166
+ *
167
+ * @experimental
168
+ */
169
+ function makeDockerRuntime(deps = {}) {
170
+ const exec = deps.exec ?? realDockerExec;
171
+ const netProbe = deps.netProbe ?? realNetProbe;
172
+ const sleep = deps.sleep ?? delay;
173
+ const readyTimeoutMs = deps.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
174
+ return {
175
+ name: "docker",
176
+ available() {
177
+ return exec(["info"]).code === 0;
178
+ },
179
+ async start(name, spec) {
180
+ const containerName = containerNameFor(name, `${process.pid}-${containerCounter++}`);
181
+ const run = exec(dockerRunArgs(spec, containerName));
182
+ if (run.code !== 0) {
183
+ throw new Error(`docker run failed for service "${name}" (${spec.image}): ${run.stderr.trim()}`);
184
+ }
185
+ const primary = publishedPorts(spec)[0];
186
+ const hostPort = primary === undefined
187
+ ? undefined
188
+ : parseDockerPort(exec(["port", containerName, String(primary)]).stdout);
189
+ const handle = {
190
+ host: "127.0.0.1",
191
+ port: hostPort, // undefined when the service exposes no port
192
+ // no `url`: the docker backend can't form a generic scheme โ€” build your
193
+ // own from host + port. (Omitted, not `""`.)
194
+ exec(command) {
195
+ const r = exec(dockerExecArgs(containerName, command));
196
+ return { stdout: r.stdout, stderr: r.stderr, code: r.code };
197
+ },
198
+ };
199
+ try {
200
+ await waitReady({ exec, netProbe, sleep, containerName }, spec.ready, readyTimeoutMs);
201
+ if (spec.seed) {
202
+ const s = exec(dockerExecArgs(containerName, spec.seed));
203
+ if (s.code !== 0) {
204
+ throw new Error(`seed failed for service "${name}": ${s.stderr.trim()}`);
205
+ }
206
+ }
207
+ }
208
+ catch (err) {
209
+ exec(["rm", "-f", containerName]);
210
+ throw err;
211
+ }
212
+ // Carry the container name for stop() via a non-enumerable back-reference.
213
+ containerNames.set(handle, containerName);
214
+ return handle;
215
+ },
216
+ async stop(handle) {
217
+ const containerName = containerNames.get(handle);
218
+ if (containerName) {
219
+ exec(["rm", "-f", containerName]);
220
+ containerNames.delete(handle);
221
+ }
222
+ return Promise.resolve();
223
+ },
224
+ };
225
+ }
226
+ /** Maps a returned handle โ†’ its container name, so stop() needs no public field. */
227
+ const containerNames = new WeakMap();
228
+ /**
229
+ * The default Docker-backed runtime โ€” pass to `experimental_startServices`.
230
+ *
231
+ * @experimental
232
+ */
233
+ exports.experimental_dockerRuntime = makeDockerRuntime();
234
+ //# sourceMappingURL=services-docker.js.map
@@ -0,0 +1,209 @@
1
+ /**
2
+ * vigiles โ€” R3 disposable-service tier (โš ๏ธ EXPERIMENTAL / UNSTABLE).
3
+ *
4
+ * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+ * EXPERIMENTAL: this surface is a DRAFT. Import it from `vigiles/experimental`,
6
+ * NOT from a stable subpath. It is NOT covered by the stability guarantee and
7
+ * may change shape or be removed WITHOUT a major-version bump. Do not build a
8
+ * production workflow on it yet. See docs/measuring-skills.md ยง Experimental.
9
+ * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
10
+ *
11
+ * WHAT THIS IS. The free tiers (`runHook`, `measureTriggerRate`, `runEval`)
12
+ * cover a skill's OUTPUT and its side-effect SAFETY (did it call / not call a
13
+ * tool, write / not write a file) with no container. What they do NOT do is let
14
+ * a skill actually PERFORM a side effect against a real service and verify the
15
+ * resulting state โ€” apply a migration to a real Postgres and check the row
16
+ * landed. That is the R3 rung (see research/eval-coverage-and-isolation.md): the
17
+ * REAL system's semantics are the thing under test, so it can't be faked.
18
+ *
19
+ * THE POSTURE. vigiles COMPOSES with a throwaway container; it does not reinvent
20
+ * the sandbox. A {@link ServiceSpec} declares a disposable service; the injected
21
+ * {@link ContainerRuntime} port starts it, and it is force-removed on teardown.
22
+ *
23
+ * โš ๏ธ SAFETY โ€” the isolation is the DISPOSABLE CONTAINER, nothing more. A skill is
24
+ * model-driven, so the MODEL chooses the actions: it can do anything the run
25
+ * ENVIRONMENT allows. vigiles creates and destroys the container; it does NOT, in
26
+ * this tier, confine the skill's filesystem or block its network. So an R3 run is
27
+ * only as safe as the environment you run it in:
28
+ * - run it in a DISPOSABLE environment โ€” a CI job, a throwaway container/VM, or
29
+ * a dev box with NO production access;
30
+ * - point the task at the disposable service's connection string ONLY;
31
+ * - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
32
+ * `~/.ssh`) โ€” pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
33
+ * cleared env) to scrub them so the model has no real keys to misuse.
34
+ * Treat it like running an untrusted script. A future increment adds an egress
35
+ * wall (the skill reaches only the model + the service); until then that job is
36
+ * the operator's. See docs/measuring-skills.md ยง Experimental.
37
+ *
38
+ * WHAT SHIPS TODAY vs LATER. Today: the TYPES, the {@link ContainerRuntime} port,
39
+ * the pure {@link experimental_startServices} / {@link experimental_withServices}
40
+ * orchestration, and a Docker backend (`src/services-docker.ts`). Deferred: the
41
+ * `runEval` / `measureArms` `services` option + `ctx.service(name)`, per-trial
42
+ * reset via an eval-loop hook, and the egress wall. Requires Docker (Linux-first).
43
+ * It is an explicit opt-in and NEVER part of `vigiles audit` (audit stays
44
+ * side-effect-free).
45
+ *
46
+ * @experimental
47
+ * @module vigiles/experimental (services)
48
+ */
49
+ /**
50
+ * How a service signals it is ready to accept work โ€” polled by the
51
+ * {@link ContainerRuntime} before {@link ContainerRuntime.start} resolves, so a
52
+ * consumer never races a half-booted service.
53
+ *
54
+ * @experimental
55
+ */
56
+ export type ServiceReady = {
57
+ readonly tcp: number;
58
+ } | {
59
+ readonly log: RegExp;
60
+ } | {
61
+ readonly exec: string;
62
+ };
63
+ /**
64
+ * When a fresh container is provisioned. `"per-trial"` (the DEFAULT) gives each
65
+ * eval trial a clean service so trial N can never observe trial N-1's writes โ€”
66
+ * the repeatability property that makes a side-effecting eval trustworthy.
67
+ * `"per-arm"` shares one container across an arm's trials: faster, but only sound
68
+ * when the task is idempotent or reads only.
69
+ *
70
+ * @experimental
71
+ */
72
+ export type ServiceReset = "per-trial" | "per-arm";
73
+ /**
74
+ * A disposable service the agent's task may act against โ€” declared per arm/spec
75
+ * on the (future) `services` eval option.
76
+ *
77
+ * @experimental
78
+ */
79
+ export interface ServiceSpec {
80
+ /** Container image, e.g. `"postgres:16"` or `"redis:7"`. */
81
+ readonly image: string;
82
+ /** Environment for the container (e.g. `POSTGRES_PASSWORD`). */
83
+ readonly env?: Readonly<Record<string, string>>;
84
+ /**
85
+ * The port to publish + reach โ€” the one `ServiceHandle.port` reports and
86
+ * `endpoints` lists. v0 supports a SINGLE port (the common disposable-service
87
+ * case: a DB, a cache); multi-port publishing is deferred rather than half-done
88
+ * (a declared-but-unexposed secondary port would be a misleading state).
89
+ */
90
+ readonly port?: number;
91
+ /** Readiness gate โ€” start() resolves only once this passes. */
92
+ readonly ready?: ServiceReady;
93
+ /**
94
+ * A one-shot command run INSIDE the fresh container after it is ready and
95
+ * before the agent runs โ€” e.g. `"psql -U postgres -d app -f schema.sql"`. Its
96
+ * output is recorded once; it is NOT model-synthesized.
97
+ */
98
+ readonly seed?: string;
99
+ /** Provisioning cadence โ€” {@link ServiceReset}. Defaults to `"per-trial"`. */
100
+ readonly reset?: ServiceReset;
101
+ }
102
+ /**
103
+ * A started service, handed to `measure((ctx) => โ€ฆ)` as `ctx.service(name)` so a
104
+ * check can inspect the REAL resulting state.
105
+ *
106
+ * NOTE: {@link ServiceHandle.exec} is SYNCHRONOUS on purpose โ€” the eval `measure`
107
+ * callback is synchronous (it blocks via `spawnSync`, like `judge()`), so state
108
+ * inspection must be sync too. `exec` is the ONLY inspection primitive by design:
109
+ * it shells the service's own CLI (`psql`, `redis-cli`, โ€ฆ) so vigiles never takes
110
+ * a per-service client dependency.
111
+ *
112
+ * @experimental
113
+ */
114
+ export interface ServiceHandle {
115
+ /** Host the agent reaches the service on (from inside the run environment). */
116
+ readonly host: string;
117
+ /** The published host port โ€” `undefined` for a service that exposes none. */
118
+ readonly port?: number;
119
+ /**
120
+ * A connection URL when the runtime can form one; omitted otherwise (build your
121
+ * own from `host` + `port` โ€” the scheme differs per service, so no generic URL).
122
+ */
123
+ readonly url?: string;
124
+ /** Run a command inside the container and read its result (synchronous). */
125
+ exec(command: string): {
126
+ readonly stdout: string;
127
+ readonly stderr: string;
128
+ readonly code: number;
129
+ };
130
+ }
131
+ /**
132
+ * A live set of started services + the teardown that disposes them. Returned by
133
+ * {@link experimental_startServices}; the caller wires `endpoints` into the run's
134
+ * egress allowlist and maps `handles` into the agent's env / into `measure`.
135
+ *
136
+ * @experimental
137
+ */
138
+ export interface ServiceSession {
139
+ /** The started services, keyed by the name declared in the `services` map. */
140
+ readonly handles: Readonly<Record<string, ServiceHandle>>;
141
+ /**
142
+ * `host:port` endpoints for the started services โ€” where to point your task.
143
+ * Intended to ALSO pin an egress allowlist (composing with `src/egress.ts`),
144
+ * but that wall is a FUTURE hardening: the eval tier does NOT apply it today, so
145
+ * right now these just identify the services. Do not read them as a network
146
+ * confinement guarantee โ€” see the SAFETY note in this module's header.
147
+ */
148
+ readonly endpoints: readonly string[];
149
+ /** Stop + remove every started container. Always await this in a `finally`. */
150
+ teardown(): Promise<void>;
151
+ }
152
+ /**
153
+ * The container-lifecycle PORT โ€” the seam a concrete backend implements (raw
154
+ * `docker` is the planned v0 default; podman / testcontainers / the macOS
155
+ * os-isolation backend swap in by injection, never a conditional). Kept
156
+ * harness-agnostic and dependency-free at the core, exactly like the five
157
+ * harness ports: the abstraction exists so a second backend is a NEW OBJECT, not
158
+ * an edit threaded through the orchestrator.
159
+ *
160
+ * `start` is responsible for HONOURING {@link ServiceSpec.ready} + running
161
+ * {@link ServiceSpec.seed}, so it resolves only with a ready, seeded service.
162
+ *
163
+ * @experimental
164
+ */
165
+ export interface ContainerRuntime {
166
+ /** Backend name for diagnostics, e.g. `"docker"`. */
167
+ readonly name: string;
168
+ /** Whether this backend can actually run here (e.g. `docker info` succeeds). */
169
+ available(): boolean | Promise<boolean>;
170
+ /** Provision + ready + seed one service, returning its handle. */
171
+ start(name: string, spec: ServiceSpec): Promise<ServiceHandle>;
172
+ /** Stop + remove a started service. */
173
+ stop(handle: ServiceHandle): Promise<void>;
174
+ }
175
+ /**
176
+ * Start every declared service against an injected {@link ContainerRuntime},
177
+ * returning a {@link ServiceSession}. Pure orchestration over the port โ€” no
178
+ * `docker` is imported here, so this is fully unit-testable with a fake runtime
179
+ * (the `decideSandbox`-is-pure / `runSandboxed`-is-real split). If any service
180
+ * fails to start, the ones already up are torn down before the error propagates,
181
+ * so a partial failure never leaks a container.
182
+ *
183
+ * A Docker-backed `ContainerRuntime` and the `runEval` / `measureArms` wiring are
184
+ * the next increment โ€” until then this is the primitive you compose by hand:
185
+ * start โ†’ pin egress to `session.endpoints` โ†’ run โ†’ read `session.handles` in
186
+ * `measure` โ†’ `await session.teardown()`.
187
+ *
188
+ * @experimental โ€” surface may change without a major-version bump.
189
+ */
190
+ export declare function experimental_startServices(services: Readonly<Record<string, ServiceSpec>>, runtime: ContainerRuntime): Promise<ServiceSession>;
191
+ /**
192
+ * Run `fn` with the declared services up, disposing them afterwards โ€” even if
193
+ * `fn` throws. The scope-guard form of {@link experimental_startServices}: it
194
+ * removes the manual `try/finally` so a `measureArms` / `measure` call can be
195
+ * wrapped in one line and its containers are always cleaned up.
196
+ *
197
+ * โš ๏ธ Read the SAFETY note in this module's header first โ€” the container is the
198
+ * only isolation; keep real credentials out of the run.
199
+ *
200
+ * LIFECYCLE NOTE (honest): the services live for the WHOLE `fn` โ€” i.e. per-RUN,
201
+ * not per-trial. An eval that mutates service state across trials should make its
202
+ * task self-contained (e.g. `drop โ€ฆ if exists; create; migrate`) or run
203
+ * `trials: 1`. True per-trial reset needs an eval-loop hook and is the next
204
+ * increment (research/r3-disposable-services.md).
205
+ *
206
+ * @experimental โ€” surface may change without a major-version bump.
207
+ */
208
+ export declare function experimental_withServices<T>(services: Readonly<Record<string, ServiceSpec>>, runtime: ContainerRuntime, fn: (session: ServiceSession) => Promise<T>): Promise<T>;
209
+ //# sourceMappingURL=services.d.ts.map
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ /**
3
+ * vigiles โ€” R3 disposable-service tier (โš ๏ธ EXPERIMENTAL / UNSTABLE).
4
+ *
5
+ * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
6
+ * EXPERIMENTAL: this surface is a DRAFT. Import it from `vigiles/experimental`,
7
+ * NOT from a stable subpath. It is NOT covered by the stability guarantee and
8
+ * may change shape or be removed WITHOUT a major-version bump. Do not build a
9
+ * production workflow on it yet. See docs/measuring-skills.md ยง Experimental.
10
+ * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
11
+ *
12
+ * WHAT THIS IS. The free tiers (`runHook`, `measureTriggerRate`, `runEval`)
13
+ * cover a skill's OUTPUT and its side-effect SAFETY (did it call / not call a
14
+ * tool, write / not write a file) with no container. What they do NOT do is let
15
+ * a skill actually PERFORM a side effect against a real service and verify the
16
+ * resulting state โ€” apply a migration to a real Postgres and check the row
17
+ * landed. That is the R3 rung (see research/eval-coverage-and-isolation.md): the
18
+ * REAL system's semantics are the thing under test, so it can't be faked.
19
+ *
20
+ * THE POSTURE. vigiles COMPOSES with a throwaway container; it does not reinvent
21
+ * the sandbox. A {@link ServiceSpec} declares a disposable service; the injected
22
+ * {@link ContainerRuntime} port starts it, and it is force-removed on teardown.
23
+ *
24
+ * โš ๏ธ SAFETY โ€” the isolation is the DISPOSABLE CONTAINER, nothing more. A skill is
25
+ * model-driven, so the MODEL chooses the actions: it can do anything the run
26
+ * ENVIRONMENT allows. vigiles creates and destroys the container; it does NOT, in
27
+ * this tier, confine the skill's filesystem or block its network. So an R3 run is
28
+ * only as safe as the environment you run it in:
29
+ * - run it in a DISPOSABLE environment โ€” a CI job, a throwaway container/VM, or
30
+ * a dev box with NO production access;
31
+ * - point the task at the disposable service's connection string ONLY;
32
+ * - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
33
+ * `~/.ssh`) โ€” pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
34
+ * cleared env) to scrub them so the model has no real keys to misuse.
35
+ * Treat it like running an untrusted script. A future increment adds an egress
36
+ * wall (the skill reaches only the model + the service); until then that job is
37
+ * the operator's. See docs/measuring-skills.md ยง Experimental.
38
+ *
39
+ * WHAT SHIPS TODAY vs LATER. Today: the TYPES, the {@link ContainerRuntime} port,
40
+ * the pure {@link experimental_startServices} / {@link experimental_withServices}
41
+ * orchestration, and a Docker backend (`src/services-docker.ts`). Deferred: the
42
+ * `runEval` / `measureArms` `services` option + `ctx.service(name)`, per-trial
43
+ * reset via an eval-loop hook, and the egress wall. Requires Docker (Linux-first).
44
+ * It is an explicit opt-in and NEVER part of `vigiles audit` (audit stays
45
+ * side-effect-free).
46
+ *
47
+ * @experimental
48
+ * @module vigiles/experimental (services)
49
+ */
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.experimental_startServices = experimental_startServices;
52
+ exports.experimental_withServices = experimental_withServices;
53
+ /**
54
+ * Start every declared service against an injected {@link ContainerRuntime},
55
+ * returning a {@link ServiceSession}. Pure orchestration over the port โ€” no
56
+ * `docker` is imported here, so this is fully unit-testable with a fake runtime
57
+ * (the `decideSandbox`-is-pure / `runSandboxed`-is-real split). If any service
58
+ * fails to start, the ones already up are torn down before the error propagates,
59
+ * so a partial failure never leaks a container.
60
+ *
61
+ * A Docker-backed `ContainerRuntime` and the `runEval` / `measureArms` wiring are
62
+ * the next increment โ€” until then this is the primitive you compose by hand:
63
+ * start โ†’ pin egress to `session.endpoints` โ†’ run โ†’ read `session.handles` in
64
+ * `measure` โ†’ `await session.teardown()`.
65
+ *
66
+ * @experimental โ€” surface may change without a major-version bump.
67
+ */
68
+ async function experimental_startServices(services, runtime) {
69
+ const handles = {};
70
+ const endpoints = [];
71
+ const started = [];
72
+ const stopAll = async () => {
73
+ await Promise.allSettled(started.map((h) => runtime.stop(h)));
74
+ };
75
+ try {
76
+ for (const [name, spec] of Object.entries(services)) {
77
+ const handle = await runtime.start(name, spec);
78
+ handles[name] = handle;
79
+ started.push(handle);
80
+ // Only a service that published a port has a reachable endpoint.
81
+ if (handle.port !== undefined) {
82
+ endpoints.push(`${handle.host}:${handle.port}`);
83
+ }
84
+ }
85
+ }
86
+ catch (err) {
87
+ await stopAll();
88
+ throw err;
89
+ }
90
+ return { handles, endpoints, teardown: stopAll };
91
+ }
92
+ /**
93
+ * Run `fn` with the declared services up, disposing them afterwards โ€” even if
94
+ * `fn` throws. The scope-guard form of {@link experimental_startServices}: it
95
+ * removes the manual `try/finally` so a `measureArms` / `measure` call can be
96
+ * wrapped in one line and its containers are always cleaned up.
97
+ *
98
+ * โš ๏ธ Read the SAFETY note in this module's header first โ€” the container is the
99
+ * only isolation; keep real credentials out of the run.
100
+ *
101
+ * LIFECYCLE NOTE (honest): the services live for the WHOLE `fn` โ€” i.e. per-RUN,
102
+ * not per-trial. An eval that mutates service state across trials should make its
103
+ * task self-contained (e.g. `drop โ€ฆ if exists; create; migrate`) or run
104
+ * `trials: 1`. True per-trial reset needs an eval-loop hook and is the next
105
+ * increment (research/r3-disposable-services.md).
106
+ *
107
+ * @experimental โ€” surface may change without a major-version bump.
108
+ */
109
+ async function experimental_withServices(services, runtime, fn) {
110
+ const session = await experimental_startServices(services, runtime);
111
+ try {
112
+ return await fn(session);
113
+ }
114
+ finally {
115
+ await session.teardown();
116
+ }
117
+ }
118
+ //# sourceMappingURL=services.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "12.3.0",
3
+ "version": "12.4.0",
4
4
  "description": "Lint & test the harness your AI agent runs on โ€” verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -44,6 +44,7 @@
44
44
  "./claude-code": "./dist/claude-code.js",
45
45
  "./codex": "./dist/codex.js",
46
46
  "./adapter": "./dist/adapter.js",
47
+ "./experimental": "./dist/experimental.js",
47
48
  "./vitest": {
48
49
  "types": "./dist/vitest.d.mts",
49
50
  "default": "./dist/vitest.mjs"