vigiles 27.3.0 → 29.0.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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter-registry.d.ts +39 -0
  3. package/dist/adapter-registry.js +45 -0
  4. package/dist/adapter.d.ts +8 -0
  5. package/dist/adapter.js +10 -1
  6. package/dist/adapters/claude-code/adapter.js +9 -2
  7. package/dist/adapters/claude-code/layout.d.ts +5 -0
  8. package/dist/adapters/claude-code/plugin-loader.d.ts +10 -1
  9. package/dist/adapters/claude-code/plugin-loader.js +10 -1
  10. package/dist/adapters/codex/adapter.js +7 -2
  11. package/dist/adapters/codex/layout.d.ts +51 -5
  12. package/dist/adapters/codex/layout.js +13 -4
  13. package/dist/adapters/opencode/adapter.js +6 -0
  14. package/dist/audit-report.template.html +1 -1
  15. package/dist/audit-score.d.ts +7 -0
  16. package/dist/audit-score.js +49 -2
  17. package/dist/cli-main.js +162 -39
  18. package/dist/core/adapter.d.ts +45 -1
  19. package/dist/core/compile.js +11 -1
  20. package/dist/core/config-schema.d.ts +244 -0
  21. package/dist/core/config-schema.js +452 -0
  22. package/dist/core/hook-program.d.ts +43 -0
  23. package/dist/core/hook-program.js +32 -0
  24. package/dist/core/refs.js +10 -1
  25. package/dist/core/surface-discovery.d.ts +270 -0
  26. package/dist/core/surface-discovery.js +425 -0
  27. package/dist/core/surface-scopes.d.ts +38 -1
  28. package/dist/core/surface-scopes.js +73 -1
  29. package/dist/core/symbols.d.ts +24 -2
  30. package/dist/core/symbols.js +66 -18
  31. package/dist/core/types.d.ts +36 -107
  32. package/dist/core/validate.d.ts +46 -18
  33. package/dist/core/validate.js +98 -172
  34. package/dist/exclude.d.ts +20 -0
  35. package/dist/exclude.js +11 -1
  36. package/dist/harness-test.js +3 -3
  37. package/dist/hook-install.d.ts +53 -0
  38. package/dist/hook-install.js +60 -0
  39. package/dist/hook-runtime.d.ts +2 -2
  40. package/dist/hook-runtime.js +82 -63
  41. package/dist/layout-registry.d.ts +14 -0
  42. package/dist/layout-registry.js +40 -0
  43. package/dist/load-hook.d.ts +1 -1
  44. package/dist/load-hook.js +2 -2
  45. package/dist/plugin-loader.d.ts +49 -1
  46. package/dist/plugin-loader.js +120 -14
  47. package/dist/run-hook.js +17 -1
  48. package/dist/scan-core.d.ts +19 -0
  49. package/dist/scan-core.js +30 -0
  50. package/dist/scan-files.js +15 -5
  51. package/dist/scan.d.ts +63 -0
  52. package/dist/scan.js +68 -12
  53. package/dist/score-core.js +8 -0
  54. package/dist/setup-plan.d.ts +2 -1
  55. package/dist/setup-plan.js +7 -2
  56. package/dist/surface-discovery-fs.d.ts +12 -0
  57. package/dist/surface-discovery-fs.js +108 -0
  58. package/dist/vigilesrc.schema.json +1689 -0
  59. package/package.json +10 -6
@@ -2,8 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_RULES = void 0;
4
4
  exports.findInstructionFiles = findInstructionFiles;
5
- exports.normalizeSeverity = normalizeSeverity;
6
- exports.asStringArray = asStringArray;
7
5
  exports.loadConfig = loadConfig;
8
6
  exports.parseRules = parseRules;
9
7
  exports.validate = validate;
@@ -14,6 +12,25 @@ const node_fs_1 = require("node:fs");
14
12
  const glob_1 = require("glob");
15
13
  const node_path_1 = require("node:path");
16
14
  const cosmiconfig_1 = require("cosmiconfig");
