vigiles 23.0.0 → 25.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.
@@ -10,7 +10,44 @@ import type { HarnessDialect } from "./dialect.js";
10
10
  /** @internal Compute SHA-256 hash of content (excluding any existing hash line). */
11
11
  export declare function computeHash(content: string): string;
12
12
  /** @internal Prepend a hash comment to compiled content. */
13
- export declare function addHash(content: string, specFile: string): string;
13
+ /**
14
+ * A compiled body that carries a valid integrity stamp — mintable ONLY by
15
+ * {@link addHash}, and by construction only handed out for a CLEAN compile.
16
+ *
17
+ * 🔴 WHY A BRAND AND NOT A CHECK AT THE WRITE SITE. #173 was a `CLAUDE.md`
18
+ * written while its refs were known-dead: `compile` printed the errors, exited
19
+ * 1, and wrote the file anyway, stamped. `lint` then verified the stamp and
20
+ * exited 0 over an artifact that names files which do not exist. The fix that
21
+ * shipped moved the write behind an error check in ONE compiler — and the same
22
+ * three lines sat unchanged in four siblings (skill, subagent, railway,
23
+ * generator), where the lint-side backstop does not even reach because
24
+ * `spec-refs` only inspects `claude` specs. Reproduced end to end after that
25
+ * fix: a skill spec with a stale ref still produced a stamped `SKILL.md` and a
26
+ * green `lint`.
27
+ *
28
+ * Fixing five write sites leaves the class writable — the sixth compiler would
29
+ * be written the same way. Branding the STAMP moves the guarantee to where it is
30
+ * produced: `writeArtifact` accepts nothing else, and an erroring compile has no
31
+ * stamp to give it.
32
+ */
33
+ export type StampedMarkdown = string & {
34
+ readonly __stamped: unique symbol;
35
+ };
36
+ export declare function addHash(content: string, specFile: string): StampedMarkdown;
37
+ /**
38
+ * How every compiler finishes: the rendered body, plus a STAMPED artifact that
39
+ * exists only when the compile is clean.
40
+ *
41
+ * `markdown` stays a plain string because callers legitimately want the draft
42
+ * even when it is wrong — `adoptDiff` diffs "what the spec WOULD produce"
43
+ * against the file on disk, and a stale ref must not stop that. `artifact` is
44
+ * what a writer needs, and it is `null` the moment there is an error, so the
45
+ * write cannot happen without narrowing.
46
+ */
47
+ export declare function seal(body: string, errors: readonly CompileError[], specFile: string): {
48
+ markdown: string;
49
+ artifact: StampedMarkdown | null;
50
+ };
14
51
  /** @internal Check if a file's hash matches its content. Returns null if no hash found. */
