Skip to main content

lean_ctx/
instructions.rs

1use crate::tools::CrpMode;
2
3/// Claude Code truncates MCP server instructions at 2048 characters.
4/// Full instructions live in the `<!-- lean-ctx -->` block in `~/.claude/CLAUDE.md`
5/// plus the on-demand skill (`~/.claude/skills/lean-ctx/SKILL.md`); the legacy
6/// always-loaded `~/.claude/rules/lean-ctx.md` was retired in 3.8 (GL #555).
7/// Session state is dynamically appended to the MCP instructions for continuity.
8///
9/// Universal instruction cap for all MCP clients (in tokens, not bytes).
10/// Enforced via `count_tokens` so truncation is accurate regardless of
11/// character mix (ASCII, CJK, emoji).
12///
13/// Budget split (#579): the static skeleton must stay <= 400 tokens
14/// (asserted in tests — details belong in LEAN-CTX.md on disk, not in every
15/// session); the remainder is headroom for dynamic session/knowledge blocks.
16const INSTRUCTION_CAP_TOKENS: usize = 800;
17
18/// Token budget for the static instruction skeleton (no session/knowledge
19/// state). CI-asserted so instruction creep cannot silently tax every session.
20/// Tdd mode pays extra for the CRP suffix + INSTRUCTION CODES decoder.
21#[cfg(test)]
22const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 400;
23#[cfg(test)]
24const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 500;
25/// Windows additionally carries the one-line `SHELL:` hint (POSIX vs
26/// PowerShell disambiguation, see `build_shell_hint`) inside the skeleton.
27/// Budgeted explicitly so the cap stays honest on every platform.
28#[cfg(all(test, windows))]
29const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
30#[cfg(all(test, not(windows)))]
31const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
32
33pub fn build_instructions(crp_mode: CrpMode) -> String {
34    build_instructions_with_client(crp_mode, "")
35}
36
37pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
38    if is_claude_code_client(client_name) {
39        return build_claude_code_instructions();
40    }
41    build_full_instructions(crp_mode, client_name)
42}
43
44pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
45    // Avoid loading dynamic on-disk session/knowledge/gotcha blocks in tests, which can
46    // vary across machines and between concurrent test runs.
47    build_full_instructions_for_test(crp_mode, "")
48}
49
50pub fn build_instructions_with_client_for_test(crp_mode: CrpMode, client_name: &str) -> String {
51    if is_claude_code_client(client_name) {
52        return build_claude_code_instructions();
53    }
54    build_full_instructions_for_test(crp_mode, client_name)
55}
56
57/// Deterministic instruction builder for the Instruction Compiler.
58///
59/// MUST NOT depend on process-global env toggles or on-disk mutable config, because the compiler
60/// output is intended to be stable and diffable across runs and in CI.
61pub fn build_instructions_with_client_for_compiler(
62    crp_mode: CrpMode,
63    client_name: &str,
64    unified_tool_mode: bool,
65) -> String {
66    if is_claude_code_client(client_name) {
67        return build_claude_code_instructions();
68    }
69    build_full_instructions_for_compiler(crp_mode, client_name, unified_tool_mode)
70}
71
72fn is_claude_code_client(client_name: &str) -> bool {
73    let lower = client_name.to_lowercase();
74    lower.contains("claude") && !lower.contains("cursor")
75}
76
77/// LITM calibration manifest rotation (#539).
78///
79/// Settles the previous manifest — every entry the agent never re-recalled is
80/// a placement *hit* (misses were already recorded by the recall hook) — then
81/// stores the manifest for the injection built from `session` right now:
82/// task + decisions go to the begin block, findings + next steps to the end.
83fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
84    use crate::core::litm_calibration::{record_outcome, Position};
85    use crate::core::session::ManifestEntry;
86
87    let mut updated = session.clone();
88
89    for entry in &updated.wakeup_manifest {
90        if !entry.missed {
91            if let Some(pos) = Position::parse(&entry.position) {
92                record_outcome(&entry.profile, pos, true);
93            }
94        }
95    }
96
97    let mut manifest: Vec<ManifestEntry> = Vec::new();
98    let mut push = |key: &str, position: &str| {
99        let key = key.trim();
100        if !key.is_empty() {
101            manifest.push(ManifestEntry {
102                key: key.chars().take(80).collect(),
103                position: position.to_string(),
104                profile: profile_name.to_string(),
105                missed: false,
106            });
107        }
108    };
109
110    if let Some(ref task) = updated.task {
111        push(&task.description, "begin");
112    }
113    for d in updated.decisions.iter().rev().take(5) {
114        push(&d.summary, "begin");
115    }
116    for f in updated.findings.iter().rev().take(8) {
117        push(&f.summary, "end");
118    }
119    for n in updated.next_steps.iter().take(3) {
120        push(n, "end");
121    }
122
123    updated.wakeup_manifest = manifest;
124    let _ = updated.save();
125}
126
127pub fn claude_config_dir_display() -> String {
128    match std::env::var("CLAUDE_CONFIG_DIR") {
129        Ok(dir) if !dir.trim().is_empty() => {
130            let dir = dir.trim().to_string();
131            if dir.starts_with('~') {
132                dir
133            } else if let Some(home) = dirs::home_dir() {
134                let home_str = home.to_string_lossy();
135                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
136                    format!("~{rest}")
137                } else {
138                    dir
139                }
140            } else {
141                dir
142            }
143        }
144        _ => "~/.claude".to_string(),
145    }
146}
147
148fn build_claude_code_instructions() -> String {
149    let shell_hint = build_shell_hint();
150    let config_dir = claude_config_dir_display();
151
152    // Load session state for continuity (compact version for Claude Code's char limit)
153    let session_block = match crate::core::session::SessionState::load_latest() {
154        Some(session) => {
155            let mut parts = Vec::new();
156            if let Some(ref task) = session.task {
157                let pct = task
158                    .progress_pct
159                    .map_or(String::new(), |p| format!(" [{p}%]"));
160                parts.push(format!("Task: {}{pct}", task.description));
161            }
162            if !session.decisions.is_empty() {
163                let items: Vec<&str> = session
164                    .decisions
165                    .iter()
166                    .rev()
167                    .take(3)
168                    .map(|d| d.summary.as_str())
169                    .collect();
170                parts.push(format!("Decisions: {}", items.join("; ")));
171            }
172            if !session.files_touched.is_empty() {
173                let modified: Vec<&str> = session
174                    .files_touched
175                    .iter()
176                    .filter(|f| f.modified)
177                    .take(5)
178                    .map(|f| f.path.as_str())
179                    .collect();
180                if !modified.is_empty() {
181                    parts.push(format!("Modified: {}", modified.join(", ")));
182                }
183            }
184            if !session.findings.is_empty() {
185                let recent: Vec<&str> = session
186                    .findings
187                    .iter()
188                    .rev()
189                    .take(3)
190                    .map(|f| f.summary.as_str())
191                    .collect();
192                parts.push(format!("Recent: {}", recent.join("; ")));
193            }
194            if parts.is_empty() {
195                String::new()
196            } else {
197                format!("\n\n--- SESSION ---\n{}\n---", parts.join("\n"))
198            }
199        }
200        None => String::new(),
201    };
202
203    let cfg = crate::core::config::Config::load();
204    let shadow_preamble = if cfg.shadow_mode {
205        "SHADOW MODE ACTIVE: ALL reads/searches/shell MUST use ctx_* tools. Native equivalents are intercepted.\n\n"
206    } else {
207        ""
208    };
209
210    let instr = format!("\
211{shadow_preamble}\
212ALWAYS use lean-ctx MCP tools instead of native equivalents.
213
214Tool mapping (MANDATORY):
215• Read/cat/head/tail -> ctx_read(path, mode)
216• Shell/bash -> ctx_shell(command)
217• Grep/rg -> ctx_search(pattern, path)
218• ls/find -> ctx_tree(path, depth)
219• Edit/StrReplace -> native (lean-ctx=READ only). If Edit needs Read and Read is unavailable, use ctx_edit.
220• Write, Delete, Glob -> normal. NEVER loop on Edit failures — use ctx_edit.
221
222ctx_read modes: full|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M
223Auto-selects mode. Re-reads ~13 tok. File refs F1,F2.. persist.
224
225Workflow: Orient(ctx_overview) → Locate(ctx_search) → Read(ctx_read) → Edit(ctx_edit/native) → Verify(ctx_read diff + ctx_shell test) → Record(ctx_knowledge).
226
227CEP: 1.ACT FIRST 2.DELTA ONLY 3.STRUCTURED(+/-/~) 4.ONE LINE 5.QUALITY
228{shell_hint}\
229Prefer: ctx_read>Read | ctx_shell>Shell | ctx_search>Grep | ctx_tree>ls
230Edit: native Edit/StrReplace preferred, ctx_edit if Edit unavailable.
231Never echo tool output. Never narrate. Show only changed code.
232Full instructions at {config_dir}/CLAUDE.md (imports rules/lean-ctx.md){session_block}");
233
234    instr
235}
236
237fn build_full_instructions(crp_mode: CrpMode, client_name: &str) -> String {
238    let cfg = crate::core::config::Config::load();
239    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
240
241    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
242    let loaded_session = if minimal {
243        None
244    } else {
245        crate::core::session::SessionState::load_latest()
246    };
247
248    let (session_block, litm_end_block) = match loaded_session {
249        Some(ref session) => {
250            // LITM calibration (#539): rotate the placement manifest — every
251            // entry the agent never re-recalled counts as a placement hit —
252            // then rebuild it for this injection and apply the learned share.
253            rotate_wakeup_manifest(session, profile.name);
254            let share = crate::core::litm_calibration::begin_share(profile.name);
255            let positioned = crate::core::litm::position_optimize_with_share(session, share);
256            let begin = format!(
257                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
258                profile.name, positioned.begin_block
259            );
260            let end = if positioned.end_block.is_empty() {
261                String::new()
262            } else {
263                format!(
264                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
265                    positioned.end_block
266                )
267            };
268            (begin, end)
269        }
270        None => (String::new(), String::new()),
271    };
272
273    let project_root_for_blocks = if minimal {
274        None
275    } else {
276        loaded_session
277            .as_ref()
278            .and_then(|s| s.project_root.clone())
279            .or_else(|| {
280                std::env::current_dir()
281                    .ok()
282                    .map(|p| p.to_string_lossy().to_string())
283            })
284    };
285
286    let knowledge_block = match &project_root_for_blocks {
287        Some(root) => {
288            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
289            match knowledge {
290                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
291                    let aaak = k.format_aaak();
292                    if aaak.is_empty() {
293                        String::new()
294                    } else {
295                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
296                    }
297                }
298                _ => String::new(),
299            }
300        }
301        None => String::new(),
302    };
303
304    let gotcha_block = match &project_root_for_blocks {
305        Some(root) => {
306            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
307            let files: Vec<String> = loaded_session
308                .as_ref()
309                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
310                .unwrap_or_default();
311            let block = store.format_injection_block(&files);
312            if block.is_empty() {
313                String::new()
314            } else {
315                format!("\n{block}\n")
316            }
317        }
318        None => String::new(),
319    };
320
321    let shell_hint = build_shell_hint();
322
323    use crate::core::rules_canonical as rc;
324    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
325    let read_modes = rc::ctx_read_modes_block();
326    let auto_block = rc::automation_block();
327    let cep = rc::cep_block();
328    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
329
330    let shadow_preamble = if cfg.shadow_mode {
331        "SHADOW MODE ACTIVE: ALL file reads, searches, and shell commands MUST go through ctx_* tools. \
332         Native Read/Grep/Shell are intercepted and redirected — using ctx_* directly is faster and more reliable.\n\n"
333    } else {
334        ""
335    };
336
337    let mut base = format!(
338        "\
339{shadow_preamble}\
340CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
341\n\
342{tool_bullets}\n\
343{shell_hint}\
344\n\
345{read_modes}\n\
346\n\
347{auto_block}\n\
348\n\
349{cep}\n\
350\n\
351{decoder_block}\n\
352\n\
353{session_block}\
354{knowledge_block}\
355{gotcha_block}\
356\n\
357{origin}\n\
358\n\
359{litm_pref}\
360{litm_end_block}",
361        decoder_block =
362            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
363        origin = crate::core::integrity::origin_line(),
364        litm_end_block = &litm_end_block
365    );
366
367    if should_use_unified(client_name) {
368        base.push_str("\n\n");
369        base.push_str(rc::unified_tool_mode_block());
370        base.push('\n');
371    }
372
373    let intelligence_block = build_intelligence_block();
374    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
375
376    // The guidance suffix (CRP-mode rules + compression/output-style + the
377    // intelligence block) is the operational contract for the agent and must
378    // survive the token cap. The variable session/knowledge/gotcha blocks live
379    // inside `base` and are the right thing to shed under pressure (H3). So we
380    // protect the suffix and truncate only `base` to fit the budget.
381    let guidance_suffix = match crp_mode_suffix(&crp_mode) {
382        "" => format!("{terse_block}{intelligence_block}"),
383        crp => format!("{crp}\n\n{terse_block}{intelligence_block}"),
384    };
385
386    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
387}
388
389/// CRP-mode contract appended to the instructions. One compact line per mode
390/// (#579): the abbreviation list and notation example double as the legend.
391fn crp_mode_suffix(crp_mode: &CrpMode) -> &'static str {
392    match crp_mode {
393        CrpMode::Off => "",
394        CrpMode::Compact => {
395            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
396             diff lines (+/-) only; <=200 tok; trust tool outputs."
397        }
398        CrpMode::Tdd => {
399            "CRP MODE: tdd — max density; Fn refs + diff lines only \
400             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
401        }
402    }
403}
404
405/// Join `base` and a protected `suffix` so the result fits `cap_tokens`,
406/// truncating only `base` if needed. The suffix is the agent's operational
407/// contract (compression/output-style guidance) and is preserved verbatim as
408/// long as it fits on its own; otherwise we fall back to capping the whole.
409fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
410    use crate::core::tokens::count_tokens;
411    let suffix = suffix.trim_end_matches('\n');
412    if suffix.is_empty() {
413        let full = base.to_string();
414        return if count_tokens(&full) > cap_tokens {
415            truncate_to_token_cap(&full, cap_tokens)
416        } else {
417            full
418        };
419    }
420
421    let full = format!("{base}\n\n{suffix}");
422    if count_tokens(&full) <= cap_tokens {
423        return full;
424    }
425
426    let suffix_tokens = count_tokens(suffix);
427    // Reserve room for the suffix plus the "\n\n" join. If the suffix alone is
428    // already at/over budget, degrade to a plain tail-cap of the whole text.
429    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
430        return truncate_to_token_cap(&full, cap_tokens);
431    };
432    let trimmed_base = truncate_to_token_cap(base, base_budget);
433    format!("{trimmed_base}\n\n{suffix}")
434}
435
436fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
437    use crate::core::tokens::count_tokens;
438    if count_tokens(s) <= cap_tokens {
439        return s.to_string();
440    }
441    // Keep whole lines: candidate cut points are the byte offsets of each
442    // newline. Token count is monotonic in prefix length, so binary-search for
443    // the longest whole-line prefix within the cap. This costs O(log lines)
444    // tokenizations instead of O(lines) — the per-line loop was pathologically
445    // slow on large session blocks (and timed out under coverage's ptrace
446    // instrumentation).
447    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
448    let (mut lo, mut hi) = (0usize, cuts.len());
449    let mut best: Option<usize> = None;
450    while lo < hi {
451        let mid = lo + (hi - lo) / 2;
452        let end = cuts[mid];
453        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
454            best = Some(end);
455            lo = mid + 1;
456        } else {
457            hi = mid;
458        }
459    }
460    if let Some(end) = best {
461        return s[..end].to_string();
462    }
463    // No line boundary fits — fall back to a char-boundary byte approximation.
464    let byte_approx = cap_tokens * 4;
465    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
466    s[..safe].to_string()
467}
468
469fn build_full_instructions_for_test(crp_mode: CrpMode, client_name: &str) -> String {
470    use crate::core::rules_canonical as rc;
471    let shell_hint = build_shell_hint();
472    let session_block = String::new();
473    let knowledge_block = String::new();
474    let gotcha_block = String::new();
475    let litm_end_block = String::new();
476
477    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
478    let read_modes = rc::ctx_read_modes_block();
479    let auto_block = rc::automation_block();
480    let cep = rc::cep_block();
481    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
482
483    let mut base = format!(
484        "\
485CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
486\n\
487{tool_bullets}\n\
488{shell_hint}\
489\n\
490{read_modes}\n\
491\n\
492{auto_block}\n\
493\n\
494{cep}\n\
495\n\
496{decoder_block}\n\
497\n\
498{session_block}\
499{knowledge_block}\
500{gotcha_block}\
501\n\
502{origin}\n\
503\n\
504{litm_pref}\
505{litm_end_block}",
506        decoder_block =
507            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
508        origin = crate::core::integrity::origin_line(),
509        litm_end_block = &litm_end_block
510    );
511
512    if should_use_unified(client_name) {
513        base.push_str("\n\n");
514        base.push_str(rc::unified_tool_mode_block());
515        base.push('\n');
516    }
517
518    let intelligence_block = build_intelligence_block();
519    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
520
521    match crp_mode_suffix(&crp_mode) {
522        "" => format!("{base}\n\n{terse_block}{intelligence_block}"),
523        crp => format!("{base}\n\n{crp}\n\n{terse_block}{intelligence_block}"),
524    }
525}
526
527fn build_full_instructions_for_compiler(
528    crp_mode: CrpMode,
529    client_name: &str,
530    unified_tool_mode: bool,
531) -> String {
532    let shell_hint = build_shell_hint();
533    let session_block = String::new();
534    let knowledge_block = String::new();
535    let gotcha_block = String::new();
536    let litm_end_block = String::new();
537
538    use crate::core::rules_canonical as rc;
539    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
540    let read_modes = rc::ctx_read_modes_block();
541    let auto_blk = rc::automation_block();
542    let cep = rc::cep_block();
543    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
544
545    let mut base = format!(
546        "\
547CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
548\n\
549{tool_bullets}\n\
550{shell_hint}\
551\n\
552{read_modes}\n\
553\n\
554{auto_blk}\n\
555\n\
556{cep}\n\
557\n\
558{decoder_block}\n\
559\n\
560{session_block}\
561{knowledge_block}\
562{gotcha_block}\
563\n\
564{origin}\n\
565\n\
566{litm_pref}\
567{litm_end_block}",
568        decoder_block =
569            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
570        origin = crate::core::integrity::origin_line(),
571        litm_end_block = &litm_end_block
572    );
573
574    if unified_tool_mode {
575        base.push_str("\n\n");
576        base.push_str(rc::unified_tool_mode_block());
577        base.push('\n');
578    }
579
580    let _ = client_name; // keep signature aligned with other builders
581    let intelligence_block = build_intelligence_block();
582
583    match crp_mode_suffix(&crp_mode) {
584        "" => format!("{base}\n\n{intelligence_block}"),
585        crp => format!("{base}\n\n{crp}\n\n{intelligence_block}"),
586    }
587}
588
589pub fn claude_code_instructions() -> String {
590    build_claude_code_instructions()
591}
592
593fn build_terse_agent_block_for_client(_crp_mode: &CrpMode, client_name: &str) -> String {
594    use crate::core::config::{CompressionLevel, Config};
595    let cfg = Config::load();
596    let compression = CompressionLevel::effective(&cfg);
597    if compression.is_active() {
598        let persona = crate::core::persona::Persona::resolve(&cfg);
599        return crate::core::terse::agent_prompts::build_prompt_block_for_persona(
600            &compression,
601            client_name,
602            &persona,
603        );
604    }
605    String::new()
606}
607
608fn build_intelligence_block() -> String {
609    "OUTPUT: never echo tool output, no narration comments, show only changed code.".to_string()
610}
611
612fn build_shell_hint() -> String {
613    if !cfg!(windows) {
614        return String::new();
615    }
616    // Keep this hint terse: it rides inside the static skeleton, which is
617    // budget-capped (#579) — the cap applies on Windows too.
618    let name = crate::shell::shell_name();
619    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
620    if is_posix {
621        format!("\nSHELL: {name} (POSIX) — POSIX commands only, no PowerShell cmdlets.\n")
622    } else if name.contains("powershell") || name.contains("pwsh") {
623        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
624    } else {
625        format!("\nSHELL: {name}.\n")
626    }
627}
628
629fn should_use_unified(client_name: &str) -> bool {
630    if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
631        return false;
632    }
633    if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
634        return true;
635    }
636    let _ = client_name;
637    false
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::core::tokens::count_tokens;
644
645    #[test]
646    fn guidance_suffix_survives_oversized_base() {
647        // Simulate a bloated session/knowledge `base` that alone exceeds the cap.
648        let base = "SESSION LINE\n".repeat(4000);
649        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
650        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
651
652        assert!(
653            out.contains("OUTPUT STYLE: expert-terse"),
654            "protected guidance suffix must survive truncation"
655        );
656        assert!(
657            count_tokens(&out) <= INSTRUCTION_CAP_TOKENS,
658            "assembled output must respect the token cap"
659        );
660        assert!(
661            out.len() < base.len(),
662            "oversized base must have been truncated"
663        );
664    }
665
666    #[test]
667    fn under_cap_keeps_everything() {
668        let base = "tool mapping block";
669        let suffix = "OUTPUT STYLE: dense";
670        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
671        assert!(out.contains(base));
672        assert!(out.contains(suffix));
673    }
674
675    #[test]
676    fn empty_suffix_caps_base_only() {
677        let base = "x\n".repeat(4000);
678        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
679        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
680    }
681
682    #[cfg(windows)]
683    #[test]
684    fn shell_hint_stays_within_its_budget() {
685        // The skeleton budget grants the Windows shell hint exactly
686        // STATIC_INSTRUCTION_SHELL_HINT_TOKENS — keep the hint inside it.
687        let hint = build_shell_hint();
688        let tokens = count_tokens(&hint);
689        assert!(
690            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
691            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
692        );
693    }
694
695    #[test]
696    fn static_skeleton_stays_within_budget() {
697        // #579: the static instruction skeleton (no session/knowledge blocks)
698        // rides in EVERY session of EVERY install. Detail documentation
699        // belongs in LEAN-CTX.md on disk — this assert stops silent creep.
700        // Isolated data dir = default config, like a fresh install (the dev
701        // machine's compression_level/profile must not leak into the budget).
702        let _iso = crate::core::data_dir::isolated_data_dir();
703        for (mode, base_budget) in [
704            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
705            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
706            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
707        ] {
708            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
709            let out = build_instructions_for_test(mode);
710            let tokens = count_tokens(&out);
711            assert!(
712                tokens <= budget,
713                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
714            );
715        }
716    }
717}