vigiles 26.0.1 → 26.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +5 -4
  2. package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
  3. package/dist/adapters/claude-code/hook-condition.js +142 -0
  4. package/dist/adapters/claude-code/hook-protocol.js +5 -0
  5. package/dist/audit-report.template.html +2 -2
  6. package/dist/cli.js +67 -115
  7. package/dist/core/bash-effects.d.ts +22 -0
  8. package/dist/core/bash-effects.js +10 -0
  9. package/dist/core/command-files.d.ts +107 -0
  10. package/dist/core/command-files.js +407 -0
  11. package/dist/core/hook-condition.d.ts +96 -0
  12. package/dist/core/hook-condition.js +63 -0
  13. package/dist/core/hook-matcher.d.ts +50 -0
  14. package/dist/core/hook-matcher.js +77 -2
  15. package/dist/core/hook-normalize.d.ts +51 -0
  16. package/dist/core/hook-normalize.js +61 -1
  17. package/dist/core/hook-program.d.ts +62 -1
  18. package/dist/core/hook-program.js +15 -1
  19. package/dist/core/hook-protocol.d.ts +16 -0
  20. package/dist/core/linters.js +97 -58
  21. package/dist/core/shell-vars.d.ts +74 -0
  22. package/dist/core/shell-vars.js +270 -0
  23. package/dist/core/skill-resources.d.ts +22 -1
  24. package/dist/core/skill-resources.js +2 -1
  25. package/dist/doc-test-script-coverage.d.ts +52 -0
  26. package/dist/doc-test-script-coverage.js +66 -0
  27. package/dist/guardrail-check.d.ts +29 -0
  28. package/dist/guardrail-check.js +69 -10
  29. package/dist/harness-assert.d.ts +8 -5
  30. package/dist/harness-assert.js +8 -5
  31. package/dist/harness-resolve-hooks.mjs +14 -37
  32. package/dist/hook-state-store.d.ts +143 -0
  33. package/dist/hook-state-store.js +241 -0
  34. package/dist/hook.d.ts +3 -1
  35. package/dist/hook.js +3 -1
  36. package/dist/run-hook.d.ts +33 -1
  37. package/dist/run-hook.js +46 -2
  38. package/dist/run-script.d.ts +94 -0
  39. package/dist/run-script.js +47 -26
  40. package/dist/scan-core.js +21 -3
  41. package/dist/score-core.d.ts +21 -1
  42. package/dist/score-core.js +30 -6
  43. package/dist/self-resolve.d.mts +20 -0
  44. package/dist/self-resolve.mjs +75 -0
  45. package/dist/spec-hooks.d.mts +10 -0
  46. package/dist/spec-hooks.mjs +17 -0
  47. package/dist/test.d.ts +5 -0
  48. package/dist/test.js +23 -2
  49. package/dist/verify-plugin-guards.d.ts +194 -0
  50. package/dist/verify-plugin-guards.js +822 -0
  51. package/package.json +1 -1
@@ -1,50 +1,27 @@
1
1
  /**
2
- * Module-resolution hook for HARNESS scripts: make a bare `vigiles` import
3
- * resolve to the CLI's OWN installation.
2
+ * Module-resolution hook for HARNESS scripts (`vigiles test` / `vigiles eval`):
3
+ * make a bare `vigiles` import resolve to the CLI's OWN installation.
4
4
  *
5
- * 🔴 WHY. A harness file does `import { runHook } from "vigiles"`, so the
6
- * package has to sit in a `node_modules` Node can reach from that file. In a
7
- * repo that already has a `package.json`, the obvious way to put it there is
8
- * `npm install` in the root — which installs the whole dependency tree. Measured
9
- * by an adopter (#184): **840 packages in 2 minutes** where vigiles alone is 42
10
- * and about 90 MB; the other 798 were a model-eval framework and an agent SDK
11
- * that the gate — `lint` and `test`, both deterministic reads — never touches.
12
- * One run sat 11 minutes in that step before being cancelled. Their workaround
13
- * was installing into a directory outside the workspace and symlinking the tree
14
- * back in, which works and is not something every adopter should reinvent.
5
+ * The rescue itself — why it exists, what it refuses to touch — lives in
6
+ * `./self-resolve.mjs`, because the spec host registers the same branch from
7
+ * `./spec-hooks.mjs` and two copies of it is exactly the divergence that put
8
+ * `test` and `compile` on different answers to the same question.
15
9
  *
16
- * ⚠️ `NODE_PATH` does NOT solve this, and that was measured rather than assumed:
17
- * Node ignores it for ESM resolution, and a harness is ESM. So the only ways to
18
- * resolve a bare specifier from elsewhere are a real `node_modules` entry (the
19
- * symlink) or a resolver hook. This is the hook.
20
- *
21
- * Scope is deliberately narrow: ONLY the `vigiles` specifier and its subpaths,
22
- * and only when the normal resolution fails. A harness that has vigiles
23
- * installed locally keeps resolving to the local copy, so nothing changes for a
24
- * repo that already worked — this only fills the hole where resolution would
25
- * otherwise throw.
10
+ * What stays here is the hook PROTOCOL: try normal resolution first, and only
11
+ * consider the rescue on the way out of the failure.
26
12
  */
