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 = build_terse_agent_block_for_client(&crp_mode, client_name);
380
381 let guidance_suffix = match crp_mode_suffix(&crp_mode) {
387 "" => format!("{terse_block}{intelligence_block}"),
388 crp => format!("{crp}\n\n{terse_block}{intelligence_block}"),
389 };
390
391 assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
392}
393
394fn crp_mode_suffix(crp_mode: &CrpMode) -> &'static str {
397 match crp_mode {
398 CrpMode::Off => "",
399 CrpMode::Compact => {
400 "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
401 diff lines (+/-) only; <=200 tok; trust tool outputs."
402 }
403 CrpMode::Tdd => {
404 "CRP MODE: tdd — max density; Fn refs + diff lines only \
405 (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
406 }
407 }
408}
409
410fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
415 use crate::core::tokens::count_tokens;
416 let suffix = suffix.trim_end_matches('\n');
417 if suffix.is_empty() {
418 let full = base.to_string();
419 return if count_tokens(&full) > cap_tokens {
420 truncate_to_token_cap(&full, cap_tokens)
421 } else {
422 full
423 };
424 }
425
426 let full = format!("{base}\n\n{suffix}");
427 if count_tokens(&full) <= cap_tokens {
428 return full;
429 }
430
431 let suffix_tokens = count_tokens(suffix);
432 let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
435 return truncate_to_token_cap(&full, cap_tokens);
436 };
437 let trimmed_base = truncate_to_token_cap(base, base_budget);
438 format!("{trimmed_base}\n\n{suffix}")
439}
440
441fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
442 use crate::core::tokens::count_tokens;
443 if count_tokens(s) <= cap_tokens {
444 return s.to_string();
445 }
446 let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
453 let (mut lo, mut hi) = (0usize, cuts.len());
454 let mut best: Option<usize> = None;
455 while lo < hi {
456 let mid = lo + (hi - lo) / 2;
457 let end = cuts[mid];
458 if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
459 best = Some(end);
460 lo = mid + 1;
461 } else {
462 hi = mid;
463 }
464 }
465 if let Some(end) = best {
466 return s[..end].to_string();
467 }
468 let byte_approx = cap_tokens * 4;
470 let safe = s.floor_char_boundary(byte_approx.min(s.len()));
471 s[..safe].to_string()
472}
473
474fn build_full_instructions_for_test(crp_mode: CrpMode, client_name: &str) -> String {
475 use crate::core::rules_canonical as rc;
476 let shell_hint = build_shell_hint();
477 let session_block = String::new();
478 let knowledge_block = String::new();
479 let gotcha_block = String::new();
480 let litm_end_block = String::new();
481
482 let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
483 let read_modes = rc::ctx_read_modes_block();
484 let auto_block = rc::automation_block();
485 let cep = rc::cep_block();
486 let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
487
488 let mut base = format!(
489 "\
490CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
491\n\
492{tool_bullets}\n\
493{shell_hint}\
494\n\
495{read_modes}\n\
496\n\
497{auto_block}\n\
498\n\
499{cep}\n\
500\n\
501{decoder_block}\n\
502\n\
503{session_block}\
504{knowledge_block}\
505{gotcha_block}\
506\n\
507{origin}\n\
508\n\
509{litm_pref}\
510{litm_end_block}",
511 decoder_block =
512 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
513 origin = crate::core::integrity::origin_line(),
514 litm_end_block = &litm_end_block
515 );
516
517 if should_use_unified(client_name) {
518 base.push_str("\n\n");
519 base.push_str(rc::unified_tool_mode_block());
520 base.push('\n');
521 }
522
523 let intelligence_block = build_intelligence_block();
524 let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
525
526 match crp_mode_suffix(&crp_mode) {
527 "" => format!("{base}\n\n{terse_block}{intelligence_block}"),
528 crp => format!("{base}\n\n{crp}\n\n{terse_block}{intelligence_block}"),
529 }
530}
531
532fn build_full_instructions_for_compiler(
533 crp_mode: CrpMode,
534 client_name: &str,
535 unified_tool_mode: bool,
536) -> String {
537 let shell_hint = build_shell_hint();
538 let session_block = String::new();
539 let knowledge_block = String::new();
540 let gotcha_block = String::new();
541 let litm_end_block = String::new();
542
543 use crate::core::rules_canonical as rc;
544 let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
545 let read_modes = rc::ctx_read_modes_block();
546 let auto_blk = rc::automation_block();
547 let cep = rc::cep_block();
548 let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
549
550 let mut base = format!(
551 "\
552CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
553\n\
554{tool_bullets}\n\
555{shell_hint}\
556\n\
557{read_modes}\n\
558\n\
559{auto_blk}\n\
560\n\
561{cep}\n\
562\n\
563{decoder_block}\n\
564\n\
565{session_block}\
566{knowledge_block}\
567{gotcha_block}\
568\n\
569{origin}\n\
570\n\
571{litm_pref}\
572{litm_end_block}",
573 decoder_block =
574 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
575 origin = crate::core::integrity::origin_line(),
576 litm_end_block = &litm_end_block
577 );
578
579 if unified_tool_mode {
580 base.push_str("\n\n");
581 base.push_str(rc::unified_tool_mode_block());
582 base.push('\n');
583 }
584
585 let _ = client_name; let intelligence_block = build_intelligence_block();
587
588 match crp_mode_suffix(&crp_mode) {
589 "" => format!("{base}\n\n{intelligence_block}"),
590 crp => format!("{base}\n\n{crp}\n\n{intelligence_block}"),
591 }
592}
593
594pub fn claude_code_instructions() -> String {
595 build_claude_code_instructions()
596}
597
598fn build_terse_agent_block_for_client(_crp_mode: &CrpMode, client_name: &str) -> String {
599 use crate::core::config::{CompressionLevel, Config};
600 let cfg = Config::load();
601 let compression = CompressionLevel::effective(&cfg);
602 if compression.is_active() {
603 let persona = crate::core::persona::Persona::resolve(&cfg);
604 return crate::core::terse::agent_prompts::build_prompt_block_for_persona(
605 &compression,
606 client_name,
607 &persona,
608 );
609 }
610 String::new()
611}
612
613fn build_intelligence_block() -> String {
614 "OUTPUT: never echo tool output, no narration comments, show only changed code.".to_string()
615}
616
617fn build_shell_hint() -> String {
618 if !cfg!(windows) {
619 return String::new();
620 }
621 let name = crate::shell::shell_name();
624 let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
625 if is_posix {
626 format!("\nSHELL: {name} (POSIX) — POSIX commands only, no PowerShell cmdlets.\n")
627 } else if name.contains("powershell") || name.contains("pwsh") {
628 format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
629 } else {
630 format!("\nSHELL: {name}.\n")
631 }
632}
633
634fn should_use_unified(client_name: &str) -> bool {
635 if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
636 return false;
637 }
638 if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
639 return true;
640 }
641 let _ = client_name;
642 false
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::core::tokens::count_tokens;
649
650 #[test]
651 fn guidance_suffix_survives_oversized_base() {
652 let base = "SESSION LINE\n".repeat(4000);
654 let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
655 let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
656
657 assert!(
658 out.contains("OUTPUT STYLE: expert-terse"),
659 "protected guidance suffix must survive truncation"
660 );
661 assert!(
662 count_tokens(&out) <= INSTRUCTION_CAP_TOKENS,
663 "assembled output must respect the token cap"
664 );
665 assert!(
666 out.len() < base.len(),
667 "oversized base must have been truncated"
668 );
669 }
670
671 #[test]
672 fn under_cap_keeps_everything() {
673 let base = "tool mapping block";
674 let suffix = "OUTPUT STYLE: dense";
675 let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
676 assert!(out.contains(base));
677 assert!(out.contains(suffix));
678 }
679
680 #[test]
681 fn empty_suffix_caps_base_only() {
682 let base = "x\n".repeat(4000);
683 let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
684 assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
685 }
686
687 #[cfg(windows)]
688 #[test]
689 fn shell_hint_stays_within_its_budget() {
690 let hint = build_shell_hint();
693 let tokens = count_tokens(&hint);
694 assert!(
695 tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
696 "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
697 );
698 }
699
700 #[test]
701 fn minimal_overhead_instructions_stay_within_budget() {
702 const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
707 STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
708 let _iso = crate::core::data_dir::isolated_data_dir();
709 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
710 let out = build_instructions(CrpMode::Compact);
711 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
712 let tokens = count_tokens(&out);
713 assert!(
714 tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
715 "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
716 );
717 }
718
719 #[test]
720 fn static_skeleton_stays_within_budget() {
721 let _iso = crate::core::data_dir::isolated_data_dir();
727 for (mode, base_budget) in [
728 (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
729 (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
730 (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
731 ] {
732 let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
733 let out = build_instructions_for_test(mode);
734 let tokens = count_tokens(&out);
735 assert!(
736 tokens <= budget,
737 "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
738 );
739 }
740 }
741}