15
+ // 🔴 A TOP-LEVEL IMPORT, and the lazy `require` it replaced is recorded because
16
+ // the instinct to reintroduce it is correct-sounding: Zod is a ~45 ms cold
17
+ // import and this module is on the CLI's barrel. Two measured facts settle it.
18
+ //
19
+ // (1) It does not reach the HOT rail at all. A compiled hook's decision
20
+ // (`vigiles hook-runtime run-program`) never loads the verb barrel — the
21
+ // dispatcher shim in `src/cli.ts` branches before it, and
22
+ // `src/hook-runtime-graph.test.ts` fails the day that stops being true — so
23
+ // it never loads this file, lazily or otherwise.
24
+ // (2) On the rails that DO load the barrel (the PostToolUse `refs` and
25
+ // `eval-lock-nudge` nudges) the floor is ~316 ms of Node startup plus ~85
26
+ // existing requires, against which 45 ms is ~13%, not a breach.
27
+ //
28
+ // And the attempt is on record as well: an in-body `require("./config-schema")`
29
+ // resolves in `dist` (CommonJS) and NOT under vitest, which imports the `.ts`
30
+ // sources — it failed every suite that touches `loadConfig`. A deferral that
31
+ // only works in one of the two worlds the code runs in is not one line of
32
+ // hygiene, it is a second module system.
33
+ const config_schema_js_1 = require("./config-schema.js");
17
34
  // ---------------------------------------------------------------------------
18
35
  // Constants & regex
19
36
  // ---------------------------------------------------------------------------
@@ -21,7 +38,6 @@ const GUIDANCE_RE = /\*\*Guidance only\*\*/;
21
38
  const DISABLE_RE = /<!--\s*vigiles-disable\s*-->/;
22
39
  const RULE_HEADER_RE = /^###\s+(.+)$/;
23
40
  const CHECKBOX_RE = /^- \[([ xX])\]\s+(.+)$/;
24
- const VALID_MARKERS = ["headings", "checkboxes"];
25
41
  // ---------------------------------------------------------------------------
26
42
  // Default config
27
43
  // ---------------------------------------------------------------------------
@@ -31,103 +47,25 @@ const VALID_MARKERS = ["headings", "checkboxes"];
31
47
  const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
32
48
  // The default instruction file to validate when no config names one.
33
49
  const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
