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 let Some(state) = run.spec.state(&run.current) {
259                    if let Some(allowed) = &state.allowed_tools {
260                        let mut allow: std::collections::HashSet<&str> =
261                            allowed.iter().map(std::string::String::as_str).collect();
262                        allow.insert("ctx");
263                        allow.insert("ctx_workflow");
264                        return Ok(ListToolsResult {
265                            tools: tools
266                                .into_iter()
267                                .filter(|t| allow.contains(t.name.as_ref()))
268                                .collect(),
269                            ..Default::default()
270                        });
271                    }
272                }
273            }
274            tools
275        };
276
277        let tools = {
278            let cfg = crate::core::config::Config::load();
279            let level = crate::core::config::CompressionLevel::effective(&cfg);
280            let mode =
281                crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
282            if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
283                tools
284            } else {
285                tools
286                    .into_iter()
287                    .map(|mut t| {
288                        let compressed = crate::core::terse::mcp_compress::compress_description(
289                            t.name.as_ref(),
290                            t.description.as_deref().unwrap_or(""),
291                            mode,
292                        );
293                        t.description = Some(compressed.into());
294                        t
295                    })
296                    .collect()
297            }
298        };
299
300        Ok(ListToolsResult {
301            tools,
302            ..Default::default()
303        })
304    }
305
306    async fn list_prompts(
307        &self,
308        _request: Option<PaginatedRequestParams>,
309        _context: RequestContext<RoleServer>,
310    ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
311        Ok(rmcp::model::ListPromptsResult::with_all_items(
312            prompts::list_prompts(),
313        ))
314    }
315
316    async fn get_prompt(
317        &self,
318        request: rmcp::model::GetPromptRequestParams,
319        _context: RequestContext<RoleServer>,
320    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
321        let ledger = self.ledger.read().await;
322        match prompts::get_prompt(&request, &ledger) {
323            Some(result) => Ok(result),
324            None => Err(ErrorData::invalid_params(
325                format!("Unknown prompt: {}", request.name),
326                None,
327            )),
328        }
329    }
330
331    async fn list_resources(
332        &self,
333        _request: Option<PaginatedRequestParams>,
334        _context: RequestContext<RoleServer>,
335    ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
336        Ok(rmcp::model::ListResourcesResult::with_all_items(
337            resources::list_resources(),
338        ))
339    }
340
341    async fn read_resource(
342        &self,
343        request: rmcp::model::ReadResourceRequestParams,
344        _context: RequestContext<RoleServer>,
345    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
346        let ledger = self.ledger.read().await;
347        match resources::read_resource(&request.uri, &ledger) {
348            Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
349            None => Err(rmcp::ErrorData::resource_not_found(
350                format!("Unknown resource: {}", request.uri),
351                None,
352            )),
353        }
354    }
355
356    async fn call_tool(
357        &self,
358        request: CallToolRequestParams,
359        _context: RequestContext<RoleServer>,
360    ) -> Result<CallToolResult, ErrorData> {
361        self.check_idle_expiry().await;
362        elicitation::increment_call();
363
364        let original_name = request.name.as_ref().to_string();
365        let (resolved_name, resolved_args) = if original_name == "ctx" {
366            let sub = request
367                .arguments
368                .as_ref()
369                .and_then(|a| a.get("tool"))
370                .and_then(|v| v.as_str())
371                .map(std::string::ToString::to_string)
372                .ok_or_else(|| {
373                    ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
374                })?;
375            let tool_name = if sub.starts_with("ctx_") {
376                sub
377            } else {
378                format!("ctx_{sub}")
379            };
380            let mut args = request.arguments.unwrap_or_default();
381            args.remove("tool");
382            (tool_name, Some(args))
383        } else {
384            (original_name, request.arguments)
385        };
386        let name = resolved_name.as_str();
387        let args = resolved_args.as_ref();
388
389        let role_check = role_guard::check_tool_access(name);
390        if let Some(denied) = role_guard::into_call_tool_result(&role_check) {
391            tracing::warn!(
392                tool = name,
393                role = %role_check.role_name,
394                "Tool blocked by role policy"
395            );
396            return Ok(denied);
397        }
398
399        if name != "ctx_workflow" {
400            let active = self.workflow.read().await.clone();
401            if let Some(run) = active {
402                if let Some(state) = run.spec.state(&run.current) {
403                    if let Some(allowed) = &state.allowed_tools {
404                        let allowed_ok = allowed.iter().any(|t| t == name) || name == "ctx";
405                        if !allowed_ok {
406                            let mut shown = allowed.clone();
407                            shown.sort();
408                            shown.truncate(30);
409                            return Ok(CallToolResult::success(vec![Content::text(format!(
410                                "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed ({} shown): {}",
411                                run.spec.name,
412                                run.current,
413                                shown.len(),
414                                shown.join(", ")
415                            ))]));
416                        }
417                    }
418                }
419            }
420        }
421
422        let auto_context = {
423            let task = {
424                let session = self.session.read().await;
425                session.task.as_ref().map(|t| t.description.clone())
426            };
427            let project_root = {
428                let session = self.session.read().await;
429                session.project_root.clone()
430            };
431            let mut cache = self.cache.write().await;
432            crate::tools::autonomy::session_lifecycle_pre_hook(
433                &self.autonomy,
434                name,
435                &mut cache,
436                task.as_deref(),
437                project_root.as_deref(),
438                CrpMode::effective(),
439            )
440        };
441
442        let throttle_result = {
443            let fp = args
444                .map(|a| {
445                    crate::core::loop_detection::LoopDetector::fingerprint(
446                        &serde_json::Value::Object(a.clone()),
447                    )
448                })
449                .unwrap_or_default();
450            let mut detector = self.loop_detector.write().await;
451
452            let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
453            let is_search_shell = name == "ctx_shell" && {
454                let cmd = args
455                    .as_ref()
456                    .and_then(|a| a.get("command"))
457                    .and_then(|v| v.as_str())
458                    .unwrap_or("");
459                crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
460            };
461
462            if is_search || is_search_shell {
463                let search_pattern = args.and_then(|a| {
464                    a.get("pattern")
465                        .or_else(|| a.get("query"))
466                        .and_then(|v| v.as_str())
467                });
468                let shell_pattern = if is_search_shell {
469                    args.and_then(|a| a.get("command"))
470                        .and_then(|v| v.as_str())
471                        .and_then(helpers::extract_search_pattern_from_command)
472                } else {
473                    None
474                };
475                let pat = search_pattern.or(shell_pattern.as_deref());
476                detector.record_search(name, &fp, pat)
477            } else {
478                detector.record_call(name, &fp)
479            }
480        };
481
482        if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
483            let msg = throttle_result.message.unwrap_or_default();
484            return Ok(CallToolResult::success(vec![Content::text(msg)]));
485        }
486
487        let throttle_warning =
488            if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
489                throttle_result.message.clone()
490            } else {
491                None
492            };
493
494        let config = crate::core::config::Config::load();
495        let minimal = config.minimal_overhead_effective();
496
497        {
498            use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
499            let snap = BudgetTracker::global().check();
500            if *snap.worst_level() == BudgetLevel::Exhausted
501                && name != "ctx_session"
502                && name != "ctx_cost"
503                && name != "ctx_metrics"
504            {
505                for (dim, lvl, used, limit) in [
506                    (
507                        "tokens",
508                        &snap.tokens.level,
509                        format!("{}", snap.tokens.used),
510                        format!("{}", snap.tokens.limit),
511                    ),
512                    (
513                        "shell",
514                        &snap.shell.level,
515                        format!("{}", snap.shell.used),
516                        format!("{}", snap.shell.limit),
517                    ),
518                    (
519                        "cost",
520                        &snap.cost.level,
521                        format!("${:.2}", snap.cost.used_usd),
522                        format!("${:.2}", snap.cost.limit_usd),
523                    ),
524                ] {
525                    if *lvl == BudgetLevel::Exhausted {
526                        crate::core::events::emit_budget_exhausted(&snap.role, dim, &used, &limit);
527                    }
528                }
529                let msg = format!(
530                    "[BUDGET EXHAUSTED] {}\n\
531                     Use `ctx_session action=role` to check/switch roles, \
532                     or `ctx_session action=reset` to start fresh.",
533                    snap.format_compact()
534                );
535                tracing::warn!(tool = name, "{msg}");
536                return Ok(CallToolResult::success(vec![Content::text(msg)]));
537            }
538        }
539
540        if is_shell_tool_name(name) {
541            crate::core::budget_tracker::BudgetTracker::global().record_shell();
542        }
543
544        let tool_start = std::time::Instant::now();
545        let mut result_text = {
546            use futures::FutureExt;
547            use std::panic::AssertUnwindSafe;
548            match AssertUnwindSafe(self.dispatch_tool(name, args, minimal))
549                .catch_unwind()
550                .await
551            {
552                Ok(Ok(text)) => text,
553                Ok(Err(e)) => return Err(e),
554                Err(panic_payload) => {
555                    let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
556                        (*s).to_string()
557                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
558                        s.clone()
559                    } else {
560                        "unknown".to_string()
561                    };
562                    tracing::error!(tool = name, "Tool panicked: {detail}");
563                    format!("ERROR: lean-ctx internal error in tool '{name}'.\n\
564                             The MCP server is still running. Please retry or use a different approach.")
565                }
566            }
567        };
568
569        let is_raw_shell = name == "ctx_shell" && {
570            let arg_raw = helpers::get_bool(args, "raw").unwrap_or(false);
571            let arg_bypass = helpers::get_bool(args, "bypass").unwrap_or(false);
572            arg_raw
573                || arg_bypass
574                || std::env::var("LEAN_CTX_DISABLED").is_ok()
575                || std::env::var("LEAN_CTX_RAW").is_ok()
576        };
577
578        let pre_terse_len = result_text.len();
579        let output_tokens = {
580            let tokens = crate::core::tokens::count_tokens(&result_text) as u64;
581            crate::core::budget_tracker::BudgetTracker::global().record_tokens(tokens);
582            tokens
583        };
584
585        crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
586        // Persist anomaly detector so dashboard state survives restarts.
587        crate::core::anomaly::save();
588
589        let budget_warning = {
590            use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
591            let snap = BudgetTracker::global().check();
592            if *snap.worst_level() == BudgetLevel::Warning {
593                for (dim, lvl, used, limit, pct) in [
594                    (
595                        "tokens",
596                        &snap.tokens.level,
597                        format!("{}", snap.tokens.used),
598                        format!("{}", snap.tokens.limit),
599                        snap.tokens.percent,
600                    ),
601                    (
602                        "shell",
603                        &snap.shell.level,
604                        format!("{}", snap.shell.used),
605                        format!("{}", snap.shell.limit),
606                        snap.shell.percent,
607                    ),
608                    (
609                        "cost",
610                        &snap.cost.level,
611                        format!("${:.2}", snap.cost.used_usd),
612                        format!("${:.2}", snap.cost.limit_usd),
613                        snap.cost.percent,
614                    ),
615                ] {
616                    if *lvl == BudgetLevel::Warning {
617                        crate::core::events::emit_budget_warning(
618                            &snap.role, dim, &used, &limit, pct,
619                        );
620                    }
621                }
622                if crate::core::protocol::meta_visible() {
623                    Some(format!("[BUDGET WARNING] {}", snap.format_compact()))
624                } else {
625                    None
626                }
627            } else {
628                None
629            }
630        };
631
632        let archive_hint = if minimal || is_raw_shell {
633            None
634        } else {
635            use crate::core::archive;
636            let archivable = matches!(
637                name,
638                "ctx_shell"
639                    | "ctx_read"
640                    | "ctx_multi_read"
641                    | "ctx_smart_read"
642                    | "ctx_execute"
643                    | "ctx_search"
644                    | "ctx_tree"
645            );
646            if archivable && archive::should_archive(&result_text) {
647                let cmd = helpers::get_str(args, "command")
648                    .or_else(|| helpers::get_str(args, "path"))
649                    .unwrap_or_default();
650                let session_id = self.session.read().await.id.clone();
651                let to_store = crate::core::redaction::redact_text_if_enabled(&result_text);
652                let tokens = crate::core::tokens::count_tokens(&to_store);
653                archive::store(name, &cmd, &to_store, Some(&session_id))
654                    .map(|id| archive::format_hint(&id, to_store.len(), tokens))
655            } else {
656                None
657            }
658        };
659
660        let pre_compression = result_text.clone();
661        let skip_terse = is_raw_shell
662            || (name == "ctx_shell"
663                && helpers::get_str(args, "command")
664                    .is_some_and(|c| crate::shell::compress::has_structural_output(&c)));
665        let compression = crate::core::config::CompressionLevel::effective(&config);
666        if compression.is_active() && !skip_terse {
667            let terse_result =
668                crate::core::terse::pipeline::compress(&result_text, &compression, None);
669            if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
670                result_text = terse_result.output;
671            }
672        }
673
674        let profile_hints = crate::core::profiles::active_profile().output_hints;
675
676        if !is_raw_shell && profile_hints.verify_footer() {
677            let verify_cfg = crate::core::profiles::active_profile().verification;
678            let vr = crate::core::output_verification::verify_output(
679                &pre_compression,
680                &result_text,
681                &verify_cfg,
682            );
683            if !vr.warnings.is_empty() {
684                let msg = format!("[VERIFY] {}", vr.format_compact());
685                result_text = format!("{result_text}\n\n{msg}");
686            }
687        }
688
689        if profile_hints.archive_hint() {
690            if let Some(hint) = archive_hint {
691                result_text = format!("{result_text}\n{hint}");
692            }
693        }
694
695        if !is_raw_shell {
696            if let Some(ctx) = auto_context {
697                if crate::core::protocol::meta_visible() {
698                    result_text = format!("{ctx}\n\n{result_text}");
699                }
700            }
701        }
702
703        if let Some(warning) = throttle_warning {
704            result_text = format!("{result_text}\n\n{warning}");
705        }
706
707        if let Some(bw) = budget_warning {
708            result_text = format!("{result_text}\n\n{bw}");
709        }
710
711        if !self
712            .rules_stale_checked
713            .swap(true, std::sync::atomic::Ordering::Relaxed)
714        {
715            let client = self.client_name.read().await.clone();
716            if !client.is_empty() {
717                if let Some(stale_msg) = crate::rules_inject::check_rules_freshness(&client) {
718                    result_text = format!("{result_text}\n\n{stale_msg}");
719                }
720            }
721        }
722
723        {
724            // Evaluate SLOs for observability (watch/dashboard), but keep tool outputs clean.
725            let _ = crate::core::slo::evaluate();
726        }
727
728        if name == "ctx_read" {
729            if minimal {
730                let mut cache = self.cache.write().await;
731                crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
732            } else {
733                let read_path = self
734                    .resolve_path_or_passthrough(
735                        &helpers::get_str(args, "path").unwrap_or_default(),
736                    )
737                    .await;
738                let project_root = {
739                    let session = self.session.read().await;
740                    session.project_root.clone()
741                };
742                let mut cache = self.cache.write().await;
743                let enrich = crate::tools::autonomy::enrich_after_read(
744                    &self.autonomy,
745                    &mut cache,
746                    &read_path,
747                    project_root.as_deref(),
748                    None,
749                    crate::tools::CrpMode::effective(),
750                    false,
751                );
752                if profile_hints.related_hint() {
753                    if let Some(hint) = enrich.related_hint {
754                        result_text = format!("{result_text}\n{hint}");
755                    }
756                }
757                crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
758
759                {
760                    let active_task = {
761                        let session = self.session.read().await;
762                        session.task.as_ref().map(|t| t.description.clone())
763                    };
764                    let mut ledger = self.ledger.write().await;
765                    let overlay = crate::core::context_overlay::OverlayStore::load_project(
766                        &std::path::PathBuf::from(project_root.as_deref().unwrap_or(".")),
767                    );
768                    let mode_used =
769                        helpers::get_str(args, "mode").unwrap_or_else(|| "auto".to_string());
770                    let gate_result = context_gate::post_dispatch_record_with_task(
771                        &read_path,
772                        &mode_used,
773                        pre_terse_len,
774                        output_tokens as usize,
775                        &mut ledger,
776                        &overlay,
777                        active_task.as_deref(),
778                    );
779                    if let Some(hint) = gate_result.eviction_hint {
780                        result_text = format!("{result_text}\n{hint}");
781                    }
782                    if profile_hints.elicitation_hint() {
783                        if let Some(hint) = gate_result.elicitation_hint {
784                            result_text = format!("{result_text}\n{hint}");
785                        }
786                    }
787                    if gate_result.resource_changed {
788                        if let Some(peer) = self.peer.read().await.as_ref() {
789                            notifications::send_resource_updated(
790                                peer,
791                                notifications::RESOURCE_URI_SUMMARY,
792                            )
793                            .await;
794                        }
795                    }
796                }
797            }
798        }
799
800        if !minimal && !is_raw_shell && name == "ctx_shell" {
801            let cmd = helpers::get_str(args, "command").unwrap_or_default();
802
803            if let Some(file_path) = extract_file_read_from_shell(&cmd) {
804                if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
805                    bt.next_seq();
806                    bt.record_shell_file_access(&file_path);
807                }
808            }
809
810            if profile_hints.efficiency_hint() {
811                let calls = self.tool_calls.read().await;
812                let last_original = calls.last().map_or(0, |c| c.original_tokens);
813                drop(calls);
814                let pre_hint_tokens = crate::core::tokens::count_tokens(&result_text);
815                if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
816                    &self.autonomy,
817                    &cmd,
818                    last_original,
819                    pre_hint_tokens,
820                ) {
821                    result_text = format!("{result_text}\n{hint}");
822                }
823            }
824        }
825
826        if !minimal && !is_raw_shell {
827            bypass_hint::record_lctx_call();
828            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
829                if let Some(hint) = bypass_hint::check(&data_dir) {
830                    result_text = format!("{result_text}\n{hint}");
831                }
832            }
833        }
834
835        #[allow(clippy::cast_possible_truncation)]
836        let output_token_count = if result_text.len() == pre_terse_len {
837            output_tokens as usize
838        } else {
839            crate::core::tokens::count_tokens(&result_text)
840        };
841        let action = helpers::get_str(args, "action");
842
843        // K-bounded staleness guard: warn if shared context has diverged.
844        const K_STALENESS_BOUND: i64 = 10;
845        if self.session_mode == crate::tools::SessionMode::Shared {
846            if let Some(ref rt) = self.context_os {
847                let latest = rt.bus.latest_id(&self.workspace_id, &self.channel_id);
848                let cursor = self
849                    .last_seen_event_id
850                    .load(std::sync::atomic::Ordering::Relaxed);
851                if cursor > 0 && latest - cursor > K_STALENESS_BOUND {
852                    let gap = latest - cursor;
853                    result_text = format!(
854                        "[CONTEXT STALE] {gap} events happened since your last read. \
855                         Use ctx_session(action=\"status\") to sync.\n\n{result_text}"
856                    );
857                }
858                self.last_seen_event_id
859                    .store(latest, std::sync::atomic::Ordering::Relaxed);
860            }
861        }
862
863        {
864            let input = helpers::canonical_args_string(args);
865            let input_md5 = helpers::hash_fast(&input);
866            let output_md5 = helpers::hash_fast(&result_text);
867            let agent_id = self.agent_id.read().await.clone();
868            let client_name = self.client_name.read().await.clone();
869            let mut explicit_intent: Option<(
870                crate::core::intent_protocol::IntentRecord,
871                Option<String>,
872                String,
873            )> = None;
874
875            let pending_session_save = {
876                let empty_args = serde_json::Map::new();
877                let args_map = args.unwrap_or(&empty_args);
878                let mut session = self.session.write().await;
879                session.record_tool_receipt(
880                    name,
881                    action.as_deref(),
882                    &input_md5,
883                    &output_md5,
884                    agent_id.as_deref(),
885                    Some(&client_name),
886                );
887
888                if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
889                    name,
890                    action.as_deref(),
891                    args_map,
892                    session.project_root.as_deref(),
893                ) {
894                    let is_explicit =
895                        intent.source == crate::core::intent_protocol::IntentSource::Explicit;
896                    let root = session.project_root.clone();
897                    let sid = session.id.clone();
898                    session.record_intent(intent.clone());
899                    if is_explicit {
900                        explicit_intent = Some((intent, root, sid));
901                    }
902                }
903                if session.should_save() {
904                    session.prepare_save().ok()
905                } else {
906                    None
907                }
908            };
909
910            if let Some(prepared) = pending_session_save {
911                tokio::task::spawn_blocking(move || {
912                    let _ = prepared.write_to_disk();
913                });
914            }
915
916            if let Some((intent, root, session_id)) = explicit_intent {
917                let _ = crate::core::intent_protocol::apply_side_effects(
918                    &intent,
919                    root.as_deref(),
920                    &session_id,
921                );
922            }
923
924            if self.autonomy.is_enabled() {
925                let (calls, project_root) = {
926                    let session = self.session.read().await;
927                    (session.stats.total_tool_calls, session.project_root.clone())
928                };
929
930                if let Some(root) = project_root {
931                    if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
932                        let root_clone = root.clone();
933                        tokio::task::spawn_blocking(move || {
934                            let _ = crate::core::consolidation_engine::consolidate_latest(
935                                &root_clone,
936                                crate::core::consolidation_engine::ConsolidationBudgets::default(),
937                            );
938                        });
939                    }
940                }
941            }
942
943            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
944            let input_token_count = crate::core::tokens::count_tokens(&input) as u64;
945            let output_token_count_u64 = output_token_count as u64;
946            let name_owned = name.to_string();
947            tokio::task::spawn_blocking(move || {
948                let pricing = crate::core::gain::model_pricing::ModelPricing::load();
949                let quote = pricing.quote_from_env_or_agent_type(&client_name);
950                let cost_usd =
951                    quote
952                        .cost
953                        .estimate_usd(input_token_count, output_token_count_u64, 0, 0);
954                crate::core::budget_tracker::BudgetTracker::global().record_cost_usd(cost_usd);
955
956                let mut store = crate::core::a2a::cost_attribution::CostStore::load();
957                store.record_tool_call(
958                    &agent_key,
959                    &client_name,
960                    &name_owned,
961                    input_token_count,
962                    output_token_count_u64,
963                    0,
964                );
965                let _ = store.save();
966            });
967        }
968
969        // Context Bus: conflict detection for knowledge writes in shared mode.
970        if self.session_mode == crate::tools::SessionMode::Shared
971            && name == "ctx_knowledge"
972            && action.as_deref() == Some("remember")
973        {
974            if let Some(ref rt) = self.context_os {
975                let my_agent = self.agent_id.read().await.clone();
976                let category = helpers::get_str(args, "category");
977                let key = helpers::get_str(args, "key");
978                if let (Some(ref cat), Some(ref k)) = (&category, &key) {
979                    let recent = rt.bus.recent_by_kind(
980                        &self.workspace_id,
981                        &self.channel_id,
982                        "knowledge_remembered",
983                        20,
984                    );
985                    for ev in &recent {
986                        let p = &ev.payload;
987                        let ev_cat = p.get("category").and_then(|v| v.as_str());
988                        let ev_key = p.get("key").and_then(|v| v.as_str());
989                        let ev_actor = ev.actor.as_deref();
990                        if ev_cat == Some(cat.as_str())
991                            && ev_key == Some(k.as_str())
992                            && ev_actor != my_agent.as_deref()
993                        {
994                            let other = ev_actor.unwrap_or("unknown");
995                            result_text = format!(
996                                "[CONFLICT] Agent '{other}' recently wrote to the same knowledge key \
997                                 '{cat}/{k}'. Review before proceeding.\n\n{result_text}"
998                            );
999                            break;
1000                        }
1001                    }
1002                }
1003            }
1004        }
1005
1006        // Context OS: persist shared session + publish events.
1007        if self.session_mode == crate::tools::SessionMode::Shared {
1008            let ws = self.workspace_id.clone();
1009            let ch = self.channel_id.clone();
1010            let rt = self.context_os.clone();
1011            let agent = self.agent_id.read().await.clone();
1012            let tool = name.to_string();
1013            let tool_action = action.clone();
1014            let tool_path = helpers::get_str(args, "path");
1015            let tool_category = helpers::get_str(args, "category");
1016            let tool_key = helpers::get_str(args, "key");
1017            let session_snapshot = self.session.read().await.clone();
1018            let session_task = session_snapshot.task.clone();
1019            tokio::task::spawn_blocking(move || {
1020                let Some(rt) = rt else {
1021                    return;
1022                };
1023                let Some(root) = session_snapshot.project_root.as_deref() else {
1024                    return;
1025                };
1026                rt.shared_sessions
1027                    .persist_best_effort(root, &ws, &ch, &session_snapshot);
1028                rt.metrics.record_session_persisted();
1029
1030                let mut base_payload = serde_json::json!({
1031                    "tool": tool,
1032                    "action": tool_action,
1033                });
1034                if let Some(ref p) = tool_path {
1035                    base_payload["path"] = serde_json::Value::String(p.clone());
1036                }
1037                if let Some(ref c) = tool_category {
1038                    base_payload["category"] = serde_json::Value::String(c.clone());
1039                }
1040                if let Some(ref k) = tool_key {
1041                    base_payload["key"] = serde_json::Value::String(k.clone());
1042                }
1043                if let Some(ref t) = session_task {
1044                    base_payload["reasoning"] = serde_json::Value::String(t.description.clone());
1045                }
1046
1047                if rt
1048                    .bus
1049                    .append(
1050                        &ws,
1051                        &ch,
1052                        &crate::core::context_os::ContextEventKindV1::ToolCallRecorded,
1053                        agent.as_deref(),
1054                        base_payload.clone(),
1055                    )
1056                    .is_some()
1057                {
1058                    rt.metrics.record_event_appended();
1059                    rt.metrics.record_event_broadcast();
1060                }
1061
1062                if let Some(secondary) =
1063                    crate::core::context_os::secondary_event_kind(&tool, tool_action.as_deref())
1064                {
1065                    if rt
1066                        .bus
1067                        .append(&ws, &ch, &secondary, agent.as_deref(), base_payload)
1068                        .is_some()
1069                    {
1070                        rt.metrics.record_event_appended();
1071                        rt.metrics.record_event_broadcast();
1072                    }
1073                }
1074            });
1075        }
1076
1077        let skip_checkpoint = minimal
1078            || matches!(
1079                name,
1080                "ctx_compress"
1081                    | "ctx_metrics"
1082                    | "ctx_benchmark"
1083                    | "ctx_analyze"
1084                    | "ctx_cache"
1085                    | "ctx_discover"
1086                    | "ctx_dedup"
1087                    | "ctx_session"
1088                    | "ctx_knowledge"
1089                    | "ctx_agent"
1090                    | "ctx_share"
1091                    | "ctx_gain"
1092                    | "ctx_overview"
1093                    | "ctx_preload"
1094                    | "ctx_cost"
1095                    | "ctx_heatmap"
1096                    | "ctx_task"
1097                    | "ctx_impact"
1098                    | "ctx_architecture"
1099                    | "ctx_smells"
1100                    | "ctx_workflow"
1101            );
1102
1103        if !skip_checkpoint && self.increment_and_check() {
1104            if let Some(checkpoint) = self.auto_checkpoint().await {
1105                let interval = LeanCtxServer::checkpoint_interval_effective();
1106                let hints = crate::core::profiles::active_profile().output_hints;
1107                if hints.checkpoint_in_output() && crate::core::protocol::meta_visible() {
1108                    let combined = format!(
1109                        "{result_text}\n\n--- AUTO CHECKPOINT (every {interval} calls) ---\n{checkpoint}"
1110                    );
1111                    return Ok(CallToolResult::success(vec![Content::text(combined)]));
1112                }
1113            }
1114        }
1115
1116        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1117        if tool_duration_ms > 100 {
1118            LeanCtxServer::append_tool_call_log(
1119                name,
1120                tool_duration_ms,
1121                0,
1122                0,
1123                None,
1124                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1125            );
1126        }
1127
1128        let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1129        if current_count > 0 && current_count.is_multiple_of(100) {
1130            std::thread::spawn(crate::cloud_sync::cloud_background_tasks);
1131        }
1132
1133        Ok(CallToolResult::success(vec![Content::text(result_text)]))
1134    }
1135}
1136
1137pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1138    crate::instructions::build_instructions_for_test(crp_mode)
1139}
1140
1141pub fn build_claude_code_instructions_for_test() -> String {
1142    crate::instructions::claude_code_instructions()
1143}
1144
1145const PROJECT_MARKERS: &[&str] = &[
1146    ".git",
1147    "Cargo.toml",
1148    "package.json",
1149    "go.mod",
1150    "pyproject.toml",
1151    "setup.py",
1152    "pom.xml",
1153    "build.gradle",
1154    "Makefile",
1155    ".lean-ctx.toml",
1156];
1157
1158fn has_project_marker(dir: &std::path::Path) -> bool {
1159    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
1160}
1161
1162fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
1163    if let Some(home) = dirs::home_dir() {
1164        if dir == home {
1165            return true;
1166        }
1167    }
1168    let dir_str = dir.to_string_lossy();
1169    dir_str.ends_with("/.claude")
1170        || dir_str.ends_with("/.codex")
1171        || dir_str.contains("/.claude/")
1172        || dir_str.contains("/.codex/")
1173}
1174
1175fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
1176    std::process::Command::new("git")
1177        .args(["rev-parse", "--show-toplevel"])
1178        .current_dir(dir)
1179        .stdout(std::process::Stdio::piped())
1180        .stderr(std::process::Stdio::null())
1181        .output()
1182        .ok()
1183        .and_then(|o| {
1184            if o.status.success() {
1185                String::from_utf8(o.stdout)
1186                    .ok()
1187                    .map(|s| s.trim().to_string())
1188            } else {
1189                None
1190            }
1191        })
1192}
1193
1194pub fn derive_project_root_from_cwd() -> Option<String> {
1195    let cwd = std::env::current_dir().ok()?;
1196    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
1197
1198    if is_home_or_agent_dir(&canonical) {
1199        return git_toplevel_from(&canonical);
1200    }
1201
1202    if has_project_marker(&canonical) {
1203        return Some(canonical.to_string_lossy().to_string());
1204    }
1205
1206    if let Some(git_root) = git_toplevel_from(&canonical) {
1207        return Some(git_root);
1208    }
1209
1210    if let Some(root) = detect_multi_root_workspace(&canonical) {
1211        return Some(root);
1212    }
1213
1214    None
1215}
1216
1217/// Detect a multi-root workspace: a directory that has no project markers
1218/// itself, but contains child directories that do. In this case, use the
1219/// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH.
1220fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
1221    let entries = std::fs::read_dir(dir).ok()?;
1222    let mut child_projects: Vec<String> = Vec::new();
1223
1224    for entry in entries.flatten() {
1225        let path = entry.path();
1226        if path.is_dir() && has_project_marker(&path) {
1227            let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
1228            child_projects.push(canonical.to_string_lossy().to_string());
1229        }
1230    }
1231
1232    if child_projects.len() >= 2 {
1233        let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
1234        let sep = if cfg!(windows) { ";" } else { ":" };
1235        let merged = if existing.is_empty() {
1236            child_projects.join(sep)
1237        } else {
1238            format!("{existing}{sep}{}", child_projects.join(sep))
1239        };
1240        std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
1241        tracing::info!(
1242            "Multi-root workspace detected at {}: auto-allowing {} child projects",
1243            dir.display(),
1244            child_projects.len()
1245        );
1246        return Some(dir.to_string_lossy().to_string());
1247    }
1248
1249    None
1250}
1251
1252pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1253    crate::tool_defs::list_all_tool_defs()
1254        .into_iter()
1255        .map(|(name, desc, _)| (name, desc))
1256        .collect()
1257}
1258
1259pub fn tool_schemas_json_for_test() -> String {
1260    crate::tool_defs::list_all_tool_defs()
1261        .iter()
1262        .map(|(name, _, schema)| format!("{name}: {schema}"))
1263        .collect::<Vec<_>>()
1264        .join("\n")
1265}
1266
1267fn is_shell_tool_name(name: &str) -> bool {
1268    matches!(name, "ctx_shell" | "ctx_execute")
1269}
1270
1271fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
1272    let trimmed = cmd.trim();
1273    let parts: Vec<&str> = trimmed.split_whitespace().collect();
1274    if parts.len() < 2 {
1275        return None;
1276    }
1277    let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
1278    match bin {
1279        "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
1280            let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
1281            Some(file_arg.to_string())
1282        }
1283        _ => None,
1284    }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290
1291    #[test]
1292    fn project_markers_detected() {
1293        let tmp = tempfile::tempdir().unwrap();
1294        let root = tmp.path().join("myproject");
1295        std::fs::create_dir_all(&root).unwrap();
1296        assert!(!has_project_marker(&root));
1297
1298        std::fs::create_dir(root.join(".git")).unwrap();
1299        assert!(has_project_marker(&root));
1300    }
1301
1302    #[test]
1303    fn home_dir_detected_as_agent_dir() {
1304        if let Some(home) = dirs::home_dir() {
1305            assert!(is_home_or_agent_dir(&home));
1306        }
1307    }
1308
1309    #[test]
1310    fn agent_dirs_detected() {
1311        let claude = std::path::PathBuf::from("/home/user/.claude");
1312        assert!(is_home_or_agent_dir(&claude));
1313        let codex = std::path::PathBuf::from("/home/user/.codex");
1314        assert!(is_home_or_agent_dir(&codex));
1315        let project = std::path::PathBuf::from("/home/user/projects/myapp");
1316        assert!(!is_home_or_agent_dir(&project));
1317    }
1318
1319    #[test]
1320    fn test_unified_tool_count() {
1321        let tools = crate::tool_defs::unified_tool_defs();
1322        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1323    }
1324
1325    #[test]
1326    fn test_granular_tool_count() {
1327        let tools = crate::tool_defs::granular_tool_defs();
1328        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
1329    }
1330
1331    #[test]
1332    fn disabled_tools_filters_list() {
1333        let all = crate::tool_defs::granular_tool_defs();
1334        let total = all.len();
1335        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
1336        let filtered: Vec<_> = all
1337            .into_iter()
1338            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1339            .collect();
1340        assert_eq!(filtered.len(), total - 2);
1341        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
1342        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
1343    }
1344
1345    #[test]
1346    fn empty_disabled_tools_returns_all() {
1347        let all = crate::tool_defs::granular_tool_defs();
1348        let total = all.len();
1349        let disabled: Vec<String> = vec![];
1350        let filtered: Vec<_> = all
1351            .into_iter()
1352            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1353            .collect();
1354        assert_eq!(filtered.len(), total);
1355    }
1356
1357    #[test]
1358    fn misspelled_disabled_tool_is_silently_ignored() {
1359        let all = crate::tool_defs::granular_tool_defs();
1360        let total = all.len();
1361        let disabled = ["ctx_nonexistent_tool".to_string()];
1362        let filtered: Vec<_> = all
1363            .into_iter()
1364            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1365            .collect();
1366        assert_eq!(filtered.len(), total);
1367    }
1368
1369    #[test]
1370    fn detect_multi_root_workspace_with_child_projects() {
1371        let tmp = tempfile::tempdir().unwrap();
1372        let workspace = tmp.path().join("workspace");
1373        std::fs::create_dir_all(&workspace).unwrap();
1374
1375        let proj_a = workspace.join("project-a");
1376        let proj_b = workspace.join("project-b");
1377        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1378        std::fs::create_dir_all(&proj_b).unwrap();
1379        std::fs::write(proj_b.join("package.json"), "{}").unwrap();
1380
1381        let result = detect_multi_root_workspace(&workspace);
1382        assert!(
1383            result.is_some(),
1384            "should detect workspace with 2 child projects"
1385        );
1386
1387        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
1388    }
1389
1390    #[test]
1391    fn detect_multi_root_workspace_returns_none_for_single_project() {
1392        let tmp = tempfile::tempdir().unwrap();
1393        let workspace = tmp.path().join("workspace");
1394        std::fs::create_dir_all(&workspace).unwrap();
1395
1396        let proj_a = workspace.join("project-a");
1397        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1398
1399        let result = detect_multi_root_workspace(&workspace);
1400        assert!(
1401            result.is_none(),
1402            "should not detect workspace with only 1 child project"
1403        );
1404    }
1405}