@@ -87,34 +87,68 @@ const FATAL_MARKERS = [
8787 'was compiled against a different Node.js version' ,
8888] ;
8989
90- function recordHookHealth ( stderr ) {
90+ // Internal timeout for the spawned bundle process. Kept BELOW the harness's
91+ // own hook timeout (settings.json) so THIS script observes and logs a stall
92+ // itself — if the harness timeout fires first, this whole process is killed
93+ // too and never gets to write the log line. (2026-06-02 postmortem: a 5s
94+ // harness timeout vs. this shim's own admitted ~30-60s cold start left
95+ // routing_outcomes writes silently dropped for a month — nothing recorded
96+ // the mismatch because nothing was watching for it.)
97+ const SPAWN_TIMEOUT_MS = Number ( process . env . AQE_HOOK_TIMEOUT_MS ) || 18000 ;
98+
99+ function recordHookHealth ( line ) {
91100 try {
92- if ( ! stderr ) return ;
93- const hit = FATAL_MARKERS . find ( ( m ) => stderr . includes ( m ) ) ;
94- if ( ! hit ) return ;
95101 const logPath = path . join ( PROJECT , '.agentic-qe' , 'hooks-health.log' ) ;
96102 // Throttle: skip if we logged within the last 5 min (avoid a line/turn).
97103 try {
98104 const st = fs . statSync ( logPath ) ;
99105 if ( Date . now ( ) - st . mtimeMs < 5 * 60 * 1000 ) return ;
100106 } catch { /* no log yet — fall through and create it */ }
101- const subcmd = args [ 0 ] || 'unknown' ;
102- const line = `[${ new Date ( ) . toISOString ( ) } ] FATAL hook persistence failure `
103- + `(cmd=${ subcmd } ): "${ hit } ". Learning is NOT being captured. `
104- + `Fix: \`npm rebuild better-sqlite3\` (host/container native-binary mismatch).\n` ;
107+ fs . mkdirSync ( path . dirname ( logPath ) , { recursive : true } ) ;
105108 fs . appendFileSync ( logPath , line ) ;
106109 } catch { /* health logging must never block a turn */ }
107110}
108111
109112let out = '' ;
110113try {
111114 const res = spawnSync ( cmd , cmdArgs , {
112- stdio : [ 'ignore' , 'pipe' , 'pipe' ] , // swallow stdin; capture stderr to scan
115+ // Claude Code delivers hook event data (prompt, tool_input, etc.) as JSON
116+ // on stdin — `$PROMPT`/`$TOOL_INPUT_*` env-var substitution is NOT
117+ // reliable on every hook surface (see hooks-shared.ts:readStdinJsonEvent's
118+ // own docstring). Discarding stdin here silently broke the CLI's
119+ // existing, already-safe stdin fallback (500ms internal timeout, never
120+ // hangs) — inherit it instead so that fallback can actually run.
121+ stdio : [ 'inherit' , 'pipe' , 'pipe' ] ,
113122 encoding : 'utf8' ,
114123 maxBuffer : 16 * 1024 * 1024 ,
124+ timeout : SPAWN_TIMEOUT_MS ,
115125 } ) ;
116126 out = ( res && res . stdout ) || '' ;
117- recordHookHealth ( ( res && res . stderr ) || '' ) ; // tee fatal markers to health log
127+ const subcmd = args [ 0 ] || 'unknown' ;
128+ const stderr = ( res && res . stderr ) || '' ;
129+ const hit = FATAL_MARKERS . find ( ( m ) => stderr . includes ( m ) ) ;
130+ const ts = ( ) => new Date ( ) . toISOString ( ) ;
131+ if ( hit ) {
132+ recordHookHealth ( `[${ ts ( ) } ] FATAL hook persistence failure `
133+ + `(cmd=${ subcmd } ): "${ hit } ". Learning is NOT being captured. `
134+ + `Fix: \`npm rebuild better-sqlite3\` (host/container native-binary mismatch).\n` ) ;
135+ } else if ( res && res . signal ) {
136+ // Killed by our own SPAWN_TIMEOUT_MS (or another signal) before finishing.
137+ // No output means no persistence happened for this invocation.
138+ recordHookHealth ( `[${ ts ( ) } ] TIMEOUT hook did not complete `
139+ + `(cmd=${ subcmd } , signal=${ res . signal } , budget=${ SPAWN_TIMEOUT_MS } ms). `
140+ + `Learning for this invocation was NOT captured.\n` ) ;
141+ } else if ( res && res . error ) {
142+ // spawnSync couldn't even launch the child (ENOENT/EACCES/etc).
143+ recordHookHealth ( `[${ ts ( ) } ] SPAWN-FAILED hook could not start `
144+ + `(cmd=${ subcmd } ): ${ res . error . message || res . error } . `
145+ + `Learning for this invocation was NOT captured.\n` ) ;
146+ } else if ( res && res . status !== 0 && ! out . includes ( '{' ) ) {
147+ // Child exited non-zero with no JSON payload — e.g. an empty-task throw
148+ // when the harness didn't populate $PROMPT/$TOOL_INPUT_*.
149+ recordHookHealth ( `[${ ts ( ) } ] EMPTY-RESULT hook exited status=${ res . status } `
150+ + `with no JSON output (cmd=${ subcmd } ). stderr: ${ stderr . slice ( 0 , 300 ) } \n` ) ;
151+ }
118152} catch { /* never block a turn */ }
119153
120154// Emit only the top-level JSON object: the first line beginning with '{' through
0 commit comments