vigiles 24.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.
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ exports.discoverNestedBundles = discoverNestedBundles;
17
17
  const node_fs_1 = require("node:fs");
18
18
  const node_path_1 = require("node:path");
19
19
  const minimatch_1 = require("minimatch");
20
+ const repo_path_js_1 = require("./core/repo-path.js");
20
21
  const node_child_process_1 = require("node:child_process");
21
22
  const glob_1 = require("glob");
22
23
  const generate_types_js_1 = require("./core/generate-types.js");
@@ -288,11 +289,14 @@ function printWarnings(specFile, warnings) {
288
289
  /** Compile a generator-skill spec from source → SKILL.md. Returns validity. */
289
290
  function compileGeneratorSkillToFile(specPath, source) {
290
291
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
291
- const { markdown, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
292
+ const { artifact, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
292
293
  basePath: process.cwd(),
293
294
  specFile: specPath,
294
295
  });
295
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
296
+ // Written only when the compile is clean: `artifact` is null otherwise, and
297
+ // `writeArtifact` takes nothing else.
298
+ if (artifact)
299
+ writeArtifact(outputPath, artifact);
296
300
  if (errors.length === 0) {
297
301
  console.log(`\n✓ ${specPath} → ${outputPath} (generator skill)`);
298
302
  return true;
@@ -393,14 +397,17 @@ function writeInstructionMirrors(primaryOutput, harnesses) {
393
397
  /** Compile a declarative SkillSpec → SKILL.md. */
394
398
  function compileSkillToFile(spec, specPath, dialect) {
395
399
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
396
- const { markdown, errors, warnings } = (0, compile_js_1.compileSkill)(spec, {
400
+ const { artifact, errors, warnings } = (0, compile_js_1.compileSkill)(spec, {
397
401
  basePath: process.cwd(),
398
402
  specFile: specPath,
399
403
  // The SKILL.md frontmatter profile comes from the resolved harness — a Codex
400
404
  // repo gets a minimal (name + description) SKILL.md; CC gets the full set.
401
405
  dialect,
402
406
  });
403
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
407
+ // Written only when the compile is clean: `artifact` is null otherwise, and
408
+ // `writeArtifact` takes nothing else.
409
+ if (artifact)
410
+ writeArtifact(outputPath, artifact);
404
411
  if (errors.length === 0) {
405
412
  console.log(`\n✓ ${specPath} → ${outputPath}`);
406
413
  printWarnings(specPath, warnings);
@@ -414,12 +421,15 @@ function compileSkillToFile(spec, specPath, dialect) {
414
421
  /** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
415
422
  function compileAgentToFile(spec, specPath, dialect) {
416
423
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
417
- const { markdown, errors, warnings } = (0, compile_js_1.compileAgent)(spec, {
424
+ const { artifact, errors, warnings } = (0, compile_js_1.compileAgent)(spec, {
418
425
  basePath: process.cwd(),
419
426
  specFile: specPath,
420
427
  dialect,
421
428
  });
422
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
429
+ // Written only when the compile is clean: `artifact` is null otherwise, and
430
+ // `writeArtifact` takes nothing else.
431
+ if (artifact)
432
+ writeArtifact(outputPath, artifact);
423
433
  if (errors.length === 0) {
424
434
  console.log(`\n✓ ${specPath} → ${outputPath}`);
425
435
  printWarnings(specPath, warnings);
@@ -437,11 +447,14 @@ function compileAgentToFile(spec, specPath, dialect) {
437
447
  */
438
448
  function compileRailwayToFile(spec, specPath, knownAgents) {
439
449
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
440
- const { markdown, errors } = (0, compile_js_1.compileRailway)(spec, {
450
+ const { artifact, errors } = (0, compile_js_1.compileRailway)(spec, {
441
451
  specFile: specPath,
442
452
  knownAgents,
443
453
  });
444
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), markdown);
454
+ // Written only when the compile is clean: `artifact` is null otherwise, and
455
+ // `writeArtifact` takes nothing else.
456
+ if (artifact)
457
+ writeArtifact(outputPath, artifact);
445
458
  if (errors.length === 0) {
446
459
  console.log(`\n✓ ${specPath} → ${outputPath}`);
447
460
  return true;
@@ -1294,6 +1307,18 @@ async function checkSpecRefs(config, silent, dialect) {
1294
1307
  }
1295
1308
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
1296
1309
  }
1310
+ /**
1311
+ * Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
1312
+ * that failed to compile cannot reach the disk — there is no stamp to pass.
1313
+ *
1314
+ * This replaces four copies of `writeFileSync(path, markdown)` that ran BEFORE
1315
+ * their error check (#173, reproduced in the skill/subagent/railway/generator
1316
+ * compilers after the CLAUDE.md one was fixed). Guarding four call sites would
1317
+ * have left the fifth writable; the type leaves nothing to remember.
1318
+ */
1319
+ function writeArtifact(outputPath, artifact) {
1320
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
1321
+ }
1297
1322
  async function runLint(restArgs, flags, config) {
1298
1323
  const summary = flags.includes("--summary");
1299
1324
  const json = flags.includes("--json");
@@ -6222,14 +6247,12 @@ async function runHookProgramCommand(file) {
6222
6247
  function ensureReportGitignored(cwd, entries) {
6223
6248
  if (entries.length === 0)
6224
6249
  return;
6225
- // An `--out` outside the repo produced entries like
6226
- // `../../../../private/tmp/x/vigiles-report.json`, which ignore NOTHING
6227
- // .gitignore does not reach outside its own tree and accumulate one dead
6228
- // block per output path. Worse in principle than in practice: `audit` is
6229
- // documented as a read-only report, and this made it edit a tracked file for
6230
- // no benefit at all. Inside the repo the write is expected and documented.
6231
- if (entries.some((e) => e.startsWith("..") || (0, node_path_1.isAbsolute)(e)))
6232
- return;
6250
+ // 🔴 THE GUARD THAT USED TO BE HERE IS GONE, and its absence is the point.
6251
+ // It checked at the write site that no entry escaped the repo (#176.8) which
6252
+ // worked, and left the bug writable: the next caller to build an entry list
6253
+ // still got a bare `string[]`. `RepoRelativePath` moves the check into the
6254
+ // TYPE, so an escaping path cannot be handed to this function at all. One
6255
+ // place mints them (`repoRelative`), and it returns null instead.
6233
6256
  const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
6234
6257
  try {
6235
6258
  if (!(0, node_fs_1.existsSync)(gi)) {
@@ -7135,10 +7158,17 @@ async function main() {
7135
7158
  // backslashes (`relative()` yields `reports\x` on Windows, which would
7136
7159
  // never match `reports/x`).
7137
7160
  if (wroteReports.length > 0) {
7138
- const rel = wroteReports.map((f) => {
7139
- const r = (0, node_path_1.relative)(process.cwd(), (0, node_path_1.resolve)(outDir, f)) || f;
7140
- return node_path_1.sep === "/" ? r : r.split(node_path_1.sep).join("/");
7141
- });
7161
+ // Only paths that PROVABLY sit inside the repo can be minted, so an
7162
+ // `--out` pointing elsewhere yields nothing to write rather than a
7163
+ // dead `../../..` entry.
7164
+ const rel = wroteReports
7165
+ .map((f) => (0, repo_path_js_1.repoRelative)(process.cwd(), (0, node_path_1.resolve)(outDir, f), {
7166
+ relative: node_path_1.relative,
7167
+ resolve: node_path_1.resolve,
7168
+ isAbsolute: node_path_1.isAbsolute,
7169
+ sep: node_path_1.sep,
7170
+ }))
7171
+ .filter((p) => p !== null);
7142
7172
  ensureReportGitignored(process.cwd(), rel);
7143
7173
  }
7144
7174
  // A shareable deep-link for a public GitHub repo: the in-browser demo
@@ -25,6 +25,7 @@
25
25
  * Gate references (`cmd`/`file`/`project`) are collected and verified, so the
26
26
  * cross-referencing moat works on generators too (literal args only).
27
27
  */
28
+ import { type StampedMarkdown } from "./compile.js";
28
29
  export interface GeneratorError {
29
30
  type: "stale-file" | "stale-command";
30
31
  message: string;
@@ -34,6 +35,11 @@ export interface CompileGeneratorResult {
34
35
  errors: GeneratorError[];
35
36
  }
36
37
  export interface CompileGeneratorSkillResult {
38
+ /**
39
+ * The stamped artifact — present ONLY when `errors` is empty. `null` is what
40
+ * makes a failed compile unwritable: `writeArtifact` accepts nothing else.
41
+ */
42
+ artifact: StampedMarkdown | null;
37
43
  markdown: string;
38
44
  errors: GeneratorError[];
39
45
  }
@@ -278,6 +278,9 @@ function compileGeneratorSkill(source, options = {}) {
278
278
  !genArg.body) {
279
279
  return {
280
280
  markdown: "",
281
+ // No stamp for a spec that did not compile — the error branch has nothing
282
+ // to write, which is the whole point of the field.
283
+ artifact: null,
281
284
  errors: [
282
285
  {
283
286
  type: "stale-command",
@@ -300,7 +303,7 @@ function compileGeneratorSkill(source, options = {}) {
300
303
  }
301
304
  fm.push("", "---");
302
305
  const content = `${fm.join("\n")}\n\n${body.trim()}\n`;
303
- return { markdown: (0, compile_js_1.addHash)(content, specFile), errors };
306
+ return { ...(0, compile_js_1.seal)(content, errors, specFile), errors };
304
307
  }
305
308
  /**
306
309
  * Compile a generator's SOURCE text to SKILL.md markdown + verified-ref
@@ -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
@@ -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": "24.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",