vigiles 2.1.1 → 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.
Files changed (49) hide show
  1. package/README.md +127 -5
  2. package/dist/action-gate.d.ts +28 -0
  3. package/dist/action-gate.js +73 -0
  4. package/dist/cli.js +450 -75
  5. package/dist/community-skills.d.ts +22 -0
  6. package/dist/community-skills.js +86 -0
  7. package/dist/compile-generator.d.ts +48 -0
  8. package/dist/compile-generator.js +322 -0
  9. package/dist/compile.d.ts +3 -0
  10. package/dist/compile.js +217 -26
  11. package/dist/eval.d.ts +87 -0
  12. package/dist/eval.js +208 -0
  13. package/dist/frontmatter.d.ts +24 -6
  14. package/dist/frontmatter.js +103 -30
  15. package/dist/generate-schema.js +10 -0
  16. package/dist/harness-assert.d.ts +68 -0
  17. package/dist/harness-assert.js +127 -0
  18. package/dist/harness-test.d.ts +45 -0
  19. package/dist/harness-test.js +138 -0
  20. package/dist/inline.d.ts +22 -4
  21. package/dist/inline.js +60 -13
  22. package/dist/jest.d.ts +9 -0
  23. package/dist/jest.js +23 -0
  24. package/dist/judge.d.ts +29 -0
  25. package/dist/judge.js +88 -0
  26. package/dist/linters.js +28 -0
  27. package/dist/mock-model.d.ts +31 -0
  28. package/dist/mock-model.js +189 -0
  29. package/dist/plugin-loader.d.ts +37 -0
  30. package/dist/plugin-loader.js +195 -0
  31. package/dist/refs.d.ts +44 -0
  32. package/dist/refs.js +144 -0
  33. package/dist/run-hook.d.ts +77 -0
  34. package/dist/run-hook.js +80 -0
  35. package/dist/run-scripts.d.ts +20 -0
  36. package/dist/run-scripts.js +70 -0
  37. package/dist/skill-driver.d.ts +77 -0
  38. package/dist/skill-driver.js +76 -0
  39. package/dist/skill-runtime.d.ts +101 -0
  40. package/dist/skill-runtime.js +289 -0
  41. package/dist/skill-test.d.ts +47 -0
  42. package/dist/skill-test.js +77 -0
  43. package/dist/spec.d.ts +90 -4
  44. package/dist/spec.js +29 -0
  45. package/dist/symbols.d.ts +30 -0
  46. package/dist/symbols.js +142 -0
  47. package/dist/vitest.d.mts +9 -0
  48. package/dist/vitest.mjs +22 -0
  49. package/package.json +45 -6
