Skip to main content

lean_ctx/server/
mod.rs

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