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 #[serde(default = "default_drain_timeout_secs")]
198 pub drain_timeout_secs: u64,
199}
200
201impl Default for SupervisorCfg {
202 fn default() -> Self {
203 Self {
204 r#type: default_supervisor_type(),
205 tmux_prefix: default_tmux_prefix(),
206 drain_timeout_secs: default_drain_timeout_secs(),
207 }
208 }
209}
210
211fn default_supervisor_type() -> String {
212 "tmux".into()
213}
214
215fn default_drain_timeout_secs() -> u64 {
216 10
217}
218
219fn default_tmux_prefix() -> String {
220 "a-".into()
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Project {
226 pub version: u32,
227 pub project: ProjectMeta,
228
229 #[serde(default)]
230 pub channels: Vec<Channel>,
231
232 #[serde(default)]
233 pub managers: BTreeMap<String, Agent>,
234
235 #[serde(default)]
236 pub workers: BTreeMap<String, Agent>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ProjectMeta {
241 pub id: String,
242 pub name: String,
243 pub cwd: PathBuf,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct Channel {
248 pub name: String,
249 #[serde(default)]
251 pub members: ChannelMembers,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(untagged)]
256pub enum ChannelMembers {
257 All(String),
258 Explicit(Vec<String>),
259}
260
261impl Default for ChannelMembers {
262 fn default() -> Self {
263 Self::Explicit(Vec::new())
264 }
265}
266
267impl ChannelMembers {
268 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
269 match self {
270 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
271 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
272 _ => false,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Agent {
279 #[serde(default = "default_runtime")]
280 pub runtime: String,
281 pub model: Option<String>,
282 pub role_prompt: Option<PathBuf>,
283 #[serde(default)]
284 pub permission_mode: Option<String>,
285 #[serde(default)]
286 pub telegram_inbox: bool,
287 #[serde(default)]
288 pub reports_to_user: bool,
289 #[serde(default = "default_autonomy")]
290 pub autonomy: String,
291 #[serde(default)]
292 pub can_dm: Vec<String>,
293 #[serde(default)]
294 pub can_broadcast: Vec<String>,
295 #[serde(default)]
296 pub reports_to: Option<String>,
297
298 #[serde(default)]
300 pub on_rate_limit: Option<Vec<String>>,
301
302 #[serde(default)]
308 pub effort: Option<EffortLevel>,
309}
310
311#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
315#[serde(rename_all = "lowercase")]
316pub enum EffortLevel {
317 Low,
318 Medium,
319 High,
320 Xhigh,
321 Max,
322}
323
324impl EffortLevel {
325 pub fn as_str(self) -> &'static str {
328 match self {
329 EffortLevel::Low => "low",
330 EffortLevel::Medium => "medium",
331 EffortLevel::High => "high",
332 EffortLevel::Xhigh => "xhigh",
333 EffortLevel::Max => "max",
334 }
335 }
336}
337
338fn default_runtime() -> String {
339 "claude-code".into()
340}
341
342fn default_autonomy() -> String {
343 "low_risk_only".into()
344}
345
346#[derive(Debug, Clone)]
348pub struct Compose {
349 pub root: PathBuf,
350 pub global: Global,
351 pub projects: Vec<Project>,
352}
353
354impl Compose {
355 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
366 let start = start
367 .canonicalize()
368 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
369 let mut cur: Option<&Path> = Some(&start);
370 while let Some(dir) = cur {
371 let candidate = dir.join(".team").join("team-compose.yaml");
372 if candidate.is_file() {
373 return Ok(dir.join(".team"));
374 }
375 cur = dir.parent();
376 }
377 Err(anyhow::anyhow!(
378 "no `.team/team-compose.yaml` found in {} or any parent",
379 start.display()
380 ))
381 }
382
383 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
385 let root = root.as_ref().to_path_buf();
386 let global_path = root.join("team-compose.yaml");
387 let global: Global = serde_yaml::from_str(
388 &std::fs::read_to_string(&global_path)
389 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
390 )
391 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
392
393 let mut projects = Vec::with_capacity(global.projects.len());
394 for r in &global.projects {
395 let p = root.join(&r.file);
396 let parsed: Project = serde_yaml::from_str(
397 &std::fs::read_to_string(&p)
398 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
399 )
400 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
401 projects.push(parsed);
402 }
403
404 Ok(Self {
405 root,
406 global,
407 projects,
408 })
409 }
410
411 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
413 self.projects.iter().flat_map(|p| {
414 p.managers
415 .iter()
416 .map(move |(id, a)| AgentHandle {
417 project: &p.project.id,
418 agent: id,
419 spec: a,
420 is_manager: true,
421 })
422 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
423 project: &p.project.id,
424 agent: id,
425 spec: a,
426 is_manager: false,
427 }))
428 })
429 }
430}
431
432#[derive(Debug, Clone, Copy)]
433pub struct AgentHandle<'a> {
434 pub project: &'a str,
435 pub agent: &'a str,
436 pub spec: &'a Agent,
437 pub is_manager: bool,
438}
439
440impl AgentHandle<'_> {
441 pub fn id(&self) -> String {
443 format!("{}:{}", self.project, self.agent)
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn channel_members_all_expands() {
453 let all = ChannelMembers::All("*".into());
454 assert!(all.includes("dev1", &["dev1", "dev2"]));
455 assert!(!all.includes("ghost", &["dev1", "dev2"]));
456 }
457
458 #[test]
459 fn channel_members_explicit_checks_list() {
460 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
461 assert!(exp.includes("dev1", &[]));
462 assert!(!exp.includes("dev2", &[]));
463 }
464
465 #[test]
466 fn agent_defaults_are_stable() {
467 let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
468 assert_eq!(a.runtime, "claude-code");
469 assert_eq!(a.autonomy, "low_risk_only");
470 assert!(!a.telegram_inbox);
471 assert!(a.effort.is_none());
472 }
473
474 #[test]
475 fn effort_parses_all_five_levels() {
476 for (yaml, expected) in [
477 ("effort: low\n", EffortLevel::Low),
478 ("effort: medium\n", EffortLevel::Medium),
479 ("effort: high\n", EffortLevel::High),
480 ("effort: xhigh\n", EffortLevel::Xhigh),
481 ("effort: max\n", EffortLevel::Max),
482 ] {
483 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
484 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
485 }
486 }
487
488 #[test]
489 fn effort_unknown_value_is_rejected() {
490 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
491 .expect_err("typo'd effort value must fail to parse");
492 let msg = err.to_string();
493 assert!(
494 msg.contains("low") && msg.contains("max"),
495 "error should enumerate valid variants; got: {msg}"
496 );
497 }
498
499 #[test]
500 fn effort_renders_to_lowercase_string() {
501 assert_eq!(EffortLevel::Low.as_str(), "low");
502 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
503 assert_eq!(EffortLevel::Max.as_str(), "max");
504 }
505
506 #[test]
507 fn discover_prefers_dot_team() {
508 let tmp = tempfile::tempdir().unwrap();
509 let repo = tmp.path();
510 std::fs::create_dir_all(repo.join(".team")).unwrap();
511 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
512 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
514
515 let sub = repo.join("src/deep/nested");
517 std::fs::create_dir_all(&sub).unwrap();
518 let found = Compose::discover(&sub).unwrap();
519 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
520 }
521
522 #[test]
523 fn discover_no_longer_falls_back_to_flat_layout() {
524 let tmp = tempfile::tempdir().unwrap();
528 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
529 let err = Compose::discover(tmp.path()).unwrap_err();
530 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
531 }
532
533 #[test]
534 fn discover_returns_first_dot_team_walking_up() {
535 let tmp = tempfile::tempdir().unwrap();
538 let outer = tmp.path();
539 let inner = outer.join("packages/inner");
540 std::fs::create_dir_all(outer.join(".team")).unwrap();
541 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
542 std::fs::create_dir_all(inner.join(".team")).unwrap();
543 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
544
545 let from_inner = inner.join("src/deep");
546 std::fs::create_dir_all(&from_inner).unwrap();
547 let found = Compose::discover(&from_inner).unwrap();
548 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
549 }
550
551 #[test]
552 fn discover_errors_when_nothing_found() {
553 let tmp = tempfile::tempdir().unwrap();
554 let err = Compose::discover(tmp.path()).unwrap_err();
555 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
556 }
557}