1use crate::tools::CrpMode;
2
3const INSTRUCTION_CAP_TOKENS: usize = 800;
17
18#[cfg(test)]
22const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 400;
23#[cfg(test)]
24const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 500;
25#[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) || is_codebuddy_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 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) || is_codebuddy_client(client_name) {
52 return build_claude_code_instructions();
53 }
54 build_full_instructions_for_test(crp_mode, client_name)
55}
56
57pub 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) || is_codebuddy_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
77fn is_codebuddy_client(client_name: &str) -> bool {
78 let lower = client_name.to_lowercase();
79 lower.contains("codebuddy")
80}
81
82fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
89 use crate::core::litm_calibration::{Position, record_outcome};
90 use crate::core::session::ManifestEntry;
91
92 let mut updated = session.clone();
93
94 for entry in &updated.wakeup_manifest {
95 if !entry.missed
96 && let Some(pos) = Position::parse(&entry.position)
97 {
98 record_outcome(&entry.profile, pos, true);
99 }
100 }
101
102 let mut manifest: Vec<ManifestEntry> = Vec::new();
103 let mut push = |key: &str, position: &str| {
104 let key = key.trim();
105 if !key.is_empty() {
106 manifest.push(ManifestEntry {
107 key: key.chars().take(80).collect(),
108 position: position.to_string(),
109 profile: profile_name.to_string(),
110 missed: false,
111 });
112 }
113 };
114
115 if let Some(ref task) = updated.task {
116 push(&task.description, "begin");
117 }
118 for d in updated.decisions.iter().rev().take(5) {
119 push(&d.summary, "begin");
120 }
121 for f in updated.findings.iter().rev().take(8) {
122 push(&f.summary, "end");
123 }
124 for n in updated.next_steps.iter().take(3) {
125 push(n, "end");
126 }
127
128 updated.wakeup_manifest = manifest;
129 let _ = updated.save();
130}
131
132pub fn claude_config_dir_display() -> String {
133 match std::env::var("CLAUDE_CONFIG_DIR") {
134 Ok(dir) if !dir.trim().is_empty() => {
135 let dir = dir.trim().to_string();
136 if dir.starts_with('~') {
137 dir
138 } else if let Some(home) = dirs::home_dir() {
139 let home_str = home.to_string_lossy();
140 if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
141 format!("~{rest}")
142 } else {
143 dir
144 }
145 } else {
146 dir
147 }
148 }
149 _ => "~/.claude".to_string(),
150 }
151}
152
153fn build_claude_code_instructions() -> String {
154 let shell_hint = build_shell_hint();
155 let config_dir = claude_config_dir_display();
156
157 let session_block = match crate::core::session::SessionState::load_latest() {
159 Some(session) => {
160 let mut parts = Vec::new();
161 if let Some(ref task) = session.task {
162 let pct = task
163 .progress_pct
164 .map_or(String::new(), |p| format!(" [{p}%]"));
165 parts.push(format!("Task: {}{pct}", task.description));
166 }
167 if !session.decisions.is_empty() {
168 let items: Vec<&str> = session
169 .decisions
170 .iter()
171 .rev()
172 .take(3)
173 .map(|d| d.summary.as_str())
174 .collect();
175 parts.push(format!("Decisions: {}", items.join("; ")));
176 }
177 if !session.files_touched.is_empty() {
178 let modified: Vec<&str> = session
179 .files_touched
180 .iter()
181 .filter(|f| f.modified)
182 .take(5)
183 .map(|f| f.path.as_str())
184 .collect();
185 if !modified.is_empty() {
186 parts.push(format!("Modified: {}", modified.join(", ")));
187 }
188 }
189 if !session.findings.is_empty() {
190 let recent: Vec<&str> = session
191 .findings
192 .iter()
193 .rev()
194 .take(3)
195 .map(|f| f.summary.as_str())
196 .collect();
197 parts.push(format!("Recent: {}", recent.join("; ")));
198 }
199 if parts.is_empty() {
200 String::new()
201 } else {
202 format!("\n\n--- SESSION ---\n{}\n---", parts.join("\n"))
203 }
204 }
205 None => String::new(),
206 };
207
208 let cfg = crate::core::config::Config::load();
209 let shadow_preamble = if cfg.shadow_mode {
210 "SHADOW MODE ACTIVE: ALL reads/searches/shell MUST use ctx_* tools. Native equivalents are intercepted.\n\n"
211 } else {
212 ""
213 };
214
215 let instr = format!("\
216{shadow_preamble}\
217ALWAYS use lean-ctx MCP tools instead of native equivalents.
218
219Tool mapping (MANDATORY):
220• Read/cat/head/tail -> ctx_read(path, mode)
221• Shell/bash -> ctx_shell(command)
222• Grep/rg -> ctx_search(pattern, path)
223• ls/find -> ctx_tree(path, depth)
224• Edit/StrReplace -> native (lean-ctx=READ only). If Edit needs Read and Read is unavailable, use ctx_edit.
225• Write, Delete, Glob -> normal. NEVER loop on Edit failures — use ctx_edit.
226
227ctx_read modes: auto(default)|full|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M
228Omit mode → auto-selects optimal. full only right before editing. Re-reads ~13 tok. File refs F1,F2.. persist.
229
230Workflow: Orient(ctx_overview) → Locate(ctx_search) → Read(ctx_read) → Edit(ctx_edit/native) → Verify(ctx_read diff + ctx_shell test) → Record(ctx_knowledge).
231
232CEP: 1.ACT FIRST 2.DELTA ONLY 3.STRUCTURED(+/-/~) 4.ONE LINE 5.QUALITY
233{shell_hint}\
234Prefer: ctx_read>Read | ctx_shell>Shell | ctx_search>Grep | ctx_tree>ls
235Edit: native Edit/StrReplace preferred, ctx_edit if Edit unavailable.
236Never echo tool output. Never narrate. Show only changed code.
237Full instructions at {config_dir}/CLAUDE.md (imports rules/lean-ctx.md){session_block}");
238
239 instr
240}
241
242fn build_full_instructions(crp_mode: CrpMode, client_name: &str) -> String {
243 let cfg = crate::core::config::Config::load();
244 let minimal = cfg.minimal_overhead_effective_for_client(client_name);
245
246 let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
247 let loaded_session = if minimal {
248 None
249 } else {
250 crate::core::session::SessionState::load_latest()
251 };
252
253 let (session_block, litm_end_block) = match loaded_session {
254 Some(ref session) => {
255 rotate_wakeup_manifest(session, profile.name);
259 let share = crate::core::litm_calibration::begin_share(profile.name);
260 let positioned = crate::core::litm::position_optimize_with_share(session, share);
261 let begin = format!(
262 "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
263 profile.name, positioned.begin_block
264 );
265 let end = if positioned.end_block.is_empty() {
266 String::new()
267 } else {
268 format!(
269 "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
270 positioned.end_block
271 )
272 };
273 (begin, end)
274 }
275 None => (String::new(), String::new()),
276 };
277
278 let project_root_for_blocks = if minimal {
279 None
280 } else {
281 loaded_session
282 .as_ref()
283 .and_then(|s| s.project_root.clone())
284 .or_else(|| {
285 std::env::current_dir()
286 .ok()
287 .map(|p| p.to_string_lossy().to_string())
288 })
289 };
290
291 let knowledge_block = match &project_root_for_blocks {
292 Some(root) => {
293 let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
294 match knowledge {
295 Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
296 let aaak = k.format_aaak();
297 if aaak.is_empty() {
298 String::new()
299 } else {
300 format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
301 }
302 }
303 _ => String::new(),
304 }
305 }
306 None => String::new(),
307 };
308
309 let gotcha_block = match &project_root_for_blocks {
310 Some(root) => {
311 let store = crate::core::gotcha_tracker::GotchaStore::load(root);
312 let files: Vec<String> = loaded_session
313 .as_ref()
314 .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
315 .unwrap_or_default();
316 let block = store.format_injection_block(&files);
317 if block.is_empty() {
318 String::new()
319 } else {
320 format!("\n{block}\n")
321 }
322 }
323 None => String::new(),
324 };
325
326 let shell_hint = build_shell_hint();
327
328 use crate::core::rules_canonical as rc;
329 let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
330 let read_modes = rc::ctx_read_modes_block();
331 let auto_block = rc::automation_block();
332 let cep = rc::cep_block();
333 let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
334
335 let shadow_preamble = if cfg.shadow_mode {
336 "SHADOW MODE ACTIVE: ALL file reads, searches, and shell commands MUST go through ctx_* tools. \
337 Native Read/Grep/Shell are intercepted and redirected — using ctx_* directly is faster and more reliable.\n\n"
338 } else {
339 ""
340 };
341
342 let mut base = format!(
343 "\
344{shadow_preamble}\
345CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
346\n\
347{tool_bullets}\n\
348{shell_hint}\
349\n\
350{read_modes}\n\
351\n\
352{auto_block}\n\
353\n\
354{cep}\n\
355\n\
356{decoder_block}\n\
357\n\
358{session_block}\
359{knowledge_block}\
360{gotcha_block}\
361\n\
362{origin}\n\
363\n\
364{litm_pref}\
365{litm_end_block}",
366 decoder_block =
367 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
368 origin = crate::core::integrity::origin_line(),
369 litm_end_block = &litm_end_block
370 );
371
372 if should_use_unified(client_name) {
373 base.push_str("\n\n");
374 base.push_str(rc::unified_tool_mode_block());
375 base.push('\n');
376 }
377
378 let intelligence_block = build_intelligence_block();
379 let terse_block = if client_loads_compression_from_file(client_name) {
383 String::new()
384 } else {
385 build_terse_agent_block_for_client(&crp_mode, client_name)
386 };
387
388 let guidance_suffix = match crp_mode_suffix(&crp_mode) {
394 "" => format!("{terse_block}{intelligence_block}"),
395 crp => format!("{crp}\n\n{terse_block}{intelligence_block}"),
396 };
397
398 assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
399}
400
401fn crp_mode_suffix(crp_mode: &CrpMode) -> &'static str {
404 match crp_mode {
405 CrpMode::Off => "",
406 CrpMode::Compact => {
407 "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
408 diff lines (+/-) only; <=200 tok; trust tool outputs."
409 }
410 CrpMode::Tdd => {
411 "CRP MODE: tdd — max density; Fn refs + diff lines only \
412 (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
413 }
414 }
415}
416
417fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
422 use crate::core::tokens::count_tokens;
423 let suffix = suffix.trim_end_matches('\n');
424 if suffix.is_empty() {
425 let full = base.to_string();
426 return if count_tokens(&full) > cap_tokens {
427 truncate_to_token_cap(&full, cap_tokens)
428 } else {
429 full
430 };
431 }
432
433 let full = format!("{base}\n\n{suffix}");
434 if count_tokens(&full) <= cap_tokens {
435 return full;
436 }
437
438 let suffix_tokens = count_tokens(suffix);
439 let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
442 return truncate_to_token_cap(&full, cap_tokens);
443 };
444 let trimmed_base = truncate_to_token_cap(base, base_budget);
445 format!("{trimmed_base}\n\n{suffix}")
446}
447
448fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
449 use crate::core::tokens::count_tokens;
450 if count_tokens(s) <= cap_tokens {
451 return s.to_string();
452 }
453 let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
460 let (mut lo, mut hi) = (0usize, cuts.len());
461 let mut best: Option<usize> = None;
462 while lo < hi {
463 let mid = lo + (hi - lo) / 2;
464 let end = cuts[mid];
465 if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
466 best = Some(end);
467 lo = mid + 1;
468 } else {
469 hi = mid;
470 }
471 }
472 if let Some(end) = best {
473 return s[..end].to_string();
474 }
475 let byte_approx = cap_tokens * 4;
477 let safe = s.floor_char_boundary(byte_approx.min(s.len()));
478 s[..safe].to_string()
479}
480
481fn build_full_instructions_for_test(crp_mode: CrpMode, client_name: &str) -> String {
482 use crate::core::rules_canonical as rc;
483 let shell_hint = build_shell_hint();
484 let session_block = String::new();
485 let knowledge_block = String::new();
486 let gotcha_block = String::new();
487 let litm_end_block = String::new();
488
489 let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
490 let read_modes = rc::ctx_read_modes_block();
491 let auto_block = rc::automation_block();
492 let cep = rc::cep_block();
493 let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
494
495 let mut base = format!(
496 "\
497CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
498\n\
499{tool_bullets}\n\
500{shell_hint}\
501\n\
502{read_modes}\n\
503\n\
504{auto_block}\n\
505\n\
506{cep}\n\
507\n\
508{decoder_block}\n\
509\n\
510{session_block}\
511{knowledge_block}\
512{gotcha_block}\
513\n\
514{origin}\n\
515\n\
516{litm_pref}\
517{litm_end_block}",
518 decoder_block =
519 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
520 origin = crate::core::integrity::origin_line(),
521 litm_end_block = &litm_end_block
522 );
523
524 if should_use_unified(client_name) {
525 base.push_str("\n\n");
526 base.push_str(rc::unified_tool_mode_block());
527 base.push('\n');
528 }
529
530 let intelligence_block = build_intelligence_block();
531 let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
532
533 match crp_mode_suffix(&crp_mode) {
534 "" => format!("{base}\n\n{terse_block}{intelligence_block}"),
535 crp => format!("{base}\n\n{crp}\n\n{terse_block}{intelligence_block}"),
536 }
537}
538
539fn build_full_instructions_for_compiler(
540 crp_mode: CrpMode,
541 client_name: &str,
542 unified_tool_mode: bool,
543) -> String {
544 let shell_hint = build_shell_hint();
545 let session_block = String::new();
546 let knowledge_block = String::new();
547 let gotcha_block = String::new();
548 let litm_end_block = String::new();
549
550 use crate::core::rules_canonical as rc;
551 let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
552 let read_modes = rc::ctx_read_modes_block();
553 let auto_blk = rc::automation_block();
554 let cep = rc::cep_block();
555 let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
556
557 let mut base = format!(
558 "\
559CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
560\n\
561{tool_bullets}\n\
562{shell_hint}\
563\n\
564{read_modes}\n\
565\n\
566{auto_blk}\n\
567\n\
568{cep}\n\
569\n\
570{decoder_block}\n\
571\n\
572{session_block}\
573{knowledge_block}\
574{gotcha_block}\
575\n\
576{origin}\n\
577\n\
578{litm_pref}\
579{litm_end_block}",
580 decoder_block =
581 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
582 origin = crate::core::integrity::origin_line(),
583 litm_end_block = &litm_end_block
584 );
585
586 if unified_tool_mode {
587 base.push_str("\n\n");
588 base.push_str(rc::unified_tool_mode_block());
589 base.push('\n');
590 }
591
592 let _ = client_name; let intelligence_block = build_intelligence_block();
594
595 match crp_mode_suffix(&crp_mode) {
596 "" => format!("{base}\n\n{intelligence_block}"),
597 crp => format!("{base}\n\n{crp}\n\n{intelligence_block}"),
598 }
599}
600
601pub fn claude_code_instructions() -> String {
602 build_claude_code_instructions()
603}
604
605fn client_loads_compression_from_file(client_name: &str) -> bool {
610 crate::core::home::resolve_home_dir().is_some_and(|home| {
611 crate::core::rules_channel::client_autoloads_compression(client_name, &home)
612 })
613}
614
615fn build_terse_agent_block_for_client(_crp_mode: &CrpMode, client_name: &str) -> String {
616 use crate::core::config::{CompressionLevel, Config};
617 let cfg = Config::load();
618 let compression = CompressionLevel::effective(&cfg);
619 if compression.is_active() {
620 let persona = crate::core::persona::Persona::resolve(&cfg);
621 return crate::core::terse::agent_prompts::build_prompt_block_for_persona(
622 &compression,
623 client_name,
624 &persona,
625 );
626 }
627 String::new()
628}
629
630fn build_intelligence_block() -> String {
631 "OUTPUT: never echo tool output, no narration comments, show only changed code.".to_string()
632}
633
634fn build_shell_hint() -> String {
635 if !cfg!(windows) {
636 return String::new();
637 }
638 let name = crate::shell::shell_name();
641 let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
642 if is_posix {
643 format!("\nSHELL: {name} (POSIX) — POSIX commands only, no PowerShell cmdlets.\n")
644 } else if name.contains("powershell") || name.contains("pwsh") {
645 format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
646 } else {
647 format!("\nSHELL: {name}.\n")
648 }
649}
650
651fn should_use_unified(client_name: &str) -> bool {
652 if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
653 return false;
654 }
655 if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
656 return true;
657 }
658 let _ = client_name;
659 false
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use crate::core::tokens::count_tokens;
666
667 #[test]
668 fn guidance_suffix_survives_oversized_base() {
669 let base = "SESSION LINE\n".repeat(4000);
671 let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
672 let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
673
674 assert!(
675 out.contains("OUTPUT STYLE: expert-terse"),
676 "protected guidance suffix must survive truncation"
677 );
678 assert!(
679 count_tokens(&out) <= INSTRUCTION_CAP_TOKENS,
680 "assembled output must respect the token cap"
681 );
682 assert!(
683 out.len() < base.len(),
684 "oversized base must have been truncated"
685 );
686 }
687
688 #[test]
689 fn empty_client_never_dedups_compression() {
690 assert!(!client_loads_compression_from_file(""));
695 assert!(!client_loads_compression_from_file("totally-unknown-agent"));
696 }
697
698 #[test]
699 fn under_cap_keeps_everything() {
700 let base = "tool mapping block";
701 let suffix = "OUTPUT STYLE: dense";
702 let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
703 assert!(out.contains(base));
704 assert!(out.contains(suffix));
705 }
706
707 #[test]
708 fn empty_suffix_caps_base_only() {
709 let base = "x\n".repeat(4000);
710 let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
711 assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
712 }
713
714 #[cfg(windows)]
715 #[test]
716 fn shell_hint_stays_within_its_budget() {
717 let hint = build_shell_hint();
720 let tokens = count_tokens(&hint);
721 assert!(
722 tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
723 "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
724 );
725 }
726
727 #[test]
728 fn minimal_overhead_instructions_stay_within_budget() {
729 const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
734 STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
735 let _iso = crate::core::data_dir::isolated_data_dir();
736 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
737 let out = build_instructions(CrpMode::Compact);
738 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
739 let tokens = count_tokens(&out);
740 assert!(
741 tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
742 "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
743 );
744 }
745
746 #[test]
747 fn static_skeleton_stays_within_budget() {
748 let _iso = crate::core::data_dir::isolated_data_dir();
754 for (mode, base_budget) in [
755 (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
756 (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
757 (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
758 ] {
759 let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
760 let out = build_instructions_for_test(mode);
761 let tokens = count_tokens(&out);
762 assert!(
763 tokens <= budget,
764 "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
765 );
766 }
767 }
768}