From b2c6eb5cae0156995b19631bedfeca7987ea98c7 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 26 Aug 2026 02:20:27 +0200 Subject: [PATCH 1/5] fix(hooks): bound Claude subagent startup --- .../src/hooks/claude.rs | 150 +++++++++++++++--- .../src/hooks/steering.rs | 16 +- .../agent_suite/claude_plugin_bundle_test.rs | 7 + plugin/hooks/hooks-claude.json | 14 ++ 4 files changed, 153 insertions(+), 34 deletions(-) diff --git a/crates/tracedecay-agent-hosts/src/hooks/claude.rs b/crates/tracedecay-agent-hosts/src/hooks/claude.rs index b6e9e3e622..fbcb346004 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/claude.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/claude.rs @@ -2,12 +2,14 @@ //! //! Claude and Codex share the common hook JSON shape. +use std::future::Future; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use serde_json::Value; use super::post_tool_use::is_post_tool_use_failure_event; -use super::steering::{cursor_index_signals_for_root, index_status_line}; +use super::steering::index_status_line; use super::tool_hints::{HintAgent, ToolHintInput, decide_hint}; use super::{ additional_context_json, compact_daemon_args, event_project_root, @@ -183,6 +185,20 @@ const CLAUDE_SUBAGENT_START_CONTEXT: &str = "graph before grep; tools may be def ToolSearch select:tracedecay_context,tracedecay_grep,tracedecay_callers; route literal->grep, \ symbol->search, concept->context"; +/// The outer Claude plugin guard is five seconds. Keep daemon-backed context +/// lookup and receipt delivery below two seconds together so a saturated but +/// connectable daemon cannot delay child startup. +const CLAUDE_SUBAGENT_START_BUDGET: Duration = Duration::from_millis(1_500); +const CLAUDE_SUBAGENT_OUTPUT_BUDGET: Duration = Duration::from_millis(250); + +#[derive(Debug, PartialEq, Eq)] +enum ClaudeSubagentStartContextOutcome { + Ready(String), + NoProject, + Unavailable, + TimedOut, +} + /// Claude Code `SubagentStart` hook handler. /// /// Mirrors [`hook_codex_subagent_start`](super::codex::hook_codex_subagent_start) @@ -192,30 +208,70 @@ symbol->search, concept->context"; /// nothing to steer toward). Analytics are fire-and-forget like `SessionStart`. pub async fn hook_claude_subagent_start() -> i32 { let event = read_hook_event!(); + let started = Instant::now(); let parsed = serde_json::from_str::(&event).unwrap_or(Value::Null); - let root = event_project_root_with_identity(&parsed).await; - let _hook_telemetry = record_hook_invoked_parsed( + // Subagent startup must not open the global registry merely to discover a + // route. Resolve a local workspace boundary and let the one bounded status + // request map a registered global-only alias when one exists. + let root = claude_subagent_project_root(&parsed); + let hook_telemetry = record_hook_invoked_parsed( root.as_deref(), HintAgent::Claude, "SubagentStart", &event, &parsed, ); - let output = if let Some(context) = claude_subagent_start_context(root.as_deref()).await { - additional_context_json("SubagentStart", &context) - } else { - serde_json::json!({}).to_string() + let remaining = CLAUDE_SUBAGENT_START_BUDGET.saturating_sub(started.elapsed()); + let outcome = match root.as_deref() { + Some(_) if remaining.is_zero() => ClaudeSubagentStartContextOutcome::TimedOut, + Some(root) => { + bounded_claude_subagent_start_context( + super::steering::cursor_index_signals_for_root_result(root), + remaining, + ) + .await + } + None => ClaudeSubagentStartContextOutcome::NoProject, }; - if !super::write_hook_output( - root.as_deref(), - tracedecay_hooks::HookHostV1::ClaudeCode, - &event, - &output, - Some(&_hook_telemetry), + let output = match outcome { + ClaudeSubagentStartContextOutcome::Ready(context) => { + additional_context_json("SubagentStart", &context) + } + ClaudeSubagentStartContextOutcome::NoProject => serde_json::json!({}).to_string(), + ClaudeSubagentStartContextOutcome::Unavailable => { + eprintln!( + "[tracedecay] Claude SubagentStart failed open: \ + stage=daemon_status outcome=unavailable elapsed_ms={}", + started.elapsed().as_millis() + ); + serde_json::json!({}).to_string() + } + ClaudeSubagentStartContextOutcome::TimedOut => { + eprintln!( + "[tracedecay] Claude SubagentStart failed open: \ + stage=daemon_status outcome=timeout elapsed_ms={}", + started.elapsed().as_millis() + ); + serde_json::json!({}).to_string() + } + }; + let delivered = tokio::time::timeout( + CLAUDE_SUBAGENT_OUTPUT_BUDGET, + super::write_hook_output( + root.as_deref(), + tracedecay_hooks::HookHostV1::ClaudeCode, + &event, + &output, + Some(&hook_telemetry), + ), ) - .await - { - return 1; + .await; + if !matches!(delivered, Ok(true)) { + eprintln!( + "[tracedecay] Claude SubagentStart failed open: \ + stage=output_delivery outcome=unavailable elapsed_ms={}", + started.elapsed().as_millis() + ); } 0 } @@ -257,16 +313,36 @@ pub async fn hook_claude_post_compact() -> i32 { 0 } -/// Builds the compact `SubagentStart` `additionalContext` for a Claude event, or -/// `None` when root detection fails (no project to steer toward). The status -/// line is resolved the same registry-aware way as `SessionStart` so a -/// global-store-only project still steers correctly. -async fn claude_subagent_start_context(root: Option<&Path>) -> Option { - let root = root?; - let (staleness, _) = cursor_index_signals_for_root(root).await; - let mut context = index_status_line(true, staleness.as_deref()); - context.push_str(CLAUDE_SUBAGENT_START_CONTEXT); - Some(context) +fn claude_subagent_project_root(parsed: &Value) -> Option { + let cwd = super::event_cwd_from_parsed(parsed)?; + if let Some(root) = super::nearest_project_like_root(&cwd) { + return Some(root); + } + let root = crate::config::discover_project_root(&cwd)?; + let is_ambient_root = root.parent().is_none() + || ["HOME", "USERPROFILE"] + .iter() + .filter_map(std::env::var_os) + .any(|home| Path::new(&home) == root); + (!is_ambient_root).then_some(root) +} + +async fn bounded_claude_subagent_start_context( + status: F, + budget: Duration, +) -> ClaudeSubagentStartContextOutcome +where + F: Future, Option)>>, +{ + match tokio::time::timeout(budget, status).await { + Ok(Ok((staleness, _))) => { + let mut context = index_status_line(true, staleness.as_deref()); + context.push_str(CLAUDE_SUBAGENT_START_CONTEXT); + ClaudeSubagentStartContextOutcome::Ready(context) + } + Ok(Err(_)) => ClaudeSubagentStartContextOutcome::Unavailable, + Err(_) => ClaudeSubagentStartContextOutcome::TimedOut, + } } /// Claude Code `PostToolUse` / `PostToolUseFailure` hook handler. @@ -777,5 +853,27 @@ mod tests { assert!(CLAUDE_SUBAGENT_START_CONTEXT.contains("literal->grep")); assert!(CLAUDE_SUBAGENT_START_CONTEXT.contains("symbol->search")); assert!(CLAUDE_SUBAGENT_START_CONTEXT.contains("concept->context")); + assert!( + CLAUDE_SUBAGENT_START_BUDGET + CLAUDE_SUBAGENT_OUTPUT_BUDGET < Duration::from_secs(2) + ); + } + + #[tokio::test] + async fn subagent_start_context_times_out_fail_open() { + let status = std::future::pending::, Option)>>(); + let outcome = + bounded_claude_subagent_start_context(status, Duration::from_millis(10)).await; + + assert_eq!(outcome, ClaudeSubagentStartContextOutcome::TimedOut); + } + + #[tokio::test] + async fn subagent_start_context_treats_daemon_errors_as_unavailable() { + let status = std::future::ready(Err(crate::errors::TraceDecayError::Config { + message: "daemon unavailable".to_string(), + })); + let outcome = bounded_claude_subagent_start_context(status, Duration::from_secs(1)).await; + + assert_eq!(outcome, ClaudeSubagentStartContextOutcome::Unavailable); } } diff --git a/crates/tracedecay-agent-hosts/src/hooks/steering.rs b/crates/tracedecay-agent-hosts/src/hooks/steering.rs index f72d31c9ad..7f0ba092e8 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/steering.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/steering.rs @@ -241,17 +241,17 @@ pub fn cursor_staleness_hint(age_secs: i64) -> String { } } -/// Opens the index once and reads both session-steering signals. -pub(super) async fn cursor_index_signals_for_root(root: &Path) -> (Option, Option) { - let Ok(status) = super::daemon_tool_json( +/// Result-preserving status lookup for latency-sensitive hooks that must +/// distinguish an unavailable daemon from a healthy index with no signals. +pub(super) async fn cursor_index_signals_for_root_result( + root: &Path, +) -> crate::errors::Result<(Option, Option)> { + let status = super::daemon_tool_json( Some(root), "tracedecay_status", serde_json::json!({ "format": "json" }), ) - .await - else { - return (None, None); - }; + .await?; let last = status .get("last_updated") .and_then(serde_json::Value::as_i64) @@ -260,7 +260,7 @@ pub(super) async fn cursor_index_signals_for_root(root: &Path) -> (Option = hooks.keys().cloned().collect(); @@ -337,6 +338,12 @@ fn claude_bundle_hooks_wire_the_expected_lifecycle_events() { "{} {event} hook subcommand must be {subcommand}", hooks_path.display() ); + assert_eq!( + hook.get("timeout").and_then(Value::as_u64), + (*event == "SubagentStart").then_some(5), + "{} {event} must only set the bounded SubagentStart outer timeout", + hooks_path.display() + ); } } diff --git a/plugin/hooks/hooks-claude.json b/plugin/hooks/hooks-claude.json index 3e97cad4e8..d1360e3bd2 100644 --- a/plugin/hooks/hooks-claude.json +++ b/plugin/hooks/hooks-claude.json @@ -53,6 +53,20 @@ } ] } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "__TRACEDECAY_BIN__", + "timeout": 5, + "args": [ + "hook-claude-subagent-start" + ] + } + ] + } ] } } From 000beb1505537e313beb26ac555e75d9b452ee2d Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 26 Aug 2026 02:39:12 +0200 Subject: [PATCH 2/5] fix(mcp): reject ambient project roots --- crates/tracedecay-runtime-core/src/config.rs | 21 ++++++++++-- crates/tracedecay/src/config.rs | 2 +- crates/tracedecay/src/config/tests.rs | 13 +++++++ .../tracedecay/src/daemon/project_routing.rs | 8 +++++ .../tracedecay/src/daemon/tests/bootstrap.rs | 16 +++++++++ crates/tracedecay/src/serve.rs | 34 ++++++++++++------- 6 files changed, 77 insertions(+), 17 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/config.rs b/crates/tracedecay-runtime-core/src/config.rs index 75b170d50c..92e7dd50d8 100644 --- a/crates/tracedecay-runtime-core/src/config.rs +++ b/crates/tracedecay-runtime-core/src/config.rs @@ -142,10 +142,10 @@ pub fn discover_project_root(start: &Path) -> Option { let at_worktree_root = worktree_root .as_ref() .is_some_and(|root| paths_same(&dir, root)); - if has_project_database(&dir) + let initialized = has_project_database(&dir) || crate::storage::has_path_local_profile_store(&dir) - || (at_worktree_root && crate::storage::has_repository_identity_marker(&dir)) - { + || (at_worktree_root && crate::storage::has_repository_identity_marker(&dir)); + if initialized && !is_ambient_project_root(&dir) { return Some(dir); } if at_worktree_root { @@ -157,6 +157,21 @@ pub fn discover_project_root(start: &Path) -> Option { } } +/// Returns whether a path is too broad to be an implicit code-project root. +/// +/// Filesystem roots and the current user profile commonly contain many +/// repositories. Treating either as an implicit project can turn MCP startup +/// freshness work into a full-machine or full-home traversal. +pub fn is_ambient_project_root(path: &Path) -> bool { + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + canonical.parent().is_none() + || ["HOME", "USERPROFILE"] + .iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .map(|home| std::fs::canonicalize(&home).unwrap_or(home)) + .any(|home| home == canonical) +} fn paths_same(left: &Path, right: &Path) -> bool { let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); diff --git a/crates/tracedecay/src/config.rs b/crates/tracedecay/src/config.rs index adb1c8c655..4c9f0e9e8f 100644 --- a/crates/tracedecay/src/config.rs +++ b/crates/tracedecay/src/config.rs @@ -51,7 +51,7 @@ pub const CONFIG_FILENAME: &str = "config.json"; pub use tracedecay_runtime_core::config::{ DB_FILENAME, TRACEDECAY_DIR, USER_DATA_DIR_ENV, active_data_dir_name, db_filename, discover_project_root, get_project_db_path, get_tracedecay_dir, has_project_database, - user_data_dir, + is_ambient_project_root, user_data_dir, }; /// Atomic project-scoped semantic runtime selection. diff --git a/crates/tracedecay/src/config/tests.rs b/crates/tracedecay/src/config/tests.rs index f49e322206..90e0cd645c 100644 --- a/crates/tracedecay/src/config/tests.rs +++ b/crates/tracedecay/src/config/tests.rs @@ -6,6 +6,7 @@ use super::{ }; use std::ffi::OsString; use std::fs; +use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; @@ -551,6 +552,18 @@ fn sync_config_env_overrides_bool_and_int() { ); } +#[test] +fn implicit_discovery_never_selects_the_user_profile_root() { + let _profile = super::PinnedUserDataDir::new(); + let home = PathBuf::from(std::env::var_os("HOME").expect("pinned HOME")); + fs::write(super::get_project_db_path(&home), b"").expect("ambient project marker"); + let nested = home.join("unrelated/nested"); + fs::create_dir_all(&nested).expect("nested directory"); + + assert!(super::is_ambient_project_root(&home)); + assert_eq!(super::discover_project_root(&nested), None); +} + #[tokio::test] async fn discover_project_root_with_identity_does_not_open_registry_only_store() { let _profile = super::PinnedUserDataDir::new(); diff --git a/crates/tracedecay/src/daemon/project_routing.rs b/crates/tracedecay/src/daemon/project_routing.rs index 9181da109c..a8523ee7e1 100644 --- a/crates/tracedecay/src/daemon/project_routing.rs +++ b/crates/tracedecay/src/daemon/project_routing.rs @@ -55,6 +55,14 @@ pub(super) fn project_route_for_handshake( let canonical_project_path = project_path .canonicalize() .unwrap_or_else(|_| project_path.clone()); + if crate::config::is_ambient_project_root(&canonical_project_path) { + return Err(TraceDecayError::Config { + message: format!( + "'{}' is an ambient user/filesystem root, not an active TraceDecay code project", + canonical_project_path.display() + ), + }); + } let route = ProjectRouteKey::from_handshake(&canonical_project_path, handshake)?; Ok((canonical_project_path, route)) } diff --git a/crates/tracedecay/src/daemon/tests/bootstrap.rs b/crates/tracedecay/src/daemon/tests/bootstrap.rs index 0e19b5e5b6..3569f57785 100644 --- a/crates/tracedecay/src/daemon/tests/bootstrap.rs +++ b/crates/tracedecay/src/daemon/tests/bootstrap.rs @@ -204,6 +204,22 @@ async fn unenrolled_ambient_directory_is_rejected_before_project_warmup() { ); } +#[test] +fn daemon_project_route_rejects_the_user_profile_root() { + let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) else { + return; + }; + let handshake = DaemonHandshake { + project_path: Some(home), + ..test_handshake_defaults() + }; + + let error = DaemonEngine::project_route(&handshake) + .expect_err("ambient home route must fail before project open"); + + assert!(error.to_string().contains("ambient user/filesystem root")); +} + /// Enrolls `project_root` on disk exactly as a previously-initialized project /// is enrolled — a `.git/` repository identity marker plus a materialized /// profile store — without touching the profile registry. This is the on-disk diff --git a/crates/tracedecay/src/serve.rs b/crates/tracedecay/src/serve.rs index f73e89b7b9..4fe3cc283c 100644 --- a/crates/tracedecay/src/serve.rs +++ b/crates/tracedecay/src/serve.rs @@ -180,13 +180,15 @@ fn proxy_serve_handshake( .is_some(); let path = sanitize_serve_path_arg(path_arg); let explicit_path = path.is_some(); - let mut project_path = if explicit_path { + let mut resolved_path = if explicit_path { crate::config::resolve_path(path) } else { crate::config::resolve_path_with_discovery(None) }; - let initialized = TraceDecay::is_initialized(&project_path); + let ambient_discovery = + !explicit_path && crate::config::is_ambient_project_root(&resolved_path); + let initialized = !ambient_discovery && TraceDecay::is_initialized(&resolved_path); // `serve` is a database-free proxy. It may consult only an already-pinned // in-memory snapshot; missing authority disables implicit auto-init rather // than reading legacy `config.json` from the client process. @@ -196,23 +198,29 @@ fn proxy_serve_handshake( // unindexed git worktree would surrender routing to MCP initialize roots // (or the daemon's own cwd) instead of initializing the client's cwd. This // mirrors the same default fallback in `resolve_daemon_initialize_route`. - let auto_init_root = (!initialized - && crate::config::cached_sync_config(&project_path).map_or_else( + let auto_init_root = (!ambient_discovery + && !initialized + && crate::config::cached_sync_config(&resolved_path).map_or_else( |_| crate::config::SyncConfig::default().auto_init, |config| config.auto_init, )) - .then(|| crate::worktree::git_worktree_root(&project_path)) - .flatten(); + .then(|| crate::worktree::git_worktree_root(&resolved_path)) + .flatten() + .filter(|root| !crate::config::is_ambient_project_root(root)); if let Some(root) = auto_init_root.as_ref() { - project_path.clone_from(root); + resolved_path.clone_from(root); } - let scope_prefix = serve_scope_prefix(original_cwd, &project_path); + let project_path = (!ambient_discovery).then_some(resolved_path); + let scope_prefix = project_path + .as_deref() + .and_then(|project_path| serve_scope_prefix(original_cwd, project_path)); let telemetry_timings = timings - || crate::config::cached_telemetry_config(&project_path) - .is_ok_and(|telemetry| telemetry.timings); + || project_path.as_deref().is_some_and(|path| { + crate::config::cached_telemetry_config(path).is_ok_and(|telemetry| telemetry.timings) + }); let mut handshake = crate::daemon::DaemonHandshake::for_current_client( - Some(project_path), + project_path, scope_prefix, telemetry_timings, auto_init_root.is_some(), @@ -222,8 +230,8 @@ fn proxy_serve_handshake( // any incidental process-cwd discovery (for example Cursor launching the // MCP process from $HOME). Ordinary discovery-mode clients retain cwd // precedence when cwd resolved or can be auto-initialized. - handshake.allow_initialize_root_routing = - unexpanded_template_path || (!explicit_path && !initialized && auto_init_root.is_none()); + handshake.allow_initialize_root_routing = unexpanded_template_path + || (!explicit_path && (!initialized || ambient_discovery) && auto_init_root.is_none()); Ok(handshake) } From 1d1979b56361380d224905e4452320dea9fb0ba7 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 26 Aug 2026 02:43:46 +0200 Subject: [PATCH 3/5] fix(daemon): skip ambient registry projects --- crates/tracedecay/src/daemon/git_watch/admission.rs | 10 ++++++++++ crates/tracedecay/src/daemon/git_watch/tests.rs | 13 +++++++++++++ .../tracedecay/src/daemon/pr_autotrack/runtime.rs | 2 +- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay/src/daemon/git_watch/admission.rs b/crates/tracedecay/src/daemon/git_watch/admission.rs index 7f5bdfe3d9..425ea1d3f5 100644 --- a/crates/tracedecay/src/daemon/git_watch/admission.rs +++ b/crates/tracedecay/src/daemon/git_watch/admission.rs @@ -36,6 +36,16 @@ impl GitWatcher { if self.inner.shutting_down.load(Ordering::Acquire) { return GitWatcherAdmission::ShuttingDown; } + if crate::config::is_ambient_project_root(project_root) { + log_daemon_event( + "git_watch_skipped", + &[ + ("project", project_root.display().to_string()), + ("reason", "ambient_root".to_string()), + ], + ); + return GitWatcherAdmission::NotRepository; + } let resolution = resolve_watch_identity(project_root.to_path_buf(), self.inner.cancellation.clone()) .await; diff --git a/crates/tracedecay/src/daemon/git_watch/tests.rs b/crates/tracedecay/src/daemon/git_watch/tests.rs index da96b7531c..f3081bd084 100644 --- a/crates/tracedecay/src/daemon/git_watch/tests.rs +++ b/crates/tracedecay/src/daemon/git_watch/tests.rs @@ -883,6 +883,19 @@ async fn disabled_watcher_never_registers() { assert!(watcher.health_report().await.is_empty()); } +#[tokio::test] +async fn ambient_user_profile_root_is_never_watched() { + let _profile = crate::config::PinnedUserDataDir::new(); + let home = PathBuf::from(std::env::var_os("HOME").expect("pinned HOME")); + let watcher = GitWatcher::new(fast_watch_config()); + + assert_eq!( + watcher.ensure_watching(&home).await, + GitWatcherAdmission::NotRepository + ); + assert!(watcher.health_report().await.is_empty()); +} + #[tokio::test] async fn missing_or_dangling_project_identity_is_rejected() { let tmp = tempfile::tempdir().expect("identity fixture"); diff --git a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs index 98745aea97..0dbbe0d9a8 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs @@ -109,7 +109,7 @@ async fn tick( return; } let root = PathBuf::from(&record.canonical_root); - if !root.is_dir() { + if !root.is_dir() || crate::config::is_ambient_project_root(&root) { continue; } // A poll loop has no right to turn an arbitrary project path into From 56f16c5592af7657def5f25258c6a9b0ec30023e Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 26 Aug 2026 03:14:24 +0200 Subject: [PATCH 4/5] fix(cli): keep unscoped tools projectless --- crates/tracedecay-cli/src/tool_command.rs | 41 +++++++------------ .../tracedecay-cli/src/tool_command/tests.rs | 11 +++-- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index 7b21646f68..54d0cc28fa 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -15,7 +15,7 @@ //! JSON, and exit without dispatching the tool. Otherwise it is forwarded as //! the tool's boolean argument. //! - `--project ` — project root to target. Defaults to the nearest -//! initialised project walking up from cwd (falling back to cwd). We use +//! initialised project walking up from cwd. We use //! `--project` (not `-p`) because several MCP tools have a `path` argument //! that filters files within the project. //! - `--args ` — escape hatch. Treats the value as the entire @@ -41,7 +41,7 @@ use std::collections::BTreeMap; use std::io::Write; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde_json::Value; @@ -447,24 +447,21 @@ impl DaemonToolDispatch { } fn project_scoped(explicit_project: Option, tool_name: &str) -> Self { - // Same resolution as `tracedecay sync`/`status`/`serve`: an explicit - // --project wins; otherwise walk up from cwd to the nearest initialised - // project so the command works from subdirectories. + // An explicit --project wins. Otherwise only route to the nearest + // initialised ancestor. Keeping an unscoped invocation projectless is + // important: falling back to cwd can turn a broad directory such as + // the user profile into an accidental project handshake. let explicitly_targeted = explicit_project.is_some(); - let project_path = tracedecay::config::resolve_path_with_discovery(explicit_project); - // Never treat the filesystem root as a discovered project fallback. - // Callers that need a project must pass --project; otherwise the daemon - // serves the profile-scoped projectless route. - if !explicitly_targeted && is_filesystem_root(&project_path) { - return Self { - project_path: None, - allow_init: false, - }; - } + let project_path = match explicit_project { + Some(path) => Some(tracedecay::config::resolve_path(Some(path))), + None => std::env::current_dir() + .ok() + .and_then(|cwd| implicit_tool_project_path(&cwd)), + }; let allow_init = explicitly_targeted && FIRST_TOUCH_STORE_TOOLS.contains(&tool_name); Self { - project_path: Some(project_path), + project_path, allow_init, } } @@ -497,16 +494,8 @@ fn requests_profile_authority(tool_args: &Value) -> bool { ) } -fn is_filesystem_root(path: &Path) -> bool { - let mut saw_root = false; - for component in path.components() { - match component { - Component::RootDir | Component::Prefix(_) => saw_root = true, - Component::CurDir => {} - Component::ParentDir | Component::Normal(_) => return false, - } - } - saw_root +fn implicit_tool_project_path(cwd: &Path) -> Option { + tracedecay::config::discover_project_root(cwd) } fn map_tool_deadline_error(tool_name: &str, error: TraceDecayError) -> TraceDecayError { diff --git a/crates/tracedecay-cli/src/tool_command/tests.rs b/crates/tracedecay-cli/src/tool_command/tests.rs index e9cc7ab8bd..32a88fe7e7 100644 --- a/crates/tracedecay-cli/src/tool_command/tests.rs +++ b/crates/tracedecay-cli/src/tool_command/tests.rs @@ -290,6 +290,7 @@ fn explicit_project_lcm_dispatch_allows_first_touch_init() { ); assert!(dispatch.allow_init); + assert_eq!(dispatch.project_path, Some(PathBuf::from("/tmp/project"))); } #[test] @@ -321,10 +322,12 @@ fn user_memory_scope_dispatch_is_projectless() { } #[test] -fn filesystem_root_path_is_never_accepted_as_discovered_project() { - assert!(is_filesystem_root(std::path::Path::new("/"))); - assert!(!is_filesystem_root(std::path::Path::new("/tmp/project"))); - assert!(!is_filesystem_root(std::path::Path::new("."))); +fn implicit_tool_dispatch_stays_projectless_without_initialized_ancestor() { + let root = tempfile::tempdir().expect("projectless tool fixture"); + let nested = root.path().join("nested"); + std::fs::create_dir_all(&nested).expect("nested directory"); + + assert_eq!(implicit_tool_project_path(&nested), None); } // --- Validation gate and corrective-error contract --- From bd2d2f93c133f65999513d9459dad5b8639b76a7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 22:38:41 +0000 Subject: [PATCH 5/5] fix(relocation): borrow search eval workspace root --- crates/tracedecay/src/bin/tracedecay-search-eval-direct.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay/src/bin/tracedecay-search-eval-direct.rs b/crates/tracedecay/src/bin/tracedecay-search-eval-direct.rs index 3582ae8632..ef288ee42d 100644 --- a/crates/tracedecay/src/bin/tracedecay-search-eval-direct.rs +++ b/crates/tracedecay/src/bin/tracedecay-search-eval-direct.rs @@ -386,11 +386,10 @@ mod tests { #[test] fn default_validation_uses_byte_pinned_activation_workload() { let summary = validate_requested_workload( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(std::path::Path::parent) - .expect("workspace root above crates/tracedecay") - .to_owned(), + .expect("workspace root above crates/tracedecay"), None, ) .expect("checked-in activation workload validates");