@@ -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
package/dist/refs.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** An inline code span with its 1-based source line. */
2
+ export interface Span {
3
+ readonly text: string;
4
+ readonly line: number;
5
+ }
6
+ /**
7
+ * Extract inline code spans, skipping fenced code blocks (R1). Returns each
8
+ * span's trimmed text and 1-based line.
9
+ */
10
+ export declare function inlineSpans(markdown: string): Span[];
11
+ /** A parsed file-qualified reference. */
12
+ export interface SymbolRef {
13
+ readonly file: string;
14
+ readonly symbol: string;
15
+ readonly line: number;
16
+ }
17
+ /** A reference that failed verification. */
18
+ export interface SymbolRefError extends SymbolRef {
19
+ readonly reason: string;
20
+ }
21
+ /** Extract the `vigiles:symbol` references from a markdown file. */
22
+ export declare function symbolRefs(markdown: string): SymbolRef[];
23
+ /**
24
+ * Verify the file-qualified symbol references in a markdown file: the named
25
+ * file must exist and define the named symbol. `basePath` is the directory the
26
+ * paths resolve against (the instruction file's own directory).
27
+ */
28
+ export declare function verifySymbolRefs(markdown: string, basePath: string): SymbolRefError[];
29
+ /**
30
+ * Whether a span looks like a *code reference* that ought to carry a
31
+ * file-qualified mark — a scoped name, or an identifier that isn't a bare
32
+ * lowercase prose word. A function-call form `` `foo(args)` `` is treated as a
33
+ * reference to its callee `foo`. Paths/filenames are excluded (they are `file`
34
+ * refs).
35
+ */
36
+ export declare function isCodeShaped(text: string): boolean;
37
+ /**
38
+ * Code-shaped inline references that are NOT yet marked — the spans the
39
+ * enforcement hook makes the agent mark as `` `vigiles:symbol path.ext#symbol` ``
40
+ * or opt out of with `<!-- vigiles:ignore -->` (or `<!-- vigiles:ignore-file -->`
41
+ * for the whole file).
42
+ */
43
+ export declare function unmarkedCodeRefs(markdown: string): Span[];
44
+ //# sourceMappingURL=refs.d.ts.map
package/dist/refs.js ADDED
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inlineSpans = inlineSpans;
4
+ exports.symbolRefs = symbolRefs;
5
+ exports.verifySymbolRefs = verifySymbolRefs;
6
+ exports.isCodeShaped = isCodeShaped;
7
+ exports.unmarkedCodeRefs = unmarkedCodeRefs;
8
+ /**
9
+ * vigiles — file-qualified symbol reference verification (variant A).
10
+ *
11
+ * A reference names both the file and the symbol, as an inline code span:
12
+ *
13
+ * See `src/config.ts#parseConfig` for the loader.
14
+ * Render with `app/models/user.rb#full_name`.
15
+ *
16
+ * We parse *that one named file* and check it defines the symbol. No
17
+ * project-wide index, no cross-file resolution, no autoloader chasing, no
18
+ * ambiguity (the file disambiguates). Because the `path.ext#symbol` shape is
19
+ * unmistakable and deliberately written, it is a *declared* reference — like
20
+ * `vigiles:file` / `vigiles:cmd` — so a broken one is an **error**, not an
21
+ * inferred-prose warning. The author names the file; vigiles proves the symbol.
22
+ *
23
+ * R1 (cross-compat): only inline code spans are read. Fenced code blocks are
24
+ * never touched, so rustdoc doctests / typescript-docs-verifier keep working.
25
+ */
26
+ const node_fs_1 = require("node:fs");
27
+ const node_path_1 = require("node:path");
28
+ const symbols_js_1 = require("./symbols.js");
29
+ const FENCE = /^\s*```/;
30
+ const SPAN = /`([^`\n]+)`/g;
31
+ /**
32
+ * Extract inline code spans, skipping fenced code blocks (R1). Returns each
33
+ * span's trimmed text and 1-based line.
34
+ */
35
+ function inlineSpans(markdown) {
36
+ const spans = [];
37
+ let inFence = false;
38
+ const lines = markdown.split("\n");
39
+ for (let i = 0; i < lines.length; i++) {
40
+ if (FENCE.test(lines[i])) {
41
+ inFence = !inFence;
42
+ continue;
43
+ }
44
+ if (inFence)
45
+ continue;
46
+ for (const m of lines[i].matchAll(SPAN)) {
47
+ spans.push({ text: m[1].trim(), line: i + 1 });
48
+ }
49
+ }
50
+ return spans;
51
+ }
52
+ // A file-qualified symbol reference: `<path>.<ext>` then `#`/`::` then a symbol.
53
+ // Requires a real file extension before the separator, so a bare scoped symbol
54
+ // An explicit `vigiles:symbol <path>.<ext>#<symbol>` directive inside a code
55
+ // span. The literal `vigiles:symbol` prefix means zero detection heuristic — a
56
+ // span either carries it or it does not — consistent with the rest of vigiles'
57
+ // markers. The mark is self-contained (file + symbol in one inline token), so
58
+ // it binds unambiguously even in a long line with several references.
59
+ const SYMBOL_MARK = /^vigiles:symbol\s+([\w@./-]+\.[A-Za-z0-9]+)(?:#|::)([A-Za-z_]\w*[?!]?)$/;
60
+ /** Extract the `vigiles:symbol` references from a markdown file. */
61
+ function symbolRefs(markdown) {
62
+ const refs = [];
63
+ for (const span of inlineSpans(markdown)) {
64
+ const m = SYMBOL_MARK.exec(span.text);
65
+ if (m)
66
+ refs.push({ file: m[1], symbol: m[2], line: span.line });
67
+ }
68
+ return refs;
69
+ }
70
+ /**
71
+ * Verify the file-qualified symbol references in a markdown file: the named
72
+ * file must exist and define the named symbol. `basePath` is the directory the
73
+ * paths resolve against (the instruction file's own directory).
74
+ */
75
+ function verifySymbolRefs(markdown, basePath) {
76
+ const errors = [];
77
+ for (const ref of symbolRefs(markdown)) {
78
+ const full = (0, node_path_1.resolve)(basePath, ref.file);
79
+ if (!(0, node_fs_1.existsSync)(full)) {
80
+ errors.push({ ...ref, reason: `File not found: "${ref.file}"` });
81
+ }
82
+ else if ((0, symbols_js_1.langForFile)(ref.file) === null) {
83
+ errors.push({
84
+ ...ref,
85
+ reason: `Unsupported language for symbol check: "${ref.file}"`,
86
+ });
87
+ }
88
+ else if (!(0, symbols_js_1.fileDefinesSymbol)(full, ref.symbol)) {
89
+ errors.push({
90
+ ...ref,
91
+ reason: `"${ref.symbol}" is not defined in ${ref.file}`,
92
+ });
93
+ }
94
+ }
95
+ return errors;
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Enforcement: force code references to carry the file-qualified mark
99
+ // ---------------------------------------------------------------------------
100
+ const PATH_LIKE = /[/\\]|\.[A-Za-z0-9]+$/; // a path or a bare filename
101
+ const PLAIN_ID = /^[A-Za-z_]\w*$/;
102
+ const SCOPED = /^[A-Za-z_]\w*(?:#|::)[\w?!]+$/;
103
+ const IGNORE_FILE = /<!--\s*vigiles:ignore-file\s*-->/;
104
+ const IGNORE_LINE = /<!--\s*vigiles:ignore\s*-->/;
105
+ /**
106
+ * Whether a span looks like a *code reference* that ought to carry a
107
+ * file-qualified mark — a scoped name, or an identifier that isn't a bare
108
+ * lowercase prose word. A function-call form `` `foo(args)` `` is treated as a
109
+ * reference to its callee `foo`. Paths/filenames are excluded (they are `file`
110
+ * refs).
111
+ */
112
+ function isCodeShaped(text) {
113
+ const callee = text.replace(/\s*\([^)]*\)\s*$/, ""); // `foo(args)` → `foo`
114
+ if (SCOPED.test(callee))
115
+ return true;
116
+ if (!PLAIN_ID.test(callee))
117
+ return false;
118
+ const hasUnderscore = callee.includes("_");
119
+ const hasCamel = /[a-z][A-Z]/.test(callee);
120
+ const isPascal = /^[A-Z][a-z]/.test(callee);
121
+ const isScreaming = /^[A-Z][A-Z0-9_]+$/.test(callee);
122
+ return hasUnderscore || hasCamel || isPascal || isScreaming;
123
+ }
124
+ /**
125
+ * Code-shaped inline references that are NOT yet marked — the spans the
126
+ * enforcement hook makes the agent mark as `` `vigiles:symbol path.ext#symbol` ``
127
+ * or opt out of with `<!-- vigiles:ignore -->` (or `<!-- vigiles:ignore-file -->`
128
+ * for the whole file).
129
+ */
130
+ function unmarkedCodeRefs(markdown) {
131
+ if (IGNORE_FILE.test(markdown))
132
+ return [];
133
+ const lines = markdown.split("\n");
134
+ return inlineSpans(markdown).filter((span) => {
135
+ if (IGNORE_LINE.test(lines[span.line - 1] ?? ""))
136
+ return false;
137
+ if (SYMBOL_MARK.test(span.text))
138
+ return false; // already a vigiles:symbol mark
139
+ if (PATH_LIKE.test(span.text))
140
+ return false; // a path/filename → file ref
141
+ return isCodeShaped(span.text);
142
+ });
143
+ }
144
+ //# sourceMappingURL=refs.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