From ebf1997850462ad48010418eb54751562c530895 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 06:25:43 +0000 Subject: [PATCH 1/6] feat(agents): add CLI-fallback steering to host prompt rules --- src/agents/claude.rs | 4 +++- src/agents/copilot.rs | 4 +++- src/agents/gemini.rs | 4 +++- src/agents/kimi.rs | 4 +++- src/agents/kiro.rs | 10 +++++++--- src/agents/mod.rs | 12 ++++++++++++ src/agents/opencode.rs | 4 +++- src/agents/vibe.rs | 4 +++- 8 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 26cdc40f..5afcb998 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -394,6 +394,7 @@ fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\ + - {cli_fallback}\n\ - If you discover a gap where an extractor, schema, or tracedecay tool could be \ improved to answer a question natively, propose to the user that they open an issue \ at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ @@ -407,7 +408,8 @@ fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { question in plain English. Do not call Read, glob, grep, or \ list_directory — the source sections returned by tracedecay_context ARE \ the relevant code. Follow the call budget in the tool description. \ - Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`.\n" + Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`.\n", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) .map_err(|e| TraceDecayError::Config { message: format!( diff --git a/src/agents/copilot.rs b/src/agents/copilot.rs index 728f76e9..ab527ea7 100644 --- a/src/agents/copilot.rs +++ b/src/agents/copilot.rs @@ -428,9 +428,11 @@ fn install_prompt_rules(instructions_path: &Path) -> Result<()> { Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\n\ + {cli_fallback}\n\n\ If you find a gap where tracedecay could answer a question natively, propose opening \ an issue at https://github.com/ScriptedAlchemy/tracedecay. Remind the user to strip \ - sensitive or proprietary code from any issue text before submitting.\n" + sensitive or proprietary code from any issue text before submitting.\n", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) .map_err(|e| crate::errors::TraceDecayError::Config { message: format!("failed to write {}: {e}", instructions_path.display()), diff --git a/src/agents/gemini.rs b/src/agents/gemini.rs index 7a95dac7..45716888 100644 --- a/src/agents/gemini.rs +++ b/src/agents/gemini.rs @@ -169,11 +169,13 @@ fn install_prompt_rules(gemini_md: &Path) -> Result<()> { Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\n\ + {cli_fallback}\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could be \ improved to answer a question natively, propose to the user that they open an issue \ at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ **Remind the user to strip any sensitive or proprietary code from the bug description \ - before submitting.**\n" + before submitting.**\n", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) .ok(); eprintln!( diff --git a/src/agents/kimi.rs b/src/agents/kimi.rs index c480d69e..cd243e8d 100644 --- a/src/agents/kimi.rs +++ b/src/agents/kimi.rs @@ -182,11 +182,13 @@ fn install_prompt_rules(agents_md: &Path) -> Result<()> { Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\n\ + {cli_fallback}\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could be \ improved to answer a question natively, propose to the user that they open an issue \ at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ **Remind the user to strip any sensitive or proprietary code from the bug description \ - before submitting.**\n" + before submitting.**\n", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) .ok(); eprintln!( diff --git a/src/agents/kiro.rs b/src/agents/kiro.rs index 22997e5a..e08b4c35 100644 --- a/src/agents/kiro.rs +++ b/src/agents/kiro.rs @@ -510,8 +510,9 @@ fn prompt_rules_text() -> String { ) } -fn prompt_rules_text_without_end_marker() -> &'static str { - "## Prefer tracedecay MCP tools\n\n\ +fn prompt_rules_text_without_end_marker() -> String { + format!( + "## Prefer tracedecay MCP tools\n\n\ Before reading source files or scanning the codebase, use the tracedecay MCP tools \ (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ @@ -533,10 +534,13 @@ For durable project/user facts, prefer `tracedecay_fact_store`, \ `tracedecay_message_search` for active-project transcript recall when prior \ conversation context matters. Do not store secrets, credentials, or unnecessary PII \ in persistent facts.\n\n\ +{cli_fallback}\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could answer a \ question natively, propose opening an issue at \ https://github.com/ScriptedAlchemy/tracedecay. Remind the user to strip sensitive \ -or proprietary code from the bug description before submitting." +or proprietary code from the bug description before submitting.", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, + ) } // --------------------------------------------------------------------------- diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 2aed9e97..7f334fc1 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -668,6 +668,18 @@ fn normalize_path_separators(path: &str) -> String { path.replace('\\', "/") } +/// CLI-fallback steering paragraph shared by every host's prompt rules. +/// +/// Mirrors the guidance in the MCP server instructions and the bundled +/// `using-the-cli` skill: when the MCP transport fails, agents should fall +/// back to the `tracedecay tool` CLI instead of abandoning tracedecay or +/// poking at `.tracedecay` databases directly. +pub(crate) const CLI_FALLBACK_PROMPT_RULES: &str = "If a tracedecay MCP call errors, times out, \ +or the server is disconnected, every tool is also available as a shell command: \ +`tracedecay tool --key value` (`tracedecay tool` lists all tools, \ +`tracedecay tool --help` shows parameters). Fall back to that CLI instead of \ +querying `.tracedecay` databases directly or abandoning tracedecay."; + pub(crate) fn hook_command(tracedecay_bin: &str, subcommand: &str) -> String { hook_command_for_platform(tracedecay_bin, subcommand, cfg!(windows)) } diff --git a/src/agents/opencode.rs b/src/agents/opencode.rs index 2beb4812..20565885 100644 --- a/src/agents/opencode.rs +++ b/src/agents/opencode.rs @@ -216,11 +216,13 @@ fn install_prompt_rules(prompt_path: &Path) -> Result<()> { Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\n\ + {cli_fallback}\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could be \ improved to answer a question natively, propose to the user that they open an issue \ at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ **Remind the user to strip any sensitive or proprietary code from the bug description \ - before submitting.**\n" + before submitting.**\n", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) .ok(); eprintln!( diff --git a/src/agents/vibe.rs b/src/agents/vibe.rs index 4ca1c1fa..1bc6db93 100644 --- a/src/agents/vibe.rs +++ b/src/agents/vibe.rs @@ -210,11 +210,13 @@ fn install_prompt_rules(prompt_path: &Path) -> Result<()> { Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\n\ + {cli_fallback}\n\n\ If you find a gap where tracedecay could answer a question natively, propose opening \ an issue at https://github.com/ScriptedAlchemy/tracedecay. Remind the user to strip \ sensitive or proprietary code from any issue text before submitting.\n\n\ When a tracedecay tool result contains a `tracedecay_metrics:` line, report the \ - savings to the user (e.g. \"TraceDecay'd ~N tokens\"). Never silently omit this.\n" + savings to the user (e.g. \"TraceDecay'd ~N tokens\"). Never silently omit this.\n", + cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) .map_err(|e| TraceDecayError::Config { message: format!("failed to write Vibe prompt: {e}"), From 0561a89196741d10501e57abd9e2622db4c3b93a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 06:25:52 +0000 Subject: [PATCH 2/6] feat(claude): register SessionStart and PostToolUse lifecycle hooks --- CHANGELOG.md | 5 + docs/USER-GUIDE.md | 8 +- src/agents/claude.rs | 188 +++++++++++++++++-------------- src/cli.rs | 6 + src/daemon.rs | 14 +++ src/hook_cmd.rs | 6 + src/hooks.rs | 257 ++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 4 + 8 files changed, 388 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80c4498..b2f90eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Claude Code installs now register `SessionStart` and `PostToolUse` lifecycle hooks, matching the freshness/steering coverage Cursor, Codex, and Kiro already had: `SessionStart` reports index freshness and injects the LCM context-recovery hint after compaction; `PostToolUse` notifies the daemon for targeted incremental sync after edits and shell commands. Existing installs pick the hooks up via the post-upgrade backfill or `tracedecay doctor`. +- The CLI-fallback steering ("if MCP fails, use `tracedecay tool ...`") now reaches every host with a prompt-rules surface — Claude Code, Copilot/VS Code, Gemini, OpenCode, Kimi, Vibe, and Kiro — instead of only the Cursor rule and Codex session hook. + ## [0.0.23](https://github.com/ScriptedAlchemy/tracedecay/compare/v0.0.22...v0.0.23) - 2026-07-02 ### Other diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 70cbf9f0..7b77ba46 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -184,7 +184,13 @@ TraceDecay works as an MCP (Model Context Protocol) server. AI coding agents con tracedecay install ``` -This is the default. It registers the MCP server in `~/.claude/settings.json`, grants tool permissions so Claude doesn't have to ask you every time, installs a `PreToolUse` hook that redirects Claude away from spawning expensive Explore agents, and adds prompt rules to `~/.claude/CLAUDE.md` that tell Claude to prefer tracedecay tools. +This is the default. It registers the MCP server in `~/.claude/settings.json`, grants tool permissions so Claude doesn't have to ask you every time, installs lifecycle hooks, and adds prompt rules to `~/.claude/CLAUDE.md` that tell Claude to prefer tracedecay tools (including the CLI fallback for MCP transport failures). The hooks: + +- `PreToolUse` redirects Claude away from spawning expensive Explore agents. +- `UserPromptSubmit` resets the per-turn token-savings counter. +- `Stop` ingests new session transcript data and prints a cost receipt. +- `SessionStart` reports index freshness (or a `tracedecay init` nudge) and, when the session restarts from compaction, injects the LCM context-recovery hint. +- `PostToolUse` (matcher `Edit|MultiEdit|Write|NotebookEdit|Bash`) notifies the daemon so edits and shell commands trigger targeted incremental sync. ### Other agents diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 5afcb998..74697363 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -3,7 +3,8 @@ //! //! Handles registration of the tracedecay MCP server in Claude Code's config //! files (`~/.claude.json`, `~/.claude/settings.json`), tool permissions, -//! the `PreToolUse` hook, CLAUDE.md prompt rules, and health checks. +//! lifecycle hooks (`PreToolUse`, `UserPromptSubmit`, `Stop`, `SessionStart`, +//! `PostToolUse`), CLAUDE.md prompt rules, and health checks. use std::io::Write; use std::path::Path; @@ -199,24 +200,38 @@ fn install_hook_quiet(settings: &mut serde_json::Value, tracedecay_bin: &str) { install_hook_inner(settings, tracedecay_bin, true); } +/// Every managed hook event with its subcommand. The single source of truth +/// for install, uninstall, doctor checks, and doctor auto-repair. +const MANAGED_HOOKS: &[(&str, &str)] = &[ + ("PreToolUse", "hook-pre-tool-use"), + ("UserPromptSubmit", "hook-prompt-submit"), + ("Stop", "hook-stop"), + ("SessionStart", "hook-claude-session-start"), + ("PostToolUse", "hook-claude-post-tool-use"), +]; + +/// Matcher registered for a managed hook event, if any. +fn managed_hook_matcher(event: &str) -> Option<&'static str> { + match event { + // Only Agent tool calls are screened for explore-agent redirection. + "PreToolUse" => Some("Agent"), + // Only edit tools and shell commands can invalidate the graph. + "PostToolUse" => Some("Edit|MultiEdit|Write|NotebookEdit|Bash"), + _ => None, + } +} + fn install_hook_inner(settings: &mut serde_json::Value, tracedecay_bin: &str, quiet: bool) { - install_single_hook( - settings, - "PreToolUse", - tracedecay_bin, - "hook-pre-tool-use", - Some("Agent"), - quiet, - ); - install_single_hook( - settings, - "UserPromptSubmit", - tracedecay_bin, - "hook-prompt-submit", - None, - quiet, - ); - install_single_hook(settings, "Stop", tracedecay_bin, "hook-stop", None, quiet); + for &(event, subcommand) in MANAGED_HOOKS { + install_single_hook( + settings, + event, + tracedecay_bin, + subcommand, + managed_hook_matcher(event), + quiet, + ); + } } /// Install a single hook entry under `settings.hooks.` (idempotent). @@ -609,7 +624,7 @@ fn uninstall_stale_mcp(settings: &mut serde_json::Value) -> bool { /// Remove all tracedecay hooks. Returns true if modified. fn uninstall_hook(settings: &mut serde_json::Value) -> bool { let mut modified = false; - for event in &["PreToolUse", "UserPromptSubmit", "Stop"] { + for &(event, _) in MANAGED_HOOKS { modified |= uninstall_single_hook(settings, event); } modified @@ -876,17 +891,15 @@ fn doctor_check_settings_json(dc: &mut DoctorCounters, home: &Path) { /// Expected subcommand for each supported hook event. fn expected_hook_subcommand(event: &str) -> Option<&'static str> { - match event { - "PreToolUse" => Some("hook-pre-tool-use"), - "UserPromptSubmit" => Some("hook-prompt-submit"), - "Stop" => Some("hook-stop"), - _ => None, - } + MANAGED_HOOKS + .iter() + .find(|(managed_event, _)| *managed_event == event) + .map(|&(_, subcommand)| subcommand) } /// Check all tracedecay hooks in settings. fn doctor_check_hook(dc: &mut DoctorCounters, settings: &serde_json::Value) { - for event in &["PreToolUse", "UserPromptSubmit", "Stop"] { + for &(event, _) in MANAGED_HOOKS { doctor_check_single_hook(dc, settings, event); } } @@ -945,14 +958,7 @@ fn doctor_fix_hooks(dc: &mut DoctorCounters, settings_path: &Path, settings: &se let mut settings = settings.clone(); let mut repaired = false; - for event in &["PreToolUse", "UserPromptSubmit", "Stop"] { - let Some(expected_sub) = expected_hook_subcommand(event) else { - dc.fail(&format!( - "Unsupported Claude hook event during repair: {event}" - )); - continue; - }; - + for &(event, expected_sub) in MANAGED_HOOKS { let current = find_tracedecay_hook(&settings, event); let correct = current .as_ref() @@ -974,12 +980,14 @@ fn doctor_fix_hooks(dc: &mut DoctorCounters, settings_path: &Path, settings: &se if current.is_some() { uninstall_single_hook(&mut settings, event); } - let matcher = if *event == "PreToolUse" { - Some("Agent") - } else { - None - }; - install_single_hook(&mut settings, event, &bin, expected_sub, matcher, true); + install_single_hook( + &mut settings, + event, + &bin, + expected_sub, + managed_hook_matcher(event), + true, + ); repaired = true; } @@ -1355,26 +1363,24 @@ mod tests { } } - /// Build a settings value with the three tracedecay hooks installed + /// Build a settings value with every managed tracedecay hook installed /// (modern `{command, args}` shape). fn settings_with_all_hooks(bin: &str) -> serde_json::Value { - json!({ - "hooks": { - "PreToolUse": [{ - "matcher": "Agent", - "hooks": [{ "type": "command", "command": bin, "args": ["hook-pre-tool-use"] }] - }], - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": bin, "args": ["hook-prompt-submit"] }] - }], - "Stop": [{ - "hooks": [{ "type": "command", "command": bin, "args": ["hook-stop"] }] - }] - }, + let mut settings = json!({ "permissions": { "allow": ["mcp__tracedecay__search", "mcp__tracedecay__lookup"] } - }) + }); + for &(event, subcommand) in MANAGED_HOOKS { + let mut entry = json!({ + "hooks": [{ "type": "command", "command": bin, "args": [subcommand] }] + }); + if let Some(matcher) = managed_hook_matcher(event) { + entry["matcher"] = json!(matcher); + } + settings["hooks"][event] = json!([entry]); + } + settings } /// Build a settings value with the legacy single-string command shape @@ -1401,11 +1407,11 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn uninstall_hook_removes_all_three_events() { + fn uninstall_hook_removes_all_managed_events() { let mut settings = settings_with_all_hooks("/usr/bin/tracedecay"); let modified = uninstall_hook(&mut settings); assert!(modified); - // All three hook events should be gone. + // Every managed hook event should be gone. assert!( settings.get("hooks").is_none() || settings["hooks"].as_object().unwrap().is_empty() ); @@ -1492,12 +1498,15 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn install_adds_all_three_hooks() { + fn install_adds_all_managed_hooks() { let mut settings = json!({}); install_hook(&mut settings, "/usr/bin/tracedecay"); - assert!(settings["hooks"]["PreToolUse"].is_array()); - assert!(settings["hooks"]["UserPromptSubmit"].is_array()); - assert!(settings["hooks"]["Stop"].is_array()); + for &(event, _) in MANAGED_HOOKS { + assert!( + settings["hooks"][event].is_array(), + "{event} hook should be installed" + ); + } } #[test] @@ -1532,11 +1541,7 @@ mod tests { let mut settings = json!({}); install_hook(&mut settings, bin); - for (event, expected_sub) in [ - ("PreToolUse", "hook-pre-tool-use"), - ("UserPromptSubmit", "hook-prompt-submit"), - ("Stop", "hook-stop"), - ] { + for &(event, expected_sub) in MANAGED_HOOKS { let inner = &settings["hooks"][event][0]["hooks"][0]; assert_eq!( inner["command"].as_str().unwrap(), @@ -1553,12 +1558,24 @@ mod tests { #[test] fn install_is_idempotent_for_legacy_shape() { - // A legacy single-string install must not get a second entry added — - // the doctor is what rewrites it, not a re-run of install. + // A legacy single-string install must not get a second entry added + // for its events — the doctor is what rewrites it, not a re-run of + // install. Events the legacy install never had are still backfilled. let mut settings = settings_with_legacy_hooks("/usr/bin/tracedecay"); let before = settings.clone(); install_hook(&mut settings, "/usr/bin/tracedecay"); - assert_eq!(settings, before); + for event in ["PreToolUse", "UserPromptSubmit", "Stop"] { + assert_eq!( + settings["hooks"][event], before["hooks"][event], + "{event}: existing legacy entry must not be duplicated" + ); + } + for event in ["SessionStart", "PostToolUse"] { + assert!( + settings["hooks"][event].is_array(), + "{event}: missing event must be backfilled" + ); + } } #[test] @@ -1581,16 +1598,16 @@ mod tests { #[test] fn doctor_check_single_hook_reports_unknown_event_instead_of_panicking() { let mut settings = settings_with_all_hooks("/usr/bin/tracedecay"); - settings["hooks"]["PostToolUse"] = json!([{ + settings["hooks"]["SessionEnd"] = json!([{ "hooks": [{ "type": "command", "command": "/usr/bin/tracedecay", - "args": ["hook-post-tool-use"] + "args": ["hook-session-end"] }] }]); let mut dc = DoctorCounters::new(); - doctor_check_single_hook(&mut dc, &settings, "PostToolUse"); + doctor_check_single_hook(&mut dc, &settings, "SessionEnd"); assert_eq!(dc.issues, 1); assert_eq!(dc.warnings, 0); @@ -1634,11 +1651,7 @@ mod tests { .to_str() .unwrap() .to_string(); - for (event, expected_sub) in [ - ("PreToolUse", "hook-pre-tool-use"), - ("UserPromptSubmit", "hook-prompt-submit"), - ("Stop", "hook-stop"), - ] { + for &(event, expected_sub) in MANAGED_HOOKS { let inner = &after["hooks"][event][0]["hooks"][0]; assert_eq!( inner["command"].as_str().unwrap(), @@ -1794,9 +1807,13 @@ mod tests { .unwrap(), "C:/Users/u/tracedecay.exe hook-stop" ); - // All three events should now be present (backfill). - assert!(parsed["hooks"]["PreToolUse"].is_array()); - assert!(parsed["hooks"]["UserPromptSubmit"].is_array()); + // Every managed event should now be present (backfill). + for &(event, _) in MANAGED_HOOKS { + assert!( + parsed["hooks"][event].is_array(), + "{event} hook should be backfilled" + ); + } } #[test] @@ -1931,12 +1948,15 @@ mod tests { let mut dc = DoctorCounters::new(); doctor_fix_hooks(&mut dc, &settings_path, &settings); - // Re-read and verify all three hooks are present. + // Re-read and verify every managed hook is present. let fixed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - assert!(fixed["hooks"]["PreToolUse"].is_array()); - assert!(fixed["hooks"]["UserPromptSubmit"].is_array()); - assert!(fixed["hooks"]["Stop"].is_array()); + for &(event, _) in MANAGED_HOOKS { + assert!( + fixed["hooks"][event].is_array(), + "{event} hook should be repaired in" + ); + } } #[test] diff --git a/src/cli.rs b/src/cli.rs index 44841cfe..ebc3c707 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -182,6 +182,12 @@ pub enum Commands { /// Stop hook handler (prints session token savings) #[command(name = "hook-stop", hide = true)] HookStop, + /// Claude Code SessionStart hook handler (called by Claude Code, not by users directly) + #[command(name = "hook-claude-session-start", hide = true)] + HookClaudeSessionStart, + /// Claude Code PostToolUse hook handler for incremental sync (called by Claude Code) + #[command(name = "hook-claude-post-tool-use", hide = true)] + HookClaudePostToolUse, /// Kiro PreToolUse hook handler (called by Kiro, not by users directly) #[command(name = "hook-kiro-pre-tool-use", hide = true)] HookKiroPreToolUse, diff --git a/src/daemon.rs b/src/daemon.rs index f4f6272a..0d641082 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -94,6 +94,20 @@ impl DaemonHookEvent { Self::new("codex", "postToolUseEdit", rel_paths, None, Some(cwd)) } + pub fn claude_post_tool_use_edit(rel_paths: Vec, cwd: PathBuf) -> Self { + Self::new("claude", "postToolUseEdit", rel_paths, None, Some(cwd)) + } + + pub fn claude_post_tool_use_shell(command: String, cwd: PathBuf) -> Self { + Self::new( + "claude", + "postToolUseShell", + Vec::new(), + Some(command), + Some(cwd), + ) + } + pub fn codex_post_tool_use_shell(command: String, cwd: PathBuf) -> Self { Self::new( "codex", diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs index d3ed2d64..d5ca6c8f 100644 --- a/src/hook_cmd.rs +++ b/src/hook_cmd.rs @@ -11,6 +11,12 @@ pub(crate) async fn handle_hook_command(command: Commands) -> tracedecay::errors Commands::HookStop => { tracedecay::hooks::hook_stop().await; } + Commands::HookClaudeSessionStart => { + exit_if_nonzero(tracedecay::hooks::hook_claude_session_start().await); + } + Commands::HookClaudePostToolUse => { + exit_if_nonzero(tracedecay::hooks::hook_claude_post_tool_use().await); + } Commands::HookKiroPreToolUse => { exit_if_nonzero(tracedecay::hooks::hook_kiro_pre_tool_use()); } diff --git a/src/hooks.rs b/src/hooks.rs index 688c7811..27d02514 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1419,16 +1419,8 @@ pub fn build_cursor_session_context( staleness_hint: Option<&str>, tokens_saved: Option, ) -> String { - let mut s = String::new(); + let mut s = index_status_line(initialized, staleness_hint); if initialized { - match staleness_hint { - Some(hint) => { - s.push_str("tracedecay index status: "); - s.push_str(hint); - s.push_str(".\n"); - } - None => s.push_str("tracedecay index status: initialized.\n"), - } s.push_str("Workflow skills: tracedecay:"); s.push_str(&CURSOR_PLUGIN_SKILLS.join(", ")); s.push_str(" — each maps a common workflow to the right tracedecay tools.\n"); @@ -1437,15 +1429,27 @@ pub fn build_cursor_session_context( s.push_str(&saved.to_string()); s.push_str(".\n"); } - } else { - s.push_str( - "tracedecay index status: no project index found in this workspace — \ - run `tracedecay init` to enable tracedecay MCP tools.\n", - ); } s } +/// One-line index freshness signal shared by the Cursor and Claude session +/// contexts. Both hosts carry the tool-routing steering in an always-applied +/// rule (Cursor plugin rule, CLAUDE.md), so their session hooks report only +/// session-specific signals. +fn index_status_line(initialized: bool, staleness_hint: Option<&str>) -> String { + if initialized { + match staleness_hint { + Some(hint) => format!("tracedecay index status: {hint}.\n"), + None => "tracedecay index status: initialized.\n".to_string(), + } + } else { + "tracedecay index status: no project index found in this workspace — \ + run `tracedecay init` to enable tracedecay MCP tools.\n" + .to_string() + } +} + /// Builds the Codex session/prompt steering context. Codex has no /// always-applied tracedecay rule, so the full tool-routing steering lives /// here. @@ -1544,7 +1548,7 @@ fn append_codex_recall_and_registry_guidance(s: &mut String) { } fn append_context_recovery_hint(context: &mut String) { - if !context.ends_with('\n') { + if !context.is_empty() && !context.ends_with('\n') { context.push('\n'); } context.push_str(COMPACTION_CONTEXT_RECOVERY_HINT); @@ -1683,6 +1687,164 @@ fn now_unix_secs() -> i64 { .map_or(0, |d| d.as_secs() as i64) } +// --------------------------------------------------------------------------- +// Claude Code lifecycle hook handlers (SessionStart / PostToolUse) +// +// Claude Code sends ONE JSON object on stdin with the same shared fields as +// Codex (session_id, transcript_path, cwd, hook_event_name, plus +// event-specific fields) and reads `hookSpecificOutput` JSON from stdout — +// Codex adopted Claude's hook schema, so these handlers share the Codex +// event/output helpers. The older Claude handlers (`hook_pre_tool_use`, +// `hook_prompt_submit`, `hook_stop`) predate that schema and keep their own +// input shapes. +// --------------------------------------------------------------------------- + +/// Claude Code `SessionStart` hook handler (fail-open). +/// +/// The CLAUDE.md prompt rules already carry the tool-routing steering, so +/// this emits only session-specific signals via +/// `hookSpecificOutput.additionalContext`: index freshness (or a +/// `tracedecay init` nudge in an unindexed project) plus the LCM +/// context-recovery hint when the session (re)starts from compaction. +pub async fn hook_claude_session_start() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Claude, "SessionStart", &event); + let mut context = claude_session_context_for_event(&event).await; + if session_start_from_compaction(&event) { + append_context_recovery_hint(&mut context); + } + if context.is_empty() { + println!("{}", serde_json::json!({})); + } else { + println!( + "{}", + codex_additional_context_json("SessionStart", &context) + ); + } + 0 +} + +/// Builds the lean Claude `SessionStart` context: the index-status line for +/// the session's project, an init nudge for unindexed project-like +/// workspaces, and nothing at all outside code workspaces. +async fn claude_session_context_for_event(event_json: &str) -> String { + let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); + // `discover_project_root` only resolves initialized tracedecay projects. + match codex_project_root_from_parsed_event(&parsed) { + Some(root) => { + let (staleness, _) = cursor_index_signals_for_root(&root).await; + index_status_line(true, staleness.as_deref()) + } + None if event_cwd_from_parsed(&parsed) + .as_deref() + .is_some_and(is_project_like_workspace) => + { + index_status_line(false, None) + } + None => String::new(), + } +} + +/// Claude Code `PostToolUse` hook handler used to keep the graph fresh after +/// writes. +/// +/// For edit tools and shell commands this notifies the daemon, which owns +/// targeted sync, branch tracking, and coalescing. Fail-open and silent. +pub async fn hook_claude_post_tool_use() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Claude, "PostToolUse", &event); + claude_post_tool_use(&event).await; + 0 +} + +async fn claude_post_tool_use(event_json: &str) { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return; + }; + let tool_name = parsed + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or_default(); + let Some(cwd) = event_cwd_from_parsed(&parsed) else { + return; + }; + let Some(root) = crate::config::discover_project_root(&cwd) + .or_else(|| crate::worktree::git_worktree_root(&cwd)) + else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { + return; + } + + if is_claude_edit_tool(tool_name) { + let rels = claude_edit_rel_paths(&parsed, &cwd, &root); + if rels.is_empty() { + return; + } + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::claude_post_tool_use_edit(rels, cwd), + ) + .await; + } else if is_claude_bash_tool(tool_name) { + let command = parsed + .get("tool_input") + .and_then(|ti| ti.get("command")) + .and_then(Value::as_str) + .unwrap_or_default(); + if command.is_empty() { + return; + } + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::claude_post_tool_use_shell(command.to_string(), cwd), + ) + .await; + } +} + +fn is_claude_edit_tool(tool_name: &str) -> bool { + matches!( + tool_name.to_ascii_lowercase().as_str(), + "edit" | "write" | "multiedit" | "notebookedit" + ) +} + +fn is_claude_bash_tool(tool_name: &str) -> bool { + tool_name.eq_ignore_ascii_case("bash") +} + +/// Extracts the project-relative path edited by a Claude edit tool. +/// +/// Claude's `Edit`/`Write`/`MultiEdit` put the target in +/// `tool_input.file_path`; `NotebookEdit` uses `tool_input.notebook_path`. +/// Paths are usually absolute but are resolved against the session `cwd` +/// when relative. Paths outside `project_root` are skipped. +fn claude_edit_rel_paths(parsed: &Value, cwd: &Path, project_root: &Path) -> Vec { + ["file_path", "notebook_path"] + .iter() + .filter_map(|key| { + parsed + .get("tool_input") + .and_then(|ti| ti.get(*key)) + .and_then(Value::as_str) + }) + .filter(|raw| !raw.is_empty()) + .filter_map(|raw| { + let candidate = Path::new(raw); + let abs = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + cwd.join(candidate) + }; + rel_under_root(project_root, &abs) + }) + .collect() +} + // --------------------------------------------------------------------------- // Codex CLI hook handlers // @@ -2869,4 +3031,69 @@ mod tests { let event = serde_json::json!({ "source": "resume" }).to_string(); assert!(!session_start_from_compaction(&event)); } + + #[test] + fn claude_edit_tools_are_recognized_case_insensitively() { + for tool in ["Edit", "Write", "MultiEdit", "NotebookEdit", "write"] { + assert!(is_claude_edit_tool(tool), "{tool} should count as an edit"); + } + assert!(!is_claude_edit_tool("Bash")); + assert!(!is_claude_edit_tool("Read")); + assert!(is_claude_bash_tool("Bash")); + assert!(!is_claude_bash_tool("Edit")); + } + + #[test] + fn claude_edit_rel_paths_resolves_file_path_against_project_root() { + let root = Path::new("/repo"); + let cwd = Path::new("/repo/sub"); + let event = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/repo/src/lib.rs" } + }); + assert_eq!( + claude_edit_rel_paths(&event, cwd, root), + vec!["src/lib.rs".to_string()] + ); + + // Relative paths resolve against the session cwd. + let event = serde_json::json!({ + "tool_name": "Write", + "tool_input": { "file_path": "module.rs" } + }); + assert_eq!( + claude_edit_rel_paths(&event, cwd, root), + vec!["sub/module.rs".to_string()] + ); + + // NotebookEdit uses notebook_path. + let event = serde_json::json!({ + "tool_name": "NotebookEdit", + "tool_input": { "notebook_path": "/repo/analysis.ipynb" } + }); + assert_eq!( + claude_edit_rel_paths(&event, cwd, root), + vec!["analysis.ipynb".to_string()] + ); + + // Paths outside the project root are skipped. + let event = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/elsewhere/other.rs" } + }); + assert!(claude_edit_rel_paths(&event, cwd, root).is_empty()); + } + + #[test] + fn index_status_line_formats_freshness_and_init_nudge() { + assert_eq!( + index_status_line(true, Some("last indexed 5m ago")), + "tracedecay index status: last indexed 5m ago.\n" + ); + assert_eq!( + index_status_line(true, None), + "tracedecay index status: initialized.\n" + ); + assert!(index_status_line(false, None).contains("run `tracedecay init`")); + } } diff --git a/src/main.rs b/src/main.rs index 0df81133..79525b46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -517,6 +517,8 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { hook_command @ (Commands::HookPreToolUse | Commands::HookPromptSubmit | Commands::HookStop + | Commands::HookClaudeSessionStart + | Commands::HookClaudePostToolUse | Commands::HookKiroPreToolUse | Commands::HookKiroPromptSubmit | Commands::HookKiroPostToolUse @@ -803,6 +805,8 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::HookPreToolUse | Commands::HookPromptSubmit | Commands::HookStop + | Commands::HookClaudeSessionStart + | Commands::HookClaudePostToolUse | Commands::HookKiroPreToolUse | Commands::HookKiroPromptSubmit | Commands::HookKiroPostToolUse From 7287cf4aaf1e24d07eb303efe7a66f600af26694 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 07:00:06 +0000 Subject: [PATCH 3/6] fix(daemon): accept claude hook events via shared HookAgent enum --- src/daemon.rs | 93 +++++++++++++++++++++--------- src/hooks.rs | 24 ++++++-- src/mcp/hook_events.rs | 54 ++++++++--------- tests/mcp_suite/mcp_server_test.rs | 62 ++++++++++++++++++++ 4 files changed, 175 insertions(+), 58 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 0d641082..b799e7ff 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -43,6 +43,53 @@ pub use service::{ socket_path_or_default, uninstall_service, DaemonServiceSpec, }; +/// A host whose lifecycle hooks notify the daemon. +/// +/// This enum is the single source of truth for hook agent identity on **both** +/// sides of the wire: the hook processes build [`DaemonHookEvent`]s through it, +/// and the daemon-side receiver (`crate::mcp::hook_events`) parses the agent +/// key back through [`HookAgent::from_wire`]. Adding a host here is the only +/// step needed for its events to be accepted — a per-side string match can +/// silently drop a new host's events (that bug shipped once for Claude). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookAgent { + Claude, + Codex, + Cursor, + Kiro, +} + +impl HookAgent { + pub fn as_wire(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Cursor => "cursor", + Self::Kiro => "kiro", + } + } + + pub fn from_wire(value: &str) -> Option { + match value { + "claude" => Some(Self::Claude), + "codex" => Some(Self::Codex), + "cursor" => Some(Self::Cursor), + "kiro" => Some(Self::Kiro), + _ => None, + } + } + + /// Marker file used to debounce this agent's incremental syncs. + pub fn sync_marker_file(self) -> &'static str { + match self { + Self::Claude => ".claude_post_tool_sync_at", + Self::Codex => ".codex_shell_sync_at", + Self::Cursor => ".cursor_shell_sync_at", + Self::Kiro => ".kiro_post_tool_sync_at", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonHookEvent { pub agent: String, @@ -57,14 +104,14 @@ pub struct DaemonHookEvent { impl DaemonHookEvent { fn new( - agent: &'static str, + agent: HookAgent, event: &'static str, rel_paths: Vec, command: Option, cwd: Option, ) -> Self { Self { - agent: agent.to_string(), + agent: agent.as_wire().to_string(), event: event.to_string(), rel_paths, command, @@ -73,12 +120,12 @@ impl DaemonHookEvent { } pub fn cursor_after_file_edit(rel_paths: Vec) -> Self { - Self::new("cursor", "afterFileEdit", rel_paths, None, None) + Self::new(HookAgent::Cursor, "afterFileEdit", rel_paths, None, None) } pub fn cursor_after_shell_execution(command: String, cwd: PathBuf) -> Self { Self::new( - "cursor", + HookAgent::Cursor, "afterShellExecution", Vec::new(), Some(command), @@ -87,30 +134,25 @@ impl DaemonHookEvent { } pub fn cursor_workspace_open(cwd: PathBuf) -> Self { - Self::new("cursor", "workspaceOpen", Vec::new(), None, Some(cwd)) - } - - pub fn codex_post_tool_use_edit(rel_paths: Vec, cwd: PathBuf) -> Self { - Self::new("codex", "postToolUseEdit", rel_paths, None, Some(cwd)) - } - - pub fn claude_post_tool_use_edit(rel_paths: Vec, cwd: PathBuf) -> Self { - Self::new("claude", "postToolUseEdit", rel_paths, None, Some(cwd)) - } - - pub fn claude_post_tool_use_shell(command: String, cwd: PathBuf) -> Self { Self::new( - "claude", - "postToolUseShell", + HookAgent::Cursor, + "workspaceOpen", Vec::new(), - Some(command), + None, Some(cwd), ) } - pub fn codex_post_tool_use_shell(command: String, cwd: PathBuf) -> Self { + /// A file-edit tool finished: request targeted sync of the edited paths. + pub fn post_tool_use_edit(agent: HookAgent, rel_paths: Vec, cwd: PathBuf) -> Self { + Self::new(agent, "postToolUseEdit", rel_paths, None, Some(cwd)) + } + + /// A shell command finished: let the daemon classify it (branch add, + /// worktree add, incremental sync, or noop). + pub fn post_tool_use_shell(agent: HookAgent, command: String, cwd: PathBuf) -> Self { Self::new( - "codex", + agent, "postToolUseShell", Vec::new(), Some(command), @@ -119,7 +161,7 @@ impl DaemonHookEvent { } pub fn kiro_post_tool_use(rel_paths: Vec, cwd: Option) -> Self { - Self::new("kiro", "postToolUse", rel_paths, None, cwd) + Self::new(HookAgent::Kiro, "postToolUse", rel_paths, None, cwd) } } @@ -381,12 +423,7 @@ fn safe_daemon_hook_rel_paths(paths: &[String]) -> Vec { #[cfg(not(unix))] fn hook_marker_file(agent: &str) -> &'static str { - match agent { - "codex" => ".codex_shell_sync_at", - "cursor" => ".cursor_shell_sync_at", - "kiro" => ".kiro_post_tool_sync_at", - _ => ".daemon_hook_shell_sync_at", - } + HookAgent::from_wire(agent).map_or(".daemon_hook_shell_sync_at", HookAgent::sync_marker_file) } #[cfg(not(unix))] diff --git a/src/hooks.rs b/src/hooks.rs index 27d02514..50f86df9 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1786,7 +1786,11 @@ async fn claude_post_tool_use(event_json: &str) { } crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::claude_post_tool_use_edit(rels, cwd), + crate::daemon::DaemonHookEvent::post_tool_use_edit( + crate::daemon::HookAgent::Claude, + rels, + cwd, + ), ) .await; } else if is_claude_bash_tool(tool_name) { @@ -1800,7 +1804,11 @@ async fn claude_post_tool_use(event_json: &str) { } crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::claude_post_tool_use_shell(command.to_string(), cwd), + crate::daemon::DaemonHookEvent::post_tool_use_shell( + crate::daemon::HookAgent::Claude, + command.to_string(), + cwd, + ), ) .await; } @@ -2302,13 +2310,21 @@ async fn codex_post_tool_use(event_json: &str) { } crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::codex_post_tool_use_edit(rels, cwd), + crate::daemon::DaemonHookEvent::post_tool_use_edit( + crate::daemon::HookAgent::Codex, + rels, + cwd, + ), ) .await; } else if is_codex_bash_tool(tool_name) { crate::daemon::notify_hook_event( &root, - crate::daemon::DaemonHookEvent::codex_post_tool_use_shell(command.to_string(), cwd), + crate::daemon::DaemonHookEvent::post_tool_use_shell( + crate::daemon::HookAgent::Codex, + command.to_string(), + cwd, + ), ) .await; } diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 7561489f..37a400e9 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -7,31 +7,10 @@ use std::path::{Component, Path, PathBuf}; use serde_json::Value; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HookAgent { - Codex, - Cursor, - Kiro, -} - -impl HookAgent { - fn from_wire(value: &str) -> Option { - match value { - "codex" => Some(Self::Codex), - "cursor" => Some(Self::Cursor), - "kiro" => Some(Self::Kiro), - _ => None, - } - } - - fn marker_file(self) -> &'static str { - match self { - Self::Codex => ".codex_shell_sync_at", - Self::Cursor => ".cursor_shell_sync_at", - Self::Kiro => ".kiro_post_tool_sync_at", - } - } -} +/// Shared hook-agent identity: the same enum the hook processes use to build +/// events, so a host registered on the send side can never be silently +/// dropped by the receiver (see `crate::daemon::HookAgent`). +pub(crate) use crate::daemon::HookAgent; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum HookEventKind { @@ -111,7 +90,7 @@ pub(crate) fn plan_hook_event( } pub(crate) fn sync_marker_path(data_root: &Path, agent: HookAgent) -> PathBuf { - data_root.join(agent.marker_file()) + data_root.join(agent.sync_marker_file()) } pub(crate) fn should_run_sync(marker: &Path, now_secs: i64, debounce_secs: i64) -> bool { @@ -257,6 +236,29 @@ mod tests { assert!(parse_hook_event(Some(¶ms)).is_none()); } + /// Regression: the receiver used to keep its own agent string match, so + /// the claude-keyed events added for Claude PostToolUse were silently + /// dropped. Every agent the send side can construct must parse here. + #[test] + fn accepts_every_constructible_hook_agent() { + for agent in [ + HookAgent::Claude, + HookAgent::Codex, + HookAgent::Cursor, + HookAgent::Kiro, + ] { + let params = json!({ + "agent": agent.as_wire(), + "event": "postToolUseEdit", + "rel_paths": ["src/lib.rs"], + "cwd": "/tmp/project" + }); + let event = parse_or_panic(¶ms); + assert_eq!(event.agent, agent); + assert_eq!(event.kind, HookEventKind::FileEdit); + } + } + #[test] fn plans_file_edit_sync_with_sanitized_paths() { let params = json!({ diff --git a/tests/mcp_suite/mcp_server_test.rs b/tests/mcp_suite/mcp_server_test.rs index 2ac533dd..b0710155 100644 --- a/tests/mcp_suite/mcp_server_test.rs +++ b/tests/mcp_suite/mcp_server_test.rs @@ -2730,6 +2730,68 @@ async fn hook_branch_add_reopens_server_from_single_db_mode() { ); } +/// Regression for the silently-dropped `claude` agent key: the hook-event +/// receiver used to accept only `codex`/`cursor`/`kiro`, so Claude's +/// PostToolUse notifications were parsed to `None` and never planned. This +/// exercises the same branch-add path as the codex test above but with the +/// `claude` agent key, proving claude events are accepted end-to-end. +#[tokio::test] +async fn claude_hook_events_are_accepted_and_processed() { + let _branch_lock = BRANCH_DRIFT_TEST_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().to_path_buf(); + + fs::create_dir_all(project.join("src")).unwrap(); + fs::write( + project.join("src/lib.rs"), + "pub fn main_only() -> u32 { 1 }\n", + ) + .unwrap(); + fs::write(project.join(".gitignore"), ".tracedecay/\n").unwrap(); + git(&project, &["init"]); + git(&project, &["config", "user.email", "test@test.com"]); + git(&project, &["config", "user.name", "Test"]); + git(&project, &["add", "."]); + git(&project, &["commit", "-m", "initial"]); + git(&project, &["branch", "-M", "main"]); + + { + let cg = TraceDecay::init(&project).await.unwrap(); + cg.index_all().await.unwrap(); + cg.checkpoint().await.unwrap(); + } + + let cg = TraceDecay::open(&project).await.unwrap(); + assert_eq!(cg.serving_branch(), Some("main")); + let server = McpServer::new(cg, None).await; + git(&project, &["checkout", "-b", "feature"]); + + let responses = run_server_with_messages( + server.clone(), + vec![jsonrpc_notification_with_params( + tracedecay::daemon::HOOK_EVENT_METHOD, + json!({ + "agent": "claude", + "event": "postToolUseShell", + "command": "git switch feature", + "cwd": project, + }), + )], + ) + .await; + assert!( + responses.is_empty(), + "hook notification should not produce a JSON-RPC response" + ); + + let cg_now = server.cg().await; + assert_eq!( + cg_now.serving_branch(), + Some("feature"), + "a claude-keyed hook event must be accepted and planned, not silently dropped" + ); +} + #[tokio::test] async fn tool_calls_reopen_branch_db_after_mid_session_checkout() { let _branch_lock = BRANCH_DRIFT_TEST_LOCK.lock().await; From 1eb71e72cb24f47201bd0bd96e278ca6654fe344 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 08:45:25 +0000 Subject: [PATCH 4/6] refactor(hooks): split host hook integrations --- docs/USER-GUIDE.md | 2 + src/agents/claude.rs | 489 ++- src/agents/claude_agents/code-explorer.md | 28 + .../claude_agents/code-health-auditor.md | 28 + src/agents/claude_agents/session-historian.md | 29 + src/agents/codex.rs | 128 +- src/agents/copilot.rs | 106 +- src/agents/cursor.rs | 79 +- src/agents/gemini.rs | 103 +- src/agents/hermes/templates/skill.md | 10 + src/agents/kimi.rs | 103 +- src/agents/kiro.rs | 168 +- src/agents/mod.rs | 1 + src/agents/opencode.rs | 103 +- src/agents/prompt_rules.rs | 190 + src/agents/vibe.rs | 98 +- src/daemon.rs | 8 +- src/hooks.rs | 3115 ----------------- src/hooks/claude.rs | 201 ++ src/hooks/codex.rs | 605 ++++ src/hooks/cursor.rs | 616 ++++ src/hooks/cursor_compact.rs | 219 ++ src/hooks/cursor_shell.rs | 425 +++ src/hooks/kiro.rs | 249 ++ src/hooks/mod.rs | 435 +++ src/hooks/post_tool_use.rs | 246 ++ src/hooks/steering.rs | 274 ++ src/mcp/hook_events.rs | 4 +- tests/agent_suite/agent_test.rs | 35 +- tests/agent_suite/main.rs | 1 + tests/agent_suite/prompt_rules_parity_test.rs | 219 ++ 31 files changed, 4468 insertions(+), 3849 deletions(-) create mode 100644 src/agents/claude_agents/code-explorer.md create mode 100644 src/agents/claude_agents/code-health-auditor.md create mode 100644 src/agents/claude_agents/session-historian.md create mode 100644 src/agents/prompt_rules.rs delete mode 100644 src/hooks.rs create mode 100644 src/hooks/claude.rs create mode 100644 src/hooks/codex.rs create mode 100644 src/hooks/cursor.rs create mode 100644 src/hooks/cursor_compact.rs create mode 100644 src/hooks/cursor_shell.rs create mode 100644 src/hooks/kiro.rs create mode 100644 src/hooks/mod.rs create mode 100644 src/hooks/post_tool_use.rs create mode 100644 src/hooks/steering.rs create mode 100644 tests/agent_suite/prompt_rules_parity_test.rs diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 2401fd37..9437d8bb 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -192,6 +192,8 @@ This is the default. It registers the MCP server in `~/.claude/settings.json`, g - `SessionStart` reports index freshness (or a `tracedecay init` nudge) and, when the session restarts from compaction, injects the LCM context-recovery hint. - `PostToolUse` (matcher `Edit|MultiEdit|Write|NotebookEdit|Bash`) notifies the daemon so edits and shell commands trigger targeted incremental sync. +The install also ships three read-only custom subagents into `~/.claude/agents/` — `code-explorer`, `code-health-auditor`, and `session-historian` — the same tracedecay subagents the Cursor plugin bundles. They are only replaced or removed when the file is tracedecay-managed; a same-named agent you authored yourself is left untouched. `tracedecay update-plugin` refreshes installed copies. + ### Other agents TraceDecay supports fifteen agents. Pass `--agent` to install for a specific one: diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 74697363..bdba810c 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -15,8 +15,8 @@ use crate::errors::{Result, TraceDecayError}; use super::{ backup_and_write_json, backup_config_file, expected_tool_perms, load_json_file_strict, - safe_write_json_file, write_json_file, AgentIntegration, DoctorCounters, HealthcheckContext, - InstallContext, + safe_write_json_file, safe_write_text_file, write_json_file, AgentIntegration, DoctorCounters, + HealthcheckContext, InstallContext, UpdatePluginOutcome, }; /// Claude Code agent. @@ -52,6 +52,7 @@ impl AgentIntegration for ClaudeIntegration { &claude_md_path, crate::automation::skill_targets::SkillInstallTarget::Claude, )?; + install_subagents(&claude_dir)?; install_clean_local_config(); eprintln!(); @@ -83,7 +84,8 @@ impl AgentIntegration for ClaudeIntegration { &ctx.home, &claude_md_path, crate::automation::skill_targets::SkillInstallTarget::Claude, - ) + )?; + install_subagents(&claude_dir) } fn uninstall(&self, ctx: &InstallContext) -> Result<()> { @@ -96,6 +98,7 @@ impl AgentIntegration for ClaudeIntegration { uninstall_settings(&settings_path); super::remove_managed_skill_prompt_index(&claude_md_path)?; uninstall_claude_md_rules(&claude_md_path); + uninstall_subagents(&claude_dir); eprintln!(); eprintln!("Uninstall complete. TraceDecay has been removed from Claude Code."); @@ -103,11 +106,23 @@ impl AgentIntegration for ClaudeIntegration { Ok(()) } + fn update_plugin(&self, ctx: &InstallContext) -> Result { + let refreshed = refresh_installed_subagents(&ctx.home.join(".claude"))?; + if refreshed.is_empty() { + // MCP entry, hooks, permissions, and CLAUDE.md rules are all + // shared-config surfaces; `tracedecay reinstall` reconciles those. + Ok(UpdatePluginOutcome::ConfigOnly) + } else { + Ok(UpdatePluginOutcome::Refreshed(refreshed)) + } + } + fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) { eprintln!("\n\x1b[1mClaude Code integration\x1b[0m"); doctor_check_claude_json(dc, &ctx.home); doctor_check_settings_json(dc, &ctx.home); doctor_check_claude_md(dc, &ctx.home); + doctor_check_subagents(dc, &ctx.home); doctor_check_local_config(dc, &ctx.project_path); } @@ -200,35 +215,60 @@ fn install_hook_quiet(settings: &mut serde_json::Value, tracedecay_bin: &str) { install_hook_inner(settings, tracedecay_bin, true); } -/// Every managed hook event with its subcommand. The single source of truth -/// for install, uninstall, doctor checks, and doctor auto-repair. -const MANAGED_HOOKS: &[(&str, &str)] = &[ - ("PreToolUse", "hook-pre-tool-use"), - ("UserPromptSubmit", "hook-prompt-submit"), - ("Stop", "hook-stop"), - ("SessionStart", "hook-claude-session-start"), - ("PostToolUse", "hook-claude-post-tool-use"), -]; +struct ManagedHook { + event: &'static str, + subcommand: &'static str, + matcher: Option String>, +} -/// Matcher registered for a managed hook event, if any. -fn managed_hook_matcher(event: &str) -> Option<&'static str> { - match event { - // Only Agent tool calls are screened for explore-agent redirection. - "PreToolUse" => Some("Agent"), - // Only edit tools and shell commands can invalidate the graph. - "PostToolUse" => Some("Edit|MultiEdit|Write|NotebookEdit|Bash"), - _ => None, +impl ManagedHook { + fn matcher_value(&self) -> Option { + self.matcher.map(|build| build()) } } +/// Only Agent tool calls are screened for explore-agent redirection. +fn pre_tool_use_matcher() -> String { + "Agent".to_string() +} + +/// Every managed hook event, in registration order. +const MANAGED_HOOKS: &[ManagedHook] = &[ + ManagedHook { + event: "PreToolUse", + subcommand: "hook-pre-tool-use", + matcher: Some(pre_tool_use_matcher), + }, + ManagedHook { + event: "UserPromptSubmit", + subcommand: "hook-prompt-submit", + matcher: None, + }, + ManagedHook { + event: "Stop", + subcommand: "hook-stop", + matcher: None, + }, + ManagedHook { + event: "SessionStart", + subcommand: "hook-claude-session-start", + matcher: None, + }, + ManagedHook { + event: "PostToolUse", + subcommand: "hook-claude-post-tool-use", + matcher: Some(crate::hooks::claude_post_tool_use_matcher), + }, +]; + fn install_hook_inner(settings: &mut serde_json::Value, tracedecay_bin: &str, quiet: bool) { - for &(event, subcommand) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { install_single_hook( settings, - event, + hook.event, tracedecay_bin, - subcommand, - managed_hook_matcher(event), + hook.subcommand, + hook.matcher_value().as_deref(), quiet, ); } @@ -352,35 +392,65 @@ fn install_permissions(settings: &mut serde_json::Value, tool_permissions: &[Str eprintln!("\x1b[32m✔\x1b[0m Added tool permissions"); } -/// Append CLAUDE.md rules (idempotent). -fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { - let marker = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; - // Display-case marker from older versions — treat as already present. - let display_marker = "## MANDATORY: No Explore Agents When TraceDecay Is Available"; - let existing_md = if claude_md_path.is_file() { - std::fs::read_to_string(claude_md_path).map_err(|e| TraceDecayError::Config { - message: format!("failed to read {}: {e}", claude_md_path.display()), - })? - } else { - String::new() +/// Marker heading of the tracedecay-managed CLAUDE.md rules block. +const CLAUDE_MD_MARKER: &str = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; +/// Display-case marker written by older versions. +const CLAUDE_MD_DISPLAY_MARKER: &str = + "## MANDATORY: No Explore Agents When TraceDecay Is Available"; +/// Marker fragment from the Codegraph product-name era. Matched as a +/// substring because historical heading prefixes varied. +const CLAUDE_MD_CODEGRAPH_MARKER: &str = "No Explore Agents When Codegraph Is Available"; + +/// Markers the uninstall path recognizes (unchanged historical behavior). +const CLAUDE_MD_UNINSTALL_MARKERS: &[&str] = &[CLAUDE_MD_MARKER, CLAUDE_MD_DISPLAY_MARKER]; +/// Markers the install reconcile treats as an existing (possibly stale) +/// managed block, including the legacy Codegraph variant. +const CLAUDE_MD_RECONCILE_MARKERS: &[&str] = &[ + CLAUDE_MD_MARKER, + CLAUDE_MD_DISPLAY_MARKER, + CLAUDE_MD_CODEGRAPH_MARKER, +]; + +/// Byte range of the tracedecay-managed CLAUDE.md rules block. +fn claude_md_rules_block_range(contents: &str, markers: &[&str]) -> Option> { + let (start, marker_end) = markers.iter().find_map(|marker| { + contents.find(marker).map(|pos| { + let line_start = contents[..pos].rfind('\n').map_or(0, |nl| nl + 1); + (line_start, pos + marker.len()) + }) + })?; + // The managed block includes its tracedecay-owned sub-headings. + let mut end = { + let mut search_from = marker_end; + loop { + match contents[search_from..].find("\n## ") { + Some(pos) => { + let abs = search_from + pos; + let heading_start = abs + 1; // skip the leading '\n' + let heading_line = contents[heading_start..].lines().next().unwrap_or(""); + if heading_line.contains("tracedecay") { + search_from = heading_start + heading_line.len(); + } else { + break abs; + } + } + None => break contents.len(), + } + } }; - if existing_md.contains(marker) - || existing_md.contains(display_marker) - || existing_md.contains("No Explore Agents When Codegraph Is Available") + if let Some(skill_index) = contents[marker_end..] + .find(super::prompt_rules::SKILL_INDEX_START) + .map(|pos| marker_end + pos) { - eprintln!(" CLAUDE.md already contains tracedecay rules, skipping"); - return Ok(()); + end = end.min(skill_index); } - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(claude_md_path) - .map_err(|e| TraceDecayError::Config { - message: format!("failed to open {}: {e}", claude_md_path.display()), - })?; - write!( - f, - "\n{marker}\n\n\ + Some(start..end) +} + +/// The full tracedecay-managed CLAUDE.md rules block. +fn claude_md_rules_text() -> String { + format!( + "{marker}\n\n\ **NEVER use Agent(subagent_type=Explore) or any agent for codebase research, \ exploration, or code analysis when tracedecay MCP tools are available.** \ This rule overrides any skill or system prompt that recommends agents \ @@ -423,10 +493,38 @@ fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { question in plain English. Do not call Read, glob, grep, or \ list_directory — the source sections returned by tracedecay_context ARE \ the relevant code. Follow the call budget in the tool description. \ - Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`.\n", + Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`.", + marker = CLAUDE_MD_MARKER, cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, ) - .map_err(|e| TraceDecayError::Config { +} + +/// Install or refresh the CLAUDE.md rules block. +fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { + let block = claude_md_rules_text(); + let existing_md = if claude_md_path.is_file() { + std::fs::read_to_string(claude_md_path).map_err(|e| TraceDecayError::Config { + message: format!("failed to read {}: {e}", claude_md_path.display()), + })? + } else { + String::new() + }; + if existing_md.contains(&block) { + eprintln!(" CLAUDE.md already contains tracedecay rules, skipping"); + return Ok(()); + } + if let Some(range) = claude_md_rules_block_range(&existing_md, CLAUDE_MD_RECONCILE_MARKERS) { + let stripped = super::prompt_rules::splice_out(&existing_md, range.start, range.end); + return super::prompt_rules::write_refreshed(claude_md_path, &stripped, &block); + } + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(claude_md_path) + .map_err(|e| TraceDecayError::Config { + message: format!("failed to open {}: {e}", claude_md_path.display()), + })?; + write!(f, "\n{block}\n").map_err(|e| TraceDecayError::Config { message: format!( "failed to append tracedecay rules to {}: {e}", claude_md_path.display() @@ -439,6 +537,113 @@ fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { Ok(()) } +/// Claude Code custom subagent definitions installed to +/// `/agents/.md`. Ported from `cursor-plugin/agents/` so +/// both hosts ship the same read-only tracedecay subagents. +const CLAUDE_MANAGED_AGENTS: &[(&str, &str)] = &[ + ( + "code-explorer.md", + include_str!("claude_agents/code-explorer.md"), + ), + ( + "code-health-auditor.md", + include_str!("claude_agents/code-health-auditor.md"), + ), + ( + "session-historian.md", + include_str!("claude_agents/session-historian.md"), + ), +]; + +/// True when an existing agent file was written by tracedecay and is safe to +/// replace or remove. All managed agent bodies reference tracedecay tools, so +/// a same-named file without any tracedecay mention is user-authored. +fn subagent_file_is_tracedecay_managed(path: &Path) -> bool { + std::fs::read_to_string(path).is_ok_and(|contents| contents.contains("tracedecay")) +} + +/// Write the managed subagent definitions under `/agents/`, +/// skipping any same-named file the user authored themselves. +fn install_subagents(claude_dir: &Path) -> Result<()> { + let agents_dir = claude_dir.join("agents"); + let mut installed = 0usize; + for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { + let path = agents_dir.join(file_name); + if path.exists() && !subagent_file_is_tracedecay_managed(&path) { + eprintln!( + " Skipping {} — an existing non-tracedecay agent uses that name", + path.display() + ); + continue; + } + safe_write_text_file(&path, contents, None)?; + installed += 1; + } + if installed > 0 { + eprintln!( + "\x1b[32m✔\x1b[0m Installed {installed} Claude subagent(s) in {}", + agents_dir.display() + ); + } + Ok(()) +} + +/// Remove the managed subagent definitions (managed copies only). +fn uninstall_subagents(claude_dir: &Path) { + let agents_dir = claude_dir.join("agents"); + let mut removed = 0usize; + for &(file_name, _) in CLAUDE_MANAGED_AGENTS { + let path = agents_dir.join(file_name); + if path.exists() + && subagent_file_is_tracedecay_managed(&path) + && std::fs::remove_file(&path).is_ok() + { + removed += 1; + } + } + if removed > 0 { + std::fs::remove_dir(&agents_dir).ok(); // only removes if now empty + eprintln!("\x1b[32m✔\x1b[0m Removed {removed} Claude subagent(s)"); + } +} + +/// Rewrite managed subagent files that are already installed, without +/// creating new ones — the config-free refresh used by `update-plugin`. +fn refresh_installed_subagents(claude_dir: &Path) -> Result> { + let agents_dir = claude_dir.join("agents"); + let mut refreshed = Vec::new(); + for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { + let path = agents_dir.join(file_name); + if path.exists() && subagent_file_is_tracedecay_managed(&path) { + safe_write_text_file(&path, contents, None)?; + refreshed.push(path); + } + } + Ok(refreshed) +} + +/// Check the managed subagent definitions are installed. +fn doctor_check_subagents(dc: &mut DoctorCounters, home: &Path) { + let agents_dir = home.join(".claude/agents"); + let missing: Vec<&str> = CLAUDE_MANAGED_AGENTS + .iter() + .filter_map(|&(file_name, _)| (!agents_dir.join(file_name).exists()).then_some(file_name)) + .collect(); + if missing.is_empty() { + dc.pass(&format!( + "All {} tracedecay subagents installed in {}", + CLAUDE_MANAGED_AGENTS.len(), + agents_dir.display() + )); + } else { + dc.warn(&format!( + "tracedecay subagent(s) missing in {}: {} — run `tracedecay install`", + agents_dir.display(), + missing.join(", ") + )); + } +} + /// Clean up local project config (.mcp.json and settings.local.json). fn install_clean_local_config() { let project_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); @@ -624,8 +829,8 @@ fn uninstall_stale_mcp(settings: &mut serde_json::Value) -> bool { /// Remove all tracedecay hooks. Returns true if modified. fn uninstall_hook(settings: &mut serde_json::Value) -> bool { let mut modified = false; - for &(event, _) in MANAGED_HOOKS { - modified |= uninstall_single_hook(settings, event); + for hook in MANAGED_HOOKS { + modified |= uninstall_single_hook(settings, hook.event); } modified } @@ -723,44 +928,10 @@ fn uninstall_claude_md_rules(claude_md_path: &Path) { return; } // Try steady marker first, then display-case marker. - let marker_new = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; - let marker_display = "## MANDATORY: No Explore Agents When TraceDecay Is Available"; - let (marker, start) = if let Some(s) = contents.find(marker_new) { - (marker_new, s) - } else if let Some(s) = contents.find(marker_display) { - (marker_display, s) - } else { + let Some(range) = claude_md_rules_block_range(&contents, CLAUDE_MD_UNINSTALL_MARKERS) else { return; }; - let after_marker = start + marker.len(); - // Skip past any sub-headings that are part of our rules block - // (e.g. "## When you spawn an Explore agent"). - let end = { - let mut search_from = after_marker; - loop { - match contents[search_from..].find("\n## ") { - Some(pos) => { - let abs = search_from + pos; - let heading_start = abs + 1; // skip the leading '\n' - let heading_line = contents[heading_start..].lines().next().unwrap_or(""); - if heading_line.contains("tracedecay") { - search_from = heading_start + heading_line.len(); - } else { - break abs; - } - } - None => break contents.len(), - } - } - }; - let mut new_contents = String::new(); - new_contents.push_str(contents[..start].trim_end()); - let remainder = &contents[end..]; - if !remainder.is_empty() { - new_contents.push_str("\n\n"); - new_contents.push_str(remainder.trim_start()); - } - let new_contents = new_contents.trim().to_string(); + let new_contents = super::prompt_rules::splice_out(&contents, range.start, range.end); if new_contents.is_empty() { std::fs::remove_file(claude_md_path).ok(); eprintln!( @@ -893,14 +1064,14 @@ fn doctor_check_settings_json(dc: &mut DoctorCounters, home: &Path) { fn expected_hook_subcommand(event: &str) -> Option<&'static str> { MANAGED_HOOKS .iter() - .find(|(managed_event, _)| *managed_event == event) - .map(|&(_, subcommand)| subcommand) + .find(|hook| hook.event == event) + .map(|hook| hook.subcommand) } /// Check all tracedecay hooks in settings. fn doctor_check_hook(dc: &mut DoctorCounters, settings: &serde_json::Value) { - for &(event, _) in MANAGED_HOOKS { - doctor_check_single_hook(dc, settings, event); + for hook in MANAGED_HOOKS { + doctor_check_single_hook(dc, settings, hook.event); } } @@ -958,11 +1129,11 @@ fn doctor_fix_hooks(dc: &mut DoctorCounters, settings_path: &Path, settings: &se let mut settings = settings.clone(); let mut repaired = false; - for &(event, expected_sub) in MANAGED_HOOKS { - let current = find_tracedecay_hook(&settings, event); + for hook in MANAGED_HOOKS { + let current = find_tracedecay_hook(&settings, hook.event); let correct = current .as_ref() - .is_some_and(|(_, s, legacy)| !*legacy && s == expected_sub); + .is_some_and(|(_, s, legacy)| !*legacy && s == hook.subcommand); if correct { continue; } @@ -978,14 +1149,14 @@ fn doctor_fix_hooks(dc: &mut DoctorCounters, settings_path: &Path, settings: &se }; if current.is_some() { - uninstall_single_hook(&mut settings, event); + uninstall_single_hook(&mut settings, hook.event); } install_single_hook( &mut settings, - event, + hook.event, &bin, - expected_sub, - managed_hook_matcher(event), + hook.subcommand, + hook.matcher_value().as_deref(), true, ); repaired = true; @@ -1371,14 +1542,14 @@ mod tests { "allow": ["mcp__tracedecay__search", "mcp__tracedecay__lookup"] } }); - for &(event, subcommand) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { let mut entry = json!({ - "hooks": [{ "type": "command", "command": bin, "args": [subcommand] }] + "hooks": [{ "type": "command", "command": bin, "args": [hook.subcommand] }] }); - if let Some(matcher) = managed_hook_matcher(event) { + if let Some(matcher) = hook.matcher_value() { entry["matcher"] = json!(matcher); } - settings["hooks"][event] = json!([entry]); + settings["hooks"][hook.event] = json!([entry]); } settings } @@ -1497,14 +1668,96 @@ mod tests { // Install tests // ----------------------------------------------------------------------- + #[test] + fn subagents_install_refresh_and_uninstall_respect_user_files() { + let dir = tempfile::tempdir().unwrap(); + let claude_dir = dir.path().join(".claude"); + let agents_dir = claude_dir.join("agents"); + + // A user-authored agent squatting on a managed name must survive + // install, refresh, and uninstall untouched. + std::fs::create_dir_all(&agents_dir).unwrap(); + let user_agent = agents_dir.join("code-explorer.md"); + std::fs::write(&user_agent, "my own agent, nothing to do with the tool").unwrap(); + + install_subagents(&claude_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(&user_agent).unwrap(), + "my own agent, nothing to do with the tool" + ); + assert!(agents_dir.join("code-health-auditor.md").exists()); + assert!(agents_dir.join("session-historian.md").exists()); + + // Refresh rewrites only installed managed copies. + std::fs::write( + agents_dir.join("session-historian.md"), + "stale tracedecay copy", + ) + .unwrap(); + let refreshed = refresh_installed_subagents(&claude_dir).unwrap(); + assert_eq!( + refreshed.len(), + 2, + "two managed copies exist: {refreshed:?}" + ); + assert!( + std::fs::read_to_string(agents_dir.join("session-historian.md")) + .unwrap() + .contains("tracedecay_message_search"), + "refresh must rewrite stale managed copies" + ); + + uninstall_subagents(&claude_dir); + assert!(user_agent.exists(), "user agent must survive uninstall"); + assert!(!agents_dir.join("code-health-auditor.md").exists()); + assert!(!agents_dir.join("session-historian.md").exists()); + } + + #[test] + fn managed_subagent_definitions_have_valid_frontmatter() { + for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { + let stem = file_name.trim_end_matches(".md"); + assert!( + contents.starts_with("---\n"), + "{file_name} must open YAML frontmatter" + ); + assert!( + contents.contains(&format!("name: {stem}\n")), + "{file_name} frontmatter name must match its filename" + ); + assert!( + contents.contains("description: "), + "{file_name} must carry a description for delegation" + ); + assert!( + contents.contains("tracedecay"), + "{file_name} must reference tracedecay so it is recognized as managed" + ); + } + } + + /// The PostToolUse matcher is derived from the hook handler's tool list, + /// so the installed matcher can never accept tools the handler ignores. + #[test] + fn post_tool_use_matcher_comes_from_the_hook_handler_tool_list() { + let matcher = MANAGED_HOOKS + .iter() + .find(|hook| hook.event == "PostToolUse") + .and_then(ManagedHook::matcher_value) + .expect("PostToolUse must register a matcher"); + assert_eq!(matcher, crate::hooks::claude_post_tool_use_matcher()); + assert!(matcher.contains("Edit") && matcher.contains("Bash")); + } + #[test] fn install_adds_all_managed_hooks() { let mut settings = json!({}); install_hook(&mut settings, "/usr/bin/tracedecay"); - for &(event, _) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { assert!( - settings["hooks"][event].is_array(), - "{event} hook should be installed" + settings["hooks"][hook.event].is_array(), + "{} hook should be installed", + hook.event ); } } @@ -1541,7 +1794,8 @@ mod tests { let mut settings = json!({}); install_hook(&mut settings, bin); - for &(event, expected_sub) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { + let (event, expected_sub) = (hook.event, hook.subcommand); let inner = &settings["hooks"][event][0]["hooks"][0]; assert_eq!( inner["command"].as_str().unwrap(), @@ -1651,7 +1905,8 @@ mod tests { .to_str() .unwrap() .to_string(); - for &(event, expected_sub) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { + let (event, expected_sub) = (hook.event, hook.subcommand); let inner = &after["hooks"][event][0]["hooks"][0]; assert_eq!( inner["command"].as_str().unwrap(), @@ -1808,10 +2063,11 @@ mod tests { "C:/Users/u/tracedecay.exe hook-stop" ); // Every managed event should now be present (backfill). - for &(event, _) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { assert!( - parsed["hooks"][event].is_array(), - "{event} hook should be backfilled" + parsed["hooks"][hook.event].is_array(), + "{} hook should be backfilled", + hook.event ); } } @@ -1951,10 +2207,11 @@ mod tests { // Re-read and verify every managed hook is present. let fixed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - for &(event, _) in MANAGED_HOOKS { + for hook in MANAGED_HOOKS { assert!( - fixed["hooks"][event].is_array(), - "{event} hook should be repaired in" + fixed["hooks"][hook.event].is_array(), + "{} hook should be repaired in", + hook.event ); } } diff --git a/src/agents/claude_agents/code-explorer.md b/src/agents/claude_agents/code-explorer.md new file mode 100644 index 00000000..dd4d3327 --- /dev/null +++ b/src/agents/claude_agents/code-explorer.md @@ -0,0 +1,28 @@ +--- +name: code-explorer +description: Read-only code exploration agent powered by the TraceDecay code graph. Use PROACTIVELY for codebase research — how/where/what questions, symbol lookup, callers/callees tracing, call chains, and impact analysis — whenever TraceDecay MCP tools are available. Also use to parallelize codebase research or isolate a deep exploration from the main thread. Never edits files. +model: inherit +tools: Read, Grep, Glob, mcp__tracedecay +disallowedTools: mcp__tracedecay__tracedecay_str_replace, mcp__tracedecay__tracedecay_multi_str_replace, mcp__tracedecay__tracedecay_insert_at, mcp__tracedecay__tracedecay_insert_at_symbol, mcp__tracedecay__tracedecay_replace_symbol, mcp__tracedecay__tracedecay_ast_grep_rewrite, mcp__tracedecay__tracedecay_run_affected_tests, mcp__tracedecay__tracedecay_diagnostics, mcp__tracedecay__tracedecay_session_start, mcp__tracedecay__tracedecay_session_end, mcp__tracedecay__tracedecay_fact_store, mcp__tracedecay__tracedecay_fact_feedback, mcp__tracedecay__tracedecay_memory_status, mcp__tracedecay__tracedecay_lcm_compress, mcp__tracedecay__tracedecay_lcm_preflight, mcp__tracedecay__tracedecay_lcm_session_boundary, mcp__tracedecay__tracedecay_lcm_doctor +--- + +# Code explorer (read-only) + +You are a read-only exploration subagent. You investigate the repository and return findings; you never edit files or run mutating tools. + +## Method + +1. Start with `tracedecay_context` (add `keywords` for concepts). **Respect the per-project call budget shown in the tool description.** Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`. +2. Narrow with `tracedecay_search` / `tracedecay_find_exact_symbol` / `tracedecay_body` / `tracedecay_outline`. +3. Trace with `tracedecay_callers` / `tracedecay_callees` / `tracedecay_call_chain`; assess reach with `tracedecay_impact`. +4. Fall back to Grep/Read only for non-indexed content or after TraceDecay pinpoints files. + +## Rules + +- Read-only: never edit files, run test runners or diagnostics, or write memory. Mutating TraceDecay tools are disabled for this agent; do not attempt to work around that. +- Do not spawn nested subagents unless explicitly asked. + +## Return + +- A concise answer plus the concrete files + qualified symbol names and key relationships found. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/src/agents/claude_agents/code-health-auditor.md b/src/agents/claude_agents/code-health-auditor.md new file mode 100644 index 00000000..de673510 --- /dev/null +++ b/src/agents/claude_agents/code-health-auditor.md @@ -0,0 +1,28 @@ +--- +name: code-health-auditor +description: Read-only code-health audit agent powered by the TraceDecay code graph. Use PROACTIVELY when asked for a health audit, tech-debt report, code-quality scorecard, or the worst complexity, duplication, coupling, doc, and test-risk offenders. Also use to run a health audit in isolation or parallelize a large-repo review. Never edits files. +model: inherit +tools: Read, Grep, Glob, Skill, mcp__tracedecay +disallowedTools: mcp__tracedecay__tracedecay_str_replace, mcp__tracedecay__tracedecay_multi_str_replace, mcp__tracedecay__tracedecay_insert_at, mcp__tracedecay__tracedecay_insert_at_symbol, mcp__tracedecay__tracedecay_replace_symbol, mcp__tracedecay__tracedecay_ast_grep_rewrite, mcp__tracedecay__tracedecay_run_affected_tests, mcp__tracedecay__tracedecay_diagnostics, mcp__tracedecay__tracedecay_session_start, mcp__tracedecay__tracedecay_session_end, mcp__tracedecay__tracedecay_fact_store, mcp__tracedecay__tracedecay_fact_feedback, mcp__tracedecay__tracedecay_memory_status, mcp__tracedecay__tracedecay_lcm_compress, mcp__tracedecay__tracedecay_lcm_preflight, mcp__tracedecay__tracedecay_lcm_session_boundary, mcp__tracedecay__tracedecay_lcm_doctor +--- + +# Code-health auditor (read-only) + +You are a read-only audit subagent. You score and rank code health and return findings; you never edit files, run the toolchain, or write memory. + +## Method + +1. Start with `tracedecay_health` (`details: true`) and let the weak dimensions drive the drill-down. +2. Drill only into weak dimensions or explicit asks: complexity/size -> `tracedecay_complexity`, `tracedecay_gini`, `tracedecay_god_class`, `tracedecay_largest`, `tracedecay_hotspots`; structure -> `tracedecay_coupling`, `tracedecay_dependency_depth`, `tracedecay_dsm`, `tracedecay_circular`, `tracedecay_recursion`; quality -> `tracedecay_redundancy`, `tracedecay_doc_coverage`, `tracedecay_unsafe_patterns`, `tracedecay_test_risk`. +3. Keep expensive scans scoped (`path`, `limit`, `max_pairs`) and stop once the ranked findings are actionable. +4. If the `tracedecay:code-health-report` skill is available, follow its full workflow. + +## Rules + +- Read-only: never edit files, run test runners or diagnostics, write session baselines, or write memory. Mutating TraceDecay tools are disabled for this agent; do not attempt to work around that. +- Keep `path`/`max_pairs` tight on `tracedecay_redundancy` (first call can be slow). Do not spawn nested subagents unless asked. + +## Return + +- The composite score, weak dimensions, ranked offenders, and a prioritized fix list with concrete files + qualified symbol names. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/src/agents/claude_agents/session-historian.md b/src/agents/claude_agents/session-historian.md new file mode 100644 index 00000000..292631d5 --- /dev/null +++ b/src/agents/claude_agents/session-historian.md @@ -0,0 +1,29 @@ +--- +name: session-historian +description: Read-only session-recall agent powered by TraceDecay's transcript index and LCM store. Use PROACTIVELY for "what did we decide/do/discuss previously" questions — message search, lossless session replay, summary-DAG drill-down, and durable fact search. Use to recover prior context without polluting the main thread. Never edits files or mutates memory. +model: inherit +tools: Read, Grep, Glob, Skill, mcp__tracedecay +disallowedTools: mcp__tracedecay__tracedecay_str_replace, mcp__tracedecay__tracedecay_multi_str_replace, mcp__tracedecay__tracedecay_insert_at, mcp__tracedecay__tracedecay_insert_at_symbol, mcp__tracedecay__tracedecay_replace_symbol, mcp__tracedecay__tracedecay_ast_grep_rewrite, mcp__tracedecay__tracedecay_run_affected_tests, mcp__tracedecay__tracedecay_diagnostics, mcp__tracedecay__tracedecay_session_start, mcp__tracedecay__tracedecay_session_end, mcp__tracedecay__tracedecay_fact_feedback, mcp__tracedecay__tracedecay_memory_status, mcp__tracedecay__tracedecay_lcm_compress, mcp__tracedecay__tracedecay_lcm_preflight, mcp__tracedecay__tracedecay_lcm_session_boundary +--- + +# Session historian (read-only) + +You are a read-only recall subagent. You retrieve what past sessions said, did, and decided for this project; you never edit files, mutate memory, or run lifecycle tools. + +## Method + +1. Start with `tracedecay_message_search` (fast FTS over ingested transcripts; note the session ids on hits). +2. Narrow with `tracedecay_lcm_grep` (scope/role/time filters), then replay with `tracedecay_lcm_load_session` (paginate via `after_store_id`, never dump whole sessions). +3. Drill into summaries with `tracedecay_lcm_describe` / `tracedecay_lcm_expand` / `tracedecay_lcm_expand_query`; inspect the store with `tracedecay_lcm_status`. +4. For durable decisions/facts, search `tracedecay_fact_store` (`action: "search"`, plus `"probe"`/`"reason"` when useful). +5. If the `tracedecay:recalling-session-context` skill is available, follow its full ladder. + +## Rules + +- Read-only: use `tracedecay_fact_store` only with read actions (`search`, `probe`, `reason`, `related`, `get`, `list`) — never `add`, `update`, or `remove`. Use `tracedecay_lcm_doctor` only in check mode — never repair/clean modes. Other mutating TraceDecay tools are disabled for this agent; do not attempt to work around that. +- Do not spawn nested subagents unless explicitly asked. + +## Return + +- A concise answer with the supporting quotes/decisions, each cited by session id + timestamp (and fact id where applicable). +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 725a9c86..9c919e9a 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -580,48 +580,66 @@ fn codex_plugin_mcp(raw: &str, tracedecay_bin: &str, scope: InstallScope) -> Res Ok(format!("{}\n", serde_json::to_string_pretty(&mcp)?)) } +/// A lifecycle hook the Codex plugin registers. +struct CodexManagedHook { + event: &'static str, + subcommand: &'static str, + timeout_secs: u64, + matcher: Option<&'static str>, +} + +/// Every Codex lifecycle hook, in registration order. The single source of +/// truth for install ([`codex_plugin_hooks`]), uninstall ([`uninstall_hooks`]), +/// and doctor checks ([`doctor_check_hooks`]). +const CODEX_MANAGED_HOOKS: &[CodexManagedHook] = &[ + CodexManagedHook { + event: "SessionStart", + subcommand: "hook-codex-session-start", + timeout_secs: 5, + matcher: None, + }, + CodexManagedHook { + event: "UserPromptSubmit", + subcommand: "hook-codex-user-prompt-submit", + timeout_secs: 5, + matcher: None, + }, + CodexManagedHook { + event: "SubagentStart", + subcommand: "hook-codex-subagent-start", + timeout_secs: 5, + matcher: None, + }, + CodexManagedHook { + event: "PostToolUse", + subcommand: "hook-codex-post-tool-use", + timeout_secs: 60, + matcher: Some("Bash|apply_patch"), + }, + CodexManagedHook { + event: "PostCompact", + subcommand: "hook-codex-post-compact", + timeout_secs: 120, + matcher: Some("auto|manual"), + }, +]; + +/// Subcommands from older bundles that uninstall must also strip even though +/// the current bundle no longer registers them. +const CODEX_LEGACY_HOOK_SUBCOMMANDS: &[&str] = &["hook-codex-pre-tool-use"]; + fn codex_plugin_hooks(raw: &str, tracedecay_bin: &str) -> Result { let mut hooks: serde_json::Value = serde_json::from_str(raw)?; - install_codex_hook_event( - &mut hooks, - "SessionStart", - tracedecay_bin, - "hook-codex-session-start", - 5, - None, - ); - install_codex_hook_event( - &mut hooks, - "UserPromptSubmit", - tracedecay_bin, - "hook-codex-user-prompt-submit", - 5, - None, - ); - install_codex_hook_event( - &mut hooks, - "SubagentStart", - tracedecay_bin, - "hook-codex-subagent-start", - 5, - None, - ); - install_codex_hook_event( - &mut hooks, - "PostToolUse", - tracedecay_bin, - "hook-codex-post-tool-use", - 60, - Some("Bash|apply_patch"), - ); - install_codex_hook_event( - &mut hooks, - "PostCompact", - tracedecay_bin, - "hook-codex-post-compact", - 120, - Some("auto|manual"), - ); + for hook in CODEX_MANAGED_HOOKS { + install_codex_hook_event( + &mut hooks, + hook.event, + tracedecay_bin, + hook.subcommand, + hook.timeout_secs, + hook.matcher, + ); + } Ok(format!("{}\n", serde_json::to_string_pretty(&hooks)?)) } @@ -968,14 +986,11 @@ fn print_hook_trust_guidance() { /// Remove tracedecay-owned hook groups from a Codex `hooks.json`. fn uninstall_hooks(hooks_path: &Path) { - const SUBCOMMANDS: [&str; 6] = [ - "hook-codex-session-start", - "hook-codex-user-prompt-submit", - "hook-codex-subagent-start", - "hook-codex-post-tool-use", - "hook-codex-post-compact", - "hook-codex-pre-tool-use", - ]; + let subcommands: Vec<&str> = CODEX_MANAGED_HOOKS + .iter() + .map(|hook| hook.subcommand) + .chain(CODEX_LEGACY_HOOK_SUBCOMMANDS.iter().copied()) + .collect(); if !hooks_path.exists() { return; @@ -989,7 +1004,7 @@ fn uninstall_hooks(hooks_path: &Path) { }; for groups in events.values_mut() { if let Some(arr) = groups.as_array_mut() { - arr.retain(|group| !SUBCOMMANDS.iter().any(|sc| group_has_subcommand(group, sc))); + arr.retain(|group| !subcommands.iter().any(|sc| group_has_subcommand(group, sc))); } } events.retain(|_, groups| groups.as_array().is_some_and(|a| !a.is_empty())); @@ -1351,23 +1366,16 @@ fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { return; } let hooks = super::load_json_file(hooks_path); - let expected = [ - ("SessionStart", "hook-codex-session-start"), - ("UserPromptSubmit", "hook-codex-user-prompt-submit"), - ("SubagentStart", "hook-codex-subagent-start"), - ("PostToolUse", "hook-codex-post-tool-use"), - ("PostCompact", "hook-codex-post-compact"), - ]; - let missing: Vec<&str> = expected + let missing: Vec<&str> = CODEX_MANAGED_HOOKS .iter() - .filter_map(|(event, command)| { - (!codex_hook_present(&hooks, event, command)).then_some(*event) + .filter_map(|hook| { + (!codex_hook_present(&hooks, hook.event, hook.subcommand)).then_some(hook.event) }) .collect(); if missing.is_empty() { dc.pass(&format!( "All {} Codex lifecycle hooks registered in {}", - expected.len(), + CODEX_MANAGED_HOOKS.len(), hooks_path.display() )); dc.info( diff --git a/src/agents/copilot.rs b/src/agents/copilot.rs index ab527ea7..5cbdb839 100644 --- a/src/agents/copilot.rs +++ b/src/agents/copilot.rs @@ -16,7 +16,7 @@ use super::{ DoctorCounters, HealthcheckContext, InstallContext, }; -const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; +use super::prompt_rules::{PromptRulesOptions, PROMPT_RULE_MARKER}; /// GitHub Copilot agent. pub struct CopilotIntegration; @@ -382,108 +382,20 @@ fn uninstall_cli_mcp_server(settings_path: &Path) { // Prompt rules helpers // --------------------------------------------------------------------------- -/// Append prompt rules to a copilot-instructions.md file (idempotent). +/// Install-or-refresh prompt rules in a copilot-instructions.md file. fn install_prompt_rules(instructions_path: &Path) -> Result<()> { - use std::io::Write; - let existing = if instructions_path.exists() { - std::fs::read_to_string(instructions_path).unwrap_or_default() - } else { - String::new() - }; - if existing.contains(PROMPT_RULE_MARKER) { - eprintln!( - " {} already contains tracedecay rules, skipping", - instructions_path.display() - ); - return Ok(()); - } - if let Some(parent) = instructions_path.parent() { - std::fs::create_dir_all(parent).ok(); - } - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(instructions_path) - .map_err(|e| crate::errors::TraceDecayError::Config { - message: format!("failed to open {}: {e}", instructions_path.display()), - })?; - write!( - f, - "\n{PROMPT_RULE_MARKER}\n\n\ - Before reading source files or scanning the codebase, use the tracedecay MCP tools \ - (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ - `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ - They provide instant semantic results from a pre-built knowledge graph and are \ - faster than file reads.\n\n\ - For project/storage identity questions, use `tracedecay_active_project` \ - or `tracedecay_storage_status` instead of inferring from repo-local marker \ - files or direct DB paths.\n\n\ - If a code analysis question cannot be fully answered by tracedecay MCP tools, \ - prefer built-in MCP tools first. If the user explicitly needs raw store \ - inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ - rather than a hardcoded repo-local path. Use SQL to answer complex structural \ - queries that go beyond what the built-in tools expose.\n\n\ - For durable project/user facts, prefer `tracedecay_fact_store`, \ - `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ - Use `tracedecay_message_search` for active-project transcript recall when \ - prior conversation context matters. Do not store secrets, credentials, or \ - unnecessary PII in persistent facts.\n\n\ - {cli_fallback}\n\n\ - If you find a gap where tracedecay could answer a question natively, propose opening \ - an issue at https://github.com/ScriptedAlchemy/tracedecay. Remind the user to strip \ - sensitive or proprietary code from any issue text before submitting.\n", - cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, - ) - .map_err(|e| crate::errors::TraceDecayError::Config { - message: format!("failed to write {}: {e}", instructions_path.display()), - })?; - eprintln!( - "\x1b[32m✔\x1b[0m Added tracedecay rules to {}", - instructions_path.display() + let block = super::prompt_rules::standard_prompt_rules( + PROMPT_RULE_MARKER, + &PromptRulesOptions { + extra_paragraphs: &[], + }, ); - Ok(()) + super::prompt_rules::reconcile_prompt_rules(instructions_path, PROMPT_RULE_MARKER, &block) } /// Remove tracedecay rules from a copilot-instructions.md file. fn uninstall_prompt_rules(instructions_path: &Path) { - if !instructions_path.exists() { - return; - } - let Ok(contents) = std::fs::read_to_string(instructions_path) else { - return; - }; - if !contents.contains("tracedecay") { - return; - } - let marker = PROMPT_RULE_MARKER; - let Some(start) = contents.find(marker) else { - return; - }; - let after_marker = start + marker.len(); - let end = contents[after_marker..] - .find("\n## ") - .map_or(contents.len(), |pos| after_marker + pos); - let mut new_contents = String::new(); - new_contents.push_str(contents[..start].trim_end()); - let remainder = &contents[end..]; - if !remainder.is_empty() { - new_contents.push_str("\n\n"); - new_contents.push_str(remainder.trim_start()); - } - let new_contents = new_contents.trim().to_string(); - if new_contents.is_empty() { - std::fs::remove_file(instructions_path).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (was empty)", - instructions_path.display() - ); - } else { - std::fs::write(instructions_path, format!("{new_contents}\n")).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay rules from {}", - instructions_path.display() - ); - } + super::prompt_rules::remove_prompt_rules(instructions_path, PROMPT_RULE_MARKER); } // --------------------------------------------------------------------------- diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 14f8c12b..8beb6ffb 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -860,6 +860,35 @@ fn doctor_check_plugin_mcp(dc: &mut DoctorCounters, mcp_path: &Path) { } } +/// `(event, hook subcommand)` pairs parsed from the embedded plugin +/// `hooks/hooks.json` template, so the doctor check can never drift from the +/// hooks the bundle actually registers. +fn cursor_plugin_hook_expectations() -> Vec<(String, String)> { + let raw = EMBEDDED_PLUGIN_FILES + .iter() + .find(|(relative, _)| *relative == "hooks/hooks.json") + .map_or("{}", |&(_, contents)| contents); + let template: serde_json::Value = serde_json::from_str(raw).unwrap_or_else(|_| json!({})); + let Some(events) = template.get("hooks").and_then(|hooks| hooks.as_object()) else { + return Vec::new(); + }; + events + .iter() + .flat_map(|(event, entries)| { + entries + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| { + entry["command"] + .as_str() + .and_then(|command| command.strip_prefix("tracedecay ")) + .map(|subcommand| (event.clone(), subcommand.to_string())) + }) + }) + .collect() +} + fn doctor_check_plugin_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { if !hooks_path.exists() { dc.warn(&format!( @@ -872,28 +901,20 @@ fn doctor_check_plugin_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { dc.fail(&format!("{e}")); json!({}) }); - let expected = [ - ("sessionStart", "hook-cursor-session-start"), - ("sessionEnd", "hook-cursor-session-end"), - ("postToolUse", "hook-cursor-post-tool-use"), - ("preCompact", "hook-cursor-pre-compact"), - ("beforeSubmitPrompt", "hook-cursor-before-submit-prompt"), - ("afterFileEdit", "hook-cursor-after-file-edit"), - ("afterShellExecution", "hook-cursor-after-shell"), - ("workspaceOpen", "hook-cursor-workspace-open"), - ("stop", "hook-cursor-stop"), - ]; + let expected = cursor_plugin_hook_expectations(); let missing: Vec<&str> = expected .iter() .filter_map(|(event, command)| { - let has = hooks["hooks"][*event].as_array().is_some_and(|entries| { - entries.iter().any(|entry| { - entry["command"] - .as_str() - .is_some_and(|value| value.contains(command)) - }) - }); - (!has).then_some(*event) + let has = hooks["hooks"][event.as_str()] + .as_array() + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry["command"] + .as_str() + .is_some_and(|value| value.contains(command)) + }) + }); + (!has).then_some(event.as_str()) }) .collect(); if missing.is_empty() { @@ -1015,6 +1036,26 @@ mod tests { paths } + /// The doctor's expected-hooks list is parsed from the embedded bundle + /// template; a parse regression would silently disable the hook checks. + #[test] + fn plugin_hook_expectations_cover_the_bundled_hooks() { + let expectations = cursor_plugin_hook_expectations(); + assert_eq!( + expectations.len(), + 9, + "expected one entry per bundled lifecycle hook, got {expectations:?}" + ); + assert!(expectations.contains(&( + "sessionStart".to_string(), + "hook-cursor-session-start".to_string() + ))); + assert!(expectations.contains(&( + "afterFileEdit".to_string(), + "hook-cursor-after-file-edit".to_string() + ))); + } + #[test] fn write_embedded_plugin_writes_core_and_bundle_files() { let tmp = TempDir::new().unwrap(); diff --git a/src/agents/gemini.rs b/src/agents/gemini.rs index 45716888..57a8222a 100644 --- a/src/agents/gemini.rs +++ b/src/agents/gemini.rs @@ -5,19 +5,18 @@ //! Gemini CLI has no hook system. Tool auto-approval is handled via the //! `trust: true` flag on the MCP server entry. -use std::io::Write; use std::path::Path; use serde_json::json; -use crate::errors::{Result, TraceDecayError}; +use crate::errors::Result; use super::{ backup_and_write_json, backup_config_file, load_json_file, load_json_file_strict, safe_write_json_file, AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, }; -const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; +use super::prompt_rules::{PromptRulesOptions, PROMPT_RULE_MARKER}; /// Gemini CLI agent. pub struct GeminiIntegration; @@ -130,59 +129,15 @@ fn install_mcp_server(settings_path: &Path, tracedecay_bin: &str) -> Result<()> Ok(()) } -/// Append prompt rules to GEMINI.md (idempotent). +/// Install-or-refresh prompt rules in GEMINI.md. fn install_prompt_rules(gemini_md: &Path) -> Result<()> { - let existing = if gemini_md.exists() { - std::fs::read_to_string(gemini_md).unwrap_or_default() - } else { - String::new() - }; - if existing.contains(PROMPT_RULE_MARKER) { - eprintln!(" GEMINI.md already contains tracedecay rules, skipping"); - return Ok(()); - } - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(gemini_md) - .map_err(|e| TraceDecayError::Config { - message: format!("failed to open GEMINI.md: {e}"), - })?; - write!( - f, - "\n{PROMPT_RULE_MARKER}\n\n\ - Before reading source files or scanning the codebase, use the tracedecay MCP tools \ - (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ - `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ - They provide instant semantic results from a pre-built knowledge graph and are \ - faster than file reads.\n\n\ - For project/storage identity questions, use `tracedecay_active_project` \ - or `tracedecay_storage_status` instead of inferring from marker \ - files or direct DB paths.\n\n\ - If a code analysis question cannot be fully answered by tracedecay MCP tools, \ - prefer built-in MCP tools first. If the user explicitly needs raw store \ - inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ - rather than a hardcoded repo path. Use SQL to answer complex structural \ - queries that go beyond what the built-in tools expose.\n\n\ - For durable project/user facts, prefer `tracedecay_fact_store`, \ - `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ - Use `tracedecay_message_search` for active-project transcript recall when \ - prior conversation context matters. Do not store secrets, credentials, or \ - unnecessary PII in persistent facts.\n\n\ - {cli_fallback}\n\n\ - If you discover a gap where an extractor, schema, or tracedecay tool could be \ - improved to answer a question natively, propose to the user that they open an issue \ - at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ - **Remind the user to strip any sensitive or proprietary code from the bug description \ - before submitting.**\n", - cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, - ) - .ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Appended tracedecay rules to {}", - gemini_md.display() + let block = super::prompt_rules::standard_prompt_rules( + PROMPT_RULE_MARKER, + &PromptRulesOptions { + extra_paragraphs: &[], + }, ); - Ok(()) + super::prompt_rules::reconcile_prompt_rules(gemini_md, PROMPT_RULE_MARKER, &block) } // --------------------------------------------------------------------------- @@ -234,45 +189,7 @@ fn uninstall_mcp_server(settings_path: &Path) { /// Remove tracedecay rules from GEMINI.md. fn uninstall_prompt_rules(gemini_md: &Path) { - if !gemini_md.exists() { - return; - } - let Ok(contents) = std::fs::read_to_string(gemini_md) else { - return; - }; - if !contents.contains("tracedecay") { - eprintln!(" GEMINI.md does not contain tracedecay rules, skipping"); - return; - } - let marker = PROMPT_RULE_MARKER; - let Some(start) = contents.find(marker) else { - return; - }; - let after_marker = start + marker.len(); - let end = contents[after_marker..] - .find("\n## ") - .map_or(contents.len(), |pos| after_marker + pos); - let mut new_contents = String::new(); - new_contents.push_str(contents[..start].trim_end()); - let remainder = &contents[end..]; - if !remainder.is_empty() { - new_contents.push_str("\n\n"); - new_contents.push_str(remainder.trim_start()); - } - let new_contents = new_contents.trim().to_string(); - if new_contents.is_empty() { - std::fs::remove_file(gemini_md).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (was empty)", - gemini_md.display() - ); - } else { - std::fs::write(gemini_md, format!("{new_contents}\n")).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay rules from {}", - gemini_md.display() - ); - } + super::prompt_rules::remove_prompt_rules(gemini_md, PROMPT_RULE_MARKER); } // --------------------------------------------------------------------------- diff --git a/src/agents/hermes/templates/skill.md b/src/agents/hermes/templates/skill.md index 9af66dd5..a59da0bc 100644 --- a/src/agents/hermes/templates/skill.md +++ b/src/agents/hermes/templates/skill.md @@ -8,6 +8,16 @@ description: Prefer tracedecay tools for codebase exploration, graph queries, an Use tracedecay tools before broad file reads for codebase exploration, symbol lookup, call graph traversal, impact analysis, affected files, and architectural navigation. +## If a tool call fails + +If a tracedecay tool invocation fails, times out, or the plugin is unavailable, +every tool is also available directly as a shell command: +`tracedecay tool --key value` (`tracedecay tool` lists all tools, +`tracedecay tool --help` shows parameters). Hermes tool calls already run +through this CLI under the hood, so a direct shell invocation follows the same +execution path without the plugin wrapper. Fall back to it instead of querying +`.tracedecay` databases directly or abandoning tracedecay. + ## Memory - **Recall before external search.** Run `fact_search` (and `lcm_grep` for past diff --git a/src/agents/kimi.rs b/src/agents/kimi.rs index cd243e8d..85eac594 100644 --- a/src/agents/kimi.rs +++ b/src/agents/kimi.rs @@ -7,19 +7,18 @@ //! and no per-tool auto-approval — approval is handled globally via //! Kimi's YOLO / AFK modes. -use std::io::Write; use std::path::Path; use serde_json::json; -use crate::errors::{Result, TraceDecayError}; +use crate::errors::Result; use super::{ backup_and_write_json, backup_config_file, load_json_file, load_json_file_strict, safe_write_json_file, AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, }; -const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; +use super::prompt_rules::{PromptRulesOptions, PROMPT_RULE_MARKER}; /// Moonshot Kimi CLI agent. pub struct KimiIntegration; @@ -143,59 +142,15 @@ fn install_mcp_server(mcp_path: &Path, tracedecay_bin: &str) -> Result<()> { Ok(()) } -/// Append prompt rules to AGENTS.md (idempotent). +/// Install-or-refresh prompt rules in AGENTS.md. fn install_prompt_rules(agents_md: &Path) -> Result<()> { - let existing = if agents_md.exists() { - std::fs::read_to_string(agents_md).unwrap_or_default() - } else { - String::new() - }; - if existing.contains(PROMPT_RULE_MARKER) { - eprintln!(" AGENTS.md already contains tracedecay rules, skipping"); - return Ok(()); - } - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(agents_md) - .map_err(|e| TraceDecayError::Config { - message: format!("failed to open AGENTS.md: {e}"), - })?; - write!( - f, - "\n{PROMPT_RULE_MARKER}\n\n\ - Before reading source files or scanning the codebase, use the tracedecay MCP tools \ - (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ - `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ - They provide instant semantic results from a pre-built knowledge graph and are \ - faster than file reads.\n\n\ - For project/storage identity questions, use `tracedecay_active_project` \ - or `tracedecay_storage_status` instead of inferring from repo-local marker \ - files or direct DB paths.\n\n\ - If a code analysis question cannot be fully answered by tracedecay MCP tools, \ - prefer built-in MCP tools first. If the user explicitly needs raw store \ - inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ - rather than a hardcoded repo-local path. Use SQL to answer complex structural \ - queries that go beyond what the built-in tools expose.\n\n\ - For durable project/user facts, prefer `tracedecay_fact_store`, \ - `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ - Use `tracedecay_message_search` for active-project transcript recall when \ - prior conversation context matters. Do not store secrets, credentials, or \ - unnecessary PII in persistent facts.\n\n\ - {cli_fallback}\n\n\ - If you discover a gap where an extractor, schema, or tracedecay tool could be \ - improved to answer a question natively, propose to the user that they open an issue \ - at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ - **Remind the user to strip any sensitive or proprietary code from the bug description \ - before submitting.**\n", - cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, - ) - .ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Appended tracedecay rules to {}", - agents_md.display() + let block = super::prompt_rules::standard_prompt_rules( + PROMPT_RULE_MARKER, + &PromptRulesOptions { + extra_paragraphs: &[], + }, ); - Ok(()) + super::prompt_rules::reconcile_prompt_rules(agents_md, PROMPT_RULE_MARKER, &block) } // --------------------------------------------------------------------------- @@ -257,45 +212,7 @@ fn uninstall_mcp_server(mcp_path: &Path) { /// Remove tracedecay rules from AGENTS.md. fn uninstall_prompt_rules(agents_md: &Path) { - if !agents_md.exists() { - return; - } - let Ok(contents) = std::fs::read_to_string(agents_md) else { - return; - }; - if !contents.contains("tracedecay") { - eprintln!(" AGENTS.md does not contain tracedecay rules, skipping"); - return; - } - let marker = PROMPT_RULE_MARKER; - let Some(start) = contents.find(marker) else { - return; - }; - let after_marker = start + marker.len(); - let end = contents[after_marker..] - .find("\n## ") - .map_or(contents.len(), |pos| after_marker + pos); - let mut new_contents = String::new(); - new_contents.push_str(contents[..start].trim_end()); - let remainder = &contents[end..]; - if !remainder.is_empty() { - new_contents.push_str("\n\n"); - new_contents.push_str(remainder.trim_start()); - } - let new_contents = new_contents.trim().to_string(); - if new_contents.is_empty() { - std::fs::remove_file(agents_md).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (was empty)", - agents_md.display() - ); - } else { - std::fs::write(agents_md, format!("{new_contents}\n")).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay rules from {}", - agents_md.display() - ); - } + super::prompt_rules::remove_prompt_rules(agents_md, PROMPT_RULE_MARKER); } // --------------------------------------------------------------------------- diff --git a/src/agents/kiro.rs b/src/agents/kiro.rs index e08b4c35..672e7a16 100644 --- a/src/agents/kiro.rs +++ b/src/agents/kiro.rs @@ -44,6 +44,68 @@ const KIRO_POST_TOOL_HOOK: &str = "hook-kiro-post-tool-use"; const KIRO_SHORT_HOOK_TIMEOUT_MS: u64 = 5_000; const KIRO_SYNC_HOOK_TIMEOUT_MS: u64 = 30_000; +/// A hook the managed Kiro agent registers. +struct KiroManagedHook { + event: &'static str, + matcher: Option<&'static str>, + subcommand: &'static str, + timeout_ms: u64, +} + +/// Every managed-agent hook, in registration order. The single source of +/// truth for the generated agent config ([`managed_agent_config`]) and the +/// doctor checks. +const KIRO_MANAGED_HOOKS: &[KiroManagedHook] = &[ + KiroManagedHook { + event: "userPromptSubmit", + matcher: None, + subcommand: KIRO_PROMPT_HOOK, + timeout_ms: KIRO_SHORT_HOOK_TIMEOUT_MS, + }, + KiroManagedHook { + event: "preToolUse", + matcher: Some("delegate"), + subcommand: KIRO_PRE_TOOL_HOOK, + timeout_ms: KIRO_SHORT_HOOK_TIMEOUT_MS, + }, + KiroManagedHook { + event: "preToolUse", + matcher: Some("subagent"), + subcommand: KIRO_PRE_TOOL_HOOK, + timeout_ms: KIRO_SHORT_HOOK_TIMEOUT_MS, + }, + KiroManagedHook { + event: "postToolUse", + matcher: Some("fs_write"), + subcommand: KIRO_POST_TOOL_HOOK, + timeout_ms: KIRO_SYNC_HOOK_TIMEOUT_MS, + }, +]; + +/// Builds the managed agent's `hooks` object from [`KIRO_MANAGED_HOOKS`], +/// grouping entries per event in table order. +fn managed_agent_hooks(tracedecay_bin: &str) -> serde_json::Value { + let mut grouped: Vec<(&str, Vec)> = Vec::new(); + for hook in KIRO_MANAGED_HOOKS { + let mut entry = json!({ + "command": super::hook_command(tracedecay_bin, hook.subcommand), + "timeout_ms": hook.timeout_ms, + }); + if let Some(matcher) = hook.matcher { + entry["matcher"] = json!(matcher); + } + match grouped.iter_mut().find(|(event, _)| *event == hook.event) { + Some((_, entries)) => entries.push(entry), + None => grouped.push((hook.event, vec![entry])), + } + } + let mut events = serde_json::Map::new(); + for (event, entries) in grouped { + events.insert(event.to_string(), serde_json::Value::Array(entries)); + } + serde_json::Value::Object(events) +} + fn kiro_home(home: &Path) -> PathBuf { if let Ok(kiro) = std::env::var("KIRO_HOME") { let kiro_path = PathBuf::from(&kiro); @@ -270,33 +332,7 @@ fn managed_agent_config( "resources": resources, "tools": [KIRO_AGENT_ALL_TOOLS], "allowedTools": [KIRO_ALLOWED_BUILTIN_TOOLS, KIRO_ALLOWED_TRACEDECAY_TOOLS], - "hooks": { - "userPromptSubmit": [ - { - "command": super::hook_command(tracedecay_bin, KIRO_PROMPT_HOOK), - "timeout_ms": KIRO_SHORT_HOOK_TIMEOUT_MS - } - ], - "preToolUse": [ - { - "matcher": "delegate", - "command": super::hook_command(tracedecay_bin, KIRO_PRE_TOOL_HOOK), - "timeout_ms": KIRO_SHORT_HOOK_TIMEOUT_MS - }, - { - "matcher": "subagent", - "command": super::hook_command(tracedecay_bin, KIRO_PRE_TOOL_HOOK), - "timeout_ms": KIRO_SHORT_HOOK_TIMEOUT_MS - } - ], - "postToolUse": [ - { - "matcher": "fs_write", - "command": super::hook_command(tracedecay_bin, KIRO_POST_TOOL_HOOK), - "timeout_ms": KIRO_SYNC_HOOK_TIMEOUT_MS - } - ] - } + "hooks": managed_agent_hooks(tracedecay_bin) }) } @@ -458,22 +494,42 @@ fn ensure_child_object(config: &mut serde_json::Value, key: &str, path: &Path) - } } -/// Add tracedecay's global steering resource for default Kiro sessions. +/// Add or refresh tracedecay's global steering resource for default Kiro +/// sessions. When the marker is present but the block content is stale (an +/// older tracedecay version wrote it), the block is replaced in place: a +/// marker-to-end-marker splice when the owned end marker exists, otherwise +/// the generic marker-to-next-heading strip plus a fresh append. fn install_steering_rules(path: &Path) -> Result<()> { let existing = if path.exists() { std::fs::read_to_string(path).unwrap_or_default() } else { String::new() }; + let block = prompt_rules_text(); + if existing.contains(&block) { + eprintln!(" Kiro steering already contains tracedecay rules, skipping"); + return Ok(()); + } if existing.contains(PROMPT_MARKER) { - if existing.contains(PROMPT_END_MARKER) { - eprintln!(" Kiro steering already contains tracedecay rules, skipping"); + if let Some(range) = tracedecay_prompt_block_range(&existing) { + let mut new_contents = String::with_capacity(existing.len() + block.len()); + new_contents.push_str(&existing[..range.start]); + new_contents.push_str(&block); + new_contents.push_str(&existing[range.end..]); + std::fs::write(path, new_contents).map_err(|e| TraceDecayError::Config { + message: format!("failed to write {}: {e}", path.display()), + })?; + eprintln!( + "\x1b[32m✔\x1b[0m Refreshed tracedecay rules in {}", + path.display() + ); return Ok(()); } - eprintln!( - " Kiro steering contains tracedecay rules without an owned end marker, leaving unchanged" - ); - return Ok(()); + // Legacy block without the owned end marker: fall back to the + // heading-based strip the other hosts use, then append fresh rules. + let stripped = + super::prompt_rules::strip_heading_block(&existing, PROMPT_MARKER).unwrap_or_default(); + return super::prompt_rules::write_refreshed(path, &stripped, &block); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| TraceDecayError::Config { @@ -492,7 +548,7 @@ fn install_steering_rules(path: &Path) -> Result<()> { } else { "\n\n" }; - writeln!(f, "{}{}", separator, prompt_rules_text()).map_err(|e| TraceDecayError::Config { + writeln!(f, "{separator}{block}").map_err(|e| TraceDecayError::Config { message: format!("failed to write {}: {e}", path.display()), })?; eprintln!( @@ -907,38 +963,16 @@ fn doctor_check_managed_agent(dc: &mut DoctorCounters, home: &Path) { ); } - doctor_check_agent_hook( - dc, - &config, - "userPromptSubmit", - None, - KIRO_PROMPT_HOOK, - KIRO_SHORT_HOOK_TIMEOUT_MS, - ); - doctor_check_agent_hook( - dc, - &config, - "preToolUse", - Some("delegate"), - KIRO_PRE_TOOL_HOOK, - KIRO_SHORT_HOOK_TIMEOUT_MS, - ); - doctor_check_agent_hook( - dc, - &config, - "preToolUse", - Some("subagent"), - KIRO_PRE_TOOL_HOOK, - KIRO_SHORT_HOOK_TIMEOUT_MS, - ); - doctor_check_agent_hook( - dc, - &config, - "postToolUse", - Some("fs_write"), - KIRO_POST_TOOL_HOOK, - KIRO_SYNC_HOOK_TIMEOUT_MS, - ); + for hook in KIRO_MANAGED_HOOKS { + doctor_check_agent_hook( + dc, + &config, + hook.event, + hook.matcher, + hook.subcommand, + hook.timeout_ms, + ); + } } fn doctor_check_agent_tools(dc: &mut DoctorCounters, config: &serde_json::Value) { diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 7f334fc1..0dafc1b0 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -18,6 +18,7 @@ pub mod kilo; pub mod kimi; pub mod kiro; pub mod opencode; +pub mod prompt_rules; pub mod roo_code; pub mod vibe; pub mod zed; diff --git a/src/agents/opencode.rs b/src/agents/opencode.rs index 20565885..ab05f85f 100644 --- a/src/agents/opencode.rs +++ b/src/agents/opencode.rs @@ -6,19 +6,18 @@ //! and prompt rules via `$HOME/.config/opencode/AGENTS.md`. `OpenCode` has no hook system or //! declarative tool permissions — it uses interactive runtime approval. -use std::io::Write; use std::path::Path; use serde_json::json; -use crate::errors::{Result, TraceDecayError}; +use crate::errors::Result; use super::{ backup_and_write_json, backup_config_file, load_json_file, load_json_file_strict, safe_write_json_file, AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, }; -const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; +use super::prompt_rules::{PromptRulesOptions, PROMPT_RULE_MARKER}; /// `OpenCode` agent. pub struct OpenCodeIntegration; @@ -177,59 +176,15 @@ fn install_mcp_server(config_path: &Path, tracedecay_bin: &str) -> Result<()> { Ok(()) } -/// Append prompt rules to AGENTS.md (idempotent). +/// Install-or-refresh prompt rules in AGENTS.md. fn install_prompt_rules(prompt_path: &Path) -> Result<()> { - let existing = if prompt_path.exists() { - std::fs::read_to_string(prompt_path).unwrap_or_default() - } else { - String::new() - }; - if existing.contains(PROMPT_RULE_MARKER) { - eprintln!(" AGENTS.md already contains tracedecay rules, skipping"); - return Ok(()); - } - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(prompt_path) - .map_err(|e| TraceDecayError::Config { - message: format!("failed to open AGENTS.md: {e}"), - })?; - write!( - f, - "\n{PROMPT_RULE_MARKER}\n\n\ - Before reading source files or scanning the codebase, use the tracedecay MCP tools \ - (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ - `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ - They provide instant semantic results from a pre-built knowledge graph and are \ - faster than file reads.\n\n\ - For project/storage identity questions, use `tracedecay_active_project` \ - or `tracedecay_storage_status` instead of inferring from repo-local marker \ - files or direct DB paths.\n\n\ - If a code analysis question cannot be fully answered by tracedecay MCP tools, \ - prefer built-in MCP tools first. If the user explicitly needs raw store \ - inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ - rather than a hardcoded repo-local path. Use SQL to answer complex structural \ - queries that go beyond what the built-in tools expose.\n\n\ - For durable project/user facts, prefer `tracedecay_fact_store`, \ - `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ - Use `tracedecay_message_search` for active-project transcript recall when \ - prior conversation context matters. Do not store secrets, credentials, or \ - unnecessary PII in persistent facts.\n\n\ - {cli_fallback}\n\n\ - If you discover a gap where an extractor, schema, or tracedecay tool could be \ - improved to answer a question natively, propose to the user that they open an issue \ - at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ - **Remind the user to strip any sensitive or proprietary code from the bug description \ - before submitting.**\n", - cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, - ) - .ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Appended tracedecay rules to {}", - prompt_path.display() + let block = super::prompt_rules::standard_prompt_rules( + PROMPT_RULE_MARKER, + &PromptRulesOptions { + extra_paragraphs: &[], + }, ); - Ok(()) + super::prompt_rules::reconcile_prompt_rules(prompt_path, PROMPT_RULE_MARKER, &block) } // --------------------------------------------------------------------------- @@ -277,45 +232,7 @@ fn uninstall_mcp_server(config_path: &Path) { /// Remove tracedecay rules from AGENTS.md. fn uninstall_prompt_rules(prompt_path: &Path) { - if !prompt_path.exists() { - return; - } - let Ok(contents) = std::fs::read_to_string(prompt_path) else { - return; - }; - if !contents.contains("tracedecay") { - eprintln!(" AGENTS.md does not contain tracedecay rules, skipping"); - return; - } - let marker = PROMPT_RULE_MARKER; - let Some(start) = contents.find(marker) else { - return; - }; - let after_marker = start + marker.len(); - let end = contents[after_marker..] - .find("\n## ") - .map_or(contents.len(), |pos| after_marker + pos); - let mut new_contents = String::new(); - new_contents.push_str(contents[..start].trim_end()); - let remainder = &contents[end..]; - if !remainder.is_empty() { - new_contents.push_str("\n\n"); - new_contents.push_str(remainder.trim_start()); - } - let new_contents = new_contents.trim().to_string(); - if new_contents.is_empty() { - std::fs::remove_file(prompt_path).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (was empty)", - prompt_path.display() - ); - } else { - std::fs::write(prompt_path, format!("{new_contents}\n")).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay rules from {}", - prompt_path.display() - ); - } + super::prompt_rules::remove_prompt_rules(prompt_path, PROMPT_RULE_MARKER); } // --------------------------------------------------------------------------- diff --git a/src/agents/prompt_rules.rs b/src/agents/prompt_rules.rs new file mode 100644 index 00000000..093be880 --- /dev/null +++ b/src/agents/prompt_rules.rs @@ -0,0 +1,190 @@ +//! Shared prompt-rules rendering and managed-block reconciliation. +//! +//! Copilot, Gemini, OpenCode, Kimi, and Vibe share the same marker-gated +//! tracedecay rules block. Claude and Kiro keep host-specific text but reuse +//! the block-splicing helpers here. + +use std::io::Write; +use std::path::Path; + +use crate::errors::{Result, TraceDecayError}; + +/// Marker heading shared by every standard prompt-rules host. +pub(crate) const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; + +/// Managed-skill index marker; strip heuristics stop here. +pub(crate) const SKILL_INDEX_START: &str = ""; + +/// Canonical rules paragraphs shared by the standard hosts. +const STANDARD_PARAGRAPHS: &[&str] = &[ + "Before reading source files or scanning the codebase, use the tracedecay MCP tools \ + (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ + `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ + They provide instant semantic results from a pre-built knowledge graph and are \ + faster than file reads.", + "For project/storage identity questions, use `tracedecay_active_project` \ + or `tracedecay_storage_status` instead of inferring from repo-local marker \ + files or direct DB paths.", + "If a code analysis question cannot be fully answered by tracedecay MCP tools, \ + prefer built-in MCP tools first. If the user explicitly needs raw store \ + inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ + rather than a hardcoded repo-local path. Use SQL to answer complex structural \ + queries that go beyond what the built-in tools expose.", + "For durable project/user facts, prefer `tracedecay_fact_store`, \ + `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ + Use `tracedecay_message_search` for active-project transcript recall when \ + prior conversation context matters. Do not store secrets, credentials, or \ + unnecessary PII in persistent facts.", + super::CLI_FALLBACK_PROMPT_RULES, + "If you discover a gap where an extractor, schema, or tracedecay tool could be \ + improved to answer a question natively, propose to the user that they open an issue \ + at https://github.com/ScriptedAlchemy/tracedecay describing the limitation. \ + **Remind the user to strip any sensitive or proprietary code from the bug description \ + before submitting.**", +]; + +/// Host-specific knobs for [`standard_prompt_rules`]. +pub(crate) struct PromptRulesOptions { + /// Extra paragraphs appended after the shared canonical text. + pub extra_paragraphs: &'static [&'static str], +} + +/// Renders the full managed block (marker heading plus paragraphs, no +/// surrounding newlines) for a standard host. +pub(crate) fn standard_prompt_rules(marker: &str, options: &PromptRulesOptions) -> String { + let mut block = String::from(marker); + for paragraph in STANDARD_PARAGRAPHS.iter().chain(options.extra_paragraphs) { + block.push_str("\n\n"); + block.push_str(paragraph); + } + block +} + +/// The CLI-fallback paragraph every host's rules must carry; exposed so +/// integration tests can assert parity across hosts. +pub fn cli_fallback_paragraph() -> &'static str { + super::CLI_FALLBACK_PROMPT_RULES +} + +/// End offset of a managed block whose marker heading ends at `search_from`: +/// the next `\n## ` heading, the managed-skill index start marker, or EOF, +/// whichever comes first. +fn heading_block_end(contents: &str, search_from: usize) -> usize { + let heading = contents[search_from..].find("\n## "); + let skill_index = contents[search_from..].find(SKILL_INDEX_START); + let relative = match (heading, skill_index) { + (Some(h), Some(s)) => h.min(s), + (Some(h), None) => h, + (None, Some(s)) => s, + (None, None) => return contents.len(), + }; + search_from + relative +} + +/// Removes `contents[start..end]` and normalizes surrounding blank lines. +pub(crate) fn splice_out(contents: &str, start: usize, end: usize) -> String { + let mut new_contents = String::new(); + new_contents.push_str(contents[..start].trim_end()); + let remainder = &contents[end..]; + if !remainder.is_empty() { + new_contents.push_str("\n\n"); + new_contents.push_str(remainder.trim_start()); + } + new_contents.trim().to_string() +} + +/// Contents with the managed block removed (marker heading through +/// [`heading_block_end`]); `None` when the marker is absent. +pub(crate) fn strip_heading_block(contents: &str, marker: &str) -> Option { + let start = contents.find(marker)?; + let end = heading_block_end(contents, start + marker.len()); + Some(splice_out(contents, start, end)) +} + +/// Writes `stripped` user content plus a fresh managed `block` to `path` and +/// reports the refresh. +pub(crate) fn write_refreshed(path: &Path, stripped: &str, block: &str) -> Result<()> { + let mut new_contents = String::with_capacity(stripped.len() + block.len() + 3); + new_contents.push_str(stripped); + if !new_contents.is_empty() { + new_contents.push_str("\n\n"); + } + new_contents.push_str(block); + new_contents.push('\n'); + std::fs::write(path, new_contents).map_err(|e| TraceDecayError::Config { + message: format!("failed to write {}: {e}", path.display()), + })?; + eprintln!( + "\x1b[32m✔\x1b[0m Refreshed tracedecay rules in {}", + path.display() + ); + Ok(()) +} + +/// Install or refresh the managed rules block in `path`. +pub(crate) fn reconcile_prompt_rules(path: &Path, marker: &str, block: &str) -> Result<()> { + let existing = if path.exists() { + std::fs::read_to_string(path).unwrap_or_default() + } else { + String::new() + }; + if existing.contains(block) { + eprintln!( + " {} already contains tracedecay rules, skipping", + path.display() + ); + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + if let Some(stripped) = strip_heading_block(&existing, marker) { + return write_refreshed(path, &stripped, block); + } + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| TraceDecayError::Config { + message: format!("failed to open {}: {e}", path.display()), + })?; + write!(f, "\n{block}\n").map_err(|e| TraceDecayError::Config { + message: format!("failed to write {}: {e}", path.display()), + })?; + eprintln!( + "\x1b[32m✔\x1b[0m Added tracedecay rules to {}", + path.display() + ); + Ok(()) +} + +/// Shared uninstall for the standard hosts: strips the managed block and +/// deletes the file when nothing else remains. +pub(crate) fn remove_prompt_rules(path: &Path, marker: &str) { + if !path.exists() { + return; + } + let Ok(contents) = std::fs::read_to_string(path) else { + return; + }; + if !contents.contains("tracedecay") { + eprintln!( + " {} does not contain tracedecay rules, skipping", + path.display() + ); + return; + } + let Some(new_contents) = strip_heading_block(&contents, marker) else { + return; + }; + if new_contents.is_empty() { + std::fs::remove_file(path).ok(); + eprintln!("\x1b[32m✔\x1b[0m Removed {} (was empty)", path.display()); + } else { + std::fs::write(path, format!("{new_contents}\n")).ok(); + eprintln!( + "\x1b[32m✔\x1b[0m Removed tracedecay rules from {}", + path.display() + ); + } +} diff --git a/src/agents/vibe.rs b/src/agents/vibe.rs index 1bc6db93..4b293364 100644 --- a/src/agents/vibe.rs +++ b/src/agents/vibe.rs @@ -35,9 +35,15 @@ fn vibe_prompt_path(home: &Path) -> std::path::PathBuf { vibe_home(home).join("prompts/cli.md") } +use super::prompt_rules::{PromptRulesOptions, PROMPT_RULE_MARKER}; + /// The TOML marker that identifies a tracedecay MCP server entry. const TOML_MARKER: &str = "name = \"tracedecay\""; -const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; + +/// Vibe-only closing paragraph appended after the shared rules text. +const VIBE_EXTRA_PARAGRAPHS: &[&str] = &["When a tracedecay tool result contains a \ + `tracedecay_metrics:` line, report the savings to the user (e.g. \"TraceDecay'd ~N \ + tokens\"). Never silently omit this."]; impl AgentIntegration for VibeIntegration { fn name(&self) -> &'static str { @@ -171,61 +177,15 @@ fn install_mcp_server(config_path: &Path, tracedecay_bin: &str) -> Result<()> { Ok(()) } -/// Append prompt rules to the Vibe system prompt (idempotent). +/// Install-or-refresh prompt rules in the Vibe system prompt. fn install_prompt_rules(prompt_path: &Path) -> Result<()> { - let existing = if prompt_path.exists() { - std::fs::read_to_string(prompt_path).unwrap_or_default() - } else { - String::new() - }; - if existing.contains(PROMPT_RULE_MARKER) { - eprintln!(" Vibe prompt already contains tracedecay rules, skipping"); - return Ok(()); - } - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(prompt_path) - .map_err(|e| TraceDecayError::Config { - message: format!("failed to open {}: {e}", prompt_path.display()), - })?; - write!( - f, - "\n{PROMPT_RULE_MARKER}\n\n\ - Before reading source files or scanning the codebase, use the tracedecay MCP tools \ - (`tracedecay_context`, `tracedecay_search`, `tracedecay_callers`, `tracedecay_callees`, \ - `tracedecay_impact`, `tracedecay_node`, `tracedecay_files`, `tracedecay_affected`). \ - They provide instant semantic results from a pre-built knowledge graph and are \ - faster than file reads.\n\n\ - For project/storage identity questions, use `tracedecay_active_project` \ - or `tracedecay_storage_status` instead of inferring from repo-local marker \ - files or direct DB paths.\n\n\ - If a code analysis question cannot be fully answered by tracedecay MCP tools, \ - prefer built-in MCP tools first. If the user explicitly needs raw store \ - inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ - rather than a hardcoded repo-local path. Use SQL to answer complex structural \ - queries that go beyond what the built-in tools expose.\n\n\ - For durable project/user facts, prefer `tracedecay_fact_store`, \ - `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ - Use `tracedecay_message_search` for active-project transcript recall when \ - prior conversation context matters. Do not store secrets, credentials, or \ - unnecessary PII in persistent facts.\n\n\ - {cli_fallback}\n\n\ - If you find a gap where tracedecay could answer a question natively, propose opening \ - an issue at https://github.com/ScriptedAlchemy/tracedecay. Remind the user to strip \ - sensitive or proprietary code from any issue text before submitting.\n\n\ - When a tracedecay tool result contains a `tracedecay_metrics:` line, report the \ - savings to the user (e.g. \"TraceDecay'd ~N tokens\"). Never silently omit this.\n", - cli_fallback = super::CLI_FALLBACK_PROMPT_RULES, - ) - .map_err(|e| TraceDecayError::Config { - message: format!("failed to write Vibe prompt: {e}"), - })?; - eprintln!( - "\x1b[32m✔\x1b[0m Added tracedecay rules to {}", - prompt_path.display() + let block = super::prompt_rules::standard_prompt_rules( + PROMPT_RULE_MARKER, + &PromptRulesOptions { + extra_paragraphs: VIBE_EXTRA_PARAGRAPHS, + }, ); - Ok(()) + super::prompt_rules::reconcile_prompt_rules(prompt_path, PROMPT_RULE_MARKER, &block) } // --------------------------------------------------------------------------- @@ -326,35 +286,7 @@ fn uninstall_mcp_server(config_path: &Path) { /// Remove tracedecay rules from the Vibe system prompt. fn uninstall_prompt_rules(prompt_path: &Path) { - if !prompt_path.exists() { - return; - } - let Ok(contents) = std::fs::read_to_string(prompt_path) else { - return; - }; - if !contents.contains("tracedecay") { - eprintln!(" Vibe prompt does not contain tracedecay rules, skipping"); - return; - } - let marker = PROMPT_RULE_MARKER; - let Some(start) = contents.find(marker) else { - return; - }; - let before = &contents[..start]; - let trimmed = before.trim_end().to_string(); - if trimmed.is_empty() { - std::fs::remove_file(prompt_path).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (was empty)", - prompt_path.display() - ); - } else { - std::fs::write(prompt_path, format!("{trimmed}\n")).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay rules from {}", - prompt_path.display() - ); - } + super::prompt_rules::remove_prompt_rules(prompt_path, PROMPT_RULE_MARKER); } // --------------------------------------------------------------------------- diff --git a/src/daemon.rs b/src/daemon.rs index b799e7ff..0d62ce1e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -45,12 +45,8 @@ pub use service::{ /// A host whose lifecycle hooks notify the daemon. /// -/// This enum is the single source of truth for hook agent identity on **both** -/// sides of the wire: the hook processes build [`DaemonHookEvent`]s through it, -/// and the daemon-side receiver (`crate::mcp::hook_events`) parses the agent -/// key back through [`HookAgent::from_wire`]. Adding a host here is the only -/// step needed for its events to be accepted — a per-side string match can -/// silently drop a new host's events (that bug shipped once for Claude). +/// Kept shared between hook emitters and daemon-side parsing so new hosts +/// cannot be accepted by one side and dropped by the other. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HookAgent { Claude, diff --git a/src/hooks.rs b/src/hooks.rs deleted file mode 100644 index 50f86df9..00000000 --- a/src/hooks.rs +++ /dev/null @@ -1,3115 +0,0 @@ -//! Hook handlers for Claude Code, Kiro, Cursor, and Codex integrations. -//! -//! These functions are invoked by each agent's hook system to intercept tool -//! calls, redirect exploration work to tracedecay MCP tools, keep the index -//! fresh after edits / git state changes, and track per-session token savings. -//! Each agent sends its own event schema on stdin and expects its own output -//! shape, so the handlers are kept agent-specific rather than shared blindly. - -use std::collections::{BTreeMap, HashSet}; -use std::io::Read; -use std::path::{Component, Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use serde_json::Value; - -pub mod tool_hints; - -use tool_hints::{decide_hint, HintAgent, HintCategory, ToolHint, ToolHintInput}; - -macro_rules! read_hook_event { - () => {{ - match read_stdin_to_string() { - Ok(event) => event, - Err(e) => { - eprintln!("tracedecay hook: failed to read stdin: {e}"); - return 1; - } - } - }}; -} - -const TRACEDECAY_RESEARCH_BLOCK_REASON: &str = "STOP: Use tracedecay MCP tools \ -(tracedecay_context, tracedecay_search, tracedecay_callees, tracedecay_callers, \ -tracedecay_impact, tracedecay_files, tracedecay_affected) instead of agents for \ -code research. TraceDecay is faster and more precise for symbol relationships, \ -call paths, and code structure. Only use agents for code exploration if you \ -have already tried tracedecay and it cannot answer the question."; - -const CODEX_SUBAGENT_START_CONTEXT: &str = "tracedecay subagent context: this looks like a \ -new/no-history subagent or code-research subagent. Use tracedecay MCP tools and the relevant TraceDecay skill/tool \ -workflow before broad file reads: `tracedecay:searching-for-code` with `tracedecay_context` \ -for code exploration, `tracedecay:reading-code-cheaply` with `tracedecay_outline` or \ -`tracedecay_body` before whole-file reads, `tracedecay:tracing-functions` with \ -`tracedecay_find_exact_symbol`, `tracedecay_callers`, and `tracedecay_callees` when \ -asked to trace functions, find callers, or inspect setup/helper/fixture dependencies, \ -`tracedecay:finding-impacted-areas` with `tracedecay_affected` and \ -`tracedecay_test_map` before guessing affected tests, `tracedecay:recalling-project-memory` when \ -project decisions/preferences matter, and `tracedecay:recalling-session-context` with \ -`tracedecay_message_search`, `tracedecay_lcm_expand_query`, and `tracedecay_lcm_describe` \ -when prior conversation context may be missing."; - -const HOOK_ANALYTICS_FILENAME: &str = "hook_analytics.jsonl"; - -fn research_block_reason(hint: Option) -> String { - let base = crate::config::brand_env("RESEARCH_BLOCK_REASON") - .unwrap_or_else(|| TRACEDECAY_RESEARCH_BLOCK_REASON.to_string()); - hint.map_or_else( - || base.clone(), - |hint| format!("{}\n\n{}", base, format_tool_hint(&hint)), - ) -} - -fn record_hook_analytics(root: Option<&Path>, event: &str, mut fields: serde_json::Value) { - let Some(path) = hook_analytics_path(root) else { - return; - }; - let Some(fields) = fields.as_object_mut() else { - return; - }; - fields.insert( - "event".to_string(), - serde_json::Value::String(event.to_string()), - ); - fields.insert( - "ts_unix_ms".to_string(), - serde_json::Value::Number(serde_json::Number::from(now_unix_millis())), - ); - let Ok(line) = serde_json::to_string(&fields) else { - return; - }; - append_private_jsonl(&path, &line); -} - -fn hook_analytics_path(root: Option<&Path>) -> Option { - match root { - Some(root) => crate::storage::resolve_layout_for_current_profile(root) - .ok() - .map(|layout| layout.data_root.join(HOOK_ANALYTICS_FILENAME)), - None => crate::storage::default_profile_root() - .ok() - .map(|root| root.join(HOOK_ANALYTICS_FILENAME)), - } -} - -fn append_private_jsonl(path: &Path, line: &str) { - let _ = crate::storage::PrivateStoreIo::append_line(path, line); -} - -fn now_unix_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or_default() -} - -fn record_hook_invoked(root: Option<&Path>, agent: HintAgent, hook_name: &str, event_json: &str) { - let parsed: Value = serde_json::from_str(event_json).unwrap_or(Value::Null); - record_hook_analytics( - root, - "hook_invoked", - serde_json::json!({ - "agent": agent.as_key(), - "hook_name": hook_name, - "hook_event_name": text_field(&parsed, &["hook_event_name", "hookEventName"]), - "session_id": event_session_id(&parsed), - "tool_name": text_field(&parsed, &["tool_name", "toolName", "name"]), - "command": text_field(&parsed, &["command", "cmd", "shell_command"]), - "prompt_category": inferred_prompt_category(&parsed), - }), - ); -} - -fn inferred_prompt_category(parsed: &Value) -> Option<&'static str> { - let text = prompt_like_text(parsed) - .unwrap_or_default() - .to_ascii_lowercase(); - if text.is_empty() { - return None; - } - if is_code_research_prompt(&text) { - Some("code_research") - } else if text.contains("test") || text.contains("failing") || text.contains("ci") { - Some("test_or_ci") - } else if text.contains("dashboard") || text.contains("ui") || text.contains("frontend") { - Some("dashboard_or_ui") - } else if text.contains("bug") || text.contains("fix") || text.contains("error") { - Some("debug_or_fix") - } else { - Some("general") - } -} - -fn record_hint_analytics( - root: Option<&Path>, - event: &str, - agent: HintAgent, - session_id: Option<&str>, - hint: &ToolHint, -) { - record_hook_analytics( - root, - event, - serde_json::json!({ - "agent": agent.as_key(), - "session_id": session_id, - "category": hint.category.as_key(), - }), - ); -} - -fn record_workspace_status_analytics( - root: Option<&Path>, - status: HookWorkspaceStatus, - session_id: Option<&str>, -) { - record_hook_analytics( - root, - "workspace_status", - serde_json::json!({ - "agent": HintAgent::Codex.as_key(), - "session_id": session_id, - "workspace_status": status.as_key(), - }), - ); -} - -fn record_hint_emitted( - root: Option<&Path>, - agent: HintAgent, - session_id: Option<&str>, - hint: &ToolHint, -) { - if session_id.is_none() { - record_hint_analytics(root, "missing_session", agent, None, hint); - } - record_hint_analytics(root, "hint_emitted", agent, session_id, hint); -} - -/// `PreToolUse` hook handler for Claude Code's Agent tool matcher. -/// -/// Reads the `TOOL_INPUT` environment variable (JSON), inspects the -/// `subagent_type` and `prompt` fields, and prints a JSON decision to -/// stdout. Blocks Explore agents and exploration-style prompts, directing -/// Claude to use tracedecay MCP tools instead. -pub fn hook_pre_tool_use() { - let tool_input = std::env::var("TOOL_INPUT").unwrap_or_default(); - record_hook_invoked(None, HintAgent::Claude, "preToolUse", &tool_input); - let decision = evaluate_hook_decision(&tool_input); - if !decision.is_empty() { - println!("{decision}"); - } -} - -/// Pure decision logic for the `PreToolUse` hook. -/// -/// Takes the raw `TOOL_INPUT` JSON string and returns the JSON decision -/// string to print to stdout. -pub fn evaluate_hook_decision(tool_input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(tool_input).unwrap_or_else(|_| serde_json::json!({})); - let hint = decide_hint(&ToolHintInput { - agent: HintAgent::Claude, - session_id: event_session_id(&parsed), - tool_name: Some("Agent".to_string()), - command: None, - prompt: prompt_like_text(&parsed), - subagent_type: parsed - .get("subagent_type") - .and_then(Value::as_str) - .map(str::to_string), - file_path: None, - hints_enabled: true, - }); - let block_reason = research_block_reason(hint); - let block_msg = || { - serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": block_reason - } - }) - }; - - // Block Explore agents outright - if parsed.get("subagent_type").and_then(|v| v.as_str()) == Some("Explore") { - return block_msg().to_string(); - } - - // Check if the prompt is exploration/research work that tracedecay can handle - if let Some(prompt) = parsed.get("prompt").and_then(|v| v.as_str()) { - if is_code_research_prompt(prompt) { - return block_msg().to_string(); - } - } - - // Empty string = no output -> Claude Code implicitly allows the tool call - String::new() -} - -fn is_code_research_prompt(prompt: &str) -> bool { - let lower = prompt.to_ascii_lowercase(); - let exploration_patterns = [ - "explore", - "codebase structure", - "codebase architecture", - "codebase overview", - "source files contents", - "read every", - "full contents", - "entire codebase", - "architecture and structure", - "call graph", - "call path", - "call chain", - "symbol relat", - "symbol lookup", - "who calls", - "callers of", - "callees of", - ]; - exploration_patterns.iter().any(|pat| lower.contains(pat)) -} - -/// Kiro `preToolUse` hook handler. -/// -/// Kiro sends the hook event JSON on stdin. Returning exit code 2 blocks the -/// tool call and sends stderr back to the model. This is intentionally separate -/// from Claude's hook handler because Claude expects a JSON decision on stdout. -pub fn hook_kiro_pre_tool_use() -> i32 { - let event = read_hook_event!(); - record_hook_invoked(None, HintAgent::Kiro, "preToolUse", &event); - if let Some(reason) = evaluate_kiro_pre_tool_use(&event) { - eprintln!("{reason}"); - 2 - } else { - 0 - } -} - -/// Cursor `subagentStart` hook handler. -/// -/// Cursor sends hook event JSON on stdin and expects Cursor-shaped JSON on -/// stdout. This intentionally does not reuse the Claude hook output schema. -pub fn hook_cursor_subagent_start() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "subagentStart", &event); - if let Some(decision) = evaluate_cursor_subagent_start(&event) { - println!("{decision}"); - } - 0 -} - -/// Cursor `postToolUse` hook handler. -/// -/// Emits soft `additional_context` hints steering exploration tools (Grep, -/// Glob, Read, semantic search, shell `rg`) toward tracedecay MCP tools. -/// Registered on `postToolUse` rather than `preToolUse` because Cursor's -/// documented `preToolUse` output schema has no context-injection field — -/// `additional_context` is only honored on `postToolUse`. The hook runs -/// unmatched (the docs enumerate no matcher value for Cursor's semantic -/// search tool) and irrelevant tools fail open with no output. Each hint -/// category is emitted at most once per session via [`ToolHintDedupe`] -/// persisted under `.tracedecay/`. -pub fn hook_cursor_post_tool_use() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "postToolUse", &event); - if let Some(decision) = cursor_post_tool_use_decision(&event) { - println!("{decision}"); - } - 0 -} - -/// Cursor `beforeSubmitPrompt` hook handler. -/// -/// Resets the project-local counter for a new prompt turn and does at most a -/// small, time-boxed *tail* ingest of newly-appended transcript lines (the bulk -/// catch-up lives on the lower-frequency `sessionStart` / `stop` hooks). The -/// output uses Cursor's documented `beforeSubmitPrompt` shape and never blocks -/// submission, even if the tail ingest times out. -pub async fn hook_cursor_before_submit_prompt() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked( - root.as_deref(), - HintAgent::Cursor, - "beforeSubmitPrompt", - &event, - ); - reset_counter_for_cursor_event(&event).await; - ingest_cursor_transcript_for_event( - &event, - Some(CURSOR_HOT_INGEST_MAX_BYTES), - CURSOR_HOT_INGEST_BUDGET, - ) - .await; - // Cursor's documented `beforeSubmitPrompt` output is `continue` + - // `user_message` only — `additional_context` is not part of this event's - // contract, so no hint is emitted here (the postToolUse and sessionStart - // hooks are the documented context channels). - println!("{}", serde_json::json!({ "continue": true })); - 0 -} - -/// Cursor `sessionEnd` hook handler (fire-and-forget). -/// -/// Final transcript-ingest flush when a conversation ends (including -/// `window_close` / `user_close`, which the end-of-turn `stop` hook can -/// miss). `sessionEnd` receives the common-schema `transcript_path`, so the -/// regular capped catch-up ingest applies. The response is logged but unused, -/// so an empty object is emitted. Fail-open. -pub async fn hook_cursor_session_end() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "sessionEnd", &event); - ingest_cursor_transcript_for_event( - &event, - Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), - CURSOR_STOP_INGEST_BUDGET, - ) - .await; - println!("{}", serde_json::json!({})); - 0 -} - -/// Cursor `stop` hook handler (fire-and-forget). -/// -/// Fires at the end of an agent turn and performs the primary transcript -/// ingest: a time-boxed incremental catch-up that picks up bounded transcript -/// tails appended during the turn. The `stop` output is informational only, so -/// we emit an empty object and never ask the agent to continue. Fail-open. -pub async fn hook_cursor_stop() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "stop", &event); - ingest_cursor_transcript_for_event( - &event, - Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), - CURSOR_STOP_INGEST_BUDGET, - ) - .await; - println!("{}", serde_json::json!({})); - 0 -} - -/// Cursor `preCompact` hook handler. -/// -/// Cursor's compaction event exposes pressure metadata but not Cursor's own -/// generated summary text. At the boundary, `TraceDecay` ingests the current -/// transcript tail, asks LCM for the compactable raw-message backlog, generates -/// a summary through `cursor-agent -p`, and stores that summary as a normal LCM -/// summary node. The hook is fail-open and emits Cursor's empty object shape. -pub async fn hook_cursor_pre_compact() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "preCompact", &event); - if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_err() { - let mut config = crate::sessions::cursor_agent::CursorAgentSummaryConfig::from_env(); - config.timeout = config.timeout.min(CURSOR_PRE_COMPACT_SUMMARY_BUDGET); - let outcome = cursor_pre_compact_for_event_with_config(&event, &config).await; - if outcome.status == "error" { - eprintln!( - "tracedecay Cursor preCompact summary failed: {}", - outcome.reason - ); - } - } - println!("{}", serde_json::json!({})); - 0 -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CursorPreCompactOutcome { - pub status: String, - pub reason: String, - pub summary_nodes_created: usize, - pub summary_node_ids: Vec, -} - -impl CursorPreCompactOutcome { - fn skipped(reason: impl Into) -> Self { - Self { - status: "skipped".to_string(), - reason: reason.into(), - summary_nodes_created: 0, - summary_node_ids: Vec::new(), - } - } - - fn error(reason: impl Into) -> Self { - Self { - status: "error".to_string(), - reason: reason.into(), - summary_nodes_created: 0, - summary_node_ids: Vec::new(), - } - } -} - -pub async fn cursor_pre_compact_for_event_with_config( - event_json: &str, - config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, -) -> CursorPreCompactOutcome { - match tokio::time::timeout( - CURSOR_PRE_COMPACT_BUDGET, - cursor_pre_compact_for_event_inner(event_json, config), - ) - .await - { - Ok(outcome) => outcome, - Err(_) => CursorPreCompactOutcome::error("timed out"), - } -} - -async fn cursor_pre_compact_for_event_inner( - event_json: &str, - config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, -) -> CursorPreCompactOutcome { - if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_ok() { - return CursorPreCompactOutcome::skipped("cursor summary child"); - } - let parsed = match serde_json::from_str::(event_json) { - Ok(parsed) => parsed, - Err(err) => return CursorPreCompactOutcome::error(format!("invalid event JSON: {err}")), - }; - let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { - return CursorPreCompactOutcome::skipped("no project root"); - }; - if !cursor_event_transcript_path_exists(&parsed) { - return CursorPreCompactOutcome::skipped("no transcript path"); - } - - let caught_up = - ingest_cursor_transcript_for_event(event_json, None, CURSOR_PRE_COMPACT_INGEST_BUDGET) - .await; - if !caught_up { - return CursorPreCompactOutcome::skipped("transcript ingest did not complete"); - } - - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return CursorPreCompactOutcome::skipped("session database unavailable"); - }; - let Some(session_id) = event_session_id(&parsed) else { - return CursorPreCompactOutcome::skipped("no session id"); - }; - - let messages_to_compact = event_usize(&parsed, &["messages_to_compact", "compact_count"]); - if messages_to_compact == Some(0) { - return CursorPreCompactOutcome::skipped("no messages to compact"); - } - let fresh_tail_count = cursor_pre_compact_fresh_tail_count(&parsed, messages_to_compact); - let current_tokens = event_i64(&parsed, &["context_tokens", "current_tokens", "tokens"]); - let context_length = event_i64(&parsed, &["context_window_size", "context_length"]); - - let first = match db - .lcm_compress(cursor_pre_compact_lcm_request( - &session_id, - current_tokens, - context_length, - messages_to_compact, - fresh_tail_count, - crate::sessions::lcm::LcmSummarizerMode::HermesAuxiliary, - None, - )) - .await - { - Ok(response) => response, - Err(err) => return CursorPreCompactOutcome::error(format!("LCM prepare failed: {err}")), - }; - let Some(summary_request) = first.summary_request else { - return CursorPreCompactOutcome::skipped(first.reason); - }; - - let summary = match crate::sessions::cursor_agent::summarize_with_cursor_agent( - &summary_request, - config, - ) { - Ok(summary) => summary, - Err(err) => { - return CursorPreCompactOutcome::error(format!("cursor-agent summary failed: {err}")) - } - }; - - let second = match db - .lcm_compress(cursor_pre_compact_lcm_request( - &session_id, - current_tokens, - context_length, - messages_to_compact, - fresh_tail_count, - crate::sessions::lcm::LcmSummarizerMode::Provided { - summary_text: summary, - route: Some("cursor_agent".to_string()), - }, - first.frontier.current_frontier_store_id.or(Some(0)), - )) - .await - { - Ok(response) => response, - Err(err) => return CursorPreCompactOutcome::error(format!("LCM persist failed: {err}")), - }; - CursorPreCompactOutcome { - status: second.status, - reason: second.reason, - summary_nodes_created: second.summary_nodes_created, - summary_node_ids: second - .summary_nodes - .iter() - .map(|node| node.node_id.clone()) - .collect(), - } -} - -/// Cursor `afterFileEdit` hook handler. -/// -/// Keeps the graph fresh after Cursor Agent writes files by notifying the -/// daemon about the edited path(s). The daemon owns targeted sync scheduling -/// and the hook fails open when no daemon is available. -pub async fn hook_cursor_after_file_edit() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "afterFileEdit", &event); - notify_cursor_after_file_edit(&event).await; - 0 -} - -/// Cursor `sessionStart` hook handler (fire-and-forget). -/// -/// Emits Cursor's `sessionStart` output shape (`additional_context` + `env`) -/// steering the agent toward tracedecay MCP tools and reporting index freshness -/// for the resolved workspace. Never blocks session creation. -pub async fn hook_cursor_session_start() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "sessionStart", &event); - // Catch-up ingest for resumed sessions whose transcript grew while no agent - // was attached. No-op (no transcript_path) for brand-new sessions. Fail-open. - ingest_cursor_transcript_for_event( - &event, - Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), - CURSOR_SESSION_INGEST_BUDGET, - ) - .await; - let mut context = cursor_session_context_for_root(root.as_deref()).await; - if session_start_from_compaction(&event) { - append_context_recovery_hint(&mut context); - } - println!("{}", cursor_session_start_json(root.as_deref(), &context)); - 0 -} - -/// Builds the lean Cursor `sessionStart` context for a resolved project root. -/// -/// Deliberately complementary to (not duplicative of) the plugin's always-on -/// rule: the rule carries the tool-routing steering, so this only adds what -/// the rule cannot know — index freshness, the skill index, and the -/// tokens-saved counter. -async fn cursor_session_context_for_root(root: Option<&Path>) -> String { - let (initialized, staleness, tokens_saved) = match root { - Some(r) if crate::tracedecay::TraceDecay::has_initialized_store(r).await => { - let (staleness, tokens_saved) = cursor_index_signals_for_root(r).await; - (true, staleness, tokens_saved) - } - _ => (false, None, None), - }; - build_cursor_session_context(initialized, staleness.as_deref(), tokens_saved) -} - -/// Builds the tracedecay steering `additional_context` for Codex session/prompt -/// hooks. Unlike Cursor, Codex has no always-applied tracedecay rule, so this -/// context carries the full tool-routing steering plus index freshness. -async fn codex_session_context_for_event(event_json: &str) -> (String, HookWorkspaceStatus) { - let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); - let root = codex_project_root_from_parsed_event(&parsed); - let cwd = event_cwd_from_parsed(&parsed); - let session_id = event_session_id(&parsed); - let status = codex_workspace_status(root.as_deref(), cwd.as_deref()); - record_workspace_status_analytics(root.as_deref(), status, session_id.as_deref()); - let staleness = match (status, root.as_deref()) { - (HookWorkspaceStatus::Initialized, Some(r)) => { - let (staleness, _) = cursor_index_signals_for_root(r).await; - staleness - } - _ => None, - }; - ( - build_codex_session_context_for_workspace(status, staleness.as_deref()), - status, - ) -} - -/// Cursor `afterShellExecution` hook handler. -/// -/// Notifies the daemon after Cursor shell execution. The daemon decides whether -/// the command requires branch tracking or coalesced incremental sync. -pub async fn hook_cursor_after_shell() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked( - root.as_deref(), - HintAgent::Cursor, - "afterShellExecution", - &event, - ); - notify_cursor_after_shell_event(&event).await; - 0 -} - -/// Cursor `workspaceOpen` hook handler. -/// -/// Notifies the daemon to run one-shot workspace catch-up when an indexed -/// workspace opens. We don't load plugins, so the output is an empty object. -/// Fail-open. -pub async fn hook_cursor_workspace_open() -> i32 { - let event = read_hook_event!(); - let root = cursor_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Cursor, "workspaceOpen", &event); - notify_cursor_workspace_open(&event).await; - println!("{}", serde_json::json!({})); - 0 -} - -/// Pure decision logic for Cursor `subagentStart` hook events. -/// -/// Cursor subagents must be allowed to start. -/// -/// Earlier versions denied research/explore subagents in favor of tracedecay MCP -/// tools. In Cursor this can surface as a misleading "bubble creation" timeout, -/// and it prevents explicit user requests to use agents. Keep this handler -/// fail-open so stale installs that still register `subagentStart` do not block -/// subagent creation. -pub fn evaluate_cursor_subagent_start(event_json: &str) -> Option { - let _ = event_json; - None -} - -/// Pure decision logic for Cursor `postToolUse` hook events. -/// -/// Returns a soft `additional_context` payload (Cursor's documented -/// `postToolUse` output shape) for exploration tools tracedecay can replace. -/// Invalid or unrelated tool events fail open with no output. Session-level -/// dedupe lives in [`cursor_post_tool_use_decision`]; this stays pure for -/// tests. -pub fn evaluate_cursor_post_tool_use(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - let hint = decide_hint(&cursor_tool_hint_input(&parsed))?; - Some( - serde_json::json!({ - "additional_context": format_tool_hint(&hint), - }) - .to_string(), - ) -} - -/// Impure `postToolUse` path: [`evaluate_cursor_post_tool_use`] plus -/// per-session hint dedupe persisted under the project's `.tracedecay/` dir. -pub fn cursor_post_tool_use_decision(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - let hint = decide_hint(&cursor_tool_hint_input(&parsed))?; - let root = cursor_project_root_candidate_from_parsed_event(&parsed); - record_hint_analytics( - root.as_deref(), - "hint_candidate", - HintAgent::Cursor, - event_session_id(&parsed).as_deref(), - &hint, - ); - let hint = deduped_cursor_hint(event_json, hint)?; - Some( - serde_json::json!({ - "additional_context": format_tool_hint(&hint), - }) - .to_string(), - ) -} - -/// Suppresses hints that were already emitted for this session. -/// -/// The `(session_id, category)` pairs are persisted in -/// `.tracedecay/tool_hints_seen.json` so each hint category surfaces at most -/// once per Cursor session across short-lived hook processes. Hints are also -/// suppressed entirely when the workspace has no tracedecay index (suggesting -/// tracedecay tools there would be misleading). When no session id is present -/// the hint is emitted as-is — dedupe is impossible but the hint is still -/// useful (fail-open). -fn deduped_cursor_hint(event_json: &str, hint: ToolHint) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - let root = cursor_project_root_candidate_from_parsed_event(&parsed)?; - if !crate::tracedecay::TraceDecay::is_initialized(&root) { - record_hint_analytics( - Some(&root), - "suppressed_uninitialized", - HintAgent::Cursor, - event_session_id(&parsed).as_deref(), - &hint, - ); - return None; - } - deduped_project_hint( - Some(root), - HintAgent::Cursor, - event_session_id(&parsed), - hint, - ) -} - -fn deduped_codex_hint(event_json: &str, parsed: &Value, hint: ToolHint) -> Option { - deduped_project_hint( - codex_project_root_from_event(event_json), - HintAgent::Codex, - event_session_id(parsed), - hint, - ) -} - -fn deduped_project_hint( - root: Option, - agent: HintAgent, - session_id: Option, - hint: ToolHint, -) -> Option { - let Some(root) = root else { - record_hint_emitted(None, agent, session_id.as_deref(), &hint); - return Some(hint); - }; - let Some(session_id) = session_id else { - record_hint_emitted(Some(&root), agent, None, &hint); - return Some(hint); - }; - if !remember_hint_in_process(&root, agent, &session_id, hint.category) { - record_hint_analytics( - Some(&root), - "suppressed_duplicate", - agent, - Some(&session_id), - &hint, - ); - return None; - } - let Ok(layout) = crate::storage::resolve_layout_for_current_profile(&root) else { - record_hint_emitted(Some(&root), agent, Some(&session_id), &hint); - return Some(hint); - }; - if !layout.data_root.is_dir() { - record_hint_emitted(Some(&root), agent, Some(&session_id), &hint); - return Some(hint); - } - let path = layout.data_root.join("tool_hints_seen.json"); - let mut dedupe = tool_hints::ToolHintDedupe::load_or_default(&path); - if !dedupe.should_emit(&session_id, hint.category) { - record_hint_analytics( - Some(&root), - "suppressed_duplicate", - agent, - Some(&session_id), - &hint, - ); - return None; - } - let _ = dedupe.save(&path); - record_hint_analytics(Some(&root), "hint_emitted", agent, Some(&session_id), &hint); - Some(hint) -} - -fn remember_hint_in_process( - root: &Path, - agent: HintAgent, - session_id: &str, - category: HintCategory, -) -> bool { - static MEMORY: OnceLock>> = OnceLock::new(); - let key = format!( - "{}\0{}\0{}\0{}", - root.display(), - agent.as_key(), - session_id, - category.as_key() - ); - let Ok(mut memory) = MEMORY.get_or_init(|| Mutex::new(HashSet::new())).lock() else { - return true; - }; - memory.insert(key) -} - -pub fn cursor_project_root_from_event(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - cursor_project_root_from_parsed_event(&parsed) -} - -fn cursor_project_root_candidate_from_parsed_event(parsed: &Value) -> Option { - cursor_project_root_from_parsed_event(parsed).or_else(|| { - cursor_event_candidates(parsed) - .into_iter() - .find_map(|candidate| nearest_project_like_root(&candidate)) - }) -} - -fn cursor_project_root_from_parsed_event(parsed: &Value) -> Option { - let resolved = cursor_event_candidates(parsed) - .into_iter() - .find_map(|candidate| crate::config::discover_project_root(&candidate)); - let cwd_root = cursor_event_cwd(parsed) - .as_deref() - .and_then(crate::config::discover_project_root); - match (cwd_root, resolved) { - // Prefer the root derived from cwd when available; this avoids routing - // a root-B event into root A just because workspace_roots listed A first. - (Some(cwd_root), Some(resolved)) if !paths_same(&cwd_root, &resolved) => Some(cwd_root), - (Some(cwd_root), None) => Some(cwd_root), - (_, other) => other, - } -} - -fn nearest_project_like_root(start: &Path) -> Option { - if let Some(root) = crate::worktree::git_worktree_root(start) { - return Some(root); - } - let mut dir = start.to_path_buf(); - loop { - if project_marker_exists(&dir) { - return Some(dir); - } - if !dir.pop() { - return None; - } - } -} - -fn cursor_event_candidates(event: &Value) -> Vec { - let mut candidates = Vec::new(); - let mut push_unique = |candidate: PathBuf| { - if !candidates.iter().any(|seen| seen == &candidate) { - candidates.push(candidate); - } - }; - if let Some(cwd) = cursor_event_cwd(event) { - push_unique(cwd); - } - if let Some(project_root) = crate::config::brand_env("PROJECT_ROOT") { - push_unique(PathBuf::from(project_root)); - } - if let Some(file_path) = event - .get("file_path") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - let path = Path::new(file_path); - push_unique(path.parent().unwrap_or(path).to_path_buf()); - } - if let Some(transcript_path) = event - .get("transcript_path") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - let path = Path::new(transcript_path); - push_unique(path.parent().unwrap_or(path).to_path_buf()); - } - if let Some(roots) = event.get("workspace_roots").and_then(Value::as_array) { - for root in roots { - if let Some(path) = root.as_str().filter(|s| !s.is_empty()) { - push_unique(PathBuf::from(path)); - } - } - } - candidates -} - -fn cursor_event_cwd(event: &Value) -> Option { - event - .get("cwd") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(PathBuf::from) -} - -/// Returns `true` when `command` is a git invocation that changes the working -/// tree / HEAD enough that a broad re-sync is warranted (checkout, switch, -/// pull, merge, rebase, reset, cherry-pick, `stash pop`/`stash apply`). -/// -/// Read-only git commands (`status`, `log`, `diff`), `commit`/`add`, and -/// non-git commands return `false`. Only commands whose first token is `git` -/// match, so `echo git checkout` is ignored. -pub fn is_git_state_changing_command(command: &str) -> bool { - let tokens = shell_words(command); - let Some(sub_pos) = git_subcommand_pos(&tokens) else { - return false; - }; - let sub = tokens[sub_pos].to_ascii_lowercase(); - match sub.as_str() { - "checkout" | "switch" | "pull" | "merge" | "rebase" | "reset" | "cherry-pick" => true, - "stash" => { - let after = tokens - .iter() - .skip(sub_pos + 1) - .map(|t| t.to_ascii_lowercase()) - .find(|t| !t.starts_with('-')); - matches!(after.as_deref(), Some("pop" | "apply")) - } - _ => false, - } -} - -/// The action a Cursor `afterShellExecution` hook should take for a command. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CursorShellSyncPlan { - /// Bootstrap/maintain branch tracking for the given branch (supersedes a - /// plain sync; the branch-add path copies the parent DB and syncs). - BranchAdd(String), - /// Bootstrap/maintain branch tracking in a newly-created linked worktree. - WorktreeBranchAdd { - branch: String, - worktree_path: String, - }, - /// Run a full incremental sync (same-branch change set). - IncrementalSync, - /// Ensure the current branch is tracked, then sync it if it already was. - CurrentBranchSync(String), - /// Do nothing. - Noop, -} - -/// Classifies a shell command into the sync action a Cursor -/// `afterShellExecution` hook should take. Branch switches take precedence -/// over plain incremental syncs. -pub fn cursor_shell_sync_plan(command: &str) -> CursorShellSyncPlan { - cursor_shell_sync_plan_with_current_branch(command, None) -} - -/// Like [`cursor_shell_sync_plan`], but supplies the post-command current branch -/// for state-changing commands whose branch target is ambiguous or implicit. -pub fn cursor_shell_sync_plan_with_current_branch( - command: &str, - current_branch: Option<&str>, -) -> CursorShellSyncPlan { - let raw = shell_words(command); - if let Some(parts) = cursor_worktree_add_parts_from_tokens(&raw) { - return CursorShellSyncPlan::WorktreeBranchAdd { - branch: parts.branch, - worktree_path: parts.worktree_path, - }; - } - if let Some(branch) = cursor_branch_switch_target_from_tokens(&raw) { - return CursorShellSyncPlan::BranchAdd(branch); - } - if is_git_state_changing_command(command) { - if let Some(branch) = current_branch.filter(|branch| !branch.is_empty()) { - return CursorShellSyncPlan::CurrentBranchSync(branch.to_string()); - } - return CursorShellSyncPlan::IncrementalSync; - } - CursorShellSyncPlan::Noop -} - -/// Returns the target branch for a branch-changing git command: -/// `git checkout `, `git switch `, `git checkout -b `, -/// and `git switch -c `. Worktree creation is classified separately by -/// [`cursor_shell_sync_plan`], which owns `git worktree add` parsing. -/// -/// Path checkouts (`git checkout -- ` or obvious file pathspecs), remote -/// tracking shortcuts such as `git switch --track origin/feature`, and -/// non-switch commands return `None`. Only commands whose first shell word is -/// `git` are considered. -pub fn cursor_branch_switch_target(command: &str) -> Option { - let raw = shell_words(command); - cursor_branch_switch_target_from_tokens(&raw) -} - -fn cursor_branch_switch_target_from_tokens(raw: &[String]) -> Option { - let sub_pos = git_subcommand_pos(raw)?; - let sub = raw[sub_pos].to_ascii_lowercase(); - - match sub.as_str() { - "checkout" | "switch" => { - let after = &raw[sub_pos + 1..]; - let mut i = 0; - let mut uses_tracking_shortcut = false; - while i < after.len() { - let tok = &after[i]; - if tok == "--" { - return None; - } - if matches!(tok.as_str(), "-b" | "-B" | "-c" | "-C" | "--orphan") { - return after.get(i + 1).cloned(); - } - if tok == "-t" || tok == "--track" || tok.starts_with("--track=") { - uses_tracking_shortcut = true; - i += 1; - continue; - } - if tok.starts_with('-') { - i += 1; - continue; - } - if uses_tracking_shortcut { - return None; - } - if is_obvious_checkout_pathspec(tok) { - return None; - } - return Some(tok.clone()); - } - None - } - _ => None, - } -} - -fn cursor_worktree_add_parts_from_tokens(raw: &[String]) -> Option { - let sub_pos = git_subcommand_pos(raw)?; - if raw.get(sub_pos)?.eq_ignore_ascii_case("worktree") - && raw.get(sub_pos + 1)?.eq_ignore_ascii_case("add") - { - return cursor_worktree_add_parts(&raw[sub_pos + 2..]); - } - None -} - -struct WorktreeAddParts { - branch: String, - worktree_path: String, -} - -fn cursor_worktree_add_parts(after: &[String]) -> Option { - let mut i = 0; - let mut positional = Vec::new(); - let mut detached = false; - let mut new_branch = None; - while i < after.len() { - let tok = &after[i]; - if tok == "--" { - positional.extend(after[i + 1..].iter().cloned()); - break; - } - if matches!(tok.as_str(), "-b" | "-B") { - new_branch = after.get(i + 1).cloned(); - i += 2; - continue; - } - if tok == "-d" || tok == "--detach" { - detached = true; - i += 1; - continue; - } - if tok == "--reason" { - i += 2; - continue; - } - if tok.starts_with('-') { - i += 1; - continue; - } - positional.push(tok.clone()); - i += 1; - } - if detached { - return None; - } - let worktree_path = positional.first()?.clone(); - let branch = new_branch.or_else(|| positional.get(1).cloned())?; - Some(WorktreeAddParts { - branch, - worktree_path, - }) -} - -fn is_obvious_checkout_pathspec(token: &str) -> bool { - token == "." - || token == ":/" - || token.starts_with("./") - || token.starts_with("../") - || token.starts_with(":/") - || token - .rsplit_once('.') - .is_some_and(|(_, ext)| !ext.is_empty()) -} - -/// Splits a shell command line into words, honoring single/double quotes and -/// backslash escapes. Shared with `tool_hints` so search-command -/// classification sees the same tokens as the checkout/sync parsing here. -pub(crate) fn shell_words(command: &str) -> Vec { - shell_words_for_platform(command, cfg!(windows)) -} - -fn shell_words_for_platform(command: &str, windows: bool) -> Vec { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - let mut escaped = false; - - for c in command.chars() { - if escaped { - current.push(c); - escaped = false; - continue; - } - - match quote { - Some('\'') => { - if c == '\'' { - quote = None; - } else { - current.push(c); - } - } - Some('"') => match c { - '"' => quote = None, - '\\' => escaped = true, - _ => current.push(c), - }, - _ => match c { - '\'' | '"' => quote = Some(c), - '\\' if windows => current.push(c), - '\\' => escaped = true, - c if c.is_whitespace() => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - _ => current.push(c), - }, - } - } - - if escaped { - current.push('\\'); - } - if !current.is_empty() { - words.push(current); - } - words -} - -fn git_subcommand_pos(tokens: &[String]) -> Option { - if !tokens.first()?.eq_ignore_ascii_case("git") { - return None; - } - - let mut i = 1; - while i < tokens.len() { - let token = tokens[i].to_ascii_lowercase(); - match token.as_str() { - "-c" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" => { - i += 2; - } - "--" => { - i += 1; - } - _ if token.starts_with("--git-dir=") - || token.starts_with("--work-tree=") - || token.starts_with("--namespace=") - || token.starts_with("--config-env=") => - { - i += 1; - } - _ if token.starts_with('-') => { - i += 1; - } - _ => return Some(i), - } - } - None -} - -pub fn cursor_shell_command_targets_project( - command: &str, - cwd: &Path, - project_root: &Path, -) -> bool { - let tokens = shell_words(command); - if !tokens - .first() - .is_some_and(|token| token.eq_ignore_ascii_case("git")) - { - return true; - } - let Some(work_dir) = git_explicit_work_dir(&tokens, cwd) else { - return true; - }; - let target_root = crate::config::discover_project_root(&work_dir).unwrap_or(work_dir); - paths_same(&target_root, project_root) -} - -fn git_explicit_work_dir(tokens: &[String], cwd: &Path) -> Option { - let mut i = 1; - let mut explicit_work_dir = None; - while i < tokens.len() { - let token = &tokens[i]; - match token.as_str() { - "-C" | "--work-tree" => { - let value = tokens.get(i + 1)?; - explicit_work_dir = Some(resolve_shell_path(cwd, value)); - i += 2; - } - "-c" | "--git-dir" | "--namespace" | "--config-env" => i += 2, - _ if token.starts_with("--work-tree=") => { - let value = token.trim_start_matches("--work-tree="); - explicit_work_dir = Some(resolve_shell_path(cwd, value)); - i += 1; - } - _ if token.starts_with("--git-dir=") - || token.starts_with("--namespace=") - || token.starts_with("--config-env=") => - { - i += 1; - } - _ if token.starts_with('-') => i += 1, - _ => break, - } - } - explicit_work_dir -} - -fn resolve_shell_path(cwd: &Path, value: &str) -> PathBuf { - let path = Path::new(value); - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } -} - -/// Resolves the filesystem root of the worktree created by a -/// `git worktree add` command. git resolves the worktree path against -/// `-C `/`--work-tree` overrides rather than the shell cwd, so those are -/// honored first. The result is canonicalized when the worktree exists (it -/// does by the time a post-shell hook fires) so symlinked components resolve -/// the way git resolved them, falling back to lexical `..` normalization. -pub fn resolve_worktree_add_root(command: &str, cwd: &Path, worktree_path: &str) -> PathBuf { - let tokens = shell_words(command); - let base = git_explicit_work_dir(&tokens, cwd).unwrap_or_else(|| cwd.to_path_buf()); - let joined = resolve_shell_path(&base, worktree_path); - joined - .canonicalize() - .unwrap_or_else(|_| normalize_lexically(&joined)) -} - -fn normalize_lexically(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - other => normalized.push(other.as_os_str()), - } - } - normalized -} - -fn paths_same(a: &Path, b: &Path) -> bool { - match (a.canonicalize(), b.canonicalize()) { - (Ok(a), Ok(b)) => a == b, - _ => a == b, - } -} - -/// Extracts the repo-relative paths edited in a Cursor `afterFileEdit` event. -/// -/// Cursor sends an absolute `file_path` (plus an `edits` array). We strip the -/// resolved `project_root` prefix and normalize to forward slashes so the hook -/// can notify the daemon about only the changed files. Paths outside the project -/// root are skipped. -pub fn cursor_after_file_edit_rel_paths(event_json: &str, project_root: &Path) -> Vec { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return Vec::new(); - }; - - let mut abs_paths: Vec = Vec::new(); - if let Some(p) = parsed - .get("file_path") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - abs_paths.push(p.to_string()); - } - // Defensive: some edit payloads may carry per-edit file paths. - if let Some(edits) = parsed.get("edits").and_then(Value::as_array) { - for edit in edits { - if let Some(p) = edit - .get("file_path") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - abs_paths.push(p.to_string()); - } - } - } - - let mut rels: Vec = Vec::new(); - for abs in abs_paths { - if let Some(rel) = rel_under_root(project_root, Path::new(&abs)) { - if !rels.contains(&rel) { - rels.push(rel); - } - } - } - rels -} - -fn rel_under_root(root: &Path, abs: &Path) -> Option { - let stripped = abs.strip_prefix(root).ok()?; - if stripped.as_os_str().is_empty() { - return None; - } - if stripped.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { - return None; - } - Some(stripped.to_string_lossy().replace('\\', "/")) -} - -/// Returns `true` when a sync should run given the last marker time and a -/// debounce window. Used to coalesce back-to-back `afterShellExecution` syncs. -pub fn cursor_should_run_sync(now_secs: i64, last_secs: Option, debounce_secs: i64) -> bool { - match last_secs { - Some(last) => now_secs - last >= debounce_secs, - None => true, - } -} - -/// Model-invocable workflow skills shipped in the tracedecay Cursor plugin's -/// `skills/` directory (slash dispatchers with `disable-model-invocation: -/// true` are excluded). Kept as one constant so the session steering context -/// and the bundle coverage test in `agents::cursor` stay in sync. -pub const CURSOR_PLUGIN_SKILLS: &[&str] = &[ - "architecture-overview", - "assessing-test-coverage", - "atomic-code-edits", - "auditing-code-safety", - "cleaning-up-dead-code", - "code-health-report", - "cross-branch-investigation", - "curating-project-memory", - "drafting-commit-and-pr", - "exploring-types-and-traits", - "finding-duplicate-logic", - "finding-impacted-areas", - "fixing-build-and-type-errors", - "porting-code", - "project-status", - "reading-code-cheaply", - "recalling-project-memory", - "recalling-session-context", - "refactoring-safely", - "reviewing-a-diff", - "running-impacted-tests", - "searching-for-code", - "tracing-functions", - "tracking-session-health", - "using-the-cli", -]; - -/// Builds the Cursor `sessionStart` `additional_context` text. -/// -/// Intentionally lean: the always-applied plugin rule already carries the -/// tool-routing steering, so repeating it here would burn tokens every -/// session. This adds only the session-specific signals — index freshness, -/// the workflow-skill index, and the tokens-saved counter. -pub fn build_cursor_session_context( - initialized: bool, - staleness_hint: Option<&str>, - tokens_saved: Option, -) -> String { - let mut s = index_status_line(initialized, staleness_hint); - if initialized { - s.push_str("Workflow skills: tracedecay:"); - s.push_str(&CURSOR_PLUGIN_SKILLS.join(", ")); - s.push_str(" — each maps a common workflow to the right tracedecay tools.\n"); - if let Some(saved) = tokens_saved.filter(|saved| *saved > 0) { - s.push_str("Tokens saved by tracedecay this session: "); - s.push_str(&saved.to_string()); - s.push_str(".\n"); - } - } - s -} - -/// One-line index freshness signal shared by the Cursor and Claude session -/// contexts. Both hosts carry the tool-routing steering in an always-applied -/// rule (Cursor plugin rule, CLAUDE.md), so their session hooks report only -/// session-specific signals. -fn index_status_line(initialized: bool, staleness_hint: Option<&str>) -> String { - if initialized { - match staleness_hint { - Some(hint) => format!("tracedecay index status: {hint}.\n"), - None => "tracedecay index status: initialized.\n".to_string(), - } - } else { - "tracedecay index status: no project index found in this workspace — \ - run `tracedecay init` to enable tracedecay MCP tools.\n" - .to_string() - } -} - -/// Builds the Codex session/prompt steering context. Codex has no -/// always-applied tracedecay rule, so the full tool-routing steering lives -/// here. -pub fn build_codex_session_context(initialized: bool, staleness_hint: Option<&str>) -> String { - let status = if initialized { - HookWorkspaceStatus::Initialized - } else { - HookWorkspaceStatus::UnindexedProject - }; - build_codex_session_context_for_workspace(status, staleness_hint) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HookWorkspaceStatus { - Initialized, - UnindexedProject, - Generic, -} - -impl HookWorkspaceStatus { - fn as_key(self) -> &'static str { - match self { - HookWorkspaceStatus::Initialized => "initialized", - HookWorkspaceStatus::UnindexedProject => "unindexed_project", - HookWorkspaceStatus::Generic => "generic", - } - } -} - -/// Builds the Codex session/prompt context for the detected workspace kind. -pub fn build_codex_session_context_for_workspace( - status: HookWorkspaceStatus, - staleness_hint: Option<&str>, -) -> String { - let mut s = String::new(); - match status { - HookWorkspaceStatus::Initialized | HookWorkspaceStatus::UnindexedProject => { - s.push_str( - "tracedecay is available via MCP. Prefer tracedecay MCP tools \ - (tracedecay_context, tracedecay_search, tracedecay_callers, tracedecay_callees, \ - tracedecay_impact, tracedecay_files, tracedecay_affected) over broad file reads \ - or shell search for codebase exploration, symbol lookup, call graphs, and \ - impact analysis. Fall back to file reads only when tracedecay cannot answer.\n\ - If an MCP call errors, times out, or the server is disconnected, every tool \ - is also a shell command: `tracedecay tool --key value` (`tracedecay \ - tool` lists tools, `tracedecay tool --help` shows parameters). Use \ - that CLI instead of querying .tracedecay databases directly or abandoning \ - tracedecay.\n", - ); - append_codex_recall_and_registry_guidance(&mut s); - match status { - HookWorkspaceStatus::Initialized => match staleness_hint { - Some(hint) => { - s.push_str("Index status: "); - s.push_str(hint); - s.push_str(".\n"); - } - None => s.push_str("Index status: initialized.\n"), - }, - HookWorkspaceStatus::UnindexedProject => s.push_str( - "Index status: no project index found in this code workspace — \ - run `tracedecay init` to enable tracedecay code-graph tools.\n", - ), - HookWorkspaceStatus::Generic => {} - } - } - HookWorkspaceStatus::Generic => { - s.push_str( - "TraceDecay session context is available via MCP. For prior conversation \ - recovery, use tracedecay_lcm_expand_query, tracedecay_message_search, and \ - tracedecay_lcm_describe before asking the user to repeat themselves. Use \ - tracedecay_fact_store only for durable preferences, environment details, \ - tool quirks, or decisions that will still matter later. Do not store task \ - progress, temporary TODOs, or soon-stale session outcomes; recover those \ - from transcripts instead.\n", - ); - s.push_str("Workspace status: no active project workspace; no setup guidance needed for this prompt.\n"); - } - } - s -} - -fn append_codex_recall_and_registry_guidance(s: &mut String) { - s.push_str( - "For other registered projects or sibling workspaces, check \ - tracedecay_project_list or tracedecay_project_search first; use \ - tracedecay_project_context to confirm the target and pass project_id or \ - project_path to tracedecay_context/search for cross-project code context before \ - scanning parent directories. When the user references prior conversation or \ - missing context, use tracedecay_message_search or tracedecay_lcm_expand_query \ - before asking the user to repeat themselves. Use tracedecay_fact_store only for \ - durable preferences, environment details, tool quirks, or decisions that will \ - still matter later. Do not store task progress, temporary TODOs, or soon-stale \ - session outcomes; recover those from transcripts instead.\n", - ); -} - -fn append_context_recovery_hint(context: &mut String) { - if !context.is_empty() && !context.ends_with('\n') { - context.push('\n'); - } - context.push_str(COMPACTION_CONTEXT_RECOVERY_HINT); - context.push('\n'); -} - -fn session_start_from_compaction(event_json: &str) -> bool { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return false; - }; - ["source", "trigger", "reason", "boundary_reason"] - .iter() - .filter_map(|key| parsed.get(*key).and_then(Value::as_str)) - .any(matches_compaction_source) -} - -fn matches_compaction_source(value: &str) -> bool { - let normalized = value - .chars() - .filter(char::is_ascii_alphanumeric) - .collect::() - .to_ascii_lowercase(); - matches!( - normalized.as_str(), - "compact" | "compaction" | "contextcompacted" | "compression" - ) -} - -/// Formats a short relative-age staleness hint from a sync age in seconds. -pub fn cursor_staleness_hint(age_secs: i64) -> String { - let age = age_secs.max(0); - if age < 60 { - "last indexed just now".to_string() - } else if age < 3_600 { - format!("last indexed {}m ago", age / 60) - } else if age < 86_400 { - format!("last indexed {}h ago", age / 3_600) - } else { - format!("last indexed {}d ago", age / 86_400) - } -} - -/// Builds the Cursor `sessionStart` output JSON (`additional_context` + `env`). -/// When `project_root` is known, exposes it as `TRACEDECAY_PROJECT_ROOT` so -/// subsequent session hooks can reuse it. -pub fn cursor_session_start_json(project_root: Option<&Path>, additional_context: &str) -> String { - let mut env = serde_json::Map::new(); - if let Some(root) = project_root { - env.insert( - "TRACEDECAY_PROJECT_ROOT".to_string(), - Value::String(root.to_string_lossy().to_string()), - ); - } - serde_json::json!({ - "additional_context": additional_context, - "env": Value::Object(env), - }) - .to_string() -} - -/// Opens the index once and reads both session-steering signals: the -/// staleness hint and the session tokens-saved counter. -async fn cursor_index_signals_for_root(root: &Path) -> (Option, Option) { - let Ok(cg) = crate::tracedecay::TraceDecay::open(root).await else { - return (None, None); - }; - let last = cg.last_sync_timestamp().await; - let staleness = (last > 0).then(|| cursor_staleness_hint(now_unix_secs() - last)); - let tokens_saved = cg.get_tokens_saved().await.ok(); - (staleness, tokens_saved) -} - -/// Best-effort daemon notification for Cursor `afterFileEdit`. -/// -/// Resolves the edited repo-relative paths locally, then lets the daemon own -/// scheduling and sync execution. No-ops when no in-project paths were edited. -async fn notify_cursor_after_file_edit(event_json: &str) { - let Some(root) = cursor_project_root_from_event(event_json) else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { - return; - } - let rels = cursor_after_file_edit_rel_paths(event_json, &root); - if rels.is_empty() { - return; - } - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::cursor_after_file_edit(rels), - ) - .await; -} - -/// Best-effort daemon notification for Cursor `afterShellExecution`. -async fn notify_cursor_after_shell_event(event_json: &str) { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return; - }; - let command = parsed - .get("command") - .and_then(Value::as_str) - .unwrap_or_default(); - let Some(root) = cursor_project_root_from_event(event_json) else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { - return; - } - let cwd = cursor_event_cwd(&parsed).unwrap_or_else(|| root.clone()); - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::cursor_after_shell_execution(command.to_string(), cwd), - ) - .await; -} - -/// Best-effort daemon notification for Cursor `workspaceOpen`. -async fn notify_cursor_workspace_open(event_json: &str) { - let Some(root) = cursor_project_root_from_event(event_json) else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { - return; - } - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::cursor_workspace_open(root.clone()), - ) - .await; -} - -fn now_unix_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs() as i64) -} - -// --------------------------------------------------------------------------- -// Claude Code lifecycle hook handlers (SessionStart / PostToolUse) -// -// Claude Code sends ONE JSON object on stdin with the same shared fields as -// Codex (session_id, transcript_path, cwd, hook_event_name, plus -// event-specific fields) and reads `hookSpecificOutput` JSON from stdout — -// Codex adopted Claude's hook schema, so these handlers share the Codex -// event/output helpers. The older Claude handlers (`hook_pre_tool_use`, -// `hook_prompt_submit`, `hook_stop`) predate that schema and keep their own -// input shapes. -// --------------------------------------------------------------------------- - -/// Claude Code `SessionStart` hook handler (fail-open). -/// -/// The CLAUDE.md prompt rules already carry the tool-routing steering, so -/// this emits only session-specific signals via -/// `hookSpecificOutput.additionalContext`: index freshness (or a -/// `tracedecay init` nudge in an unindexed project) plus the LCM -/// context-recovery hint when the session (re)starts from compaction. -pub async fn hook_claude_session_start() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Claude, "SessionStart", &event); - let mut context = claude_session_context_for_event(&event).await; - if session_start_from_compaction(&event) { - append_context_recovery_hint(&mut context); - } - if context.is_empty() { - println!("{}", serde_json::json!({})); - } else { - println!( - "{}", - codex_additional_context_json("SessionStart", &context) - ); - } - 0 -} - -/// Builds the lean Claude `SessionStart` context: the index-status line for -/// the session's project, an init nudge for unindexed project-like -/// workspaces, and nothing at all outside code workspaces. -async fn claude_session_context_for_event(event_json: &str) -> String { - let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); - // `discover_project_root` only resolves initialized tracedecay projects. - match codex_project_root_from_parsed_event(&parsed) { - Some(root) => { - let (staleness, _) = cursor_index_signals_for_root(&root).await; - index_status_line(true, staleness.as_deref()) - } - None if event_cwd_from_parsed(&parsed) - .as_deref() - .is_some_and(is_project_like_workspace) => - { - index_status_line(false, None) - } - None => String::new(), - } -} - -/// Claude Code `PostToolUse` hook handler used to keep the graph fresh after -/// writes. -/// -/// For edit tools and shell commands this notifies the daemon, which owns -/// targeted sync, branch tracking, and coalescing. Fail-open and silent. -pub async fn hook_claude_post_tool_use() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Claude, "PostToolUse", &event); - claude_post_tool_use(&event).await; - 0 -} - -async fn claude_post_tool_use(event_json: &str) { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return; - }; - let tool_name = parsed - .get("tool_name") - .and_then(Value::as_str) - .unwrap_or_default(); - let Some(cwd) = event_cwd_from_parsed(&parsed) else { - return; - }; - let Some(root) = crate::config::discover_project_root(&cwd) - .or_else(|| crate::worktree::git_worktree_root(&cwd)) - else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { - return; - } - - if is_claude_edit_tool(tool_name) { - let rels = claude_edit_rel_paths(&parsed, &cwd, &root); - if rels.is_empty() { - return; - } - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::post_tool_use_edit( - crate::daemon::HookAgent::Claude, - rels, - cwd, - ), - ) - .await; - } else if is_claude_bash_tool(tool_name) { - let command = parsed - .get("tool_input") - .and_then(|ti| ti.get("command")) - .and_then(Value::as_str) - .unwrap_or_default(); - if command.is_empty() { - return; - } - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::post_tool_use_shell( - crate::daemon::HookAgent::Claude, - command.to_string(), - cwd, - ), - ) - .await; - } -} - -fn is_claude_edit_tool(tool_name: &str) -> bool { - matches!( - tool_name.to_ascii_lowercase().as_str(), - "edit" | "write" | "multiedit" | "notebookedit" - ) -} - -fn is_claude_bash_tool(tool_name: &str) -> bool { - tool_name.eq_ignore_ascii_case("bash") -} - -/// Extracts the project-relative path edited by a Claude edit tool. -/// -/// Claude's `Edit`/`Write`/`MultiEdit` put the target in -/// `tool_input.file_path`; `NotebookEdit` uses `tool_input.notebook_path`. -/// Paths are usually absolute but are resolved against the session `cwd` -/// when relative. Paths outside `project_root` are skipped. -fn claude_edit_rel_paths(parsed: &Value, cwd: &Path, project_root: &Path) -> Vec { - ["file_path", "notebook_path"] - .iter() - .filter_map(|key| { - parsed - .get("tool_input") - .and_then(|ti| ti.get(*key)) - .and_then(Value::as_str) - }) - .filter(|raw| !raw.is_empty()) - .filter_map(|raw| { - let candidate = Path::new(raw); - let abs = if candidate.is_absolute() { - candidate.to_path_buf() - } else { - cwd.join(candidate) - }; - rel_under_root(project_root, &abs) - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Codex CLI hook handlers -// -// Codex sends ONE JSON object on stdin (shared fields: session_id, -// transcript_path, cwd, hook_event_name, model, plus event-specific fields) -// and reads a Codex-shaped JSON object from stdout. These handlers intentionally -// emit Codex's documented output schema (`hookSpecificOutput.additionalContext` -// for steering, `hookSpecificOutput.permissionDecision` for PreToolUse) rather -// than reusing the Claude / Cursor / Kiro output shapes. -// --------------------------------------------------------------------------- - -/// Codex `SessionStart` hook handler (fire-and-forget). -/// -/// Emits `hookSpecificOutput.additionalContext` steering the agent toward -/// tracedecay MCP tools and reporting index freshness for the session `cwd`. -pub async fn hook_codex_session_start() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Codex, "SessionStart", &event); - let (mut context, _) = codex_session_context_for_event(&event).await; - if session_start_from_compaction(&event) { - append_context_recovery_hint(&mut context); - } - println!( - "{}", - codex_additional_context_json("SessionStart", &context) - ); - 0 -} - -/// Codex `UserPromptSubmit` hook handler. -/// -/// Resets the per-project local counter for the new turn and injects the same -/// tracedecay steering context as `SessionStart`. Never blocks the prompt. -pub async fn hook_codex_user_prompt_submit() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked( - root.as_deref(), - HintAgent::Codex, - "UserPromptSubmit", - &event, - ); - reset_counter_for_codex_event(&event).await; - let context = codex_user_prompt_submit_context_for_event(&event).await; - println!( - "{}", - codex_additional_context_json("UserPromptSubmit", &context) - ); - 0 -} - -pub async fn codex_user_prompt_submit_context_for_event(event: &str) -> String { - let (mut context, status) = codex_session_context_for_event(event).await; - if !matches!(status, HookWorkspaceStatus::Generic) { - if let Some(hint) = codex_prompt_hint(event) { - append_tool_hint(&mut context, &hint); - } - } - context -} - -/// Codex `SubagentStart` hook handler. -/// -/// Steers research/explore subagents toward tracedecay MCP tools. Codex cannot -/// hard-stop a subagent at start (`continue: false` is ignored for this event), -/// so this injects `additionalContext` instead of denying. -pub fn hook_codex_subagent_start() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Codex, "SubagentStart", &event); - let count = record_codex_subagent_start(&event); - let output = evaluate_codex_subagent_start(&event); - eprintln!( - "{}", - codex_subagent_start_log_line(&event, count, output.is_some()) - ); - if let Some(output) = output { - println!("{output}"); - } - 0 -} - -/// Codex `PostToolUse` hook handler used to keep the graph fresh after writes. -/// -/// For edit tools and shell commands this notifies the daemon, which owns -/// targeted sync, branch tracking, and coalescing. Fail-open and silent. -pub async fn hook_codex_post_tool_use() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Codex, "PostToolUse", &event); - codex_post_tool_use(&event).await; - 0 -} - -/// Codex `PostCompact` hook handler. -/// -/// Codex stores compacted context bodies encrypted in the transcript. This hook -/// uses the visible source messages already ingested into the LCM store, asks a -/// child Codex app-server turn to summarize them, and replaces the temporary -/// deterministic summary node. Fail-open: compaction must never block Codex. -pub async fn hook_codex_post_compact() -> i32 { - let event = read_hook_event!(); - let root = codex_project_root_from_event(&event); - record_hook_invoked(root.as_deref(), HintAgent::Codex, "PostCompact", &event); - if std::env::var_os(crate::sessions::codex_app_server::CODEX_SUMMARY_CHILD_ENV).is_none() { - codex_post_compact(&event).await; - } - println!("{}", serde_json::json!({})); - 0 -} - -/// Builds a Codex hook stdout payload that injects model-visible context via -/// `hookSpecificOutput.additionalContext`. Used by `SessionStart`, -/// `UserPromptSubmit`, and `SubagentStart`. -pub fn codex_additional_context_json(event_name: &str, additional_context: &str) -> String { - serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": event_name, - "additionalContext": additional_context, - } - }) - .to_string() -} - -/// Pure decision logic for Codex `SubagentStart` events. -/// -/// Returns a Codex `additionalContext` payload steering research/explore -/// or new/no-history subagents toward tracedecay MCP tools and compact memory -/// recall, or `None` for execution-style subagents that already have history. -/// Inspects `agent_type` (Codex's documented field), any prompt/task/description -/// text, and conservative history/newness fields when present. -pub fn evaluate_codex_subagent_start(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - let agent_type = parsed - .get("agent_type") - .or_else(|| parsed.get("subagent_type")) - .and_then(Value::as_str) - .unwrap_or_default(); - let task = parsed - .get("prompt") - .or_else(|| parsed.get("task")) - .or_else(|| parsed.get("description")) - .and_then(Value::as_str) - .unwrap_or_default(); - - let hint = decide_hint(&ToolHintInput { - agent: HintAgent::Codex, - session_id: event_session_id(&parsed), - tool_name: Some("SubagentStart".to_string()), - command: None, - prompt: (!task.is_empty()).then(|| task.to_string()), - subagent_type: (!agent_type.is_empty()).then(|| agent_type.to_string()), - file_path: None, - hints_enabled: true, - }); - let is_explore = agent_type.eq_ignore_ascii_case("explore"); - let is_research = is_explore || is_code_research_prompt(task); - let needs_context = codex_subagent_needs_context(&parsed); - if is_research || needs_context { - let dedupe_hint = ToolHint { - category: if is_research { - HintCategory::ExploreSubagent - } else { - HintCategory::SubagentStartContext - }, - message: "For Codex subagents, add compact TraceDecay context before isolated work." - .to_string(), - context: CODEX_SUBAGENT_START_CONTEXT.to_string(), - nonblocking: true, - }; - let root = codex_project_root_from_event(event_json); - record_hint_analytics( - root.as_deref(), - "hint_candidate", - HintAgent::Codex, - event_session_id(&parsed).as_deref(), - &dedupe_hint, - ); - let _ = deduped_codex_hint(event_json, &parsed, dedupe_hint)?; - let context = codex_subagent_start_context(hint, needs_context); - return Some(codex_additional_context_json("SubagentStart", &context)); - } - None -} - -/// Records a Codex `SubagentStart` in the current project's profile-sharded -/// hook state and returns the session-local count. Fail-open: malformed events, -/// missing roots, and storage errors only disable counting. -pub fn record_codex_subagent_start(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - let root = codex_project_root_from_parsed_event(&parsed)?; - let layout = crate::storage::resolve_layout_for_current_profile(&root).ok()?; - let path = layout.data_root.join("codex_subagent_starts.json"); - let analytics_session_id = event_session_id(&parsed); - let session_id = analytics_session_id - .clone() - .unwrap_or_else(|| "unknown-codex-session".to_string()); - let mut counts = read_codex_subagent_start_counts(&path); - let count = counts.entry(session_id).or_insert(0); - *count = count.saturating_add(1); - let next = *count; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string_pretty(&counts) { - let _ = std::fs::write(path, format!("{json}\n")); - } - let agent_type = parsed - .get("agent_type") - .or_else(|| parsed.get("subagent_type")) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .unwrap_or("unknown"); - record_hook_analytics( - Some(&root), - "codex_subagent_start", - serde_json::json!({ - "agent": HintAgent::Codex.as_key(), - "session_id": analytics_session_id.as_deref(), - "agent_type": agent_type, - "count": next, - }), - ); - Some(next) -} - -pub fn codex_subagent_start_log_line( - event_json: &str, - count: Option, - emitted_context: bool, -) -> String { - let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); - let agent_type = parsed - .get("agent_type") - .or_else(|| parsed.get("subagent_type")) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .unwrap_or("unknown"); - let session_id = event_session_id(&parsed).unwrap_or_else(|| "unknown".to_string()); - let count = count.map_or_else(|| "#?".to_string(), |value| format!("#{value}")); - format!( - "tracedecay Codex SubagentStart {count}: session_id={session_id} agent_type={agent_type} additional_context={emitted_context}" - ) -} - -fn read_codex_subagent_start_counts(path: &Path) -> BTreeMap { - std::fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() -} - -fn codex_subagent_start_context(hint: Option, no_history: bool) -> String { - let mut context = String::new(); - if no_history { - context.push_str("new/no-history subagent: recover only relevant project memory or prior-session context before assuming missing decisions.\n"); - } - context.push_str(CODEX_SUBAGENT_START_CONTEXT); - context.push('\n'); - if let Some(hint) = hint { - context.push('\n'); - context.push_str(&format_tool_hint(&hint)); - context.push('\n'); - } - context -} - -fn codex_subagent_needs_context(parsed: &Value) -> bool { - bool_field( - parsed, - &["is_new", "new_subagent", "fresh_subagent", "no_history"], - ) == Some(true) - || bool_field( - parsed, - &[ - "has_history", - "history_included", - "receives_history", - "conversation_history_included", - ], - ) == Some(false) - || text_field( - parsed, - &[ - "history_mode", - "context_mode", - "conversation_history", - "source", - "reason", - ], - ) - .is_some_and(|value| matches_no_history_marker(&value)) - || empty_array_field(parsed, &["history", "messages", "conversation"]) -} - -fn bool_field(value: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| value.get(*key).and_then(Value::as_bool)) -} - -fn empty_array_field(value: &Value, keys: &[&str]) -> bool { - keys.iter().any(|key| { - value - .get(*key) - .and_then(Value::as_array) - .is_some_and(Vec::is_empty) - }) -} - -fn matches_no_history_marker(value: &str) -> bool { - let normalized = value - .chars() - .filter(char::is_ascii_alphanumeric) - .collect::() - .to_ascii_lowercase(); - matches!( - normalized.as_str(), - "new" | "fresh" | "none" | "empty" | "nohistory" | "withoutconversationhistory" - ) -} - -/// Resolves the tracedecay project root for a Codex event from its `cwd`. -pub fn codex_project_root_from_event(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - codex_project_root_from_parsed_event(&parsed) -} - -fn codex_project_root_from_parsed_event(parsed: &Value) -> Option { - let cwd = event_cwd_from_parsed(parsed)?; - crate::config::discover_project_root(&cwd) -} - -fn codex_workspace_status(root: Option<&Path>, cwd: Option<&Path>) -> HookWorkspaceStatus { - if root.is_some() { - return HookWorkspaceStatus::Initialized; - } - if cwd.is_some_and(is_project_like_workspace) { - HookWorkspaceStatus::UnindexedProject - } else { - HookWorkspaceStatus::Generic - } -} - -pub fn codex_workspace_status_from_event(event_json: &str) -> HookWorkspaceStatus { - let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); - let root = codex_project_root_from_parsed_event(&parsed); - let cwd = event_cwd_from_parsed(&parsed); - codex_workspace_status(root.as_deref(), cwd.as_deref()) -} - -fn is_project_like_workspace(cwd: &Path) -> bool { - nearest_project_like_root(cwd).is_some() -} - -fn project_marker_exists(dir: &Path) -> bool { - const MARKERS: &[&str] = &[ - ".git", - "Cargo.toml", - "package.json", - "pyproject.toml", - "go.mod", - "pom.xml", - "build.gradle", - "build.gradle.kts", - "deno.json", - "tsconfig.json", - ]; - MARKERS.iter().any(|marker| dir.join(marker).exists()) -} - -/// Extracts the project-relative paths touched by a Codex `apply_patch` command. -/// -/// Codex sends the patch text as `tool_input.command`. The `apply_patch` envelope -/// names each file with `*** Add File:`, `*** Update File:`, `*** Delete File:`, -/// or `*** Move to:` lines. Patch paths are relative to the session `cwd` -/// (which may be a subdirectory of the discovered project root), so we resolve -/// each against `cwd` and then make it relative to `project_root`. Absolute -/// paths outside the root are skipped. The result feeds the daemon's targeted -/// single-file sync event. -pub fn codex_apply_patch_rel_paths(command: &str, cwd: &Path, project_root: &Path) -> Vec { - const PREFIXES: [&str; 4] = [ - "*** Add File:", - "*** Update File:", - "*** Delete File:", - "*** Move to:", - ]; - let mut rels: Vec = Vec::new(); - for line in command.lines() { - let line = line.trim(); - for prefix in PREFIXES { - if let Some(rest) = line.strip_prefix(prefix) { - let raw = rest.trim(); - if raw.is_empty() { - continue; - } - let candidate = Path::new(raw); - let abs = if candidate.is_absolute() { - candidate.to_path_buf() - } else { - cwd.join(candidate) - }; - if let Some(rel) = rel_under_root(project_root, &abs) { - if !rels.contains(&rel) { - rels.push(rel); - } - } - } - } - } - rels -} - -fn is_codex_edit_tool(tool_name: &str) -> bool { - matches!( - tool_name.to_ascii_lowercase().as_str(), - "apply_patch" | "edit" | "write" - ) -} - -fn is_codex_bash_tool(tool_name: &str) -> bool { - matches!(tool_name.to_ascii_lowercase().as_str(), "bash" | "shell") -} - -async fn codex_post_tool_use(event_json: &str) { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return; - }; - let tool_name = parsed - .get("tool_name") - .and_then(Value::as_str) - .unwrap_or_default(); - let command = parsed - .get("tool_input") - .and_then(|ti| ti.get("command")) - .and_then(Value::as_str) - .unwrap_or_default(); - - let Some(cwd) = event_cwd(event_json) else { - return; - }; - let Some(root) = crate::config::discover_project_root(&cwd) - .or_else(|| crate::worktree::git_worktree_root(&cwd)) - else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { - return; - } - - if is_codex_edit_tool(tool_name) { - let rels = codex_apply_patch_rel_paths(command, &cwd, &root); - if rels.is_empty() { - return; - } - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::post_tool_use_edit( - crate::daemon::HookAgent::Codex, - rels, - cwd, - ), - ) - .await; - } else if is_codex_bash_tool(tool_name) { - crate::daemon::notify_hook_event( - &root, - crate::daemon::DaemonHookEvent::post_tool_use_shell( - crate::daemon::HookAgent::Codex, - command.to_string(), - cwd, - ), - ) - .await; - } -} - -const CODEX_POST_COMPACT_BUDGET: Duration = Duration::from_secs(115); - -async fn codex_post_compact(event_json: &str) { - let work = async { - let Some(project_root) = codex_project_root_from_event(event_json) else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&project_root).await { - return; - } - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return; - }; - if let Some(source) = crate::sessions::codex::CodexSource::new() { - let _ = crate::sessions::source::ingest_source(&db, &source, &project_root, None).await; - } - let session_id = serde_json::from_str::(event_json) - .ok() - .and_then(|parsed| event_session_id(&parsed)); - let Ok(mut pending) = db - .pending_codex_compaction_summary_requests(session_id.as_deref(), 1) - .await - else { - return; - }; - let Some(pending) = pending.pop() else { - return; - }; - let config = crate::sessions::codex_app_server::CodexAppServerSummaryConfig::from_env(); - let summary = match crate::sessions::codex_app_server::summarize_with_codex_app_server( - &pending.request, - &config, - ) { - Ok(summary) => summary, - Err(err) => { - eprintln!("tracedecay Codex PostCompact summary failed: {err}"); - return; - } - }; - if let Err(err) = db - .replace_codex_compaction_summary( - &pending.node_id, - &summary.text, - "codex_app_server", - summary.model.as_deref().or(config.model.as_deref()), - ) - .await - { - eprintln!("tracedecay Codex PostCompact summary replacement failed: {err}"); - } - }; - let _ = tokio::time::timeout(CODEX_POST_COMPACT_BUDGET, work).await; -} - -async fn reset_counter_for_codex_event(event_json: &str) { - let Some(project_root) = codex_project_root_from_event(event_json) else { - return; - }; - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { - let _ = cg.reset_local_counter().await; - } -} - -/// Pure decision logic for Kiro `preToolUse` hook events. -/// -/// Returns a block reason only for Kiro delegation/subagent tool calls whose -/// task text looks like codebase research that tracedecay MCP tools should -/// answer first. -pub fn evaluate_kiro_pre_tool_use(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - let tool_name = parsed.get("tool_name").and_then(Value::as_str)?; - if !is_kiro_delegation_tool(tool_name) { - return None; - } - - let tool_input = parsed.get("tool_input").unwrap_or(&Value::Null); - if let Some(prompt) = kiro_event_text(tool_input).filter(|text| is_code_research_prompt(text)) { - let hint = decide_hint(&ToolHintInput { - agent: HintAgent::Kiro, - session_id: event_session_id(&parsed), - tool_name: Some(tool_name.to_string()), - command: None, - prompt: Some(prompt), - subagent_type: Some(tool_name.to_string()), - file_path: None, - hints_enabled: true, - }); - Some(research_block_reason(hint)) - } else { - None - } -} - -fn is_kiro_delegation_tool(tool_name: &str) -> bool { - matches!(tool_name, "delegate" | "subagent" | "use_subagent") -} - -fn kiro_event_text(value: &Value) -> Option { - let mut text = Vec::new(); - collect_kiro_task_strings(value, &mut text); - if text.is_empty() { - collect_strings(value, &mut text); - } - (!text.is_empty()).then(|| text.join("\n")) -} - -fn collect_kiro_task_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { - match value { - Value::Object(map) => { - for (key, child) in map { - let key = key.to_ascii_lowercase(); - if key.contains("prompt") - || key.contains("task") - || key.contains("query") - || key.contains("instruction") - || key.contains("message") - || key.contains("description") - { - collect_strings(child, out); - } else { - collect_kiro_task_strings(child, out); - } - } - } - Value::Array(items) => { - for item in items { - collect_kiro_task_strings(item, out); - } - } - Value::String(s) => out.push(s), - _ => {} - } -} - -fn collect_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { - match value { - Value::String(s) => out.push(s), - Value::Array(items) => { - for item in items { - collect_strings(item, out); - } - } - Value::Object(map) => { - for child in map.values() { - collect_strings(child, out); - } - } - _ => {} - } -} - -/// `UserPromptSubmit` hook handler: resets the per-session local counter. -/// -/// Token savings are now reported inline in each MCP tool response, -/// so this hook only needs to reset the counter for the new turn. -pub async fn hook_prompt_submit() { - let project_path = crate::config::resolve_path(None); - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_path).await { - let _ = cg.reset_local_counter().await; - } -} - -/// Kiro `userPromptSubmit` hook handler. -/// -/// Kiro adds successful hook stdout to context, so this handler stays silent. -/// Resets the per-turn counter and runs a bounded catch-up ingest of Kiro IDE -/// transcripts for the resolved workspace. -pub async fn hook_kiro_prompt_submit() -> i32 { - let event = read_hook_event!(); - record_hook_invoked(None, HintAgent::Kiro, "userPromptSubmit", &event); - reset_counter_for_kiro_event(&event).await; - ingest_kiro_transcript_for_event( - &event, - Some(KIRO_HOT_INGEST_MAX_BYTES), - KIRO_HOT_INGEST_BUDGET, - ) - .await; - 0 -} - -/// Kiro `postToolUse` hook handler used to keep the graph fresh after writes. -/// -/// The installed Kiro agent maps this to `fs_write`. The hook discovers the -/// nearest tracedecay project from Kiro's `cwd` field and notifies the daemon, -/// which owns silent incremental sync scheduling. Missing daemon/index state is -/// fail-open. -pub async fn hook_kiro_post_tool_use() -> i32 { - let event = read_hook_event!(); - record_hook_invoked(None, HintAgent::Kiro, "postToolUse", &event); - notify_kiro_post_tool_use(&event).await; - 0 -} - -async fn reset_counter_for_kiro_event(event_json: &str) { - let Some(project_root) = kiro_project_root(event_json) else { - return; - }; - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { - let _ = cg.reset_local_counter().await; - } -} - -/// Largest transcript tail the Kiro `userPromptSubmit` hook will read per call. -const KIRO_HOT_INGEST_MAX_BYTES: u64 = 256 * 1024; -/// Wall-clock budget for the Kiro prompt-submit catch-up ingest. -const KIRO_HOT_INGEST_BUDGET: std::time::Duration = std::time::Duration::from_millis(1_500); - -/// Incrementally ingests Kiro IDE transcripts for the workspace referenced by -/// `event_json`. Always fails open. -async fn ingest_kiro_transcript_for_event( - event_json: &str, - max_new_bytes: Option, - budget: std::time::Duration, -) { - let work = async { - let Some(project_root) = kiro_project_root(event_json) else { - return; - }; - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return; - }; - let _ = - crate::sessions::kiro::ingest_kiro_for_project(&db, &project_root, max_new_bytes).await; - }; - let _ = tokio::time::timeout(budget, work).await; -} - -async fn reset_counter_for_cursor_event(event_json: &str) { - let Some(project_root) = cursor_project_root_from_event(event_json) else { - return; - }; - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { - let _ = cg.reset_local_counter().await; - } -} - -/// Largest tail the `beforeSubmitPrompt` hot path will read in one call. Larger -/// backlogs are left for the `sessionStart` / `stop` catch-up ingests. -const CURSOR_HOT_INGEST_MAX_BYTES: u64 = 256 * 1024; -/// Largest transcript tail a low-priority Cursor catch-up hook will read. -/// Oversized backlogs stay queued instead of blocking hook execution. Public -/// so ingest-health reporting (`tracedecay_status`, doctor) can flag a backlog -/// the hooks will never drain on their own. -pub const CURSOR_CATCH_UP_INGEST_MAX_BYTES: u64 = 2 * 1024 * 1024; -/// Hard wall-clock budget for the `beforeSubmitPrompt` tail ingest. Well under -/// Cursor's 5s hook timeout; on expiry we fail open and let heavier hooks catch up. -const CURSOR_HOT_INGEST_BUDGET: Duration = Duration::from_millis(1_500); -/// Budget for the `sessionStart` catch-up ingest (registered with a 5s timeout). -const CURSOR_SESSION_INGEST_BUDGET: Duration = Duration::from_secs(4); -/// Budget for the end-of-turn `stop` catch-up ingest (registered with a 30s timeout). -const CURSOR_STOP_INGEST_BUDGET: Duration = Duration::from_secs(25); -/// Budget for the transcript catch-up portion of the `preCompact` hook. -const CURSOR_PRE_COMPACT_INGEST_BUDGET: Duration = Duration::from_secs(30); -/// Budget for the auxiliary `cursor-agent` summary call inside the hook. Kept -/// below the registered Cursor hook timeout so the child can be killed/reaped -/// by `TraceDecay` rather than by Cursor killing the hook process. Sized so -/// the ingest budget plus this cap stay below the overall preCompact budget, -/// leaving slack for LCM prepare/persist and process overhead. -const CURSOR_PRE_COMPACT_SUMMARY_BUDGET: Duration = Duration::from_secs(75); -/// Overall budget for the `preCompact` hook (registered with a 120s timeout). -const CURSOR_PRE_COMPACT_BUDGET: Duration = Duration::from_secs(115); -const COMPACTION_CONTEXT_RECOVERY_HINT: &str = "Context was just compacted. If important prior-session context seems missing, query TraceDecay session context before assuming the compacted summary is complete. Start with `tracedecay_message_search` or `tracedecay_lcm_expand_query`; use `tracedecay_lcm_describe` and `tracedecay_lcm_expand` when you need the summary DAG sources."; - -fn cursor_pre_compact_lcm_request( - session_id: &str, - current_tokens: Option, - context_length: Option, - max_source_messages: Option, - fresh_tail_count: Option, - summarizer: crate::sessions::lcm::LcmSummarizerMode, - expected_current_frontier_store_id: Option, -) -> crate::sessions::lcm::LcmCompressionRequest { - crate::sessions::lcm::LcmCompressionRequest { - provider: "cursor".to_string(), - session_id: session_id.to_string(), - messages: Vec::new(), - current_tokens, - focus_topic: Some("Cursor context compaction".to_string()), - ignore_session_patterns: Vec::new(), - stateless_session_patterns: Vec::new(), - ignore_message_patterns: Vec::new(), - expected_current_frontier_store_id, - threshold_tokens: None, - max_assembly_tokens: None, - leaf_chunk_tokens: None, - max_source_messages, - summary_fan_in: None, - incremental_max_depth: None, - fresh_tail_count, - dynamic_leaf_chunk_enabled: None, - dynamic_leaf_chunk_max: None, - context_length, - reserve_tokens_floor: None, - summarizer, - } -} - -fn cursor_pre_compact_fresh_tail_count( - parsed: &Value, - messages_to_compact: Option, -) -> Option { - let message_count = event_usize(parsed, &["message_count", "messages_count"])?; - let messages_to_compact = messages_to_compact?; - Some(message_count.saturating_sub(messages_to_compact)) -} - -fn event_i64(parsed: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - let value = parsed.get(*key)?; - value - .as_i64() - .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) - .or_else(|| value.as_str()?.parse::().ok()) - }) -} - -fn event_usize(parsed: &Value, keys: &[&str]) -> Option { - event_i64(parsed, keys).and_then(|value| usize::try_from(value).ok()) -} - -fn cursor_event_transcript_path_exists(parsed: &Value) -> bool { - parsed - .get("transcript_path") - .and_then(Value::as_str) - .filter(|path| !path.is_empty()) - .is_some_and(|path| Path::new(path).exists()) -} - -/// Incrementally ingests the Cursor transcript referenced by `event_json` into -/// the resolved project session DB, bounded by `max_new_bytes` (the hot-path cap) -/// and an overall `budget`. Always fails open: a timeout, missing transcript, or -/// any error is swallowed so the calling hook never blocks the agent. -async fn ingest_cursor_transcript_for_event( - event_json: &str, - max_new_bytes: Option, - budget: Duration, -) -> bool { - let work = async { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return false; - }; - let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { - return false; - }; - if let Some(cwd_root) = cursor_event_cwd(&parsed) - .as_deref() - .and_then(crate::config::discover_project_root) - { - if !paths_same(&cwd_root, &project_root) { - return false; - } - } - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return false; - }; - let _ = crate::sessions::cursor::ingest_cursor_transcript_event_capped( - event_json, - &db, - max_new_bytes, - ) - .await; - true - }; - // Short-lived CLI hook processes exit immediately, so the ingest must run - // inline (not on a detached task); the timeout keeps it inside budget. - tokio::time::timeout(budget, work).await.unwrap_or(false) -} - -async fn notify_kiro_post_tool_use(event_json: &str) { - let Some(project_root) = kiro_project_root(event_json) else { - return; - }; - if !crate::tracedecay::TraceDecay::has_initialized_store(&project_root).await { - return; - } - let rel_paths = kiro_post_tool_use_rel_paths(event_json, &project_root); - crate::daemon::notify_hook_event( - &project_root, - crate::daemon::DaemonHookEvent::kiro_post_tool_use(rel_paths, event_cwd(event_json)), - ) - .await; -} - -pub fn kiro_post_tool_use_rel_paths(event_json: &str, project_root: &Path) -> Vec { - let Ok(parsed) = serde_json::from_str::(event_json) else { - return Vec::new(); - }; - let cwd = event_cwd_from_parsed(&parsed).unwrap_or_else(|| project_root.to_path_buf()); - let tool_input = parsed - .get("tool_input") - .or_else(|| parsed.get("toolInput")) - .or_else(|| parsed.get("input")) - .unwrap_or(&Value::Null); - - let mut paths = Vec::new(); - collect_event_path_fields(&parsed, &mut paths); - collect_event_path_fields(tool_input, &mut paths); - - let mut rels = Vec::new(); - for path in paths { - let path = Path::new(&path); - let abs = if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - }; - if let Some(rel) = rel_under_root(project_root, &abs) { - if !rels.contains(&rel) { - rels.push(rel); - } - } - } - rels -} - -fn collect_event_path_fields(value: &Value, out: &mut Vec) { - for key in ["file_path", "filePath", "path", "target_file", "targetFile"] { - match value.get(key) { - Some(Value::String(path)) if !path.is_empty() => out.push(path.clone()), - Some(Value::Array(paths)) => { - out.extend( - paths - .iter() - .filter_map(Value::as_str) - .filter(|path| !path.is_empty()) - .map(str::to_string), - ); - } - _ => {} - } - } -} - -fn cursor_tool_hint_input(parsed: &Value) -> ToolHintInput { - let tool_input = parsed - .get("tool_input") - .or_else(|| parsed.get("toolInput")) - .or_else(|| parsed.get("input")) - .unwrap_or(&Value::Null); - ToolHintInput { - agent: HintAgent::Cursor, - session_id: event_session_id(parsed), - tool_name: text_field(parsed, &["tool_name", "toolName", "name"]), - command: text_field(tool_input, &["command", "cmd"]) - .or_else(|| text_field(parsed, &["command", "cmd"])), - prompt: text_field( - tool_input, - &["prompt", "query", "pattern", "task", "description"], - ) - .or_else(|| { - text_field( - parsed, - &["prompt", "query", "pattern", "task", "description"], - ) - }), - subagent_type: text_field(parsed, &["subagent_type", "subagentType", "agent_type"]), - file_path: text_field(tool_input, &["file_path", "filePath", "path"]) - .or_else(|| text_field(parsed, &["file_path", "filePath", "path"])), - hints_enabled: true, - } -} - -fn codex_prompt_hint(event_json: &str) -> Option { - let parsed = serde_json::from_str::(event_json).ok()?; - let hint = decide_hint(&ToolHintInput { - agent: HintAgent::Codex, - session_id: event_session_id(&parsed), - tool_name: None, - command: None, - prompt: prompt_like_text(&parsed), - subagent_type: None, - file_path: None, - hints_enabled: true, - })?; - let root = codex_project_root_from_event(event_json); - record_hint_analytics( - root.as_deref(), - "hint_candidate", - HintAgent::Codex, - event_session_id(&parsed).as_deref(), - &hint, - ); - deduped_codex_hint(event_json, &parsed, hint) -} - -fn text_field(value: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| value.get(*key).and_then(Value::as_str)) - .filter(|text| !text.is_empty()) - .map(str::to_string) -} - -fn prompt_like_text(parsed: &Value) -> Option { - [ - "prompt", - "user_prompt", - "message", - "input", - "task", - "description", - ] - .iter() - .find_map(|key| parsed.get(*key).and_then(Value::as_str)) - .filter(|text| !text.is_empty()) - .map(str::to_string) -} - -fn event_session_id(parsed: &Value) -> Option { - ["session_id", "conversation_id", "chat_id"] - .iter() - .find_map(|key| parsed.get(*key).and_then(Value::as_str)) - .filter(|id| !id.is_empty()) - .map(str::to_string) -} - -fn format_tool_hint(hint: &ToolHint) -> String { - format!("tracedecay hint: {}\n{}", hint.message, hint.context) -} - -fn append_tool_hint(context: &mut String, hint: &ToolHint) { - if !context.ends_with('\n') { - context.push('\n'); - } - context.push_str(&format_tool_hint(hint)); - context.push('\n'); -} - -fn kiro_project_root(event_json: &str) -> Option { - let cwd = event_cwd(event_json).or_else(|| std::env::current_dir().ok())?; - crate::config::discover_project_root(&cwd) -} - -/// Reads the `cwd` string field from a hook event JSON payload. Shared by the -/// Kiro and Codex handlers, both of which send the session working directory. -fn event_cwd(event_json: &str) -> Option { - let parsed: Value = serde_json::from_str(event_json).ok()?; - event_cwd_from_parsed(&parsed) -} - -fn event_cwd_from_parsed(parsed: &Value) -> Option { - let cwd = parsed.get("cwd").and_then(Value::as_str)?; - let path = Path::new(cwd); - if path.as_os_str().is_empty() { - None - } else { - Some(path.to_path_buf()) - } -} - -fn read_stdin_to_string() -> std::io::Result { - let mut input = String::new(); - std::io::stdin().read_to_string(&mut input)?; - Ok(input) -} - -/// `Stop` hook handler: ingests new session data and prints a cost receipt. -/// -/// Parses any new JSONL lines from Claude Code sessions, inserts them into -/// the global DB, and prints a one-line summary to stderr showing the -/// session cost, tokens saved, and efficiency ratio. -pub async fn hook_stop() { - let Some(gdb) = crate::global_db::GlobalDb::open().await else { - return; - }; - - let stats = crate::accounting::parser::ingest(&gdb).await; - if stats.turns_inserted == 0 { - return; - } - - // Read tokens saved for efficiency calculation - let project_path = crate::config::resolve_path(None); - let tokens_saved = if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_path).await { - cg.get_tokens_saved().await.unwrap_or(0) - } else { - 0 - }; - - let efficiency = if tokens_saved + stats.tokens_consumed > 0 { - (tokens_saved as f64 / (tokens_saved + stats.tokens_consumed) as f64) * 100.0 - } else { - 0.0 - }; - - let saved_str = crate::display::format_token_count(tokens_saved); - - // Print to stderr so it appears in the terminal but doesn't interfere - // with stdout (which Claude Code may parse). - if stats.cost_usd >= 0.001 { - eprintln!( - "\x1b[36mSession: ${:.2} spent | {saved_str} saved | {efficiency:.0}% efficiency\x1b[0m", - stats.cost_usd - ); - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use crate::config::USER_DATA_DIR_ENV; - use std::sync::{Mutex, OnceLock}; - - struct EnvGuard { - key: &'static str, - previous: Option, - } - - impl EnvGuard { - fn set_path(key: &'static str, value: &Path) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - unsafe { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - - fn env_lock() -> &'static Mutex<()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK.get_or_init(|| Mutex::new(())) - } - - #[test] - fn shell_words_preserves_unquoted_windows_paths() { - assert_eq!( - shell_words_for_platform(r"git --work-tree=C:\Users\me\repo pull", true), - vec!["git", r"--work-tree=C:\Users\me\repo", "pull"] - ); - assert_eq!( - shell_words_for_platform(r"git --work-tree=C:\Users\me\repo pull", false), - vec!["git", r"--work-tree=C:Usersmerepo", "pull"] - ); - } - - #[test] - fn git_work_tree_overrides_prior_c_directory() { - let temp = tempfile::tempdir().unwrap(); - let project = temp.path().join("repo"); - let outside = temp.path().join("outside"); - std::fs::create_dir_all(project.join(".git")).unwrap(); - std::fs::create_dir_all(&outside).unwrap(); - - let command = format!( - "git -C {} --git-dir={}/.git --work-tree={} pull", - outside.display(), - project.display(), - project.display() - ); - - assert!(cursor_shell_command_targets_project( - &command, &outside, &project - )); - } - - #[test] - fn codex_prompt_hints_dedupe_by_session_and_category() { - let _lock = env_lock().lock().unwrap(); - let project = tempfile::tempdir().unwrap(); - let profile = tempfile::tempdir().unwrap(); - let project_root = project.path().canonicalize().unwrap(); - let profile_root = profile.path().canonicalize().unwrap(); - let _profile_env = EnvGuard::set_path(USER_DATA_DIR_ENV, &profile_root); - crate::storage::write_enrollment_marker( - &project_root, - &crate::storage::EnrollmentMarker { - project_id: "proj_hook_codex_prompt".to_string(), - storage_mode: crate::storage::StorageMode::ProfileSharded, - }, - ) - .unwrap(); - let layout = crate::storage::resolve_layout_for_current_profile(&project_root).unwrap(); - std::fs::create_dir_all(&layout.data_root).unwrap(); - let event = serde_json::json!({ - "session_id": "codex-session-1", - "cwd": project_root, - "prompt": "Please explain the impact of changing parse_user" - }) - .to_string(); - - let first = codex_prompt_hint(&event).unwrap(); - assert_eq!(first.category, tool_hints::HintCategory::Impact); - - assert!( - codex_prompt_hint(&event).is_none(), - "Codex should use shared per-session hint dedupe for prompt hints" - ); - } - - #[test] - fn compact_session_start_events_get_recovery_hint() { - let event = serde_json::json!({ "source": "compact" }).to_string(); - assert!(session_start_from_compaction(&event)); - - let mut context = build_codex_session_context(true, None); - append_context_recovery_hint(&mut context); - assert!(context.contains("Context was just compacted")); - assert!(context.contains("tracedecay_lcm_expand_query")); - assert!(context.contains("tracedecay_lcm_describe")); - } - - #[test] - fn non_compact_session_start_events_do_not_get_recovery_hint() { - let event = serde_json::json!({ "source": "resume" }).to_string(); - assert!(!session_start_from_compaction(&event)); - } - - #[test] - fn claude_edit_tools_are_recognized_case_insensitively() { - for tool in ["Edit", "Write", "MultiEdit", "NotebookEdit", "write"] { - assert!(is_claude_edit_tool(tool), "{tool} should count as an edit"); - } - assert!(!is_claude_edit_tool("Bash")); - assert!(!is_claude_edit_tool("Read")); - assert!(is_claude_bash_tool("Bash")); - assert!(!is_claude_bash_tool("Edit")); - } - - #[test] - fn claude_edit_rel_paths_resolves_file_path_against_project_root() { - let root = Path::new("/repo"); - let cwd = Path::new("/repo/sub"); - let event = serde_json::json!({ - "tool_name": "Edit", - "tool_input": { "file_path": "/repo/src/lib.rs" } - }); - assert_eq!( - claude_edit_rel_paths(&event, cwd, root), - vec!["src/lib.rs".to_string()] - ); - - // Relative paths resolve against the session cwd. - let event = serde_json::json!({ - "tool_name": "Write", - "tool_input": { "file_path": "module.rs" } - }); - assert_eq!( - claude_edit_rel_paths(&event, cwd, root), - vec!["sub/module.rs".to_string()] - ); - - // NotebookEdit uses notebook_path. - let event = serde_json::json!({ - "tool_name": "NotebookEdit", - "tool_input": { "notebook_path": "/repo/analysis.ipynb" } - }); - assert_eq!( - claude_edit_rel_paths(&event, cwd, root), - vec!["analysis.ipynb".to_string()] - ); - - // Paths outside the project root are skipped. - let event = serde_json::json!({ - "tool_name": "Edit", - "tool_input": { "file_path": "/elsewhere/other.rs" } - }); - assert!(claude_edit_rel_paths(&event, cwd, root).is_empty()); - } - - #[test] - fn index_status_line_formats_freshness_and_init_nudge() { - assert_eq!( - index_status_line(true, Some("last indexed 5m ago")), - "tracedecay index status: last indexed 5m ago.\n" - ); - assert_eq!( - index_status_line(true, None), - "tracedecay index status: initialized.\n" - ); - assert!(index_status_line(false, None).contains("run `tracedecay init`")); - } -} diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs new file mode 100644 index 00000000..2ef993a4 --- /dev/null +++ b/src/hooks/claude.rs @@ -0,0 +1,201 @@ +//! Claude Code hook handlers. +//! +//! Claude and Codex share the common hook JSON shape, while older Claude +//! handlers keep their legacy input/output contracts. + +use serde_json::Value; + +use super::codex::{ + codex_additional_context_json, codex_project_root_from_event, + codex_project_root_from_parsed_event, +}; +use super::post_tool_use::{notify_post_tool_use, CLAUDE_POST_TOOL_USE_SPEC}; +use super::steering::{ + append_context_recovery_hint, cursor_index_signals_for_root, index_status_line, + session_start_from_compaction, +}; +use super::tool_hints::{decide_hint, HintAgent, ToolHintInput}; +use super::{ + event_cwd_from_parsed, event_session_id, is_project_like_workspace, prompt_like_text, + read_hook_event, record_hook_invoked, research_block_reason, +}; + +/// `PreToolUse` hook handler for Claude Code's Agent tool matcher. +/// +/// Blocks Explore agents and exploration-style prompts, directing Claude to +/// use tracedecay MCP tools instead. +pub fn hook_pre_tool_use() { + let tool_input = std::env::var("TOOL_INPUT").unwrap_or_default(); + record_hook_invoked(None, HintAgent::Claude, "preToolUse", &tool_input); + let decision = evaluate_hook_decision(&tool_input); + if !decision.is_empty() { + println!("{decision}"); + } +} + +/// Pure decision logic for the `PreToolUse` hook. +/// +/// Returns the JSON decision for Claude to print to stdout. +pub fn evaluate_hook_decision(tool_input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(tool_input).unwrap_or_else(|_| serde_json::json!({})); + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Claude, + session_id: event_session_id(&parsed), + tool_name: Some("Agent".to_string()), + command: None, + prompt: prompt_like_text(&parsed), + subagent_type: parsed + .get("subagent_type") + .and_then(Value::as_str) + .map(str::to_string), + file_path: None, + hints_enabled: true, + }); + let block_reason = research_block_reason(hint); + let block_msg = || { + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": block_reason + } + }) + }; + + if parsed.get("subagent_type").and_then(|v| v.as_str()) == Some("Explore") { + return block_msg().to_string(); + } + + if let Some(prompt) = parsed.get("prompt").and_then(|v| v.as_str()) { + if is_code_research_prompt(prompt) { + return block_msg().to_string(); + } + } + + String::new() +} + +pub(super) fn is_code_research_prompt(prompt: &str) -> bool { + let lower = prompt.to_ascii_lowercase(); + let exploration_patterns = [ + "explore", + "codebase structure", + "codebase architecture", + "codebase overview", + "source files contents", + "read every", + "full contents", + "entire codebase", + "architecture and structure", + "call graph", + "call path", + "call chain", + "symbol relat", + "symbol lookup", + "who calls", + "callers of", + "callees of", + ]; + exploration_patterns.iter().any(|pat| lower.contains(pat)) +} + +/// Claude Code `SessionStart` hook handler (fail-open). +/// +/// Emits session-specific index freshness and compaction recovery context. +pub async fn hook_claude_session_start() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Claude, "SessionStart", &event); + let mut context = claude_session_context_for_event(&event).await; + if session_start_from_compaction(&event) { + append_context_recovery_hint(&mut context); + } + if context.is_empty() { + println!("{}", serde_json::json!({})); + } else { + println!( + "{}", + codex_additional_context_json("SessionStart", &context) + ); + } + 0 +} + +/// Builds the lean Claude `SessionStart` context for code workspaces. +async fn claude_session_context_for_event(event_json: &str) -> String { + let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); + match codex_project_root_from_parsed_event(&parsed) { + Some(root) => { + let (staleness, _) = cursor_index_signals_for_root(&root).await; + index_status_line(true, staleness.as_deref()) + } + None if event_cwd_from_parsed(&parsed) + .as_deref() + .is_some_and(is_project_like_workspace) => + { + index_status_line(false, None) + } + None => String::new(), + } +} + +/// Claude Code `PostToolUse` hook handler used to keep the graph fresh after +/// writes. +/// +/// Notifies the daemon, which owns targeted sync, branch tracking, and +/// coalescing. Fail-open and silent. +pub async fn hook_claude_post_tool_use() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Claude, "PostToolUse", &event); + notify_post_tool_use(&CLAUDE_POST_TOOL_USE_SPEC, &event).await; + 0 +} + +/// `UserPromptSubmit` hook handler: resets the per-session local counter. +/// +/// Token savings are now reported inline in each MCP tool response, +/// so this hook only needs to reset the counter for the new turn. +pub async fn hook_prompt_submit() { + let project_path = crate::config::resolve_path(None); + if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_path).await { + let _ = cg.reset_local_counter().await; + } +} + +/// `Stop` hook handler: ingests new session data and prints a cost receipt. +/// +/// Ingests new Claude Code session lines and prints a one-line cost receipt. +pub async fn hook_stop() { + let Some(gdb) = crate::global_db::GlobalDb::open().await else { + return; + }; + + let stats = crate::accounting::parser::ingest(&gdb).await; + if stats.turns_inserted == 0 { + return; + } + + let project_path = crate::config::resolve_path(None); + let tokens_saved = if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_path).await { + cg.get_tokens_saved().await.unwrap_or(0) + } else { + 0 + }; + + let efficiency = if tokens_saved + stats.tokens_consumed > 0 { + (tokens_saved as f64 / (tokens_saved + stats.tokens_consumed) as f64) * 100.0 + } else { + 0.0 + }; + + let saved_str = crate::display::format_token_count(tokens_saved); + + if stats.cost_usd >= 0.001 { + eprintln!( + "\x1b[36mSession: ${:.2} spent | {saved_str} saved | {efficiency:.0}% efficiency\x1b[0m", + stats.cost_usd + ); + } +} diff --git a/src/hooks/codex.rs b/src/hooks/codex.rs new file mode 100644 index 00000000..0d747941 --- /dev/null +++ b/src/hooks/codex.rs @@ -0,0 +1,605 @@ +//! Codex CLI hook handlers. +//! +//! Codex emits its documented hook output shape instead of reusing the Claude, +//! Cursor, or Kiro contracts. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::Value; + +use super::claude::is_code_research_prompt; +use super::post_tool_use::{notify_post_tool_use, CODEX_POST_TOOL_USE_SPEC}; +use super::steering::{ + append_context_recovery_hint, build_codex_session_context_for_workspace, + cursor_index_signals_for_root, session_start_from_compaction, HookWorkspaceStatus, +}; +use super::tool_hints::{decide_hint, HintAgent, HintCategory, ToolHint, ToolHintInput}; +use super::{ + append_tool_hint, deduped_project_hint, event_cwd_from_parsed, event_session_id, + format_tool_hint, is_project_like_workspace, prompt_like_text, read_hook_event, + record_hint_analytics, record_hook_analytics, record_hook_invoked, + record_workspace_status_analytics, rel_under_root, text_field, +}; + +const CODEX_SUBAGENT_START_CONTEXT: &str = "tracedecay subagent context: this looks like a \ +new/no-history subagent or code-research subagent. Use tracedecay MCP tools and the relevant TraceDecay skill/tool \ +workflow before broad file reads: `tracedecay:searching-for-code` with `tracedecay_context` \ +for code exploration, `tracedecay:reading-code-cheaply` with `tracedecay_outline` or \ +`tracedecay_body` before whole-file reads, `tracedecay:tracing-functions` with \ +`tracedecay_find_exact_symbol`, `tracedecay_callers`, and `tracedecay_callees` when \ +asked to trace functions, find callers, or inspect setup/helper/fixture dependencies, \ +`tracedecay:finding-impacted-areas` with `tracedecay_affected` and \ +`tracedecay_test_map` before guessing affected tests, `tracedecay:recalling-project-memory` when \ +project decisions/preferences matter, and `tracedecay:recalling-session-context` with \ +`tracedecay_message_search`, `tracedecay_lcm_expand_query`, and `tracedecay_lcm_describe` \ +when prior conversation context may be missing."; + +const CODEX_POST_COMPACT_BUDGET: Duration = Duration::from_secs(115); + +/// Codex `SessionStart` hook handler (fire-and-forget). +/// +/// Emits tracedecay steering and index freshness for the session `cwd`. +pub async fn hook_codex_session_start() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Codex, "SessionStart", &event); + let (mut context, _) = codex_session_context_for_event(&event).await; + if session_start_from_compaction(&event) { + append_context_recovery_hint(&mut context); + } + println!( + "{}", + codex_additional_context_json("SessionStart", &context) + ); + 0 +} + +/// Codex `UserPromptSubmit` hook handler. +/// +/// Resets the per-project local counter for the new turn and injects the same +/// tracedecay steering context as `SessionStart`. Never blocks the prompt. +pub async fn hook_codex_user_prompt_submit() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked( + root.as_deref(), + HintAgent::Codex, + "UserPromptSubmit", + &event, + ); + reset_counter_for_codex_event(&event).await; + let context = codex_user_prompt_submit_context_for_event(&event).await; + println!( + "{}", + codex_additional_context_json("UserPromptSubmit", &context) + ); + 0 +} + +pub async fn codex_user_prompt_submit_context_for_event(event: &str) -> String { + let (mut context, status) = codex_session_context_for_event(event).await; + if !matches!(status, HookWorkspaceStatus::Generic) { + if let Some(hint) = codex_prompt_hint(event) { + append_tool_hint(&mut context, &hint); + } + } + context +} + +/// Builds Codex session/prompt context. Unlike Cursor, Codex has no +/// always-applied tracedecay rule, so this carries full steering. +async fn codex_session_context_for_event(event_json: &str) -> (String, HookWorkspaceStatus) { + let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); + let root = codex_project_root_from_parsed_event(&parsed); + let cwd = event_cwd_from_parsed(&parsed); + let session_id = event_session_id(&parsed); + let status = codex_workspace_status(root.as_deref(), cwd.as_deref()); + record_workspace_status_analytics(root.as_deref(), status, session_id.as_deref()); + let staleness = match (status, root.as_deref()) { + (HookWorkspaceStatus::Initialized, Some(r)) => { + let (staleness, _) = cursor_index_signals_for_root(r).await; + staleness + } + _ => None, + }; + ( + build_codex_session_context_for_workspace(status, staleness.as_deref()), + status, + ) +} + +/// Codex `SubagentStart` hook handler. +/// +/// Steers research/explore subagents toward tracedecay MCP tools. Codex cannot +/// hard-stop a subagent at start (`continue: false` is ignored for this event), +/// so this injects `additionalContext` instead of denying. +pub fn hook_codex_subagent_start() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Codex, "SubagentStart", &event); + let count = record_codex_subagent_start(&event); + let output = evaluate_codex_subagent_start(&event); + eprintln!( + "{}", + codex_subagent_start_log_line(&event, count, output.is_some()) + ); + if let Some(output) = output { + println!("{output}"); + } + 0 +} + +/// Codex `PostToolUse` hook handler used to keep the graph fresh after writes. +/// +/// Notifies the daemon, which owns targeted sync, branch tracking, and +/// coalescing. Fail-open and silent. +pub async fn hook_codex_post_tool_use() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Codex, "PostToolUse", &event); + notify_post_tool_use(&CODEX_POST_TOOL_USE_SPEC, &event).await; + 0 +} + +/// Codex `PostCompact` hook handler. +/// +/// Codex stores compacted context bodies encrypted in the transcript. This hook +/// uses the visible source messages already ingested into the LCM store, asks a +/// child Codex app-server turn to summarize them, and replaces the temporary +/// deterministic summary node. Fail-open: compaction must never block Codex. +pub async fn hook_codex_post_compact() -> i32 { + let event = read_hook_event!(); + let root = codex_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Codex, "PostCompact", &event); + if std::env::var_os(crate::sessions::codex_app_server::CODEX_SUMMARY_CHILD_ENV).is_none() { + codex_post_compact(&event).await; + } + println!("{}", serde_json::json!({})); + 0 +} + +/// Builds a Codex hook stdout payload that injects model-visible context via +/// `hookSpecificOutput.additionalContext`. Used by `SessionStart`, +/// `UserPromptSubmit`, and `SubagentStart`. +pub fn codex_additional_context_json(event_name: &str, additional_context: &str) -> String { + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": event_name, + "additionalContext": additional_context, + } + }) + .to_string() +} + +/// Pure decision logic for Codex `SubagentStart` events. +/// +/// Returns Codex context for research or no-history subagents, or `None` for +/// execution-style subagents that already have history. +pub fn evaluate_codex_subagent_start(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let agent_type = parsed + .get("agent_type") + .or_else(|| parsed.get("subagent_type")) + .and_then(Value::as_str) + .unwrap_or_default(); + let task = parsed + .get("prompt") + .or_else(|| parsed.get("task")) + .or_else(|| parsed.get("description")) + .and_then(Value::as_str) + .unwrap_or_default(); + + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Codex, + session_id: event_session_id(&parsed), + tool_name: Some("SubagentStart".to_string()), + command: None, + prompt: (!task.is_empty()).then(|| task.to_string()), + subagent_type: (!agent_type.is_empty()).then(|| agent_type.to_string()), + file_path: None, + hints_enabled: true, + }); + let is_explore = agent_type.eq_ignore_ascii_case("explore"); + let is_research = is_explore || is_code_research_prompt(task); + let needs_context = codex_subagent_needs_context(&parsed); + if is_research || needs_context { + let dedupe_hint = ToolHint { + category: if is_research { + HintCategory::ExploreSubagent + } else { + HintCategory::SubagentStartContext + }, + message: "For Codex subagents, add compact TraceDecay context before isolated work." + .to_string(), + context: CODEX_SUBAGENT_START_CONTEXT.to_string(), + nonblocking: true, + }; + let root = codex_project_root_from_event(event_json); + record_hint_analytics( + root.as_deref(), + "hint_candidate", + HintAgent::Codex, + event_session_id(&parsed).as_deref(), + &dedupe_hint, + ); + let _ = deduped_codex_hint(event_json, &parsed, dedupe_hint)?; + let context = codex_subagent_start_context(hint, needs_context); + return Some(codex_additional_context_json("SubagentStart", &context)); + } + None +} + +/// Records a Codex `SubagentStart` in the current project's profile-sharded +/// hook state and returns the session-local count. Fail-open: malformed events, +/// missing roots, and storage errors only disable counting. +pub fn record_codex_subagent_start(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let root = codex_project_root_from_parsed_event(&parsed)?; + let layout = crate::storage::resolve_layout_for_current_profile(&root).ok()?; + let path = layout.data_root.join("codex_subagent_starts.json"); + let analytics_session_id = event_session_id(&parsed); + let session_id = analytics_session_id + .clone() + .unwrap_or_else(|| "unknown-codex-session".to_string()); + let mut counts = read_codex_subagent_start_counts(&path); + let count = counts.entry(session_id).or_insert(0); + *count = count.saturating_add(1); + let next = *count; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(&counts) { + let _ = std::fs::write(path, format!("{json}\n")); + } + let agent_type = parsed + .get("agent_type") + .or_else(|| parsed.get("subagent_type")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + record_hook_analytics( + Some(&root), + "codex_subagent_start", + serde_json::json!({ + "agent": HintAgent::Codex.as_key(), + "session_id": analytics_session_id.as_deref(), + "agent_type": agent_type, + "count": next, + }), + ); + Some(next) +} + +pub fn codex_subagent_start_log_line( + event_json: &str, + count: Option, + emitted_context: bool, +) -> String { + let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); + let agent_type = parsed + .get("agent_type") + .or_else(|| parsed.get("subagent_type")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"); + let session_id = event_session_id(&parsed).unwrap_or_else(|| "unknown".to_string()); + let count = count.map_or_else(|| "#?".to_string(), |value| format!("#{value}")); + format!( + "tracedecay Codex SubagentStart {count}: session_id={session_id} agent_type={agent_type} additional_context={emitted_context}" + ) +} + +fn read_codex_subagent_start_counts(path: &Path) -> BTreeMap { + std::fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() +} + +fn codex_subagent_start_context(hint: Option, no_history: bool) -> String { + let mut context = String::new(); + if no_history { + context.push_str("new/no-history subagent: recover only relevant project memory or prior-session context before assuming missing decisions.\n"); + } + context.push_str(CODEX_SUBAGENT_START_CONTEXT); + context.push('\n'); + if let Some(hint) = hint { + context.push('\n'); + context.push_str(&format_tool_hint(&hint)); + context.push('\n'); + } + context +} + +fn codex_subagent_needs_context(parsed: &Value) -> bool { + bool_field( + parsed, + &["is_new", "new_subagent", "fresh_subagent", "no_history"], + ) == Some(true) + || bool_field( + parsed, + &[ + "has_history", + "history_included", + "receives_history", + "conversation_history_included", + ], + ) == Some(false) + || text_field( + parsed, + &[ + "history_mode", + "context_mode", + "conversation_history", + "source", + "reason", + ], + ) + .is_some_and(|value| matches_no_history_marker(&value)) + || empty_array_field(parsed, &["history", "messages", "conversation"]) +} + +fn bool_field(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_bool)) +} + +fn empty_array_field(value: &Value, keys: &[&str]) -> bool { + keys.iter().any(|key| { + value + .get(*key) + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + }) +} + +fn matches_no_history_marker(value: &str) -> bool { + let normalized = value + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::() + .to_ascii_lowercase(); + matches!( + normalized.as_str(), + "new" | "fresh" | "none" | "empty" | "nohistory" | "withoutconversationhistory" + ) +} + +/// Resolves the tracedecay project root for a Codex event from its `cwd`. +pub fn codex_project_root_from_event(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + codex_project_root_from_parsed_event(&parsed) +} + +pub(super) fn codex_project_root_from_parsed_event(parsed: &Value) -> Option { + let cwd = event_cwd_from_parsed(parsed)?; + crate::config::discover_project_root(&cwd) +} + +fn codex_workspace_status(root: Option<&Path>, cwd: Option<&Path>) -> HookWorkspaceStatus { + if root.is_some() { + return HookWorkspaceStatus::Initialized; + } + if cwd.is_some_and(is_project_like_workspace) { + HookWorkspaceStatus::UnindexedProject + } else { + HookWorkspaceStatus::Generic + } +} + +pub fn codex_workspace_status_from_event(event_json: &str) -> HookWorkspaceStatus { + let parsed = serde_json::from_str::(event_json).unwrap_or(Value::Null); + let root = codex_project_root_from_parsed_event(&parsed); + let cwd = event_cwd_from_parsed(&parsed); + codex_workspace_status(root.as_deref(), cwd.as_deref()) +} + +/// Extracts the project-relative paths touched by a Codex `apply_patch` command. +/// +/// Codex sends the patch text as `tool_input.command`. The `apply_patch` envelope +/// names each file with `*** Add File:`, `*** Update File:`, `*** Delete File:`, +/// or `*** Move to:` lines. Patch paths are relative to the session `cwd` +/// (which may be a subdirectory of the discovered project root), so we resolve +/// each against `cwd` and then make it relative to `project_root`. Absolute +/// paths outside the root are skipped. The result feeds the daemon's targeted +/// single-file sync event. +pub fn codex_apply_patch_rel_paths(command: &str, cwd: &Path, project_root: &Path) -> Vec { + const PREFIXES: [&str; 4] = [ + "*** Add File:", + "*** Update File:", + "*** Delete File:", + "*** Move to:", + ]; + let mut rels: Vec = Vec::new(); + for line in command.lines() { + let line = line.trim(); + for prefix in PREFIXES { + if let Some(rest) = line.strip_prefix(prefix) { + let raw = rest.trim(); + if raw.is_empty() { + continue; + } + let candidate = Path::new(raw); + let abs = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + cwd.join(candidate) + }; + if let Some(rel) = rel_under_root(project_root, &abs) { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } + } + } + rels +} + +async fn codex_post_compact(event_json: &str) { + let work = async { + let Some(project_root) = codex_project_root_from_event(event_json) else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&project_root).await { + return; + } + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return; + }; + if let Some(source) = crate::sessions::codex::CodexSource::new() { + let _ = crate::sessions::source::ingest_source(&db, &source, &project_root, None).await; + } + let session_id = serde_json::from_str::(event_json) + .ok() + .and_then(|parsed| event_session_id(&parsed)); + let Ok(mut pending) = db + .pending_codex_compaction_summary_requests(session_id.as_deref(), 1) + .await + else { + return; + }; + let Some(pending) = pending.pop() else { + return; + }; + let config = crate::sessions::codex_app_server::CodexAppServerSummaryConfig::from_env(); + let summary = match crate::sessions::codex_app_server::summarize_with_codex_app_server( + &pending.request, + &config, + ) { + Ok(summary) => summary, + Err(err) => { + eprintln!("tracedecay Codex PostCompact summary failed: {err}"); + return; + } + }; + if let Err(err) = db + .replace_codex_compaction_summary( + &pending.node_id, + &summary.text, + "codex_app_server", + summary.model.as_deref().or(config.model.as_deref()), + ) + .await + { + eprintln!("tracedecay Codex PostCompact summary replacement failed: {err}"); + } + }; + let _ = tokio::time::timeout(CODEX_POST_COMPACT_BUDGET, work).await; +} + +async fn reset_counter_for_codex_event(event_json: &str) { + let Some(project_root) = codex_project_root_from_event(event_json) else { + return; + }; + if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { + let _ = cg.reset_local_counter().await; + } +} + +fn deduped_codex_hint(event_json: &str, parsed: &Value, hint: ToolHint) -> Option { + deduped_project_hint( + codex_project_root_from_event(event_json), + HintAgent::Codex, + event_session_id(parsed), + hint, + ) +} + +fn codex_prompt_hint(event_json: &str) -> Option { + let parsed = serde_json::from_str::(event_json).ok()?; + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Codex, + session_id: event_session_id(&parsed), + tool_name: None, + command: None, + prompt: prompt_like_text(&parsed), + subagent_type: None, + file_path: None, + hints_enabled: true, + })?; + let root = codex_project_root_from_event(event_json); + record_hint_analytics( + root.as_deref(), + "hint_candidate", + HintAgent::Codex, + event_session_id(&parsed).as_deref(), + &hint, + ); + deduped_codex_hint(event_json, &parsed, hint) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::config::USER_DATA_DIR_ENV; + use std::sync::{Mutex, OnceLock}; + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set_path(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + fn env_lock() -> &'static Mutex<()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn codex_prompt_hints_dedupe_by_session_and_category() { + let _lock = env_lock().lock().unwrap(); + let project = tempfile::tempdir().unwrap(); + let profile = tempfile::tempdir().unwrap(); + let project_root = project.path().canonicalize().unwrap(); + let profile_root = profile.path().canonicalize().unwrap(); + let _profile_env = EnvGuard::set_path(USER_DATA_DIR_ENV, &profile_root); + crate::storage::write_enrollment_marker( + &project_root, + &crate::storage::EnrollmentMarker { + project_id: "proj_hook_codex_prompt".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .unwrap(); + let layout = crate::storage::resolve_layout_for_current_profile(&project_root).unwrap(); + std::fs::create_dir_all(&layout.data_root).unwrap(); + let event = serde_json::json!({ + "session_id": "codex-session-1", + "cwd": project_root, + "prompt": "Please explain the impact of changing parse_user" + }) + .to_string(); + + let first = codex_prompt_hint(&event).unwrap(); + assert_eq!(first.category, HintCategory::Impact); + + assert!( + codex_prompt_hint(&event).is_none(), + "Codex should use shared per-session hint dedupe for prompt hints" + ); + } +} diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs new file mode 100644 index 00000000..613dc815 --- /dev/null +++ b/src/hooks/cursor.rs @@ -0,0 +1,616 @@ +//! Cursor hook handlers: subagent/tool-use steering, transcript ingest, +//! post-edit / post-shell daemon notifications, and session lifecycle +//! context. +//! +//! Cursor expects Cursor-shaped stdout, separate from Claude, Codex, and Kiro. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::Value; + +use super::cursor_compact::{ + cursor_pre_compact_for_event_with_config, CURSOR_PRE_COMPACT_SUMMARY_BUDGET, +}; +use super::cursor_shell::paths_same; +use super::steering::{ + append_context_recovery_hint, build_cursor_session_context, cursor_index_signals_for_root, + session_start_from_compaction, +}; +use super::tool_hints::{decide_hint, HintAgent, ToolHint, ToolHintInput}; +use super::{ + deduped_project_hint, event_session_id, format_tool_hint, nearest_project_like_root, + read_hook_event, record_hint_analytics, record_hook_invoked, rel_under_root, text_field, +}; + +/// Largest tail the `beforeSubmitPrompt` hot path will read in one call. Larger +/// backlogs are left for the `sessionStart` / `stop` catch-up ingests. +const CURSOR_HOT_INGEST_MAX_BYTES: u64 = 256 * 1024; +/// Largest transcript tail a low-priority Cursor catch-up hook will read. +/// Oversized backlogs stay queued instead of blocking hook execution. Public +/// so ingest-health reporting (`tracedecay_status`, doctor) can flag a backlog +/// the hooks will never drain on their own. +pub const CURSOR_CATCH_UP_INGEST_MAX_BYTES: u64 = 2 * 1024 * 1024; +/// Hard wall-clock budget for the `beforeSubmitPrompt` tail ingest. Well under +/// Cursor's 5s hook timeout; on expiry we fail open and let heavier hooks catch up. +const CURSOR_HOT_INGEST_BUDGET: Duration = Duration::from_millis(1_500); +/// Budget for the `sessionStart` catch-up ingest (registered with a 5s timeout). +const CURSOR_SESSION_INGEST_BUDGET: Duration = Duration::from_secs(4); +/// Budget for the end-of-turn `stop` catch-up ingest (registered with a 30s timeout). +const CURSOR_STOP_INGEST_BUDGET: Duration = Duration::from_secs(25); + +/// Cursor `subagentStart` hook handler. +/// +/// Allows Cursor subagents while preserving legacy hook compatibility. +pub fn hook_cursor_subagent_start() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "subagentStart", &event); + if let Some(decision) = evaluate_cursor_subagent_start(&event) { + println!("{decision}"); + } + 0 +} + +/// Cursor `postToolUse` hook handler. +/// +/// Emits soft `additional_context` hints steering exploration tools (Grep, +/// Glob, Read, semantic search, shell `rg`) toward tracedecay MCP tools. +/// Registered on `postToolUse` rather than `preToolUse` because Cursor's +/// documented `preToolUse` output schema has no context-injection field — +/// `additional_context` is only honored on `postToolUse`. The hook runs +/// unmatched (the docs enumerate no matcher value for Cursor's semantic +/// search tool) and irrelevant tools fail open with no output. Each hint +/// category is emitted at most once per session via +/// [`super::tool_hints::ToolHintDedupe`] persisted under `.tracedecay/`. +pub fn hook_cursor_post_tool_use() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "postToolUse", &event); + if let Some(decision) = cursor_post_tool_use_decision(&event) { + println!("{decision}"); + } + 0 +} + +/// Cursor `beforeSubmitPrompt` hook handler. +/// +/// Resets the project-local counter for a new prompt turn and does at most a +/// small, time-boxed *tail* ingest of newly-appended transcript lines (the bulk +/// catch-up lives on the lower-frequency `sessionStart` / `stop` hooks). The +/// output uses Cursor's documented `beforeSubmitPrompt` shape and never blocks +/// submission, even if the tail ingest times out. +pub async fn hook_cursor_before_submit_prompt() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked( + root.as_deref(), + HintAgent::Cursor, + "beforeSubmitPrompt", + &event, + ); + reset_counter_for_cursor_event(&event).await; + ingest_cursor_transcript_for_event( + &event, + Some(CURSOR_HOT_INGEST_MAX_BYTES), + CURSOR_HOT_INGEST_BUDGET, + ) + .await; + println!("{}", serde_json::json!({ "continue": true })); + 0 +} + +/// Cursor `sessionEnd` hook handler (fire-and-forget). +/// +/// Final transcript-ingest flush when a conversation ends (including +/// `window_close` / `user_close`, which the end-of-turn `stop` hook can +/// miss). `sessionEnd` receives the common-schema `transcript_path`, so the +/// regular capped catch-up ingest applies. The response is logged but unused, +/// so an empty object is emitted. Fail-open. +pub async fn hook_cursor_session_end() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "sessionEnd", &event); + ingest_cursor_transcript_for_event( + &event, + Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), + CURSOR_STOP_INGEST_BUDGET, + ) + .await; + println!("{}", serde_json::json!({})); + 0 +} + +/// Cursor `stop` hook handler (fire-and-forget). +/// +/// Fires at the end of an agent turn and performs the primary transcript +/// ingest: a time-boxed incremental catch-up that picks up bounded transcript +/// tails appended during the turn. The `stop` output is informational only, so +/// we emit an empty object and never ask the agent to continue. Fail-open. +pub async fn hook_cursor_stop() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "stop", &event); + ingest_cursor_transcript_for_event( + &event, + Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), + CURSOR_STOP_INGEST_BUDGET, + ) + .await; + println!("{}", serde_json::json!({})); + 0 +} + +/// Cursor `preCompact` hook handler. +/// +/// Cursor's compaction event exposes pressure metadata but not Cursor's own +/// generated summary text. At the boundary, `TraceDecay` ingests the current +/// transcript tail, asks LCM for the compactable raw-message backlog, generates +/// a summary through `cursor-agent -p`, and stores that summary as a normal LCM +/// summary node. The hook is fail-open and emits Cursor's empty object shape. +pub async fn hook_cursor_pre_compact() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "preCompact", &event); + if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_err() { + let mut config = crate::sessions::cursor_agent::CursorAgentSummaryConfig::from_env(); + config.timeout = config.timeout.min(CURSOR_PRE_COMPACT_SUMMARY_BUDGET); + let outcome = cursor_pre_compact_for_event_with_config(&event, &config).await; + if outcome.status == "error" { + eprintln!( + "tracedecay Cursor preCompact summary failed: {}", + outcome.reason + ); + } + } + println!("{}", serde_json::json!({})); + 0 +} + +/// Cursor `afterFileEdit` hook handler. +/// +/// Keeps the graph fresh after Cursor Agent writes files by notifying the +/// daemon about the edited path(s). The daemon owns targeted sync scheduling +/// and the hook fails open when no daemon is available. +pub async fn hook_cursor_after_file_edit() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "afterFileEdit", &event); + notify_cursor_after_file_edit(&event).await; + 0 +} + +/// Cursor `sessionStart` hook handler (fire-and-forget). +/// +/// Emits Cursor's `sessionStart` output shape (`additional_context` + `env`) +/// steering the agent toward tracedecay MCP tools and reporting index freshness +/// for the resolved workspace. Never blocks session creation. +pub async fn hook_cursor_session_start() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "sessionStart", &event); + ingest_cursor_transcript_for_event( + &event, + Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), + CURSOR_SESSION_INGEST_BUDGET, + ) + .await; + let mut context = cursor_session_context_for_root(root.as_deref()).await; + if session_start_from_compaction(&event) { + append_context_recovery_hint(&mut context); + } + println!("{}", cursor_session_start_json(root.as_deref(), &context)); + 0 +} + +/// Builds the lean Cursor `sessionStart` context for a resolved project root. +/// +/// Adds index freshness, the skill index, and tokens-saved counter that the +/// always-on plugin rule cannot know. +async fn cursor_session_context_for_root(root: Option<&Path>) -> String { + let (initialized, staleness, tokens_saved) = match root { + Some(r) if crate::tracedecay::TraceDecay::has_initialized_store(r).await => { + let (staleness, tokens_saved) = cursor_index_signals_for_root(r).await; + (true, staleness, tokens_saved) + } + _ => (false, None, None), + }; + build_cursor_session_context(initialized, staleness.as_deref(), tokens_saved) +} + +/// Cursor `afterShellExecution` hook handler. +/// +/// Notifies the daemon after Cursor shell execution. The daemon decides whether +/// the command requires branch tracking or coalesced incremental sync. +pub async fn hook_cursor_after_shell() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked( + root.as_deref(), + HintAgent::Cursor, + "afterShellExecution", + &event, + ); + notify_cursor_after_shell_event(&event).await; + 0 +} + +/// Cursor `workspaceOpen` hook handler. +/// +/// Notifies the daemon to run one-shot workspace catch-up. Fail-open. +pub async fn hook_cursor_workspace_open() -> i32 { + let event = read_hook_event!(); + let root = cursor_project_root_from_event(&event); + record_hook_invoked(root.as_deref(), HintAgent::Cursor, "workspaceOpen", &event); + notify_cursor_workspace_open(&event).await; + println!("{}", serde_json::json!({})); + 0 +} + +/// Pure decision logic for Cursor `subagentStart` hook events. +/// +/// Cursor subagents must be allowed to start. +/// +/// Earlier versions denied research/explore subagents in favor of tracedecay MCP +/// tools. In Cursor this can surface as a misleading "bubble creation" timeout, +/// and it prevents explicit user requests to use agents. Keep this handler +/// fail-open so stale installs that still register `subagentStart` do not block +/// subagent creation. +pub fn evaluate_cursor_subagent_start(event_json: &str) -> Option { + let _ = event_json; + None +} + +/// Pure decision logic for Cursor `postToolUse` hook events. +/// +/// Returns a soft `additional_context` payload (Cursor's documented +/// `postToolUse` output shape) for exploration tools tracedecay can replace. +/// Invalid or unrelated tool events fail open with no output. Session-level +/// dedupe lives in [`cursor_post_tool_use_decision`]; this stays pure for +/// tests. +pub fn evaluate_cursor_post_tool_use(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let hint = decide_hint(&cursor_tool_hint_input(&parsed))?; + Some( + serde_json::json!({ + "additional_context": format_tool_hint(&hint), + }) + .to_string(), + ) +} + +/// Impure `postToolUse` path: [`evaluate_cursor_post_tool_use`] plus +/// per-session hint dedupe persisted under the project's `.tracedecay/` dir. +pub fn cursor_post_tool_use_decision(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let hint = decide_hint(&cursor_tool_hint_input(&parsed))?; + let root = cursor_project_root_candidate_from_parsed_event(&parsed); + record_hint_analytics( + root.as_deref(), + "hint_candidate", + HintAgent::Cursor, + event_session_id(&parsed).as_deref(), + &hint, + ); + let hint = deduped_cursor_hint(event_json, hint)?; + Some( + serde_json::json!({ + "additional_context": format_tool_hint(&hint), + }) + .to_string(), + ) +} + +/// Suppresses hints that were already emitted for this session. +/// +/// The `(session_id, category)` pairs are persisted in +/// `.tracedecay/tool_hints_seen.json` so each hint category surfaces at most +/// once per Cursor session across short-lived hook processes. Hints are also +/// suppressed entirely when the workspace has no tracedecay index (suggesting +/// tracedecay tools there would be misleading). When no session id is present +/// the hint is emitted as-is — dedupe is impossible but the hint is still +/// useful (fail-open). +fn deduped_cursor_hint(event_json: &str, hint: ToolHint) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let root = cursor_project_root_candidate_from_parsed_event(&parsed)?; + if !crate::tracedecay::TraceDecay::is_initialized(&root) { + record_hint_analytics( + Some(&root), + "suppressed_uninitialized", + HintAgent::Cursor, + event_session_id(&parsed).as_deref(), + &hint, + ); + return None; + } + deduped_project_hint( + Some(root), + HintAgent::Cursor, + event_session_id(&parsed), + hint, + ) +} + +pub fn cursor_project_root_from_event(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + cursor_project_root_from_parsed_event(&parsed) +} + +fn cursor_project_root_candidate_from_parsed_event(parsed: &Value) -> Option { + cursor_project_root_from_parsed_event(parsed).or_else(|| { + cursor_event_candidates(parsed) + .into_iter() + .find_map(|candidate| nearest_project_like_root(&candidate)) + }) +} + +pub(super) fn cursor_project_root_from_parsed_event(parsed: &Value) -> Option { + let resolved = cursor_event_candidates(parsed) + .into_iter() + .find_map(|candidate| crate::config::discover_project_root(&candidate)); + let cwd_root = cursor_event_cwd(parsed) + .as_deref() + .and_then(crate::config::discover_project_root); + match (cwd_root, resolved) { + // Prefer the root derived from cwd when available; this avoids routing + // a root-B event into root A just because workspace_roots listed A first. + (Some(cwd_root), Some(resolved)) if !paths_same(&cwd_root, &resolved) => Some(cwd_root), + (Some(cwd_root), None) => Some(cwd_root), + (_, other) => other, + } +} + +fn cursor_event_candidates(event: &Value) -> Vec { + let mut candidates = Vec::new(); + let mut push_unique = |candidate: PathBuf| { + if !candidates.iter().any(|seen| seen == &candidate) { + candidates.push(candidate); + } + }; + if let Some(cwd) = cursor_event_cwd(event) { + push_unique(cwd); + } + if let Some(project_root) = crate::config::brand_env("PROJECT_ROOT") { + push_unique(PathBuf::from(project_root)); + } + if let Some(file_path) = event + .get("file_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + let path = Path::new(file_path); + push_unique(path.parent().unwrap_or(path).to_path_buf()); + } + if let Some(transcript_path) = event + .get("transcript_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + let path = Path::new(transcript_path); + push_unique(path.parent().unwrap_or(path).to_path_buf()); + } + if let Some(roots) = event.get("workspace_roots").and_then(Value::as_array) { + for root in roots { + if let Some(path) = root.as_str().filter(|s| !s.is_empty()) { + push_unique(PathBuf::from(path)); + } + } + } + candidates +} + +fn cursor_event_cwd(event: &Value) -> Option { + event + .get("cwd") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) +} + +/// Extracts the repo-relative paths edited in a Cursor `afterFileEdit` event. +/// +/// Cursor sends an absolute `file_path` (plus an `edits` array). We strip the +/// resolved `project_root` prefix and normalize to forward slashes so the hook +/// can notify the daemon about only the changed files. Paths outside the project +/// root are skipped. +pub fn cursor_after_file_edit_rel_paths(event_json: &str, project_root: &Path) -> Vec { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return Vec::new(); + }; + + let mut abs_paths: Vec = Vec::new(); + if let Some(p) = parsed + .get("file_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + abs_paths.push(p.to_string()); + } + // Defensive: some edit payloads may carry per-edit file paths. + if let Some(edits) = parsed.get("edits").and_then(Value::as_array) { + for edit in edits { + if let Some(p) = edit + .get("file_path") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + abs_paths.push(p.to_string()); + } + } + } + + let mut rels: Vec = Vec::new(); + for abs in abs_paths { + if let Some(rel) = rel_under_root(project_root, Path::new(&abs)) { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } + rels +} + +/// Returns `true` when a sync should run given the last marker time and a +/// debounce window. Used to coalesce back-to-back `afterShellExecution` syncs. +pub fn cursor_should_run_sync(now_secs: i64, last_secs: Option, debounce_secs: i64) -> bool { + match last_secs { + Some(last) => now_secs - last >= debounce_secs, + None => true, + } +} + +/// Builds the Cursor `sessionStart` output JSON (`additional_context` + `env`). +/// When `project_root` is known, exposes it as `TRACEDECAY_PROJECT_ROOT` so +/// subsequent session hooks can reuse it. +pub fn cursor_session_start_json(project_root: Option<&Path>, additional_context: &str) -> String { + let mut env = serde_json::Map::new(); + if let Some(root) = project_root { + env.insert( + "TRACEDECAY_PROJECT_ROOT".to_string(), + Value::String(root.to_string_lossy().to_string()), + ); + } + serde_json::json!({ + "additional_context": additional_context, + "env": Value::Object(env), + }) + .to_string() +} + +/// Best-effort daemon notification for Cursor `afterFileEdit`. +/// +/// Resolves the edited repo-relative paths locally, then lets the daemon own +/// scheduling and sync execution. No-ops when no in-project paths were edited. +async fn notify_cursor_after_file_edit(event_json: &str) { + let Some(root) = cursor_project_root_from_event(event_json) else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { + return; + } + let rels = cursor_after_file_edit_rel_paths(event_json, &root); + if rels.is_empty() { + return; + } + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::cursor_after_file_edit(rels), + ) + .await; +} + +/// Best-effort daemon notification for Cursor `afterShellExecution`. +async fn notify_cursor_after_shell_event(event_json: &str) { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return; + }; + let command = parsed + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + let Some(root) = cursor_project_root_from_event(event_json) else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { + return; + } + let cwd = cursor_event_cwd(&parsed).unwrap_or_else(|| root.clone()); + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::cursor_after_shell_execution(command.to_string(), cwd), + ) + .await; +} + +/// Best-effort daemon notification for Cursor `workspaceOpen`. +async fn notify_cursor_workspace_open(event_json: &str) { + let Some(root) = cursor_project_root_from_event(event_json) else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { + return; + } + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::cursor_workspace_open(root.clone()), + ) + .await; +} + +async fn reset_counter_for_cursor_event(event_json: &str) { + let Some(project_root) = cursor_project_root_from_event(event_json) else { + return; + }; + if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { + let _ = cg.reset_local_counter().await; + } +} + +/// Incrementally ingests the Cursor transcript referenced by `event_json` into +/// the resolved project session DB, bounded by `max_new_bytes` (the hot-path cap) +/// and an overall `budget`. Always fails open: a timeout, missing transcript, or +/// any error is swallowed so the calling hook never blocks the agent. +pub(super) async fn ingest_cursor_transcript_for_event( + event_json: &str, + max_new_bytes: Option, + budget: Duration, +) -> bool { + let work = async { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return false; + }; + let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { + return false; + }; + if let Some(cwd_root) = cursor_event_cwd(&parsed) + .as_deref() + .and_then(crate::config::discover_project_root) + { + if !paths_same(&cwd_root, &project_root) { + return false; + } + } + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return false; + }; + let _ = crate::sessions::cursor::ingest_cursor_transcript_event_capped( + event_json, + &db, + max_new_bytes, + ) + .await; + true + }; + // Short-lived CLI hook processes exit immediately, so the ingest must run + // inline (not on a detached task); the timeout keeps it inside budget. + tokio::time::timeout(budget, work).await.unwrap_or(false) +} + +fn cursor_tool_hint_input(parsed: &Value) -> ToolHintInput { + let tool_input = parsed + .get("tool_input") + .or_else(|| parsed.get("toolInput")) + .or_else(|| parsed.get("input")) + .unwrap_or(&Value::Null); + ToolHintInput { + agent: HintAgent::Cursor, + session_id: event_session_id(parsed), + tool_name: text_field(parsed, &["tool_name", "toolName", "name"]), + command: text_field(tool_input, &["command", "cmd"]) + .or_else(|| text_field(parsed, &["command", "cmd"])), + prompt: text_field( + tool_input, + &["prompt", "query", "pattern", "task", "description"], + ) + .or_else(|| { + text_field( + parsed, + &["prompt", "query", "pattern", "task", "description"], + ) + }), + subagent_type: text_field(parsed, &["subagent_type", "subagentType", "agent_type"]), + file_path: text_field(tool_input, &["file_path", "filePath", "path"]) + .or_else(|| text_field(parsed, &["file_path", "filePath", "path"])), + hints_enabled: true, + } +} diff --git a/src/hooks/cursor_compact.rs b/src/hooks/cursor_compact.rs new file mode 100644 index 00000000..434255d4 --- /dev/null +++ b/src/hooks/cursor_compact.rs @@ -0,0 +1,219 @@ +//! Cursor `preCompact` machinery. +//! +//! Cursor's compaction event exposes pressure metadata but not Cursor's own +//! generated summary text, so at the boundary `TraceDecay` ingests the current +//! transcript tail, asks LCM for the compactable raw-message backlog, +//! generates a summary through `cursor-agent -p`, and stores that summary as +//! a normal LCM summary node. + +use std::path::Path; +use std::time::Duration; + +use serde_json::Value; + +use super::cursor::{cursor_project_root_from_parsed_event, ingest_cursor_transcript_for_event}; +use super::{event_i64, event_session_id, event_usize}; + +/// Budget for the transcript catch-up portion of the `preCompact` hook. +const CURSOR_PRE_COMPACT_INGEST_BUDGET: Duration = Duration::from_secs(30); +/// Budget for the auxiliary `cursor-agent` summary call inside the hook. Kept +/// below the registered Cursor hook timeout so the child can be killed/reaped +/// by `TraceDecay` rather than by Cursor killing the hook process. Sized so +/// the ingest budget plus this cap stay below the overall preCompact budget, +/// leaving slack for LCM prepare/persist and process overhead. +pub(super) const CURSOR_PRE_COMPACT_SUMMARY_BUDGET: Duration = Duration::from_secs(75); +/// Overall budget for the `preCompact` hook (registered with a 120s timeout). +const CURSOR_PRE_COMPACT_BUDGET: Duration = Duration::from_secs(115); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CursorPreCompactOutcome { + pub status: String, + pub reason: String, + pub summary_nodes_created: usize, + pub summary_node_ids: Vec, +} + +impl CursorPreCompactOutcome { + fn skipped(reason: impl Into) -> Self { + Self { + status: "skipped".to_string(), + reason: reason.into(), + summary_nodes_created: 0, + summary_node_ids: Vec::new(), + } + } + + fn error(reason: impl Into) -> Self { + Self { + status: "error".to_string(), + reason: reason.into(), + summary_nodes_created: 0, + summary_node_ids: Vec::new(), + } + } +} + +pub async fn cursor_pre_compact_for_event_with_config( + event_json: &str, + config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, +) -> CursorPreCompactOutcome { + match tokio::time::timeout( + CURSOR_PRE_COMPACT_BUDGET, + cursor_pre_compact_for_event_inner(event_json, config), + ) + .await + { + Ok(outcome) => outcome, + Err(_) => CursorPreCompactOutcome::error("timed out"), + } +} + +async fn cursor_pre_compact_for_event_inner( + event_json: &str, + config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, +) -> CursorPreCompactOutcome { + if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_ok() { + return CursorPreCompactOutcome::skipped("cursor summary child"); + } + let parsed = match serde_json::from_str::(event_json) { + Ok(parsed) => parsed, + Err(err) => return CursorPreCompactOutcome::error(format!("invalid event JSON: {err}")), + }; + let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { + return CursorPreCompactOutcome::skipped("no project root"); + }; + if !cursor_event_transcript_path_exists(&parsed) { + return CursorPreCompactOutcome::skipped("no transcript path"); + } + + let caught_up = + ingest_cursor_transcript_for_event(event_json, None, CURSOR_PRE_COMPACT_INGEST_BUDGET) + .await; + if !caught_up { + return CursorPreCompactOutcome::skipped("transcript ingest did not complete"); + } + + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return CursorPreCompactOutcome::skipped("session database unavailable"); + }; + let Some(session_id) = event_session_id(&parsed) else { + return CursorPreCompactOutcome::skipped("no session id"); + }; + + let messages_to_compact = event_usize(&parsed, &["messages_to_compact", "compact_count"]); + if messages_to_compact == Some(0) { + return CursorPreCompactOutcome::skipped("no messages to compact"); + } + let fresh_tail_count = cursor_pre_compact_fresh_tail_count(&parsed, messages_to_compact); + let current_tokens = event_i64(&parsed, &["context_tokens", "current_tokens", "tokens"]); + let context_length = event_i64(&parsed, &["context_window_size", "context_length"]); + + let first = match db + .lcm_compress(cursor_pre_compact_lcm_request( + &session_id, + current_tokens, + context_length, + messages_to_compact, + fresh_tail_count, + crate::sessions::lcm::LcmSummarizerMode::HermesAuxiliary, + None, + )) + .await + { + Ok(response) => response, + Err(err) => return CursorPreCompactOutcome::error(format!("LCM prepare failed: {err}")), + }; + let Some(summary_request) = first.summary_request else { + return CursorPreCompactOutcome::skipped(first.reason); + }; + + let summary = match crate::sessions::cursor_agent::summarize_with_cursor_agent( + &summary_request, + config, + ) { + Ok(summary) => summary, + Err(err) => { + return CursorPreCompactOutcome::error(format!("cursor-agent summary failed: {err}")) + } + }; + + let second = match db + .lcm_compress(cursor_pre_compact_lcm_request( + &session_id, + current_tokens, + context_length, + messages_to_compact, + fresh_tail_count, + crate::sessions::lcm::LcmSummarizerMode::Provided { + summary_text: summary, + route: Some("cursor_agent".to_string()), + }, + first.frontier.current_frontier_store_id.or(Some(0)), + )) + .await + { + Ok(response) => response, + Err(err) => return CursorPreCompactOutcome::error(format!("LCM persist failed: {err}")), + }; + CursorPreCompactOutcome { + status: second.status, + reason: second.reason, + summary_nodes_created: second.summary_nodes_created, + summary_node_ids: second + .summary_nodes + .iter() + .map(|node| node.node_id.clone()) + .collect(), + } +} + +fn cursor_pre_compact_lcm_request( + session_id: &str, + current_tokens: Option, + context_length: Option, + max_source_messages: Option, + fresh_tail_count: Option, + summarizer: crate::sessions::lcm::LcmSummarizerMode, + expected_current_frontier_store_id: Option, +) -> crate::sessions::lcm::LcmCompressionRequest { + crate::sessions::lcm::LcmCompressionRequest { + provider: "cursor".to_string(), + session_id: session_id.to_string(), + messages: Vec::new(), + current_tokens, + focus_topic: Some("Cursor context compaction".to_string()), + ignore_session_patterns: Vec::new(), + stateless_session_patterns: Vec::new(), + ignore_message_patterns: Vec::new(), + expected_current_frontier_store_id, + threshold_tokens: None, + max_assembly_tokens: None, + leaf_chunk_tokens: None, + max_source_messages, + summary_fan_in: None, + incremental_max_depth: None, + fresh_tail_count, + dynamic_leaf_chunk_enabled: None, + dynamic_leaf_chunk_max: None, + context_length, + reserve_tokens_floor: None, + summarizer, + } +} + +fn cursor_pre_compact_fresh_tail_count( + parsed: &Value, + messages_to_compact: Option, +) -> Option { + let message_count = event_usize(parsed, &["message_count", "messages_count"])?; + let messages_to_compact = messages_to_compact?; + Some(message_count.saturating_sub(messages_to_compact)) +} + +fn cursor_event_transcript_path_exists(parsed: &Value) -> bool { + parsed + .get("transcript_path") + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + .is_some_and(|path| Path::new(path).exists()) +} diff --git a/src/hooks/cursor_shell.rs b/src/hooks/cursor_shell.rs new file mode 100644 index 00000000..5ad2666f --- /dev/null +++ b/src/hooks/cursor_shell.rs @@ -0,0 +1,425 @@ +//! Cursor shell-plan logic: classifies `afterShellExecution` commands into +//! sync actions (branch add, worktree add, incremental sync) and owns the +//! shared shell-word / git-command parsing helpers. + +use std::path::{Component, Path, PathBuf}; + +/// Returns `true` when `command` is a git invocation that changes the working +/// tree / HEAD enough that a broad re-sync is warranted (checkout, switch, +/// pull, merge, rebase, reset, cherry-pick, `stash pop`/`stash apply`). +/// +/// Read-only git commands (`status`, `log`, `diff`), `commit`/`add`, and +/// non-git commands return `false`. Only commands whose first token is `git` +/// match, so `echo git checkout` is ignored. +pub fn is_git_state_changing_command(command: &str) -> bool { + let tokens = shell_words(command); + let Some(sub_pos) = git_subcommand_pos(&tokens) else { + return false; + }; + let sub = tokens[sub_pos].to_ascii_lowercase(); + match sub.as_str() { + "checkout" | "switch" | "pull" | "merge" | "rebase" | "reset" | "cherry-pick" => true, + "stash" => { + let after = tokens + .iter() + .skip(sub_pos + 1) + .map(|t| t.to_ascii_lowercase()) + .find(|t| !t.starts_with('-')); + matches!(after.as_deref(), Some("pop" | "apply")) + } + _ => false, + } +} + +/// The action a Cursor `afterShellExecution` hook should take for a command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CursorShellSyncPlan { + /// Bootstrap/maintain branch tracking for the given branch (supersedes a + /// plain sync; the branch-add path copies the parent DB and syncs). + BranchAdd(String), + /// Bootstrap/maintain branch tracking in a newly-created linked worktree. + WorktreeBranchAdd { + branch: String, + worktree_path: String, + }, + /// Run a full incremental sync (same-branch change set). + IncrementalSync, + /// Ensure the current branch is tracked, then sync it if it already was. + CurrentBranchSync(String), + /// Do nothing. + Noop, +} + +/// Classifies a shell command into the sync action a Cursor +/// `afterShellExecution` hook should take. Branch switches take precedence +/// over plain incremental syncs. +pub fn cursor_shell_sync_plan(command: &str) -> CursorShellSyncPlan { + cursor_shell_sync_plan_with_current_branch(command, None) +} + +/// Like [`cursor_shell_sync_plan`], but supplies the post-command current branch +/// for state-changing commands whose branch target is ambiguous or implicit. +pub fn cursor_shell_sync_plan_with_current_branch( + command: &str, + current_branch: Option<&str>, +) -> CursorShellSyncPlan { + let raw = shell_words(command); + if let Some(parts) = cursor_worktree_add_parts_from_tokens(&raw) { + return CursorShellSyncPlan::WorktreeBranchAdd { + branch: parts.branch, + worktree_path: parts.worktree_path, + }; + } + if let Some(branch) = cursor_branch_switch_target_from_tokens(&raw) { + return CursorShellSyncPlan::BranchAdd(branch); + } + if is_git_state_changing_command(command) { + if let Some(branch) = current_branch.filter(|branch| !branch.is_empty()) { + return CursorShellSyncPlan::CurrentBranchSync(branch.to_string()); + } + return CursorShellSyncPlan::IncrementalSync; + } + CursorShellSyncPlan::Noop +} + +/// Returns the target branch for a branch-changing git command: +/// `git checkout `, `git switch `, `git checkout -b `, +/// and `git switch -c `. Worktree creation is classified separately by +/// [`cursor_shell_sync_plan`], which owns `git worktree add` parsing. +/// +/// Path checkouts (`git checkout -- ` or obvious file pathspecs), remote +/// tracking shortcuts such as `git switch --track origin/feature`, and +/// non-switch commands return `None`. Only commands whose first shell word is +/// `git` are considered. +pub fn cursor_branch_switch_target(command: &str) -> Option { + let raw = shell_words(command); + cursor_branch_switch_target_from_tokens(&raw) +} + +fn cursor_branch_switch_target_from_tokens(raw: &[String]) -> Option { + let sub_pos = git_subcommand_pos(raw)?; + let sub = raw[sub_pos].to_ascii_lowercase(); + + match sub.as_str() { + "checkout" | "switch" => { + let after = &raw[sub_pos + 1..]; + let mut i = 0; + let mut uses_tracking_shortcut = false; + while i < after.len() { + let tok = &after[i]; + if tok == "--" { + return None; + } + if matches!(tok.as_str(), "-b" | "-B" | "-c" | "-C" | "--orphan") { + return after.get(i + 1).cloned(); + } + if tok == "-t" || tok == "--track" || tok.starts_with("--track=") { + uses_tracking_shortcut = true; + i += 1; + continue; + } + if tok.starts_with('-') { + i += 1; + continue; + } + if uses_tracking_shortcut { + return None; + } + if is_obvious_checkout_pathspec(tok) { + return None; + } + return Some(tok.clone()); + } + None + } + _ => None, + } +} + +fn cursor_worktree_add_parts_from_tokens(raw: &[String]) -> Option { + let sub_pos = git_subcommand_pos(raw)?; + if raw.get(sub_pos)?.eq_ignore_ascii_case("worktree") + && raw.get(sub_pos + 1)?.eq_ignore_ascii_case("add") + { + return cursor_worktree_add_parts(&raw[sub_pos + 2..]); + } + None +} + +struct WorktreeAddParts { + branch: String, + worktree_path: String, +} + +fn cursor_worktree_add_parts(after: &[String]) -> Option { + let mut i = 0; + let mut positional = Vec::new(); + let mut detached = false; + let mut new_branch = None; + while i < after.len() { + let tok = &after[i]; + if tok == "--" { + positional.extend(after[i + 1..].iter().cloned()); + break; + } + if matches!(tok.as_str(), "-b" | "-B") { + new_branch = after.get(i + 1).cloned(); + i += 2; + continue; + } + if tok == "-d" || tok == "--detach" { + detached = true; + i += 1; + continue; + } + if tok == "--reason" { + i += 2; + continue; + } + if tok.starts_with('-') { + i += 1; + continue; + } + positional.push(tok.clone()); + i += 1; + } + if detached { + return None; + } + let worktree_path = positional.first()?.clone(); + let branch = new_branch.or_else(|| positional.get(1).cloned())?; + Some(WorktreeAddParts { + branch, + worktree_path, + }) +} + +fn is_obvious_checkout_pathspec(token: &str) -> bool { + token == "." + || token == ":/" + || token.starts_with("./") + || token.starts_with("../") + || token.starts_with(":/") + || token + .rsplit_once('.') + .is_some_and(|(_, ext)| !ext.is_empty()) +} + +/// Splits a shell command line into words, honoring single/double quotes and +/// backslash escapes. Shared with `tool_hints` so search-command +/// classification sees the same tokens as the checkout/sync parsing here. +pub(crate) fn shell_words(command: &str) -> Vec { + shell_words_for_platform(command, cfg!(windows)) +} + +fn shell_words_for_platform(command: &str, windows: bool) -> Vec { + let mut words = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut escaped = false; + + for c in command.chars() { + if escaped { + current.push(c); + escaped = false; + continue; + } + + match quote { + Some('\'') => { + if c == '\'' { + quote = None; + } else { + current.push(c); + } + } + Some('"') => match c { + '"' => quote = None, + '\\' => escaped = true, + _ => current.push(c), + }, + _ => match c { + '\'' | '"' => quote = Some(c), + '\\' if windows => current.push(c), + '\\' => escaped = true, + c if c.is_whitespace() => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + _ => current.push(c), + }, + } + } + + if escaped { + current.push('\\'); + } + if !current.is_empty() { + words.push(current); + } + words +} + +fn git_subcommand_pos(tokens: &[String]) -> Option { + if !tokens.first()?.eq_ignore_ascii_case("git") { + return None; + } + + let mut i = 1; + while i < tokens.len() { + let token = tokens[i].to_ascii_lowercase(); + match token.as_str() { + "-c" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" => { + i += 2; + } + "--" => { + i += 1; + } + _ if token.starts_with("--git-dir=") + || token.starts_with("--work-tree=") + || token.starts_with("--namespace=") + || token.starts_with("--config-env=") => + { + i += 1; + } + _ if token.starts_with('-') => { + i += 1; + } + _ => return Some(i), + } + } + None +} + +pub fn cursor_shell_command_targets_project( + command: &str, + cwd: &Path, + project_root: &Path, +) -> bool { + let tokens = shell_words(command); + if !tokens + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("git")) + { + return true; + } + let Some(work_dir) = git_explicit_work_dir(&tokens, cwd) else { + return true; + }; + let target_root = crate::config::discover_project_root(&work_dir).unwrap_or(work_dir); + paths_same(&target_root, project_root) +} + +fn git_explicit_work_dir(tokens: &[String], cwd: &Path) -> Option { + let mut i = 1; + let mut explicit_work_dir = None; + while i < tokens.len() { + let token = &tokens[i]; + match token.as_str() { + "-C" | "--work-tree" => { + let value = tokens.get(i + 1)?; + explicit_work_dir = Some(resolve_shell_path(cwd, value)); + i += 2; + } + "-c" | "--git-dir" | "--namespace" | "--config-env" => i += 2, + _ if token.starts_with("--work-tree=") => { + let value = token.trim_start_matches("--work-tree="); + explicit_work_dir = Some(resolve_shell_path(cwd, value)); + i += 1; + } + _ if token.starts_with("--git-dir=") + || token.starts_with("--namespace=") + || token.starts_with("--config-env=") => + { + i += 1; + } + _ if token.starts_with('-') => i += 1, + _ => break, + } + } + explicit_work_dir +} + +fn resolve_shell_path(cwd: &Path, value: &str) -> PathBuf { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +/// Resolves the filesystem root of the worktree created by a +/// `git worktree add` command. git resolves the worktree path against +/// `-C `/`--work-tree` overrides rather than the shell cwd, so those are +/// honored first. The result is canonicalized when the worktree exists (it +/// does by the time a post-shell hook fires) so symlinked components resolve +/// the way git resolved them, falling back to lexical `..` normalization. +pub fn resolve_worktree_add_root(command: &str, cwd: &Path, worktree_path: &str) -> PathBuf { + let tokens = shell_words(command); + let base = git_explicit_work_dir(&tokens, cwd).unwrap_or_else(|| cwd.to_path_buf()); + let joined = resolve_shell_path(&base, worktree_path); + joined + .canonicalize() + .unwrap_or_else(|_| normalize_lexically(&joined)) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +pub(super) fn paths_same(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn shell_words_preserves_unquoted_windows_paths() { + assert_eq!( + shell_words_for_platform(r"git --work-tree=C:\Users\me\repo pull", true), + vec!["git", r"--work-tree=C:\Users\me\repo", "pull"] + ); + assert_eq!( + shell_words_for_platform(r"git --work-tree=C:\Users\me\repo pull", false), + vec!["git", r"--work-tree=C:Usersmerepo", "pull"] + ); + } + + #[test] + fn git_work_tree_overrides_prior_c_directory() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("repo"); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(project.join(".git")).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + + let command = format!( + "git -C {} --git-dir={}/.git --work-tree={} pull", + outside.display(), + project.display(), + project.display() + ); + + assert!(cursor_shell_command_targets_project( + &command, &outside, &project + )); + } +} diff --git a/src/hooks/kiro.rs b/src/hooks/kiro.rs new file mode 100644 index 00000000..44f51594 --- /dev/null +++ b/src/hooks/kiro.rs @@ -0,0 +1,249 @@ +//! Kiro hook handlers and helpers. +//! +//! Kiro sends hook event JSON on stdin. Successful hook stdout is added to +//! context, so handlers stay silent unless they intend to block (exit code 2 +//! with stderr sent back to the model). + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use super::claude::is_code_research_prompt; +use super::tool_hints::{decide_hint, HintAgent, ToolHintInput}; +use super::{ + event_cwd, event_cwd_from_parsed, event_session_id, read_hook_event, record_hook_invoked, + rel_under_root, research_block_reason, +}; + +/// Largest transcript tail the Kiro `userPromptSubmit` hook will read per call. +const KIRO_HOT_INGEST_MAX_BYTES: u64 = 256 * 1024; +/// Wall-clock budget for the Kiro prompt-submit catch-up ingest. +const KIRO_HOT_INGEST_BUDGET: std::time::Duration = std::time::Duration::from_millis(1_500); + +/// Kiro `preToolUse` hook handler. +/// +/// Blocks with exit code 2 and stderr, per Kiro's hook contract. +pub fn hook_kiro_pre_tool_use() -> i32 { + let event = read_hook_event!(); + record_hook_invoked(None, HintAgent::Kiro, "preToolUse", &event); + if let Some(reason) = evaluate_kiro_pre_tool_use(&event) { + eprintln!("{reason}"); + 2 + } else { + 0 + } +} + +/// Pure decision logic for Kiro `preToolUse` hook events. +/// +/// Returns a block reason only for Kiro delegation/subagent tool calls whose +/// task text looks like codebase research that tracedecay MCP tools should +/// answer first. +pub fn evaluate_kiro_pre_tool_use(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + let tool_name = parsed.get("tool_name").and_then(Value::as_str)?; + if !is_kiro_delegation_tool(tool_name) { + return None; + } + + let tool_input = parsed.get("tool_input").unwrap_or(&Value::Null); + if let Some(prompt) = kiro_event_text(tool_input).filter(|text| is_code_research_prompt(text)) { + let hint = decide_hint(&ToolHintInput { + agent: HintAgent::Kiro, + session_id: event_session_id(&parsed), + tool_name: Some(tool_name.to_string()), + command: None, + prompt: Some(prompt), + subagent_type: Some(tool_name.to_string()), + file_path: None, + hints_enabled: true, + }); + Some(research_block_reason(hint)) + } else { + None + } +} + +fn is_kiro_delegation_tool(tool_name: &str) -> bool { + matches!(tool_name, "delegate" | "subagent" | "use_subagent") +} + +fn kiro_event_text(value: &Value) -> Option { + let mut text = Vec::new(); + collect_kiro_task_strings(value, &mut text); + if text.is_empty() { + collect_strings(value, &mut text); + } + (!text.is_empty()).then(|| text.join("\n")) +} + +fn collect_kiro_task_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { + match value { + Value::Object(map) => { + for (key, child) in map { + let key = key.to_ascii_lowercase(); + if key.contains("prompt") + || key.contains("task") + || key.contains("query") + || key.contains("instruction") + || key.contains("message") + || key.contains("description") + { + collect_strings(child, out); + } else { + collect_kiro_task_strings(child, out); + } + } + } + Value::Array(items) => { + for item in items { + collect_kiro_task_strings(item, out); + } + } + Value::String(s) => out.push(s), + _ => {} + } +} + +fn collect_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { + match value { + Value::String(s) => out.push(s), + Value::Array(items) => { + for item in items { + collect_strings(item, out); + } + } + Value::Object(map) => { + for child in map.values() { + collect_strings(child, out); + } + } + _ => {} + } +} + +/// Kiro `userPromptSubmit` hook handler. +/// +/// Resets the per-turn counter and runs bounded Kiro transcript catch-up. +pub async fn hook_kiro_prompt_submit() -> i32 { + let event = read_hook_event!(); + record_hook_invoked(None, HintAgent::Kiro, "userPromptSubmit", &event); + reset_counter_for_kiro_event(&event).await; + ingest_kiro_transcript_for_event( + &event, + Some(KIRO_HOT_INGEST_MAX_BYTES), + KIRO_HOT_INGEST_BUDGET, + ) + .await; + 0 +} + +/// Kiro `postToolUse` hook handler used to keep the graph fresh after writes. +/// +/// Notifies the daemon after Kiro writes. Missing daemon/index state is +/// fail-open. +pub async fn hook_kiro_post_tool_use() -> i32 { + let event = read_hook_event!(); + record_hook_invoked(None, HintAgent::Kiro, "postToolUse", &event); + notify_kiro_post_tool_use(&event).await; + 0 +} + +async fn reset_counter_for_kiro_event(event_json: &str) { + let Some(project_root) = kiro_project_root(event_json) else { + return; + }; + if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_root).await { + let _ = cg.reset_local_counter().await; + } +} + +/// Incrementally ingests Kiro IDE transcripts for the workspace referenced by +/// `event_json`. Always fails open. +async fn ingest_kiro_transcript_for_event( + event_json: &str, + max_new_bytes: Option, + budget: std::time::Duration, +) { + let work = async { + let Some(project_root) = kiro_project_root(event_json) else { + return; + }; + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return; + }; + let _ = + crate::sessions::kiro::ingest_kiro_for_project(&db, &project_root, max_new_bytes).await; + }; + let _ = tokio::time::timeout(budget, work).await; +} + +async fn notify_kiro_post_tool_use(event_json: &str) { + let Some(project_root) = kiro_project_root(event_json) else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&project_root).await { + return; + } + let rel_paths = kiro_post_tool_use_rel_paths(event_json, &project_root); + crate::daemon::notify_hook_event( + &project_root, + crate::daemon::DaemonHookEvent::kiro_post_tool_use(rel_paths, event_cwd(event_json)), + ) + .await; +} + +pub fn kiro_post_tool_use_rel_paths(event_json: &str, project_root: &Path) -> Vec { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return Vec::new(); + }; + let cwd = event_cwd_from_parsed(&parsed).unwrap_or_else(|| project_root.to_path_buf()); + let tool_input = parsed + .get("tool_input") + .or_else(|| parsed.get("toolInput")) + .or_else(|| parsed.get("input")) + .unwrap_or(&Value::Null); + + let mut paths = Vec::new(); + collect_event_path_fields(&parsed, &mut paths); + collect_event_path_fields(tool_input, &mut paths); + + let mut rels = Vec::new(); + for path in paths { + let path = Path::new(&path); + let abs = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + if let Some(rel) = rel_under_root(project_root, &abs) { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } + rels +} + +fn collect_event_path_fields(value: &Value, out: &mut Vec) { + for key in ["file_path", "filePath", "path", "target_file", "targetFile"] { + match value.get(key) { + Some(Value::String(path)) if !path.is_empty() => out.push(path.clone()), + Some(Value::Array(paths)) => { + out.extend( + paths + .iter() + .filter_map(Value::as_str) + .filter(|path| !path.is_empty()) + .map(str::to_string), + ); + } + _ => {} + } + } +} + +fn kiro_project_root(event_json: &str) -> Option { + let cwd = event_cwd(event_json).or_else(|| std::env::current_dir().ok())?; + crate::config::discover_project_root(&cwd) +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 00000000..20735944 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,435 @@ +//! Hook handlers for Claude Code, Kiro, Cursor, and Codex integrations. +//! +//! These functions are invoked by each agent's hook system to intercept tool +//! calls, redirect exploration work to tracedecay MCP tools, keep the index +//! fresh after edits / git state changes, and track per-session token savings. +//! Each agent sends its own event schema on stdin and expects its own output +//! shape, so the handlers are kept agent-specific rather than shared blindly. +//! +//! This module holds the shared plumbing (stdin reader, hook analytics, +//! event-field helpers, and per-session hint dedupe); the per-agent handlers +//! live in the `claude`, `codex`, `cursor`, and `kiro` submodules, with the +//! shared post-tool-use pipeline in `post_tool_use` and the session/steering +//! context builders in `steering`. Every public item is re-exported here so +//! it stays reachable at `crate::hooks::`. + +use std::collections::HashSet; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +mod claude; +mod codex; +mod cursor; +mod cursor_compact; +mod cursor_shell; +mod kiro; +mod post_tool_use; +mod steering; +pub mod tool_hints; + +pub use claude::{ + evaluate_hook_decision, hook_claude_post_tool_use, hook_claude_session_start, + hook_pre_tool_use, hook_prompt_submit, hook_stop, +}; +pub use codex::{ + codex_additional_context_json, codex_apply_patch_rel_paths, codex_project_root_from_event, + codex_subagent_start_log_line, codex_user_prompt_submit_context_for_event, + codex_workspace_status_from_event, evaluate_codex_subagent_start, hook_codex_post_compact, + hook_codex_post_tool_use, hook_codex_session_start, hook_codex_subagent_start, + hook_codex_user_prompt_submit, record_codex_subagent_start, +}; +pub use cursor::{ + cursor_after_file_edit_rel_paths, cursor_post_tool_use_decision, + cursor_project_root_from_event, cursor_session_start_json, cursor_should_run_sync, + evaluate_cursor_post_tool_use, evaluate_cursor_subagent_start, hook_cursor_after_file_edit, + hook_cursor_after_shell, hook_cursor_before_submit_prompt, hook_cursor_post_tool_use, + hook_cursor_pre_compact, hook_cursor_session_end, hook_cursor_session_start, hook_cursor_stop, + hook_cursor_subagent_start, hook_cursor_workspace_open, CURSOR_CATCH_UP_INGEST_MAX_BYTES, +}; +pub use cursor_compact::{cursor_pre_compact_for_event_with_config, CursorPreCompactOutcome}; +pub use cursor_shell::{ + cursor_branch_switch_target, cursor_shell_command_targets_project, cursor_shell_sync_plan, + cursor_shell_sync_plan_with_current_branch, is_git_state_changing_command, + resolve_worktree_add_root, CursorShellSyncPlan, +}; +pub use kiro::{ + evaluate_kiro_pre_tool_use, hook_kiro_post_tool_use, hook_kiro_pre_tool_use, + hook_kiro_prompt_submit, kiro_post_tool_use_rel_paths, +}; +pub use post_tool_use::{ + claude_post_tool_use_matcher, CLAUDE_POST_TOOL_USE_EDIT_TOOLS, CLAUDE_POST_TOOL_USE_SHELL_TOOLS, +}; +pub use steering::{ + build_codex_session_context, build_codex_session_context_for_workspace, + build_cursor_session_context, cursor_staleness_hint, HookWorkspaceStatus, CURSOR_PLUGIN_SKILLS, +}; + +pub(crate) use cursor_shell::shell_words; + +use tool_hints::{HintAgent, HintCategory, ToolHint}; + +macro_rules! read_hook_event { + () => {{ + match $crate::hooks::read_stdin_to_string() { + Ok(event) => event, + Err(e) => { + eprintln!("tracedecay hook: failed to read stdin: {e}"); + return 1; + } + } + }}; +} +pub(crate) use read_hook_event; + +const TRACEDECAY_RESEARCH_BLOCK_REASON: &str = "STOP: Use tracedecay MCP tools \ +(tracedecay_context, tracedecay_search, tracedecay_callees, tracedecay_callers, \ +tracedecay_impact, tracedecay_files, tracedecay_affected) instead of agents for \ +code research. TraceDecay is faster and more precise for symbol relationships, \ +call paths, and code structure. Only use agents for code exploration if you \ +have already tried tracedecay and it cannot answer the question."; + +const HOOK_ANALYTICS_FILENAME: &str = "hook_analytics.jsonl"; + +fn research_block_reason(hint: Option) -> String { + let base = crate::config::brand_env("RESEARCH_BLOCK_REASON") + .unwrap_or_else(|| TRACEDECAY_RESEARCH_BLOCK_REASON.to_string()); + hint.map_or_else( + || base.clone(), + |hint| format!("{}\n\n{}", base, format_tool_hint(&hint)), + ) +} + +fn record_hook_analytics(root: Option<&Path>, event: &str, mut fields: serde_json::Value) { + let Some(path) = hook_analytics_path(root) else { + return; + }; + let Some(fields) = fields.as_object_mut() else { + return; + }; + fields.insert( + "event".to_string(), + serde_json::Value::String(event.to_string()), + ); + fields.insert( + "ts_unix_ms".to_string(), + serde_json::Value::Number(serde_json::Number::from(now_unix_millis())), + ); + let Ok(line) = serde_json::to_string(&fields) else { + return; + }; + append_private_jsonl(&path, &line); +} + +fn hook_analytics_path(root: Option<&Path>) -> Option { + match root { + Some(root) => crate::storage::resolve_layout_for_current_profile(root) + .ok() + .map(|layout| layout.data_root.join(HOOK_ANALYTICS_FILENAME)), + None => crate::storage::default_profile_root() + .ok() + .map(|root| root.join(HOOK_ANALYTICS_FILENAME)), + } +} + +fn append_private_jsonl(path: &Path, line: &str) { + let _ = crate::storage::PrivateStoreIo::append_line(path, line); +} + +fn now_unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or_default() +} + +fn now_unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +fn record_hook_invoked(root: Option<&Path>, agent: HintAgent, hook_name: &str, event_json: &str) { + let parsed: Value = serde_json::from_str(event_json).unwrap_or(Value::Null); + record_hook_analytics( + root, + "hook_invoked", + serde_json::json!({ + "agent": agent.as_key(), + "hook_name": hook_name, + "hook_event_name": text_field(&parsed, &["hook_event_name", "hookEventName"]), + "session_id": event_session_id(&parsed), + "tool_name": text_field(&parsed, &["tool_name", "toolName", "name"]), + "command": text_field(&parsed, &["command", "cmd", "shell_command"]), + "prompt_category": inferred_prompt_category(&parsed), + }), + ); +} + +fn inferred_prompt_category(parsed: &Value) -> Option<&'static str> { + let text = prompt_like_text(parsed) + .unwrap_or_default() + .to_ascii_lowercase(); + if text.is_empty() { + return None; + } + if claude::is_code_research_prompt(&text) { + Some("code_research") + } else if text.contains("test") || text.contains("failing") || text.contains("ci") { + Some("test_or_ci") + } else if text.contains("dashboard") || text.contains("ui") || text.contains("frontend") { + Some("dashboard_or_ui") + } else if text.contains("bug") || text.contains("fix") || text.contains("error") { + Some("debug_or_fix") + } else { + Some("general") + } +} + +fn record_hint_analytics( + root: Option<&Path>, + event: &str, + agent: HintAgent, + session_id: Option<&str>, + hint: &ToolHint, +) { + record_hook_analytics( + root, + event, + serde_json::json!({ + "agent": agent.as_key(), + "session_id": session_id, + "category": hint.category.as_key(), + }), + ); +} + +fn record_workspace_status_analytics( + root: Option<&Path>, + status: HookWorkspaceStatus, + session_id: Option<&str>, +) { + record_hook_analytics( + root, + "workspace_status", + serde_json::json!({ + "agent": HintAgent::Codex.as_key(), + "session_id": session_id, + "workspace_status": status.as_key(), + }), + ); +} + +fn record_hint_emitted( + root: Option<&Path>, + agent: HintAgent, + session_id: Option<&str>, + hint: &ToolHint, +) { + if session_id.is_none() { + record_hint_analytics(root, "missing_session", agent, None, hint); + } + record_hint_analytics(root, "hint_emitted", agent, session_id, hint); +} + +fn deduped_project_hint( + root: Option, + agent: HintAgent, + session_id: Option, + hint: ToolHint, +) -> Option { + let Some(root) = root else { + record_hint_emitted(None, agent, session_id.as_deref(), &hint); + return Some(hint); + }; + let Some(session_id) = session_id else { + record_hint_emitted(Some(&root), agent, None, &hint); + return Some(hint); + }; + if !remember_hint_in_process(&root, agent, &session_id, hint.category) { + record_hint_analytics( + Some(&root), + "suppressed_duplicate", + agent, + Some(&session_id), + &hint, + ); + return None; + } + let Ok(layout) = crate::storage::resolve_layout_for_current_profile(&root) else { + record_hint_emitted(Some(&root), agent, Some(&session_id), &hint); + return Some(hint); + }; + if !layout.data_root.is_dir() { + record_hint_emitted(Some(&root), agent, Some(&session_id), &hint); + return Some(hint); + } + let path = layout.data_root.join("tool_hints_seen.json"); + let mut dedupe = tool_hints::ToolHintDedupe::load_or_default(&path); + if !dedupe.should_emit(&session_id, hint.category) { + record_hint_analytics( + Some(&root), + "suppressed_duplicate", + agent, + Some(&session_id), + &hint, + ); + return None; + } + let _ = dedupe.save(&path); + record_hint_analytics(Some(&root), "hint_emitted", agent, Some(&session_id), &hint); + Some(hint) +} + +fn remember_hint_in_process( + root: &Path, + agent: HintAgent, + session_id: &str, + category: HintCategory, +) -> bool { + static MEMORY: OnceLock>> = OnceLock::new(); + let key = format!( + "{}\0{}\0{}\0{}", + root.display(), + agent.as_key(), + session_id, + category.as_key() + ); + let Ok(mut memory) = MEMORY.get_or_init(|| Mutex::new(HashSet::new())).lock() else { + return true; + }; + memory.insert(key) +} + +fn nearest_project_like_root(start: &Path) -> Option { + if let Some(root) = crate::worktree::git_worktree_root(start) { + return Some(root); + } + let mut dir = start.to_path_buf(); + loop { + if project_marker_exists(&dir) { + return Some(dir); + } + if !dir.pop() { + return None; + } + } +} + +fn is_project_like_workspace(cwd: &Path) -> bool { + nearest_project_like_root(cwd).is_some() +} + +fn project_marker_exists(dir: &Path) -> bool { + const MARKERS: &[&str] = &[ + ".git", + "Cargo.toml", + "package.json", + "pyproject.toml", + "go.mod", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "deno.json", + "tsconfig.json", + ]; + MARKERS.iter().any(|marker| dir.join(marker).exists()) +} + +fn rel_under_root(root: &Path, abs: &Path) -> Option { + let stripped = abs.strip_prefix(root).ok()?; + if stripped.as_os_str().is_empty() { + return None; + } + if stripped.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return None; + } + Some(stripped.to_string_lossy().replace('\\', "/")) +} + +fn text_field(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn prompt_like_text(parsed: &Value) -> Option { + [ + "prompt", + "user_prompt", + "message", + "input", + "task", + "description", + ] + .iter() + .find_map(|key| parsed.get(*key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn event_session_id(parsed: &Value) -> Option { + ["session_id", "conversation_id", "chat_id"] + .iter() + .find_map(|key| parsed.get(*key).and_then(Value::as_str)) + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +fn event_i64(parsed: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + let value = parsed.get(*key)?; + value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .or_else(|| value.as_str()?.parse::().ok()) + }) +} + +fn event_usize(parsed: &Value, keys: &[&str]) -> Option { + event_i64(parsed, keys).and_then(|value| usize::try_from(value).ok()) +} + +/// Reads the `cwd` string field from a hook event JSON payload. Shared by the +/// Kiro and Codex handlers, both of which send the session working directory. +fn event_cwd(event_json: &str) -> Option { + let parsed: Value = serde_json::from_str(event_json).ok()?; + event_cwd_from_parsed(&parsed) +} + +fn event_cwd_from_parsed(parsed: &Value) -> Option { + let cwd = parsed.get("cwd").and_then(Value::as_str)?; + let path = Path::new(cwd); + if path.as_os_str().is_empty() { + None + } else { + Some(path.to_path_buf()) + } +} + +fn format_tool_hint(hint: &ToolHint) -> String { + format!("tracedecay hint: {}\n{}", hint.message, hint.context) +} + +fn append_tool_hint(context: &mut String, hint: &ToolHint) { + if !context.ends_with('\n') { + context.push('\n'); + } + context.push_str(&format_tool_hint(hint)); + context.push('\n'); +} + +pub(crate) fn read_stdin_to_string() -> std::io::Result { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input)?; + Ok(input) +} diff --git a/src/hooks/post_tool_use.rs b/src/hooks/post_tool_use.rs new file mode 100644 index 00000000..d56b79b9 --- /dev/null +++ b/src/hooks/post_tool_use.rs @@ -0,0 +1,246 @@ +//! Shared post-tool-use pipeline for the Claude Code and Codex hooks. +//! +//! Both agents send the same shaped event (Codex adopted Claude's hook +//! schema): parse the event JSON, read `tool_name`, resolve the session `cwd` +//! and project root, check for an initialized store, then notify the daemon +//! about edits (targeted sync) or shell commands (branch tracking / +//! coalesced sync). Each agent supplies a [`PostToolUseSpec`] with its own +//! tool-name predicates and edit-path extractor. + +use std::path::Path; + +use serde_json::Value; + +use super::{codex, event_cwd_from_parsed, rel_under_root}; + +/// Claude Code tools whose `PostToolUse` events the hook consumes. The +/// installer's `PostToolUse` matcher is derived from this list so the matcher +/// and the handler predicates can never drift. +pub const CLAUDE_POST_TOOL_USE_EDIT_TOOLS: &[&str] = + &["Edit", "MultiEdit", "Write", "NotebookEdit"]; +pub const CLAUDE_POST_TOOL_USE_SHELL_TOOLS: &[&str] = &["Bash"]; + +/// `Edit|MultiEdit|Write|NotebookEdit|Bash` — the Claude settings.json matcher. +pub fn claude_post_tool_use_matcher() -> String { + CLAUDE_POST_TOOL_USE_EDIT_TOOLS + .iter() + .chain(CLAUDE_POST_TOOL_USE_SHELL_TOOLS) + .copied() + .collect::>() + .join("|") +} + +/// Per-agent parameterization of the shared post-tool-use pipeline. +pub(crate) struct PostToolUseSpec { + pub agent: crate::daemon::HookAgent, + pub is_edit_tool: fn(&str) -> bool, + pub is_shell_tool: fn(&str) -> bool, + /// (parsed event, session cwd, project root) -> project-relative paths + pub edit_rel_paths: fn(&Value, &Path, &Path) -> Vec, +} + +pub(crate) const CLAUDE_POST_TOOL_USE_SPEC: PostToolUseSpec = PostToolUseSpec { + agent: crate::daemon::HookAgent::Claude, + is_edit_tool: is_claude_edit_tool, + is_shell_tool: is_claude_bash_tool, + edit_rel_paths: claude_edit_rel_paths, +}; + +pub(crate) const CODEX_POST_TOOL_USE_SPEC: PostToolUseSpec = PostToolUseSpec { + agent: crate::daemon::HookAgent::Codex, + is_edit_tool: is_codex_edit_tool, + is_shell_tool: is_codex_bash_tool, + edit_rel_paths: codex_edit_rel_paths, +}; + +/// Shared post-tool-use daemon notification. Fail-open and silent. +/// +/// Behavior note: empty shell commands are skipped for both agents. The +/// Claude path always did this; the Codex path previously forwarded empty +/// commands (which produced no-op daemon events), so the two were unified on +/// the safer skip-empty behavior. +pub(crate) async fn notify_post_tool_use(spec: &PostToolUseSpec, event_json: &str) { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return; + }; + let tool_name = parsed + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or_default(); + let Some(cwd) = event_cwd_from_parsed(&parsed) else { + return; + }; + let Some(root) = crate::config::discover_project_root(&cwd) + .or_else(|| crate::worktree::git_worktree_root(&cwd)) + else { + return; + }; + if !crate::tracedecay::TraceDecay::has_initialized_store(&root).await { + return; + } + + if (spec.is_edit_tool)(tool_name) { + let rels = (spec.edit_rel_paths)(&parsed, &cwd, &root); + if rels.is_empty() { + return; + } + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::post_tool_use_edit(spec.agent, rels, cwd), + ) + .await; + } else if (spec.is_shell_tool)(tool_name) { + let command = tool_input_command(&parsed); + if command.is_empty() { + return; + } + crate::daemon::notify_hook_event( + &root, + crate::daemon::DaemonHookEvent::post_tool_use_shell( + spec.agent, + command.to_string(), + cwd, + ), + ) + .await; + } +} + +fn tool_input_command(parsed: &Value) -> &str { + parsed + .get("tool_input") + .and_then(|ti| ti.get("command")) + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn is_claude_edit_tool(tool_name: &str) -> bool { + CLAUDE_POST_TOOL_USE_EDIT_TOOLS + .iter() + .any(|tool| tool.eq_ignore_ascii_case(tool_name)) +} + +fn is_claude_bash_tool(tool_name: &str) -> bool { + CLAUDE_POST_TOOL_USE_SHELL_TOOLS + .iter() + .any(|tool| tool.eq_ignore_ascii_case(tool_name)) +} + +fn is_codex_edit_tool(tool_name: &str) -> bool { + matches!( + tool_name.to_ascii_lowercase().as_str(), + "apply_patch" | "edit" | "write" + ) +} + +fn is_codex_bash_tool(tool_name: &str) -> bool { + matches!(tool_name.to_ascii_lowercase().as_str(), "bash" | "shell") +} + +/// Extracts the project-relative path edited by a Claude edit tool. +/// +/// Claude's `Edit`/`Write`/`MultiEdit` put the target in +/// `tool_input.file_path`; `NotebookEdit` uses `tool_input.notebook_path`. +/// Paths are usually absolute but are resolved against the session `cwd` +/// when relative. Paths outside `project_root` are skipped. +fn claude_edit_rel_paths(parsed: &Value, cwd: &Path, project_root: &Path) -> Vec { + ["file_path", "notebook_path"] + .iter() + .filter_map(|key| { + parsed + .get("tool_input") + .and_then(|ti| ti.get(*key)) + .and_then(Value::as_str) + }) + .filter(|raw| !raw.is_empty()) + .filter_map(|raw| { + let candidate = Path::new(raw); + let abs = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + cwd.join(candidate) + }; + rel_under_root(project_root, &abs) + }) + .collect() +} + +/// Extracts the project-relative paths edited by a Codex edit tool. Codex +/// sends the `apply_patch` envelope as `tool_input.command`; the per-file +/// parsing lives in [`codex::codex_apply_patch_rel_paths`]. +fn codex_edit_rel_paths(parsed: &Value, cwd: &Path, project_root: &Path) -> Vec { + codex::codex_apply_patch_rel_paths(tool_input_command(parsed), cwd, project_root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_edit_tools_are_recognized_case_insensitively() { + for tool in ["Edit", "Write", "MultiEdit", "NotebookEdit", "write"] { + assert!(is_claude_edit_tool(tool), "{tool} should count as an edit"); + } + assert!(!is_claude_edit_tool("Bash")); + assert!(!is_claude_edit_tool("Read")); + assert!(is_claude_bash_tool("Bash")); + assert!(!is_claude_bash_tool("Edit")); + } + + #[test] + fn claude_post_tool_use_matcher_derives_from_tool_lists() { + assert_eq!( + claude_post_tool_use_matcher(), + "Edit|MultiEdit|Write|NotebookEdit|Bash" + ); + for tool in CLAUDE_POST_TOOL_USE_EDIT_TOOLS { + assert!(is_claude_edit_tool(tool), "{tool} should count as an edit"); + assert!(!is_claude_bash_tool(tool)); + } + for tool in CLAUDE_POST_TOOL_USE_SHELL_TOOLS { + assert!(is_claude_bash_tool(tool), "{tool} should count as shell"); + assert!(!is_claude_edit_tool(tool)); + } + } + + #[test] + fn claude_edit_rel_paths_resolves_file_path_against_project_root() { + let root = Path::new("/repo"); + let cwd = Path::new("/repo/sub"); + let event = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/repo/src/lib.rs" } + }); + assert_eq!( + claude_edit_rel_paths(&event, cwd, root), + vec!["src/lib.rs".to_string()] + ); + + // Relative paths resolve against the session cwd. + let event = serde_json::json!({ + "tool_name": "Write", + "tool_input": { "file_path": "module.rs" } + }); + assert_eq!( + claude_edit_rel_paths(&event, cwd, root), + vec!["sub/module.rs".to_string()] + ); + + // NotebookEdit uses notebook_path. + let event = serde_json::json!({ + "tool_name": "NotebookEdit", + "tool_input": { "notebook_path": "/repo/analysis.ipynb" } + }); + assert_eq!( + claude_edit_rel_paths(&event, cwd, root), + vec!["analysis.ipynb".to_string()] + ); + + // Paths outside the project root are skipped. + let event = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/elsewhere/other.rs" } + }); + assert!(claude_edit_rel_paths(&event, cwd, root).is_empty()); + } +} diff --git a/src/hooks/steering.rs b/src/hooks/steering.rs new file mode 100644 index 00000000..e0fb44f6 --- /dev/null +++ b/src/hooks/steering.rs @@ -0,0 +1,274 @@ +//! Session/steering context builders shared by the Cursor, Claude, and Codex +//! session hooks: index-freshness lines, the workflow-skill index, and the +//! post-compaction context-recovery hint. + +use std::path::Path; + +use serde_json::Value; + +use super::now_unix_secs; + +/// Model-invocable workflow skills shipped in the tracedecay Cursor plugin's +/// `skills/` directory (slash dispatchers with `disable-model-invocation: +/// true` are excluded). Kept as one constant so the session steering context +/// and the bundle coverage test in `agents::cursor` stay in sync. +pub const CURSOR_PLUGIN_SKILLS: &[&str] = &[ + "architecture-overview", + "assessing-test-coverage", + "atomic-code-edits", + "auditing-code-safety", + "cleaning-up-dead-code", + "code-health-report", + "cross-branch-investigation", + "curating-project-memory", + "drafting-commit-and-pr", + "exploring-types-and-traits", + "finding-duplicate-logic", + "finding-impacted-areas", + "fixing-build-and-type-errors", + "porting-code", + "project-status", + "reading-code-cheaply", + "recalling-project-memory", + "recalling-session-context", + "refactoring-safely", + "reviewing-a-diff", + "running-impacted-tests", + "searching-for-code", + "tracing-functions", + "tracking-session-health", + "using-the-cli", +]; + +pub(super) const COMPACTION_CONTEXT_RECOVERY_HINT: &str = "Context was just compacted. If important prior-session context seems missing, query TraceDecay session context before assuming the compacted summary is complete. Start with `tracedecay_message_search` or `tracedecay_lcm_expand_query`; use `tracedecay_lcm_describe` and `tracedecay_lcm_expand` when you need the summary DAG sources."; + +/// Builds the Cursor `sessionStart` `additional_context` text. +/// +/// Intentionally lean: the always-applied plugin rule already carries the +/// tool-routing steering, so repeating it here would burn tokens every +/// session. This adds only the session-specific signals — index freshness, +/// the workflow-skill index, and the tokens-saved counter. +pub fn build_cursor_session_context( + initialized: bool, + staleness_hint: Option<&str>, + tokens_saved: Option, +) -> String { + let mut s = index_status_line(initialized, staleness_hint); + if initialized { + s.push_str("Workflow skills: tracedecay:"); + s.push_str(&CURSOR_PLUGIN_SKILLS.join(", ")); + s.push_str(" — each maps a common workflow to the right tracedecay tools.\n"); + if let Some(saved) = tokens_saved.filter(|saved| *saved > 0) { + s.push_str("Tokens saved by tracedecay this session: "); + s.push_str(&saved.to_string()); + s.push_str(".\n"); + } + } + s +} + +/// One-line index freshness signal shared by the Cursor and Claude session +/// contexts. Both hosts carry the tool-routing steering in an always-applied +/// rule (Cursor plugin rule, CLAUDE.md), so their session hooks report only +/// session-specific signals. +pub(super) fn index_status_line(initialized: bool, staleness_hint: Option<&str>) -> String { + if initialized { + match staleness_hint { + Some(hint) => format!("tracedecay index status: {hint}.\n"), + None => "tracedecay index status: initialized.\n".to_string(), + } + } else { + "tracedecay index status: no project index found in this workspace — \ + run `tracedecay init` to enable tracedecay MCP tools.\n" + .to_string() + } +} + +/// Builds the Codex session/prompt steering context. Codex has no +/// always-applied tracedecay rule, so the full tool-routing steering lives +/// here. +pub fn build_codex_session_context(initialized: bool, staleness_hint: Option<&str>) -> String { + let status = if initialized { + HookWorkspaceStatus::Initialized + } else { + HookWorkspaceStatus::UnindexedProject + }; + build_codex_session_context_for_workspace(status, staleness_hint) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookWorkspaceStatus { + Initialized, + UnindexedProject, + Generic, +} + +impl HookWorkspaceStatus { + pub(super) fn as_key(self) -> &'static str { + match self { + HookWorkspaceStatus::Initialized => "initialized", + HookWorkspaceStatus::UnindexedProject => "unindexed_project", + HookWorkspaceStatus::Generic => "generic", + } + } +} + +/// Builds the Codex session/prompt context for the detected workspace kind. +pub fn build_codex_session_context_for_workspace( + status: HookWorkspaceStatus, + staleness_hint: Option<&str>, +) -> String { + let mut s = String::new(); + match status { + HookWorkspaceStatus::Initialized | HookWorkspaceStatus::UnindexedProject => { + s.push_str( + "tracedecay is available via MCP. Prefer tracedecay MCP tools \ + (tracedecay_context, tracedecay_search, tracedecay_callers, tracedecay_callees, \ + tracedecay_impact, tracedecay_files, tracedecay_affected) over broad file reads \ + or shell search for codebase exploration, symbol lookup, call graphs, and \ + impact analysis. Fall back to file reads only when tracedecay cannot answer.\n\ + If an MCP call errors, times out, or the server is disconnected, every tool \ + is also a shell command: `tracedecay tool --key value` (`tracedecay \ + tool` lists tools, `tracedecay tool --help` shows parameters). Use \ + that CLI instead of querying .tracedecay databases directly or abandoning \ + tracedecay.\n", + ); + append_codex_recall_and_registry_guidance(&mut s); + match status { + HookWorkspaceStatus::Initialized => match staleness_hint { + Some(hint) => { + s.push_str("Index status: "); + s.push_str(hint); + s.push_str(".\n"); + } + None => s.push_str("Index status: initialized.\n"), + }, + HookWorkspaceStatus::UnindexedProject => s.push_str( + "Index status: no project index found in this code workspace — \ + run `tracedecay init` to enable tracedecay code-graph tools.\n", + ), + HookWorkspaceStatus::Generic => {} + } + } + HookWorkspaceStatus::Generic => { + s.push_str( + "TraceDecay session context is available via MCP. For prior conversation \ + recovery, use tracedecay_lcm_expand_query, tracedecay_message_search, and \ + tracedecay_lcm_describe before asking the user to repeat themselves. Use \ + tracedecay_fact_store only for durable preferences, environment details, \ + tool quirks, or decisions that will still matter later. Do not store task \ + progress, temporary TODOs, or soon-stale session outcomes; recover those \ + from transcripts instead.\n", + ); + s.push_str("Workspace status: no active project workspace; no setup guidance needed for this prompt.\n"); + } + } + s +} + +fn append_codex_recall_and_registry_guidance(s: &mut String) { + s.push_str( + "For other registered projects or sibling workspaces, check \ + tracedecay_project_list or tracedecay_project_search first; use \ + tracedecay_project_context to confirm the target and pass project_id or \ + project_path to tracedecay_context/search for cross-project code context before \ + scanning parent directories. When the user references prior conversation or \ + missing context, use tracedecay_message_search or tracedecay_lcm_expand_query \ + before asking the user to repeat themselves. Use tracedecay_fact_store only for \ + durable preferences, environment details, tool quirks, or decisions that will \ + still matter later. Do not store task progress, temporary TODOs, or soon-stale \ + session outcomes; recover those from transcripts instead.\n", + ); +} + +pub(super) fn append_context_recovery_hint(context: &mut String) { + if !context.is_empty() && !context.ends_with('\n') { + context.push('\n'); + } + context.push_str(COMPACTION_CONTEXT_RECOVERY_HINT); + context.push('\n'); +} + +pub(super) fn session_start_from_compaction(event_json: &str) -> bool { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return false; + }; + ["source", "trigger", "reason", "boundary_reason"] + .iter() + .filter_map(|key| parsed.get(*key).and_then(Value::as_str)) + .any(matches_compaction_source) +} + +fn matches_compaction_source(value: &str) -> bool { + let normalized = value + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::() + .to_ascii_lowercase(); + matches!( + normalized.as_str(), + "compact" | "compaction" | "contextcompacted" | "compression" + ) +} + +/// Formats a short relative-age staleness hint from a sync age in seconds. +pub fn cursor_staleness_hint(age_secs: i64) -> String { + let age = age_secs.max(0); + if age < 60 { + "last indexed just now".to_string() + } else if age < 3_600 { + format!("last indexed {}m ago", age / 60) + } else if age < 86_400 { + format!("last indexed {}h ago", age / 3_600) + } else { + format!("last indexed {}d ago", age / 86_400) + } +} + +/// Opens the index once and reads both session-steering signals: the +/// staleness hint and the session tokens-saved counter. +pub(super) async fn cursor_index_signals_for_root(root: &Path) -> (Option, Option) { + let Ok(cg) = crate::tracedecay::TraceDecay::open(root).await else { + return (None, None); + }; + let last = cg.last_sync_timestamp().await; + let staleness = (last > 0).then(|| cursor_staleness_hint(now_unix_secs() - last)); + let tokens_saved = cg.get_tokens_saved().await.ok(); + (staleness, tokens_saved) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_session_start_events_get_recovery_hint() { + let event = serde_json::json!({ "source": "compact" }).to_string(); + assert!(session_start_from_compaction(&event)); + + let mut context = build_codex_session_context(true, None); + append_context_recovery_hint(&mut context); + assert!(context.contains("Context was just compacted")); + assert!(context.contains("tracedecay_lcm_expand_query")); + assert!(context.contains("tracedecay_lcm_describe")); + } + + #[test] + fn non_compact_session_start_events_do_not_get_recovery_hint() { + let event = serde_json::json!({ "source": "resume" }).to_string(); + assert!(!session_start_from_compaction(&event)); + } + + #[test] + fn index_status_line_formats_freshness_and_init_nudge() { + assert_eq!( + index_status_line(true, Some("last indexed 5m ago")), + "tracedecay index status: last indexed 5m ago.\n" + ); + assert_eq!( + index_status_line(true, None), + "tracedecay index status: initialized.\n" + ); + assert!(index_status_line(false, None).contains("run `tracedecay init`")); + } +} diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 37a400e9..1e9d640b 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -7,9 +7,7 @@ use std::path::{Component, Path, PathBuf}; use serde_json::Value; -/// Shared hook-agent identity: the same enum the hook processes use to build -/// events, so a host registered on the send side can never be silently -/// dropped by the receiver (see `crate::daemon::HookAgent`). +/// Shared with hook emitters so the receiver accepts the same agent keys. pub(crate) use crate::daemon::HookAgent; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 302baa0d..9d2cd13d 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -621,11 +621,29 @@ fn valid_single_quoted_yaml_scalar(value: &str) -> bool { #[test] fn generated_prompt_rules_do_not_hardcode_repo_local_graph_db() { + // Hosts that render their own rule text, plus the shared renderer that + // copilot/gemini/opencode/kimi/vibe delegate to. for (name, source) in [ ("claude", include_str!("../../src/agents/claude.rs")), + ("kiro", include_str!("../../src/agents/kiro.rs")), + ( + "prompt_rules", + include_str!("../../src/agents/prompt_rules.rs"), + ), + ] { + assert!( + !source.contains(".tracedecay/tracedecay.db"), + "{name} generated guidance must not hardcode the repo-local graph DB path" + ); + assert!( + source.contains("tracedecay_active_project") + && source.contains("tracedecay_storage_status"), + "{name} generated guidance should point store questions to active-project/storage-status tools" + ); + } + for (name, source) in [ ("copilot", include_str!("../../src/agents/copilot.rs")), ("gemini", include_str!("../../src/agents/gemini.rs")), - ("kiro", include_str!("../../src/agents/kiro.rs")), ("kimi", include_str!("../../src/agents/kimi.rs")), ("opencode", include_str!("../../src/agents/opencode.rs")), ("vibe", include_str!("../../src/agents/vibe.rs")), @@ -635,9 +653,8 @@ fn generated_prompt_rules_do_not_hardcode_repo_local_graph_db() { "{name} generated guidance must not hardcode the repo-local graph DB path" ); assert!( - source.contains("tracedecay_active_project") - && source.contains("tracedecay_storage_status"), - "{name} generated guidance should point store questions to active-project/storage-status tools" + source.contains("standard_prompt_rules"), + "{name} should delegate prompt rules to the shared renderer" ); } } @@ -1040,6 +1057,16 @@ fn test_hermes_local_install_writes_profile_plugin() { let skill = std::fs::read_to_string(plugin_dir.join("skills/tracedecay/SKILL.md")).unwrap(); assert!(skill.contains("Use tracedecay")); + // CLI-fallback steering: mirrors CLI_FALLBACK_PROMPT_RULES, worded for the + // Hermes plugin surface (tool calls already shell out to the tracedecay CLI). + assert!( + skill.contains("tracedecay tool ") && skill.contains("--help"), + "Hermes skill must steer the agent to the `tracedecay tool` CLI when a tool invocation fails" + ); + assert!( + skill.contains("instead of querying"), + "Hermes skill must warn against querying .tracedecay databases directly" + ); assert_hermes_config_enables_tracedecay_memory(&project.path().join(".hermes/config.yaml")); assert!( diff --git a/tests/agent_suite/main.rs b/tests/agent_suite/main.rs index 6c7d7c27..12630592 100644 --- a/tests/agent_suite/main.rs +++ b/tests/agent_suite/main.rs @@ -16,6 +16,7 @@ mod kiro_agent_test; mod managed_skills_test; mod opencode_agent_test; mod plugin_skill_contract_test; +mod prompt_rules_parity_test; mod skill_targets_test; mod skill_usage_test; mod update_plugin_test; diff --git a/tests/agent_suite/prompt_rules_parity_test.rs b/tests/agent_suite/prompt_rules_parity_test.rs new file mode 100644 index 00000000..dc42fe69 --- /dev/null +++ b/tests/agent_suite/prompt_rules_parity_test.rs @@ -0,0 +1,219 @@ +//! CLI-fallback parity for every prompt-rules host. + +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; +use tracedecay::agents::prompt_rules::cli_fallback_paragraph; +use tracedecay::agents::{expected_tool_perms, get_integration, InstallContext}; +use tracedecay::config::USER_DATA_DIR_ENV; + +use crate::common::{EnvVarGuard, PROCESS_ENV_LOCK}; + +const STANDARD_MARKER: &str = "## Prefer tracedecay MCP tools"; +const CLAUDE_MARKER: &str = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; +const KIRO_END_MARKER: &str = ""; + +struct HostCase { + id: &'static str, + rules_path: fn(&Path) -> PathBuf, + marker: &'static str, + stale_block_tail: &'static str, +} + +fn hosts() -> Vec { + vec![ + HostCase { + id: "claude", + rules_path: |home| home.join(".claude/CLAUDE.md"), + marker: CLAUDE_MARKER, + stale_block_tail: "", + }, + HostCase { + id: "copilot", + rules_path: |home| home.join(".copilot/copilot-instructions.md"), + marker: STANDARD_MARKER, + stale_block_tail: "", + }, + HostCase { + id: "gemini", + rules_path: |home| home.join(".gemini/GEMINI.md"), + marker: STANDARD_MARKER, + stale_block_tail: "", + }, + HostCase { + id: "opencode", + rules_path: |home| home.join(".config/opencode/AGENTS.md"), + marker: STANDARD_MARKER, + stale_block_tail: "", + }, + HostCase { + id: "kimi", + rules_path: |home| home.join(".kimi/AGENTS.md"), + marker: STANDARD_MARKER, + stale_block_tail: "", + }, + HostCase { + id: "vibe", + rules_path: |home| home.join(".vibe/prompts/cli.md"), + marker: STANDARD_MARKER, + stale_block_tail: "", + }, + HostCase { + id: "kiro", + rules_path: |home| home.join(".kiro/steering/tracedecay.md"), + marker: STANDARD_MARKER, + stale_block_tail: "\n\n", + }, + ] +} + +fn install_ctx(home: &Path) -> InstallContext { + InstallContext { + home: home.to_path_buf(), + tracedecay_bin: "/usr/local/bin/tracedecay".to_string(), + tool_permissions: expected_tool_perms(), + profile: None, + project_root: None, + dashboard: true, + } +} + +/// Pins profile storage into the temp home and clears host-home overrides so +/// installers resolve every path under the throwaway home. Callers must hold +/// [`PROCESS_ENV_LOCK`] while the guards are alive. +fn env_guards(home: &Path) -> Vec { + vec![ + EnvVarGuard::set(USER_DATA_DIR_ENV, home.join(".tracedecay")), + EnvVarGuard::unset("XDG_CONFIG_HOME"), + EnvVarGuard::unset("KIRO_HOME"), + EnvVarGuard::unset("VIBE_HOME"), + ] +} + +#[tokio::test] +async fn fresh_install_writes_cli_fallback_rules_for_every_host() { + let _env_lock = PROCESS_ENV_LOCK.lock().await; + let fallback = cli_fallback_paragraph(); + assert!( + fallback.contains("also available as a shell command"), + "CLI-fallback paragraph lost its distinctive wording" + ); + + for case in hosts() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let _guards = env_guards(home); + + let integration = get_integration(case.id).unwrap(); + integration.install(&install_ctx(home)).unwrap(); + + let path = (case.rules_path)(home); + let contents = std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "{}: rules file {} should exist after install: {e}", + case.id, + path.display() + ) + }); + assert_eq!( + contents.matches(fallback).count(), + 1, + "{}: fresh install should write the CLI-fallback paragraph exactly once to {}", + case.id, + path.display() + ); + assert!( + contents.contains(case.marker), + "{}: fresh install should write the managed rules marker", + case.id + ); + } +} + +#[tokio::test] +async fn reinstall_refreshes_stale_prompt_rules_for_every_host() { + let _env_lock = PROCESS_ENV_LOCK.lock().await; + let fallback = cli_fallback_paragraph(); + let stale_sentinel = "Old tracedecay guidance without the CLI fallback paragraph."; + + for case in hosts() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let _guards = env_guards(home); + + let path = (case.rules_path)(home); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let stale = format!( + "## User rules before\n\nKeep this user guidance.\n\n\ + {marker}\n\n{stale_sentinel}{tail}\n\n\ + ## User rules after\n\nAlso keep this user guidance.\n", + marker = case.marker, + tail = case.stale_block_tail, + ); + std::fs::write(&path, &stale).unwrap(); + + let integration = get_integration(case.id).unwrap(); + integration.install(&install_ctx(home)).unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + contents.matches(fallback).count(), + 1, + "{}: reinstall over a stale block should leave exactly one CLI-fallback paragraph in {}\n---\n{contents}", + case.id, + path.display() + ); + assert!( + !contents.contains(stale_sentinel), + "{}: stale managed block should be replaced on reinstall", + case.id + ); + assert_eq!( + contents.matches(case.marker).count(), + 1, + "{}: the managed rules marker should appear exactly once after refresh", + case.id + ); + assert!( + contents.contains("Keep this user guidance.") + && contents.contains("## User rules before"), + "{}: user content before the managed block must be preserved", + case.id + ); + assert!( + contents.contains("Also keep this user guidance.") + && contents.contains("## User rules after"), + "{}: user content after the managed block must be preserved", + case.id + ); + + integration.install(&install_ctx(home)).unwrap(); + let contents_again = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + contents_again.matches(fallback).count(), + 1, + "{}: repeated install after refresh must stay idempotent", + case.id + ); + } +} + +/// The kiro end-marker constant this test seeds must stay in sync with the +/// integration's owned end marker. +#[tokio::test] +async fn kiro_stale_seed_uses_current_end_marker() { + let _env_lock = PROCESS_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let _guards = env_guards(home); + + get_integration("kiro") + .unwrap() + .install(&install_ctx(home)) + .unwrap(); + let steering = std::fs::read_to_string(home.join(".kiro/steering/tracedecay.md")).unwrap(); + assert!( + steering.contains(KIRO_END_MARKER), + "kiro steering should end with the owned end marker" + ); +} From b0d7b785eb9055eecc9bbec44b05aa32a7e7607b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 09:05:16 +0000 Subject: [PATCH 5/6] fix(hooks): satisfy clippy on host integration tests --- src/agents/claude.rs | 8 +++++--- src/agents/prompt_rules.rs | 2 +- src/mcp/hook_events.rs | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/agents/claude.rs b/src/agents/claude.rs index bdba810c..d8e9e8f4 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -1736,15 +1736,17 @@ mod tests { } } - /// The PostToolUse matcher is derived from the hook handler's tool list, + /// The `PostToolUse` matcher is derived from the hook handler's tool list, /// so the installed matcher can never accept tools the handler ignores. #[test] fn post_tool_use_matcher_comes_from_the_hook_handler_tool_list() { - let matcher = MANAGED_HOOKS + let Some(matcher) = MANAGED_HOOKS .iter() .find(|hook| hook.event == "PostToolUse") .and_then(ManagedHook::matcher_value) - .expect("PostToolUse must register a matcher"); + else { + panic!("PostToolUse must register a matcher"); + }; assert_eq!(matcher, crate::hooks::claude_post_tool_use_matcher()); assert!(matcher.contains("Edit") && matcher.contains("Bash")); } diff --git a/src/agents/prompt_rules.rs b/src/agents/prompt_rules.rs index 093be880..8c15000a 100644 --- a/src/agents/prompt_rules.rs +++ b/src/agents/prompt_rules.rs @@ -1,6 +1,6 @@ //! Shared prompt-rules rendering and managed-block reconciliation. //! -//! Copilot, Gemini, OpenCode, Kimi, and Vibe share the same marker-gated +//! Copilot, Gemini, `OpenCode`, Kimi, and Vibe share the same marker-gated //! tracedecay rules block. Claude and Kiro keep host-specific text but reuse //! the block-splicing helpers here. diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index 1e9d640b..529d20f9 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -235,7 +235,7 @@ mod tests { } /// Regression: the receiver used to keep its own agent string match, so - /// the claude-keyed events added for Claude PostToolUse were silently + /// the claude-keyed events added for Claude `PostToolUse` were silently /// dropped. Every agent the send side can construct must parse here. #[test] fn accepts_every_constructible_hook_agent() { From 8f67f0f7e2e7fdc5ea7507ef177147e417fb8f43 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 2 Jul 2026 10:05:56 +0000 Subject: [PATCH 6/6] test(claude): tolerate CRLF agent frontmatter --- src/agents/claude.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agents/claude.rs b/src/agents/claude.rs index d8e9e8f4..f14a93e0 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -1717,16 +1717,19 @@ mod tests { fn managed_subagent_definitions_have_valid_frontmatter() { for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { let stem = file_name.trim_end_matches(".md"); - assert!( - contents.starts_with("---\n"), + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!( + lines.first().copied(), + Some("---"), "{file_name} must open YAML frontmatter" ); + let expected_name = format!("name: {stem}"); assert!( - contents.contains(&format!("name: {stem}\n")), + lines.contains(&expected_name.as_str()), "{file_name} frontmatter name must match its filename" ); assert!( - contents.contains("description: "), + lines.iter().any(|line| line.starts_with("description: ")), "{file_name} must carry a description for delegation" ); assert!(