1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Global {
11 pub version: u32,
12
13 #[serde(default)]
14 pub broker: Broker,
15
16 #[serde(default)]
17 pub supervisor: SupervisorCfg,
18
19 #[serde(default)]
20 pub budget: Budget,
21
22 #[serde(default)]
23 pub hitl: Hitl,
24
25 #[serde(default)]
26 pub rate_limits: RateLimits,
27
28 #[serde(default)]
31 pub interfaces: Vec<Interface>,
32
33 #[serde(default)]
35 pub projects: Vec<ProjectRef>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Interface {
40 pub r#type: String,
42 pub name: String,
44 #[serde(default)]
46 pub config: serde_yaml::Value,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct Budget {
51 #[serde(default)]
52 pub daily_usd_limit: Option<f64>,
53 #[serde(default)]
54 pub warn_threshold_pct: Option<u32>,
55 #[serde(default)]
56 pub message_ttl_hours: Option<u32>,
57 #[serde(default)]
58 pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct RateLimits {
64 #[serde(default)]
66 pub default_on_hit: Vec<String>,
67
68 #[serde(default)]
70 pub hooks: Vec<RateLimitHook>,
71
72 #[serde(default = "default_fallback_wait")]
75 pub fallback_wait_seconds: u64,
76}
77
78fn default_fallback_wait() -> u64 {
79 30 * 60
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RateLimitHook {
96 pub name: String,
97 pub action: String,
98 #[serde(default)]
99 pub to: Option<String>,
100 #[serde(default)]
101 pub template: Option<String>,
102 #[serde(default)]
103 pub url: Option<String>,
104 #[serde(default)]
105 pub url_env: Option<String>,
106 #[serde(default)]
107 pub method: Option<String>,
108 #[serde(default)]
109 pub command: Vec<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct Hitl {
114 #[serde(default = "default_sensitive_actions")]
115 pub globally_sensitive_actions: Vec<String>,
116 #[serde(default)]
117 pub auto_approve_windows: Vec<AutoApprove>,
118}
119
120impl Default for Hitl {
121 fn default() -> Self {
122 Self {
123 globally_sensitive_actions: default_sensitive_actions(),
124 auto_approve_windows: Vec::new(),
125 }
126 }
127}
128
129fn default_sensitive_actions() -> Vec<String> {
130 vec![
131 "publish".into(),
132 "release".into(),
133 "payment".into(),
134 "external_email".into(),
135 "external_api_post".into(),
136 "merge_to_main".into(),
137 "dns_change".into(),
138 "deploy".into(),
139 ]
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct AutoApprove {
144 pub action: String,
145 #[serde(default)]
146 pub project: Option<String>,
147 #[serde(default)]
148 pub agent: Option<String>,
149 #[serde(default)]
150 pub scope: Option<String>,
151 pub until: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ProjectRef {
157 pub file: PathBuf,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct Broker {
162 #[serde(default = "default_broker_type")]
163 pub r#type: String,
164 #[serde(default = "default_mailbox_path")]
165 pub path: PathBuf,
166}
167
168impl Default for Broker {
169 fn default() -> Self {
170 Self {
171 r#type: default_broker_type(),
172 path: default_mailbox_path(),
173 }
174 }
175}
176
177fn default_broker_type() -> String {
178 "sqlite".into()
179}
180
181fn default_mailbox_path() -> PathBuf {
182 PathBuf::from("state/mailbox.db")
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct SupervisorCfg {
187 #[serde(default = "default_supervisor_type")]
188 pub r#type: String,
189 #[serde(default = "default_tmux_prefix")]
190 pub tmux_prefix: String,
191}
192
193impl Default for SupervisorCfg {
194 fn default() -> Self {
195 Self {
196 r#type: default_supervisor_type(),
197 tmux_prefix: default_tmux_prefix(),
198 }
199 }
200}
201
202fn default_supervisor_type() -> String {
203 "tmux".into()
204}
205
206fn default_tmux_prefix() -> String {
207 "a-".into()
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Project {
213 pub version: u32,
214 pub project: ProjectMeta,
215
216 #[serde(default)]
217 pub channels: Vec<Channel>,
218
219 #[serde(default)]
220 pub managers: BTreeMap<String, Agent>,
221
222 #[serde(default)]
223 pub workers: BTreeMap<String, Agent>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ProjectMeta {
228 pub id: String,
229 pub name: String,
230 pub cwd: PathBuf,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct Channel {
235 pub name: String,
236 #[serde(default)]
238 pub members: ChannelMembers,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(untagged)]
243pub enum ChannelMembers {
244 All(String),
245 Explicit(Vec<String>),
246}
247
248impl Default for ChannelMembers {
249 fn default() -> Self {
250 Self::Explicit(Vec::new())
251 }
252}
253
254impl ChannelMembers {
255 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
256 match self {
257 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
258 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
259 _ => false,
260 }
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct Agent {
266 #[serde(default = "default_runtime")]
267 pub runtime: String,
268 pub model: Option<String>,
269 pub role_prompt: Option<PathBuf>,
270 #[serde(default)]
271 pub permission_mode: Option<String>,
272 #[serde(default)]
273 pub telegram_inbox: bool,
274 #[serde(default)]
275 pub reports_to_user: bool,
276 #[serde(default = "default_autonomy")]
277 pub autonomy: String,
278 #[serde(default)]
279 pub can_dm: Vec<String>,
280 #[serde(default)]
281 pub can_broadcast: Vec<String>,
282 #[serde(default)]
283 pub reports_to: Option<String>,
284
285 #[serde(default)]
287 pub on_rate_limit: Option<Vec<String>>,
288}
289
290fn default_runtime() -> String {
291 "claude-code".into()
292}
293
294fn default_autonomy() -> String {
295 "low_risk_only".into()
296}
297
298#[derive(Debug, Clone)]
300pub struct Compose {
301 pub root: PathBuf,
302 pub global: Global,
303 pub projects: Vec<Project>,
304}
305
306impl Compose {
307 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
316 let start = start
317 .canonicalize()
318 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
319 let mut cur: Option<&Path> = Some(&start);
321 while let Some(dir) = cur {
322 let candidate = dir.join(".team").join("team-compose.yaml");
323 if candidate.is_file() {
324 return Ok(dir.join(".team"));
325 }
326 cur = dir.parent();
327 }
328 if start.join("team-compose.yaml").is_file() {
330 return Ok(start);
331 }
332 let mut cur: Option<&Path> = start.parent();
334 while let Some(dir) = cur {
335 if dir.join("team-compose.yaml").is_file() {
336 eprintln!(
337 "warning: using legacy flat layout at {}; consider migrating to {}",
338 dir.display(),
339 dir.join(".team").display()
340 );
341 return Ok(dir.to_path_buf());
342 }
343 cur = dir.parent();
344 }
345 Err(anyhow::anyhow!(
346 "no `.team/team-compose.yaml` found in {} or any parent",
347 start.display()
348 ))
349 }
350
351 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
353 let root = root.as_ref().to_path_buf();
354 let global_path = root.join("team-compose.yaml");
355 let global: Global = serde_yaml::from_str(
356 &std::fs::read_to_string(&global_path)
357 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
358 )
359 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
360
361 let mut projects = Vec::with_capacity(global.projects.len());
362 for r in &global.projects {
363 let p = root.join(&r.file);
364 let parsed: Project = serde_yaml::from_str(
365 &std::fs::read_to_string(&p)
366 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
367 )
368 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
369 projects.push(parsed);
370 }
371
372 Ok(Self {
373 root,
374 global,
375 projects,
376 })
377 }
378
379 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
381 self.projects.iter().flat_map(|p| {
382 p.managers
383 .iter()
384 .map(move |(id, a)| AgentHandle {
385 project: &p.project.id,
386 agent: id,
387 spec: a,
388 is_manager: true,
389 })
390 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
391 project: &p.project.id,
392 agent: id,
393 spec: a,
394 is_manager: false,
395 }))
396 })
397 }
398}
399
400#[derive(Debug, Clone, Copy)]
401pub struct AgentHandle<'a> {
402 pub project: &'a str,
403 pub agent: &'a str,
404 pub spec: &'a Agent,
405 pub is_manager: bool,
406}
407
408impl AgentHandle<'_> {
409 pub fn id(&self) -> String {
411 format!("{}:{}", self.project, self.agent)
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn channel_members_all_expands() {
421 let all = ChannelMembers::All("*".into());
422 assert!(all.includes("dev1", &["dev1", "dev2"]));
423 assert!(!all.includes("ghost", &["dev1", "dev2"]));
424 }
425
426 #[test]
427 fn channel_members_explicit_checks_list() {
428 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
429 assert!(exp.includes("dev1", &[]));
430 assert!(!exp.includes("dev2", &[]));
431 }
432
433 #[test]
434 fn agent_defaults_are_stable() {
435 let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
436 assert_eq!(a.runtime, "claude-code");
437 assert_eq!(a.autonomy, "low_risk_only");
438 assert!(!a.telegram_inbox);
439 }
440
441 #[test]
442 fn discover_prefers_dot_team() {
443 let tmp = tempfile::tempdir().unwrap();
444 let repo = tmp.path();
445 std::fs::create_dir_all(repo.join(".team")).unwrap();
446 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
447 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
449
450 let sub = repo.join("src/deep/nested");
452 std::fs::create_dir_all(&sub).unwrap();
453 let found = Compose::discover(&sub).unwrap();
454 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
455 }
456
457 #[test]
458 fn discover_falls_back_to_flat_layout() {
459 let tmp = tempfile::tempdir().unwrap();
460 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
461 let found = Compose::discover(tmp.path()).unwrap();
462 assert_eq!(found, tmp.path().canonicalize().unwrap());
463 }
464
465 #[test]
466 fn discover_errors_when_nothing_found() {
467 let tmp = tempfile::tempdir().unwrap();
468 let err = Compose::discover(tmp.path()).unwrap_err();
469 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
470 }
471}