vigiles 2.4.0 → 2.5.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.
@@ -0,0 +1,241 @@
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.parseRequestLog = parseRequestLog;
8
+ exports.runSandboxed = runSandboxed;
9
+ /**
10
+ * vigiles — safe-by-default confinement for executing untrusted harness code.
11
+ *
12
+ * `runHarnessTest` runs the real `claude` CLI, which runs the real hooks of
13
+ * whatever plugin you load. For code YOU authored (inline `settings`/`files`)
14
+ * that's fine — trust is implicit. But pointing it at someone else's `plugin` /
15
+ * `pluginDir` executes THEIR hooks with your privileges. This module makes that
16
+ * safe by default: untrusted code is confined under bubblewrap, or — if no
17
+ * sandbox is available — the run refuses rather than executing unconfined.
18
+ *
19
+ * Confinement (proven on bwrap 0.9): `--unshare-all` gives a fresh network
20
+ * namespace whose loopback is auto-up but has NO external route — so the
21
+ * scripted mock, co-launched INSIDE the namespace, is reachable over 127.0.0.1
22
+ * while a malicious hook cannot phone home. The filesystem is `--ro-bind`
23
+ * read-only except the throwaway work dir, a fresh empty `$HOME`, and an IO dir
24
+ * used to hand the script in and stream captured requests back out.
25
+ *
26
+ * The policy (`decideSandbox`), trust test (`specTrusted`), and bwrap argv
27
+ * (`bwrapArgs`) are pure and unit-tested; the executor (`runSandboxed`) needs a
28
+ * real bwrap and is covered by the integration test, which skips where bwrap is
29
+ * absent — the same pattern as the real-`claude` paths.
30
+ */
31
+ const node_child_process_1 = require("node:child_process");
32
+ const node_fs_1 = require("node:fs");
33
+ const node_os_1 = require("node:os");
34
+ const node_path_1 = require("node:path");
35
+ /**
36
+ * Whether bubblewrap is available to confine untrusted code. **Linux only** —
37
+ * bubblewrap is a Linux tool, so this is always `false` on macOS / Windows,
38
+ * where confined execution isn't supported and untrusted code must instead be
39
+ * run via `sandbox: false` (trusting the outer container) or skipped.
40
+ */
41
+ function sandboxAvailable() {
42
+ /* v8 ignore next -- non-Linux has no bwrap; CI/coverage runs on Linux */
43
+ if (process.platform !== "linux")
44
+ return false;
45
+ try {
46
+ return (0, node_child_process_1.spawnSync)("bwrap", ["--version"], { stdio: "ignore" }).status === 0;
47
+ }
48
+ catch {
49
+ /* v8 ignore next -- defensive: spawnSync only throws on a fork failure */
50
+ return false;
51
+ }
52
+ }
53
+ /**
54
+ * Is this spec's executed code trusted? Inline `settings`/`files` you authored
55
+ * are trusted; any external `plugin` / `pluginDir` brings in third-party hooks
56
+ * and is NOT — committing it to your repo is the same trust decision as a
57
+ * dependency, so the trust boundary follows provenance: foreign = confined.
58
+ */
59
+ function specTrusted(spec) {
60
+ return spec.plugin === undefined && spec.pluginDir === undefined;
61
+ }
62
+ /**
63
+ * The pure safe-by-default policy. Untrusted code NEVER runs unconfined unless
64
+ * the caller explicitly opted out (`mode: false`). This is the whole security
65
+ * contract, isolated as a pure function so it is exhaustively unit-tested.
66
+ */
67
+ function decideSandbox(opts) {
68
+ // Explicit dangerous opt-out: run unconfined, trusted or not.
69
+ if (opts.mode === false)
70
+ return { action: "direct" };
71
+ // Force confinement regardless of trust; refuse if we can't.
72
+ if (opts.mode === "strict") {
73
+ return opts.available
74
+ ? { action: "sandbox" }
75
+ : {
76
+ action: "throw",
77
+ reason: "sandbox: 'strict' requires Linux + bubblewrap (bwrap), which was not available",
78
+ };
79
+ }
80
+ // auto: trusted code runs directly; untrusted must be confined or refused.
81
+ if (opts.trusted)
82
+ return { action: "direct" };
83
+ return opts.available
84
+ ? { action: "sandbox" }
85
+ : {
86
+ action: "throw",
87
+ reason: "refusing to execute an untrusted plugin's hooks without a sandbox: " +
88
+ "the sandbox needs Linux + bubblewrap (bwrap) — install it to run " +
89
+ "confined, or pass sandbox: false to run unconfined if you trust this " +
90
+ "code / the outer container",
91
+ };
92
+ }
93
+ /**
94
+ * The bubblewrap confinement argv (everything before the command): a fresh
95
+ * network namespace (`--unshare-all`, loopback-only, no egress), a read-only
96
+ * system, writable mounts limited to the work dir, the IO dir, and a fresh empty
97
+ * HOME (inside the IO dir so it needs no mountpoint on the read-only root, and so
98
+ * no host credentials/config leak in), and a **cleared environment** —
99
+ * `--clearenv` drops every host variable (API keys, cloud creds) and only PATH /
100
+ * HOME / TMPDIR are set back, so untrusted code can't even read your secrets.
101
+ * Pure, so the confinement shape is asserted in a unit test.
102
+ */
103
+ function bwrapArgs(opts) {
104
+ return [
105
+ // New user/net/pid/ipc/uts/cgroup namespaces. The net namespace has only a
106
+ // loopback route, so the in-sandbox mock is reachable but egress is blocked.
107
+ "--unshare-all",
108
+ // Drop ALL inherited env (host secrets); only the essentials are set back.
109
+ "--clearenv",
110
+ "--ro-bind",
111
+ "/",
112
+ "/",
113
+ "--dev",
114
+ "/dev",
115
+ "--proc",
116
+ "/proc",
117
+ // Writable: the work dir and the IO dir (later binds override the ro-bind).
118
+ "--bind",
119
+ opts.cwd,
120
+ opts.cwd,
121
+ "--bind",
122
+ opts.ioDir,
123
+ opts.ioDir,
124
+ // A fresh empty HOME so no host credentials/config are visible.
125
+ "--setenv",
126
+ "HOME",
127
+ opts.home,
128
+ "--setenv",
129
+ "TMPDIR",
130
+ opts.ioDir,
131
+ // PATH must be set back explicitly (cleared above) so node/claude resolve.
132
+ "--setenv",
133
+ "PATH",
134
+ opts.path,
135
+ "--chdir",
136
+ opts.cwd,
137
+ "--die-with-parent",
138
+ "--new-session",
139
+ ];
140
+ }
141
+ /** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
142
+ function parseRequestLog(ndjson) {
143
+ const out = [];
144
+ for (const line of ndjson.split("\n")) {
145
+ if (!line.trim())
146
+ continue;
147
+ try {
148
+ out.push(JSON.parse(line));
149
+ }
150
+ catch {
151
+ /* a partially-written final line — skip */
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ /**
157
+ * Co-launch the scripted mock and `claude` inside ONE bubblewrap network
158
+ * namespace: the mock serves on the sandbox's loopback (reachable), egress is
159
+ * blocked, and captured requests stream out through the bound IO dir. Paths come
160
+ * in via env so the wrapper needs no escaping; `claude`'s args are the wrapper's
161
+ * positional params (`"$@"`).
162
+ */
163
+ const WRAPPER = [
164
+ // start the in-sandbox mock; it writes its port to $VIG_PORT when ready
165
+ 'node "$VIG_MOCKENTRY" "$VIG_SCRIPT" "$VIG_REQS" "$VIG_PORT" &',
166
+ "MOCKPID=$!",
167
+ "i=0",
168
+ 'while [ ! -s "$VIG_PORT" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i+1)); done',
169
+ 'export ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$VIG_PORT")"',
170
+ "export ANTHROPIC_API_KEY=sk-vigiles-mock",
171
+ 'claude "$@"',
172
+ "code=$?",
173
+ 'kill "$MOCKPID" 2>/dev/null',
174
+ 'exit "$code"',
175
+ ].join("\n");
176
+ /* v8 ignore start -- spawns bwrap + the real claude CLI; exercised by the
177
+ bwrap-backed integration test (skipped without bwrap), not the unit gate —
178
+ the pure policy/args/parse helpers above carry the testable logic. */
179
+ function runSandboxed(opts) {
180
+ const ioDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-sbx-"));
181
+ const home = (0, node_path_1.join)(ioDir, "home");
182
+ (0, node_fs_1.mkdirSync)(home);
183
+ const scriptF = (0, node_path_1.join)(ioDir, "script.json");
184
+ const reqsF = (0, node_path_1.join)(ioDir, "requests.ndjson");
185
+ const portF = (0, node_path_1.join)(ioDir, "port");
186
+ (0, node_fs_1.writeFileSync)(scriptF, JSON.stringify(opts.script));
187
+ (0, node_fs_1.writeFileSync)(reqsF, "");
188
+ // The mock entry is only runnable as built JS. In production __dirname is
189
+ // dist/ (sibling); under vitest the source runs from src/, so fall back to
190
+ // the built dist/ copy.
191
+ const mockEntry = [
192
+ (0, node_path_1.join)(__dirname, "mock-entry.js"),
193
+ (0, node_path_1.join)(__dirname, "..", "dist", "mock-entry.js"),
194
+ ].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "mock-entry.js");
195
+ const args = [
196
+ ...bwrapArgs({
197
+ cwd: opts.cwd,
198
+ ioDir,
199
+ home,
200
+ path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
201
+ }),
202
+ "--setenv",
203
+ "VIG_MOCKENTRY",
204
+ mockEntry,
205
+ "--setenv",
206
+ "VIG_SCRIPT",
207
+ scriptF,
208
+ "--setenv",
209
+ "VIG_REQS",
210
+ reqsF,
211
+ "--setenv",
212
+ "VIG_PORT",
213
+ portF,
214
+ "sh",
215
+ "-c",
216
+ WRAPPER,
217
+ "sh",
218
+ ...opts.claudeArgs,
219
+ ];
220
+ return new Promise((resolvePromise) => {
221
+ const child = (0, node_child_process_1.spawn)("bwrap", args, {
222
+ cwd: opts.cwd,
223
+ env: process.env,
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ });
226
+ let stdout = "";
227
+ child.stdout.on("data", (d) => (stdout += d.toString()));
228
+ child.stderr.on("data", () => {
229
+ /* hook diagnostics — not needed for the captured result */
230
+ });
231
+ const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
232
+ child.on("close", (code) => {
233
+ clearTimeout(timer);
234
+ const requests = parseRequestLog((0, node_fs_1.existsSync)(reqsF) ? (0, node_fs_1.readFileSync)(reqsF, "utf-8") : "");
235
+ (0, node_fs_1.rmSync)(ioDir, { recursive: true, force: true });
236
+ resolvePromise({ code: code ?? 0, stdout, requests });
237
+ });
238
+ });
239
+ }
240
+ /* v8 ignore stop */
241
+ //# 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
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Compile .spec.ts files to instruction files (CLAUDE.md, AGENTS.md) with linter cross-referencing",
5
5
  "bin": {
6
6
  "vigiles": "dist/cli.js"
@@ -40,7 +40,8 @@
40
40
  ],
