diff --git a/src/daemon.rs b/src/daemon.rs index d4878546..beec0942 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -86,6 +86,20 @@ impl HookAgent { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HookRouteMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonHookEvent { pub agent: String, @@ -96,6 +110,8 @@ pub struct DaemonHookEvent { pub command: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, } impl DaemonHookEvent { @@ -112,9 +128,16 @@ impl DaemonHookEvent { rel_paths, command, cwd, + route: None, } } + #[must_use] + pub fn with_route(mut self, route: Option) -> Self { + self.route = route; + self + } + pub fn cursor_after_file_edit(rel_paths: Vec) -> Self { Self::new(HookAgent::Cursor, "afterFileEdit", rel_paths, None, None) } diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs index 12d72d2a..06873c56 100644 --- a/src/hooks/cursor.rs +++ b/src/hooks/cursor.rs @@ -20,8 +20,9 @@ use super::steering::{ }; use super::tool_hints::{decide_hint, HintAgent, ToolHint, ToolHintInput}; use super::{ - deduped_project_hint, event_session_id, format_tool_hint, nearest_project_like_root, - read_hook_event, record_hint_analytics, record_hook_invoked, rel_under_root, text_field, + deduped_project_hint, event_session_id, format_tool_hint, hook_route_metadata_from_event, + nearest_project_like_root, read_hook_event, record_hint_analytics, record_hook_invoked, + rel_under_root, text_field, }; /// Largest tail the `beforeSubmitPrompt` hot path will read in one call. Larger @@ -510,7 +511,8 @@ async fn notify_cursor_after_file_edit(event_json: &str) { } crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::cursor_after_file_edit(rels), + crate::daemon::DaemonHookEvent::cursor_after_file_edit(rels) + .with_route(hook_route_metadata_from_event(event_json, &root)), ) .await; } @@ -533,7 +535,8 @@ async fn notify_cursor_after_shell_event(event_json: &str) { let cwd = cursor_event_cwd(&parsed).unwrap_or_else(|| root.clone()); crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::cursor_after_shell_execution(command.to_string(), cwd), + crate::daemon::DaemonHookEvent::cursor_after_shell_execution(command.to_string(), cwd) + .with_route(hook_route_metadata_from_event(event_json, &root)), ) .await; } @@ -548,7 +551,8 @@ async fn notify_cursor_workspace_open(event_json: &str) { } crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::cursor_workspace_open(root.clone()), + crate::daemon::DaemonHookEvent::cursor_workspace_open(root.clone()) + .with_route(hook_route_metadata_from_event(event_json, &root)), ) .await; } diff --git a/src/hooks/kiro.rs b/src/hooks/kiro.rs index 44f51594..564d92d8 100644 --- a/src/hooks/kiro.rs +++ b/src/hooks/kiro.rs @@ -11,8 +11,8 @@ use serde_json::Value; use super::claude::is_code_research_prompt; use super::tool_hints::{decide_hint, HintAgent, ToolHintInput}; use super::{ - event_cwd, event_cwd_from_parsed, event_session_id, read_hook_event, record_hook_invoked, - rel_under_root, research_block_reason, + event_cwd, event_cwd_from_parsed, event_session_id, hook_route_metadata_from_event, + read_hook_event, record_hook_invoked, rel_under_root, research_block_reason, }; /// Largest transcript tail the Kiro `userPromptSubmit` hook will read per call. @@ -188,7 +188,8 @@ async fn notify_kiro_post_tool_use(event_json: &str) { let rel_paths = kiro_post_tool_use_rel_paths(event_json, &project_root); crate::daemon::notify_hook_event( &project_root, - crate::daemon::DaemonHookEvent::kiro_post_tool_use(rel_paths, event_cwd(event_json)), + crate::daemon::DaemonHookEvent::kiro_post_tool_use(rel_paths, event_cwd(event_json)) + .with_route(hook_route_metadata_from_event(event_json, &project_root)), ) .await; } diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 8dfdbb7e..f55f42b5 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -236,6 +236,54 @@ fn record_hint_emitted( record_hint_analytics(root, "hint_emitted", agent, session_id, hint); } +fn hook_route_metadata_from_event( + event_json: &str, + project_root: &Path, +) -> Option { + let parsed = serde_json::from_str::(event_json).ok()?; + Some(hook_route_metadata_from_parsed(&parsed, project_root)) +} + +fn hook_route_metadata_from_parsed( + parsed: &Value, + project_root: &Path, +) -> crate::daemon::HookRouteMetadata { + let cwd = event_cwd_from_parsed(parsed); + let route_root = cwd.as_deref().unwrap_or(project_root); + let worktree = crate::worktree::git_worktree_root(route_root) + .unwrap_or_else(|| project_root.to_path_buf()); + let branch = crate::branch::current_branch(&worktree); + crate::daemon::HookRouteMetadata { + session_id: hook_route_session_id(parsed), + thread_id: text_field( + parsed, + &[ + "thread_id", + "threadId", + "conversation_thread_id", + "conversationThreadId", + ], + ), + cwd, + worktree: Some(worktree), + branch, + } +} + +fn hook_route_session_id(parsed: &Value) -> Option { + text_field( + parsed, + &[ + "session_id", + "sessionId", + "conversation_id", + "conversationId", + "chat_id", + "chatId", + ], + ) +} + fn deduped_project_hint( root: Option, agent: HintAgent, @@ -434,3 +482,40 @@ pub(crate) fn read_stdin_to_string() -> std::io::Result { std::io::stdin().read_to_string(&mut input)?; Ok(input) } + +#[cfg(test)] +mod tests { + use super::hook_route_metadata_from_event; + + #[test] + fn hook_route_metadata_preserves_camel_case_session_ids() { + let event = serde_json::json!({ + "sessionId": "session-camel", + "conversationId": "conversation-camel", + "cwd": "/tmp/project" + }) + .to_string(); + + let Some(route) = + hook_route_metadata_from_event(&event, std::path::Path::new("/tmp/project")) + else { + panic!("route metadata should parse"); + }; + + assert_eq!(route.session_id.as_deref(), Some("session-camel")); + + let event = serde_json::json!({ + "conversationId": "conversation-camel", + "cwd": "/tmp/project" + }) + .to_string(); + + let Some(route) = + hook_route_metadata_from_event(&event, std::path::Path::new("/tmp/project")) + else { + panic!("route metadata should parse"); + }; + + assert_eq!(route.session_id.as_deref(), Some("conversation-camel")); + } +} diff --git a/src/hooks/post_tool_use.rs b/src/hooks/post_tool_use.rs index d56b79b9..b135af11 100644 --- a/src/hooks/post_tool_use.rs +++ b/src/hooks/post_tool_use.rs @@ -11,7 +11,7 @@ use std::path::Path; use serde_json::Value; -use super::{codex, event_cwd_from_parsed, rel_under_root}; +use super::{codex, event_cwd_from_parsed, hook_route_metadata_from_parsed, rel_under_root}; /// Claude Code tools whose `PostToolUse` events the hook consumes. The /// installer's `PostToolUse` matcher is derived from this list so the matcher @@ -86,7 +86,8 @@ pub(crate) async fn notify_post_tool_use(spec: &PostToolUseSpec, event_json: &st } crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::post_tool_use_edit(spec.agent, rels, cwd), + crate::daemon::DaemonHookEvent::post_tool_use_edit(spec.agent, rels, cwd) + .with_route(Some(hook_route_metadata_from_parsed(&parsed, &root))), ) .await; } else if (spec.is_shell_tool)(tool_name) { @@ -100,7 +101,8 @@ pub(crate) async fn notify_post_tool_use(spec: &PostToolUseSpec, event_json: &st spec.agent, command.to_string(), cwd, - ), + ) + .with_route(Some(hook_route_metadata_from_parsed(&parsed, &root))), ) .await; } diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 529d20f9..930d58f6 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -28,6 +28,15 @@ impl HookEventKind { _ => None, } } + + pub(crate) fn as_key(self) -> &'static str { + match self { + Self::FileEdit => "file_edit", + Self::Shell => "shell", + Self::WorkspaceOpen => "workspace_open", + Self::IncrementalSync => "incremental_sync", + } + } } pub(crate) struct HookEvent { @@ -36,6 +45,7 @@ pub(crate) struct HookEvent { pub(crate) rel_paths: Vec, pub(crate) command: Option, pub(crate) cwd: Option, + pub(crate) route: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,6 +66,7 @@ pub(crate) fn parse_hook_event(params: Option<&Value>) -> Option { rel_paths: safe_hook_rel_paths(&event.rel_paths), command: event.command.filter(|command| !command.is_empty()), cwd: event.cwd, + route: event.route, }) } @@ -214,6 +225,37 @@ mod tests { assert_eq!(workspace.kind, HookEventKind::WorkspaceOpen); } + #[test] + fn preserves_route_metadata_from_hook_notification() { + let params = json!({ + "agent": "codex", + "event": "postToolUseShell", + "command": "cargo test", + "cwd": "/tmp/project", + "route": { + "session_id": "session-123", + "thread_id": "thread-456", + "cwd": "/tmp/project", + "worktree": "/tmp/project-worktree", + "branch": "feature/hook-route" + } + }); + + let event = parse_or_panic(¶ms); + + let Some(route) = event.route.as_ref() else { + panic!("route metadata should parse"); + }; + assert_eq!(route.session_id.as_deref(), Some("session-123")); + assert_eq!(route.thread_id.as_deref(), Some("thread-456")); + assert_eq!(route.cwd.as_deref(), Some(Path::new("/tmp/project"))); + assert_eq!( + route.worktree.as_deref(), + Some(Path::new("/tmp/project-worktree")) + ); + assert_eq!(route.branch.as_deref(), Some("feature/hook-route")); + } + #[test] fn ignores_unknown_hook_event_names() { let params = json!({ diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 22c8ee97..8ef280a5 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -18,7 +18,9 @@ use crate::global_db::GlobalDb; use crate::mcp::response_handles::{ cleanup_expired_response_handles, response_handle_stats_json, RESPONSE_RETRIEVE_TOOL, }; -use crate::mcp::tool_analytics::{mcp_tool_analytics_event, McpToolAnalyticsEvent}; +use crate::mcp::tool_analytics::{ + hook_route_analytics_event, mcp_tool_analytics_event, McpToolAnalyticsEvent, +}; use crate::path_tree::format_compact_annotated_path_list; use crate::tracedecay::TraceDecay; @@ -1607,6 +1609,7 @@ impl McpServer { let root = cg.project_root().to_path_buf(); self.update_hook_workspace_route(&event, connection).await; let current_branch = crate::branch::current_branch(&root); + self.record_hook_route_analytics(&root, &event, current_branch.as_deref()); let plan = hook_events::plan_hook_event(&event, &root, current_branch.as_deref()); self.run_hook_event_plan(cg, &root, plan).await; } @@ -2307,6 +2310,30 @@ impl McpServer { }); } + fn record_hook_route_analytics( + &self, + project_root: &std::path::Path, + event: &hook_events::HookEvent, + current_branch: Option<&str>, + ) { + let Some(event) = hook_route_analytics_event( + project_root, + event, + current_branch, + crate::tracedecay::current_timestamp(), + ) else { + return; + }; + let Some(gdb) = self.global_db.clone() else { + return; + }; + self.spawn_observed_ledger_write(async move { + if let Err(e) = gdb.append_analytics_event(&event).await { + eprintln!("[tracedecay] hook route analytics insert failed: {e}"); + } + }); + } + fn spawn_observed_ledger_write(&self, future: F) where F: std::future::Future + Send + 'static, diff --git a/src/mcp/tool_analytics.rs b/src/mcp/tool_analytics.rs index 5de9d7c1..af47ebe1 100644 --- a/src/mcp/tool_analytics.rs +++ b/src/mcp/tool_analytics.rs @@ -1,6 +1,7 @@ use serde_json::{json, Value}; use crate::global_db::{AnalyticsEventInsert, GlobalDb}; +use crate::mcp::hook_events::HookEvent; pub(super) struct McpToolAnalyticsEvent<'a> { pub(super) project_root: &'a std::path::Path, @@ -59,6 +60,42 @@ pub(super) fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> Anal } } +pub(super) fn hook_route_analytics_event( + project_root: &std::path::Path, + event: &HookEvent, + current_branch: Option<&str>, + timestamp: i64, +) -> Option { + let route = event.route.as_ref()?; + let metadata = json!({ + "agent": event.agent.as_wire(), + "hook_kind": event.kind.as_key(), + "event_cwd": event.cwd.as_ref().map(|path| path.display().to_string()), + "route_cwd": route.cwd.as_ref().map(|path| path.display().to_string()), + "worktree": route.worktree.as_ref().map(|path| path.display().to_string()), + "route_branch": route.branch.as_deref(), + "current_branch": current_branch, + "thread_id": route.thread_id.as_deref(), + "rel_path_count": event.rel_paths.len(), + "has_command": event.command.is_some(), + }); + Some(AnalyticsEventInsert { + provider: "daemon_hook".to_string(), + project_id: GlobalDb::canonical_project_key(project_root), + session_id: route.session_id.clone(), + timestamp, + event_kind: "hook_route".to_string(), + hook_name: Some(event.kind.as_key().to_string()), + tool_name: None, + tool_category: None, + skill_name: None, + hint_category: None, + hint_id: None, + outcome: Some("observed".to_string()), + metadata_json: Some(metadata.to_string()), + }) +} + fn append_tool_response_analytics( tool_name: &str, arguments: &Value, @@ -95,3 +132,53 @@ fn append_tool_response_analytics( "error": null, }); } + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use crate::daemon::HookRouteMetadata; + use crate::mcp::hook_events::{HookAgent, HookEvent, HookEventKind}; + + use super::hook_route_analytics_event; + + #[test] + fn hook_route_analytics_event_preserves_correlation_fields() { + let event = HookEvent { + agent: HookAgent::Codex, + kind: HookEventKind::Shell, + rel_paths: Vec::new(), + command: Some("cargo test".to_string()), + cwd: Some(PathBuf::from("/repo")), + route: Some(HookRouteMetadata { + session_id: Some("session-123".to_string()), + thread_id: Some("thread-456".to_string()), + cwd: Some(PathBuf::from("/repo")), + worktree: Some(PathBuf::from("/repo")), + branch: Some("feature/hook-route".to_string()), + }), + }; + + let Some(record) = + hook_route_analytics_event(Path::new("/repo"), &event, Some("main"), 12345) + else { + panic!("route metadata should create analytics record"); + }; + let metadata: serde_json::Value = + match serde_json::from_str(record.metadata_json.as_deref().unwrap_or("{}")) { + Ok(metadata) => metadata, + Err(err) => panic!("metadata should parse: {err}"), + }; + + assert_eq!(record.provider, "daemon_hook"); + assert_eq!(record.session_id.as_deref(), Some("session-123")); + assert_eq!(record.event_kind, "hook_route"); + assert_eq!(record.hook_name.as_deref(), Some("shell")); + assert_eq!(record.outcome.as_deref(), Some("observed")); + assert_eq!(metadata["agent"], "codex"); + assert_eq!(metadata["thread_id"], "thread-456"); + assert_eq!(metadata["route_branch"], "feature/hook-route"); + assert_eq!(metadata["current_branch"], "main"); + assert_eq!(metadata["has_command"], true); + } +}