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.
Files changed (102) hide show
  1. package/dist/adapters/claude-code/event-capability.js +24 -8
  2. package/dist/adapters/claude-code/hook-protocol.js +2 -1
  3. package/dist/adapters/codex/hook-protocol.js +2 -1
  4. package/dist/cli-flag-check.d.ts +36 -0
  5. package/dist/cli-flag-check.js +72 -1
  6. package/dist/cli-main.d.ts +2 -2
  7. package/dist/cli-main.js +548 -379
  8. package/dist/core/bash-effects.d.ts +0 -57
  9. package/dist/core/bash-effects.js +0 -147
  10. package/dist/core/compile-generator.d.ts +6 -6
  11. package/dist/core/compile-generator.js +4 -4
  12. package/dist/core/compile.d.ts +72 -8
  13. package/dist/core/compile.js +276 -133
  14. package/dist/core/config-schema.d.ts +40 -10
  15. package/dist/core/config-schema.js +139 -16
  16. package/dist/core/coverage.d.ts +16 -0
  17. package/dist/core/coverage.js +22 -0
  18. package/dist/core/doc-refs.d.ts +3 -2
  19. package/dist/core/doc-refs.js +2 -2
  20. package/dist/core/enforce-marker.d.ts +28 -0
  21. package/dist/core/enforce-marker.js +34 -0
  22. package/dist/core/eslint-inventory.d.ts +96 -0
  23. package/dist/core/eslint-inventory.js +251 -0
  24. package/dist/core/event-capability.d.ts +5 -1
  25. package/dist/core/frame.d.ts +43 -2
  26. package/dist/core/frame.js +85 -2
  27. package/dist/core/generate-schema.d.ts +3 -2
  28. package/dist/core/generate-schema.js +2 -2
  29. package/dist/core/generate-types.d.ts +11 -2
  30. package/dist/core/generate-types.js +12 -5
  31. package/dist/core/hook-command.d.ts +172 -0
  32. package/dist/core/hook-command.js +277 -0
  33. package/dist/core/hook-delivery.d.ts +133 -0
  34. package/dist/core/hook-delivery.js +150 -0
  35. package/dist/core/hook-paths.d.ts +14 -0
  36. package/dist/core/hook-paths.js +22 -0
  37. package/dist/core/hook-program.d.ts +4 -4
  38. package/dist/core/hook-program.js +13 -25
  39. package/dist/core/hook-state.d.ts +1 -1
  40. package/dist/core/hook-state.js +1 -1
  41. package/dist/core/instruction-remedies.d.ts +35 -0
  42. package/dist/core/instruction-remedies.js +70 -1
  43. package/dist/core/integrity-header.d.ts +54 -0
  44. package/dist/core/integrity-header.js +90 -0
  45. package/dist/core/integrity.d.ts +1 -25
  46. package/dist/core/integrity.js +16 -74
  47. package/dist/core/linter-adapter.d.ts +27 -3
  48. package/dist/core/linters.d.ts +8 -1
  49. package/dist/core/linters.js +116 -81
  50. package/dist/core/orphans.d.ts +3 -5
  51. package/dist/core/orphans.js +9 -10
  52. package/dist/core/presets.d.ts +19 -7
  53. package/dist/core/presets.js +19 -6
  54. package/dist/core/rule-catalog.d.ts +11 -12
  55. package/dist/core/rule-catalog.js +25 -94
  56. package/dist/core/rule-meta.d.ts +12 -0
  57. package/dist/core/rule-meta.js +47 -10
  58. package/dist/core/source-refs.d.ts +3 -23
  59. package/dist/core/source-refs.js +3 -27
  60. package/dist/core/test-utils.d.ts +13 -0
  61. package/dist/core/test-utils.js +40 -1
  62. package/dist/core/types.d.ts +18 -26
  63. package/dist/core/validate.d.ts +1 -2
  64. package/dist/core/validate.js +60 -47
  65. package/dist/coverage-evidence.js +2 -3
  66. package/dist/deprecated-key-files.d.ts +6 -0
  67. package/dist/deprecated-key-files.js +7 -6
  68. package/dist/exclude.d.ts +3 -1
  69. package/dist/exclude.js +9 -33
  70. package/dist/harness-assert.d.ts +18 -0
  71. package/dist/harness-assert.js +38 -0
  72. package/dist/hook-check-install.js +15 -6
  73. package/dist/hook-check.d.ts +1 -3
  74. package/dist/hook-check.js +3 -5
  75. package/dist/hook-install.d.ts +7 -41
  76. package/dist/hook-install.js +29 -96
  77. package/dist/hook-runtime.js +2 -1
  78. package/dist/load-hook.d.ts +3 -2
  79. package/dist/load-hook.js +28 -6
  80. package/dist/overrides.d.ts +25 -0
  81. package/dist/overrides.js +51 -0
  82. package/dist/plugin-loader.d.ts +23 -0
  83. package/dist/plugin-loader.js +42 -12
  84. package/dist/root-globs.d.ts +9 -0
  85. package/dist/root-globs.js +48 -0
  86. package/dist/rule-routing.js +6 -3
  87. package/dist/run-hook.d.ts +26 -2
  88. package/dist/run-hook.js +48 -12
  89. package/dist/scan-core.d.ts +96 -20
  90. package/dist/scan-core.js +171 -176
  91. package/dist/scan-files.d.ts +0 -8
  92. package/dist/scan-files.js +78 -20
  93. package/dist/scan.d.ts +12 -12
  94. package/dist/scan.js +33 -10
  95. package/dist/setup-plan.d.ts +15 -9
  96. package/dist/setup-plan.js +26 -18
  97. package/dist/test-coverage.d.ts +3 -8
  98. package/dist/test-coverage.js +6 -6
  99. package/dist/test.d.ts +1 -1
  100. package/dist/vigilesrc.schema.json +1329 -118
  101. package/package.json +1 -1
  102. 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