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 #[serde(default)]
41 pub attachments: Attachments,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct Attachments {
46 #[serde(default = "default_attachments_enabled")]
47 pub enabled: bool,
48 #[serde(default = "default_attachments_max_size_bytes")]
49 pub max_size_bytes: u64,
50 #[serde(default = "default_attachments_allowed_roots")]
55 pub allowed_roots: Vec<String>,
56 #[serde(default)]
57 pub scanner: Option<AttachmentScanner>,
58 #[serde(default)]
62 pub audit_log_path: Option<PathBuf>,
63 #[serde(default = "default_attachments_tempfile_ttl_seconds")]
71 pub tempfile_ttl_seconds: u64,
72}
73
74impl Default for Attachments {
75 fn default() -> Self {
76 Self {
77 enabled: default_attachments_enabled(),
78 max_size_bytes: default_attachments_max_size_bytes(),
79 allowed_roots: default_attachments_allowed_roots(),
80 scanner: None,
81 audit_log_path: None,
82 tempfile_ttl_seconds: default_attachments_tempfile_ttl_seconds(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct AttachmentScanner {
89 pub command: String,
92 #[serde(default = "default_scanner_timeout_seconds")]
93 pub timeout_seconds: u64,
94}
95
96fn default_attachments_enabled() -> bool {
97 true
98}
99
100fn default_attachments_max_size_bytes() -> u64 {
101 5 * 1024 * 1024
102}
103
104fn default_attachments_allowed_roots() -> Vec<String> {
105 vec!["$HOME".to_string()]
106}
107
108fn default_scanner_timeout_seconds() -> u64 {
109 30
110}
111
112fn default_attachments_tempfile_ttl_seconds() -> u64 {
113 6 * 60 * 60
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Interface {
118 pub r#type: String,
120 pub name: String,
122 #[serde(default)]
124 pub config: serde_yaml::Value,
125}
126
127impl Interface {
128 pub fn is_telegram(&self) -> bool {
129 self.r#type == "telegram"
130 }
131
132 pub fn manager(&self) -> Option<String> {
134 self.config_str("manager")
135 }
136
137 pub fn bot_token_env(&self) -> Option<String> {
139 self.config_str("bot_token_env")
140 }
141
142 pub fn authorized_chat_ids_env(&self) -> Option<String> {
144 self.config_str("authorized_chat_ids_env")
145 }
146
147 fn config_str(&self, key: &str) -> Option<String> {
148 match &self.config {
149 serde_yaml::Value::Mapping(m) => m
150 .get(serde_yaml::Value::String(key.into()))
151 .and_then(|v| v.as_str())
152 .map(str::to_owned),
153 _ => None,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
159pub struct Budget {
160 #[serde(default)]
161 pub daily_usd_limit: Option<f64>,
162 #[serde(default)]
163 pub warn_threshold_pct: Option<u32>,
164 #[serde(default)]
165 pub message_ttl_hours: Option<u32>,
166 #[serde(default)]
167 pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct RateLimits {
173 #[serde(default)]
175 pub default_on_hit: Vec<String>,
176
177 #[serde(default)]
179 pub hooks: Vec<RateLimitHook>,
180
181 #[serde(default = "default_fallback_wait")]
184 pub fallback_wait_seconds: u64,
185}
186
187fn default_fallback_wait() -> u64 {
188 30 * 60
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct RateLimitHook {
205 pub name: String,
206 pub action: String,
207 #[serde(default)]
208 pub to: Option<String>,
209 #[serde(default)]
210 pub template: Option<String>,
211 #[serde(default)]
212 pub url: Option<String>,
213 #[serde(default)]
214 pub url_env: Option<String>,
215 #[serde(default)]
216 pub method: Option<String>,
217 #[serde(default)]
218 pub command: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct Hitl {
223 #[serde(default = "default_sensitive_actions")]
224 pub globally_sensitive_actions: Vec<String>,
225 #[serde(default)]
226 pub auto_approve_windows: Vec<AutoApprove>,
227}
228
229impl Default for Hitl {
230 fn default() -> Self {
231 Self {
232 globally_sensitive_actions: default_sensitive_actions(),
233 auto_approve_windows: Vec::new(),
234 }
235 }
236}
237
238fn default_sensitive_actions() -> Vec<String> {
239 vec![
240 "publish".into(),
241 "release".into(),
242 "payment".into(),
243 "external_email".into(),
244 "external_api_post".into(),
245 "merge_to_main".into(),
246 "dns_change".into(),
247 "deploy".into(),
248 ]
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct AutoApprove {
253 pub action: String,
254 #[serde(default)]
255 pub project: Option<String>,
256 #[serde(default)]
257 pub agent: Option<String>,
258 #[serde(default)]
259 pub scope: Option<String>,
260 pub until: String,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ProjectRef {
266 pub file: PathBuf,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270pub struct Broker {
271 #[serde(default = "default_broker_type")]
272 pub r#type: String,
273 #[serde(default = "default_mailbox_path")]
274 pub path: PathBuf,
275}
276
277impl Default for Broker {
278 fn default() -> Self {
279 Self {
280 r#type: default_broker_type(),
281 path: default_mailbox_path(),
282 }
283 }
284}
285
286fn default_broker_type() -> String {
287 "sqlite".into()
288}
289
290fn default_mailbox_path() -> PathBuf {
291 PathBuf::from("state/mailbox.db")
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295pub struct SupervisorCfg {
296 #[serde(default = "default_supervisor_type")]
297 pub r#type: String,
298 #[serde(default = "default_tmux_prefix")]
299 pub tmux_prefix: String,
300 #[serde(default = "default_drain_timeout_secs")]
307 pub drain_timeout_secs: u64,
308}
309
310impl Default for SupervisorCfg {
311 fn default() -> Self {
312 Self {
313 r#type: default_supervisor_type(),
314 tmux_prefix: default_tmux_prefix(),
315 drain_timeout_secs: default_drain_timeout_secs(),
316 }
317 }
318}
319
320fn default_supervisor_type() -> String {
321 "tmux".into()
322}
323
324fn default_drain_timeout_secs() -> u64 {
325 10
326}
327
328fn default_tmux_prefix() -> String {
329 "a-".into()
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct Project {
335 pub version: u32,
336 pub project: ProjectMeta,
337
338 #[serde(default)]
339 pub channels: Vec<Channel>,
340
341 #[serde(default)]
342 pub managers: BTreeMap<String, Agent>,
343
344 #[serde(default)]
345 pub workers: BTreeMap<String, Agent>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct ProjectMeta {
350 pub id: String,
351 pub name: String,
352 pub cwd: PathBuf,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct Channel {
357 pub name: String,
358 #[serde(default)]
360 pub members: ChannelMembers,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(untagged)]
365pub enum ChannelMembers {
366 All(String),
367 Explicit(Vec<String>),
368}
369
370impl Default for ChannelMembers {
371 fn default() -> Self {
372 Self::Explicit(Vec::new())
373 }
374}
375
376impl ChannelMembers {
377 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
378 match self {
379 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
380 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
381 _ => false,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(untagged)]
394pub enum RolePrompt {
395 Single(PathBuf),
396 Multiple(Vec<PathBuf>),
397}
398
399impl RolePrompt {
400 pub fn paths(&self) -> Vec<&Path> {
403 match self {
404 RolePrompt::Single(p) => vec![p.as_path()],
405 RolePrompt::Multiple(v) => v.iter().map(|p| p.as_path()).collect(),
406 }
407 }
408
409 pub fn is_blank(&self) -> bool {
414 match self {
415 RolePrompt::Single(p) => p.as_os_str().is_empty(),
416 RolePrompt::Multiple(v) => v.is_empty(),
417 }
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct Agent {
423 #[serde(default = "default_runtime")]
424 pub runtime: String,
425 pub model: Option<String>,
426 pub role_prompt: Option<RolePrompt>,
427 #[serde(default)]
428 pub permission_mode: Option<String>,
429 #[serde(default = "default_autonomy")]
430 pub autonomy: String,
431 #[serde(default)]
432 pub can_dm: Vec<String>,
433 #[serde(default)]
434 pub can_broadcast: Vec<String>,
435 #[serde(default)]
436 pub reports_to: Option<String>,
437
438 #[serde(default)]
440 pub on_rate_limit: Option<Vec<String>>,
441
442 #[serde(default)]
448 pub effort: Option<EffortLevel>,
449
450 #[serde(default)]
455 pub interfaces: Option<AgentInterfaces>,
456
457 #[serde(default)]
465 pub display_name: Option<String>,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize, Default)]
471pub struct AgentInterfaces {
472 #[serde(default)]
477 pub telegram: Option<TelegramConfig>,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct TelegramConfig {
484 pub bot_token_env: String,
487 pub chat_ids_env: String,
490 #[serde(default)]
495 pub speech_to_text: Option<SttConfig>,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct SttConfig {
503 pub provider: String,
505 pub api_key_env: String,
509 pub model: String,
511 #[serde(default)]
514 pub language: Option<String>,
515}
516
517impl Agent {
518 pub fn telegram(&self) -> Option<&TelegramConfig> {
522 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
523 }
524}
525
526#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "lowercase")]
531pub enum EffortLevel {
532 Low,
533 Medium,
534 High,
535 Xhigh,
536 Max,
537}
538
539impl EffortLevel {
540 pub fn as_str(self) -> &'static str {
543 match self {
544 EffortLevel::Low => "low",
545 EffortLevel::Medium => "medium",
546 EffortLevel::High => "high",
547 EffortLevel::Xhigh => "xhigh",
548 EffortLevel::Max => "max",
549 }
550 }
551}
552
553fn default_runtime() -> String {
554 "claude-code".into()
555}
556
557fn default_autonomy() -> String {
558 "low_risk_only".into()
559}
560
561#[derive(Debug, Clone)]
563pub struct Compose {
564 pub root: PathBuf,
565 pub global: Global,
566 pub projects: Vec<Project>,
567}
568
569impl Compose {
570 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
581 let start = start
582 .canonicalize()
583 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
584 let mut cur: Option<&Path> = Some(&start);
585 while let Some(dir) = cur {
586 let candidate = dir.join(".team").join("team-compose.yaml");
587 if candidate.is_file() {
588 return Ok(dir.join(".team"));
589 }
590 cur = dir.parent();
591 }
592 Err(anyhow::anyhow!(
593 "no `.team/team-compose.yaml` found in {} or any parent",
594 start.display()
595 ))
596 }
597
598 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
600 let root = root.as_ref().to_path_buf();
601 let global_path = root.join("team-compose.yaml");
602 let global: Global = serde_yaml::from_str(
603 &std::fs::read_to_string(&global_path)
604 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
605 )
606 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
607
608 let mut projects = Vec::with_capacity(global.projects.len());
609 for r in &global.projects {
610 let p = root.join(&r.file);
611 let parsed: Project = serde_yaml::from_str(
612 &std::fs::read_to_string(&p)
613 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
614 )
615 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
616 projects.push(parsed);
617 }
618
619 Ok(Self {
620 root,
621 global,
622 projects,
623 })
624 }
625
626 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
628 self.projects.iter().flat_map(|p| {
629 p.managers
630 .iter()
631 .map(move |(id, a)| AgentHandle {
632 project: &p.project.id,
633 agent: id,
634 spec: a,
635 is_manager: true,
636 })
637 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
638 project: &p.project.id,
639 agent: id,
640 spec: a,
641 is_manager: false,
642 }))
643 })
644 }
645}
646
647#[derive(Debug, Clone, Copy)]
648pub struct AgentHandle<'a> {
649 pub project: &'a str,
650 pub agent: &'a str,
651 pub spec: &'a Agent,
652 pub is_manager: bool,
653}
654
655impl AgentHandle<'_> {
656 pub fn id(&self) -> String {
658 format!("{}:{}", self.project, self.agent)
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn channel_members_all_expands() {
668 let all = ChannelMembers::All("*".into());
669 assert!(all.includes("dev1", &["dev1", "dev2"]));
670 assert!(!all.includes("ghost", &["dev1", "dev2"]));
671 }
672
673 #[test]
674 fn channel_members_explicit_checks_list() {
675 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
676 assert!(exp.includes("dev1", &[]));
677 assert!(!exp.includes("dev2", &[]));
678 }
679
680 #[test]
681 fn agent_defaults_are_stable() {
682 let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
683 assert_eq!(a.runtime, "claude-code");
684 assert_eq!(a.autonomy, "low_risk_only");
685 assert!(a.interfaces.is_none());
686 assert!(a.telegram().is_none());
687 assert!(a.effort.is_none());
688 }
689
690 #[test]
691 fn agent_telegram_block_parses_under_interfaces() {
692 let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
693 let a: Agent = serde_yaml::from_str(yaml).unwrap();
694 let tg = a.telegram().expect("telegram parsed");
695 assert_eq!(tg.bot_token_env, "T");
696 assert_eq!(tg.chat_ids_env, "C");
697 assert!(tg.speech_to_text.is_none());
698 }
699
700 #[test]
701 fn agent_telegram_block_parses_speech_to_text() {
702 let yaml = "\
703interfaces:
704 telegram:
705 bot_token_env: T
706 chat_ids_env: C
707 speech_to_text:
708 provider: groq
709 api_key_env: GROQ_API_KEY
710 model: whisper-large-v3
711 language: en
712";
713 let a: Agent = serde_yaml::from_str(yaml).unwrap();
714 let stt = a
715 .telegram()
716 .and_then(|t| t.speech_to_text.as_ref())
717 .expect("speech_to_text parsed");
718 assert_eq!(stt.provider, "groq");
719 assert_eq!(stt.api_key_env, "GROQ_API_KEY");
720 assert_eq!(stt.model, "whisper-large-v3");
721 assert_eq!(stt.language.as_deref(), Some("en"));
722 }
723
724 #[test]
725 fn agent_telegram_block_parses_speech_to_text_without_language() {
726 let yaml = "\
727interfaces:
728 telegram:
729 bot_token_env: T
730 chat_ids_env: C
731 speech_to_text:
732 provider: groq
733 api_key_env: K
734 model: whisper-large-v3
735";
736 let a: Agent = serde_yaml::from_str(yaml).unwrap();
737 let stt = a
738 .telegram()
739 .and_then(|t| t.speech_to_text.as_ref())
740 .expect("speech_to_text parsed");
741 assert!(stt.language.is_none());
742 }
743
744 #[test]
745 fn effort_parses_all_five_levels() {
746 for (yaml, expected) in [
747 ("effort: low\n", EffortLevel::Low),
748 ("effort: medium\n", EffortLevel::Medium),
749 ("effort: high\n", EffortLevel::High),
750 ("effort: xhigh\n", EffortLevel::Xhigh),
751 ("effort: max\n", EffortLevel::Max),
752 ] {
753 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
754 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
755 }
756 }
757
758 #[test]
759 fn effort_unknown_value_is_rejected() {
760 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
761 .expect_err("typo'd effort value must fail to parse");
762 let msg = err.to_string();
763 assert!(
764 msg.contains("low") && msg.contains("max"),
765 "error should enumerate valid variants; got: {msg}"
766 );
767 }
768
769 #[test]
770 fn effort_renders_to_lowercase_string() {
771 assert_eq!(EffortLevel::Low.as_str(), "low");
772 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
773 assert_eq!(EffortLevel::Max.as_str(), "max");
774 }
775
776 #[test]
777 fn discover_prefers_dot_team() {
778 let tmp = tempfile::tempdir().unwrap();
779 let repo = tmp.path();
780 std::fs::create_dir_all(repo.join(".team")).unwrap();
781 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
782 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
784
785 let sub = repo.join("src/deep/nested");
787 std::fs::create_dir_all(&sub).unwrap();
788 let found = Compose::discover(&sub).unwrap();
789 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
790 }
791
792 #[test]
793 fn discover_no_longer_falls_back_to_flat_layout() {
794 let tmp = tempfile::tempdir().unwrap();
798 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
799 let err = Compose::discover(tmp.path()).unwrap_err();
800 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
801 }
802
803 #[test]
804 fn discover_returns_first_dot_team_walking_up() {
805 let tmp = tempfile::tempdir().unwrap();
808 let outer = tmp.path();
809 let inner = outer.join("packages/inner");
810 std::fs::create_dir_all(outer.join(".team")).unwrap();
811 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
812 std::fs::create_dir_all(inner.join(".team")).unwrap();
813 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
814
815 let from_inner = inner.join("src/deep");
816 std::fs::create_dir_all(&from_inner).unwrap();
817 let found = Compose::discover(&from_inner).unwrap();
818 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
819 }
820
821 #[test]
822 fn discover_errors_when_nothing_found() {
823 let tmp = tempfile::tempdir().unwrap();
824 let err = Compose::discover(tmp.path()).unwrap_err();
825 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
826 }
827
828 #[test]
829 fn role_prompt_parses_single_string_form() {
830 let yaml = "role_prompt: roles/mgr.md\n";
831 let agent: Agent = serde_yaml::from_str(&format!(
832 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
833 ))
834 .unwrap();
835 match agent.role_prompt.unwrap() {
836 RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
837 other => panic!("expected Single, got {other:?}"),
838 }
839 }
840
841 #[test]
842 fn role_prompt_parses_list_form() {
843 let yaml = "role_prompt:\n - roles/_base.md\n - roles/mgr.md\n";
844 let agent: Agent = serde_yaml::from_str(&format!(
845 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
846 ))
847 .unwrap();
848 match agent.role_prompt.unwrap() {
849 RolePrompt::Multiple(v) => assert_eq!(
850 v,
851 vec![
852 PathBuf::from("roles/_base.md"),
853 PathBuf::from("roles/mgr.md"),
854 ]
855 ),
856 other => panic!("expected Multiple, got {other:?}"),
857 }
858 }
859
860 #[test]
861 fn role_prompt_paths_returns_declared_order() {
862 let rp = RolePrompt::Multiple(vec![
863 PathBuf::from("a.md"),
864 PathBuf::from("b.md"),
865 PathBuf::from("c.md"),
866 ]);
867 let got: Vec<&Path> = rp.paths();
868 assert_eq!(
869 got,
870 vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
871 );
872 }
873}