vigiles 27.1.7 → 27.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 (43) hide show
  1. package/dist/adapter-conformance.js +15 -3
  2. package/dist/adapters/claude-code/dialect.js +30 -0
  3. package/dist/adapters/claude-code/event-capability.d.ts +20 -0
  4. package/dist/adapters/claude-code/event-capability.js +81 -0
  5. package/dist/adapters/claude-code/hook-protocol.js +26 -3
  6. package/dist/adapters/claude-code/run-scripts.js +47 -8
  7. package/dist/adapters/codex/dialect.js +19 -0
  8. package/dist/cli-main.js +100 -11
  9. package/dist/core/adopt.js +23 -5
  10. package/dist/core/compile.d.ts +40 -1
  11. package/dist/core/compile.js +76 -2
  12. package/dist/core/dialect.d.ts +43 -1
  13. package/dist/core/event-capability.d.ts +128 -0
  14. package/dist/core/event-capability.js +114 -0
  15. package/dist/core/hook-program.d.ts +38 -0
  16. package/dist/core/hook-program.js +103 -0
  17. package/dist/core/hook-protocol.d.ts +19 -3
  18. package/dist/core/instruction-weight.d.ts +86 -0
  19. package/dist/core/instruction-weight.js +86 -0
  20. package/dist/core/linters.js +3 -3
  21. package/dist/core/test-utils.d.ts +1 -2
  22. package/dist/core/test-utils.js +7 -9
  23. package/dist/core/tmp-root.d.ts +12 -0
  24. package/dist/core/tmp-root.js +59 -0
  25. package/dist/core/vocabulary-consistency.js +10 -0
  26. package/dist/eval.js +6 -5
  27. package/dist/harness-test.js +2 -2
  28. package/dist/hook-install.d.ts +9 -7
  29. package/dist/hook-install.js +75 -16
  30. package/dist/hook-runtime.js +4 -1
  31. package/dist/posix-path.js +1 -1
  32. package/dist/run-script.js +3 -3
  33. package/dist/sandbox.js +2 -2
  34. package/dist/scan-behavioral.js +2 -2
  35. package/dist/scan-files.js +10 -3
  36. package/dist/scan.d.ts +9 -1
  37. package/dist/scan.js +96 -3
  38. package/dist/setup-plan.d.ts +8 -0
  39. package/dist/test.d.ts +1 -0
  40. package/dist/test.js +17 -2
  41. package/package.json +1 -1
  42. package/skills/adopt-spec/SKILL.md +7 -1
  43. package/skills/edit-spec/SKILL.md +13 -0
