Skip to main content

lean_ctx/server/
mod.rs

1pub mod bypass_hint;
2pub mod context_gate;
3mod dispatch;
4pub mod dynamic_tools;
5pub mod elicitation;
6pub(crate) mod execute;
7pub mod helpers;
8pub mod notifications;
9pub mod prompts;
10pub mod reference_store;
11pub mod registry;
12pub mod resources;
13pub mod role_guard;
14pub mod tool_trait;
15
16use rmcp::handler::server::ServerHandler;
17use rmcp::model::{
18    CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
19    InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
20};
21use rmcp::service::{RequestContext, RoleServer};
22use rmcp::ErrorData;
23
24use crate::tools::{CrpMode, LeanCtxServer};
25
26impl ServerHandler for LeanCtxServer {
27    fn get_info(&self) -> ServerInfo {
28        let capabilities = ServerCapabilities::builder()
29            .enable_tools()
30            .enable_resources()
31            .enable_resources_subscribe()
32            .enable_prompts()
33            .build();
34
35        let config = crate::core::config::Config::load();
36        let level = crate::core::config::CompressionLevel::effective(&config);
37        let _ = crate::core::terse::rules_inject::inject(&level);
38
39        let instructions = crate::instructions::build_instructions(CrpMode::effective());
40
41        InitializeResult::new(capabilities)
42            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
43            .with_instructions(instructions)
44    }
45
46    async fn initialize(
47        &self,
48        request: InitializeRequestParams,
49        context: RequestContext<RoleServer>,
50    ) -> Result<InitializeResult, ErrorData> {
51        let name = request.client_info.name.clone();
52        tracing::info!("MCP client connected: {:?}", name);
53        *self.client_name.write().await = name.clone();
54        *self.peer.write().await = Some(context.peer.clone());
55
56        if self.session_mode != crate::tools::SessionMode::Shared {
57            crate::core::budget_tracker::BudgetTracker::global().reset();
58            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
59                let radar = data_dir.join("context_radar.jsonl");
60                if radar.exists() {
61                    let prev = data_dir.join("context_radar.prev.jsonl");
62                    let _ = std::fs::rename(&radar, &prev);
63                }
64            }
65        }
66
67        let derived_root = derive_project_root_from_cwd();
68        let cwd_str = std::env::current_dir()
69            .ok()
70            .map(|p| p.to_string_lossy().to_string())
71            .unwrap_or_default();
72        {
73            let mut session = self.session.write().await;
74            if !cwd_str.is_empty() {
75                session.shell_cwd = Some(cwd_str.clone());
76            }
77            if let Some(ref root) = derived_root {
78                session.project_root = Some(root.clone());
79                tracing::info!("Project root set to: {root}");
80            } else if let Some(ref root) = session.project_root {
81                let root_path = std::path::Path::new(root);
82                let root_has_marker = has_project_marker(root_path);
83                let root_str = root_path.to_string_lossy();
84                let root_suspicious = root_str.contains("/.claude")
85                    || root_str.contains("/.codex")
86                    || root_str.contains("/var/folders/")
87                    || root_str.contains("/tmp/")
88                    || root_str.contains("\\.claude")
89                    || root_str.contains("\\.codex")
90                    || root_str.contains("\\AppData\\Local\\Temp")
91                    || root_str.contains("\\Temp\\");
92                if root_suspicious && !root_has_marker {
93                    session.project_root = None;
94                }
95            }
96            if self.session_mode == crate::tools::SessionMode::Shared {
97                if let Some(ref root) = session.project_root {
98                    if let Some(ref rt) = self.context_os {
99                        rt.shared_sessions.persist_best_effort(
100                            root,
101                            &self.workspace_id,
102                            &self.channel_id,
103                            &session,
104                        );
105                        rt.metrics.record_session_persisted();
106                    }
107                }
108            } else {
109                let _ = session.save();
110            }
111        }
112
113        let agent_name = name.clone();
114        let agent_root = derived_root.clone().unwrap_or_default();
115        let agent_id_handle = self.agent_id.clone();
116        tokio::task::spawn_blocking(move || {
117            if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
118                return;
119            }
120
121            // Avoid startup stampedes when multiple agent sessions initialize at once.
122            // These are best-effort maintenance tasks; it's fine to skip if another
123            // lean-ctx instance is already doing them.
124            let maintenance = crate::core::startup_guard::try_acquire_lock(
125                "startup-maintenance",
126                std::time::Duration::from_secs(2),
127                std::time::Duration::from_mins(2),
128            );
129            if maintenance.is_some() {
130                if let Some(home) = dirs::home_dir() {
131                    let _ = crate::rules_inject::inject_all_rules(&home);
132                }
133                crate::hooks::refresh_installed_hooks();
134                crate::core::version_check::check_background();
135            }
136            drop(maintenance);
137
138            if !agent_root.is_empty() {
139                let heuristic_role = match agent_name.to_lowercase().as_str() {
140                    n if n.contains("cursor") => Some("coder"),
141                    n if n.contains("claude") => Some("coder"),
142                    n if n.contains("codex") => Some("coder"),
143                    n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
144                    n if n.contains("review") => Some("reviewer"),
145                    n if n.contains("test") => Some("debugger"),
146                    _ => None,
147                };
148                let env_role = std::env::var("LEAN_CTX_ROLE")
149                    .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
150                    .ok();
151                let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
152
153                let _ = crate::core::roles::set_active_role(effective_role);
154
155                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
156                registry.cleanup_stale(24);
157                let id = registry.register("mcp", Some(effective_role), &agent_root);
158                let _ = registry.save();
159                if let Ok(mut guard) = agent_id_handle.try_write() {
160                    *guard = Some(id);
161                }
162            }
163        });
164
165        let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
166        tracing::info!("Client capabilities: {}", client_caps.format_summary());
167
168        if client_caps.dynamic_tools {
169            if let Ok(mut dt) = dynamic_tools::global().lock() {
170                dt.set_supports_list_changed(true);
171            }
172        }
173        if let Some(max) = client_caps.max_tools {
174            if let Ok(mut dt) = dynamic_tools::global().lock() {
175                dt.set_supports_list_changed(true);
176                if max < 100 {
177                    dt.unload_category(dynamic_tools::ToolCategory::Debug);
178                    dt.unload_category(dynamic_tools::ToolCategory::Memory);
179                }
180            }
181        }
182
183        crate::core::client_capabilities::set_detected(&client_caps);
184
185        let instructions =
186            crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
187
188        let capabilities = match (client_caps.resources, client_caps.prompts) {
189            (true, true) => ServerCapabilities::builder()
190                .enable_tools()
191                .enable_resources()
192                .enable_resources_subscribe()
193                .enable_prompts()
194                .build(),
195            (true, false) => ServerCapabilities::builder()
196                .enable_tools()
197                .enable_resources()
198                .enable_resources_subscribe()
199                .build(),
200            (false, true) => ServerCapabilities::builder()
201                .enable_tools()
202                .enable_prompts()
203                .build(),
204            (false, false) => ServerCapabilities::builder().enable_tools().build(),
205        };
206
207        Ok(InitializeResult::new(capabilities)
208            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
209            .with_instructions(instructions))
210    }
211
212    async fn list_tools(
213        &self,
214        _request: Option<PaginatedRequestParams>,
215        _context: RequestContext<RoleServer>,
216    ) -> Result<ListToolsResult, ErrorData> {
217        let all_tools = if crate::tool_defs::is_full_mode() {
218            crate::tool_defs::granular_tool_defs()
219        } else if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
220            crate::tool_defs::unified_tool_defs()
221        } else {
222            crate::tool_defs::lazy_tool_defs()
223        };
224
225        let disabled = crate::core::config::Config::load().disabled_tools_effective();
226        let client = self.client_name.read().await.clone();
227        let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
228
229        let tools: Vec<_> = all_tools
230            .into_iter()
231            .filter(|t| {
232                let name = t.name.as_ref();
233                if !disabled.is_empty() && disabled.iter().any(|d| d.as_str() == name) {
234                    return false;
235                }
236                if is_zed && name == "ctx_edit" {
237                    return false;
238                }
239                true
240            })
241            .collect();
242
243        let tools = {
244            let dyn_state = dynamic_tools::global().lock().unwrap();
245            if dyn_state.supports_list_changed() {
246                tools
247                    .into_iter()
248                    .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
249                    .collect()
250            } else {
251                tools
252            }
253        };
254
255        let tools = {
256            let active = self.workflow.read().await.clone();
257            if let Some(run) = active {
258                if run.current == "done" || is_workflow_stale(&run) {
259                    let mut wf = self.workflow.write().await;
260                    *wf = None;
261                    let _ = crate::core::workflow::clear_active();
262                } else if let Some(state) = run.spec.state(&run.current) {
263                    if let Some(allowed) = &state.allowed_tools {
264                        let mut allow: std::collections::HashSet<&str> =
265                            allowed.iter().map(std::string::String::as_str).collect();
266                        for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
267                            allow.insert(passthrough);
268                        }
269                        return Ok(ListToolsResult {
270                            tools: tools
271                                .into_iter()
272                                .filter(|t| allow.contains(t.name.as_ref()))
273                                .collect(),
274                            ..Default::default()
275                        });
276                    }
277                }
278            }
279            tools
280        };
281
282        let tools = {
283            let cfg = crate::core::config::Config::load();
284            let level = crate::core::config::CompressionLevel::effective(&cfg);
285            let mode =
286                crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
287            if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
288                tools
289            } else {
290                tools
291                    .into_iter()
292                    .map(|mut t| {
293                        let compressed = crate::core::terse::mcp_compress::compress_description(
294                            t.name.as_ref(),
295                            t.description.as_deref().unwrap_or(""),
296                            mode,
297                        );
298                        t.description = Some(compressed.into());
299                        t
300                    })
301                    .collect()
302            }
303        };
304
305        Ok(ListToolsResult {
306            tools,
307            ..Default::default()
308        })
309    }
310
311    async fn list_prompts(
312        &self,
313        _request: Option<PaginatedRequestParams>,
314        _context: RequestContext<RoleServer>,
315    ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
316        Ok(rmcp::model::ListPromptsResult::with_all_items(
317            prompts::list_prompts(),
318        ))
319    }
320
321    async fn get_prompt(
322        &self,
323        request: rmcp::model::GetPromptRequestParams,
324        _context: RequestContext<RoleServer>,
325    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
326        let ledger = self.ledger.read().await;
327        match prompts::get_prompt(&request, &ledger) {
328            Some(result) => Ok(result),
329            None => Err(ErrorData::invalid_params(
330                format!("Unknown prompt: {}", request.name),
331                None,
332            )),
333        }
334    }
335
336    async fn list_resources(
337        &self,
338        _request: Option<PaginatedRequestParams>,
339        _context: RequestContext<RoleServer>,
340    ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
341        Ok(rmcp::model::ListResourcesResult::with_all_items(
342            resources::list_resources(),
343        ))
344    }
345
346    async fn read_resource(
347        &self,
348        request: rmcp::model::ReadResourceRequestParams,
349        _context: RequestContext<RoleServer>,
350    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
351        let ledger = self.ledger.read().await;
352        match resources::read_resource(&request.uri, &ledger) {
353            Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
354            None => Err(rmcp::ErrorData::resource_not_found(
355                format!("Unknown resource: {}", request.uri),
356                None,
357            )),
358        }
359    }
360
361    async fn call_tool(
362        &self,
363        request: CallToolRequestParams,
364        _context: RequestContext<RoleServer>,
365    ) -> Result<CallToolResult, ErrorData> {
366        self.check_idle_expiry().await;
367        elicitation::increment_call();
368
369        let original_name = request.name.as_ref().to_string();
370        let (resolved_name, resolved_args) = if original_name == "ctx" {
371            let sub = request
372                .arguments
373                .as_ref()
374                .and_then(|a| a.get("tool"))
375                .and_then(|v| v.as_str())
376                .map(std::string::ToString::to_string)
377                .ok_or_else(|| {
378                    ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
379                })?;
380            let tool_name = if sub.starts_with("ctx_") {
381                sub
382            } else {
383                format!("ctx_{sub}")
384            };
385            let mut args = request.arguments.unwrap_or_default();
386            args.remove("tool");
387            (tool_name, Some(args))
388        } else {
389            (original_name, request.arguments)
390        };
391        let name = resolved_name.as_str();
392        let args = resolved_args.as_ref();
393
394        let role_check = role_guard::check_tool_access(name);
395        if let Some(denied) = role_guard::into_call_tool_result(&role_check) {
396            tracing::warn!(
397                tool = name,
398                role = %role_check.role_name,
399                "Tool blocked by role policy"
400            );
401            return Ok(denied);
402        }
403
404        if name != "ctx_workflow" {
405            let active = self.workflow.read().await.clone();
406            if let Some(run) = active {
407                if run.current == "done" || is_workflow_stale(&run) {
408                    let mut wf = self.workflow.write().await;
409                    *wf = None;
410                    let _ = crate::core::workflow::clear_active();
411                } else if !WORKFLOW_PASSTHROUGH_TOOLS.contains(&name) {
412                    if let Some(state) = run.spec.state(&run.current) {
413                        if let Some(allowed) = &state.allowed_tools {
414                            let allowed_ok = allowed.iter().any(|t| t == name);
415                            if !allowed_ok {
416                                let mut shown = allowed.clone();
417                                shown.sort();
418                                shown.truncate(30);
419                                return Ok(CallToolResult::success(vec![Content::text(format!(
420                                    "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed: {}. Use ctx_workflow(action=\"stop\") to exit.",
421                                    run.spec.name,
422                                    run.current,
423                                    shown.join(", ")
424                                ))]));
425                            }
426                        }
427                    }
428                }
429            }
430        }
431
432        let auto_context = {
433            let task = {
434                let session = self.session.read().await;
435                session.task.as_ref().map(|t| t.description.clone())
436            };
437            let project_root = {
438                let session = self.session.read().await;
439                session.project_root.clone()
440            };
441            let mut cache = self.cache.write().await;
442            crate::tools::autonomy::session_lifecycle_pre_hook(
443                &self.autonomy,
444                name,
445                &mut cache,
446                task.as_deref(),
447                project_root.as_deref(),
448                CrpMode::effective(),
449            )
450        };
451
452        let throttle_result = {
453            let fp = args
454                .map(|a| {
455                    crate::core::loop_detection::LoopDetector::fingerprint(
456                        &serde_json::Value::Object(a.clone()),
457                    )
458                })
459                .unwrap_or_default();
460            let mut detector = self.loop_detector.write().await;
461
462            let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
463            let is_search_shell = name == "ctx_shell" && {
464                let cmd = args
465                    .as_ref()
466                    .and_then(|a| a.get("command"))
467                    .and_then(|v| v.as_str())
468                    .unwrap_or("");
469                crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
470            };
471
472            if is_search || is_search_shell {
473                let search_pattern = args.and_then(|a| {
474                    a.get("pattern")
475                        .or_else(|| a.get("query"))
476                        .and_then(|v| v.as_str())
477                });
478                let shell_pattern = if is_search_shell {
479                    args.and_then(|a| a.get("command"))
480                        .and_then(|v| v.as_str())
481                        .and_then(helpers::extract_search_pattern_from_command)
482                } else {
483                    None
484                };
485                let pat = search_pattern.or(shell_pattern.as_deref());
486                detector.record_search(name, &fp, pat)
487            } else {
488                detector.record_call(name, &fp)
489            }
490        };
491
492        if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
493            let msg = throttle_result.message.unwrap_or_default();
494            return Ok(CallToolResult::success(vec![Content::text(msg)]));
495        }
496
497        let throttle_warning =
498            if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
499                throttle_result.message.clone()
500            } else {
501                None
502            };
503
504        let config = crate::core::config::Config::load();
505        let minimal = config.minimal_overhead_effective();
506
507        {
508            use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
509            let snap = BudgetTracker::global().check();
510            if *snap.worst_level() == BudgetLevel::Exhausted
511                && name != "ctx_session"
512                && name != "ctx_cost"
513                && name != "ctx_metrics"
514            {
515                for (dim, lvl, used, limit) in [
516                    (
517                        "tokens",
518                        &snap.tokens.level,
519                        format!("{}", snap.tokens.used),
520                        format!("{}", snap.tokens.limit),
521                    ),
522                    (
523                        "shell",
524                        &snap.shell.level,
525                        format!("{}", snap.shell.used),
526                        format!("{}", snap.shell.limit),
527                    ),
528                    (
529                        "cost",
530                        &snap.cost.level,
531                        format!("${:.2}", snap.cost.used_usd),
532                        format!("${:.2}", snap.cost.limit_usd),
533                    ),
534                ] {
535                    if *lvl == BudgetLevel::Exhausted {
536                        crate::core::events::emit_budget_exhausted(&snap.role, dim, &used, &limit);
537                    }
538                }
539                let msg = format!(
540                    "[BUDGET EXHAUSTED] {}\n\
541                     Use `ctx_session action=role` to check/switch roles, \
542                     or `ctx_session action=reset` to start fresh.",
543                    snap.format_compact()
544                );
545                tracing::warn!(tool = name, "{msg}");
546                return Ok(CallToolResult::success(vec![Content::text(msg)]));
547            }
548        }
549
550        if is_shell_tool_name(name) {
551            crate::core::budget_tracker::BudgetTracker::global().record_shell();
552        }
553
554        let tool_start = std::time::Instant::now();
555        let (mut result_text, tool_saved_tokens) = {
556            use futures::FutureExt;
557            use std::panic::AssertUnwindSafe;
558            match AssertUnwindSafe(self.dispatch_tool(name, args, minimal))
559                .catch_unwind()
560                .await
561            {
562                Ok(Ok(pair)) => pair,
563                Ok(Err(e)) => return Err(e),
564                Err(panic_payload) => {
565                    let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
566                        (*s).to_string()
567                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
568                        s.clone()
569                    } else {
570                        "unknown".to_string()
571                    };
572                    tracing::error!(tool = name, "Tool panicked: {detail}");
573                    (format!("ERROR: lean-ctx internal error in tool '{name}'.\n\
574                             The MCP server is still running. Please retry or use a different approach."), 0)
575                }
576            }
577        };
578
579        let is_raw_shell = name == "ctx_shell" && {
580            let arg_raw = helpers::get_bool(args, "raw").unwrap_or(false);
581            let arg_bypass = helpers::get_bool(args, "bypass").unwrap_or(false);
582            arg_raw
583                || arg_bypass
584                || std::env::var("LEAN_CTX_DISABLED").is_ok()
585                || std::env::var("LEAN_CTX_RAW").is_ok()
586        };
587
588        let pre_terse_len = result_text.len();
589        let output_tokens = {
590            let tokens = crate::core::tokens::count_tokens(&result_text) as u64;
591            crate::core::budget_tracker::BudgetTracker::global().record_tokens(tokens);
592            tokens
593        };
594
595        crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
596
597        // Context IR: record lineage for every tool call.
598        if let Some(ref ir) = self.context_ir {
599            let tool_duration = tool_start.elapsed();
600            let source_kind = match name {
601                n if n.contains("read") || n.contains("multi_read") || n.contains("smart_read") => {
602                    crate::core::context_ir::ContextIrSourceKindV1::Read
603                }
604                "ctx_shell" => crate::core::context_ir::ContextIrSourceKindV1::Shell,
605                "ctx_search" | "ctx_semantic_search" => {
606                    crate::core::context_ir::ContextIrSourceKindV1::Search
607                }
608                "ctx_provider" => crate::core::context_ir::ContextIrSourceKindV1::Provider,
609                _ => crate::core::context_ir::ContextIrSourceKindV1::Other,
610            };
611            let ir_path = helpers::get_str(args, "path");
612            let ir_command = helpers::get_str(args, "command");
613            let ir_mode = helpers::get_str(args, "mode");
614            let excerpt = if result_text.len() > 200 {
615                &result_text[..200]
616            } else {
617                &result_text
618            };
619            let input = crate::core::context_ir::RecordIrInput {
620                kind: source_kind,
621                tool: name,
622                client_name: None,
623                agent_id: None,
624                path: ir_path.as_deref(),
625                command: ir_command.as_deref(),
626                pattern: ir_mode.as_deref(),
627                input_tokens: pre_terse_len / 4,
628                output_tokens: output_tokens as usize,
629                duration: tool_duration,
630                content_excerpt: excerpt,
631            };
632            ir.write().await.record(input);
633        }
634
635        // Correction-loop detection: track re-reads and re-runs as quality signals.
636        {
637            let mut detector = self.loop_detector.write().await;
638            if name == "ctx_read" {
639                let path = helpers::get_str(args, "path").unwrap_or_default();
640                let mode = helpers::get_str(args, "mode").unwrap_or_else(|| "auto".into());
641                let fresh = helpers::get_bool(args, "fresh").unwrap_or(false);
642                detector.record_read_for_correction(&path, &mode, fresh);
643            } else if name == "ctx_shell" {
644                let cmd = helpers::get_str(args, "command").unwrap_or_default();
645                detector.record_shell_for_correction(&cmd);
646            }
647            let correction_count = detector.correction_count();
648            if correction_count > 0 {
649                crate::core::anomaly::record_metric(
650                    "correction_loop_rate",
651                    f64::from(correction_count),
652                );
653            }
654            // Auto-degrade: reduce compression when correction rate is high
655            use crate::core::config::CompressionLevel;
656            if correction_count >= 5 {
657                CompressionLevel::set_session_degrade(&CompressionLevel::Off);
658            } else if correction_count >= 3 {
659                CompressionLevel::set_session_degrade(&CompressionLevel::Lite);
660            } else if correction_count == 0 {
661                CompressionLevel::clear_session_degrade();
662            }
663            detector.prune_corrections();
664        }
665
666        // Persist anomaly detector — debounced to reduce I/O in burst sequences.
667        crate::core::anomaly::save_debounced();
668
669        let budget_warning = {
670            use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
671            let snap = BudgetTracker::global().check();
672            if *snap.worst_level() == BudgetLevel::Warning {
673                for (dim, lvl, used, limit, pct) in [
674                    (
675                        "tokens",
676                        &snap.tokens.level,
677                        format!("{}", snap.tokens.used),
678                        format!("{}", snap.tokens.limit),
679                        snap.tokens.percent,
680                    ),
681                    (
682                        "shell",
683                        &snap.shell.level,
684                        format!("{}", snap.shell.used),
685                        format!("{}", snap.shell.limit),
686                        snap.shell.percent,
687                    ),
688                    (
689                        "cost",
690                        &snap.cost.level,
691                        format!("${:.2}", snap.cost.used_usd),
692                        format!("${:.2}", snap.cost.limit_usd),
693                        snap.cost.percent,
694                    ),
695                ] {
696                    if *lvl == BudgetLevel::Warning {
697                        crate::core::events::emit_budget_warning(
698                            &snap.role, dim, &used, &limit, pct,
699                        );
700                    }
701                }
702                if crate::core::protocol::meta_visible() {
703                    Some(format!("[BUDGET WARNING] {}", snap.format_compact()))
704                } else {
705                    None
706                }
707            } else {
708                None
709            }
710        };
711
712        let archive_hint = if minimal || is_raw_shell {
713            None
714        } else {
715            use crate::core::archive;
716            let archivable = matches!(
717                name,
718                "ctx_shell"
719                    | "ctx_read"
720                    | "ctx_multi_read"
721                    | "ctx_smart_read"
722                    | "ctx_execute"
723                    | "ctx_search"
724                    | "ctx_tree"
725            );
726            if archivable && archive::should_archive(&result_text) {
727                let cmd = helpers::get_str(args, "command")
728                    .or_else(|| helpers::get_str(args, "path"))
729                    .unwrap_or_default();
730                let session_id = self.session.read().await.id.clone();
731                let to_store = crate::core::redaction::redact_text_if_enabled(&result_text);
732                let tokens = crate::core::tokens::count_tokens(&to_store);
733                archive::store(name, &cmd, &to_store, Some(&session_id))
734                    .map(|id| archive::format_hint(&id, to_store.len(), tokens))
735            } else {
736                None
737            }
738        };
739
740        let pre_compression = result_text.clone();
741        let skip_terse = is_raw_shell
742            || tool_saved_tokens > 0
743            || (name == "ctx_shell"
744                && helpers::get_str(args, "command")
745                    .is_some_and(|c| crate::shell::compress::has_structural_output(&c)));
746        let compression = crate::core::config::CompressionLevel::effective(&config);
747        if compression.is_active() && !skip_terse {
748            let terse_result =
749                crate::core::terse::pipeline::compress(&result_text, &compression, None);
750            if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
751                result_text = terse_result.output;
752            }
753        }
754
755        let profile_hints = crate::core::profiles::active_profile().output_hints;
756
757        if !is_raw_shell && profile_hints.verify_footer() {
758            let verify_cfg = crate::core::profiles::active_profile().verification;
759            let vr = crate::core::output_verification::verify_output(
760                &pre_compression,
761                &result_text,
762                &verify_cfg,
763            );
764            if !vr.warnings.is_empty() {
765                let msg = format!("[VERIFY] {}", vr.format_compact());
766                result_text = format!("{result_text}\n\n{msg}");
767            }
768        }
769
770        if profile_hints.archive_hint() {
771            if let Some(hint) = archive_hint {
772                result_text = format!("{result_text}\n{hint}");
773            }
774        }
775
776        if !is_raw_shell {
777            if let Some(ctx) = auto_context {
778                let ctx_tokens = crate::core::tokens::count_tokens(&ctx);
779                if ctx_tokens <= 400 {
780                    result_text = format!("{ctx}\n\n{result_text}");
781                }
782            }
783        }
784
785        if let Some(warning) = throttle_warning {
786            result_text = format!("{result_text}\n\n{warning}");
787        }
788
789        if let Some(bw) = budget_warning {
790            result_text = format!("{result_text}\n\n{bw}");
791        }
792
793        if !self
794            .rules_stale_checked
795            .swap(true, std::sync::atomic::Ordering::Relaxed)
796        {
797            let client = self.client_name.read().await.clone();
798            if !client.is_empty() {
799                if let Some(stale_msg) = crate::rules_inject::check_rules_freshness(&client) {
800                    result_text = format!("{result_text}\n\n{stale_msg}");
801                }
802            }
803        }
804
805        {
806            // Evaluate SLOs for observability (watch/dashboard), but keep tool outputs clean.
807            let _ = crate::core::slo::evaluate();
808        }
809
810        if name == "ctx_read" {
811            if minimal {
812                let mut cache = self.cache.write().await;
813                crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
814            } else {
815                let read_path = self
816                    .resolve_path_or_passthrough(
817                        &helpers::get_str(args, "path").unwrap_or_default(),
818                    )
819                    .await;
820                let project_root = {
821                    let session = self.session.read().await;
822                    session.project_root.clone()
823                };
824                let mut cache = self.cache.write().await;
825                let enrich = crate::tools::autonomy::enrich_after_read(
826                    &self.autonomy,
827                    &mut cache,
828                    &read_path,
829                    project_root.as_deref(),
830                    None,
831                    crate::tools::CrpMode::effective(),
832                    false,
833                );
834                if profile_hints.related_hint() {
835                    if let Some(hint) = enrich.related_hint {
836                        result_text = format!("{result_text}\n{hint}");
837                    }
838                }
839                crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
840
841                {
842                    let active_task = {
843                        let session = self.session.read().await;
844                        session.task.as_ref().map(|t| t.description.clone())
845                    };
846                    let mut ledger = self.ledger.write().await;
847                    let overlay = crate::core::context_overlay::OverlayStore::load_project(
848                        &std::path::PathBuf::from(project_root.as_deref().unwrap_or(".")),
849                    );
850                    let mode_used =
851                        helpers::get_str(args, "mode").unwrap_or_else(|| "auto".to_string());
852                    let sent_tokens_final = crate::core::tokens::count_tokens(&result_text);
853                    let gate_result = context_gate::post_dispatch_record_with_task(
854                        &read_path,
855                        &mode_used,
856                        output_tokens as usize,
857                        sent_tokens_final,
858                        &mut ledger,
859                        &overlay,
860                        active_task.as_deref(),
861                    );
862                    if let Some(hint) = gate_result.eviction_hint {
863                        result_text = format!("{result_text}\n{hint}");
864                    }
865                    if profile_hints.elicitation_hint() {
866                        if let Some(hint) = gate_result.elicitation_hint {
867                            result_text = format!("{result_text}\n{hint}");
868                        }
869                    }
870                    if gate_result.resource_changed {
871                        if let Some(peer) = self.peer.read().await.as_ref() {
872                            notifications::send_resource_updated(
873                                peer,
874                                notifications::RESOURCE_URI_SUMMARY,
875                            )
876                            .await;
877                        }
878                    }
879                }
880            }
881        }
882
883        if !minimal && !is_raw_shell && name == "ctx_shell" {
884            let cmd = helpers::get_str(args, "command").unwrap_or_default();
885
886            if let Some(file_path) = extract_file_read_from_shell(&cmd) {
887                if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
888                    bt.next_seq();
889                    bt.record_shell_file_access(&file_path);
890                }
891            }
892
893            if profile_hints.efficiency_hint() {
894                let calls = self.tool_calls.read().await;
895                let last_original = calls.last().map_or(0, |c| c.original_tokens);
896                drop(calls);
897                let pre_hint_tokens = crate::core::tokens::count_tokens(&result_text);
898                if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
899                    &self.autonomy,
900                    &cmd,
901                    last_original,
902                    pre_hint_tokens,
903                ) {
904                    result_text = format!("{result_text}\n{hint}");
905                }
906            }
907        }
908
909        if !minimal && !is_raw_shell {
910            bypass_hint::record_lctx_call();
911            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
912                if let Some(hint) = bypass_hint::check(&data_dir) {
913                    result_text = format!("{result_text}\n{hint}");
914                }
915            }
916        }
917
918        #[allow(clippy::cast_possible_truncation)]
919        let output_token_count = if result_text.len() == pre_terse_len {
920            output_tokens as usize
921        } else {
922            crate::core::tokens::count_tokens(&result_text)
923        };
924        let action = helpers::get_str(args, "action");
925
926        // K-bounded staleness guard: warn if shared context has diverged.
927        const K_STALENESS_BOUND: i64 = 10;
928        if self.session_mode == crate::tools::SessionMode::Shared {
929            if let Some(ref rt) = self.context_os {
930                let latest = rt.bus.latest_id(&self.workspace_id, &self.channel_id);
931                let cursor = self
932                    .last_seen_event_id
933                    .load(std::sync::atomic::Ordering::Relaxed);
934                if cursor > 0 && latest - cursor > K_STALENESS_BOUND {
935                    let gap = latest - cursor;
936                    result_text = format!(
937                        "[CONTEXT STALE] {gap} events happened since your last read. \
938                         Use ctx_session(action=\"status\") to sync.\n\n{result_text}"
939                    );
940                }
941                self.last_seen_event_id
942                    .store(latest, std::sync::atomic::Ordering::Relaxed);
943            }
944        }
945
946        {
947            let input = helpers::canonical_args_string(args);
948            let input_md5 = helpers::hash_fast(&input);
949            let output_md5 = helpers::hash_fast(&result_text);
950            let agent_id = self.agent_id.read().await.clone();
951            let client_name = self.client_name.read().await.clone();
952            let mut explicit_intent: Option<(
953                crate::core::intent_protocol::IntentRecord,
954                Option<String>,
955                String,
956            )> = None;
957
958            let pending_session_save = {
959                let empty_args = serde_json::Map::new();
960                let args_map = args.unwrap_or(&empty_args);
961                let mut session = self.session.write().await;
962                session.record_tool_receipt(
963                    name,
964                    action.as_deref(),
965                    &input_md5,
966                    &output_md5,
967                    agent_id.as_deref(),
968                    Some(&client_name),
969                );
970
971                if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
972                    name,
973                    action.as_deref(),
974                    args_map,
975                    session.project_root.as_deref(),
976                ) {
977                    let is_explicit =
978                        intent.source == crate::core::intent_protocol::IntentSource::Explicit;
979                    let root = session.project_root.clone();
980                    let sid = session.id.clone();
981                    session.record_intent(intent.clone());
982                    if is_explicit {
983                        explicit_intent = Some((intent, root, sid));
984                    }
985                }
986                if session.should_save() {
987                    session.prepare_save().ok()
988                } else {
989                    None
990                }
991            };
992
993            if let Some(prepared) = pending_session_save {
994                let ir_clone = self.context_ir.clone();
995                tokio::task::spawn_blocking(move || {
996                    let _ = prepared.write_to_disk();
997                    if let Some(ir) = ir_clone {
998                        if let Ok(ir_guard) = ir.try_read() {
999                            ir_guard.save();
1000                        }
1001                    }
1002                });
1003            }
1004
1005            if let Some((intent, root, session_id)) = explicit_intent {
1006                let _ = crate::core::intent_protocol::apply_side_effects(
1007                    &intent,
1008                    root.as_deref(),
1009                    &session_id,
1010                );
1011            }
1012
1013            if self.autonomy.is_enabled() {
1014                let (calls, project_root) = {
1015                    let session = self.session.read().await;
1016                    (session.stats.total_tool_calls, session.project_root.clone())
1017                };
1018
1019                if let Some(root) = project_root {
1020                    if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
1021                        let root_clone = root.clone();
1022                        tokio::task::spawn_blocking(move || {
1023                            let _ = crate::core::consolidation_engine::consolidate_latest(
1024                                &root_clone,
1025                                crate::core::consolidation_engine::ConsolidationBudgets::default(),
1026                            );
1027                        });
1028                    }
1029                }
1030            }
1031
1032            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
1033            let input_token_count = crate::core::tokens::count_tokens(&input) as u64;
1034            let output_token_count_u64 = output_token_count as u64;
1035            let name_owned = name.to_string();
1036            tokio::task::spawn_blocking(move || {
1037                let pricing = crate::core::gain::model_pricing::ModelPricing::load();
1038                let quote = pricing.quote_from_env_or_agent_type(&client_name);
1039                let cost_usd =
1040                    quote
1041                        .cost
1042                        .estimate_usd(input_token_count, output_token_count_u64, 0, 0);
1043                crate::core::budget_tracker::BudgetTracker::global().record_cost_usd(cost_usd);
1044
1045                let mut store = crate::core::a2a::cost_attribution::CostStore::load();
1046                store.record_tool_call(
1047                    &agent_key,
1048                    &client_name,
1049                    &name_owned,
1050                    input_token_count,
1051                    output_token_count_u64,
1052                    0,
1053                );
1054                let _ = store.save();
1055            });
1056        }
1057
1058        // Context Bus: conflict detection for knowledge writes in shared mode.
1059        if self.session_mode == crate::tools::SessionMode::Shared
1060            && name == "ctx_knowledge"
1061            && action.as_deref() == Some("remember")
1062        {
1063            if let Some(ref rt) = self.context_os {
1064                let my_agent = self.agent_id.read().await.clone();
1065                let category = helpers::get_str(args, "category");
1066                let key = helpers::get_str(args, "key");
1067                if let (Some(ref cat), Some(ref k)) = (&category, &key) {
1068                    let recent = rt.bus.recent_by_kind(
1069                        &self.workspace_id,
1070                        &self.channel_id,
1071                        "knowledge_remembered",
1072                        20,
1073                    );
1074                    for ev in &recent {
1075                        let p = &ev.payload;
1076                        let ev_cat = p.get("category").and_then(|v| v.as_str());
1077                        let ev_key = p.get("key").and_then(|v| v.as_str());
1078                        let ev_actor = ev.actor.as_deref();
1079                        if ev_cat == Some(cat.as_str())
1080                            && ev_key == Some(k.as_str())
1081                            && ev_actor != my_agent.as_deref()
1082                        {
1083                            let other = ev_actor.unwrap_or("unknown");
1084                            result_text = format!(
1085                                "[CONFLICT] Agent '{other}' recently wrote to the same knowledge key \
1086                                 '{cat}/{k}'. Review before proceeding.\n\n{result_text}"
1087                            );
1088                            break;
1089                        }
1090                    }
1091                }
1092            }
1093        }
1094
1095        // Context OS: persist shared session + publish events.
1096        if self.session_mode == crate::tools::SessionMode::Shared {
1097            let ws = self.workspace_id.clone();
1098            let ch = self.channel_id.clone();
1099            let rt = self.context_os.clone();
1100            let agent = self.agent_id.read().await.clone();
1101            let tool = name.to_string();
1102            let tool_action = action.clone();
1103            let tool_path = helpers::get_str(args, "path");
1104            let tool_category = helpers::get_str(args, "category");
1105            let tool_key = helpers::get_str(args, "key");
1106            let session_snapshot = self.session.read().await.clone();
1107            let session_task = session_snapshot.task.clone();
1108            tokio::task::spawn_blocking(move || {
1109                let Some(rt) = rt else {
1110                    return;
1111                };
1112                let Some(root) = session_snapshot.project_root.as_deref() else {
1113                    return;
1114                };
1115                rt.shared_sessions
1116                    .persist_best_effort(root, &ws, &ch, &session_snapshot);
1117                rt.metrics.record_session_persisted();
1118
1119                let mut base_payload = serde_json::json!({
1120                    "tool": tool,
1121                    "action": tool_action,
1122                });
1123                if let Some(ref p) = tool_path {
1124                    base_payload["path"] = serde_json::Value::String(p.clone());
1125                }
1126                if let Some(ref c) = tool_category {
1127                    base_payload["category"] = serde_json::Value::String(c.clone());
1128                }
1129                if let Some(ref k) = tool_key {
1130                    base_payload["key"] = serde_json::Value::String(k.clone());
1131                }
1132                if let Some(ref t) = session_task {
1133                    base_payload["reasoning"] = serde_json::Value::String(t.description.clone());
1134                }
1135
1136                if rt
1137                    .bus
1138                    .append(
1139                        &ws,
1140                        &ch,
1141                        &crate::core::context_os::ContextEventKindV1::ToolCallRecorded,
1142                        agent.as_deref(),
1143                        base_payload.clone(),
1144                    )
1145                    .is_some()
1146                {
1147                    rt.metrics.record_event_appended();
1148                    rt.metrics.record_event_broadcast();
1149                }
1150
1151                if let Some(secondary) =
1152                    crate::core::context_os::secondary_event_kind(&tool, tool_action.as_deref())
1153                {
1154                    if rt
1155                        .bus
1156                        .append(&ws, &ch, &secondary, agent.as_deref(), base_payload)
1157                        .is_some()
1158                    {
1159                        rt.metrics.record_event_appended();
1160                        rt.metrics.record_event_broadcast();
1161                    }
1162                }
1163            });
1164        }
1165
1166        let skip_checkpoint = minimal
1167            || matches!(
1168                name,
1169                "ctx_compress"
1170                    | "ctx_metrics"
1171                    | "ctx_benchmark"
1172                    | "ctx_analyze"
1173                    | "ctx_cache"
1174                    | "ctx_discover"
1175                    | "ctx_dedup"
1176                    | "ctx_session"
1177                    | "ctx_knowledge"
1178                    | "ctx_agent"
1179                    | "ctx_share"
1180                    | "ctx_gain"
1181                    | "ctx_overview"
1182                    | "ctx_preload"
1183                    | "ctx_cost"
1184                    | "ctx_heatmap"
1185                    | "ctx_task"
1186                    | "ctx_impact"
1187                    | "ctx_architecture"
1188                    | "ctx_smells"
1189                    | "ctx_workflow"
1190            );
1191
1192        if !skip_checkpoint && self.increment_and_check() {
1193            if let Some(checkpoint) = self.auto_checkpoint().await {
1194                let interval = LeanCtxServer::checkpoint_interval_effective();
1195                let hints = crate::core::profiles::active_profile().output_hints;
1196                if hints.checkpoint_in_output() && crate::core::protocol::meta_visible() {
1197                    let combined = format!(
1198                        "{result_text}\n\n--- AUTO CHECKPOINT (every {interval} calls) ---\n{checkpoint}"
1199                    );
1200                    return Ok(CallToolResult::success(vec![Content::text(combined)]));
1201                }
1202            }
1203        }
1204
1205        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1206        if tool_duration_ms > 100 {
1207            LeanCtxServer::append_tool_call_log(
1208                name,
1209                tool_duration_ms,
1210                0,
1211                0,
1212                None,
1213                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1214            );
1215        }
1216
1217        let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1218        if current_count > 0 && current_count.is_multiple_of(100) {
1219            std::thread::spawn(crate::cloud_sync::cloud_background_tasks);
1220        }
1221
1222        Ok(CallToolResult::success(vec![Content::text(result_text)]))
1223    }
1224}
1225
1226pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1227    crate::instructions::build_instructions_for_test(crp_mode)
1228}
1229
1230pub fn build_claude_code_instructions_for_test() -> String {
1231    crate::instructions::claude_code_instructions()
1232}
1233
1234const PROJECT_MARKERS: &[&str] = &[
1235    ".git",
1236    "Cargo.toml",
1237    "package.json",
1238    "go.mod",
1239    "pyproject.toml",
1240    "setup.py",
1241    "pom.xml",
1242    "build.gradle",
1243    "Makefile",
1244    ".lean-ctx.toml",
1245];
1246
1247fn has_project_marker(dir: &std::path::Path) -> bool {
1248    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
1249}
1250
1251fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
1252    if let Some(home) = dirs::home_dir() {
1253        if dir == home {
1254            return true;
1255        }
1256    }
1257    let dir_str = dir.to_string_lossy();
1258    dir_str.ends_with("/.claude")
1259        || dir_str.ends_with("/.codex")
1260        || dir_str.contains("/.claude/")
1261        || dir_str.contains("/.codex/")
1262}
1263
1264fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
1265    std::process::Command::new("git")
1266        .args(["rev-parse", "--show-toplevel"])
1267        .current_dir(dir)
1268        .stdout(std::process::Stdio::piped())
1269        .stderr(std::process::Stdio::null())
1270        .output()
1271        .ok()
1272        .and_then(|o| {
1273            if o.status.success() {
1274                String::from_utf8(o.stdout)
1275                    .ok()
1276                    .map(|s| s.trim().to_string())
1277            } else {
1278                None
1279            }
1280        })
1281}
1282
1283pub fn derive_project_root_from_cwd() -> Option<String> {
1284    let cwd = std::env::current_dir().ok()?;
1285    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
1286
1287    if is_home_or_agent_dir(&canonical) {
1288        return git_toplevel_from(&canonical);
1289    }
1290
1291    if has_project_marker(&canonical) {
1292        return Some(canonical.to_string_lossy().to_string());
1293    }
1294
1295    if let Some(git_root) = git_toplevel_from(&canonical) {
1296        return Some(git_root);
1297    }
1298
1299    if let Some(root) = detect_multi_root_workspace(&canonical) {
1300        return Some(root);
1301    }
1302
1303    // Fallback: use CWD as project root if it's a specific, safe directory.
1304    // This ensures bare directories (no .git, no markers) still work.
1305    // Guard: reject home dir, filesystem root, and agent sandbox dirs.
1306    if !is_broad_or_unsafe_root(&canonical) {
1307        tracing::info!(
1308            "No project markers found — using CWD as project root: {}",
1309            canonical.display()
1310        );
1311        return Some(canonical.to_string_lossy().to_string());
1312    }
1313
1314    None
1315}
1316
1317/// Returns true if a directory is too broad/unsafe to serve as a jail root.
1318/// Rejects: home directory, filesystem root, agent sandbox dirs.
1319/// Does NOT reject specific subdirectories like /tmp/my-project or /home/user/data.
1320fn is_broad_or_unsafe_root(dir: &std::path::Path) -> bool {
1321    if let Some(home) = dirs::home_dir() {
1322        if dir == home {
1323            return true;
1324        }
1325    }
1326    let s = dir.to_string_lossy();
1327    if s == "/" || s == "\\" || s == "." {
1328        return true;
1329    }
1330    // Agent sandbox directories
1331    s.ends_with("/.claude")
1332        || s.ends_with("/.codex")
1333        || s.contains("/.claude/")
1334        || s.contains("/.codex/")
1335}
1336
1337/// Detect a multi-root workspace: a directory that has no project markers
1338/// itself, but contains child directories that do. In this case, use the
1339/// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH.
1340fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
1341    let entries = std::fs::read_dir(dir).ok()?;
1342    let mut child_projects: Vec<String> = Vec::new();
1343
1344    for entry in entries.flatten() {
1345        let path = entry.path();
1346        if path.is_dir() && has_project_marker(&path) {
1347            let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
1348            child_projects.push(canonical.to_string_lossy().to_string());
1349        }
1350    }
1351
1352    if child_projects.len() >= 2 {
1353        let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
1354        let sep = if cfg!(windows) { ";" } else { ":" };
1355        let merged = if existing.is_empty() {
1356            child_projects.join(sep)
1357        } else {
1358            format!("{existing}{sep}{}", child_projects.join(sep))
1359        };
1360        std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
1361        tracing::info!(
1362            "Multi-root workspace detected at {}: auto-allowing {} child projects",
1363            dir.display(),
1364            child_projects.len()
1365        );
1366        return Some(dir.to_string_lossy().to_string());
1367    }
1368
1369    None
1370}
1371
1372pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1373    crate::tool_defs::list_all_tool_defs()
1374        .into_iter()
1375        .map(|(name, desc, _)| (name, desc))
1376        .collect()
1377}
1378
1379pub fn tool_schemas_json_for_test() -> String {
1380    crate::tool_defs::list_all_tool_defs()
1381        .iter()
1382        .map(|(name, _, schema)| format!("{name}: {schema}"))
1383        .collect::<Vec<_>>()
1384        .join("\n")
1385}
1386
1387/// Tools that always pass through the workflow gate regardless of state.
1388/// Read-only tools should never be blocked — agents need them for context
1389/// recovery after crashes or session transitions.
1390pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
1391    "ctx",
1392    "ctx_workflow",
1393    "ctx_read",
1394    "ctx_multi_read",
1395    "ctx_smart_read",
1396    "ctx_search",
1397    "ctx_tree",
1398    "ctx_session",
1399    "ctx_ledger",
1400];
1401
1402/// A workflow is stale if it hasn't been updated in 30 minutes.
1403/// This prevents dead workflows from blocking tools across sessions.
1404pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
1405    let elapsed = chrono::Utc::now()
1406        .signed_duration_since(run.updated_at)
1407        .num_minutes();
1408    elapsed > 30
1409}
1410
1411fn is_shell_tool_name(name: &str) -> bool {
1412    matches!(name, "ctx_shell" | "ctx_execute")
1413}
1414
1415fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
1416    let trimmed = cmd.trim();
1417    let parts: Vec<&str> = trimmed.split_whitespace().collect();
1418    if parts.len() < 2 {
1419        return None;
1420    }
1421    let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
1422    match bin {
1423        "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
1424            let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
1425            Some(file_arg.to_string())
1426        }
1427        _ => None,
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::*;
1434
1435    #[test]
1436    fn project_markers_detected() {
1437        let tmp = tempfile::tempdir().unwrap();
1438        let root = tmp.path().join("myproject");
1439        std::fs::create_dir_all(&root).unwrap();
1440        assert!(!has_project_marker(&root));
1441
1442        std::fs::create_dir(root.join(".git")).unwrap();
1443        assert!(has_project_marker(&root));
1444    }
1445
1446    #[test]
1447    fn home_dir_detected_as_agent_dir() {
1448        if let Some(home) = dirs::home_dir() {
1449            assert!(is_home_or_agent_dir(&home));
1450        }
1451    }
1452
1453    #[test]
1454    fn agent_dirs_detected() {
1455        let claude = std::path::PathBuf::from("/home/user/.claude");
1456        assert!(is_home_or_agent_dir(&claude));
1457        let codex = std::path::PathBuf::from("/home/user/.codex");
1458        assert!(is_home_or_agent_dir(&codex));
1459        let project = std::path::PathBuf::from("/home/user/projects/myapp");
1460        assert!(!is_home_or_agent_dir(&project));
1461    }
1462
1463    #[test]
1464    fn test_unified_tool_count() {
1465        let tools = crate::tool_defs::unified_tool_defs();
1466        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1467    }
1468
1469    #[test]
1470    fn test_granular_tool_count() {
1471        let tools = crate::tool_defs::granular_tool_defs();
1472        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
1473    }
1474
1475    #[test]
1476    fn disabled_tools_filters_list() {
1477        let all = crate::tool_defs::granular_tool_defs();
1478        let total = all.len();
1479        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
1480        let filtered: Vec<_> = all
1481            .into_iter()
1482            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1483            .collect();
1484        assert_eq!(filtered.len(), total - 2);
1485        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
1486        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
1487    }
1488
1489    #[test]
1490    fn empty_disabled_tools_returns_all() {
1491        let all = crate::tool_defs::granular_tool_defs();
1492        let total = all.len();
1493        let disabled: Vec<String> = vec![];
1494        let filtered: Vec<_> = all
1495            .into_iter()
1496            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1497            .collect();
1498        assert_eq!(filtered.len(), total);
1499    }
1500
1501    #[test]
1502    fn misspelled_disabled_tool_is_silently_ignored() {
1503        let all = crate::tool_defs::granular_tool_defs();
1504        let total = all.len();
1505        let disabled = ["ctx_nonexistent_tool".to_string()];
1506        let filtered: Vec<_> = all
1507            .into_iter()
1508            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1509            .collect();
1510        assert_eq!(filtered.len(), total);
1511    }
1512
1513    #[test]
1514    fn detect_multi_root_workspace_with_child_projects() {
1515        let tmp = tempfile::tempdir().unwrap();
1516        let workspace = tmp.path().join("workspace");
1517        std::fs::create_dir_all(&workspace).unwrap();
1518
1519        let proj_a = workspace.join("project-a");
1520        let proj_b = workspace.join("project-b");
1521        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1522        std::fs::create_dir_all(&proj_b).unwrap();
1523        std::fs::write(proj_b.join("package.json"), "{}").unwrap();
1524
1525        let result = detect_multi_root_workspace(&workspace);
1526        assert!(
1527            result.is_some(),
1528            "should detect workspace with 2 child projects"
1529        );
1530
1531        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
1532    }
1533
1534    #[test]
1535    fn detect_multi_root_workspace_returns_none_for_single_project() {
1536        let tmp = tempfile::tempdir().unwrap();
1537        let workspace = tmp.path().join("workspace");
1538        std::fs::create_dir_all(&workspace).unwrap();
1539
1540        let proj_a = workspace.join("project-a");
1541        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1542
1543        let result = detect_multi_root_workspace(&workspace);
1544        assert!(
1545            result.is_none(),
1546            "should not detect workspace with only 1 child project"
1547        );
1548    }
1549
1550    #[test]
1551    fn is_broad_or_unsafe_root_rejects_home() {
1552        if let Some(home) = dirs::home_dir() {
1553            assert!(is_broad_or_unsafe_root(&home));
1554        }
1555    }
1556
1557    #[test]
1558    fn is_broad_or_unsafe_root_rejects_filesystem_root() {
1559        assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
1560    }
1561
1562    #[test]
1563    fn is_broad_or_unsafe_root_rejects_agent_dirs() {
1564        assert!(is_broad_or_unsafe_root(std::path::Path::new(
1565            "/home/user/.claude"
1566        )));
1567        assert!(is_broad_or_unsafe_root(std::path::Path::new(
1568            "/home/user/.codex"
1569        )));
1570    }
1571
1572    #[test]
1573    fn is_broad_or_unsafe_root_allows_project_subdir() {
1574        let tmp = tempfile::tempdir().unwrap();
1575        let subdir = tmp.path().join("my-project");
1576        std::fs::create_dir_all(&subdir).unwrap();
1577        assert!(!is_broad_or_unsafe_root(&subdir));
1578    }
1579
1580    #[test]
1581    fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
1582        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
1583            "/tmp/leanctx-test"
1584        )));
1585        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
1586            "/tmp/my-project"
1587        )));
1588    }
1589
1590    #[test]
1591    fn is_broad_or_unsafe_root_allows_home_subdirs() {
1592        if let Some(home) = dirs::home_dir() {
1593            let subdir = home.join("projects").join("my-app");
1594            assert!(!is_broad_or_unsafe_root(&subdir));
1595        }
1596    }
1597
1598    #[test]
1599    fn derive_project_root_falls_back_to_bare_cwd() {
1600        let tmp = tempfile::tempdir().unwrap();
1601        let bare = tmp.path().join("bare-dir");
1602        std::fs::create_dir_all(&bare).unwrap();
1603
1604        let original = std::env::current_dir().unwrap();
1605        std::env::set_current_dir(&bare).unwrap();
1606        let result = derive_project_root_from_cwd();
1607        std::env::set_current_dir(original).unwrap();
1608
1609        assert!(result.is_some(), "bare dir should produce a project root");
1610        let root = result.unwrap();
1611        assert!(
1612            root.contains("bare-dir"),
1613            "fallback should use the bare dir path"
1614        );
1615    }
1616}