2026-04-23 11:52:09 +00:00
|
|
|
//! Project-local agent prompt layer.
|
|
|
|
|
//!
|
2026-05-13 12:14:54 +00:00
|
|
|
//! 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`
|
|
|
|
|
//!
|
2026-05-15 07:48:18 +01:00
|
|
|
//! `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.
|
2026-04-23 11:52:09 +00:00
|
|
|
//!
|
|
|
|
|
//! Behaviour contract:
|
2026-05-13 12:14:54 +00:00
|
|
|
//! - 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.
|
2026-04-23 11:52:09 +00:00
|
|
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
2026-05-13 12:14:54 +00:00
|
|
|
/// 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.
|
2026-04-23 11:52:09 +00:00
|
|
|
///
|
2026-05-13 12:14:54 +00:00
|
|
|
/// 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.
|
2026-04-23 11:52:09 +00:00
|
|
|
pub fn read_project_local_prompt(project_root: &Path) -> Option<String> {
|
2026-05-13 12:14:54 +00:00
|
|
|
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()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 07:48:18 +01:00
|
|
|
if sections.is_empty() {
|
2026-04-23 11:52:09 +00:00
|
|
|
return None;
|
|
|
|
|
}
|
2026-05-13 12:14:54 +00:00
|
|
|
|
2026-05-15 07:48:18 +01:00
|
|
|
let included_files: Vec<&str> = sections.iter().map(|(name, _)| *name).collect();
|
2026-05-13 12:14:54 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 11:52:09 +00:00
|
|
|
crate::slog!(
|
2026-05-13 12:14:54 +00:00
|
|
|
"[agents] orientation bundle: {} bytes, files: [{}]",
|
|
|
|
|
bundle.len(),
|
|
|
|
|
included_files.join(", ")
|
2026-04-23 11:52:09 +00:00
|
|
|
);
|
2026-05-13 12:14:54 +00:00
|
|
|
|
|
|
|
|
Some(bundle)
|
2026-04-23 11:52:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-05-13 12:14:54 +00:00
|
|
|
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) ──────────────────────────────────────────
|
|
|
|
|
|
2026-04-23 11:52:09 +00:00
|
|
|
#[test]
|
|
|
|
|
fn returns_none_when_file_absent() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = read_project_local_prompt(tmp.path());
|
2026-05-13 12:14:54 +00:00
|
|
|
assert!(result.is_none(), "all files missing must return None");
|
2026-04-23 11:52:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn returns_none_when_file_empty() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
2026-05-13 12:14:54 +00:00
|
|
|
write_file(tmp.path(), ".huskies/AGENT.md", "");
|
2026-04-23 11:52:09 +00:00
|
|
|
let result = read_project_local_prompt(tmp.path());
|
2026-05-13 12:14:54 +00:00
|
|
|
assert!(
|
|
|
|
|
result.is_none(),
|
|
|
|
|
"empty file must return None when others absent"
|
|
|
|
|
);
|
2026-04-23 11:52:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn returns_none_when_file_whitespace_only() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
2026-05-13 12:14:54 +00:00
|
|
|
write_file(tmp.path(), ".huskies/AGENT.md", " \n\n ");
|
2026-04-23 11:52:09 +00:00
|
|
|
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";
|
2026-05-13 12:14:54 +00:00
|
|
|
write_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
".huskies/AGENT.md",
|
|
|
|
|
&format!("# Hints\n{marker}\n"),
|
|
|
|
|
);
|
2026-04-23 11:52:09 +00:00
|
|
|
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";
|
2026-05-13 12:14:54 +00:00
|
|
|
write_file(tmp_with.path(), ".huskies/AGENT.md", marker);
|
2026-04-23 11:52:09 +00:00
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-13 12:14:54 +00:00
|
|
|
|
|
|
|
|
// ── 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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-13 14:08:58 +00:00
|
|
|
|
2026-05-15 07:48:18 +01:00
|
|
|
// ── 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.
|
2026-05-13 14:08:58 +00:00
|
|
|
|
|
|
|
|
#[test]
|
2026-05-15 07:48:18 +01:00
|
|
|
fn source_map_not_included_even_when_present() {
|
2026-05-13 14:08:58 +00:00
|
|
|
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!(
|
2026-05-15 07:48:18 +01:00
|
|
|
!result.contains("=== .huskies/source-map.json ==="),
|
|
|
|
|
"source-map must not appear as an orientation section: {result}"
|
2026-05-13 14:08:58 +00:00
|
|
|
);
|
|
|
|
|
assert!(
|
2026-05-15 07:48:18 +01:00
|
|
|
!result.contains("src/lib.rs"),
|
|
|
|
|
"source-map content must not be inlined: {result}"
|
2026-05-13 14:08:58 +00:00
|
|
|
);
|
|
|
|
|
}
|
2026-04-23 11:52:09 +00:00
|
|
|
}
|