Skip to main content

lean_ctx/
instructions.rs

1use crate::core::config::CompressionLevel;
2use crate::core::rules_canonical::{self as rc, Wrapper};
3use crate::tools::CrpMode;
4
5/// Universal instruction cap for all MCP clients (in tokens, not bytes).
6const INSTRUCTION_CAP_TOKENS: usize = 800;
7
8/// Token budget for the static instruction skeleton (no session/knowledge
9/// state).  Asserted in CI so instruction creep cannot silently tax every
10/// session. Measured at compression `Off` (the test pins `LEAN_CTX_COMPRESSION=off`)
11/// so the budget is deterministic across dev machines, not just clean CI (#498).
12/// Raised in reviewed steps: 520→540 / 600→640 for the sharpened ctx_* redirects
13/// (#1030), then 540→590 / 640→680 (#609 loop one-liner), then 590→615 / 680→712
14/// (proactive `RECOVER` line). Lowered 615→545 / 712→675 by the v5 rules diet
15/// (#578): the COMPACT skeleton folded loop/paradox into INTENT (measured ~505 /
16/// ~639 + headroom). Clients whose rule file already carries the canonical block
17/// get a one-line anchor instead of the skeleton (`client_loads_rules_from_file`)
18/// and land far below even this.
19#[cfg(test)]
20const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 545;
21#[cfg(test)]
22const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 675;
23/// Windows carries a one-line SHELL hint inside the skeleton.
24#[cfg(all(test, windows))]
25const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
26#[cfg(all(test, not(windows)))]
27const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
28
29#[must_use]
30pub fn build_instructions(crp_mode: CrpMode) -> String {
31    build_instructions_with_client(crp_mode, "")
32}
33
34#[must_use]
35pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
36    let cfg = crate::core::config::Config::load();
37    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
38    let shadow = cfg.shadow_mode;
39    // Cross-channel dedup: if the client auto-loads compression from its own rule
40    // file, skip it here to avoid duplicate billing.
41    let level = if client_loads_compression_from_file(client_name) {
42        CompressionLevel::Off
43    } else {
44        CompressionLevel::effective(&cfg)
45    };
46    // persona-spec-v1 — non-coding personas carry their domain block
47    // (intent vocabulary + defaults) into the session instructions. Empty for
48    // the `coding` default, so the skeleton stays byte-identical (#498).
49    let persona_block = crate::core::persona::Persona::resolve(&cfg).prompt_block();
50    build_full_instructions(
51        crp_mode,
52        client_name,
53        minimal,
54        level,
55        shadow,
56        &persona_block,
57    )
58}
59
60/// Deterministic STATIC Claude Code instructions for the char-budget test: the
61/// cold first-contact handshake surface (skeleton + shell hint + decoder +
62/// CLAUDE.md pointer + guidance). It pins `minimal=true` (the dynamic
63/// session/knowledge/gotcha payload is governed by `INSTRUCTION_CAP_TOKENS`, not
64/// the char budget) plus `level=Off`, `shadow=false` (the default template) and
65/// an empty persona block (the `coding` default), so the result is independent
66/// of the developer's local lean-ctx config and the assertion stays
67/// deterministic (#498) for every contributor, not just clean CI.
68#[must_use]
69pub fn claude_code_static_instructions_for_test() -> String {
70    build_full_instructions(CrpMode::Off, "", true, CompressionLevel::Off, false, "")
71}
72
73/// Deterministic variant for tests (no session/knowledge state).
74#[must_use]
75pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
76    let shadow = false;
77    // Resolve the effective compression level from config/env (matches the live
78    // build_full_instructions path) so terse/compression env vars are honoured.
79    let level = CompressionLevel::effective(&crate::core::config::Config::load());
80    let tp =
81        crate::core::tool_profiles::ToolProfile::from_config(&crate::core::config::Config::load());
82    let skeleton = rc::render(shadow, Wrapper::Bare, level, &tp);
83    let shell_hint = build_shell_hint();
84
85    let base = format!(
86        "{skeleton}\n\
87        {shell_hint}\n\
88        {decoder_block}\n\
89        {origin}",
90        decoder_block =
91            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
92        origin = crate::core::integrity::origin_line(),
93    );
94
95    match crp_mode_suffix(crp_mode) {
96        "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
97        crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
98    }
99}
100
101/// Deterministic instruction builder for the Instruction Compiler.
102/// Uses shadow mode (COMPACT_SHADOW profile) to avoid duplicating
103/// BULLETS/NEVER/CRITICAL that the CLAUDE.md / dedicated rule file carries.
104#[must_use]
105pub fn build_instructions_with_client_for_compiler(
106    crp_mode: CrpMode,
107    client_name: &str,
108    _unified_tool_mode: bool,
109) -> String {
110    let tp =
111        crate::core::tool_profiles::ToolProfile::from_config(&crate::core::config::Config::load());
112    let skeleton = rc::render(true, Wrapper::Bare, CompressionLevel::Off, &tp);
113    let shell_hint = build_shell_hint();
114
115    let base = format!(
116        "{skeleton}\n\
117        {shell_hint}\n\
118        {decoder_block}\n\
119        {origin}",
120        decoder_block =
121            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
122        origin = crate::core::integrity::origin_line(),
123    );
124
125    let _ = client_name;
126
127    match crp_mode_suffix(crp_mode) {
128        "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
129        crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
130    }
131}
132
133/// LITM calibration manifest rotation (#539).
134fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
135    use crate::core::litm_calibration::{Position, record_outcome};
136    use crate::core::session::ManifestEntry;
137
138    let mut updated = session.clone();
139
140    for entry in &updated.wakeup_manifest {
141        if !entry.missed
142            && let Some(pos) = Position::parse(&entry.position)
143        {
144            record_outcome(&entry.profile, pos, true);
145        }
146    }
147
148    let mut manifest: Vec<ManifestEntry> = Vec::new();
149    let mut push = |key: &str, position: &str| {
150        let key = key.trim();
151        if !key.is_empty() {
152            manifest.push(ManifestEntry {
153                key: key.chars().take(80).collect(),
154                position: position.to_string(),
155                profile: profile_name.to_string(),
156                missed: false,
157            });
158        }
159    };
160
161    if let Some(ref task) = updated.task {
162        push(&task.description, "begin");
163    }
164    for d in updated.decisions.iter().rev().take(5) {
165        push(&d.summary, "begin");
166    }
167    for f in updated.findings.iter().rev().take(8) {
168        push(&f.summary, "end");
169    }
170    for n in updated.next_steps.iter().take(3) {
171        push(n, "end");
172    }
173
174    updated.wakeup_manifest = manifest;
175    let _ = updated.save();
176}
177
178/// Display path for the Claude config directory (respected by CLAUDE_CONFIG_DIR).
179#[must_use]
180pub fn claude_config_dir_display() -> String {
181    match std::env::var("CLAUDE_CONFIG_DIR") {
182        Ok(dir) if !dir.trim().is_empty() => {
183            let dir = dir.trim().to_string();
184            if dir.starts_with('~') {
185                dir
186            } else if let Some(home) = dirs::home_dir() {
187                let home_str = home.to_string_lossy();
188                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
189                    format!("~{rest}")
190                } else {
191                    dir
192                }
193            } else {
194                dir
195            }
196        }
197        _ => "~/.claude".to_string(),
198    }
199}
200
201// ── MCP per-session instructions builder ──────────────────────
202
203fn build_full_instructions(
204    crp_mode: CrpMode,
205    client_name: &str,
206    minimal: bool,
207    level: CompressionLevel,
208    shadow: bool,
209    persona_block: &str,
210) -> String {
211    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
212    let loaded_session = if minimal {
213        None
214    } else {
215        crate::core::session::SessionState::load_latest()
216    };
217
218    let (session_block, litm_end_block) = match loaded_session {
219        Some(ref session) => {
220            rotate_wakeup_manifest(session, profile.name);
221            let share = crate::core::litm_calibration::begin_share(profile.name);
222            let mut positioned = crate::core::litm::position_optimize_with_share(session, share);
223            // #962: hard token ceiling so the re-injected ACTIVE SESSION block can
224            // never crowd out the user's task (deterministic, generous default).
225            positioned.enforce_token_budget(crate::core::litm::active_session_budget());
226            let begin = format!(
227                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
228                profile.name, positioned.begin_block
229            );
230            let end = if positioned.end_block.is_empty() {
231                String::new()
232            } else {
233                format!(
234                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
235                    positioned.end_block
236                )
237            };
238            (begin, end)
239        }
240        None => (String::new(), String::new()),
241    };
242
243    let project_root_for_blocks = if minimal {
244        None
245    } else {
246        loaded_session
247            .as_ref()
248            .and_then(|s| s.project_root.clone())
249            .or_else(|| {
250                std::env::current_dir()
251                    .ok()
252                    .map(|p| p.to_string_lossy().to_string())
253            })
254    };
255
256    let knowledge_block = match &project_root_for_blocks {
257        Some(root) => {
258            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
259            match knowledge {
260                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
261                    let aaak = k.format_aaak();
262                    if aaak.is_empty() {
263                        String::new()
264                    } else {
265                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
266                    }
267                }
268                _ => String::new(),
269            }
270        }
271        None => String::new(),
272    };
273
274    let gotcha_block = match &project_root_for_blocks {
275        Some(root) => {
276            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
277            let files: Vec<String> = loaded_session
278                .as_ref()
279                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
280                .unwrap_or_default();
281            let block = store.format_injection_block(&files);
282            if block.is_empty() {
283                String::new()
284            } else {
285                format!("\n{block}\n")
286            }
287        }
288        None => String::new(),
289    };
290
291    let health_block = match &project_root_for_blocks {
292        Some(root) => {
293            let block = crate::core::code_health::persist::format_session_block(root);
294            if block.is_empty() {
295                String::new()
296            } else {
297                format!("\n{block}\n")
298            }
299        }
300        None => String::new(),
301    };
302
303    let shell_hint = build_shell_hint();
304
305    // Skeleton includes tool-mapping rules + compression prompt (if level active).
306    // Shadow mode omits BULLETS/NEVER/CRITICAL automatically.
307    //
308    // Cross-channel dedup (#578): when the client's own auto-loaded rule file
309    // already carries the canonical rules block (Cursor mdc, Codex
310    // instructions.md), repeating the skeleton here would bill the same
311    // guidance twice on every session. A one-line anchor keeps the binding;
312    // the compression payload is deduped separately via `level` above.
313    //
314    // Hook-covered hosts (GL #1153) get the hook-aware anchor: repeating
315    // "ctx_* replaces native tools" to a Cursor whose hooks already compress
316    // the native calls re-creates exactly the instruction dissonance the
317    // HookCovered rule profile removes.
318    let cfg = crate::core::config::Config::load();
319    let tool_profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
320    let skeleton = if client_loads_rules_from_file(client_name) {
321        let anchor = if client_is_hook_covered(client_name) {
322            hook_covered_anchor(&tool_profile)
323        } else {
324            skeleton_anchor(&tool_profile)
325        };
326        let compression = rc::compression_text(level);
327        if compression.is_empty() {
328            anchor
329        } else {
330            format!("{anchor}\n{compression}")
331        }
332    } else {
333        // #987: shadow claim must match reality. `shadow_mode=true` (the
334        // config default) only means the *config* prefers shadow — it does NOT
335        // prove interception hooks are actually installed for this client.
336        // Emitting "native calls auto-route" when the install denies native
337        // tools (replace mode) wastes a round-trip when the agent trusts it.
338        // Gate on hook coverage: only clients with installed hooks get the
339        // shadow instruction.
340        let effective_shadow = shadow && client_is_hook_covered(client_name);
341        rc::render(effective_shadow, Wrapper::Bare, level, &tool_profile)
342    };
343
344    // Pointer to the full rule file (honours CLAUDE_CONFIG_DIR): agents load the
345    // detailed instructions on demand from there instead of inlining them.
346    let config_dir = claude_config_dir_display();
347
348    // Persona domain block (persona-spec-v1): placed right after the skeleton
349    // so the vocabulary frames everything that follows. Empty for `coding`.
350    let persona_section = if persona_block.is_empty() {
351        String::new()
352    } else {
353        format!("\n{persona_block}")
354    };
355
356    let base = format!(
357        "{skeleton}\n\
358        {persona_section}\
359        {shell_hint}\n\
360        {decoder_block}\n\
361        Full instructions at {config_dir}/CLAUDE.md\n\
362        {session_block}\n\
363        {knowledge_block}\n\
364        {gotcha_block}\n\
365        {health_block}\n\
366        {origin}\n\
367        {litm_end_block}",
368        decoder_block =
369            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
370        origin = crate::core::integrity::origin_line(),
371        litm_end_block = litm_end_block
372    );
373
374    // Guidance suffix: CRP mode + general output rule.
375    // This is the operational contract — protected from truncation.
376    let guidance_suffix = match crp_mode_suffix(crp_mode) {
377        "" => rc::INTELLIGENCE.to_string(),
378        crp => format!("{crp}\n\n{}", rc::INTELLIGENCE),
379    };
380
381    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
382}
383
384fn crp_mode_suffix(crp_mode: CrpMode) -> &'static str {
385    match crp_mode {
386        CrpMode::Off => "",
387        CrpMode::Compact => {
388            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
389             diff lines (+/-) only; <=200 tok; trust tool outputs."
390        }
391        CrpMode::Tdd => {
392            "CRP MODE: tdd — max density; Fn refs + diff lines only \
393             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
394        }
395    }
396}
397
398fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
399    use crate::core::tokens::count_tokens;
400    let suffix = suffix.trim_end_matches('\n');
401    if suffix.is_empty() {
402        let full = base.to_string();
403        return if count_tokens(&full) > cap_tokens {
404            truncate_to_token_cap(&full, cap_tokens)
405        } else {
406            full
407        };
408    }
409
410    let full = format!("{base}\n\n{suffix}");
411    if count_tokens(&full) <= cap_tokens {
412        return full;
413    }
414
415    let suffix_tokens = count_tokens(suffix);
416    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
417        return truncate_to_token_cap(&full, cap_tokens);
418    };
419    let trimmed_base = truncate_to_token_cap(base, base_budget);
420    format!("{trimmed_base}\n\n{suffix}")
421}
422
423fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
424    use crate::core::tokens::count_tokens;
425    if count_tokens(s) <= cap_tokens {
426        return s.to_string();
427    }
428    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
429    let (mut lo, mut hi) = (0usize, cuts.len());
430    let mut best: Option<usize> = None;
431    while lo < hi {
432        let mid = lo + (hi - lo) / 2;
433        let end = cuts[mid];
434        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
435            best = Some(end);
436            lo = mid + 1;
437        } else {
438            hi = mid;
439        }
440    }
441    if let Some(end) = best {
442        return s[..end].to_string();
443    }
444    let byte_approx = cap_tokens * 4;
445    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
446    s[..safe].to_string()
447}
448
449/// Backward-compat alias kept for external callers.
450#[must_use]
451pub fn claude_code_instructions() -> String {
452    build_instructions(CrpMode::Off)
453}
454
455/// One-line anchor for clients whose rule file carries the canonical block (#578).
456/// Profile-aware (#756): only mentions tools the profile exposes.
457fn skeleton_anchor(tp: &crate::core::tool_profiles::ToolProfile) -> String {
458    if tp.is_tool_enabled("ctx_compose") {
459        "lean-ctx active — your auto-loaded lean-ctx rules apply: \
460         ctx_* tools replace native Read/Grep/Shell/Glob (ctx_compose first)."
461            .into()
462    } else {
463        "lean-ctx active — your auto-loaded lean-ctx rules apply: \
464         ctx_* tools replace native Read/Grep/Shell/Glob."
465            .into()
466    }
467}
468
469/// Anchor for hook-covered hosts (GL #1153). Profile-aware (#756).
470fn hook_covered_anchor(tp: &crate::core::tool_profiles::ToolProfile) -> String {
471    let mut s =
472        String::from("lean-ctx active — hooks compress native Shell/Read/Grep transparently");
473    if tp.is_tool_enabled("ctx_compose") {
474        s.push_str("; call ctx_compose to orient");
475    }
476    // #509: ctx_semantic_search folded into ctx_search(action=semantic)
477    if tp.is_tool_enabled("ctx_session") || tp.is_tool_enabled("ctx_knowledge") {
478        s.push_str(", ctx_search(action=semantic) / ctx_knowledge for meaning & memory");
479    }
480    s.push('.');
481    s
482}
483
484// Test-only backward-compat constants for assertion substrings.
485#[cfg(test)]
486const SKELETON_ANCHOR: &str = "lean-ctx active — your auto-loaded lean-ctx rules apply: \
487    ctx_* tools replace native Read/Grep/Shell/Glob (ctx_compose first).";
488
489fn client_loads_compression_from_file(client_name: &str) -> bool {
490    crate::core::home::resolve_home_dir().is_some_and(|home| {
491        crate::core::rules_channel::client_autoloads_compression(client_name, &home)
492    })
493}
494
495fn client_loads_rules_from_file(client_name: &str) -> bool {
496    crate::core::home::resolve_home_dir()
497        .is_some_and(|home| crate::core::rules_channel::client_autoloads_rules(client_name, &home))
498}
499
500fn client_is_hook_covered(client_name: &str) -> bool {
501    crate::core::home::resolve_home_dir()
502        .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
503}
504
505fn build_shell_hint() -> String {
506    if !cfg!(windows) {
507        return String::new();
508    }
509    let name = crate::shell::shell_name();
510    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
511    if is_posix {
512        format!("\nSHELL: {name} (POSIX) — no PowerShell cmdlets.\n")
513    } else if name.contains("powershell") || name.contains("pwsh") {
514        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
515    } else {
516        format!("\nSHELL: {name}.\n")
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::core::tokens::count_tokens;
524
525    #[test]
526    fn guidance_suffix_survives_oversized_base() {
527        let base = "SESSION LINE\n".repeat(4000);
528        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
529        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
530        assert!(out.contains("OUTPUT STYLE: expert-terse"));
531        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
532        assert!(out.len() < base.len());
533    }
534
535    #[test]
536    fn empty_client_never_dedups_compression() {
537        assert!(!client_loads_compression_from_file(""));
538        assert!(!client_loads_compression_from_file("totally-unknown-agent"));
539    }
540
541    #[test]
542    fn covered_client_gets_anchor_instead_of_skeleton() {
543        // #578: a client whose rule file carries the canonical block must not
544        // pay for the full skeleton again in every MCP session.
545        let _guard = crate::core::data_dir::test_env_lock();
546        let tmp = tempfile::tempdir().unwrap();
547        let home = tmp.path();
548        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
549        let cfg = crate::core::config::Config::load();
550        let shadow = cfg.shadow_mode;
551        let tp = crate::core::tool_profiles::ToolProfile::Power;
552        std::fs::write(
553            home.join(".cursor/rules/lean-ctx.mdc"),
554            rc::render(
555                shadow,
556                Wrapper::Dedicated,
557                crate::core::config::CompressionLevel::Standard,
558                &tp,
559            ),
560        )
561        .unwrap();
562        let old_home = std::env::var("HOME").ok();
563        crate::test_env::set_var("HOME", home);
564        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
565
566        let covered = build_instructions_with_client(CrpMode::Off, "cursor");
567        let uncovered = build_instructions_with_client(CrpMode::Off, "some-other-agent");
568
569        if let Some(h) = old_home {
570            crate::test_env::set_var("HOME", h);
571        } else {
572            crate::test_env::remove_var("HOME");
573        }
574        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
575
576        assert!(
577            covered.contains(SKELETON_ANCHOR),
578            "covered client must get the anchor:\n{covered}"
579        );
580        if shadow {
581            assert!(
582                !covered.contains("auto-route"),
583                "covered shadow client must not re-pay the shadow nudge:\n{covered}"
584            );
585        } else {
586            assert!(
587                !covered.contains("MANDATORY MAPPING"),
588                "covered client must not re-pay the skeleton:\n{covered}"
589            );
590        }
591        // The mdc also carries the compression block → level dedups to Off.
592        assert!(
593            !covered.contains("OUTPUT STYLE:"),
594            "covered client must not re-pay the compression prompt:\n{covered}"
595        );
596        // #987: uncovered client without hooks no longer gets shadow instruction.
597        // `effective_shadow` = false when no hooks installed, so it falls back
598        // to the non-shadow (MANDATORY MAPPING) skeleton regardless of config.
599        if shadow {
600            assert!(
601                !uncovered.contains("auto-route"),
602                "#987: uncovered client WITHOUT hooks must NOT get shadow nudge:\n{uncovered}"
603            );
604            assert!(
605                uncovered.contains("MANDATORY MAPPING") || uncovered.contains("ctx_"),
606                "uncovered client without hooks gets explicit tool mapping:\n{uncovered}"
607            );
608        } else {
609            assert!(
610                uncovered.contains("MANDATORY MAPPING"),
611                "uncovered client keeps the full skeleton:\n{uncovered}"
612            );
613        }
614        eprintln!(
615            "instructions footprint: covered={} tok, uncovered={} tok",
616            count_tokens(&covered),
617            count_tokens(&uncovered)
618        );
619        assert!(
620            count_tokens(&covered) < count_tokens(&uncovered),
621            "anchor path must be strictly cheaper"
622        );
623    }
624
625    #[test]
626    fn hook_covered_client_gets_hook_aware_anchor() {
627        // GL #1153: with lean-ctx hooks covering the native tools, the anchor
628        // must not repeat "ctx_* replaces native tools" — that is exactly the
629        // instruction dissonance the HookCovered profile removes.
630        let _guard = crate::core::data_dir::test_env_lock();
631        let tmp = tempfile::tempdir().unwrap();
632        let home = tmp.path();
633        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
634        let tp = crate::core::tool_profiles::ToolProfile::Power;
635        std::fs::write(
636            home.join(".cursor/rules/lean-ctx.mdc"),
637            rc::render(
638                false,
639                Wrapper::HookCovered,
640                crate::core::config::CompressionLevel::Off,
641                &tp,
642            ),
643        )
644        .unwrap();
645        std::fs::write(
646            home.join(".cursor/hooks.json"),
647            r#"{"version":1,"hooks":{"preToolUse":[
648                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
649                {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
650            ]}}"#,
651        )
652        .unwrap();
653        let old_home = std::env::var("HOME").ok();
654        crate::test_env::set_var("HOME", home);
655        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
656
657        let covered = build_instructions_with_client(CrpMode::Off, "cursor");
658
659        if let Some(h) = old_home {
660            crate::test_env::set_var("HOME", h);
661        } else {
662            crate::test_env::remove_var("HOME");
663        }
664        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
665
666        assert!(
667            covered.contains("hooks compress native Shell/Read/Grep"),
668            "hook-covered client must get the hook-aware anchor:\n{covered}"
669        );
670        assert!(
671            !covered.contains("ctx_* tools replace native")
672                && !covered.contains("MANDATORY MAPPING"),
673            "hook-covered client must not carry the replace-native wording:\n{covered}"
674        );
675    }
676
677    #[test]
678    fn non_coding_persona_block_lands_in_instructions() {
679        let _guard = crate::core::data_dir::test_env_lock();
680        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
681
682        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
683        let research = build_instructions_with_client(CrpMode::Off, "");
684
685        crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
686        let coding = build_instructions_with_client(CrpMode::Off, "");
687
688        crate::test_env::remove_var("LEAN_CTX_PERSONA");
689        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
690
691        assert!(
692            research.contains("PERSONA: research"),
693            "research persona must announce its domain block:\n{research}"
694        );
695        assert!(
696            research.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
697            "research persona must carry its intent vocabulary:\n{research}"
698        );
699        assert!(
700            !coding.contains("PERSONA:"),
701            "the coding default must keep the instructions byte-stable (#498):\n{coding}"
702        );
703    }
704
705    #[test]
706    fn under_cap_keeps_everything() {
707        let base = "tool mapping block";
708        let suffix = "OUTPUT STYLE: dense";
709        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
710        assert!(out.contains(base));
711        assert!(out.contains(suffix));
712    }
713
714    #[test]
715    fn empty_suffix_caps_base_only() {
716        let base = "x\n".repeat(4000);
717        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
718        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
719    }
720
721    #[cfg(windows)]
722    #[test]
723    fn shell_hint_stays_within_its_budget() {
724        let hint = build_shell_hint();
725        let tokens = count_tokens(&hint);
726        assert!(
727            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
728            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
729        );
730    }
731
732    #[test]
733    fn minimal_overhead_instructions_stay_within_budget() {
734        const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
735            STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
736        let _iso = crate::core::data_dir::isolated_data_dir();
737        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
738        let out = build_instructions(CrpMode::Compact);
739        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
740        let tokens = count_tokens(&out);
741        assert!(
742            tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
743            "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
744        );
745    }
746
747    #[test]
748    fn static_skeleton_stays_within_budget() {
749        let _iso = crate::core::data_dir::isolated_data_dir();
750        // Pin compression Off so the measured skeleton — and thus this budget —
751        // is deterministic regardless of the dev's local compression_level (#498).
752        crate::test_env::set_var("LEAN_CTX_COMPRESSION", "off");
753        for (mode, base_budget) in [
754            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
755            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
756            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
757        ] {
758            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
759            let out = build_instructions_for_test(mode);
760            let tokens = count_tokens(&out);
761            assert!(
762                tokens <= budget,
763                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
764            );
765        }
766        crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
767    }
768}