34
- exports.DEFAULT_RULES = {
35
- // 🔴 BOTH DROP TO "warn", and that is a deliberate behaviour change.
36
- //
37
- // They used to feed the exit code directly and could not be tiered at all
38
- // (#181), so a single unreferenced doc turned a PR red with no way to say
39
- // "report it, do not block". Naming them as rules made the contradiction
40
- // visible: both are HEURISTIC-BEHAVIORAL (an NCD similarity proxy, an
41
- // "unreferenced" guess that an OSS sweep measured at ~100% false positives on
42
- // nav-managed doc sites), and this repo's own calibration rule is that a
43
- // heuristic never defaults to `error` because it cries wolf. `orphan-docs`
44
- // already DECLARED `warn` in its meta while behaving as `error` — the gate
45
- // caught that disagreement the moment the rule was registered properly.
46
- //
47
- // Set either to `"error"` to keep the old blocking behaviour.
48
- // Hard error, like `compile` itself: a dead reference is decidable from the
49
- // filesystem, not a proxy — the calibration rule's `external-decidable` tier.
50
- "spec-refs": "error",
51
- "orphan-docs": "warn",
52
- "duplicate-rules": "warn",
53
- "require-instructions-spec": "warn",
54
- // Default OFF — the consistent `require-<surface>-spec` parallel. Skills are
55
- // legitimately hand-written, so requiring a .spec.ts per SKILL.md is the wrong
56
- // default (it would nag about vendored/fixture/bench skills); the coverage that
57
- // matters is the `untested-*` rules ("every skill/agent/hook ships with a test
58
- // or eval"). Set `require-skill-spec` explicitly if your team wants every skill
59
- // spec-managed.
60
- "require-skill-spec": false,
61
- integrity: "warn",
62
- coverage: false,
63
- // Per-kind surface-coverage: a skill/agent/hook must ship with a test or eval.
64
- "untested-skill": "warn",
65
- "untested-subagent": "warn",
66
- "untested-hook": "warn",
67
- "unmarked-refs": "warn",
68
- // High-precision (never-available + close typos only), so on by default at warn.
69
- "subagent-tool-contract": "warn",
70
- // High-precision (close typos only), on by default at warn.
71
- "hook-events": "warn",
72
- // Missing required frontmatter (name/description) — on by default at warn.
73
- "subagent-frontmatter": "warn",
74
- // A declared MCP server with no command/url can't start — on by default at warn.
75
- "mcp-config": "warn",
76
- // Best-practice nudge (skills load without frontmatter) — warn, not error.
77
- "skill-frontmatter": "warn",
78
- // High-precision (gated on a declared MCP set; built-ins allowlisted) — warn.
79
- "mcp-tool-resolves": "warn",
80
- // A hook script referenced but missing never runs — on by default at warn.
81
- "hook-script-exists": "warn",
82
- // Discovery nudge toward compiled hooks (one finding) — default OFF: it's a
83
- // recommendation, not a defect (the hand-written shell lane stays first-class),
84
- // so it shouldn't fire unasked. Set "warn"/"error" to opt in.
85
- "prefer-compiled-hooks": false,
86
- // High-precision (close-typo only) deny-list mirror of subagent-tool-contract.
87
- "disallowed-tools-contract": "warn",
88
- // Deterministic NCD precision proxy (near-identical skill descriptions) — warn.
89
- "description-overlap": "warn",
90
- // A model-invocable skill's description so long the trigger signal is buried —
91
- // WARN only (heuristic proxy, generous 500-char budget); never gates.
92
- "skill-description-budget": "warn",
93
- // Malformed-YAML frontmatter — WARN only (js-yaml is stricter than some loaders).
94
- "frontmatter-valid": "warn",
95
- // A mcp_tool hook incomplete / targeting an undeclared server — on by default at warn.
96
- "mcp-hook-target-resolves": "warn",
97
- // Lethal-trifecta capability set-intersection (read-private + ingest-untrusted +
98
- // exfiltrate in one unit) — WARN by default (don't-cry-wolf rollout); raise to error.
99
- "lethal-trifecta": "warn",
100
- // A SKILL.md body referencing a missing bundled resource — WARN by default
101
- // (don't-cry-wolf rollout, FP-safe); raise to error to gate CI.
102
- "skill-resource-resolves": "warn",
103
- // A SKILL.md missing its opening `---` fence (invisible skill) — WARN by
104
- // default (FP-safe key whitelist); raise to error to gate CI.
105
- "skill-missing-fence": "warn",
106
- // Functional dirs nested inside `.claude-plugin/` (invisible surfaces) — WARN
107
- // by default; raise to error to gate CI.
108
- "plugin-dir-layout": "warn",
109
- // A lethal trifecta emerging across a delegation edge (combined blast radius) —
110
- // WARN by default (don't-cry-wolf rollout); raise to error to gate CI.
111
- "delegation-trifecta": "warn",
112
- // A hook that looks like it blocks but silently doesn't (#19009) — WARN by
113
- // default (FP-safe literal patterns); raise to error to gate CI.
114
- "hook-block-ineffective": "warn",
115
- // A hook matcher that doesn't fire as written (tool typo, an MCP pattern
116
- // that matches no tool name, or one too narrow for real server names) — WARN
117
- // by default (high-precision); raise to error to gate CI.
118
- "hook-matcher": "warn",
119
- // Builder calls quoted inside ```ts fences in markdown — default OFF. Measured
120
- // over 2 582 markdown files across two repos: 52 refs, 0 true positives, and
121
- // every error raised was a false one (a design sketch's `cmd("npm test")`, a
122
- // vendored third-party CLAUDE.md). A fence in prose is a DRAWING of config;
123
- // reading it as config is the defect. Opt in where markdown IS the source.
124
- "doc-refs": false,
125
- };
126
- const DEFAULT_CONFIG = {
127
- ruleMarkers: ["headings", "checkboxes"],
128
- rules: exports.DEFAULT_RULES,
129
- files: DEFAULT_FILES,
130
- };
50
+ /**
51
+ * The shipped default severity of every rule — DERIVED from the schema, never
52
+ * listed twice.
53
+ *
54
+ * It used to be the literal beside the type, which is exactly the pair that
55
+ * drifts: `rule-meta.test.ts` already cross-checks each rule's documented
56
+ * `defaultSeverity` against this object, and it could only ever catch a doc that
57
+ * disagreed with the literal, never a literal that disagreed with what parsing
58
+ * actually produced. Reading it out of the parser closes that gap: this IS what
59
+ * a `{}` config loads as.
60
+ */
61
+ exports.DEFAULT_RULES = defaultConfig()
62
+ .rules;
63
+ /** The default rule markers — read from the schema, like every other default. */
64
+ const DEFAULT_MARKERS = defaultConfig().ruleMarkers;
65
+ /** A freshly parsed empty config — the defaults, straight from the schema. */
66
+ function defaultConfig() {
67
+ return config_schema_js_1.vigilesConfigSchema.parse({});
68
+ }
131
69
  // ---------------------------------------------------------------------------
