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,
}
impl Interface {
pub fn is_telegram(&self) -> bool {
self.r#type == "telegram"
}
pub fn manager(&self) -> Option<String> {
self.config_str("manager")
}
pub fn bot_token_env(&self) -> Option<String> {
self.config_str("bot_token_env")
}
pub fn authorized_chat_ids_env(&self) -> Option<String> {
self.config_str("authorized_chat_ids_env")
}
fn config_str(&self, key: &str) -> Option<String> {
match &self.config {
serde_yaml::Value::Mapping(m) => m
.get(serde_yaml::Value::String(key.into()))
.and_then(|v| v.as_str())
.map(str::to_owned),
_ => None,
}
}
}
#[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,
#[serde(default = "default_drain_timeout_secs")]
pub drain_timeout_secs: u64,
}
impl Default for SupervisorCfg {
fn default() -> Self {
Self {
r#type: default_supervisor_type(),
tmux_prefix: default_tmux_prefix(),
drain_timeout_secs: default_drain_timeout_secs(),
}
}
}
fn default_supervisor_type() -> String {
"tmux".into()
}
fn default_drain_timeout_secs() -> u64 {
10
}
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 = "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>>,
#[serde(default)]
pub effort: Option<EffortLevel>,
#[serde(default)]
pub interfaces: Option<AgentInterfaces>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentInterfaces {
#[serde(default)]
pub telegram: Option<TelegramConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelegramConfig {
pub bot_token_env: String,
pub chat_ids_env: String,
}
impl Agent {
pub fn telegram(&self) -> Option<&TelegramConfig> {
self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EffortLevel {
Low,
Medium,
High,
Xhigh,
Max,
}
impl EffortLevel {
pub fn as_str(self) -> &'static str {
match self {
EffortLevel::Low => "low",
EffortLevel::Medium => "medium",
EffortLevel::High => "high",
EffortLevel::Xhigh => "xhigh",
EffortLevel::Max => "max",
}
}
}
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 discover(start: &Path) -> anyhow::Result<PathBuf> {
let start = start
.canonicalize()
.map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
let mut cur: Option<&Path> = Some(&start);
while let Some(dir) = cur {
let candidate = dir.join(".team").join("team-compose.yaml");
if candidate.is_file() {
return Ok(dir.join(".team"));
}
cur = dir.parent();
}
Err(anyhow::anyhow!(
"no `.team/team-compose.yaml` found in {} or any parent",
start.display()
))
}
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.interfaces.is_none());
assert!(a.telegram().is_none());
assert!(a.effort.is_none());
}
#[test]
fn agent_telegram_block_parses_under_interfaces() {
let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
let a: Agent = serde_yaml::from_str(yaml).unwrap();
let tg = a.telegram().expect("telegram parsed");
assert_eq!(tg.bot_token_env, "T");
assert_eq!(tg.chat_ids_env, "C");
}
#[test]
fn effort_parses_all_five_levels() {
for (yaml, expected) in [
("effort: low\n", EffortLevel::Low),
("effort: medium\n", EffortLevel::Medium),
("effort: high\n", EffortLevel::High),
("effort: xhigh\n", EffortLevel::Xhigh),
("effort: max\n", EffortLevel::Max),
] {
let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
}
}
#[test]
fn effort_unknown_value_is_rejected() {
let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
.expect_err("typo'd effort value must fail to parse");
let msg = err.to_string();
assert!(
msg.contains("low") && msg.contains("max"),
"error should enumerate valid variants; got: {msg}"
);
}
#[test]
fn effort_renders_to_lowercase_string() {
assert_eq!(EffortLevel::Low.as_str(), "low");
assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
assert_eq!(EffortLevel::Max.as_str(), "max");
}
#[test]
fn discover_prefers_dot_team() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
std::fs::create_dir_all(repo.join(".team")).unwrap();
std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
let sub = repo.join("src/deep/nested");
std::fs::create_dir_all(&sub).unwrap();
let found = Compose::discover(&sub).unwrap();
assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
}
#[test]
fn discover_no_longer_falls_back_to_flat_layout() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
let err = Compose::discover(tmp.path()).unwrap_err();
assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
}
#[test]
fn discover_returns_first_dot_team_walking_up() {
let tmp = tempfile::tempdir().unwrap();
let outer = tmp.path();
let inner = outer.join("packages/inner");
std::fs::create_dir_all(outer.join(".team")).unwrap();
std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
std::fs::create_dir_all(inner.join(".team")).unwrap();
std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
let from_inner = inner.join("src/deep");
std::fs::create_dir_all(&from_inner).unwrap();
let found = Compose::discover(&from_inner).unwrap();
assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
}
#[test]
fn discover_errors_when_nothing_found() {
let tmp = tempfile::tempdir().unwrap();
let err = Compose::discover(tmp.path()).unwrap_err();
assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
}
}