use serde::{Deserialize, Serialize};
pub mod constraints;
pub mod context;
pub mod default_skills;
pub mod gates;
pub mod goals;
pub mod graph;
pub mod guidelines;
pub mod info;
pub mod providers;
pub mod skills;
pub mod success;
pub mod triggers;
pub mod validation;
pub mod work;
pub use constraints::{ConstraintSet, Constraints};
pub use context::ContextPolicy;
pub use default_skills::{is_safe_repo_url, DefaultSkill, SkillOrigin};
pub use gates::StopGates;
pub use goals::Goal;
pub use graph::{Concurrency, GraphSpec, NodeSpec, Role, Tier};
pub use guidelines::{parse_chain, ExecutionGuidelines, Guideline, Phase};
pub use info::InfoItem;
pub use providers::{ProviderKind, ProviderRouting, ProviderSpec};
pub use skills::{AcquisitionSource, SkillPolicy};
pub use success::SuccessScenario;
pub use triggers::Trigger;
pub use validation::{CompareOp, Detector, Mode, Validation};
pub use work::WorkItem;
pub const OVERALL: &str = "overall";
pub(crate) fn yes() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoopConfig {
pub name: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub information: Vec<InfoItem>,
#[serde(default)]
pub pre_execution: Vec<WorkItem>,
pub goals: Vec<Goal>,
pub validations: Vec<Validation>,
#[serde(default)]
pub success: Vec<SuccessScenario>,
#[serde(default)]
pub stop_gates: StopGates,
#[serde(default)]
pub schedules: Vec<Trigger>,
#[serde(default)]
pub constraints: Constraints,
#[serde(default)]
pub execution_guidelines: ExecutionGuidelines,
#[serde(default)]
pub default_skills: Vec<DefaultSkill>,
#[serde(default)]
pub graph: GraphSpec,
#[serde(default)]
pub providers: ProviderRouting,
#[serde(default)]
pub skills: SkillPolicy,
#[serde(default)]
pub context: ContextPolicy,
}
fn default_version() -> String {
"0.1.0".into()
}
impl LoopConfig {
pub fn goal_names(&self) -> Vec<&str> {
self.goals.iter().map(|g| g.name.as_str()).collect()
}
pub fn blocking_validations_for(&self, target: &str) -> Vec<&Validation> {
self.validations
.iter()
.filter(|v| v.target == target && v.blocking)
.collect()
}
pub fn provider(&self, id: &str) -> Option<&ProviderSpec> {
self.providers.providers.iter().find(|p| p.id == id)
}
pub fn cascade_for(&self, tier: Tier) -> Vec<&ProviderSpec> {
let key = match tier {
Tier::Cheap => "cheap",
Tier::Standard => "standard",
Tier::Strong => "strong",
};
if let Some(ids) = self.providers.cascade.get(key) {
return ids.iter().filter_map(|id| self.provider(id)).collect();
}
self.providers
.providers
.iter()
.filter(|p| p.tiers.is_empty() || p.tiers.contains(&tier))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL: &str = r#"
name: t
goals:
- name: g1
description: a goal with a long enough description
validations:
- target: g1
name: v1
mode: objective
statement: it works
detector: { type: file_exists, path: out.txt }
"#;
fn parse(text: &str) -> Result<LoopConfig, serde_yaml::Error> {
serde_yaml::from_str::<LoopConfig>(text)
}
#[test]
fn the_minimal_config_parses() {
let cfg = parse(MINIMAL).expect("minimal config parses");
assert_eq!(cfg.name, "t");
assert_eq!(cfg.version, "0.1.0");
assert_eq!(cfg.stop_gates.max_iterations, 10);
}
#[test]
fn a_misspelled_top_level_section_is_refused_not_ignored() {
let typo = MINIMAL.to_string() + "stop_gate:\n max_iterations: 2\n";
let err = parse(&typo).expect_err("a misspelled section must be refused");
assert!(
err.to_string().contains("stop_gate"),
"the error must name the offending key, got: {err}"
);
}
#[test]
fn a_misspelled_nested_field_is_refused_not_ignored() {
let typo = MINIMAL.to_string() + "stop_gates:\n max_iteration: 2\n";
let err = parse(&typo).expect_err("a misspelled field must be refused");
assert!(err.to_string().contains("max_iteration"), "got: {err}");
}
#[test]
fn provider_kind_aliases_still_resolve() {
for (written, expected) in [
("claude", ProviderKind::ClaudeCode),
("claude-code", ProviderKind::ClaudeCode),
("openai", ProviderKind::OpenAi),
("open_ai", ProviderKind::OpenAi),
("OpenAI", ProviderKind::OpenAi),
("grok", ProviderKind::GrokCli),
("custom", ProviderKind::Byok),
("MCP", ProviderKind::Mcp),
] {
let text = format!(
"{MINIMAL}providers:\n providers:\n - id: p\n kind: {written}\n command: echo\n"
);
let cfg = parse(&text).unwrap_or_else(|e| panic!("`{written}` should parse: {e}"));
assert_eq!(cfg.providers.providers[0].kind, expected, "for `{written}`");
}
}
}