132
70
  // Instruction file discovery
133
71
  // ---------------------------------------------------------------------------
@@ -139,92 +77,80 @@ function findInstructionFiles(cwd = process.cwd(), configFiles) {
139
77
  // Config loading
140
78
  // ---------------------------------------------------------------------------
141
79
  /**
142
- * ESLint users write `"off"` / `0` / `1` / `2` for severity; normalize to
143
- * vigiles's `"warn" | "error" | false` so a rule the user meant to DISABLE
144
- * (`"off"`) or GATE (`2`) actually does — instead of a truthy string / number
145
- * silently rendering as a non-gating warn (issue #112 + numeric severities). The
146
- * array form `[sev, opts]` recurses on the head. An unrecognized value is left
147
- * as-is (it renders as a warn, the pre-existing behavior).
80
+ * Read and VALIDATE `.vigilesrc.json`.
81
+ *
82
+ * 🔴 `searchFrom` IS NOT A CONVENIENCE. cosmiconfig defaults to the process's
83
+ * working directory and walks up — right for a CLI verb, where the user is
84
+ * standing in the project they mean, and wrong for a hook, whose process has no
85
+ * stable cwd. A hook rail that omits it reads a DIFFERENT project's config, or
86
+ * none, and the failure runs the wrong way: a missing file means defaults, so a
87
+ * rule the author switched OFF comes back on, silently, because the file saying
88
+ * "off" was never found. Nothing in the output distinguishes that from a project
89
+ * that never configured the rule.
90
+ *
91
+ * 🔴 `onInvalid` IS THE ONE PLACE THE CALL SITES DIFFER, AND IT IS NOT A SPLIT
92
+ * IN WHAT IS CHECKED. Every reader validates; they disagree only about what a
93
+ * bad config is allowed to do to them. A VERB is a human standing at a prompt
94
+ * having just edited the file — `"throw"`, so the line they typed is refused out
95
+ * loud. A HOOK RAIL is a fresh process inside somebody's editing session, and a
96
+ * hook that dies on a malformed config turns a typo in a JSON file into a failed
97
+ * edit, which is a worse outcome than the nudge not firing — `"warn"`, print the
98
+ * same lines to stderr and carry on with the defaults.
99
+ *
100
+ * The two hook rails are `refsHookCommand` and `evalLockNudgeHookCommand`
101
+ * (`vigiles hook-runtime refs` / `eval-lock-nudge`, both registered as
102
+ * PostToolUse `Edit|Write` in `.claude-plugin/plugin.json`). Every other reader
103
+ * is a verb.
104
+ *
105
+ * ⚠️ THE SCHEMA MODULE IS REQUIRED IN-BODY, and that is hygiene rather than an
106
+ * optimization worth a paragraph: Zod is a ~45 ms cold import, `tsc` emits
107
+ * CommonJS across 230 separate files, so a `require` in a function body genuinely
108
+ * does not execute until the function is called. A rail that never reads a
109
+ * config never pays for the validator.
148
110
  */
149
- function normalizeSeverity(v) {
150
- if (Array.isArray(v))
151
- return [normalizeSeverity(v[0]), v[1]];
152
- if (v === "off" || v === 0 || v === false)
153
- return false;
154
- if (v === "error" || v === 2)
155
- return "error";
156
- if (v === "warn" || v === 1)
157
- return "warn";
158
- return v;
159
- }
160
- /**
161
- * Coerce a config value that should be a `string[]`: a bare STRING becomes a
162
- * one-element array (the natural first-value mistake), so `exclude` /
163
- * `orphans.include` don't silently iterate a string's CHARACTERS as globs — a
164
- * no-op at best, and garbage "orphan" matches (`.`, `/`, `README.md`) at worst.
165
- * A non-string/array value falls back with a warning (the `ruleMarkers` pattern).
166
- */
167
- function asStringArray(v, fallback, key) {
168
- if (typeof v === "string")
169
- return [v];
170
- if (Array.isArray(v))
171
- return v.filter((x) => typeof x === "string");
172
- console.warn(`Invalid ${key} in config: expected a string or string[], got ${JSON.stringify(v)}. Ignoring.`);
173
- return fallback;
174
- }
175
- function loadConfig() {
111
+ function loadConfig(searchFrom, { onInvalid = "throw" } = {}) {
112
+ let raw;
176
113
  try {
177
114
  const explorer = (0, cosmiconfig_1.cosmiconfigSync)("vigiles", {
178
115
  searchPlaces: [".vigilesrc.json"],
179
116
  mergeSearchPlaces: false,
180
117
  });
181
- const result = explorer.search();
182
- if (!result?.config)
183
- return { ...DEFAULT_CONFIG };
184
- const userConfig = result.config;
185
- const config = {
186
- ...DEFAULT_CONFIG,
187
- ...userConfig,
188
- // Normalize ESLint-idiom severities ("off"/0/1/2) across the merged rules,
189
- // so a config value coerces to a real gating decision instead of a truthy
190
- // string silently downgrading to warn.
191
- rules: Object.fromEntries(Object.entries({ ...exports.DEFAULT_RULES, ...userConfig.rules }).map(([k, v]) => [k, normalizeSeverity(v)])),
192
- files: Array.isArray(userConfig.files) ? userConfig.files : DEFAULT_FILES,
193
- };
194
- // Parse-don't-validate the array-shaped keys ONCE here: a bare string is
195
- // accepted as [string]; anything else warns + falls back. Downstream code
196
- // then always sees a real string[] (no char-by-char glob spread).
197
- if (userConfig.exclude !== undefined)
198
- config.exclude = asStringArray(userConfig.exclude, [], "exclude");
199
- if (userConfig.sharedDirs !== undefined)
200
- config.sharedDirs = asStringArray(userConfig.sharedDirs, [], "sharedDirs");
201
- if (config.orphans) {
202
- config.orphans = {
203
- ...config.orphans,
204
- include: config.orphans.include === undefined
205
- ? undefined
206
- : asStringArray(config.orphans.include, [], "orphans.include"),
207
- exclude: config.orphans.exclude === undefined
208
- ? undefined
209
- : asStringArray(config.orphans.exclude, [], "orphans.exclude"),
210
- };
211
- }
212
- if (!Array.isArray(config.ruleMarkers) ||
213
- !config.ruleMarkers.every((m) => VALID_MARKERS.includes(m))) {
214
- console.warn(`Invalid ruleMarkers in config: ${JSON.stringify(config.ruleMarkers)}. Using default.`);
215
- config.ruleMarkers = [...DEFAULT_CONFIG.ruleMarkers];
216
- }
217
- return config;
118
+ raw = explorer.search(searchFrom)?.config;
218
119
  }
219
120
  catch {
220
- return { ...DEFAULT_CONFIG };
121
+ // No file, unreadable file, malformed JSON — the defaults, as always. A
122
+ // config we CAN read and refuse is a different thing and is handled below.
123
+ return defaultConfig();
124
+ }
125
+ if (raw === undefined || raw === null)
126
+ return defaultConfig();
127
+ // The replaced keys get their own message BEFORE the schema's, because the
128
+ // reader is someone whose config used to work: `.strict()` would tell them
129
+ // "unknown key harness", which is true and useless. See REPLACED_KEYS.
130
+ const problems = [];
131
+ if (typeof raw === "object" && !Array.isArray(raw)) {
132
+ const present = config_schema_js_1.REPLACED_KEYS.filter((k) => k.key in raw);
133
+ if (present.length > 0)
134
+ problems.push((0, config_schema_js_1.replacedKeyMessage)(present));
135
+ }
136
+ if (problems.length === 0) {
137
+ const parsed = config_schema_js_1.vigilesConfigSchema.safeParse(raw);
138
+ if (parsed.success)
139
+ return parsed.data;
140
+ problems.push(...(0, config_schema_js_1.formatConfigIssues)(parsed.error.issues));
221
141
  }
142
+ if (onInvalid === "throw")
143
+ throw new config_schema_js_1.VigilesConfigError(problems.join("\n"));
144
+ for (const line of problems)
145
+ console.warn(`⚠ ${line}`);
146
+ console.warn("⚠ Using default configuration.");
147
+ return defaultConfig();
222
148
  }
223
149
  // ---------------------------------------------------------------------------
224
150
  // Parsing
225
151
  // ---------------------------------------------------------------------------
226
152
  function parseRules(content, { ruleMarkers } = {}) {
227
- const markers = ruleMarkers ?? DEFAULT_CONFIG.ruleMarkers;
153
+ const markers = ruleMarkers ?? DEFAULT_MARKERS;
228
154
  const lines = content.split("\n");
229
155
  const rules = [];
230
156
  let currentRule = null;
package/dist/exclude.d.ts CHANGED
@@ -22,4 +22,24 @@ export interface ExcludeSet {
22
22
  }
23
23
  /** Parse `.vigilesrc.json#exclude` once, against `root`. */
24
24
  export declare function excludeSet(root: string, patterns: readonly string[] | undefined): ExcludeSet;
25
+ /**
26
+ * Is this ABSOLUTE path dropped by `.vigilesrc.json#exclude`?
27
+ *
28
+ * The PREDICATE face, for a hand-rolled recursive walk that has an absolute path
29
+ * in hand and no glob (`readTree` in `src/plugin-loader.ts`, the bounded root
30
+ * walk in `src/surface-discovery-fs.ts`). A predicate rather than an
31
+ * `ExcludeSet` so the walk never has to remember which root the patterns are
32
+ * relative to: {@link excludedBy} closes over `excludes.root`, which is the REPO
33
+ * root and NOT the audited dir — `vigiles audit some/dir` must still honour a
34
+ * root-relative `exclude`.
35
+ *
36
+ * Lives HERE, beside the other two faces, because this file is the one
37
+ * exclusion policy (#192) and this was its third face living in one caller's
38
+ * private scope — which is how a second walk ends up writing a fourth.
39
+ */
40
+ export type Excluded = (absPath: string) => boolean;
41
+ /** The default: exclude nothing (every caller that passes no `ExcludeSet`). */
42
+ export declare const excludesNothing: Excluded;
43
+ /** The {@link Excluded} face of an `ExcludeSet`, or {@link excludesNothing}. */
44
+ export declare function excludedBy(excludes: ExcludeSet | undefined): Excluded;
25
45
  //# sourceMappingURL=exclude.d.ts.map
package/dist/exclude.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EXCLUDE_FLOOR = void 0;
3
+ exports.excludesNothing = exports.EXCLUDE_FLOOR = void 0;
4
4
  exports.excludeSet = excludeSet;
5
+ exports.excludedBy = excludedBy;
5
6
  /**
6
7
  * The ONE exclusion policy for every walk that polices the user's repository (#192) — the parsed `.vigilesrc.json#exclude` as an `ExcludeSet`, built ONCE where `loadConfig()` runs and taken as a REQUIRED parameter by every in-scope discovery (`findSpecs`, `findInstructionFiles`, `discoverNestedBundles`, `collectDocumentedRules`, `gatherInstructionFiles` in cli.ts; the string face handed to `findDocRefs`, `findOrphanDocs` (`repoExclude`), `findUntestedSurfaces`/`skillTestNudge`, `discoverScripts`, `computeScriptCoverage`).
7
8
  * Two faces: `globIgnore` (an `IgnoreLike` keyed on the path's position relative to the REPO root, so a glob rooted below it — `vigiles lint some/dir` — still applies a root-relative exclude) and `ignore` (the normalized string list for pure core detectors that glob from the root).
@@ -65,4 +66,13 @@ function excludeSet(root, patterns) {
65
66
  explain,
66
67
  };
67
68
  }
69
+ /** The default: exclude nothing (every caller that passes no `ExcludeSet`). */
70
+ const excludesNothing = () => false;
71
+ exports.excludesNothing = excludesNothing;
72
+ /** The {@link Excluded} face of an `ExcludeSet`, or {@link excludesNothing}. */
73
+ function excludedBy(excludes) {
74
+ if (!excludes)
75
+ return exports.excludesNothing;
76
+ return (abs) => excludes.matches((0, node_path_1.relative)(excludes.root, abs));
77
+ }
68
78
  //# sourceMappingURL=exclude.js.map
@@ -430,7 +430,7 @@ async function runHarnessTest(spec, opts = {}) {
430
430
  // Default (no adapter): the unchanged Claude Code driver — keeps the
431
431
  // sandbox/confined path and behaviour byte-for-byte identical.
432
432
  const driver = adapter
433
- ? requireDriver(adapter)
433
+ ? await requireDriver(adapter)
434
434
  : exports.claudeCodeDriver;
435
435
  const isClaudeCode = driver.runtime.name === runtime_js_1.claudeCodeRuntime.name;
436
436
  const decision = (0, sandbox_js_1.decideSandbox)({
@@ -528,12 +528,12 @@ async function runHarness(spec, opts = {}) {
528
528
  return runHarnessTest(spec, opts);
529
529
  }
530
530
  /** Pull the pillar-2 driver off an adapter, asserting it supports testing. */
531
- function requireDriver(adapter) {
531
+ async function requireDriver(adapter) {
532
532
  (0, adapter_conformance_js_1.assertHarnessTestable)(adapter);
533
533
  if (!adapter.harnessTestDriver) {
534
534
  throw new Error(`Adapter "${adapter.name}" declares harnessTesting but carries no harnessTestDriver — it cannot drive runHarnessTest.`);
535
535
  }
536
- return adapter.harnessTestDriver;
536
+ return await adapter.harnessTestDriver();
537
537
  }
538
538
  /* v8 ignore stop */
539
539
  //# sourceMappingURL=harness-test.js.map
@@ -1,3 +1,4 @@
1
+ import type { DispatchKind } from "./core/hook-program.js";
1
2
  /** The agnostic, committed home for hook SOURCE — one dir, cross-adapter. */
2
3
  export declare const HOOKS_DIR = ".vigiles/hooks";
3
4
  /** The committed home for registered context-provider SOURCE (v2). */
@@ -55,6 +56,58 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
55
56
  * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
56
57
  * it is asserted directly rather than left to inspection.
57
58
  */
59
+ /**
60
+ * Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
61
+ *
62
+ * 🔴 MEASURED 2026-09-19, warm cache, five runs each:
63
+ *
64
+ * node <local>/dist/cli.js hook-runtime run-program … 193 ms
65
+ * npx vigiles hook-runtime run-program … 2545 ms
66
+ *
67
+ * Thirteen times, on every tool call, because `npx` re-resolves the package on
68
+ * each invocation — local, then global, then the registry. That search is the
69
+ * single largest cost in a hook's life; everything the runtime does inside adds
70
+ * up to less than a fifth of it.
71
+ *
72
+ * A harness with no project-root token gets the relative spelling, which is all
73
+ * it can be given — see {@link hookGateRef} for the same fallback.
74
+ */
75
+ export declare function hookRuntimeRef(projectRootTokens: readonly string[] | undefined): string;
76
+ /**
77
+ * What the shell must do when the runtime above CANNOT START — a missing
78
+ * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
79
+ * single line of ours runs. No code of ours executes in that case, so the policy
80
+ * has to be expressed in the emitted command or not at all.
81
+ *
82
+ * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
83
+ * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
84
+ * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
85
+ * fail closed."* That branch only reaches failures that happen AFTER the runtime
86
+ * starts. This carries the same rule one layer out, to the failures that happen
87
+ * before it.
88
+ *
89
+ * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
90
+ * not comparable:
91
+ *
92
+ * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
93
+ * refusal, and a harness that reports protection it is not providing is the
94
+ * one state worse than admitting it has none. So a gate whose runtime is
95
+ * missing exits 2: loud, blocking, and the cause is on stderr.
96
+ *
97
+ * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
98
+ * merge-conflict markers in `package.json` stopped every hook loading, the
99
+ * Bash gate then refused `git merge --abort` — the one command that undoes the
100
+ * cause — and the session could not be repaired from inside. A reminder is
101
+ * never worth that, so a nudge exits 0 and says nothing it cannot say.
102
+ *
103
+ * The role is not a flag someone can flip: `Reaction` has no `deny` and an
104
+ * inject returns context, so "nudge" is a fact about the TYPE the author chose.
105
+ *
106
+ * (Industry does not agree on one answer either — husky and lefthook skip,
107
+ * pre-commit fails. Which is itself the argument for deciding by role instead
108
+ * of picking one and imposing it on both.)
109
+ */
110
+ export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2;
58
111
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
59
112
  /**
60
113
  * Idempotently merge a compiled hook's block into an existing `settings.json`
@@ -4,6 +4,8 @@ exports.PROVIDERS_DIR = exports.HOOKS_DIR = void 0;
4
4
  exports.discoverHookFiles = discoverHookFiles;
5
5
  exports.discoverProviderFiles = discoverProviderFiles;
6
6
  exports.normalizeHookRef = normalizeHookRef;
7
+ exports.hookRuntimeRef = hookRuntimeRef;
8
+ exports.hookRuntimeMissingExit = hookRuntimeMissingExit;
7
9
  exports.hookGateRef = hookGateRef;
8
10
  exports.mergeHooksJson = mergeHooksJson;
9
11
  exports.mergeHooksToml = mergeHooksToml;
@@ -99,6 +101,64 @@ function normalizeHookRef(hookPath, cwd = process.cwd()) {
99
101
  * `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
100
102
  * it is asserted directly rather than left to inspection.
101
103
  */
104
+ /**
105
+ * Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
106
+ *
107
+ * 🔴 MEASURED 2026-09-19, warm cache, five runs each:
108
+ *
109
+ * node <local>/dist/cli.js hook-runtime run-program … 193 ms
110
+ * npx vigiles hook-runtime run-program … 2545 ms
111
+ *
112
+ * Thirteen times, on every tool call, because `npx` re-resolves the package on
113
+ * each invocation — local, then global, then the registry. That search is the
114
+ * single largest cost in a hook's life; everything the runtime does inside adds
115
+ * up to less than a fifth of it.
116
+ *
117
+ * A harness with no project-root token gets the relative spelling, which is all
118
+ * it can be given — see {@link hookGateRef} for the same fallback.
119
+ */
120
+ function hookRuntimeRef(projectRootTokens) {
121
+ const rel = "node_modules/vigiles/dist/cli.js";
122
+ const token = projectRootTokens?.[0];
123
+ return token === undefined ? `node ${rel}` : `node "${token}/${rel}"`;
124
+ }
125
+ /**
126
+ * What the shell must do when the runtime above CANNOT START — a missing
127
+ * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
128
+ * single line of ours runs. No code of ours executes in that case, so the policy
129
+ * has to be expressed in the emitted command or not at all.
130
+ *
131
+ * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
132
+ * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
133
+ * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
134
+ * fail closed."* That branch only reaches failures that happen AFTER the runtime
135
+ * starts. This carries the same rule one layer out, to the failures that happen
136
+ * before it.
137
+ *
138
+ * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
139
+ * not comparable:
140
+ *
141
+ * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
142
+ * refusal, and a harness that reports protection it is not providing is the
143
+ * one state worse than admitting it has none. So a gate whose runtime is
144
+ * missing exits 2: loud, blocking, and the cause is on stderr.
145
+ *
146
+ * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
147
+ * merge-conflict markers in `package.json` stopped every hook loading, the
148
+ * Bash gate then refused `git merge --abort` — the one command that undoes the
149
+ * cause — and the session could not be repaired from inside. A reminder is
150
+ * never worth that, so a nudge exits 0 and says nothing it cannot say.
151
+ *
152
+ * The role is not a flag someone can flip: `Reaction` has no `deny` and an
153
+ * inject returns context, so "nudge" is a fact about the TYPE the author chose.
154
+ *
155
+ * (Industry does not agree on one answer either — husky and lefthook skip,
156
+ * pre-commit fails. Which is itself the argument for deciding by role instead
157
+ * of picking one and imposing it on both.)
158
+ */
159
+ function hookRuntimeMissingExit(kind) {
160
+ return kind === "inject" || kind === "react" ? 0 : 2;
161
+ }
102
162
  function hookGateRef(ref, projectRootTokens) {
103
163
  const token = projectRootTokens?.[0];
104
164
  return token === undefined ? ref : `"${token}/${ref}"`;
@@ -45,9 +45,9 @@ import { loadHook } from "./load-hook.js";
45
45
  */
46
46
  export declare const loadHookProgram: typeof loadHook;
47
47
  /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
48
- export declare function loadProvider(file: string): Promise<RegisteredProvider>;
48
+ export declare function loadProvider(file: string, root?: string): Promise<RegisteredProvider>;
49
49
  /** Path of the tamper-evident stamp sidecar for a hook file. */
50
- export declare function hookStampPath(file: string): string;
50
+ export declare function hookStampPath(file: string, root?: string): string;
51
51
  /**
52
52
  * `vigiles hook-runtime run-program <file>` — the runtime the compiled hooks block
53
53
  * points at. Reads the live event on stdin, loads the typed program, verifies