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::fmt;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Deserializer, Serialize};
8
9/// T-265 PR-a: compose schema version. Stored as a semver string;
10/// validate-time check (`validate::validate`) enforces the semver
11/// shape via the `semver` crate.
12///
13/// **Custom Deserialize accepts two shapes:**
14///
15/// - YAML string (e.g. `version: "2.0.0"`) — taken verbatim; semver
16///   shape is checked later at validate time, NOT here, so the
17///   deserializer's job stays narrow (parse, not validate).
18/// - YAML integer literal `2` only (the one legacy value that ever
19///   shipped in any in-tree compose) → coerced to `SchemaVersion`
20///   carrying `"2.0.0"` AND flagged `from_legacy_int = true`. The
21///   load orchestration in [`Compose::load`] reads that flag to
22///   decide whether to auto-rewrite the on-disk file so the
23///   integer self-heals to the semver shape (owner-ratified tg
24///   2989 + tg 3440, "option 1 + variant A").
25///
26/// Anything else — `version: 1`, `version: 3`, `version: true`,
27/// `version: [1,2,3]` — fails to deserialize with a message that
28/// names the constraint: only `"X.Y.Z"` or the legacy `2`.
29///
30/// `from_legacy_int` is `#[serde(skip)]` so it never round-trips
31/// through serialize; it's a deserialize-side signal only.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(transparent)]
34pub struct SchemaVersion {
35    pub value: String,
36    #[serde(skip)]
37    pub from_legacy_int: bool,
38}
39
40impl SchemaVersion {
41    /// Construct directly from a semver-shaped string; for fixtures
42    /// and tests + the in-memory legacy coercion.
43    pub fn new(value: impl Into<String>) -> Self {
44        Self {
45            value: value.into(),
46            from_legacy_int: false,
47        }
48    }
49}
50
51impl<'de> Deserialize<'de> for SchemaVersion {
52    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
53        use serde::de::{self, Visitor};
54        struct V;
55        impl<'de> Visitor<'de> for V {
56            type Value = SchemaVersion;
57            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
58                f.write_str(
59                    "a semver string like \"2.0.0\" (legacy integer `2` also accepted \
60                     and auto-rewritten to \"2.0.0\" on next save)",
61                )
62            }
63            fn visit_str<E: de::Error>(self, s: &str) -> Result<SchemaVersion, E> {
64                Ok(SchemaVersion {
65                    value: s.to_string(),
66                    from_legacy_int: false,
67                })
68            }
69            fn visit_string<E: de::Error>(self, s: String) -> Result<SchemaVersion, E> {
70                Ok(SchemaVersion {
71                    value: s,
72                    from_legacy_int: false,
73                })
74            }
75            fn visit_u64<E: de::Error>(self, n: u64) -> Result<SchemaVersion, E> {
76                if n == 2 {
77                    Ok(SchemaVersion {
78                        value: "2.0.0".to_string(),
79                        from_legacy_int: true,
80                    })
81                } else {
82                    Err(E::custom(format!(
83                        "compose schema version must be a semver string like \"2.0.0\"; \
84                         got integer {n} — only legacy `2` is auto-coerced"
85                    )))
86                }
87            }
88            fn visit_i64<E: de::Error>(self, n: i64) -> Result<SchemaVersion, E> {
89                if n == 2 {
90                    Ok(SchemaVersion {
91                        value: "2.0.0".to_string(),
92                        from_legacy_int: true,
93                    })
94                } else {
95                    Err(E::custom(format!(
96                        "compose schema version must be a semver string like \"2.0.0\"; \
97                         got integer {n} — only legacy `2` is auto-coerced"
98                    )))
99                }
100            }
101        }
102        d.deserialize_any(V)
103    }
104}
105
106/// Top-level `team-compose.yaml`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Global {
109    pub version: SchemaVersion,
110
111    #[serde(default)]
112    pub broker: Broker,
113
114    #[serde(default)]
115    pub supervisor: SupervisorCfg,
116
117    #[serde(default)]
118    pub budget: Budget,
119
120    #[serde(default)]
121    pub hitl: Hitl,
122
123    #[serde(default)]
124    pub rate_limits: RateLimits,
125
126    /// Human-facing inbound channels. Telegram is one adapter; Discord,
127    /// iMessage, CLI, and webhook share the same shape.
128    #[serde(default)]
129    pub interfaces: Vec<Interface>,
130
131    /// Relative paths from the compose root.
132    #[serde(default)]
133    pub projects: Vec<ProjectRef>,
134
135    /// T-32 file attachments. Optional — omit the entire block to get
136    /// default behavior (enabled, 5MB cap, `$HOME` allowed root, no
137    /// scanner, no audit log). Each field is also optional.
138    #[serde(default)]
139    pub attachments: Attachments,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct Attachments {
144    #[serde(default = "default_attachments_enabled")]
145    pub enabled: bool,
146    #[serde(default = "default_attachments_max_size_bytes")]
147    pub max_size_bytes: u64,
148    /// Roots the attachment path must be a descendant of after
149    /// canonicalization. Default is the operator's `$HOME` (resolved
150    /// at policy-check time, not at deserialize time, so a snapshot
151    /// taken on machine A still resolves correctly on machine B).
152    #[serde(default = "default_attachments_allowed_roots")]
153    pub allowed_roots: Vec<String>,
154    #[serde(default)]
155    pub scanner: Option<AttachmentScanner>,
156    /// When set, every attempt is appended to this file (path,
157    /// sha256, size, accept/reject, scanner stderr). Relative paths
158    /// resolve against the compose root.
159    #[serde(default)]
160    pub audit_log_path: Option<PathBuf>,
161    /// T-32b: TTL in seconds for staged tempfiles in
162    /// `state/attachments-staging/`. The agent's `read_attachment`
163    /// MCP tool returns a staging path; the file lives until the TTL
164    /// expires (sweep on team-mcp startup) or the operator explicitly
165    /// persists it via a future tool. Default 6h gives an LLM
166    /// session enough room to round-trip without the staging dir
167    /// bloating indefinitely.
168    #[serde(default = "default_attachments_tempfile_ttl_seconds")]
169    pub tempfile_ttl_seconds: u64,
170}
171
172impl Default for Attachments {
173    fn default() -> Self {
174        Self {
175            enabled: default_attachments_enabled(),
176            max_size_bytes: default_attachments_max_size_bytes(),
177            allowed_roots: default_attachments_allowed_roots(),
178            scanner: None,
179            audit_log_path: None,
180            tempfile_ttl_seconds: default_attachments_tempfile_ttl_seconds(),
181        }
182    }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct AttachmentScanner {
187    /// Operator-provided executable. Spawned per attempt with the
188    /// resolved path as a single argument; non-zero exit → reject.
189    pub command: String,
190    #[serde(default = "default_scanner_timeout_seconds")]
191    pub timeout_seconds: u64,
192}
193
194fn default_attachments_enabled() -> bool {
195    true
196}
197
198fn default_attachments_max_size_bytes() -> u64 {
199    5 * 1024 * 1024
200}
201
202fn default_attachments_allowed_roots() -> Vec<String> {
203    vec!["$HOME".to_string()]
204}
205
206fn default_scanner_timeout_seconds() -> u64 {
207    30
208}
209
210fn default_attachments_tempfile_ttl_seconds() -> u64 {
211    6 * 60 * 60
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct Interface {
216    /// Adapter type: `telegram`, `discord`, `imessage`, `cli`, `webhook`, ...
217    pub r#type: String,
218    /// Free-form name; used in logs and to route approvals.
219    pub name: String,
220    /// Adapter-specific config (bot token, channel id, allowlist, …).
221    #[serde(default)]
222    pub config: serde_yaml::Value,
223}
224
225impl Interface {
226    pub fn is_telegram(&self) -> bool {
227        self.r#type == "telegram"
228    }
229
230    /// `<project>:<manager>` this interface routes to, when set.
231    pub fn manager(&self) -> Option<String> {
232        self.config_str("manager")
233    }
234
235    /// Env var name holding the bot token (e.g. `TEAMCTL_TG_PM_TOKEN`).
236    pub fn bot_token_env(&self) -> Option<String> {
237        self.config_str("bot_token_env")
238    }
239
240    /// Env var name holding a comma-separated allow-list of chat ids.
241    pub fn authorized_chat_ids_env(&self) -> Option<String> {
242        self.config_str("authorized_chat_ids_env")
243    }
244
245    fn config_str(&self, key: &str) -> Option<String> {
246        match &self.config {
247            serde_yaml::Value::Mapping(m) => m
248                .get(serde_yaml::Value::String(key.into()))
249                .and_then(|v| v.as_str())
250                .map(str::to_owned),
251            _ => None,
252        }
253    }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, Default)]
257pub struct Budget {
258    #[serde(default)]
259    pub daily_usd_limit: Option<f64>,
260    #[serde(default)]
261    pub warn_threshold_pct: Option<u32>,
262    #[serde(default)]
263    pub message_ttl_hours: Option<u32>,
264    #[serde(default)]
265    pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
266}
267
268/// Rate-limit handling policy.
269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
270pub struct RateLimits {
271    /// Default hook-name chain to run on a hit. Empty means `[wait]`.
272    #[serde(default)]
273    pub default_on_hit: Vec<String>,
274
275    /// Named hooks. Agents reference these by name in their `on_rate_limit:`.
276    #[serde(default)]
277    pub hooks: Vec<RateLimitHook>,
278
279    /// Fallback wait when the hit can't be parsed for a reset time.
280    /// Default 30 minutes.
281    #[serde(default = "default_fallback_wait")]
282    pub fallback_wait_seconds: u64,
283}
284
285fn default_fallback_wait() -> u64 {
286    30 * 60
287}
288
289/// One named action that can run on a rate-limit hit.
290///
291/// `action` is one of:
292/// - `wait` — sleep until `resets_at` (or `fallback_wait_seconds`).
293/// - `send` — write a message into the mailbox; `to` and `template` required.
294/// - `webhook` — POST/GET to `url` (or `url_env`); the rate-limit row
295///   serializes as JSON in the body.
296/// - `run` — exec `command` with placeholders substituted.
297///
298/// Placeholders in `template` and `command` arguments:
299/// `{agent}`, `{runtime}`, `{hit_at}`, `{resets_at}`, `{resets_at_local}`,
300/// `{raw_match}`.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct RateLimitHook {
303    pub name: String,
304    pub action: String,
305    #[serde(default)]
306    pub to: Option<String>,
307    #[serde(default)]
308    pub template: Option<String>,
309    #[serde(default)]
310    pub url: Option<String>,
311    #[serde(default)]
312    pub url_env: Option<String>,
313    #[serde(default)]
314    pub method: Option<String>,
315    #[serde(default)]
316    pub command: Vec<String>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct Hitl {
321    #[serde(default = "default_sensitive_actions")]
322    pub globally_sensitive_actions: Vec<String>,
323    #[serde(default)]
324    pub auto_approve_windows: Vec<AutoApprove>,
325}
326
327impl Default for Hitl {
328    fn default() -> Self {
329        Self {
330            globally_sensitive_actions: default_sensitive_actions(),
331            auto_approve_windows: Vec::new(),
332        }
333    }
334}
335
336fn default_sensitive_actions() -> Vec<String> {
337    vec![
338        "publish".into(),
339        "release".into(),
340        "payment".into(),
341        "external_email".into(),
342        "external_api_post".into(),
343        "merge_to_main".into(),
344        "dns_change".into(),
345        "deploy".into(),
346    ]
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct AutoApprove {
351    pub action: String,
352    #[serde(default)]
353    pub project: Option<String>,
354    #[serde(default)]
355    pub agent: Option<String>,
356    #[serde(default)]
357    pub scope: Option<String>,
358    /// RFC 3339 timestamp in UTC.
359    pub until: String,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ProjectRef {
364    pub file: PathBuf,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368pub struct Broker {
369    #[serde(default = "default_broker_type")]
370    pub r#type: String,
371    #[serde(default = "default_mailbox_path")]
372    pub path: PathBuf,
373}
374
375impl Default for Broker {
376    fn default() -> Self {
377        Self {
378            r#type: default_broker_type(),
379            path: default_mailbox_path(),
380        }
381    }
382}
383
384fn default_broker_type() -> String {
385    "sqlite".into()
386}
387
388fn default_mailbox_path() -> PathBuf {
389    PathBuf::from("state/mailbox.db")
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
393pub struct SupervisorCfg {
394    #[serde(default = "default_supervisor_type")]
395    pub r#type: String,
396    #[serde(default = "default_tmux_prefix")]
397    pub tmux_prefix: String,
398    /// Seconds reload waits for an agent to exit gracefully after
399    /// SIGINT before falling through to a hard `kill-session`. Default
400    /// 10 — enough for an in-flight Claude Code tool call to finish
401    /// in the common case, short enough that operators don't sit
402    /// staring at a frozen reload. Set to 0 to disable graceful
403    /// drain (matches pre-PR-B hard-kill behaviour).
404    #[serde(default = "default_drain_timeout_secs")]
405    pub drain_timeout_secs: u64,
406}
407
408impl Default for SupervisorCfg {
409    fn default() -> Self {
410        Self {
411            r#type: default_supervisor_type(),
412            tmux_prefix: default_tmux_prefix(),
413            drain_timeout_secs: default_drain_timeout_secs(),
414        }
415    }
416}
417
418fn default_supervisor_type() -> String {
419    "tmux".into()
420}
421
422fn default_drain_timeout_secs() -> u64 {
423    10
424}
425
426fn default_tmux_prefix() -> String {
427    "a-".into()
428}
429
430/// Per-project file, e.g. `projects/hello.yaml`.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct Project {
433    pub version: u32,
434    pub project: ProjectMeta,
435
436    #[serde(default)]
437    pub channels: Vec<Channel>,
438
439    #[serde(default)]
440    pub managers: BTreeMap<String, Agent>,
441
442    #[serde(default)]
443    pub workers: BTreeMap<String, Agent>,
444
445    /// Project-scoped human-facing interfaces (#132 PR-1). Mirrors the
446    /// per-agent `Agent.interfaces` shape one level up — `telegram` is
447    /// today's only adapter, with room for future `discord:` /
448    /// `imessage:` under the same `ProjectInterfaces` container. Hosts
449    /// the shared bot-family config (manager bot for managed-bots flow,
450    /// profile-picture defaults) that's scoped to one project's bot
451    /// family but spawns N per-agent children — not per-agent because
452    /// it's shared infra, not global because each project deserves its
453    /// own bot-family identity. Absent → existing manual BotFather
454    /// per-manager flow runs verbatim (zero-touch).
455    #[serde(default)]
456    pub interfaces: Option<ProjectInterfaces>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct ProjectMeta {
461    pub id: String,
462    pub name: String,
463    pub cwd: PathBuf,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct Channel {
468    pub name: String,
469    /// Either a list of agent ids or the literal string `"*"`.
470    #[serde(default)]
471    pub members: ChannelMembers,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(untagged)]
476pub enum ChannelMembers {
477    All(String),
478    Explicit(Vec<String>),
479}
480
481impl Default for ChannelMembers {
482    fn default() -> Self {
483        Self::Explicit(Vec::new())
484    }
485}
486
487impl ChannelMembers {
488    pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
489        match self {
490            ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
491            ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
492            _ => false,
493        }
494    }
495}
496
497/// Reference to one or more role-instruction markdown files.
498///
499/// Single-string form (current) keeps every existing compose parsing
500/// unchanged. List form lets a role compose from multiple files
501/// concatenated in declared order at boot — base + tweaks without
502/// duplicating shared role copy.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(untagged)]
505pub enum RolePrompt {
506    Single(PathBuf),
507    Multiple(Vec<PathBuf>),
508}
509
510impl RolePrompt {
511    /// All source paths in declared order. Single yields a one-element
512    /// slice; Multiple yields the list as-is.
513    pub fn paths(&self) -> Vec<&Path> {
514        match self {
515            RolePrompt::Single(p) => vec![p.as_path()],
516            RolePrompt::Multiple(v) => v.iter().map(|p| p.as_path()).collect(),
517        }
518    }
519
520    /// True when the configured value resolves to no actual source
521    /// path: an empty string in the single form, or an empty list in
522    /// the multi form. Renderer would silently produce
523    /// `SYSTEM_PROMPT_PATH=<root>/` otherwise — caught at validate.
524    pub fn is_blank(&self) -> bool {
525        match self {
526            RolePrompt::Single(p) => p.as_os_str().is_empty(),
527            RolePrompt::Multiple(v) => v.is_empty(),
528        }
529    }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct Agent {
534    #[serde(default = "default_runtime")]
535    pub runtime: String,
536    pub model: Option<String>,
537    pub role_prompt: Option<RolePrompt>,
538    #[serde(default)]
539    pub permission_mode: Option<String>,
540    #[serde(default = "default_autonomy")]
541    pub autonomy: String,
542    #[serde(default)]
543    pub can_dm: Vec<String>,
544    #[serde(default)]
545    pub can_broadcast: Vec<String>,
546    #[serde(default)]
547    pub reports_to: Option<String>,
548
549    /// Override the global rate-limit hook chain for this agent.
550    #[serde(default)]
551    pub on_rate_limit: Option<Vec<String>>,
552
553    /// Per-agent reasoning effort. Renders as `EFFORT=<value>` in the
554    /// agent env file; the wrapper passes it to the runtime (e.g.
555    /// `claude --effort <value>`). Strict enum: typos like `hgih` fail
556    /// compose validation rather than silently falling back to the
557    /// wrapper default.
558    #[serde(default)]
559    pub effort: Option<EffortLevel>,
560
561    /// Per-manager human-facing interfaces. Today's only adapter is
562    /// `telegram`; the shape is reserved for future adapters
563    /// (`discord`, `imessage`, …) so a manager can declare every
564    /// channel it speaks on in one place. Workers leave this unset.
565    #[serde(default)]
566    pub interfaces: Option<AgentInterfaces>,
567
568    /// T-160: optional human-friendly label rendered by the TUI in
569    /// place of the agent id (roster, details header, mailbox row
570    /// attribution, statusline). Absent → render the agent id (current
571    /// behavior). Validation: non-empty, ≤64 chars, UTF-8 anything.
572    /// The agent id stays canonical for routing, tmux session names,
573    /// CLI args, and YAML cross-refs (`can_dm`, `can_broadcast`,
574    /// `reports_to`) — display_name is render-time only.
575    #[serde(default)]
576    pub display_name: Option<String>,
577}
578
579/// Container for per-manager interface adapters. Open shape so adding
580/// `discord:` / `imessage:` later is a strictly-additive YAML edit.
581#[derive(Debug, Clone, Serialize, Deserialize, Default)]
582pub struct AgentInterfaces {
583    /// 1:1 Telegram bot for this manager. When set, `teamctl up`
584    /// spawns a `team-bot` tmux session scoped to this manager so the
585    /// human DMs the bot directly (no `/dm role text` required).
586    /// Configured by `teamctl bot setup`.
587    #[serde(default)]
588    pub telegram: Option<TelegramConfig>,
589}
590
591/// Per-manager Telegram bot config. Both fields are env-var *names* —
592/// the actual token/chat-ids live in `.team/.env` (kept out of git).
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct TelegramConfig {
595    /// Env var holding the BotFather token. Default chosen by
596    /// `teamctl bot setup`: `TEAMCTL_TG_<MANAGER>_TOKEN`.
597    pub bot_token_env: String,
598    /// Env var holding a comma-separated list of authorized chat ids.
599    /// Default: `TEAMCTL_TG_<MANAGER>_CHATS`.
600    pub chat_ids_env: String,
601    /// Optional speech-to-text provider for voice messages. When set,
602    /// inbound Telegram voice notes are transcribed and forwarded to the
603    /// agent prefixed so the model knows the input came from audio.
604    /// Absent → voice messages stay unhandled (default).
605    #[serde(default)]
606    pub speech_to_text: Option<SttConfig>,
607}
608
609/// Speech-to-text settings for the per-manager Telegram bot. The provider
610/// arm is the only switch v1 needs (`groq`); adding OpenAI Whisper or
611/// whisper.cpp later is one match arm in `team-bot`'s transcribe function.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct SttConfig {
614    /// Provider arm. v1: `groq`.
615    pub provider: String,
616    /// Env var holding the provider's API key (mirrors `bot_token_env`).
617    /// The actual secret lives in `.team/.env` and is resolved by
618    /// `teamctl bot up` at spawn time before being passed to `team-bot`.
619    pub api_key_env: String,
620    /// Provider model id (e.g. `whisper-large-v3` for Groq).
621    pub model: String,
622    /// Optional ISO-639 language hint forwarded verbatim to the provider
623    /// (e.g. `en`, `fa`). When unset, the provider auto-detects.
624    #[serde(default)]
625    pub language: Option<String>,
626}
627
628impl Agent {
629    /// Convenience: pull the manager's Telegram config out of
630    /// `interfaces.telegram` without forcing every callsite to handle
631    /// the nested options.
632    pub fn telegram(&self) -> Option<&TelegramConfig> {
633        self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
634    }
635}
636
637/// #132 PR-1: project-scoped interface container. One level up from
638/// `AgentInterfaces`, same open-shape rationale — today's only adapter
639/// is `telegram`, future `discord:` / `imessage:` slot in as
640/// strictly-additive YAML edits.
641#[derive(Debug, Clone, Serialize, Deserialize, Default)]
642pub struct ProjectInterfaces {
643    /// Project-scoped Telegram config: the manager bot that spawns
644    /// per-agent children + default profile-picture rendering. Per-
645    /// agent telegram config (under `Agent.interfaces.telegram`) is
646    /// orthogonal — agents still declare their own `bot_token_env` /
647    /// `chat_ids_env` slots; the managed-bots flow writes child tokens
648    /// into those slots untouched.
649    #[serde(default)]
650    pub telegram: Option<ProjectTelegramConfig>,
651}
652
653/// #132 PR-1: project-scoped Telegram config. Hosts the shared-infra
654/// fields (manager bot, profile-picture defaults) that drive the
655/// managed-bots flow in `teamctl bot setup`. Absent / both fields
656/// absent → existing manual BotFather walkthrough runs verbatim for
657/// each per-agent `Agent.interfaces.telegram` block (zero-touch).
658#[derive(Debug, Clone, Serialize, Deserialize, Default)]
659pub struct ProjectTelegramConfig {
660    /// Manager bot for the managed-bots flow. When set + its env var
661    /// resolves, `teamctl bot setup` uses it to spawn per-agent child
662    /// bots via Telegram's Bot API 10.0 managed-bot endpoints. Absent
663    /// → operator runs the manual BotFather flow per agent as today.
664    #[serde(default)]
665    pub manager_bot: Option<ManagerBotConfig>,
666
667    /// Default profile-picture rendering for spawned child bots.
668    /// Image-model path is opt-in; absent / failure-of-generation
669    /// falls back to deterministic initials-in-colored-circle (Q3-
670    /// ratified). When the whole block is absent, no profile-picture
671    /// is applied — child bots keep Telegram's default avatar.
672    #[serde(default)]
673    pub profile_picture: Option<ProfilePictureConfig>,
674}
675
676/// #132 PR-1: manager-bot config. Mirrors the env-var-name pattern of
677/// `TelegramConfig.bot_token_env` — the actual BotFather token lives
678/// in `.team/.env`, this field names the env var that holds it.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct ManagerBotConfig {
681    /// Env var holding the manager bot's BotFather token. The
682    /// operator-facing setup steps (BotFather click path to enable the
683    /// Managed Bots capability) are documented in `docs/`; this schema
684    /// pins the env-var name the wizard reads at setup time.
685    pub token_env: String,
686}
687
688/// #132 PR-1: profile-picture rendering settings for spawned child
689/// bots. `image_model` is opt-in for AI-generated avatars; `fallback`
690/// names the rendering used when `image_model` is absent OR generation
691/// fails OR the API key is missing. Q3 (owner-ratified, tg 3445):
692/// initials-in-colored-circle, no embedded emoji-font binary growth.
693#[derive(Debug, Clone, Serialize, Deserialize, Default)]
694pub struct ProfilePictureConfig {
695    /// AI image-generation config. When set + API key resolves, the
696    /// wizard generates a square 512×512 image seeded from the agent's
697    /// name + role and applies it via Bot API `setProfilePhoto`. Any
698    /// failure path (missing API key, generation error, upload error)
699    /// falls through to `fallback`.
700    #[serde(default)]
701    pub image_model: Option<ImageModelConfig>,
702
703    /// Fallback rendering when `image_model` is absent or fails. v1
704    /// has one variant (`Initials`); the field is explicit so future
705    /// variants (per-agent override, `None`-to-skip) slot in
706    /// additively without breaking existing YAML.
707    #[serde(default)]
708    pub fallback: ProfilePictureFallback,
709}
710
711/// #132 PR-1: profile-picture fallback rendering. Q3-ratified to
712/// initials-in-colored-circle for v1. Enum-shaped so future variants
713/// (e.g. `Emoji`, `None`) land additively; current single variant is
714/// the default so omitting `fallback:` in YAML keeps the contract.
715#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
716#[serde(rename_all = "lowercase")]
717pub enum ProfilePictureFallback {
718    /// Render a deterministic colored circle with the agent's
719    /// uppercase initials (Slack-style). Deterministic = same agent
720    /// name always renders the same circle, so rebuilds don't shuffle.
721    #[default]
722    Initials,
723}
724
725/// #132 PR-1: AI image-generation config for child-bot profile
726/// pictures. Mirrors the `SttConfig` shape (provider / api_key_env /
727/// model). v1 provider is `openai`; adding a future provider is one
728/// match arm in the call site.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct ImageModelConfig {
731    /// Provider arm. v1: `openai`.
732    pub provider: String,
733    /// Env var holding the provider's API key (mirrors `bot_token_env`
734    /// and `SttConfig.api_key_env`). The actual secret lives in
735    /// `.team/.env` and is resolved by `teamctl bot setup` at wizard
736    /// time.
737    pub api_key_env: String,
738    /// Provider model id (e.g. `gpt-image-2` for OpenAI; snapshot id
739    /// `gpt-image-2-2026-04-21` for pinning).
740    pub model: String,
741}
742
743impl Project {
744    /// Convenience: pull the project's Telegram config out of
745    /// `interfaces.telegram` without forcing every callsite to handle
746    /// the nested options. Mirrors [`Agent::telegram`].
747    pub fn telegram(&self) -> Option<&ProjectTelegramConfig> {
748        self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
749    }
750}
751
752/// Reasoning-effort level forwarded to the runtime. Maps 1:1 to
753/// `claude --effort <value>` today; if the runtime taxonomy evolves we
754/// extend the enum and bump the schema version.
755#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
756#[serde(rename_all = "lowercase")]
757pub enum EffortLevel {
758    Low,
759    Medium,
760    High,
761    Xhigh,
762    Max,
763}
764
765impl EffortLevel {
766    /// Lowercase rendering for the env-file `EFFORT=<value>` line and
767    /// the `claude --effort <value>` CLI flag.
768    pub fn as_str(self) -> &'static str {
769        match self {
770            EffortLevel::Low => "low",
771            EffortLevel::Medium => "medium",
772            EffortLevel::High => "high",
773            EffortLevel::Xhigh => "xhigh",
774            EffortLevel::Max => "max",
775        }
776    }
777}
778
779fn default_runtime() -> String {
780    "claude-code".into()
781}
782
783fn default_autonomy() -> String {
784    "low_risk_only".into()
785}
786
787/// Fully loaded compose tree: global + resolved projects.
788#[derive(Debug, Clone)]
789pub struct Compose {
790    pub root: PathBuf,
791    pub global: Global,
792    pub projects: Vec<Project>,
793}
794
795impl Compose {
796    /// Walk up from `start` looking for the **first** `.team/team-compose.yaml`
797    /// and return the directory containing the compose file (the "root"),
798    /// suitable for passing to [`Compose::load`]. The first hit wins; we do
799    /// not keep walking past it to look for a parent `.team/`.
800    ///
801    /// This is the equivalent of git's `.git/` discovery — once a repo carries
802    /// a `.team/` folder, every `teamctl` subcommand finds it from anywhere
803    /// inside the tree. T-008 retired the legacy flat-layout fallback and
804    /// the second-hit / parent-`.team/` walk: the convention is `.team/` and
805    /// the nearest one wins, no exceptions.
806    pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
807        let start = start
808            .canonicalize()
809            .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
810        let mut cur: Option<&Path> = Some(&start);
811        while let Some(dir) = cur {
812            let candidate = dir.join(".team").join("team-compose.yaml");
813            if candidate.is_file() {
814                return Ok(dir.join(".team"));
815            }
816            cur = dir.parent();
817        }
818        Err(anyhow::anyhow!(
819            "no `.team/team-compose.yaml` found in {} or any parent",
820            start.display()
821        ))
822    }
823
824    /// Parse `team-compose.yaml` at `root` and every referenced project file.
825    pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
826        let root = root.as_ref().to_path_buf();
827        let global_path = root.join("team-compose.yaml");
828        let raw = std::fs::read_to_string(&global_path)
829            .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?;
830        let global: Global = serde_yaml::from_str(&raw)
831            .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
832
833        // T-265 PR-a: legacy-`2` auto-rewrite on load. When the
834        // operator's compose still uses the pre-semver shape
835        // (`version: 2`, integer literal), the Deserialize impl on
836        // `SchemaVersion` has already coerced the in-memory value to
837        // `"2.0.0"` and flagged `from_legacy_int = true`. Now we
838        // best-effort rewrite the file so the on-disk shape matches
839        // the runtime semantics — eliminating the file-vs-runtime
840        // divergence the operator would otherwise see in git diff
841        // forever. On RO filesystems (CI sandboxes, immutable image
842        // mounts) or any other write failure, we emit a single warn
843        // and proceed with the in-memory normalized value rather
844        // than hard-erroring — owner-ratified (tg 3440, "RO-FS
845        // degrades to in-memory + warning"). Single hardcoded
846        // legacy-value exception, NOT the general migration engine
847        // — that stays deferred to its own ticket per #265's
848        // non-goals.
849        if global.version.from_legacy_int {
850            if let Err(e) = rewrite_legacy_version_in_file(&global_path, &raw) {
851                tracing::warn!(
852                    target: "team-core::compose",
853                    "could not rewrite legacy `version: 2` in {}: {e}; \
854                     proceeding with in-memory `\"2.0.0\"`",
855                    global_path.display()
856                );
857            }
858        }
859
860        let mut projects = Vec::with_capacity(global.projects.len());
861        for r in &global.projects {
862            let p = root.join(&r.file);
863            let parsed: Project = serde_yaml::from_str(
864                &std::fs::read_to_string(&p)
865                    .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
866            )
867            .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
868            projects.push(parsed);
869        }
870
871        Ok(Self {
872            root,
873            global,
874            projects,
875        })
876    }
877
878    /// Return every agent in the compose tree tagged with manager/worker.
879    pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
880        self.projects.iter().flat_map(|p| {
881            p.managers
882                .iter()
883                .map(move |(id, a)| AgentHandle {
884                    project: &p.project.id,
885                    agent: id,
886                    spec: a,
887                    is_manager: true,
888                })
889                .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
890                    project: &p.project.id,
891                    agent: id,
892                    spec: a,
893                    is_manager: false,
894                }))
895        })
896    }
897}
898
899/// T-265 PR-a: rewrite the legacy `version: 2` integer literal in
900/// `team-compose.yaml` to the semver string form `"2.0.0"`,
901/// preserving comments + key ordering via the `yaml_edit` substrate.
902/// Caller has already loaded the raw text (passed as `raw` to avoid
903/// a second disk read) and decided we're in the legacy path.
904fn rewrite_legacy_version_in_file(path: &Path, raw: &str) -> anyhow::Result<()> {
905    let updated = crate::yaml_edit::set_top_level_scalar(raw, "version", "\"2.0.0\"")?;
906    std::fs::write(path, updated).map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
907    Ok(())
908}
909
910#[derive(Debug, Clone, Copy)]
911pub struct AgentHandle<'a> {
912    pub project: &'a str,
913    pub agent: &'a str,
914    pub spec: &'a Agent,
915    pub is_manager: bool,
916}
917
918impl AgentHandle<'_> {
919    /// Canonical id as `<project>:<agent>`.
920    pub fn id(&self) -> String {
921        format!("{}:{}", self.project, self.agent)
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    #[test]
930    fn channel_members_all_expands() {
931        let all = ChannelMembers::All("*".into());
932        assert!(all.includes("dev1", &["dev1", "dev2"]));
933        assert!(!all.includes("ghost", &["dev1", "dev2"]));
934    }
935
936    #[test]
937    fn channel_members_explicit_checks_list() {
938        let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
939        assert!(exp.includes("dev1", &[]));
940        assert!(!exp.includes("dev2", &[]));
941    }
942
943    #[test]
944    fn agent_defaults_are_stable() {
945        let a: Agent = serde_yaml::from_str("model: claude-opus-4-8\n").unwrap();
946        assert_eq!(a.runtime, "claude-code");
947        assert_eq!(a.autonomy, "low_risk_only");
948        assert!(a.interfaces.is_none());
949        assert!(a.telegram().is_none());
950        assert!(a.effort.is_none());
951    }
952
953    #[test]
954    fn agent_telegram_block_parses_under_interfaces() {
955        let yaml = "interfaces:\n  telegram:\n    bot_token_env: T\n    chat_ids_env: C\n";
956        let a: Agent = serde_yaml::from_str(yaml).unwrap();
957        let tg = a.telegram().expect("telegram parsed");
958        assert_eq!(tg.bot_token_env, "T");
959        assert_eq!(tg.chat_ids_env, "C");
960        assert!(tg.speech_to_text.is_none());
961    }
962
963    #[test]
964    fn agent_telegram_block_parses_speech_to_text() {
965        let yaml = "\
966interfaces:
967  telegram:
968    bot_token_env: T
969    chat_ids_env: C
970    speech_to_text:
971      provider: groq
972      api_key_env: GROQ_API_KEY
973      model: whisper-large-v3
974      language: en
975";
976        let a: Agent = serde_yaml::from_str(yaml).unwrap();
977        let stt = a
978            .telegram()
979            .and_then(|t| t.speech_to_text.as_ref())
980            .expect("speech_to_text parsed");
981        assert_eq!(stt.provider, "groq");
982        assert_eq!(stt.api_key_env, "GROQ_API_KEY");
983        assert_eq!(stt.model, "whisper-large-v3");
984        assert_eq!(stt.language.as_deref(), Some("en"));
985    }
986
987    #[test]
988    fn agent_telegram_block_parses_speech_to_text_without_language() {
989        let yaml = "\
990interfaces:
991  telegram:
992    bot_token_env: T
993    chat_ids_env: C
994    speech_to_text:
995      provider: groq
996      api_key_env: K
997      model: whisper-large-v3
998";
999        let a: Agent = serde_yaml::from_str(yaml).unwrap();
1000        let stt = a
1001            .telegram()
1002            .and_then(|t| t.speech_to_text.as_ref())
1003            .expect("speech_to_text parsed");
1004        assert!(stt.language.is_none());
1005    }
1006
1007    #[test]
1008    fn effort_parses_all_five_levels() {
1009        for (yaml, expected) in [
1010            ("effort: low\n", EffortLevel::Low),
1011            ("effort: medium\n", EffortLevel::Medium),
1012            ("effort: high\n", EffortLevel::High),
1013            ("effort: xhigh\n", EffortLevel::Xhigh),
1014            ("effort: max\n", EffortLevel::Max),
1015        ] {
1016            let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
1017            assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
1018        }
1019    }
1020
1021    #[test]
1022    fn effort_unknown_value_is_rejected() {
1023        let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
1024            .expect_err("typo'd effort value must fail to parse");
1025        let msg = err.to_string();
1026        assert!(
1027            msg.contains("low") && msg.contains("max"),
1028            "error should enumerate valid variants; got: {msg}"
1029        );
1030    }
1031
1032    #[test]
1033    fn effort_renders_to_lowercase_string() {
1034        assert_eq!(EffortLevel::Low.as_str(), "low");
1035        assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
1036        assert_eq!(EffortLevel::Max.as_str(), "max");
1037    }
1038
1039    #[test]
1040    fn discover_prefers_dot_team() {
1041        let tmp = tempfile::tempdir().unwrap();
1042        let repo = tmp.path();
1043        std::fs::create_dir_all(repo.join(".team")).unwrap();
1044        std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1045        // a stray flat-layout file in the same dir should NOT be preferred.
1046        std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
1047
1048        // Walking up from a sub-dir should still find the .team/ root.
1049        let sub = repo.join("src/deep/nested");
1050        std::fs::create_dir_all(&sub).unwrap();
1051        let found = Compose::discover(&sub).unwrap();
1052        assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
1053    }
1054
1055    #[test]
1056    fn discover_no_longer_falls_back_to_flat_layout() {
1057        // T-008: a flat `team-compose.yaml` at cwd (no `.team/` wrapper) is
1058        // not discoverable. The convention is `.team/`. Operators must
1059        // either `init` a `.team/` or pass `--root` explicitly.
1060        let tmp = tempfile::tempdir().unwrap();
1061        std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
1062        let err = Compose::discover(tmp.path()).unwrap_err();
1063        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1064    }
1065
1066    #[test]
1067    fn discover_returns_first_dot_team_walking_up() {
1068        // T-008 boundary: nested `.team/`s win over outer ones. We do NOT
1069        // keep walking past the first hit.
1070        let tmp = tempfile::tempdir().unwrap();
1071        let outer = tmp.path();
1072        let inner = outer.join("packages/inner");
1073        std::fs::create_dir_all(outer.join(".team")).unwrap();
1074        std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1075        std::fs::create_dir_all(inner.join(".team")).unwrap();
1076        std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1077
1078        let from_inner = inner.join("src/deep");
1079        std::fs::create_dir_all(&from_inner).unwrap();
1080        let found = Compose::discover(&from_inner).unwrap();
1081        assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
1082    }
1083
1084    #[test]
1085    fn discover_errors_when_nothing_found() {
1086        let tmp = tempfile::tempdir().unwrap();
1087        let err = Compose::discover(tmp.path()).unwrap_err();
1088        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1089    }
1090
1091    #[test]
1092    fn role_prompt_parses_single_string_form() {
1093        let yaml = "role_prompt: roles/mgr.md\n";
1094        let agent: Agent = serde_yaml::from_str(&format!(
1095            "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1096        ))
1097        .unwrap();
1098        match agent.role_prompt.unwrap() {
1099            RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
1100            other => panic!("expected Single, got {other:?}"),
1101        }
1102    }
1103
1104    #[test]
1105    fn role_prompt_parses_list_form() {
1106        let yaml = "role_prompt:\n  - roles/_base.md\n  - roles/mgr.md\n";
1107        let agent: Agent = serde_yaml::from_str(&format!(
1108            "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1109        ))
1110        .unwrap();
1111        match agent.role_prompt.unwrap() {
1112            RolePrompt::Multiple(v) => assert_eq!(
1113                v,
1114                vec![
1115                    PathBuf::from("roles/_base.md"),
1116                    PathBuf::from("roles/mgr.md"),
1117                ]
1118            ),
1119            other => panic!("expected Multiple, got {other:?}"),
1120        }
1121    }
1122
1123    #[test]
1124    fn role_prompt_paths_returns_declared_order() {
1125        let rp = RolePrompt::Multiple(vec![
1126            PathBuf::from("a.md"),
1127            PathBuf::from("b.md"),
1128            PathBuf::from("c.md"),
1129        ]);
1130        let got: Vec<&Path> = rp.paths();
1131        assert_eq!(
1132            got,
1133            vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
1134        );
1135    }
1136
1137    // T-265 PR-a: SchemaVersion Deserialize semantics. The owner-
1138    // ratified contract is: accept YAML string verbatim; accept the
1139    // single legacy integer `2` (coerce to "2.0.0", flag
1140    // `from_legacy_int = true` so Compose::load knows to rewrite
1141    // the file); reject anything else with a message naming the
1142    // constraint.
1143
1144    #[test]
1145    fn schema_version_accepts_semver_string() {
1146        let v: SchemaVersion = serde_yaml::from_str("\"2.0.0\"").unwrap();
1147        assert_eq!(v.value, "2.0.0");
1148        assert!(!v.from_legacy_int, "string form is NOT the legacy path");
1149    }
1150
1151    #[test]
1152    fn schema_version_accepts_arbitrary_semver_string_for_later_validation() {
1153        // Deserialize doesn't enforce the semver shape — validate
1154        // does. So `"abc"` parses fine here; the validate-time check
1155        // rejects it later. Test pins this contract — keeps the
1156        // deserialize impl narrow.
1157        let v: SchemaVersion = serde_yaml::from_str("\"abc\"").unwrap();
1158        assert_eq!(v.value, "abc");
1159    }
1160
1161    #[test]
1162    fn schema_version_coerces_legacy_integer_two() {
1163        let v: SchemaVersion = serde_yaml::from_str("2").unwrap();
1164        assert_eq!(v.value, "2.0.0");
1165        assert!(v.from_legacy_int, "integer-2 must be flagged for rewrite");
1166    }
1167
1168    #[test]
1169    fn schema_version_rejects_other_integers() {
1170        // The hardcoded-legacy-exception is EXACTLY `2`. Anything
1171        // else (`1`, `3`, `99`) hard-errors with a message naming
1172        // the constraint.
1173        for n in [0u64, 1, 3, 99] {
1174            let err = serde_yaml::from_str::<SchemaVersion>(&n.to_string())
1175                .expect_err("non-2 integer must fail");
1176            let msg = err.to_string();
1177            assert!(
1178                msg.contains("only legacy `2` is auto-coerced"),
1179                "error must name the constraint; got: {msg}"
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn schema_version_rejects_non_string_non_int_shapes() {
1186        // Booleans, lists, mappings — none of them are a version.
1187        for yaml in ["true", "[1,2,3]", "{a: b}"] {
1188            let res = serde_yaml::from_str::<SchemaVersion>(yaml);
1189            assert!(res.is_err(), "yaml `{yaml}` must fail to deserialize");
1190        }
1191    }
1192
1193    /// T-265 PR-a: Compose::load orchestration test — legacy `2`
1194    /// file gets auto-rewritten to the semver string AND the
1195    /// in-memory representation is `"2.0.0"` + `from_legacy_int =
1196    /// true` (the flag the load logic reads to decide whether to
1197    /// rewrite). The on-disk content after load must be `version:
1198    /// "2.0.0"`, comments preserved.
1199    #[test]
1200    fn load_rewrites_legacy_version_two_in_file_and_in_memory() {
1201        let tmp = tempfile::tempdir().unwrap();
1202        let root = tmp.path().join(".team");
1203        std::fs::create_dir_all(&root).unwrap();
1204        let yaml = "\
1205# T-265 fixture — legacy version
1206version: 2
1207broker:
1208  type: sqlite
1209  path: state/mailbox.db
1210";
1211        std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1212        let compose = Compose::load(&root).expect("load succeeds on legacy file");
1213        // In-memory: normalized + flagged legacy.
1214        assert_eq!(compose.global.version.value, "2.0.0");
1215        // On-disk: rewritten to the semver string.
1216        let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1217        assert!(
1218            after.contains("version: \"2.0.0\""),
1219            "file must be rewritten;\n{after}"
1220        );
1221        assert!(
1222            !after.contains("\nversion: 2\n"),
1223            "no legacy literal must survive;\n{after}"
1224        );
1225        // Comment preserved.
1226        assert!(
1227            after.contains("# T-265 fixture"),
1228            "comment must survive the rewrite;\n{after}"
1229        );
1230        // broker block survives.
1231        assert!(after.contains("type: sqlite"));
1232    }
1233
1234    #[test]
1235    fn load_leaves_canonical_semver_file_untouched() {
1236        let tmp = tempfile::tempdir().unwrap();
1237        let root = tmp.path().join(".team");
1238        std::fs::create_dir_all(&root).unwrap();
1239        let yaml = "\
1240version: \"2.0.0\"
1241broker:
1242  type: sqlite
1243";
1244        std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1245        let compose = Compose::load(&root).expect("load succeeds");
1246        assert_eq!(compose.global.version.value, "2.0.0");
1247        assert!(
1248            !compose.global.version.from_legacy_int,
1249            "canonical file must NOT be flagged for rewrite"
1250        );
1251        // File content byte-identical (no auto-rewrite when not legacy).
1252        let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1253        assert_eq!(after, yaml, "canonical file must NOT be mutated on load");
1254    }
1255
1256    #[test]
1257    fn load_hard_errors_on_non_two_integer_version() {
1258        let tmp = tempfile::tempdir().unwrap();
1259        let root = tmp.path().join(".team");
1260        std::fs::create_dir_all(&root).unwrap();
1261        std::fs::write(root.join("team-compose.yaml"), "version: 3\n").unwrap();
1262        let err = Compose::load(&root).expect_err("must reject integer-3 at parse");
1263        assert!(
1264            err.to_string().contains("only legacy `2` is auto-coerced"),
1265            "error must name the constraint; got: {err}"
1266        );
1267    }
1268
1269    // ── #132 PR-1: Project.interfaces.telegram schema ──────────────
1270
1271    /// Minimal Project YAML head for the new-schema tests. Each test
1272    /// appends its own `interfaces:` block (or omits it for the
1273    /// zero-touch baseline). Mirrors the per-agent test fixture
1274    /// pattern but at one level up.
1275    const PROJECT_YAML_HEAD: &str = "\
1276version: 2
1277project:
1278  id: p
1279  name: P
1280  cwd: .
1281";
1282
1283    #[test]
1284    fn project_without_interfaces_block_parses_unchanged() {
1285        // Zero-touch baseline: existing project YAMLs (which today have
1286        // no `interfaces:` block) keep parsing exactly as before.
1287        let p: Project = serde_yaml::from_str(PROJECT_YAML_HEAD).unwrap();
1288        assert!(p.interfaces.is_none());
1289        assert!(p.telegram().is_none());
1290    }
1291
1292    #[test]
1293    fn project_telegram_block_parses_under_interfaces() {
1294        // Mirror precedent: `agent_telegram_block_parses_under_interfaces`
1295        // at compose.rs:691. Both `manager_bot` and `profile_picture`
1296        // present, exercises the full top-level accessor path.
1297        let yaml = format!(
1298            "{PROJECT_YAML_HEAD}\
1299interfaces:
1300  telegram:
1301    manager_bot:
1302      token_env: TEAMCTL_TG_MANAGER_TOKEN
1303    profile_picture:
1304      image_model:
1305        provider: openai
1306        api_key_env: OPENAI_API_KEY
1307        model: gpt-image-2
1308      fallback: initials
1309"
1310        );
1311        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1312        let tg = p.telegram().expect("project telegram parsed");
1313        let mb = tg.manager_bot.as_ref().expect("manager_bot parsed");
1314        assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
1315        let pp = tg.profile_picture.as_ref().expect("profile_picture parsed");
1316        let im = pp.image_model.as_ref().expect("image_model parsed");
1317        assert_eq!(im.provider, "openai");
1318        assert_eq!(im.api_key_env, "OPENAI_API_KEY");
1319        assert_eq!(im.model, "gpt-image-2");
1320        assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1321    }
1322
1323    #[test]
1324    fn project_telegram_block_parses_manager_bot_only() {
1325        // Realistic case: operator opts into managed-bots flow without
1326        // configuring AI profile pictures (uses the initials fallback
1327        // by absence-of-image-model). Each sub-block is independently
1328        // optional.
1329        let yaml = format!(
1330            "{PROJECT_YAML_HEAD}\
1331interfaces:
1332  telegram:
1333    manager_bot:
1334      token_env: TEAMCTL_TG_MANAGER_TOKEN
1335"
1336        );
1337        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1338        let tg = p.telegram().expect("project telegram parsed");
1339        assert_eq!(
1340            tg.manager_bot.as_ref().unwrap().token_env,
1341            "TEAMCTL_TG_MANAGER_TOKEN"
1342        );
1343        assert!(tg.profile_picture.is_none());
1344    }
1345
1346    #[test]
1347    fn profile_picture_fallback_defaults_to_initials_when_omitted() {
1348        // Q3 contract: omitting `fallback:` is operator-readable as
1349        // "use the v1 default", which is initials. Future variants
1350        // slot in without breaking this default.
1351        let yaml = format!(
1352            "{PROJECT_YAML_HEAD}\
1353interfaces:
1354  telegram:
1355    profile_picture:
1356      image_model:
1357        provider: openai
1358        api_key_env: OPENAI_API_KEY
1359        model: gpt-image-2
1360"
1361        );
1362        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1363        let pp = p
1364            .telegram()
1365            .and_then(|t| t.profile_picture.as_ref())
1366            .expect("profile_picture parsed");
1367        assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1368    }
1369
1370    #[test]
1371    fn profile_picture_image_model_optional_with_initials_fallback() {
1372        // Initials-only path: no AI generation configured, the
1373        // fallback is the entire rendering. Pins the (b) Q3-ratified
1374        // shape end to end at the schema level.
1375        let yaml = format!(
1376            "{PROJECT_YAML_HEAD}\
1377interfaces:
1378  telegram:
1379    profile_picture:
1380      fallback: initials
1381"
1382        );
1383        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1384        let pp = p
1385            .telegram()
1386            .and_then(|t| t.profile_picture.as_ref())
1387            .expect("profile_picture parsed");
1388        assert!(pp.image_model.is_none());
1389        assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1390    }
1391
1392    #[test]
1393    fn manager_bot_missing_token_env_rejected() {
1394        // `token_env` is required (no `#[serde(default)]`). A YAML
1395        // missing it must reject at parse with a clear error so a
1396        // malformed setup is caught before it reaches the wizard.
1397        let yaml = format!(
1398            "{PROJECT_YAML_HEAD}\
1399interfaces:
1400  telegram:
1401    manager_bot: {{}}
1402"
1403        );
1404        let err =
1405            serde_yaml::from_str::<Project>(&yaml).expect_err("malformed manager_bot must reject");
1406        assert!(
1407            err.to_string().contains("token_env"),
1408            "error must name the missing field: {err}"
1409        );
1410    }
1411
1412    #[test]
1413    fn image_model_missing_required_fields_rejected() {
1414        // All three fields (provider, api_key_env, model) are
1415        // required. A YAML missing any of them must reject — mirrors
1416        // SttConfig's required-fields contract.
1417        for (label, yaml_fragment) in [
1418            ("missing provider", "api_key_env: K\n        model: M"),
1419            ("missing api_key_env", "provider: openai\n        model: M"),
1420            ("missing model", "provider: openai\n        api_key_env: K"),
1421        ] {
1422            let yaml = format!(
1423                "{PROJECT_YAML_HEAD}\
1424interfaces:
1425  telegram:
1426    profile_picture:
1427      image_model:
1428        {yaml_fragment}
1429"
1430            );
1431            let result = serde_yaml::from_str::<Project>(&yaml);
1432            assert!(
1433                result.is_err(),
1434                "malformed image_model ({label}) must reject, got: {result:?}"
1435            );
1436        }
1437    }
1438
1439    #[test]
1440    fn profile_picture_fallback_unknown_value_rejected() {
1441        // Mirror precedent: `effort_unknown_value_is_rejected` at
1442        // compose.rs. An unknown enum variant must reject at parse so
1443        // typos surface immediately rather than silently defaulting.
1444        let yaml = format!(
1445            "{PROJECT_YAML_HEAD}\
1446interfaces:
1447  telegram:
1448    profile_picture:
1449      fallback: emoji
1450"
1451        );
1452        let err = serde_yaml::from_str::<Project>(&yaml)
1453            .expect_err("unknown fallback variant must reject");
1454        assert!(
1455            err.to_string().contains("emoji") || err.to_string().contains("variant"),
1456            "error must explain the unknown variant: {err}"
1457        );
1458    }
1459}