1use crate::core::config::CompressionLevel;
2use crate::core::rules_canonical::{self as rc, Wrapper};
3use crate::tools::CrpMode;
4
5const INSTRUCTION_CAP_TOKENS: usize = 800;
7
8#[cfg(test)]
20const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 545;
21#[cfg(test)]
22const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 675;
23#[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 let level = if client_loads_compression_from_file(client_name) {
42 CompressionLevel::Off
43 } else {
44 CompressionLevel::effective(&cfg)
45 };
46 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#[must_use]
69pub fn claude_code_static_instructions_for_test() -> String {
70 build_full_instructions(CrpMode::Off, "", true, CompressionLevel::Off, false, "")
71}
72
73#[must_use]
75pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
76 let shadow = false;
77 let level = CompressionLevel::effective(&crate::core::config::Config::load());
80 let skeleton = rc::render(shadow, Wrapper::Bare, level);
81 let shell_hint = build_shell_hint();
82
83 let base = format!(
84 "{skeleton}\n\
85 {shell_hint}\n\
86 {decoder_block}\n\
87 {origin}",
88 decoder_block =
89 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
90 origin = crate::core::integrity::origin_line(),
91 );
92
93 match crp_mode_suffix(crp_mode) {
94 "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
95 crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
96 }
97}
98
99#[must_use]
103pub fn build_instructions_with_client_for_compiler(
104 crp_mode: CrpMode,
105 client_name: &str,
106 _unified_tool_mode: bool,
107) -> String {
108 let skeleton = rc::render(true, Wrapper::Bare, CompressionLevel::Off);
109 let shell_hint = build_shell_hint();
110
111 let base = format!(
112 "{skeleton}\n\
113 {shell_hint}\n\
114 {decoder_block}\n\
115 {origin}",
116 decoder_block =
117 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
118 origin = crate::core::integrity::origin_line(),
119 );
120
121 let _ = client_name;
122
123 match crp_mode_suffix(crp_mode) {
124 "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
125 crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
126 }
127}
128
129fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
131 use crate::core::litm_calibration::{Position, record_outcome};
132 use crate::core::session::ManifestEntry;
133
134 let mut updated = session.clone();
135
136 for entry in &updated.wakeup_manifest {
137 if !entry.missed
138 && let Some(pos) = Position::parse(&entry.position)
139 {
140 record_outcome(&entry.profile, pos, true);
141 }
142 }
143
144 let mut manifest: Vec<ManifestEntry> = Vec::new();
145 let mut push = |key: &str, position: &str| {
146 let key = key.trim();
147 if !key.is_empty() {
148 manifest.push(ManifestEntry {
149 key: key.chars().take(80).collect(),
150 position: position.to_string(),
151 profile: profile_name.to_string(),
152 missed: false,
153 });
154 }
155 };
156
157 if let Some(ref task) = updated.task {
158 push(&task.description, "begin");
159 }
160 for d in updated.decisions.iter().rev().take(5) {
161 push(&d.summary, "begin");
162 }
163 for f in updated.findings.iter().rev().take(8) {
164 push(&f.summary, "end");
165 }
166 for n in updated.next_steps.iter().take(3) {
167 push(n, "end");
168 }
169
170 updated.wakeup_manifest = manifest;
171 let _ = updated.save();
172}
173
174#[must_use]
176pub fn claude_config_dir_display() -> String {
177 match std::env::var("CLAUDE_CONFIG_DIR") {
178 Ok(dir) if !dir.trim().is_empty() => {
179 let dir = dir.trim().to_string();
180 if dir.starts_with('~') {
181 dir
182 } else if let Some(home) = dirs::home_dir() {
183 let home_str = home.to_string_lossy();
184 if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
185 format!("~{rest}")
186 } else {
187 dir
188 }
189 } else {
190 dir
191 }
192 }
193 _ => "~/.claude".to_string(),
194 }
195}
196
197fn build_full_instructions(
200 crp_mode: CrpMode,
201 client_name: &str,
202 minimal: bool,
203 level: CompressionLevel,
204 shadow: bool,
205 persona_block: &str,
206) -> String {
207 let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
208 let loaded_session = if minimal {
209 None
210 } else {
211 crate::core::session::SessionState::load_latest()
212 };
213
214 let (session_block, litm_end_block) = match loaded_session {
215 Some(ref session) => {
216 rotate_wakeup_manifest(session, profile.name);
217 let share = crate::core::litm_calibration::begin_share(profile.name);
218 let mut positioned = crate::core::litm::position_optimize_with_share(session, share);
219 positioned.enforce_token_budget(crate::core::litm::active_session_budget());
222 let begin = format!(
223 "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
224 profile.name, positioned.begin_block
225 );
226 let end = if positioned.end_block.is_empty() {
227 String::new()
228 } else {
229 format!(
230 "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
231 positioned.end_block
232 )
233 };
234 (begin, end)
235 }
236 None => (String::new(), String::new()),
237 };
238
239 let project_root_for_blocks = if minimal {
240 None
241 } else {
242 loaded_session
243 .as_ref()
244 .and_then(|s| s.project_root.clone())
245 .or_else(|| {
246 std::env::current_dir()
247 .ok()
248 .map(|p| p.to_string_lossy().to_string())
249 })
250 };
251
252 let knowledge_block = match &project_root_for_blocks {
253 Some(root) => {
254 let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
255 match knowledge {
256 Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
257 let aaak = k.format_aaak();
258 if aaak.is_empty() {
259 String::new()
260 } else {
261 format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
262 }
263 }
264 _ => String::new(),
265 }
266 }
267 None => String::new(),
268 };
269
270 let gotcha_block = match &project_root_for_blocks {
271 Some(root) => {
272 let store = crate::core::gotcha_tracker::GotchaStore::load(root);
273 let files: Vec<String> = loaded_session
274 .as_ref()
275 .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
276 .unwrap_or_default();
277 let block = store.format_injection_block(&files);
278 if block.is_empty() {
279 String::new()
280 } else {
281 format!("\n{block}\n")
282 }
283 }
284 None => String::new(),
285 };
286
287 let health_block = match &project_root_for_blocks {
288 Some(root) => {
289 let block = crate::core::code_health::persist::format_session_block(root);
290 if block.is_empty() {
291 String::new()
292 } else {
293 format!("\n{block}\n")
294 }
295 }
296 None => String::new(),
297 };
298
299 let shell_hint = build_shell_hint();
300
301 let skeleton = if client_loads_rules_from_file(client_name) {
315 let anchor = if client_is_hook_covered(client_name) {
316 HOOK_COVERED_ANCHOR
317 } else {
318 SKELETON_ANCHOR
319 };
320 let compression = rc::compression_text(level);
321 if compression.is_empty() {
322 anchor.to_string()
323 } else {
324 format!("{anchor}\n{compression}")
325 }
326 } else {
327 rc::render(shadow, Wrapper::Bare, level)
328 };
329
330 let config_dir = claude_config_dir_display();
333
334 let persona_section = if persona_block.is_empty() {
337 String::new()
338 } else {
339 format!("\n{persona_block}")
340 };
341
342 let base = format!(
343 "{skeleton}\n\
344 {persona_section}\
345 {shell_hint}\n\
346 {decoder_block}\n\
347 Full instructions at {config_dir}/CLAUDE.md\n\
348 {session_block}\n\
349 {knowledge_block}\n\
350 {gotcha_block}\n\
351 {health_block}\n\
352 {origin}\n\
353 {litm_end_block}",
354 decoder_block =
355 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
356 origin = crate::core::integrity::origin_line(),
357 litm_end_block = litm_end_block
358 );
359
360 let guidance_suffix = match crp_mode_suffix(crp_mode) {
363 "" => rc::INTELLIGENCE.to_string(),
364 crp => format!("{crp}\n\n{}", rc::INTELLIGENCE),
365 };
366
367 assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
368}
369
370fn crp_mode_suffix(crp_mode: CrpMode) -> &'static str {
371 match crp_mode {
372 CrpMode::Off => "",
373 CrpMode::Compact => {
374 "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
375 diff lines (+/-) only; <=200 tok; trust tool outputs."
376 }
377 CrpMode::Tdd => {
378 "CRP MODE: tdd — max density; Fn refs + diff lines only \
379 (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
380 }
381 }
382}
383
384fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
385 use crate::core::tokens::count_tokens;
386 let suffix = suffix.trim_end_matches('\n');
387 if suffix.is_empty() {
388 let full = base.to_string();
389 return if count_tokens(&full) > cap_tokens {
390 truncate_to_token_cap(&full, cap_tokens)
391 } else {
392 full
393 };
394 }
395
396 let full = format!("{base}\n\n{suffix}");
397 if count_tokens(&full) <= cap_tokens {
398 return full;
399 }
400
401 let suffix_tokens = count_tokens(suffix);
402 let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
403 return truncate_to_token_cap(&full, cap_tokens);
404 };
405 let trimmed_base = truncate_to_token_cap(base, base_budget);
406 format!("{trimmed_base}\n\n{suffix}")
407}
408
409fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
410 use crate::core::tokens::count_tokens;
411 if count_tokens(s) <= cap_tokens {
412 return s.to_string();
413 }
414 let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
415 let (mut lo, mut hi) = (0usize, cuts.len());
416 let mut best: Option<usize> = None;
417 while lo < hi {
418 let mid = lo + (hi - lo) / 2;
419 let end = cuts[mid];
420 if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
421 best = Some(end);
422 lo = mid + 1;
423 } else {
424 hi = mid;
425 }
426 }
427 if let Some(end) = best {
428 return s[..end].to_string();
429 }
430 let byte_approx = cap_tokens * 4;
431 let safe = s.floor_char_boundary(byte_approx.min(s.len()));
432 s[..safe].to_string()
433}
434
435#[must_use]
437pub fn claude_code_instructions() -> String {
438 build_instructions(CrpMode::Off)
439}
440
441const SKELETON_ANCHOR: &str = "lean-ctx active — your auto-loaded lean-ctx rules apply: \
446 ctx_* tools replace native Read/Grep/Shell/Glob (ctx_compose first).";
447
448const HOOK_COVERED_ANCHOR: &str = "lean-ctx active — hooks compress native Shell/Read/Grep \
452 transparently; call ctx_compose to orient, ctx_semantic_search / ctx_knowledge for \
453 meaning & memory.";
454
455fn client_loads_compression_from_file(client_name: &str) -> bool {
456 crate::core::home::resolve_home_dir().is_some_and(|home| {
457 crate::core::rules_channel::client_autoloads_compression(client_name, &home)
458 })
459}
460
461fn client_loads_rules_from_file(client_name: &str) -> bool {
462 crate::core::home::resolve_home_dir()
463 .is_some_and(|home| crate::core::rules_channel::client_autoloads_rules(client_name, &home))
464}
465
466fn client_is_hook_covered(client_name: &str) -> bool {
467 crate::core::home::resolve_home_dir()
468 .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
469}
470
471fn build_shell_hint() -> String {
472 if !cfg!(windows) {
473 return String::new();
474 }
475 let name = crate::shell::shell_name();
476 let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
477 if is_posix {
478 format!("\nSHELL: {name} (POSIX) — no PowerShell cmdlets.\n")
479 } else if name.contains("powershell") || name.contains("pwsh") {
480 format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
481 } else {
482 format!("\nSHELL: {name}.\n")
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::core::tokens::count_tokens;
490
491 #[test]
492 fn guidance_suffix_survives_oversized_base() {
493 let base = "SESSION LINE\n".repeat(4000);
494 let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
495 let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
496 assert!(out.contains("OUTPUT STYLE: expert-terse"));
497 assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
498 assert!(out.len() < base.len());
499 }
500
501 #[test]
502 fn empty_client_never_dedups_compression() {
503 assert!(!client_loads_compression_from_file(""));
504 assert!(!client_loads_compression_from_file("totally-unknown-agent"));
505 }
506
507 #[test]
508 fn covered_client_gets_anchor_instead_of_skeleton() {
509 let _guard = crate::core::data_dir::test_env_lock();
512 let tmp = tempfile::tempdir().unwrap();
513 let home = tmp.path();
514 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
515 std::fs::write(
516 home.join(".cursor/rules/lean-ctx.mdc"),
517 rc::render(
518 false,
519 Wrapper::Dedicated,
520 crate::core::config::CompressionLevel::Standard,
521 ),
522 )
523 .unwrap();
524 let old_home = std::env::var("HOME").ok();
525 crate::test_env::set_var("HOME", home);
526 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
527
528 let covered = build_instructions_with_client(CrpMode::Off, "cursor");
529 let uncovered = build_instructions_with_client(CrpMode::Off, "some-other-agent");
530
531 if let Some(h) = old_home {
532 crate::test_env::set_var("HOME", h);
533 } else {
534 crate::test_env::remove_var("HOME");
535 }
536 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
537
538 assert!(
539 covered.contains(SKELETON_ANCHOR),
540 "covered client must get the anchor:\n{covered}"
541 );
542 assert!(
543 !covered.contains("MANDATORY MAPPING"),
544 "covered client must not re-pay the skeleton:\n{covered}"
545 );
546 assert!(
548 !covered.contains("OUTPUT STYLE:"),
549 "covered client must not re-pay the compression prompt:\n{covered}"
550 );
551 assert!(
552 uncovered.contains("MANDATORY MAPPING"),
553 "uncovered client keeps the full skeleton:\n{uncovered}"
554 );
555 eprintln!(
556 "instructions footprint: covered={} tok, uncovered={} tok",
557 count_tokens(&covered),
558 count_tokens(&uncovered)
559 );
560 assert!(
561 count_tokens(&covered) < count_tokens(&uncovered),
562 "anchor path must be strictly cheaper"
563 );
564 }
565
566 #[test]
567 fn hook_covered_client_gets_hook_aware_anchor() {
568 let _guard = crate::core::data_dir::test_env_lock();
572 let tmp = tempfile::tempdir().unwrap();
573 let home = tmp.path();
574 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
575 std::fs::write(
576 home.join(".cursor/rules/lean-ctx.mdc"),
577 rc::render(
578 false,
579 Wrapper::HookCovered,
580 crate::core::config::CompressionLevel::Off,
581 ),
582 )
583 .unwrap();
584 std::fs::write(
585 home.join(".cursor/hooks.json"),
586 r#"{"version":1,"hooks":{"preToolUse":[
587 {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
588 {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
589 ]}}"#,
590 )
591 .unwrap();
592 let old_home = std::env::var("HOME").ok();
593 crate::test_env::set_var("HOME", home);
594 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
595
596 let covered = build_instructions_with_client(CrpMode::Off, "cursor");
597
598 if let Some(h) = old_home {
599 crate::test_env::set_var("HOME", h);
600 } else {
601 crate::test_env::remove_var("HOME");
602 }
603 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
604
605 assert!(
606 covered.contains(HOOK_COVERED_ANCHOR),
607 "hook-covered client must get the hook-aware anchor:\n{covered}"
608 );
609 assert!(
610 !covered.contains(SKELETON_ANCHOR) && !covered.contains("MANDATORY MAPPING"),
611 "hook-covered client must not carry the replace-native wording:\n{covered}"
612 );
613 }
614
615 #[test]
616 fn non_coding_persona_block_lands_in_instructions() {
617 let _guard = crate::core::data_dir::test_env_lock();
618 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
619
620 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
621 let research = build_instructions_with_client(CrpMode::Off, "");
622
623 crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
624 let coding = build_instructions_with_client(CrpMode::Off, "");
625
626 crate::test_env::remove_var("LEAN_CTX_PERSONA");
627 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
628
629 assert!(
630 research.contains("PERSONA: research"),
631 "research persona must announce its domain block:\n{research}"
632 );
633 assert!(
634 research.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
635 "research persona must carry its intent vocabulary:\n{research}"
636 );
637 assert!(
638 !coding.contains("PERSONA:"),
639 "the coding default must keep the instructions byte-stable (#498):\n{coding}"
640 );
641 }
642
643 #[test]
644 fn under_cap_keeps_everything() {
645 let base = "tool mapping block";
646 let suffix = "OUTPUT STYLE: dense";
647 let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
648 assert!(out.contains(base));
649 assert!(out.contains(suffix));
650 }
651
652 #[test]
653 fn empty_suffix_caps_base_only() {
654 let base = "x\n".repeat(4000);
655 let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
656 assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
657 }
658
659 #[cfg(windows)]
660 #[test]
661 fn shell_hint_stays_within_its_budget() {
662 let hint = build_shell_hint();
663 let tokens = count_tokens(&hint);
664 assert!(
665 tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
666 "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
667 );
668 }
669
670 #[test]
671 fn minimal_overhead_instructions_stay_within_budget() {
672 const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
673 STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
674 let _iso = crate::core::data_dir::isolated_data_dir();
675 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
676 let out = build_instructions(CrpMode::Compact);
677 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
678 let tokens = count_tokens(&out);
679 assert!(
680 tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
681 "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
682 );
683 }
684
685 #[test]
686 fn static_skeleton_stays_within_budget() {
687 let _iso = crate::core::data_dir::isolated_data_dir();
688 crate::test_env::set_var("LEAN_CTX_COMPRESSION", "off");
691 for (mode, base_budget) in [
692 (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
693 (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
694 (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
695 ] {
696 let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
697 let out = build_instructions_for_test(mode);
698 let tokens = count_tokens(&out);
699 assert!(
700 tokens <= budget,
701 "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
702 );
703 }
704 crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
705 }
706}