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.
- package/dist/cli.js +108 -10
- package/dist/core/bash-effects.d.ts +20 -0
- package/dist/core/bash-effects.js +41 -1
- package/dist/core/hook-program.d.ts +167 -28
- package/dist/core/hook-program.js +251 -37
- package/dist/core/hook-providers.d.ts +22 -5
- package/dist/core/hook-providers.js +13 -1
- package/dist/core/hook-state.d.ts +307 -0
- package/dist/core/hook-state.js +349 -0
- package/dist/core/sidecar.d.ts +16 -0
- package/dist/core/sidecar.js +23 -8
- package/dist/hook.d.ts +3 -1
- package/dist/hook.js +18 -1
- package/package.json +1 -1
|
@@ -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
|