huskies: merge 966

This commit is contained in:
dave
2026-05-13 12:14:54 +00:00
parent 2f1274ec7c
commit c89a5c2da6
2 changed files with 184 additions and 42 deletions
+180 -38
View File
@@ -1,66 +1,117 @@
//! Project-local agent prompt layer.
//!
//! Reads `.huskies/AGENT.md` from the project root and appends its content to
//! the baked-in agent prompt at spawn time. This lets projects record
//! non-obvious facts (directory conventions, known traps, etc.) that every
//! agent should know without modifying the shared agent configuration.
//! 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` is intentionally excluded — it is large and changes often; agents
//! should grep it on demand.
//!
//! Behaviour contract:
//! - If the file is missing or empty the caller receives `None`; agents spawn
//! normally with no warnings or errors.
//! - If the file exists and is non-empty, the content is returned and an
//! INFO-level log line is emitted with the file path and byte count.
//! - The file is read fresh on every agent spawn — no caching.
//! - 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;
/// Attempt to load the project-local agent prompt from `.huskies/AGENT.md`.
/// 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(content)` when the file exists and is non-empty, or `None`
/// when the file is absent or empty. Never returns an error; any I/O problem
/// is silently treated as "no local prompt".
/// Returns `Some(bundle)` when at least one file exists and is non-empty, with
/// sections separated by `=== <file> ===` delimiters. Returns `None` when all
/// files are absent or empty. Never returns an error.
pub fn read_project_local_prompt(project_root: &Path) -> Option<String> {
let path = project_root.join(".huskies/AGENT.md");
let content = std::fs::read_to_string(&path).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
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] project-local prompt loaded: {} ({} bytes)",
path.display(),
trimmed.len()
"[agents] orientation bundle: {} bytes, files: [{}]",
bundle.len(),
included_files.join(", ")
);
Some(trimmed.to_string())
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(), "missing file must return None");
assert!(result.is_none(), "all files missing must return None");
}
#[test]
fn returns_none_when_file_empty() {
let tmp = tempfile::tempdir().unwrap();
let huskies_dir = tmp.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
std::fs::write(huskies_dir.join("AGENT.md"), "").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");
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();
let huskies_dir = tmp.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
std::fs::write(huskies_dir.join("AGENT.md"), " \n\n ").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");
}
@@ -68,10 +119,12 @@ mod tests {
#[test]
fn returns_content_when_file_non_empty() {
let tmp = tempfile::tempdir().unwrap();
let huskies_dir = tmp.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
let marker = "DISTINCTIVE_MARKER_XYZ42";
std::fs::write(huskies_dir.join("AGENT.md"), format!("# Hints\n{marker}\n")).unwrap();
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();
@@ -83,13 +136,9 @@ mod tests {
#[test]
fn appended_to_prompt_integration() {
// Simulates the start.rs usage: marker appears in the constructed
// system prompt when the file is present, absent when it is not.
let tmp_with = tempfile::tempdir().unwrap();
let huskies_dir = tmp_with.path().join(".huskies");
std::fs::create_dir_all(&huskies_dir).unwrap();
let marker = "INTEGRATION_MARKER_601";
std::fs::write(huskies_dir.join("AGENT.md"), marker).unwrap();
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());
@@ -102,7 +151,6 @@ mod tests {
"marker must appear in effective prompt when file present: {effective}"
);
// Without the file
let tmp_without = tempfile::tempdir().unwrap();
let local2 = read_project_local_prompt(tmp_without.path());
assert!(local2.is_none(), "no marker when file absent");
@@ -115,4 +163,98 @@ mod tests {
"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}"
);
}
}