use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Global {
pub version: u32,
#[serde(default)]
pub broker: Broker,
#[serde(default)]
pub supervisor: SupervisorCfg,
#[serde(default)]
pub budget: Budget,
#[serde(default)]
pub hitl: Hitl,
#[serde(default)]
pub rate_limits: RateLimits,
#[serde(default)]
pub interfaces: Vec<Interface>,
#[serde(default)]
pub projects: Vec<ProjectRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interface {
pub r#type: String,
pub name: String,
#[serde(default)]
pub config: serde_yaml::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Budget {
#[serde(default)]
pub daily_usd_limit: Option<f64>,
#[serde(default)]
pub warn_threshold_pct: Option<u32>,
#[serde(default)]
pub message_ttl_hours: Option<u32>,
#[serde(default)]
pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RateLimits {
#[serde(default)]
pub default_on_hit: Vec<String>,
#[serde(default)]
pub hooks: Vec<RateLimitHook>,
#[serde(default = "default_fallback_wait")]
pub fallback_wait_seconds: u64,
}
fn default_fallback_wait() -> u64 {
30 * 60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitHook {
pub name: String,
pub action: String,
#[serde(default)]
pub to: Option<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub url_env: Option<String>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub command: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hitl {
#[serde(default = "default_sensitive_actions")]
pub globally_sensitive_actions: Vec<String>,
#[serde(default)]
pub auto_approve_windows: Vec<AutoApprove>,
}
impl Default for Hitl {
fn default() -> Self {
Self {
globally_sensitive_actions: default_sensitive_actions(),
auto_approve_windows: Vec::new(),
}
}
}
fn default_sensitive_actions() -> Vec<String> {
vec![
"publish".into(),
"release".into(),
"payment".into(),
"external_email".into(),
"external_api_post".into(),
"merge_to_main".into(),
"dns_change".into(),
"deploy".into(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoApprove {
pub action: String,
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub scope: Option<String>,
pub until: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectRef {
pub file: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Broker {
#[serde(default = "default_broker_type")]
pub r#type: String,
#[serde(default = "default_mailbox_path")]
pub path: PathBuf,
}
impl Default for Broker {
fn default() -> Self {
Self {
r#type: default_broker_type(),
path: default_mailbox_path(),
}
}
}
fn default_broker_type() -> String {
"sqlite".into()
}
fn default_mailbox_path() -> PathBuf {
PathBuf::from("state/mailbox.db")
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SupervisorCfg {
#[serde(default = "default_supervisor_type")]
pub r#type: String,
#[serde(default = "default_tmux_prefix")]
pub tmux_prefix: String,
}
impl Default for SupervisorCfg {
fn default() -> Self {
Self {
r#type: default_supervisor_type(),
tmux_prefix: default_tmux_prefix(),
}
}
}
fn default_supervisor_type() -> String {
"tmux".into()
}
fn default_tmux_prefix() -> String {
"a-".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub version: u32,
pub project: ProjectMeta,
#[serde(default)]
pub channels: Vec<Channel>,
#[serde(default)]
pub managers: BTreeMap<String, Agent>,
#[serde(default)]
pub workers: BTreeMap<String, Agent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMeta {
pub id: String,
pub name: String,
pub cwd: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
pub name: String,
#[serde(default)]
pub members: ChannelMembers,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChannelMembers {
All(String),
Explicit(Vec<String>),
}
impl Default for ChannelMembers {
fn default() -> Self {
Self::Explicit(Vec::new())
}
}
impl ChannelMembers {
pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
match self {
ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
#[serde(default = "default_runtime")]
pub runtime: String,
pub model: Option<String>,
pub role_prompt: Option<PathBuf>,
#[serde(default)]
pub permission_mode: Option<String>,
#[serde(default)]
pub telegram_inbox: bool,
#[serde(default)]
pub reports_to_user: bool,
#[serde(default = "default_autonomy")]
pub autonomy: String,
#[serde(default)]
pub can_dm: Vec<String>,
#[serde(default)]
pub can_broadcast: Vec<String>,
#[serde(default)]
pub reports_to: Option<String>,
#[serde(default)]
pub on_rate_limit: Option<Vec<String>>,
}
fn default_runtime() -> String {
"claude-code".into()
}
fn default_autonomy() -> String {
"low_risk_only".into()
}
#[derive(Debug, Clone)]
pub struct Compose {
pub root: PathBuf,
pub global: Global,
pub projects: Vec<Project>,
}
impl Compose {
pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
let root = root.as_ref().to_path_buf();
let global_path = root.join("team-compose.yaml");
let global: Global = serde_yaml::from_str(
&std::fs::read_to_string(&global_path)
.map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
let mut projects = Vec::with_capacity(global.projects.len());
for r in &global.projects {
let p = root.join(&r.file);
let parsed: Project = serde_yaml::from_str(
&std::fs::read_to_string(&p)
.map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
projects.push(parsed);
}
Ok(Self {
root,
global,
projects,
})
}
pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
self.projects.iter().flat_map(|p| {
p.managers
.iter()
.map(move |(id, a)| AgentHandle {
project: &p.project.id,
agent: id,
spec: a,
is_manager: true,
})
.chain(p.workers.iter().map(move |(id, a)| AgentHandle {
project: &p.project.id,
agent: id,
spec: a,
is_manager: false,
}))
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct AgentHandle<'a> {
pub project: &'a str,
pub agent: &'a str,
pub spec: &'a Agent,
pub is_manager: bool,
}
impl AgentHandle<'_> {
pub fn id(&self) -> String {
format!("{}:{}", self.project, self.agent)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_members_all_expands() {
let all = ChannelMembers::All("*".into());
assert!(all.includes("dev1", &["dev1", "dev2"]));
assert!(!all.includes("ghost", &["dev1", "dev2"]));
}
#[test]
fn channel_members_explicit_checks_list() {
let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
assert!(exp.includes("dev1", &[]));
assert!(!exp.includes("dev2", &[]));
}
#[test]
fn agent_defaults_are_stable() {
let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
assert_eq!(a.runtime, "claude-code");
assert_eq!(a.autonomy, "low_risk_only");
assert!(!a.telegram_inbox);
}
}