vigiles 27.1.7 → 27.2.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.
@@ -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
@@ -15,6 +15,7 @@ exports.dispatchKind = dispatchKind;
15
15
  exports.hookMode = hookMode;
16
16
  exports.hookNeeds = hookNeeds;
17
17
  exports.hookRouting = hookRouting;
18
+ exports.checkRoleEventFit = checkRoleEventFit;
18
19
  exports.compileHookProgram = compileHookProgram;
19
20
  exports.stampHook = stampHook;
20
21
  exports.verifyHookStamp = verifyHookStamp;
@@ -88,6 +89,7 @@ const hash_js_1 = require("./hash.js");
88
89
  * fails if `@iarna/toml` reappears in a decision's module graph.
89
90
  */
90
91
  const stringifyToml = (value) => require("@iarna/toml").stringify(value);
92
+ const event_capability_js_1 = require("./event-capability.js");
91
93
  const hook_events_js_1 = require("./hook-events.js");
92
94
  const tool_contract_js_1 = require("./tool-contract.js");
93
95
  const merge_conflict_js_1 = require("./merge-conflict.js");
@@ -743,6 +745,79 @@ function renderSettingsBlock(on, matcher, gateCommand, format) {
743
745
  : { matcher, hooks: [{ type: "command", command: gateCommand }] };
744
746
  return JSON.stringify({ hooks: { [on]: [entry] } }, null, 2);
745
747
  }
748
+ /** What payload a dispatch kind must be handed to be able to decide at all. */
749
+ function requiredPayload(kind) {
750
+ switch (kind) {
751
+ case "bash-gate":
752
+ case "file-gate":
753
+ return "tool";
754
+ case "prompt-gate":
755
+ return "prompt";
756
+ case "stop-gate":
757
+ return "stop";
758
+ // An inject or a react reads whatever the event carries; neither claims a
759
+ // field that might be absent, so neither constrains the payload.
760
+ case "inject":
761
+ case "react":
762
+ return undefined;
763
+ }
764
+ }
765
+ /** Whether this dispatch kind's whole purpose is a block decision. */
766
+ function isGate(kind) {
767
+ return (kind === "bash-gate" ||
768
+ kind === "file-gate" ||
769
+ kind === "prompt-gate" ||
770
+ kind === "stop-gate");
771
+ }
772
+ function checkRoleEventFit(kind, on, hasMatcher, table) {
773
+ const verdict = (0, event_capability_js_1.capabilityOf)(table, on);
774
+ if (verdict.kind === "unknown")
775
+ return { kind: "unknown" };
776
+ const cap = verdict.capability;
777
+ const needs = requiredPayload(kind);
778
+ if (needs !== undefined && cap.carries !== needs) {
779
+ return {
780
+ kind: "dead",
781
+ message: `a ${kind} on \`${on}\` can never decide: the role reads the event's ` +
782
+ `${needs}, and ${on} carries ${cap.carries === "none" ? "nothing" : cap.carries}. ` +
783
+ `The field it reads is absent, so the hook runs and waves everything through.`,
784
+ };
785
+ }
786
+ if (hasMatcher && !cap.matcher) {
787
+ return {
788
+ kind: "dead",
789
+ message: `a tool matcher is meaningless on \`${on}\` — it carries ` +
790
+ `${cap.carries === "none" ? "nothing" : cap.carries}, not a tool, so the ` +
791
+ `matcher can match nothing and the hook never fires.`,
792
+ };
793
+ }
794
+ if (isGate(kind) && !cap.honours.includes("veto")) {
795
+ // The degraded case: it still reaches the model, it just does not block.
796
+ if (cap.honours.includes("feedback")) {
797
+ return {
798
+ kind: "degraded",
799
+ message: `\`${on}\` does not honour a veto — a deny there reaches the model as ` +
800
+ `FEEDBACK after the action already happened. Fine as a nudge, but this ` +
801
+ `is a ${kind}, so it stops nothing. Use a react, or gate on an event ` +
802
+ `that vetoes.`,
803
+ };
804
+ }
805
+ return {
806
+ kind: "dead",
807
+ message: `a ${kind} on \`${on}\` blocks nothing: the event honours ` +
808
+ `${cap.honours.length === 0 ? "no channel at all" : cap.honours.join(", ")}, ` +
809
+ `so a deny is discarded silently — no veto, and no feedback to the model.`,
810
+ };
811
+ }
812
+ if (kind === "inject" && !cap.honours.includes("inject")) {
813
+ return {
814
+ kind: "dead",
815
+ message: `an inject on \`${on}\` reaches nobody — the event does not honour ` +
816
+ `additionalContext, so the text goes to the debug log.`,
817
+ };
818
+ }
819
+ return { kind: "ok" };
820
+ }
746
821
  /**
747
822
  * Compile a hook program from its source. Runs the capability check FIRST (an
748
823
  * out-of-API import does NOT compile), validates the event against the target
@@ -761,6 +836,26 @@ function compileHookProgram(source, hook, opts = {}) {
761
836
  if (violations.length > 0) {
762
837
  throw new HookCompileError(`hook program uses capabilities outside \`${ALLOWED_IMPORT}\`: ${violations.join(", ")} — only the sanctioned API is allowed (capability = API surface).`);
763
838
  }
839
+ // A BARE gate is a Bash gate by construction — `hookRouting` emits the matcher
840
+ // `Bash` for it unconditionally, and `HookProgram` has no `match` field. So a
841
+ // `match` here is a field the author wrote and the compiler ignores, and the
842
+ // result is the WORST of the six measured fixtures (d): not a dead hook but a
843
+ // LIVE one guarding the wrong thing — `defineHook({match: tools("mcp__…")})`
844
+ // compiled to a matcher of `Bash`, asking on every shell command while the MCP
845
+ // call it was written for went straight through.
846
+ //
847
+ // `tsc` rejects the excess property, which is why this looked covered. It is
848
+ // not: a compiled hook is often `.mjs` (this repo's own two are), and
849
+ // `vigiles compile` does not run `tsc` at all — so for a JS author the type
850
+ // was never in the path. Same defect, different population; the rule the
851
+ // repo already states as prevent-at-stage-1 AND detect-at-stage-3.
852
+ if (!("role" in hook) && "match" in hook) {
853
+ throw new HookCompileError(`a bare hook gate is a BASH gate — it matches \`Bash\` by construction, so ` +
854
+ `the \`match\` you passed is ignored and the hook would guard shell ` +
855
+ `commands instead of what you named. For a file tool use ` +
856
+ `experimental_defineFileGate; for any other tool matcher use ` +
857
+ `experimental_defineReact, whose event carries the tool.`);
858
+ }
764
859
  const { on, matcher: rawMatcher } = hookRouting(hook);
765
860
  // A tool pattern that is not a valid regex cannot match under the semantics the
766
861
  // emitted matcher is read with, so it would compile to a hook that never fires.
@@ -804,6 +899,13 @@ function compileHookProgram(source, hook, opts = {}) {
804
899
  throw new HookCompileError(fatal[0].message);
805
900
  }
806
901
  }
902
+ // …and an event the harness DOES fire can still be one this role cannot work
903
+ // on. Checked against the dialect's capability table; silent where the table
904
+ // has no row, so our gaps never become the author's error.
905
+ const fit = checkRoleEventFit(dispatchKind(hook), on, rawMatcher !== undefined, opts.dialect?.eventCapabilities);
906
+ if (fit.kind === "dead")
907
+ throw new HookCompileError(fit.message);
908
+ const fitWarnings = fit.kind === "degraded" ? [fit.message] : [];
807
909
  // A `needs` entry that isn't a built-in provider never resolves — reject it
808
910
  // (the typo-won't-compile guarantee, for JS authors the type can't reach).
809
911
  const needs = hookNeeds(hook);
@@ -830,6 +932,7 @@ function compileHookProgram(source, hook, opts = {}) {
830
932
  hooks: { [on]: [entry] },
831
933
  settingsBlock: renderSettingsBlock(on, matcher, gateCommand, opts.settingsFormat ?? "json"),
832
934
  stamp: stampHook(source),
935
+ ...(fitWarnings.length > 0 ? { warnings: fitWarnings } : {}),
833
936
  };
834
937
  }
835
938
  /** Stamp a hook's source (the integrity.ts pattern, applied to a hook artifact). */
