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) {
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) {
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) {
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 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 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 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 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
389fn 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
405fn 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 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 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 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; 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 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 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 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 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}