Skip to main content

lean_ctx/core/
tool_lifecycle.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared tool lifecycle — ensures CLI and MCP paths have identical side effects.
4//!
5//! The MCP server dispatcher handles session, ledger, heatmap, intent detection,
6//! and knowledge consolidation inline (via in-memory state). When the daemon is
7//! unavailable, CLI commands call functions here to achieve the same coverage by
8//! loading/saving state from disk.
9//!
10//! NOTE: When the daemon IS running, CLI routes through `daemon_client` which
11//! calls the MCP server — these functions are NOT called in that path.
12
13use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
14use crate::core::context_ledger::ContextLedger;
15use crate::core::heatmap;
16use crate::core::intent_engine::StructuredIntent;
17use crate::core::session::SessionState;
18use crate::core::stats;
19use std::sync::atomic::{AtomicU32, Ordering};
20
21/// How many recently-touched files form the "working set" a new read is
22/// associated with for traversal (co-access) edges (#289). Small, so the signal
23/// stays local to what the agent is actively juggling.
24const PERIODIC_FLUSH_INTERVAL: u32 = 50;
25static TOOL_CALL_COUNT: AtomicU32 = AtomicU32::new(0);
26
27/// Whether `root` is a usable project root for repo-relative normalization.
28pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
29    root.filter(|r| !r.trim().is_empty() && *r != ".")
30}
31
32/// First 200 chars of `text` on a UTF-8 boundary — the exact excerpt bound the
33/// MCP dispatcher applies before handing content to the IR store
34/// (`server/call_tool.rs`), kept identical so CLI- and MCP-recorded IR items are
35/// byte-compatible. `ContextIrV1::record` redacts and further caps it.
36pub fn ir_excerpt(text: &str) -> &str {
37    const MAX: usize = 200;
38    if text.len() <= MAX {
39        return text;
40    }
41    let mut end = MAX;
42    while end > 0 && !text.is_char_boundary(end) {
43        end -= 1;
44    }
45    &text[..end]
46}
47
48/// Record a file-read operation with full Context OS side effects.
49///
50/// `duration` and `output_excerpt` feed the Context IR lineage (#566); the MCP
51/// dispatcher records both for every tool call but the shadow-mode `lean-ctx
52/// read` subprocess used to drop them, so IR/`ctx_proof` exports were blind to
53/// compressed shadow reads.
54pub fn record_file_read(
55    path: &str,
56    mode: &str,
57    original_tokens: usize,
58    output_tokens: usize,
59    is_cache_hit: bool,
60    duration: std::time::Duration,
61    output_excerpt: &str,
62) {
63    record_file_read_accounting(
64        path,
65        mode,
66        original_tokens,
67        output_tokens,
68        is_cache_hit,
69        duration,
70    );
71    let mut ir = ContextIrV1::load();
72    ir.record(RecordIrInput {
73        kind: ContextIrSourceKindV1::Read,
74        tool: "ctx_read",
75        client_name: None,
76        agent_id: None,
77        path: Some(path),
78        command: None,
79        pattern: Some(mode),
80        input_tokens: original_tokens,
81        output_tokens,
82        duration,
83        content_excerpt: ir_excerpt(output_excerpt),
84    });
85    ir.save();
86    record_file_read_projection(path, mode, original_tokens, output_tokens);
87    if mode == "aggressive" && original_tokens > output_tokens {
88        maybe_periodic_flush();
89    }
90    maybe_periodic_flush();
91}
92
93/// Compatibility accounting without IR persistence or an independent flush.
94/// The CLI lifecycle calls this once in its accounting stage.
95pub fn record_file_read_accounting(
96    path: &str,
97    mode: &str,
98    original_tokens: usize,
99    output_tokens: usize,
100    is_cache_hit: bool,
101    duration: std::time::Duration,
102) {
103    let saved = original_tokens.saturating_sub(output_tokens);
104    let tool_key = format!("cli_{mode}");
105
106    stats::record(&tool_key, original_tokens, output_tokens);
107    crate::core::execution_lifecycle::record_heatmap_access(path, original_tokens, saved);
108
109    // Emit event so the live dashboard feed sees hook-intercepted reads.
110    crate::core::events::emit_tool_call(
111        &tool_key,
112        original_tokens as u64,
113        saved as u64,
114        Some(mode.to_string()),
115        duration.as_millis() as u64,
116        Some(path.to_string()),
117    );
118    if is_cache_hit {
119        crate::core::events::emit_cache_hit(path, saved as u64);
120    }
121    // Verified ledger (#685): recorded explicitly now that the heatmap chokepoint
122    // no longer bundles it. This direct-CLI path (daemon off) only has o200k
123    // counts; the model-correct re-tokenization happens on the MCP read path,
124    // which holds the source text. For the default O200kBase model these are
125    // identical anyway.
126    crate::core::savings_ledger::record_read_event(original_tokens, saved, None, None);
127
128    if is_cache_hit {
129        crate::core::telemetry::global_metrics().record_cache(true);
130    }
131
132    // Session accounting goes into the caller's own session only. A CLI or
133    // hook process has none and writes none: the project's latest saved
134    // session may be another agent's (I07/S03).
135    let session_root = crate::core::session::caller::with_mut(|session| {
136        session.touch_file(path, None, mode, original_tokens);
137        if is_cache_hit {
138            session.record_cache_hit();
139        }
140        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
141            let touched: Vec<String> = session
142                .files_touched
143                .iter()
144                .map(|ft| ft.path.clone())
145                .collect();
146            let inferred = StructuredIntent::from_file_patterns(&touched);
147            if inferred.confidence >= 0.4 {
148                session.active_structured_intent = Some(inferred);
149            }
150        }
151        session.project_root.clone()
152    })
153    .flatten();
154
155    // Project root the read observation below is scoped to: the caller's
156    // project, else the read file's own project. Defaults to "." (the MCP
157    // path's `project_root_snapshot` fallback) so a rootless read is still
158    // observed rather than dropped.
159    let learning_root = usable_root(session_root.as_deref())
160        .map(str::to_string)
161        .or_else(|| crate::core::protocol::detect_project_root(path))
162        .unwrap_or_else(|| String::from("."));
163
164    // Only real files belong in the context ledger (GL #512): directory
165    // overviews and synthetic paths would show up as "files" in the pressure
166    // table with eviction/pin semantics that make no sense for them.
167    if std::path::Path::new(path).is_file() {
168        let mut ledger = ContextLedger::load();
169        ledger.record(path, mode, original_tokens, output_tokens);
170        ledger.save();
171    }
172
173    // The read observation the MCP read path records in a background thread but
174    // the CLI path historically skipped for shadow-mode (`view`/`grep` →
175    // `lean-ctx read`) hook reads (#550). Run inline: a single-shot CLI process
176    // must finish it before it flushes and exits.
177    record_read_observation(path, mode, &learning_root);
178}
179
180pub fn record_file_read_projection(
181    path: &str,
182    mode: &str,
183    original_tokens: usize,
184    output_tokens: usize,
185) {
186    let saved = original_tokens.saturating_sub(output_tokens);
187    // OCLA CompressionProvider runtime projection: for aggressive-mode reads with
188    // positive savings, record the compression event through the canonical OCLA
189    // capability so the registry tracks real compression evidence.
190    if saved > 0 {
191        project_ocla_savings(path, original_tokens as u64, output_tokens as u64);
192    }
193    if mode == "aggressive" && saved > 0 {
194        project_ocla_compression(path, original_tokens as u64, output_tokens as u64);
195    }
196}
197
198/// Record the read as an OCLA observation for the standalone CLI path, as the
199/// MCP read path does (#550). A read is observed delivery, never task
200/// acceptance: the canonical execution protocol alone admits outcome-based
201/// learning.
202fn record_read_observation(path: &str, resolved_mode: &str, project_root: &str) {
203    let _ = crate::core::ocla::OclaRegistry::global()
204        .outcome_tracker
205        .record_outcome(read_observation(path, resolved_mode, project_root));
206}
207
208fn read_observation(
209    path: &str,
210    resolved_mode: &str,
211    project_root: &str,
212) -> crate::core::ocla::Outcome {
213    let context = crate::core::ocla::OclaRequestContext {
214        request_id: format!("cli-read:{path}:{resolved_mode}"),
215        session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
216        agent_id: "lean-ctx".to_string(),
217        content_ref: format!("file:{path}"),
218        tenant_id: None,
219        trace_id: "tr-unit".into(),
220        task_id: None,
221        parent_task_id: None,
222    };
223    crate::core::ocla::Outcome {
224        context,
225        accepted: None,
226        quality_score_milli: None,
227        outcome_ref: Some(format!("read:{project_root}:{resolved_mode}")),
228    }
229}
230
231/// Record a search/grep operation with full Context OS side effects.
232///
233/// `modeled_baseline` (native-tool estimate, GL #479 D1) feeds the estimated
234/// stats series; `observed_tokens` (raw measured match lines, no factor) feeds
235/// the verified ledger (GL #479 D2). `pattern`/`path`/`duration`/`output_excerpt`
236/// feed the Context IR lineage (#566).
237#[cfg(test)]
238pub(crate) fn record_search(
239    modeled_baseline: usize,
240    observed_tokens: usize,
241    output_tokens: usize,
242    pattern: &str,
243    path: &str,
244    duration: std::time::Duration,
245    output_excerpt: &str,
246) {
247    record_search_accounting(
248        modeled_baseline,
249        observed_tokens,
250        output_tokens,
251        path,
252        duration,
253    );
254    let mut ir = ContextIrV1::load();
255    ir.record(RecordIrInput {
256        kind: ContextIrSourceKindV1::Search,
257        tool: "ctx_search",
258        client_name: None,
259        agent_id: None,
260        path: Some(path),
261        command: None,
262        pattern: Some(pattern),
263        input_tokens: observed_tokens,
264        output_tokens,
265        duration,
266        content_excerpt: ir_excerpt(output_excerpt),
267    });
268    ir.save();
269}
270
271/// Existing search bookkeeping, separated from the IR writer for stage ownership.
272pub fn record_search_accounting(
273    modeled_baseline: usize,
274    observed_tokens: usize,
275    output_tokens: usize,
276    path: &str,
277    duration: std::time::Duration,
278) {
279    stats::record("cli_grep", modeled_baseline, output_tokens);
280
281    // Emit event so the live dashboard feed sees shadow-mode search.
282    let search_saved = modeled_baseline.saturating_sub(output_tokens);
283    if search_saved > 0 {
284        crate::core::events::emit_tool_call(
285            "cli_grep",
286            modeled_baseline as u64,
287            search_saved as u64,
288            Some("search".to_string()),
289            duration.as_millis() as u64,
290            Some(path.to_string()),
291        );
292    }
293    crate::core::savings_ledger::record_tool_event(
294        "cli_grep",
295        observed_tokens,
296        output_tokens,
297        None,
298        None,
299    );
300    crate::core::savings_tracker::record_compression(
301        modeled_baseline as u64,
302        output_tokens as u64,
303        "cli_grep",
304    );
305
306    crate::core::session::caller::with_mut(SessionState::record_command);
307}
308
309/// Record a tree/ls operation with full Context OS side effects.
310pub fn record_tree(original_tokens: usize, output_tokens: usize) {
311    stats::record("cli_ls", original_tokens, output_tokens);
312
313    // Emit event so the live dashboard feed sees shadow-mode tree/ls.
314    let saved = original_tokens.saturating_sub(output_tokens);
315    if saved > 0 {
316        crate::core::events::emit_tool_call(
317            "cli_ls",
318            original_tokens as u64,
319            saved as u64,
320            Some("tree".to_string()),
321            0,
322            None,
323        );
324    }
325
326    crate::core::session::caller::with_mut(SessionState::record_command);
327}
328
329/// Record a shell command with full Context OS side effects.
330/// Always records in stats (even for track-only 0-token calls) so the dashboard
331/// command counter stays accurate. Adding 0 tokens does not inflate savings.
332pub(crate) fn record_shell_command(original_tokens: usize, output_tokens: usize) {
333    record_shell_command_named("(shell)", original_tokens, output_tokens);
334}
335
336pub(crate) fn record_shell_command_named(
337    command: &str,
338    original_tokens: usize,
339    output_tokens: usize,
340) {
341    stats::record("cli_shell", original_tokens, output_tokens);
342
343    // Emit event so the live dashboard feed sees shadow-mode shell compression.
344    let saved = original_tokens.saturating_sub(output_tokens);
345    if saved > 0 {
346        crate::core::events::emit_tool_call(
347            "cli_shell",
348            original_tokens as u64,
349            saved as u64,
350            Some("shell".to_string()),
351            0,
352            None,
353        );
354    }
355    // Shell compression is *measured* (raw output vs sent output), so it belongs
356    // in the verified ledger too (GL #479 D2). Zero-saving calls are skipped.
357    crate::core::savings_ledger::record_tool_event(
358        "cli_shell",
359        original_tokens,
360        output_tokens,
361        None,
362        None,
363    );
364    crate::core::savings_tracker::record_compression(
365        original_tokens as u64,
366        output_tokens as u64,
367        "cli_shell",
368    );
369
370    if original_tokens > 0 {
371        let label = shell_ledger_label(command);
372        let mut ledger = crate::core::context_ledger::ContextLedger::load();
373        ledger.record_shell(&label, original_tokens, output_tokens);
374        ledger.save();
375    }
376    crate::core::session::caller::with_mut(SessionState::record_command);
377}
378
379/// Increment the tool-call counter and flush buffered telemetry every
380/// [`PERIODIC_FLUSH_INTERVAL`] calls. The MCP daemon is long-lived;
381/// without periodic flush, counters like `compressed_cache_hit` only
382/// appear in `auto_mode_sources.json` after the process exits.
383pub(crate) fn maybe_periodic_flush() {
384    let count = TOOL_CALL_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
385    if count.is_multiple_of(PERIODIC_FLUSH_INTERVAL) {
386        let _ = std::thread::spawn(flush_all);
387    }
388}
389
390/// Flush every buffered telemetry sink to disk.
391///
392/// The long-lived MCP daemon flushes these once at shutdown
393/// (`cli/dispatch/server.rs`). Single-shot CLI commands — and the shadow-mode
394/// hook subprocesses that spawn `lean-ctx read`/`grep` — exit immediately, so
395/// without this the buffered heatmap and threshold
396/// writes are silently lost the moment the process ends: `lean-ctx heatmap`
397/// stays empty and `lean-ctx gain` reports nothing for compressed reads (#550).
398///
399/// Centralized so the daemon shutdown, the parent watchdog and every CLI tool
400/// command flush the *exact same* set — the historical per-arm copies had
401/// drifted (the `read` arm flushed only `stats`, the `-c` arm four sinks, the
402/// daemon nine), which is precisely how the gap went unnoticed.
403pub fn flush_all() {
404    stats::flush();
405    heatmap::flush();
406    crate::core::path_mode_memory::flush();
407    crate::core::grammar_usage::flush();
408    crate::core::auto_mode_resolver::flush_sources();
409    crate::core::edit_quality::flush();
410    crate::core::edit_metering::flush();
411}
412
413/// [`flush_all`] for a process that is about to exit: first commits the
414/// provisional bounces still waiting for their edit window, so a bounce
415/// detected just before shutdown reaches the ledger and the path memory that
416/// `flush_all` then persists. Not part of `flush_all` itself, which also runs
417/// after single tool executions where committing early would book a
418/// read-before-edit as a bounce.
419pub fn flush_all_at_exit() {
420    crate::core::bounce_tracker::flush_pending();
421    flush_all();
422    // Observations a serving process handed to the detached writer, and its
423    // published request snapshot (#2196, #2197).
424    crate::core::request_ledger::finish_serving(std::time::Duration::from_secs(5));
425}
426
427/// The caller's own session id, if this runs inside an agent's request.
428fn caller_session_id() -> Option<String> {
429    crate::core::session::caller::with(|session| session.id.clone())
430}
431
432/// Project an aggressive-mode compression event into the OCLA CompressionProvider.
433/// Best-effort: silently drops if provider is unavailable or source_ref can't be
434/// constructed. This is the canonical production callsite for the compression capability.
435fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
436    use crate::core::ocla::OclaRegistry;
437    use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
438
439    let reg = OclaRegistry::global();
440    let source_ref = format!("file:{path}");
441    let request = CompressionRequest {
442        context: OclaRequestContext {
443            request_id: format!("cli-read-{}", path.len()),
444            session_id: caller_session_id().unwrap_or_default(),
445            agent_id: String::new(),
446            content_ref: source_ref.clone(),
447            tenant_id: None,
448            trace_id: "tr-unit".into(),
449            task_id: None,
450            parent_task_id: None,
451        },
452        source_ref,
453        source_tokens,
454        target_tokens: output_tokens,
455        quality_policy_ref: None,
456    };
457    let _ = reg.compression_provider.compress(request);
458}
459
460/// Project a caller-accounted read-savings observation into OCLA summaries.
461/// Best-effort: silently drops if the provider is unavailable. Canonical
462/// production callsite for the savings-evidence capability.
463fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
464    use crate::core::ocla::OclaRegistry;
465    use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
466
467    let context = OclaRequestContext {
468        request_id: format!("cli-read-{}", path.len()),
469        session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
470        agent_id: "lean-ctx".to_string(),
471        content_ref: format!("file:{path}"),
472        tenant_id: None,
473        trace_id: String::new(),
474        task_id: None,
475        parent_task_id: None,
476    };
477    let evidence = SavingsEvidence {
478        context,
479        original_tokens,
480        delivered_tokens: output_tokens,
481        quality_ref: None,
482        evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
483    };
484    let _ = OclaRegistry::global()
485        .savings_ledger
486        .project_savings(evidence);
487}
488
489/// Truncate a shell command to a short label suitable for the context ledger.
490fn shell_ledger_label(command: &str) -> String {
491    let first_line = command.lines().next().unwrap_or(command);
492    let trimmed = first_line.trim();
493    if trimmed.len() <= 80 {
494        format!("$ {trimmed}")
495    } else {
496        format!("$ {}…", &trimmed[..trimmed.floor_char_boundary(77)])
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    // The record_* paths now drive process-global telemetry sinks (heatmap
505    // buffer, anomaly singleton) and read the data-dir env (#550), so
506    // every test here takes the shared isolation lock to serialize that state
507    // and keep its disk writes inside a throwaway dir.
508
509    #[test]
510    fn read_observation_does_not_invent_acceptance_or_quality() {
511        let _data = crate::core::data_dir::isolated_data_dir();
512        let outcome = read_observation("sample.rs", "full", "/project");
513        assert_eq!(outcome.accepted, None);
514        assert_eq!(outcome.quality_score_milli, None);
515        assert_eq!(outcome.context.content_ref, "file:sample.rs");
516        assert_eq!(outcome.outcome_ref.as_deref(), Some("read:/project:full"));
517    }
518
519    #[test]
520    fn record_file_read_does_not_panic_without_session() {
521        let _dir = crate::core::data_dir::isolated_data_dir();
522        record_file_read(
523            "/tmp/nonexistent.rs",
524            "full",
525            100,
526            50,
527            false,
528            std::time::Duration::from_millis(1),
529            "excerpt",
530        );
531    }
532
533    #[test]
534    fn record_search_does_not_panic_without_session() {
535        let _dir = crate::core::data_dir::isolated_data_dir();
536        record_search(
537            500,
538            200,
539            150,
540            "pattern",
541            "/tmp",
542            std::time::Duration::from_millis(1),
543            "matches",
544        );
545    }
546
547    #[test]
548    fn record_tree_does_not_panic_without_session() {
549        let _dir = crate::core::data_dir::isolated_data_dir();
550        record_tree(100, 80);
551    }
552
553    #[test]
554    fn record_shell_does_not_panic_without_session() {
555        let _dir = crate::core::data_dir::isolated_data_dir();
556        record_shell_command(500, 200);
557    }
558
559    #[test]
560    fn flush_all_is_idempotent_and_safe_without_state() {
561        let _dir = crate::core::data_dir::isolated_data_dir();
562        // Empty buffers: flushing must be a harmless no-op, and calling it twice
563        // (e.g. a CLI arm followed by an atexit path) must never panic.
564        flush_all();
565        flush_all();
566    }
567
568    #[test]
569    fn periodic_flush_counter_increments() {
570        TOOL_CALL_COUNT.store(0, Ordering::Relaxed);
571        maybe_periodic_flush();
572        assert_eq!(TOOL_CALL_COUNT.load(Ordering::Relaxed), 1);
573    }
574
575    #[test]
576    fn cli_read_persists_observation_sinks_to_disk() {
577        // #550 regression: a single-shot CLI read must leave the heatmap on
578        // disk, and no retired mode-predictor store. Task acceptance cannot be
579        // inferred from a read;
580        // the real-process read_observation_acceptance test guards historical
581        // feedback preservation and absence of fabricated completion records.
582        // The daemon used to be the only path that flushed them, so hook reads
583        // (`view`/`grep` →
584        // `lean-ctx read`) recorded nothing and `lean-ctx heatmap` stayed empty.
585        let dir = crate::core::data_dir::isolated_data_dir();
586        let file = dir.path().join("sample.rs");
587        std::fs::write(&file, "fn main() {\n    println!(\"hi\");\n}\n").unwrap();
588        let path = file.to_string_lossy();
589
590        record_file_read(
591            &path,
592            "full",
593            1000,
594            200,
595            false,
596            std::time::Duration::from_millis(2),
597            "sample.rs [3L]\nfn main() {}",
598        );
599        flush_all();
600
601        let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
602        let state = crate::core::paths::state_dir().expect("state dir");
603        assert!(
604            !data.join("mode_stats.json").exists(),
605            "a CLI read must not train the retired mode predictor"
606        );
607        assert!(
608            state.join("heatmap.json").exists(),
609            "heatmap must persist after a CLI read + flush"
610        );
611    }
612
613    #[test]
614    fn cli_read_records_context_ir_lineage() {
615        // #566: the MCP dispatcher records Context IR for every tool call, but the
616        // shadow-mode `lean-ctx read` subprocess used to skip it, so IR/ctx_proof
617        // exports were blind to compressed shadow reads. A single-shot CLI read
618        // must now persist exactly one IR item (disk-backed load→record→save).
619        let dir = crate::core::data_dir::isolated_data_dir();
620        let file = dir.path().join("ir_sample.rs");
621        std::fs::write(&file, "fn main() {}\n").unwrap();
622        let path = file.to_string_lossy();
623
624        record_file_read(
625            &path,
626            "full",
627            1000,
628            200,
629            false,
630            std::time::Duration::from_millis(3),
631            "ir_sample.rs [1L]\nfn main() {}",
632        );
633
634        let ir = ContextIrV1::load();
635        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
636        let item = &ir.items[0];
637        assert_eq!(item.source.tool, "ctx_read");
638        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
639        assert!(
640            item.source
641                .path
642                .as_deref()
643                .unwrap_or("")
644                .ends_with("ir_sample.rs"),
645            "IR records the read path, got {:?}",
646            item.source.path
647        );
648        assert_eq!(item.source.pattern.as_deref(), Some("full"));
649        assert_eq!(item.input_tokens, 1000);
650        assert_eq!(item.output_tokens, 200);
651        assert!(item.duration_us > 0, "a real duration must be recorded");
652        assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
653    }
654
655    #[test]
656    fn cli_search_records_context_ir_lineage() {
657        // #566: the shadow-mode `grep` → `lean-ctx grep` path records IR too.
658        let _dir = crate::core::data_dir::isolated_data_dir();
659
660        record_search(
661            800,
662            500,
663            120,
664            "fn handle",
665            "src/",
666            std::time::Duration::from_millis(4),
667            "src/lib.rs:12: fn handle() {}",
668        );
669
670        let ir = ContextIrV1::load();
671        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
672        let item = &ir.items[0];
673        assert_eq!(item.source.tool, "ctx_search");
674        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
675        // Input is the raw matched-line estimate, not the modeled baseline.
676        assert_eq!(item.input_tokens, 500);
677        assert_eq!(item.output_tokens, 120);
678        assert!(item.duration_us > 0, "a real duration must be recorded");
679    }
680}