@@ -40,11 +40,27 @@ export interface HookProtocol {
40
40
  * **which events honor it** — and encoding it here is what makes "this harness
41
41
  * can deliver an inject hook" a TESTED contract instead of an assumption. Both
42
42
  * Claude Code and Codex support the main lifecycle events (SessionStart,
43
- * UserPromptSubmit, PostToolUse); a few (Stop, SubagentStop, PreCompact) carry
44
- * no context on either. An empty list means the harness cannot inject context
45
- * from a hook at all. Verified for Codex against the official hooks docs
43
+ * UserPromptSubmit, PreToolUse, PostToolUse); beyond that they DIVERGE, which
44
+ * is why this is a port and not a core constant: Claude Code also honors
45
+ * `Stop` (measured 2026-09-15 on 2.1.273, headless), Codex instead honors
46
+ * `SubagentStart`. An empty list means the harness cannot inject context from
47
+ * a hook at all. Verified for Codex against the official hooks docs
46
48
  * (developers.openai.com/codex/hooks). The conformance kit asserts a
47
49
  * shell-hook harness declares a non-empty set.
50
+ *
51
+ * ⚠️ This comment previously asserted that "a few (Stop, SubagentStop,
52
+ * PreCompact) carry no context on EITHER" harness. For Claude Code's `Stop`
53
+ * that was wrong, and the cost was structural rather than cosmetic: an
54
+ * unmeasured claim in a doc-comment became the runtime's emit gate, so three
55
+ * react hooks were reported undeliverable-by-vocabulary when the harness
56
+ * would have delivered them. A per-harness fact belongs in the adapter WITH
57
+ * its measurement; the residue (`SubagentStop`, `PreCompact`) stays unclaimed
58
+ * here rather than re-asserted. *
59
+ * @deprecated Superseded by `HarnessDialect.eventCapabilities`
60
+ * (`honours: "inject"`), which is asserted to reproduce this list exactly. It
61
+ * lives on the DIALECT rather than here because three sibling event facts
62
+ * already did, and because the browser-side engine is handed a dialect and
63
+ * never a protocol. Kept for third-party adapters; goes in the next major.
48
64
  */
