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.
- package/dist/adapter-conformance.js +13 -1
- package/dist/adapters/claude-code/dialect.js +30 -0
- package/dist/adapters/claude-code/event-capability.d.ts +20 -0
- package/dist/adapters/claude-code/event-capability.js +81 -0
- package/dist/adapters/claude-code/hook-protocol.js +26 -3
- package/dist/adapters/codex/dialect.js +19 -0
- package/dist/cli-main.js +58 -7
- package/dist/core/adopt.js +23 -5
- package/dist/core/compile.d.ts +40 -1
- package/dist/core/compile.js +76 -2
- package/dist/core/dialect.d.ts +43 -1
- package/dist/core/event-capability.d.ts +128 -0
- package/dist/core/event-capability.js +114 -0
- package/dist/core/hook-program.d.ts +38 -0
- package/dist/core/hook-program.js +103 -0
- package/dist/core/hook-protocol.d.ts +19 -3
- package/dist/core/instruction-weight.d.ts +86 -0
- package/dist/core/instruction-weight.js +86 -0
- package/dist/core/vocabulary-consistency.js +10 -0
- package/dist/hook-install.d.ts +9 -7
- package/dist/hook-install.js +75 -16
- package/dist/hook-runtime.js +4 -1
- package/dist/posix-path.js +1 -1
- package/dist/scan-files.js +10 -3
- package/dist/scan.d.ts +9 -1
- package/dist/scan.js +96 -3
- package/dist/setup-plan.d.ts +8 -0
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +7 -1
- package/skills/edit-spec/SKILL.md +13 -0
package/dist/core/dialect.d.ts
CHANGED
|
@@ -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);
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|