41
41
  "scripts": {
42
42
  "build": "tsc",
43
- "test": "npm run build && node --test dist/spec.test.js dist/validate.test.js dist/cli.test.js dist/proofs.test.js dist/inline.test.js dist/sidecar.test.js dist/coverage.test.js dist/session.test.js dist/orphans.test.js dist/cedar.test.js dist/doc-refs.test.js dist/frontmatter.test.js dist/skill-pipeline.test.js dist/skill-runtime.test.js dist/skill-driver.test.js dist/skill-test.test.js dist/compile-generator.test.js dist/community-skills.test.js dist/action-gate.test.js dist/symbols.test.js dist/refs.test.js dist/harness-test.test.js dist/eval.test.js dist/run-scripts.test.js dist/plugin-loader.test.js dist/harness-assert.test.js dist/judge.test.js dist/run-hook.test.js dist/mcp.test.js",
43
+ "test": "npm run build && vitest run",
44
+ "coverage": "npm run build && vitest run --coverage",
44
45
  "lint": "eslint src/",
45
46
  "fmt": "prettier --write .",
46
47
  "fmt:check": "prettier --check .",
@@ -48,7 +49,7 @@
48
49
  "test:e2e": "bash test/e2e/run.sh",
49
50
  "test:harness": "npm run build && node dist/cli.js test",
50
51
  "test:eval": "npm run build && node dist/cli.js eval",
51
- "test:vitest": "npm run build && vitest run",
52
+ "test:vitest": "npm run build && vitest run --project runners",
52
53
  "test:jest": "npm run build && jest",
53
54
  "test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json"
54
55
  },
@@ -59,6 +60,7 @@
59
60
  "@types/node": "^20.19.39",
60
61
  "@typescript-eslint/eslint-plugin": "^8.58.0",
61
62
  "@typescript-eslint/parser": "^8.58.0",
63
+ "@vitest/coverage-v8": "^4.1.8",
62
64
  "eslint": "^10.1.0",
63
65
  "eslint-plugin-sonarjs": "^4.0.2",
64
66
  "globals": "^17.4.0",