33//
44// renamed-from: bundle-stale-guard
55//
6- // Mirrors extension-build-current-reminder. Fires after an Edit/Write whose
7- // path is a hook-bundle SOURCE: the `_dispatch/` dispatcher, the generated
8- // `dispatch-table.mts`, any bundled hook's `index.mts`, or anything under
9- // `_shared/`. When the edited source is NEWER than the built
10- // `_dist/bundle.cjs`, the bundle is stale and the operator is reminded to
11- // rebuild it with `node scripts/fleet/build-hook-bundle.mts`.
6+ // Fires after an Edit/Write whose path is a hook-bundle SOURCE: the
7+ // `_dispatch/` dispatcher, the generated `dispatch-table.mts`, any bundled
8+ // hook's `index.mts`, or anything under `_shared/`. When the edited source is
9+ // NEWER than the built `_dist/bundle.cjs`, the bundle is stale and the
10+ // operator is reminded to rebuild it with
11+ // `node scripts/fleet/build-hook-bundle.mts`.
1212//
13- // The hook is a REMINDER, never a block: it only writes to stderr and always
14- // exits 0. PostToolUse can't reject the prior tool call anyway.
13+ // The hook is a REMINDER, never a block: it returns a notify verdict, so the
14+ // tool call always proceeds. PostToolUse can't reject the prior tool call
15+ // anyway.
1516//
16- // Bypass: `Allow hook-bundle-current bypass` (silences the reminder when the
17- // rebuild is genuinely deferred). See docs/agents.md/fleet/hook-bundle.md.
17+ // Bypass: the `hook-bundle-current` slug is declared as `bypass` metadata on
18+ // defineHook — the framework wires phrase detection and the uniform footer
19+ // from that one array, so typing the phrase silences the reminder when the
20+ // rebuild is genuinely deferred. See docs/agents.md/fleet/hook-bundle.md.
1821
1922import { existsSync , statSync } from 'node:fs'
2023import path from 'node:path'
21- import process from 'node:process'
2224
2325import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'
2426
25- import { isHookEntrypoint } from '../_shared/entrypoint.mts'
26- import { bypassPhrasePresent } from '../_shared/transcript.mts'
27+ import { defineHook , notify , runHook } from '../_shared/guard.mts'
28+ import type { GuardResult } from '../_shared/guard.mts'
29+ import { readFilePath } from '../_shared/payload.mts'
30+ import type { ToolCallPayload } from '../_shared/payload.mts'
2731import { resolveProjectDir } from '../_shared/project-dir.mts'
2832
29- export interface BundleStalePayload {
30- readonly cwd ? : string | undefined
31- readonly hook_event_name ? : string | undefined
32- readonly tool_input ?: { readonly file_path ?: unknown | undefined } | undefined
33- readonly tool_name ?: string | undefined
34- readonly transcript_path ? : string | undefined
35- }
36-
37- // Read by scripts/fleet/gen/hook-dispatch.mts to place this hook in the
38- // static dispatch table (the bundled fast-path). PostToolUse, Edit|Write.
39- export const DISPATCH_EVENT = 'PostToolUse'
40- export const DISPATCH_TOOLS : readonly string [ ] = [ 'Edit' , 'Write' ]
41-
42- const BYPASS_PHRASE = 'Allow hook-bundle-current bypass'
4333const BUNDLE_REL = '.claude/hooks/fleet/_dist/bundle.cjs'
4434// The wheelhouse holds TWO bundles: the live one (above) and the cascaded
4535// canonical under template/base/. A hook-source edit leaves both stale until
@@ -144,47 +134,40 @@ export function bundleIsStale(
144134}
145135
146136/**
147- * Builds the multi-line stderr reminder.
137+ * Builds the multi-line reminder. The bypass instruction is NOT part of the
138+ * message — defineHook appends the uniform footer from the `bypass` metadata.
148139 */
149140export function formatReminder ( sourceRel : string ) : string {
150- return (
151- [
152- `[bundle-stale-reminder] Edited a hook-bundle source without rebuilding the bundle.` ,
153- `` ,
154- ` Source: ${ sourceRel } ` ,
155- ` Bundle: ${ BUNDLE_REL } ` ,
156- ` (+ ${ TEMPLATE_BUNDLE_REL } in the wheelhouse) — missing or older than the source` ,
157- `` ,
158- ` Rebuild so warm hook dispatch loads current code:` ,
159- ` node scripts/fleet/build-hook-bundle.mts` ,
160- `` ,
161- ` Deferring the rebuild on purpose? Type "${ BYPASS_PHRASE } ".` ,
162- ] . join ( '\n' ) + '\n'
163- )
141+ return [
142+ `[bundle-stale-reminder] Edited a hook-bundle source without rebuilding the bundle.` ,
143+ `` ,
144+ ` Source: ${ sourceRel } ` ,
145+ ` Bundle: ${ BUNDLE_REL } ` ,
146+ ` (+ ${ TEMPLATE_BUNDLE_REL } in the wheelhouse) — missing or older than the source` ,
147+ `` ,
148+ ` Rebuild so warm hook dispatch loads current code:` ,
149+ ` node scripts/fleet/build-hook-bundle.mts` ,
150+ ] . join ( '\n' )
164151}
165152
166153/**
167154 * Core hook logic, decoupled from process I/O so the dispatcher bundle can
168- * call it directly . Returns the reminder text when the bundle is stale, or
169- * undefined when there is nothing to say.
155+ * call it via the `check` seam . Returns a notify verdict when the bundle is
156+ * stale, or undefined when there is nothing to say.
170157 */
171- export function run ( payload : BundleStalePayload ) : string | undefined {
172- if ( payload . hook_event_name && payload . hook_event_name !== 'PostToolUse' ) {
158+ export const check = ( payload : ToolCallPayload ) : GuardResult => {
159+ const eventName = ( payload as { hook_event_name ?: unknown | undefined } )
160+ . hook_event_name
161+ if ( eventName && eventName !== 'PostToolUse' ) {
173162 return undefined
174163 }
175164 if ( payload . tool_name !== 'Edit' && payload . tool_name !== 'Write' ) {
176165 return undefined
177166 }
178- const filePath =
179- typeof payload . tool_input ?. file_path === 'string'
180- ? payload . tool_input . file_path
181- : ''
167+ const filePath = readFilePath ( payload )
182168 if ( ! filePath || ! isBundledSource ( filePath ) ) {
183169 return undefined
184170 }
185- if ( bypassPhrasePresent ( payload . transcript_path , BYPASS_PHRASE ) ) {
186- return undefined
187- }
188171 const cwd = resolveProjectDir (
189172 typeof payload . cwd === 'string' ? payload . cwd : undefined ,
190173 )
@@ -199,50 +182,14 @@ export function run(payload: BundleStalePayload): string | undefined {
199182 return undefined
200183 }
201184 const sourceRel = path . relative ( repoRoot , sourceAbs ) || filePath
202- return formatReminder ( sourceRel )
203- }
204-
205- /* c8 ignore start - process-entrypoint I/O; only reachable when invoked as main script */
206- async function readStdin ( ) : Promise < string > {
207- let raw = ''
208- for await ( const chunk of process . stdin ) {
209- raw += chunk
210- }
211- return raw
212- }
213-
214- async function main ( ) : Promise < void > {
215- let raw : string
216- try {
217- raw = await readStdin ( )
218- } catch {
219- process . exit ( 0 )
220- }
221- if ( ! raw . trim ( ) ) {
222- process . exit ( 0 )
223- }
224- let payload : BundleStalePayload
225- try {
226- payload = JSON . parse ( raw ) as BundleStalePayload
227- } catch {
228- process . exit ( 0 )
229- }
230- const reminder = run ( payload )
231- if ( reminder ) {
232- process . stderr . write ( reminder )
233- }
234- // Reminder-only: never blocks.
235- process . exit ( 0 )
185+ return notify ( formatReminder ( sourceRel ) )
236186}
237187
238- // Entrypoint-guarded: run main() only when invoked directly, NOT when the test
239- // or the dispatch bundle imports this module for its pure `run` helper.
240- // `isHookEntrypoint` also short-circuits inside a snapshot build pass, where the
241- // absolute-`--build-snapshot`-path coincidence would otherwise fire this guard
242- // during the build and abort serialization (see _shared/entrypoint.mts).
243- if ( isHookEntrypoint ( import . meta. url ) ) {
244- main ( ) . catch ( ( ) => {
245- process . exit ( 0 )
246- } )
247- }
248- /* c8 ignore stop */
188+ export const hook = defineHook ( {
189+ bypass : [ 'hook-bundle-current' ] ,
190+ check,
191+ event : 'PostToolUse' ,
192+ matcher : [ 'Edit' , 'Write' ] ,
193+ type : 'nudge' ,
194+ } )
195+ void runHook ( hook , import . meta. url )
0 commit comments