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.
- package/README.md +5 -4
- package/dist/adapters/claude-code/hook-condition.d.ts +46 -0
- package/dist/adapters/claude-code/hook-condition.js +142 -0
- package/dist/adapters/claude-code/hook-protocol.js +5 -0
- package/dist/audit-report.template.html +2 -2
- package/dist/cli.js +67 -115
- package/dist/core/bash-effects.d.ts +22 -0
- package/dist/core/bash-effects.js +10 -0
- package/dist/core/command-files.d.ts +107 -0
- package/dist/core/command-files.js +407 -0
- package/dist/core/hook-condition.d.ts +96 -0
- package/dist/core/hook-condition.js +63 -0
- package/dist/core/hook-matcher.d.ts +50 -0
- package/dist/core/hook-matcher.js +77 -2
- package/dist/core/hook-normalize.d.ts +51 -0
- package/dist/core/hook-normalize.js +61 -1
- package/dist/core/hook-program.d.ts +62 -1
- package/dist/core/hook-program.js +15 -1
- package/dist/core/hook-protocol.d.ts +16 -0
- package/dist/core/linters.js +97 -58
- package/dist/core/shell-vars.d.ts +74 -0
- package/dist/core/shell-vars.js +270 -0
- package/dist/core/skill-resources.d.ts +22 -1
- package/dist/core/skill-resources.js +2 -1
- package/dist/doc-test-script-coverage.d.ts +52 -0
- package/dist/doc-test-script-coverage.js +66 -0
- package/dist/guardrail-check.d.ts +29 -0
- package/dist/guardrail-check.js +69 -10
- package/dist/harness-assert.d.ts +8 -5
- package/dist/harness-assert.js +8 -5
- package/dist/harness-resolve-hooks.mjs +14 -37
- package/dist/hook-state-store.d.ts +143 -0
- package/dist/hook-state-store.js +241 -0
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +3 -1
- package/dist/run-hook.d.ts +33 -1
- package/dist/run-hook.js +46 -2
- package/dist/run-script.d.ts +94 -0
- package/dist/run-script.js +47 -26
- package/dist/scan-core.js +21 -3
- package/dist/score-core.d.ts +21 -1
- package/dist/score-core.js +30 -6
- package/dist/self-resolve.d.mts +20 -0
- package/dist/self-resolve.mjs +75 -0
- package/dist/spec-hooks.d.mts +10 -0
- package/dist/spec-hooks.mjs +17 -0
- package/dist/test.d.ts +5 -0
- package/dist/test.js +23 -2
- package/dist/verify-plugin-guards.d.ts +194 -0
- package/dist/verify-plugin-guards.js +822 -0
- package/package.json +1 -1
|
@@ -1,50 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Module-resolution hook for HARNESS scripts
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
*/
|
package/dist/run-hook.d.ts
CHANGED
|
@@ -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 {
|
|
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
|