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