15
52
  export declare function verifyHash(content: string): {
16
53
  valid: boolean;
@@ -74,6 +111,11 @@ export interface CompileClaudeOptions {
74
111
  */
75
112
  export declare function compileClaude(spec: ClaudeSpec, options?: CompileClaudeOptions): CompileClaudeResult;
76
113
  export interface CompileSkillResult {
114
+ /**
115
+ * The stamped artifact — present ONLY when `errors` is empty. `null` is what
116
+ * makes a failed compile unwritable: `writeArtifact` accepts nothing else.
117
+ */
118
+ artifact: StampedMarkdown | null;
77
119
  markdown: string;
78
120
  errors: CompileError[];
79
121
  /** Non-blocking advisories (e.g. an over-long inline code block). */
@@ -90,6 +132,11 @@ export declare function compileSkill(spec: SkillSpec, options?: {
90
132
  dialect?: HarnessDialect;
91
133
  }): CompileSkillResult;
92
134
  export interface CompileAgentResult {
135
+ /**
136
+ * The stamped artifact — present ONLY when `errors` is empty. `null` is what
137
+ * makes a failed compile unwritable: `writeArtifact` accepts nothing else.
138
+ */
139
+ artifact: StampedMarkdown | null;
93
140
  markdown: string;
94
141
  errors: CompileError[];
95
142
  /** Non-blocking advisories (e.g. an over-long inline code block). */
@@ -113,6 +160,11 @@ export interface CompileRailwayOptions {
113
160
  specFile?: string;
114
161
  }
115
162
  export interface CompileRailwayResult {
163
+ /**
164
+ * The stamped artifact — present ONLY when `errors` is empty. `null` is what
165
+ * makes a failed compile unwritable: `writeArtifact` accepts nothing else.
166
+ */
167
+ artifact: StampedMarkdown | null;
116
168
  markdown: string;
117
169
  errors: CompileError[];
118
170
  }
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.computeHash = computeHash;
13
13
  exports.addHash = addHash;
14
+ exports.seal = seal;
14
15
  exports.verifyHash = verifyHash;
15
16
  exports.estimateTokens = estimateTokens;
16
17
  exports.validateFileRef = validateFileRef;
@@ -53,7 +54,6 @@ const DEFAULT_TARGET = "CLAUDE.md";
53
54
  function computeHash(content) {
54
55
  return (0, hash_js_1.sha256short)((0, integrity_js_1.findIntegrityHeader)(content)?.withoutHeader ?? content);
55
56
  }
56
- /** @internal Prepend a hash comment to compiled content. */
57
57
  function addHash(content, specFile) {
58
58
  // 🔴 THE LAST GATE BEFORE A COMPILED FILE IS WRITTEN. Every compile path returns through here
59
59
  // (four call sites), which makes it the one place a whole class of defect can be stopped.
@@ -72,8 +72,24 @@ function addHash(content, specFile) {
72
72
  `where a string was expected, and JavaScript stringified it. Check the arguments to ` +
73
73
  `input()/file()/cmd() and friends: they take strings, not option objects.`);
74
74
  }
75
+ // The ONE mint. Every stamped artifact in the codebase originates here, which
76
+ // is what makes the brand meaningful rather than decorative.
75
77
  return (0, integrity_js_1.placeIntegrityHeader)(content, computeHash(content), specFile);
76
78
  }
79
+ /**
80
+ * How every compiler finishes: the rendered body, plus a STAMPED artifact that
81
+ * exists only when the compile is clean.
82
+ *
83
+ * `markdown` stays a plain string because callers legitimately want the draft
84
+ * even when it is wrong — `adoptDiff` diffs "what the spec WOULD produce"
85
+ * against the file on disk, and a stale ref must not stop that. `artifact` is
86
+ * what a writer needs, and it is `null` the moment there is an error, so the
87
+ * write cannot happen without narrowing.
88
+ */
89
+ function seal(body, errors, specFile) {
90
+ const stamped = addHash(body, specFile);
91
+ return { markdown: stamped, artifact: errors.length === 0 ? stamped : null };
92
+ }
77
93
  /** @internal Check if a file's hash matches its content. Returns null if no hash found. */
78
94
  function verifyHash(content) {
79
95
  const found = (0, integrity_js_1.findIntegrityHeader)(content);
@@ -859,7 +875,7 @@ function compileSkill(spec, options = {}) {
859
875
  (marker ? marker + "\n\n" : "") +
860
876
  sections.trim() +
861
877
  "\n";
862
- return { markdown: addHash(content, specFile), errors, warnings };
878
+ return { ...seal(content, errors, specFile), errors, warnings };
863
879
  }
864
880
  // ---------------------------------------------------------------------------
865
881
  // Compile a subagent spec → agents/<name>.md
@@ -1058,7 +1074,7 @@ function compileAgent(spec, options) {
1058
1074
  (marker ? marker + "\n\n" : "") +
1059
1075
  body.trim() +
1060
1076
  "\n";
1061
- return { markdown: addHash(content, specFile), errors, warnings };
1077
+ return { ...seal(content, errors, specFile), errors, warnings };
1062
1078
  }
1063
1079
  /** Verify a railway: non-empty, bounded recovery, every delegate target real. */
1064
1080
  function validateRailway(rw, knownAgents) {
@@ -1126,7 +1142,7 @@ function compileRailway(rw, options = {}) {
1126
1142
  const errors = validateRailway(rw, options.knownAgents);
1127
1143
  const specFile = options.specFile ?? `${rw.name}.railway.spec.ts`;
1128
1144
  const content = renderRailwayMarkdown(rw) + "\n";
1129
- return { markdown: addHash(content, specFile), errors };
1145
+ return { ...seal(content, errors, specFile), errors };
1130
1146
  }
1131
1147
  /** Check if a generated file's hash is intact. */
1132
1148
  function checkFileHash(filePath) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `RepoRelativePath` — a path that is provably INSIDE the repository.
3
+ *
4
+ * 🔴 WHY A TYPE AND NOT A CHECK. `audit --out=/tmp/x` appended entries like
5
+ * `../../../../private/tmp/x/vigiles-report.json` to the user's `.gitignore`
6
+ * (#176.8). Those ignore nothing — `.gitignore` does not reach outside its own
7
+ * tree — and accumulate one dead block per output path, in a file the tool is
8
+ * documented as never writing. The fix that shipped was a guard at the write
9
+ * site, which works and leaves the bug WRITABLE: the next caller to build an
10
+ * entry list gets a `string[]` and no reason to think twice.
11
+ *
12
+ * This makes it unwritable instead. `ensureReportGitignored` accepts only
13
+ * `RepoRelativePath[]`, and the only way to obtain one is `repoRelative()`,
14
+ * which returns `null` for anything that escapes the root. There is no cast at
15
+ * the call site and no second guard to keep in sync — a path that leaves the
16
+ * repo cannot reach the writer, because it cannot be given the type.
17
+ *
18
+ * The brand is the pattern this codebase already uses for `VerifiedPath` and
19
+ * friends: a nominal marker on `string` that only a smart constructor mints.
20
+ */
21
+ declare const REPO_RELATIVE: unique symbol;
22
+ /** A POSIX-separated path known to resolve inside the repository root. */
23
+ export type RepoRelativePath = string & {
24
+ readonly [REPO_RELATIVE]: true;
25
+ };
26
+ /**
27
+ * Mint a {@link RepoRelativePath}, or `null` when the target escapes `root`.
28
+ *
29
+ * Rejects an absolute path and anything whose relative form starts with `..`.
30
+ * Normalizes to POSIX separators, because `.gitignore` patterns are POSIX and a
31
+ * Windows `reports\x` would never match `reports/x` — a second silent-miss that
32
+ * lived next to the first.
33
+ */
34
+ export declare function repoRelative(root: string, target: string, io: {
35
+ relative: (from: string, to: string) => string;
36
+ resolve: (...parts: string[]) => string;
37
+ isAbsolute: (p: string) => boolean;
38
+ sep: string;
39
+ }): RepoRelativePath | null;
40
+ export {};
41
+ //# sourceMappingURL=repo-path.d.ts.map
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.repoRelative = repoRelative;
4
+ /**
5
+ * Mint a {@link RepoRelativePath}, or `null` when the target escapes `root`.
6
+ *
7
+ * Rejects an absolute path and anything whose relative form starts with `..`.
8
+ * Normalizes to POSIX separators, because `.gitignore` patterns are POSIX and a
9
+ * Windows `reports\x` would never match `reports/x` — a second silent-miss that
10
+ * lived next to the first.
11
+ */
12
+ function repoRelative(root, target, io) {
13
+ const rel = io.relative(root, io.resolve(root, target));
14
+ if (rel === "" || rel.startsWith("..") || io.isAbsolute(rel))
15
+ return null;
16
+ const posix = io.sep === "/" ? rel : rel.split(io.sep).join("/");
17
+ return posix;
18
+ }
19
+ //# sourceMappingURL=repo-path.js.map
@@ -41,7 +41,7 @@ export type RuleSurface = "instruction" | "skill" | "subagent" | "hook" | "mcp"
41
41
  /** Where a rule sits by default — `"off"` is the normalized form of `false`. */
42
42
  export type RuleDefaultSeverity = "error" | "warn" | "off";
43
43
  /** Every named rule: the `RulesConfig` keys plus the built-in `orphan-docs`. */
44
- export type RuleName = keyof RulesConfig | "orphan-docs";
44
+ export type RuleName = keyof RulesConfig;
45
45
  /**
46
46
  * The declared shape of one rule — co-located metadata in the ESLint `meta`
47
47
  * sense, gathered into one registry because vigiles shares detectors.
@@ -262,6 +262,22 @@ exports.RULE_META = {
262
262
  detector: "delegationTrifectaIssues",
263
263
  },
264
264
  // --- Docs hygiene ---------------------------------------------------------
265
+ "duplicate-rules": {
266
+ id: "duplicate-rules",
267
+ bucket: "heuristic-behavioral",
268
+ surface: ["instruction"],
269
+ defaultSeverity: "warn",
270
+ summary: "Near-duplicate rules within one spec (NCD similarity) — two rules saying the same thing.",
271
+ detector: "findDuplicateRules",
272
+ },
273
+ "spec-refs": {
274
+ id: "spec-refs",
275
+ bucket: "external-decidable",
276
+ surface: ["instruction"],
277
+ defaultSeverity: "error",
278
+ summary: "A compiled instruction file whose spec references a file/script that no longer exists.",
279
+ detector: "compileClaude",
280
+ },
265
281
  "orphan-docs": {
266
282
  id: "orphan-docs",
267
283
  bucket: "heuristic-behavioral",
@@ -99,6 +99,36 @@ export interface TestCoverageConfig {
99
99
  testExtension?: string;
100
100
  }
101
101
  export interface RulesConfig {
102
+ /**
103
+ * Opt-in: a doc in a configured dir (default `docs/`) that no other `.md`
104
+ * references. The `orphans` block turns the SCAN on; this turns the FINDING
105
+ * into a warning or an error.
106
+ *
107
+ * It lived outside this interface until 2026-09 and therefore could not be
108
+ * set at all: `"warn"` and `"off"` were both ignored and the check always
109
+ * exited 1, so a repo's only choice was an always-blocking check or deleting
110
+ * the `orphans` block (#181). Default: "error", matching the old behaviour.
111
+ */
112
+ "orphan-docs"?: RuleSeverity;
113
+ /**
114
+ * Re-derive a compiled instruction file's references from its `.spec.ts` and
115
+ * report the dead ones.
116
+ *
117
+ * The integrity hash answers "is this file still what the spec compiled to";
118
+ * it says nothing about whether the paths and scripts it NAMES still exist. So
119
+ * an artifact committed while its refs were live stayed green forever after
120
+ * the target was deleted — `compile` errored, `lint` said "hash valid" and
121
+ * exited 0 (#173). Default: "error", matching `compile`.
122
+ */
123
+ "spec-refs"?: RuleSeverity;
124
+ /**
125
+ * Near-duplicate rules WITHIN one spec, by NCD similarity — spec bloat, two
126
+ * rules saying the same thing in different words.
127
+ *
128
+ * Also previously untierable, and worse: it had no rule id at all, so there
129
+ * was no name a config could even mention (#181). Default: "error".
130
+ */
131
+ "duplicate-rules"?: RuleSeverity;
102
132
  /**
103
133
  * Require a `.spec.ts` behind each instruction file (CLAUDE.md / AGENTS.md) —
104
134
  * the file must be compiled from a typed spec, not hand-written. NARROW: only a
@@ -363,6 +393,20 @@ export interface VigilesConfig {
363
393
  rulesDir?: string | string[];
364
394
  }>;
365
395
  /** Orphan-docs check configuration. Include/exclude globs, tsconfig-style. */
396
+ /**
397
+ * Which bundles `lint` scores: `"root"` (default) or `"all"`.
398
+ *
399
+ * A monorepo holding `skills/` plus `plugins/ * /skills/` had its nested skills
400
+ * silently uncounted — the counters looked complete while whole surfaces were
401
+ * never read (#185). `"all"` scores every discovered bundle in one pass, so a
402
+ * CI gate keeps ONE exit code over the whole repo.
403
+ *
404
+ * Root-only remains the default because descending unconditionally would score
405
+ * vendored third-party plugins (a repo may keep a pinned corpus on disk) as if
406
+ * they were the project's own. The default no longer hides the skip: `lint`
407
+ * names the bundles it did not score.
408
+ */
409
+ bundles?: "root" | "all";
366
410
  orphans?: OrphansConfig;
367
411
  /**
368
412
  * Glob patterns of instruction/skill files to EXCLUDE from `lint` discovery
@@ -32,6 +32,24 @@ const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
32
32
  // The default instruction file to validate when no config names one.
33
33
  const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
34
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",
35
53
  "require-instructions-spec": "warn",
36
54
  // Default OFF — the consistent `require-<surface>-spec` parallel. Skills are
37
55
  // legitimately hand-written, so requiring a .spec.ts per SKILL.md is the wrong
@@ -0,0 +1,13 @@
1
+ type ResolveContext = {
2
+ parentURL?: string;
3
+ conditions: string[];
4
+ };
5
+ type Resolved = {
6
+ url: string;
7
+ format?: string | null;
8
+ shortCircuit?: boolean;
9
+ };
10
+ type NextResolve = (specifier: string, context: ResolveContext) => Resolved | Promise<Resolved>;
11
+ export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<Resolved>;
12
+ export {};
13
+ //# sourceMappingURL=harness-resolve-hooks.d.mts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Module-resolution hook for HARNESS scripts: make a bare `vigiles` import
3
+ * resolve to the CLI's OWN installation.
4
+ *
5
+ * 🔴 WHY. A harness file does `import { runHook } from "vigiles"`, so the
6
+ * package has to sit in a `node_modules` Node can reach from that file. In a
7
+ * repo that already has a `package.json`, the obvious way to put it there is
8
+ * `npm install` in the root — which installs the whole dependency tree. Measured
9
+ * by an adopter (#184): **840 packages in 2 minutes** where vigiles alone is 42
10
+ * and about 90 MB; the other 798 were a model-eval framework and an agent SDK
11
+ * that the gate — `lint` and `test`, both deterministic reads — never touches.
12
+ * One run sat 11 minutes in that step before being cancelled. Their workaround
13
+ * was installing into a directory outside the workspace and symlinking the tree
14
+ * back in, which works and is not something every adopter should reinvent.
15
+ *
16
+ * ⚠️ `NODE_PATH` does NOT solve this, and that was measured rather than assumed:
17
+ * Node ignores it for ESM resolution, and a harness is ESM. So the only ways to
18
+ * resolve a bare specifier from elsewhere are a real `node_modules` entry (the
19
+ * symlink) or a resolver hook. This is the hook.
20
+ *
21
+ * Scope is deliberately narrow: ONLY the `vigiles` specifier and its subpaths,
22
+ * and only when the normal resolution fails. A harness that has vigiles
23
+ * installed locally keeps resolving to the local copy, so nothing changes for a
24
+ * repo that already worked — this only fills the hole where resolution would
25
+ * otherwise throw.
26
+ */
27
+ import { createRequire } from "node:module";
28
+ import { pathToFileURL } from "node:url";
29
+ /** The CLI's own package root, handed in by the parent process. */
30
+ const SELF = process.env.VIGILES_SELF_ROOT ?? "";
31
+ export async function resolve(specifier, context, nextResolve) {
32
+ try {
33
+ return await nextResolve(specifier, context);
34
+ }
35
+ catch (err) {
36
+ // Only rescue OUR specifier, and only after normal resolution failed, so a
37
+ // locally installed vigiles always wins and no other package is affected.
38
+ if (!SELF)
39
+ throw err;
40
+ if (specifier !== "vigiles" && !specifier.startsWith("vigiles/"))
41
+ throw err;
42
+ const require = createRequire(pathToFileURL(`${SELF}/package.json`));
43
+ // Resolve through the package's own `exports` map rather than guessing a
44
+ // file path, so a subpath like `vigiles/eval` obeys the same contract it
45
+ // would from a normal install.
46
+ const target = require.resolve(specifier, { paths: [SELF] });
47
+ return { url: pathToFileURL(target).href, shortCircuit: true };
48
+ }
49
+ }
50
+ //# sourceMappingURL=harness-resolve-hooks.mjs.map
@@ -187,6 +187,13 @@ export interface HookRunResult extends ScriptRunResult {
187
187
  * a halt sets both.
188
188
  */
189
189
  readonly haltsTurn: boolean;
190
+ /**
191
+ * Every block mechanism that fired, from the closed {@link BlockMechanism}
192
+ * set. Reported so the next mechanism needs no new boolean on this interface —
193
+ * `haltsTurn` exists because the halt case was added as a one-off, and this is
194
+ * the shape that stops the pattern repeating.
195
+ */
196
+ readonly blockedBy: readonly BlockMechanism[];
190
197
  /**
191
198
  * The decision the hook expressed, preferring the structured
192
199
  * `permissionDecision` ("allow"|"deny"|"ask") then legacy `decision`
@@ -196,14 +203,34 @@ export interface HookRunResult extends ScriptRunResult {
196
203
  }
197
204
  /** Parse stdout as a hook JSON decision (pure, testable without a process). */
198
205
  export declare function parseHookOutput(stdout: string): HookOutput | null;
206
+ /**
207
+ * The ways a hook can stop an action — a CLOSED set, listed once.
208
+ *
209
+ * 🔴 WHY A TABLE AND NOT THREE `||` TERMS. `decideHook` used to be a boolean
210
+ * expression over three mechanisms while `HookOutput` declared a fourth,
211
+ * `continue`, that nothing read (#174). The consequence was not a missed
212
+ * detection but an INVERTED verdict in the flagship feature: a real guard that
213
+ * stopped every command in the disaster battery was reported by
214
+ * `assertBlocksDisasters` as blocking none of them.
215
+ *
216
+ * A `||` chain has no shape that can be incomplete — every term is optional by
217
+ * construction, so nothing can say "you declared a mechanism and did not handle
218
+ * it". `Record<BlockMechanism, …>` can: adding a member to the union without
219
+ * adding its row is a tsc error, the same device that keeps `RULE_META` honest.
220
+ */
221
+ export type BlockMechanism = "exit-code" | "deny-decision" | "halt-field";
199
222
  /**
200
223
  * Decide whether a hook result blocked, and the normalized decision. Pure, so
201
224
  * the policy is unit-testable independent of spawning anything.
225
+ *
226
+ * Folds the closed {@link BLOCK_MECHANISMS} table rather than testing three
227
+ * conditions inline, so a mechanism cannot be declared and left unhandled.
202
228
  */
203
229
  export declare function decideHook(exitCode: number, json: HookOutput | null, protocol?: HookProtocol): {
204
230
  blocked: boolean;
205
231
  decision: HookRunResult["decision"];
206
232
  haltsTurn: boolean;
233
+ blockedBy: readonly BlockMechanism[];
207
234
  };
208
235
  /**
209
236
  * The hook layer over {@link runScriptWith}: serialize the event to stdin, run
package/dist/run-hook.js CHANGED
@@ -123,23 +123,42 @@ function parseHookOutput(stdout) {
123
123
  return null;
124
124
  }
125
125
  }
126
+ const BLOCK_MECHANISMS = {
127
+ "exit-code": ({ exitCode, protocol }) => exitCode === protocol.blockExitCode,
128
+ "deny-decision": ({ json, protocol }) => {
129
+ const decision = json?.hookSpecificOutput?.permissionDecision ?? json?.decision;
130
+ return (decision !== undefined && protocol.denyDecisionValues.includes(decision));
131
+ },
132
+ // Read from the PORT, never hard-coded: `"continue"` is a documented Claude
133
+ // Code fact and unverified for Codex, so the harness that has it declares it
134
+ // (core ⊄ adapter). `=== false` and not falsy — an absent field is not a halt.
135
+ "halt-field": ({ json, protocol }) => {
136
+ const field = protocol.haltsTurnField;
137
+ return field !== undefined && json?.[field] === false;
138
+ },
139
+ };
126
140
  /**
127
141
  * Decide whether a hook result blocked, and the normalized decision. Pure, so
128
142
  * the policy is unit-testable independent of spawning anything.
143
+ *
144
+ * Folds the closed {@link BLOCK_MECHANISMS} table rather than testing three
145
+ * conditions inline, so a mechanism cannot be declared and left unhandled.
129
146
  */
130
147
  function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHookProtocol) {
131
148
  const permission = json?.hookSpecificOutput?.permissionDecision;
132
149
  const decision = permission ?? json?.decision;
133
- // The halt field is read from the PORT, never hard-coded: `"continue"` is a
134
- // documented Claude Code fact and an unverified one for Codex, so the harness
135
- // that has it declares it (core ⊄ adapter). `=== false` and not falsy —
136
- // an absent field must not read as a halt.
137
- const haltField = protocol.haltsTurnField;
138
- const haltsTurn = haltField !== undefined && json?.[haltField] === false;
139
- const blocked = exitCode === protocol.blockExitCode ||
140
- haltsTurn ||
141
- (decision !== undefined && protocol.denyDecisionValues.includes(decision));
142
- return { blocked, decision, haltsTurn };
150
+ const ctx = { exitCode, json, protocol };
151
+ // EVERY mechanism is evaluated, not the first match: a hook may both exit 2
152
+ // and halt the turn, and reporting only the first would make `haltsTurn`
153
+ // depend on the table's order.
154
+ const blockedBy = Object.keys(BLOCK_MECHANISMS).filter((kind) => BLOCK_MECHANISMS[kind](ctx));
155
+ return {
156
+ blocked: blockedBy.length > 0,
157
+ decision,
158
+ // Derived, not computed twice — the next mechanism needs no new boolean.
159
+ haltsTurn: blockedBy.includes("halt-field"),
160
+ blockedBy,
161
+ };
143
162
  }
144
163
  /**
145
164
  * The hook layer over {@link runScriptWith}: serialize the event to stdin, run
@@ -150,8 +169,8 @@ function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHook
150
169
  function runHookWith(command, input, opts, deps) {
151
170
  const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
152
171
  const json = parseHookOutput(res.stdout);
153
- const { blocked, decision, haltsTurn } = decideHook(res.exitCode, json);
154
- return { ...res, json, blocked, decision, haltsTurn };
172
+ const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json);
173
+ return { ...res, json, blocked, decision, haltsTurn, blockedBy };
155
174
  }
156
175
  /**
157
176
  * Run a hook command, piping `input` as JSON to its stdin, and report the exit
package/dist/test.d.ts CHANGED
@@ -54,7 +54,7 @@ export { recordCheck } from "./check-count.js";
54
54
  export { runScript } from "./run-script.js";
55
55
  export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
56
56
  export { runHook, parseHookOutput, decideHook, propertyHook, fileToolEvents, egressRoutes, } from "./run-hook.js";
57
- export type { HookRunResult, RunHookOptions, HookInput, HookOutput, HookPropertyResult, FileToolEventOptions, } from "./run-hook.js";
57
+ export type { HookRunResult, BlockMechanism, RunHookOptions, HookInput, HookOutput, HookPropertyResult, FileToolEventOptions, } from "./run-hook.js";
58
58
  export * from "./harness-assert.js";
59
59
  export { experimental_emitTool, experimental_parseEmitted, experimental_assertEmittedOk, type EmitFieldSchema, type EmitObjectSchema, type EmitPropertySchema, type EmitTrackSchema, type EmitToolDefinition, type ExperimentalEmitTool, } from "./experimental-emit.js";
60
60
  export { loadHook } from "./load-hook.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",