27
- import { createRequire } from "node:module";
28
- import { pathToFileURL } from "node:url";
29
- /** The CLI's own package root, handed in by the parent process. */
30
- const SELF = process.env.VIGILES_SELF_ROOT ?? "";
13
+ import { resolveSelfSpecifier } from "./self-resolve.mjs";
31
14
  export async function resolve(specifier, context, nextResolve) {
32
15
  try {
33
16
  return await nextResolve(specifier, context);
34
17
  }
35
18
  catch (err) {
36
- // Only rescue OUR specifier, and only after normal resolution failed, so a
37
- // locally installed vigiles always wins and no other package is affected.
38
- if (!SELF)
39
- throw err;
40
- if (specifier !== "vigiles" && !specifier.startsWith("vigiles/"))
19
+ // Only AFTER normal resolution failed, so a locally installed vigiles always
20
+ // wins and no other package is affected.
21
+ const rescued = resolveSelfSpecifier(specifier);
22
+ if (!rescued)
41
23
  throw err;
42
- const require = createRequire(pathToFileURL(`${SELF}/package.json`));
43
- // Resolve through the package's own `exports` map rather than guessing a
44
- // file path, so a subpath like `vigiles/eval` obeys the same contract it
45
- // would from a normal install.
46
- const target = require.resolve(specifier, { paths: [SELF] });
47
- return { url: pathToFileURL(target).href, shortCircuit: true };
24
+ return rescued;
48
25
  }
49
26
  }
50
27
  //# sourceMappingURL=harness-resolve-hooks.mjs.map
