diff --git a/cursor-plugin/README.md b/cursor-plugin/README.md index 9ec9a09f..1dfb73c4 100644 --- a/cursor-plugin/README.md +++ b/cursor-plugin/README.md @@ -55,6 +55,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` "mcpAllowlist": [ "tracedecay:tracedecay_active_project", "tracedecay:tracedecay_affected", + "tracedecay:tracedecay_automation_run_artifact_view", "tracedecay:tracedecay_body", "tracedecay:tracedecay_branch_diff", "tracedecay:tracedecay_branch_list", @@ -89,6 +90,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` "tracedecay:tracedecay_gini", "tracedecay:tracedecay_god_class", "tracedecay:tracedecay_health", + "tracedecay:tracedecay_hermes_skill_bridge", "tracedecay:tracedecay_hotspots", "tracedecay:tracedecay_impact", "tracedecay:tracedecay_implementations", @@ -123,6 +125,8 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` "tracedecay:tracedecay_signature_search", "tracedecay:tracedecay_similar", "tracedecay:tracedecay_simplify_scan", + "tracedecay:tracedecay_skill_list", + "tracedecay:tracedecay_skill_view", "tracedecay:tracedecay_status", "tracedecay:tracedecay_storage_status", "tracedecay:tracedecay_test_map", diff --git a/src/automation/skill_usage.rs b/src/automation/skill_usage.rs index dcc627c8..846ecf9c 100644 --- a/src/automation/skill_usage.rs +++ b/src/automation/skill_usage.rs @@ -10,6 +10,7 @@ use crate::tracedecay::current_timestamp; mod analytics; mod recommendations; +pub(crate) use analytics::analytics_import_key_for_request; pub use analytics::{ingest_analytics_events, ingest_project_analytics_events}; pub use recommendations::{skill_improvement_recommendations, stale_skill_recommendations}; @@ -236,28 +237,41 @@ pub async fn record_skill_usage( _actor: impl Into, targets: Vec, target: Option, - _metadata: Option, + metadata: Option, ) -> Result { let skill_id = skill.metadata.id.clone(); let timestamp = current_timestamp(); let mut ledger = load_skill_usage_ledger(profile_root).await?; - let record = ledger - .records - .entry(skill_id.clone()) - .or_insert_with(|| SkillUsageRecord::new(skill_id, timestamp)); - record.merge_skill_metadata(skill); - record.record(&SkillUsageEvent { - skill_name: skill.metadata.id.clone(), - action, - timestamp, - target, - }); - for target in targets { - if let Some(target) = normalize_target(&target) { - insert_sorted_unique(&mut record.targets, target); + let updated = { + let record = ledger + .records + .entry(skill_id.clone()) + .or_insert_with(|| SkillUsageRecord::new(skill_id, timestamp)); + record.merge_skill_metadata(skill); + record.record(&SkillUsageEvent { + skill_name: skill.metadata.id.clone(), + action, + timestamp, + target, + }); + for target in targets { + if let Some(target) = normalize_target(&target) { + insert_sorted_unique(&mut record.targets, target); + } } + record.clone() + }; + if let Some(import_key) = metadata + .as_ref() + .and_then(|metadata| metadata.get("imported_analytics_event_key")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|key| !key.is_empty()) + { + ledger + .imported_analytics_events + .insert(import_key.to_string()); } - let updated = record.clone(); save_skill_usage_ledger(profile_root, &ledger).await?; Ok(updated) } diff --git a/src/automation/skill_usage/analytics.rs b/src/automation/skill_usage/analytics.rs index cf5b4953..5f2ce944 100644 --- a/src/automation/skill_usage/analytics.rs +++ b/src/automation/skill_usage/analytics.rs @@ -103,9 +103,12 @@ fn analytics_import_key( action: SkillUsageAction, ) -> String { if let Some(request_id) = analytics_request_id(event) { - return format!( - "{}:{}:request:{request_id}:{}:{:?}", - event.project_id, event.provider, skill_id, action + return analytics_import_key_for_request( + &event.project_id, + &event.provider, + &request_id, + skill_id, + action, ); } format!( @@ -114,6 +117,16 @@ fn analytics_import_key( ) } +pub(crate) fn analytics_import_key_for_request( + project_id: &str, + provider: &str, + request_id: &str, + skill_id: &str, + action: SkillUsageAction, +) -> String { + format!("{project_id}:{provider}:request:{request_id}:{skill_id}:{action:?}") +} + fn should_skip_analytics_event(event: &AnalyticsEventRecord) -> bool { event.event_kind == "mcp_tool_call" && event @@ -136,10 +149,9 @@ fn analytics_request_id(event: &AnalyticsEventRecord) -> Option { .or_else(|| metadata.pointer("/metadata/request_id")) .or_else(|| metadata.pointer("/runtime/request_id")) .or_else(|| metadata.pointer("/function/request_id")) - .and_then(|value| value.as_str()) - .map(str::trim) + .and_then(request_id_value) + .map(|request_id| request_id.trim().to_string()) .filter(|request_id| !request_id.is_empty()) - .map(ToOwned::to_owned) } fn analytics_action(event: &AnalyticsEventRecord) -> SkillUsageAction { @@ -158,3 +170,11 @@ fn analytics_action(event: &AnalyticsEventRecord) -> SkillUsageAction { _ => SkillUsageAction::Use, } } + +fn request_id_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 650d89c6..e7a48e96 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -405,7 +405,7 @@ struct VersionCheckState { } /// The MCP server wrapping a `TraceDecay` instance. -// Lock ordering: file_token_map -> tool_call_counts (never nested) +// Lock ordering: file_token_map -> method/resource/tool call counts (never nested) pub struct McpServer { /// The served code graph. Guarded so a mid-session `git checkout` can /// hot-swap the instance onto the new branch's DB @@ -416,6 +416,8 @@ pub struct McpServer { /// each call is internally consistent. cg: tokio::sync::RwLock>, stats: ServerStats, + method_call_counts: std::sync::Mutex>, + resource_read_counts: std::sync::Mutex>, tool_call_counts: std::sync::Mutex>, /// Approximate token count per indexed file (`file_path` -> tokens). file_token_map: std::sync::Mutex>, @@ -544,6 +546,8 @@ impl McpServer { let server = Arc::new(Self { cg: tokio::sync::RwLock::new(Arc::new(cg)), stats: ServerStats::new(), + method_call_counts: std::sync::Mutex::new(HashMap::new()), + resource_read_counts: std::sync::Mutex::new(HashMap::new()), tool_call_counts: std::sync::Mutex::new(HashMap::new()), file_token_map: std::sync::Mutex::new(file_token_map), tokens_saved: AtomicU64::new(persisted), @@ -1285,6 +1289,9 @@ impl McpServer { "handle_request called with empty method" ); self.stats.total_requests.fetch_add(1, Ordering::Relaxed); + if let Ok(mut counts) = self.method_call_counts.lock() { + *counts.entry(request.method.clone()).or_insert(0) += 1; + } let id = request.id.clone()?; let result = match request.method.as_str() { @@ -1419,6 +1426,9 @@ impl McpServer { "missing 'uri' in resources/read params".to_string(), ); }; + if let Ok(mut counts) = self.resource_read_counts.lock() { + *counts.entry(uri.to_string()).or_insert(0) += 1; + } match uri { "tracedecay://status" => self.read_resource_status(id).await, @@ -1646,6 +1656,7 @@ impl McpServer { }; let arguments = params.get("arguments").cloned().unwrap_or(json!({})); + let analytics_arguments = arguments.clone(); let analytics_session_id = mcp_analytics_session_id(&arguments); // Branch-drift hot-swap: if the working tree switched branches since @@ -1675,10 +1686,19 @@ impl McpServer { } else { None }; + let mut handler_arguments = arguments; + if crate::analytics::is_skill_view_tool(tool_name) { + if let Some(request_id) = json_rpc_request_id_string(&id) { + if let Some(map) = handler_arguments.as_object_mut() { + map.insert("__mcp_request_id".to_string(), json!(request_id)); + } + } + } + let dispatch_outcome = handle_tool_call_with_registry( &cg, tool_name, - arguments, + handler_arguments, server_stats, self.scope_prefix(), self.registry_db.as_deref(), @@ -1765,6 +1785,7 @@ impl McpServer { net_saved_tokens, timestamp: ts, request_id: &request_id, + arguments: &analytics_arguments, }); self.spawn_observed_ledger_write(async move { gdb.record_savings( @@ -1917,6 +1938,7 @@ impl McpServer { analytics_session_id, tool_name, &request_id, + &analytics_arguments, ); tool_error_response(id, tool_name, &e) } @@ -1929,6 +1951,7 @@ impl McpServer { session_id: Option, tool_name: &str, request_id: &Value, + arguments: &Value, ) { let Some(gdb) = self.global_db.clone() else { return; @@ -1943,6 +1966,7 @@ impl McpServer { net_saved_tokens: 0, timestamp: crate::tracedecay::current_timestamp(), request_id, + arguments, }); self.spawn_observed_ledger_write(async move { if let Err(e) = gdb.append_analytics_event(&event).await { @@ -1968,18 +1992,45 @@ impl McpServer { /// Returns the current server runtime statistics as a JSON value. pub async fn server_stats_json(&self) -> Value { let uptime = self.stats.started_at.elapsed(); + let total_requests = self.stats.total_requests.load(Ordering::Relaxed); + let tool_calls = self.stats.tool_calls.load(Ordering::Relaxed); + let errors = self.stats.errors.load(Ordering::Relaxed); + let method_counts: Value = self + .method_call_counts + .lock() + .map(|counts| json!(*counts)) + .unwrap_or(json!({})); + let resource_counts: Value = self + .resource_read_counts + .lock() + .map(|counts| json!(*counts)) + .unwrap_or(json!({})); let tool_counts: Value = self .tool_call_counts .lock() .map(|counts| json!(*counts)) .unwrap_or(json!({})); + let ratio = |n: u64| { + if total_requests == 0 { + 0.0 + } else { + n as f64 / total_requests as f64 + } + }; let mut stats = json!({ "uptime_secs": uptime.as_secs(), - "total_requests": self.stats.total_requests.load(Ordering::Relaxed), - "tool_calls": self.stats.tool_calls.load(Ordering::Relaxed), - "errors": self.stats.errors.load(Ordering::Relaxed), + "total_requests": total_requests, + "jsonrpc_messages": total_requests, + "tool_calls": tool_calls, + "errors": errors, + "method_call_counts": method_counts, + "resource_read_counts": resource_counts, "tool_call_counts": tool_counts, + "ratios": { + "tool_calls_per_jsonrpc_message": ratio(tool_calls), + "errors_per_jsonrpc_message": ratio(errors), + }, "approx_tokens_saved": self.tokens_saved.load(Ordering::Relaxed), }); @@ -2036,10 +2087,24 @@ struct McpToolAnalyticsEvent<'a> { net_saved_tokens: u64, timestamp: i64, request_id: &'a Value, + arguments: &'a Value, } fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> AnalyticsEventInsert { let category = crate::accounting::classifier::classify(&[input.tool_name], &[]); + let mut metadata = json!({ + "request_id": input.request_id, + "before_tokens": input.raw_file_tokens, + "after_tokens": input.response_tokens, + "tokens_saved": input.net_saved_tokens, + }); + if crate::analytics::is_skill_view_tool(input.tool_name) { + metadata["arguments"] = input.arguments.clone(); + metadata["function"] = json!({ + "name": input.tool_name, + "arguments": input.arguments, + }); + } AnalyticsEventInsert { provider: "mcp".to_string(), project_id: GlobalDb::canonical_project_key(input.project_root), @@ -2053,15 +2118,15 @@ fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> AnalyticsEventI hint_category: None, hint_id: None, outcome: Some(input.outcome.to_string()), - metadata_json: Some( - json!({ - "request_id": input.request_id, - "before_tokens": input.raw_file_tokens, - "after_tokens": input.response_tokens, - "tokens_saved": input.net_saved_tokens, - }) - .to_string(), - ), + metadata_json: Some(metadata.to_string()), + } +} + +fn json_rpc_request_id_string(id: &Value) -> Option { + match id { + Value::String(id) => Some(id.clone()), + Value::Number(id) => Some(id.to_string()), + _ => None, } } diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index f45dac4c..3d9a970b 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -296,6 +296,10 @@ pub fn get_tool_definitions() -> Vec { def_fact_store(), def_fact_feedback(), def_memory_status(), + def_automation_run_artifact_view(), + def_skill_list(), + def_skill_view(), + def_hermes_skill_bridge(), def_dashboard(), def_message_search(), def_lcm_status(), @@ -2172,11 +2176,107 @@ fn def_memory_status() -> ToolDefinition { ) } +fn def_automation_run_artifact_view() -> ToolDefinition { + def( + "tracedecay_automation_run_artifact_view", + "Automation Run Artifact View", + "Read and hash-verify one durable automation run artifact payload from the active project's dashboard sidecar. Returns the run id, artifact metadata, and JSON payload without mutating automation state. Human/operator equivalents: `tracedecay automation runs artifact --json` and `GET /api/automation/runs/{run_id}/artifacts/{kind}`.", + json!({ + "type": "object", + "properties": { + "run_id": { + "type": "string", + "description": "Automation run id to inspect." + }, + "kind": { + "type": "string", + "description": "Artifact kind to read, such as traces, feedback, generated_evals, validation_gate, optimizer_diagnosis, or codex_handoff." + } + }, + "required": ["run_id", "kind"] + }), + ) +} + +fn skill_state_property() -> Value { + json!({ + "type": "string", + "enum": ["pending_approval", "active", "disabled", "archived"], + "description": "Optional managed-skill lifecycle state filter." + }) +} + +fn def_skill_list() -> ToolDefinition { + def( + "tracedecay_skill_list", + "Skill List", + "List agent-managed skills from the active TraceDecay profile. Returns metadata, lifecycle state, support-file paths, usage summary, stale/archive and improvement recommendation evidence, and optional body text without mutating the skill store.", + json!({ + "type": "object", + "properties": { + "state": skill_state_property(), + "include_body": { + "type": "boolean", + "description": "If true, include each skill's body_markdown in the list response (default: false)." + } + } + }), + ) +} + +fn def_skill_view() -> ToolDefinition { + def( + "tracedecay_skill_view", + "Skill View", + "Read one agent-managed skill package from the active TraceDecay profile. Returns full metadata, body text, usage summary, stale/archive and improvement recommendation evidence, and support files by default.", + json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Managed skill id to read." + }, + "include_support_files": { + "type": "boolean", + "description": "If false, omit support file byte payloads from the response (default: true)." + } + }, + "required": ["id"] + }), + ) +} + +fn def_hermes_skill_bridge() -> ToolDefinition { + def( + "tracedecay_hermes_skill_bridge", + "Hermes Skill Bridge", + "Read Hermes-owned profile skill state without mutating Hermes. Returns skill summaries, pending skill approval records, usage telemetry, archive count, and the explicit host-owned lifecycle contract. Requires an absolute hermes_home path.", + json!({ + "type": "object", + "properties": { + "hermes_home": { + "type": "string", + "description": "Absolute path to the Hermes profile home whose skills/ and pending/skills/ stores should be inspected." + }, + "include_skill_bodies": { + "type": "boolean", + "description": "If true, include SKILL.md contents capped at the Hermes bridge body limit (default: false)." + }, + "include_pending_payloads": { + "type": "boolean", + "description": "If true, include staged skill write replay payloads from pending/skills (default: false)." + } + }, + "required": ["hermes_home"] + }), + ) +} + fn def_message_search() -> ToolDefinition { def( "tracedecay_message_search", "Message Search", - "Search ingested Cursor/Codex/agent transcript messages. Defaults to all transcript providers in the active project's session-message FTS index; pass provider to constrain one provider, or project_id/project_path only when intentionally searching another registered project.", + "Search ingested transcript messages across all supported providers by default. Every search first catches up all supported provider adapters for the selected project; pass provider only when explicitly scoping results to one provider.", json!({ "type": "object", "properties": { @@ -2186,8 +2286,8 @@ fn def_message_search() -> ToolDefinition { }, "provider": { "type": "string", - "description": "Optional message provider to search. Omit or use 'all' to search all ingested providers. Use 'hermes' for Hermes agent conversation history ingested from per-profile state.db stores.", - "enum": ["all", "cursor", "claude", "codex", "vibe", "cline", "roo-code", "kilo", "hermes"] + "description": "Optional explicit result scope. Omit or use 'all' for unified cross-provider recall; even scoped searches still ingest all supported providers first. Use 'hermes' for Hermes agent conversation history ingested from per-profile state.db stores.", + "enum": ["all", "cursor", "claude", "codex", "vibe", "cline", "roo-code", "kilo", "kiro", "hermes"] }, "project_key": { "type": "string", diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index a175028e..8e63269b 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -14,6 +14,7 @@ pub mod info; pub mod memory; pub mod redundancy; pub mod session; +pub mod skills; mod support; pub mod workflow; @@ -313,6 +314,12 @@ pub async fn handle_tool_call_with_registry( "tracedecay_memory_status" => { memory::handle_memory_status(cg, args, global_db, allow_default_registry_fallback).await } + "tracedecay_automation_run_artifact_view" => { + skills::handle_automation_run_artifact_view(cg, args).await + } + "tracedecay_skill_list" => skills::handle_skill_list(cg, args).await, + "tracedecay_skill_view" => skills::handle_skill_view(cg, args).await, + "tracedecay_hermes_skill_bridge" => skills::handle_hermes_skill_bridge(cg, &args), "tracedecay_dashboard" => dashboard::handle_dashboard(cg, args).await, "tracedecay_message_search" => { session::handle_message_search(cg, args, global_db, allow_default_registry_fallback) @@ -807,7 +814,7 @@ mod tests { // host CLI capabilities they need; agents should never see a tool that // will instantly fail. The count and per-tool checks below adapt to // the host's capability set. - let expected_total = 93 + usize::from(super::super::definitions::ast_grep_available()); + let expected_total = 97 + usize::from(super::super::definitions::ast_grep_available()); assert_eq!(tools.len(), expected_total); let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 4072b746..b341bb69 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -1384,7 +1384,7 @@ pub(super) async fn handle_message_search( .ok_or_else(|| TraceDecayError::Config { message: "missing required parameter: query".to_string(), })?; - let provider = optional_search_provider_arg(&args); + let requested_provider = optional_search_provider_arg(&args); let project_key = args .get("project_key") .and_then(Value::as_str) @@ -1439,14 +1439,8 @@ pub(super) async fn handle_message_search( }), )); }; - if provider.is_none() || provider == Some("hermes") { - // Hermes history lives in per-profile state.db stores normally swept - // by the serve/dashboard startup catch-ups; an incremental - // search-time catch-up makes the `tracedecay tool` / generated-plugin - // path self-sufficient (cursor-based, so it is cheap when fresh). - let _ = crate::sessions::hermes::ingest_for_project(&db, &target_root).await; - } - let results = if let Some(provider) = provider { + let _ = crate::sessions::ingest_global_sources(&db, &target_root).await; + let results = if let Some(provider) = requested_provider { db.search_session_messages_filtered( provider, project_key, @@ -1471,7 +1465,8 @@ pub(super) async fn handle_message_search( Some(&target_root), &json!({ "status": "ok", - "provider": provider.unwrap_or("all"), + "provider": requested_provider.unwrap_or("all"), + "requested_provider": requested_provider, "selected_project_root": target_root, "project_key": project_key, "parent_session_id": parent_session_id, diff --git a/src/mcp/tools/handlers/skills.rs b/src/mcp/tools/handlers/skills.rs new file mode 100644 index 00000000..d9ef7162 --- /dev/null +++ b/src/mcp/tools/handlers/skills.rs @@ -0,0 +1,260 @@ +//! Handlers for read-only managed-skill MCP tools. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::automation::hermes_bridge::{load_hermes_skill_bridge, HermesSkillBridgeOptions}; +use crate::automation::managed_skills::{ + list_managed_skills, load_managed_skill, ManagedSkill, ManagedSkillState, +}; +use crate::automation::run_ledger::{find_run_record, read_run_artifact_payload}; +use crate::automation::skill_usage::{ + analytics_import_key_for_request, ingest_project_analytics_events, record_skill_usage, + skill_improvement_recommendations, stale_skill_recommendations, summarize_skill_usage, + summarize_skill_usage_for, SkillUsageAction, +}; +use crate::errors::{Result, TraceDecayError}; +use crate::mcp::tools::ToolResult; +use crate::tracedecay::TraceDecay; + +const SKILL_ANALYTICS_IMPORT_LIMIT: usize = 10_000; +const STALE_SKILL_AFTER_SECS: i64 = 60 * 60 * 24 * 90; + +fn config_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} + +fn tool_json(value: &Value) -> ToolResult { + let formatted = serde_json::to_string_pretty(value).unwrap_or_default(); + ToolResult { + value: json!({ "content": [{ "type": "text", "text": formatted }] }), + touched_files: vec![], + } +} + +fn optional_bool(args: &Value, key: &str, default: bool) -> bool { + args.get(key).and_then(Value::as_bool).unwrap_or(default) +} + +fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> { + args.get(key) + .and_then(Value::as_str) + .ok_or_else(|| config_error(format!("missing required parameter: {key}"))) +} + +fn parse_state(args: &Value) -> Result> { + let Some(state) = args.get("state").and_then(Value::as_str) else { + return Ok(None); + }; + match state { + "pending_approval" => Ok(Some(ManagedSkillState::PendingApproval)), + "active" => Ok(Some(ManagedSkillState::Active)), + "disabled" => Ok(Some(ManagedSkillState::Disabled)), + "archived" => Ok(Some(ManagedSkillState::Archived)), + other => Err(config_error(format!( + "unknown managed skill state: {other}" + ))), + } +} + +fn skill_summary(skill: &ManagedSkill, include_body: bool, usage_summary: &Value) -> Value { + let mut summary = json!({ + "metadata": skill.metadata, + "support_file_count": skill.support_files.len(), + "support_file_paths": skill + .support_files + .iter() + .map(|support| support.path.display().to_string()) + .collect::>(), + "usage_summary": usage_summary, + }); + if include_body { + summary["body_markdown"] = json!(skill.body_markdown); + } + summary +} + +fn json_by_skill( + items: &[T], + skill_id: impl Fn(&T) -> &str, +) -> BTreeMap { + items + .iter() + .map(|item| (skill_id(item).to_string(), json!(item))) + .collect() +} + +pub(super) async fn handle_skill_list(cg: &TraceDecay, args: Value) -> Result { + let profile_root = crate::storage::default_profile_root()?; + sync_project_skill_analytics(cg, &profile_root).await?; + let state = parse_state(&args)?; + let include_body = optional_bool(&args, "include_body", false); + let mut skills = list_managed_skills(&profile_root).await?; + if let Some(state) = state { + skills.retain(|skill| skill.metadata.state == state); + } + let usage_summaries = summarize_skill_usage(&profile_root, &skills).await?; + let recommendations = stale_skill_recommendations( + &usage_summaries, + crate::tracedecay::current_timestamp(), + STALE_SKILL_AFTER_SECS, + ); + let improvement_recommendations = skill_improvement_recommendations(&usage_summaries); + let usage_by_skill = json_by_skill(&usage_summaries, |summary| &summary.skill_id); + let recommendation_by_skill = + json_by_skill(&recommendations, |recommendation| &recommendation.skill_id); + let improvement_by_skill = json_by_skill(&improvement_recommendations, |recommendation| { + &recommendation.skill_id + }); + let payload = json!({ + "status": "ok", + "profile_root": profile_root, + "count": skills.len(), + "skills": skills + .iter() + .map(|skill| { + let skill_id = &skill.metadata.id; + let usage_summary = usage_by_skill + .get(skill_id) + .cloned() + .unwrap_or(Value::Null); + let stale_recommendation = recommendation_by_skill + .get(skill_id) + .cloned() + .unwrap_or(Value::Null); + let improvement_recommendation = improvement_by_skill + .get(skill_id) + .cloned() + .unwrap_or(Value::Null); + let mut summary = skill_summary(skill, include_body, &usage_summary); + summary["stale_recommendation"] = stale_recommendation; + summary["improvement_recommendation"] = improvement_recommendation; + summary + }) + .collect::>(), + }); + Ok(tool_json(&payload)) +} + +pub(super) async fn handle_skill_view(cg: &TraceDecay, args: Value) -> Result { + let profile_root = crate::storage::default_profile_root()?; + sync_project_skill_analytics(cg, &profile_root).await?; + let include_support_files = optional_bool(&args, "include_support_files", true); + let mut skill = load_managed_skill(&profile_root, required_str(&args, "id")?).await?; + let targets = skill + .metadata + .targets + .iter() + .map(|target| target.prompt_label().to_string()) + .collect::>(); + record_skill_usage( + &profile_root, + &skill, + SkillUsageAction::View, + "mcp", + targets, + Some("mcp".to_string()), + Some(json!({ + "tool": "tracedecay_skill_view", + "include_support_files": include_support_files, + "imported_analytics_event_key": args + .get("__mcp_request_id") + .and_then(Value::as_str) + .map(|request_id| analytics_import_key_for_request( + &crate::global_db::GlobalDb::canonical_project_key(cg.project_root()), + "mcp", + request_id, + &skill.metadata.id, + SkillUsageAction::View, + )), + })), + ) + .await?; + let usage_summary = summarize_skill_usage_for(&profile_root, &skill).await?; + let stale_recommendation = stale_skill_recommendations( + std::slice::from_ref(&usage_summary), + crate::tracedecay::current_timestamp(), + STALE_SKILL_AFTER_SECS, + ) + .into_iter() + .next(); + let improvement_recommendation = + skill_improvement_recommendations(std::slice::from_ref(&usage_summary)) + .into_iter() + .next(); + if !include_support_files { + skill.support_files.clear(); + } + let payload = json!({ + "status": "ok", + "profile_root": profile_root, + "skill": skill, + "usage_summary": usage_summary, + "stale_recommendation": stale_recommendation, + "improvement_recommendation": improvement_recommendation, + "support_files_included": include_support_files, + }); + Ok(tool_json(&payload)) +} + +pub(super) async fn handle_automation_run_artifact_view( + cg: &TraceDecay, + args: Value, +) -> Result { + let run_id = required_str(&args, "run_id")?; + let kind = required_str(&args, "kind")?; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let record = find_run_record(&dashboard_root, run_id) + .await? + .ok_or_else(|| config_error(format!("automation run not found: {run_id}")))?; + let artifact = record + .artifacts + .iter() + .find(|artifact| artifact.kind == kind) + .ok_or_else(|| { + config_error(format!( + "automation run artifact not found: {run_id}/{kind}" + )) + })?; + let payload = read_run_artifact_payload(&dashboard_root, &record.run_id, artifact).await?; + let payload = json!({ + "status": "ok", + "run_id": record.run_id, + "artifact": artifact, + "payload": payload, + }); + Ok(tool_json(&payload)) +} + +async fn sync_project_skill_analytics(cg: &TraceDecay, profile_root: &Path) -> Result<()> { + let global_db = crate::global_db::GlobalDb::open().await; + ingest_project_analytics_events( + profile_root, + cg.project_root(), + global_db.as_ref(), + SKILL_ANALYTICS_IMPORT_LIMIT, + ) + .await + .map(|_| ()) +} + +pub(super) fn handle_hermes_skill_bridge(_cg: &TraceDecay, args: &Value) -> Result { + let hermes_home = required_str(args, "hermes_home")?; + let snapshot = load_hermes_skill_bridge( + Path::new(hermes_home), + HermesSkillBridgeOptions { + include_skill_bodies: optional_bool(args, "include_skill_bodies", false), + include_pending_payloads: optional_bool(args, "include_pending_payloads", false), + }, + )?; + let payload = json!({ + "status": "ok", + "bridge": snapshot, + }); + Ok(tool_json(&payload)) +} diff --git a/tests/mcp_cli_serve_test.rs b/tests/mcp_cli_serve_test.rs index 94c407f1..af2b51b0 100644 --- a/tests/mcp_cli_serve_test.rs +++ b/tests/mcp_cli_serve_test.rs @@ -13,11 +13,21 @@ use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; #[cfg(unix)] use tokio::sync::Mutex; +use tracedecay::automation::managed_skills::{ + approve_managed_skill, create_managed_skill_draft, ManagedSkillDraft, ManagedSkillProvenance, + ManagedSkillSource, ManagedSupportFile, +}; +use tracedecay::automation::run_ledger::{ + append_run_record, write_run_artifact, AutomationRunArtifactKind, AutomationRunLedgerRecord, + AutomationRunStatus, AutomationTrigger, +}; use tracedecay::db::Database; use tracedecay::global_db::GlobalDb; use tracedecay::mcp::handle_tool_call; use tracedecay::serve; -use tracedecay::storage::{write_enrollment_marker, EnrollmentMarker, StorageMode}; +use tracedecay::storage::{ + default_profile_sharded_layout, write_enrollment_marker, EnrollmentMarker, StorageMode, +}; use tracedecay::tracedecay::TraceDecay; #[cfg(unix)] @@ -82,6 +92,55 @@ fn runtime_project_root(stdout: &[u8], id: i64) -> String { .to_string() } +fn json_rpc_tool_payload(stdout: &[u8], id: i64) -> Value { + let stdout_text = String::from_utf8(stdout.to_vec()).unwrap(); + let response: Value = stdout_text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|response| response.get("id") == Some(&json!(id))) + .unwrap_or_else(|| panic!("missing JSON-RPC response {id} in stdout:\n{stdout_text}")); + assert!( + response.get("error").is_none(), + "JSON-RPC response {id} should not be an error: {response}" + ); + let content = response["result"]["content"] + .as_array() + .expect("tool result should include content items"); + for item in content { + let Some(text) = item["text"].as_str() else { + continue; + }; + let Some(json_start) = text.find('{').or_else(|| text.find('[')) else { + continue; + }; + if let Ok(payload) = serde_json::from_str(&text[json_start..]) { + return payload; + } + } + panic!("tool response {id} should include a JSON payload:\n{response}") +} + +fn managed_skill_stdio_draft(id: &str, title: &str) -> ManagedSkillDraft { + ManagedSkillDraft { + id: id.to_string(), + title: title.to_string(), + summary: format!("{title} summary."), + category: "maintenance".to_string(), + targets: tracedecay::automation::managed_skills::default_managed_skill_targets(), + body_markdown: format!("Use {title} before applying repository changes."), + support_files: vec![ManagedSupportFile::new( + "references/checklist.md", + b"- inspect context\n- run focused tests\n".to_vec(), + ) + .unwrap()], + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::AutomationRun, + actor: "tracedecay-stdio-test".to_string(), + run_id: Some("run_mcp_stdio_skill".to_string()), + }, + } +} + #[cfg(unix)] fn run_serve_runtime_with_initialize_root( home: &Path, @@ -324,6 +383,236 @@ async fn serve_without_daemon_socket_falls_back_to_in_process_mcp() { ); } +#[tokio::test] +async fn serve_stdio_smokes_managed_skill_list_and_view() { + let home = TempDir::new().unwrap(); + let project = init_project_with_file(home.path(), "pub fn skill_stdio_marker() {}\n").await; + let profile_root = profile_root(home.path()); + + create_managed_skill_draft( + &profile_root, + managed_skill_stdio_draft("pending-stdio-skill", "Pending stdio skill"), + ) + .await + .unwrap(); + create_managed_skill_draft( + &profile_root, + managed_skill_stdio_draft("active-stdio-skill", "Active stdio skill"), + ) + .await + .unwrap(); + approve_managed_skill(&profile_root, "active-stdio-skill") + .await + .unwrap(); + + let mut child = tracedecay_command_with_home(home.path()) + .arg("serve") + .arg("--path") + .arg(project.path()) + .env_remove("TRACEDECAY_DAEMON_SOCKET") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("tracedecay serve should run"); + { + let stdin = child.stdin.as_mut().expect("stdin should be piped"); + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + ) + .unwrap(); + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "tracedecay_skill_list", + "arguments": { "state": "active" } + } + }) + ) + .unwrap(); + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "tracedecay_skill_view", + "arguments": { + "id": "active-stdio-skill", + "include_support_files": false + } + } + }) + ) + .unwrap(); + } + + let output = child + .wait_with_output() + .expect("tracedecay serve should exit after stdin closes"); + assert!( + output.status.success(), + "serve skill stdio smoke failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let list = json_rpc_tool_payload(&output.stdout, 2); + assert_eq!(list["status"], "ok"); + assert_eq!(list["count"], 1); + assert_eq!(list["skills"][0]["metadata"]["id"], "active-stdio-skill"); + assert_eq!(list["skills"][0]["metadata"]["state"], "active"); + assert_eq!(list["skills"][0]["support_file_count"], 1); + assert!(list["skills"][0].get("body_markdown").is_none()); + + let view = json_rpc_tool_payload(&output.stdout, 3); + assert_eq!(view["status"], "ok"); + assert_eq!(view["skill"]["metadata"]["id"], "active-stdio-skill"); + assert!(view["skill"]["body_markdown"] + .as_str() + .unwrap() + .contains("Active stdio skill")); + assert_eq!(view["skill"]["support_files"].as_array().unwrap().len(), 0); + assert_eq!(view["support_files_included"], false); +} + +#[tokio::test] +async fn serve_stdio_smokes_automation_run_artifact_view() { + let home = TempDir::new().unwrap(); + let project = init_project_with_file(home.path(), "pub fn artifact_stdio_marker() {}\n").await; + let dashboard_root = default_profile_sharded_layout(project.path(), &profile_root(home.path())) + .unwrap() + .dashboard_root; + let run_id = "run-stdio-artifact"; + let artifact = write_run_artifact( + &dashboard_root, + run_id, + AutomationRunArtifactKind::CodexHandoff, + &json!({ + "status": "ready_for_review", + "next_actions": ["inspect stdio artifact payload"], + }), + Some("stdio handoff ready".to_string()), + "1782283200", + ) + .await + .unwrap(); + append_run_record( + &dashboard_root, + &AutomationRunLedgerRecord { + schema_version: 2, + run_id: run_id.to_string(), + trigger: AutomationTrigger::Dashboard, + task: tracedecay::automation::backend::AgentTaskKind::MemoryCurator, + task_key: Some("memory_curator".to_string()), + backend: "codex_app_server".to_string(), + host_mode: Some("standalone".to_string()), + prompt_version: Some("memory_curator:v1".to_string()), + response_schema: None, + strict_json: Some(true), + model: Some("test-model".to_string()), + status: AutomationRunStatus::Succeeded, + evidence_hash: Some("sha256:evidence".to_string()), + input_hash: Some("sha256:input".to_string()), + output_hash: Some("sha256:output".to_string()), + proposed_ops: Some(json!({"ops": []})), + applied_ops: None, + rejected_ops: None, + validation_report: None, + reviewed_count: 0, + accepted_count: 0, + rejected_count: 0, + skipped_count: 0, + error: None, + error_classification: None, + error_retryable: None, + fallback_status: None, + report_ref: None, + artifacts: vec![artifact], + started_at: "1782283199".to_string(), + completed_at: "1782283200".to_string(), + }, + ) + .await + .unwrap(); + + let mut child = tracedecay_command_with_home(home.path()) + .arg("serve") + .arg("--path") + .arg(project.path()) + .env_remove("TRACEDECAY_DAEMON_SOCKET") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("tracedecay serve should run"); + { + let stdin = child.stdin.as_mut().expect("stdin should be piped"); + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + ) + .unwrap(); + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "tracedecay_automation_run_artifact_view", + "arguments": { + "run_id": run_id, + "kind": "codex_handoff" + } + } + }) + ) + .unwrap(); + } + + let output = child + .wait_with_output() + .expect("tracedecay serve should exit after stdin closes"); + assert!( + output.status.success(), + "serve artifact stdio smoke failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let payload = json_rpc_tool_payload(&output.stdout, 2); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["run_id"], run_id); + assert_eq!(payload["artifact"]["kind"], "codex_handoff"); + assert_eq!(payload["payload"]["status"], "ready_for_review"); + assert_eq!( + payload["payload"]["next_actions"][0], + "inspect stdio artifact payload" + ); +} + #[cfg(unix)] #[tokio::test] async fn serve_daemon_proxy_reports_daemon_disconnect_as_json_rpc_error() { diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index 776c8194..d64a412d 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -17,19 +17,29 @@ use std::time::{Duration, SystemTime}; use serde_json::{json, Value}; use tempfile::TempDir; use tokio::sync::{Mutex, MutexGuard}; +use tracedecay::automation::managed_skills::{ + approve_managed_skill, create_managed_skill_draft, ManagedSkillDraft, ManagedSkillProvenance, + ManagedSkillSource, ManagedSupportFile, +}; +use tracedecay::automation::run_ledger::{ + append_run_record, write_run_artifact, AutomationRunArtifactKind, AutomationRunLedgerRecord, + AutomationRunStatus, AutomationTrigger, +}; +use tracedecay::automation::skill_usage::{ + load_skill_usage_record, record_skill_usage, SkillUsageAction, +}; use tracedecay::db::Database; use tracedecay::errors::TraceDecayError; use tracedecay::global_db::GlobalDb; use tracedecay::mcp::{get_tool_definitions, ToolResult}; use tracedecay::memory::store::MemoryStore; -use tracedecay::sessions::cursor::open_project_session_db; +use tracedecay::sessions::cursor::{cursor_project_slug, open_project_session_db}; use tracedecay::sessions::lcm::{ LcmLifecycleUpdate, LcmMaintenanceDebt, LcmSourceRef, LcmSummaryNodeDraft, }; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; use tracedecay::storage::{ - resolve_layout_for_current_profile, resolve_lcm_payload_root, resolve_project_session_db_path, - resolve_response_handle_root, + resolve_layout_for_current_profile, resolve_lcm_payload_root, resolve_response_handle_root, }; use tracedecay::tracedecay::TraceDecay; @@ -478,8 +488,13 @@ fn lcm_payload_dir(cg: &TraceDecay) -> PathBuf { } fn project_session_db_path(cg: &TraceDecay) -> PathBuf { - resolve_project_session_db_path(cg.project_root()) - .unwrap_or_else(|err| panic!("failed to resolve test project session DB path: {err}")) + cg.store_layout().sessions_db_path.clone() +} + +async fn open_active_project_session_db(cg: &TraceDecay) -> GlobalDb { + GlobalDb::open_at(&project_session_db_path(cg)) + .await + .expect("active project-local session db should open") } /// Creates a small Rust library with an integration-style test that calls a @@ -6400,9 +6415,7 @@ async fn memory_tools_validate_malformed_inputs() { #[tokio::test] async fn message_search_reads_project_local_session_db() { let (cg, _dir) = setup_project().await; - let db = open_project_session_db(cg.project_root()) - .await - .expect("project-local session db should open"); + let db = open_active_project_session_db(&cg).await; let session = SessionRecord { provider: "cursor".to_string(), session_id: "cursor-session".to_string(), @@ -6517,6 +6530,8 @@ async fn message_search_reads_project_local_session_db() { .unwrap(); let parsed = extract_json(&result.value); assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["provider"], "cursor"); + assert_eq!(parsed["requested_provider"], "cursor"); assert_eq!(parsed["count"], 1); assert_eq!( parsed["results"][0]["message"]["message_id"], @@ -6578,6 +6593,142 @@ async fn message_search_reads_project_local_session_db() { ); } +#[tokio::test] +async fn message_search_catches_up_provider_transcripts_before_querying() { + let (cg, _dir) = setup_project().await; + let home = cg.project_root().join("home"); + let project = cg.project_root().to_path_buf(); + let project_text = project.to_string_lossy(); + + let codex_dir = home.join(".codex/sessions/2026/01/01"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("rollout-2026-01-01T00-00-00-codex-catchup.jsonl"), + format!( + "{}\n{}\n", + json!({ + "timestamp": "2026-01-01T00:00:00.000Z", + "type": "session_meta", + "payload": {"id": "codex-catchup", "cwd": project_text} + }), + json!({ + "timestamp": "2026-01-01T00:00:01.000Z", + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "Codex provider catchup sees rsbuild recall evidence." + } + }) + ), + ) + .unwrap(); + + let cursor_slug = cursor_project_slug(&project).expect("test project should have cursor slug"); + let cursor_dir = home + .join(".cursor/projects") + .join(cursor_slug) + .join("agent-transcripts"); + fs::create_dir_all(&cursor_dir).unwrap(); + fs::write( + cursor_dir.join("cursor-catchup.jsonl"), + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"Cursor provider catchup sees rsbuild recall evidence."}]}} +"#, + ) + .unwrap(); + + let codex_result = handle_tool_call( + &cg, + "tracedecay_message_search", + json!({ + "query": "codex provider catchup", + "provider": "codex", + "limit": 5 + }), + None, + None, + ) + .await + .unwrap(); + let codex = extract_json(&codex_result.value); + assert_eq!(codex["status"], "ok"); + assert_eq!(codex["count"], 1); + assert_eq!(codex["results"][0]["message"]["provider"], "codex"); + + let requested_codex_unified_result = handle_tool_call( + &cg, + "tracedecay_message_search", + json!({ + "query": "rsbuild recall evidence", + "provider": "codex", + "limit": 5 + }), + None, + None, + ) + .await + .unwrap(); + let requested_codex_unified = extract_json(&requested_codex_unified_result.value); + assert_eq!(requested_codex_unified["status"], "ok"); + assert_eq!(requested_codex_unified["provider"], "codex"); + assert_eq!(requested_codex_unified["requested_provider"], "codex"); + let requested_codex_providers = requested_codex_unified["results"] + .as_array() + .unwrap() + .iter() + .map(|result| result["message"]["provider"].as_str().unwrap()) + .collect::>(); + assert!(requested_codex_providers.contains("codex")); + assert!(!requested_codex_providers.contains("cursor")); + let db = open_active_project_session_db(&cg).await; + let ingested_cursor = db + .search_session_messages("cursor", None, "cursor provider catchup", 10) + .await; + assert_eq!( + ingested_cursor.len(), + 1, + "provider-scoped search should still ingest every supported provider" + ); + + let cursor_result = handle_tool_call( + &cg, + "tracedecay_message_search", + json!({ + "query": "cursor provider catchup", + "provider": "cursor", + "limit": 5 + }), + None, + None, + ) + .await + .unwrap(); + let cursor = extract_json(&cursor_result.value); + assert_eq!(cursor["status"], "ok"); + assert_eq!(cursor["count"], 1); + assert_eq!(cursor["results"][0]["message"]["provider"], "cursor"); + + let all_result = handle_tool_call( + &cg, + "tracedecay_message_search", + json!({"query": "rsbuild recall evidence", "limit": 5}), + None, + None, + ) + .await + .unwrap(); + let all = extract_json(&all_result.value); + assert_eq!(all["status"], "ok"); + assert_eq!(all["provider"], "all"); + let providers = all["results"] + .as_array() + .unwrap() + .iter() + .map(|result| result["message"]["provider"].as_str().unwrap()) + .collect::>(); + assert!(providers.contains("codex")); + assert!(providers.contains("cursor")); +} + #[tokio::test] async fn message_search_reads_profile_sharded_session_db() { let _guard = GLOBAL_DB_ENV_LOCK.lock().await; @@ -6688,9 +6839,7 @@ async fn seed_lcm_session_message_for_provider( text: impl Into, ordinal: i64, ) { - let db = open_project_session_db(cg.project_root()) - .await - .expect("project-local session db should open"); + let db = open_active_project_session_db(cg).await; assert!( db.upsert_session(&SessionRecord { provider: provider.to_string(), @@ -6736,9 +6885,7 @@ async fn seed_lcm_tool_result_message( text: impl Into, ordinal: i64, ) { - let db = open_project_session_db(cg.project_root()) - .await - .expect("project-local session db should open"); + let db = open_active_project_session_db(cg).await; assert!( db.upsert_session(&SessionRecord { provider: "cursor".to_string(), @@ -6788,9 +6935,7 @@ async fn seed_lcm_session_message_with_role_source_timestamp( source: &str, timestamp: i64, ) { - let db = open_project_session_db(cg.project_root()) - .await - .expect("project-local session db should open"); + let db = open_active_project_session_db(cg).await; assert!( db.upsert_session(&SessionRecord { provider: "cursor".to_string(), @@ -9792,6 +9937,7 @@ async fn message_search_preserves_provider_project_parent_scope_shape_after_lcm( assert_eq!(payload["status"], "ok"); assert_eq!(payload["scope"], "subagents_only"); assert_eq!(payload["provider"], "cursor"); + assert_eq!(payload["requested_provider"], "cursor"); assert_eq!(payload["parent_session_id"], "parent"); assert_eq!(payload["results"].as_array().unwrap().len(), 1); assert!(payload["results"][0]["message"].get("text").is_some()); @@ -9999,6 +10145,552 @@ fn memory_tool_definitions_include_hermes_payload_fields() { ); } +fn managed_skill_test_draft(id: &str, title: &str) -> ManagedSkillDraft { + ManagedSkillDraft { + id: id.to_string(), + title: title.to_string(), + summary: format!("{title} summary."), + category: "maintenance".to_string(), + targets: tracedecay::automation::managed_skills::default_managed_skill_targets(), + body_markdown: format!("Use {title} before applying repository changes."), + support_files: vec![ManagedSupportFile::new( + "references/checklist.md", + b"- inspect context\n- run focused tests\n".to_vec(), + ) + .unwrap()], + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::AutomationRun, + actor: "tracedecay-test".to_string(), + run_id: Some("run_mcp_skill".to_string()), + }, + } +} + +#[test] +fn managed_skill_tool_definitions_are_read_only() { + let tools = get_tool_definitions(); + let artifact = tools + .iter() + .find(|tool| tool.name == "tracedecay_automation_run_artifact_view") + .expect("tracedecay_automation_run_artifact_view definition"); + let list = tools + .iter() + .find(|tool| tool.name == "tracedecay_skill_list") + .expect("tracedecay_skill_list definition"); + let view = tools + .iter() + .find(|tool| tool.name == "tracedecay_skill_view") + .expect("tracedecay_skill_view definition"); + let hermes_bridge = tools + .iter() + .find(|tool| tool.name == "tracedecay_hermes_skill_bridge") + .expect("tracedecay_hermes_skill_bridge definition"); + + assert_eq!(artifact.annotations.as_ref().unwrap()["readOnlyHint"], true); + assert_eq!(artifact.input_schema["required"], json!(["run_id", "kind"])); + assert_eq!(list.annotations.as_ref().unwrap()["readOnlyHint"], true); + assert_eq!(view.annotations.as_ref().unwrap()["readOnlyHint"], true); + assert_eq!( + hermes_bridge.annotations.as_ref().unwrap()["readOnlyHint"], + true + ); + assert_eq!( + list.input_schema["properties"]["state"]["enum"], + json!(["pending_approval", "active", "disabled", "archived"]) + ); + assert_eq!(view.input_schema["required"], json!(["id"])); + assert_eq!( + hermes_bridge.input_schema["required"], + json!(["hermes_home"]) + ); +} + +#[tokio::test] +async fn automation_run_artifact_mcp_tool_reads_verified_payload() { + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap(); + let (cg, _env) = init_test_project(&project).await; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let run_id = "run-mcp-artifact"; + let artifact = write_run_artifact( + &dashboard_root, + run_id, + AutomationRunArtifactKind::CodexHandoff, + &json!({ + "status": "ready_for_review", + "next_actions": ["inspect artifact through MCP"], + }), + Some("handoff ready".to_string()), + "1782283200", + ) + .await + .unwrap(); + append_run_record( + &dashboard_root, + &AutomationRunLedgerRecord { + schema_version: 2, + run_id: run_id.to_string(), + trigger: AutomationTrigger::Dashboard, + task: tracedecay::automation::backend::AgentTaskKind::MemoryCurator, + task_key: Some("memory_curator".to_string()), + backend: "codex_app_server".to_string(), + host_mode: Some("standalone".to_string()), + prompt_version: Some("memory_curator:v1".to_string()), + response_schema: None, + strict_json: Some(true), + model: Some("test-model".to_string()), + status: AutomationRunStatus::Succeeded, + evidence_hash: Some("sha256:evidence".to_string()), + input_hash: Some("sha256:input".to_string()), + output_hash: Some("sha256:output".to_string()), + proposed_ops: Some(json!({"ops": []})), + applied_ops: None, + rejected_ops: None, + validation_report: None, + reviewed_count: 0, + accepted_count: 0, + rejected_count: 0, + skipped_count: 0, + error: None, + error_classification: None, + error_retryable: None, + fallback_status: None, + report_ref: None, + artifacts: vec![artifact], + started_at: "1782283199".to_string(), + completed_at: "1782283200".to_string(), + }, + ) + .await + .unwrap(); + + let result = handle_tool_call( + &cg, + "tracedecay_automation_run_artifact_view", + json!({"run_id": run_id, "kind": "codex_handoff"}), + None, + None, + ) + .await + .unwrap(); + let payload = extract_json(&result.value); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["run_id"], run_id); + assert_eq!(payload["artifact"]["kind"], "codex_handoff"); + assert_eq!(payload["payload"]["status"], "ready_for_review"); + assert_eq!( + payload["payload"]["next_actions"][0], + "inspect artifact through MCP" + ); + + let missing = handle_tool_call( + &cg, + "tracedecay_automation_run_artifact_view", + json!({"run_id": run_id, "kind": "generated_evals"}), + None, + None, + ) + .await + .unwrap_err(); + assert!(missing + .to_string() + .contains("automation run artifact not found")); + + close_test_graph(cg).await; +} + +#[tokio::test] +async fn managed_skill_mcp_tools_list_and_view_profile_store() { + let env_lock = GLOBAL_DB_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap(); + let home = dir.path().join("home"); + let _home_guard = HomeEnvGuard::set(&home); + let _global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); + let cg = TestTraceDecay::new(TraceDecay::init(&project).await.unwrap()); + let profile_root = tracedecay::storage::default_profile_root().unwrap(); + + create_managed_skill_draft( + &profile_root, + managed_skill_test_draft("pending-skill", "Pending skill"), + ) + .await + .unwrap(); + create_managed_skill_draft( + &profile_root, + managed_skill_test_draft("active-skill", "Active skill"), + ) + .await + .unwrap(); + let active_skill = approve_managed_skill(&profile_root, "active-skill") + .await + .unwrap(); + record_skill_usage( + &profile_root, + &active_skill, + SkillUsageAction::Use, + "mcp-test", + vec!["codex".to_string(), "cursor".to_string()], + Some("codex".to_string()), + None, + ) + .await + .unwrap(); + let global_db = GlobalDb::open().await.unwrap(); + global_db + .append_analytics_event(&tracedecay::global_db::AnalyticsEventInsert { + provider: "mcp".to_string(), + project_id: GlobalDb::canonical_project_key(cg.project_root()), + session_id: Some("mcp-skill-session".to_string()), + timestamp: tracedecay::tracedecay::current_timestamp(), + event_kind: "mcp_tool_call".to_string(), + hook_name: None, + tool_name: Some("tracedecay_skill_view".to_string()), + tool_category: None, + skill_name: None, + hint_category: None, + hint_id: None, + outcome: Some("success".to_string()), + metadata_json: Some( + json!({ + "function": { + "name": "tracedecay_skill_view", + "arguments": { "id": "active-skill" } + } + }) + .to_string(), + ), + }) + .await + .unwrap(); + + let list = handle_tool_call( + &cg, + "tracedecay_skill_list", + json!({"state": "active"}), + None, + None, + ) + .await + .unwrap(); + assert!(list.touched_files.is_empty()); + let payload = extract_json(&list.value); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["count"], 1); + assert_eq!(payload["skills"][0]["metadata"]["id"], "active-skill"); + assert_eq!(payload["skills"][0]["metadata"]["state"], "active"); + assert_eq!(payload["skills"][0]["support_file_count"], 1); + assert_eq!(payload["skills"][0]["usage_summary"]["view_count"], 1); + assert_eq!(payload["skills"][0]["usage_summary"]["use_count"], 1); + assert_eq!( + payload["skills"][0]["usage_summary"]["targets"], + json!(["codex", "cursor", "lifecycle", "mcp"]) + ); + assert_eq!( + payload["skills"][0]["stale_recommendation"]["skill_id"], + "active-skill" + ); + assert_eq!( + payload["skills"][0]["improvement_recommendation"]["skill_id"], + "active-skill" + ); + assert_eq!( + payload["skills"][0]["improvement_recommendation"]["recommendation"], + "none" + ); + assert!(payload["skills"][0].get("body_markdown").is_none()); + + let view = handle_tool_call( + &cg, + "tracedecay_skill_view", + json!({ + "id": "active-skill", + "include_support_files": false, + "__mcp_request_id": "req-active-view", + }), + None, + None, + ) + .await + .unwrap(); + assert!(view.touched_files.is_empty()); + let payload = extract_json(&view.value); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["skill"]["metadata"]["id"], "active-skill"); + assert_eq!(payload["usage_summary"]["view_count"], 2); + assert_eq!(payload["usage_summary"]["use_count"], 1); + assert!( + payload["usage_summary"]["targets"] + .as_array() + .unwrap() + .iter() + .any(|target| target == "mcp"), + "direct MCP view should mark mcp as a usage target: {payload:#}" + ); + assert_eq!(payload["stale_recommendation"]["skill_id"], "active-skill"); + assert_eq!( + payload["improvement_recommendation"]["skill_id"], + "active-skill" + ); + assert!(payload["skill"]["body_markdown"] + .as_str() + .unwrap() + .contains("Active skill")); + assert_eq!( + payload["skill"]["support_files"].as_array().unwrap().len(), + 0 + ); + assert_eq!(payload["support_files_included"], false); + let usage_record = load_skill_usage_record(&profile_root, "active-skill") + .await + .unwrap() + .expect("skill view should write direct usage telemetry"); + assert_eq!(usage_record.view_count, 2); + assert_eq!(usage_record.use_count, 1); + assert!(usage_record.targets.iter().any(|target| target == "mcp")); + + global_db + .append_analytics_event(&tracedecay::global_db::AnalyticsEventInsert { + provider: "mcp".to_string(), + project_id: GlobalDb::canonical_project_key(cg.project_root()), + session_id: Some("mcp-skill-session".to_string()), + timestamp: tracedecay::tracedecay::current_timestamp(), + event_kind: "mcp_tool_call".to_string(), + hook_name: None, + tool_name: Some("tracedecay_skill_view".to_string()), + tool_category: None, + skill_name: None, + hint_category: None, + hint_id: None, + outcome: Some("success".to_string()), + metadata_json: Some( + json!({ + "request_id": "req-active-view", + "function": { + "name": "tracedecay_skill_view", + "arguments": { "id": "active-skill" } + } + }) + .to_string(), + ), + }) + .await + .unwrap(); + let list_after_view = handle_tool_call( + &cg, + "tracedecay_skill_list", + json!({"state": "active"}), + None, + None, + ) + .await + .unwrap(); + let payload = extract_json(&list_after_view.value); + assert_eq!(payload["skills"][0]["usage_summary"]["view_count"], 2); + + close_test_graph(cg).await; + drop(env_lock); +} + +#[tokio::test] +async fn hermes_skill_bridge_mcp_tool_reads_host_owned_profile_state() { + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap(); + let cg = TestTraceDecay::new(TraceDecay::init(&project).await.unwrap()); + + let hermes_home = dir.path().join("hermes"); + let skills_dir = hermes_home.join("skills"); + let skill_dir = skills_dir.join("repo-hygiene"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: repo-hygiene\ndescription: Keep repositories tidy\n---\n\nRun focused checks.\n", + ) + .unwrap(); + fs::write( + skills_dir.join(".usage.json"), + r#"{"repo-hygiene":{"created_by":"agent","use_count":3}}"#, + ) + .unwrap(); + let pending_dir = hermes_home.join("pending").join("skills"); + fs::create_dir_all(&pending_dir).unwrap(); + fs::write( + pending_dir.join("pending1.json"), + r#"{"id":"pending1","action":"patch","summary":"improve repo hygiene","origin":"background_review","payload":{"action":"patch","name":"repo-hygiene"}}"#, + ) + .unwrap(); + fs::write( + hermes_home.join("config.json"), + r#"{"projectRoot":"/workspace/repo","memory":{"write_approval":true},"skills":{"write_approval":"json-pending"}}"#, + ) + .unwrap(); + fs::write( + hermes_home.join("config.yaml"), + r#" +plugins: + tracedecay: + project_root: /workspace/repo-from-yaml +curator: + enabled: true + auxiliary: + provider: openai + model: gpt-test + api_key: secret-value +memory: + nudge_interval: 14 + write_approval: manual +skills: + creation_nudge_interval: 16 + write_approval: pending +"#, + ) + .unwrap(); + fs::write( + skills_dir.join(".curator_state"), + r#"{"paused":false,"run_count":7,"last_run_summary":"reviewed skills"}"#, + ) + .unwrap(); + fs::write(hermes_home.join("state.db"), b"").unwrap(); + + let result = handle_tool_call( + &cg, + "tracedecay_hermes_skill_bridge", + json!({ + "hermes_home": hermes_home, + "include_skill_bodies": true + }), + None, + None, + ) + .await + .unwrap(); + let payload = extract_json(&result.value); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["bridge"]["contracts"]["lifecycle_owner"], "hermes"); + assert_eq!(payload["bridge"]["config"]["exists"], true); + assert_eq!(payload["bridge"]["config"]["config_yaml_exists"], true); + assert_eq!(payload["bridge"]["config"]["config_format"], "yaml"); + assert_eq!( + payload["bridge"]["config"]["project_root_pin"], + json!("/workspace/repo-from-yaml") + ); + assert_eq!( + payload["bridge"]["config"]["curator"]["enabled"], + json!(true) + ); + assert_eq!( + payload["bridge"]["config"]["self_improvement"]["memory_nudge_interval"], + json!(14) + ); + assert_eq!( + payload["bridge"]["config"]["self_improvement"]["skill_creation_nudge_interval"], + json!(16) + ); + assert_eq!( + payload["bridge"]["config"]["write_approval"]["memory"], + json!("manual") + ); + assert_eq!( + payload["bridge"]["config"]["write_approval"]["memory_enabled"], + json!(false) + ); + assert_eq!( + payload["bridge"]["config"]["write_approval"]["skills"], + json!("pending") + ); + assert_eq!( + payload["bridge"]["config"]["write_approval"]["skills_enabled"], + json!(false) + ); + assert_eq!( + payload["bridge"]["config"]["auxiliary_curator"]["api_key_configured"], + json!(true) + ); + assert!( + !serde_json::to_string(&payload) + .unwrap() + .contains("secret-value"), + "Hermes bridge must not expose auxiliary secrets" + ); + assert_eq!(payload["bridge"]["state"]["exists"], true); + assert_eq!( + payload["bridge"]["state"]["raw_lcm_owner"], + "hermes_runtime" + ); + assert_eq!( + payload["bridge"]["state"]["hermes_state_owner"], + "hermes_runtime" + ); + assert_eq!( + payload["bridge"]["state"]["trace_decay_lcm_store_owner"], + "tracedecay_hermes_plugin" + ); + assert_eq!( + payload["bridge"]["state"]["trace_decay_lcm_role"], + "hermes_profile_session_store" + ); + assert_eq!( + payload["bridge"]["state"]["trace_decay_ingest_role"], + "read_only_session_message_projector" + ); + assert_eq!(payload["bridge"]["curator"]["owner"], "hermes"); + assert_eq!( + payload["bridge"]["curator"]["trace_decay_role"], + "read_only_projector" + ); + assert_eq!( + payload["bridge"]["curator"]["standalone_automation_blocked"], + true + ); + assert_eq!(payload["bridge"]["curator"]["state"]["run_count"], json!(7)); + assert_eq!( + payload["bridge"]["curator"]["policy"]["max_destructive_action"], + "archive" + ); + assert_eq!( + payload["bridge"]["curator"]["policy"]["eligible_provenance"], + json!(["agent", "agent_created"]) + ); + assert_eq!( + payload["bridge"]["background_review"]["owner"], + "hermes_runtime" + ); + assert_eq!( + payload["bridge"]["background_review"]["skill_nudge_interval"], + json!(16) + ); + assert_eq!(payload["bridge"]["skill_count"], 1); + assert_eq!(payload["bridge"]["pending_skill_count"], 1); + assert_eq!(payload["bridge"]["skills"][0]["name"], "repo-hygiene"); + assert_eq!( + payload["bridge"]["skills"][0]["ownership"]["owner"], + "hermes_local" + ); + assert_eq!( + payload["bridge"]["skills"][0]["ownership"]["curator_managed_record"], + json!(true) + ); + assert_eq!( + payload["bridge"]["skills"][0]["pending_write_ids"], + json!(["pending1"]) + ); + assert_eq!( + payload["bridge"]["usage_records"]["repo-hygiene"]["created_by"], + "agent" + ); + assert!(payload["bridge"]["pending_skills"][0] + .get("payload") + .is_none()); + + close_test_graph(cg).await; +} + #[test] fn message_search_provider_schema_matches_ingested_providers() { let tools = get_tool_definitions(); @@ -10010,7 +10702,8 @@ fn message_search_provider_schema_matches_ingested_providers() { assert_eq!( message_search.input_schema["properties"]["provider"]["enum"], serde_json::json!([ - "all", "cursor", "claude", "codex", "vibe", "cline", "roo-code", "kilo", "hermes" + "all", "cursor", "claude", "codex", "vibe", "cline", "roo-code", "kilo", "kiro", + "hermes" ]) ); assert!( @@ -11834,10 +12527,13 @@ async fn lcm_status_reports_dag_store_and_config_diagnostics_over_mcp() { 1, ) .await; - let store_id = lcm_raw_store_id(&cg, "diag-message").await; - let db = open_project_session_db(cg.project_root()) + let db = open_active_project_session_db(&cg).await; + let raw = db + .lcm_load_raw_message("cursor", "diag-message") .await - .expect("project-local session db should open"); + .expect("raw message should load from the active project-local store"); + assert_eq!(raw.session_id, "lcm-diag-session"); + let store_id = raw.store_id; db.lcm_insert_summary_node(LcmSummaryNodeDraft { provider: "cursor".to_string(), conversation_id: "lcm-diag-session".to_string(), diff --git a/tests/mcp_server_test.rs b/tests/mcp_server_test.rs index 549a56e1..a1fdee88 100644 --- a/tests/mcp_server_test.rs +++ b/tests/mcp_server_test.rs @@ -936,6 +936,9 @@ async fn test_server_stats_initial() { ); assert_eq!(stats["tool_calls"], 0, "initial tool_calls should be 0"); assert_eq!(stats["errors"], 0, "initial errors should be 0"); + assert!(stats["method_call_counts"].is_object()); + assert!(stats["resource_read_counts"].is_object()); + assert_eq!(stats["ratios"]["tool_calls_per_jsonrpc_message"], 0.0); } #[tokio::test] @@ -1129,12 +1132,20 @@ async fn test_server_stats_include_response_handle_metrics() { #[tokio::test] async fn test_server_stats_after_run() { let (server, _dir) = setup_server().await; + let server_handle = server.clone(); // Send several requests then a tracedecay_status to check stats are embedded. let responses = run_server_with_messages( server, vec![ jsonrpc_request(json!(200), "initialize", json!({})), jsonrpc_request(json!(201), "ping", json!({})), + jsonrpc_request( + json!(203), + "resources/read", + json!({ + "uri": "tracedecay://status" + }), + ), jsonrpc_request( json!(202), "tools/call", @@ -1169,6 +1180,16 @@ async fn test_server_stats_after_run() { "status response should contain server stats, got: {}", text ); + + let stats = server_handle.server_stats_json().await; + assert_eq!(stats["jsonrpc_messages"], 4); + assert_eq!(stats["method_call_counts"]["initialize"], 1); + assert_eq!(stats["method_call_counts"]["ping"], 1); + assert_eq!(stats["method_call_counts"]["resources/read"], 1); + assert_eq!(stats["method_call_counts"]["tools/call"], 1); + assert_eq!(stats["resource_read_counts"]["tracedecay://status"], 1); + assert_eq!(stats["tool_call_counts"]["tracedecay_status"], 1); + assert_eq!(stats["ratios"]["tool_calls_per_jsonrpc_message"], 0.25); } // --------------------------------------------------------------------------- @@ -2147,6 +2168,53 @@ async fn failed_tool_call_writes_mcp_runtime_analytics_event() { assert_eq!(metadata["tokens_saved"], 0); } +#[tokio::test] +// Intentional: serializes env-mutating savings tests; #[tokio::test] +// defaults to a current-thread runtime, so no executor thread blocks. +#[allow(clippy::await_holding_lock)] +async fn skill_view_call_writes_skill_arguments_to_mcp_runtime_analytics() { + let _env_guard = SAVINGS_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp_global = persistent_temp_dir(); + let _env = isolated_savings_env(&tmp_global.join("global.db")); + + let (server, _proj_tmp) = setup_server().await; + let server_handle = server.clone(); + let db_path = locked_global_db_path(); + + let resp = call_tool( + server, + 9004, + "tracedecay_skill_view", + json!({ + "id": "repo-hygiene", + "session_id": "mcp-session-9004" + }), + ) + .await; + + assert!( + resp["error"].is_object(), + "missing fixture skill should make the tool call fail" + ); + + server_handle.ledger_writes_settled().await; + let event = expect_mcp_runtime_event( + &db_path, + "tracedecay_skill_view", + "mcp-session-9004", + "durable skill-view MCP runtime analytics event", + ) + .await; + let metadata = analytics_metadata(&event); + + assert_eq!(event.outcome.as_deref(), Some("error")); + assert_eq!(metadata["arguments"]["id"], "repo-hygiene"); + assert_eq!(metadata["function"]["name"], "tracedecay_skill_view"); + assert_eq!(metadata["function"]["arguments"]["id"], "repo-hygiene"); +} + #[tokio::test] // Intentional: serializes env-mutating savings tests; #[tokio::test] // defaults to a current-thread runtime, so no executor thread blocks. diff --git a/tests/mcp_test.rs b/tests/mcp_test.rs index 1772512e..b536e7cc 100644 --- a/tests/mcp_test.rs +++ b/tests/mcp_test.rs @@ -100,7 +100,7 @@ fn test_tool_definitions_count() { // is available. Outline stays registered and reports the ast-grep outline // requirement at runtime so plugin docs/rules can consistently reference it. // LCM comparison and profile-storage registry support add extra tools. - let expected = 93 + usize::from(tracedecay::mcp::tools::ast_grep_available()); + let expected = 97 + usize::from(tracedecay::mcp::tools::ast_grep_available()); assert_eq!(tools.len(), expected); } diff --git a/tests/skill_usage_test.rs b/tests/skill_usage_test.rs index 9b7061c0..17d38e98 100644 --- a/tests/skill_usage_test.rs +++ b/tests/skill_usage_test.rs @@ -3,8 +3,9 @@ use tracedecay::automation::managed_skills::{ ManagedSkillProvenance, ManagedSkillSource, }; use tracedecay::automation::skill_usage::{ - ingest_analytics_events, record_skill_usage_event, skill_improvement_recommendations, - stale_skill_recommendations, summarize_skill_usage, SkillUsageAction, SkillUsageEvent, + ingest_analytics_events, record_skill_usage, record_skill_usage_event, + skill_improvement_recommendations, stale_skill_recommendations, summarize_skill_usage, + SkillUsageAction, SkillUsageEvent, }; use tracedecay::global_db::AnalyticsEventRecord; @@ -249,6 +250,66 @@ async fn analytics_ingest_dedupes_tracedecay_skill_view_by_request_id() { assert_eq!(record.targets, vec!["codex"]); } +#[tokio::test] +async fn direct_skill_view_marks_matching_analytics_request_imported() { + let temp = tempfile::tempdir().unwrap(); + let profile_root = temp.path().join("profile"); + let skill = create_managed_skill_draft( + &profile_root, + draft("repo-hygiene", ManagedSkillSource::AutomationRun), + ) + .await + .unwrap(); + + record_skill_usage( + &profile_root, + &skill, + SkillUsageAction::View, + "mcp", + vec!["mcp".to_string()], + Some("mcp".to_string()), + Some(serde_json::json!({ + "imported_analytics_event_key": "project:mcp:request:req-view-1:repo-hygiene:View", + })), + ) + .await + .unwrap(); + + let touched = ingest_analytics_events( + &profile_root, + &[AnalyticsEventRecord { + id: 13, + provider: "mcp".to_string(), + project_id: "project".to_string(), + session_id: Some("session".to_string()), + timestamp: 300, + event_kind: "mcp_tool_call".to_string(), + hook_name: None, + tool_name: Some("tracedecay_skill_view".to_string()), + tool_category: None, + skill_name: None, + hint_category: None, + hint_id: None, + outcome: Some("success".to_string()), + metadata_json: Some( + r#"{"request_id":"req-view-1","function":{"name":"tracedecay_skill_view","arguments":{"id":"repo-hygiene"}}}"# + .to_string(), + ), + }], + ) + .await + .unwrap(); + + assert!(touched.is_empty()); + let record = + tracedecay::automation::skill_usage::load_skill_usage_record(&profile_root, "repo-hygiene") + .await + .unwrap() + .unwrap(); + assert_eq!(record.view_count, 1); + assert_eq!(record.targets, vec!["mcp"]); +} + #[tokio::test] async fn analytics_ingest_is_idempotent_and_accepts_bare_skill_name_rows() { let temp = tempfile::tempdir().unwrap();