Skip to main content

mur_common/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6/// Default model id seeded for the built-in "Mur" agent and used to name the
7/// bundled MLX weights. This is the DEFAULT VALUE only — it is written into the
8/// seed agent's profile and can be changed by the user afterwards; it is not a
9/// behavioural constant baked into logic.
10pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12pub const DEFAULT_MAX_RETRIES: u32 = 1;
13pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
14pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
15pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
16pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
17
18/// Smart background routing is opt-in. Its failure mode is silent and
19/// irreversible for the turn it degrades, which is the kind of automation that
20/// has to be asked for. See the capability-gate spec §2.
21pub const DEFAULT_SMART_ENABLED: bool = false;
22
23/// The Ollama provider default endpoint. Single definition — `BackendConfig`
24/// resolution (`default_ollama_endpoint`), `config_migrate`, and the
25/// conversations `doctor`/`preflight` probes all read this constant instead of
26/// repeating the literal.
27pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
28
29fn default_max_retries() -> u32 {
30    DEFAULT_MAX_RETRIES
31}
32fn default_backoff_base_ms() -> u64 {
33    DEFAULT_BACKOFF_BASE_MS
34}
35fn default_cooldown_secs() -> u64 {
36    DEFAULT_COOLDOWN_SECS
37}
38fn default_smart_max_escalations() -> u32 {
39    DEFAULT_SMART_MAX_ESCALATIONS
40}
41fn default_smart_enabled() -> bool {
42    DEFAULT_SMART_ENABLED
43}
44
45/// Smart background routing: auto-pick a cheap model for low-stakes/background
46/// requests instead of always dialing the agent's primary model_ref. Defaults
47/// OFF — enable it globally (`mur model smart on`) or per agent. `cheap: None`
48/// auto-picks the cheapest registry entry that can serve the request
49/// (`mur_common::model::pick_cheap_model`).
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct SmartConfig {
52    #[serde(default = "default_smart_enabled")]
53    pub enabled: bool,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub cheap: Option<String>,
56    #[serde(default = "default_smart_max_escalations")]
57    pub max_escalations: u32,
58}
59
60impl Default for SmartConfig {
61    fn default() -> Self {
62        Self {
63            enabled: DEFAULT_SMART_ENABLED,
64            cheap: None,
65            max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
66        }
67    }
68}
69
70/// Config-layered model selection + failure fallback. See
71/// docs/superpowers/specs/2026-07-12-intelligent-model-switch-design.md.
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct ModelSwitchConfig {
74    /// Global default model_ref when an agent has no `model_ref`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub default: Option<String>,
77    /// Global fallback chain (ordered model_refs).
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub fallback_chain: Vec<String>,
80    #[serde(default)]
81    pub retry: RetryConfig,
82    #[serde(default)]
83    pub routing: RoutingConfig,
84    #[serde(default)]
85    pub smart: SmartConfig,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct RetryConfig {
90    #[serde(default = "default_max_retries")]
91    pub max_retries: u32,
92    #[serde(default = "default_backoff_base_ms")]
93    pub backoff_base_ms: u64,
94    #[serde(default = "default_cooldown_secs")]
95    pub cooldown_secs: u64,
96}
97
98impl Default for RetryConfig {
99    fn default() -> Self {
100        Self {
101            max_retries: DEFAULT_MAX_RETRIES,
102            backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
103            cooldown_secs: DEFAULT_COOLDOWN_SECS,
104        }
105    }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
109pub struct RoutingConfig {
110    #[serde(default)]
111    pub enabled: bool,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub cheap: Option<String>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub frontier: Option<String>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub threshold_input_tokens: Option<u32>,
118}
119
120/// Partial view of [`SmartConfig`] for per-agent overrides: `None` on a field
121/// means "inherit the global value". A distinct type rather than reusing
122/// `SmartConfig`, because a full config standing in for a partial is exactly
123/// what made an omitted field silently mean `false` instead of "unset".
124#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
125pub struct SmartOverride {
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub enabled: Option<bool>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub cheap: Option<String>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub max_escalations: Option<u32>,
132}
133
134/// Partial view of [`RoutingConfig`]. Same inheritance rule as
135/// [`SmartOverride`].
136#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
137pub struct RoutingOverride {
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub enabled: Option<bool>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub cheap: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub frontier: Option<String>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub threshold_input_tokens: Option<u32>,
146    /// Legacy nesting. Smart used to be overridden at `routing.smart`;
147    /// profiles written before the promotion to `AgentProfile.smart` — and
148    /// every exported `.muragent` bundle — still carry it, so it stays
149    /// readable forever. MUR never writes it.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub smart: Option<SmartOverride>,
152}
153
154impl SmartConfig {
155    /// Global values with an agent's override layered on, field by field.
156    pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
157        let Some(o) = ov else { return self.clone() };
158        SmartConfig {
159            enabled: o.enabled.unwrap_or(self.enabled),
160            cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
161            max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
162        }
163    }
164}
165
166impl RoutingConfig {
167    /// Global values with an agent's override layered on, field by field.
168    pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
169        let Some(o) = ov else { return self.clone() };
170        RoutingConfig {
171            enabled: o.enabled.unwrap_or(self.enabled),
172            cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
173            frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
174            threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
175        }
176    }
177}
178
179/// Global MUR configuration (~/.mur/config.yaml)
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct Config {
182    #[serde(default)]
183    pub embedding: EmbeddingConfig,
184
185    #[serde(default)]
186    pub llm: LlmConfig,
187
188    #[serde(default)]
189    pub models: ModelSwitchConfig,
190
191    #[serde(default)]
192    pub retrieval: RetrievalConfig,
193
194    #[serde(default)]
195    pub paths: PathConfig,
196
197    #[serde(default)]
198    pub server: ServerConfig,
199
200    #[serde(default)]
201    pub community: CommunityConfig,
202
203    #[serde(default)]
204    pub conversations: ConversationsConfig,
205
206    #[serde(default)]
207    pub sync: SyncConfig,
208
209    // --- P1.1 additions ---
210    #[serde(default)]
211    pub storage: StorageConfig,
212
213    #[serde(default)]
214    pub sources_global: SourcesGlobalConfig,
215
216    // --- E3 additions ---
217    #[serde(default)]
218    pub sleep_cycle: SleepCycleConfig,
219
220    // --- M2 additions ---
221    #[serde(default)]
222    pub skills: SkillsConfig,
223
224    // --- M6c additions ---
225    #[serde(default)]
226    pub skill_llm: SkillLlmConfig,
227
228    // --- M7a additions ---
229    #[serde(default)]
230    pub cross_agent: CrossAgentConfig,
231
232    // --- nudge additions ---
233    #[serde(default)]
234    pub nudge: NudgeConfig,
235
236    // --- mobile P4 additions ---
237    #[serde(default)]
238    pub mobile_relay: MobileRelayConfig,
239
240    // --- Ambient capture & harvest (2026-06-11 spec) ---
241    #[serde(default)]
242    pub session: SessionCfg,
243
244    #[serde(default)]
245    pub harvest: HarvestCfg,
246
247    // --- OAuth bridge (cc-proxy) routing for subscription tokens ---
248    #[serde(default)]
249    pub cc_proxy: CcProxyConfig,
250
251    // --- Agent CLI TUI ---
252    #[serde(default)]
253    pub cli: CliConfig,
254
255    // --- parallel_jobs MCP tool ---
256    #[serde(default)]
257    pub parallel_jobs: ParallelJobsConfig,
258
259    /// Memory-federation snapshot settings (`federation_snapshot:`).
260    #[serde(default)]
261    pub federation_snapshot: SnapshotConfig,
262
263    /// Proactive memory capture (`memory:`, federation P2).
264    #[serde(default)]
265    pub memory: MemoryConfig,
266
267    // --- fleet_run runtime built-in tool ---
268    #[serde(default)]
269    pub fleet_run: FleetRunConfig,
270
271    // --- `mur open` display policy ---
272    #[serde(default)]
273    pub open_items: OpenItemsConfig,
274
275    // --- Hub Fleet Manager redesign ---
276    #[serde(default)]
277    pub fleet: FleetConfig,
278
279    /// `mur update` post-upgrade behavior (`update:`, issue #866).
280    #[serde(default)]
281    pub update: UpdateConfig,
282
283    /// Job/fleet/workflow run-status heartbeat tuning (`runs:`).
284    #[serde(default)]
285    pub runs: RunsConfig,
286
287    /// Capture-queue rotation (`capture:`).
288    #[serde(default)]
289    pub capture: CaptureConfig,
290
291    /// Global execution limits (`limits:`), the outermost scope of spec
292    /// 2026-09-12 §3.1. Written textually by `mur limits --global`, never by
293    /// load-modify-save (that drops blocks other binaries own).
294    #[serde(default)]
295    pub limits: crate::limits::Limits,
296
297    /// Durable-monitor notification channels (`notifications:`). Absent
298    /// means log-only: `desktop` is opt-in.
299    #[serde(default)]
300    pub notifications: NotificationsConfig,
301
302    /// AgentResolver (`monitor_resolver:`), spec §混合處置策略 step 3.
303    /// Absent means off — see [`MonitorResolverConfig`].
304    #[serde(default)]
305    pub monitor_resolver: MonitorResolverConfig,
306
307    /// Pre-dispatch triage (`triage:`). Absent means shadow mode: triage
308    /// runs and records, and never stops a dispatch.
309    #[serde(default)]
310    pub triage: TriageConfig,
311
312    /// MUR's credit line on work an agent publishes (`attribution:`).
313    #[serde(default)]
314    pub attribution: AttributionConfig,
315}
316
317/// MUR's signature on outgoing work, stored under `attribution:` in
318/// `~/.mur/config.yaml`.
319///
320/// Both surfaces are agent *behavior*, not runtime behavior: no Rust code in
321/// this repo authors a user's commit or opens their PR — an agent does it by
322/// shelling out to `git` and `gh`. So the only lever is the system prompt, and
323/// this config is its single source of truth. Putting the text in a skill body
324/// instead would fork it: skill markdown is loaded verbatim, with no template
325/// expansion, so a signature written there could never follow this file.
326///
327/// The two knobs default asymmetrically on purpose. A PR body is a message
328/// MUR is writing, and a credit line in it is ours to place. A commit trailer
329/// is written permanently into a repository's history — often someone else's —
330/// where it outlives the PR, shows up in `git log` and `git blame` forever, and
331/// cannot be edited away without a rewrite. Opt-in is the honest default for
332/// the second one.
333///
334/// There is no `enabled` flag, because with per-surface strings it would be a
335/// second switch that can disagree with the first ("enabled: true" plus an
336/// empty `pr` — which wins?). An empty or whitespace-only string IS the off
337/// switch, and it is off for exactly one surface.
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339#[serde(default)]
340pub struct AttributionConfig {
341    /// Appended to PR bodies the agent creates. Empty = no PR signature.
342    pub pr: Option<String>,
343    /// Appended as a trailer to commits the agent makes. `None`/empty = off,
344    /// which is the default; see the type docs for why this one is opt-in.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub commit: Option<String>,
347}
348
349/// The shipped PR credit line.
350pub const DEFAULT_PR_ATTRIBUTION: &str = "Generated with [MUR](https://app.mur.run/products/mur)";
351
352impl Default for AttributionConfig {
353    fn default() -> Self {
354        Self {
355            pr: Some(DEFAULT_PR_ATTRIBUTION.to_string()),
356            commit: None,
357        }
358    }
359}
360
361/// `Some(trimmed)` only when the value carries actual text — the shared
362/// "empty means off" rule for both surfaces.
363fn signature(v: &Option<String>) -> Option<&str> {
364    v.as_deref().map(str::trim).filter(|s| !s.is_empty())
365}
366
367impl AttributionConfig {
368    /// The PR credit line, or `None` when the user switched it off.
369    pub fn pr_signature(&self) -> Option<&str> {
370        signature(&self.pr)
371    }
372
373    /// The commit trailer, or `None` (the default) when off.
374    pub fn commit_signature(&self) -> Option<&str> {
375        signature(&self.commit)
376    }
377
378    /// The system-prompt rule, or `None` when both surfaces are off so a user
379    /// who wants no signature pays no tokens for one.
380    ///
381    /// The signature text is quoted verbatim rather than described, so the
382    /// model copies it instead of paraphrasing a credit line into something
383    /// that is not the configured one.
384    pub fn prompt_fragment(&self) -> Option<String> {
385        let (pr, commit) = (self.pr_signature(), self.commit_signature());
386        if pr.is_none() && commit.is_none() {
387            return None;
388        }
389        let mut s = String::from(
390            "\n\n## MUR attribution\n\
391             When you publish work on the user's behalf, sign it with the exact text below — \
392             copy it verbatim, do not reword it, and add it once.\n",
393        );
394        if let Some(pr) = pr {
395            s.push_str(&format!(
396                "- Opening a pull request (`gh pr create`, or any PR/MR body you author): \
397                 end the body with its own line reading:\n  {pr}\n"
398            ));
399        }
400        if let Some(commit) = commit {
401            s.push_str(&format!(
402                "- Making a commit (`git commit`): add this as a trailer in the last paragraph \
403                 of the message, after a blank line:\n  {commit}\n"
404            ));
405        }
406        s.push_str(
407            "Never add a signature the user did not configure, and never sign a surface that is \
408             not listed here.",
409        );
410        Some(s)
411    }
412}
413
414/// Pre-dispatch triage settings (`triage:` in `~/.mur/config.yaml`).
415///
416/// Both knobs default to the passive posture, and `enforce` in particular is
417/// off for a reason that is not caution: `triage_calibration` cannot score a
418/// decision that prevented a run. Enforcing from the start produces only
419/// unfalsifiable `held_back` records, so there would never be evidence that
420/// enforcing was correct. Shadow mode records the same predictions AND lets
421/// the run happen, which is what makes `Calibration::shadow_precision`
422/// computable — turn `enforce` on once that number says triage is right often
423/// enough to be worth the refusals.
424#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
425pub struct TriageConfig {
426    /// Run triage before dispatch at all. Off = not even the free prefilter
427    /// runs, and no verdicts are recorded.
428    #[serde(default)]
429    pub enabled: bool,
430    /// Let a triage decision actually stop a dispatch. Requires `enabled`.
431    /// See the type docs before turning this on.
432    #[serde(default)]
433    pub enforce: bool,
434}
435
436impl TriageConfig {
437    /// Does triage actually bind here? `enforce` alone is inert — with
438    /// `enabled: false` nothing runs to enforce — so the two are resolved in
439    /// one place rather than at each call site, where the pair would
440    /// eventually be got wrong in one of them.
441    pub fn enforces(&self) -> bool {
442        self.enabled && self.enforce
443    }
444}
445
446/// Rotation for `~/.mur/queue/events.jsonl`, in the shape FreeBSD's
447/// `newsyslog(8)` uses: rotate past a size, keep a bounded number of
448/// generations, compress all but the newest, drop the oldest.
449///
450/// The point of generations is that nobody has to decide to delete anything.
451/// A 934 MB queue becomes `.0`, then `.1.gz`, and ages out on a policy the
452/// user set rather than on a judgement call someone makes once.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
454pub struct CaptureConfig {
455    /// Rotate once the live file passes this. 64 MB keeps `mur hook stats`
456    /// responsive — parsing is O(file), and 934 MB took minutes.
457    #[serde(default = "default_rotate_at_mb")]
458    pub rotate_at_mb: u64,
459    /// How many rotated generations to keep. `.0` stays uncompressed like
460    /// newsyslog's, the rest are gzipped.
461    #[serde(default = "default_keep_generations")]
462    pub keep_generations: u32,
463}
464
465fn default_rotate_at_mb() -> u64 {
466    64
467}
468
469fn default_keep_generations() -> u32 {
470    5
471}
472
473impl Default for CaptureConfig {
474    fn default() -> Self {
475        Self {
476            rotate_at_mb: default_rotate_at_mb(),
477            keep_generations: default_keep_generations(),
478        }
479    }
480}
481
482/// Post-upgrade settings for `mur update`. Stored under `update:` in
483/// `~/.mur/config.yaml`.
484#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
485pub struct UpdateConfig {
486    /// macOS code-signing identity used to re-sign installed binaries after an
487    /// upgrade. Keychain grants bind to the signing identity; fresh installs
488    /// are ad-hoc (new CDHash per build), so without a stable identity every
489    /// upgrade kills the grants and service-launched agents fail silently
490    /// (#849/#866). Fallback: the `MUR_CODESIGN_IDENTITY` env var.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub codesign_identity: Option<String>,
493    /// Agent names `mur update --restart-agents` must never touch.
494    #[serde(default, skip_serializing_if = "Vec::is_empty")]
495    pub restart_exclude: Vec<String>,
496}
497
498/// Which notification channels the durable monitor may use. `log` is not
499/// listed: it is always on and cannot be disabled, because a notable event
500/// must leave a trace somewhere even when a user has turned everything off.
501#[derive(Debug, Clone, Default, Serialize, Deserialize)]
502pub struct NotificationsConfig {
503    /// OS desktop notification. Opt-in: a background daemon that starts
504    /// popping banners on upgrade is a hostile default.
505    #[serde(default)]
506    pub desktop: bool,
507}
508
509/// Whether the durable monitor may ask a model what to do about a terminal
510/// failure the structured rules did not settle.
511///
512/// Off by default, and not merely as a courtesy: enabling it lets a
513/// background daemon send monitor context to a model on its own schedule,
514/// with no one watching. Nobody watching is not permission, so this is a
515/// decision a user makes once, explicitly, rather than something an upgrade
516/// makes for them.
517///
518/// The bound on how often it may ask is NOT here: one proposal per
519/// observation cycle is an invariant of the design, not a knob (see
520/// `mur_core::monitor::resolver`). A tunable would let a user turn a bounded
521/// feature into an unbounded one.
522#[derive(Debug, Clone, Default, Serialize, Deserialize)]
523pub struct MonitorResolverConfig {
524    /// Opt-in. While false, nothing in the resolver path runs and no request
525    /// leaves the machine.
526    #[serde(default)]
527    pub enabled: bool,
528
529    /// Which model to ask. `None` uses the same backend `mur chat` resolves,
530    /// so a user who has already configured one does not configure it twice.
531    #[serde(default)]
532    pub model: Option<String>,
533}
534
535/// Run-status heartbeat tuning. Both values are config, never literals at a
536/// call site: the right interval depends on how long the machine's steps take.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct RunsConfig {
539    /// How often `execute_dag` stamps `last_heartbeat_at`.
540    #[serde(default = "default_heartbeat_interval_secs")]
541    pub heartbeat_interval_secs: u64,
542    /// How many missed intervals before a live process counts as `stalled`.
543    /// Three tolerates one lost tick plus scheduling jitter without calling a
544    /// healthy run dead.
545    #[serde(default = "default_heartbeat_stale_after_intervals")]
546    pub heartbeat_stale_after_intervals: u32,
547}
548
549fn default_heartbeat_interval_secs() -> u64 {
550    10
551}
552
553fn default_heartbeat_stale_after_intervals() -> u32 {
554    3
555}
556
557impl Default for RunsConfig {
558    fn default() -> Self {
559        Self {
560            heartbeat_interval_secs: default_heartbeat_interval_secs(),
561            heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
562        }
563    }
564}
565
566/// Authorization gate for the `parallel_jobs` MCP tool. Stored under `parallel_jobs:`
567/// in `~/.mur/config.yaml`. Deny-by-default: an empty `targets` list means the
568/// tool cannot delegate to ANY agent (inert until the user opts specific
569/// agents in). This is a deterministic, out-of-model gate that a
570/// prompt-injected concierge cannot widen (OWASP Agentic ASI02/03/04).
571#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
572pub struct ParallelJobsConfig {
573    /// Canonical agent names the `parallel_jobs` tool is allowed to delegate to.
574    /// Empty = deny all.
575    #[serde(default)]
576    pub targets: Vec<String>,
577}
578
579// --- memory-federation snapshot (spec 2026-08-04-unified-memory-federation) ---
580
581/// Daemon-side settings for the signed snapshot pull. Stored under
582/// `federation_snapshot:` in `~/.mur/config.yaml`; every field has a default
583/// so an absent block means "defaults", never "off".
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
585#[serde(default)]
586pub struct SnapshotConfig {
587    /// How often the daemon sweeps `inbox/snapshot-requests/`, in seconds.
588    pub poll_secs: u64,
589    /// Reject requests older than this (replay blunting), in seconds.
590    pub request_max_age_secs: u64,
591    /// Minimum lifecycle state a global skill needs to enter a snapshot.
592    pub min_lifecycle: crate::skill::stats::LifecycleState,
593}
594
595impl Default for SnapshotConfig {
596    fn default() -> Self {
597        Self {
598            poll_secs: 30,
599            request_max_age_secs: 600,
600            min_lifecycle: crate::skill::stats::LifecycleState::Stable,
601        }
602    }
603}
604
605/// Proactive memory capture (memory federation P2): gates the runtime's
606/// built-in `remember` tool and its system-prompt directive. Stored under
607/// `memory:` in `~/.mur/config.yaml`.
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
609#[serde(default)]
610pub struct MemoryConfig {
611    pub capture: CaptureMode,
612    /// How many memories may enter the system prompt per turn. Deliberately
613    /// NOT `skills.max_skills_in_prompt`: sharing that budget means saving a
614    /// memory silently evicts a skill, and five is a plausible number of
615    /// standing preferences for one user to have.
616    #[serde(default = "default_memory_in_prompt")]
617    pub max_in_prompt: usize,
618    /// Character ceiling for the whole memory block, spent before skills.
619    #[serde(default = "default_memory_chars")]
620    pub max_chars: usize,
621}
622
623fn default_memory_in_prompt() -> usize {
624    20
625}
626
627fn default_memory_chars() -> usize {
628    1500
629}
630
631impl Default for MemoryConfig {
632    fn default() -> Self {
633        Self {
634            capture: CaptureMode::AutoAnnounce,
635            max_in_prompt: default_memory_in_prompt(),
636            max_chars: default_memory_chars(),
637        }
638    }
639}
640
641/// How agents capture memories mid-conversation.
642#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
643#[serde(rename_all = "snake_case")]
644pub enum CaptureMode {
645    /// Agent asks for a one-line confirmation before saving.
646    Ask,
647    /// Agent saves immediately and announces in the same reply (default).
648    AutoAnnounce,
649    /// The `remember` tool is not registered at all.
650    Off,
651}
652
653/// Authorization gate for the runtime's built-in `fleet_run` tool. Stored under
654/// `fleet_run:` in `~/.mur/config.yaml`. Deny-by-default on BOTH axes: an agent
655/// not named in `agents` never even sees the tool, and a fleet not named in
656/// `fleets` cannot be run. Lives in the global config (not the agent profile)
657/// because the profile is writable by the concierge itself — this gate must be
658/// out of reach of a prompt-injected agent (same rationale as
659/// [`ParallelJobsConfig`]).
660#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
661pub struct FleetRunConfig {
662    /// Canonical agent names allowed to call `fleet_run`. Empty = deny all.
663    #[serde(default)]
664    pub agents: Vec<String>,
665    /// Fleet names those agents may run. Empty = deny all.
666    #[serde(default)]
667    pub fleets: Vec<String>,
668}
669
670/// Display policy for `mur open`.
671///
672/// Lives in `config.yaml` rather than in `open-items.jsonl` because that log
673/// is append-only and agent-writable via the `open_item` tool. A user's
674/// decision to stop looking at a source must not be overturnable by an agent
675/// appending a record. Same reasoning as `fleet_run.agents`.
676#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
677pub struct OpenItemsConfig {
678    /// Exact `origin` strings to collapse out of `mur open`. Exact match
679    /// only — `fleet` never matches `fleet:acme`.
680    #[serde(default)]
681    pub muted: Vec<String>,
682}
683
684/// Daemon-wide gate for unattended fleet auto-run (`mur-daemon`'s `fleet_tick`).
685/// Stored under `fleet:` in `~/.mur/config.yaml`. Either this flag OR the
686/// `MUR_FLEET_AUTORUN` env var satisfies the gate — both are equally explicit,
687/// off-by-default opt-ins; the env var remains for ops/CI use, this flag is
688/// what the Hub's Settings toggle controls. Per-fleet `budget_usd > 0` and the
689/// `.stopped` kill-switch are unaffected — see `mur-daemon/src/fleet_tick.rs`.
690#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
691pub struct FleetConfig {
692    /// Allow fleets with a trigger + budget configured to auto-run unattended.
693    #[serde(default)]
694    pub autorun: bool,
695}
696
697#[cfg(test)]
698mod fleet_config_tests {
699    use super::*;
700
701    #[test]
702    fn fleet_config_defaults_off_and_roundtrips() {
703        assert!(!FleetConfig::default().autorun);
704
705        let cfg: Config = serde_yaml_ng::from_str("fleet:\n  autorun: true\n").unwrap();
706        assert!(cfg.fleet.autorun);
707
708        // `fleet:` key entirely absent → defaults to off
709        let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
710        assert!(!cfg2.fleet.autorun);
711    }
712
713    #[test]
714    fn fleet_run_config_defaults_deny_all_and_roundtrips() {
715        // Absent section → both allowlists empty → deny all.
716        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
717        assert!(cfg.fleet_run.agents.is_empty());
718        assert!(cfg.fleet_run.fleets.is_empty());
719
720        let cfg2: Config =
721            serde_yaml_ng::from_str("fleet_run:\n  agents: [mur]\n  fleets: [deep-research]\n")
722                .unwrap();
723        assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
724        assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
725    }
726}
727
728/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
729/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
730/// claude-code betas disguise the upstream requires.
731///
732/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
733/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
734/// the bridge is actually listening; otherwise it leaves the runtime on the
735/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
736/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
737/// overridden.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct CcProxyConfig {
740    /// Bridge base URL. Defaults to cc-proxy's default bind.
741    #[serde(default = "default_cc_proxy_url")]
742    pub url: String,
743
744    /// Master switch. When false the Hub never routes runtimes through the
745    /// bridge, regardless of reachability.
746    #[serde(default = "default_true")]
747    pub enabled: bool,
748}
749
750fn default_cc_proxy_url() -> String {
751    "http://127.0.0.1:8088".to_string()
752}
753
754fn default_true() -> bool {
755    true
756}
757
758impl Default for CcProxyConfig {
759    fn default() -> Self {
760        Self {
761            url: default_cc_proxy_url(),
762            enabled: true,
763        }
764    }
765}
766
767/// Configuration for the agent CLI TUI.
768/// Stored in ~/.mur/config.yaml under the `cli:` key.
769#[derive(Debug, Clone, Serialize, Deserialize, Default)]
770pub struct CliConfig {
771    /// Default visual skin for `mur agent cli`. Overridable with --skin.
772    /// Valid values: "ansi" (default; "dark" is an alias), "light", "mur".
773    pub skin: Option<String>,
774
775    /// True once the one-time "permanent instructions" notice has been shown
776    /// (memories P1 §10).
777    ///
778    /// The notice is informational and must appear at most once — repeating a
779    /// feature announcement every session reads as a warning about something
780    /// wrong. Absent (false) in existing configs, which is correct: a user who
781    /// has never seen it should.
782    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
783    pub seen_permanent_instructions_notice: bool,
784}
785
786/// Configuration for the mobile relay (P4).
787/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
788#[derive(Debug, Clone, Serialize, Deserialize, Default)]
789pub struct MobileRelayConfig {
790    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
791    /// Leave blank to disable relay forwarding on the Mac daemon side.
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub relay_url: Option<String>,
794
795    /// API key or JWT used by the Mac daemon to authenticate with the relay.
796    /// The value is typically a `mur_...` API key from app.mur.run.
797    #[serde(default, skip_serializing_if = "Option::is_none")]
798    pub api_key: Option<String>,
799}
800
801impl Config {
802    /// Read from disk, falling back to defaults. Legacy conversation model
803    /// fields are migrated **in memory only** — this is called from agent
804    /// runtime processes (`mur-agent-runtime`), which must never write the
805    /// user's config file.
806    pub fn load_or_default(path: &std::path::Path) -> Self {
807        let Ok(text) = std::fs::read_to_string(path) else {
808            return Self::default();
809        };
810        let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
811        let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
812        cfg.sanitize();
813        cfg
814    }
815
816    /// Clamp values that are legal YAML but illegal at runtime. Called once
817    /// from [`Config::load_or_default`] — never from call sites, so every
818    /// reader (CLI, executor heartbeat ticker, `status_of`'s stale
819    /// threshold) sees the sanitized value.
820    fn sanitize(&mut self) {
821        // A zero interval would make the stale threshold zero (a healthy
822        // run instantly reports STALLED) and `tokio::time::interval` panics
823        // on a zero period — both from one user-edited line.
824        if self.runs.heartbeat_interval_secs == 0 {
825            self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
826        }
827        // A zero rotate size would rotate on every single append.
828        if self.capture.rotate_at_mb == 0 {
829            self.capture.rotate_at_mb = default_rotate_at_mb();
830        }
831        if self.runs.heartbeat_stale_after_intervals == 0 {
832            self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
833        }
834    }
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize, Default)]
838pub struct SyncConfig {
839    /// Sync method: "cloud", "git", or "local"
840    #[serde(default = "default_sync_method")]
841    pub method: String,
842
843    /// Git remote URL for git sync
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub git_remote: Option<String>,
846
847    /// Auto-sync on context pull / session stop
848    #[serde(default)]
849    pub auto: bool,
850
851    /// Default team ID for cloud sync (set on first successful sync)
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub team_id: Option<String>,
854}
855
856fn default_sync_method() -> String {
857    "local".to_string()
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct ServerConfig {
862    /// Server URL (default: https://mur-server.fly.dev)
863    #[serde(default = "default_server_url")]
864    pub url: String,
865}
866
867impl Default for ServerConfig {
868    fn default() -> Self {
869        Self {
870            url: default_server_url(),
871        }
872    }
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize, Default)]
876pub struct CommunityConfig {
877    /// Whether community pattern sharing is enabled
878    #[serde(default)]
879    pub enabled: bool,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
883pub struct EmbeddingConfig {
884    /// "ollama", "openai", "gemini", or "anthropic"
885    #[serde(default = "default_embedding_provider")]
886    pub provider: String,
887
888    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
889    #[serde(default = "default_embedding_model")]
890    pub model: String,
891
892    /// Vector dimensions (fixed after first index build)
893    #[serde(default = "default_dimensions")]
894    pub dimensions: usize,
895
896    /// Ollama endpoint. `None` for every non-Ollama provider — the OpenAI
897    /// path uses `openai_url`. Kept out of the serialized document when
898    /// unset so it stops reappearing in configs that never use it.
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub ollama_endpoint: Option<String>,
901
902    /// API key env var name (e.g. "OPENAI_API_KEY")
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub api_key_env: Option<String>,
905
906    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
907    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub api_key_ref: Option<String>,
910
911    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
912    #[serde(default, skip_serializing_if = "Option::is_none")]
913    pub openai_url: Option<String>,
914}
915
916impl Default for EmbeddingConfig {
917    fn default() -> Self {
918        Self {
919            provider: default_embedding_provider(),
920            model: default_embedding_model(),
921            dimensions: default_dimensions(),
922            ollama_endpoint: Some(default_ollama_endpoint()),
923            api_key_env: None,
924            api_key_ref: None,
925            openai_url: None,
926        }
927    }
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize)]
931pub struct LlmConfig {
932    /// "anthropic", "openai", "gemini", or "ollama"
933    #[serde(default = "default_llm_provider")]
934    pub provider: String,
935
936    #[serde(default = "default_llm_model")]
937    pub model: String,
938
939    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub api_key_env: Option<String>,
942
943    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
944    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub api_key_ref: Option<String>,
947
948    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub openai_url: Option<String>,
951}
952
953impl Default for LlmConfig {
954    fn default() -> Self {
955        Self {
956            provider: default_llm_provider(),
957            model: default_llm_model(),
958            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
959            api_key_ref: None,
960            openai_url: None,
961        }
962    }
963}
964
965impl LlmConfig {
966    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
967    /// into a BackendConfig that the new ChatBackend factory consumes.
968    /// Mapping:
969    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
970    ///   (preserves the historical LlmConfig::llm_complete fall-through for
971    ///   OpenAI-compatible passthrough proxies).
972    /// - `model` 1:1.
973    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
974    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
975    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
976    /// - `timeout_secs` always None (factory defaults to 120s — matches
977    ///   the historical 60s reqwest default behavior closely enough).
978    pub fn to_backend_config(&self) -> BackendConfig {
979        let provider = match self.provider.as_str() {
980            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
981            _ if self.openai_url.is_some() => "openai".into(),
982            other => other.into(), // factory will reject with "unsupported provider"
983        };
984        BackendConfig {
985            provider,
986            model: self.model.clone(),
987            endpoint: self.openai_url.clone(),
988            api_key_env: self.api_key_env.clone(),
989            api_key_ref: self.api_key_ref.clone(),
990            timeout_secs: None,
991        }
992    }
993}
994
995/// Backend selection for a single chat-completion call site.
996///
997/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
998/// (per-stage) and `AskConfig` (per-stage) to override the legacy
999/// Ollama-only path. None of the `Option` fields are required;
1000/// resolution falls back to provider defaults
1001/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
1002///
1003/// Stays in mur-common (not mur-core) because it is pure data and
1004/// will be reused by mur-agent-runtime in a future phase.
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1006#[serde(default)]
1007pub struct BackendConfig {
1008    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
1009    pub provider: String,
1010    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
1011    pub model: String,
1012    /// Provider endpoint. None = provider default
1013    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
1014    pub endpoint: Option<String>,
1015    /// Env var holding the API key. None = no auth (ollama).
1016    pub api_key_env: Option<String>,
1017    /// SecretRef string for the API key. Takes precedence over `api_key_env`.
1018    pub api_key_ref: Option<String>,
1019    /// Per-call timeout in seconds. None = 120s.
1020    pub timeout_secs: Option<u64>,
1021}
1022
1023impl Default for BackendConfig {
1024    fn default() -> Self {
1025        Self {
1026            provider: "ollama".into(),
1027            model: DEFAULT_LOCAL_LLM_MODEL.into(),
1028            endpoint: None,
1029            api_key_env: None,
1030            api_key_ref: None,
1031            timeout_secs: None,
1032        }
1033    }
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1037pub struct RetrievalConfig {
1038    /// Max patterns to inject per query
1039    #[serde(default = "default_max_patterns")]
1040    pub max_patterns: usize,
1041
1042    /// Max tokens for injected content
1043    #[serde(default = "default_max_tokens")]
1044    pub max_tokens: usize,
1045
1046    /// Minimum score threshold
1047    #[serde(default = "default_min_score")]
1048    pub min_score: f64,
1049
1050    /// MMR diversity threshold (cosine > this = too similar)
1051    #[serde(default = "default_mmr_threshold")]
1052    pub mmr_threshold: f64,
1053
1054    /// Injection slots reserved for notes when mature skills would otherwise
1055    /// fill every seat (memory federation P1). 0 disables the reservation.
1056    #[serde(default = "default_reserved_note_slots")]
1057    pub reserved_note_slots: usize,
1058}
1059
1060impl Default for RetrievalConfig {
1061    fn default() -> Self {
1062        Self {
1063            max_patterns: default_max_patterns(),
1064            max_tokens: default_max_tokens(),
1065            min_score: default_min_score(),
1066            mmr_threshold: default_mmr_threshold(),
1067            reserved_note_slots: default_reserved_note_slots(),
1068        }
1069    }
1070}
1071
1072fn default_reserved_note_slots() -> usize {
1073    1
1074}
1075
1076#[derive(Debug, Clone, Serialize, Deserialize)]
1077pub struct PathConfig {
1078    /// Root MUR directory (default: ~/.mur)
1079    #[serde(default = "default_mur_dir")]
1080    pub mur_dir: PathBuf,
1081}
1082
1083impl Default for PathConfig {
1084    fn default() -> Self {
1085        Self {
1086            mur_dir: default_mur_dir(),
1087        }
1088    }
1089}
1090
1091#[derive(Debug, Clone, Serialize, Deserialize)]
1092pub struct StorageConfig {
1093    /// Vector backend identifier: "lancedb" (default) or "qdrant".
1094    #[serde(default = "default_vector_backend")]
1095    pub vector_backend: String,
1096
1097    /// Qdrant connection URL (only used when vector_backend = "qdrant").
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub qdrant_url: Option<String>,
1100
1101    /// Keyring account name holding the Qdrant API key, if any.
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub qdrant_api_key_ref: Option<String>,
1104}
1105
1106impl Default for StorageConfig {
1107    fn default() -> Self {
1108        Self {
1109            vector_backend: default_vector_backend(),
1110            qdrant_url: None,
1111            qdrant_api_key_ref: None,
1112        }
1113    }
1114}
1115
1116fn default_vector_backend() -> String {
1117    "lancedb".to_string()
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121pub struct SourcesGlobalConfig {
1122    /// Polling interval for cloud sources (seconds).
1123    #[serde(default = "default_poll_interval_secs")]
1124    pub poll_interval_secs: u64,
1125
1126    /// Safety cap: do not sync more than this many chunks per run.
1127    #[serde(default = "default_max_chunks_per_sync")]
1128    pub max_chunks_per_sync: usize,
1129
1130    /// Upper bound on parallel source sync tasks.
1131    #[serde(default = "default_max_parallel_sources")]
1132    pub max_parallel_sources: usize,
1133
1134    /// Weight applied to new sources unless overridden.
1135    #[serde(default = "default_source_weight")]
1136    pub default_weight: f32,
1137
1138    /// Embedding request batch size.
1139    #[serde(default = "default_embedding_batch_size")]
1140    pub embedding_batch_size: usize,
1141}
1142
1143impl Default for SourcesGlobalConfig {
1144    fn default() -> Self {
1145        Self {
1146            poll_interval_secs: default_poll_interval_secs(),
1147            max_chunks_per_sync: default_max_chunks_per_sync(),
1148            max_parallel_sources: default_max_parallel_sources(),
1149            default_weight: default_source_weight(),
1150            embedding_batch_size: default_embedding_batch_size(),
1151        }
1152    }
1153}
1154
1155fn default_poll_interval_secs() -> u64 {
1156    600
1157}
1158fn default_max_chunks_per_sync() -> usize {
1159    10_000
1160}
1161fn default_max_parallel_sources() -> usize {
1162    3
1163}
1164fn default_source_weight() -> f32 {
1165    1.0
1166}
1167fn default_embedding_batch_size() -> usize {
1168    32
1169}
1170
1171fn default_embedding_provider() -> String {
1172    "ollama".to_string()
1173}
1174fn default_embedding_model() -> String {
1175    "qwen3-embedding:0.6b".to_string()
1176}
1177fn default_dimensions() -> usize {
1178    1024
1179}
1180fn default_ollama_endpoint() -> String {
1181    DEFAULT_OLLAMA_ENDPOINT.to_string()
1182}
1183fn default_llm_provider() -> String {
1184    "anthropic".to_string()
1185}
1186fn default_llm_model() -> String {
1187    "claude-opus-5".to_string()
1188}
1189fn default_max_patterns() -> usize {
1190    5
1191}
1192fn default_max_tokens() -> usize {
1193    2000
1194}
1195fn default_min_score() -> f64 {
1196    0.35
1197}
1198fn default_mmr_threshold() -> f64 {
1199    0.85
1200}
1201fn default_mur_dir() -> PathBuf {
1202    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
1203    // Callers in mur-core that need the real home dir should use `dirs` there.
1204    let home = std::env::var("HOME")
1205        .map(PathBuf::from)
1206        .unwrap_or_else(|_| PathBuf::from("/tmp"));
1207    home.join(".mur")
1208}
1209fn default_server_url() -> String {
1210    "https://mur-server.fly.dev".to_string()
1211}
1212
1213// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
1214
1215#[derive(Debug, Clone, Serialize, Deserialize)]
1216pub struct AskConfig {
1217    #[serde(default = "ask_default_k_summary")]
1218    pub k_summary: u32,
1219    #[serde(default = "ask_default_k_raw")]
1220    pub k_raw: u32,
1221    #[serde(default = "ask_default_esc")]
1222    pub escalation_threshold: f64,
1223    #[serde(default = "ask_default_mmr")]
1224    pub mmr_threshold: f64,
1225    #[serde(default = "ask_default_max_ctx")]
1226    pub max_context_tokens: u32,
1227    #[serde(default = "ask_default_resp_tok")]
1228    pub response_tokens: u32,
1229    #[serde(default = "ask_default_timeout")]
1230    pub timeout_secs: u32,
1231    #[serde(default = "ask_default_min_score")]
1232    pub min_score: f64,
1233    #[serde(default = "ask_default_continue_history_turns")]
1234    pub continue_history_turns: u32,
1235    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
1236    /// Rewriter output is small (~80 tokens) and falling back to the raw
1237    /// question on failure is non-fatal, so we don't want to burn the full
1238    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
1239    /// the user sees any response.
1240    #[serde(default = "ask_default_rewriter_timeout")]
1241    pub rewriter_timeout_secs: u32,
1242    #[serde(default = "ask_default_compress_hits_enabled")]
1243    pub compress_hits_enabled: bool,
1244    #[serde(default = "ask_default_summarize_hits_enabled")]
1245    pub summarize_hits_enabled: bool,
1246    #[serde(default)]
1247    pub summarize_model: Option<String>,
1248    /// Per-stage backend override for the answer-generation model.
1249    /// None = inherit the smart slot (`config.llm`).
1250    #[serde(default)]
1251    pub backend: Option<BackendConfig>,
1252    /// Per-stage backend override for the query rewriter.
1253    /// None = inherit the answer stage's backend (`self.backend`), falling
1254    /// through to the smart slot (`config.llm`) only if that is also unset.
1255    #[serde(default)]
1256    pub rewriter_backend: Option<BackendConfig>,
1257}
1258
1259impl AskConfig {
1260    /// Effective backend for answer generation. An explicit per-stage
1261    /// override wins; otherwise the stage inherits the smart slot
1262    /// (`config.llm`) with this stage's own timeout baked in, so a slow
1263    /// backend cannot silently fall back to the factory's 120s default.
1264    pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
1265        self.backend.clone().unwrap_or_else(|| BackendConfig {
1266            timeout_secs: Some(self.timeout_secs as u64),
1267            ..llm.to_backend_config()
1268        })
1269    }
1270
1271    /// Effective backend for the query rewriter. An explicit
1272    /// `rewriter_backend` override wins outright. Otherwise the rewriter
1273    /// follows the answer stage's backend (`self.backend`), and only falls
1274    /// through to the smart slot (`llm`) when the answer stage has no
1275    /// override either — the rewriter is not an independent pinning point,
1276    /// only an independent timeout. Whichever source it resolves from, it
1277    /// always keeps its own much tighter `rewriter_timeout_secs` budget:
1278    /// the rewriter's output is small and falling back to the raw question
1279    /// on timeout is non-fatal.
1280    pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
1281        self.rewriter_backend
1282            .clone()
1283            .unwrap_or_else(|| BackendConfig {
1284                timeout_secs: Some(self.rewriter_timeout_secs as u64),
1285                ..self
1286                    .backend
1287                    .clone()
1288                    .unwrap_or_else(|| llm.to_backend_config())
1289            })
1290    }
1291}
1292
1293impl Default for AskConfig {
1294    fn default() -> Self {
1295        Self {
1296            k_summary: ask_default_k_summary(),
1297            k_raw: ask_default_k_raw(),
1298            escalation_threshold: ask_default_esc(),
1299            mmr_threshold: ask_default_mmr(),
1300            max_context_tokens: ask_default_max_ctx(),
1301            response_tokens: ask_default_resp_tok(),
1302            timeout_secs: ask_default_timeout(),
1303            min_score: ask_default_min_score(),
1304            continue_history_turns: ask_default_continue_history_turns(),
1305            rewriter_timeout_secs: ask_default_rewriter_timeout(),
1306            compress_hits_enabled: ask_default_compress_hits_enabled(),
1307            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
1308            summarize_model: None,
1309            backend: None,
1310            rewriter_backend: None,
1311        }
1312    }
1313}
1314
1315fn ask_default_k_summary() -> u32 {
1316    5
1317}
1318fn ask_default_k_raw() -> u32 {
1319    10
1320}
1321fn ask_default_esc() -> f64 {
1322    0.5
1323}
1324fn ask_default_mmr() -> f64 {
1325    0.88
1326}
1327fn ask_default_max_ctx() -> u32 {
1328    6000
1329}
1330fn ask_default_resp_tok() -> u32 {
1331    1024
1332}
1333fn ask_default_timeout() -> u32 {
1334    120
1335}
1336fn ask_default_min_score() -> f64 {
1337    0.35
1338}
1339fn ask_default_rewriter_timeout() -> u32 {
1340    8
1341}
1342fn ask_default_continue_history_turns() -> u32 {
1343    3
1344}
1345fn ask_default_compress_hits_enabled() -> bool {
1346    true
1347}
1348fn ask_default_summarize_hits_enabled() -> bool {
1349    true
1350}
1351
1352// ── Conversations archive config (Task 23) ────────────────────────────────────
1353
1354/// Phase 1 conversations archive config (Task 23).
1355///
1356/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
1357/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
1358/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
1359/// without a `conversations:` section still parses.
1360#[derive(Debug, Clone, Serialize, Deserialize)]
1361pub struct ConversationsConfig {
1362    #[serde(default)]
1363    pub enabled: bool,
1364    #[serde(default = "conv_default_retention_days")]
1365    pub retention_days: u32,
1366    #[serde(default = "conv_default_poll_interval")]
1367    pub poll_interval_secs: u64,
1368    #[serde(default)]
1369    pub sources: ConversationsSources,
1370    #[serde(default)]
1371    pub filter: ConversationsFilter,
1372    #[serde(default)]
1373    pub compact: CompactConfig,
1374    #[serde(default)]
1375    pub ask: AskConfig,
1376    #[serde(default)]
1377    pub rollup: RollupConfig,
1378}
1379
1380impl Default for ConversationsConfig {
1381    fn default() -> Self {
1382        Self {
1383            enabled: false,
1384            retention_days: conv_default_retention_days(),
1385            poll_interval_secs: conv_default_poll_interval(),
1386            sources: ConversationsSources::default(),
1387            filter: ConversationsFilter::default(),
1388            compact: CompactConfig::default(),
1389            ask: AskConfig::default(),
1390            rollup: RollupConfig::default(),
1391        }
1392    }
1393}
1394
1395fn conv_default_retention_days() -> u32 {
1396    30
1397}
1398fn conv_default_poll_interval() -> u64 {
1399    300
1400}
1401fn conv_truthy() -> bool {
1402    true
1403}
1404fn conv_default_dedup() -> f64 {
1405    0.85
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1409pub struct CompactConfig {
1410    #[serde(default = "conv_truthy")]
1411    pub enabled_in_daemon: bool,
1412    #[serde(default = "compact_default_max_days")]
1413    pub max_days_per_run: u32,
1414    #[serde(default = "compact_default_max_spans")]
1415    pub max_extractive_spans: u32,
1416    #[serde(default = "compact_default_max_words")]
1417    pub max_abstractive_words: u32,
1418    #[serde(default = "compact_default_chunk_tokens")]
1419    pub chunk_tokens: u32,
1420    #[serde(default = "compact_default_history_retain")]
1421    pub history_retain: u32,
1422    #[serde(default = "compact_default_cron")]
1423    pub daemon_cron: String,
1424    /// Per-stage backend override for extractive summarization.
1425    /// None = inherit the smart slot (`config.llm`).
1426    #[serde(default)]
1427    pub extractive_backend: Option<BackendConfig>,
1428    /// Per-stage backend override for abstractive summarization.
1429    /// None = inherit the smart slot (`config.llm`).
1430    #[serde(default)]
1431    pub abstractive_backend: Option<BackendConfig>,
1432}
1433
1434impl CompactConfig {
1435    /// Effective backend for the extractive stage. Override wins; otherwise
1436    /// inherit the smart slot. CompactConfig has no per-stage timeout field,
1437    /// so inheritance bakes the same conservative 120s the fabricated Ollama
1438    /// config used.
1439    pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1440        self.extractive_backend
1441            .clone()
1442            .unwrap_or_else(|| BackendConfig {
1443                timeout_secs: Some(120),
1444                ..llm.to_backend_config()
1445            })
1446    }
1447
1448    /// Effective backend for the abstractive stage. See
1449    /// `effective_extractive_backend` for the timeout rationale.
1450    pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1451        self.abstractive_backend
1452            .clone()
1453            .unwrap_or_else(|| BackendConfig {
1454                timeout_secs: Some(120),
1455                ..llm.to_backend_config()
1456            })
1457    }
1458}
1459
1460impl Default for CompactConfig {
1461    fn default() -> Self {
1462        Self {
1463            enabled_in_daemon: true,
1464            max_days_per_run: compact_default_max_days(),
1465            max_extractive_spans: compact_default_max_spans(),
1466            max_abstractive_words: compact_default_max_words(),
1467            chunk_tokens: compact_default_chunk_tokens(),
1468            history_retain: compact_default_history_retain(),
1469            daemon_cron: compact_default_cron(),
1470            extractive_backend: None,
1471            abstractive_backend: None,
1472        }
1473    }
1474}
1475
1476fn compact_default_max_days() -> u32 {
1477    7
1478}
1479fn compact_default_max_spans() -> u32 {
1480    20
1481}
1482fn compact_default_max_words() -> u32 {
1483    400
1484}
1485fn compact_default_chunk_tokens() -> u32 {
1486    6000
1487}
1488fn compact_default_history_retain() -> u32 {
1489    5
1490}
1491fn compact_default_cron() -> String {
1492    "0 0 3 * * * *".into()
1493}
1494
1495// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
1496
1497#[derive(Debug, Clone, Serialize, Deserialize)]
1498pub struct RollupConfig {
1499    #[serde(default = "rollup_default_enabled")]
1500    pub enabled: bool,
1501    #[serde(default = "rollup_default_max_weeks")]
1502    pub max_weeks_per_run: u32,
1503    #[serde(default = "rollup_default_max_months")]
1504    pub max_months_per_run: u32,
1505    #[serde(default = "rollup_default_max_spans_week")]
1506    pub max_extractive_spans_per_week: u32,
1507    #[serde(default = "rollup_default_max_words_week")]
1508    pub max_abstractive_words_per_week: u32,
1509    #[serde(default = "rollup_default_max_spans_month")]
1510    pub max_extractive_spans_per_month: u32,
1511    #[serde(default = "rollup_default_max_words_month")]
1512    pub max_abstractive_words_per_month: u32,
1513    #[serde(default = "rollup_default_week_mmr")]
1514    pub week_mmr_threshold: f64,
1515    #[serde(default = "rollup_default_month_mmr")]
1516    pub month_mmr_threshold: f64,
1517    /// Per-stage backend override for the extractive stage.
1518    /// None = inherit the smart slot (`config.llm`).
1519    #[serde(default)]
1520    pub extractive_backend: Option<BackendConfig>,
1521    /// Per-stage backend override for the abstractive stage.
1522    /// None = inherit the smart slot (`config.llm`).
1523    #[serde(default)]
1524    pub abstractive_backend: Option<BackendConfig>,
1525}
1526
1527impl Default for RollupConfig {
1528    fn default() -> Self {
1529        Self {
1530            enabled: rollup_default_enabled(),
1531            max_weeks_per_run: rollup_default_max_weeks(),
1532            max_months_per_run: rollup_default_max_months(),
1533            max_extractive_spans_per_week: rollup_default_max_spans_week(),
1534            max_abstractive_words_per_week: rollup_default_max_words_week(),
1535            max_extractive_spans_per_month: rollup_default_max_spans_month(),
1536            max_abstractive_words_per_month: rollup_default_max_words_month(),
1537            week_mmr_threshold: rollup_default_week_mmr(),
1538            month_mmr_threshold: rollup_default_month_mmr(),
1539            extractive_backend: None,
1540            abstractive_backend: None,
1541        }
1542    }
1543}
1544
1545impl RollupConfig {
1546    /// Effective backend for the extractive stage. Override wins; otherwise
1547    /// inherit the smart slot with the same 120s budget the previously
1548    /// hardcoded inline config used (`summarize/rollup.rs`).
1549    pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1550        self.extractive_backend
1551            .clone()
1552            .unwrap_or_else(|| BackendConfig {
1553                timeout_secs: Some(120),
1554                ..llm.to_backend_config()
1555            })
1556    }
1557
1558    /// Effective backend for the abstractive stage.
1559    pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1560        self.abstractive_backend
1561            .clone()
1562            .unwrap_or_else(|| BackendConfig {
1563                timeout_secs: Some(120),
1564                ..llm.to_backend_config()
1565            })
1566    }
1567}
1568
1569fn rollup_default_enabled() -> bool {
1570    true
1571}
1572fn rollup_default_max_weeks() -> u32 {
1573    4
1574}
1575fn rollup_default_max_months() -> u32 {
1576    2
1577}
1578fn rollup_default_max_spans_week() -> u32 {
1579    20
1580}
1581fn rollup_default_max_words_week() -> u32 {
1582    500
1583}
1584fn rollup_default_max_spans_month() -> u32 {
1585    20
1586}
1587fn rollup_default_max_words_month() -> u32 {
1588    700
1589}
1590fn rollup_default_week_mmr() -> f64 {
1591    0.85
1592}
1593fn rollup_default_month_mmr() -> f64 {
1594    0.82
1595}
1596
1597#[derive(Debug, Clone, Serialize, Deserialize)]
1598pub struct ConversationsSources {
1599    #[serde(default = "conv_truthy")]
1600    pub claude_code: bool,
1601    #[serde(default = "conv_truthy")]
1602    pub cursor: bool,
1603    #[serde(default = "conv_truthy")]
1604    pub gemini: bool,
1605    #[serde(default)]
1606    pub aider: AiderSourceConfig,
1607}
1608
1609impl Default for ConversationsSources {
1610    fn default() -> Self {
1611        Self {
1612            claude_code: true,
1613            cursor: true,
1614            gemini: true,
1615            aider: AiderSourceConfig::default(),
1616        }
1617    }
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize)]
1621pub struct AiderSourceConfig {
1622    #[serde(default = "conv_truthy")]
1623    pub enabled: bool,
1624    #[serde(default)]
1625    pub watched_dirs: Vec<String>,
1626}
1627
1628impl Default for AiderSourceConfig {
1629    fn default() -> Self {
1630        Self {
1631            enabled: true,
1632            watched_dirs: Vec::new(),
1633        }
1634    }
1635}
1636
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct ConversationsFilter {
1639    #[serde(default = "conv_default_dedup")]
1640    pub dedup_threshold: f64,
1641    #[serde(default = "conv_truthy")]
1642    pub reject_heartbeat: bool,
1643    #[serde(default = "conv_truthy")]
1644    pub reject_system_restatement: bool,
1645}
1646
1647impl Default for ConversationsFilter {
1648    fn default() -> Self {
1649        Self {
1650            dedup_threshold: conv_default_dedup(),
1651            reject_heartbeat: true,
1652            reject_system_restatement: true,
1653        }
1654    }
1655}
1656
1657#[cfg(test)]
1658mod conversations_tests {
1659    use super::*;
1660
1661    #[test]
1662    fn conversations_section_defaults() {
1663        let c = ConversationsConfig::default();
1664        assert!(!c.enabled);
1665        assert_eq!(c.retention_days, 30);
1666        assert_eq!(c.poll_interval_secs, 300);
1667        assert!(c.sources.claude_code);
1668        assert!(c.sources.cursor);
1669        assert!(c.sources.gemini);
1670        assert!(c.sources.aider.enabled);
1671        assert!(c.sources.aider.watched_dirs.is_empty());
1672        assert_eq!(c.filter.dedup_threshold, 0.85);
1673        assert!(c.filter.reject_heartbeat);
1674        assert!(c.filter.reject_system_restatement);
1675    }
1676
1677    #[test]
1678    fn parse_from_yaml_with_overrides() {
1679        let y = r#"
1680conversations:
1681  enabled: true
1682  retention_days: 45
1683  poll_interval_secs: 120
1684  sources:
1685    cursor: false
1686    aider:
1687      watched_dirs: ["~/Projects/a", "~/Projects/b"]
1688  filter:
1689    dedup_threshold: 0.9
1690"#;
1691        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1692        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1693        assert!(conv.enabled);
1694        assert_eq!(conv.retention_days, 45);
1695        assert_eq!(conv.poll_interval_secs, 120);
1696        assert!(conv.sources.claude_code); // defaulted true
1697        assert!(!conv.sources.cursor); // override
1698        assert!(conv.sources.gemini); // defaulted true
1699        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1700        assert_eq!(conv.filter.dedup_threshold, 0.9);
1701        assert!(conv.filter.reject_heartbeat); // defaulted true
1702    }
1703
1704    #[test]
1705    fn missing_conversations_section_is_fine() {
1706        let y = r#"
1707# No conversations section at all
1708foo: bar
1709"#;
1710        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1711        // Default when absent
1712        let conv: ConversationsConfig = v
1713            .get("conversations")
1714            .cloned()
1715            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1716            .unwrap_or_default();
1717        assert_eq!(conv.retention_days, 30);
1718    }
1719
1720    #[test]
1721    fn compact_config_defaults() {
1722        let c = CompactConfig::default();
1723        assert!(c.enabled_in_daemon);
1724        assert_eq!(c.max_days_per_run, 7);
1725        assert_eq!(c.max_extractive_spans, 20);
1726        assert_eq!(c.chunk_tokens, 6000);
1727        assert_eq!(c.history_retain, 5);
1728        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1729    }
1730
1731    #[test]
1732    fn compact_parses_partial_overrides() {
1733        let y = r#"
1734conversations:
1735  compact:
1736    max_days_per_run: 3
1737    extractive_backend:
1738      provider: anthropic
1739      model: claude-haiku-4-5
1740"#;
1741        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1742        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1743        assert_eq!(conv.compact.max_days_per_run, 3);
1744        assert_eq!(
1745            conv.compact.extractive_backend.as_ref().unwrap().model,
1746            "claude-haiku-4-5"
1747        );
1748        assert!(conv.compact.enabled_in_daemon); // default preserved
1749        assert!(conv.compact.abstractive_backend.is_none()); // default preserved
1750    }
1751
1752    #[test]
1753    fn ask_config_defaults() {
1754        let c = AskConfig::default();
1755        assert_eq!(c.k_raw, 10);
1756        assert_eq!(c.escalation_threshold, 0.5);
1757        assert_eq!(c.mmr_threshold, 0.88);
1758        assert_eq!(c.max_context_tokens, 6000);
1759        assert_eq!(c.response_tokens, 1024);
1760        assert_eq!(c.timeout_secs, 120);
1761        assert_eq!(c.min_score, 0.35);
1762    }
1763
1764    #[test]
1765    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1766        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
1767        let c = AskConfig::default();
1768        assert!(
1769            (c.mmr_threshold - 0.88).abs() < 1e-9,
1770            "expected 0.88, got {}",
1771            c.mmr_threshold
1772        );
1773    }
1774
1775    #[test]
1776    fn rollup_config_defaults() {
1777        let c = RollupConfig::default();
1778        assert!(c.enabled);
1779        assert_eq!(c.max_weeks_per_run, 4);
1780        assert_eq!(c.max_months_per_run, 2);
1781        assert_eq!(c.max_extractive_spans_per_week, 20);
1782        assert_eq!(c.max_abstractive_words_per_week, 500);
1783        assert_eq!(c.max_extractive_spans_per_month, 20);
1784        assert_eq!(c.max_abstractive_words_per_month, 700);
1785        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1786        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1787    }
1788
1789    #[test]
1790    fn rollup_config_plumbed_into_conversations_config() {
1791        let c = ConversationsConfig::default();
1792        assert!(c.rollup.enabled);
1793    }
1794
1795    #[test]
1796    fn ask_config_default_continue_history_turns_is_3() {
1797        let c = AskConfig::default();
1798        assert_eq!(c.continue_history_turns, 3);
1799    }
1800
1801    #[test]
1802    fn ask_config_default_compress_hits_enabled_is_true() {
1803        let c = AskConfig::default();
1804        assert!(c.compress_hits_enabled);
1805    }
1806
1807    #[test]
1808    fn ask_config_default_summarize_hits_enabled_is_true() {
1809        let c = AskConfig::default();
1810        assert!(c.summarize_hits_enabled);
1811    }
1812
1813    #[test]
1814    fn ask_config_default_summarize_model_is_none() {
1815        let c = AskConfig::default();
1816        assert!(c.summarize_model.is_none());
1817    }
1818
1819    #[test]
1820    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1821        let y = r#"
1822conversations:
1823  ask:
1824    summarize_hits_enabled: false
1825    summarize_model: qwen3:4b
1826"#;
1827        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1828        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1829        assert!(!conv.ask.summarize_hits_enabled);
1830        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1831    }
1832
1833    #[test]
1834    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1835        // Phase 3.5 must be additive: an existing config.yaml with NO
1836        // summarize_* keys must still parse and default to enabled=true,
1837        // model=None.
1838        let y = r#"
1839conversations:
1840  ask:
1841    min_score: 0.4
1842"#;
1843        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1844        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1845        assert!(conv.ask.summarize_hits_enabled);
1846        assert!(conv.ask.summarize_model.is_none());
1847    }
1848}
1849
1850#[cfg(test)]
1851mod attribution_tests {
1852    use super::{AttributionConfig, Config};
1853
1854    /// The shipped default: a PR gets a MUR credit line, a commit does not.
1855    /// The asymmetry is the whole design — a PR body is ours to sign, a commit
1856    /// trailer is written permanently into someone else's git history.
1857    #[test]
1858    fn pr_signature_ships_on_and_commit_trailer_ships_off() {
1859        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1860        assert_eq!(
1861            c.attribution.pr.as_deref(),
1862            Some("Generated with [MUR](https://app.mur.run/products/mur)")
1863        );
1864        assert_eq!(c.attribution.commit, None);
1865    }
1866
1867    /// Both surfaces are independently overridable, and an empty string is the
1868    /// off switch — there is no separate `enabled` flag to disagree with.
1869    #[test]
1870    fn empty_string_is_the_off_switch_per_surface() {
1871        let c: Config = serde_yaml_ng::from_str("attribution:\n  pr: \"\"\n").unwrap();
1872        assert!(c.attribution.pr_signature().is_none());
1873
1874        let c: Config = serde_yaml_ng::from_str(
1875            "attribution:\n  pr: \"   \"\n  commit: \"Co-Authored-By: MUR <noreply@mur.run>\"\n",
1876        )
1877        .unwrap();
1878        assert!(
1879            c.attribution.pr_signature().is_none(),
1880            "whitespace-only counts as off, not as a signature of spaces"
1881        );
1882        assert_eq!(
1883            c.attribution.commit_signature(),
1884            Some("Co-Authored-By: MUR <noreply@mur.run>")
1885        );
1886    }
1887
1888    #[test]
1889    fn a_custom_pr_signature_replaces_the_default() {
1890        let c: Config = serde_yaml_ng::from_str("attribution:\n  pr: \"Made by ACME\"\n").unwrap();
1891        assert_eq!(c.attribution.pr_signature(), Some("Made by ACME"));
1892    }
1893
1894    /// New config files advertise the default PR signature, but do not write a
1895    /// misleading `commit: null`: absence is the explicit opt-in default.
1896    #[test]
1897    fn serialization_ships_pr_attribution_but_omits_disabled_commit_trailer() {
1898        let yaml = serde_yaml::to_string(&Config::default()).unwrap();
1899        assert!(
1900            yaml.contains(
1901                "attribution:\n  pr: Generated with [MUR](https://app.mur.run/products/mur)"
1902            ),
1903            "{yaml}"
1904        );
1905        assert!(!yaml.contains("commit:"), "{yaml}");
1906    }
1907
1908    /// Nothing to say → nothing injected. A user who blanks both surfaces must
1909    /// not pay system-prompt tokens for an empty rule.
1910    #[test]
1911    fn prompt_fragment_is_none_when_both_surfaces_are_off() {
1912        let off = AttributionConfig {
1913            pr: Some(String::new()),
1914            commit: None,
1915        };
1916        assert_eq!(off.prompt_fragment(), None);
1917    }
1918
1919    /// The fragment names the surface it governs and quotes the text verbatim,
1920    /// so the model has no room to paraphrase the credit line.
1921    #[test]
1922    fn prompt_fragment_quotes_each_enabled_surface_verbatim() {
1923        let c = AttributionConfig::default();
1924        let f = c.prompt_fragment().expect("pr is on by default");
1925        assert!(f.contains("## MUR attribution"), "{f}");
1926        assert!(
1927            f.contains("Generated with [MUR](https://app.mur.run/products/mur)"),
1928            "the exact signature must appear, not a description of it: {f}"
1929        );
1930        assert!(f.contains("gh pr create"), "{f}");
1931        assert!(
1932            !f.to_lowercase().contains("git commit"),
1933            "commit trailer is off by default, so its instruction must be absent: {f}"
1934        );
1935
1936        let both = AttributionConfig {
1937            pr: Some("Generated with MUR".into()),
1938            commit: Some("Co-Authored-By: MUR <noreply@mur.run>".into()),
1939        };
1940        let f = both.prompt_fragment().unwrap();
1941        assert!(f.contains("git commit"), "{f}");
1942        assert!(f.contains("Co-Authored-By: MUR <noreply@mur.run>"), "{f}");
1943    }
1944
1945    /// Commit-only is a real configuration: a user may keep their PR bodies
1946    /// clean and still credit MUR in the trailer.
1947    #[test]
1948    fn commit_only_configuration_injects_only_the_commit_rule() {
1949        let c = AttributionConfig {
1950            pr: Some(String::new()),
1951            commit: Some("Co-Authored-By: MUR <noreply@mur.run>".into()),
1952        };
1953        let f = c.prompt_fragment().unwrap();
1954        assert!(f.contains("git commit"), "{f}");
1955        assert!(!f.contains("gh pr create"), "{f}");
1956    }
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961    #[test]
1962    fn update_defaults_apply_when_block_absent_and_fields_parse() {
1963        let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1964        assert_eq!(c.update, super::UpdateConfig::default());
1965        assert!(c.update.codesign_identity.is_none());
1966        assert!(c.update.restart_exclude.is_empty());
1967
1968        let c: super::Config = serde_yaml_ng::from_str(
1969            "update:\n  codesign_identity: \"Developer ID Application: X (TEAM)\"\n  restart_exclude: [dr_worker_1]\n",
1970        )
1971        .unwrap();
1972        assert_eq!(
1973            c.update.codesign_identity.as_deref(),
1974            Some("Developer ID Application: X (TEAM)")
1975        );
1976        assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1977    }
1978
1979    #[test]
1980    fn federation_snapshot_defaults_apply_when_block_absent() {
1981        let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1982        assert_eq!(c.federation_snapshot.poll_secs, 30);
1983        assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1984        assert_eq!(
1985            c.federation_snapshot.min_lifecycle,
1986            crate::skill::stats::LifecycleState::Stable
1987        );
1988        // Memory capture (P2a): defaults to auto_announce; `off` parses.
1989        assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1990        let c: super::Config = serde_yaml_ng::from_str("memory:\n  capture: off\n").unwrap();
1991        assert_eq!(c.memory.capture, super::CaptureMode::Off);
1992    }
1993
1994    use super::*;
1995
1996    #[test]
1997    fn default_bundled_model_id_is_qwen35_2b() {
1998        assert_eq!(
1999            crate::config::DEFAULT_BUNDLED_MODEL_ID,
2000            "Qwen3.5-2B-MLX-4bit"
2001        );
2002    }
2003
2004    #[test]
2005    fn nudge_config_defaults() {
2006        let c = NudgeConfig::default();
2007        assert!(c.enabled);
2008        assert_eq!(c.daily_cap, 3);
2009        assert_eq!(c.snooze_days, 7);
2010        assert_eq!(c.threshold, 3);
2011    }
2012
2013    #[test]
2014    fn config_has_nudge_section_with_defaults() {
2015        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
2016        assert_eq!(c.nudge.daily_cap, 3);
2017    }
2018
2019    #[test]
2020    fn storage_config_default_is_lancedb() {
2021        let c = StorageConfig::default();
2022        assert_eq!(c.vector_backend, "lancedb");
2023        assert_eq!(c.qdrant_url, None);
2024        assert_eq!(c.qdrant_api_key_ref, None);
2025    }
2026
2027    #[test]
2028    fn sources_global_config_has_sensible_defaults() {
2029        let c = SourcesGlobalConfig::default();
2030        assert_eq!(c.poll_interval_secs, 600);
2031        assert_eq!(c.max_chunks_per_sync, 10_000);
2032        assert_eq!(c.max_parallel_sources, 3);
2033        assert_eq!(c.default_weight, 1.0);
2034        assert_eq!(c.embedding_batch_size, 32);
2035    }
2036
2037    #[test]
2038    fn config_default_has_storage_and_sources_global() {
2039        let c = Config::default();
2040        assert_eq!(c.storage.vector_backend, "lancedb");
2041        assert_eq!(c.sources_global.default_weight, 1.0);
2042    }
2043
2044    #[test]
2045    fn config_loads_yaml_without_new_fields() {
2046        // Existing users' config.yaml won't mention storage or sources_global.
2047        // It must still parse.
2048        let yaml = r#"
2049embedding:
2050  provider: ollama
2051  model: test-model
2052  dimensions: 512
2053  ollama_endpoint: http://localhost:11434
2054"#;
2055        let c: Config = serde_yaml::from_str(yaml).expect("parses");
2056        assert_eq!(c.storage.vector_backend, "lancedb");
2057        assert_eq!(c.sources_global.max_parallel_sources, 3);
2058    }
2059
2060    #[test]
2061    fn llm_config_to_backend_config_anthropic_passthrough() {
2062        let cfg = LlmConfig {
2063            provider: "anthropic".into(),
2064            model: "claude-haiku-4-5".into(),
2065            api_key_env: Some("ANTHROPIC_API_KEY".into()),
2066            api_key_ref: None,
2067            openai_url: None,
2068        };
2069        let b = cfg.to_backend_config();
2070        assert_eq!(b.provider, "anthropic");
2071        assert_eq!(b.model, "claude-haiku-4-5");
2072        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
2073        assert_eq!(b.endpoint, None);
2074        assert_eq!(b.timeout_secs, None);
2075    }
2076
2077    #[test]
2078    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
2079        let cfg = LlmConfig {
2080            provider: "openai".into(),
2081            model: "gpt-4o-mini".into(),
2082            api_key_env: None,
2083            api_key_ref: None,
2084            openai_url: Some("https://api.together.xyz/v1".into()),
2085        };
2086        let b = cfg.to_backend_config();
2087        assert_eq!(b.provider, "openai");
2088        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
2089        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
2090    }
2091
2092    #[test]
2093    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
2094        let cfg = LlmConfig {
2095            provider: "ollama".into(),
2096            model: "qwen3:14b".into(),
2097            api_key_env: None,
2098            api_key_ref: None,
2099            openai_url: Some("http://192.168.1.10:11434".into()),
2100        };
2101        let b = cfg.to_backend_config();
2102        assert_eq!(b.provider, "ollama");
2103        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
2104    }
2105
2106    #[test]
2107    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
2108        // Historical LlmConfig allowed provider="custom" + openai_url to act as
2109        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
2110        // "openai" so factory dispatches to OpenAIBackend.
2111        let cfg = LlmConfig {
2112            provider: "custom-name".into(),
2113            model: "some-model".into(),
2114            api_key_env: Some("CUSTOM_KEY".into()),
2115            api_key_ref: None,
2116            openai_url: Some("https://my-proxy.local/v1".into()),
2117        };
2118        let b = cfg.to_backend_config();
2119        assert_eq!(
2120            b.provider, "openai",
2121            "unknown provider + openai_url should alias to openai"
2122        );
2123        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
2124    }
2125
2126    #[test]
2127    fn api_key_ref_roundtrips_and_defaults_none() {
2128        // Old YAML without the field still parses, field defaults to None.
2129        let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
2130        assert_eq!(b.api_key_ref, None);
2131        let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
2132        assert_eq!(l.api_key_ref, None);
2133        let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
2134        assert_eq!(e.api_key_ref, None);
2135
2136        // Set → survives YAML round-trip and to_backend_config.
2137        let l2 = LlmConfig {
2138            api_key_ref: Some("keychain:mur/anthropic".into()),
2139            ..Default::default()
2140        };
2141        let y = serde_yaml_ng::to_string(&l2).unwrap();
2142        let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
2143        assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
2144        assert_eq!(
2145            l3.to_backend_config().api_key_ref.as_deref(),
2146            Some("keychain:mur/anthropic")
2147        );
2148    }
2149
2150    #[test]
2151    fn open_items_muted_parses_and_defaults_empty() {
2152        let c: Config = serde_yaml::from_str("open_items:\n  muted:\n    - inbox\n").unwrap();
2153        assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
2154
2155        let d: Config = serde_yaml::from_str("llm:\n  model: x\n").unwrap();
2156        assert!(d.open_items.muted.is_empty(), "must default to no mutes");
2157    }
2158
2159    /// Fail toward showing. A config that will not parse must yield an empty
2160    /// mute set, never a quiet, confident, incomplete list.
2161    #[test]
2162    fn unreadable_config_yields_no_mutes() {
2163        let tmp = tempfile::tempdir().unwrap();
2164        let path = tmp.path().join("config.yaml");
2165        std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
2166        let cfg = Config::load_or_default(&path);
2167        assert!(
2168            cfg.open_items.muted.is_empty(),
2169            "a broken config must hide nothing"
2170        );
2171
2172        // Same for a config that is simply absent.
2173        let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
2174        assert!(missing.open_items.muted.is_empty());
2175    }
2176
2177    #[test]
2178    fn rollup_config_accepts_backend_overrides() {
2179        let yaml = r#"
2180enabled: true
2181extractive_backend:
2182  provider: openai
2183  model: Qwen3.5-4B-MLX-4bit
2184  endpoint: http://127.0.0.1:8000/v1
2185"#;
2186        let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
2187        let b = c.extractive_backend.expect("override present");
2188        assert_eq!(b.provider, "openai");
2189        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2190        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2191        assert!(c.abstractive_backend.is_none());
2192    }
2193
2194    #[test]
2195    fn legacy_conversation_fields_are_gone_from_serialized_output() {
2196        let cfg = Config::default();
2197        // Scoped to the conversations block intentionally: `embedding` still
2198        // carries its own `ollama_endpoint`, and asserting over the whole
2199        // document here would require that field to be omitted, which is not
2200        // the case. The Config type will never serialize without it, so this
2201        // scoping to conversations is permanent.
2202        let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
2203        for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
2204            assert!(
2205                !yaml.contains(key),
2206                "legacy key {key} still serialized:\n{yaml}"
2207            );
2208        }
2209    }
2210
2211    #[test]
2212    fn embedding_ollama_endpoint_is_omitted_when_unset() {
2213        let mut cfg = Config::default();
2214        cfg.embedding.provider = "omlx".into();
2215        cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
2216        cfg.embedding.ollama_endpoint = None;
2217        let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
2218        assert!(
2219            !yaml.contains("ollama_endpoint"),
2220            "dead field re-emitted:\n{yaml}"
2221        );
2222    }
2223
2224    #[test]
2225    fn embedding_ollama_endpoint_still_round_trips_when_set() {
2226        let yaml =
2227            "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
2228        let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
2229        assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
2230    }
2231}
2232
2233#[cfg(test)]
2234mod backend_config_tests {
2235    use super::*;
2236
2237    #[test]
2238    fn default_is_ollama_qwen3() {
2239        let cfg = BackendConfig::default();
2240        assert_eq!(cfg.provider, "ollama");
2241        assert_eq!(cfg.model, "qwen3.5:4b");
2242        assert_eq!(cfg.endpoint, None);
2243        assert_eq!(cfg.api_key_env, None);
2244        assert_eq!(cfg.timeout_secs, None);
2245    }
2246
2247    #[test]
2248    fn deserializes_anthropic_full() {
2249        let yaml = "\
2250provider: anthropic
2251model: claude-haiku-4-5
2252api_key_env: ANTHROPIC_API_KEY
2253timeout_secs: 60
2254";
2255        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
2256        assert_eq!(cfg.provider, "anthropic");
2257        assert_eq!(cfg.model, "claude-haiku-4-5");
2258        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
2259        assert_eq!(cfg.timeout_secs, Some(60));
2260        assert_eq!(cfg.endpoint, None);
2261    }
2262
2263    #[test]
2264    fn deserializes_partial_fills_defaults() {
2265        let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
2266        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
2267        assert_eq!(cfg.provider, "anthropic");
2268        assert_eq!(cfg.model, "claude-sonnet-5");
2269        assert_eq!(cfg.api_key_env, None);
2270        assert_eq!(cfg.timeout_secs, None);
2271    }
2272
2273    #[test]
2274    fn round_trips_through_yaml() {
2275        let original = BackendConfig {
2276            provider: "anthropic".into(),
2277            model: "claude-haiku-4-5".into(),
2278            endpoint: Some("https://api.anthropic.com".into()),
2279            api_key_env: Some("ANTHROPIC_API_KEY".into()),
2280            api_key_ref: None,
2281            timeout_secs: Some(60),
2282        };
2283        let yaml = serde_yaml::to_string(&original).unwrap();
2284        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
2285        assert_eq!(parsed, original);
2286    }
2287
2288    #[test]
2289    fn skills_config_curation_gate_defaults_on() {
2290        let c = SkillsConfig::default();
2291        assert!(c.require_human_curation_before_stable);
2292    }
2293}
2294
2295/// Configuration for the daemon-side sleep cycle (idle background learning).
2296///
2297/// Skill injection configuration (M2 — runtime injection).
2298///
2299/// Whether the `mur-dev` discipline hub appears in the session-start learning
2300/// index on the AI-tool (CLI hook) surface. Runtime injection for MUR agents
2301/// is never affected by this setting.
2302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2303#[serde(rename_all = "lowercase")]
2304pub enum DevDisciplineIndex {
2305    /// Suppress the hub when a superpowers plugin install is detected (default).
2306    #[default]
2307    Auto,
2308    /// Always list the hub, even when superpowers is installed.
2309    Always,
2310    /// Never list the hub on the CLI surface.
2311    Never,
2312}
2313
2314#[derive(Debug, Clone, Serialize, Deserialize)]
2315#[serde(default)]
2316pub struct SkillsConfig {
2317    pub max_skills_in_prompt: usize,
2318    pub max_total_tokens: usize,
2319    pub priority_order: Vec<String>,
2320    pub adaptive: Option<AdaptiveSkillsConfig>,
2321
2322    /// When true (default), LLM-authored skills cannot auto-promote past
2323    /// `Emerging` until a human curates them (amendment A1). Set false to
2324    /// let LLM-extracted skills promote on run stats alone.
2325    #[serde(default = "default_require_human_curation")]
2326    pub require_human_curation_before_stable: bool,
2327
2328    /// Lifecycle scoring thresholds (W3b-P4). All fields default to the
2329    /// compile-time constants in `mur_common::skill::lifecycle` so existing
2330    /// deployments see no behaviour change without an explicit config entry.
2331    #[serde(default)]
2332    pub lifecycle: SkillLifecycleConfig,
2333
2334    /// Daily daemon auto-upgrade of origin-stamped (registry-installed)
2335    /// skills (`mur-daemon` `skill_upgrade_tick`). Non-destructive: never
2336    /// overwrites a locally-modified skill (origin hash drift blocks it).
2337    /// Defaults to `true`.
2338    #[serde(default = "default_auto_upgrade")]
2339    pub auto_upgrade: bool,
2340
2341    /// See [`DevDisciplineIndex`]. Key: `skills.dev_discipline_index`.
2342    #[serde(default)]
2343    pub dev_discipline_index: DevDisciplineIndex,
2344}
2345
2346fn default_require_human_curation() -> bool {
2347    true
2348}
2349
2350fn default_auto_upgrade() -> bool {
2351    true
2352}
2353
2354impl Default for SkillsConfig {
2355    fn default() -> Self {
2356        Self {
2357            max_skills_in_prompt: 5,
2358            max_total_tokens: 2000,
2359            priority_order: vec!["agent".into(), "global".into()],
2360            adaptive: Some(AdaptiveSkillsConfig::default()),
2361            require_human_curation_before_stable: default_require_human_curation(),
2362            lifecycle: SkillLifecycleConfig::default(),
2363            auto_upgrade: default_auto_upgrade(),
2364            dev_discipline_index: DevDisciplineIndex::default(),
2365        }
2366    }
2367}
2368
2369/// Per-skill lifecycle scoring thresholds.
2370///
2371/// Stored under `skill.lifecycle.*` in `~/.mur/config.yaml`.
2372/// All fields are optional on disk — missing keys fall back to the
2373/// compile-time defaults so a partial config is always valid.
2374#[derive(Debug, Clone, Serialize, Deserialize)]
2375#[serde(default)]
2376pub struct SkillLifecycleConfig {
2377    // ── Per-kind decay curves (memory federation P1) ─────────────────────
2378    /// Half-life multiplier for `kind=rule` notes — behavioral guidance
2379    /// iterates fast, so it decays fast.
2380    pub note_rule_half_life_factor: f64,
2381    /// Half-life multiplier for `kind=fact` notes — environment truths
2382    /// decay slowly.
2383    pub note_fact_half_life_factor: f64,
2384    // ── Promotion thresholds (must be exceeded) ──────────────────────────
2385    pub promote_draft_uses: u64,
2386    pub promote_emerging_uses: u64,
2387    pub promote_emerging_success_rate: f64,
2388    pub promote_emerging_age_days: i64,
2389    pub promote_stable_uses: u64,
2390    pub promote_stable_success_rate: f64,
2391    pub promote_stable_age_days: i64,
2392
2393    // ── Demotion thresholds (must drop below) ────────────────────────────
2394    pub demote_emerging_uses: u64,
2395    pub demote_emerging_success_rate: f64,
2396    pub demote_stable_uses: u64,
2397    pub demote_stable_success_rate: f64,
2398    pub deprecated_success_rate: f64,
2399    pub deprecated_no_success_days: i64,
2400
2401    // ── Auto-archive thresholds ───────────────────────────────────────────
2402    pub auto_archive_confidence: f64,
2403    pub auto_archive_age_days: i64,
2404
2405    // ── P4: broken fast-path ─────────────────────────────────────────────
2406    /// Number of consecutive `Execution` events with `env_class == "workflow"`
2407    /// that immediately triggers a `Deprecated` transition, bypassing the
2408    /// normal scoring path. Set to 0 to disable the fast-path.
2409    pub broken_workflow_streak: u32,
2410
2411    // ── P4: archived hard-delete ─────────────────────────────────────────
2412    /// Days a skill must remain in `Archived` state before `mur skill sweep`
2413    /// transitions it to `Destroyed` and removes its directory from disk.
2414    /// Set to 0 to disable hard-delete.
2415    pub archive_destroy_grace_days: i64,
2416}
2417
2418impl Default for SkillLifecycleConfig {
2419    fn default() -> Self {
2420        Self {
2421            promote_draft_uses: 3,
2422            promote_emerging_uses: 10,
2423            promote_emerging_success_rate: 0.6,
2424            promote_emerging_age_days: 7,
2425            promote_stable_uses: 30,
2426            promote_stable_success_rate: 0.8,
2427            promote_stable_age_days: 30,
2428            demote_emerging_uses: 8,
2429            demote_emerging_success_rate: 0.55,
2430            demote_stable_uses: 25,
2431            demote_stable_success_rate: 0.75,
2432            deprecated_success_rate: 0.3,
2433            deprecated_no_success_days: 90,
2434            auto_archive_confidence: 0.10,
2435            auto_archive_age_days: 180,
2436            broken_workflow_streak: 3,
2437            archive_destroy_grace_days: 30,
2438            note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
2439            note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
2440        }
2441    }
2442}
2443
2444#[derive(Debug, Clone, Serialize, Deserialize)]
2445#[serde(default)]
2446pub struct AdaptiveSkillsConfig {
2447    pub context_fill_decay: f64,
2448    pub min_remaining_context_ratio: f64,
2449    pub recent_fire_boost_turns: usize,
2450    /// Model max context window in tokens. Used to compute
2451    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
2452    /// Default 200_000 (Claude 3.5/4.x).
2453    pub model_max_context_tokens: u64,
2454}
2455
2456impl Default for AdaptiveSkillsConfig {
2457    fn default() -> Self {
2458        Self {
2459            context_fill_decay: 1.5,
2460            min_remaining_context_ratio: 0.20,
2461            recent_fire_boost_turns: 5,
2462            model_max_context_tokens: 200_000,
2463        }
2464    }
2465}
2466
2467/// When enabled, the daemon fires a consolidation pipeline after the user has been
2468/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
2469#[derive(Debug, Clone, Serialize, Deserialize)]
2470pub struct SleepCycleConfig {
2471    /// Master switch. False by default (opt-in).
2472    #[serde(default)]
2473    pub enabled: bool,
2474
2475    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
2476    #[serde(default = "default_idle_threshold_minutes")]
2477    pub idle_threshold_minutes: u64,
2478
2479    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
2480    #[serde(default = "default_agent_idle_minutes")]
2481    pub agent_idle_minutes: u64,
2482}
2483
2484fn default_idle_threshold_minutes() -> u64 {
2485    15
2486}
2487
2488fn default_agent_idle_minutes() -> u64 {
2489    5
2490}
2491
2492impl Default for SleepCycleConfig {
2493    fn default() -> Self {
2494        Self {
2495            enabled: false,
2496            idle_threshold_minutes: default_idle_threshold_minutes(),
2497            agent_idle_minutes: default_agent_idle_minutes(),
2498        }
2499    }
2500}
2501
2502// ── Nudge config ───────────────────────────────────────────────────
2503
2504#[derive(Debug, Clone, Serialize, Deserialize)]
2505pub struct NudgeConfig {
2506    /// Master switch. Default on — Phase 2 companion surface is live.
2507    #[serde(default = "default_nudge_enabled")]
2508    pub enabled: bool,
2509    #[serde(default = "default_nudge_daily_cap")]
2510    pub daily_cap: u32,
2511    #[serde(default = "default_nudge_snooze_days")]
2512    pub snooze_days: u32,
2513    #[serde(default = "default_nudge_threshold")]
2514    pub threshold: usize,
2515}
2516
2517fn default_nudge_enabled() -> bool {
2518    true
2519}
2520fn default_nudge_daily_cap() -> u32 {
2521    3
2522}
2523fn default_nudge_snooze_days() -> u32 {
2524    7
2525}
2526fn default_nudge_threshold() -> usize {
2527    3
2528}
2529
2530impl Default for NudgeConfig {
2531    fn default() -> Self {
2532        Self {
2533            enabled: true,
2534            daily_cap: default_nudge_daily_cap(),
2535            snooze_days: default_nudge_snooze_days(),
2536            threshold: default_nudge_threshold(),
2537        }
2538    }
2539}
2540
2541// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
2542
2543/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
2544#[derive(Debug, Clone, Serialize, Deserialize)]
2545pub struct SessionCfg {
2546    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
2547    #[serde(default = "default_capture_mode")]
2548    pub capture: String,
2549    /// Recordings older than this many days are removed by `mur session gc`.
2550    #[serde(default = "default_retention_days")]
2551    pub retention_days: u32,
2552}
2553
2554impl Default for SessionCfg {
2555    fn default() -> Self {
2556        Self {
2557            capture: default_capture_mode(),
2558            retention_days: default_retention_days(),
2559        }
2560    }
2561}
2562
2563fn default_capture_mode() -> String {
2564    "ambient".to_string()
2565}
2566fn default_retention_days() -> u32 {
2567    14
2568}
2569
2570/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
2571#[derive(Debug, Clone, Serialize, Deserialize)]
2572pub struct HarvestCfg {
2573    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
2574    #[serde(default = "default_harvest_enabled")]
2575    pub auto_gate: bool,
2576    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
2577    #[serde(default = "default_harvest_llm")]
2578    pub llm: String,
2579    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
2580    #[serde(default = "default_min_events")]
2581    pub min_events: usize,
2582    #[serde(default = "default_min_user_turns")]
2583    pub min_user_turns: usize,
2584    #[serde(default = "default_min_duration_secs")]
2585    pub min_duration_secs: i64,
2586    /// A session is considered ended when its last event is older than this.
2587    #[serde(default = "default_idle_minutes")]
2588    pub idle_minutes: i64,
2589    /// Ceilings — past these a recording is a session, not a procedure (#781).
2590    /// A session marked with `mur in` bypasses both.
2591    #[serde(default = "default_max_steps")]
2592    pub max_steps: usize,
2593    #[serde(default = "default_max_duration_secs")]
2594    pub max_duration_secs: i64,
2595    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
2596    #[serde(default = "default_max_llm_calls_per_day")]
2597    pub max_llm_calls_per_day: u32,
2598    #[serde(default = "default_max_extract_input_tokens")]
2599    pub max_extract_input_tokens: usize,
2600    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
2601    #[serde(default = "default_harvest_enabled")]
2602    pub session_start_hint: bool,
2603    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
2604    /// Doubles as the "same procedure?" test for the recurrence index (#783).
2605    #[serde(default = "default_similarity_merge_threshold")]
2606    pub similarity_merge_threshold: f32,
2607    /// A procedure is something done more than once (#783): a session's skeleton
2608    /// must have been seen this many times before it becomes a proposal.
2609    /// A session marked with `mur in` bypasses it.
2610    #[serde(default = "default_min_occurrences")]
2611    pub min_occurrences: usize,
2612}
2613
2614impl Default for HarvestCfg {
2615    fn default() -> Self {
2616        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2617    }
2618}
2619
2620fn default_harvest_enabled() -> bool {
2621    true
2622}
2623fn default_harvest_llm() -> String {
2624    "local-first".to_string()
2625}
2626fn default_min_events() -> usize {
2627    5
2628}
2629fn default_min_user_turns() -> usize {
2630    2
2631}
2632fn default_min_duration_secs() -> i64 {
2633    120
2634}
2635fn default_idle_minutes() -> i64 {
2636    30
2637}
2638/// Above ~20 distinct commands a recording reads as a transcript, not a
2639/// procedure a human would write down. Measured against a real 38-proposal
2640/// inbox: everything plausible sat below it, nothing accepted sat above (#781).
2641fn default_max_steps() -> usize {
2642    20
2643}
2644/// 30 minutes. Long enough for a real deploy/release procedure including waits,
2645/// short enough to exclude debugging sessions (#781).
2646fn default_max_duration_secs() -> i64 {
2647    1800
2648}
2649fn default_max_llm_calls_per_day() -> u32 {
2650    10
2651}
2652fn default_max_extract_input_tokens() -> usize {
2653    12000
2654}
2655fn default_similarity_merge_threshold() -> f32 {
2656    0.6
2657}
2658/// Twice. The minimum that can distinguish "did it again" from "did it" — a
2659/// higher bar would silently discard real routines while the index is young (#783).
2660fn default_min_occurrences() -> usize {
2661    2
2662}
2663
2664// ── M7a: Cross-agent observability ─────────────────────────────────
2665
2666#[derive(Debug, Clone, Serialize, Deserialize)]
2667#[serde(default)]
2668pub struct CrossAgentConfig {
2669    #[serde(default = "default_half_life_days")]
2670    pub fitness_half_life_days: u32,
2671    #[serde(default = "default_fitness_floor")]
2672    pub fitness_floor: f64,
2673}
2674
2675fn default_half_life_days() -> u32 {
2676    7
2677}
2678fn default_fitness_floor() -> f64 {
2679    0.1
2680}
2681
2682impl Default for CrossAgentConfig {
2683    fn default() -> Self {
2684        Self {
2685            fitness_half_life_days: default_half_life_days(),
2686            fitness_floor: default_fitness_floor(),
2687        }
2688    }
2689}
2690
2691// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
2692
2693#[derive(Debug, Clone, Serialize, Deserialize)]
2694#[serde(default)]
2695pub struct SkillLlmConfig {
2696    /// Per-call output token cap.
2697    #[serde(default = "default_per_call_token_cap")]
2698    pub per_call_token_cap: u32,
2699
2700    /// Per-day USD cap for all maintenance LLM calls.
2701    #[serde(default = "default_per_day_usd_cap")]
2702    pub per_day_usd_cap: f64,
2703
2704    /// Cache TTL in days.
2705    #[serde(default = "default_cache_ttl_days")]
2706    pub cache_ttl_days: u32,
2707
2708    /// Optional explicit model key override. When `None`, role resolution picks.
2709    #[serde(default, skip_serializing_if = "Option::is_none")]
2710    pub model_ref: Option<String>,
2711}
2712
2713fn default_per_call_token_cap() -> u32 {
2714    1500
2715}
2716fn default_per_day_usd_cap() -> f64 {
2717    0.50
2718}
2719fn default_cache_ttl_days() -> u32 {
2720    30
2721}
2722
2723impl Default for SkillLlmConfig {
2724    fn default() -> Self {
2725        Self {
2726            per_call_token_cap: default_per_call_token_cap(),
2727            per_day_usd_cap: default_per_day_usd_cap(),
2728            cache_ttl_days: default_cache_ttl_days(),
2729            model_ref: None,
2730        }
2731    }
2732}
2733#[cfg(test)]
2734mod per_stage_backend_tests {
2735    use super::*;
2736
2737    #[test]
2738    fn compact_extractive_backend_override_parses() {
2739        let yaml = "\
2740extractive_backend:
2741  provider: anthropic
2742  model: claude-haiku-4-5
2743  api_key_env: ANTHROPIC_API_KEY
2744";
2745        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2746        let extractive = cfg
2747            .extractive_backend
2748            .as_ref()
2749            .expect("override should parse");
2750        assert_eq!(extractive.provider, "anthropic");
2751        assert_eq!(extractive.model, "claude-haiku-4-5");
2752        assert!(cfg.abstractive_backend.is_none());
2753    }
2754
2755    #[test]
2756    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2757        let yaml = "\
2758backend:
2759  provider: anthropic
2760  model: claude-sonnet-5
2761  api_key_env: ANTHROPIC_API_KEY
2762rewriter_backend:
2763  provider: ollama
2764  model: llama3.2:3b
2765";
2766        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2767        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2768        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2769    }
2770
2771    #[test]
2772    fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2773        // C1 regression test: effective_rewriter_backend must follow the
2774        // answer stage's `backend` when `rewriter_backend` is unset, NOT
2775        // fall straight through to the smart slot (`llm`) — the rewriter is
2776        // not an independent pinning point, only an independent timeout.
2777        // `llm` below uses a distinctly different provider ("omlx", which
2778        // to_backend_config() maps to "openai") than `cfg.backend`
2779        // ("anthropic"), so this test cannot pass by coincidentally landing
2780        // on the same provider from either source: if the fix regresses to
2781        // falling through to `llm`, `rewriter.provider` comes back
2782        // "openai" and the first assertion fails.
2783        let cfg = AskConfig {
2784            backend: Some(BackendConfig {
2785                provider: "anthropic".into(),
2786                model: "claude-sonnet-5".into(),
2787                endpoint: None,
2788                api_key_env: Some("ANTHROPIC_API_KEY".into()),
2789                api_key_ref: None,
2790                timeout_secs: None,
2791            }),
2792            ..Default::default()
2793        };
2794        let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2795        assert_eq!(rewriter.provider, "anthropic");
2796        assert_eq!(rewriter.model, "claude-sonnet-5");
2797        assert_eq!(
2798            rewriter.timeout_secs,
2799            Some(cfg.rewriter_timeout_secs as u64),
2800            "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2801        );
2802    }
2803
2804    #[test]
2805    fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2806        let mut cfg = AskConfig {
2807            rewriter_timeout_secs: 8,
2808            ..AskConfig::default()
2809        };
2810        cfg.rewriter_backend = Some(BackendConfig {
2811            provider: "anthropic".into(),
2812            model: "claude-haiku-4-5".into(),
2813            endpoint: None,
2814            api_key_env: Some("ANTHROPIC_API_KEY".into()),
2815            api_key_ref: None,
2816            timeout_secs: Some(30),
2817        });
2818        let b = cfg.effective_rewriter_backend(&omlx_llm());
2819        assert_eq!(
2820            b.timeout_secs,
2821            Some(30),
2822            "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2823        );
2824    }
2825
2826    fn omlx_llm() -> LlmConfig {
2827        LlmConfig {
2828            provider: "omlx".into(),
2829            model: "Qwen3.5-4B-MLX-4bit".into(),
2830            api_key_env: None,
2831            api_key_ref: Some("env:OMLX_API_KEY".into()),
2832            openai_url: Some("http://127.0.0.1:8000/v1".into()),
2833        }
2834    }
2835
2836    #[test]
2837    fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2838        let ask = AskConfig::default();
2839        let b = ask.effective_backend(&omlx_llm());
2840        assert_eq!(b.provider, "openai");
2841        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2842        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2843        assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2844        // stage timeout is baked in, not left to the factory's 120s default
2845        assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2846    }
2847
2848    #[test]
2849    fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2850        let ask = AskConfig::default();
2851        let b = ask.effective_rewriter_backend(&omlx_llm());
2852        assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2853        assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2854    }
2855
2856    #[test]
2857    fn explicit_override_wins_over_the_smart_slot() {
2858        let ask = AskConfig {
2859            backend: Some(BackendConfig {
2860                provider: "anthropic".into(),
2861                model: "claude-haiku-4-5".into(),
2862                endpoint: None,
2863                api_key_env: None,
2864                api_key_ref: None,
2865                timeout_secs: Some(42),
2866            }),
2867            ..Default::default()
2868        };
2869        let b = ask.effective_backend(&omlx_llm());
2870        assert_eq!(b.provider, "anthropic");
2871        assert_eq!(b.timeout_secs, Some(42));
2872    }
2873
2874    #[test]
2875    fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2876        let llm = omlx_llm();
2877        for b in [
2878            CompactConfig::default().effective_extractive_backend(&llm),
2879            CompactConfig::default().effective_abstractive_backend(&llm),
2880            RollupConfig::default().effective_extractive_backend(&llm),
2881            RollupConfig::default().effective_abstractive_backend(&llm),
2882        ] {
2883            assert_eq!(b.provider, "openai");
2884            assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2885            assert_eq!(b.timeout_secs, Some(120));
2886        }
2887    }
2888
2889    #[test]
2890    fn rollup_override_is_honored() {
2891        let r = RollupConfig {
2892            abstractive_backend: Some(BackendConfig {
2893                provider: "ollama".into(),
2894                model: "qwen3:4b".into(),
2895                endpoint: Some("http://box.local:11434".into()),
2896                api_key_env: None,
2897                api_key_ref: None,
2898                timeout_secs: None,
2899            }),
2900            ..Default::default()
2901        };
2902        let b = r.effective_abstractive_backend(&omlx_llm());
2903        assert_eq!(b.provider, "ollama");
2904        assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2905    }
2906}
2907
2908#[cfg(test)]
2909mod skills_config_tests {
2910    use super::*;
2911
2912    #[test]
2913    fn empty_yaml_hydrates_defaults() {
2914        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2915        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2916        assert_eq!(cfg.skills.max_total_tokens, 2000);
2917        assert!(cfg.skills.adaptive.is_some());
2918    }
2919
2920    #[test]
2921    fn load_or_default_missing_file_returns_default() {
2922        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2923        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2924    }
2925
2926    #[test]
2927    fn dev_discipline_index_defaults_auto_and_parses() {
2928        use crate::config::DevDisciplineIndex;
2929        let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2930        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2931        let cfg: Config =
2932            serde_yaml_ng::from_str("skills:\n  dev_discipline_index: never\n").unwrap();
2933        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2934        let cfg: Config =
2935            serde_yaml_ng::from_str("skills:\n  dev_discipline_index: always\n").unwrap();
2936        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2937    }
2938}
2939
2940#[cfg(test)]
2941mod runs_config_tests {
2942    use super::*;
2943
2944    /// A zero `heartbeat_interval_secs` is legal YAML but illegal at runtime:
2945    /// it zeroes the stale threshold (every live run instantly reads STALLED)
2946    /// and `tokio::time::interval(Duration::ZERO)` panics in the executor's
2947    /// ticker. The loader must clamp zeroes to the defaults so one
2948    /// user-edited line can neither lie nor crash.
2949    #[test]
2950    fn zero_heartbeat_values_clamp_to_defaults_at_load() {
2951        let tmp = tempfile::tempdir().unwrap();
2952        let path = tmp.path().join("config.yaml");
2953        std::fs::write(
2954            &path,
2955            "runs:\n  heartbeat_interval_secs: 0\n  heartbeat_stale_after_intervals: 0\n",
2956        )
2957        .unwrap();
2958
2959        let cfg = Config::load_or_default(&path);
2960        assert_eq!(
2961            cfg.runs.heartbeat_interval_secs, 10,
2962            "a zero interval must load as the default, not 0"
2963        );
2964        assert_eq!(
2965            cfg.runs.heartbeat_stale_after_intervals, 3,
2966            "a zero interval count must load as the default, not 0"
2967        );
2968    }
2969
2970    /// The clamp must not rewrite legitimate tuning: positive values survive.
2971    #[test]
2972    fn positive_heartbeat_values_survive_the_load() {
2973        let tmp = tempfile::tempdir().unwrap();
2974        let path = tmp.path().join("config.yaml");
2975        std::fs::write(
2976            &path,
2977            "runs:\n  heartbeat_interval_secs: 60\n  heartbeat_stale_after_intervals: 2\n",
2978        )
2979        .unwrap();
2980
2981        let cfg = Config::load_or_default(&path);
2982        assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
2983        assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
2984    }
2985}
2986
2987#[cfg(test)]
2988mod ambient_capture_cfg_tests {
2989    use super::*;
2990
2991    #[test]
2992    fn session_and_harvest_defaults() {
2993        let cfg: Config = serde_yaml::from_str("{}").unwrap();
2994        assert_eq!(cfg.session.capture, "ambient");
2995        assert_eq!(cfg.session.retention_days, 14);
2996        assert!(cfg.harvest.auto_gate);
2997        assert_eq!(cfg.harvest.llm, "local-first");
2998        assert_eq!(cfg.harvest.min_events, 5);
2999        assert_eq!(cfg.harvest.min_user_turns, 2);
3000        assert_eq!(cfg.harvest.min_duration_secs, 120);
3001        assert_eq!(cfg.harvest.idle_minutes, 30);
3002        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
3003        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
3004        assert!(cfg.harvest.session_start_hint);
3005        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
3006    }
3007
3008    #[test]
3009    fn session_capture_override_parses() {
3010        let cfg: Config =
3011            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
3012        assert_eq!(cfg.session.capture, "off");
3013        assert_eq!(cfg.session.retention_days, 3);
3014    }
3015}
3016
3017#[cfg(test)]
3018mod cc_proxy_cfg_tests {
3019    use super::*;
3020
3021    #[test]
3022    fn defaults_to_local_cc_proxy_enabled() {
3023        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
3024        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
3025        assert!(cfg.cc_proxy.enabled);
3026    }
3027
3028    #[test]
3029    fn url_and_enabled_override_parse() {
3030        let cfg: Config =
3031            serde_yaml_ng::from_str("cc_proxy:\n  url: http://127.0.0.1:9999\n  enabled: false\n")
3032                .unwrap();
3033        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
3034        assert!(!cfg.cc_proxy.enabled);
3035    }
3036
3037    #[test]
3038    fn partial_section_keeps_other_default() {
3039        // Only `enabled` given → url stays at the default.
3040        let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n  enabled: false\n").unwrap();
3041        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
3042        assert!(!cfg.cc_proxy.enabled);
3043    }
3044}
3045
3046#[cfg(test)]
3047mod model_switch_config_tests {
3048    use super::*;
3049
3050    #[test]
3051    fn model_switch_config_defaults_and_omitted_block() {
3052        // Omitted `models:` block deserializes to defaults.
3053        let cfg: Config = serde_yaml::from_str("{}").unwrap();
3054        assert_eq!(cfg.models.default, None);
3055        assert!(cfg.models.fallback_chain.is_empty());
3056        assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
3057        assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
3058        assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
3059        assert!(!cfg.models.routing.enabled);
3060
3061        // A populated block round-trips.
3062        let yaml = "models:\n  default: claude_sonnet\n  fallback_chain: [claude_sonnet, deepseek_v4_pro]\n  routing:\n    enabled: true\n    cheap: deepseek_v4_flash\n    frontier: claude_opus\n    threshold_input_tokens: 1500\n";
3063        let cfg: Config = serde_yaml::from_str(yaml).unwrap();
3064        assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
3065        assert_eq!(
3066            cfg.models.fallback_chain,
3067            vec!["claude_sonnet", "deepseek_v4_pro"]
3068        );
3069        assert!(cfg.models.routing.enabled);
3070        assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
3071    }
3072
3073    #[test]
3074    fn smart_config_defaults_off_with_autopick() {
3075        let cfg: Config = serde_yaml::from_str("{}").unwrap();
3076        assert!(
3077            !cfg.models.smart.enabled,
3078            "Smart background routing is opt-in (capability-gate spec §2)"
3079        );
3080        assert_eq!(cfg.models.smart.cheap, None); // auto-pick
3081        assert_eq!(
3082            cfg.models.smart.max_escalations,
3083            DEFAULT_SMART_MAX_ESCALATIONS
3084        );
3085    }
3086
3087    #[test]
3088    fn smart_override_inherits_field_by_field() {
3089        let global = SmartConfig {
3090            enabled: true,
3091            cheap: Some("g".into()),
3092            max_escalations: 3,
3093        };
3094        assert_eq!(global.merged(None), global);
3095        assert_eq!(global.merged(Some(&SmartOverride::default())), global);
3096        // Overriding one field must not reset the others — the whole point.
3097        let only_cheap = SmartOverride {
3098            cheap: Some("a".into()),
3099            ..Default::default()
3100        };
3101        let m = global.merged(Some(&only_cheap));
3102        assert!(m.enabled, "overriding cheap must not disable Smart");
3103        assert_eq!(m.cheap.as_deref(), Some("a"));
3104        assert_eq!(m.max_escalations, 3);
3105        // An explicit false still beats a global true.
3106        let off = SmartOverride {
3107            enabled: Some(false),
3108            ..Default::default()
3109        };
3110        assert!(!global.merged(Some(&off)).enabled);
3111    }
3112
3113    #[test]
3114    fn routing_override_inherits_field_by_field() {
3115        let global = RoutingConfig {
3116            enabled: true,
3117            cheap: Some("c".into()),
3118            frontier: Some("f".into()),
3119            threshold_input_tokens: Some(9),
3120        };
3121        let only_cheap = RoutingOverride {
3122            cheap: Some("a".into()),
3123            ..Default::default()
3124        };
3125        let m = global.merged(Some(&only_cheap));
3126        assert!(m.enabled, "overriding cheap must not disable routing");
3127        assert_eq!(m.cheap.as_deref(), Some("a"));
3128        assert_eq!(m.frontier.as_deref(), Some("f"));
3129        assert_eq!(m.threshold_input_tokens, Some(9));
3130    }
3131}
3132
3133#[cfg(test)]
3134mod notifications_config_tests {
3135    use super::*;
3136
3137    #[test]
3138    fn notifications_default_to_log_only() {
3139        let c: Config = serde_yaml::from_str("{}").unwrap();
3140        assert!(!c.notifications.desktop, "desktop must be opt-in");
3141    }
3142
3143    #[test]
3144    fn an_existing_config_without_the_block_still_parses() {
3145        // Every user upgrading has a config.yaml with no `notifications:` key.
3146        let c: Config = serde_yaml::from_str("retrieval:\n  min_score: 0.42\n").unwrap();
3147        assert!(!c.notifications.desktop);
3148    }
3149
3150    /// The whole safety argument for the resolver rests on this one bit: an
3151    /// upgrade must never start letting the daemon talk to a model. Asserted
3152    /// from both an empty config and a realistic existing one, because the
3153    /// failure that matters is an upgrade, not a fresh install.
3154    #[test]
3155    fn the_monitor_resolver_is_off_until_a_user_turns_it_on() {
3156        let empty: Config = serde_yaml::from_str("{}").unwrap();
3157        assert!(
3158            !empty.monitor_resolver.enabled,
3159            "asking a model must be opt-in"
3160        );
3161        assert!(empty.monitor_resolver.model.is_none());
3162
3163        let upgraded: Config = serde_yaml::from_str(
3164            "retrieval:\n  min_score: 0.42\nnotifications:\n  desktop: true\n",
3165        )
3166        .unwrap();
3167        assert!(
3168            !upgraded.monitor_resolver.enabled,
3169            "a config written before this feature existed must not enable it"
3170        );
3171    }
3172
3173    #[test]
3174    fn the_monitor_resolver_reads_back_what_a_user_wrote() {
3175        let c: Config =
3176            serde_yaml::from_str("monitor_resolver:\n  enabled: true\n  model: claude_haiku\n")
3177                .unwrap();
3178        assert!(c.monitor_resolver.enabled);
3179        assert_eq!(c.monitor_resolver.model.as_deref(), Some("claude_haiku"));
3180    }
3181
3182    /// Triage must be inert for everyone who never asked for it — including
3183    /// every config file written before it existed.
3184    #[test]
3185    fn triage_is_off_and_non_binding_by_default() {
3186        let c: Config = serde_yaml::from_str("retrieval:\n  min_score: 0.42\n").unwrap();
3187        assert!(!c.triage.enabled, "triage must not run unasked");
3188        assert!(!c.triage.enforce);
3189        assert!(!c.triage.enforces());
3190    }
3191
3192    /// The trap this guards: `enforce: true` alone looks like it turned
3193    /// something on, but there is nothing running to enforce.
3194    #[test]
3195    fn enforce_without_enabled_binds_nothing() {
3196        let c: Config = serde_yaml::from_str("triage:\n  enforce: true\n").unwrap();
3197        assert!(c.triage.enforce, "the user's word is kept verbatim");
3198        assert!(
3199            !c.triage.enforces(),
3200            "but it binds nothing while triage does not run"
3201        );
3202    }
3203
3204    #[test]
3205    fn triage_binds_only_when_both_knobs_are_set() {
3206        let c: Config =
3207            serde_yaml::from_str("triage:\n  enabled: true\n  enforce: true\n").unwrap();
3208        assert!(c.triage.enforces());
3209    }
3210
3211    /// Enabled without enforce is shadow mode: it runs, it records, it never
3212    /// stops anything.
3213    #[test]
3214    fn enabled_alone_is_shadow_mode() {
3215        let c: Config = serde_yaml::from_str("triage:\n  enabled: true\n").unwrap();
3216        assert!(c.triage.enabled);
3217        assert!(!c.triage.enforces());
3218    }
3219}