@@ -35,6 +35,8 @@ pub struct DebugEntry {
3535}
3636
3737lazy_static ! {
38+ static ref HOOK_MSG_RE : Regex =
39+ Regex :: new( r"^Hook ([^ (]+) \(([^)]+)\) (success|error):$" ) . unwrap( ) ;
3840 static ref DEBUG_LINE_RE : Regex = Regex :: new(
3941 r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\s+\[(DEBUG|WARN|ERROR)\]\s+(.*)$"
4042 )
@@ -206,6 +208,54 @@ pub fn filter_by_text(entries: &[DebugEntry], query: &str) -> Vec<DebugEntry> {
206208 . collect ( )
207209}
208210
211+ /// Extract hook execution events from a session's debug log as ClassifiedMsg::Hook.
212+ ///
213+ /// Claude Code writes one `[DEBUG] Hook {name} ({event}) success:` line per hook
214+ /// execution in `~/.claude/debug/{session_id}.txt` (only when run with `--debug`).
215+ /// This function reads those lines and returns HookMsg entries that can be merged
216+ /// into the session's classified message list to surface non-Stop hooks (PreToolUse,
217+ /// PostToolUse, UserPromptSubmit, SessionStart, etc.) that are not recorded in JSONL.
218+ ///
219+ /// Stop hooks are excluded because they are already captured via `stop_hook_summary`
220+ /// system entries in the JSONL file.
221+ pub fn extract_hook_msgs ( session_path : & str ) -> Vec < super :: classify:: ClassifiedMsg > {
222+ let debug_path = debug_log_path ( session_path) ;
223+ if debug_path. is_empty ( ) {
224+ return Vec :: new ( ) ;
225+ }
226+ let ( entries, _) = match read_debug_log ( & debug_path) {
227+ Ok ( v) => v,
228+ Err ( _) => return Vec :: new ( ) ,
229+ } ;
230+ entries
231+ . iter ( )
232+ . filter_map ( |e| {
233+ let caps = HOOK_MSG_RE . captures ( & e. message ) ?;
234+ let hook_name_full = caps. get ( 1 ) ?. as_str ( ) ; // e.g. "PreToolUse:Agent"
235+ let hook_event = caps. get ( 2 ) ?. as_str ( ) ; // e.g. "PreToolUse"
236+ // Stop hooks are already captured via stop_hook_summary in the JSONL.
237+ if hook_event == "Stop" {
238+ return None ;
239+ }
240+ // Extract the tool/matcher name after the colon (e.g. "Agent" from "PreToolUse:Agent").
241+ let hook_name = hook_name_full
242+ . find ( ':' )
243+ . map ( |i| & hook_name_full[ i + 1 ..] )
244+ . unwrap_or ( hook_name_full)
245+ . to_string ( ) ;
246+ let command = e. extra . clone ( ) ;
247+ Some ( super :: classify:: ClassifiedMsg :: Hook (
248+ super :: classify:: HookMsg {
249+ timestamp : e. timestamp ,
250+ hook_event : hook_event. to_string ( ) ,
251+ hook_name,
252+ command,
253+ } ,
254+ ) )
255+ } )
256+ . collect ( )
257+ }
258+
209259/// Collapse consecutive duplicate entries.
210260pub fn collapse_duplicates ( entries : Vec < DebugEntry > ) -> Vec < DebugEntry > {
211261 if entries. is_empty ( ) {
@@ -225,3 +275,114 @@ pub fn collapse_duplicates(entries: Vec<DebugEntry>) -> Vec<DebugEntry> {
225275 result. push ( current) ;
226276 result
227277}
278+
279+ #[ cfg( test) ]
280+ mod tests {
281+ use super :: * ;
282+ use crate :: parser:: classify:: ClassifiedMsg ;
283+ use std:: io:: Write ;
284+ use tempfile:: NamedTempFile ;
285+
286+ fn write_debug_file ( content : & str ) -> NamedTempFile {
287+ let mut f = NamedTempFile :: new ( ) . unwrap ( ) ;
288+ f. write_all ( content. as_bytes ( ) ) . unwrap ( ) ;
289+ f
290+ }
291+
292+ #[ test]
293+ fn extract_hook_msgs_returns_empty_for_missing_debug_log ( ) {
294+ // session_path with no corresponding debug file → empty
295+ let result = extract_hook_msgs ( "/nonexistent/path/fake-uuid.jsonl" ) ;
296+ assert ! ( result. is_empty( ) ) ;
297+ }
298+
299+ #[ test]
300+ fn extract_hook_msgs_parses_pretooluse_and_posttooluse ( ) {
301+ // Write a fake debug log to a temp file with known session UUID pattern.
302+ // We test the inner parsing by calling read_debug_log directly + applying
303+ // the same regex, since extract_hook_msgs relies on debug_log_path() which
304+ // looks up ~/.claude/debug/{session_id}.txt.
305+ let content = "\
306+ 2026-03-03T01:01:41.147Z [DEBUG] Hook PreToolUse:Agent (PreToolUse) success:\n \
307+ [entire] PreToolUse[Task] hook invoked\n \
308+ 2026-03-03T01:01:48.664Z [DEBUG] Hook PostToolUse:Agent (PostToolUse) success:\n \
309+ [entire] PostToolUse[Task] hook invoked\n \
310+ 2026-03-03T01:02:38.628Z [DEBUG] Hook Stop (Stop) success:\n \
311+ stop output\n \
312+ 2026-03-03T01:03:00.000Z [DEBUG] Hook UserPromptSubmit (UserPromptSubmit) success:\n \
313+ prompt captured\n \
314+ ";
315+ let f = write_debug_file ( content) ;
316+ let ( entries, _) = read_debug_log ( f. path ( ) . to_str ( ) . unwrap ( ) ) . unwrap ( ) ;
317+
318+ let hooks: Vec < ClassifiedMsg > = entries
319+ . iter ( )
320+ . filter_map ( |e| {
321+ let caps = HOOK_MSG_RE . captures ( & e. message ) ?;
322+ let hook_name_full = caps. get ( 1 ) ?. as_str ( ) ;
323+ let hook_event = caps. get ( 2 ) ?. as_str ( ) ;
324+ if hook_event == "Stop" {
325+ return None ;
326+ }
327+ let hook_name = hook_name_full
328+ . find ( ':' )
329+ . map ( |i| & hook_name_full[ i + 1 ..] )
330+ . unwrap_or ( hook_name_full)
331+ . to_string ( ) ;
332+ Some ( ClassifiedMsg :: Hook ( crate :: parser:: classify:: HookMsg {
333+ timestamp : e. timestamp ,
334+ hook_event : hook_event. to_string ( ) ,
335+ hook_name,
336+ command : e. extra . clone ( ) ,
337+ } ) )
338+ } )
339+ . collect ( ) ;
340+
341+ assert_eq ! ( hooks. len( ) , 3 ) ; // PreToolUse, PostToolUse, UserPromptSubmit (Stop excluded)
342+
343+ let events: Vec < & str > = hooks
344+ . iter ( )
345+ . map ( |m| match m {
346+ ClassifiedMsg :: Hook ( h) => h. hook_event . as_str ( ) ,
347+ _ => "" ,
348+ } )
349+ . collect ( ) ;
350+ assert ! ( events. contains( & "PreToolUse" ) ) ;
351+ assert ! ( events. contains( & "PostToolUse" ) ) ;
352+ assert ! ( events. contains( & "UserPromptSubmit" ) ) ;
353+ assert ! ( !events. contains( & "Stop" ) , "Stop should be excluded" ) ;
354+ }
355+
356+ #[ test]
357+ fn extract_hook_msgs_extracts_tool_name_from_colon_format ( ) {
358+ let content =
359+ "2026-03-03T01:01:41.147Z [DEBUG] Hook PreToolUse:Read (PreToolUse) success:\n " ;
360+ let f = write_debug_file ( content) ;
361+ let ( entries, _) = read_debug_log ( f. path ( ) . to_str ( ) . unwrap ( ) ) . unwrap ( ) ;
362+
363+ let caps = HOOK_MSG_RE . captures ( & entries[ 0 ] . message ) . unwrap ( ) ;
364+ let hook_name_full = caps. get ( 1 ) . unwrap ( ) . as_str ( ) ;
365+ let hook_name = hook_name_full
366+ . find ( ':' )
367+ . map ( |i| & hook_name_full[ i + 1 ..] )
368+ . unwrap_or ( hook_name_full) ;
369+ assert_eq ! ( hook_name, "Read" ) ;
370+ }
371+
372+ #[ test]
373+ fn extract_hook_msgs_handles_hook_without_colon ( ) {
374+ // e.g. "Hook UserPromptSubmit (UserPromptSubmit) success:"
375+ let content =
376+ "2026-03-03T01:01:41.147Z [DEBUG] Hook UserPromptSubmit (UserPromptSubmit) success:\n " ;
377+ let f = write_debug_file ( content) ;
378+ let ( entries, _) = read_debug_log ( f. path ( ) . to_str ( ) . unwrap ( ) ) . unwrap ( ) ;
379+
380+ let caps = HOOK_MSG_RE . captures ( & entries[ 0 ] . message ) . unwrap ( ) ;
381+ let hook_name_full = caps. get ( 1 ) . unwrap ( ) . as_str ( ) ;
382+ let hook_name = hook_name_full
383+ . find ( ':' )
384+ . map ( |i| & hook_name_full[ i + 1 ..] )
385+ . unwrap_or ( hook_name_full) ;
386+ assert_eq ! ( hook_name, "UserPromptSubmit" ) ;
387+ }
388+ }
0 commit comments