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.
- package/dist/adapter-conformance.js +15 -3
- 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/claude-code/run-scripts.js +47 -8
- package/dist/adapters/codex/dialect.js +19 -0
- package/dist/cli-main.js +100 -11
- 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/linters.js +3 -3
- package/dist/core/test-utils.d.ts +1 -2
- package/dist/core/test-utils.js +7 -9
- package/dist/core/tmp-root.d.ts +12 -0
- package/dist/core/tmp-root.js +59 -0
- package/dist/core/vocabulary-consistency.js +10 -0
- package/dist/eval.js +6 -5
- package/dist/harness-test.js +2 -2
- 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/run-script.js +3 -3
- package/dist/sandbox.js +2 -2
- package/dist/scan-behavioral.js +2 -2
- 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/dist/test.d.ts +1 -0
- package/dist/test.js +17 -2
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +7 -1
- package/skills/edit-spec/SKILL.md +13 -0
package/dist/core/compile.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/core/compile.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
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
|