vigiles 15.0.3 → 15.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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * RUNTIME-OWNED NAMED STATE — the one thing a compiled hook could not do.
3
+ *
4
+ * ## In one sentence
5
+ *
6
+ * A hook names a fact; the runtime stores it; any hook in the same directory can
7
+ * declare that name in `needs` and read it back with its age.
8
+ *
9
+ * There is no "and also". Throttling is not a second feature — it is this one,
10
+ * used twice: a hook records the fact that it spoke, and reads that fact's age
11
+ * before speaking again. See "Why there is no `throttle:` field" below, which is
12
+ * the design's load-bearing decision and the one a reader should challenge first.
13
+ *
14
+ * ## The hole it fills, measured
15
+ *
16
+ * `prefer-compiled-hooks` says a hook should be a typed program the compiler can
17
+ * check. In the knowledge base this repo dogfoods on, ALL SEVEN advisory hooks
18
+ * were still hand-written shell (measured 2026-08-12), and the reason was uniform:
19
+ * every one of them both READS and WRITES a stamp file.
20
+ *
21
+ * calendar-heartbeat reads .cal-last-sync + .cal-last-nag writes .cal-last-nag
22
+ * calendar-sync-record — writes .cal-last-sync
23
+ * merge-nudge reads stamp + branch age writes stamp
24
+ * paper-status-gates reads status lines writes —
25
+ * retro-nudge reads clock + git log writes stamp
26
+ * scratchpad-guard reads artifact mtimes writes stamp
27
+ * vigiles-check reads last report writes report + stamp
28
+ *
29
+ * The vocabulary could express the READ (`needs` → `e.ctx`) and could express a
30
+ * WRITE only by shelling out through a react's `run()` — which hands the hook a
31
+ * subprocess to get a timestamp into a file. So "remind at most once an hour" was
32
+ * inexpressible, and seven hooks stayed shell.
33
+ *
34
+ * ## What is UNREPRESENTABLE afterwards, and what is merely legible
35
+ *
36
+ * The product's claim is "remove the capability, don't catch its misuse", so the
37
+ * honest accounting has two columns.
38
+ *
39
+ * Gone by construction — a hook CANNOT:
40
+ * - touch the filesystem. `record()` returns a VALUE. The hook's return type is
41
+ * data; the trusted runtime performs the write. `checkHookImports` still
42
+ * rejects every import but `vigiles/hook`, and this API hands out no writer.
43
+ * - name another owner's state. Namespaces are not in the vocabulary at all —
44
+ * {@link StateWrite} and {@link StateNeed} carry a KEY and nothing else, and
45
+ * the runtime derives the namespace from the hook's own path. There is no
46
+ * string a hook can pass to reach a sibling plugin's store.
47
+ * - escape its namespace through the key. {@link isValidStateKey} admits
48
+ * `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` and nothing else, so `../`, `/`, `.`,
49
+ * `..` and the reserved `@` are all unspellable, and {@link record} THROWS on
50
+ * a bad key rather than returning a value that fails later.
51
+ * - read a fact it did not declare — inherited from `needs`/`HookCtx`: an
52
+ * undeclared key is a `tsc` error, not an empty string at runtime.
53
+ * - silence itself by never having run. The freshness reads are TOTAL over
54
+ * "never recorded" (see the `Infinity` note on {@link StateFact.ageSeconds}).
55
+ *
56
+ * Legible but still possible — a hook CAN:
57
+ * - record a fact under a name that misdescribes what happened. Nothing in a
58
+ * runtime can decide whether "the calendar was really synced"; that is a
59
+ * claim about the world. What the design guarantees is narrower and is the
60
+ * property that was actually missing: **the framework never writes a fact on
61
+ * its own initiative.** Every entry in the store exists because some hook's
62
+ * source contains a `record("that-name")` at a site a reader can point at,
63
+ * and the entry records WHICH hook wrote it ({@link StateEntry.by}). Under
64
+ * the old shell design and under any automatic-stamp design, the write is
65
+ * implicit and there is no such site.
66
+ *
67
+ * ## Why there is no `throttle:` field
68
+ *
69
+ * A throttle field was the obvious shape and it was designed first: `throttle:
70
+ * "1h"`, the runtime reads a hidden stamp, skips while fresh, updates on emit.
71
+ * Two measurements killed it.
72
+ *
73
+ * 1. IT CHANGES BEHAVIOUR THE HOOKS DEPEND ON. An automatic stamp can only be
74
+ * written at one place — where the runtime sees the hook emit. Of the five
75
+ * hooks that want throttling, `scratchpad-guard` stamps BEFORE its own
76
+ * threshold test (it marks "I looked" at line 25 and only decides whether to
77
+ * speak at line 43), so an emit-triggered stamp silently converts "check
78
+ * hourly" into "check every turn until something is worth saying". The write
79
+ * site is a design decision per hook; a field takes it away.
80
+ *
81
+ * 2. IT IS THE EXACT DEFECT THIS FEATURE EXISTS TO RETIRE. The knowledge base ran
82
+ * a single automatic stamp for months: `calendar-heartbeat` wrote it at print
83
+ * time, so an IGNORED reminder silenced itself for an hour and a skipped sync
84
+ * became indistinguishable from a completed one. Cost, from that repo's own
85
+ * record: 28 unclosed events and six blank calendar days behind a hook that
86
+ * was firing correctly the entire time. The fix that repo reached for was to
87
+ * split the stamp in two and add a SECOND hook whose only job is to write the
88
+ * "it really happened" one. A `throttle:` field would have re-manufactured the
89
+ * stamp whose meaning was wrong, given it no name, and made it the ergonomic
90
+ * default.
91
+ *
92
+ * So the two meanings of a stamp stay apart the only way that survives contact:
93
+ * BOTH are named, and neither is automatic. "I spoke at T" is
94
+ * `record("retro.nagged")` written on the branch that speaks. "The work happened
95
+ * at T" is `record("calendar.synced")` written by the hook that WATCHED the work
96
+ * — a different hook, on a different event. A reader can see which is which by
97
+ * reading the name and the site; a framework-manufactured stamp offers neither.
98
+ *
99
+ * The cost is one line per throttled hook:
100
+ *
101
+ * needs: [state("retro.nagged")],
102
+ * react: (e) => e.ctx["retro.nagged"].fresherThan("1d")
103
+ * ? nothing()
104
+ * : notice(MESSAGE, record("retro.nagged")),
105
+ *
106
+ * and the line says out loud what the field would have hidden.
107
+ *
108
+ * ## What this SUBTRACTS
109
+ *
110
+ * - The stamp-file convention it replaces: seven bespoke `.claude/.*-last` files,
111
+ * each with its own hand-rolled read-and-clamp (`case "$v" in '' | *[!0-9]*) v=0`
112
+ * appears in three of the seven, because a raw file is untyped and every reader
113
+ * has to re-derive that a missing file means "never"). The clamp is what
114
+ * {@link StateFact} is: the parse happens once, in the runtime, typed.
115
+ * - `run()`'s side career as a writer. Today the only sanctioned way for a react
116
+ * to remember anything is `run("date +%s > .claude/.stamp")` — a subprocess, a
117
+ * shell, a path, and an effect-classification of "side-effecting" for what is
118
+ * really a variable assignment. After this, `run()` goes back to meaning what
119
+ * its doc comment says it means: invoke a real tool.
120
+ * - An asymmetry in `needs`. Gates could declare context; injects and reacts could
121
+ * not, for no reason anyone recorded. State is useless to a hook that cannot
122
+ * read it, so `needs` is now uniform across roles — one fewer special case
123
+ * rather than one more.
124
+ *
125
+ * It does NOT retire the `observe`-mode record (`.vigiles/hook-observations.jsonl`):
126
+ * that is an append-only audit log of what a gate WOULD have blocked, a different
127
+ * shape (many rows, never read back by a hook) from a named current-value fact.
128
+ *
129
+ * ## Failure modes, and whether they are loud
130
+ *
131
+ * forget to `record` → the reader's fact never freshens → it fires
132
+ * EVERY TIME. Loud (noisy), self-announcing.
133
+ * `record` the wrong key → same as forgetting. Loud.
134
+ * `record` too eagerly → the reader goes QUIET. This is the dangerous
135
+ * direction and the design does not make it
136
+ * impossible; it makes it locatable. Every entry
137
+ * carries `by`, so `cat .vigiles/state/**` names
138
+ * the hook that claimed the fact.
139
+ * invalid key → {@link record} throws, in-process, in the hook's
140
+ * own unit test — the tier this product exists to
141
+ * make cheap. A hand-built `{kind:"record",…}`
142
+ * object that bypasses the constructor is refused
143
+ * again by the runtime before the write.
144
+ * fact never recorded → age is `Infinity`, so every freshness test says
145
+ * "not fresh" and the hook SPEAKS. Fails toward
146
+ * noise, never toward silence.
147
+ *
148
+ * That last one is not a slogan; it is why the read view exposes `Infinity`
149
+ * instead of `null`. MEASURED: `null < 3600` is `true` in JavaScript (null
150
+ * numifies to 0), so the natural spelling of a freshness test — the one every
151
+ * author writes first — reads a NEVER-RECORDED fact as maximally fresh and
152
+ * silences the hook forever. That is the precise failure this whole feature was
153
+ * commissioned to end, reintroduced by a type choice. `Infinity` makes every
154
+ * comparison come out the safe way with no special case to remember.
155
+ *
156
+ * ## Alternatives rejected, with what rejected them
157
+ *
158
+ * - **Give reacts a filesystem.** Trades one unchecked capability for another and
159
+ * makes `checkHookImports` theatre: if the sanctioned API hands out a writer,
160
+ * "capability = API surface" says nothing. Concretely it also puts
161
+ * `.claude/settings.json` and `.git/hooks/*` one path string away from a guard
162
+ * that could then rewrite its own wiring. And it does not even solve the stated
163
+ * problem well: the seven shell hooks touch eight stamp paths between them and
164
+ * three re-implement the same numeric clamp, which is what a raw file interface
165
+ * COSTS rather than what it saves.
166
+ * - **Keep throttle as its own feature.** Rejected by the two measurements above.
167
+ * - **`ageSeconds: number | null`.** Rejected by `null < 3600 === true`.
168
+ * - **A separate accessor, `e.state("k")`, instead of `needs`.** Rejected: it
169
+ * would be a second way to read external facts, and it would destroy the
170
+ * property that makes `needs` worth having — the dependency is declared, so it
171
+ * is auditable from the outside and an undeclared read does not compile.
172
+ * - **One store file per HOOK** (isolation by hook rather than by directory).
173
+ * Rejected because it cannot express the requirement: the case that forced this
174
+ * feature is one hook recording a fact for a DIFFERENT hook to read tomorrow.
175
+ * - **One JSON blob per namespace.** Rejected on concurrency — see below.
176
+ *
177
+ * ## Storage, scope and concurrency
178
+ *
179
+ * One file per key, under a directory the runtime derives from the hook's own
180
+ * location: `.vigiles/state/<hook's dir>/<key>.json`. The layout mirrors the
181
+ * directory rather than slugging it, so it is injective and a human debugging a
182
+ * hook can find the fact by walking the path they already know.
183
+ *
184
+ * Scope is the hook's DIRECTORY. Hooks shipped together share their facts (which
185
+ * is the requirement); a vendored plugin's hooks live in the plugin's own
186
+ * directory and cannot see or clobber the project's. Two unrelated plugins that
187
+ * both install into `.claude/hooks/` do share — and they also share
188
+ * `settings.json` and the checkout, so they are already one trust domain.
189
+ *
190
+ * Concurrency: two hook processes can run at once, and the store is designed so
191
+ * the answer is "nothing to coordinate" rather than "acceptable loss". Distinct
192
+ * keys are distinct files and never interact — which is exactly why a single JSON
193
+ * blob was rejected, since read-modify-write on a shared blob loses a concurrent
194
+ * write silently. Same-key concurrent writes are resolved by writing a temp file
195
+ * in the same directory and `rename()`ing it over, which is atomic on POSIX: a
196
+ * reader sees the whole old entry or the whole new one, never a torn mix of one
197
+ * write's value with another's timestamp. Two writers of the same key are both
198
+ * writing "now", so either outcome is correct.
199
+ *
200
+ * Pure by construction: this module reads no disk and imports nothing. The store
201
+ * I/O lives in the trusted runtime and is injected, so the whole model is
202
+ * testable against a fake store with no filesystem.
203
+ */
204
+ /** A duration a freshness test is measured against: `"90s"`, `"30m"`, `"1h"`, `"7d"`. */
205
+ export type Duration = `${number}${"s" | "m" | "h" | "d"}`;
206
+ /**
207
+ * Seconds in a duration string, or `null` if it is not one. A bad duration must
208
+ * not quietly become 0 (which would read as "always stale" — noisy but wrong) nor
209
+ * `Infinity` (silent, the failure this feature exists to end), so callers turn
210
+ * `null` into a throw at the point the author can see it.
211
+ */
212
+ export declare function durationSeconds(d: string): number | null;
213
+ /**
214
+ * The stored shape of one named fact. `at` is ISO-8601 so the store is readable
215
+ * with `cat`; `by` names the hook that claimed it, which is the only handle a
216
+ * human has on "who decided this fact is true" (see the failure modes above).
217
+ */
218
+ export interface StateEntry {
219
+ readonly value: string;
220
+ readonly at: string;
221
+ readonly by?: string;
222
+ }
223
+ /**
224
+ * A recorded fact as a hook reads it — the typed replacement for "cat the stamp
225
+ * file and clamp whatever comes back".
226
+ */
227
+ export interface StateFact {
228
+ /** Has this key ever been recorded? The only way to distinguish "never" from "long ago". */
229
+ readonly recorded: boolean;
230
+ /** The recorded string; `""` when never recorded. */
231
+ readonly value: string;
232
+ /** ISO-8601 instant of the recording; `""` when never recorded. */
233
+ readonly at: string;
234
+ /**
235
+ * Seconds since the recording — `Infinity` when never recorded, never `null`.
236
+ *
237
+ * 🔴 THE TYPE IS THE SAFETY PROPERTY. With `number | null`, the test every
238
+ * author writes first (`age < 3600`) reads a never-recorded fact as FRESH,
239
+ * because `null < 3600` is `true` in JavaScript. A hook that has never run
240
+ * would therefore never run. With `Infinity` every comparison lands on the
241
+ * side that speaks, and there is no null case to forget.
242
+ */
243
+ readonly ageSeconds: number;
244
+ /** True iff recorded within `within`. Never-recorded → `false` (so the hook speaks). */
245
+ fresherThan(within: Duration): boolean;
246
+ /** True iff not recorded within `within`. Never-recorded → `true` (so the hook speaks). */
247
+ olderThan(within: Duration): boolean;
248
+ }
249
+ /** Thrown for a malformed duration or key — a programming error, surfaced in the hook's own test. */
250
+ export declare class HookStateError extends Error {
251
+ }
252
+ /**
253
+ * Build the read view of a stored entry. `entry === null` means the key has never
254
+ * been recorded. `nowMs` is passed in rather than read from the clock so the whole
255
+ * model stays pure and a test can pin the age exactly.
256
+ */
257
+ export declare function stateFact(entry: StateEntry | null, nowMs: number): StateFact;
258
+ export declare function isValidStateKey(key: string): boolean;
259
+ /** A declaration that the runtime should record a fact. Carries a key — never a path, never a namespace. */
260
+ export interface StateWrite {
261
+ readonly kind: "record";
262
+ readonly name: string;
263
+ readonly value: string;
264
+ }
265
+ /**
266
+ * Declare that a named fact just became true: `record("calendar.synced")`.
267
+ *
268
+ * The hook performs no write — it returns this, attached to whatever it was
269
+ * already returning, and the trusted runtime stores it. An optional `value` lets
270
+ * a hook remember WHAT as well as WHEN (`record("merge.nagged", branch)`), which
271
+ * is how a nudge avoids repeating itself about the same thing.
272
+ *
273
+ * Throws on an invalid key rather than returning a value that fails somewhere
274
+ * later: a compiled hook's decision is a pure function that its own test calls
275
+ * in-process, so this surfaces at the cheapest possible tier.
276
+ */
277
+ export declare function record(name: string, value?: string): StateWrite;
278
+ /** A `needs` entry that reads a recorded fact: `needs: [state("calendar.synced")]`. */
279
+ export interface StateNeed<Name extends string = string> {
280
+ readonly kind: "state";
281
+ readonly name: Name;
282
+ }
283
+ /**
284
+ * Declare a recorded fact as an input: `state("calendar.synced")` in `needs` makes
285
+ * `e.ctx["calendar.synced"]` a {@link StateFact}. Rides the existing `needs`
286
+ * path on purpose — a second way to read external state would give up the
287
+ * property that the dependency is declared and checkable from outside the hook.
288
+ */
289
+ export declare function state<const Name extends string>(name: Name): StateNeed<Name>;
290
+ /** True iff a `needs` entry is a {@link StateNeed}. */
291
+ export declare function isStateNeed(need: unknown): need is StateNeed;
292
+ /** True iff a value is a {@link StateWrite} (the runtime re-checks what it is handed). */
293
+ export declare function isStateWrite(w: unknown): w is StateWrite;
294
+ /**
295
+ * The writes the runtime may actually perform, given what a hook returned.
296
+ *
297
+ * Defence in depth, and the threat is specific: {@link record} throws on a bad
298
+ * key, but nothing stops a hook returning a hand-built `{kind:"record", name:
299
+ * "../../settings"}` object literal that never went through the constructor.
300
+ * Everything that is not a well-formed write with a valid key is dropped here,
301
+ * before any path is computed from it.
302
+ */
303
+ export declare function admissibleWrites(writes: readonly unknown[]): {
304
+ readonly ok: readonly StateWrite[];
305
+ readonly refused: readonly string[];
306
+ };
307
+ //# sourceMappingURL=hook-state.d.ts.map
@@ -0,0 +1,349 @@
1
+ "use strict";
2
+ /**
3
+ * RUNTIME-OWNED NAMED STATE — the one thing a compiled hook could not do.
4
+ *
5
+ * ## In one sentence
6
+ *
7
+ * A hook names a fact; the runtime stores it; any hook in the same directory can
8
+ * declare that name in `needs` and read it back with its age.
9
+ *
10
+ * There is no "and also". Throttling is not a second feature — it is this one,
11
+ * used twice: a hook records the fact that it spoke, and reads that fact's age
12
+ * before speaking again. See "Why there is no `throttle:` field" below, which is
13
+ * the design's load-bearing decision and the one a reader should challenge first.
14
+ *
15
+ * ## The hole it fills, measured
16
+ *
17
+ * `prefer-compiled-hooks` says a hook should be a typed program the compiler can
18
+ * check. In the knowledge base this repo dogfoods on, ALL SEVEN advisory hooks
19
+ * were still hand-written shell (measured 2026-08-12), and the reason was uniform:
20
+ * every one of them both READS and WRITES a stamp file.
21
+ *
22
+ * calendar-heartbeat reads .cal-last-sync + .cal-last-nag writes .cal-last-nag
23
+ * calendar-sync-record — writes .cal-last-sync
24
+ * merge-nudge reads stamp + branch age writes stamp
25
+ * paper-status-gates reads status lines writes —
26
+ * retro-nudge reads clock + git log writes stamp
27
+ * scratchpad-guard reads artifact mtimes writes stamp
28
+ * vigiles-check reads last report writes report + stamp
29
+ *
30
+ * The vocabulary could express the READ (`needs` → `e.ctx`) and could express a
31
+ * WRITE only by shelling out through a react's `run()` — which hands the hook a
32
+ * subprocess to get a timestamp into a file. So "remind at most once an hour" was
33
+ * inexpressible, and seven hooks stayed shell.
34
+ *
35
+ * ## What is UNREPRESENTABLE afterwards, and what is merely legible
36
+ *
37
+ * The product's claim is "remove the capability, don't catch its misuse", so the
38
+ * honest accounting has two columns.
39
+ *
40
+ * Gone by construction — a hook CANNOT:
41
+ * - touch the filesystem. `record()` returns a VALUE. The hook's return type is
42
+ * data; the trusted runtime performs the write. `checkHookImports` still
43
+ * rejects every import but `vigiles/hook`, and this API hands out no writer.
44
+ * - name another owner's state. Namespaces are not in the vocabulary at all —
45
+ * {@link StateWrite} and {@link StateNeed} carry a KEY and nothing else, and
46
+ * the runtime derives the namespace from the hook's own path. There is no
47
+ * string a hook can pass to reach a sibling plugin's store.
48
+ * - escape its namespace through the key. {@link isValidStateKey} admits
49
+ * `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` and nothing else, so `../`, `/`, `.`,
50
+ * `..` and the reserved `@` are all unspellable, and {@link record} THROWS on
51
+ * a bad key rather than returning a value that fails later.
52
+ * - read a fact it did not declare — inherited from `needs`/`HookCtx`: an
53
+ * undeclared key is a `tsc` error, not an empty string at runtime.
54
+ * - silence itself by never having run. The freshness reads are TOTAL over
55
+ * "never recorded" (see the `Infinity` note on {@link StateFact.ageSeconds}).
56
+ *
57
+ * Legible but still possible — a hook CAN:
58
+ * - record a fact under a name that misdescribes what happened. Nothing in a
59
+ * runtime can decide whether "the calendar was really synced"; that is a
60
+ * claim about the world. What the design guarantees is narrower and is the
61
+ * property that was actually missing: **the framework never writes a fact on
62
+ * its own initiative.** Every entry in the store exists because some hook's
63
+ * source contains a `record("that-name")` at a site a reader can point at,
64
+ * and the entry records WHICH hook wrote it ({@link StateEntry.by}). Under
65
+ * the old shell design and under any automatic-stamp design, the write is
66
+ * implicit and there is no such site.
67
+ *
68
+ * ## Why there is no `throttle:` field
69
+ *
70
+ * A throttle field was the obvious shape and it was designed first: `throttle:
71
+ * "1h"`, the runtime reads a hidden stamp, skips while fresh, updates on emit.
72
+ * Two measurements killed it.
73
+ *
74
+ * 1. IT CHANGES BEHAVIOUR THE HOOKS DEPEND ON. An automatic stamp can only be
75
+ * written at one place — where the runtime sees the hook emit. Of the five
76
+ * hooks that want throttling, `scratchpad-guard` stamps BEFORE its own
77
+ * threshold test (it marks "I looked" at line 25 and only decides whether to
78
+ * speak at line 43), so an emit-triggered stamp silently converts "check
79
+ * hourly" into "check every turn until something is worth saying". The write
80
+ * site is a design decision per hook; a field takes it away.
81
+ *
82
+ * 2. IT IS THE EXACT DEFECT THIS FEATURE EXISTS TO RETIRE. The knowledge base ran
83
+ * a single automatic stamp for months: `calendar-heartbeat` wrote it at print
84
+ * time, so an IGNORED reminder silenced itself for an hour and a skipped sync
85
+ * became indistinguishable from a completed one. Cost, from that repo's own
86
+ * record: 28 unclosed events and six blank calendar days behind a hook that
87
+ * was firing correctly the entire time. The fix that repo reached for was to
88
+ * split the stamp in two and add a SECOND hook whose only job is to write the
89
+ * "it really happened" one. A `throttle:` field would have re-manufactured the
90
+ * stamp whose meaning was wrong, given it no name, and made it the ergonomic
91
+ * default.
92
+ *
93
+ * So the two meanings of a stamp stay apart the only way that survives contact:
94
+ * BOTH are named, and neither is automatic. "I spoke at T" is
95
+ * `record("retro.nagged")` written on the branch that speaks. "The work happened
96
+ * at T" is `record("calendar.synced")` written by the hook that WATCHED the work
97
+ * — a different hook, on a different event. A reader can see which is which by
98
+ * reading the name and the site; a framework-manufactured stamp offers neither.
99
+ *
100
+ * The cost is one line per throttled hook:
101
+ *
102
+ * needs: [state("retro.nagged")],
103
+ * react: (e) => e.ctx["retro.nagged"].fresherThan("1d")
104
+ * ? nothing()
105
+ * : notice(MESSAGE, record("retro.nagged")),
106
+ *
107
+ * and the line says out loud what the field would have hidden.
108
+ *
109
+ * ## What this SUBTRACTS
110
+ *
111
+ * - The stamp-file convention it replaces: seven bespoke `.claude/.*-last` files,
112
+ * each with its own hand-rolled read-and-clamp (`case "$v" in '' | *[!0-9]*) v=0`
113
+ * appears in three of the seven, because a raw file is untyped and every reader
114
+ * has to re-derive that a missing file means "never"). The clamp is what
115
+ * {@link StateFact} is: the parse happens once, in the runtime, typed.
116
+ * - `run()`'s side career as a writer. Today the only sanctioned way for a react
117
+ * to remember anything is `run("date +%s > .claude/.stamp")` — a subprocess, a
118
+ * shell, a path, and an effect-classification of "side-effecting" for what is
119
+ * really a variable assignment. After this, `run()` goes back to meaning what
120
+ * its doc comment says it means: invoke a real tool.
121
+ * - An asymmetry in `needs`. Gates could declare context; injects and reacts could
122
+ * not, for no reason anyone recorded. State is useless to a hook that cannot
123
+ * read it, so `needs` is now uniform across roles — one fewer special case
124
+ * rather than one more.
125
+ *
126
+ * It does NOT retire the `observe`-mode record (`.vigiles/hook-observations.jsonl`):
127
+ * that is an append-only audit log of what a gate WOULD have blocked, a different
128
+ * shape (many rows, never read back by a hook) from a named current-value fact.
129
+ *
130
+ * ## Failure modes, and whether they are loud
131
+ *
132
+ * forget to `record` → the reader's fact never freshens → it fires
133
+ * EVERY TIME. Loud (noisy), self-announcing.
134
+ * `record` the wrong key → same as forgetting. Loud.
135
+ * `record` too eagerly → the reader goes QUIET. This is the dangerous
136
+ * direction and the design does not make it
137
+ * impossible; it makes it locatable. Every entry
138
+ * carries `by`, so `cat .vigiles/state/**` names
139
+ * the hook that claimed the fact.
140
+ * invalid key → {@link record} throws, in-process, in the hook's
141
+ * own unit test — the tier this product exists to
142
+ * make cheap. A hand-built `{kind:"record",…}`
143
+ * object that bypasses the constructor is refused
144
+ * again by the runtime before the write.
145
+ * fact never recorded → age is `Infinity`, so every freshness test says
146
+ * "not fresh" and the hook SPEAKS. Fails toward
147
+ * noise, never toward silence.
148
+ *
149
+ * That last one is not a slogan; it is why the read view exposes `Infinity`
150
+ * instead of `null`. MEASURED: `null < 3600` is `true` in JavaScript (null
151
+ * numifies to 0), so the natural spelling of a freshness test — the one every
152
+ * author writes first — reads a NEVER-RECORDED fact as maximally fresh and
153
+ * silences the hook forever. That is the precise failure this whole feature was
154
+ * commissioned to end, reintroduced by a type choice. `Infinity` makes every
155
+ * comparison come out the safe way with no special case to remember.
156
+ *
157
+ * ## Alternatives rejected, with what rejected them
158
+ *
159
+ * - **Give reacts a filesystem.** Trades one unchecked capability for another and
160
+ * makes `checkHookImports` theatre: if the sanctioned API hands out a writer,
161
+ * "capability = API surface" says nothing. Concretely it also puts
162
+ * `.claude/settings.json` and `.git/hooks/*` one path string away from a guard
163
+ * that could then rewrite its own wiring. And it does not even solve the stated
164
+ * problem well: the seven shell hooks touch eight stamp paths between them and
165
+ * three re-implement the same numeric clamp, which is what a raw file interface
166
+ * COSTS rather than what it saves.
167
+ * - **Keep throttle as its own feature.** Rejected by the two measurements above.
168
+ * - **`ageSeconds: number | null`.** Rejected by `null < 3600 === true`.
169
+ * - **A separate accessor, `e.state("k")`, instead of `needs`.** Rejected: it
170
+ * would be a second way to read external facts, and it would destroy the
171
+ * property that makes `needs` worth having — the dependency is declared, so it
172
+ * is auditable from the outside and an undeclared read does not compile.
173
+ * - **One store file per HOOK** (isolation by hook rather than by directory).
174
+ * Rejected because it cannot express the requirement: the case that forced this
175
+ * feature is one hook recording a fact for a DIFFERENT hook to read tomorrow.
176
+ * - **One JSON blob per namespace.** Rejected on concurrency — see below.
177
+ *
178
+ * ## Storage, scope and concurrency
179
+ *
180
+ * One file per key, under a directory the runtime derives from the hook's own
181
+ * location: `.vigiles/state/<hook's dir>/<key>.json`. The layout mirrors the
182
+ * directory rather than slugging it, so it is injective and a human debugging a
183
+ * hook can find the fact by walking the path they already know.
184
+ *
185
+ * Scope is the hook's DIRECTORY. Hooks shipped together share their facts (which
186
+ * is the requirement); a vendored plugin's hooks live in the plugin's own
187
+ * directory and cannot see or clobber the project's. Two unrelated plugins that
188
+ * both install into `.claude/hooks/` do share — and they also share
189
+ * `settings.json` and the checkout, so they are already one trust domain.
190
+ *
191
+ * Concurrency: two hook processes can run at once, and the store is designed so
192
+ * the answer is "nothing to coordinate" rather than "acceptable loss". Distinct
193
+ * keys are distinct files and never interact — which is exactly why a single JSON
194
+ * blob was rejected, since read-modify-write on a shared blob loses a concurrent
195
+ * write silently. Same-key concurrent writes are resolved by writing a temp file
196
+ * in the same directory and `rename()`ing it over, which is atomic on POSIX: a
197
+ * reader sees the whole old entry or the whole new one, never a torn mix of one
198
+ * write's value with another's timestamp. Two writers of the same key are both
199
+ * writing "now", so either outcome is correct.
200
+ *
201
+ * Pure by construction: this module reads no disk and imports nothing. The store
202
+ * I/O lives in the trusted runtime and is injected, so the whole model is
203
+ * testable against a fake store with no filesystem.
204
+ */
205
+ Object.defineProperty(exports, "__esModule", { value: true });
206
+ exports.HookStateError = void 0;
207
+ exports.durationSeconds = durationSeconds;
208
+ exports.stateFact = stateFact;
209
+ exports.isValidStateKey = isValidStateKey;
210
+ exports.record = record;
211
+ exports.state = state;
212
+ exports.isStateNeed = isStateNeed;
213
+ exports.isStateWrite = isStateWrite;
214
+ exports.admissibleWrites = admissibleWrites;
215
+ const UNIT_SECONDS = {
216
+ s: 1,
217
+ m: 60,
218
+ h: 3600,
219
+ d: 86400,
220
+ };
221
+ /**
222
+ * Seconds in a duration string, or `null` if it is not one. A bad duration must
223
+ * not quietly become 0 (which would read as "always stale" — noisy but wrong) nor
224
+ * `Infinity` (silent, the failure this feature exists to end), so callers turn
225
+ * `null` into a throw at the point the author can see it.
226
+ */
227
+ function durationSeconds(d) {
228
+ const m = /^(\d+(?:\.\d+)?)([smhd])$/.exec(d);
229
+ if (m === null)
230
+ return null;
231
+ const unit = UNIT_SECONDS[m[2]];
232
+ if (unit === undefined)
233
+ return null;
234
+ return Number(m[1]) * unit;
235
+ }
236
+ /** Thrown for a malformed duration or key — a programming error, surfaced in the hook's own test. */
237
+ class HookStateError extends Error {
238
+ }
239
+ exports.HookStateError = HookStateError;
240
+ function secondsOrThrow(within) {
241
+ const s = durationSeconds(within);
242
+ if (s === null) {
243
+ throw new HookStateError(`invalid duration "${within}" — use <number><s|m|h|d>, e.g. "90s", "30m", "1h", "7d".`);
244
+ }
245
+ return s;
246
+ }
247
+ /**
248
+ * Build the read view of a stored entry. `entry === null` means the key has never
249
+ * been recorded. `nowMs` is passed in rather than read from the clock so the whole
250
+ * model stays pure and a test can pin the age exactly.
251
+ */
252
+ function stateFact(entry, nowMs) {
253
+ const parsed = entry === null ? NaN : Date.parse(entry.at);
254
+ // An unparseable `at` is corruption, and it must fail toward NOISE: treat the
255
+ // key as never recorded rather than as recorded-just-now, which would silence
256
+ // a throttled hook for a window with no way to notice.
257
+ const recorded = entry !== null && !Number.isNaN(parsed);
258
+ const ageSeconds = recorded ? Math.max(0, (nowMs - parsed) / 1000) : Infinity;
259
+ return {
260
+ recorded,
261
+ value: recorded ? entry.value : "",
262
+ at: recorded ? entry.at : "",
263
+ ageSeconds,
264
+ fresherThan: (within) => ageSeconds < secondsOrThrow(within),
265
+ olderThan: (within) => ageSeconds >= secondsOrThrow(within),
266
+ };
267
+ }
268
+ /**
269
+ * Keys a hook may name. Deliberately narrow: it must be a safe path segment on
270
+ * every filesystem, greppable, and unable to reach out of its directory.
271
+ *
272
+ * The leading-alphanumeric requirement is what does the security work — it makes
273
+ * `.`, `..`, `.hidden` and the reserved `@` unspellable in one rule, rather than
274
+ * as a list of special cases someone extends later and gets wrong. There is no
275
+ * `@` namespace in this design (no automatic stamps exist to protect), but the
276
+ * character stays reserved so that adding one later cannot collide with a key
277
+ * some hook already records.
278
+ */
279
+ const STATE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
280
+ function isValidStateKey(key) {
281
+ return STATE_KEY.test(key);
282
+ }
283
+ /**
284
+ * Declare that a named fact just became true: `record("calendar.synced")`.
285
+ *
286
+ * The hook performs no write — it returns this, attached to whatever it was
287
+ * already returning, and the trusted runtime stores it. An optional `value` lets
288
+ * a hook remember WHAT as well as WHEN (`record("merge.nagged", branch)`), which
289
+ * is how a nudge avoids repeating itself about the same thing.
290
+ *
291
+ * Throws on an invalid key rather than returning a value that fails somewhere
292
+ * later: a compiled hook's decision is a pure function that its own test calls
293
+ * in-process, so this surfaces at the cheapest possible tier.
294
+ */
295
+ function record(name, value = "") {
296
+ if (!isValidStateKey(name)) {
297
+ throw new HookStateError(`invalid state key "${name}" — must match ${String(STATE_KEY)} ` +
298
+ `(letters, digits, dot, dash, underscore; must start with a letter or digit). ` +
299
+ `A key is a name, not a path: the runtime chooses where it is stored.`);
300
+ }
301
+ return { kind: "record", name, value };
302
+ }
303
+ /**
304
+ * Declare a recorded fact as an input: `state("calendar.synced")` in `needs` makes
305
+ * `e.ctx["calendar.synced"]` a {@link StateFact}. Rides the existing `needs`
306
+ * path on purpose — a second way to read external state would give up the
307
+ * property that the dependency is declared and checkable from outside the hook.
308
+ */
309
+ function state(name) {
310
+ if (!isValidStateKey(name)) {
311
+ throw new HookStateError(`invalid state key "${name}" — must match ${String(STATE_KEY)}.`);
312
+ }
313
+ return { kind: "state", name };
314
+ }
315
+ /** True iff a `needs` entry is a {@link StateNeed}. */
316
+ function isStateNeed(need) {
317
+ return (typeof need === "object" &&
318
+ need !== null &&
319
+ need.kind === "state");
320
+ }
321
+ /** True iff a value is a {@link StateWrite} (the runtime re-checks what it is handed). */
322
+ function isStateWrite(w) {
323
+ return (typeof w === "object" &&
324
+ w !== null &&
325
+ w.kind === "record" &&
326
+ typeof w.name === "string" &&
327
+ typeof w.value === "string");
328
+ }
329
+ /**
330
+ * The writes the runtime may actually perform, given what a hook returned.
331
+ *
332
+ * Defence in depth, and the threat is specific: {@link record} throws on a bad
333
+ * key, but nothing stops a hook returning a hand-built `{kind:"record", name:
334
+ * "../../settings"}` object literal that never went through the constructor.
335
+ * Everything that is not a well-formed write with a valid key is dropped here,
336
+ * before any path is computed from it.
337
+ */
338
+ function admissibleWrites(writes) {
339
+ const ok = [];
340
+ const refused = [];
341
+ for (const w of writes) {
342
+ if (isStateWrite(w) && isValidStateKey(w.name))
343
+ ok.push(w);
344
+ else
345
+ refused.push(isStateWrite(w) ? w.name : JSON.stringify(w));
346
+ }
347
+ return { ok, refused };
348
+ }
349
+ //# sourceMappingURL=hook-state.js.map