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/// One per-agent Claude Code hook declared in compose (#383 Phase 2).
533///
534/// Maps onto Claude Code's `settings.json` hook shape: an `event` bucket
535/// (`PreToolUse`, `PostToolUse`, `Stop`, …) holding entries of
536/// `{ matcher, hooks: [{ type: "command", command }] }`. teamctl does not
537/// enumerate the runtime's event names — `event` is passed through
538/// verbatim so a new Claude Code event works without a teamctl release.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct HookSpec {
541    /// Claude Code hook event the command fires on, e.g. `PreToolUse`.
542    pub event: String,
543
544    /// Optional tool-name regex (`Bash`, `Edit|Write`). Omitted → the
545    /// hook matches every tool for the event, matching Claude Code's own
546    /// behavior when `matcher` is absent.
547    #[serde(default)]
548    pub matcher: Option<String>,
549
550    /// Compose-root-relative path to the hook command, resolved the same
551    /// way as `role_prompt: roles/x.md` and rendered into the settings
552    /// file as an absolute path. v1 is a single executable path (no
553    /// inline args) — matching the issue's `command: hooks/guard.sh`
554    /// shape.
555    pub command: PathBuf,
556}
557
558/// One per-agent MCP server declared in compose (#383 Phase 4).
559///
560/// Serializes straight into the runtime's MCP config entry — `command` /
561/// `args` / `env` map onto the same shape the built-in `team` server
562/// emits. The HTTP (`url` / `headers`) transport variant is deferred
563/// (spike E2) until a concrete need lands.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct McpServer {
566    /// Executable that launches the MCP server, resolved on `$PATH` by
567    /// the runtime (e.g. `npx`, `docker`).
568    pub command: String,
569
570    /// Arguments passed to `command`. Empty (the default) → `[]`.
571    #[serde(default)]
572    pub args: Vec<String>,
573
574    /// Environment variables for the server process. Values pass through
575    /// verbatim — the runtime performs any `${VAR}` expansion, matching
576    /// how teamctl treats env elsewhere (render does no interpolation).
577    #[serde(default)]
578    pub env: BTreeMap<String, String>,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct Agent {
583    #[serde(default = "default_runtime")]
584    pub runtime: String,
585    pub model: Option<String>,
586    pub role_prompt: Option<RolePrompt>,
587    #[serde(default)]
588    pub permission_mode: Option<String>,
589    #[serde(default = "default_autonomy")]
590    pub autonomy: String,
591    #[serde(default)]
592    pub can_dm: Vec<String>,
593    #[serde(default)]
594    pub can_broadcast: Vec<String>,
595    #[serde(default)]
596    pub reports_to: Option<String>,
597
598    /// Override the global rate-limit hook chain for this agent.
599    #[serde(default)]
600    pub on_rate_limit: Option<Vec<String>>,
601
602    /// Per-agent reasoning effort. Renders as `EFFORT=<value>` in the
603    /// agent env file; the wrapper passes it to the runtime (e.g.
604    /// `claude --effort <value>`). Strict enum: typos like `hgih` fail
605    /// compose validation rather than silently falling back to the
606    /// wrapper default.
607    #[serde(default)]
608    pub effort: Option<EffortLevel>,
609
610    /// #461: per-agent ultracode opt-in. When `true`, teamctl emits
611    /// `"ultracode": true` into the agent's Claude Code settings JSON (the
612    /// file the wrapper passes via `--settings`); the resulting session
613    /// behavior is Claude Code's, not teamctl's. ultracode is a Claude Code
614    /// *setting* — not an effort level and not a CLI flag — so it's
615    /// orthogonal to `effort:` above (which rides the `--effort` flag) and
616    /// the two compose freely. Like the `enableAllProjectMcpServers` key in
617    /// `render_claude_settings`, the name is vendor-owned: verified against
618    /// Claude Code 2.1.175, and a future rename would silently no-op.
619    /// claude-only v1: on non-claude runtimes the settings file is skipped
620    /// and a declared opt-in warns (codex/gemini have no equivalent).
621    /// Defaults to `false` → the settings JSON is byte-unchanged for agents
622    /// that don't opt in.
623    #[serde(default)]
624    pub ultracode: bool,
625
626    /// Per-manager human-facing interfaces. Today's only adapter is
627    /// `telegram`; the shape is reserved for future adapters
628    /// (`discord`, `imessage`, …) so a manager can declare every
629    /// channel it speaks on in one place. Workers leave this unset.
630    #[serde(default)]
631    pub interfaces: Option<AgentInterfaces>,
632
633    /// T-160: optional human-friendly label rendered by the TUI in
634    /// place of the agent id (roster, details header, mailbox row
635    /// attribution, statusline). Absent → render the agent id (current
636    /// behavior). Validation: non-empty, ≤64 chars, UTF-8 anything.
637    /// The agent id stays canonical for routing, tmux session names,
638    /// CLI args, and YAML cross-refs (`can_dm`, `can_broadcast`,
639    /// `reports_to`) — display_name is render-time only.
640    #[serde(default)]
641    pub display_name: Option<String>,
642
643    /// #383 Phase 2: per-agent Claude Code hooks, merged additively into
644    /// the per-agent `settings.json` that render already builds, on top
645    /// of the built-in interactive-prompt deny hook (which keeps
646    /// precedence — see `render::render_claude_settings`). Commands are
647    /// compose-root-relative paths, resolved like `role_prompt`.
648    /// claude-only v1: declared on a non-`claude-code` agent they render
649    /// nothing and render logs an "unsupported runtime" warning. Empty
650    /// (the default) → settings unchanged.
651    #[serde(default)]
652    pub hooks: Vec<HookSpec>,
653
654    /// #383 Phase 4: per-agent MCP servers, merged into the rendered
655    /// per-agent MCP config alongside the built-in `team` mailbox server
656    /// (which stays unconditional and non-clobberable — a declared server
657    /// named `team` is rejected at validate and skipped at render). Unlike
658    /// hooks, MCP is runtime-agnostic: declared servers render for every
659    /// runtime whose descriptor sets `supports_mcp`, and are skipped with
660    /// a warning otherwise. Empty (the default) → MCP config unchanged.
661    #[serde(default)]
662    pub mcps: BTreeMap<String, McpServer>,
663
664    /// #383 Phase 3a: per-agent Claude Code sub-agents declared in compose.
665    /// Each entry is a compose-root-relative path to a standard sub-agent
666    /// markdown file (frontmatter `name` / `description` / optional `tools`
667    /// / `model`, body → the sub-agent's system prompt), resolved like
668    /// `role_prompt`. render transforms the list into Claude Code's
669    /// `--agents` inline JSON so each agent gets its own sub-agents
670    /// additively, on top of the project `.claude/agents/` and the built-in
671    /// sub-agents (verified: `--agents` adds, never replaces), without an
672    /// arbitrary-path flag (the only cwd-stationary mechanism — see the
673    /// Phase-1 spike). claude-only v1: declared on a non-`claude-code`
674    /// agent they render nothing and render logs an "unsupported runtime"
675    /// warning. Empty (the default) → no `--agents` flag.
676    #[serde(default)]
677    pub subagents: Vec<PathBuf>,
678
679    /// #383 Phase 3b: per-agent Claude Code skills declared in compose.
680    /// Each entry is a compose-root-relative path to a skill directory
681    /// (the folder holding `SKILL.md`), resolved like `role_prompt`. render
682    /// materializes a per-agent scope dir under
683    /// `state/agent-scope/<project>-<agent>/.claude/skills/` holding a
684    /// symlink to each declared skill, and the wrapper passes that scope
685    /// dir via `claude --add-dir` so the agent discovers its skills
686    /// additively, on top of the project `.claude/skills/` (verified:
687    /// `--add-dir` adds, never replaces — see the Phase-1 spike §9-E1).
688    /// claude-only v1: declared on a non-`claude-code` agent they
689    /// materialize nothing and render logs an "unsupported runtime"
690    /// warning. Empty (the default) → no `--add-dir` flag.
691    #[serde(default)]
692    pub skills: Vec<PathBuf>,
693}
694
695/// Container for per-manager interface adapters. Open shape so adding
696/// `discord:` / `imessage:` later is a strictly-additive YAML edit.
697#[derive(Debug, Clone, Serialize, Deserialize, Default)]
698pub struct AgentInterfaces {
699    /// 1:1 Telegram bot for this manager. When set, `teamctl up`
700    /// spawns a `team-bot` tmux session scoped to this manager so the
701    /// human DMs the bot directly (no `/dm role text` required).
702    /// Configured by `teamctl bot setup`.
703    #[serde(default)]
704    pub telegram: Option<TelegramConfig>,
705}
706
707/// Per-manager Telegram bot config. Both fields are env-var *names* —
708/// the actual token/chat-ids live in `.team/.env` (kept out of git).
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct TelegramConfig {
711    /// Env var holding the BotFather token. Default chosen by
712    /// `teamctl bot setup`: `TEAMCTL_TG_<MANAGER>_TOKEN`.
713    pub bot_token_env: String,
714    /// Env var holding a comma-separated list of authorized chat ids.
715    /// Default: `TEAMCTL_TG_<MANAGER>_CHATS`.
716    pub chat_ids_env: String,
717    /// Optional speech-to-text provider for voice messages. When set,
718    /// inbound Telegram voice notes are transcribed and forwarded to the
719    /// agent prefixed so the model knows the input came from audio.
720    /// Absent → voice messages stay unhandled (default).
721    #[serde(default)]
722    pub speech_to_text: Option<SttConfig>,
723}
724
725/// Speech-to-text settings for the per-manager Telegram bot. The provider
726/// arm is the only switch v1 needs (`groq`); adding OpenAI Whisper or
727/// whisper.cpp later is one match arm in `team-bot`'s transcribe function.
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct SttConfig {
730    /// Provider arm. v1: `groq`.
731    pub provider: String,
732    /// Env var holding the provider's API key (mirrors `bot_token_env`).
733    /// The actual secret lives in `.team/.env` and is resolved by
734    /// `teamctl bot up` at spawn time before being passed to `team-bot`.
735    pub api_key_env: String,
736    /// Provider model id (e.g. `whisper-large-v3` for Groq).
737    pub model: String,
738    /// Optional ISO-639 language hint forwarded verbatim to the provider
739    /// (e.g. `en`, `fa`). When unset, the provider auto-detects.
740    #[serde(default)]
741    pub language: Option<String>,
742}
743
744impl Agent {
745    /// Convenience: pull the manager's Telegram config out of
746    /// `interfaces.telegram` without forcing every callsite to handle
747    /// the nested options.
748    pub fn telegram(&self) -> Option<&TelegramConfig> {
749        self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
750    }
751}
752
753/// #132 PR-1: project-scoped interface container. One level up from
754/// `AgentInterfaces`, same open-shape rationale — today's only adapter
755/// is `telegram`, future `discord:` / `imessage:` slot in as
756/// strictly-additive YAML edits.
757#[derive(Debug, Clone, Serialize, Deserialize, Default)]
758pub struct ProjectInterfaces {
759    /// Project-scoped Telegram config: the manager bot that spawns
760    /// per-agent children + default profile-picture rendering. Per-
761    /// agent telegram config (under `Agent.interfaces.telegram`) is
762    /// orthogonal — agents still declare their own `bot_token_env` /
763    /// `chat_ids_env` slots; the managed-bots flow writes child tokens
764    /// into those slots untouched.
765    #[serde(default)]
766    pub telegram: Option<ProjectTelegramConfig>,
767}
768
769/// #132 PR-1: project-scoped Telegram config. Hosts the shared-infra
770/// fields (manager bot, profile-picture defaults) that drive the
771/// managed-bots flow in `teamctl bot setup`. Absent / both fields
772/// absent → existing manual BotFather walkthrough runs verbatim for
773/// each per-agent `Agent.interfaces.telegram` block (zero-touch).
774#[derive(Debug, Clone, Serialize, Deserialize, Default)]
775pub struct ProjectTelegramConfig {
776    /// Manager bot for the managed-bots flow. When set + its env var
777    /// resolves, `teamctl bot setup` uses it to spawn per-agent child
778    /// bots via Telegram's Bot API 10.0 managed-bot endpoints. Absent
779    /// → operator runs the manual BotFather flow per agent as today.
780    #[serde(default)]
781    pub manager_bot: Option<ManagerBotConfig>,
782
783    /// Default profile-picture rendering for spawned child bots.
784    /// Image-model path is opt-in; absent / failure-of-generation
785    /// falls back to deterministic initials-in-colored-circle (Q3-
786    /// ratified). When the whole block is absent, no profile-picture
787    /// is applied — child bots keep Telegram's default avatar.
788    #[serde(default)]
789    pub profile_picture: Option<ProfilePictureConfig>,
790}
791
792/// #132 PR-1: manager-bot config. Mirrors the env-var-name pattern of
793/// `TelegramConfig.bot_token_env` — the actual BotFather token lives
794/// in `.team/.env`, this field names the env var that holds it.
795#[derive(Debug, Clone, Serialize, Deserialize)]
796pub struct ManagerBotConfig {
797    /// Env var holding the manager bot's BotFather token. The
798    /// operator-facing setup steps (BotFather click path to enable the
799    /// Managed Bots capability) are documented in `docs/`; this schema
800    /// pins the env-var name the wizard reads at setup time.
801    pub token_env: String,
802}
803
804/// #132 PR-1: profile-picture rendering settings for spawned child
805/// bots. `image_model` is opt-in for AI-generated avatars; `fallback`
806/// names the rendering used when `image_model` is absent OR generation
807/// fails OR the API key is missing. Q3 (owner-ratified, tg 3445):
808/// initials-in-colored-circle, no embedded emoji-font binary growth.
809#[derive(Debug, Clone, Serialize, Deserialize, Default)]
810pub struct ProfilePictureConfig {
811    /// AI image-generation config. When set + API key resolves, the
812    /// wizard generates a square 512×512 image seeded from the agent's
813    /// name + role and applies it via Bot API `setProfilePhoto`. Any
814    /// failure path (missing API key, generation error, upload error)
815    /// falls through to `fallback`.
816    #[serde(default)]
817    pub image_model: Option<ImageModelConfig>,
818
819    /// Fallback rendering when `image_model` is absent or fails. v1
820    /// has one variant (`Initials`); the field is explicit so future
821    /// variants (per-agent override, `None`-to-skip) slot in
822    /// additively without breaking existing YAML.
823    #[serde(default)]
824    pub fallback: ProfilePictureFallback,
825}
826
827/// #132 PR-1: profile-picture fallback rendering. Q3-ratified to
828/// initials-in-colored-circle for v1. Enum-shaped so future variants
829/// (e.g. `Emoji`, `None`) land additively; current single variant is
830/// the default so omitting `fallback:` in YAML keeps the contract.
831#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
832#[serde(rename_all = "lowercase")]
833pub enum ProfilePictureFallback {
834    /// Render a deterministic colored circle with the agent's
835    /// uppercase initials (Slack-style). Deterministic = same agent
836    /// name always renders the same circle, so rebuilds don't shuffle.
837    #[default]
838    Initials,
839}
840
841/// #132 PR-1: AI image-generation config for child-bot profile
842/// pictures. Mirrors the `SttConfig` shape (provider / api_key_env /
843/// model). v1 provider is `openai`; adding a future provider is one
844/// match arm in the call site.
845#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct ImageModelConfig {
847    /// Provider arm. v1: `openai`.
848    pub provider: String,
849    /// Env var holding the provider's API key (mirrors `bot_token_env`
850    /// and `SttConfig.api_key_env`). The actual secret lives in
851    /// `.team/.env` and is resolved by `teamctl bot setup` at wizard
852    /// time.
853    pub api_key_env: String,
854    /// Provider model id (e.g. `gpt-image-2` for OpenAI; snapshot id
855    /// `gpt-image-2-2026-04-21` for pinning).
856    pub model: String,
857}
858
859impl Project {
860    /// Convenience: pull the project's Telegram config out of
861    /// `interfaces.telegram` without forcing every callsite to handle
862    /// the nested options. Mirrors [`Agent::telegram`].
863    pub fn telegram(&self) -> Option<&ProjectTelegramConfig> {
864        self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
865    }
866}
867
868/// Reasoning-effort level forwarded to the runtime. Maps 1:1 to
869/// `claude --effort <value>` today; if the runtime taxonomy evolves we
870/// extend the enum and bump the schema version.
871#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
872#[serde(rename_all = "lowercase")]
873pub enum EffortLevel {
874    Low,
875    Medium,
876    High,
877    Xhigh,
878    Max,
879}
880
881impl EffortLevel {
882    /// Lowercase rendering for the env-file `EFFORT=<value>` line and
883    /// the `claude --effort <value>` CLI flag.
884    pub fn as_str(self) -> &'static str {
885        match self {
886            EffortLevel::Low => "low",
887            EffortLevel::Medium => "medium",
888            EffortLevel::High => "high",
889            EffortLevel::Xhigh => "xhigh",
890            EffortLevel::Max => "max",
891        }
892    }
893}
894
895fn default_runtime() -> String {
896    "claude-code".into()
897}
898
899fn default_autonomy() -> String {
900    "low_risk_only".into()
901}
902
903/// Fully loaded compose tree: global + resolved projects.
904#[derive(Debug, Clone)]
905pub struct Compose {
906    pub root: PathBuf,
907    pub global: Global,
908    pub projects: Vec<Project>,
909}
910
911impl Compose {
912    /// Walk up from `start` looking for the **first** `.team/team-compose.yaml`
913    /// and return the directory containing the compose file (the "root"),
914    /// suitable for passing to [`Compose::load`]. The first hit wins; we do
915    /// not keep walking past it to look for a parent `.team/`.
916    ///
917    /// This is the equivalent of git's `.git/` discovery — once a repo carries
918    /// a `.team/` folder, every `teamctl` subcommand finds it from anywhere
919    /// inside the tree. T-008 retired the legacy flat-layout fallback and
920    /// the second-hit / parent-`.team/` walk: the convention is `.team/` and
921    /// the nearest one wins, no exceptions.
922    pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
923        let start = start
924            .canonicalize()
925            .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
926        let mut cur: Option<&Path> = Some(&start);
927        while let Some(dir) = cur {
928            let candidate = dir.join(".team").join("team-compose.yaml");
929            if candidate.is_file() {
930                return Ok(dir.join(".team"));
931            }
932            cur = dir.parent();
933        }
934        Err(anyhow::anyhow!(
935            "no `.team/team-compose.yaml` found in {} or any parent",
936            start.display()
937        ))
938    }
939
940    /// Parse `team-compose.yaml` at `root` and every referenced project file.
941    pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
942        let root = root.as_ref().to_path_buf();
943        let global_path = root.join("team-compose.yaml");
944        let raw = std::fs::read_to_string(&global_path)
945            .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?;
946        let global: Global = serde_yaml::from_str(&raw)
947            .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
948
949        // T-265 PR-a: legacy-`2` auto-rewrite on load. When the
950        // operator's compose still uses the pre-semver shape
951        // (`version: 2`, integer literal), the Deserialize impl on
952        // `SchemaVersion` has already coerced the in-memory value to
953        // `"2.0.0"` and flagged `from_legacy_int = true`. Now we
954        // best-effort rewrite the file so the on-disk shape matches
955        // the runtime semantics — eliminating the file-vs-runtime
956        // divergence the operator would otherwise see in git diff
957        // forever. On RO filesystems (CI sandboxes, immutable image
958        // mounts) or any other write failure, we emit a single warn
959        // and proceed with the in-memory normalized value rather
960        // than hard-erroring — owner-ratified (tg 3440, "RO-FS
961        // degrades to in-memory + warning"). Single hardcoded
962        // legacy-value exception, NOT the general migration engine
963        // — that stays deferred to its own ticket per #265's
964        // non-goals.
965        if global.version.from_legacy_int {
966            if let Err(e) = rewrite_legacy_version_in_file(&global_path, &raw) {
967                tracing::warn!(
968                    target: "team-core::compose",
969                    "could not rewrite legacy `version: 2` in {}: {e}; \
970                     proceeding with in-memory `\"2.0.0\"`",
971                    global_path.display()
972                );
973            }
974        }
975
976        let mut projects = Vec::with_capacity(global.projects.len());
977        for r in &global.projects {
978            let p = root.join(&r.file);
979            let parsed: Project = serde_yaml::from_str(
980                &std::fs::read_to_string(&p)
981                    .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
982            )
983            .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
984            projects.push(parsed);
985        }
986
987        Ok(Self {
988            root,
989            global,
990            projects,
991        })
992    }
993
994    /// Return every agent in the compose tree tagged with manager/worker.
995    pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
996        self.projects.iter().flat_map(|p| {
997            p.managers
998                .iter()
999                .map(move |(id, a)| AgentHandle {
1000                    project: &p.project.id,
1001                    agent: id,
1002                    spec: a,
1003                    is_manager: true,
1004                })
1005                .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
1006                    project: &p.project.id,
1007                    agent: id,
1008                    spec: a,
1009                    is_manager: false,
1010                }))
1011        })
1012    }
1013}
1014
1015/// T-265 PR-a: rewrite the legacy `version: 2` integer literal in
1016/// `team-compose.yaml` to the semver string form `"2.0.0"`,
1017/// preserving comments + key ordering via the `yaml_edit` substrate.
1018/// Caller has already loaded the raw text (passed as `raw` to avoid
1019/// a second disk read) and decided we're in the legacy path.
1020fn rewrite_legacy_version_in_file(path: &Path, raw: &str) -> anyhow::Result<()> {
1021    let updated = crate::yaml_edit::set_top_level_scalar(raw, "version", "\"2.0.0\"")?;
1022    std::fs::write(path, updated).map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
1023    Ok(())
1024}
1025
1026#[derive(Debug, Clone, Copy)]
1027pub struct AgentHandle<'a> {
1028    pub project: &'a str,
1029    pub agent: &'a str,
1030    pub spec: &'a Agent,
1031    pub is_manager: bool,
1032}
1033
1034impl AgentHandle<'_> {
1035    /// Canonical id as `<project>:<agent>`.
1036    pub fn id(&self) -> String {
1037        format!("{}:{}", self.project, self.agent)
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    #[test]
1046    fn channel_members_all_expands() {
1047        let all = ChannelMembers::All("*".into());
1048        assert!(all.includes("dev1", &["dev1", "dev2"]));
1049        assert!(!all.includes("ghost", &["dev1", "dev2"]));
1050    }
1051
1052    #[test]
1053    fn channel_members_explicit_checks_list() {
1054        let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
1055        assert!(exp.includes("dev1", &[]));
1056        assert!(!exp.includes("dev2", &[]));
1057    }
1058
1059    #[test]
1060    fn agent_defaults_are_stable() {
1061        let a: Agent = serde_yaml::from_str("model: claude-opus-4-8\n").unwrap();
1062        assert_eq!(a.runtime, "claude-code");
1063        assert_eq!(a.autonomy, "low_risk_only");
1064        assert!(a.interfaces.is_none());
1065        assert!(a.telegram().is_none());
1066        assert!(a.effort.is_none());
1067    }
1068
1069    #[test]
1070    fn agent_telegram_block_parses_under_interfaces() {
1071        let yaml = "interfaces:\n  telegram:\n    bot_token_env: T\n    chat_ids_env: C\n";
1072        let a: Agent = serde_yaml::from_str(yaml).unwrap();
1073        let tg = a.telegram().expect("telegram parsed");
1074        assert_eq!(tg.bot_token_env, "T");
1075        assert_eq!(tg.chat_ids_env, "C");
1076        assert!(tg.speech_to_text.is_none());
1077    }
1078
1079    #[test]
1080    fn agent_telegram_block_parses_speech_to_text() {
1081        let yaml = "\
1082interfaces:
1083  telegram:
1084    bot_token_env: T
1085    chat_ids_env: C
1086    speech_to_text:
1087      provider: groq
1088      api_key_env: GROQ_API_KEY
1089      model: whisper-large-v3
1090      language: en
1091";
1092        let a: Agent = serde_yaml::from_str(yaml).unwrap();
1093        let stt = a
1094            .telegram()
1095            .and_then(|t| t.speech_to_text.as_ref())
1096            .expect("speech_to_text parsed");
1097        assert_eq!(stt.provider, "groq");
1098        assert_eq!(stt.api_key_env, "GROQ_API_KEY");
1099        assert_eq!(stt.model, "whisper-large-v3");
1100        assert_eq!(stt.language.as_deref(), Some("en"));
1101    }
1102
1103    #[test]
1104    fn agent_telegram_block_parses_speech_to_text_without_language() {
1105        let yaml = "\
1106interfaces:
1107  telegram:
1108    bot_token_env: T
1109    chat_ids_env: C
1110    speech_to_text:
1111      provider: groq
1112      api_key_env: K
1113      model: whisper-large-v3
1114";
1115        let a: Agent = serde_yaml::from_str(yaml).unwrap();
1116        let stt = a
1117            .telegram()
1118            .and_then(|t| t.speech_to_text.as_ref())
1119            .expect("speech_to_text parsed");
1120        assert!(stt.language.is_none());
1121    }
1122
1123    #[test]
1124    fn effort_parses_all_five_levels() {
1125        for (yaml, expected) in [
1126            ("effort: low\n", EffortLevel::Low),
1127            ("effort: medium\n", EffortLevel::Medium),
1128            ("effort: high\n", EffortLevel::High),
1129            ("effort: xhigh\n", EffortLevel::Xhigh),
1130            ("effort: max\n", EffortLevel::Max),
1131        ] {
1132            let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
1133            assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
1134        }
1135    }
1136
1137    #[test]
1138    fn ultracode_parses_from_yaml_and_defaults_false() {
1139        // #461: the opt-in must round-trip through the YAML parse path (the
1140        // render tests set the field by struct mutation, so this pins the
1141        // serde wiring the operator actually hits).
1142        let on: Agent = serde_yaml::from_str("ultracode: true\n").expect("ultracode: true");
1143        assert!(on.ultracode, "ultracode: true must parse to true");
1144        let off: Agent = serde_yaml::from_str("ultracode: false\n").expect("ultracode: false");
1145        assert!(!off.ultracode, "ultracode: false must parse to false");
1146        // Absent key: #[serde(default)] on a bool defaults to false.
1147        let absent: Agent = serde_yaml::from_str("effort: low\n").expect("no ultracode key");
1148        assert!(!absent.ultracode, "omitted ultracode must default to false");
1149    }
1150
1151    #[test]
1152    fn effort_unknown_value_is_rejected() {
1153        let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
1154            .expect_err("typo'd effort value must fail to parse");
1155        let msg = err.to_string();
1156        assert!(
1157            msg.contains("low") && msg.contains("max"),
1158            "error should enumerate valid variants; got: {msg}"
1159        );
1160    }
1161
1162    #[test]
1163    fn effort_renders_to_lowercase_string() {
1164        assert_eq!(EffortLevel::Low.as_str(), "low");
1165        assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
1166        assert_eq!(EffortLevel::Max.as_str(), "max");
1167    }
1168
1169    #[test]
1170    fn discover_prefers_dot_team() {
1171        let tmp = tempfile::tempdir().unwrap();
1172        let repo = tmp.path();
1173        std::fs::create_dir_all(repo.join(".team")).unwrap();
1174        std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1175        // a stray flat-layout file in the same dir should NOT be preferred.
1176        std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
1177
1178        // Walking up from a sub-dir should still find the .team/ root.
1179        let sub = repo.join("src/deep/nested");
1180        std::fs::create_dir_all(&sub).unwrap();
1181        let found = Compose::discover(&sub).unwrap();
1182        assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
1183    }
1184
1185    #[test]
1186    fn discover_no_longer_falls_back_to_flat_layout() {
1187        // T-008: a flat `team-compose.yaml` at cwd (no `.team/` wrapper) is
1188        // not discoverable. The convention is `.team/`. Operators must
1189        // either `init` a `.team/` or pass `--root` explicitly.
1190        let tmp = tempfile::tempdir().unwrap();
1191        std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
1192        let err = Compose::discover(tmp.path()).unwrap_err();
1193        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1194    }
1195
1196    #[test]
1197    fn discover_returns_first_dot_team_walking_up() {
1198        // T-008 boundary: nested `.team/`s win over outer ones. We do NOT
1199        // keep walking past the first hit.
1200        let tmp = tempfile::tempdir().unwrap();
1201        let outer = tmp.path();
1202        let inner = outer.join("packages/inner");
1203        std::fs::create_dir_all(outer.join(".team")).unwrap();
1204        std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1205        std::fs::create_dir_all(inner.join(".team")).unwrap();
1206        std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1207
1208        let from_inner = inner.join("src/deep");
1209        std::fs::create_dir_all(&from_inner).unwrap();
1210        let found = Compose::discover(&from_inner).unwrap();
1211        assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
1212    }
1213
1214    #[test]
1215    fn discover_errors_when_nothing_found() {
1216        let tmp = tempfile::tempdir().unwrap();
1217        let err = Compose::discover(tmp.path()).unwrap_err();
1218        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1219    }
1220
1221    #[test]
1222    fn role_prompt_parses_single_string_form() {
1223        let yaml = "role_prompt: roles/mgr.md\n";
1224        let agent: Agent = serde_yaml::from_str(&format!(
1225            "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1226        ))
1227        .unwrap();
1228        match agent.role_prompt.unwrap() {
1229            RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
1230            other => panic!("expected Single, got {other:?}"),
1231        }
1232    }
1233
1234    #[test]
1235    fn role_prompt_parses_list_form() {
1236        let yaml = "role_prompt:\n  - roles/_base.md\n  - roles/mgr.md\n";
1237        let agent: Agent = serde_yaml::from_str(&format!(
1238            "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1239        ))
1240        .unwrap();
1241        match agent.role_prompt.unwrap() {
1242            RolePrompt::Multiple(v) => assert_eq!(
1243                v,
1244                vec![
1245                    PathBuf::from("roles/_base.md"),
1246                    PathBuf::from("roles/mgr.md"),
1247                ]
1248            ),
1249            other => panic!("expected Multiple, got {other:?}"),
1250        }
1251    }
1252
1253    #[test]
1254    fn role_prompt_paths_returns_declared_order() {
1255        let rp = RolePrompt::Multiple(vec![
1256            PathBuf::from("a.md"),
1257            PathBuf::from("b.md"),
1258            PathBuf::from("c.md"),
1259        ]);
1260        let got: Vec<&Path> = rp.paths();
1261        assert_eq!(
1262            got,
1263            vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
1264        );
1265    }
1266
1267    // T-265 PR-a: SchemaVersion Deserialize semantics. The owner-
1268    // ratified contract is: accept YAML string verbatim; accept the
1269    // single legacy integer `2` (coerce to "2.0.0", flag
1270    // `from_legacy_int = true` so Compose::load knows to rewrite
1271    // the file); reject anything else with a message naming the
1272    // constraint.
1273
1274    #[test]
1275    fn schema_version_accepts_semver_string() {
1276        let v: SchemaVersion = serde_yaml::from_str("\"2.0.0\"").unwrap();
1277        assert_eq!(v.value, "2.0.0");
1278        assert!(!v.from_legacy_int, "string form is NOT the legacy path");
1279    }
1280
1281    #[test]
1282    fn schema_version_accepts_arbitrary_semver_string_for_later_validation() {
1283        // Deserialize doesn't enforce the semver shape — validate
1284        // does. So `"abc"` parses fine here; the validate-time check
1285        // rejects it later. Test pins this contract — keeps the
1286        // deserialize impl narrow.
1287        let v: SchemaVersion = serde_yaml::from_str("\"abc\"").unwrap();
1288        assert_eq!(v.value, "abc");
1289    }
1290
1291    #[test]
1292    fn schema_version_coerces_legacy_integer_two() {
1293        let v: SchemaVersion = serde_yaml::from_str("2").unwrap();
1294        assert_eq!(v.value, "2.0.0");
1295        assert!(v.from_legacy_int, "integer-2 must be flagged for rewrite");
1296    }
1297
1298    #[test]
1299    fn schema_version_rejects_other_integers() {
1300        // The hardcoded-legacy-exception is EXACTLY `2`. Anything
1301        // else (`1`, `3`, `99`) hard-errors with a message naming
1302        // the constraint.
1303        for n in [0u64, 1, 3, 99] {
1304            let err = serde_yaml::from_str::<SchemaVersion>(&n.to_string())
1305                .expect_err("non-2 integer must fail");
1306            let msg = err.to_string();
1307            assert!(
1308                msg.contains("only legacy `2` is auto-coerced"),
1309                "error must name the constraint; got: {msg}"
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn schema_version_rejects_non_string_non_int_shapes() {
1316        // Booleans, lists, mappings — none of them are a version.
1317        for yaml in ["true", "[1,2,3]", "{a: b}"] {
1318            let res = serde_yaml::from_str::<SchemaVersion>(yaml);
1319            assert!(res.is_err(), "yaml `{yaml}` must fail to deserialize");
1320        }
1321    }
1322
1323    /// T-265 PR-a: Compose::load orchestration test — legacy `2`
1324    /// file gets auto-rewritten to the semver string AND the
1325    /// in-memory representation is `"2.0.0"` + `from_legacy_int =
1326    /// true` (the flag the load logic reads to decide whether to
1327    /// rewrite). The on-disk content after load must be `version:
1328    /// "2.0.0"`, comments preserved.
1329    #[test]
1330    fn load_rewrites_legacy_version_two_in_file_and_in_memory() {
1331        let tmp = tempfile::tempdir().unwrap();
1332        let root = tmp.path().join(".team");
1333        std::fs::create_dir_all(&root).unwrap();
1334        let yaml = "\
1335# T-265 fixture — legacy version
1336version: 2
1337broker:
1338  type: sqlite
1339  path: state/mailbox.db
1340";
1341        std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1342        let compose = Compose::load(&root).expect("load succeeds on legacy file");
1343        // In-memory: normalized + flagged legacy.
1344        assert_eq!(compose.global.version.value, "2.0.0");
1345        // On-disk: rewritten to the semver string.
1346        let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1347        assert!(
1348            after.contains("version: \"2.0.0\""),
1349            "file must be rewritten;\n{after}"
1350        );
1351        assert!(
1352            !after.contains("\nversion: 2\n"),
1353            "no legacy literal must survive;\n{after}"
1354        );
1355        // Comment preserved.
1356        assert!(
1357            after.contains("# T-265 fixture"),
1358            "comment must survive the rewrite;\n{after}"
1359        );
1360        // broker block survives.
1361        assert!(after.contains("type: sqlite"));
1362    }
1363
1364    #[test]
1365    fn load_leaves_canonical_semver_file_untouched() {
1366        let tmp = tempfile::tempdir().unwrap();
1367        let root = tmp.path().join(".team");
1368        std::fs::create_dir_all(&root).unwrap();
1369        let yaml = "\
1370version: \"2.0.0\"
1371broker:
1372  type: sqlite
1373";
1374        std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1375        let compose = Compose::load(&root).expect("load succeeds");
1376        assert_eq!(compose.global.version.value, "2.0.0");
1377        assert!(
1378            !compose.global.version.from_legacy_int,
1379            "canonical file must NOT be flagged for rewrite"
1380        );
1381        // File content byte-identical (no auto-rewrite when not legacy).
1382        let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1383        assert_eq!(after, yaml, "canonical file must NOT be mutated on load");
1384    }
1385
1386    #[test]
1387    fn load_hard_errors_on_non_two_integer_version() {
1388        let tmp = tempfile::tempdir().unwrap();
1389        let root = tmp.path().join(".team");
1390        std::fs::create_dir_all(&root).unwrap();
1391        std::fs::write(root.join("team-compose.yaml"), "version: 3\n").unwrap();
1392        let err = Compose::load(&root).expect_err("must reject integer-3 at parse");
1393        assert!(
1394            err.to_string().contains("only legacy `2` is auto-coerced"),
1395            "error must name the constraint; got: {err}"
1396        );
1397    }
1398
1399    /// #347 (regression guard, owner-ratified tg 3440): when the legacy-`2`
1400    /// auto-rewrite cannot write the file (read-only filesystem: CI sandboxes,
1401    /// immutable image mounts), `Compose::load` must NOT hard-error. It swallows
1402    /// the rewrite failure, emits a single `tracing::warn`, and proceeds with
1403    /// the in-memory normalized value. This pins the exact regression flagged in
1404    /// the #346 review: a future refactor flipping the rewrite's `if let Err`
1405    /// into `?` would silently turn the graceful degrade into a hard error, and
1406    /// nothing else would catch it.
1407    ///
1408    /// `#[cfg(unix)]` because the read-only condition is set via a unix `chmod`;
1409    /// unix is where operators run teamctl. The write failure is simulated by
1410    /// making the compose FILE read-only (`0o444`), which reproduces the
1411    /// `std::fs::write` EACCES a read-only filesystem produces. The containing
1412    /// dir is left writable so the tempdir teardown can still remove the file.
1413    /// Caveat: skipped behavior is undefined under `sudo` (root bypasses the
1414    /// file mode); CI runners are non-root.
1415    #[cfg(unix)]
1416    #[test]
1417    fn load_degrades_to_in_memory_when_legacy_rewrite_cannot_write() {
1418        use std::io::Write;
1419        use std::os::unix::fs::PermissionsExt;
1420        use std::sync::{Arc, Mutex};
1421
1422        // A `tracing` writer that captures formatted events into a buffer, so
1423        // the test can assert the degrade's warn fired (acceptance b).
1424        struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1425        impl Write for CaptureWriter {
1426            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1427                self.0.lock().unwrap().extend_from_slice(buf);
1428                Ok(buf.len())
1429            }
1430            fn flush(&mut self) -> std::io::Result<()> {
1431                Ok(())
1432            }
1433        }
1434
1435        let tmp = tempfile::tempdir().unwrap();
1436        let root = tmp.path().join(".team");
1437        std::fs::create_dir_all(&root).unwrap();
1438        let compose_path = root.join("team-compose.yaml");
1439        std::fs::write(
1440            &compose_path,
1441            "version: 2\nbroker:\n  type: sqlite\n  path: state/mailbox.db\n",
1442        )
1443        .unwrap();
1444
1445        // Read-only file: the load can still read it, but the legacy-`2`
1446        // in-place rewrite (`std::fs::write`) fails with EACCES.
1447        std::fs::set_permissions(&compose_path, std::fs::Permissions::from_mode(0o444)).unwrap();
1448
1449        // Capture WARN-and-above tracing for the duration of the load only
1450        // (a thread-scoped default subscriber, never global).
1451        let logs = Arc::new(Mutex::new(Vec::<u8>::new()));
1452        let sink = logs.clone();
1453        let subscriber = tracing_subscriber::fmt()
1454            .with_max_level(tracing::Level::WARN)
1455            .with_writer(move || CaptureWriter(sink.clone()))
1456            .finish();
1457        let result = tracing::subscriber::with_default(subscriber, || Compose::load(&root));
1458
1459        // Restore write perms before any assertion can unwind, independent of
1460        // the tempdir teardown, so a failing assert never leaves a stuck file.
1461        std::fs::set_permissions(&compose_path, std::fs::Permissions::from_mode(0o644)).unwrap();
1462
1463        // (a) No error: the rewrite failure degrades, it does not bubble up.
1464        let compose = result.expect("a read-only compose file must degrade, not error");
1465        // (c) In-memory state is normalized despite the un-rewritable file.
1466        assert_eq!(compose.global.version.value, "2.0.0");
1467        assert!(
1468            compose.global.version.from_legacy_int,
1469            "legacy-int normalization must still be flagged in memory"
1470        );
1471        // (b) The degrade is observable: the warn fired, not a silent swallow.
1472        let captured = String::from_utf8(logs.lock().unwrap().clone()).unwrap();
1473        assert!(
1474            captured.contains("could not rewrite legacy"),
1475            "the read-only degrade must emit its tracing::warn; captured:\n{captured}"
1476        );
1477        // And the rewrite genuinely did not happen (degrade is real, not a
1478        // silently-succeeded write): the legacy literal still on disk.
1479        let after = std::fs::read_to_string(&compose_path).unwrap();
1480        assert!(
1481            after.contains("version: 2"),
1482            "the file must be left un-rewritten on the degrade path;\n{after}"
1483        );
1484    }
1485
1486    // ── #132 PR-1: Project.interfaces.telegram schema ──────────────
1487
1488    /// Minimal Project YAML head for the new-schema tests. Each test
1489    /// appends its own `interfaces:` block (or omits it for the
1490    /// zero-touch baseline). Mirrors the per-agent test fixture
1491    /// pattern but at one level up.
1492    const PROJECT_YAML_HEAD: &str = "\
1493version: 2
1494project:
1495  id: p
1496  name: P
1497  cwd: .
1498";
1499
1500    #[test]
1501    fn project_without_interfaces_block_parses_unchanged() {
1502        // Zero-touch baseline: existing project YAMLs (which today have
1503        // no `interfaces:` block) keep parsing exactly as before.
1504        let p: Project = serde_yaml::from_str(PROJECT_YAML_HEAD).unwrap();
1505        assert!(p.interfaces.is_none());
1506        assert!(p.telegram().is_none());
1507    }
1508
1509    #[test]
1510    fn project_telegram_block_parses_under_interfaces() {
1511        // Mirror precedent: `agent_telegram_block_parses_under_interfaces`
1512        // at compose.rs:691. Both `manager_bot` and `profile_picture`
1513        // present, exercises the full top-level accessor path.
1514        let yaml = format!(
1515            "{PROJECT_YAML_HEAD}\
1516interfaces:
1517  telegram:
1518    manager_bot:
1519      token_env: TEAMCTL_TG_MANAGER_TOKEN
1520    profile_picture:
1521      image_model:
1522        provider: openai
1523        api_key_env: OPENAI_API_KEY
1524        model: gpt-image-2
1525      fallback: initials
1526"
1527        );
1528        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1529        let tg = p.telegram().expect("project telegram parsed");
1530        let mb = tg.manager_bot.as_ref().expect("manager_bot parsed");
1531        assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
1532        let pp = tg.profile_picture.as_ref().expect("profile_picture parsed");
1533        let im = pp.image_model.as_ref().expect("image_model parsed");
1534        assert_eq!(im.provider, "openai");
1535        assert_eq!(im.api_key_env, "OPENAI_API_KEY");
1536        assert_eq!(im.model, "gpt-image-2");
1537        assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1538    }
1539
1540    #[test]
1541    fn project_telegram_block_parses_manager_bot_only() {
1542        // Realistic case: operator opts into managed-bots flow without
1543        // configuring AI profile pictures (uses the initials fallback
1544        // by absence-of-image-model). Each sub-block is independently
1545        // optional.
1546        let yaml = format!(
1547            "{PROJECT_YAML_HEAD}\
1548interfaces:
1549  telegram:
1550    manager_bot:
1551      token_env: TEAMCTL_TG_MANAGER_TOKEN
1552"
1553        );
1554        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1555        let tg = p.telegram().expect("project telegram parsed");
1556        assert_eq!(
1557            tg.manager_bot.as_ref().unwrap().token_env,
1558            "TEAMCTL_TG_MANAGER_TOKEN"
1559        );
1560        assert!(tg.profile_picture.is_none());
1561    }
1562
1563    #[test]
1564    fn profile_picture_fallback_defaults_to_initials_when_omitted() {
1565        // Q3 contract: omitting `fallback:` is operator-readable as
1566        // "use the v1 default", which is initials. Future variants
1567        // slot in without breaking this default.
1568        let yaml = format!(
1569            "{PROJECT_YAML_HEAD}\
1570interfaces:
1571  telegram:
1572    profile_picture:
1573      image_model:
1574        provider: openai
1575        api_key_env: OPENAI_API_KEY
1576        model: gpt-image-2
1577"
1578        );
1579        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1580        let pp = p
1581            .telegram()
1582            .and_then(|t| t.profile_picture.as_ref())
1583            .expect("profile_picture parsed");
1584        assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1585    }
1586
1587    #[test]
1588    fn profile_picture_image_model_optional_with_initials_fallback() {
1589        // Initials-only path: no AI generation configured, the
1590        // fallback is the entire rendering. Pins the (b) Q3-ratified
1591        // shape end to end at the schema level.
1592        let yaml = format!(
1593            "{PROJECT_YAML_HEAD}\
1594interfaces:
1595  telegram:
1596    profile_picture:
1597      fallback: initials
1598"
1599        );
1600        let p: Project = serde_yaml::from_str(&yaml).unwrap();
1601        let pp = p
1602            .telegram()
1603            .and_then(|t| t.profile_picture.as_ref())
1604            .expect("profile_picture parsed");
1605        assert!(pp.image_model.is_none());
1606        assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1607    }
1608
1609    #[test]
1610    fn manager_bot_missing_token_env_rejected() {
1611        // `token_env` is required (no `#[serde(default)]`). A YAML
1612        // missing it must reject at parse with a clear error so a
1613        // malformed setup is caught before it reaches the wizard.
1614        let yaml = format!(
1615            "{PROJECT_YAML_HEAD}\
1616interfaces:
1617  telegram:
1618    manager_bot: {{}}
1619"
1620        );
1621        let err =
1622            serde_yaml::from_str::<Project>(&yaml).expect_err("malformed manager_bot must reject");
1623        assert!(
1624            err.to_string().contains("token_env"),
1625            "error must name the missing field: {err}"
1626        );
1627    }
1628
1629    #[test]
1630    fn image_model_missing_required_fields_rejected() {
1631        // All three fields (provider, api_key_env, model) are
1632        // required. A YAML missing any of them must reject — mirrors
1633        // SttConfig's required-fields contract.
1634        for (label, yaml_fragment) in [
1635            ("missing provider", "api_key_env: K\n        model: M"),
1636            ("missing api_key_env", "provider: openai\n        model: M"),
1637            ("missing model", "provider: openai\n        api_key_env: K"),
1638        ] {
1639            let yaml = format!(
1640                "{PROJECT_YAML_HEAD}\
1641interfaces:
1642  telegram:
1643    profile_picture:
1644      image_model:
1645        {yaml_fragment}
1646"
1647            );
1648            let result = serde_yaml::from_str::<Project>(&yaml);
1649            assert!(
1650                result.is_err(),
1651                "malformed image_model ({label}) must reject, got: {result:?}"
1652            );
1653        }
1654    }
1655
1656    #[test]
1657    fn profile_picture_fallback_unknown_value_rejected() {
1658        // Mirror precedent: `effort_unknown_value_is_rejected` at
1659        // compose.rs. An unknown enum variant must reject at parse so
1660        // typos surface immediately rather than silently defaulting.
1661        let yaml = format!(
1662            "{PROJECT_YAML_HEAD}\
1663interfaces:
1664  telegram:
1665    profile_picture:
1666      fallback: emoji
1667"
1668        );
1669        let err = serde_yaml::from_str::<Project>(&yaml)
1670            .expect_err("unknown fallback variant must reject");
1671        assert!(
1672            err.to_string().contains("emoji") || err.to_string().contains("variant"),
1673            "error must explain the unknown variant: {err}"
1674        );
1675    }
1676}