//! Project-local agent prompt layer. //! //! Reads a fixed set of orientation files from the project root and concatenates //! them into a single bundle that is appended to every coder's system prompt at //! spawn time. This eliminates the cold-start tax where agents spent early turns //! reading files that could have been preloaded. //! //! Files bundled (in order): //! - `CLAUDE.md` //! - `.huskies/README.md` //! - `.huskies/specs/00_CONTEXT.md` //! - `.huskies/AGENT.md` //! - `.huskies/source-map.json` (up to 200 KB; truncated with a log if larger) //! //! `STACK.md` is intentionally excluded — it is large and changes often; agents //! should grep it on demand. //! //! Behaviour contract: //! - Files that are missing or empty are skipped silently (no error, no section). //! - If all files are absent or empty, `None` is returned and agents spawn normally. //! - A single INFO-level log line is emitted with the total byte count and the //! list of files that were actually included. //! - Files are read fresh on every agent spawn — no caching. use std::path::Path; /// Files to bundle into the orientation prompt, in order. /// Paths are relative to the project root. const ORIENTATION_FILES: &[&str] = &[ "CLAUDE.md", ".huskies/README.md", ".huskies/specs/00_CONTEXT.md", ".huskies/AGENT.md", ]; /// Path to the source map (relative to project root), appended after AGENT.md. const SOURCE_MAP_REL: &str = ".huskies/source-map.json"; /// Maximum bytes of source-map content to embed in the prompt. const SOURCE_MAP_BYTE_CAP: usize = 200 * 1024; /// Attempt to load the project-local agent prompt by concatenating orientation /// files from the project root. /// /// Returns `Some(bundle)` when at least one file exists and is non-empty, with /// sections separated by `=== ===` delimiters. Returns `None` when all /// files are absent or empty. Never returns an error. pub fn read_project_local_prompt(project_root: &Path) -> Option { let mut sections: Vec<(&str, String)> = Vec::new(); for &rel_path in ORIENTATION_FILES { let abs_path = project_root.join(rel_path); let Ok(content) = std::fs::read_to_string(&abs_path) else { continue; }; let trimmed = content.trim(); if trimmed.is_empty() { continue; } sections.push((rel_path, trimmed.to_string())); } // Read source-map.json (after AGENT.md) with a byte cap. let source_map_content = read_source_map_section(project_root); if sections.is_empty() && source_map_content.is_none() { return None; } let mut included_files: Vec<&str> = sections.iter().map(|(name, _)| *name).collect(); let mut bundle = String::new(); for (i, (name, content)) in sections.iter().enumerate() { if i > 0 { bundle.push('\n'); } bundle.push_str(&format!("=== {name} ===\n")); bundle.push_str(content); } if let Some(sm) = source_map_content { if !bundle.is_empty() { bundle.push('\n'); } bundle.push_str(&format!("=== {SOURCE_MAP_REL} ===\n")); bundle.push_str(&sm); included_files.push(SOURCE_MAP_REL); } crate::slog!( "[agents] orientation bundle: {} bytes, files: [{}]", bundle.len(), included_files.join(", ") ); Some(bundle) } /// Read `.huskies/source-map.json` from `project_root`, applying a byte cap. /// /// Returns `None` when the file is absent, unreadable, or empty. /// When the content exceeds [`SOURCE_MAP_BYTE_CAP`], truncates at a char /// boundary and logs the truncation. #[allow(clippy::string_slice)] // cap is walked back to a char boundary before slicing fn read_source_map_section(project_root: &Path) -> Option { let path = project_root.join(SOURCE_MAP_REL); let Ok(content) = std::fs::read_to_string(&path) else { return None; }; let trimmed = content.trim(); if trimmed.is_empty() { return None; } if trimmed.len() > SOURCE_MAP_BYTE_CAP { let mut cap = SOURCE_MAP_BYTE_CAP; while cap > 0 && !trimmed.is_char_boundary(cap) { cap -= 1; } crate::slog!( "[agents] source-map.json truncated: {} bytes > {} byte cap; \ including first {} bytes", trimmed.len(), SOURCE_MAP_BYTE_CAP, cap ); Some(trimmed[..cap].to_string()) } else { Some(trimmed.to_string()) } } #[cfg(test)] mod tests { use super::*; fn write_file(base: &Path, rel: &str, content: &str) { let path = base.join(rel); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(path, content).unwrap(); } // ── Legacy tests (kept passing) ────────────────────────────────────────── #[test] fn returns_none_when_file_absent() { let tmp = tempfile::tempdir().unwrap(); let result = read_project_local_prompt(tmp.path()); assert!(result.is_none(), "all files missing must return None"); } #[test] fn returns_none_when_file_empty() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), ".huskies/AGENT.md", ""); let result = read_project_local_prompt(tmp.path()); assert!( result.is_none(), "empty file must return None when others absent" ); } #[test] fn returns_none_when_file_whitespace_only() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), ".huskies/AGENT.md", " \n\n "); let result = read_project_local_prompt(tmp.path()); assert!(result.is_none(), "whitespace-only file must return None"); } #[test] fn returns_content_when_file_non_empty() { let tmp = tempfile::tempdir().unwrap(); let marker = "DISTINCTIVE_MARKER_XYZ42"; write_file( tmp.path(), ".huskies/AGENT.md", &format!("# Hints\n{marker}\n"), ); let result = read_project_local_prompt(tmp.path()); assert!(result.is_some(), "non-empty file must return Some"); let content = result.unwrap(); assert!( content.contains(marker), "returned content must include the marker: {content}" ); } #[test] fn appended_to_prompt_integration() { let tmp_with = tempfile::tempdir().unwrap(); let marker = "INTEGRATION_MARKER_601"; write_file(tmp_with.path(), ".huskies/AGENT.md", marker); let base_prompt = "You are a coder agent.".to_string(); let local = read_project_local_prompt(tmp_with.path()); let effective = match local { Some(ref extra) => format!("{base_prompt}\n\n{extra}"), None => base_prompt.clone(), }; assert!( effective.contains(marker), "marker must appear in effective prompt when file present: {effective}" ); let tmp_without = tempfile::tempdir().unwrap(); let local2 = read_project_local_prompt(tmp_without.path()); assert!(local2.is_none(), "no marker when file absent"); let effective2 = match local2 { Some(ref extra) => format!("{base_prompt}\n\n{extra}"), None => base_prompt.clone(), }; assert!( !effective2.contains(marker), "marker must NOT appear in effective prompt when file absent: {effective2}" ); } // ── New tests for four-file bundle behaviour ────────────────────────────── #[test] fn all_four_files_present_concatenated() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), "CLAUDE.md", "claude content"); write_file(tmp.path(), ".huskies/README.md", "readme content"); write_file( tmp.path(), ".huskies/specs/00_CONTEXT.md", "context content", ); write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( result.contains("claude content"), "must include CLAUDE.md: {result}" ); assert!( result.contains("readme content"), "must include README.md: {result}" ); assert!( result.contains("context content"), "must include 00_CONTEXT.md: {result}" ); assert!( result.contains("agent content"), "must include AGENT.md: {result}" ); } #[test] fn some_files_missing_only_present_in_bundle() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), "CLAUDE.md", "claude only"); // README.md, 00_CONTEXT.md, AGENT.md absent let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( result.contains("claude only"), "must include CLAUDE.md: {result}" ); assert!( !result.contains("=== .huskies/README.md ==="), "absent README must not have a section delimiter: {result}" ); } #[test] fn all_files_missing_returns_none() { let tmp = tempfile::tempdir().unwrap(); let result = read_project_local_prompt(tmp.path()); assert!(result.is_none(), "all absent must return None"); } #[test] fn section_delimiters_appear_between_files() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), "CLAUDE.md", "first"); write_file(tmp.path(), ".huskies/AGENT.md", "last"); let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( result.contains("=== CLAUDE.md ==="), "must have CLAUDE.md delimiter: {result}" ); assert!( result.contains("=== .huskies/AGENT.md ==="), "must have AGENT.md delimiter: {result}" ); // Delimiter for CLAUDE.md must appear before its content let claude_delim = result.find("=== CLAUDE.md ===").unwrap(); let first_content = result.find("first").unwrap(); assert!( claude_delim < first_content, "delimiter must precede content" ); } #[test] fn stack_md_not_included() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), "CLAUDE.md", "hello"); write_file(tmp.path(), ".huskies/specs/tech/STACK.md", "stack content"); let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( !result.contains("stack content"), "STACK.md must not appear in bundle: {result}" ); } // ── source-map.json tests ──────────────────────────────────────────────── #[test] fn source_map_included_after_agent_md() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); write_file( tmp.path(), ".huskies/source-map.json", r#"{"src/lib.rs": {"symbols": ["foo"]}}"#, ); let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( result.contains("=== .huskies/source-map.json ==="), "source-map delimiter must be present: {result}" ); assert!( result.contains(r#""src/lib.rs""#), "source-map content must be present: {result}" ); // source-map section must appear after AGENT.md section let agent_pos = result.find("=== .huskies/AGENT.md ===").unwrap(); let sm_pos = result.find("=== .huskies/source-map.json ===").unwrap(); assert!( sm_pos > agent_pos, "source-map section must come after AGENT.md section" ); } #[test] fn source_map_missing_skipped_silently() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); // source-map.json intentionally absent let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( !result.contains("source-map.json"), "absent source-map must not create a section: {result}" ); } #[test] fn source_map_empty_skipped_silently() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); write_file(tmp.path(), ".huskies/source-map.json", ""); let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( !result.contains("source-map.json"), "empty source-map must not create a section: {result}" ); } #[test] fn source_map_only_returns_some() { let tmp = tempfile::tempdir().unwrap(); // Only source-map.json present; all orientation files absent. write_file( tmp.path(), ".huskies/source-map.json", r#"{"src/main.rs": {}}"#, ); let result = read_project_local_prompt(tmp.path()); assert!( result.is_some(), "source-map alone must produce Some bundle" ); assert!( result.unwrap().contains("=== .huskies/source-map.json ==="), "bundle must contain source-map section" ); } #[test] #[allow(clippy::string_slice)] // sm_start is derived from str::find — always a char boundary fn source_map_truncated_at_byte_cap() { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), ".huskies/AGENT.md", "agent"); // Build content larger than SOURCE_MAP_BYTE_CAP (200 KB). let big = "x".repeat(SOURCE_MAP_BYTE_CAP + 1024); write_file(tmp.path(), ".huskies/source-map.json", &big); let result = read_project_local_prompt(tmp.path()).unwrap(); assert!( result.contains("=== .huskies/source-map.json ==="), "truncated source-map must still produce a section: {result}" ); // The content length of just the source-map section must be <= SOURCE_MAP_BYTE_CAP. let sm_start = result.find("=== .huskies/source-map.json ===").unwrap() + "=== .huskies/source-map.json ===\n".len(); let sm_content = &result[sm_start..]; assert!( sm_content.len() <= SOURCE_MAP_BYTE_CAP, "source-map section content must be <= {} bytes, got {}", SOURCE_MAP_BYTE_CAP, sm_content.len() ); } }