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