Skip to main content

lean_ctx/core/
tool_health.rs

1//! `lean-ctx tools health` — does every advertised MCP tool and injected rule
2//! earn its always-on token cost? (#848)
3//!
4//! lean-ctx ships a broad MCP tool set plus injected rules files; the thesis is "every
5//! token earns its place". This report cross-references the *fixed cost* of each
6//! advertised tool schema and rules file with *recorded usage* (the
7//! [`CostStore`] post-dispatch ledger) and flags "rot":
8//!
9//! * tools that cost schema tokens every session but are never called,
10//! * rules files that bill the same guidance to a client more than once,
11//! * stale knowledge facts (old and never retrieved).
12//!
13//! Deterministic and local-only: it reads existing on-disk telemetry, sorts
14//! everything stably, and adds **no** new hot-path cost (`last_used` rides the
15//! existing cost-attribution write). It never auto-applies anything — every
16//! finding is a suggestion the operator acts on explicitly.
17
18use std::path::Path;
19
20use serde::Serialize;
21
22use crate::core::a2a::cost_attribution::CostStore;
23use crate::core::context_overhead::tool_tokens;
24use crate::core::rules_overhead::{RulesFileCost, collect_rules_files, duplicate_clients};
25
26/// A tool whose schema costs >= this many tokens *and* is used for <1% of all
27/// calls is flagged `LowUse` — expensive surface that barely pays its way.
28const LOW_USE_TOKEN_FLOOR: usize = 150;
29/// Share of total recorded calls below which a heavy tool counts as `LowUse`.
30const LOW_USE_CALL_SHARE: f64 = 0.01;
31/// A fact older than this (days) that was never retrieved is a prune candidate.
32const STALE_FACT_DAYS: i64 = 30;
33/// A rules carrier larger than this is more than a pointer (#684). On hosts
34/// whose MCP instructions already deliver the full mapping, anything beyond a
35/// pointer duplicates guidance that is billed once per session anyway.
36const CROSS_LAYER_POINTER_MAX: usize = 120;
37/// Approximate size of a pointer-only lean-ctx block.
38const POINTER_TOKENS: usize = 40;
39/// Clients that receive the full lean-ctx mapping via MCP `instructions` +
40/// SessionStart context (dedicated hosts) — their static rules files only
41/// need a pointer. Claude Code is not one: its `CLAUDE.md` block is the single
42/// rules channel and the instructions collapse to an anchor (A3).
43const DEDICATED_INSTRUCTION_CLIENTS: &[&str] = &["codex", "codebuddy"];
44
45#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum ToolStatus {
48    /// Called regularly — earns its schema cost.
49    Active,
50    /// Used, but rarely, while carrying a heavy schema.
51    LowUse,
52    /// Never called in the recorded history — pure rot.
53    Unused,
54    /// No usage telemetry yet — cannot judge.
55    Unknown,
56}
57
58impl ToolStatus {
59    #[must_use]
60    pub fn label(self) -> &'static str {
61        match self {
62            ToolStatus::Active => "active",
63            ToolStatus::LowUse => "low-use",
64            ToolStatus::Unused => "unused",
65            ToolStatus::Unknown => "unknown",
66        }
67    }
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct ToolEntry {
72    pub name: String,
73    pub schema_tokens: usize,
74    pub calls: u64,
75    pub last_used: Option<String>,
76    pub status: ToolStatus,
77    pub action: String,
78    /// Value-per-token signal: recorded calls per 1 000 always-on schema tokens.
79    /// The telemetry fallback for #961 when no per-tool outcome eval exists.
80    pub value_per_1k_tokens: f64,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct RuleEntry {
85    pub path: String,
86    pub file_tokens: usize,
87    pub lean_ctx_tokens: usize,
88    pub carries_full: bool,
89    pub clients: Vec<String>,
90    pub action: String,
91}
92
93#[derive(Debug, Clone, Serialize, Default)]
94pub struct KnowledgeHealth {
95    pub total_facts: usize,
96    pub active_facts: usize,
97    pub stale_facts: usize,
98    pub action: String,
99}
100
101/// Measured agent-behavior signals (turn economy): how reads and tool chains
102/// actually happen, from local telemetry only.
103#[derive(Debug, Clone, Serialize, Default)]
104pub struct BehaviorHealth {
105    /// Explicit full/raw share of recorded `ctx_read` mode invocations (%),
106    /// `None` until mode telemetry exists.
107    pub heavy_read_share_pct: Option<u8>,
108    pub heavy_mode_reads: u64,
109    pub total_mode_reads: u64,
110    /// search→read→search/graph sequences one `ctx_compose` call would bundle.
111    pub chains_detected: u64,
112    pub compose_calls: u64,
113    pub hints_shown: u64,
114    pub action: String,
115}
116
117/// Share of recorded read modes above which explicit full/raw reads count as
118/// a steering problem rather than deliberate edit prep.
119const HEAVY_READ_SHARE_WARN_PCT: u8 = 40;
120
121/// Read-mode keys that participate in the heavy-read share. The CEP mode map
122/// also carries non-read instrumentation keys (register/compose/…) that must
123/// not dilute the denominator.
124fn is_read_mode(key: &str) -> bool {
125    matches!(
126        key,
127        "full"
128            | "raw"
129            | "auto"
130            | "map"
131            | "signatures"
132            | "task"
133            | "reference"
134            | "aggressive"
135            | "entropy"
136            | "diff"
137            | "delta"
138            | "fresh"
139            | "anchored"
140    ) || key.starts_with("lines:")
141        || key.starts_with("anchored:")
142        || key.starts_with("budget:")
143}
144
145/// Pure builder for the behavior section: read-mode skew from the CEP mode
146/// counters plus the nudge store's chain/heavy-read detections.
147fn behavior_health(
148    modes: &std::collections::HashMap<String, u64>,
149    nudges: &crate::core::behavior_nudge::BehaviorStore,
150    compose_calls: u64,
151) -> BehaviorHealth {
152    let total_mode_reads: u64 = modes
153        .iter()
154        .filter(|(k, _)| is_read_mode(k))
155        .map(|(_, v)| *v)
156        .sum();
157    let heavy_mode_reads: u64 = modes
158        .iter()
159        .filter(|(k, _)| matches!(k.as_str(), "full" | "raw"))
160        .map(|(_, v)| *v)
161        .sum();
162    let heavy_read_share_pct = (total_mode_reads > 0)
163        .then(|| u8::try_from(heavy_mode_reads * 100 / total_mode_reads).unwrap_or(100));
164    let action = match heavy_read_share_pct {
165        Some(pct) if pct >= HEAVY_READ_SHARE_WARN_PCT => format!(
166            "explicit full/raw dominates recorded reads ({pct}%) — signatures/map cover orientation at a fraction of the tokens; in-band nudges are counting (see behavior_nudges)"
167        ),
168        _ => String::new(),
169    };
170    BehaviorHealth {
171        heavy_read_share_pct,
172        heavy_mode_reads,
173        total_mode_reads,
174        chains_detected: nudges.chains_detected,
175        compose_calls,
176        hints_shown: nudges.hints_shown,
177        action,
178    }
179}
180
181#[derive(Debug, Clone, Serialize)]
182pub struct ToolHealthReport {
183    pub tool_profile: String,
184    pub advertised_tools: usize,
185    pub tool_schema_tokens: usize,
186    pub instruction_tokens: usize,
187    pub rules_tokens: usize,
188    /// Tool schemas + MCP instructions + every auto-loaded rules file.
189    pub fixed_total_tokens: usize,
190    pub has_usage_data: bool,
191    pub total_recorded_calls: u64,
192    pub unused_tools: usize,
193    /// Schema tokens spent every session on tools that are never called.
194    pub unused_tool_tokens: usize,
195    /// Low-value tools (unused or low-use) the operator should consider disabling.
196    pub disable_candidates: Vec<String>,
197    /// Schema tokens reclaimable by disabling every [`Self::disable_candidates`].
198    pub reclaimable_tokens: usize,
199    /// Aggregated, copy-pasteable "consider disabling X" recommendation.
200    pub disable_action: String,
201    pub tools: Vec<ToolEntry>,
202    pub rules: Vec<RuleEntry>,
203    pub duplicate_clients: Vec<(String, usize)>,
204    pub knowledge: KnowledgeHealth,
205    /// Non-lean-ctx MCP servers the client loads every session, with measured
206    /// transcript usage — unused ones are pure fixed cost lean-ctx cannot see.
207    pub foreign_servers: Vec<crate::core::foreign_mcp::ForeignServerEntry>,
208    /// Measured behavior signals: read-mode skew, chains vs compose, hints.
209    pub behavior: BehaviorHealth,
210}
211
212fn classify(has_usage: bool, calls: u64, schema_tokens: usize, total_calls: u64) -> ToolStatus {
213    if !has_usage {
214        return ToolStatus::Unknown;
215    }
216    if calls == 0 {
217        return ToolStatus::Unused;
218    }
219    if schema_tokens >= LOW_USE_TOKEN_FLOOR
220        && (calls as f64) < (total_calls as f64) * LOW_USE_CALL_SHARE
221    {
222        return ToolStatus::LowUse;
223    }
224    ToolStatus::Active
225}
226
227/// Recorded calls per 1 000 always-on schema tokens — higher = better value.
228fn value_per_1k(calls: u64, schema_tokens: usize) -> f64 {
229    if schema_tokens == 0 {
230        0.0
231    } else {
232        calls as f64 / schema_tokens as f64 * 1000.0
233    }
234}
235
236fn action_for(status: ToolStatus, calls: u64, schema_tokens: usize) -> String {
237    match status {
238        ToolStatus::Unused => format!(
239            "never called — trim via a leaner tool profile to reclaim {schema_tokens} tok/session"
240        ),
241        ToolStatus::LowUse => {
242            format!("rarely used ({calls}×) yet costs {schema_tokens} tok/session — review")
243        }
244        ToolStatus::Active | ToolStatus::Unknown => String::new(),
245    }
246}
247
248/// Maps alias tool names to their delegate target for usage lookup.
249/// Alias tools (e.g. `shell`) delegate to a canonical tool (e.g. `ctx_shell`)
250/// and stats are recorded under the canonical name. Without this mapping,
251/// `tools health` reports the alias as "never called".
252fn resolve_alias(name: &str) -> Option<&'static str> {
253    match name {
254        "shell" => Some("ctx_shell"),
255        _ => None,
256    }
257}
258
259/// Pure report builder — every input is supplied, so it is fully deterministic
260/// and unit-testable without touching disk or the clock.
261#[must_use]
262pub(crate) fn build_report(
263    advertised: &[rmcp::model::Tool],
264    usage: &CostStore,
265    rules: &[RulesFileCost],
266    duplicates: Vec<(String, usize)>,
267    instruction_tokens: usize,
268    tool_profile: String,
269    knowledge: KnowledgeHealth,
270) -> ToolHealthReport {
271    let total_recorded_calls: u64 = usage.tools.values().map(|t| t.total_calls).sum();
272    let has_usage_data = total_recorded_calls > 0;
273
274    let mut tools: Vec<ToolEntry> = advertised
275        .iter()
276        .map(|t| {
277            let name = t.name.as_ref().to_string();
278            let schema_tokens = tool_tokens(t);
279            let (calls, last_used) = usage
280                .tools
281                .get(&name)
282                .or_else(|| resolve_alias(&name).and_then(|a| usage.tools.get(a)))
283                .map_or((0, None), |c| (c.total_calls, c.last_used.clone()));
284            let status = classify(has_usage_data, calls, schema_tokens, total_recorded_calls);
285            let action = action_for(status, calls, schema_tokens);
286            ToolEntry {
287                name,
288                schema_tokens,
289                calls,
290                last_used,
291                status,
292                action,
293                value_per_1k_tokens: value_per_1k(calls, schema_tokens),
294            }
295        })
296        .collect();
297    tools.sort_by(|a, b| a.name.cmp(&b.name));
298
299    let tool_schema_tokens: usize = tools.iter().map(|t| t.schema_tokens).sum();
300    let unused_tools = tools
301        .iter()
302        .filter(|t| t.status == ToolStatus::Unused)
303        .count();
304    let unused_tool_tokens = tools
305        .iter()
306        .filter(|t| t.status == ToolStatus::Unused)
307        .map(|t| t.schema_tokens)
308        .sum();
309
310    // Low-value tools the operator should consider disabling: never-called or
311    // heavy-but-rarely-called. `tools` is already name-sorted → deterministic.
312    let low_value = |t: &&ToolEntry| matches!(t.status, ToolStatus::Unused | ToolStatus::LowUse);
313    let disable_candidates: Vec<String> = tools
314        .iter()
315        .filter(low_value)
316        .map(|t| t.name.clone())
317        .collect();
318    let reclaimable_tokens: usize = tools
319        .iter()
320        .filter(low_value)
321        .map(|t| t.schema_tokens)
322        .sum();
323    let disable_action = if disable_candidates.is_empty() {
324        String::new()
325    } else {
326        format!(
327            "consider disabling {} low-value tool(s) to reclaim {reclaimable_tokens} tok/session: {} — apply via `disabled_tools` in config or a leaner `tool_profile`",
328            disable_candidates.len(),
329            disable_candidates.join(", ")
330        )
331    };
332
333    let dup_clients: std::collections::HashSet<&str> =
334        duplicates.iter().map(|(c, _)| c.as_str()).collect();
335    let rules_out: Vec<RuleEntry> = rules
336        .iter()
337        .map(|r| {
338            let is_dup = r.carries_full && r.clients.iter().any(|c| dup_clients.contains(c));
339            let cross_layer = !is_dup
340                && r.lean_ctx_tokens > CROSS_LAYER_POINTER_MAX
341                && r.clients
342                    .iter()
343                    .any(|c| DEDICATED_INSTRUCTION_CLIENTS.contains(c));
344            let action = if is_dup {
345                "duplicate full lean-ctx source — run `lean-ctx rules dedup --apply`".to_string()
346            } else if cross_layer {
347                format!(
348                    "carries {} lean-ctx tokens although MCP instructions already deliver the full mapping on dedicated hosts — thin to a pointer to reclaim ~{} tok/session",
349                    r.lean_ctx_tokens,
350                    r.lean_ctx_tokens.saturating_sub(POINTER_TOKENS)
351                )
352            } else {
353                String::new()
354            };
355            RuleEntry {
356                path: r.path.clone(),
357                file_tokens: r.file_tokens,
358                lean_ctx_tokens: r.lean_ctx_tokens,
359                carries_full: r.carries_full,
360                clients: r.clients.iter().map(|c| (*c).to_string()).collect(),
361                action,
362            }
363        })
364        .collect();
365
366    let rules_tokens: usize = rules_out.iter().map(|r| r.file_tokens).sum();
367    let fixed_total_tokens = tool_schema_tokens + instruction_tokens + rules_tokens;
368
369    ToolHealthReport {
370        tool_profile,
371        advertised_tools: tools.len(),
372        tool_schema_tokens,
373        instruction_tokens,
374        rules_tokens,
375        fixed_total_tokens,
376        has_usage_data,
377        total_recorded_calls,
378        unused_tools,
379        unused_tool_tokens,
380        disable_candidates,
381        reclaimable_tokens,
382        disable_action,
383        tools,
384        rules: rules_out,
385        duplicate_clients: duplicates,
386        knowledge,
387        foreign_servers: Vec::new(),
388        behavior: BehaviorHealth::default(),
389    }
390}
391
392fn resolve_tool_profile() -> String {
393    let cfg = crate::core::config::Config::load();
394    if crate::core::tool_visibility::explicit_profile(&cfg) {
395        cfg.tool_profile_effective().as_str().to_string()
396    } else {
397        "lean (default)".to_string()
398    }
399}
400
401fn knowledge_health(project: &Path) -> KnowledgeHealth {
402    let Some(knowledge) =
403        crate::core::knowledge::ProjectKnowledge::load(&project.to_string_lossy())
404    else {
405        return KnowledgeHealth::default();
406    };
407    let total = knowledge.facts.len();
408    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
409    let now = chrono::Utc::now();
410    let stale = current
411        .iter()
412        .filter(|f| (now - f.created_at).num_days() > STALE_FACT_DAYS && f.retrieval_count == 0)
413        .count();
414    let action = if stale > 0 {
415        format!(
416            "{stale} stale fact(s) (>{STALE_FACT_DAYS}d, never retrieved) — review with `lean-ctx knowledge`"
417        )
418    } else {
419        String::new()
420    };
421    KnowledgeHealth {
422        total_facts: total,
423        active_facts: current.len(),
424        stale_facts: stale,
425        action,
426    }
427}
428
429/// Gathers real on-disk telemetry for `home`/`project` and builds the report.
430#[must_use]
431pub fn compute(home: &Path, project: &Path) -> ToolHealthReport {
432    let advertised = crate::core::tool_visibility::advertised_tool_defs_default();
433    let usage = CostStore::load();
434    let rules = collect_rules_files(home, project);
435    let duplicates = duplicate_clients(&rules);
436    let instructions =
437        crate::instructions::build_instructions(crate::core::protocol::CrpMode::effective());
438    let instruction_tokens = crate::core::tokens::count_tokens(&instructions);
439    let knowledge = knowledge_health(project);
440    let mut report = build_report(
441        &advertised,
442        &usage,
443        &rules,
444        duplicates,
445        instruction_tokens,
446        resolve_tool_profile(),
447        knowledge,
448    );
449    report.foreign_servers = crate::core::foreign_mcp::audit(home, project);
450    report.behavior = behavior_health(
451        &crate::core::stats::load_for_display().cep.modes,
452        &crate::core::behavior_nudge::BehaviorStore::load(),
453        usage.tools.get("ctx_compose").map_or(0, |t| t.total_calls),
454    );
455    report
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::core::a2a::cost_attribution::ToolCost;
462
463    fn tool(name: &'static str) -> rmcp::model::Tool {
464        crate::tool_defs::tool_def(
465            name,
466            "a representative description used to give the schema some token weight",
467            serde_json::json!({
468                "type": "object",
469                "properties": { "path": { "type": "string", "description": "file path" } }
470            }),
471        )
472    }
473
474    fn usage_with(calls: &[(&str, u64)]) -> CostStore {
475        let mut store = CostStore::default();
476        for (name, n) in calls {
477            store.tools.insert(
478                (*name).to_string(),
479                ToolCost {
480                    tool_name: (*name).to_string(),
481                    total_calls: *n,
482                    last_used: Some("2026-06-01T00:00:00+00:00".to_string()),
483                    ..Default::default()
484                },
485            );
486        }
487        store
488    }
489
490    #[test]
491    fn classify_unknown_without_usage_history() {
492        assert_eq!(classify(false, 0, 500, 0), ToolStatus::Unknown);
493        assert_eq!(classify(false, 9, 500, 0), ToolStatus::Unknown);
494    }
495
496    #[test]
497    fn classify_unused_when_history_exists_but_tool_never_called() {
498        assert_eq!(classify(true, 0, 500, 1000), ToolStatus::Unused);
499    }
500
501    #[test]
502    fn classify_low_use_for_expensive_rarely_called_tool() {
503        // 1 call out of 10_000, heavy schema → low-use.
504        assert_eq!(classify(true, 1, 400, 10_000), ToolStatus::LowUse);
505        // Same rarity but a cheap schema → still active (not worth flagging).
506        assert_eq!(classify(true, 1, 50, 10_000), ToolStatus::Active);
507    }
508
509    #[test]
510    fn classify_active_for_well_used_tool() {
511        assert_eq!(classify(true, 500, 400, 1000), ToolStatus::Active);
512    }
513
514    #[test]
515    fn build_report_flags_unused_and_sorts_tools() {
516        let advertised = vec![tool("ctx_search"), tool("ctx_read"), tool("ctx_shell")];
517        // History exists (ctx_read used), ctx_search/ctx_shell never called.
518        let usage = usage_with(&[("ctx_read", 40)]);
519        let report = build_report(
520            &advertised,
521            &usage,
522            &[],
523            Vec::new(),
524            100,
525            "lean (default)".to_string(),
526            KnowledgeHealth::default(),
527        );
528
529        assert!(report.has_usage_data);
530        assert_eq!(report.total_recorded_calls, 40);
531        // Sorted alphabetically.
532        let names: Vec<&str> = report.tools.iter().map(|t| t.name.as_str()).collect();
533        assert_eq!(names, vec!["ctx_read", "ctx_search", "ctx_shell"]);
534        // Two unused tools, each contributing its schema tokens.
535        assert_eq!(report.unused_tools, 2);
536        assert!(report.unused_tool_tokens > 0);
537        let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
538        assert_eq!(read.status, ToolStatus::Active);
539        assert!(read.last_used.is_some());
540        // Fixed total = tool schemas + instructions(100) + rules(0).
541        assert_eq!(
542            report.fixed_total_tokens,
543            report.tool_schema_tokens + 100 + report.rules_tokens
544        );
545    }
546
547    #[test]
548    fn value_per_1k_rewards_cheap_well_used_tools() {
549        assert!(value_per_1k(100, 50) > value_per_1k(100, 500));
550        assert_eq!(value_per_1k(0, 100), 0.0);
551        assert_eq!(value_per_1k(10, 0), 0.0, "no schema cost → no division");
552    }
553
554    #[test]
555    fn build_report_recommends_disabling_low_value_tools() {
556        let advertised = vec![tool("ctx_read"), tool("ctx_search"), tool("ctx_shell")];
557        // History exists; ctx_search + ctx_shell never called → disable candidates.
558        let usage = usage_with(&[("ctx_read", 40)]);
559        let report = build_report(
560            &advertised,
561            &usage,
562            &[],
563            Vec::new(),
564            0,
565            "lean (default)".to_string(),
566            KnowledgeHealth::default(),
567        );
568        assert!(
569            report
570                .disable_candidates
571                .contains(&"ctx_search".to_string())
572        );
573        assert!(report.disable_candidates.contains(&"ctx_shell".to_string()));
574        assert!(
575            !report.disable_candidates.contains(&"ctx_read".to_string()),
576            "an active tool is never a disable candidate"
577        );
578        assert!(report.reclaimable_tokens > 0);
579        assert!(report.disable_action.contains("consider disabling"));
580        let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
581        assert!(read.value_per_1k_tokens > 0.0);
582    }
583
584    #[test]
585    fn build_report_unknown_status_without_history() {
586        let advertised = vec![tool("ctx_read")];
587        let report = build_report(
588            &advertised,
589            &CostStore::default(),
590            &[],
591            Vec::new(),
592            0,
593            "lean (default)".to_string(),
594            KnowledgeHealth::default(),
595        );
596        assert!(!report.has_usage_data);
597        assert_eq!(report.unused_tools, 0, "never flag rot without history");
598        assert_eq!(report.tools[0].status, ToolStatus::Unknown);
599    }
600
601    #[test]
602    fn build_report_marks_duplicate_rules() {
603        let rules = vec![
604            RulesFileCost {
605                path: "a/.cursor/rules/lean-ctx.mdc".into(),
606                file_tokens: 200,
607                lean_ctx_tokens: 200,
608                carries_full: true,
609                clients: vec!["cursor"],
610            },
611            RulesFileCost {
612                path: "a/.cursorrules".into(),
613                file_tokens: 150,
614                lean_ctx_tokens: 150,
615                carries_full: true,
616                clients: vec!["cursor"],
617            },
618        ];
619        let dups = duplicate_clients(&rules);
620        let report = build_report(
621            &[],
622            &CostStore::default(),
623            &rules,
624            dups,
625            0,
626            "lean (default)".to_string(),
627            KnowledgeHealth::default(),
628        );
629        assert_eq!(report.rules.len(), 2);
630        assert!(
631            report.rules.iter().all(|r| r.action.contains("dedup")),
632            "both cursor full sources flagged as duplicates"
633        );
634        assert_eq!(report.rules_tokens, 350);
635    }
636
637    /// Cross-layer dedup: a dedicated-host carrier (codebuddy) holding more than a
638    /// pointer duplicates what MCP instructions already deliver — flagged with
639    /// the reclaimable size. Non-dedicated clients (gemini) are left alone.
640    #[test]
641    fn build_report_flags_dedicated_carrier_exceeding_pointer() {
642        let rules = vec![
643            RulesFileCost {
644                path: "/home/u/.codebuddy/CODEBUDDY.md".into(),
645                file_tokens: 700,
646                lean_ctx_tokens: 384,
647                carries_full: false,
648                clients: vec!["codebuddy"],
649            },
650            RulesFileCost {
651                path: "/home/u/.gemini/GEMINI.md".into(),
652                file_tokens: 800,
653                lean_ctx_tokens: 800,
654                carries_full: true,
655                clients: vec!["gemini"],
656            },
657        ];
658        let report = build_report(
659            &[tool("ctx_read")],
660            &usage_with(&[("ctx_read", 5)]),
661            &rules,
662            Vec::new(),
663            0,
664            "lean (default)".to_string(),
665            KnowledgeHealth::default(),
666        );
667        let codebuddy = report
668            .rules
669            .iter()
670            .find(|r| r.path.contains("CODEBUDDY"))
671            .unwrap();
672        assert!(
673            codebuddy.action.contains("thin to a pointer"),
674            "dedicated carrier above pointer size must be flagged: {}",
675            codebuddy.action
676        );
677        assert!(
678            codebuddy.action.contains("~344 tok"),
679            "reclaimable size = lean tokens minus pointer: {}",
680            codebuddy.action
681        );
682        let gemini = report
683            .rules
684            .iter()
685            .find(|r| r.path.contains("GEMINI"))
686            .unwrap();
687        assert!(
688            gemini.action.is_empty(),
689            "non-dedicated client without duplication stays unflagged"
690        );
691    }
692
693    /// A pointer-only carrier on a dedicated host is exactly the desired end
694    /// state — it must never be flagged.
695    #[test]
696    fn build_report_leaves_pointer_only_dedicated_carrier_alone() {
697        let rules = vec![RulesFileCost {
698            path: "/home/u/.claude/CLAUDE.md".into(),
699            file_tokens: 100,
700            lean_ctx_tokens: 40,
701            carries_full: false,
702            clients: vec!["claude"],
703        }];
704        let report = build_report(
705            &[tool("ctx_read")],
706            &usage_with(&[("ctx_read", 5)]),
707            &rules,
708            Vec::new(),
709            0,
710            "lean (default)".to_string(),
711            KnowledgeHealth::default(),
712        );
713        {
714            let found = &report.rules[0].action;
715            assert!(found.is_empty(), "{found:?}");
716        }
717        assert!(
718            report.foreign_servers.is_empty(),
719            "pure builder adds no foreign servers"
720        );
721    }
722
723    /// Behavior section: the share only counts read modes — instrumentation
724    /// keys in the CEP map (register/compose/…) must not dilute it — and the
725    /// warn threshold gates the action text.
726    #[test]
727    fn behavior_health_computes_share_from_read_modes_only() {
728        let mut modes = std::collections::HashMap::new();
729        modes.insert("full".to_string(), 50u64);
730        modes.insert("raw".to_string(), 10);
731        modes.insert("signatures".to_string(), 30);
732        modes.insert("lines:1-20".to_string(), 10);
733        modes.insert("register".to_string(), 500);
734        modes.insert("compose".to_string(), 130);
735        let store = crate::core::behavior_nudge::BehaviorStore {
736            chains_detected: 7,
737            hints_shown: 2,
738            ..Default::default()
739        };
740        let b = behavior_health(&modes, &store, 130);
741        assert_eq!(b.total_mode_reads, 100, "register/compose are not reads");
742        assert_eq!(b.heavy_mode_reads, 60);
743        assert_eq!(b.heavy_read_share_pct, Some(60));
744        assert!(b.action.contains("full/raw dominates"));
745        assert_eq!(b.chains_detected, 7);
746        assert_eq!(b.compose_calls, 130);
747    }
748
749    #[test]
750    fn behavior_health_stays_quiet_below_threshold_and_without_data() {
751        let mut modes = std::collections::HashMap::new();
752        modes.insert("full".to_string(), 10u64);
753        modes.insert("signatures".to_string(), 90);
754        let b = behavior_health(&modes, &Default::default(), 0);
755        assert_eq!(b.heavy_read_share_pct, Some(10));
756        assert!(
757            b.action.is_empty(),
758            "10% heavy reads is deliberate edit prep"
759        );
760
761        let empty = behavior_health(&std::collections::HashMap::new(), &Default::default(), 0);
762        assert_eq!(
763            empty.heavy_read_share_pct, None,
764            "no telemetry → no verdict"
765        );
766        assert!(empty.action.is_empty(), "{:?}", empty.action);
767    }
768
769    /// GH #838: `shell` alias inherits usage from `ctx_shell`.
770    #[test]
771    fn shell_alias_inherits_ctx_shell_usage_838() {
772        let shell_tool = tool("shell");
773        let mut usage = CostStore::default();
774        let tc = ToolCost {
775            total_calls: 42,
776            last_used: Some("2026-07-15".to_string()),
777            ..ToolCost::default()
778        };
779        usage.tools.insert("ctx_shell".to_string(), tc);
780        let report = build_report(
781            &[shell_tool],
782            &usage,
783            &[],
784            Vec::new(),
785            0,
786            "lean".to_string(),
787            KnowledgeHealth::default(),
788        );
789        assert_eq!(report.tools[0].name, "shell");
790        assert_eq!(
791            report.tools[0].calls, 42,
792            "shell must inherit ctx_shell calls"
793        );
794        assert_ne!(
795            report.tools[0].status,
796            ToolStatus::Unused,
797            "shell must not be flagged unused"
798        );
799    }
800}