vigiles 2.2.0 → 2.3.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,29 @@
1
+ export interface JudgeResult {
2
+ /** Score in [0, 1] (clamped). 0 on any failure to obtain a verdict. */
3
+ readonly score: number;
4
+ /** score ≥ threshold (default 0.5). */
5
+ readonly pass: boolean;
6
+ /** The model's one-line rationale, or an error string. */
7
+ readonly reason: string;
8
+ }
9
+ export interface JudgeOptions {
10
+ /** The text to grade. */
11
+ readonly output: string;
12
+ /** The grading rubric — describe what earns a high vs low score. */
13
+ readonly rubric: string;
14
+ /** Model alias. Default "haiku" (cheap; judging is a simple call). */
15
+ readonly model?: string;
16
+ /** pass = score ≥ threshold. Default 0.5. */
17
+ readonly threshold?: number;
18
+ /** Per-call timeout ms. Default 60000. */
19
+ readonly timeoutMs?: number;
20
+ }
21
+ /** Grade `output` against `rubric` with a model. Synchronous (for `measure`). */
22
+ export declare function judge(opts: JudgeOptions): JudgeResult;
23
+ /**
24
+ * Parse a verdict out of the grader's stdout — pure, so the parsing is testable
25
+ * without a model. Handles `claude --output-format json` (text wrapped in a
26
+ * `result` field), bare/prose-wrapped JSON, clamping, and the pass threshold.
27
+ */
28
+ export declare function parseJudgeOutput(stdout: string, threshold?: number): JudgeResult;
29
+ //# sourceMappingURL=judge.d.ts.map
package/dist/judge.js ADDED
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.judge = judge;
4
+ exports.parseJudgeOutput = parseJudgeOutput;
5
+ /**
6
+ * vigiles — a thin LLM-as-judge for the eval tier.
7
+ *
8
+ * Some outcomes aren't a regex: "is this commit message clear?", "did the SKILL
9
+ * produce a sensible plan?". `judge` grades an output against a rubric with a
10
+ * model and returns a numeric score + pass/fail, for use *inside* an eval's
11
+ * `measure` (which is synchronous — so this shells out via the `claude` CLI
12
+ * synchronously, no extra deps):
13
+ *
14
+ * measure: (ctx) => {
15
+ * const v = judge({ output: ctx.file("PLAN.md") ?? "", rubric:
16
+ * "1 if the plan lists concrete, ordered steps; else 0." });
17
+ * return { quality: v.score, ok: v.pass };
18
+ * }
19
+ *
20
+ * This is deliberately minimal — for datasets, tracing, and dashboards use a
21
+ * dedicated eval platform (Braintrust, DeepEval). vigiles owns the harness A/B,
22
+ * not the judging platform. Needs the `claude` CLI + model auth.
23
+ */
24
+ const node_child_process_1 = require("node:child_process");
25
+ const clamp01 = (n) => Math.max(0, Math.min(1, n));
26
+ /** Extract the first JSON object from a string (models often wrap it in prose). */
27
+ function firstJsonObject(s) {
28
+ const start = s.indexOf("{");
29
+ const end = s.lastIndexOf("}");
30
+ if (start === -1 || end <= start)
31
+ return null;
32
+ try {
33
+ return JSON.parse(s.slice(start, end + 1));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Grade `output` against `rubric` with a model. Synchronous (for `measure`). */
40
+ function judge(opts) {
41
+ const threshold = opts.threshold ?? 0.5;
42
+ const prompt = "You are a strict grader. Score the OUTPUT against the RUBRIC. " +
43
+ 'Respond with ONLY a JSON object: {"score": <number 0..1>, "reason": "<one line>"}.\n\n' +
44
+ `RUBRIC:\n${opts.rubric}\n\nOUTPUT:\n${opts.output}`;
45
+ let res;
46
+ try {
47
+ res = (0, node_child_process_1.spawnSync)("claude", [
48
+ "-p",
49
+ prompt,
50
+ "--model",
51
+ opts.model ?? "haiku",
52
+ "--output-format",
53
+ "json",
54
+ ], { encoding: "utf-8", timeout: opts.timeoutMs ?? 60000 });
55
+ }
56
+ catch (e) {
57
+ return {
58
+ score: 0,
59
+ pass: false,
60
+ reason: `judge spawn failed: ${String(e)}`,
61
+ };
62
+ }
63
+ if (res.status !== 0) {
64
+ return { score: 0, pass: false, reason: "judge: no model output" };
65
+ }
66
+ return parseJudgeOutput(res.stdout ?? "", threshold);
67
+ }
68
+ /**
69
+ * Parse a verdict out of the grader's stdout — pure, so the parsing is testable
70
+ * without a model. Handles `claude --output-format json` (text wrapped in a
71
+ * `result` field), bare/prose-wrapped JSON, clamping, and the pass threshold.
72
+ */
73
+ function parseJudgeOutput(stdout, threshold = 0.5) {
74
+ if (!stdout)
75
+ return { score: 0, pass: false, reason: "judge: no model output" };
76
+ // claude --output-format json wraps the model text in a `result` field.
77
+ let text = stdout;
78
+ const wrapper = firstJsonObject(stdout);
79
+ if (wrapper && typeof wrapper.result === "string")
80
+ text = wrapper.result;
81
+ const verdict = firstJsonObject(text);
82
+ if (!verdict || typeof verdict.score !== "number") {
83
+ return { score: 0, pass: false, reason: "judge: unparseable verdict" };
84
+ }
85
+ const score = clamp01(verdict.score);
86
+ return { score, pass: score >= threshold, reason: verdict.reason ?? "" };
87
+ }
88
+ //# sourceMappingURL=judge.js.map
@@ -0,0 +1,37 @@
1
+ export interface LoadedPlugin {
2
+ /** A `.claude/settings.json`-shaped object with hooks resolved. */
3
+ readonly settings: {
4
+ hooks?: unknown;
5
+ };
6
+ /** Files to materialize in the sandbox (CLAUDE.md, skills, agents, commands). */
7
+ readonly files: Record<string, string>;
8
+ /**
9
+ * Surfaces that are present in the plugin but cannot be exercised at the
10
+ * deterministic tier (subagents and slash commands need a real model; MCP
11
+ * servers aren't wired by the loader). Empty when the plugin is fully
12
+ * covered. Surfaced so "load the whole plugin" never silently tests nothing —
13
+ * read it in a test, or just to know what the deterministic run won't reach.
14
+ */
15
+ readonly warnings: readonly string[];
16
+ }
17
+ /**
18
+ * Load the real harness at `pluginPath`. Returns the resolved settings (hooks),
19
+ * the files (CLAUDE.md + skills + agents + commands) to write into the test
20
+ * sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
21
+ * `settings` with any inline settings and spread `files` into the fixture.
22
+ */
23
+ export declare function loadPlugin(pluginPath: string): LoadedPlugin;
24
+ /**
25
+ * Resolve the effective harness for a test/eval (arm): load the plugin if given,
26
+ * then layer inline settings + files on top. Shared by `runHarnessTest` and
27
+ * `runEval` so both test the assembled machine the same way.
28
+ */
29
+ export declare function resolveHarness(opts: {
30
+ plugin?: string;
31
+ settings?: unknown;
32
+ files?: Record<string, string>;
33
+ }): {
34
+ settings: unknown;
35
+ files: Record<string, string>;
36
+ };
37
+ //# sourceMappingURL=plugin-loader.d.ts.map
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadPlugin = loadPlugin;
4
+ exports.resolveHarness = resolveHarness;
5
+ /**
6
+ * vigiles — load a real plugin/repo harness for testing.
7
+ *
8
+ * The unit that matters is not a single hook but the *assembled machine*: the
9
+ * hooks, settings, CLAUDE.md, skills, subagents, and commands a plugin/repo
10
+ * actually ships, working together. `loadPlugin` reads that real harness so a
11
+ * `runHarnessTest` / `runEval` runs against what ships — not a hand-retyped
12
+ * subset that can drift. Hooks, CLAUDE.md and skills are exercisable at the
13
+ * deterministic tier; subagents/commands/MCP are materialized but only run under
14
+ * a real model, so `LoadedPlugin.warnings` flags them (no silent empty machine).
15
+ *
16
+ * runHarnessTest({ plugin: "./", model: scriptModel([...]) });
17
+ *
18
+ * Resolution order for hooks: inline `hooks` in `.claude-plugin/plugin.json`, a
19
+ * `hooks` string path in plugin.json, the `hooks/hooks.json` convention (e.g.
20
+ * obra/superpowers), then a plain repo's `.claude/settings.json`. `${CLAUDE_
21
+ * PLUGIN_ROOT}` in any hook command is expanded to the plugin's absolute path,
22
+ * so the real hook scripts run from where they live (no copying needed). The
23
+ * plugin's CLAUDE.md and skills/ are materialized into the sandbox so the
24
+ * assembled context is present too.
25
+ */
26
+ const node_fs_1 = require("node:fs");
27
+ const node_path_1 = require("node:path");
28
+ const MAX_SKILL_FILE_BYTES = 256 * 1024;
29
+ /** Read and return the `.hooks` field of a JSON file, or undefined on any error. */
30
+ function readHooksFile(path) {
31
+ try {
32
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"))
33
+ .hooks;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Read the hooks block, handling the real-world plugin layouts:
41
+ * 1. inline `hooks` object in .claude-plugin/plugin.json,
42
+ * 2. a `hooks` *string* in plugin.json pointing at a hooks JSON file,
43
+ * 3. the `hooks/hooks.json` convention (e.g. obra/superpowers) — auto-discovered,
44
+ * 4. a plain repo's `.claude/settings.json`.
45
+ */
46
+ function readHooks(root) {
47
+ const manifestPath = (0, node_path_1.join)(root, ".claude-plugin", "plugin.json");
48
+ if ((0, node_fs_1.existsSync)(manifestPath)) {
49
+ const m = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf-8"));
50
+ if (typeof m.hooks === "string")
51
+ return readHooksFile((0, node_path_1.join)(root, m.hooks));
52
+ if (m.hooks !== undefined)
53
+ return m.hooks;
54
+ }
55
+ const conventionPath = (0, node_path_1.join)(root, "hooks", "hooks.json");
56
+ if ((0, node_fs_1.existsSync)(conventionPath))
57
+ return readHooksFile(conventionPath);
58
+ const settingsPath = (0, node_path_1.join)(root, ".claude", "settings.json");
59
+ if ((0, node_fs_1.existsSync)(settingsPath))
60
+ return readHooksFile(settingsPath);
61
+ return undefined;
62
+ }
63
+ /** Recursively collect text files under `dir` as `relativePath → contents`. */
64
+ function readTree(dir, base) {
65
+ const out = {};
66
+ for (const entry of (0, node_fs_1.readdirSync)(dir)) {
67
+ const full = (0, node_path_1.join)(dir, entry);
68
+ const st = (0, node_fs_1.statSync)(full);
69
+ if (st.isDirectory()) {
70
+ Object.assign(out, readTree(full, base));
71
+ }
72
+ else if (st.isFile() && st.size <= MAX_SKILL_FILE_BYTES) {
73
+ out[(0, node_path_1.relative)(base, full)] = (0, node_fs_1.readFileSync)(full, "utf-8");
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * Load the real harness at `pluginPath`. Returns the resolved settings (hooks),
80
+ * the files (CLAUDE.md + skills + agents + commands) to write into the test
81
+ * sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
82
+ * `settings` with any inline settings and spread `files` into the fixture.
83
+ */
84
+ function loadPlugin(pluginPath) {
85
+ const root = (0, node_path_1.resolve)(pluginPath);
86
+ const hooks = readHooks(root);
87
+ // Expand ${CLAUDE_PLUGIN_ROOT} to the real absolute path so the actual hook
88
+ // scripts execute — we test the shipped wiring, not a reimplementation.
89
+ const resolvedHooks = hooks
90
+ ? JSON.parse(JSON.stringify(hooks).replaceAll("${CLAUDE_PLUGIN_ROOT}", root))
91
+ : undefined;
92
+ const files = {};
93
+ const claudeMd = (0, node_path_1.join)(root, "CLAUDE.md");
94
+ if ((0, node_fs_1.existsSync)(claudeMd)) {
95
+ files["CLAUDE.md"] = (0, node_fs_1.readFileSync)(claudeMd, "utf-8");
96
+ }
97
+ // Materialize each project-level surface under .claude/<surface>/ so the
98
+ // assembled context is present in the sandbox (best-effort — headless
99
+ // activation of plugin skills/subagents/commands is not guaranteed; the body
100
+ // is present for the agent to read either way). Counting what we materialize
101
+ // also lets us warn about surfaces the deterministic tier can't drive.
102
+ const counts = {};
103
+ for (const surface of ["skills", "agents", "commands"]) {
104
+ const dir = (0, node_path_1.join)(root, surface);
105
+ if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
106
+ continue;
107
+ const tree = readTree(dir, root);
108
+ for (const [rel, content] of Object.entries(tree)) {
109
+ files[(0, node_path_1.join)(".claude", rel)] = content;
110
+ }
111
+ counts[surface] = Object.keys(tree).length;
112
+ }
113
+ return {
114
+ settings: resolvedHooks ? { hooks: resolvedHooks } : {},
115
+ files,
116
+ warnings: pluginWarnings(root, counts, resolvedHooks, files),
117
+ };
118
+ }
119
+ /**
120
+ * Flag surfaces present-but-not-deterministically-exercisable. Subagents
121
+ * (`agents/`) and slash commands (`commands/`) are materialized into the sandbox
122
+ * but only run under a real model (Task / slash invocation), so they belong to
123
+ * the eval tier. MCP servers aren't wired by the loader at all. And a plugin
124
+ * that yields neither hooks nor files would otherwise be a silent empty machine.
125
+ */
126
+ function pluginWarnings(root, counts, hooks, files) {
127
+ const warnings = [];
128
+ if (counts.agents) {
129
+ warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
130
+ }
131
+ if (counts.commands) {
132
+ warnings.push(`plugin defines ${String(counts.commands)} slash-command file(s) under commands/ — slash-command invocation needs a real model; test at the eval tier.`);
133
+ }
134
+ if (hasMcp(root)) {
135
+ warnings.push(`plugin declares MCP server(s) (mcpServers / .mcp.json) — the loader does not wire MCP; bring the server up yourself if your test needs it.`);
136
+ }
137
+ if (!hooks && Object.keys(files).length === 0) {
138
+ warnings.push(`nothing was loaded (no hooks, CLAUDE.md, skills, agents, or commands) — the deterministic harness would run an effectively empty machine.`);
139
+ }
140
+ return warnings;
141
+ }
142
+ /** Whether the plugin declares any MCP servers (manifest field or .mcp.json). */
143
+ function hasMcp(root) {
144
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(root, ".mcp.json")))
145
+ return true;
146
+ const manifestPath = (0, node_path_1.join)(root, ".claude-plugin", "plugin.json");
147
+ if (!(0, node_fs_1.existsSync)(manifestPath))
148
+ return false;
149
+ try {
150
+ const m = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf-8"));
151
+ return m.mcpServers !== undefined;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ /**
158
+ * Merge a loaded plugin's settings with inline settings. Inline wins; when both
159
+ * declare hooks, the per-event arrays are concatenated (plugin hooks first), so
160
+ * a test can layer an extra hook on top of the real plugin. Returns `undefined`
161
+ * when neither side has hooks (so the caller skips `--settings`).
162
+ */
163
+ function mergeSettings(base, override) {
164
+ const baseHasHooks = base.hooks !== undefined;
165
+ if (override === undefined)
166
+ return baseHasHooks ? base : undefined;
167
+ if (!baseHasHooks)
168
+ return override;
169
+ const b = base;
170
+ const o = override;
171
+ const events = new Set([
172
+ ...Object.keys(b.hooks ?? {}),
173
+ ...Object.keys(o.hooks ?? {}),
174
+ ]);
175
+ const hooks = {};
176
+ for (const e of events) {
177
+ hooks[e] = [...(b.hooks?.[e] ?? []), ...(o.hooks?.[e] ?? [])];
178
+ }
179
+ return { ...o, hooks };
180
+ }
181
+ /**
182
+ * Resolve the effective harness for a test/eval (arm): load the plugin if given,
183
+ * then layer inline settings + files on top. Shared by `runHarnessTest` and
184
+ * `runEval` so both test the assembled machine the same way.
185
+ */
186
+ function resolveHarness(opts) {
187
+ const loaded = opts.plugin
188
+ ? loadPlugin(opts.plugin)
189
+ : { settings: {}, files: {} };
190
+ return {
191
+ files: { ...loaded.files, ...opts.files },
192
+ settings: mergeSettings(loaded.settings, opts.settings),
193
+ };
194
+ }
195
+ //# sourceMappingURL=plugin-loader.js.map
@@ -0,0 +1,77 @@
1
+ /** A hook event payload (the JSON Claude Code writes to the hook's stdin). */
2
+ export interface HookInput {
3
+ /** e.g. "PreToolUse", "PostToolUse", "Stop", "SessionStart", "PreCompact". */
4
+ readonly hook_event_name?: string;
5
+ /** PreToolUse/PostToolUse. */
6
+ readonly tool_name?: string;
7
+ readonly tool_input?: unknown;
8
+ readonly tool_response?: unknown;
9
+ /** UserPromptSubmit. */
10
+ readonly prompt?: string;
11
+ /** SessionStart. */
12
+ readonly source?: string;
13
+ /** Stop / SubagentStop. */
14
+ readonly stop_hook_active?: boolean;
15
+ /** Any other event-specific fields. */
16
+ readonly [k: string]: unknown;
17
+ }
18
+ /** The JSON a hook may print on stdout (all fields optional). */
19
+ export interface HookOutput {
20
+ readonly decision?: "approve" | "block";
21
+ readonly reason?: string;
22
+ readonly continue?: boolean;
23
+ readonly stopReason?: string;
24
+ readonly suppressOutput?: boolean;
25
+ readonly systemMessage?: string;
26
+ readonly hookSpecificOutput?: {
27
+ readonly hookEventName?: string;
28
+ readonly permissionDecision?: "allow" | "deny" | "ask";
29
+ readonly permissionDecisionReason?: string;
30
+ readonly additionalContext?: string;
31
+ };
32
+ readonly [k: string]: unknown;
33
+ }
34
+ export interface RunHookOptions {
35
+ /** Working directory for the hook process. Default: a value won't be set. */
36
+ readonly cwd?: string;
37
+ /** Extra env vars (merged over process.env). `{cwd}` in values is left as-is. */
38
+ readonly env?: Record<string, string>;
39
+ /** Per-run timeout ms. Default 10000. */
40
+ readonly timeoutMs?: number;
41
+ }
42
+ export interface HookRunResult {
43
+ readonly exitCode: number;
44
+ readonly stdout: string;
45
+ readonly stderr: string;
46
+ /** Parsed stdout JSON if the hook emitted a JSON decision, else null. */
47
+ readonly json: HookOutput | null;
48
+ /**
49
+ * Normalized decision: a deny/block via exit 2, `decision:"block"`, or
50
+ * `permissionDecision:"deny"` all set `blocked = true`.
51
+ */
52
+ readonly blocked: boolean;
53
+ /**
54
+ * The decision the hook expressed, preferring the structured
55
+ * `permissionDecision` ("allow"|"deny"|"ask") then legacy `decision`
56
+ * ("approve"|"block"), else undefined.
57
+ */
58
+ readonly decision: HookOutput["decision"] | "allow" | "deny" | "ask" | undefined;
59
+ }
60
+ /** Parse stdout as a hook JSON decision (pure, testable without a process). */
61
+ export declare function parseHookOutput(stdout: string): HookOutput | null;
62
+ /**
63
+ * Decide whether a hook result blocked, and the normalized decision. Pure, so
64
+ * the policy is unit-testable independent of spawning anything.
65
+ */
66
+ export declare function decideHook(exitCode: number, json: HookOutput | null): {
67
+ blocked: boolean;
68
+ decision: HookRunResult["decision"];
69
+ };
70
+ /**
71
+ * Run a hook command, piping `input` as JSON to its stdin, and report the exit
72
+ * code + parsed decision. Synchronous (so it can be used inside an eval's
73
+ * `measure` too). `command` is run through a shell, so the same command string a
74
+ * plugin ships (with args / env refs) works verbatim.
75
+ */
76
+ export declare function runHook(command: string, input: HookInput, opts?: RunHookOptions): HookRunResult;
77
+ //# sourceMappingURL=run-hook.d.ts.map
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseHookOutput = parseHookOutput;
4
+ exports.decideHook = decideHook;
5
+ exports.runHook = runHook;
6
+ /**
7
+ * vigiles — the *unit* tier for Claude Code hooks.
8
+ *
9
+ * A hook is just a process: Claude Code pipes a JSON event to its stdin and
10
+ * reads back an exit code (0 ok, 2 = block) and, optionally, a JSON decision on
11
+ * stdout. `runHook` exercises exactly that contract directly — no `claude`
12
+ * binary, no model, no sandbox — so a hook's logic can be unit-tested in
13
+ * milliseconds:
14
+ *
15
+ * const r = runHook('"$GUARD" ', {
16
+ * hook_event_name: "PreToolUse",
17
+ * tool_name: "Bash",
18
+ * tool_input: { command: "git commit --no-verify" },
19
+ * }, { env: { GUARD: guardPath } });
20
+ * assert.ok(r.blocked); // exit 2 / decision:block / permission:deny
21
+ *
22
+ * Why this exists alongside `runHarnessTest`:
23
+ * - It is the cheap base of the pyramid — no CLI dependency, runs anywhere.
24
+ * - It reaches every event. The deterministic `runHarnessTest` mock can drive
25
+ * SessionStart/Stop/UserPromptSubmit/Bash PreToolUse|PostToolUse, but NOT
26
+ * Edit/Write tool events (headless-gated), PreCompact, Notification,
27
+ * SessionEnd, or SubagentStop. At this tier you hand the hook the event
28
+ * JSON yourself, so all of them are testable.
29
+ *
30
+ * It does NOT prove the hook is *wired* into the harness (that the settings
31
+ * point at it, that `${CLAUDE_PLUGIN_ROOT}` resolves) — that is what the
32
+ * `plugin:` loader + `runHarnessTest` cover. Use both: unit-test the hook's
33
+ * logic here, then assert it fires in the assembled machine there.
34
+ */
35
+ const node_child_process_1 = require("node:child_process");
36
+ /** Parse stdout as a hook JSON decision (pure, testable without a process). */
37
+ function parseHookOutput(stdout) {
38
+ const s = stdout.trim();
39
+ if (!s.startsWith("{"))
40
+ return null;
41
+ try {
42
+ return JSON.parse(s);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * Decide whether a hook result blocked, and the normalized decision. Pure, so
50
+ * the policy is unit-testable independent of spawning anything.
51
+ */
52
+ function decideHook(exitCode, json) {
53
+ const permission = json?.hookSpecificOutput?.permissionDecision;
54
+ const decision = permission ?? json?.decision;
55
+ const blocked = exitCode === 2 || decision === "block" || decision === "deny";
56
+ return { blocked, decision };
57
+ }
58
+ /**
59
+ * Run a hook command, piping `input` as JSON to its stdin, and report the exit
60
+ * code + parsed decision. Synchronous (so it can be used inside an eval's
61
+ * `measure` too). `command` is run through a shell, so the same command string a
62
+ * plugin ships (with args / env refs) works verbatim.
63
+ */
64
+ function runHook(command, input, opts = {}) {
65
+ const res = (0, node_child_process_1.spawnSync)(command, {
66
+ shell: true,
67
+ cwd: opts.cwd,
68
+ env: { ...process.env, ...opts.env },
69
+ input: JSON.stringify(input),
70
+ encoding: "utf-8",
71
+ timeout: opts.timeoutMs ?? 10000,
72
+ });
73
+ const exitCode = res.status ?? (res.signal ? 1 : 0);
74
+ const stdout = res.stdout ?? "";
75
+ const stderr = res.stderr ?? "";
76
+ const json = parseHookOutput(stdout);
77
+ const { blocked, decision } = decideHook(exitCode, json);
78
+ return { exitCode, stdout, stderr, json, blocked, decision };
79
+ }
80
+ //# sourceMappingURL=run-hook.js.map
@@ -0,0 +1,20 @@
1
+ export interface ScriptRunResult {
2
+ readonly file: string;
3
+ readonly code: number;
4
+ }
5
+ /**
6
+ * Expand the given path/glob patterns into concrete script files. A pattern
7
+ * that is an existing file passes through unchanged; anything else is treated
8
+ * as a glob. Falls back to `defaultGlob` when no patterns are given. Results
9
+ * are deduped and sorted; `node_modules` and `dist` are always ignored.
10
+ */
11
+ export declare function discoverScripts(patterns: readonly string[], defaultGlob: string, cwd: string): string[];
12
+ /**
13
+ * Run each script as `node <file>`, inheriting stdio so the script's own report
14
+ * streams to the console. `env` is merged over `process.env` for every child
15
+ * (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes.
16
+ */
17
+ export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv): ScriptRunResult[];
18
+ /** Format a one-line-per-file run summary with a pass/fail tally. */
19
+ export declare function formatScriptSummary(results: readonly ScriptRunResult[]): string;
20
+ //# sourceMappingURL=run-scripts.d.ts.map
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoverScripts = discoverScripts;
4
+ exports.runScripts = runScripts;
5
+ exports.formatScriptSummary = formatScriptSummary;
6
+ /**
7
+ * vigiles — run harness-test / eval script files via the CLI.
8
+ *
9
+ * `vigiles test` and `vigiles eval` discover `*.harness.mjs` / `*.eval.mjs`
10
+ * scripts and run each as a child `node` process, so the two-tier
11
+ * harness-testing API (`src/harness-test.ts`, `src/eval.ts`) works as a CI
12
+ * command, not just `node x.mjs`. The scripts stay plain Node modules (they
13
+ * import from the built `dist/`), so they also run standalone — the CLI just
14
+ * discovers, runs, and aggregates exit codes.
15
+ */
16
+ const node_child_process_1 = require("node:child_process");
17
+ const node_path_1 = require("node:path");
18
+ const node_fs_1 = require("node:fs");
19
+ const glob_1 = require("glob");
20
+ /**
21
+ * Expand the given path/glob patterns into concrete script files. A pattern
22
+ * that is an existing file passes through unchanged; anything else is treated
23
+ * as a glob. Falls back to `defaultGlob` when no patterns are given. Results
24
+ * are deduped and sorted; `node_modules` and `dist` are always ignored.
25
+ */
26
+ function discoverScripts(patterns, defaultGlob, cwd) {
27
+ const globs = patterns.length > 0 ? patterns : [defaultGlob];
28
+ const found = new Set();
29
+ for (const p of globs) {
30
+ if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, p))) {
31
+ found.add(p);
32
+ continue;
33
+ }
34
+ for (const m of (0, glob_1.globSync)(p, {
35
+ cwd,
36
+ ignore: ["node_modules/**", "dist/**"],
37
+ })) {
38
+ found.add(m);
39
+ }
40
+ }
41
+ return [...found].sort();
42
+ }
43
+ /**
44
+ * Run each script as `node <file>`, inheriting stdio so the script's own report
45
+ * streams to the console. `env` is merged over `process.env` for every child
46
+ * (e.g. `VIGILES_TRIALS`). Returns the per-file exit codes.
47
+ */
48
+ function runScripts(files, cwd, env = {}) {
49
+ const results = [];
50
+ for (const file of files) {
51
+ const res = (0, node_child_process_1.spawnSync)("node", [file], {
52
+ cwd,
53
+ stdio: "inherit",
54
+ env: { ...process.env, ...env },
55
+ });
56
+ results.push({ file, code: res.status ?? 1 });
57
+ }
58
+ return results;
59
+ }
60
+ /** Format a one-line-per-file run summary with a pass/fail tally. */
61
+ function formatScriptSummary(results) {
62
+ const lines = results.map((r) => ` ${r.code === 0 ? "✓" : "✗"} ${r.file}` +
63
+ (r.code === 0 ? "" : ` (exit ${String(r.code)})`));
64
+ const failed = results.filter((r) => r.code !== 0).length;
65
+ lines.push(failed === 0
66
+ ? `\n${String(results.length)} passed.`
67
+ : `\n${String(failed)}/${String(results.length)} failed.`);
68
+ return lines.join("\n");
69
+ }
70
+ //# sourceMappingURL=run-scripts.js.map
@@ -0,0 +1,9 @@
1
+ declare module "@vitest/expect" {
2
+ interface Matchers<T = any> {
3
+ toHaveCreated(path: string): T;
4
+ toBlock(): T;
5
+ toBeatBaseline(baseline: string, arm: string, metric: string, by?: number): T;
6
+ }
7
+ }
8
+ export {};
9
+ //# sourceMappingURL=vitest.d.mts.map
@@ -0,0 +1,22 @@
1
+ /* eslint-disable max-params, @typescript-eslint/no-explicit-any --
2
+ The matcher signatures mirror the runtime vigilesMatchers (positional args),
3
+ and `Matchers<T = any>` must match @vitest/expect's generic default to merge. */
4
+ /**
5
+ * vigiles — vitest integration (opt-in). ESM, because vitest is ESM-only.
6
+ *
7
+ * Importing this entry registers the vigiles matchers AND augments vitest's
8
+ * types so `toHaveCreated` / `toBeatBaseline` type-check.
9
+ *
10
+ * // vitest.config.ts → test: { setupFiles: ["vigiles/vitest"] }
11
+ * // …or at the top of a test file:
12
+ * import "vigiles/vitest";
13
+ *
14
+ * expect(result).toHaveCreated("DONE");
15
+ * expect(report).toBeatBaseline("vanilla", "gated", "caught");
16
+ *
17
+ * vitest is an optional peer dependency — only vitest users load this entry.
18
+ */
19
+ import { expect } from "vitest";
20
+ import { vigilesMatchers } from "./harness-assert.js";
21
+ expect.extend(vigilesMatchers);
22
+ //# sourceMappingURL=vitest.mjs.map