Skip to main content

team_core/
compose.rs

1//! YAML schema for `team-compose.yaml` and `projects/<id>.yaml`.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8/// Top-level `team-compose.yaml`.
9#[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    /// Human-facing inbound channels. Telegram is one adapter; Discord,
29    /// iMessage, CLI, and webhook share the same shape.
30    #[serde(default)]
31    pub interfaces: Vec<Interface>,
32
33    /// Relative paths from the compose root.
34    #[serde(default)]
35    pub projects: Vec<ProjectRef>,
36
37    /// T-32 file attachments. Optional — omit the entire block to get
38    /// default behavior (enabled, 5MB cap, `$HOME` allowed root, no
39    /// scanner, no audit log). Each field is also optional.
40    #[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    /// Roots the attachment path must be a descendant of after
51    /// canonicalization. Default is the operator's `$HOME` (resolved
52    /// at policy-check time, not at deserialize time, so a snapshot
53    /// taken on machine A still resolves correctly on machine B).
54    #[serde(default = "default_attachments_allowed_roots")]
55    pub allowed_roots: Vec<String>,
56    #[serde(default)]
57    pub scanner: Option<AttachmentScanner>,
58    /// When set, every attempt is appended to this file (path,
59    /// sha256, size, accept/reject, scanner stderr). Relative paths
60    /// resolve against the compose root.
61    #[serde(default)]
62    pub audit_log_path: Option<PathBuf>,
63    /// T-32b: TTL in seconds for staged tempfiles in
64    /// `state/attachments-staging/`. The agent's `read_attachment`
65    /// MCP tool returns a staging path; the file lives until the TTL
66    /// expires (sweep on team-mcp startup) or the operator explicitly
67    /// persists it via a future tool. Default 6h gives an LLM
68    /// session enough room to round-trip without the staging dir
69    /// bloating indefinitely.
70    #[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    /// Operator-provided executable. Spawned per attempt with the
90    /// resolved path as a single argument; non-zero exit → reject.
91    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    /// Adapter type: `telegram`, `discord`, `imessage`, `cli`, `webhook`, ...
119    pub r#type: String,
120    /// Free-form name; used in logs and to route approvals.
121    pub name: String,
122    /// Adapter-specific config (bot token, channel id, allowlist, …).
123    #[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    /// `<project>:<manager>` this interface routes to, when set.
133    pub fn manager(&self) -> Option<String> {
134        self.config_str("manager")
135    }
136
137    /// Env var name holding the bot token (e.g. `TEAMCTL_TG_PM_TOKEN`).
138    pub fn bot_token_env(&self) -> Option<String> {
139        self.config_str("bot_token_env")
140    }
141
142    /// Env var name holding a comma-separated allow-list of chat ids.
143    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/// Rate-limit handling policy.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct RateLimits {
173    /// Default hook-name chain to run on a hit. Empty means `[wait]`.
174    #[serde(default)]
175    pub default_on_hit: Vec<String>,
176
177    /// Named hooks. Agents reference these by name in their `on_rate_limit:`.
178    #[serde(default)]
179    pub hooks: Vec<RateLimitHook>,
180
181    /// Fallback wait when the hit can't be parsed for a reset time.
182    /// Default 30 minutes.
183    #[serde(default = "default_fallback_wait")]
184    pub fallback_wait_seconds: u64,
185}
186
187fn default_fallback_wait() -> u64 {
188    30 * 60
189}
190
191/// One named action that can run on a rate-limit hit.
192///
193/// `action` is one of:
194/// - `wait` — sleep until `resets_at` (or `fallback_wait_seconds`).
195/// - `send` — write a message into the mailbox; `to` and `template` required.
196/// - `webhook` — POST/GET to `url` (or `url_env`); the rate-limit row
197///   serializes as JSON in the body.
198/// - `run` — exec `command` with placeholders substituted.
199///
200/// Placeholders in `template` and `command` arguments:
201/// `{agent}`, `{runtime}`, `{hit_at}`, `{resets_at}`, `{resets_at_local}`,
202/// `{raw_match}`.
203#[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    /// RFC 3339 timestamp in UTC.
261    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    /// Seconds reload waits for an agent to exit gracefully after
301    /// SIGINT before falling through to a hard `kill-session`. Default
302    /// 10 — enough for an in-flight Claude Code tool call to finish
303    /// in the common case, short enough that operators don't sit
304    /// staring at a frozen reload. Set to 0 to disable graceful
305    /// drain (matches pre-PR-B hard-kill behaviour).
306    #[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/// Per-project file, e.g. `projects/hello.yaml`.
333#[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    /// Either a list of agent ids or the literal string `"*"`.
359    #[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/// Reference to one or more role-instruction markdown files.
387///
388/// Single-string form (current) keeps every existing compose parsing
389/// unchanged. List form lets a role compose from multiple files
390/// concatenated in declared order at boot — base + tweaks without
391/// duplicating shared role copy.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(untagged)]
394pub enum RolePrompt {
395    Single(PathBuf),
396    Multiple(Vec<PathBuf>),
397}
398
399impl RolePrompt {
400    /// All source paths in declared order. Single yields a one-element
401    /// slice; Multiple yields the list as-is.
402    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    /// True when the configured value resolves to no actual source
410    /// path: an empty string in the single form, or an empty list in
411    /// the multi form. Renderer would silently produce
412    /// `SYSTEM_PROMPT_PATH=<root>/` otherwise — caught at validate.
413    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    /// Override the global rate-limit hook chain for this agent.
439    #[serde(default)]
440    pub on_rate_limit: Option<Vec<String>>,
441
442    /// Per-agent reasoning effort. Renders as `EFFORT=<value>` in the
443    /// agent env file; the wrapper passes it to the runtime (e.g.
444    /// `claude --effort <value>`). Strict enum: typos like `hgih` fail
445    /// compose validation rather than silently falling back to the
446    /// wrapper default.
447    #[serde(default)]
448    pub effort: Option<EffortLevel>,
449
450    /// Per-manager human-facing interfaces. Today's only adapter is
451    /// `telegram`; the shape is reserved for future adapters
452    /// (`discord`, `imessage`, …) so a manager can declare every
453    /// channel it speaks on in one place. Workers leave this unset.
454    #[serde(default)]
455    pub interfaces: Option<AgentInterfaces>,
456
457    /// T-160: optional human-friendly label rendered by the TUI in
458    /// place of the agent id (roster, details header, mailbox row
459    /// attribution, statusline). Absent → render the agent id (current
460    /// behavior). Validation: non-empty, ≤64 chars, UTF-8 anything.
461    /// The agent id stays canonical for routing, tmux session names,
462    /// CLI args, and YAML cross-refs (`can_dm`, `can_broadcast`,
463    /// `reports_to`) — display_name is render-time only.
464    #[serde(default)]
465    pub display_name: Option<String>,
466}
467
468/// Container for per-manager interface adapters. Open shape so adding
469/// `discord:` / `imessage:` later is a strictly-additive YAML edit.
470#[derive(Debug, Clone, Serialize, Deserialize, Default)]
471pub struct AgentInterfaces {
472    /// 1:1 Telegram bot for this manager. When set, `teamctl up`
473    /// spawns a `team-bot` tmux session scoped to this manager so the
474    /// human DMs the bot directly (no `/dm role text` required).
475    /// Configured by `teamctl bot setup`.
476    #[serde(default)]
477    pub telegram: Option<TelegramConfig>,
478}
479
480/// Per-manager Telegram bot config. Both fields are env-var *names* —
481/// the actual token/chat-ids live in `.team/.env` (kept out of git).
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct TelegramConfig {
484    /// Env var holding the BotFather token. Default chosen by
485    /// `teamctl bot setup`: `TEAMCTL_TG_<MANAGER>_TOKEN`.
486    pub bot_token_env: String,
487    /// Env var holding a comma-separated list of authorized chat ids.
488    /// Default: `TEAMCTL_TG_<MANAGER>_CHATS`.
489    pub chat_ids_env: String,
490    /// Optional speech-to-text provider for voice messages. When set,
491    /// inbound Telegram voice notes are transcribed and forwarded to the
492    /// agent prefixed so the model knows the input came from audio.
493    /// Absent → voice messages stay unhandled (default).
494    #[serde(default)]
495    pub speech_to_text: Option<SttConfig>,
496}
497
498/// Speech-to-text settings for the per-manager Telegram bot. The provider
499/// arm is the only switch v1 needs (`groq`); adding OpenAI Whisper or
500/// whisper.cpp later is one match arm in `team-bot`'s transcribe function.
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct SttConfig {
503    /// Provider arm. v1: `groq`.
504    pub provider: String,
505    /// Env var holding the provider's API key (mirrors `bot_token_env`).
506    /// The actual secret lives in `.team/.env` and is resolved by
507    /// `teamctl bot up` at spawn time before being passed to `team-bot`.
508    pub api_key_env: String,
509    /// Provider model id (e.g. `whisper-large-v3` for Groq).
510    pub model: String,
511    /// Optional ISO-639 language hint forwarded verbatim to the provider
512    /// (e.g. `en`, `fa`). When unset, the provider auto-detects.
513    #[serde(default)]
514    pub language: Option<String>,
515}
516
517impl Agent {
518    /// Convenience: pull the manager's Telegram config out of
519    /// `interfaces.telegram` without forcing every callsite to handle
520    /// the nested options.
521    pub fn telegram(&self) -> Option<&TelegramConfig> {
522        self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
523    }
524}
525
526/// Reasoning-effort level forwarded to the runtime. Maps 1:1 to
527/// `claude --effort <value>` today; if the runtime taxonomy evolves we
528/// extend the enum and bump the schema version.
529#[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    /// Lowercase rendering for the env-file `EFFORT=<value>` line and
541    /// the `claude --effort <value>` CLI flag.
542    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/// Fully loaded compose tree: global + resolved projects.
562#[derive(Debug, Clone)]
563pub struct Compose {
564    pub root: PathBuf,
565    pub global: Global,
566    pub projects: Vec<Project>,
567}
568
569impl Compose {
570    /// Walk up from `start` looking for the **first** `.team/team-compose.yaml`
571    /// and return the directory containing the compose file (the "root"),
572    /// suitable for passing to [`Compose::load`]. The first hit wins; we do
573    /// not keep walking past it to look for a parent `.team/`.
574    ///
575    /// This is the equivalent of git's `.git/` discovery — once a repo carries
576    /// a `.team/` folder, every `teamctl` subcommand finds it from anywhere
577    /// inside the tree. T-008 retired the legacy flat-layout fallback and
578    /// the second-hit / parent-`.team/` walk: the convention is `.team/` and
579    /// the nearest one wins, no exceptions.
580    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    /// Parse `team-compose.yaml` at `root` and every referenced project file.
599    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    /// Return every agent in the compose tree tagged with manager/worker.
627    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    /// Canonical id as `<project>:<agent>`.
657    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        // a stray flat-layout file in the same dir should NOT be preferred.
783        std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
784
785        // Walking up from a sub-dir should still find the .team/ root.
786        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        // T-008: a flat `team-compose.yaml` at cwd (no `.team/` wrapper) is
795        // not discoverable. The convention is `.team/`. Operators must
796        // either `init` a `.team/` or pass `--root` explicitly.
797        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        // T-008 boundary: nested `.team/`s win over outer ones. We do NOT
806        // keep walking past the first hit.
807        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}