//! 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` //! //! `STACK.md` and `.huskies/source-map.json` are intentionally excluded — they //! are large and change often; agents should grep on demand instead. Earlier //! versions of this bundle inlined the source map, which ballooned the orientation //! to ~96 KB and drowned out the workflow rules in AGENT.md; the file is still //! kept on disk for the merge-time `source-map-check` doc-coverage gate. //! //! 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", ]; /// 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())); } if sections.is_empty() { return None; } let 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); } crate::slog!( "[agents] orientation bundle: {} bytes, files: [{}]", bundle.len(), included_files.join(", ") ); Some(bundle) } #[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 must NOT be inlined into the bundle ────────────────── // The file is kept on disk for the merge-time source-map-check gate, but // inlining it into every agent spawn ballooned the orientation past 96 KB // and drowned out the workflow rules in AGENT.md. #[test] fn source_map_not_included_even_when_present() { 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 must not appear as an orientation section: {result}" ); assert!( !result.contains("src/lib.rs"), "source-map content must not be inlined: {result}" ); } }