huskies: merge 482_refactor_split_agent_definitions_from_project_toml_into_agents_toml
This commit is contained in:
+89
-9
@@ -224,20 +224,47 @@ impl Default for ProjectConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Config parsed from `.huskies/agents.toml` — agent definitions only.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentsConfig {
|
||||
#[serde(default)]
|
||||
agent: Vec<AgentConfig>,
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
/// Load from `.huskies/project.toml` relative to the given root.
|
||||
/// Falls back to sensible defaults if the file doesn't exist.
|
||||
/// Load from `.huskies/project.toml` relative to the given root,
|
||||
/// then overlay agents from `.huskies/agents.toml` if present.
|
||||
///
|
||||
/// Supports both the new `[[agent]]` array format and the legacy
|
||||
/// `[agent]` single-table format (with a deprecation warning).
|
||||
/// Loading order:
|
||||
/// 1. Project settings (watcher, default_qa, etc.) always come from `project.toml`.
|
||||
/// 2. Agent definitions come from `agents.toml` when that file exists.
|
||||
/// 3. Falls back to inline `[[agent]]` blocks in `project.toml` for backwards
|
||||
/// compatibility with projects that haven't migrated yet.
|
||||
/// 4. Falls back to a single default agent when neither file defines agents.
|
||||
pub fn load(project_root: &Path) -> Result<Self, String> {
|
||||
let config_path = project_root.join(".huskies/project.toml");
|
||||
if !config_path.exists() {
|
||||
return Ok(Self::default());
|
||||
let mut config = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
|
||||
Self::parse(&content)?
|
||||
} else {
|
||||
Self::default()
|
||||
};
|
||||
|
||||
// agents.toml takes priority over inline [[agent]] in project.toml.
|
||||
let agents_path = project_root.join(".huskies/agents.toml");
|
||||
if agents_path.exists() {
|
||||
let content = std::fs::read_to_string(&agents_path)
|
||||
.map_err(|e| format!("Read agents.toml: {e}"))?;
|
||||
let agents_cfg: AgentsConfig =
|
||||
toml::from_str(&content).map_err(|e| format!("Parse agents.toml: {e}"))?;
|
||||
if !agents_cfg.agent.is_empty() {
|
||||
validate_agents(&agents_cfg.agent)?;
|
||||
config.agent = agents_cfg.agent;
|
||||
}
|
||||
}
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
|
||||
Self::parse(&content)
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Parse config from a TOML string, supporting both new and legacy formats.
|
||||
@@ -675,6 +702,59 @@ model = "sonnet"
|
||||
assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_toml_overrides_project_toml_agents() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
// project.toml has inline agents
|
||||
fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "from-project-toml"
|
||||
model = "sonnet"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// agents.toml overrides with different agents
|
||||
fs::write(
|
||||
sk.join("agents.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "from-agents-toml"
|
||||
model = "opus"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.agent.len(), 1);
|
||||
assert_eq!(config.agent[0].name, "from-agents-toml");
|
||||
assert_eq!(config.agent[0].model, Some("opus".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_toml_absent_falls_back_to_project_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "inline-agent"
|
||||
model = "sonnet"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// No agents.toml — should use inline agents from project.toml
|
||||
|
||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.agent.len(), 1);
|
||||
assert_eq!(config.agent[0].name, "inline-agent");
|
||||
}
|
||||
|
||||
// ── WatcherConfig ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -103,7 +103,7 @@ const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
|
||||
}
|
||||
"#;
|
||||
|
||||
const DEFAULT_PROJECT_AGENTS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human".
|
||||
const DEFAULT_PROJECT_SETTINGS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human".
|
||||
# Per-story `qa` front matter overrides this setting.
|
||||
default_qa = "server"
|
||||
|
||||
@@ -114,8 +114,9 @@ default_qa = "server"
|
||||
# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York").
|
||||
# Timer HH:MM inputs are interpreted in this timezone.
|
||||
# timezone = "America/New_York"
|
||||
"#;
|
||||
|
||||
[[agent]]
|
||||
const DEFAULT_AGENTS_TOML: &str = r#"[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
@@ -248,13 +249,14 @@ pub fn detect_script_test(root: &Path) -> String {
|
||||
script
|
||||
}
|
||||
|
||||
/// Generate a complete `project.toml` for a new project at `root`.
|
||||
/// Generate a `project.toml` for a new project at `root`.
|
||||
///
|
||||
/// Detects the tech stack via [`detect_components_toml`] and prepends the
|
||||
/// resulting `[[component]]` entries before the default `[[agent]]` sections.
|
||||
/// Detects the tech stack via [`detect_components_toml`] and combines the
|
||||
/// resulting `[[component]]` entries with the default project settings.
|
||||
/// Agent definitions are written to `agents.toml` separately.
|
||||
fn generate_project_toml(root: &Path) -> String {
|
||||
let components = detect_components_toml(root);
|
||||
format!("{components}\n{DEFAULT_PROJECT_AGENTS_TOML}")
|
||||
format!("{components}\n{DEFAULT_PROJECT_SETTINGS_TOML}")
|
||||
}
|
||||
|
||||
fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> {
|
||||
@@ -413,6 +415,7 @@ pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
||||
write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?;
|
||||
let project_toml_content = generate_project_toml(root);
|
||||
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
|
||||
write_file_if_missing(&story_kit_root.join("agents.toml"), DEFAULT_AGENTS_TOML)?;
|
||||
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
|
||||
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
|
||||
let script_test_content = detect_script_test(root);
|
||||
@@ -556,16 +559,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_story_kit_project_toml_has_coder_qa_mergemaster() {
|
||||
fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
||||
assert!(content.contains("[[agent]]"));
|
||||
assert!(content.contains("stage = \"coder\""));
|
||||
assert!(content.contains("stage = \"qa\""));
|
||||
assert!(content.contains("stage = \"mergemaster\""));
|
||||
assert!(content.contains("model = \"sonnet\""));
|
||||
// Agent definitions go into agents.toml, not project.toml.
|
||||
let agents = fs::read_to_string(dir.path().join(".huskies/agents.toml")).unwrap();
|
||||
assert!(agents.contains("[[agent]]"));
|
||||
assert!(agents.contains("stage = \"coder\""));
|
||||
assert!(agents.contains("stage = \"qa\""));
|
||||
assert!(agents.contains("stage = \"mergemaster\""));
|
||||
assert!(agents.contains("model = \"sonnet\""));
|
||||
|
||||
// project.toml should NOT contain [[agent]] blocks.
|
||||
let project = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
||||
assert!(!project.contains("[[agent]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1146,19 +1154,24 @@ mod tests {
|
||||
// --- generate_project_toml ---
|
||||
|
||||
#[test]
|
||||
fn generate_project_toml_includes_both_components_and_agents() {
|
||||
fn generate_project_toml_includes_components_but_not_agents() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
|
||||
|
||||
let toml = generate_project_toml(dir.path());
|
||||
// Component section
|
||||
// Component section should be present
|
||||
assert!(toml.contains("[[component]]"));
|
||||
assert!(toml.contains("name = \"server\""));
|
||||
// Agent sections
|
||||
assert!(toml.contains("[[agent]]"));
|
||||
assert!(toml.contains("stage = \"coder\""));
|
||||
assert!(toml.contains("stage = \"qa\""));
|
||||
assert!(toml.contains("stage = \"mergemaster\""));
|
||||
// Agent sections must NOT be in project.toml — they go in agents.toml
|
||||
assert!(!toml.contains("[[agent]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_agents_toml_has_coder_qa_mergemaster() {
|
||||
assert!(DEFAULT_AGENTS_TOML.contains("[[agent]]"));
|
||||
assert!(DEFAULT_AGENTS_TOML.contains("stage = \"coder\""));
|
||||
assert!(DEFAULT_AGENTS_TOML.contains("stage = \"qa\""));
|
||||
assert!(DEFAULT_AGENTS_TOML.contains("stage = \"mergemaster\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+22
-13
@@ -90,8 +90,8 @@ pub enum WatcherEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// Return `true` if `path` is the root-level `.huskies/project.toml`, i.e.
|
||||
/// `{git_root}/.huskies/project.toml`.
|
||||
/// Return `true` if `path` is the root-level `.huskies/project.toml` or
|
||||
/// `.huskies/agents.toml`, i.e. `{git_root}/.huskies/{project,agents}.toml`.
|
||||
///
|
||||
/// Returns `false` for paths inside worktree directories (paths containing
|
||||
/// a `worktrees` component).
|
||||
@@ -100,8 +100,8 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
|
||||
if path.components().any(|c| c.as_os_str() == "worktrees") {
|
||||
return false;
|
||||
}
|
||||
let expected = git_root.join(".huskies").join("project.toml");
|
||||
path == expected
|
||||
let huskies = git_root.join(".huskies");
|
||||
path == huskies.join("project.toml") || path == huskies.join("agents.toml")
|
||||
}
|
||||
|
||||
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
||||
@@ -421,15 +421,17 @@ pub fn start_watcher(
|
||||
return;
|
||||
}
|
||||
|
||||
// Also watch .huskies/project.toml for hot-reload of agent config.
|
||||
let config_file = git_root.join(".huskies").join("project.toml");
|
||||
if config_file.exists()
|
||||
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
|
||||
{
|
||||
slog!(
|
||||
"[watcher] failed to watch config file {}: {e}",
|
||||
config_file.display()
|
||||
);
|
||||
// Also watch .huskies/project.toml and .huskies/agents.toml for hot-reload.
|
||||
let huskies = git_root.join(".huskies");
|
||||
for config_file in [huskies.join("project.toml"), huskies.join("agents.toml")] {
|
||||
if config_file.exists()
|
||||
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
|
||||
{
|
||||
slog!(
|
||||
"[watcher] failed to watch config file {}: {e}",
|
||||
config_file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
slog!("[watcher] watching {}", work_dir.display());
|
||||
@@ -1100,6 +1102,13 @@ mod tests {
|
||||
assert!(is_config_file(&config, &git_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_config_file_identifies_root_agents_toml() {
|
||||
let git_root = PathBuf::from("/proj");
|
||||
let agents = git_root.join(".huskies").join("agents.toml");
|
||||
assert!(is_config_file(&agents, &git_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_config_file_rejects_worktree_copies() {
|
||||
let git_root = PathBuf::from("/proj");
|
||||
|
||||
Reference in New Issue
Block a user