@@ -56,7 +56,7 @@ export declare function verifyHash(content: string): {
56
56
  */
57
57
  /** @internal */ export declare function estimateTokens(text: string): number;
58
58
  export interface CompileError {
59
- type: "stale-file" | "stale-command" | "stale-ref" | "invalid-rule" | "budget-exceeded" | "section-too-long" | "section-has-header" | "reserved-section-key" | "spec-name-mismatch" | "unknown-tool" | "invalid-railway" | "purity-violation" | "output-without-fork" | "effect-in-skill" | "inline-code-too-long";
59
+ type: "stale-file" | "stale-command" | "stale-ref" | "invalid-rule" | "budget-exceeded" | "section-too-long" | "section-has-header" | "reserved-section-key" | "spec-name-mismatch" | "unknown-tool" | "invalid-railway" | "purity-violation" | "output-without-fork" | "effect-in-skill" | "inline-code-too-long" | "entry-too-long" | "section-too-large";
60
60
  message: string;
61
61
  path?: string;
62
62
  }
@@ -69,6 +69,13 @@ export declare function validateGlobRef(pattern: string, basePath: string): Comp
69
69
  export interface CompileClaudeResult {
70
70
  markdown: string;
71
71
  errors: CompileError[];
72
+ /**
73
+ * Budget findings — never errors. An over-long `keyFiles` description or a
74
+ * bloated section does not break the harness, it makes every request more
75
+ * expensive, so it must not stop a faithful adoption from compiling. Same
76
+ * channel discipline as `checkInlineCode`.
77
+ */
78
+ warnings: CompileError[];
72
79
  linterResults: LinterCheckResult[];
73
80
  /** Estimated token count of compiled output (~4 chars/token). */
74
81
  tokens: number;
@@ -76,6 +83,10 @@ export interface CompileClaudeResult {
76
83
  targets: string[];
77
84
  }
78
85
  export interface CompileClaudeOptions {
86
+ /** Per-entry `keyFiles`/`commands` character budget (0 disables). */
87
+ maxEntryChars?: number;
88
+ /** Per-section character budget, beside the line budget (0 disables). */
89
+ maxSectionChars?: number;
79
90
  basePath?: string;
80
91
  specFile?: string;
81
92
  /** Injected harness dialect; its instructionTargets[0] is the default target. */
@@ -97,6 +108,34 @@ export interface CompileClaudeOptions {
97
108
  /** Per-linter verification mode: true (full), "catalog-only", or false (skip). */
98
109
  linterModes?: Record<string, boolean | "catalog-only">;
99
110
  }
111
+ /**
112
+ * The generous per-section line guard. Exported because `adopt` raises it to
113
+ * exactly the longest adopted section and must name the same number rather than
114
+ * keep a second copy that drifts.
115
+ */
116
+ export declare const DEFAULT_MAX_SECTION_LINES = 200;
117
+ /**
118
+ * Per-ENTRY budget for a `keyFiles` / `commands` description, in characters.
119
+ *
120
+ * WHY AN ENTRY AND NOT THE SECTION. The list is append-only in practice: every
121
+ * session adds a row and none removes one, so the section total says "too big"
122
+ * long after the point where a reader could act on it, and it names no
123
+ * offender. A per-entry budget names the row. 200 is deliberately loose — an
124
+ * entry is a POINTER ("what is this file for"), and anything that needs a
125
+ * paragraph has a better home in that file's own header, where it is read when
126
+ * someone opens the file rather than on every request.
127
+ */
128
+ export declare const DEFAULT_MAX_ENTRY_CHARS = 200;
129
+ /**
130
+ * Per-section budget in CHARACTERS, beside the line budget.
131
+ *
132
+ * The line guard alone is measured to be useless on the shape that actually
133
+ * bites: this repo's own `Positioning` section was 24 lines and 20 416
134
+ * characters, passing a 200-LINE gate with two orders of magnitude to spare.
135
+ * Long lines are the normal shape of compiled prose, so lines do not measure
136
+ * cost — characters do, because that is what the harness loads.
137
+ */
138
+ export declare const DEFAULT_MAX_SECTION_CHARS = 15000;
100
139
  /**
101
140
  * Compile a ClaudeSpec into markdown.
102
141
  *
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_MAX_SECTION_CHARS = exports.DEFAULT_MAX_ENTRY_CHARS = exports.DEFAULT_MAX_SECTION_LINES = void 0;
6
7
  exports.computeHash = computeHash;
7
8
  exports.addHash = addHash;
8
9
  exports.seal = seal;
@@ -318,7 +319,79 @@ function compileRule(id, rule) {
318
319
  // generous (don't-cry-wolf): real prose sections are short, so this only trips on
319
320
  // an egregious dump (a whole essay pasted into one section / prose``).
320
321
  // Override per spec with `maxSectionLines`; `maxTokens` is the global backstop.
321
- const DEFAULT_MAX_SECTION_LINES = 200;
322
+ /**
323
+ * The generous per-section line guard. Exported because `adopt` raises it to
324
+ * exactly the longest adopted section and must name the same number rather than
325
+ * keep a second copy that drifts.
326
+ */
327
+ exports.DEFAULT_MAX_SECTION_LINES = 200;
328
+ /**
329
+ * Per-ENTRY budget for a `keyFiles` / `commands` description, in characters.
330
+ *
331
+ * WHY AN ENTRY AND NOT THE SECTION. The list is append-only in practice: every
332
+ * session adds a row and none removes one, so the section total says "too big"
333
+ * long after the point where a reader could act on it, and it names no
334
+ * offender. A per-entry budget names the row. 200 is deliberately loose — an
335
+ * entry is a POINTER ("what is this file for"), and anything that needs a
336
+ * paragraph has a better home in that file's own header, where it is read when
337
+ * someone opens the file rather than on every request.
338
+ */
339
+ exports.DEFAULT_MAX_ENTRY_CHARS = 200;
340
+ /**
341
+ * Per-section budget in CHARACTERS, beside the line budget.
342
+ *
343
+ * The line guard alone is measured to be useless on the shape that actually
344
+ * bites: this repo's own `Positioning` section was 24 lines and 20 416
345
+ * characters, passing a 200-LINE gate with two orders of magnitude to spare.
346
+ * Long lines are the normal shape of compiled prose, so lines do not measure
347
+ * cost — characters do, because that is what the harness loads.
348
+ */
349
+ exports.DEFAULT_MAX_SECTION_CHARS = 15000;
350
+ /**
351
+ * Budget findings for the two append-only maps and for section size. WARNINGS
352
+ * by construction (`lint-rule-calibration`: severity tracks confidence, and a
353
+ * gate that fails every real repo on day one is switched off on day two — this
354
+ * repo's own corpus opens at 26 over-long entries).
355
+ */
356
+ function checkContentBudgets(spec, specFile, maxEntryChars, maxSectionChars) {
357
+ const warns = [];
358
+ const entries = [
359
+ ["keyFiles", spec.keyFiles],
360
+ ["commands", spec.commands],
361
+ ];
362
+ for (const [field, map] of entries) {
363
+ if (!map || maxEntryChars <= 0)
364
+ continue;
365
+ for (const [key, description] of Object.entries(map)) {
366
+ if (description.length <= maxEntryChars)
367
+ continue;
368
+ warns.push({
369
+ type: "entry-too-long",
370
+ path: specFile,
371
+ message: `${field}[${JSON.stringify(key)}] description is ${String(description.length)} ` +
372
+ `characters (budget ${String(maxEntryChars)}). An entry is a pointer — say what the ` +
373
+ `file is for in one line and move the explanation into its own header, which is read ` +
374
+ `when someone opens it rather than on every request.`,
375
+ });
376
+ }
377
+ }
378
+ if (spec.sections && maxSectionChars > 0) {
379
+ for (const [name, body] of Object.entries(spec.sections)) {
380
+ // Fragment-valued sections are assembled later; only plain prose is
381
+ // measurable here, and it is the shape that grows.
382
+ if (typeof body !== "string" || body.length <= maxSectionChars)
383
+ continue;
384
+ warns.push({
385
+ type: "section-too-large",
386
+ path: specFile,
387
+ message: `section ${JSON.stringify(name)} is ${String(body.length)} characters ` +
388
+ `(budget ${String(maxSectionChars)}). The line guard cannot see this — long lines are ` +
389
+ `the normal shape of compiled prose — and every character is loaded on every request.`,
390
+ });
391
+ }
392
+ }
393
+ return warns;
394
+ }
322
395
  // A key-files entry is a POINTER, not an essay: the prose about why a file is
323
396
  // shaped the way it is belongs in that file's own header, where it is read when
324
397
  // the file is opened. Calibrated against this repo on 2026-09-08 — 285 entries,
@@ -343,7 +416,7 @@ function validateSectionContent(name, text, maxSectionLines) {
343
416
  break;
344
417
  }
345
418
  }
346
- const max = maxSectionLines ?? DEFAULT_MAX_SECTION_LINES;
419
+ const max = maxSectionLines ?? exports.DEFAULT_MAX_SECTION_LINES;
347
420
  if (contentLines.length > max) {
348
421
  errors.push({
349
422
  type: "section-too-long",
@@ -546,6 +619,7 @@ function compileClaude(spec, options = {}) {
546
619
  return {
547
620
  markdown,
548
621
  errors,
622
+ warnings: checkContentBudgets(spec, specFile, options.maxEntryChars ?? exports.DEFAULT_MAX_ENTRY_CHARS, options.maxSectionChars ?? exports.DEFAULT_MAX_SECTION_CHARS),
549
623
  linterResults: rules.linterResults,
550
624
  tokens,
551
625
  targets: allTargets,
@@ -23,6 +23,8 @@
23
23
  * `HarnessDialect.skillFrontmatter`.
24
24
  */
25
25
  import type { HarnessVocabulary } from "./vocabulary.js";
26
+ import type { EventCapabilityTable } from "./event-capability.js";
27
+ import type { InstructionBudget } from "./instruction-weight.js";
26
28
  export type SkillFrontmatterProfile = "claude-code" | "minimal";
27
29
  export interface HarnessDialect {
28
30
  /** Stable identifier, e.g. "claude-code". */
@@ -52,13 +54,28 @@ export interface HarnessDialect {
52
54
  * cries wolf on a PostToolUse feedback/nudge hook). Optional (additive,
53
55
  * non-breaking) — absent ⇒ the harness's block semantics are undeclared and the
54
56
  * check does not run for it.
57
+ *
58
+ * @deprecated Superseded by {@link eventCapabilities}, from which this list is
59
+ * now DERIVED (`blockIneffectiveEvents`) and against which it is asserted. Kept
60
+ * because removing a public port field breaks third-party adapters; it will go
61
+ * in the next major.
62
+ *
63
+ * 🔴 THE NAME IS WRONG AND THAT MATTERED. "No effect" reads as "a hook here does
64
+ * nothing", but it MEANS "no effect OF A BLOCK" — Claude Code's `SessionStart`
65
+ * is in this list and injects context perfectly well; exit 2 is the only thing
66
+ * it ignores. Deriving the list caught this: the obvious predicate
67
+ * (`honours === []`) silently dropped SessionStart and would have changed what
68
+ * `hook-block-ineffective` flags.
55
69
  */
56
70
  readonly noEffectHookEvents?: readonly string[];
57
71
  /**
58
72
  * The subset of blocking events whose deny REQUIRES the structured
59
73
  * `permissionDecision` field (e.g. Claude Code's `PreToolUse`), where the
60
74
  * legacy top-level `decision` field is silently ignored. The basis for the
61
- * `hook-block-ineffective` "wrong-field" check. Optional (additive).
75
+ * `hook-block-ineffective` "wrong-field" check. Optional (additive). *
76
+ * @deprecated Superseded by {@link eventCapabilities} (`denyShape:
77
+ * "permission-decision"`), from which this list is derived and against which it
78
+ * is asserted. Kept for third-party adapters; goes in the next major.
62
79
  */
63
80
  readonly permissionDecisionHookEvents?: readonly string[];
64
81
  /** Instruction-file targets the harness reads (also the h1 heading). */
@@ -98,6 +115,31 @@ export interface HarnessDialect {
98
115
  * and its unknowns become advisories rather than silence.
99
116
  */
100
117
  readonly hookEventVocabulary?: HarnessVocabulary;
118
+ /**
119
+ * What this harness loads WITHOUT being asked, how it MEASURES that, and what
120
+ * it does when there is too much — {@link InstructionBudget}.
121
+ *
122
+ * On the dialect because it is a FORMAT fact (which files, counted in which
123
+ * unit), and because the browser engine is handed a dialect. Optional: absent
124
+ * ⇒ the weight report does not run for that adapter, which is the honest
125
+ * answer for a harness whose limits nobody has read.
126
+ */
127
+ readonly instructionBudget?: InstructionBudget;
128
+ /**
129
+ * What each hook event CARRIES and HONOURS — {@link EventCapabilityTable}.
130
+ *
131
+ * The single table three flat lists had been approximating: `hookEvents`
132
+ * (membership), `noEffectHookEvents` (blocks ignored) and
133
+ * `permissionDecisionHookEvents` (deny needs a field) all ask about an event,
134
+ * none relate it to its PAYLOAD, and the fourth — `injectableEvents` — sits on
135
+ * a different port entirely. A list per question cannot answer a question
136
+ * about a PAIR, which is what "is this role legal on this event?" is.
137
+ *
138
+ * Optional (additive, non-breaking): absent ⇒ the flat lists still decide, and
139
+ * the (role, event) compatibility check does not run for that adapter. Present
140
+ * ⇒ it is the source the derived lists are read from, so the two cannot drift.
141
+ */
142
+ readonly eventCapabilities?: EventCapabilityTable;
101
143
  /**
102
144
  * The subagent-tool catalog as a {@link HarnessVocabulary}. Same contract as
103
145
  * `hookEventVocabulary`; absent ⇒ synthesised from `builtinAgentTools`
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What a hook event CARRIES and what it HONOURS — the one table three lists had
3
+ * been disagreeing across.
4
+ *
5
+ * THE DEFECT THIS EXISTS FOR (measured 2026-09-16, six fixtures compiled at HEAD
6
+ * and run through `hook-runtime run-program`): a compiled hook can name a role
7
+ * and an event that cannot work together, and nothing says so. A prompt-gate on
8
+ * `PreToolUse` reads an absent `prompt` as `""` and allows everything. A
9
+ * stop-gate on `SessionStart` exits 2 into an event that ignores it. A file-gate
10
+ * on `Stop` matches a tool on an event that carries none. `lint` and `audit`
11
+ * found ZERO on that fixture; `tsc` caught one of six, and `vigiles compile`
12
+ * does not run `tsc` at all.
13
+ *
14
+ * The facts needed to reject all of them already existed — scattered across
15
+ * `hookEvents` (dialect), `noEffectHookEvents` (dialect),
16
+ * `permissionDecisionHookEvents` (dialect) and `injectableEvents` (hookProtocol),
17
+ * three of them optional, none of them relating an event to what it CARRIES. A
18
+ * list per question cannot answer a question about the pair.
19
+ *
20
+ * WHY THE DIALECT AND NOT THE HOOK PROTOCOL (which is where the fourth list
21
+ * lives, and where the original proposal put this): the dialect is REQUIRED of
22
+ * every adapter while `hookProtocol` is optional — OpenCode has hook events and
23
+ * no shell-hook protocol — and `scanFiles`, the browser-side engine, is handed a
24
+ * layout and a dialect and never a protocol. Putting the table on the protocol
25
+ * would have meant widening a public signature to carry one list to the place
26
+ * three already are.
27
+ *
28
+ * WHY IT IS PARTIAL, AND WHY THAT IS THE HONEST SHAPE: Claude Code documents 31
29
+ * hook events. We know the capabilities of nine. Filling in the other 22 would
30
+ * be inventing facts about somebody else's product, which is the failure mode
31
+ * `vocabulary.ts` was written to stop — so this mirrors it: a recorded capture,
32
+ * a partial table, and a TOTAL classifier whose fourth answer is `unknown`.
33
+ * Unknown is FAIL-OPEN by design: a check that cannot know stays silent rather
34
+ * than accusing, because a false rejection at compile time costs more than a
35
+ * missed one (`lint-rule-calibration`).
36
+ */
37
+ /**
38
+ * The channels a hook can reach the harness through. Distinct from the ROLE an
39
+ * author writes: a role is a promise about the return type, a channel is what
40
+ * the harness will actually do with it.
41
+ *
42
+ * - `veto` — a deny STOPS the thing (the tool call, the prompt, the stopping).
43
+ * - `ask` — a decision can defer to the human instead of allow/deny.
44
+ * - `inject` — `additionalContext` reaches the MODEL.
45
+ * - `feedback` — a non-zero exit reaches the model as text, but stops nothing.
46
+ * - `halt` — the whole turn can be ended (Claude Code's `continue: false`).
47
+ */
48
+ export type HookChannel = "veto" | "ask" | "inject" | "feedback" | "halt";
49
+ /** What the event hands the hook — and therefore what a decide() can read. */
50
+ export type EventPayload = "tool" | "prompt" | "stop" | "session" | "none";
51
+ /**
52
+ * How a deny must be SPELLED on this event. Deliberately NOT folded into
53
+ * `honours: "ask"`, though on Claude Code both are `PreToolUse` alone and the
54
+ * merge would look free today: "this event can ask a human" and "this event's
55
+ * deny needs a structured field rather than an exit code" are different facts
56
+ * about different mechanisms, and a coincidence on one harness is not a reason
57
+ * to make them inexpressible apart on the next.
58
+ */
59
+ export type DenyShape = "exit-code" | "permission-decision";
60
+ /** Everything the compiler needs to judge one (role, event) pair. */
61
+ export interface EventCapability {
62
+ readonly carries: EventPayload;
63
+ readonly honours: readonly HookChannel[];
64
+ /** Whether a tool matcher is meaningful here (it needs `carries: "tool"`). */
65
+ readonly matcher: boolean;
66
+ readonly denyShape: DenyShape;
67
+ }
68
+ /**
69
+ * A capability table for the events an adapter has actually recorded, with the
70
+ * vendor artifact it was read from — the same shape, and the same reason, as
71
+ * {@link HarnessVocabulary}.
72
+ */
73
+ export interface EventCapabilityTable {
74
+ /** e.g. `"code.claude.com/docs/en/hooks + measured on claude-code 2.1.273"`. */
75
+ readonly capturedFrom: string;
76
+ readonly events: Readonly<Record<string, EventCapability>>;
77
+ }
78
+ /** Total: every name gets an answer, and `unknown` is one of them. */
79
+ export type CapabilityVerdict = {
80
+ readonly kind: "known";
81
+ readonly event: string;
82
+ readonly capability: EventCapability;
83
+ } | {
84
+ readonly kind: "unknown";
85
+ readonly event: string;
86
+ };
87
+ /** Look one event up in a table that may not hold it. */
88
+ export declare function capabilityOf(table: EventCapabilityTable | undefined, event: string): CapabilityVerdict;
89
+ /**
90
+ * Does this event honour this channel? THREE answers, never two — collapsing
91
+ * `unknown` into `false` is how a partial table turns into a confident wrong
92
+ * rejection, which is the bug this module is written against.
93
+ */
94
+ export declare function honoursChannel(table: EventCapabilityTable | undefined, event: string, channel: HookChannel): boolean | "unknown";
95
+ /** The events honouring a channel — the derived form of the old flat lists. */
96
+ export declare function eventsHonouring(table: EventCapabilityTable | undefined, channel: HookChannel): readonly string[];
97
+ /**
98
+ * The events where a BLOCK DECISION reaches nobody — neither vetoing the action
99
+ * nor feeding the model. The derived replacement for `noEffectHookEvents`.
100
+ *
101
+ * 🔴 IT IS NOT `honours === []`, and getting that wrong is what the derivation
102
+ * caught: `noEffectHookEvents` is named for "no effect" but MEANS "no effect OF
103
+ * A BLOCK". Claude Code's `SessionStart` is in that list and honours `inject`
104
+ * perfectly well — exit 2 is what it ignores. An `honours.length === 0` reading
105
+ * would have dropped SessionStart from the derived list and quietly changed
106
+ * what `hook-block-ineffective` flags.
107
+ *
108
+ * So the predicate is "honours neither veto nor feedback": a deny there stops
109
+ * nothing and tells nobody, which is the property the check is about. An event
110
+ * absent from the table is NOT reported (the old flat list could not express
111
+ * "we have not looked"; this can).
112
+ */
113
+ export declare function blockIneffectiveEvents(table: EventCapabilityTable | undefined): readonly string[];
114
+ /** The derived replacement for `permissionDecisionHookEvents`. */
115
+ export declare function permissionDecisionEvents(table: EventCapabilityTable | undefined): readonly string[];
116
+ /** Enough of a dialect to answer the event questions, without importing one. */
117
+ export interface EventFactSource {
118
+ readonly eventCapabilities?: EventCapabilityTable;
119
+ readonly noEffectHookEvents?: readonly string[];
120
+ readonly permissionDecisionHookEvents?: readonly string[];
121
+ }
122
+ /** Events honouring `inject`: the table, else the protocol's legacy list. */
123
+ export declare function injectableEventsOf(source: EventFactSource | undefined, legacy: readonly string[] | undefined): readonly string[];
124
+ /** Events where a block reaches nobody: the table, else the legacy list. */
125
+ export declare function blockIneffectiveEventsOf(source: EventFactSource | undefined): readonly string[];
126
+ /** Events whose deny needs the structured field: the table, else the list. */
127
+ export declare function permissionDecisionEventsOf(source: EventFactSource | undefined): readonly string[];
128
+ //# sourceMappingURL=event-capability.d.ts.map
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ /**
3
+ * What a hook event CARRIES and what it HONOURS — the one table three lists had
4
+ * been disagreeing across.
5
+ *
6
+ * THE DEFECT THIS EXISTS FOR (measured 2026-09-16, six fixtures compiled at HEAD
7
+ * and run through `hook-runtime run-program`): a compiled hook can name a role
8
+ * and an event that cannot work together, and nothing says so. A prompt-gate on
9
+ * `PreToolUse` reads an absent `prompt` as `""` and allows everything. A
10
+ * stop-gate on `SessionStart` exits 2 into an event that ignores it. A file-gate
11
+ * on `Stop` matches a tool on an event that carries none. `lint` and `audit`
12
+ * found ZERO on that fixture; `tsc` caught one of six, and `vigiles compile`
13
+ * does not run `tsc` at all.
14
+ *
15
+ * The facts needed to reject all of them already existed — scattered across
16
+ * `hookEvents` (dialect), `noEffectHookEvents` (dialect),
17
+ * `permissionDecisionHookEvents` (dialect) and `injectableEvents` (hookProtocol),
18
+ * three of them optional, none of them relating an event to what it CARRIES. A
19
+ * list per question cannot answer a question about the pair.
20
+ *
21
+ * WHY THE DIALECT AND NOT THE HOOK PROTOCOL (which is where the fourth list
22
+ * lives, and where the original proposal put this): the dialect is REQUIRED of
23
+ * every adapter while `hookProtocol` is optional — OpenCode has hook events and
24
+ * no shell-hook protocol — and `scanFiles`, the browser-side engine, is handed a
25
+ * layout and a dialect and never a protocol. Putting the table on the protocol
26
+ * would have meant widening a public signature to carry one list to the place
27
+ * three already are.
28
+ *
29
+ * WHY IT IS PARTIAL, AND WHY THAT IS THE HONEST SHAPE: Claude Code documents 31
30
+ * hook events. We know the capabilities of nine. Filling in the other 22 would
31
+ * be inventing facts about somebody else's product, which is the failure mode
32
+ * `vocabulary.ts` was written to stop — so this mirrors it: a recorded capture,
33
+ * a partial table, and a TOTAL classifier whose fourth answer is `unknown`.
34
+ * Unknown is FAIL-OPEN by design: a check that cannot know stays silent rather
35
+ * than accusing, because a false rejection at compile time costs more than a
36
+ * missed one (`lint-rule-calibration`).
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.capabilityOf = capabilityOf;
40
+ exports.honoursChannel = honoursChannel;
41
+ exports.eventsHonouring = eventsHonouring;
42
+ exports.blockIneffectiveEvents = blockIneffectiveEvents;
43
+ exports.permissionDecisionEvents = permissionDecisionEvents;
44
+ exports.injectableEventsOf = injectableEventsOf;
45
+ exports.blockIneffectiveEventsOf = blockIneffectiveEventsOf;
46
+ exports.permissionDecisionEventsOf = permissionDecisionEventsOf;
47
+ /** Look one event up in a table that may not hold it. */
48
+ function capabilityOf(table, event) {
49
+ const capability = table?.events[event];
50
+ return capability === undefined
51
+ ? { kind: "unknown", event }
52
+ : { kind: "known", event, capability };
53
+ }
54
+ /**
55
+ * Does this event honour this channel? THREE answers, never two — collapsing
56
+ * `unknown` into `false` is how a partial table turns into a confident wrong
57
+ * rejection, which is the bug this module is written against.
58
+ */
59
+ function honoursChannel(table, event, channel) {
60
+ const v = capabilityOf(table, event);
61
+ return v.kind === "unknown" ? "unknown" : v.capability.honours.includes(channel); // prettier-ignore
62
+ }
63
+ /** The events honouring a channel — the derived form of the old flat lists. */
64
+ function eventsHonouring(table, channel) {
65
+ return Object.entries(table?.events ?? {})
66
+ .filter(([, c]) => c.honours.includes(channel))
67
+ .map(([name]) => name);
68
+ }
69
+ /**
70
+ * The events where a BLOCK DECISION reaches nobody — neither vetoing the action
71
+ * nor feeding the model. The derived replacement for `noEffectHookEvents`.
72
+ *
73
+ * 🔴 IT IS NOT `honours === []`, and getting that wrong is what the derivation
74
+ * caught: `noEffectHookEvents` is named for "no effect" but MEANS "no effect OF
75
+ * A BLOCK". Claude Code's `SessionStart` is in that list and honours `inject`
76
+ * perfectly well — exit 2 is what it ignores. An `honours.length === 0` reading
77
+ * would have dropped SessionStart from the derived list and quietly changed
78
+ * what `hook-block-ineffective` flags.
79
+ *
80
+ * So the predicate is "honours neither veto nor feedback": a deny there stops
81
+ * nothing and tells nobody, which is the property the check is about. An event
82
+ * absent from the table is NOT reported (the old flat list could not express
83
+ * "we have not looked"; this can).
84
+ */
85
+ function blockIneffectiveEvents(table) {
86
+ return Object.entries(table?.events ?? {})
87
+ .filter(([, c]) => !c.honours.includes("veto") && !c.honours.includes("feedback"))
88
+ .map(([name]) => name);
89
+ }
90
+ /** The derived replacement for `permissionDecisionHookEvents`. */
91
+ function permissionDecisionEvents(table) {
92
+ return Object.entries(table?.events ?? {})
93
+ .filter(([, c]) => c.denyShape === "permission-decision")
94
+ .map(([name]) => name);
95
+ }
96
+ /** Events honouring `inject`: the table, else the protocol's legacy list. */
97
+ function injectableEventsOf(source, legacy) {
98
+ return source?.eventCapabilities
99
+ ? eventsHonouring(source.eventCapabilities, "inject")
100
+ : (legacy ?? []);
101
+ }
102
+ /** Events where a block reaches nobody: the table, else the legacy list. */
103
+ function blockIneffectiveEventsOf(source) {
104
+ return source?.eventCapabilities
105
+ ? blockIneffectiveEvents(source.eventCapabilities)
106
+ : (source?.noEffectHookEvents ?? []);
107
+ }
108
+ /** Events whose deny needs the structured field: the table, else the list. */
109
+ function permissionDecisionEventsOf(source) {
110
+ return source?.eventCapabilities
111
+ ? permissionDecisionEvents(source.eventCapabilities)
112
+ : (source?.permissionDecisionHookEvents ?? []);
113
+ }
114
+ //# sourceMappingURL=event-capability.js.map
@@ -37,6 +37,7 @@ import { type BashEffect } from "./bash-effects.js";
37
37
  import { type SHA256Hash } from "./hash.js";
38
38
  import type { HarnessDialect } from "./dialect.js";
39
39
  import type { HookProtocol } from "./hook-protocol.js";
40
+ import { type EventCapabilityTable } from "./event-capability.js";
40
41
  import { type ProviderName, type NeedSpec, type HookCtx } from "./hook-providers.js";
41
42
  import { type StateFact, type StateWrite } from "./hook-state.js";
42
43
  /**
@@ -367,6 +368,13 @@ export interface CompiledHookProgram {
367
368
  readonly command: string;
368
369
  }[];
369
370
  }[]>;
371
+ /**
372
+ * Non-fatal findings about the compiled hook — today, a role that FIRES on its
373
+ * event but cannot do what the role promises (a gate on `PostToolUse`, whose
374
+ * deny feeds the model rather than vetoing). Absent when there is nothing to
375
+ * say; a FATAL mismatch throws instead, so this never carries a dead hook.
376
+ */
377
+ readonly warnings?: readonly string[];
370
378
  /**
371
379
  * The rendered settings block to add to the harness's hooks config — JSON for
372
380
  * Claude Code (`.claude/settings.json`), TOML `[[hooks.<event>]]` for Codex
@@ -427,6 +435,36 @@ export declare function hookRouting(hook: AnyHook): {
427
435
  on: string;
428
436
  matcher?: string;
429
437
  };
438
+ /**
439
+ * Is this ROLE legal on this EVENT? The check six measured fixtures showed
440
+ * nothing was making (2026-09-16): a prompt-gate on `PreToolUse` reads an absent
441
+ * prompt as `""` and allows everything; a stop-gate on `SessionStart` exits 2
442
+ * into an event that ignores it; a file-gate on `Stop` matches a tool on an
443
+ * event that carries none. All six compiled clean, `lint` and `audit` found
444
+ * zero, and `tsc` — which `vigiles compile` does not run — caught one.
445
+ *
446
+ * THREE VERDICTS, and the third is the point:
447
+ * - `dead` — the harness's own table says this cannot work. A compile error.
448
+ * - `degraded` — it fires, but not as the role promises. A WARNING, never an
449
+ * error: the worked case is a gate on `PostToolUse`, whose exit 2 feeds the
450
+ * model instead of vetoing, and that is a channel this very repo ships a hook
451
+ * on. Failing it would be the cry-wolf `lint-rule-calibration` names.
452
+ * - `unknown` — the event is not in the capability table. SILENT. We record 9
453
+ * of Claude Code's 31 events; rejecting an author for using one of the other
454
+ * 22 would be punishing them for our gap.
455
+ */
456
+ export type RoleEventFit = {
457
+ readonly kind: "ok";
458
+ } | {
459
+ readonly kind: "unknown";
460
+ } | {
461
+ readonly kind: "dead";
462
+ readonly message: string;
463
+ } | {
464
+ readonly kind: "degraded";
465
+ readonly message: string;
466
+ };
467
+ export declare function checkRoleEventFit(kind: DispatchKind, on: string, hasMatcher: boolean, table: EventCapabilityTable | undefined): RoleEventFit;
430
468
  /**
431
469
  * Compile a hook program from its source. Runs the capability check FIRST (an
432
470
  * out-of-API import does NOT compile), validates the event against the target