49
65
  readonly injectableEvents: readonly string[];
50
66
  /**
@@ -0,0 +1,86 @@
1
+ /**
2
+ * How heavy are the instructions this harness loads WITHOUT BEING ASKED — and
3
+ * what does the harness do when that is too much.
4
+ *
5
+ * WHY THIS IS NOT "the size of CLAUDE.md". Measured 2026-09-16 in a consumer
6
+ * repo: a 4 101-line root instruction file was "cut" to 2 665 lines by moving
7
+ * 225 837 characters of it into a sibling directory, and the cost of a request
8
+ * did not move at all — because the harness loads that directory
9
+ * unconditionally too. Splitting a file that is loaded either way relocates
10
+ * bytes; it does not remove them. So the number that matters is the SUM over
11
+ * everything loaded without a decision, and a per-file check silently INVITES
12
+ * the evasion (it rewards the split that changes nothing).
13
+ *
14
+ * WHY THE UNIT IS PER-HARNESS AND NOT TOKENS. The harnesses measure different
15
+ * things and neither gates on tokens:
16
+ *
17
+ * - Claude Code counts CHARACTERS and WARNS ("Large file will impact
18
+ * performance"); the instructions still reach the model.
19
+ * - Codex counts BYTES (`project_doc_max_bytes`, default 32 KiB) and
20
+ * TRUNCATES — silently. Its own source says so: "Maximum number of bytes of
21
+ * the documentation that will be embedded. Larger files are *silently
22
+ * truncated*" (openai/codex#7138, CLOSED AS NOT PLANNED, so this is the
23
+ * standing behaviour rather than a bug in flight).
24
+ *
25
+ * That asymmetry is the whole point of reporting `onExceed`: over budget on
26
+ * Claude Code costs money and attention, over budget on Codex means some of
27
+ * your rules DO NOT EXIST for the model and nothing tells you which. The same
28
+ * number carries a different severity per harness, so the harness must supply
29
+ * it — hence a port field, not a constant.
30
+ *
31
+ * NOT A GATE, AND THAT IS MEASURED. Both corpora this was built against sit at
32
+ * roughly four times the Claude Code threshold. A rule that fails every real
33
+ * repo on day one is switched off on day one (`lint-rule-calibration`: severity
34
+ * tracks confidence, and a check nobody leaves on catches nothing). So the
35
+ * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
36
+ * exists that it would not immediately fail.
37
+ */
38
+ /** What the harness counts, and what it does when the count is exceeded. */
39
+ export interface InstructionBudget {
40
+ /** Claude Code counts characters; Codex counts bytes. Never tokens. */
41
+ readonly unit: "chars" | "bytes";
42
+ /** The harness's own threshold, in `unit`. */
43
+ readonly limit: number;
44
+ /**
45
+ * `warns` — the instructions still reach the model (Claude Code).
46
+ * `truncates` — everything past the limit DOES NOT EXIST for the model, with
47
+ * no signal in the session (Codex). The difference is losing money versus
48
+ * losing rules.
49
+ */
50
+ readonly onExceed: "warns" | "truncates";
51
+ /** The vendor artifact this was read from, version included. */
52
+ readonly capturedFrom: string;
53
+ /**
54
+ * Globs the harness loads WITHOUT the user asking — the set the SUM is taken
55
+ * over. A file reachable only by an explicit read does not belong here; that
56
+ * is exactly the distinction the relocation trick exploits.
57
+ */
58
+ readonly alwaysLoaded: readonly string[];
59
+ }
60
+ /** One file's contribution, so a report can say WHERE the weight is. */
61
+ export interface WeighedFile {
62
+ readonly path: string;
63
+ readonly size: number;
64
+ }
65
+ export interface InstructionWeight {
66
+ readonly unit: "chars" | "bytes";
67
+ readonly limit: number;
68
+ readonly onExceed: "warns" | "truncates";
69
+ /** Heaviest first — a report's first line should name the biggest payer. */
70
+ readonly files: readonly WeighedFile[];
71
+ /** The number that matters: everything loaded without a decision. */
72
+ readonly total: number;
73
+ /** `null` when within budget; otherwise how far over, in `unit`. */
74
+ readonly overBy: number | null;
75
+ }
76
+ /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
77
+ export declare function sizeIn(text: string, unit: "chars" | "bytes"): number;
78
+ /**
79
+ * Weigh every unconditionally-loaded file in a file map.
80
+ *
81
+ * Takes a MAP rather than a directory so the same function serves the CLI and
82
+ * the browser engine (the `scan-files.ts` split), and so a test states its
83
+ * input instead of building a tree.
84
+ */
85
+ export declare function weighInstructions(files: Readonly<Record<string, string>>, budget: InstructionBudget): InstructionWeight;
86
+ //# sourceMappingURL=instruction-weight.d.ts.map