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