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)]
28 pub interfaces: Vec<Interface>,
29
30 #[serde(default)]
32 pub projects: Vec<ProjectRef>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Interface {
37 pub r#type: String,
39 pub name: String,
41 #[serde(default)]
43 pub config: serde_yaml::Value,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct Budget {
48 #[serde(default)]
49 pub daily_usd_limit: Option<f64>,
50 #[serde(default)]
51 pub warn_threshold_pct: Option<u32>,
52 #[serde(default)]
53 pub message_ttl_hours: Option<u32>,
54 #[serde(default)]
55 pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Hitl {
60 #[serde(default = "default_sensitive_actions")]
61 pub globally_sensitive_actions: Vec<String>,
62 #[serde(default)]
63 pub auto_approve_windows: Vec<AutoApprove>,
64}
65
66impl Default for Hitl {
67 fn default() -> Self {
68 Self {
69 globally_sensitive_actions: default_sensitive_actions(),
70 auto_approve_windows: Vec::new(),
71 }
72 }
73}
74
75fn default_sensitive_actions() -> Vec<String> {
76 vec![
77 "publish".into(),
78 "release".into(),
79 "payment".into(),
80 "external_email".into(),
81 "external_api_post".into(),
82 "merge_to_main".into(),
83 "dns_change".into(),
84 "deploy".into(),
85 ]
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct AutoApprove {
90 pub action: String,
91 #[serde(default)]
92 pub project: Option<String>,
93 #[serde(default)]
94 pub agent: Option<String>,
95 #[serde(default)]
96 pub scope: Option<String>,
97 pub until: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ProjectRef {
103 pub file: PathBuf,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct Broker {
108 #[serde(default = "default_broker_type")]
109 pub r#type: String,
110 #[serde(default = "default_mailbox_path")]
111 pub path: PathBuf,
112}
113
114impl Default for Broker {
115 fn default() -> Self {
116 Self {
117 r#type: default_broker_type(),
118 path: default_mailbox_path(),
119 }
120 }
121}
122
123fn default_broker_type() -> String {
124 "sqlite".into()
125}
126
127fn default_mailbox_path() -> PathBuf {
128 PathBuf::from("state/mailbox.db")
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct SupervisorCfg {
133 #[serde(default = "default_supervisor_type")]
134 pub r#type: String,
135 #[serde(default = "default_tmux_prefix")]
136 pub tmux_prefix: String,
137}
138
139impl Default for SupervisorCfg {
140 fn default() -> Self {
141 Self {
142 r#type: default_supervisor_type(),
143 tmux_prefix: default_tmux_prefix(),
144 }
145 }
146}
147
148fn default_supervisor_type() -> String {
149 "tmux".into()
150}
151
152fn default_tmux_prefix() -> String {
153 "a-".into()
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Project {
159 pub version: u32,
160 pub project: ProjectMeta,
161
162 #[serde(default)]
163 pub channels: Vec<Channel>,
164
165 #[serde(default)]
166 pub managers: BTreeMap<String, Agent>,
167
168 #[serde(default)]
169 pub workers: BTreeMap<String, Agent>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ProjectMeta {
174 pub id: String,
175 pub name: String,
176 pub cwd: PathBuf,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct Channel {
181 pub name: String,
182 #[serde(default)]
184 pub members: ChannelMembers,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(untagged)]
189pub enum ChannelMembers {
190 All(String),
191 Explicit(Vec<String>),
192}
193
194impl Default for ChannelMembers {
195 fn default() -> Self {
196 Self::Explicit(Vec::new())
197 }
198}
199
200impl ChannelMembers {
201 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
202 match self {
203 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
204 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
205 _ => false,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Agent {
212 #[serde(default = "default_runtime")]
213 pub runtime: String,
214 pub model: Option<String>,
215 pub role_prompt: Option<PathBuf>,
216 #[serde(default)]
217 pub permission_mode: Option<String>,
218 #[serde(default)]
219 pub telegram_inbox: bool,
220 #[serde(default)]
221 pub reports_to_user: bool,
222 #[serde(default = "default_autonomy")]
223 pub autonomy: String,
224 #[serde(default)]
225 pub can_dm: Vec<String>,
226 #[serde(default)]
227 pub can_broadcast: Vec<String>,
228 #[serde(default)]
229 pub reports_to: Option<String>,
230}
231
232fn default_runtime() -> String {
233 "claude-code".into()
234}
235
236fn default_autonomy() -> String {
237 "low_risk_only".into()
238}
239
240#[derive(Debug, Clone)]
242pub struct Compose {
243 pub root: PathBuf,
244 pub global: Global,
245 pub projects: Vec<Project>,
246}
247
248impl Compose {
249 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
251 let root = root.as_ref().to_path_buf();
252 let global_path = root.join("team-compose.yaml");
253 let global: Global = serde_yaml::from_str(
254 &std::fs::read_to_string(&global_path)
255 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
256 )
257 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
258
259 let mut projects = Vec::with_capacity(global.projects.len());
260 for r in &global.projects {
261 let p = root.join(&r.file);
262 let parsed: Project = serde_yaml::from_str(
263 &std::fs::read_to_string(&p)
264 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
265 )
266 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
267 projects.push(parsed);
268 }
269
270 Ok(Self {
271 root,
272 global,
273 projects,
274 })
275 }
276
277 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
279 self.projects.iter().flat_map(|p| {
280 p.managers
281 .iter()
282 .map(move |(id, a)| AgentHandle {
283 project: &p.project.id,
284 agent: id,
285 spec: a,
286 is_manager: true,
287 })
288 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
289 project: &p.project.id,
290 agent: id,
291 spec: a,
292 is_manager: false,
293 }))
294 })
295 }
296}
297
298#[derive(Debug, Clone, Copy)]
299pub struct AgentHandle<'a> {
300 pub project: &'a str,
301 pub agent: &'a str,
302 pub spec: &'a Agent,
303 pub is_manager: bool,
304}
305
306impl AgentHandle<'_> {
307 pub fn id(&self) -> String {
309 format!("{}:{}", self.project, self.agent)
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn channel_members_all_expands() {
319 let all = ChannelMembers::All("*".into());
320 assert!(all.includes("dev1", &["dev1", "dev2"]));
321 assert!(!all.includes("ghost", &["dev1", "dev2"]));
322 }
323
324 #[test]
325 fn channel_members_explicit_checks_list() {
326 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
327 assert!(exp.includes("dev1", &[]));
328 assert!(!exp.includes("dev2", &[]));
329 }
330
331 #[test]
332 fn agent_defaults_are_stable() {
333 let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
334 assert_eq!(a.runtime, "claude-code");
335 assert_eq!(a.autonomy, "low_risk_only");
336 assert!(!a.telegram_inbox);
337 }
338}