@@ -0,0 +1,143 @@
1
+ import { type Duration, type StateEntry, type StateFact, type StateWrite } from "./core/hook-state.js";
2
+ /**
3
+ * The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
4
+ *
5
+ * Derived from the hook's own location and never from anything the hook said, so
6
+ * a key cannot address another owner's store: hooks shipped in the same directory
7
+ * share their facts (the requirement — one hook records, another reads), a
8
+ * vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
9
+ * rather than slugging it, which keeps it injective and lets a human debugging a
10
+ * hook find the fact by walking the path they already know:
11
+ *
12
+ * .claude/hooks/calendar-sync-record.hook.ts
13
+ * → .vigiles/state/.claude/hooks/calendar.synced.json
14
+ *
15
+ * A hook outside the project (an absolute path elsewhere) falls back to a hash of
16
+ * its directory: still stable and still isolated, just not readable — which is the
17
+ * right trade for a case that should not happen in a project's own harness.
18
+ */
19
+ export declare function hookStateDir(file: string, cwd?: string): string;
20
+ /** Read one recorded fact for a hook, or `null` if it was never recorded. */
21
+ export declare function readHookState(file: string, key: string, cwd?: string): StateEntry | null;
22
+ /** Options for {@link writeHookState}. */
23
+ export interface WriteHookStateOptions {
24
+ /** The project root the store hangs off. Default `process.cwd()`. */
25
+ readonly cwd?: string;
26
+ /**
27
+ * The instant to stamp. Default: now.
28
+ *
29
+ * The runtime never passes this — a fact it records happened just now, by
30
+ * definition. It exists for {@link experimental_hookState}'s `seed`, which
31
+ * must be able to write "four days ago" for a throttle test to have anything
32
+ * to test. Threading it through the REAL writer rather than letting a test
33
+ * build its own entry is what keeps the two in step.
34
+ */
35
+ readonly at?: Date;
36
+ }
37
+ /**
38
+ * Record one fact. Atomic: written to a temp file in the same directory and
39
+ * `rename()`d over, so a concurrent reader sees the whole old entry or the whole
40
+ * new one — never one write's value with another's timestamp. Distinct keys are
41
+ * distinct files and never interact at all.
42
+ */
43
+ export declare function writeHookState(file: string, w: StateWrite, opts?: WriteHookStateOptions): void;
44
+ /**
45
+ * Options for {@link HookStateHandle.seed}.
46
+ *
47
+ * Spelled out as a three-way union rather than `{value} & SeedWhen` so the whole
48
+ * type is IN this declaration: a public signature that names a type a consumer
49
+ * cannot import is a surface you can read but not write against. The union is
50
+ * also what makes `{ ago, at }` — two disagreeing answers to "when" — a tsc
51
+ * error; `seedInstant` throws on it as well, because harness tests are `.mjs`
52
+ * and a type does not run there.
53
+ */
54
+ export type SeedStateOptions =
55
+ /** Recorded that long ago — the reason this exists (a throttle needs an OLD fact). */
56
+ {
57
+ readonly value?: string;
58
+ readonly ago: Duration;
59
+ readonly at?: never;
60
+ }
61
+ /** Recorded at exactly this instant. */
62
+ | {
63
+ readonly value?: string;
64
+ readonly at: Date;
65
+ readonly ago?: never;
66
+ }
67
+ /** Recorded just now. */
68
+ | {
69
+ readonly value?: string;
70
+ readonly ago?: never;
71
+ readonly at?: never;
72
+ };
73
+ /**
74
+ * A handle on ONE hook's recorded facts — what {@link experimental_hookState}
75
+ * returns. Every method goes through the runtime's own store functions, so a
76
+ * seeded fact is indistinguishable from one the hook recorded itself.
77
+ */
78
+ export interface HookStateHandle {
79
+ /**
80
+ * Where this hook's facts live. Exposed for a failure message ("no fact under
81
+ * …"), NOT as a path to build on — build on it and you are back to the
82
+ * hard-coded private path this handle exists to retire.
83
+ */
84
+ readonly dir: string;
85
+ /**
86
+ * Write a fact as if the hook had recorded it, then read it back through the
87
+ * real reader — so what you get is exactly what the hook will see, and a write
88
+ * that did not land cannot be mistaken for one that did.
89
+ *
90
+ * `ago` is the reason this exists: a throttle is only interesting against an
91
+ * OLD fact, and "old" is not something a test can produce by waiting.
92
+ *
93
+ * ```js
94
+ * const st = experimental_hookState(".vigiles/hooks/nag.mjs", { cwd });
95
+ * st.seed("retro.nagged", { ago: "4d" }); // → the hook speaks
96
+ * st.seed("retro.nagged", { ago: "10m" }); // → the hook stays quiet
97
+ * ```
98
+ */
99
+ seed(key: string, opts?: SeedStateOptions): StateFact;
100
+ /**
101
+ * Read a fact exactly as the hook's `e.ctx[key]` would — same `recorded` /
102
+ * `ageSeconds` / `fresherThan`. Never-recorded reads back as the total
103
+ * `Infinity` fact rather than throwing, because that is what the hook sees.
104
+ *
105
+ * Also the cheap way to drive the IN-PROCESS tier: the value it returns is a
106
+ * `StateFact`, which is what `runHookProgram(hook, event, ctx)` wants in `ctx`.
107
+ */
108
+ read(key: string): StateFact;
109
+ /**
110
+ * Forget the facts THIS hook recorded. A no-op when it recorded none.
111
+ *
112
+ * Scoped to this hook's own writes, because the store is shared per DIRECTORY
113
+ * — that sharing is what lets one hook read a fact another recorded — so a
114
+ * co-located hook's facts (and any entry with no recorded owner) are left
115
+ * untouched. When a test wants the whole store empty rather than this hook's
116
+ * share of it, give it a throwaway `cwd`: the store hangs off that root.
117
+ */
118
+ clear(): void;
119
+ }
120
+ /**
121
+ * Seed and read a compiled hook's named state from a test.
122
+ *
123
+ * @experimental — the store LAYOUT is not a stable contract (this handle exists
124
+ * so you never depend on it), and the hook vocabulary it serves is itself
125
+ * `experimental_`. See `docs/compiled-hooks.md` § Status / pending.
126
+ *
127
+ * Pass the hook file exactly as the wiring names it, and the same `cwd` the hook
128
+ * will run under — the store's location is derived from both, so a handle built
129
+ * with a different root points at a different (empty) store.
130
+ *
131
+ * ```js
132
+ * import { experimental_hookState, runHook } from "vigiles";
133
+ *
134
+ * const st = experimental_hookState(hookFile, { cwd });
135
+ * st.clear();
136
+ * st.seed("retro.nagged", { ago: "4d" });
137
+ * const r = runHook(`npx vigiles hook-runtime run-program ${hookFile}`, event, { cwd });
138
+ * ```
139
+ */
140
+ export declare function experimental_hookState(hookFile: string, opts?: {
141
+ readonly cwd?: string;
142
+ }): HookStateHandle;
143
+ //# sourceMappingURL=hook-state-store.d.ts.map
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hookStateDir = hookStateDir;
4
+ exports.readHookState = readHookState;
5
+ exports.writeHookState = writeHookState;
6
+ exports.experimental_hookState = experimental_hookState;
7
+ /**
8
+ * The named-state STORE — the one place on disk a compiled hook's facts live,
9
+ * and the one seam a test seeds them through.
10
+ *
11
+ * `src/core/hook-state.ts` is the pure MODEL of a fact (`record`, `state`,
12
+ * `stateFact`, the key charset). It reads no disk on purpose. This module is the
13
+ * other half: where a fact is written, under what name, and how a reader finds
14
+ * it again. It sits at the composition root rather than in `src/core/` because
15
+ * it touches the filesystem, and rather than in an adapter because nothing here
16
+ * is harness-specific — `.vigiles/state/` is vigiles's own directory on Claude
17
+ * Code, on Codex, and on whatever comes next.
18
+ *
19
+ * ## Why it is a module and not three private functions in `cli.ts`
20
+ *
21
+ * It WAS three private functions in `cli.ts` (`hookStateDir`, `readHookState`,
22
+ * `writeHookState`), exported nowhere and named in no `api-surface/*.api.md`.
23
+ * The consequence is quoted in `docs/compiled-hooks.md` as a reason the hook
24
+ * vocabulary still carries the `experimental_` prefix:
25
+ *
26
+ * > Testing a hook that uses named state is archaeology today. The runtime
27
+ * > derives the store's path from the hook's own location and validates the key
28
+ * > charset, so a test that wants to seed "this fact was recorded four days ago"
29
+ * > must reconstruct a private path. The dogfood repo does exactly that,
30
+ * > hard-coded, and it broke when the facts were renamed.
31
+ *
32
+ * A throttled hook's ONLY interesting behaviour is what it does with an old fact
33
+ * versus a fresh one, so a store nobody can seed is a hook nobody can test. The
34
+ * fix is not a second store for tests — that is the drift this repo's
35
+ * one-detector rule exists to forbid, and a seeder that agrees with a private
36
+ * path derivation only until someone edits the derivation is worth less than
37
+ * nothing, because it fails silently and green. {@link experimental_hookState}
38
+ * is a THIN handle over the SAME {@link writeHookState} / {@link readHookState}
39
+ * / {@link hookStateDir} the live runtime calls; there is no second
40
+ * implementation to keep in step. Same shape as `src/load-hook.ts`, which was
41
+ * lifted out of `cli.ts` for the same reason: the CLI and a test must agree, so
42
+ * they call one function.
43
+ *
44
+ * ## Why the handle is on `vigiles` and NOT on `vigiles/hook`
45
+ *
46
+ * `vigiles/hook` is the closed authoring vocabulary, and `checkHookImports`
47
+ * makes it the ONLY import a compiled hook may have. Its guarantee — stated at
48
+ * the top of `core/hook-state.ts` — is that a hook "cannot touch the filesystem:
49
+ * `record()` returns a VALUE… this API hands out no writer". Exporting a store
50
+ * writer there would hand out exactly that writer, to exactly the code the
51
+ * guarantee is about. So the seeding handle lives on the `vigiles` TEST root,
52
+ * beside `runHook`, `loadHook` and the `assertHook*` helpers, where a test can
53
+ * reach it and a hook cannot.
54
+ */
55
+ const node_fs_1 = require("node:fs");
56
+ const node_path_1 = require("node:path");
57
+ const hash_js_1 = require("./core/hash.js");
58
+ const hook_state_js_1 = require("./core/hook-state.js");
59
+ const hook_install_js_1 = require("./hook-install.js");
60
+ /**
61
+ * The directory a hook's recorded facts live in — the SCOPE of `state()`/`record()`.
62
+ *
63
+ * Derived from the hook's own location and never from anything the hook said, so
64
+ * a key cannot address another owner's store: hooks shipped in the same directory
65
+ * share their facts (the requirement — one hook records, another reads), a
66
+ * vendored plugin's hooks get their own. The layout MIRRORS the hook's directory
67
+ * rather than slugging it, which keeps it injective and lets a human debugging a
68
+ * hook find the fact by walking the path they already know:
69
+ *
70
+ * .claude/hooks/calendar-sync-record.hook.ts
71
+ * → .vigiles/state/.claude/hooks/calendar.synced.json
72
+ *
73
+ * A hook outside the project (an absolute path elsewhere) falls back to a hash of
74
+ * its directory: still stable and still isolated, just not readable — which is the
75
+ * right trade for a case that should not happen in a project's own harness.
76
+ */
77
+ function hookStateDir(file, cwd = process.cwd()) {
78
+ const dir = (0, node_path_1.dirname)((0, node_path_1.resolve)(cwd, file));
79
+ const rel = (0, node_path_1.relative)(cwd, dir);
80
+ const inside = rel !== "" && !rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel);
81
+ return (0, node_path_1.resolve)(cwd, ".vigiles/state", inside ? rel : `external-${(0, hash_js_1.sha256short)(dir)}`);
82
+ }
83
+ /** Read one recorded fact for a hook, or `null` if it was never recorded. */
84
+ function readHookState(file, key, cwd = process.cwd()) {
85
+ try {
86
+ const raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(hookStateDir(file, cwd), key + ".json"), "utf-8");
87
+ const parsed = JSON.parse(raw);
88
+ return typeof parsed.value === "string" && typeof parsed.at === "string"
89
+ ? parsed
90
+ : null;
91
+ }
92
+ catch {
93
+ // Never recorded, unreadable, or corrupt — all "no fact", which `stateFact`
94
+ // turns into an infinite age, so the reading hook SPEAKS. Failing toward
95
+ // noise is the whole point; a store problem must never look like freshness.
96
+ return null;
97
+ }
98
+ }
99
+ /**
100
+ * Record one fact. Atomic: written to a temp file in the same directory and
101
+ * `rename()`d over, so a concurrent reader sees the whole old entry or the whole
102
+ * new one — never one write's value with another's timestamp. Distinct keys are
103
+ * distinct files and never interact at all.
104
+ */
105
+ function writeHookState(file, w, opts = {}) {
106
+ const cwd = opts.cwd ?? process.cwd();
107
+ const dir = hookStateDir(file, cwd);
108
+ const target = (0, node_path_1.resolve)(dir, w.name + ".json");
109
+ const entry = {
110
+ value: w.value,
111
+ at: (opts.at ?? new Date()).toISOString(),
112
+ by: (0, hook_install_js_1.normalizeHookRef)(file, cwd),
113
+ };
114
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
115
+ const tmp = `${target}.${String(process.pid)}.tmp`;
116
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2) + "\n");
117
+ (0, node_fs_1.renameSync)(tmp, target);
118
+ }
119
+ /**
120
+ * Forget the facts THIS hook recorded, leaving a co-located hook's alone.
121
+ *
122
+ * The store is keyed by DIRECTORY, not by file, and deliberately so: that
123
+ * sharing is what lets one hook declare `state("calendar.synced")` and read a
124
+ * fact another hook in the same folder recorded. So "delete the directory" and
125
+ * "forget this hook's facts" are different operations, and the recursive
126
+ * delete this replaced was the wrong one — a test following the documented
127
+ * `st.clear()` opener reset every co-located hook, and two tests seeding
128
+ * different hooks in one directory erased each other.
129
+ *
130
+ * Ownership is {@link StateEntry.by}, which {@link writeHookState} stamps from
131
+ * the hook's own path. It is the SAME derivation {@link hookStateDir} uses
132
+ * (`resolve` then `relative`), so a handle built with a relative spelling and a
133
+ * runtime invoked with an absolute one agree — verified against the live
134
+ * runtime in both directions in the colocated test, because a scope that only
135
+ * matched the writer a test happens to use would fail silently and green.
136
+ *
137
+ * An entry we cannot READ, or one carrying no `by` at all, is left in place:
138
+ * every write this module performs stamps an owner, so an unattributed entry
139
+ * was written by something else, and deleting it is exactly the collateral
140
+ * damage being removed here. It reads back as another owner's fact — seed over
141
+ * it, or point `cwd` at a throwaway root when a completely empty store is what
142
+ * the test wants.
143
+ *
144
+ * The directory itself is left behind, possibly empty. Removing it would race
145
+ * a concurrent test writing into the same shared directory, which is the class
146
+ * of interference this function exists to stop.
147
+ */
148
+ function clearOwnState(file, cwd) {
149
+ const dir = hookStateDir(file, cwd);
150
+ const mine = (0, hook_install_js_1.normalizeHookRef)(file, cwd);
151
+ let entries;
152
+ try {
153
+ entries = (0, node_fs_1.readdirSync)(dir);
154
+ }
155
+ catch {
156
+ // Nothing was ever recorded here — the documented no-op.
157
+ return;
158
+ }
159
+ for (const name of entries) {
160
+ // A torn `<key>.json.<pid>.tmp` from an interrupted write is not an entry.
161
+ if (!name.endsWith(".json"))
162
+ continue;
163
+ const path = (0, node_path_1.resolve)(dir, name);
164
+ let owner;
165
+ try {
166
+ owner = JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8")).by;
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ if (owner === mine)
172
+ (0, node_fs_1.rmSync)(path, { force: true });
173
+ }
174
+ }
175
+ /**
176
+ * Seed and read a compiled hook's named state from a test.
177
+ *
178
+ * @experimental — the store LAYOUT is not a stable contract (this handle exists
179
+ * so you never depend on it), and the hook vocabulary it serves is itself
180
+ * `experimental_`. See `docs/compiled-hooks.md` § Status / pending.
181
+ *
182
+ * Pass the hook file exactly as the wiring names it, and the same `cwd` the hook
183
+ * will run under — the store's location is derived from both, so a handle built
184
+ * with a different root points at a different (empty) store.
185
+ *
186
+ * ```js
187
+ * import { experimental_hookState, runHook } from "vigiles";
188
+ *
189
+ * const st = experimental_hookState(hookFile, { cwd });
190
+ * st.clear();
191
+ * st.seed("retro.nagged", { ago: "4d" });
192
+ * const r = runHook(`npx vigiles hook-runtime run-program ${hookFile}`, event, { cwd });
193
+ * ```
194
+ */
195
+ function experimental_hookState(hookFile, opts = {}) {
196
+ const cwd = opts.cwd ?? process.cwd();
197
+ const dir = hookStateDir(hookFile, cwd);
198
+ const read = (key) => {
199
+ // Validate the key the way a hook's `needs` does — one validator, one
200
+ // message. A test that reads `"../settings"` should hear about it, not get
201
+ // a silent never-recorded fact back.
202
+ (0, hook_state_js_1.state)(key);
203
+ return (0, hook_state_js_1.stateFact)(readHookState(hookFile, key, cwd), Date.now());
204
+ };
205
+ return {
206
+ dir,
207
+ read,
208
+ seed(key, seedOpts = {}) {
209
+ // `record()` is the hook's OWN constructor, so the key charset is checked
210
+ // here by the same code and with the same error a hook would hit.
211
+ const w = (0, hook_state_js_1.record)(key, seedOpts.value ?? "");
212
+ writeHookState(hookFile, w, { cwd, at: seedInstant(seedOpts) });
213
+ // Read back through the real reader rather than returning what we meant to
214
+ // write: a seed that did not land must not look like one that did.
215
+ return read(key);
216
+ },
217
+ clear() {
218
+ clearOwnState(hookFile, cwd);
219
+ },
220
+ };
221
+ }
222
+ /**
223
+ * The instant a seed lands on. The `ago`/`at` exclusion is a type error AND a
224
+ * throw: harness tests are `.mjs` by convention (`dual-language-tests`), so a
225
+ * type-only guard would not run where most of these calls live.
226
+ */
227
+ function seedInstant(opts) {
228
+ if (opts.ago !== undefined && opts.at !== undefined) {
229
+ throw new hook_state_js_1.HookStateError(`pass either ago or at, not both — "${opts.ago}" and ${opts.at.toISOString()} disagree about when the fact was recorded.`);
230
+ }
231
+ if (opts.at !== undefined)
232
+ return opts.at;
233
+ if (opts.ago === undefined)
234
+ return new Date();
235
+ const seconds = (0, hook_state_js_1.durationSeconds)(opts.ago);
236
+ if (seconds === null) {
237
+ throw new hook_state_js_1.HookStateError(`invalid duration "${opts.ago}" — use <number><s|m|h|d>, e.g. "90s", "30m", "1h", "7d".`);
238
+ }
239
+ return new Date(Date.now() - seconds * 1000);
240
+ }
241
+ //# sourceMappingURL=hook-state-store.js.map
package/dist/hook.d.ts CHANGED
@@ -43,7 +43,9 @@
43
43
  * harness's DELIVERY. The delivery floor MOVED — #34692 (a subagent's tool calls
44
44
  * never reaching PreToolUse) is FIXED as of Claude Code 2.1.241, measured against
