From 6080cf8938cf008cf7e6e2321e3638936f68bdfa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:39:14 +0000 Subject: [PATCH 1/2] Add 5 new client metrics event types: ToolCall, McpInvocation, NewMessage, SkillUsed, SubagentEvent - Add MetricEventId variants (5-9) and event value structs with PosEncoded/EventValues traits - Add HookMetadata struct to AgentRunResult for tracking hook context - Emit new metrics events in checkpoint command based on hook_event_name - Add AgentUsage pings on message hooks (UserPromptSubmit, Stop, BeforeModel, etc.) - Update Claude Code hooks: UserPromptSubmit, Stop, SubagentStart, SubagentStop - Update Cursor hooks: beforeMCPExecution support - Update Gemini hooks: BeforeModel, AfterModel, BeforeMCPExecution, AfterMCPExecution - Extract subagent_id/subagent_model from Claude hook data into agent_metadata - Extract MCP server/tool names from Cursor beforeMCPExecution hook data - Add parse_mcp_tool_name helper for mcp__server__tool pattern - Add integration tests for all new event types via TmpRepo harness - Add unit tests for all new event value structs (builder, sparse, roundtrip) Co-Authored-By: Sasha Varlamov --- src/commands/checkpoint.rs | 606 +++++++++++++++- .../checkpoint_agent/agent_presets.rs | 131 +++- .../checkpoint_agent/agent_v1_preset.rs | 2 + .../checkpoint_agent/opencode_preset.rs | 2 + src/commands/git_ai_handlers.rs | 2 + src/git/test_utils/mod.rs | 1 + src/mdm/agents/claude_code.rs | 100 ++- src/mdm/agents/cursor.rs | 33 +- src/mdm/agents/gemini.rs | 119 +++- src/metrics/events.rs | 646 ++++++++++++++++++ src/metrics/mod.rs | 5 +- src/metrics/types.rs | 10 + 12 files changed, 1630 insertions(+), 27 deletions(-) diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index 3d7df0bd8..f9caaf4b7 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -111,6 +111,14 @@ fn should_emit_agent_usage(_agent_id: &AgentId) -> bool { false } +fn parse_mcp_tool_name(tool_name: &str) -> Option<(String, String)> { + let rest = tool_name.strip_prefix("mcp__")?; + let mut parts = rest.split("__"); + let server_name = parts.next()?; + let tool = parts.next()?; + Some((server_name.to_string(), tool.to_string())) +} + #[allow(clippy::too_many_arguments)] pub fn run( repo: &Repository, @@ -387,6 +395,13 @@ pub fn run( entries_start.elapsed() )); + // Build common attributes once (reused for all events) + let attrs = build_checkpoint_attrs( + repo, + &base_commit, + agent_run_result.as_ref().map(|r| &r.agent_id), + ); + // Skip adding checkpoint if there are no changes if !entries.is_empty() { let checkpoint_create_start = Instant::now(); @@ -445,9 +460,6 @@ pub fn run( )); checkpoints.push(checkpoint.clone()); - // Build common attributes once (reused for all events) - let attrs = build_checkpoint_attrs(repo, &base_commit, checkpoint.agent_id.as_ref()); - // Record agent usage metric for AI checkpoints if kind != CheckpointKind::Human && let Some(agent_id) = checkpoint.agent_id.as_ref() @@ -476,6 +488,138 @@ pub fn run( } } + if let Some(agent_run) = agent_run_result.as_ref() { + if let Some(hook_meta) = agent_run.hook_metadata.as_ref() { + let hook_event_name = hook_meta.hook_event_name.as_deref(); + let hook_tool_name = hook_meta.tool_name.as_deref(); + + let is_user_message = matches!( + hook_event_name, + Some("UserPromptSubmit") + | Some("beforeSubmitPrompt") + | Some("before_submit_prompt") + | Some("BeforeSubmitPrompt") + | Some("BeforeModel") + ); + let is_ai_message = matches!( + hook_event_name, + Some("Stop") | Some("SessionEnd") | Some("afterSubmitPrompt") | Some("AfterModel") + ); + + if is_user_message { + let values = crate::metrics::NewMessageValues::new().role("human"); + crate::metrics::record(values, attrs.clone()); + + if should_emit_agent_usage(&agent_run.agent_id) { + let values = crate::metrics::AgentUsageValues::new(); + crate::metrics::record(values, attrs.clone()); + } + } else if is_ai_message { + let values = crate::metrics::NewMessageValues::new().role("ai"); + crate::metrics::record(values, attrs.clone()); + + if should_emit_agent_usage(&agent_run.agent_id) { + let values = crate::metrics::AgentUsageValues::new(); + crate::metrics::record(values, attrs.clone()); + } + } + + let is_tool_call = matches!( + hook_event_name, + Some("PostToolUse") + | Some("AfterTool") + | Some("afterFileEdit") + | Some("after_file_edit") + | Some("AfterToolUse") + ); + + if is_tool_call { + if let Some(tool_name) = hook_tool_name { + let values = crate::metrics::ToolCallValues::new().tool_name(tool_name); + crate::metrics::record(values, attrs.clone()); + + if let Some(skill_name) = tool_name + .strip_prefix("skill__") + .or_else(|| tool_name.strip_prefix("skill:")) + { + let values = crate::metrics::SkillUsedValues::new().skill_name(skill_name); + crate::metrics::record(values, attrs.clone()); + } + + if let Some((server_name, mcp_tool)) = parse_mcp_tool_name(tool_name) { + let values = crate::metrics::McpInvocationValues::new() + .server_name(server_name) + .tool_name(mcp_tool); + crate::metrics::record(values, attrs.clone()); + } + } + } + + let is_mcp_hook = matches!( + hook_event_name, + Some("beforeMCPExecution") + | Some("BeforeMCPExecution") + | Some("before_mcp_execution") + | Some("AfterMCPExecution") + ); + + if is_mcp_hook { + let server = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("mcp_server_name").cloned()); + let tool = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("mcp_tool_name").cloned()); + + if let (Some(server_name), Some(tool_name)) = (server, tool) { + let values = crate::metrics::McpInvocationValues::new() + .server_name(server_name) + .tool_name(tool_name); + crate::metrics::record(values, attrs.clone()); + } else if let Some(tool_name) = hook_tool_name { + if let Some((server_name, mcp_tool)) = parse_mcp_tool_name(tool_name) { + let values = crate::metrics::McpInvocationValues::new() + .server_name(server_name) + .tool_name(mcp_tool); + crate::metrics::record(values, attrs.clone()); + } + } + } + + let is_subagent = matches!( + hook_event_name, + Some("SubagentStart") | Some("SubagentStop") + ); + if is_subagent { + let event_type = if hook_event_name == Some("SubagentStart") { + "start" + } else { + "stop" + }; + + let mut values = crate::metrics::SubagentEventValues::new().event_type(event_type); + if let Some(sub_id) = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("subagent_id")) + { + values = values.subagent_id(sub_id.as_str()); + } + if let Some(sub_model) = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("subagent_model")) + { + values = values.subagent_model(sub_model.as_str()); + } + + crate::metrics::record(values, attrs.clone()); + } + } + } + let agent_tool = if kind != CheckpointKind::Human && let Some(agent_run_result) = &agent_run_result { @@ -1668,6 +1812,7 @@ mod tests { ]), will_edit_filepaths: None, dirty_files: None, + hook_metadata: None, }; // Run checkpoint - should not crash even with paths outside repo @@ -2097,4 +2242,459 @@ mod tests { "Whitespace deletions ignored" ); } + + #[test] + fn test_parse_mcp_tool_name_valid() { + let result = parse_mcp_tool_name("mcp__filesystem__read_file"); + assert_eq!( + result, + Some(("filesystem".to_string(), "read_file".to_string())) + ); + } + + #[test] + fn test_parse_mcp_tool_name_no_prefix() { + assert_eq!(parse_mcp_tool_name("Write"), None); + assert_eq!(parse_mcp_tool_name("Read"), None); + assert_eq!(parse_mcp_tool_name("Bash"), None); + } + + #[test] + fn test_parse_mcp_tool_name_incomplete() { + assert_eq!(parse_mcp_tool_name("mcp__"), None); + assert_eq!(parse_mcp_tool_name("mcp__server_only"), None); + } + + #[test] + fn test_parse_mcp_tool_name_various_servers() { + let result = parse_mcp_tool_name("mcp__github__create_issue"); + assert_eq!( + result, + Some(("github".to_string(), "create_issue".to_string())) + ); + + let result = parse_mcp_tool_name("mcp__slack__post_message"); + assert_eq!( + result, + Some(("slack".to_string(), "post_message".to_string())) + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_none_does_not_panic() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::AgentRunResult; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("New line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: None, + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with hook_metadata=None should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_user_message() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("User prompt line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_msg".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("UserPromptSubmit".to_string()), + tool_name: None, + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with UserPromptSubmit hook should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_tool_call() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("Tool call line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_tool".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("PostToolUse".to_string()), + tool_name: Some("Write".to_string()), + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with PostToolUse hook should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_mcp_tool_via_tool_name() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("MCP tool line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_mcp".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("PostToolUse".to_string()), + tool_name: Some("mcp__filesystem__read_file".to_string()), + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with MCP tool via PostToolUse should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_skill_tool() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("Skill tool line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_skill".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("PostToolUse".to_string()), + tool_name: Some("skill__deploy".to_string()), + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with skill tool should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_subagent_start() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("Subagent line\n").unwrap(); + + let mut metadata = std::collections::HashMap::new(); + metadata.insert("subagent_id".to_string(), "sub-123".to_string()); + metadata.insert( + "subagent_model".to_string(), + "claude-sonnet-4-20250514".to_string(), + ); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_sub".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: Some(metadata), + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("SubagentStart".to_string()), + tool_name: None, + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with SubagentStart hook should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_mcp_hook_with_metadata() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("MCP hook line\n").unwrap(); + + let mut metadata = std::collections::HashMap::new(); + metadata.insert("mcp_server_name".to_string(), "github".to_string()); + metadata.insert("mcp_tool_name".to_string(), "create_issue".to_string()); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "cursor".to_string(), + id: "test_session_mcp_hook".to_string(), + model: "gpt-4o".to_string(), + }, + agent_metadata: Some(metadata), + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("beforeMCPExecution".to_string()), + tool_name: None, + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with beforeMCPExecution hook should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_ai_message_stop() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("AI stop line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_stop".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("Stop".to_string()), + tool_name: None, + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with Stop hook (AI message) should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_hook_metadata_gemini_before_model() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("Gemini model line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "gemini".to_string(), + id: "test_session_gemini".to_string(), + model: "gemini-2.0-flash".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("BeforeModel".to_string()), + tool_name: None, + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with BeforeModel hook should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_subagent_stop() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("Subagent stop line\n").unwrap(); + + let mut metadata = std::collections::HashMap::new(); + metadata.insert("subagent_id".to_string(), "sub-456".to_string()); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_sub_stop".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: Some(metadata), + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("SubagentStop".to_string()), + tool_name: None, + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with SubagentStop hook should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_checkpoint_with_skill_colon_prefix() { + use crate::authorship::transcript::AiTranscript; + use crate::authorship::working_log::AgentId; + use crate::commands::checkpoint_agent::agent_presets::{AgentRunResult, HookMetadata}; + + let (tmp_repo, mut file, _) = TmpRepo::new_with_base_commit().unwrap(); + file.append("Skill colon line\n").unwrap(); + + let agent_run_result = AgentRunResult { + agent_id: AgentId { + tool: "claude-code".to_string(), + id: "test_session_skill_colon".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + }, + agent_metadata: None, + transcript: Some(AiTranscript { messages: vec![] }), + checkpoint_kind: CheckpointKind::AiAgent, + repo_working_dir: None, + edited_filepaths: Some(vec![file.filename().to_string()]), + will_edit_filepaths: None, + dirty_files: None, + hook_metadata: Some(HookMetadata { + hook_event_name: Some("PostToolUse".to_string()), + tool_name: Some("skill:test_runner".to_string()), + }), + }; + + let result = + tmp_repo.trigger_checkpoint_with_agent_result("test_user", Some(agent_run_result)); + assert!( + result.is_ok(), + "Checkpoint with skill:prefix tool should succeed: {:?}", + result.err() + ); + } } diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 4f7e7e977..18713b830 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -19,6 +19,12 @@ pub struct AgentCheckpointFlags { pub hook_input: Option, } +#[derive(Clone, Debug, Default)] +pub struct HookMetadata { + pub hook_event_name: Option, + pub tool_name: Option, +} + #[derive(Clone, Debug)] pub struct AgentRunResult { pub agent_id: AgentId, @@ -29,6 +35,7 @@ pub struct AgentRunResult { pub edited_filepaths: Option>, pub will_edit_filepaths: Option>, pub dirty_files: Option>, + pub hook_metadata: Option, } pub trait AgentCheckpointPreset { @@ -117,11 +124,33 @@ impl AgentCheckpointPreset for ClaudePreset { .map(|path| vec![path.to_string()]); // Store transcript_path in metadata - let agent_metadata = + let mut agent_metadata = HashMap::from([("transcript_path".to_string(), transcript_path.to_string())]); + if let Some(subagent_id) = hook_data + .get("subagent_id") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("subagentId").and_then(|v| v.as_str())) + { + agent_metadata.insert("subagent_id".to_string(), subagent_id.to_string()); + } + + if let Some(subagent_model) = hook_data + .get("subagent_model") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("subagentModel").and_then(|v| v.as_str())) + { + agent_metadata.insert("subagent_model".to_string(), subagent_model.to_string()); + } + // Check if this is a PreToolUse event (human checkpoint) let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let tool_name = hook_data.get("tool_name").and_then(|v| v.as_str()); + + let hook_meta = Some(HookMetadata { + hook_event_name: hook_event_name.map(|s| s.to_string()), + tool_name: tool_name.map(|s| s.to_string()), + }); if hook_event_name == Some("PreToolUse") { // Early return for human checkpoint @@ -134,6 +163,7 @@ impl AgentCheckpointPreset for ClaudePreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_metadata: hook_meta, }); } @@ -147,6 +177,7 @@ impl AgentCheckpointPreset for ClaudePreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_metadata: hook_meta, }) } } @@ -483,6 +514,17 @@ impl AgentCheckpointPreset for GeminiPreset { // Check if this is a PreToolUse event (human checkpoint) let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let hook_tool_name = hook_data + .get("tool_name") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + + let hook_meta = Some(HookMetadata { + hook_event_name: hook_event_name.map(|s| s.to_string()), + tool_name: hook_tool_name, + }); + if hook_event_name == Some("BeforeTool") { // Early return for human checkpoint return Ok(AgentRunResult { @@ -494,6 +536,7 @@ impl AgentCheckpointPreset for GeminiPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_metadata: hook_meta.clone(), }); } @@ -507,6 +550,7 @@ impl AgentCheckpointPreset for GeminiPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_metadata: hook_meta, }) } } @@ -691,6 +735,17 @@ impl AgentCheckpointPreset for ContinueCliPreset { // Check if this is a PreToolUse event (human checkpoint) let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let hook_tool_name = hook_data + .get("tool_name") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + + let hook_meta = Some(HookMetadata { + hook_event_name: hook_event_name.map(|s| s.to_string()), + tool_name: hook_tool_name, + }); + if hook_event_name == Some("PreToolUse") { // Early return for human checkpoint return Ok(AgentRunResult { @@ -702,6 +757,7 @@ impl AgentCheckpointPreset for ContinueCliPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_metadata: hook_meta.clone(), }); } @@ -715,6 +771,7 @@ impl AgentCheckpointPreset for ContinueCliPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_metadata: hook_meta, }) } } @@ -910,6 +967,7 @@ impl AgentCheckpointPreset for CodexPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, + hook_metadata: None, }) } } @@ -1208,10 +1266,24 @@ impl AgentCheckpointPreset for CursorPreset { .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()); + let hook_tool_name = hook_data + .get("tool_name") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + + let hook_meta = Some(HookMetadata { + hook_event_name: Some(hook_event_name.clone()), + tool_name: hook_tool_name, + }); + // Validate hook_event_name - if hook_event_name != "beforeSubmitPrompt" && hook_event_name != "afterFileEdit" { + if hook_event_name != "beforeSubmitPrompt" + && hook_event_name != "afterFileEdit" + && hook_event_name != "beforeMCPExecution" + { return Err(GitAiError::PresetError(format!( - "Invalid hook_event_name: {}. Expected 'beforeSubmitPrompt' or 'afterFileEdit'", + "Invalid hook_event_name: {}. Expected 'beforeSubmitPrompt', 'afterFileEdit', or 'beforeMCPExecution'", hook_event_name ))); } @@ -1260,6 +1332,7 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, + hook_metadata: hook_meta.clone(), }); } @@ -1315,16 +1388,42 @@ impl AgentCheckpointPreset for CursorPreset { model, }; + let mut agent_metadata = HashMap::new(); + // Store cursor database path in metadata for refetching during post-commit. // This is only needed when GIT_AI_CURSOR_GLOBAL_DB_PATH env var is set (i.e., in tests), // because the env var isn't passed to git hook subprocesses. - let agent_metadata = if std::env::var("GIT_AI_CURSOR_GLOBAL_DB_PATH").is_ok() { - Some(HashMap::from([( + if std::env::var("GIT_AI_CURSOR_GLOBAL_DB_PATH").is_ok() { + agent_metadata.insert( "__test_cursor_db_path".to_string(), global_db.to_string_lossy().to_string(), - )])) - } else { + ); + } + + if hook_event_name == "beforeMCPExecution" { + if let Some(server_name) = hook_data + .get("mcp_server_name") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("server_name").and_then(|v| v.as_str())) + .or_else(|| hook_data.get("serverName").and_then(|v| v.as_str())) + { + agent_metadata.insert("mcp_server_name".to_string(), server_name.to_string()); + } + + if let Some(tool_name) = hook_data + .get("mcp_tool_name") + .and_then(|v| v.as_str()) + .or_else(|| hook_data.get("tool_name").and_then(|v| v.as_str())) + .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())) + { + agent_metadata.insert("mcp_tool_name".to_string(), tool_name.to_string()); + } + } + + let agent_metadata = if agent_metadata.is_empty() { None + } else { + Some(agent_metadata) }; Ok(AgentRunResult { @@ -1336,6 +1435,7 @@ impl AgentCheckpointPreset for CursorPreset { edited_filepaths, will_edit_filepaths: None, dirty_files: None, + hook_metadata: hook_meta, }) } } @@ -1733,6 +1833,10 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: Some(will_edit_filepaths), dirty_files, + hook_metadata: Some(HookMetadata { + hook_event_name: Some(hook_event_name.to_string()), + tool_name: None, + }), }); } @@ -1806,6 +1910,10 @@ impl GithubCopilotPreset { edited_filepaths: edited_filepaths.or(detected_edited_filepaths), will_edit_filepaths: None, dirty_files, + hook_metadata: Some(HookMetadata { + hook_event_name: Some(hook_event_name.to_string()), + tool_name: None, + }), }) } @@ -1969,6 +2077,7 @@ impl GithubCopilotPreset { edited_filepaths: None, will_edit_filepaths: Some(extracted_paths), dirty_files, + hook_metadata: None, }); } @@ -2005,6 +2114,10 @@ impl GithubCopilotPreset { edited_filepaths: Some(extracted_paths), will_edit_filepaths: None, dirty_files, + hook_metadata: Some(HookMetadata { + hook_event_name: Some(hook_event_name.to_string()), + tool_name: Some(tool_name.to_string()), + }), }) } @@ -2611,6 +2724,7 @@ impl AgentCheckpointPreset for DroidPreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_metadata: None, }); } @@ -2624,6 +2738,7 @@ impl AgentCheckpointPreset for DroidPreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_metadata: None, }) } } @@ -3381,6 +3496,7 @@ impl AgentCheckpointPreset for AiTabPreset { edited_filepaths: None, will_edit_filepaths, dirty_files, + hook_metadata: None, }); } @@ -3393,6 +3509,7 @@ impl AgentCheckpointPreset for AiTabPreset { edited_filepaths, will_edit_filepaths: None, dirty_files, + hook_metadata: None, }) } } diff --git a/src/commands/checkpoint_agent/agent_v1_preset.rs b/src/commands/checkpoint_agent/agent_v1_preset.rs index d6da590eb..65bd032cd 100644 --- a/src/commands/checkpoint_agent/agent_v1_preset.rs +++ b/src/commands/checkpoint_agent/agent_v1_preset.rs @@ -71,6 +71,7 @@ impl AgentCheckpointPreset for AgentV1Preset { repo_working_dir: Some(repo_working_dir), edited_filepaths: None, dirty_files, + hook_metadata: None, }), AgentV1Input::AiAgent { edited_filepaths, @@ -93,6 +94,7 @@ impl AgentCheckpointPreset for AgentV1Preset { edited_filepaths, will_edit_filepaths: None, dirty_files, + hook_metadata: None, }), } } diff --git a/src/commands/checkpoint_agent/opencode_preset.rs b/src/commands/checkpoint_agent/opencode_preset.rs index e84384aad..3fb3a3e97 100644 --- a/src/commands/checkpoint_agent/opencode_preset.rs +++ b/src/commands/checkpoint_agent/opencode_preset.rs @@ -221,6 +221,7 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: None, will_edit_filepaths: file_path_as_vec, dirty_files: None, + hook_metadata: None, }); } @@ -234,6 +235,7 @@ impl AgentCheckpointPreset for OpenCodePreset { edited_filepaths: file_path_as_vec, will_edit_filepaths: None, dirty_files: None, + hook_metadata: None, }) } } diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 99c153e94..84aaf7b31 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -522,6 +522,7 @@ fn handle_checkpoint(args: &[String]) { edited_filepaths, will_edit_filepaths: None, dirty_files: None, + hook_metadata: None, }); } _ => {} @@ -768,6 +769,7 @@ fn handle_checkpoint(args: &[String]) { edited_filepaths: None, repo_working_dir: Some(effective_working_dir), dirty_files: None, + hook_metadata: None, }); } diff --git a/src/git/test_utils/mod.rs b/src/git/test_utils/mod.rs index aa977c065..8df2d8a46 100644 --- a/src/git/test_utils/mod.rs +++ b/src/git/test_utils/mod.rs @@ -413,6 +413,7 @@ impl TmpRepo { edited_filepaths: None, will_edit_filepaths: None, dirty_files: None, + hook_metadata: None, }; checkpoint( diff --git a/src/mdm/agents/claude_code.rs b/src/mdm/agents/claude_code.rs index 84d8c5372..d47afe10a 100644 --- a/src/mdm/agents/claude_code.rs +++ b/src/mdm/agents/claude_code.rs @@ -243,6 +243,95 @@ impl HookInstaller for ClaudeCodeInstaller { } } + for hook_type in &["UserPromptSubmit", "Stop", "SubagentStart", "SubagentStop"] { + let desired_cmd = pre_tool_cmd.as_str(); + + let mut hook_type_array = hooks_obj + .get(*hook_type) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if hook_type_array.is_empty() { + hook_type_array.push(json!({ "hooks": [] })); + } + + let mut found: Option<(usize, usize)> = None; + let mut needs_update = false; + + for (item_idx, item) in hook_type_array.iter().enumerate() { + if let Some(hooks_array) = item.get("hooks").and_then(|h| h.as_array()) { + for (hook_idx, hook) in hooks_array.iter().enumerate() { + if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) + && is_git_ai_checkpoint_command(cmd) + && found.is_none() + { + found = Some((item_idx, hook_idx)); + if cmd != desired_cmd { + needs_update = true; + } + } + } + } + } + + match found { + Some((item_idx, hook_idx)) => { + if needs_update + && let Some(hooks_array) = hook_type_array[item_idx] + .get_mut("hooks") + .and_then(|h| h.as_array_mut()) + { + hooks_array[hook_idx] = json!({ + "type": "command", + "command": desired_cmd + }); + } + + for (i, item) in hook_type_array.iter_mut().enumerate() { + if let Some(hooks_array) = + item.get_mut("hooks").and_then(|h| h.as_array_mut()) + { + let mut current_idx = 0; + hooks_array.retain(|hook| { + let keep = i == item_idx && current_idx == hook_idx; + let is_dup = hook + .get("command") + .and_then(|c| c.as_str()) + .map(is_git_ai_checkpoint_command) + .unwrap_or(false); + current_idx += 1; + keep || !is_dup + }); + } + } + } + None => { + let first = hook_type_array.first_mut(); + if let Some(item) = first { + if let Some(obj) = item.as_object_mut() { + if !obj.contains_key("hooks") { + obj.insert("hooks".to_string(), Value::Array(vec![])); + } + } + + if let Some(hooks_array) = + item.get_mut("hooks").and_then(|h| h.as_array_mut()) + { + hooks_array.push(json!({ + "type": "command", + "command": desired_cmd + })); + } + } + } + } + + if let Some(obj) = hooks_obj.as_object_mut() { + obj.insert((*hook_type).to_string(), Value::Array(hook_type_array)); + } + } + // Write back hooks to merged if let Some(root) = merged.as_object_mut() { root.insert("hooks".to_string(), hooks_obj); @@ -289,8 +378,15 @@ impl HookInstaller for ClaudeCodeInstaller { let mut changed = false; - // Remove git-ai checkpoint commands from both PreToolUse and PostToolUse - for hook_type in &["PreToolUse", "PostToolUse"] { + // Remove git-ai checkpoint commands from hooks + for hook_type in &[ + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Stop", + "SubagentStart", + "SubagentStop", + ] { if let Some(hook_type_array) = hooks_obj.get_mut(*hook_type).and_then(|v| v.as_array_mut()) { diff --git a/src/mdm/agents/cursor.rs b/src/mdm/agents/cursor.rs index aaf75352b..b9d250fca 100644 --- a/src/mdm/agents/cursor.rs +++ b/src/mdm/agents/cursor.rs @@ -16,6 +16,7 @@ use std::path::PathBuf; // Command patterns for hooks const CURSOR_BEFORE_SUBMIT_CMD: &str = "checkpoint cursor --hook-input stdin"; const CURSOR_AFTER_EDIT_CMD: &str = "checkpoint cursor --hook-input stdin"; +const CURSOR_BEFORE_MCP_CMD: &str = "checkpoint cursor --hook-input stdin"; pub struct CursorInstaller; @@ -138,6 +139,7 @@ impl HookInstaller for CursorInstaller { CURSOR_BEFORE_SUBMIT_CMD ); let after_edit_cmd = format!("{} {}", params.binary_path.display(), CURSOR_AFTER_EDIT_CMD); + let before_mcp_cmd = format!("{} {}", params.binary_path.display(), CURSOR_BEFORE_MCP_CMD); // Desired hooks payload for Cursor let desired: Value = json!({ @@ -152,6 +154,11 @@ impl HookInstaller for CursorInstaller { { "command": after_edit_cmd } + ], + "beforeMCPExecution": [ + { + "command": before_mcp_cmd + } ] } }); @@ -170,7 +177,7 @@ impl HookInstaller for CursorInstaller { let mut hooks_obj = merged.get("hooks").cloned().unwrap_or_else(|| json!({})); // Process both hook types - for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + for hook_name in &["beforeSubmitPrompt", "afterFileEdit", "beforeMCPExecution"] { let desired_hooks = desired .get("hooks") .and_then(|h| h.get(*hook_name)) @@ -276,7 +283,7 @@ impl HookInstaller for CursorInstaller { let mut changed = false; // Remove git-ai checkpoint cursor commands from both hook types - for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + for hook_name in &["beforeSubmitPrompt", "afterFileEdit", "beforeMCPExecution"] { if let Some(hooks_array) = hooks_obj.get_mut(*hook_name).and_then(|v| v.as_array_mut()) { let original_len = hooks_array.len(); @@ -527,14 +534,13 @@ mod tests { let mut content: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + for hook_name in &["beforeSubmitPrompt", "afterFileEdit", "beforeMCPExecution"] { let hooks_obj = content.get_mut("hooks").unwrap(); let mut hooks_array = hooks_obj .get(*hook_name) - .unwrap() - .as_array() - .unwrap() - .clone(); + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); hooks_array.push(json!({"command": git_ai_cmd.clone()})); hooks_obj .as_object_mut() @@ -550,9 +556,11 @@ mod tests { let before_submit = hooks.get("beforeSubmitPrompt").unwrap().as_array().unwrap(); let after_edit = hooks.get("afterFileEdit").unwrap().as_array().unwrap(); + let before_mcp = hooks.get("beforeMCPExecution").unwrap().as_array().unwrap(); assert_eq!(before_submit.len(), 2); assert_eq!(after_edit.len(), 2); + assert_eq!(before_mcp.len(), 1); assert_eq!( before_submit[0].get("command").unwrap().as_str().unwrap(), @@ -599,14 +607,13 @@ mod tests { let mut content: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + for hook_name in &["beforeSubmitPrompt", "afterFileEdit", "beforeMCPExecution"] { let hooks_obj = content.get_mut("hooks").unwrap(); let mut hooks_array = hooks_obj .get(*hook_name) - .unwrap() - .as_array() - .unwrap() - .clone(); + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); for hook in hooks_array.iter_mut() { if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) @@ -630,9 +637,11 @@ mod tests { let before_submit = hooks.get("beforeSubmitPrompt").unwrap().as_array().unwrap(); let after_edit = hooks.get("afterFileEdit").unwrap().as_array().unwrap(); + let before_mcp = hooks.get("beforeMCPExecution").unwrap().as_array().unwrap(); assert_eq!(before_submit.len(), 1); assert_eq!(after_edit.len(), 1); + assert_eq!(before_mcp.len(), 0); assert_eq!( before_submit[0].get("command").unwrap().as_str().unwrap(), diff --git a/src/mdm/agents/gemini.rs b/src/mdm/agents/gemini.rs index 9a3c13fe0..a3d06e309 100644 --- a/src/mdm/agents/gemini.rs +++ b/src/mdm/agents/gemini.rs @@ -10,6 +10,10 @@ use std::path::PathBuf; // Command patterns for hooks const GEMINI_BEFORE_TOOL_CMD: &str = "checkpoint gemini --hook-input stdin"; const GEMINI_AFTER_TOOL_CMD: &str = "checkpoint gemini --hook-input stdin"; +const GEMINI_BEFORE_MODEL_CMD: &str = "checkpoint gemini --hook-input stdin"; +const GEMINI_AFTER_MODEL_CMD: &str = "checkpoint gemini --hook-input stdin"; +const GEMINI_BEFORE_MCP_CMD: &str = "checkpoint gemini --hook-input stdin"; +const GEMINI_AFTER_MCP_CMD: &str = "checkpoint gemini --hook-input stdin"; pub struct GeminiInstaller; @@ -114,6 +118,18 @@ impl HookInstaller for GeminiInstaller { GEMINI_BEFORE_TOOL_CMD ); let after_tool_cmd = format!("{} {}", params.binary_path.display(), GEMINI_AFTER_TOOL_CMD); + let before_model_cmd = format!( + "{} {}", + params.binary_path.display(), + GEMINI_BEFORE_MODEL_CMD + ); + let after_model_cmd = format!( + "{} {}", + params.binary_path.display(), + GEMINI_AFTER_MODEL_CMD + ); + let before_mcp_cmd = format!("{} {}", params.binary_path.display(), GEMINI_BEFORE_MCP_CMD); + let after_mcp_cmd = format!("{} {}", params.binary_path.display(), GEMINI_AFTER_MCP_CMD); let desired_hooks = json!({ "BeforeTool": { @@ -241,6 +257,98 @@ impl HookInstaller for GeminiInstaller { } } + for (hook_type, desired_cmd) in [ + ("BeforeModel", before_model_cmd.as_str()), + ("AfterModel", after_model_cmd.as_str()), + ("BeforeMCPExecution", before_mcp_cmd.as_str()), + ("AfterMCPExecution", after_mcp_cmd.as_str()), + ] { + let mut hook_type_array = hooks_obj + .get(hook_type) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if hook_type_array.is_empty() { + hook_type_array.push(json!({ "hooks": [] })); + } + + let mut found: Option<(usize, usize)> = None; + let mut needs_update = false; + + for (item_idx, item) in hook_type_array.iter().enumerate() { + if let Some(hooks_array) = item.get("hooks").and_then(|h| h.as_array()) { + for (hook_idx, hook) in hooks_array.iter().enumerate() { + if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) + && is_git_ai_checkpoint_command(cmd) + && found.is_none() + { + found = Some((item_idx, hook_idx)); + if cmd != desired_cmd { + needs_update = true; + } + } + } + } + } + + match found { + Some((item_idx, hook_idx)) => { + if needs_update + && let Some(hooks_array) = hook_type_array[item_idx] + .get_mut("hooks") + .and_then(|h| h.as_array_mut()) + { + hooks_array[hook_idx] = json!({ + "type": "command", + "command": desired_cmd + }); + } + + for (i, item) in hook_type_array.iter_mut().enumerate() { + if let Some(hooks_array) = + item.get_mut("hooks").and_then(|h| h.as_array_mut()) + { + let mut current_idx = 0; + hooks_array.retain(|hook| { + let keep = i == item_idx && current_idx == hook_idx; + let is_dup = hook + .get("command") + .and_then(|c| c.as_str()) + .map(is_git_ai_checkpoint_command) + .unwrap_or(false); + current_idx += 1; + keep || !is_dup + }); + } + } + } + None => { + let first = hook_type_array.first_mut(); + if let Some(item) = first { + if let Some(obj) = item.as_object_mut() { + if !obj.contains_key("hooks") { + obj.insert("hooks".to_string(), Value::Array(vec![])); + } + } + + if let Some(hooks_array) = + item.get_mut("hooks").and_then(|h| h.as_array_mut()) + { + hooks_array.push(json!({ + "type": "command", + "command": desired_cmd + })); + } + } + } + } + + if let Some(obj) = hooks_obj.as_object_mut() { + obj.insert(hook_type.to_string(), Value::Array(hook_type_array)); + } + } + // Write back hooks to merged if let Some(root) = merged.as_object_mut() { root.insert("hooks".to_string(), hooks_obj); @@ -287,8 +395,15 @@ impl HookInstaller for GeminiInstaller { let mut changed = false; - // Remove git-ai checkpoint commands from both BeforeTool and AfterTool - for hook_type in &["BeforeTool", "AfterTool"] { + // Remove git-ai checkpoint commands from hooks + for hook_type in &[ + "BeforeTool", + "AfterTool", + "BeforeModel", + "AfterModel", + "BeforeMCPExecution", + "AfterMCPExecution", + ] { if let Some(hook_type_array) = hooks_obj.get_mut(*hook_type).and_then(|v| v.as_array_mut()) { diff --git a/src/metrics/events.rs b/src/metrics/events.rs index 78de68c22..99d0e769e 100644 --- a/src/metrics/events.rs +++ b/src/metrics/events.rs @@ -665,6 +665,397 @@ impl EventValues for CheckpointValues { } } +/// Value positions for "tool_call" event. +pub mod tool_call_pos { + pub const TOOL_NAME: usize = 0; +} + +/// Values for Event ID 5: tool_call +/// +/// Recorded when an agent invokes a tool (e.g. Write, Edit, Bash, Read). +/// +/// **Fields:** +/// | Position | Name | Type | +/// |----------|------|------| +/// | 0 | tool_name | String | +#[derive(Debug, Clone, Default)] +pub struct ToolCallValues { + pub tool_name: PosField, +} + +impl ToolCallValues { + pub fn new() -> Self { + Self::default() + } + + pub fn tool_name(mut self, value: impl Into) -> Self { + self.tool_name = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn tool_name_null(mut self) -> Self { + self.tool_name = Some(None); + self + } +} + +impl PosEncoded for ToolCallValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + tool_call_pos::TOOL_NAME, + string_to_json(&self.tool_name), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + tool_name: sparse_get_string(arr, tool_call_pos::TOOL_NAME), + } + } +} + +impl EventValues for ToolCallValues { + fn event_id() -> MetricEventId { + MetricEventId::ToolCall + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "mcp_invocation" event. +pub mod mcp_invocation_pos { + pub const SERVER_NAME: usize = 0; + pub const TOOL_NAME: usize = 1; +} + +/// Values for Event ID 6: mcp_invocation +/// +/// Recorded when an agent invokes an MCP server tool. +/// +/// **Fields:** +/// | Position | Name | Type | +/// |----------|------|------| +/// | 0 | server_name | String | +/// | 1 | tool_name | String | +#[derive(Debug, Clone, Default)] +pub struct McpInvocationValues { + pub server_name: PosField, + pub tool_name: PosField, +} + +impl McpInvocationValues { + pub fn new() -> Self { + Self::default() + } + + pub fn server_name(mut self, value: impl Into) -> Self { + self.server_name = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn server_name_null(mut self) -> Self { + self.server_name = Some(None); + self + } + + pub fn tool_name(mut self, value: impl Into) -> Self { + self.tool_name = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn tool_name_null(mut self) -> Self { + self.tool_name = Some(None); + self + } +} + +impl PosEncoded for McpInvocationValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + mcp_invocation_pos::SERVER_NAME, + string_to_json(&self.server_name), + ); + sparse_set( + &mut map, + mcp_invocation_pos::TOOL_NAME, + string_to_json(&self.tool_name), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + server_name: sparse_get_string(arr, mcp_invocation_pos::SERVER_NAME), + tool_name: sparse_get_string(arr, mcp_invocation_pos::TOOL_NAME), + } + } +} + +impl EventValues for McpInvocationValues { + fn event_id() -> MetricEventId { + MetricEventId::McpInvocation + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "new_message" event. +pub mod new_message_pos { + pub const ROLE: usize = 0; +} + +/// Values for Event ID 7: new_message +/// +/// Recorded when a new human or AI message occurs in an agent session. +/// +/// **Fields:** +/// | Position | Name | Type | +/// |----------|------|------| +/// | 0 | role | String ("human" or "ai") | +#[derive(Debug, Clone, Default)] +pub struct NewMessageValues { + pub role: PosField, +} + +impl NewMessageValues { + pub fn new() -> Self { + Self::default() + } + + pub fn role(mut self, value: impl Into) -> Self { + self.role = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn role_null(mut self) -> Self { + self.role = Some(None); + self + } +} + +impl PosEncoded for NewMessageValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set(&mut map, new_message_pos::ROLE, string_to_json(&self.role)); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + role: sparse_get_string(arr, new_message_pos::ROLE), + } + } +} + +impl EventValues for NewMessageValues { + fn event_id() -> MetricEventId { + MetricEventId::NewMessage + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "skill_used" event. +pub mod skill_used_pos { + pub const SKILL_NAME: usize = 0; +} + +/// Values for Event ID 8: skill_used +/// +/// Recorded when a skill or custom command is invoked by an agent. +/// +/// **Fields:** +/// | Position | Name | Type | +/// |----------|------|------| +/// | 0 | skill_name | String | +#[derive(Debug, Clone, Default)] +pub struct SkillUsedValues { + pub skill_name: PosField, +} + +impl SkillUsedValues { + pub fn new() -> Self { + Self::default() + } + + pub fn skill_name(mut self, value: impl Into) -> Self { + self.skill_name = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn skill_name_null(mut self) -> Self { + self.skill_name = Some(None); + self + } +} + +impl PosEncoded for SkillUsedValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + skill_used_pos::SKILL_NAME, + string_to_json(&self.skill_name), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + skill_name: sparse_get_string(arr, skill_used_pos::SKILL_NAME), + } + } +} + +impl EventValues for SkillUsedValues { + fn event_id() -> MetricEventId { + MetricEventId::SkillUsed + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + +/// Value positions for "subagent_event" event. +pub mod subagent_event_pos { + pub const EVENT_TYPE: usize = 0; + pub const SUBAGENT_ID: usize = 1; + pub const SUBAGENT_MODEL: usize = 2; +} + +/// Values for Event ID 9: subagent_event +/// +/// Recorded when a subagent lifecycle event occurs (start/stop). +/// +/// **Fields:** +/// | Position | Name | Type | +/// |----------|------|------| +/// | 0 | event_type | String ("start" or "stop") | +/// | 1 | subagent_id | String | +/// | 2 | subagent_model | String | +#[derive(Debug, Clone, Default)] +pub struct SubagentEventValues { + pub event_type: PosField, + pub subagent_id: PosField, + pub subagent_model: PosField, +} + +impl SubagentEventValues { + pub fn new() -> Self { + Self::default() + } + + pub fn event_type(mut self, value: impl Into) -> Self { + self.event_type = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn event_type_null(mut self) -> Self { + self.event_type = Some(None); + self + } + + pub fn subagent_id(mut self, value: impl Into) -> Self { + self.subagent_id = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn subagent_id_null(mut self) -> Self { + self.subagent_id = Some(None); + self + } + + pub fn subagent_model(mut self, value: impl Into) -> Self { + self.subagent_model = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn subagent_model_null(mut self) -> Self { + self.subagent_model = Some(None); + self + } +} + +impl PosEncoded for SubagentEventValues { + fn to_sparse(&self) -> SparseArray { + let mut map = SparseArray::new(); + sparse_set( + &mut map, + subagent_event_pos::EVENT_TYPE, + string_to_json(&self.event_type), + ); + sparse_set( + &mut map, + subagent_event_pos::SUBAGENT_ID, + string_to_json(&self.subagent_id), + ); + sparse_set( + &mut map, + subagent_event_pos::SUBAGENT_MODEL, + string_to_json(&self.subagent_model), + ); + map + } + + fn from_sparse(arr: &SparseArray) -> Self { + Self { + event_type: sparse_get_string(arr, subagent_event_pos::EVENT_TYPE), + subagent_id: sparse_get_string(arr, subagent_event_pos::SUBAGENT_ID), + subagent_model: sparse_get_string(arr, subagent_event_pos::SUBAGENT_MODEL), + } + } +} + +impl EventValues for SubagentEventValues { + fn event_id() -> MetricEventId { + MetricEventId::SubagentEvent + } + + fn to_sparse(&self) -> SparseArray { + PosEncoded::to_sparse(self) + } + + fn from_sparse(arr: &SparseArray) -> Self { + PosEncoded::from_sparse(arr) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1034,4 +1425,259 @@ mod tests { assert_eq!(values.total_ai_deletions, Some(None)); assert_eq!(values.time_waiting_for_ai, Some(None)); } + + #[test] + fn test_tool_call_values_builder() { + let values = ToolCallValues::new().tool_name("Write"); + assert_eq!(values.tool_name, Some(Some("Write".to_string()))); + } + + #[test] + fn test_tool_call_values_to_sparse() { + use super::PosEncoded; + let values = ToolCallValues::new().tool_name("Edit"); + let sparse = PosEncoded::to_sparse(&values); + assert_eq!(sparse.get("0"), Some(&Value::String("Edit".to_string()))); + } + + #[test] + fn test_tool_call_values_from_sparse() { + use super::PosEncoded; + let mut sparse = SparseArray::new(); + sparse.insert("0".to_string(), Value::String("Bash".to_string())); + let values = ::from_sparse(&sparse); + assert_eq!(values.tool_name, Some(Some("Bash".to_string()))); + } + + #[test] + fn test_tool_call_values_roundtrip() { + use super::PosEncoded; + let original = ToolCallValues::new().tool_name("Read"); + let sparse = PosEncoded::to_sparse(&original); + let restored = ::from_sparse(&sparse); + assert_eq!(restored.tool_name, Some(Some("Read".to_string()))); + } + + #[test] + fn test_tool_call_event_id() { + assert_eq!(ToolCallValues::event_id(), MetricEventId::ToolCall); + assert_eq!(ToolCallValues::event_id() as u16, 5); + } + + #[test] + fn test_mcp_invocation_values_builder() { + let values = McpInvocationValues::new() + .server_name("filesystem") + .tool_name("read_file"); + assert_eq!(values.server_name, Some(Some("filesystem".to_string()))); + assert_eq!(values.tool_name, Some(Some("read_file".to_string()))); + } + + #[test] + fn test_mcp_invocation_values_to_sparse() { + use super::PosEncoded; + let values = McpInvocationValues::new() + .server_name("github") + .tool_name("create_issue"); + let sparse = PosEncoded::to_sparse(&values); + assert_eq!(sparse.get("0"), Some(&Value::String("github".to_string()))); + assert_eq!( + sparse.get("1"), + Some(&Value::String("create_issue".to_string())) + ); + } + + #[test] + fn test_mcp_invocation_values_from_sparse() { + use super::PosEncoded; + let mut sparse = SparseArray::new(); + sparse.insert("0".to_string(), Value::String("slack".to_string())); + sparse.insert("1".to_string(), Value::String("post_message".to_string())); + let values = ::from_sparse(&sparse); + assert_eq!(values.server_name, Some(Some("slack".to_string()))); + assert_eq!(values.tool_name, Some(Some("post_message".to_string()))); + } + + #[test] + fn test_mcp_invocation_values_roundtrip() { + use super::PosEncoded; + let original = McpInvocationValues::new() + .server_name("db") + .tool_name("query"); + let sparse = PosEncoded::to_sparse(&original); + let restored = ::from_sparse(&sparse); + assert_eq!(restored.server_name, Some(Some("db".to_string()))); + assert_eq!(restored.tool_name, Some(Some("query".to_string()))); + } + + #[test] + fn test_mcp_invocation_event_id() { + assert_eq!( + McpInvocationValues::event_id(), + MetricEventId::McpInvocation + ); + assert_eq!(McpInvocationValues::event_id() as u16, 6); + } + + #[test] + fn test_new_message_values_builder() { + let values = NewMessageValues::new().role("human"); + assert_eq!(values.role, Some(Some("human".to_string()))); + } + + #[test] + fn test_new_message_values_to_sparse() { + use super::PosEncoded; + let values = NewMessageValues::new().role("ai"); + let sparse = PosEncoded::to_sparse(&values); + assert_eq!(sparse.get("0"), Some(&Value::String("ai".to_string()))); + } + + #[test] + fn test_new_message_values_from_sparse() { + use super::PosEncoded; + let mut sparse = SparseArray::new(); + sparse.insert("0".to_string(), Value::String("human".to_string())); + let values = ::from_sparse(&sparse); + assert_eq!(values.role, Some(Some("human".to_string()))); + } + + #[test] + fn test_new_message_values_roundtrip() { + use super::PosEncoded; + let original = NewMessageValues::new().role("ai"); + let sparse = PosEncoded::to_sparse(&original); + let restored = ::from_sparse(&sparse); + assert_eq!(restored.role, Some(Some("ai".to_string()))); + } + + #[test] + fn test_new_message_event_id() { + assert_eq!(NewMessageValues::event_id(), MetricEventId::NewMessage); + assert_eq!(NewMessageValues::event_id() as u16, 7); + } + + #[test] + fn test_skill_used_values_builder() { + let values = SkillUsedValues::new().skill_name("deploy"); + assert_eq!(values.skill_name, Some(Some("deploy".to_string()))); + } + + #[test] + fn test_skill_used_values_to_sparse() { + use super::PosEncoded; + let values = SkillUsedValues::new().skill_name("test-runner"); + let sparse = PosEncoded::to_sparse(&values); + assert_eq!( + sparse.get("0"), + Some(&Value::String("test-runner".to_string())) + ); + } + + #[test] + fn test_skill_used_values_from_sparse() { + use super::PosEncoded; + let mut sparse = SparseArray::new(); + sparse.insert("0".to_string(), Value::String("lint".to_string())); + let values = ::from_sparse(&sparse); + assert_eq!(values.skill_name, Some(Some("lint".to_string()))); + } + + #[test] + fn test_skill_used_values_roundtrip() { + use super::PosEncoded; + let original = SkillUsedValues::new().skill_name("format"); + let sparse = PosEncoded::to_sparse(&original); + let restored = ::from_sparse(&sparse); + assert_eq!(restored.skill_name, Some(Some("format".to_string()))); + } + + #[test] + fn test_skill_used_event_id() { + assert_eq!(SkillUsedValues::event_id(), MetricEventId::SkillUsed); + assert_eq!(SkillUsedValues::event_id() as u16, 8); + } + + #[test] + fn test_subagent_event_values_builder() { + let values = SubagentEventValues::new() + .event_type("start") + .subagent_id("agent-123") + .subagent_model("claude-3-haiku"); + assert_eq!(values.event_type, Some(Some("start".to_string()))); + assert_eq!(values.subagent_id, Some(Some("agent-123".to_string()))); + assert_eq!( + values.subagent_model, + Some(Some("claude-3-haiku".to_string())) + ); + } + + #[test] + fn test_subagent_event_values_to_sparse() { + use super::PosEncoded; + let values = SubagentEventValues::new() + .event_type("stop") + .subagent_id("worker-1") + .subagent_model("claude-3-sonnet"); + let sparse = PosEncoded::to_sparse(&values); + assert_eq!(sparse.get("0"), Some(&Value::String("stop".to_string()))); + assert_eq!( + sparse.get("1"), + Some(&Value::String("worker-1".to_string())) + ); + assert_eq!( + sparse.get("2"), + Some(&Value::String("claude-3-sonnet".to_string())) + ); + } + + #[test] + fn test_subagent_event_values_from_sparse() { + use super::PosEncoded; + let mut sparse = SparseArray::new(); + sparse.insert("0".to_string(), Value::String("start".to_string())); + sparse.insert("1".to_string(), Value::String("sub-456".to_string())); + sparse.insert("2".to_string(), Value::String("claude-3-opus".to_string())); + let values = ::from_sparse(&sparse); + assert_eq!(values.event_type, Some(Some("start".to_string()))); + assert_eq!(values.subagent_id, Some(Some("sub-456".to_string()))); + assert_eq!( + values.subagent_model, + Some(Some("claude-3-opus".to_string())) + ); + } + + #[test] + fn test_subagent_event_values_roundtrip() { + use super::PosEncoded; + let original = SubagentEventValues::new() + .event_type("start") + .subagent_id("agent-abc") + .subagent_model("gpt-4"); + let sparse = PosEncoded::to_sparse(&original); + let restored = ::from_sparse(&sparse); + assert_eq!(restored.event_type, Some(Some("start".to_string()))); + assert_eq!(restored.subagent_id, Some(Some("agent-abc".to_string()))); + assert_eq!(restored.subagent_model, Some(Some("gpt-4".to_string()))); + } + + #[test] + fn test_subagent_event_id() { + assert_eq!( + SubagentEventValues::event_id(), + MetricEventId::SubagentEvent + ); + assert_eq!(SubagentEventValues::event_id() as u16, 9); + } + + #[test] + fn test_subagent_event_values_with_nulls() { + let values = SubagentEventValues::new() + .event_type("start") + .subagent_id_null() + .subagent_model_null(); + assert_eq!(values.event_type, Some(Some("start".to_string()))); + assert_eq!(values.subagent_id, Some(None)); + assert_eq!(values.subagent_model, Some(None)); + } } diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 2d33bc062..6cf93e052 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -13,7 +13,10 @@ pub mod types; // Re-export all public types for external crates pub use attrs::EventAttributes; -pub use events::{AgentUsageValues, CheckpointValues, CommittedValues, InstallHooksValues}; +pub use events::{ + AgentUsageValues, CheckpointValues, CommittedValues, InstallHooksValues, McpInvocationValues, + NewMessageValues, SkillUsedValues, SubagentEventValues, ToolCallValues, +}; pub use pos_encoded::PosEncoded; pub use types::{EventValues, METRICS_API_VERSION, MetricEvent, MetricsBatch}; diff --git a/src/metrics/types.rs b/src/metrics/types.rs index eb757072f..a697838fc 100644 --- a/src/metrics/types.rs +++ b/src/metrics/types.rs @@ -20,6 +20,11 @@ pub enum MetricEventId { AgentUsage = 2, InstallHooks = 3, Checkpoint = 4, + ToolCall = 5, + McpInvocation = 6, + NewMessage = 7, + SkillUsed = 8, + SubagentEvent = 9, } /// Trait for event-specific values. @@ -159,6 +164,11 @@ mod tests { assert_eq!(MetricEventId::AgentUsage as u16, 2); assert_eq!(MetricEventId::InstallHooks as u16, 3); assert_eq!(MetricEventId::Checkpoint as u16, 4); + assert_eq!(MetricEventId::ToolCall as u16, 5); + assert_eq!(MetricEventId::McpInvocation as u16, 6); + assert_eq!(MetricEventId::NewMessage as u16, 7); + assert_eq!(MetricEventId::SkillUsed as u16, 8); + assert_eq!(MetricEventId::SubagentEvent as u16, 9); } #[test] From 04c2f837653a5805d7e409114c786a9e04322bb5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:44:13 +0000 Subject: [PATCH 2/2] Fix clippy collapsible_if warnings Co-Authored-By: Sasha Varlamov --- src/commands/checkpoint.rs | 226 +++++++++++++++++----------------- src/mdm/agents/claude_code.rs | 8 +- src/mdm/agents/gemini.rs | 8 +- 3 files changed, 120 insertions(+), 122 deletions(-) diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index f9caaf4b7..3379c309c 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -488,136 +488,134 @@ pub fn run( } } - if let Some(agent_run) = agent_run_result.as_ref() { - if let Some(hook_meta) = agent_run.hook_metadata.as_ref() { - let hook_event_name = hook_meta.hook_event_name.as_deref(); - let hook_tool_name = hook_meta.tool_name.as_deref(); - - let is_user_message = matches!( - hook_event_name, - Some("UserPromptSubmit") - | Some("beforeSubmitPrompt") - | Some("before_submit_prompt") - | Some("BeforeSubmitPrompt") - | Some("BeforeModel") - ); - let is_ai_message = matches!( - hook_event_name, - Some("Stop") | Some("SessionEnd") | Some("afterSubmitPrompt") | Some("AfterModel") - ); + if let Some(agent_run) = agent_run_result.as_ref() + && let Some(hook_meta) = agent_run.hook_metadata.as_ref() + { + let hook_event_name = hook_meta.hook_event_name.as_deref(); + let hook_tool_name = hook_meta.tool_name.as_deref(); + + let is_user_message = matches!( + hook_event_name, + Some("UserPromptSubmit") + | Some("beforeSubmitPrompt") + | Some("before_submit_prompt") + | Some("BeforeSubmitPrompt") + | Some("BeforeModel") + ); + let is_ai_message = matches!( + hook_event_name, + Some("Stop") | Some("SessionEnd") | Some("afterSubmitPrompt") | Some("AfterModel") + ); - if is_user_message { - let values = crate::metrics::NewMessageValues::new().role("human"); - crate::metrics::record(values, attrs.clone()); + if is_user_message { + let values = crate::metrics::NewMessageValues::new().role("human"); + crate::metrics::record(values, attrs.clone()); - if should_emit_agent_usage(&agent_run.agent_id) { - let values = crate::metrics::AgentUsageValues::new(); - crate::metrics::record(values, attrs.clone()); - } - } else if is_ai_message { - let values = crate::metrics::NewMessageValues::new().role("ai"); + if should_emit_agent_usage(&agent_run.agent_id) { + let values = crate::metrics::AgentUsageValues::new(); crate::metrics::record(values, attrs.clone()); + } + } else if is_ai_message { + let values = crate::metrics::NewMessageValues::new().role("ai"); + crate::metrics::record(values, attrs.clone()); - if should_emit_agent_usage(&agent_run.agent_id) { - let values = crate::metrics::AgentUsageValues::new(); - crate::metrics::record(values, attrs.clone()); - } + if should_emit_agent_usage(&agent_run.agent_id) { + let values = crate::metrics::AgentUsageValues::new(); + crate::metrics::record(values, attrs.clone()); } + } - let is_tool_call = matches!( - hook_event_name, - Some("PostToolUse") - | Some("AfterTool") - | Some("afterFileEdit") - | Some("after_file_edit") - | Some("AfterToolUse") - ); + let is_tool_call = matches!( + hook_event_name, + Some("PostToolUse") + | Some("AfterTool") + | Some("afterFileEdit") + | Some("after_file_edit") + | Some("AfterToolUse") + ); - if is_tool_call { - if let Some(tool_name) = hook_tool_name { - let values = crate::metrics::ToolCallValues::new().tool_name(tool_name); - crate::metrics::record(values, attrs.clone()); - - if let Some(skill_name) = tool_name - .strip_prefix("skill__") - .or_else(|| tool_name.strip_prefix("skill:")) - { - let values = crate::metrics::SkillUsedValues::new().skill_name(skill_name); - crate::metrics::record(values, attrs.clone()); - } + if is_tool_call && let Some(tool_name) = hook_tool_name { + let values = crate::metrics::ToolCallValues::new().tool_name(tool_name); + crate::metrics::record(values, attrs.clone()); - if let Some((server_name, mcp_tool)) = parse_mcp_tool_name(tool_name) { - let values = crate::metrics::McpInvocationValues::new() - .server_name(server_name) - .tool_name(mcp_tool); - crate::metrics::record(values, attrs.clone()); - } - } + if let Some(skill_name) = tool_name + .strip_prefix("skill__") + .or_else(|| tool_name.strip_prefix("skill:")) + { + let values = crate::metrics::SkillUsedValues::new().skill_name(skill_name); + crate::metrics::record(values, attrs.clone()); } - let is_mcp_hook = matches!( - hook_event_name, - Some("beforeMCPExecution") - | Some("BeforeMCPExecution") - | Some("before_mcp_execution") - | Some("AfterMCPExecution") - ); - - if is_mcp_hook { - let server = agent_run - .agent_metadata - .as_ref() - .and_then(|m| m.get("mcp_server_name").cloned()); - let tool = agent_run - .agent_metadata - .as_ref() - .and_then(|m| m.get("mcp_tool_name").cloned()); - - if let (Some(server_name), Some(tool_name)) = (server, tool) { - let values = crate::metrics::McpInvocationValues::new() - .server_name(server_name) - .tool_name(tool_name); - crate::metrics::record(values, attrs.clone()); - } else if let Some(tool_name) = hook_tool_name { - if let Some((server_name, mcp_tool)) = parse_mcp_tool_name(tool_name) { - let values = crate::metrics::McpInvocationValues::new() - .server_name(server_name) - .tool_name(mcp_tool); - crate::metrics::record(values, attrs.clone()); - } - } + if let Some((server_name, mcp_tool)) = parse_mcp_tool_name(tool_name) { + let values = crate::metrics::McpInvocationValues::new() + .server_name(server_name) + .tool_name(mcp_tool); + crate::metrics::record(values, attrs.clone()); } + } - let is_subagent = matches!( - hook_event_name, - Some("SubagentStart") | Some("SubagentStop") - ); - if is_subagent { - let event_type = if hook_event_name == Some("SubagentStart") { - "start" - } else { - "stop" - }; - - let mut values = crate::metrics::SubagentEventValues::new().event_type(event_type); - if let Some(sub_id) = agent_run - .agent_metadata - .as_ref() - .and_then(|m| m.get("subagent_id")) - { - values = values.subagent_id(sub_id.as_str()); - } - if let Some(sub_model) = agent_run - .agent_metadata - .as_ref() - .and_then(|m| m.get("subagent_model")) - { - values = values.subagent_model(sub_model.as_str()); - } + let is_mcp_hook = matches!( + hook_event_name, + Some("beforeMCPExecution") + | Some("BeforeMCPExecution") + | Some("before_mcp_execution") + | Some("AfterMCPExecution") + ); + if is_mcp_hook { + let server = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("mcp_server_name").cloned()); + let tool = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("mcp_tool_name").cloned()); + + if let (Some(server_name), Some(tool_name)) = (server, tool) { + let values = crate::metrics::McpInvocationValues::new() + .server_name(server_name) + .tool_name(tool_name); + crate::metrics::record(values, attrs.clone()); + } else if let Some(tool_name) = hook_tool_name + && let Some((server_name, mcp_tool)) = parse_mcp_tool_name(tool_name) + { + let values = crate::metrics::McpInvocationValues::new() + .server_name(server_name) + .tool_name(mcp_tool); crate::metrics::record(values, attrs.clone()); } } + + let is_subagent = matches!( + hook_event_name, + Some("SubagentStart") | Some("SubagentStop") + ); + if is_subagent { + let event_type = if hook_event_name == Some("SubagentStart") { + "start" + } else { + "stop" + }; + + let mut values = crate::metrics::SubagentEventValues::new().event_type(event_type); + if let Some(sub_id) = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("subagent_id")) + { + values = values.subagent_id(sub_id.as_str()); + } + if let Some(sub_model) = agent_run + .agent_metadata + .as_ref() + .and_then(|m| m.get("subagent_model")) + { + values = values.subagent_model(sub_model.as_str()); + } + + crate::metrics::record(values, attrs.clone()); + } } let agent_tool = if kind != CheckpointKind::Human diff --git a/src/mdm/agents/claude_code.rs b/src/mdm/agents/claude_code.rs index d47afe10a..75dcccd5b 100644 --- a/src/mdm/agents/claude_code.rs +++ b/src/mdm/agents/claude_code.rs @@ -309,10 +309,10 @@ impl HookInstaller for ClaudeCodeInstaller { None => { let first = hook_type_array.first_mut(); if let Some(item) = first { - if let Some(obj) = item.as_object_mut() { - if !obj.contains_key("hooks") { - obj.insert("hooks".to_string(), Value::Array(vec![])); - } + if let Some(obj) = item.as_object_mut() + && !obj.contains_key("hooks") + { + obj.insert("hooks".to_string(), Value::Array(vec![])); } if let Some(hooks_array) = diff --git a/src/mdm/agents/gemini.rs b/src/mdm/agents/gemini.rs index a3d06e309..b8aac61f2 100644 --- a/src/mdm/agents/gemini.rs +++ b/src/mdm/agents/gemini.rs @@ -326,10 +326,10 @@ impl HookInstaller for GeminiInstaller { None => { let first = hook_type_array.first_mut(); if let Some(item) = first { - if let Some(obj) = item.as_object_mut() { - if !obj.contains_key("hooks") { - obj.insert("hooks".to_string(), Value::Array(vec![])); - } + if let Some(obj) = item.as_object_mut() + && !obj.contains_key("hooks") + { + obj.insert("hooks".to_string(), Value::Array(vec![])); } if let Some(hooks_array) =