Skip to main content

mur_common/
agent.rs

1//! Agent profile, Agent Card, and LockFile types shared between
2//! mur-agent-runtime and mur-core.
3
4use crate::companion::{Formality, Relationship};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
9///
10/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
11/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
12/// `mur agent skill add`.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14pub struct SkillCardEntry {
15    pub name: String,
16    #[serde(default, skip_serializing_if = "String::is_empty")]
17    pub version: String,
18    #[serde(default, skip_serializing_if = "String::is_empty")]
19    pub publisher: String,
20    #[serde(default, skip_serializing_if = "String::is_empty")]
21    pub description: String,
22    #[serde(default, skip_serializing_if = "String::is_empty")]
23    pub category: String,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub tags: Vec<String>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub triggers: Vec<SkillCardTrigger>,
28    /// Layer 2 abstract — injected at session start (~200 tokens).
29    /// On-disk YAML key is `abstract` (a Rust reserved word).
30    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
31    pub abstract_text: String,
32    /// Provenance chain copied from the installed manifest. Empty for
33    /// registry-installed skills.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub transfer_chain: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
39pub struct SkillCardTrigger {
40    #[serde(rename = "type")]
41    pub kind: String,
42    #[serde(default, skip_serializing_if = "String::is_empty")]
43    pub pattern: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct AgentProfile {
48    pub schema: u32,
49    pub id: String, // UUIDv7
50    pub name: String,
51    pub display_name: String,
52    pub version: String,
53    pub persona: Persona,
54    pub sys_prompt_file: String,
55    pub model: ModelConfig,
56    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
57    /// prefers the registry entry over the inline `model:` block.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub model_ref: Option<String>,
60    #[serde(default)]
61    pub mcp_servers: Vec<McpServerEntry>,
62    #[serde(default)]
63    pub skills: Vec<String>,
64    /// Skills installed via `mur skill install`. Distinct from `skills`
65    /// (which holds legacy per-agent paths from `mur agent skill add`).
66    /// Broadcast in the Agent Card alongside `skills`.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub installed_skills: Vec<SkillCardEntry>,
69    pub transport: TransportConfig,
70    pub communication: CommunicationConfig,
71    #[serde(default)]
72    pub capabilities: Vec<String>,
73    pub entitlements: Entitlements,
74    #[serde(default)]
75    pub notifications: NotificationsConfig,
76    pub retry: RetryConfig,
77    pub lifecycle: LifecycleConfig,
78    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
79    /// (legacy P0a profiles continue to load without this block).
80    #[serde(default)]
81    pub identity: IdentityConfig,
82    #[serde(default)]
83    pub file_transfer: FileTransferConfig,
84    #[serde(default)]
85    pub deployment: DeploymentConfig,
86    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
87    /// continue to load without this block).
88    #[serde(default)]
89    pub companion: CompanionConfig,
90    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
91    #[serde(default)]
92    pub hitl: HitlConfig,
93    /// Voice I/O configuration (D1). Default = disabled.
94    #[serde(default)]
95    pub voice: VoiceConfig,
96    /// A1: config-driven handler picker. Absent block = all defaults.
97    #[serde(default)]
98    pub hooks: crate::HooksConfig,
99    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
100    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
101    #[serde(default)]
102    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
103    pub created_at: String,
104    pub updated_at: String,
105    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
106    #[serde(default)]
107    pub appearance: AgentAppearance,
108    /// E6: Pattern federation — snapshot filter + outbox config.
109    #[serde(default)]
110    pub federation: FederationConfig,
111
112    /// A1: declarative UI action list — file_actions rendered as action
113    /// buttons in the pending-item selection UI. New top-level key; NOT
114    /// nested under `capabilities:`.
115    #[serde(default)]
116    pub file_actions: Vec<crate::action::FileAction>,
117
118    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
119    #[serde(default)]
120    pub action_pipeline: crate::action::ActionPipelineConfig,
121}
122
123fn default_algorithm() -> String {
124    "ed25519".into()
125}
126
127/// Algorithms the runtime can generate + verify.
128pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct IdentityConfig {
132    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
133    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
134    #[serde(default)]
135    pub pubkey: String,
136    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub owner: Option<String>,
139
140    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
141    /// Cryptographic algorithm for this key. Defaults to "ed25519".
142    #[serde(default = "default_algorithm")]
143    pub algorithm: String,
144    /// Monotonic version counter; 0 = initial create, increments on each rotation.
145    #[serde(default)]
146    pub key_version: u32,
147    /// RFC3339 timestamp of when this key was created.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub created_at_key: Option<String>,
150    /// Previous public key (before most recent rotation). None if not rotated yet.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub previous_pubkey: Option<String>,
153    /// Version of the previous key. None if not rotated yet.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub previous_key_version: Option<u32>,
156    /// RFC3339 timestamp when grace period expires and old key is fully retired.
157    /// Only set during rotation; cleared once grace period ends.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub grace_expires_at: Option<String>,
160    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub rotated_at: Option<String>,
163    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub emergency_rekey_at: Option<String>,
166}
167
168impl Default for IdentityConfig {
169    fn default() -> Self {
170        Self {
171            pubkey: String::new(),
172            owner: None,
173            algorithm: default_algorithm(),
174            key_version: 0,
175            created_at_key: None,
176            previous_pubkey: None,
177            previous_key_version: None,
178            grace_expires_at: None,
179            rotated_at: None,
180            emergency_rekey_at: None,
181        }
182    }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct Persona {
187    pub category: PersonaCategory,
188    pub description: String,
189    pub traits: PersonaTraits,
190}
191
192#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "lowercase")]
194pub enum PersonaCategory {
195    Research,
196    Automation,
197    Monitor,
198    Notify,
199    Commerce,
200    Custom,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204pub struct PersonaTraits {
205    pub tone: String,
206    pub risk: String,
207    pub verbosity: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211pub struct ModelConfig {
212    pub provider: String,
213    pub name: String,
214    #[serde(default)]
215    pub params: BTreeMap<String, serde_yaml_ng::Value>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
219pub struct McpServerEntry {
220    pub name: String,
221    pub command: String,
222    #[serde(default)]
223    pub args: Vec<String>,
224
225    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
226    /// path, captured at install time. `None` means the entry was
227    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
228    /// not applied — the supervisor will warn but not block.
229    /// (B0 rule 6 / M9.1)
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub binary_sha256: Option<String>,
232
233    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
234    /// `tools/list` response, captured at install time. `None` means
235    /// the install path skipped the description probe (e.g. the MCP
236    /// uses a non-stdio transport or the binary couldn't be reached)
237    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub description_hash: Option<String>,
240
241    /// Display-only publisher metadata captured at install time so
242    /// the user can recall what they consented to. `None` for older
243    /// entries. (B0 rule 6 / M9.1)
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub publisher: Option<McpPublisherInfo>,
246
247    /// RFC3339 timestamp of when the entry was added or last
248    /// re-approved by the user via `mur agent mcp pin`. Used by the
249    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
252}
253
254/// Display-only publisher metadata captured at install time. None of
255/// the fields are validated against any external authority — they're
256/// shown to the user during the install confirm prompt and reproduced
257/// in `mur agent mcp inspect` output so the user can audit who they
258/// thought they were trusting. (B0 rule 6 / M9.1)
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
260pub struct McpPublisherInfo {
261    /// Free-form publisher identifier — e.g. `"Anthropic"`,
262    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
263    pub name: String,
264
265    /// Optional homepage / docs URL. Best-effort: extracted from the
266    /// MCP's `serverInfo.metadata.homepage` or registry entry when
267    /// available; otherwise left unset.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub homepage: Option<String>,
270
271    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
272    /// Used purely for display; not consumed by any verification path.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub registry_id: Option<String>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
278pub struct TransportConfig {
279    pub stdio: bool,
280    pub socket: SocketTransportConfig,
281    #[serde(default)]
282    pub tcp: TcpTransportConfig,
283    /// Track C5 — HTTP webhook receiver. Default off; enabling
284    /// requires an HMAC secret in the OS keychain (`SecretRef`).
285    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
286    #[serde(default)]
287    pub webhook: WebhookTransportConfig,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
291pub struct TcpTransportConfig {
292    #[serde(default)]
293    pub enabled: bool,
294    #[serde(default)]
295    pub bind: String,
296    #[serde(default)]
297    pub noise: NoiseConfig,
298}
299
300/// HTTP webhook receiver — Track C5.
301///
302/// External systems POST `SharePayload`-shaped JSON to
303/// `http://<bind>:<port>/agents/<slug>/webhook` with an
304/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
305/// over the raw body. The HMAC secret is stored in the OS keychain
306/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
307/// `hmac_secret_ref` is the `service:account` lookup key.
308///
309/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
310/// inadvertently expose the agent to the local network. Users who
311/// want VPN / Tailscale reachability override to `0.0.0.0` or the
312/// VPN interface address explicitly.
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
314pub struct WebhookTransportConfig {
315    #[serde(default)]
316    pub enabled: bool,
317    #[serde(default = "default_webhook_bind")]
318    pub bind: String,
319    #[serde(default = "default_webhook_port")]
320    pub port: u16,
321    /// `service:account` key into the OS keychain. Empty string
322    /// when `enabled = false`; required (and validated) at startup
323    /// when enabled.
324    #[serde(default)]
325    pub hmac_secret_ref: String,
326}
327
328fn default_webhook_bind() -> String {
329    "127.0.0.1".to_string()
330}
331
332fn default_webhook_port() -> u16 {
333    6789
334}
335
336impl Default for WebhookTransportConfig {
337    fn default() -> Self {
338        Self {
339            enabled: false,
340            bind: default_webhook_bind(),
341            port: default_webhook_port(),
342            hmac_secret_ref: String::new(),
343        }
344    }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
348pub struct NoiseConfig {
349    pub pattern: String,
350}
351
352impl Default for NoiseConfig {
353    fn default() -> Self {
354        Self {
355            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
356        }
357    }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
361pub struct SocketTransportConfig {
362    pub enabled: bool,
363    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub auth: Option<AuthConfig>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
369pub struct AuthConfig {
370    pub scheme: String,
371    pub token_file: String,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct CommunicationConfig {
376    #[serde(default = "default_accepts_all")]
377    pub accepts_from: Vec<String>,
378    #[serde(default)]
379    pub sends_to: Vec<String>,
380}
381fn default_accepts_all() -> Vec<String> {
382    vec!["*".to_string()]
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
386pub struct Entitlements {
387    pub network: NetworkEntitlement,
388    pub filesystem: FilesystemEntitlement,
389    pub processes: ProcessesEntitlement,
390    #[serde(default)]
391    pub syscalls: SyscallsEntitlement,
392    #[serde(default)]
393    pub limits: LimitsEntitlement,
394    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
395    /// so the supervisor refuses to construct an LLM client.
396    #[serde(default)]
397    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
398    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub tools: Vec<ToolRule>,
401    /// When `true` (the default), a sandbox apply failure is fatal: the agent
402    /// refuses to start rather than running advisory-only (unconfined).
403    /// Set to `false` only for development or trusted-workstation agents that
404    /// intentionally run without kernel sandbox enforcement.
405    #[serde(default = "default_true")]
406    pub fail_closed_on_sandbox_error: bool,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct NetworkEntitlement {
411    pub inbound: InboundNetwork,
412    pub outbound: OutboundNetwork,
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
416pub struct InboundNetwork {
417    #[serde(default)]
418    pub ports: Vec<u16>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
422pub struct OutboundNetwork {
423    pub mode: NetworkOutboundMode,
424    #[serde(default)]
425    pub allow_hosts: Vec<String>,
426    #[serde(default = "default_protocols")]
427    pub protocols: Vec<String>,
428    #[serde(default)]
429    pub resolve_dns: ResolveDnsConfig,
430}
431fn default_protocols() -> Vec<String> {
432    vec!["tcp".to_string()]
433}
434
435#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
436#[serde(rename_all = "lowercase")]
437pub enum NetworkOutboundMode {
438    Unrestricted,
439    Restricted,
440    Off,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
444pub struct ResolveDnsConfig {
445    #[serde(default = "default_dns_mode")]
446    pub mode: String,
447    #[serde(default)]
448    pub servers: Vec<String>,
449}
450impl Default for ResolveDnsConfig {
451    fn default() -> Self {
452        Self {
453            mode: default_dns_mode(),
454            servers: vec![],
455        }
456    }
457}
458fn default_dns_mode() -> String {
459    "system".to_string()
460}
461
462#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
463pub struct FilesystemEntitlement {
464    #[serde(default)]
465    pub read: Vec<String>,
466    #[serde(default)]
467    pub write: Vec<String>,
468    #[serde(default)]
469    pub deny: Vec<String>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
473pub struct ProcessesEntitlement {
474    pub spawn: SpawnEntitlement,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
478pub struct SpawnEntitlement {
479    pub mode: SpawnMode,
480    #[serde(default)]
481    pub allowed: Vec<String>,
482}
483
484#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
485#[serde(rename_all = "lowercase")]
486pub enum SpawnMode {
487    Allowlist,
488    Any,
489    None,
490}
491
492#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
493pub struct SyscallsEntitlement {
494    #[serde(default = "default_syscalls_mode")]
495    pub mode: String,
496    #[serde(default)]
497    pub extra_deny: Vec<String>,
498}
499fn default_syscalls_mode() -> String {
500    "default".to_string()
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
504pub struct LimitsEntitlement {
505    #[serde(default)]
506    pub cpu_seconds: Option<u64>,
507    #[serde(default = "default_memory_mb")]
508    pub memory_mb: u64,
509    #[serde(default = "default_fds")]
510    pub file_descriptors: u32,
511    #[serde(default = "default_procs")]
512    pub processes: u32,
513}
514fn default_memory_mb() -> u64 {
515    512
516}
517fn default_fds() -> u32 {
518    1024
519}
520fn default_procs() -> u32 {
521    32
522}
523
524#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
525#[serde(rename_all = "lowercase")]
526pub enum ToolPolicy {
527    Allow,
528    #[default]
529    Ask,
530    Deny,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
534pub struct ToolRule {
535    pub pattern: String,
536    pub policy: ToolPolicy,
537}
538
539/// Resolve the effective policy for `tool_name` against an ordered rule list.
540///
541/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
542pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
543    for rule in rules {
544        if rule.pattern == tool_name {
545            return rule.policy;
546        }
547    }
548    let mut best: Option<(&ToolRule, usize)> = None;
549    for rule in rules {
550        if let Some(prefix) = rule.pattern.strip_suffix('*')
551            && tool_name.starts_with(prefix)
552        {
553            let len = prefix.len();
554            if best.is_none_or(|(_, best_len)| len > best_len) {
555                best = Some((rule, len));
556            }
557        }
558    }
559    if let Some((rule, _)) = best {
560        return rule.policy;
561    }
562    ToolPolicy::default()
563}
564
565#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
566pub struct NotificationsConfig {
567    #[serde(default)]
568    pub on_task_complete: Vec<NotificationTarget>,
569    #[serde(default)]
570    pub on_error: Vec<NotificationTarget>,
571    #[serde(default)]
572    pub on_shutdown: Vec<NotificationTarget>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
576#[serde(tag = "target", rename_all = "lowercase")]
577pub enum NotificationTarget {
578    Agent {
579        name: String,
580    },
581    Commander,
582    Email {
583        address: String,
584        #[serde(default)]
585        smtp_config_file: Option<String>,
586    },
587    Slack {
588        #[serde(default)]
589        channel: Option<String>,
590        #[serde(default)]
591        webhook_url_env: Option<String>,
592    },
593    Webpush {
594        url: String,
595    },
596    Webhook {
597        url: String,
598        #[serde(default = "default_post")]
599        method: String,
600        #[serde(default)]
601        auth: Option<String>,
602    },
603}
604fn default_post() -> String {
605    "POST".to_string()
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
609pub struct RetryConfig {
610    pub llm: RetryPolicy,
611    pub tool: RetryPolicy,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
615pub struct RetryPolicy {
616    pub max_retries: u32,
617    pub backoff: BackoffStrategy,
618    pub initial_delay_ms: u64,
619    #[serde(default)]
620    pub max_delay_ms: Option<u64>,
621    #[serde(default)]
622    pub retry_on: Vec<String>,
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
626#[serde(rename_all = "lowercase")]
627pub enum BackoffStrategy {
628    Linear,
629    Exponential,
630    Fixed,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
634pub struct LifecycleConfig {
635    pub restart: RestartPolicy,
636    #[serde(default = "default_max_restarts")]
637    pub max_restarts: u32,
638    #[serde(default = "default_window")]
639    pub restart_window_secs: u64,
640    #[serde(default = "default_stop_timeout")]
641    pub stop_timeout_secs: u64,
642    #[serde(default = "default_mcp_required")]
643    pub mcp_required: bool,
644    #[serde(default)]
645    pub execution: ExecutionMode,
646    #[serde(default)]
647    pub schedule: Vec<ScheduleEntry>,
648    #[serde(default)]
649    pub idle_triggers: Vec<IdleTrigger>,
650}
651fn default_max_restarts() -> u32 {
652    3
653}
654fn default_window() -> u64 {
655    600
656}
657fn default_stop_timeout() -> u64 {
658    15
659}
660fn default_mcp_required() -> bool {
661    true
662}
663
664#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
665#[serde(rename_all = "snake_case")]
666pub enum RestartPolicy {
667    Never,
668    OnFailure,
669    Always,
670}
671
672#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
673#[serde(rename_all = "snake_case")]
674pub enum ExecutionMode {
675    #[default]
676    Daemon,
677    OnDemand,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
681pub struct ScheduleEntry {
682    pub cron: String,
683    pub message: String,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub sends_to: Option<String>,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
689pub struct IdleTrigger {
690    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
691    pub after_secs: u64,
692    /// Message body injected into the task runner when this trigger fires.
693    pub message: String,
694    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub sends_to: Option<String>,
697    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
698    /// idle threshold is short and the runner finishes quickly. Default 600.
699    #[serde(default = "default_idle_cooldown")]
700    pub cooldown_secs: u64,
701    /// When true, suppress firing during the agent's quiet-hours window.
702    /// Default true — idle pings should not wake the user at 3 a.m.
703    #[serde(default = "default_true")]
704    pub respect_quiet_hours: bool,
705}
706
707fn default_idle_cooldown() -> u64 {
708    600
709}
710fn default_true() -> bool {
711    true
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
715pub struct FileTransferConfig {
716    #[serde(default = "default_accept_max")]
717    pub accept_incoming_file_max_bytes: u64,
718    #[serde(default = "default_accept_total")]
719    pub accept_incoming_total_per_hour: u64,
720    #[serde(default = "default_approval_threshold")]
721    pub require_approval_above_bytes: u64,
722    #[serde(default = "default_reject_paths")]
723    pub reject_paths: Vec<String>,
724    #[serde(default = "default_allowed_mime")]
725    pub allowed_mime_types: Vec<String>,
726}
727
728impl Default for FileTransferConfig {
729    fn default() -> Self {
730        Self {
731            accept_incoming_file_max_bytes: default_accept_max(),
732            accept_incoming_total_per_hour: default_accept_total(),
733            require_approval_above_bytes: default_approval_threshold(),
734            reject_paths: default_reject_paths(),
735            allowed_mime_types: default_allowed_mime(),
736        }
737    }
738}
739
740fn default_accept_max() -> u64 {
741    10_485_760
742}
743fn default_accept_total() -> u64 {
744    104_857_600
745}
746fn default_approval_threshold() -> u64 {
747    10_485_760
748}
749fn default_reject_paths() -> Vec<String> {
750    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
751}
752fn default_allowed_mime() -> Vec<String> {
753    vec!["*".into()]
754}
755
756#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
757#[serde(rename_all = "snake_case")]
758pub enum DeploymentType {
759    #[default]
760    Laptop,
761    Vm,
762    Docker,
763    K8s,
764    Lambda,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
768pub struct DeploymentConfig {
769    #[serde(rename = "type", default)]
770    pub deployment_type: DeploymentType,
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub region: Option<String>,
773    #[serde(default = "default_env")]
774    pub environment: Option<String>,
775}
776
777impl Default for DeploymentConfig {
778    fn default() -> Self {
779        Self {
780            deployment_type: DeploymentType::default(),
781            region: None,
782            environment: default_env(),
783        }
784    }
785}
786
787fn default_env() -> Option<String> {
788    Some("dev".into())
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
792pub struct LockFile {
793    pub schema: u32,
794    pub uuid: String,
795    pub name: String,
796    pub pid: u32,
797    pub ppid: u32,
798    pub started_at: String,
799    pub binary_version: String,
800    pub transports: LockTransports,
801    pub card_digest: String,
802    pub capabilities: Vec<String>,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
806pub struct LockTransports {
807    pub stdio: bool,
808    #[serde(default)]
809    pub unix_socket: Option<String>,
810    #[serde(default)]
811    pub tcp: Option<String>,
812    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
813    /// Populated by the supervisor when `transport.webhook.enabled =
814    /// true` so peers and the commander can discover the live
815    /// endpoint without re-reading `profile.yaml`.
816    #[serde(default)]
817    pub webhook: Option<String>,
818}
819
820// ──────────────────────────────────────────────────────────────────────────
821// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
822// ──────────────────────────────────────────────────────────────────────────
823
824/// Kokoro 82M voice identity. Maps to the per-voice style vector
825/// embedded in the Kokoro ONNX model.
826#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
827#[serde(rename_all = "snake_case")]
828pub enum VoiceId {
829    /// Default: Kokoro af_heart voice.
830    #[default]
831    AfHeart,
832    AfBella,
833    AfNicole,
834    AmAdam,
835    AmMichael,
836}
837
838impl VoiceId {
839    /// Index into the Kokoro voices.bin style matrix (row index).
840    pub fn style_index(&self) -> usize {
841        match self {
842            VoiceId::AfHeart => 0,
843            VoiceId::AfBella => 1,
844            VoiceId::AfNicole => 2,
845            VoiceId::AmAdam => 3,
846            VoiceId::AmMichael => 4,
847        }
848    }
849
850    /// Canonical lowercase string representation (matches `FromStr` inputs).
851    pub fn as_str(&self) -> &'static str {
852        match self {
853            VoiceId::AfHeart => "af_heart",
854            VoiceId::AfBella => "af_bella",
855            VoiceId::AfNicole => "af_nicole",
856            VoiceId::AmAdam => "am_adam",
857            VoiceId::AmMichael => "am_michael",
858        }
859    }
860}
861
862impl std::str::FromStr for VoiceId {
863    type Err = anyhow::Error;
864
865    fn from_str(s: &str) -> anyhow::Result<Self> {
866        match s {
867            "af_heart" => Ok(VoiceId::AfHeart),
868            "af_bella" => Ok(VoiceId::AfBella),
869            "af_nicole" => Ok(VoiceId::AfNicole),
870            "am_adam" => Ok(VoiceId::AmAdam),
871            "am_michael" => Ok(VoiceId::AmMichael),
872            other => anyhow::bail!(
873                "unknown voice ID '{other}' \
874                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
875            ),
876        }
877    }
878}
879
880/// Per-agent voice I/O configuration (D1).
881/// Default = disabled so existing profiles continue to load unchanged.
882#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
883pub struct VoiceConfig {
884    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
885    #[serde(default)]
886    pub enabled: bool,
887    /// Kokoro voice identity for TTS output. Default: af_heart.
888    #[serde(default)]
889    pub voice_id: VoiceId,
890    /// Optional cpal input device name for mic capture.
891    /// None means the OS default input device.
892    #[serde(default, skip_serializing_if = "Option::is_none")]
893    pub input_device: Option<String>,
894}
895
896// ──────────────────────────────────────────────────────────────────────────
897// Human-in-the-loop configuration (Phase 2)
898// ──────────────────────────────────────────────────────────────────────────
899
900#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
901pub struct HitlConfig {
902    #[serde(default = "default_hitl_timeout_secs")]
903    pub timeout_secs: u32,
904    #[serde(default)]
905    pub max_iterations: Option<u32>,
906}
907
908fn default_hitl_timeout_secs() -> u32 {
909    300
910}
911
912impl Default for HitlConfig {
913    fn default() -> Self {
914        Self {
915            timeout_secs: default_hitl_timeout_secs(),
916            max_iterations: None,
917        }
918    }
919}
920
921#[cfg(test)]
922mod hitl_tests {
923    use super::*;
924
925    #[test]
926    fn hitl_config_default_max_iterations_is_none() {
927        let cfg = HitlConfig::default();
928        assert!(cfg.max_iterations.is_none());
929    }
930
931    #[test]
932    fn hitl_config_max_iterations_explicit() {
933        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
934        assert_eq!(cfg.max_iterations, Some(5));
935    }
936}
937
938// ──────────────────────────────────────────────────────────────────────────
939// Companion subsystem (Phase 1.1+) — see
940// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
941// ──────────────────────────────────────────────────────────────────────────
942
943#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
944pub struct CompanionConfig {
945    #[serde(default)]
946    pub enabled: bool,
947    #[serde(default = "default_locale")]
948    pub locale: String,
949    #[serde(default)]
950    pub relationship: Relationship,
951    #[serde(default)]
952    pub voice_overrides: VoiceOverrides,
953    #[serde(default)]
954    pub onboarding: OnboardingState,
955    #[serde(default)]
956    pub rhythm: RhythmConfig,
957    #[serde(default)]
958    pub proactive: ProactiveConfig,
959}
960
961/// Resolve a default BCP-47 locale from the `LANG` environment variable
962/// (e.g. `zh_TW.UTF-8` → `zh-TW`). Falls back to `en-US`.
963pub fn default_locale() -> String {
964    std::env::var("LANG")
965        .ok()
966        .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
967        .unwrap_or_else(|| "en-US".into())
968}
969
970#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
971pub struct VoiceOverrides {
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub name_for_user: Option<String>,
974    #[serde(default, skip_serializing_if = "Option::is_none")]
975    pub formality: Option<Formality>,
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub extra_instructions: Option<String>,
978}
979
980#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
981pub struct FirstMemory {
982    pub text: String,
983    pub established_at: chrono::DateTime<chrono::Utc>,
984}
985
986#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
987pub struct OnboardingState {
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
990    #[serde(default)]
991    pub version: u32,
992    #[serde(default, skip_serializing_if = "Option::is_none")]
993    pub agent_display_name: Option<String>,
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub first_memory: Option<FirstMemory>,
996}
997
998/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
999/// out of 1.1 scope).
1000#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1001pub struct RhythmConfig {
1002    #[serde(default)]
1003    pub enabled: bool,
1004}
1005
1006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1007pub struct ProactiveConfig {
1008    #[serde(default)]
1009    pub enabled: bool,
1010    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1013    #[serde(default, skip_serializing_if = "Option::is_none")]
1014    pub quiet_hours: Option<QuietHours>,
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub active_hours: Option<ActiveHours>,
1017    #[serde(default = "default_daily_cap")]
1018    pub daily_cap: u8,
1019    #[serde(default = "default_channels")]
1020    pub channels: Vec<String>,
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1023}
1024
1025impl Default for ProactiveConfig {
1026    fn default() -> Self {
1027        Self {
1028            enabled: false,
1029            learning_until: None,
1030            quiet_hours: None,
1031            active_hours: None,
1032            daily_cap: default_daily_cap(),
1033            channels: default_channels(),
1034            paused_until: None,
1035        }
1036    }
1037}
1038
1039fn default_daily_cap() -> u8 {
1040    3
1041}
1042fn default_channels() -> Vec<String> {
1043    vec!["stdout".into()]
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1047pub struct QuietHours {
1048    pub start: String,
1049    pub end: String,
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1053pub struct ActiveHours {
1054    pub start: String,
1055    pub end: String,
1056}
1057
1058// ──────────────────────────────────────────────────────────────────────────
1059// Hub companion appearance (M-h3)
1060// ──────────────────────────────────────────────────────────────────────────
1061
1062#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1063pub struct AgentAppearance {
1064    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
1065    #[serde(default = "default_style_preset")]
1066    pub style_preset: String,
1067    #[serde(default)]
1068    pub behavior_preset: BehaviorPreset,
1069    /// Required for the polaroid family; none for all others.
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub source_image_path: Option<std::path::PathBuf>,
1072    /// Local dir where rendered .webp expression frames are stored.
1073    #[serde(default = "default_expressions_dir")]
1074    pub expressions_dir: std::path::PathBuf,
1075    #[serde(default, skip_serializing_if = "Option::is_none")]
1076    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1077    #[serde(default)]
1078    pub render_status: RenderStatus,
1079}
1080
1081fn default_style_preset() -> String {
1082    "default-blob".into()
1083}
1084
1085fn default_expressions_dir() -> std::path::PathBuf {
1086    std::path::PathBuf::from("expressions")
1087}
1088
1089impl Default for AgentAppearance {
1090    fn default() -> Self {
1091        Self {
1092            style_preset: default_style_preset(),
1093            behavior_preset: BehaviorPreset::Normal,
1094            source_image_path: None,
1095            expressions_dir: default_expressions_dir(),
1096            last_rendered_at: None,
1097            render_status: RenderStatus::Pending,
1098        }
1099    }
1100}
1101
1102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1103#[serde(rename_all = "snake_case")]
1104pub enum BehaviorPreset {
1105    Quiet,
1106    #[default]
1107    Normal,
1108    Lively,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1112#[serde(tag = "status", rename_all = "snake_case")]
1113pub enum RenderStatus {
1114    #[default]
1115    Pending,
1116    Rendering {
1117        done: u8,
1118        total: u8,
1119    },
1120    Ready,
1121    Failed {
1122        reason: String,
1123    },
1124}
1125
1126// ──────────────────────────────────────────────────────────────────────────
1127// E6 — Agent Pattern Federation types
1128// ──────────────────────────────────────────────────────────────────────────
1129
1130/// When the agent pulls an updated pattern snapshot from the daemon.
1131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1132#[serde(rename_all = "kebab-case")]
1133pub enum SnapshotPolicy {
1134    #[default]
1135    PullOnStart,
1136    PullPeriodic,
1137    Manual,
1138}
1139
1140/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1142pub struct PatternFilter {
1143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1144    pub applies_in: Vec<String>,
1145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1146    pub tier: Vec<String>,
1147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1148    pub maturity: Vec<String>,
1149    #[serde(default)]
1150    pub importance_min: f64,
1151    #[serde(default = "default_max_snapshot_count")]
1152    pub max_count: usize,
1153    #[serde(default)]
1154    pub snapshot_policy: SnapshotPolicy,
1155}
1156
1157fn default_max_snapshot_count() -> usize {
1158    200
1159}
1160
1161impl Default for PatternFilter {
1162    fn default() -> Self {
1163        Self {
1164            applies_in: vec![],
1165            tier: vec![],
1166            maturity: vec![],
1167            importance_min: 0.0,
1168            max_count: 200,
1169            snapshot_policy: SnapshotPolicy::default(),
1170        }
1171    }
1172}
1173
1174/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1176pub struct SnapshotRef {
1177    pub knowledge_commit: String,
1178    pub taken_at: String,
1179    pub filter: PatternFilter,
1180}
1181
1182/// Federation configuration embedded in AgentProfile (E6).
1183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1184pub struct FederationConfig {
1185    #[serde(default)]
1186    pub filter: PatternFilter,
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    pub snapshot_ref: Option<SnapshotRef>,
1189    #[serde(default)]
1190    pub evidence_flush_interval_minutes: u32,
1191}
1192
1193impl AgentProfile {
1194    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1195    ///
1196    /// Available in all compilation modes so integration tests in
1197    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1198    /// are invisible to downstream test binaries).
1199    #[doc(hidden)]
1200    pub fn default_for_tests() -> Self {
1201        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1202            .expect("minimal profile fixture")
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    #[test]
1211    fn profile_round_trip_yaml() {
1212        let yaml = r#"
1213schema: 1
1214id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1215name: agent_a
1216display_name: "Price Hunter"
1217version: "0.1.0"
1218persona:
1219  category: research
1220  description: "Finds prices"
1221  traits: { tone: concise, risk: cautious, verbosity: low }
1222sys_prompt_file: "sys_prompt.md"
1223model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1224mcp_servers: []
1225skills: []
1226transport:
1227  stdio: true
1228  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1229communication: { accepts_from: ["*"], sends_to: [] }
1230capabilities: ["a2a.message.send", "a2a.tasks"]
1231entitlements:
1232  network:
1233    inbound: { ports: [] }
1234    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1235  filesystem: { read: [], write: [], deny: [] }
1236  processes: { spawn: { mode: allowlist, allowed: [] } }
1237  syscalls: { mode: default }
1238  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1239notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1240retry:
1241  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1242  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1243lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1244created_at: "2026-04-22T10:00:00+08:00"
1245updated_at: "2026-04-22T10:00:00+08:00"
1246"#;
1247        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1248        assert_eq!(profile.name, "agent_a");
1249        assert_eq!(profile.persona.category, PersonaCategory::Research);
1250        assert_eq!(
1251            profile.entitlements.network.outbound.mode,
1252            NetworkOutboundMode::Restricted
1253        );
1254        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1255        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1256        assert_eq!(profile.id, round_tripped.id);
1257    }
1258}
1259
1260#[cfg(test)]
1261mod model_ref_tests {
1262    use super::*;
1263
1264    #[test]
1265    fn legacy_profile_without_model_ref_still_parses() {
1266        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1267        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1268        assert!(
1269            p.model_ref.is_none(),
1270            "legacy profile must not have model_ref"
1271        );
1272    }
1273
1274    #[test]
1275    fn round_trip_with_model_ref_preserves_field() {
1276        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1277        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1278        p.model_ref = Some("anthropic_opus_4_7".into());
1279        let s = serde_yaml_ng::to_string(&p).unwrap();
1280        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1281        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1282        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1283    }
1284}
1285
1286/// GUI-facing reification of the companion's three-layer permission toggle.
1287///
1288/// On-disk schema doesn't change — this helper just maps between the
1289/// three independent booleans (`enabled`, `rhythm.enabled`,
1290/// `proactive.enabled`) and a single ordered tier. Use
1291/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
1292/// to write.
1293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1294#[serde(rename_all = "snake_case")]
1295pub enum ProactiveTier {
1296    Off,
1297    WarmOnly,
1298    WarmAndBehavior,
1299    All,
1300}
1301
1302impl ProactiveTier {
1303    pub fn from_config(c: &CompanionConfig) -> Self {
1304        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1305            (false, _, _) => Self::Off,
1306            (true, false, false) => Self::WarmOnly,
1307            (true, true, false) => Self::WarmAndBehavior,
1308            (true, _, true) => Self::All,
1309        }
1310    }
1311
1312    pub fn apply(&self, c: &mut CompanionConfig) {
1313        match self {
1314            Self::Off => {
1315                c.enabled = false;
1316                c.rhythm.enabled = false;
1317                c.proactive.enabled = false;
1318            }
1319            Self::WarmOnly => {
1320                c.enabled = true;
1321                c.rhythm.enabled = false;
1322                c.proactive.enabled = false;
1323            }
1324            Self::WarmAndBehavior => {
1325                c.enabled = true;
1326                c.rhythm.enabled = true;
1327                c.proactive.enabled = false;
1328            }
1329            Self::All => {
1330                c.enabled = true;
1331                c.rhythm.enabled = true;
1332                c.proactive.enabled = true;
1333            }
1334        }
1335    }
1336}
1337
1338#[cfg(test)]
1339mod mcp_pin_tests {
1340    use super::*;
1341
1342    /// Pre-M9 profiles must continue to deserialize with the new
1343    /// optional fields absent. Round-trip: serialize back out and
1344    /// confirm the optional fields don't leak into the YAML.
1345    #[test]
1346    fn pre_m9_entry_roundtrips_without_pin_fields() {
1347        let yaml = r#"
1348name: weather
1349command: /opt/mcp/weather
1350args: ["--port", "0"]
1351"#;
1352        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1353        assert_eq!(entry.name, "weather");
1354        assert_eq!(entry.binary_sha256, None);
1355        assert_eq!(entry.description_hash, None);
1356        assert_eq!(entry.publisher, None);
1357        assert_eq!(entry.installed_at, None);
1358
1359        // skip_serializing_if = "Option::is_none" must keep the YAML
1360        // free of empty pin fields when the entry is pre-M9.
1361        let out = serde_yaml_ng::to_string(&entry).unwrap();
1362        assert!(!out.contains("binary_sha256"), "got {out}");
1363        assert!(!out.contains("description_hash"), "got {out}");
1364        assert!(!out.contains("publisher"), "got {out}");
1365        assert!(!out.contains("installed_at"), "got {out}");
1366    }
1367
1368    /// Full M9 entry with all fields set round-trips losslessly.
1369    #[test]
1370    fn full_m9_entry_roundtrips_all_fields() {
1371        let yaml = r#"
1372name: weather
1373command: /opt/mcp/weather
1374args: []
1375binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1376description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1377publisher:
1378  name: "@anthropic-mcp/weather"
1379  homepage: "https://github.com/anthropic-mcp/weather"
1380  registry_id: "@anthropic-mcp/weather@1.2.3"
1381installed_at: "2026-05-06T08:00:00Z"
1382"#;
1383        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1384        assert!(
1385            entry
1386                .binary_sha256
1387                .as_deref()
1388                .unwrap()
1389                .starts_with("3f4abca8")
1390        );
1391        assert!(
1392            entry
1393                .description_hash
1394                .as_deref()
1395                .unwrap()
1396                .starts_with("9a01b2c3")
1397        );
1398        let pub_info = entry.publisher.clone().unwrap();
1399        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1400        assert_eq!(
1401            pub_info.homepage.as_deref(),
1402            Some("https://github.com/anthropic-mcp/weather"),
1403        );
1404        assert_eq!(
1405            pub_info.registry_id.as_deref(),
1406            Some("@anthropic-mcp/weather@1.2.3"),
1407        );
1408        let installed = entry.installed_at.unwrap();
1409        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1410    }
1411
1412    /// Partial — only the binary hash is set (e.g. probe failed but
1413    /// install proceeded). The supervisor still needs to be able to
1414    /// deserialize this without panicking.
1415    #[test]
1416    fn partial_pin_only_binary_sha_roundtrips() {
1417        let yaml = r#"
1418name: weather
1419command: /opt/mcp/weather
1420args: []
1421binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1422"#;
1423        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1424        assert_eq!(
1425            entry.binary_sha256.as_deref(),
1426            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1427        );
1428        assert_eq!(entry.description_hash, None);
1429        assert_eq!(entry.publisher, None);
1430    }
1431
1432    /// Publisher with only the required `name` field — homepage and
1433    /// registry_id are optional.
1434    #[test]
1435    fn publisher_minimal_just_name() {
1436        let yaml = r#"
1437name: weather
1438command: /opt/mcp/weather
1439args: []
1440publisher:
1441  name: "alice"
1442"#;
1443        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1444        let p = entry.publisher.as_ref().unwrap();
1445        assert_eq!(p.name, "alice");
1446        assert_eq!(p.homepage, None);
1447        assert_eq!(p.registry_id, None);
1448
1449        // skip_serializing_if must omit the optional sub-fields too.
1450        let out = serde_yaml_ng::to_string(&entry).unwrap();
1451        assert!(!out.contains("homepage:"), "got {out}");
1452        assert!(!out.contains("registry_id:"), "got {out}");
1453    }
1454}
1455
1456#[cfg(test)]
1457mod voice_tests {
1458    use super::*;
1459    use std::str::FromStr;
1460
1461    #[test]
1462    fn voice_config_round_trips() {
1463        // Base: use the canonical minimal fixture and append a voice: block.
1464        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1465        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
1466
1467        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1468        assert!(profile.voice.enabled);
1469        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1470
1471        // Legacy profiles (no voice: block) must still load.
1472        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1473        assert!(!legacy.voice.enabled);
1474        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1475    }
1476
1477    #[test]
1478    fn voice_id_from_str_roundtrips() {
1479        let cases = [
1480            ("af_heart", VoiceId::AfHeart),
1481            ("af_bella", VoiceId::AfBella),
1482            ("af_nicole", VoiceId::AfNicole),
1483            ("am_adam", VoiceId::AmAdam),
1484            ("am_michael", VoiceId::AmMichael),
1485        ];
1486        for (s, expected) in cases {
1487            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1488            assert_eq!(expected.as_str(), s);
1489        }
1490    }
1491
1492    #[test]
1493    fn voice_id_from_str_rejects_unknown() {
1494        assert!(VoiceId::from_str("bogus").is_err());
1495    }
1496}
1497
1498#[cfg(test)]
1499mod idle_trigger_tests {
1500    use super::*;
1501
1502    #[test]
1503    fn idle_trigger_yaml_round_trip() {
1504        let yaml = r#"
1505restart: on_failure
1506idle_triggers:
1507  - after_secs: 3600
1508    message: "still there?"
1509    sends_to: other_agent
1510    cooldown_secs: 1800
1511    respect_quiet_hours: true
1512"#;
1513        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1514        assert_eq!(cfg.idle_triggers.len(), 1);
1515        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1516        assert_eq!(cfg.idle_triggers[0].message, "still there?");
1517        assert_eq!(
1518            cfg.idle_triggers[0].sends_to.as_deref(),
1519            Some("other_agent")
1520        );
1521        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1522        assert!(cfg.idle_triggers[0].respect_quiet_hours);
1523    }
1524
1525    #[test]
1526    fn idle_trigger_defaults_when_omitted() {
1527        let yaml = "restart: on_failure\n";
1528        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1529        assert!(cfg.idle_triggers.is_empty());
1530    }
1531}
1532
1533#[cfg(test)]
1534mod appearance_tests {
1535    use super::*;
1536
1537    #[test]
1538    fn appearance_default_style_preset_is_default_blob() {
1539        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1540    }
1541
1542    #[test]
1543    fn appearance_default_behavior_is_normal() {
1544        assert_eq!(
1545            AgentAppearance::default().behavior_preset,
1546            BehaviorPreset::Normal
1547        );
1548    }
1549
1550    #[test]
1551    fn appearance_default_render_status_is_pending() {
1552        assert_eq!(
1553            AgentAppearance::default().render_status,
1554            RenderStatus::Pending
1555        );
1556    }
1557
1558    #[test]
1559    fn render_status_serde_round_trip() {
1560        let cases = [
1561            RenderStatus::Pending,
1562            RenderStatus::Rendering { done: 3, total: 12 },
1563            RenderStatus::Ready,
1564            RenderStatus::Failed {
1565                reason: "out of quota".into(),
1566            },
1567        ];
1568        for status in cases {
1569            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1570            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1571            assert_eq!(status, back);
1572        }
1573    }
1574
1575    #[test]
1576    fn agent_profile_with_appearance_round_trips() {
1577        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1578        let yaml = format!(
1579            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
1580        );
1581        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1582        assert_eq!(profile.appearance.style_preset, "chiikawa");
1583        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1584
1585        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1586        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1587        assert_eq!(profile.appearance, back.appearance);
1588    }
1589
1590    #[test]
1591    fn legacy_profile_without_appearance_uses_default() {
1592        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1593        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1594        assert_eq!(profile.appearance.style_preset, "default-blob");
1595        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1596        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1597    }
1598
1599    #[test]
1600    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1601        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1602        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1603        assert!(p.file_actions.is_empty());
1604        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1605        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1606    }
1607}
1608
1609#[cfg(test)]
1610mod federation_tests {
1611    use super::*;
1612
1613    #[test]
1614    fn test_pattern_filter_default() {
1615        let f = PatternFilter::default();
1616        assert_eq!(f.max_count, 200);
1617        assert_eq!(f.importance_min, 0.0);
1618        assert!(f.tier.is_empty());
1619    }
1620
1621    #[test]
1622    fn test_federation_config_roundtrip() {
1623        let cfg = FederationConfig {
1624            filter: PatternFilter {
1625                tier: vec!["core".into()],
1626                max_count: 50,
1627                ..Default::default()
1628            },
1629            snapshot_ref: Some(SnapshotRef {
1630                knowledge_commit: "abc123def456".into(),
1631                taken_at: "2026-05-19T00:00:00Z".into(),
1632                filter: PatternFilter::default(),
1633            }),
1634            evidence_flush_interval_minutes: 15,
1635        };
1636        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1637        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1638        assert_eq!(cfg, back);
1639    }
1640
1641    #[test]
1642    fn test_agent_profile_federation_defaults() {
1643        // AgentProfile without a federation block deserializes with FederationConfig::default().
1644        // Use the minimal YAML that passes validation — just the required fields.
1645        // (We check only that the field has its zero value, not full profile parse.)
1646        let cfg = FederationConfig::default();
1647        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1648        assert!(cfg.snapshot_ref.is_none());
1649    }
1650}
1651
1652#[cfg(test)]
1653mod skill_card_tests {
1654    use super::*;
1655
1656    #[test]
1657    fn installed_skills_default_to_empty_when_absent() {
1658        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1659        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1660        assert!(p.installed_skills.is_empty());
1661    }
1662
1663    #[test]
1664    fn installed_skills_roundtrip_preserves_entries() {
1665        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1666        let yaml = format!(
1667            "{base}installed_skills:\n  - name: s1\n    version: 1.0.0\n    publisher: human:d\n    description: desc\n    category: workflow\n    tags: [web]\n    triggers:\n      - type: command\n        pattern: /find\n    abstract: does things\n    transfer_chain:\n      - agent://alice\n"
1668        );
1669        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1670        assert_eq!(p.installed_skills.len(), 1);
1671        assert_eq!(p.installed_skills[0].name, "s1");
1672        assert_eq!(p.installed_skills[0].abstract_text, "does things");
1673        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1674
1675        let out = serde_yaml_ng::to_string(&p).unwrap();
1676        assert!(out.contains("abstract: does things"));
1677        assert!(out.contains("pattern: /find"));
1678
1679        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1680        assert_eq!(p.installed_skills, back.installed_skills);
1681    }
1682
1683    #[test]
1684    fn installed_skills_minimal_entry_serializes_compactly() {
1685        // A name-only entry must NOT emit empty string fields.
1686        let entry = SkillCardEntry {
1687            name: "minimal".into(),
1688            ..Default::default()
1689        };
1690        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1691        assert!(yaml.contains("name: minimal"));
1692        assert!(
1693            !yaml.contains("version:"),
1694            "empty version must be skipped: {yaml}"
1695        );
1696        assert!(
1697            !yaml.contains("publisher:"),
1698            "empty publisher must be skipped: {yaml}"
1699        );
1700        assert!(
1701            !yaml.contains("abstract:"),
1702            "empty abstract must be skipped: {yaml}"
1703        );
1704    }
1705}
1706
1707#[cfg(test)]
1708mod tool_policy_tests {
1709    use super::*;
1710
1711    fn rules() -> Vec<ToolRule> {
1712        vec![
1713            ToolRule {
1714                pattern: "mcp__github__merge_pr".into(),
1715                policy: ToolPolicy::Ask,
1716            },
1717            ToolRule {
1718                pattern: "mcp__github__*".into(),
1719                policy: ToolPolicy::Allow,
1720            },
1721            ToolRule {
1722                pattern: "mcp__*".into(),
1723                policy: ToolPolicy::Deny,
1724            },
1725            ToolRule {
1726                pattern: "bash".into(),
1727                policy: ToolPolicy::Allow,
1728            },
1729        ]
1730    }
1731
1732    #[test]
1733    fn exact_beats_glob() {
1734        assert_eq!(
1735            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
1736            ToolPolicy::Ask
1737        );
1738    }
1739
1740    #[test]
1741    fn longer_glob_wins() {
1742        assert_eq!(
1743            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
1744            ToolPolicy::Allow
1745        );
1746    }
1747
1748    #[test]
1749    fn shorter_glob_fallback() {
1750        assert_eq!(
1751            resolve_tool_policy(&rules(), "mcp__slack__send"),
1752            ToolPolicy::Deny
1753        );
1754    }
1755
1756    #[test]
1757    fn exact_bash() {
1758        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
1759    }
1760
1761    #[test]
1762    fn unknown_tool_defaults_ask() {
1763        assert_eq!(
1764            resolve_tool_policy(&rules(), "unknown_tool"),
1765            ToolPolicy::Ask
1766        );
1767    }
1768
1769    #[test]
1770    fn empty_rules_defaults_ask() {
1771        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
1772    }
1773
1774    fn minimal_entitlements_yaml() -> &'static str {
1775        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
1776    }
1777
1778    #[test]
1779    fn entitlements_tools_defaults_empty() {
1780        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
1781        assert!(e.tools.is_empty());
1782    }
1783
1784    #[test]
1785    fn entitlements_tools_roundtrip() {
1786        let base = minimal_entitlements_yaml();
1787        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
1788        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
1789        assert_eq!(e.tools.len(), 1);
1790        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
1791        let y = serde_yaml_ng::to_string(&e).unwrap();
1792        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
1793        assert_eq!(back.tools.len(), 1);
1794        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
1795    }
1796}