vigiles 38.2.0 โ 39.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code/event-capability.js +24 -8
- package/dist/adapters/claude-code/hook-protocol.js +2 -1
- package/dist/adapters/codex/hook-protocol.js +2 -1
- package/dist/cli-flag-check.d.ts +36 -0
- package/dist/cli-flag-check.js +72 -1
- package/dist/cli-main.d.ts +2 -2
- package/dist/cli-main.js +548 -379
- package/dist/core/bash-effects.d.ts +0 -57
- package/dist/core/bash-effects.js +0 -147
- package/dist/core/compile-generator.d.ts +6 -6
- package/dist/core/compile-generator.js +4 -4
- package/dist/core/compile.d.ts +72 -8
- package/dist/core/compile.js +276 -133
- package/dist/core/config-schema.d.ts +40 -10
- package/dist/core/config-schema.js +139 -16
- package/dist/core/coverage.d.ts +16 -0
- package/dist/core/coverage.js +22 -0
- package/dist/core/doc-refs.d.ts +3 -2
- package/dist/core/doc-refs.js +2 -2
- package/dist/core/enforce-marker.d.ts +28 -0
- package/dist/core/enforce-marker.js +34 -0
- package/dist/core/eslint-inventory.d.ts +96 -0
- package/dist/core/eslint-inventory.js +251 -0
- package/dist/core/event-capability.d.ts +5 -1
- package/dist/core/frame.d.ts +43 -2
- package/dist/core/frame.js +85 -2
- package/dist/core/generate-schema.d.ts +3 -2
- package/dist/core/generate-schema.js +2 -2
- package/dist/core/generate-types.d.ts +11 -2
- package/dist/core/generate-types.js +12 -5
- package/dist/core/hook-command.d.ts +172 -0
- package/dist/core/hook-command.js +277 -0
- package/dist/core/hook-delivery.d.ts +133 -0
- package/dist/core/hook-delivery.js +150 -0
- package/dist/core/hook-paths.d.ts +14 -0
- package/dist/core/hook-paths.js +22 -0
- package/dist/core/hook-program.d.ts +4 -4
- package/dist/core/hook-program.js +13 -25
- package/dist/core/hook-state.d.ts +1 -1
- package/dist/core/hook-state.js +1 -1
- package/dist/core/instruction-remedies.d.ts +35 -0
- package/dist/core/instruction-remedies.js +70 -1
- package/dist/core/integrity-header.d.ts +54 -0
- package/dist/core/integrity-header.js +90 -0
- package/dist/core/integrity.d.ts +1 -25
- package/dist/core/integrity.js +16 -74
- package/dist/core/linter-adapter.d.ts +27 -3
- package/dist/core/linters.d.ts +8 -1
- package/dist/core/linters.js +116 -81
- package/dist/core/orphans.d.ts +3 -5
- package/dist/core/orphans.js +9 -10
- package/dist/core/presets.d.ts +19 -7
- package/dist/core/presets.js +19 -6
- package/dist/core/rule-catalog.d.ts +11 -12
- package/dist/core/rule-catalog.js +25 -94
- package/dist/core/rule-meta.d.ts +12 -0
- package/dist/core/rule-meta.js +47 -10
- package/dist/core/source-refs.d.ts +3 -23
- package/dist/core/source-refs.js +3 -27
- package/dist/core/test-utils.d.ts +13 -0
- package/dist/core/test-utils.js +40 -1
- package/dist/core/types.d.ts +18 -26
- package/dist/core/validate.d.ts +1 -2
- package/dist/core/validate.js +60 -47
- package/dist/coverage-evidence.js +2 -3
- package/dist/deprecated-key-files.d.ts +6 -0
- package/dist/deprecated-key-files.js +7 -6
- package/dist/exclude.d.ts +3 -1
- package/dist/exclude.js +9 -33
- package/dist/harness-assert.d.ts +18 -0
- package/dist/harness-assert.js +38 -0
- package/dist/hook-check-install.js +15 -6
- package/dist/hook-check.d.ts +1 -3
- package/dist/hook-check.js +3 -5
- package/dist/hook-install.d.ts +7 -41
- package/dist/hook-install.js +29 -96
- package/dist/hook-runtime.js +2 -1
- package/dist/load-hook.d.ts +3 -2
- package/dist/load-hook.js +28 -6
- package/dist/overrides.d.ts +25 -0
- package/dist/overrides.js +51 -0
- package/dist/plugin-loader.d.ts +23 -0
- package/dist/plugin-loader.js +42 -12
- package/dist/root-globs.d.ts +9 -0
- package/dist/root-globs.js +48 -0
- package/dist/rule-routing.js +6 -3
- package/dist/run-hook.d.ts +26 -2
- package/dist/run-hook.js +48 -12
- package/dist/scan-core.d.ts +96 -20
- package/dist/scan-core.js +171 -176
- package/dist/scan-files.d.ts +0 -8
- package/dist/scan-files.js +78 -20
- package/dist/scan.d.ts +12 -12
- package/dist/scan.js +33 -10
- package/dist/setup-plan.d.ts +15 -9
- package/dist/setup-plan.js +26 -18
- package/dist/test-coverage.d.ts +3 -8
- package/dist/test-coverage.js +6 -6
- package/dist/test.d.ts +1 -1
- package/dist/vigilesrc.schema.json +1329 -118
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +2 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { type DispatchKind } from "./hook-program.js";
|
|
2
|
+
/** Where `compile` writes the start-of-session check. Committed, next to the hook sources. */
|
|
3
|
+
export declare const HOOK_CHECK_REF = ".vigiles/hook-check.sh";
|
|
4
|
+
/** The CLI verb every compiled hook line starts the runtime with. */
|
|
5
|
+
export declare const HOOK_RUNTIME_VERB = "hook-runtime";
|
|
6
|
+
/** The runtime kind that runs a compiled hook program; its one operand is the program. */
|
|
7
|
+
export declare const RUN_PROGRAM_KIND = "run-program";
|
|
8
|
+
/**
|
|
9
|
+
* The lines `init` wires for a harness with no plugin store (Codex): the
|
|
10
|
+
* shipped nudges, started through npx. Data, so the plan below knows them as
|
|
11
|
+
* vigiles's own without reading their text.
|
|
12
|
+
*/
|
|
13
|
+
export declare const INIT_NUDGE_COMMANDS: {
|
|
14
|
+
readonly evalLock: "npx --no-install vigiles hook-runtime eval-lock-nudge";
|
|
15
|
+
readonly refs: "npx --no-install vigiles hook-runtime refs";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The command line `compile` writes to run a hook program: the runtime started
|
|
19
|
+
* by `runnerRef`, the program at `programRef`. {@link plannedHookLines} uses it, so a reader compares a wired line with the same string.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runProgramCommand(runnerRef: string, programRef: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
|
|
24
|
+
*
|
|
25
|
+
* ๐ด MEASURED 2026-09-19, warm cache, five runs each:
|
|
26
|
+
*
|
|
27
|
+
* node <local>/dist/cli.js hook-runtime run-program โฆ 193 ms
|
|
28
|
+
* npx vigiles hook-runtime run-program โฆ 2545 ms
|
|
29
|
+
*
|
|
30
|
+
* Thirteen times, on every tool call, because `npx` re-resolves the package on
|
|
31
|
+
* each invocation โ local, then global, then the registry. That search is the
|
|
32
|
+
* single largest cost in a hook's life; everything the runtime does inside adds
|
|
33
|
+
* up to less than a fifth of it.
|
|
34
|
+
*
|
|
35
|
+
* A harness with no project-root token gets the relative spelling, which is all
|
|
36
|
+
* it can be given โ see {@link hookGateRef} for the same fallback.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hookRuntimeRef(projectRootTokens: readonly string[] | undefined, runtime?: HookRuntime): string;
|
|
39
|
+
/**
|
|
40
|
+
* Which file a project starts the hook runtime from, and the command that puts
|
|
41
|
+
* that file on disk when it is missing.
|
|
42
|
+
*/
|
|
43
|
+
export interface HookRuntime {
|
|
44
|
+
/** Project-relative path of the runtime's entry point. */
|
|
45
|
+
readonly path: string;
|
|
46
|
+
/** What the start-of-session notice tells the user to run when it is missing. */
|
|
47
|
+
readonly cure: string;
|
|
48
|
+
}
|
|
49
|
+
/** A consumer's runtime: the vigiles it installed as a dependency. */
|
|
50
|
+
export declare const INSTALLED_HOOK_RUNTIME: HookRuntime;
|
|
51
|
+
/**
|
|
52
|
+
* The runtime a project with this `package.json` can actually start.
|
|
53
|
+
*
|
|
54
|
+
* ๐ด THE VIGILES REPOSITORY IS NOT ITS OWN DEPENDENCY. It compiles its own hooks,
|
|
55
|
+
* and the installed path above does not exist in it: nothing puts a package into
|
|
56
|
+
* its own `node_modules`. Measured 2026-10-11: both of the repository's compiled
|
|
57
|
+
* nudges had died with MODULE_NOT_FOUND on every tool call since #260
|
|
58
|
+
* (2026-09-21), and each one's `|| exit 0` turned the death into silence โ a
|
|
59
|
+
* reminder that cannot load is skipped by design (see `hookRuntimeMissingExit`).
|
|
60
|
+
* So the package itself starts the bin it declares, which its build writes in
|
|
61
|
+
* place; the cure the notice names is that build, because `npm ci` alone does
|
|
62
|
+
* not produce `dist/`.
|
|
63
|
+
*
|
|
64
|
+
* Decided from the manifest's CONTENT (name + declared bin), never from where the
|
|
65
|
+
* running CLI happens to sit: a linked or pnpm-store copy would otherwise emit a
|
|
66
|
+
* path that is only true on the machine that ran `compile`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function hookRuntimeFor(manifest: unknown): HookRuntime;
|
|
69
|
+
/**
|
|
70
|
+
* The path token `compile` EMITS into the harness config โ anchored at the project root
|
|
71
|
+
* when the harness declares such a variable.
|
|
72
|
+
*
|
|
73
|
+
* ๐ด IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
|
|
74
|
+
* and these quotes; this one ADDS them. They are one contract read from two ends, and while
|
|
75
|
+
* the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
|
|
76
|
+
* anchored spelling, and the emitter went on writing the relative one for three more weeks.
|
|
77
|
+
*
|
|
78
|
+
* Why anchored at all, from the two measurements already in this file and in
|
|
79
|
+
* `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
|
|
80
|
+
* relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
|
|
81
|
+
* PreToolUse gate that is not a lost nudge โ a gate that cannot load must block, so the
|
|
82
|
+
* repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
|
|
83
|
+
* only, because every command was refused, including the one that repairs it.
|
|
84
|
+
*
|
|
85
|
+
* `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
|
|
86
|
+
* it is asserted directly rather than left to inspection.
|
|
87
|
+
*/
|
|
88
|
+
export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
|
|
89
|
+
/**
|
|
90
|
+
* A command token reduced to the PATH it names, so two spellings of the same
|
|
91
|
+
* hook file compare equal.
|
|
92
|
+
*
|
|
93
|
+
* ๐ด BOTH STRIPS ARE REGRESSIONS, MEASURED IN A CONSUMER REPO 2026-08-21, and
|
|
94
|
+
* they compound: a settings.json wired the Claude-Code-recommended way carries
|
|
95
|
+
* BOTH a quote and the project-dir variable, so `managesHook` saw
|
|
96
|
+
* `"$CLAUDE_PROJECT_DIR/.claude/hooks/x.hook.ts"`, canonicalized it to
|
|
97
|
+
* something under the cwd that resembles nothing, and reported "not managed by
|
|
98
|
+
* this hook". Recompiling then APPENDED its own block beside the existing one.
|
|
99
|
+
* Twelve hooks, twelve duplicates, and the duplicate is the WORSE of the two:
|
|
100
|
+
* it spells the path relative to the cwd, so it dies with exit 2 the moment the
|
|
101
|
+
* agent runs from a subdirectory โ while the healthy copy beside it keeps
|
|
102
|
+
* working, which is why this survived a full day unnoticed.
|
|
103
|
+
*
|
|
104
|
+
* - QUOTES. A path with a space MUST be quoted, so the quoted form is not an
|
|
105
|
+
* exotic spelling โ it is the correct one. Comparing it raw could never match.
|
|
106
|
+
* - the harness's PROJECT-ROOT variable (Claude Code's `$CLAUDE_PROJECT_DIR`),
|
|
107
|
+
* handed in by the caller from its layout. It is defined as the project
|
|
108
|
+
* root, which is exactly what a relative path resolves against, so stripping
|
|
109
|
+
* it makes the two spellings the same path by definition rather than by
|
|
110
|
+
* guess. `${NAME}` and `$NAME` are the same variable. Core names no harness
|
|
111
|
+
* variable itself.
|
|
112
|
+
*
|
|
113
|
+
* Nothing else is stripped. A token this function does not recognise is left
|
|
114
|
+
* alone and simply fails to match, which is the pre-existing behaviour: the
|
|
115
|
+
* cost of a miss here is a duplicate block, and the cost of an over-match is
|
|
116
|
+
* deleting a hook the user wrote themselves.
|
|
117
|
+
*/
|
|
118
|
+
export declare function bareToken(token: string, rootTokens: readonly string[]): string;
|
|
119
|
+
/** The start-of-session check line `compile` wires beside the compiled hooks. */
|
|
120
|
+
export declare function sessionCheckCommand(projectRootTokens: readonly string[] | undefined): string;
|
|
121
|
+
/**
|
|
122
|
+
* A hook source and what its last compile recorded about it. `role` comes from
|
|
123
|
+
* the stamp sidecar; a source never compiled (or compiled by a vigiles that did
|
|
124
|
+
* not record it) has none, and so has no planned line.
|
|
125
|
+
*/
|
|
126
|
+
export interface HookSourceRecord {
|
|
127
|
+
/** Repo-relative path of the hook source (`.vigiles/hooks/x.hook.mjs`). */
|
|
128
|
+
readonly ref: string;
|
|
129
|
+
readonly role?: DispatchKind;
|
|
130
|
+
}
|
|
131
|
+
/** One line `compile` would write, and the files it needs on disk. */
|
|
132
|
+
export interface PlannedHookLine {
|
|
133
|
+
readonly command: string;
|
|
134
|
+
/** Root-anchored (`${TOKEN}/โฆ`) or repo-relative paths. */
|
|
135
|
+
readonly files: readonly string[];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The lines `compile` and `init` would write in a repo, and the hook sources
|
|
139
|
+
* whose wiring `compile` owns (its merge key) โ what a reader compares a hook
|
|
140
|
+
* line against.
|
|
141
|
+
*/
|
|
142
|
+
export interface HookLinePlan {
|
|
143
|
+
readonly lines: readonly PlannedHookLine[];
|
|
144
|
+
/** Repo-relative refs of every hook source, compiled or not. */
|
|
145
|
+
readonly sources: readonly string[];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The plan for a repo, from what its files say: each hook source's role from
|
|
149
|
+
* its stamp sidecar, the runtime from `package.json`. `readText` is the
|
|
150
|
+
* engine's file port (disk or a fetched file map); `undefined` = absent.
|
|
151
|
+
*/
|
|
152
|
+
export declare function hookLinePlan(sourceRefs: readonly string[], readText: (ref: string) => string | undefined, projectRootTokens: readonly string[] | undefined): HookLinePlan;
|
|
153
|
+
/** What the plan needs: the repo's hook sources, its runtime, the harness's root variables. */
|
|
154
|
+
export interface HookPlanInput {
|
|
155
|
+
readonly sources: readonly HookSourceRecord[];
|
|
156
|
+
readonly runtime: HookRuntime;
|
|
157
|
+
readonly projectRootTokens: readonly string[] | undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Every hook line `compile` (and `init`) would write in this repo โ the same
|
|
161
|
+
* template functions the writers call, so the reader cannot drift from them.
|
|
162
|
+
*/
|
|
163
|
+
export declare function plannedHookLines(input: HookPlanInput): readonly PlannedHookLine[];
|
|
164
|
+
/**
|
|
165
|
+
* Whether a command names one of `refs` โ `compile`'s MERGE KEY, the test it
|
|
166
|
+
* uses to find the entries it owns when it rewrites a settings file. The
|
|
167
|
+
* command is split on whitespace and each word reduced by {@link bareToken};
|
|
168
|
+
* nothing is parsed as shell. `normalize` makes a word comparable to a ref
|
|
169
|
+
* (the writer and the reader each know their own root).
|
|
170
|
+
*/
|
|
171
|
+
export declare function commandNamesRef(command: string, refs: readonly string[], normalize: (word: string) => string, rootTokens: readonly string[]): boolean;
|
|
172
|
+
//# sourceMappingURL=hook-command.d.ts.map
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INSTALLED_HOOK_RUNTIME = exports.INIT_NUDGE_COMMANDS = exports.RUN_PROGRAM_KIND = exports.HOOK_RUNTIME_VERB = exports.HOOK_CHECK_REF = void 0;
|
|
4
|
+
exports.runProgramCommand = runProgramCommand;
|
|
5
|
+
exports.hookRuntimeRef = hookRuntimeRef;
|
|
6
|
+
exports.hookRuntimeFor = hookRuntimeFor;
|
|
7
|
+
exports.hookGateRef = hookGateRef;
|
|
8
|
+
exports.bareToken = bareToken;
|
|
9
|
+
exports.sessionCheckCommand = sessionCheckCommand;
|
|
10
|
+
exports.hookLinePlan = hookLinePlan;
|
|
11
|
+
exports.plannedHookLines = plannedHookLines;
|
|
12
|
+
exports.commandNamesRef = commandNamesRef;
|
|
13
|
+
/**
|
|
14
|
+
* The hook command lines vigiles WRITES, and the one way they are recognised:
|
|
15
|
+
* by IDENTITY with what `compile` would write (#361, #362).
|
|
16
|
+
*
|
|
17
|
+
* ๐ด VIGILES NEVER PARSES A HAND-WRITTEN HOOK COMMAND LINE. Two branches of
|
|
18
|
+
* this repository tried: #359 taught `resolveScript` one more shell shape
|
|
19
|
+
* (`$CLAUDE_PROJECT_DIR`), and #361's first version parsed the line as shell
|
|
20
|
+
* and kept an interpreter list to find "the script". Each shape it learned
|
|
21
|
+
* exposed the next one it did not (`env X=1 cmd`, `sh -c 'โฆ'`, `cd d && ./x`,
|
|
22
|
+
* `bash -x x.sh`), and even a perfect parse answers only "does one path exist",
|
|
23
|
+
* never "does the hook work". Both were removed (#362). The lines vigiles owns
|
|
24
|
+
* need no parse: `compile` is their only writer, so a line is `compiled`
|
|
25
|
+
* exactly when it equals the line the plan below says compile would write for
|
|
26
|
+
* one of this repo's hook sources. Everything else is hand-written, and the
|
|
27
|
+
* answer to a hand-written hook is to compile it. Whether any hook works is
|
|
28
|
+
* answered by running it (`runHook`, `vigiles test`).
|
|
29
|
+
*
|
|
30
|
+
* Enforced by `no-restricted-imports` in eslint.config.mjs: this module and the
|
|
31
|
+
* scan's hook readers may not import the shell parser.
|
|
32
|
+
*
|
|
33
|
+
* Pure and harness-agnostic: the root variables, the runtime and the hook
|
|
34
|
+
* sources come from the caller.
|
|
35
|
+
*/
|
|
36
|
+
const hook_paths_js_1 = require("./hook-paths.js");
|
|
37
|
+
const hook_program_js_1 = require("./hook-program.js");
|
|
38
|
+
/** Where `compile` writes the start-of-session check. Committed, next to the hook sources. */
|
|
39
|
+
exports.HOOK_CHECK_REF = ".vigiles/hook-check.sh";
|
|
40
|
+
/** The CLI verb every compiled hook line starts the runtime with. */
|
|
41
|
+
exports.HOOK_RUNTIME_VERB = "hook-runtime";
|
|
42
|
+
/** The runtime kind that runs a compiled hook program; its one operand is the program. */
|
|
43
|
+
exports.RUN_PROGRAM_KIND = "run-program";
|
|
44
|
+
/**
|
|
45
|
+
* The lines `init` wires for a harness with no plugin store (Codex): the
|
|
46
|
+
* shipped nudges, started through npx. Data, so the plan below knows them as
|
|
47
|
+
* vigiles's own without reading their text.
|
|
48
|
+
*/
|
|
49
|
+
exports.INIT_NUDGE_COMMANDS = {
|
|
50
|
+
evalLock: "npx --no-install vigiles hook-runtime eval-lock-nudge",
|
|
51
|
+
refs: "npx --no-install vigiles hook-runtime refs",
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The command line `compile` writes to run a hook program: the runtime started
|
|
55
|
+
* by `runnerRef`, the program at `programRef`. {@link plannedHookLines} uses it, so a reader compares a wired line with the same string.
|
|
56
|
+
*/
|
|
57
|
+
function runProgramCommand(runnerRef, programRef) {
|
|
58
|
+
return `${runnerRef} ${exports.HOOK_RUNTIME_VERB} ${exports.RUN_PROGRAM_KIND} ${programRef}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where the hook runtime lives, spelled so the shell can find it WITHOUT `npx`.
|
|
62
|
+
*
|
|
63
|
+
* ๐ด MEASURED 2026-09-19, warm cache, five runs each:
|
|
64
|
+
*
|
|
65
|
+
* node <local>/dist/cli.js hook-runtime run-program โฆ 193 ms
|
|
66
|
+
* npx vigiles hook-runtime run-program โฆ 2545 ms
|
|
67
|
+
*
|
|
68
|
+
* Thirteen times, on every tool call, because `npx` re-resolves the package on
|
|
69
|
+
* each invocation โ local, then global, then the registry. That search is the
|
|
70
|
+
* single largest cost in a hook's life; everything the runtime does inside adds
|
|
71
|
+
* up to less than a fifth of it.
|
|
72
|
+
*
|
|
73
|
+
* A harness with no project-root token gets the relative spelling, which is all
|
|
74
|
+
* it can be given โ see {@link hookGateRef} for the same fallback.
|
|
75
|
+
*/
|
|
76
|
+
function hookRuntimeRef(projectRootTokens, runtime = exports.INSTALLED_HOOK_RUNTIME) {
|
|
77
|
+
const token = projectRootTokens?.[0];
|
|
78
|
+
return token === undefined
|
|
79
|
+
? `node ${runtime.path}`
|
|
80
|
+
: `node "${token}/${runtime.path}"`;
|
|
81
|
+
}
|
|
82
|
+
/** A consumer's runtime: the vigiles it installed as a dependency. */
|
|
83
|
+
exports.INSTALLED_HOOK_RUNTIME = {
|
|
84
|
+
path: "node_modules/vigiles/dist/cli.js",
|
|
85
|
+
cure: "npm ci (or npm install)",
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* The runtime a project with this `package.json` can actually start.
|
|
89
|
+
*
|
|
90
|
+
* ๐ด THE VIGILES REPOSITORY IS NOT ITS OWN DEPENDENCY. It compiles its own hooks,
|
|
91
|
+
* and the installed path above does not exist in it: nothing puts a package into
|
|
92
|
+
* its own `node_modules`. Measured 2026-10-11: both of the repository's compiled
|
|
93
|
+
* nudges had died with MODULE_NOT_FOUND on every tool call since #260
|
|
94
|
+
* (2026-09-21), and each one's `|| exit 0` turned the death into silence โ a
|
|
95
|
+
* reminder that cannot load is skipped by design (see `hookRuntimeMissingExit`).
|
|
96
|
+
* So the package itself starts the bin it declares, which its build writes in
|
|
97
|
+
* place; the cure the notice names is that build, because `npm ci` alone does
|
|
98
|
+
* not produce `dist/`.
|
|
99
|
+
*
|
|
100
|
+
* Decided from the manifest's CONTENT (name + declared bin), never from where the
|
|
101
|
+
* running CLI happens to sit: a linked or pnpm-store copy would otherwise emit a
|
|
102
|
+
* path that is only true on the machine that ran `compile`.
|
|
103
|
+
*/
|
|
104
|
+
function hookRuntimeFor(manifest) {
|
|
105
|
+
if (fieldOf(manifest, "name") !== "vigiles")
|
|
106
|
+
return exports.INSTALLED_HOOK_RUNTIME;
|
|
107
|
+
const bin = fieldOf(manifest, "bin");
|
|
108
|
+
// npm's two spellings: `"bin": "x.js"` or `"bin": { "vigiles": "x.js" }`.
|
|
109
|
+
const declared = typeof bin === "string" ? bin : fieldOf(bin, "vigiles");
|
|
110
|
+
if (typeof declared !== "string" || declared === "")
|
|
111
|
+
return exports.INSTALLED_HOOK_RUNTIME;
|
|
112
|
+
return {
|
|
113
|
+
path: declared.replace(/^\.\//, ""),
|
|
114
|
+
cure: "npm ci && npm run build",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** `value[key]` when `value` is an object, else `undefined` โ no cast. */
|
|
118
|
+
function fieldOf(value, key) {
|
|
119
|
+
if (typeof value !== "object" || value === null)
|
|
120
|
+
return undefined;
|
|
121
|
+
const found = Object.entries(value).find(([k]) => k === key)?.[1];
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The path token `compile` EMITS into the harness config โ anchored at the project root
|
|
126
|
+
* when the harness declares such a variable.
|
|
127
|
+
*
|
|
128
|
+
* ๐ด IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
|
|
129
|
+
* and these quotes; this one ADDS them. They are one contract read from two ends, and while
|
|
130
|
+
* the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
|
|
131
|
+
* anchored spelling, and the emitter went on writing the relative one for three more weeks.
|
|
132
|
+
*
|
|
133
|
+
* Why anchored at all, from the two measurements already in this file and in
|
|
134
|
+
* `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
|
|
135
|
+
* relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
|
|
136
|
+
* PreToolUse gate that is not a lost nudge โ a gate that cannot load must block, so the
|
|
137
|
+
* repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
|
|
138
|
+
* only, because every command was refused, including the one that repairs it.
|
|
139
|
+
*
|
|
140
|
+
* `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
|
|
141
|
+
* it is asserted directly rather than left to inspection.
|
|
142
|
+
*/
|
|
143
|
+
function hookGateRef(ref, projectRootTokens) {
|
|
144
|
+
const token = projectRootTokens?.[0];
|
|
145
|
+
return token === undefined ? ref : `"${token}/${ref}"`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A command token reduced to the PATH it names, so two spellings of the same
|
|
149
|
+
* hook file compare equal.
|
|
150
|
+
*
|
|
151
|
+
* ๐ด BOTH STRIPS ARE REGRESSIONS, MEASURED IN A CONSUMER REPO 2026-08-21, and
|
|
152
|
+
* they compound: a settings.json wired the Claude-Code-recommended way carries
|
|
153
|
+
* BOTH a quote and the project-dir variable, so `managesHook` saw
|
|
154
|
+
* `"$CLAUDE_PROJECT_DIR/.claude/hooks/x.hook.ts"`, canonicalized it to
|
|
155
|
+
* something under the cwd that resembles nothing, and reported "not managed by
|
|
156
|
+
* this hook". Recompiling then APPENDED its own block beside the existing one.
|
|
157
|
+
* Twelve hooks, twelve duplicates, and the duplicate is the WORSE of the two:
|
|
158
|
+
* it spells the path relative to the cwd, so it dies with exit 2 the moment the
|
|
159
|
+
* agent runs from a subdirectory โ while the healthy copy beside it keeps
|
|
160
|
+
* working, which is why this survived a full day unnoticed.
|
|
161
|
+
*
|
|
162
|
+
* - QUOTES. A path with a space MUST be quoted, so the quoted form is not an
|
|
163
|
+
* exotic spelling โ it is the correct one. Comparing it raw could never match.
|
|
164
|
+
* - the harness's PROJECT-ROOT variable (Claude Code's `$CLAUDE_PROJECT_DIR`),
|
|
165
|
+
* handed in by the caller from its layout. It is defined as the project
|
|
166
|
+
* root, which is exactly what a relative path resolves against, so stripping
|
|
167
|
+
* it makes the two spellings the same path by definition rather than by
|
|
168
|
+
* guess. `${NAME}` and `$NAME` are the same variable. Core names no harness
|
|
169
|
+
* variable itself.
|
|
170
|
+
*
|
|
171
|
+
* Nothing else is stripped. A token this function does not recognise is left
|
|
172
|
+
* alone and simply fails to match, which is the pre-existing behaviour: the
|
|
173
|
+
* cost of a miss here is a duplicate block, and the cost of an over-match is
|
|
174
|
+
* deleting a hook the user wrote themselves.
|
|
175
|
+
*/
|
|
176
|
+
function bareToken(token, rootTokens) {
|
|
177
|
+
const unquoted = token.replace(/^["']/, "").replace(/["']$/, "");
|
|
178
|
+
const prefix = rootTokens
|
|
179
|
+
.map((t) => t.replace(/^\$\{(.+)\}$/, "$1"))
|
|
180
|
+
.flatMap((name) => [`\${${name}}`, `$${name}`])
|
|
181
|
+
.flatMap((spelled) => [`${spelled}/`, `${spelled}\\`])
|
|
182
|
+
.find((p) => unquoted.startsWith(p));
|
|
183
|
+
return prefix === undefined ? unquoted : unquoted.slice(prefix.length);
|
|
184
|
+
}
|
|
185
|
+
/** The start-of-session check line `compile` wires beside the compiled hooks. */
|
|
186
|
+
function sessionCheckCommand(projectRootTokens) {
|
|
187
|
+
return `sh ${hookGateRef(exports.HOOK_CHECK_REF, projectRootTokens)} || exit 0`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The plan for a repo, from what its files say: each hook source's role from
|
|
191
|
+
* its stamp sidecar, the runtime from `package.json`. `readText` is the
|
|
192
|
+
* engine's file port (disk or a fetched file map); `undefined` = absent.
|
|
193
|
+
*/
|
|
194
|
+
function hookLinePlan(sourceRefs, readText, projectRootTokens) {
|
|
195
|
+
const sources = sourceRefs.map((ref) => {
|
|
196
|
+
const role = fieldOf(parseJson(readText((0, hook_paths_js_1.hookStampRef)(ref))), "role");
|
|
197
|
+
return isDispatchKind(role) ? { ref, role } : { ref };
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
lines: plannedHookLines({
|
|
201
|
+
sources,
|
|
202
|
+
runtime: hookRuntimeFor(parseJson(readText("package.json"))),
|
|
203
|
+
projectRootTokens,
|
|
204
|
+
}),
|
|
205
|
+
sources: sourceRefs,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** JSON text โ value; absent or malformed โ undefined (the plan then has less). */
|
|
209
|
+
function parseJson(text) {
|
|
210
|
+
if (text === undefined)
|
|
211
|
+
return undefined;
|
|
212
|
+
try {
|
|
213
|
+
const value = JSON.parse(text);
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Every role a compiled hook can have โ a record, so a new kind is a tsc error here. */
|
|
221
|
+
const DISPATCH_KINDS = {
|
|
222
|
+
"bash-gate": true,
|
|
223
|
+
"file-gate": true,
|
|
224
|
+
"prompt-gate": true,
|
|
225
|
+
"stop-gate": true,
|
|
226
|
+
inject: true,
|
|
227
|
+
react: true,
|
|
228
|
+
};
|
|
229
|
+
function isDispatchKind(value) {
|
|
230
|
+
return (typeof value === "string" &&
|
|
231
|
+
Object.keys(DISPATCH_KINDS).some((k) => k === value));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Every hook line `compile` (and `init`) would write in this repo โ the same
|
|
235
|
+
* template functions the writers call, so the reader cannot drift from them.
|
|
236
|
+
*/
|
|
237
|
+
function plannedHookLines(input) {
|
|
238
|
+
const { sources, projectRootTokens: tokens } = input;
|
|
239
|
+
return [
|
|
240
|
+
...sources.flatMap((s) => compiledLine(s, input)),
|
|
241
|
+
{
|
|
242
|
+
command: sessionCheckCommand(tokens),
|
|
243
|
+
files: [anchored(exports.HOOK_CHECK_REF, tokens)],
|
|
244
|
+
},
|
|
245
|
+
...Object.values(exports.INIT_NUDGE_COMMANDS).map((command) => ({
|
|
246
|
+
command,
|
|
247
|
+
files: [],
|
|
248
|
+
})),
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
/** The line `compile` writes for one recorded hook source; none without a role. */
|
|
252
|
+
function compiledLine({ ref, role }, { runtime, projectRootTokens: tokens }) {
|
|
253
|
+
if (role === undefined)
|
|
254
|
+
return [];
|
|
255
|
+
const command = (0, hook_program_js_1.withRoleExit)(runProgramCommand(hookRuntimeRef(tokens, runtime), hookGateRef(ref, tokens)), role);
|
|
256
|
+
const files = [runtime.path, ref, (0, hook_paths_js_1.hookStampRef)(ref)].map((f) => anchored(f, tokens));
|
|
257
|
+
return [{ command, files }];
|
|
258
|
+
}
|
|
259
|
+
/** `rel` anchored at the harness's project root, as the emitted line spells it. */
|
|
260
|
+
function anchored(rel, projectRootTokens) {
|
|
261
|
+
const token = projectRootTokens?.[0];
|
|
262
|
+
return token === undefined ? rel : `${token}/${rel}`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Whether a command names one of `refs` โ `compile`'s MERGE KEY, the test it
|
|
266
|
+
* uses to find the entries it owns when it rewrites a settings file. The
|
|
267
|
+
* command is split on whitespace and each word reduced by {@link bareToken};
|
|
268
|
+
* nothing is parsed as shell. `normalize` makes a word comparable to a ref
|
|
269
|
+
* (the writer and the reader each know their own root).
|
|
270
|
+
*/
|
|
271
|
+
function commandNamesRef(command, refs, normalize, rootTokens) {
|
|
272
|
+
return command.split(/\s+/).some((word) => {
|
|
273
|
+
const bare = bareToken(word, rootTokens);
|
|
274
|
+
return bare !== "" && refs.includes(normalize(bare));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=hook-command.js.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did a hook's TEXT reach the model? โ the observed-output twin of
|
|
3
|
+
* `noticeDelivery` (which answers the same question for a compiled react
|
|
4
|
+
* BEFORE anything runs).
|
|
5
|
+
*
|
|
6
|
+
* ๐ด THE DEFECT THIS EXISTS FOR, MEASURED 2026-10-11 IN A DOWNSTREAM REPO. Its
|
|
7
|
+
* main knowledge-base linter ran as a `PostToolUse` hook, wrote every finding to
|
|
8
|
+
* stderr and exited 0 โ for weeks. Probe: exit 0, 0 bytes of stdout, 237 bytes
|
|
9
|
+
* of stderr. Every `runHook`-based test of it was green, because every one of
|
|
10
|
+
* them asserted on `r.stderr`, which is exactly the stream nobody reads. The
|
|
11
|
+
* host's hooks documentation is explicit:
|
|
12
|
+
*
|
|
13
|
+
* "Claude never sees stderr from a hook that exits 0. [...] To surface a
|
|
14
|
+
* warning to Claude from a `PostToolUse` or `PostToolUseFailure` hook, exit 2
|
|
15
|
+
* instead so Claude sees the stderr even though the tool already ran."
|
|
16
|
+
* โ code.claude.com/docs/en/hooks ยง Exit code 0, read 2026-10-11
|
|
17
|
+
*
|
|
18
|
+
* "The hook printed it" and "the model received it" are the repo's own
|
|
19
|
+
* `fired โ landed` split, at the unit tier. A test of a nudge must assert the
|
|
20
|
+
* second, and the second is a function of FOUR facts โ the event, the exit
|
|
21
|
+
* code, which stream carried the text, and what the harness's capability table
|
|
22
|
+
* says that event honours. This module is that function. Pure: no process, no
|
|
23
|
+
* harness literal (the table and the block code are injected), so a test and
|
|
24
|
+
* the runtime agree about delivery without running a harness.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT DELIBERATELY DOES NOT CLAIM:
|
|
27
|
+
* - an event with no row in the table is `unknown`, never `undelivered` โ the
|
|
28
|
+
* same fail-open stance as `capabilityOf`. A harness with no table at all
|
|
29
|
+
* (Codex today) can still be judged on the ONE fact its port carries, the
|
|
30
|
+
* legacy `injectableEvents`: `additionalContext` there is `delivered`, and
|
|
31
|
+
* anything else is `unknown`;
|
|
32
|
+
* - a BLOCK (a veto, or a halt of the turn) is reported as `blocked`, not as
|
|
33
|
+
* delivery. Where the block reason
|
|
34
|
+
* goes differs per event (a vetoed prompt shows it to the user only), the
|
|
35
|
+
* capability table does not record that, and a block is not a nudge โ its
|
|
36
|
+
* assertion is `assertHookBlocked`;
|
|
37
|
+
* - `systemMessage` is not judged: it is addressed to the user by design.
|
|
38
|
+
*/
|
|
39
|
+
import type { EventCapabilityTable } from "./event-capability.js";
|
|
40
|
+
/** A route by which hook text reached the model. */
|
|
41
|
+
export type DeliveryChannel =
|
|
42
|
+
/** `hookSpecificOutput.additionalContext` on an event honouring `inject`. */
|
|
43
|
+
"additional-context"
|
|
44
|
+
/** Plain (non-JSON) stdout at exit 0, on an event honouring `stdout-context`. */
|
|
45
|
+
| "plain-stdout"
|
|
46
|
+
/** stderr at the block exit code, on an event honouring `feedback`. */
|
|
47
|
+
| "exit-code-feedback"
|
|
48
|
+
/** JSON `decision: "block"` + `reason`, on an event honouring `feedback`. */
|
|
49
|
+
| "decision-reason";
|
|
50
|
+
/** Text the hook printed that reached NO channel the model reads. */
|
|
51
|
+
export interface StrandedOutput {
|
|
52
|
+
readonly stream: "stdout" | "stderr";
|
|
53
|
+
readonly bytes: number;
|
|
54
|
+
/** Why it went nowhere โ phrased as an instruction, not a verdict. */
|
|
55
|
+
readonly why: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The verdict. FIVE answers, because "the model saw nothing" has four distinct
|
|
59
|
+
* causes and a test failure must name the right one (`make-the-distinction-a-type`):
|
|
60
|
+
* the hook said nothing, it said something on a dead stream, it blocked
|
|
61
|
+
* instead, or we do not know this event.
|
|
62
|
+
*/
|
|
63
|
+
export type HookDelivery = {
|
|
64
|
+
readonly kind: "delivered";
|
|
65
|
+
readonly via: readonly DeliveryChannel[];
|
|
66
|
+
/** Output that ALSO went nowhere โ informational, not a failure. */
|
|
67
|
+
readonly stranded: readonly StrandedOutput[];
|
|
68
|
+
}
|
|
69
|
+
/** The hook vetoed the action. Not a nudge; see the module header. */
|
|
70
|
+
| {
|
|
71
|
+
readonly kind: "blocked";
|
|
72
|
+
readonly stranded: readonly StrandedOutput[];
|
|
73
|
+
}
|
|
74
|
+
/** The hook printed text and none of it reached the model. Never empty. */
|
|
75
|
+
| {
|
|
76
|
+
readonly kind: "undelivered";
|
|
77
|
+
readonly stranded: readonly StrandedOutput[];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Nothing addressed to the model: empty streams, or control-only JSON
|
|
81
|
+
* (`permissionDecision: "allow"`, a user-facing `systemMessage`).
|
|
82
|
+
*/
|
|
83
|
+
| {
|
|
84
|
+
readonly kind: "silent";
|
|
85
|
+
}
|
|
86
|
+
/** The table has no row for this event, so delivery cannot be judged. */
|
|
87
|
+
| {
|
|
88
|
+
readonly kind: "unknown";
|
|
89
|
+
readonly event: string;
|
|
90
|
+
readonly why: string;
|
|
91
|
+
};
|
|
92
|
+
/** What a finished hook process left behind โ the observable half. */
|
|
93
|
+
export interface HookEmission {
|
|
94
|
+
readonly event: string;
|
|
95
|
+
readonly exitCode: number;
|
|
96
|
+
readonly stdout: string;
|
|
97
|
+
readonly stderr: string;
|
|
98
|
+
/** stdout parsed as one JSON object, or null when it is plain text / empty. */
|
|
99
|
+
readonly json: Readonly<Record<string, unknown>> | null;
|
|
100
|
+
}
|
|
101
|
+
/** The harness facts the verdict needs โ injected, never a literal. */
|
|
102
|
+
export interface DeliveryFacts {
|
|
103
|
+
/** The dialect's capability table, when the adapter has one. */
|
|
104
|
+
readonly table: EventCapabilityTable | undefined;
|
|
105
|
+
/** The protocol's block exit code (exit 2 on both shipped harnesses). */
|
|
106
|
+
readonly blockExitCode: number;
|
|
107
|
+
/** The protocol's deny decision values (`"block"`, `"deny"`). */
|
|
108
|
+
readonly denyDecisionValues: readonly string[];
|
|
109
|
+
/** The protocol's halt-the-turn field, if it has one. */
|
|
110
|
+
readonly haltsTurnField?: string;
|
|
111
|
+
/**
|
|
112
|
+
* The legacy flat list of events honouring `additionalContext` โ the fallback
|
|
113
|
+
* for an adapter with no table. Ignored when `table` is present.
|
|
114
|
+
*/
|
|
115
|
+
readonly injectableEvents?: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Judge one finished hook run. Pure.
|
|
119
|
+
*
|
|
120
|
+
* Order matters and is the specification:
|
|
121
|
+
* 1. nothing printed โ `silent` (true on every event, known or not);
|
|
122
|
+
* 2. no table row โ `delivered` only on the one fact the legacy list proves,
|
|
123
|
+
* else `unknown`;
|
|
124
|
+
* 3. a veto on an event that honours one, or a halt on an event that
|
|
125
|
+
* honours `halt` โ `blocked`;
|
|
126
|
+
* 4. otherwise collect every channel that carried text, and every stream that
|
|
127
|
+
* carried text to no channel; at least one channel โ `delivered`. A JSON
|
|
128
|
+
* object on stdout is applied on any exit code (the host's exit-code table:
|
|
129
|
+
* valid JSON is "Success" at exit 0 and at any other code, and its fields
|
|
130
|
+
* are still read at exit 2); plain text is context only at exit 0.
|
|
131
|
+
*/
|
|
132
|
+
export declare function hookOutputDelivery(e: HookEmission, f: DeliveryFacts): HookDelivery;
|
|
133
|
+
//# sourceMappingURL=hook-delivery.d.ts.map
|