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
49impl Interface {
50 pub fn is_telegram(&self) -> bool {
51 self.r#type == "telegram"
52 }
53
54 pub fn manager(&self) -> Option<String> {
56 self.config_str("manager")
57 }
58
59 pub fn bot_token_env(&self) -> Option<String> {
61 self.config_str("bot_token_env")
62 }
63
64 pub fn authorized_chat_ids_env(&self) -> Option<String> {
66 self.config_str("authorized_chat_ids_env")
67 }
68
69 fn config_str(&self, key: &str) -> Option<String> {
70 match &self.config {
71 serde_yaml::Value::Mapping(m) => m
72 .get(serde_yaml::Value::String(key.into()))
73 .and_then(|v| v.as_str())
74 .map(str::to_owned),
75 _ => None,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct Budget {
82 #[serde(default)]
83 pub daily_usd_limit: Option<f64>,
84 #[serde(default)]
85 pub warn_threshold_pct: Option<u32>,
86 #[serde(default)]
87 pub message_ttl_hours: Option<u32>,
88 #[serde(default)]
89 pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct RateLimits {
95 #[serde(default)]
97 pub default_on_hit: Vec<String>,
98
99 #[serde(default)]
101 pub hooks: Vec<RateLimitHook>,
102
103 #[serde(default = "default_fallback_wait")]
106 pub fallback_wait_seconds: u64,
107}
108
109fn default_fallback_wait() -> u64 {
110 30 * 60
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct RateLimitHook {
127 pub name: String,
128 pub action: String,
129 #[serde(default)]
130 pub to: Option<String>,
131 #[serde(default)]
132 pub template: Option<String>,
133 #[serde(default)]
134 pub url: Option<String>,
135 #[serde(default)]
136 pub url_env: Option<String>,
137 #[serde(default)]
138 pub method: Option<String>,
139 #[serde(default)]
140 pub command: Vec<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Hitl {
145 #[serde(default = "default_sensitive_actions")]
146 pub globally_sensitive_actions: Vec<String>,
147 #[serde(default)]
148 pub auto_approve_windows: Vec<AutoApprove>,
149}
150
151impl Default for Hitl {
152 fn default() -> Self {
153 Self {
154 globally_sensitive_actions: default_sensitive_actions(),
155 auto_approve_windows: Vec::new(),
156 }
157 }
158}
159
160fn default_sensitive_actions() -> Vec<String> {
161 vec![
162 "publish".into(),
163 "release".into(),
164 "payment".into(),
165 "external_email".into(),
166 "external_api_post".into(),
167 "merge_to_main".into(),
168 "dns_change".into(),
169 "deploy".into(),
170 ]
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct AutoApprove {
175 pub action: String,
176 #[serde(default)]
177 pub project: Option<String>,
178 #[serde(default)]
179 pub agent: Option<String>,
180 #[serde(default)]
181 pub scope: Option<String>,
182 pub until: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ProjectRef {
188 pub file: PathBuf,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct Broker {
193 #[serde(default = "default_broker_type")]
194 pub r#type: String,
195 #[serde(default = "default_mailbox_path")]
196 pub path: PathBuf,
197}
198
199impl Default for Broker {
200 fn default() -> Self {
201 Self {
202 r#type: default_broker_type(),
203 path: default_mailbox_path(),
204 }
205 }
206}
207
208fn default_broker_type() -> String {
209 "sqlite".into()
210}
211
212fn default_mailbox_path() -> PathBuf {
213 PathBuf::from("state/mailbox.db")
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub struct SupervisorCfg {
218 #[serde(default = "default_supervisor_type")]
219 pub r#type: String,
220 #[serde(default = "default_tmux_prefix")]
221 pub tmux_prefix: String,
222 #[serde(default = "default_drain_timeout_secs")]
229 pub drain_timeout_secs: u64,
230}
231
232impl Default for SupervisorCfg {
233 fn default() -> Self {
234 Self {
235 r#type: default_supervisor_type(),
236 tmux_prefix: default_tmux_prefix(),
237 drain_timeout_secs: default_drain_timeout_secs(),
238 }
239 }
240}
241
242fn default_supervisor_type() -> String {
243 "tmux".into()
244}
245
246fn default_drain_timeout_secs() -> u64 {
247 10
248}
249
250fn default_tmux_prefix() -> String {
251 "a-".into()
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Project {
257 pub version: u32,
258 pub project: ProjectMeta,
259
260 #[serde(default)]
261 pub channels: Vec<Channel>,
262
263 #[serde(default)]
264 pub managers: BTreeMap<String, Agent>,
265
266 #[serde(default)]
267 pub workers: BTreeMap<String, Agent>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct ProjectMeta {
272 pub id: String,
273 pub name: String,
274 pub cwd: PathBuf,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Channel {
279 pub name: String,
280 #[serde(default)]
282 pub members: ChannelMembers,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(untagged)]
287pub enum ChannelMembers {
288 All(String),
289 Explicit(Vec<String>),
290}
291
292impl Default for ChannelMembers {
293 fn default() -> Self {
294 Self::Explicit(Vec::new())
295 }
296}
297
298impl ChannelMembers {
299 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
300 match self {
301 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
302 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
303 _ => false,
304 }
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct Agent {
310 #[serde(default = "default_runtime")]
311 pub runtime: String,
312 pub model: Option<String>,
313 pub role_prompt: Option<PathBuf>,
314 #[serde(default)]
315 pub permission_mode: Option<String>,
316 #[serde(default = "default_autonomy")]
317 pub autonomy: String,
318 #[serde(default)]
319 pub can_dm: Vec<String>,
320 #[serde(default)]
321 pub can_broadcast: Vec<String>,
322 #[serde(default)]
323 pub reports_to: Option<String>,
324
325 #[serde(default)]
327 pub on_rate_limit: Option<Vec<String>>,
328
329 #[serde(default)]
335 pub effort: Option<EffortLevel>,
336
337 #[serde(default)]
342 pub interfaces: Option<AgentInterfaces>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
348pub struct AgentInterfaces {
349 #[serde(default)]
354 pub telegram: Option<TelegramConfig>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct TelegramConfig {
361 pub bot_token_env: String,
364 pub chat_ids_env: String,
367}
368
369impl Agent {
370 pub fn telegram(&self) -> Option<&TelegramConfig> {
374 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
375 }
376}
377
378#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
382#[serde(rename_all = "lowercase")]
383pub enum EffortLevel {
384 Low,
385 Medium,
386 High,
387 Xhigh,
388 Max,
389}
390
391impl EffortLevel {
392 pub fn as_str(self) -> &'static str {
395 match self {
396 EffortLevel::Low => "low",
397 EffortLevel::Medium => "medium",
398 EffortLevel::High => "high",
399 EffortLevel::Xhigh => "xhigh",
400 EffortLevel::Max => "max",
401 }
402 }
403}
404
405fn default_runtime() -> String {
406 "claude-code".into()
407}
408
409fn default_autonomy() -> String {
410 "low_risk_only".into()
411}
412
413#[derive(Debug, Clone)]
415pub struct Compose {
416 pub root: PathBuf,
417 pub global: Global,
418 pub projects: Vec<Project>,
419}
420
421impl Compose {
422 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
433 let start = start
434 .canonicalize()
435 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
436 let mut cur: Option<&Path> = Some(&start);
437 while let Some(dir) = cur {
438 let candidate = dir.join(".team").join("team-compose.yaml");
439 if candidate.is_file() {
440 return Ok(dir.join(".team"));
441 }
442 cur = dir.parent();
443 }
444 Err(anyhow::anyhow!(
445 "no `.team/team-compose.yaml` found in {} or any parent",
446 start.display()
447 ))
448 }
449
450 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
452 let root = root.as_ref().to_path_buf();
453 let global_path = root.join("team-compose.yaml");
454 let global: Global = serde_yaml::from_str(
455 &std::fs::read_to_string(&global_path)
456 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
457 )
458 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
459
460 let mut projects = Vec::with_capacity(global.projects.len());
461 for r in &global.projects {
462 let p = root.join(&r.file);
463 let parsed: Project = serde_yaml::from_str(
464 &std::fs::read_to_string(&p)
465 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
466 )
467 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
468 projects.push(parsed);
469 }
470
471 Ok(Self {
472 root,
473 global,
474 projects,
475 })
476 }
477
478 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
480 self.projects.iter().flat_map(|p| {
481 p.managers
482 .iter()
483 .map(move |(id, a)| AgentHandle {
484 project: &p.project.id,
485 agent: id,
486 spec: a,
487 is_manager: true,
488 })
489 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
490 project: &p.project.id,
491 agent: id,
492 spec: a,
493 is_manager: false,
494 }))
495 })
496 }
497}
498
499#[derive(Debug, Clone, Copy)]
500pub struct AgentHandle<'a> {
501 pub project: &'a str,
502 pub agent: &'a str,
503 pub spec: &'a Agent,
504 pub is_manager: bool,
505}
506
507impl AgentHandle<'_> {
508 pub fn id(&self) -> String {
510 format!("{}:{}", self.project, self.agent)
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn channel_members_all_expands() {
520 let all = ChannelMembers::All("*".into());
521 assert!(all.includes("dev1", &["dev1", "dev2"]));
522 assert!(!all.includes("ghost", &["dev1", "dev2"]));
523 }
524
525 #[test]
526 fn channel_members_explicit_checks_list() {
527 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
528 assert!(exp.includes("dev1", &[]));
529 assert!(!exp.includes("dev2", &[]));
530 }
531
532 #[test]
533 fn agent_defaults_are_stable() {
534 let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
535 assert_eq!(a.runtime, "claude-code");
536 assert_eq!(a.autonomy, "low_risk_only");
537 assert!(a.interfaces.is_none());
538 assert!(a.telegram().is_none());
539 assert!(a.effort.is_none());
540 }
541
542 #[test]
543 fn agent_telegram_block_parses_under_interfaces() {
544 let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
545 let a: Agent = serde_yaml::from_str(yaml).unwrap();
546 let tg = a.telegram().expect("telegram parsed");
547 assert_eq!(tg.bot_token_env, "T");
548 assert_eq!(tg.chat_ids_env, "C");
549 }
550
551 #[test]
552 fn effort_parses_all_five_levels() {
553 for (yaml, expected) in [
554 ("effort: low\n", EffortLevel::Low),
555 ("effort: medium\n", EffortLevel::Medium),
556 ("effort: high\n", EffortLevel::High),
557 ("effort: xhigh\n", EffortLevel::Xhigh),
558 ("effort: max\n", EffortLevel::Max),
559 ] {
560 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
561 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
562 }
563 }
564
565 #[test]
566 fn effort_unknown_value_is_rejected() {
567 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
568 .expect_err("typo'd effort value must fail to parse");
569 let msg = err.to_string();
570 assert!(
571 msg.contains("low") && msg.contains("max"),
572 "error should enumerate valid variants; got: {msg}"
573 );
574 }
575
576 #[test]
577 fn effort_renders_to_lowercase_string() {
578 assert_eq!(EffortLevel::Low.as_str(), "low");
579 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
580 assert_eq!(EffortLevel::Max.as_str(), "max");
581 }
582
583 #[test]
584 fn discover_prefers_dot_team() {
585 let tmp = tempfile::tempdir().unwrap();
586 let repo = tmp.path();
587 std::fs::create_dir_all(repo.join(".team")).unwrap();
588 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
589 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
591
592 let sub = repo.join("src/deep/nested");
594 std::fs::create_dir_all(&sub).unwrap();
595 let found = Compose::discover(&sub).unwrap();
596 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
597 }
598
599 #[test]
600 fn discover_no_longer_falls_back_to_flat_layout() {
601 let tmp = tempfile::tempdir().unwrap();
605 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
606 let err = Compose::discover(tmp.path()).unwrap_err();
607 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
608 }
609
610 #[test]
611 fn discover_returns_first_dot_team_walking_up() {
612 let tmp = tempfile::tempdir().unwrap();
615 let outer = tmp.path();
616 let inner = outer.join("packages/inner");
617 std::fs::create_dir_all(outer.join(".team")).unwrap();
618 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
619 std::fs::create_dir_all(inner.join(".team")).unwrap();
620 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
621
622 let from_inner = inner.join("src/deep");
623 std::fs::create_dir_all(&from_inner).unwrap();
624 let found = Compose::discover(&from_inner).unwrap();
625 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
626 }
627
628 #[test]
629 fn discover_errors_when_nothing_found() {
630 let tmp = tempfile::tempdir().unwrap();
631 let err = Compose::discover(tmp.path()).unwrap_err();
632 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
633 }
634}