45
45
  * a stock registry install and pinned by src/subagent-delivery.test.ts, which goes
46
- * red if it regresses. What has NOT changed: a model can still route around a tool
46
+ * red if it regresses. SCOPE of that measurement: headless `claude -p` only —
47
+ * interactive is unmeasured, and depth-2 subagent nesting does not occur there at
48
+ * all. What has NOT changed: a model can still route around a tool
47
49
  * entirely (#45427 / #32376 — a Bash heredoc instead of `Write`), so a gate is a
48
50
  * strong default and is NEVER an unbypassable wall. See `docs/compiled-hooks.md`.
49
51
  */
package/dist/hook.js CHANGED
@@ -47,7 +47,9 @@ exports.leafCommandsNormalized = exports.HookStateError = exports.durationSecond
47
47
  * harness's DELIVERY. The delivery floor MOVED — #34692 (a subagent's tool calls
48
48
  * never reaching PreToolUse) is FIXED as of Claude Code 2.1.241, measured against
49
49
  * a stock registry install and pinned by src/subagent-delivery.test.ts, which goes
50
- * red if it regresses. What has NOT changed: a model can still route around a tool
50
+ * red if it regresses. SCOPE of that measurement: headless `claude -p` only —
51
+ * interactive is unmeasured, and depth-2 subagent nesting does not occur there at
52
+ * all. What has NOT changed: a model can still route around a tool
51
53
  * entirely (#45427 / #32376 — a Bash heredoc instead of `Write`), so a gate is a
52
54
  * strong default and is NEVER an unbypassable wall. See `docs/compiled-hooks.md`.
53
55
  */
@@ -45,7 +45,25 @@ export type { RunScriptOptions, ScriptRunResult, ScriptSpawnResult, ScriptSpawne
45
45
  * Options for {@link runHook} — every {@link RunScriptOptions} knob except
46
46
  * `stdin`, which the hook layer owns (it serializes the event there).
47
47
  */
48
- export type RunHookOptions = Omit<RunScriptOptions, "stdin">;
48
+ export type RunHookOptions = Omit<RunScriptOptions, "stdin"> & {
49
+ /**
50
+ * The hook's CONDITION as the harness config writes it — Claude Code's `if`,
51
+ * e.g. `"Bash(git push *--force*)"`. When the condition does not match the
52
+ * event, the harness never spawns the hook, so neither do we: the result comes
53
+ * back `ran: false`, `blocked: false`.
54
+ *
55
+ * 🔴 PASS IT WHENEVER THE HOOK YOU ARE TESTING DECLARES ONE. A conditional hook
56
+ * tested without its condition is tested as an unconditional one, and since the
57
+ * bodies of real conditional guards are unconditional denies, that reports the
58
+ * guard as blocking everything you feed it. See `core/hook-condition.ts`.
59
+ */
60
+ readonly condition?: string;
61
+ /**
62
+ * The harness whose wire protocol + condition grammar to use. Defaults to
63
+ * Claude Code, so every existing caller is unaffected.
64
+ */
65
+ readonly protocol?: HookProtocol;
66
+ };
49
67
  /** Result of {@link propertyHook}: the first shrunk counterexample, if any. */
50
68
  export interface HookPropertyResult<E> {
51
69
  readonly passed: boolean;
@@ -200,6 +218,20 @@ export interface HookRunResult extends ScriptRunResult {
200
218
  * ("approve"|"block"), else undefined.
201
219
  */
202
220
  readonly decision: HookOutput["decision"] | "allow" | "deny" | "ask" | undefined;
221
+ /**
222
+ * Whether the hook process was actually SPAWNED. False only when a declared
223
+ * condition (`RunHookOptions.condition`) did not match, meaning the harness
224
+ * would not have run it either.
225
+ *
226
+ * 🔴 READ THIS BEFORE READING `blocked`. `ran: false` and a hook that ran and
227
+ * allowed both arrive as `blocked: false`, and they are completely different
228
+ * facts — "the harness would never invoke this guard here" versus "the guard
229
+ * looked and let it through". Collapsing them is what let a conditional guard
230
+ * be reported as blocking a battery it cannot see.
231
+ */
232
+ readonly ran: boolean;
233
+ /** Why the hook ran or did not — the condition verdict, always present. */
234
+ readonly conditionReason: string;
203
235
  }
204
236
  /** Parse stdout as a hook JSON decision (pure, testable without a process). */
205
237
  export declare function parseHookOutput(stdout: string): HookOutput | null;
package/dist/run-hook.js CHANGED
@@ -7,6 +7,7 @@ exports.parseHookOutput = parseHookOutput;
7
7
  exports.decideHook = decideHook;
8
8
  exports.runHookWith = runHookWith;
9
9
  exports.runHook = runHook;
10
+ const hook_condition_js_1 = require("./core/hook-condition.js");
10
11
  const hook_protocol_js_1 = require("./adapters/claude-code/hook-protocol.js");
11
12
  const proofs_js_1 = require("./core/proofs.js");
12
13
  const hook_program_js_1 = require("./core/hook-program.js");
@@ -167,10 +168,53 @@ function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHook
167
168
  * bwrap. `runHook` is this with the real seams.
168
169
  */
169
170
  function runHookWith(command, input, opts, deps) {
171
+ const protocol = opts.protocol ?? hook_protocol_js_1.claudeCodeHookProtocol;
172
+ // The condition is decided BEFORE the spawn, because that is what the harness
173
+ // does — a non-matching `if` means the process never starts. Deciding it after
174
+ // would still report the right verdict but would run the user's hook for an
175
+ // event the harness would never have handed it.
176
+ const verdict = (0, hook_condition_js_1.decideHookCondition)(opts.condition, {
177
+ event: input.hook_event_name ?? "",
178
+ tool: input.tool_name ?? "",
179
+ input: (input.tool_input ?? {}),
180
+ }, protocol.condition);
181
+ if (!verdict.runs)
182
+ return {
183
+ ...skippedScriptResult(),
184
+ json: null,
185
+ blocked: false,
186
+ decision: undefined,
187
+ haltsTurn: false,
188
+ blockedBy: [],
189
+ ran: false,
190
+ conditionReason: verdict.why,
191
+ };
170
192
  const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
171
193
  const json = parseHookOutput(res.stdout);
172
- const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json);
173
- return { ...res, json, blocked, decision, haltsTurn, blockedBy };
194
+ const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json, protocol);
195
+ return {
196
+ ...res,
197
+ json,
198
+ blocked,
199
+ decision,
200
+ haltsTurn,
201
+ blockedBy,
202
+ ran: true,
203
+ conditionReason: verdict.why,
204
+ };
205
+ }
206
+ /**
207
+ * The `ScriptRunResult` shape for a hook that was never spawned. Exit code 0 and
208
+ * empty streams describe reality: no process existed, so the tool call proceeded.
209
+ * The fact that distinguishes this from a hook that ran and allowed is `ran`.
210
+ *
211
+ * `egressDropped` and `filesWritten` stay UNDEFINED on purpose — they mean
212
+ * "recorded nothing", and nothing was recorded because nothing ran. Filling them
213
+ * with zeroes would claim an observation that never happened, which is the same
214
+ * class of lie this whole change removes.
215
+ */
216
+ function skippedScriptResult() {
217
+ return { exitCode: 0, stdout: "", stderr: "", egress: [] };
174
218
  }
175
219
  /**
176
220
  * Run a hook command, piping `input` as JSON to its stdin, and report the exit