Skip to main content

newt_core/
config.rs

1//! Configuration loading for Newt-Agent.
2//!
3//! Base resolution order: `$NEWT_CONFIG` env var, then `./newt.toml`,
4//! `$NEWT_CONFIG_DIR/config.toml` (or `~/.newt/config.toml`), then
5//! `/etc/newt/config.toml`. If none exist the built-in defaults are used
6//! (a single Ollama backend on localhost).
7//!
8//! A project-local `.newt/config.toml` (found by walking up from the current
9//! directory) is then deep-merged **over** that base, so a git repo can pin its
10//! own models, endpoints, rules, and local stdio MCP services without copying
11//! the whole global config. See [`Config::resolve`] and issue #222.
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{NewtError, Result};
18use crate::router::Tier;
19
20/// Process-scoped user config root override, set by the CLI's `--config-dir`.
21pub const NEWT_CONFIG_DIR_ENV: &str = "NEWT_CONFIG_DIR";
22
23// ---------------------------------------------------------------------------
24// Config types
25// ---------------------------------------------------------------------------
26
27/// `[scratch]` — the ephemeral-state location (#844). See [`Config::scratch`].
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct ScratchConfig {
31    /// The scratch dir: relative (under the repo, default `.scratch`) or absolute
32    /// (`/tmp`, a PVC mount) for a read-only checkout. `NEWT_SCRATCH_DIR` wins.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub dir: Option<String>,
35}
36
37/// Top-level Newt-Agent configuration.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct Config {
41    /// Inference backends (Ollama, vLLM, etc.).
42    ///
43    /// Absent `[[backends]]` deserializes to **empty** (not the struct-level
44    /// default's localhost fallback) so a config that defines its backends as
45    /// per-file `~/.newt/backends/*.toml` drop-ins does NOT also pick up a
46    /// spurious synthesized `ollama` entry. The localhost fallback is restored
47    /// in [`Config::resolve`] only if backends are still empty after the disk
48    /// merge (so a truly bare setup still talks to a local Ollama).
49    #[serde(default = "Vec::new")]
50    pub backends: Vec<BackendConfig>,
51
52    /// External provider-plugin definitions.
53    pub providers: Vec<ProviderConfig>,
54
55    /// `[scratch]` — where ephemeral state (crew worktrees, the crew cargo
56    /// target, per-session plans) lives (#844). `dir` may be relative (under the
57    /// repo, default `.scratch`) or absolute (`/tmp`, a k8s PVC mount) for
58    /// read-only checkouts. `NEWT_SCRATCH_DIR` overrides it. Applied in
59    /// [`Config::resolve`] via [`crate::scratch::set_scratch_dir`].
60    #[serde(default)]
61    pub scratch: Option<ScratchConfig>,
62
63    /// Default tier ordering used by the router when no per-backend
64    /// override is specified.
65    pub default_tier_order: Vec<Tier>,
66
67    /// `[lifecycle]` — the repo's build/dev commands per lifecycle phase
68    /// (`format`, `check`, `clean`, …), #880. Overrides the per-ecosystem tooling
69    /// packs. Applied in [`Config::resolve`] via
70    /// [`crate::tooling::set_lifecycle_override`].
71    #[serde(default)]
72    pub lifecycle: Option<crate::tooling::PhaseCommands>,
73
74    /// Optional NVIDIA DGX endpoint-management config powering the
75    /// `newt dgx` command suite. `None` when unconfigured — newt never
76    /// dials a DGX endpoint unless this (or a `NEWT_DGX_*` env var) is set.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub dgx: Option<crate::dgx::DgxConfig>,
79
80    /// TUI appearance and behaviour. `None` → built-in defaults apply.
81    /// Overridable at runtime via `NEWT_CHAT_STYLE` and `NEWT_PROMPT`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub tui: Option<TuiConfig>,
84
85    /// `[shell]` — which engine runs `run_command` (ADR 0005 D2 seam). `None` /
86    /// unset → the `safe-subset` default, except `--full-access` auto-upgrades to
87    /// `host`. Overridable per-session by `--shell-engine`. The L3 backend
88    /// (Landlock/Seatbelt/AppContainer) is a separate, auto-selected axis.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub shell: Option<ShellConfig>,
91
92    /// `[context]` — context-management strategy selection (Step 24.8, #559).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub context: Option<ContextConfig>,
95
96    /// `[tools]` — tool-execution behaviour (#726). `None` → built-in defaults
97    /// (notably `max_output_tokens` = 10000). See [`ToolsConfig`].
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub tools: Option<ToolsConfig>,
100
101    /// Inference cost modeling. `None` → built-in rate table only.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub pricing: Option<crate::pricing::PricingConfig>,
104
105    /// Memory / context-window management. `None` → RollingWindow(20).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub memory: Option<MemoryConfig>,
108
109    /// Project-instruction loading (`AGENTS.md` / `CLAUDE.md`) into the system
110    /// prompt. Enabled by default. Overridable via `--agents-file` /
111    /// `--no-agents-file`.
112    #[serde(default)]
113    pub agents: AgentsConfig,
114
115    /// newt-native MCP servers (`[[mcp_servers]]`). Merged with the servers
116    /// discovered from Claude Code's config by [`crate::mcp::discover`]; these
117    /// take precedence on a name clash. Empty by default.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub mcp_servers: Vec<crate::mcp::McpServerEntry>,
120
121    /// Usage-log rotation policy. `None` → built-in defaults apply
122    /// (keep last 7 sessions, no size/age limit).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub logs: Option<LogConfig>,
125
126    /// Skill discovery search path — the ordered list of directories newt
127    /// reads `SKILL.md` folders from. `None` → just `~/.newt/skills`.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub skills: Option<SkillsConfig>,
130
131    /// Per-model inference tuning overrides (`[[model_tuning]]`).
132    ///
133    /// Each entry locks specific parameters for a named model. Values here
134    /// take precedence over empirically derived values from
135    /// `model-capabilities.json` and over global `[tui]` defaults.
136    ///
137    /// Example `~/.newt/config.toml`:
138    /// ```toml
139    /// [[model_tuning]]
140    /// model = "nemotron3:33b"
141    /// num_ctx = 24576            # explicit Ollama context window
142    /// mid_loop_trim_threshold = 12
143    /// max_tool_rounds = 20
144    /// ```
145    ///
146    /// Human-authored entries are never overwritten by the auto-tuner.
147    /// Auto-tuned entries are **appended** by the harness when
148    /// `tune_confidence` reaches `High`; delete or edit them freely.
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub model_tuning: Vec<ModelTuning>,
151
152    /// Durable conversation save/restore policy. `None` uses built-in defaults.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub conversations: Option<ConversationsConfig>,
155
156    /// How a project-local `.newt/config.toml` is layered over the global
157    /// config (issue #222). `None` → built-in default (arrays replace).
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub merge: Option<MergeConfig>,
160
161    /// Named permission presets (`[permission_presets.<name>]`, issue #307).
162    /// Each maps onto the role-profile caveat mechanism (a
163    /// [`crate::NamedPermissionPreset`]) and, when applied via `/mode`, clamps
164    /// the session's authority as a hard floor. Empty by default — no preset,
165    /// behavior unchanged.
166    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
167    pub permission_presets: std::collections::BTreeMap<String, crate::NamedPermissionPreset>,
168
169    /// Named modes (`[modes.<name>]`, issue #307) for the `/mode` command. Each
170    /// mode atomically binds a skill body to preload, a permission preset to
171    /// apply as an authority floor, and a one-line system-prompt framing. Empty
172    /// by default. See [`ModeConfig`].
173    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
174    pub modes: std::collections::BTreeMap<String, ModeConfig>,
175
176    /// Named profiles (`[profiles.<name>]`) — a composition of harness
177    /// *techniques* plus each technique's tunable knob settings (the technique
178    /// library, `docs/design/technique-library.md`). A profile is selected by
179    /// `--profile <name>` and tunes the harness per model family / context.
180    /// Empty by default — no profile, behavior unchanged. See [`ProfileConfig`].
181    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
182    pub profiles: std::collections::BTreeMap<String, ProfileConfig>,
183
184    /// Named bundles (`[bundles.<name>]`) — the loadable unit of the model support
185    /// kit (`docs/design/model-support-kit.md`). A bundle pins which model families
186    /// it applies to and which profile each resolves to. Selected by `--bundle
187    /// <name>` or inferred from the model via `applies_to`. Empty by default — no
188    /// bundle, behavior unchanged. See [`BundleConfig`].
189    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
190    pub bundles: std::collections::BTreeMap<String, BundleConfig>,
191
192    /// Named loadouts (`[loadouts.<name>]` or `~/.newt/loadouts/<name>.toml`) — the
193    /// top-level composition of `provider → model → kit → role → settings`
194    /// (`docs/design/loadout-composition.md`). Inert until the resolver is wired
195    /// (Slice 1): this carries the data model + reference validation + `/loadout`
196    /// show. Empty by default. See [`Loadout`].
197    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
198    pub loadouts: std::collections::BTreeMap<String, Loadout>,
199
200    /// Named crews (`[crews.<name>]` or `crews/<name>.toml`) — role-specialized
201    /// ensembles over the backend pool (`docs/design/crew-loadout.md`). Each role
202    /// names a `[loadouts.<name>]`. Empty by default. See [`Crew`].
203    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
204    pub crews: std::collections::BTreeMap<String, Crew>,
205
206    /// `[crew]` — crew/team **dispatch policy** (#749). Carries the authority
207    /// *clamp* every dispatched crew is met against, so a crew's effective
208    /// authority is `session ⊓ clamp` — never above the session ceiling, and as
209    /// tight as the operator configures. `None` (and the default clamp) is
210    /// `Caveats::top()`, i.e. the meet is the identity and behavior is unchanged.
211    /// This is the structural tightening point the per-subtask `team_clamp`
212    /// (#749 step 8) plugs into. See [`CrewPolicyConfig`].
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub crew: Option<CrewPolicyConfig>,
215
216    /// `[plan]` — plan-authoring policy. Today: the `[plan.prune]` droppable
217    /// override for the decompose prune's anti-pattern lexicon (#801/#803 →
218    /// #819). `None` = compiled defaults, behavior unchanged. See
219    /// [`PlanPruneConfig`].
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub plan: Option<PlanConfig>,
222}
223
224/// One named mode (`[modes.<name>]`, issue #307): the atomic binding the
225/// `/mode <name>` command applies in a single invocation.
226///
227/// ```toml
228/// [modes.triage]
229/// skill   = "oncall-triage"        # skill body to preload (use_skill path)
230/// preset  = "readonly-triage"      # [permission_presets.<name>] to clamp to
231/// framing = "On-call triage: investigate, do not change production."
232/// ```
233///
234/// Every field is optional so a mode can do any subset (e.g. preset-only, or
235/// framing-only). A `skill`/`preset` that names a missing entry is reported as
236/// an error by the command rather than silently ignored — a mode that claims a
237/// clamp it never applied would be a false security claim.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
239pub struct ModeConfig {
240    /// Skill name to preload (the same `use_skill` / `load_body_from` path).
241    /// `None` ⇒ no skill is loaded.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub skill: Option<String>,
244    /// `[permission_presets.<name>]` to apply as the session authority floor.
245    /// `None` ⇒ authority unchanged.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub preset: Option<String>,
248    /// One-line framing injected into the system prompt. `None` ⇒ no framing.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub framing: Option<String>,
251}
252
253/// The known harness techniques a profile may compose — the registry the
254/// validator checks against. A profile naming a technique outside this set is
255/// rejected (an unknown technique a profile claims but cannot apply would be a
256/// false claim). Extend this as techniques land (R3 `fact_preserving_compression`,
257/// R4 `self_grounding`, …).
258pub const KNOWN_TECHNIQUES: &[&str] = &[
259    "knowledge_base", // R1 — inject the authoritative import surface (#74)
260    "verify_gate",    // R2 — revert files with fabricated imports (#73)
261    "retry",          // revert-retry loop over the gate's revert set
262];
263
264/// One named profile (`[profiles.<name>]`): the harness techniques to compose for
265/// a model family / context, plus each technique's knob settings.
266///
267/// ```toml
268/// [profiles.nemotron]
269/// techniques = ["knowledge_base", "verify_gate", "retry"]
270///
271/// [profiles.nemotron.verify_gate]
272/// surface_match = "exact"        # SurfaceMatch — leaf-exact (the complete-gate default)
273///
274/// [profiles.nemotron.retry]
275/// max_retries = 2
276/// ```
277///
278/// A knob table only takes effect when its technique is enabled. An unknown
279/// technique name is an error ([`ProfileConfig::validate`]).
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
281pub struct ProfileConfig {
282    /// The ordered set of techniques this profile composes. Empty ⇒ the profile
283    /// applies no techniques (equivalent to the `default`/light profile).
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub techniques: Vec<String>,
286    /// Knobs for the `verify_gate` technique (applied iff it is enabled).
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub verify_gate: Option<VerifyGateKnobs>,
289    /// Knobs for the `retry` technique (applied iff it is enabled).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub retry: Option<RetryKnobs>,
292}
293
294/// Tunable knobs for the `verify_gate` technique.
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
296pub struct VerifyGateKnobs {
297    /// How strictly the project surface is matched. Default `Exact` — the
298    /// adversarially-complete setting (the retry-Goodhart finding).
299    #[serde(default)]
300    pub surface_match: crate::verify_gate::SurfaceMatch,
301    /// How strictly the gate ACTS on flagged output — the tier. Default
302    /// `RevertRetry` (today's behavior when the `retry` technique is on); lower
303    /// tiers (`off`/`advisory`/`revert_once`) trade enforcement for latitude.
304    #[serde(default)]
305    pub tier: crate::verify_gate::VerifyTier,
306}
307
308/// Tunable knobs for the `retry` technique.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310pub struct RetryKnobs {
311    /// Maximum revert-retry attempts. Default 2.
312    #[serde(default = "default_max_retries")]
313    pub max_retries: u32,
314}
315
316const fn default_max_retries() -> u32 {
317    2
318}
319
320impl Default for RetryKnobs {
321    fn default() -> Self {
322        Self {
323            max_retries: default_max_retries(),
324        }
325    }
326}
327
328impl ProfileConfig {
329    /// Validate the profile against the [component registry](crate::kit): every
330    /// named technique must be a known component, and every component's
331    /// `presupposes` must also be enabled (e.g. `retry` presupposes `verify_gate`).
332    /// A presupposition gap is a **load-time** error, not a silent partial apply.
333    ///
334    /// # Errors
335    /// Returns the first unknown-technique or unmet-presupposition as a message.
336    pub fn validate(&self) -> std::result::Result<(), String> {
337        for t in &self.techniques {
338            let Some(entry) = crate::kit::component(t) else {
339                return Err(format!(
340                    "unknown technique '{t}' in profile (known: {})",
341                    KNOWN_TECHNIQUES.join(", ")
342                ));
343            };
344            for pre in entry.presupposes {
345                if !self.techniques.iter().any(|x| x == pre) {
346                    return Err(format!(
347                        "technique '{t}' presupposes '{pre}', which the profile does not enable"
348                    ));
349                }
350            }
351        }
352        Ok(())
353    }
354
355    /// Whether this profile enables `technique`.
356    #[must_use]
357    pub fn enables(&self, technique: &str) -> bool {
358        self.techniques.iter().any(|t| t == technique)
359    }
360
361    /// The effective `verify_gate` knobs (defaults when unset).
362    #[must_use]
363    pub fn verify_gate_knobs(&self) -> VerifyGateKnobs {
364        self.verify_gate.unwrap_or_default()
365    }
366
367    /// The effective `retry` knobs (defaults when unset).
368    #[must_use]
369    pub fn retry_knobs(&self) -> RetryKnobs {
370        self.retry.unwrap_or_default()
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Project-local config layering (issue #222)
376// ---------------------------------------------------------------------------
377
378/// How arrays (`[[backends]]`, `[[providers]]`, `[[mcp_servers]]`,
379/// `[[model_tuning]]`) are combined when a project-local `.newt/config.toml`
380/// is layered over the global config.
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum ArrayMergeStrategy {
384    /// The project array replaces the global array wholesale. Predictable and
385    /// safe — the project fully owns that list. **Default.**
386    #[default]
387    Replace,
388    /// The project array is appended to the global array (global entries first,
389    /// then the project's). Additive — e.g. register an extra local stdio MCP
390    /// server without redefining the global ones.
391    Append,
392}
393
394/// Controls how a project-local `.newt/config.toml` is merged over the global
395/// config. Tables always merge recursively (project keys win); this only
396/// governs array handling. See issue #222.
397///
398/// Example project `.newt/config.toml`:
399/// ```toml
400/// [merge]
401/// arrays = "append"     # add to the global lists instead of replacing them
402///
403/// [[mcp_servers]]
404/// name = "project-fs"
405/// command = "mcp-fs"
406/// args = ["--root", "."]
407/// ```
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(default)]
410pub struct MergeConfig {
411    /// Array-combination strategy. Default: [`ArrayMergeStrategy::Replace`].
412    #[serde(default)]
413    pub arrays: ArrayMergeStrategy,
414}
415
416// ---------------------------------------------------------------------------
417// Durable conversation config
418// ---------------------------------------------------------------------------
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(default)]
422pub struct ConversationsConfig {
423    /// Maximum saved conversations per workspace. Default: 100. 0 = no pruning.
424    #[serde(default = "default_conversations_max_per_workspace")]
425    pub max_per_workspace: usize,
426
427    /// Auto-resume this workspace's most recently active conversation at TUI
428    /// session start (Step 17.7, issue #246). "Most recently active" means
429    /// the highest §6 activity tick — never a wall-clock comparison.
430    ///
431    /// **Default: true.** The off-switch:
432    ///
433    /// ```toml
434    /// [conversations]
435    /// resume = false      # always start fresh
436    /// ```
437    ///
438    /// Per-session overrides win over this key either way: `--ephemeral`
439    /// (no persistence at all) and `NEWT_CONVERSATION_ID=<id>` (resume
440    /// exactly that conversation).
441    #[serde(default = "default_conversations_resume")]
442    pub resume: bool,
443}
444
445fn default_conversations_max_per_workspace() -> usize {
446    100
447}
448
449fn default_conversations_resume() -> bool {
450    true
451}
452
453impl Default for ConversationsConfig {
454    fn default() -> Self {
455        Self {
456            max_per_workspace: default_conversations_max_per_workspace(),
457            resume: default_conversations_resume(),
458        }
459    }
460}
461
462// ---------------------------------------------------------------------------
463// Skill search path
464// ---------------------------------------------------------------------------
465
466/// The skill discovery **search path**: an ordered list of directories newt
467/// scans for agentskills.io-format `SKILL.md` folders.
468///
469/// A skill is the same folder in every harness, so cross-harness use is just a
470/// matter of *pointing newt at the directories* — list `~/.claude/skills`,
471/// `~/.codex/skills`, a project-local `.skills/`, whatever — and their skills
472/// become visible with no copying. The list is open-ended on purpose: there is
473/// no hard-coded knowledge of any particular harness. Earlier entries win on a
474/// name collision.
475///
476/// Example `~/.newt/config.toml`:
477/// ```toml
478/// [skills]
479/// search = ["~/.newt/skills", "~/.claude/skills", "~/.codex/skills"]
480/// ```
481#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(default)]
483pub struct SkillsConfig {
484    /// Ordered directories to scan for skills. Empty → `~/.newt/skills`.
485    /// `~/` is expanded to `$HOME`.
486    #[serde(default, skip_serializing_if = "Vec::is_empty")]
487    pub search: Vec<String>,
488}
489
490// ---------------------------------------------------------------------------
491// Log rotation config
492// ---------------------------------------------------------------------------
493
494/// Rotation policy for `~/.newt/usage.jsonl`.
495///
496/// All limits default to the values shown. Set a field to `0` to disable
497/// that particular limit. Multiple active limits compose — the most
498/// restrictive one wins after each append.
499///
500/// Example `newt.toml`:
501/// ```toml
502/// [logs]
503/// max_sessions = 100   # keep the last 100 turns
504/// max_size_mb  = 5     # also cap at 5 MiB
505/// max_age_days = 14    # and drop anything older than 2 weeks
506/// keep_rotated = 2     # keep usage.jsonl.1 and .2 as backup
507/// ```
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(default)]
510pub struct LogConfig {
511    /// Keep at most this many JSONL entries (most recent). Default: 7. 0 = no limit.
512    #[serde(default = "default_log_max_sessions")]
513    pub max_sessions: usize,
514
515    /// Rotate when the file exceeds this size in MiB. Default: 0 (no size limit).
516    #[serde(default)]
517    pub max_size_mb: u64,
518
519    /// Drop entries older than this many days. Default: 0 (no age limit).
520    /// Requires a `recorded_at` field in the log entry; entries without it
521    /// are kept.
522    #[serde(default)]
523    pub max_age_days: u64,
524
525    /// How many rotated copies to keep alongside the live log
526    /// (`usage.jsonl.1`, `.2`, …). Default: 3. 0 = overwrite silently.
527    #[serde(default = "default_log_keep_rotated")]
528    pub keep_rotated: usize,
529}
530
531fn default_log_max_sessions() -> usize {
532    7
533}
534
535fn default_log_keep_rotated() -> usize {
536    3
537}
538
539impl Default for LogConfig {
540    fn default() -> Self {
541        Self {
542            max_sessions: default_log_max_sessions(),
543            max_size_mb: 0,
544            max_age_days: 0,
545            keep_rotated: default_log_keep_rotated(),
546        }
547    }
548}
549
550// ---------------------------------------------------------------------------
551// Memory config
552// ---------------------------------------------------------------------------
553
554/// Memory management stored under `[memory]` in `newt.toml`.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(default)]
557pub struct MemoryConfig {
558    /// Which memory provider to activate.
559    #[serde(default)]
560    pub provider: MemoryProviderKind,
561    /// Turns retained by `RollingWindow`. Default: 20.
562    #[serde(default = "default_memory_window")]
563    pub window: usize,
564    /// Explicit context-token budget for `TokenBudget` / `Summarizing` — a
565    /// deliberate user override that wins over everything else (Step 18.2,
566    /// #247). When unset, the budget derives from the empirical capability
567    /// cache (`max_ok_input` else `safe_context` in
568    /// `model-capabilities.json`); the static default
569    /// (`DEFAULT_CONTEXT_TOKENS`, 8,192) applies only when neither exists.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub context_tokens: Option<u32>,
572
573    /// Explicit path to a soul file (overrides workspace + global resolution).
574    /// Default: auto-resolve from `.newt/soul.md` → `~/.newt/soul.md`.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub soul_file: Option<String>,
577
578    /// User turns without an organic `save_note` call before the in-band
579    /// memory nudge is appended to the next user message (Step 19.3, #248).
580    /// `0` disables the nudge. Default: 10.
581    #[serde(default = "default_note_nudge_interval")]
582    pub note_nudge_interval: usize,
583
584    /// End-of-conversation note extraction (Step 19.4, #248): when `true`,
585    /// closing a conversation (`/new` or a clean exit) runs ONE synchronous
586    /// tools-disabled completion that distills at most 3 durable facts into
587    /// NOTES.md through the scanned `save_note` write path. Default: `false`
588    /// — the pass is optional and costs one completion per close.
589    #[serde(default)]
590    pub extract_notes_on_close: bool,
591
592    /// How memory is disclosed to the model (progressive-disclosure memory,
593    /// Workstream A MVP, #319). `Frozen` (the default) is today's behavior
594    /// exactly: NOTES are frozen verbatim into the system prompt and the
595    /// `memory_fetch` tool is not wired. `Index` opts in to the budgeted
596    /// memory INDEX (note titles/ids instead of full bodies) plus the
597    /// `memory_fetch` tool that pulls a body on demand. This is a context-cost
598    /// facet, never an authorization knob.
599    #[serde(default)]
600    pub disclosure: MemoryDisclosure,
601}
602
603/// Memory disclosure mode — the `[memory] disclosure` key (#319).
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
605#[serde(rename_all = "snake_case")]
606pub enum MemoryDisclosure {
607    /// Today's behavior: NOTES frozen verbatim into the system prompt, no
608    /// `memory_fetch` tool. The MVP default — inert unless opted in.
609    #[default]
610    Frozen,
611    /// Progressive disclosure: a budgeted memory INDEX in the prompt plus the
612    /// `memory_fetch` tool to pull bodies on demand.
613    Index,
614}
615
616/// Prompt richness — the `[tui] footer` key. Selects the *default* prompt
617/// template when `[tui] prompt` is unset; an explicit `[tui] prompt` always
618/// wins. The rich default folds a timestamp + status into the prompt line
619/// itself (`[<ts> · <model> · <ws> · <mode> ] ❯ `), so the input surface floats
620/// it at the bottom while idle (like cargo's progress line) and it doubles as a
621/// greppable per-turn log marker — no region, no cursor games.
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
623#[serde(rename_all = "snake_case")]
624pub enum FooterMode {
625    /// Rich default prompt on a TTY, plain `\w $ ` otherwise (the default).
626    /// The amphibious choice: decorated on a human terminal, bare in pipes /
627    /// `newt worker` / the wyvern deep-cut.
628    #[default]
629    Auto,
630    /// Always use the rich default prompt (even off a TTY — screenshots, tests).
631    On,
632    /// Always use the plain bare prompt. Equivalent to `--plain`.
633    Off,
634}
635
636/// Color / theme mode — the `[tui] color` key and the `--color` CLI flag
637/// (issue #527). Selects whether — and eventually how — ANSI color is emitted
638/// for the interactive prompt and chat surface. The default is `auto`: color on
639/// a TTY, none in pipes / under `NO_COLOR` / `TERM=dumb`.
640///
641/// `dark`/`light`/`inverted`/`minimal` are accepted and parse today; their
642/// palettes are initial mappings (currently the chromatic default) tuned in a
643/// later pass. The terminal-aware *resolution* lives in the TUI layer — newt-core
644/// has no business probing the terminal — so this enum only exposes the pure
645/// pieces ([`from_keyword`](Self::from_keyword) / [`keyword`](Self::keyword) /
646/// [`forced`](Self::forced) / [`is_mono`](Self::is_mono)).
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
648#[serde(rename_all = "lowercase")]
649pub enum ColorMode {
650    /// Color on a TTY; none off one or under `NO_COLOR`/`TERM=dumb` (default).
651    #[default]
652    Auto,
653    /// Always emit color — even off a TTY (screenshots, captured logs). An
654    /// explicit `--color=always` also overrides `NO_COLOR` (documented deviation).
655    Always,
656    /// Never emit color.
657    Never,
658    /// Reduced color: structure only, no bright accents. (Initial mapping:
659    /// chromatic; tuned later.)
660    Minimal,
661    /// Swapped foreground/background accents for high-contrast terminals.
662    /// (Initial mapping: chromatic; tuned later.)
663    Inverted,
664    /// Palette tuned for a dark background — the current chromatic default.
665    Dark,
666    /// Palette tuned for a light background. (Initial mapping: chromatic; tuned later.)
667    Light,
668    /// Force monochrome — no color, ASCII glyph fallbacks. Equivalent to `--mono`.
669    Mono,
670}
671
672impl ColorMode {
673    /// Parse a CLI/config keyword (case-insensitive) into a mode. `on`/`off` are
674    /// accepted as aliases of `always`/`never`; `monochrome` aliases `mono`.
675    pub fn from_keyword(s: &str) -> Option<Self> {
676        match s.trim().to_ascii_lowercase().as_str() {
677            "auto" => Some(Self::Auto),
678            "always" | "on" => Some(Self::Always),
679            "never" | "off" => Some(Self::Never),
680            "minimal" => Some(Self::Minimal),
681            "inverted" => Some(Self::Inverted),
682            "dark" => Some(Self::Dark),
683            "light" => Some(Self::Light),
684            "mono" | "monochrome" => Some(Self::Mono),
685            _ => None,
686        }
687    }
688
689    /// The canonical lowercase keyword for this mode (round-trips `from_keyword`
690    /// and matches the serde representation).
691    pub fn keyword(self) -> &'static str {
692        match self {
693            Self::Auto => "auto",
694            Self::Always => "always",
695            Self::Never => "never",
696            Self::Minimal => "minimal",
697            Self::Inverted => "inverted",
698            Self::Dark => "dark",
699            Self::Light => "light",
700            Self::Mono => "mono",
701        }
702    }
703
704    /// Whether this mode forces a color decision regardless of the terminal:
705    /// `Some(true)` = force color on, `Some(false)` = force off, `None` = defer
706    /// to terminal detection (`Auto`).
707    pub fn forced(self) -> Option<bool> {
708        match self {
709            Self::Always | Self::Minimal | Self::Inverted | Self::Dark | Self::Light => Some(true),
710            Self::Never | Self::Mono => Some(false),
711            Self::Auto => None,
712        }
713    }
714
715    /// Whether color is fully disabled in monochrome form. `Mono` additionally
716    /// signals ASCII-glyph fallbacks (`>` for `❯`) to callers; `Never` just
717    /// drops color.
718    pub fn is_mono(self) -> bool {
719        matches!(self, Self::Mono)
720    }
721}
722
723/// Markdown rendering mode — the `[tui] markdown` key and the `/markdown`
724/// command (Step 25.4, #568). `Auto` renders Markdown whenever color is active;
725/// `On`/`Off` force the choice (`On` still needs color to emit ANSI). The
726/// effective decision is `mode.forced().unwrap_or(color_on) && color_on`.
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
728#[serde(rename_all = "lowercase")]
729pub enum MarkdownMode {
730    /// Render Markdown whenever color is active (default).
731    #[default]
732    Auto,
733    /// Force Markdown rendering on (still gated by color support).
734    On,
735    /// Disable Markdown rendering — stream raw text.
736    Off,
737}
738
739impl MarkdownMode {
740    /// Parse a CLI/config/command keyword (case-insensitive). `always`/`never`
741    /// alias `on`/`off`.
742    pub fn from_keyword(s: &str) -> Option<Self> {
743        match s.trim().to_ascii_lowercase().as_str() {
744            "auto" => Some(Self::Auto),
745            "on" | "always" => Some(Self::On),
746            "off" | "never" => Some(Self::Off),
747            _ => None,
748        }
749    }
750
751    /// The canonical lowercase keyword (round-trips `from_keyword` + serde).
752    pub fn keyword(self) -> &'static str {
753        match self {
754            Self::Auto => "auto",
755            Self::On => "on",
756            Self::Off => "off",
757        }
758    }
759
760    /// `Some(true)`/`Some(false)` force the decision; `None` (`Auto`) defers to
761    /// color detection.
762    pub fn forced(self) -> Option<bool> {
763        match self {
764            Self::On => Some(true),
765            Self::Off => Some(false),
766            Self::Auto => None,
767        }
768    }
769}
770
771/// Context-management strategy — the `[context] manager` key and the
772/// `/context manager <name>` command (Step 24.8, #559). `standard` is the
773/// current prune → summary → static-marker pipeline. `progressive` and
774/// `distributed` are the retrievable-card managers **owned by #546** and not
775/// yet available — selecting them reports that and stays on `standard`.
776#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
777#[serde(rename_all = "lowercase")]
778pub enum ContextManager {
779    /// Prune → summarize → static-marker (today's behavior). The only one
780    /// implemented; the selector seam for the others.
781    #[default]
782    Standard,
783    /// Leave a lookup marker; retrieve cards on demand (ephemeral → local DB).
784    /// Owned by #546 — not yet available.
785    Progressive,
786    /// Agent-mesh-shared card store across a swarm. Owned by #546 — not yet
787    /// available.
788    Distributed,
789}
790
791impl ContextManager {
792    /// Parse a CLI/config/command keyword (case-insensitive).
793    pub fn from_keyword(s: &str) -> Option<Self> {
794        match s.trim().to_ascii_lowercase().as_str() {
795            "standard" => Some(Self::Standard),
796            "progressive" => Some(Self::Progressive),
797            "distributed" => Some(Self::Distributed),
798            _ => None,
799        }
800    }
801
802    /// The canonical lowercase keyword (round-trips `from_keyword` + serde).
803    pub fn keyword(self) -> &'static str {
804        match self {
805            Self::Standard => "standard",
806            Self::Progressive => "progressive",
807            Self::Distributed => "distributed",
808        }
809    }
810
811    /// Whether this manager is implemented. Only `standard` today; the others
812    /// are owned by #546 (the selector reports "not yet available").
813    pub fn available(self) -> bool {
814        matches!(self, Self::Standard)
815    }
816
817    /// The default feature bundle this preset turns on (Phase 26, #588). A
818    /// preset is a named bundle of composable [`ContextFeature`]s; config and
819    /// `/context feature` overrides layer on top (see [`ContextFeatures`]).
820    /// Every preset currently resolves to the all-off baseline (today's
821    /// `standard` behavior) because no composable feature is implemented yet;
822    /// presets gain features as 26.3–26.6 land.
823    pub fn base_features(self) -> ContextFeatureSet {
824        ContextFeatureSet::default()
825    }
826}
827
828/// A composable context-management feature (Phase 26, #588) — an independent
829/// on/off technique under `[context.features]` and the `/context feature <name>
830/// on|off` command. None are implemented yet (`available()` is false for all);
831/// they land in 26.3–26.6 and report "not yet available" until then (same
832/// pattern as the `ContextManager` presets under #546).
833#[derive(Debug, Clone, Copy, PartialEq, Eq)]
834pub enum ContextFeature {
835    /// Cap oversized tool outputs; spill the full payload to a re-readable store (#584).
836    ToolOffload,
837    /// Structured `<state>` store mutated via tools, kept out of the log (#583).
838    Scratchpad,
839    /// Structure-aligned retrieval of repo evidence (#582).
840    Semantic,
841    /// Provenance-preserving compaction with retrievable handles (#584).
842    Provenance,
843    /// Write-gated cross-task experience memory (#585).
844    Experiential,
845    /// Per-step compiled context view instead of a rolling buffer (#586).
846    Scheduled,
847}
848
849impl ContextFeature {
850    /// Every feature, in display order.
851    pub const ALL: [Self; 6] = [
852        Self::ToolOffload,
853        Self::Scratchpad,
854        Self::Semantic,
855        Self::Provenance,
856        Self::Experiential,
857        Self::Scheduled,
858    ];
859
860    /// Parse a keyword (case-insensitive; `-`/`_` interchangeable; short aliases).
861    pub fn from_keyword(s: &str) -> Option<Self> {
862        match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
863            "tool_offload" | "tooloffload" | "offload" => Some(Self::ToolOffload),
864            "scratchpad" | "state" => Some(Self::Scratchpad),
865            "semantic" | "retrieval" => Some(Self::Semantic),
866            "provenance" | "handles" => Some(Self::Provenance),
867            "experiential" | "experience" => Some(Self::Experiential),
868            "scheduled" | "compiled" => Some(Self::Scheduled),
869            _ => None,
870        }
871    }
872
873    /// The canonical lowercase keyword (matches the `[context.features]` key).
874    pub fn keyword(self) -> &'static str {
875        match self {
876            Self::ToolOffload => "tool_offload",
877            Self::Scratchpad => "scratchpad",
878            Self::Semantic => "semantic",
879            Self::Provenance => "provenance",
880            Self::Experiential => "experiential",
881            Self::Scheduled => "scheduled",
882        }
883    }
884
885    /// Whether this feature is implemented yet. Flips true per feature as it
886    /// lands (26.3–26.6). `tool_offload` 26.3 (#584); `scratchpad` 26.4 (#583);
887    /// `semantic` 26.5 (#582); `experiential` 26.6a (#585); `scheduled` 26.6b
888    /// (#586). Only `provenance` (#584, a later compaction-handle feature) is
889    /// still pending.
890    pub fn available(self) -> bool {
891        match self {
892            Self::ToolOffload
893            | Self::Scratchpad
894            | Self::Semantic
895            | Self::Experiential
896            | Self::Scheduled => true,
897            Self::Provenance => false,
898        }
899    }
900
901    /// The tracking issue for this feature (cited in "not yet available").
902    pub fn issue(self) -> u32 {
903        match self {
904            Self::ToolOffload | Self::Provenance => 584,
905            Self::Scratchpad => 583,
906            Self::Semantic => 582,
907            Self::Experiential => 585,
908            Self::Scheduled => 586,
909        }
910    }
911}
912
913/// The resolved on/off state of every context feature (Phase 26, #588) — the
914/// effective set after a `manager` preset's defaults and config/session
915/// overrides are applied.
916#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
917pub struct ContextFeatureSet {
918    pub tool_offload: bool,
919    pub scratchpad: bool,
920    pub semantic: bool,
921    pub provenance: bool,
922    pub experiential: bool,
923    pub scheduled: bool,
924}
925
926impl ContextFeatureSet {
927    /// Read one feature's resolved state.
928    pub fn get(&self, f: ContextFeature) -> bool {
929        match f {
930            ContextFeature::ToolOffload => self.tool_offload,
931            ContextFeature::Scratchpad => self.scratchpad,
932            ContextFeature::Semantic => self.semantic,
933            ContextFeature::Provenance => self.provenance,
934            ContextFeature::Experiential => self.experiential,
935            ContextFeature::Scheduled => self.scheduled,
936        }
937    }
938
939    /// Set one feature's resolved state.
940    pub fn set(&mut self, f: ContextFeature, on: bool) {
941        match f {
942            ContextFeature::ToolOffload => self.tool_offload = on,
943            ContextFeature::Scratchpad => self.scratchpad = on,
944            ContextFeature::Semantic => self.semantic = on,
945            ContextFeature::Provenance => self.provenance = on,
946            ContextFeature::Experiential => self.experiential = on,
947            ContextFeature::Scheduled => self.scheduled = on,
948        }
949    }
950
951    /// The features currently on, in display order.
952    pub fn enabled(self) -> Vec<ContextFeature> {
953        ContextFeature::ALL
954            .into_iter()
955            .filter(|&f| self.get(f))
956            .collect()
957    }
958
959    /// The base feature set *before* `[context.features]` / session overrides:
960    /// the `manager` preset's bundle, with `tool_offload` defaulted ON because
961    /// it is local, deterministic spill storage and should not depend on
962    /// semantic embedding availability. Local-assist features (`scratchpad`,
963    /// `semantic`, and `scheduled`) additionally default ON for local
964    /// (`BackendKind::Ollama`) backends. A weak local model needs the `<plan>` /
965    /// `<state>` ledger to carry a checklist across tool-call rounds instead of
966    /// re-deriving state each round (Step 27.4), and semantic retrieval should
967    /// be ready without a live toggle once an embedder is configured. Explicit
968    /// overrides still win — they layer on top via [`ContextFeatures::apply_to`].
969    ///
970    /// Note: a *local* vLLM / llama.cpp server reports as `Openai` (that's the
971    /// wire protocol, not the host), so those users opt in via
972    /// `[context.features]` rather than getting it by default.
973    pub fn base_for(manager: ContextManager, kind: BackendKind) -> Self {
974        let mut base = manager.base_features();
975        base.tool_offload = true;
976        if matches!(kind, BackendKind::Ollama) {
977            base.scratchpad = true;
978            base.semantic = true;
979            base.scheduled = true;
980        }
981        base
982    }
983}
984
985/// Per-feature overrides under `[context.features]` (config) and `/context
986/// feature` (session) (Phase 26, #588). `None` inherits the `manager` preset's
987/// default; `Some(b)` forces the feature on/off.
988#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
989pub struct ContextFeatures {
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub tool_offload: Option<bool>,
992    #[serde(default, skip_serializing_if = "Option::is_none")]
993    pub scratchpad: Option<bool>,
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub semantic: Option<bool>,
996    #[serde(default, skip_serializing_if = "Option::is_none")]
997    pub provenance: Option<bool>,
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub experiential: Option<bool>,
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub scheduled: Option<bool>,
1002}
1003
1004impl ContextFeatures {
1005    /// Read one feature's override (`None` = inherit the preset).
1006    pub fn get(&self, f: ContextFeature) -> Option<bool> {
1007        match f {
1008            ContextFeature::ToolOffload => self.tool_offload,
1009            ContextFeature::Scratchpad => self.scratchpad,
1010            ContextFeature::Semantic => self.semantic,
1011            ContextFeature::Provenance => self.provenance,
1012            ContextFeature::Experiential => self.experiential,
1013            ContextFeature::Scheduled => self.scheduled,
1014        }
1015    }
1016
1017    /// Set one feature's override.
1018    pub fn set(&mut self, f: ContextFeature, v: Option<bool>) {
1019        match f {
1020            ContextFeature::ToolOffload => self.tool_offload = v,
1021            ContextFeature::Scratchpad => self.scratchpad = v,
1022            ContextFeature::Semantic => self.semantic = v,
1023            ContextFeature::Provenance => self.provenance = v,
1024            ContextFeature::Experiential => self.experiential = v,
1025            ContextFeature::Scheduled => self.scheduled = v,
1026        }
1027    }
1028
1029    /// Layer these overrides onto a base set (a preset's defaults).
1030    pub fn apply_to(&self, mut base: ContextFeatureSet) -> ContextFeatureSet {
1031        for f in ContextFeature::ALL {
1032            if let Some(v) = self.get(f) {
1033                base.set(f, v);
1034            }
1035        }
1036        base
1037    }
1038}
1039
1040/// `[context]` config section (Step 24.8, #559; features added Phase 26, #588).
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct ContextConfig {
1043    /// Context-management strategy preset. Default `standard`.
1044    #[serde(default)]
1045    pub manager: ContextManager,
1046
1047    /// Per-feature overrides under `[context.features]` — each inherits the
1048    /// `manager` preset's default unless explicitly set (Phase 26, #588).
1049    #[serde(default)]
1050    pub features: ContextFeatures,
1051
1052    /// `[context.semantic]` — settings for the `semantic` RAG feature (Step
1053    /// 26.5, #582).
1054    #[serde(default)]
1055    pub semantic: SemanticConfig,
1056
1057    /// `[context.estimation]` — the cheap token-estimation heuristic
1058    /// (`chars_per_token`, default 4). Threaded through the estimators; the
1059    /// per-model calibration ratio scales the result on top.
1060    #[serde(default)]
1061    pub estimation: crate::tokens::TokenEstimation,
1062
1063    /// Floor (chars) for the whole-middle summarizer input cap. The cap is
1064    /// normally the compression budget converted to chars, but a tight budget
1065    /// would starve the summarizer of material — never give it less than this.
1066    #[serde(default = "default_summary_input_cap_floor_chars")]
1067    pub summary_input_cap_floor_chars: usize,
1068
1069    /// `[context.api_surface]` — the workspace-API-surface knowledge_base
1070    /// technique + its pluggable language packs (#669).
1071    #[serde(default)]
1072    pub api_surface: ApiSurfaceConfig,
1073}
1074
1075fn default_summary_input_cap_floor_chars() -> usize {
1076    8_192
1077}
1078
1079impl Default for ContextConfig {
1080    fn default() -> Self {
1081        Self {
1082            manager: ContextManager::default(),
1083            features: ContextFeatures::default(),
1084            semantic: SemanticConfig::default(),
1085            estimation: crate::tokens::TokenEstimation::default(),
1086            summary_input_cap_floor_chars: default_summary_input_cap_floor_chars(),
1087            api_surface: ApiSurfaceConfig::default(),
1088        }
1089    }
1090}
1091
1092/// One symbol-extraction rule in a [`LanguagePack`]: a regex over a single source
1093/// line whose **first capture group is the public symbol's name**, plus a
1094/// free-form kind label. Free-form so a pack is not locked to one language's
1095/// vocabulary (`fn`/`struct` for Rust, `class`/`def` for Python, `func` for Go…).
1096#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1097pub struct SymbolRule {
1098    /// Regex; capture group 1 = the symbol name.
1099    pub pattern: String,
1100    /// Kind shown in the surface (e.g. `"fn"`, `"struct"`, `"class"`, `"func"`).
1101    pub kind: String,
1102}
1103
1104/// A **language pack** for the workspace API surface (#669): how to recognize a
1105/// language's files, which files expose its public API, and how to extract its
1106/// public symbols — entirely as DATA, so a new language is config, not code.
1107///
1108/// Built-in packs cover Rust, Python, Bash, and C/C++. A project ships more by
1109/// dropping a `<name>.toml` into `~/.newt/language-packs/` (global) or
1110/// `.newt/language-packs/` (project-local), or inline under
1111/// `[[context.api_surface.language_packs]]`. Packs merge **by `name`** (a custom
1112/// pack with a built-in's name replaces it), so anyone can add Java, Ruby, Swift,
1113/// Objective-C, … without touching the binary.
1114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1115pub struct LanguagePack {
1116    /// Stable id (a config pack with a built-in's name replaces that built-in).
1117    pub name: String,
1118    /// File extensions this pack claims, no dot — `["rs"]`, `["h", "hpp", "cpp"]`.
1119    pub extensions: Vec<String>,
1120    /// Entry-point filename globs (the public-API files, listed first in the
1121    /// surface). Supported globs: exact (`lib.rs`), suffix (`*.h`), or all (`*`).
1122    /// Empty ⇒ no file is prioritized for this pack.
1123    #[serde(default)]
1124    pub entry_points: Vec<String>,
1125    /// Public-symbol extraction rules, applied per source line.
1126    pub symbols: Vec<SymbolRule>,
1127}
1128
1129/// `[context.api_surface]` — the workspace-API-surface knowledge_base technique.
1130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1131pub struct ApiSurfaceConfig {
1132    /// Inline language packs, merged by `name` over the built-ins and the
1133    /// drop-in directories (the highest-precedence layer).
1134    #[serde(default)]
1135    pub language_packs: Vec<LanguagePack>,
1136    /// Char ceiling on the injected surface block (it rides every turn).
1137    #[serde(default = "default_api_surface_max_block_chars")]
1138    pub max_block_chars: usize,
1139    /// Per-file symbol cap, so one huge file can't crowd out the surface.
1140    #[serde(default = "default_api_surface_max_symbols_per_file")]
1141    pub max_symbols_per_file: usize,
1142}
1143
1144impl Default for ApiSurfaceConfig {
1145    fn default() -> Self {
1146        Self {
1147            language_packs: Vec::new(),
1148            max_block_chars: default_api_surface_max_block_chars(),
1149            max_symbols_per_file: default_api_surface_max_symbols_per_file(),
1150        }
1151    }
1152}
1153
1154fn default_api_surface_max_block_chars() -> usize {
1155    3_000
1156}
1157
1158fn default_api_surface_max_symbols_per_file() -> usize {
1159    12
1160}
1161
1162/// `[context.semantic]` — the embedding RAG-for-code feature's settings (Step
1163/// 26.5.4, #582).
1164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1165pub struct SemanticConfig {
1166    /// Embedding model used to index the repo + embed queries. Default
1167    /// `nomic-embed-text` (the HTTP path). The model must exist on the embeddings
1168    /// endpoint (see `embeddings_endpoint`); when it can't be reached the feature
1169    /// follows `on_embed_failure`.
1170    ///
1171    /// For the **embedded backend** (`embeddings_api = "embedded"`, #720) this is
1172    /// only a label — the model is loaded from `embedding_model_path` — and it
1173    /// should name a **candle-clean standard-BERT** model (e.g.
1174    /// `bge-small-en-v1.5`), NOT `nomic-embed-text`, which candle 0.8 cannot load.
1175    #[serde(default = "default_embedding_model")]
1176    pub embedding_model: String,
1177    /// Local model **directory** for the embedded embedder (#720): a
1178    /// candle-clean standard-BERT model dir holding
1179    /// `config.json` + `tokenizer.json` + `model.safetensors` (e.g. a fetched
1180    /// `BAAI/bge-small-en-v1.5`). `None` (default) ⇒ the embedded path can't
1181    /// load and reports a clear error. When `embeddings_api` and
1182    /// `embeddings_endpoint` are unset, a configured path selects embedded
1183    /// embeddings automatically. Ignored by explicit HTTP embeddings targets.
1184    /// Mirrors the summarizer's `model_path`.
1185    #[serde(default, skip_serializing_if = "Option::is_none")]
1186    pub embedding_model_path: Option<String>,
1187    /// How many code chunks to retrieve per turn. Default 5.
1188    #[serde(default = "default_semantic_top_k")]
1189    pub top_k: usize,
1190    /// Dedicated endpoint that serves embeddings (e.g. an Ollama
1191    /// `http://host:11434`). `None` (default) leaves semantic retrieval on the
1192    /// embedded path unless `embeddings_api` explicitly selects an HTTP protocol.
1193    /// Set this to a real embeddings host when remote/vector-server embeddings
1194    /// are a deliberate performance choice.
1195    #[serde(default, skip_serializing_if = "Option::is_none")]
1196    pub embeddings_endpoint: Option<String>,
1197    /// Wire protocol of `embeddings_endpoint` — `ollama` (`/api/embeddings`) or
1198    /// `openai` (`/v1/embeddings`). `embedded` selects the in-process embedder.
1199    /// `None` (default) selects embedded embeddings when `embeddings_endpoint`
1200    /// is also unset; with an explicit endpoint, `None` assumes `ollama`.
1201    #[serde(default, skip_serializing_if = "Option::is_none")]
1202    pub embeddings_api: Option<BackendKind>,
1203    /// What to do when embedding fails structurally (wrong endpoint / model
1204    /// absent): `disable` (default) stops indexing after the first failure with
1205    /// one actionable message; `warn` logs per-chunk and keeps trying.
1206    #[serde(default)]
1207    pub on_embed_failure: OnEmbedFailure,
1208}
1209
1210impl Default for SemanticConfig {
1211    fn default() -> Self {
1212        Self {
1213            embedding_model: default_embedding_model(),
1214            embedding_model_path: None,
1215            top_k: default_semantic_top_k(),
1216            embeddings_endpoint: None,
1217            embeddings_api: None,
1218            on_embed_failure: OnEmbedFailure::default(),
1219        }
1220    }
1221}
1222
1223/// Policy when an embedding request fails structurally during indexing.
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1225#[serde(rename_all = "lowercase")]
1226pub enum OnEmbedFailure {
1227    /// Stop indexing on the first failure and log one actionable error — a
1228    /// structural failure (wrong endpoint / missing model) is total, not
1229    /// transient, so degrading per-chunk just produces an empty index quietly.
1230    #[default]
1231    Disable,
1232    /// Log every failed chunk and keep going (the historical behaviour).
1233    Warn,
1234}
1235
1236fn default_embedding_model() -> String {
1237    "nomic-embed-text".to_string()
1238}
1239
1240fn default_semantic_top_k() -> usize {
1241    5
1242}
1243
1244/// How a thinking model's streamed reasoning is surfaced — the `[tui] thinking`
1245/// key. Newt strips `<think>…</think>` from the reply regardless (#385); this
1246/// only controls the live human display.
1247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1248#[serde(rename_all = "snake_case")]
1249pub enum ThinkingMode {
1250    /// Cargo-style: reasoning streams as dim scrolled lines (kept in
1251    /// scrollback) with an ephemeral spinner line pinned at the bottom. The
1252    /// default — but only on a TTY; a pipe / `newt worker` shows nothing.
1253    #[default]
1254    Stream,
1255    /// No reasoning display at all (the answer still streams normally).
1256    Off,
1257}
1258
1259fn default_memory_window() -> usize {
1260    20
1261}
1262
1263fn default_note_nudge_interval() -> usize {
1264    10
1265}
1266
1267impl Default for MemoryConfig {
1268    fn default() -> Self {
1269        Self {
1270            provider: MemoryProviderKind::RollingWindow,
1271            window: 20,
1272            context_tokens: None,
1273            soul_file: None,
1274            note_nudge_interval: 10,
1275            extract_notes_on_close: false,
1276            disclosure: MemoryDisclosure::Frozen,
1277        }
1278    }
1279}
1280
1281/// Which built-in memory strategy to use.
1282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1283#[serde(rename_all = "snake_case")]
1284pub enum MemoryProviderKind {
1285    #[default]
1286    RollingWindow,
1287    TokenBudget,
1288    Summarizing,
1289}
1290
1291// ---------------------------------------------------------------------------
1292// Project-instruction (AGENTS.md / CLAUDE.md) config
1293// ---------------------------------------------------------------------------
1294
1295/// Project-instruction loading stored under `[agents]` in `newt.toml`.
1296///
1297/// When enabled (the default), newt reads `AGENTS.md` / `CLAUDE.md` from the
1298/// workspace and injects them into the agent's system prompt.
1299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1300#[serde(default)]
1301pub struct AgentsConfig {
1302    /// Whether to load project instructions into the system prompt. Default: true.
1303    pub enabled: bool,
1304    /// Directory to search for `AGENTS.md` / `CLAUDE.md`, or a specific
1305    /// instructions file. Relative paths are resolved against the workspace.
1306    /// Default: the workspace root.
1307    #[serde(default, skip_serializing_if = "Option::is_none")]
1308    pub path: Option<String>,
1309}
1310
1311impl Default for AgentsConfig {
1312    fn default() -> Self {
1313        Self {
1314            enabled: true,
1315            path: None,
1316        }
1317    }
1318}
1319
1320// ---------------------------------------------------------------------------
1321// Tools config
1322// ---------------------------------------------------------------------------
1323
1324/// Tool-execution behaviour stored under `[tools]` in `newt.toml` (#726).
1325///
1326/// Tool-output knobs: the **token budget** that caps every tool's model-facing
1327/// output, plus the head/tail split for oversized shell output. This bounds
1328/// what a single tool result can add to the context window — applied to both
1329/// `read_file` (its char backstop) and `run_command` (its shell envelope) — so a
1330/// verbose command or a huge file can't saturate a small local model's window
1331/// and abandon the task. Mirrors Codex's `exec_command.max_output_tokens`.
1332/// Distinct from `[tui] tool_output_lines`, which caps the on-screen DISPLAY by
1333/// lines.
1334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1335#[serde(default)]
1336pub struct ToolsConfig {
1337    /// Maximum tokens of model-facing output any single tool may return before
1338    /// it is truncated (with a marker steering the model to a narrower command /
1339    /// a paginated read). Default: 10000. `0` disables the cap. Tokens are
1340    /// estimated with the shared chars/token heuristic (see [`crate::tokens`]).
1341    #[serde(default = "default_max_output_tokens")]
1342    pub max_output_tokens: usize,
1343
1344    /// Tokens reserved for the head of an oversized `run_command` result. The
1345    /// remaining budget is spent on the tail, so failures and summaries at the
1346    /// end survive by default. `0` is pure-tail; values greater than
1347    /// `max_output_tokens` are clamped to pure-head.
1348    #[serde(default = "default_output_head_tokens")]
1349    pub output_head_tokens: usize,
1350}
1351
1352impl Default for ToolsConfig {
1353    fn default() -> Self {
1354        Self {
1355            max_output_tokens: default_max_output_tokens(),
1356            output_head_tokens: default_output_head_tokens(),
1357        }
1358    }
1359}
1360
1361fn default_max_output_tokens() -> usize {
1362    10_000
1363}
1364
1365fn default_output_head_tokens() -> usize {
1366    1_500
1367}
1368
1369// ---------------------------------------------------------------------------
1370// TUI config
1371// ---------------------------------------------------------------------------
1372
1373/// TUI appearance preferences stored under `[tui]` in `newt.toml`.
1374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1375#[serde(default)]
1376pub struct TuiConfig {
1377    /// Whether to show "newt" / "you" labels before the carets.
1378    pub chat_style: ChatStyle,
1379
1380    /// PS1-style prompt template.
1381    ///
1382    /// Tokens: `\w` workspace basename, `\W` full path, `\h` hostname,
1383    /// `\v` newt version.  Default: `"\\w $ "` (compact) / `"you $ "` (verbose).
1384    #[serde(default, skip_serializing_if = "Option::is_none")]
1385    pub prompt: Option<String>,
1386
1387    /// Skip the full-screen ANSI art splash and show a compact header instead.
1388    /// Equivalent to the `--no-splash` CLI flag.
1389    #[serde(default)]
1390    pub no_splash: bool,
1391
1392    /// Key binding mode for the chat input line.
1393    /// `"emacs"` (default), `"vi"`, or `"nano"`. Also overridable via
1394    /// `NEWT_EDIT_MODE`. (`nano` is emacs-style/modeless — it differs from
1395    /// `emacs` only in label today; the rich-tui surface honors it.)
1396    #[serde(default)]
1397    pub edit_mode: EditMode,
1398
1399    /// Rich-tui (issue #416) input gutter width, in columns. Unset = `auto`
1400    /// (the responsive default: a prompt gutter when it fits under ~1/3 of the
1401    /// width, else a stacked prompt row). `0` turns the gutter off (prompt on
1402    /// its own row, input flush-left); a positive value indents the input that
1403    /// many columns (a value wide enough to hold the prompt renders it inline).
1404    /// No effect on the lean surface.
1405    #[serde(default, skip_serializing_if = "Option::is_none")]
1406    pub gutter: Option<u16>,
1407
1408    /// Input-footer mode: the transient multi-line `❯` input block with a
1409    /// status header. `"auto"` (default) shows it on a TTY and degrades to a
1410    /// plain scroller otherwise; `"on"` always shows it; `"off"` never does
1411    /// (the `--plain` CLI flag, or `NEWT_FOOTER=off`).
1412    #[serde(default)]
1413    pub footer: FooterMode,
1414
1415    /// Color / theme mode (issue #527): `"auto"` (default), `"always"`,
1416    /// `"never"`, `"minimal"`, `"inverted"`, `"dark"`, `"light"`, `"mono"`.
1417    /// The `--color` CLI flag and `--mono` override this; `NO_COLOR` /
1418    /// `TERM=dumb` force it off unless an explicit `--color` is given. This is
1419    /// the on/off + theme *mode*; `[tui.colors]` below is the palette.
1420    #[serde(default)]
1421    pub color: ColorMode,
1422
1423    /// How a thinking model's streamed reasoning is shown: `"stream"` (default
1424    /// — dim reasoning + a cargo-style spinner, TTY only) or `"off"`.
1425    #[serde(default)]
1426    pub thinking: ThinkingMode,
1427
1428    /// Maximum lines of tool output shown inline before offering "show all?".
1429    /// Default: 20. Set to 0 to always show everything.
1430    #[serde(default = "default_tool_output_lines")]
1431    pub tool_output_lines: usize,
1432
1433    /// Maximum number of tool-call rounds the model may take within a single
1434    /// turn before the agent forces a final, tools-disabled completion. Each
1435    /// round is one model response that may emit tool calls; once this many
1436    /// rounds have run without a tool-free answer, newt asks the model once
1437    /// more with tools disabled so the user still gets a real (partial)
1438    /// answer instead of a placeholder. Default: 40 (raised from 25 — a
1439    /// modest safety margin alongside `workflow_grace_rounds` and the
1440    /// workflow-classifier delegate hint; genuinely open-ended diagnostic work
1441    /// should reach for `crew`/`team` delegation rather than depend on an
1442    /// unbounded cap here).
1443    #[serde(default = "default_max_tool_rounds")]
1444    pub max_tool_rounds: usize,
1445
1446    /// Additional progress-aware rounds available after `max_tool_rounds` when
1447    /// an active workflow still has incomplete steps and the recent rounds show
1448    /// repair progress or actionable evidence. Default: 5. Set to 0 to make the
1449    /// normal round cap hard again.
1450    #[serde(default = "default_workflow_grace_rounds")]
1451    pub workflow_grace_rounds: usize,
1452
1453    /// Tool-call permission policy for the interactive TUI: which tools the
1454    /// model may invoke and over which targets. This is a *preset that selects
1455    /// an attenuation* — the host (`newt-identity`) lowers it into a signed,
1456    /// attenuation-only capability that enforcement consults. Default:
1457    /// `WorkspaceDev`.
1458    #[serde(default)]
1459    pub permissions: ToolPermissions,
1460
1461    /// Enable per-round agent-loop diagnostics printed to the TUI. Shows each
1462    /// round's content excerpt, tool-call count, token usage, and flags empty
1463    /// model responses before they become silent failures. Also set via the
1464    /// `NEWT_DEBUG=1` environment variable.
1465    #[serde(default, skip_serializing_if = "Option::is_none")]
1466    pub debug: Option<bool>,
1467
1468    /// Enable deep backend/inference diagnostics. Intended for issue reports
1469    /// and compatibility debugging; also set via `NEWT_TRACE=1`.
1470    #[serde(default, skip_serializing_if = "Option::is_none")]
1471    pub trace: Option<bool>,
1472
1473    /// Shell command to run after every successful file write or edit, to
1474    /// give the agent immediate ground-truth feedback on whether its change
1475    /// compiled / passed basic checks. Output is appended to the tool result
1476    /// so the model sees it without needing to ask.
1477    ///
1478    /// Set this per-workspace in `.newt/config.toml` — not globally — because
1479    /// the right command depends on the project's build system:
1480    ///
1481    /// ```toml
1482    /// [tui]
1483    /// build_check_cmd = "cargo check -q --workspace"  # Rust
1484    /// # build_check_cmd = "npm run build --silent"    # Node
1485    /// # build_check_cmd = "python -m py_compile"      # Python
1486    /// ```
1487    ///
1488    /// `None` (default) disables auto-checking — no extra command is run.
1489    #[serde(default, skip_serializing_if = "Option::is_none")]
1490    pub build_check_cmd: Option<String>,
1491
1492    // -----------------------------------------------------------------------
1493    // DGX / inference endpoint resource management
1494    // -----------------------------------------------------------------------
1495    /// Ollama context-window cap sent as `options.num_ctx` on every request.
1496    /// Limits the KV-cache allocation so a large model can't exhaust VRAM
1497    /// mid-session. `None` → newt trusts the model's declared context window
1498    /// from Ollama `/api/show` and sends ~80 % of it as `num_ctx` (see
1499    /// `real_context_discovery`). Set an explicit cap here (e.g. 8192 / 16384)
1500    /// on VRAM-constrained hosts; this always takes precedence over the
1501    /// declared window.
1502    #[serde(default, skip_serializing_if = "Option::is_none")]
1503    pub num_ctx: Option<u32>,
1504
1505    /// Opt into **empirical** context-window discovery (the conserve-for-tiny-
1506    /// hardware mode). When `false`/unset (the default), newt **trusts the
1507    /// declared** `/api/show` window: `safe_context` tracks ~80 % of it and is
1508    /// raised back to it each session, so a model is never permanently capped
1509    /// by a past overflow. When `true`, newt instead keeps the conservative
1510    /// behaviour — bootstrap `safe_context` once, never auto-raise it, and let
1511    /// runtime context-window 400s ratchet it down and persist (for hardware
1512    /// that genuinely can't serve the full declared window). Overridable
1513    /// per-model via `[[model_tuning]] real_context_discovery`.
1514    #[serde(default, skip_serializing_if = "Option::is_none")]
1515    pub real_context_discovery: Option<bool>,
1516
1517    /// TCP connect timeout in seconds for inference requests (default: 5).
1518    /// A fast failure here means the endpoint is down (connection refused),
1519    /// distinguishing it from a slow-but-alive endpoint that needs the full
1520    /// `inference_timeout_secs` to respond. Keep this short.
1521    #[serde(default = "default_connect_timeout_secs")]
1522    pub connect_timeout_secs: u64,
1523
1524    /// Total inference request timeout in seconds (default: 120). This is the
1525    /// wall-clock budget for the model to generate a complete response —
1526    /// large models on a busy DGX may need the full window.
1527    #[serde(default = "default_inference_timeout_secs")]
1528    pub inference_timeout_secs: u64,
1529
1530    /// How long Ollama keeps a model resident in VRAM after the last request,
1531    /// as an Ollama duration string (e.g. `"5m"`, `"0"`, `"-1"`).
1532    /// Default: `"5m"`. Use `"0"` to unload immediately after each turn
1533    /// (maximum headroom for multi-model or multi-agent workloads at the cost
1534    /// of a reload on each turn). Use `"-1"` to keep forever.
1535    #[serde(default = "default_keep_alive")]
1536    pub keep_alive: String,
1537
1538    // Summarizer knobs (timeout / retries / fallback model) moved to the
1539    // dedicated `~/.newt/summarizer.toml` ([`SummarizerConfig`]) in Step 24.10
1540    // (#559), so the summarizer can run on its own backend. Old `[tui]` keys
1541    // (`summarizer_timeout_secs` / `summarizer_retries` / `summarizer_model`)
1542    // are no longer read — `#[serde(default)]` ignores them in stale configs.
1543    /// Markdown rendering of assistant output (Step 25.4, #568). `auto`
1544    /// (default) renders whenever color is active; `on`/`off` force it. The
1545    /// `/markdown [on|off]` command overrides this for the session.
1546    #[serde(default)]
1547    pub markdown: MarkdownMode,
1548
1549    /// Maximum number of messages in the in-progress tool-call message list
1550    /// before the agent trims the middle to prevent context overflow.
1551    /// Default: 40 (≈ 20 tool-call rounds). Set lower on memory-constrained
1552    /// endpoints or when `num_ctx` is small.
1553    #[serde(default = "default_mid_loop_trim_threshold")]
1554    pub mid_loop_trim_threshold: usize,
1555
1556    /// Estimated-token threshold that triggers a mid-loop context trim,
1557    /// independent of `mid_loop_trim_threshold` (which counts *messages*).
1558    /// A single tool round can return a multi-KB file listing or JSON payload
1559    /// that adds hundreds of thousands of tokens in one message — far below the
1560    /// message-count threshold but well past the model's context window. When
1561    /// set, trimming fires as soon as the estimated token count (chars / 4)
1562    /// exceeds this value. `None` disables token-based trimming.
1563    /// Default: `None` (message-count trimming only). See issue #223.
1564    #[serde(default, skip_serializing_if = "Option::is_none")]
1565    pub mid_loop_trim_tokens: Option<usize>,
1566
1567    /// Normalise hyphens to underscores in MCP server names when advertising
1568    /// tool definitions and routing tool calls.  Some API proxies (e.g. those
1569    /// that wrap the Anthropic backend) replace hyphens with underscores in
1570    /// tool names; advertising the sanitised form ensures the model's tool
1571    /// calls round-trip back unchanged and routes correctly.  Default: `true`.
1572    /// Set to `false` only when every connected MCP server is behind a proxy
1573    /// that preserves hyphens verbatim.
1574    #[serde(default = "default_sanitize_mcp_server_names")]
1575    pub sanitize_mcp_server_names: bool,
1576
1577    /// Hosts (IP or hostname) for which newt may send an MCP OAuth Bearer token
1578    /// over an UNENCRYPTED (non-`https`) connection. Empty by default: a stored
1579    /// Bearer is sent only over `https` or to loopback (`localhost`/`127.0.0.1`/
1580    /// `::1`). newt WARNs on every non-loopback unencrypted MCP connection
1581    /// regardless; an allow-listed host still warns but the token is sent.
1582    /// This is the explicit opt-out of the secure-by-default transport policy —
1583    /// see `docs/decisions/mcp_transport_security.md`.
1584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1585    pub mcp_allow_insecure_hosts: Vec<String>,
1586
1587    /// Whether the interactive `!` bang-escape is available — typing `! <cmd>`
1588    /// at the prompt runs a host command with the user's own authority (not the
1589    /// agent's OCAP leash). Default: `true`. Set `false` for locked-down or
1590    /// shared deployments where the human at the keyboard should not have an
1591    /// unconfined host shell-out. The model can never invoke `!` either way;
1592    /// this only governs the human. See `docs/decisions/plain_scroller_tui.md`.
1593    #[serde(default = "default_allow_bang_escape")]
1594    pub allow_bang_escape: bool,
1595}
1596
1597fn default_tool_output_lines() -> usize {
1598    20
1599}
1600
1601fn default_max_tool_rounds() -> usize {
1602    40
1603}
1604
1605fn default_workflow_grace_rounds() -> usize {
1606    5
1607}
1608
1609fn default_connect_timeout_secs() -> u64 {
1610    5
1611}
1612
1613fn default_inference_timeout_secs() -> u64 {
1614    120
1615}
1616
1617fn default_keep_alive() -> String {
1618    "5m".to_string()
1619}
1620
1621fn default_summarizer_timeout_secs() -> u64 {
1622    60
1623}
1624
1625fn default_summarizer_retries() -> u32 {
1626    1
1627}
1628
1629fn default_mid_loop_trim_threshold() -> usize {
1630    40
1631}
1632
1633fn default_sanitize_mcp_server_names() -> bool {
1634    true
1635}
1636
1637fn default_allow_bang_escape() -> bool {
1638    true
1639}
1640
1641// ---------------------------------------------------------------------------
1642// Per-model tuning
1643// ---------------------------------------------------------------------------
1644
1645/// Inference-parameter overrides for a specific model name.
1646///
1647/// Matched against the active model by exact string equality.  Add entries
1648/// under `[[model_tuning]]` in `~/.newt/config.toml` to pin parameters
1649/// for models whose defaults cause problems (e.g. context overflow).
1650///
1651/// Human-authored entries are never touched by the auto-tuner.  Auto-tuned
1652/// entries are appended (not modified) when the harness gains high confidence
1653/// in its empirical measurements.
1654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1655pub struct ModelTuning {
1656    /// Model name as it appears in Ollama (e.g. `"nemotron3:33b"`).
1657    pub model: String,
1658
1659    /// Ollama `options.num_ctx` — hard cap on KV-cache allocation.
1660    /// Overrides both the global `[tui].num_ctx` and the empirically
1661    /// derived `safe_context` from `model-capabilities.json`.
1662    #[serde(default, skip_serializing_if = "Option::is_none")]
1663    pub num_ctx: Option<u32>,
1664
1665    /// Per-model override of `[tui].real_context_discovery`. `Some(true)` opts
1666    /// this model into empirical conserve-and-ratchet discovery; `Some(false)`
1667    /// forces trust-the-declared-window even when the global default is
1668    /// empirical; `None` inherits the global setting.
1669    #[serde(default, skip_serializing_if = "Option::is_none")]
1670    pub real_context_discovery: Option<bool>,
1671
1672    /// Per-model `mid_loop_trim_threshold` override.
1673    #[serde(default, skip_serializing_if = "Option::is_none")]
1674    pub mid_loop_trim_threshold: Option<usize>,
1675
1676    /// Per-model `mid_loop_trim_tokens` override (estimated-token trim trigger).
1677    #[serde(default, skip_serializing_if = "Option::is_none")]
1678    pub mid_loop_trim_tokens: Option<usize>,
1679
1680    /// Per-model `max_tool_rounds` override.
1681    #[serde(default, skip_serializing_if = "Option::is_none")]
1682    pub max_tool_rounds: Option<usize>,
1683
1684    /// Per-model `[tui].workflow_grace_rounds` override.
1685    #[serde(default, skip_serializing_if = "Option::is_none")]
1686    pub workflow_grace_rounds: Option<usize>,
1687}
1688
1689impl Config {
1690    /// Find the first `[[model_tuning]]` entry whose `model` field matches
1691    /// `name` exactly.  Returns `None` when no entry exists.
1692    pub fn find_model_tuning(&self, name: &str) -> Option<&ModelTuning> {
1693        self.model_tuning.iter().find(|t| t.model == name)
1694    }
1695
1696    /// Look up and validate a named profile (`[profiles.<name>]`). The caller
1697    /// selects it via `--profile <name>` / `NEWT_PROFILE`.
1698    ///
1699    /// # Errors
1700    /// `no such profile` when the name is undefined; the validation error when
1701    /// the profile names an unknown technique — a `--profile` that silently did
1702    /// nothing would be a false claim, so both fail loudly.
1703    pub fn resolve_profile(&self, name: &str) -> std::result::Result<&ProfileConfig, String> {
1704        let profile = self.profiles.get(name).ok_or_else(|| {
1705            let known = if self.profiles.is_empty() {
1706                "none defined".to_string()
1707            } else {
1708                self.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
1709            };
1710            format!("no such profile (known: {known})")
1711        })?;
1712        profile.validate()?;
1713        Ok(profile)
1714    }
1715
1716    /// Look up a named bundle (`[bundles.<name>]`).
1717    ///
1718    /// # Errors
1719    /// `no such bundle` when undefined — a `--bundle` that silently did nothing
1720    /// would be a false claim.
1721    pub fn resolve_bundle(&self, name: &str) -> std::result::Result<&BundleConfig, String> {
1722        self.bundles.get(name).ok_or_else(|| {
1723            let known = if self.bundles.is_empty() {
1724                "none defined".to_string()
1725            } else {
1726                self.bundles.keys().cloned().collect::<Vec<_>>().join(", ")
1727            };
1728            format!("no such bundle (known: {known})")
1729        })
1730    }
1731
1732    /// The profile name `bundle` yields for `model`: the longest-prefix `families`
1733    /// match, else `default_profile`. `None` ⇒ the bundle applies no profile here.
1734    #[must_use]
1735    pub fn bundle_profile_for_model<'a>(
1736        &self,
1737        bundle: &'a BundleConfig,
1738        model: &str,
1739    ) -> Option<&'a str> {
1740        bundle
1741            .families
1742            .iter()
1743            .filter(|(prefix, _)| model.starts_with(prefix.as_str()))
1744            .max_by_key(|(prefix, _)| prefix.len()) // longest-prefix-wins
1745            .map(|(_, p)| p.as_str())
1746            .or(bundle.default_profile.as_deref())
1747    }
1748
1749    /// Infer the bundle for `model` from `applies_to` (longest-prefix-wins). Only
1750    /// bundles with a non-empty `applies_to` participate — a use-case bundle (empty
1751    /// `applies_to`) is never auto-inferred, only chosen explicitly via `--bundle`.
1752    #[must_use]
1753    pub fn infer_bundle(&self, model: &str) -> Option<(&str, &BundleConfig)> {
1754        self.bundles
1755            .iter()
1756            .filter_map(|(name, b)| {
1757                b.applies_to
1758                    .iter()
1759                    .filter(|p| model.starts_with(p.as_str()))
1760                    .map(String::len)
1761                    .max()
1762                    .map(|best| (best, name.as_str(), b))
1763            })
1764            .max_by_key(|(best, _, _)| *best)
1765            .map(|(_, name, b)| (name, b))
1766    }
1767
1768    /// Resolve the active profile from the selectors + the active `model`:
1769    /// `--profile` (explicit) > `--bundle` (its profile for this model) > an
1770    /// inferred bundle (`applies_to`) > `None` (today's no-profile behavior).
1771    /// Returns the profile NAME + how it was chosen (for the banner).
1772    ///
1773    /// # Errors
1774    /// An unknown explicit `--bundle` is a hard error. An unknown explicit
1775    /// `--profile` is left for the caller's [`resolve_profile`](Self::resolve_profile)
1776    /// so the message stays profile-specific.
1777    pub fn pick_active_profile(
1778        &self,
1779        profile_flag: Option<&str>,
1780        bundle_flag: Option<&str>,
1781        model: &str,
1782    ) -> std::result::Result<Option<ProfilePick>, String> {
1783        if let Some(p) = profile_flag.filter(|s| !s.is_empty()) {
1784            return Ok(Some(ProfilePick {
1785                name: p.to_string(),
1786                via: PickVia::Profile,
1787            }));
1788        }
1789        if let Some(b) = bundle_flag.filter(|s| !s.is_empty()) {
1790            let bundle = self.resolve_bundle(b)?;
1791            return Ok(self
1792                .bundle_profile_for_model(bundle, model)
1793                .map(|p| ProfilePick {
1794                    name: p.to_string(),
1795                    via: PickVia::Bundle(b.to_string()),
1796                }));
1797        }
1798        if let Some((name, bundle)) = self.infer_bundle(model) {
1799            return Ok(self
1800                .bundle_profile_for_model(bundle, model)
1801                .map(|p| ProfilePick {
1802                    name: p.to_string(),
1803                    via: PickVia::InferredBundle(name.to_string()),
1804                }));
1805        }
1806        Ok(None)
1807    }
1808}
1809
1810/// One named bundle (`[bundles.<name>]`) — the loadable unit of the model support
1811/// kit. It pins which model families it applies to and which profile each resolves
1812/// to, shipping the `[profiles.*]` it references.
1813///
1814/// ```toml
1815/// [bundles.nemotron]
1816/// about = "Support bundle for the nemotron family"
1817/// applies_to = ["nemotron"]                 # longest-prefix-wins; "nemotron3:33b" matches
1818/// default_profile = "nemotron"
1819/// families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }
1820/// ```
1821///
1822/// A bundle carries **no authority** — there is deliberately no caveats/preset
1823/// field; it recombines vetted parts, it cannot grant (`docs/design/model-support-kit.md`).
1824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1825pub struct BundleConfig {
1826    /// One-line provenance, shown in the startup banner.
1827    #[serde(default, skip_serializing_if = "Option::is_none")]
1828    pub about: Option<String>,
1829    /// Model-id prefixes this bundle auto-applies to (longest-prefix-wins). Empty ⇒
1830    /// a use-case bundle: chosen only via explicit `--bundle`, never auto-inferred.
1831    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1832    pub applies_to: Vec<String>,
1833    /// Profile applied when this bundle is selected and no `families` entry matches.
1834    /// Must name a key in `[profiles.*]`. `None` ⇒ no profile (the light path).
1835    #[serde(default, skip_serializing_if = "Option::is_none")]
1836    pub default_profile: Option<String>,
1837    /// model-family-prefix → profile name (longest-prefix-wins over `default_profile`).
1838    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1839    pub families: std::collections::BTreeMap<String, String>,
1840}
1841
1842/// The active-profile selection + how it was chosen (for honest banner output).
1843#[derive(Debug, Clone, PartialEq, Eq)]
1844pub struct ProfilePick {
1845    /// The chosen profile name (to feed [`Config::resolve_profile`]).
1846    pub name: String,
1847    /// Which selector won.
1848    pub via: PickVia,
1849}
1850
1851/// How a [`ProfilePick`] was selected.
1852#[derive(Debug, Clone, PartialEq, Eq)]
1853pub enum PickVia {
1854    /// An explicit `--profile` / `NEWT_PROFILE`.
1855    Profile,
1856    /// An explicit `--bundle <name>`.
1857    Bundle(String),
1858    /// A bundle inferred from the model via `applies_to`.
1859    InferredBundle(String),
1860}
1861
1862/// One named loadout (`[loadouts.<name>]` / `~/.newt/loadouts/<name>.toml`) — the
1863/// top-level composition the user *loads* (`docs/design/loadout-composition.md`).
1864/// Every field is optional and is a **name reference** into the surface that owns
1865/// that axis; the loadout itself stores nothing but the selection + per-axis
1866/// overrides. It carries **no authority** — `settings` cannot widen caveats.
1867///
1868/// ```toml
1869/// [loadouts.dev-nemotron]
1870/// provider = "dgx"          # → the catalog/provider card (#387)
1871/// model    = "nemotron@deep"
1872/// kit      = "nemotron"     # → a [bundles.<name>] (the loadable kit unit)
1873/// profile  = "nemotron"     # → a [profiles.<name>] (optional; the bundle implies it)
1874/// role     = "python-developer"   # → ~/.newt/personas/<name>.md
1875///   [loadouts.dev-nemotron.settings]
1876///   num_ctx = 24576
1877///   framing = "Ship small, verify."
1878/// ```
1879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1880pub struct Loadout {
1881    /// Provider id (→ the catalog/provider card). Resolution is Slice 2.
1882    #[serde(default, skip_serializing_if = "Option::is_none")]
1883    pub provider: Option<String>,
1884    /// Model id, optionally `model@variant`. Resolution is Slice 2.
1885    #[serde(default, skip_serializing_if = "Option::is_none")]
1886    pub model: Option<String>,
1887    /// Bundle name (the loadable kit unit) — must name a `[bundles.<name>]`.
1888    #[serde(default, skip_serializing_if = "Option::is_none")]
1889    pub kit: Option<String>,
1890    /// Profile name — must name a `[profiles.<name>]`. Omitted ⇒ the bundle/model
1891    /// implies it.
1892    #[serde(default, skip_serializing_if = "Option::is_none")]
1893    pub profile: Option<String>,
1894    /// Role/persona name (`~/.newt/personas/<name>.md`). Not validated against the
1895    /// filesystem here — personas are resolved at session start.
1896    #[serde(default, skip_serializing_if = "Option::is_none")]
1897    pub role: Option<String>,
1898    /// Per-axis overrides (parameters / prompt). Never authority.
1899    #[serde(default, skip_serializing_if = "Option::is_none")]
1900    pub settings: Option<LoadoutSettings>,
1901}
1902
1903/// Per-axis overrides a loadout may pin. **No authority axis** — a loadout cannot
1904/// widen caveats (`docs/design/loadout-composition.md` §Authority safety).
1905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1906pub struct LoadoutSettings {
1907    /// Parameter axis: KV-cache window override (top of the `ModelTuning` chain).
1908    #[serde(default, skip_serializing_if = "Option::is_none")]
1909    pub num_ctx: Option<u32>,
1910    /// Prompt axis: a one-line system-prompt framing (the `ModeConfig.framing` shape).
1911    #[serde(default, skip_serializing_if = "Option::is_none")]
1912    pub framing: Option<String>,
1913}
1914
1915impl Loadout {
1916    /// Validate the loadout's name references against `cfg`: a named `kit` must be a
1917    /// known bundle, a named `profile` must be a known, valid profile, and a named
1918    /// `provider` must name a `[backends]` entry (Slice 2 — the provider/model axis).
1919    /// A dangling reference is a hard error — a loadout that silently did nothing
1920    /// would be a false claim. The `@variant` half of `model` and `role` are resolved
1921    /// by their own surfaces later and are not checked here.
1922    ///
1923    /// # Errors
1924    /// The first dangling `kit`, `profile`, or `provider` reference, as a message.
1925    pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
1926        if let Some(kit) = &self.kit {
1927            cfg.resolve_bundle(kit)
1928                .map_err(|e| format!("loadout kit '{kit}': {e}"))?;
1929        }
1930        if let Some(profile) = &self.profile {
1931            cfg.resolve_profile(profile)
1932                .map_err(|e| format!("loadout profile '{profile}': {e}"))?;
1933        }
1934        if let Some(provider) = &self.provider {
1935            if !cfg.backends.iter().any(|b| &b.name == provider) {
1936                let known = if cfg.backends.is_empty() {
1937                    "none defined".to_string()
1938                } else {
1939                    cfg.backends
1940                        .iter()
1941                        .map(|b| b.name.as_str())
1942                        .collect::<Vec<_>>()
1943                        .join(", ")
1944                };
1945                return Err(format!(
1946                    "loadout provider '{provider}': no [backends] entry named '{provider}' (known: {known})"
1947                ));
1948            }
1949        }
1950        Ok(())
1951    }
1952}
1953
1954/// A named crew (`[crews.<name>]` or `crews/<name>.toml`): a role-specialized
1955/// ensemble over the heterogeneous backend pool. Each role names a `[loadouts.*]`
1956/// (so a crew is a *composition of loadouts* — the canonical example routes the
1957/// planner/triage to frontier models and bulk work to cheap local inference,
1958/// `docs/design/config-scaling-deployment-and-trust.md`). The harness owns the
1959/// control loop (`run_crew`); these fields pin the workers + budgets.
1960///
1961/// ```toml
1962/// [crews.coder]
1963/// planner = "planner"          # → [loadouts.planner]  (required)
1964/// navigator = "navigator"      # → [loadouts.navigator]
1965/// triage = "triage"            # → [loadouts.triage]
1966/// loop = "patch-revise"        # control program (default)
1967///   [crews.coder.budgets]
1968///   max_attempts = 4
1969/// ```
1970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1971pub struct Crew {
1972    /// Planner/editor role — must name a `[loadouts.<name>]`. Required (a crew
1973    /// with no planner can't make edits).
1974    pub planner: String,
1975    /// Repo-navigator role — names a `[loadouts.<name>]`. Optional.
1976    #[serde(default, skip_serializing_if = "Option::is_none")]
1977    pub navigator: Option<String>,
1978    /// Test-triage role — names a `[loadouts.<name>]`. Optional.
1979    #[serde(default, skip_serializing_if = "Option::is_none")]
1980    pub triage: Option<String>,
1981    /// Control program (e.g. `"patch-revise"`). Omitted ⇒ the default loop.
1982    #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
1983    pub loop_program: Option<String>,
1984    /// Per-role dispatch wall-clock bound, seconds (#698). Omitted ⇒ the
1985    /// env/default (`NEWT_ROLE_TIMEOUT_SECS` → 600s). Widen it here for a slow
1986    /// loadout instead of relying on the env var.
1987    #[serde(default, skip_serializing_if = "Option::is_none")]
1988    pub role_timeout_secs: Option<u64>,
1989    /// Verification command override (e.g. `"just check"`). Omitted ⇒ inferred
1990    /// from the repo (justfile → `just check`, Cargo → `cargo test`, …).
1991    #[serde(default, skip_serializing_if = "Option::is_none")]
1992    pub test: Option<String>,
1993    /// Budgets / safety gates for the control loop.
1994    #[serde(default, skip_serializing_if = "Option::is_none")]
1995    pub budgets: Option<CrewBudgets>,
1996}
1997
1998/// `[crew]` dispatch policy (#749 step 2): the operator's structural tightening
1999/// point for crews/teams the overseer fields.
2000///
2001/// A model that fields a crew is the recursion / Confused-Deputy case. Dispatch
2002/// hands each crew `session ⊓ clamp` (the [`crate::Caveats`] meet), so the crew's
2003/// authority is **always `≤ session`** (the overseer cannot escalate by
2004/// dispatching) and **`≤ clamp`** (the operator's bound). With the default
2005/// `clamp = Caveats::top()` the meet is the identity — today's behavior is
2006/// unchanged — while the seam exists for tighter clamps (and the per-subtask
2007/// `team_clamp`, #749 step 8) to plug into.
2008///
2009/// ```toml
2010/// [crew]
2011/// # crews may reach only this host, even if the session's net grant is wider
2012/// [crew.clamp]
2013/// net = { only = ["registry.internal"] }
2014/// ```
2015/// `[plan]` — plan-authoring policy (#819).
2016#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2017pub struct PlanConfig {
2018    /// `[plan.prune]` — the decompose-prune lexicon override.
2019    #[serde(default)]
2020    pub prune: PlanPruneConfig,
2021}
2022
2023/// `[plan.prune]` — droppable, three-Cs override for the #801/#803 planner-
2024/// decomposition prune. The compiled `ACTION_MARKERS` lexicon is the default;
2025/// this table composes with it (remove first, then additions), so a new
2026/// anti-pattern — or un-marking a verb your domain uses for real work — is
2027/// CONFIG, not code. The prune itself stays grade-neutral either way: it only
2028/// removes no-diff leaves before any authority grant (#803's n=5 A/B measured
2029/// no grade lift; it removes a failure *mechanism*).
2030///
2031/// ```toml
2032/// [plan.prune]
2033/// disabled = false
2034/// add_inspect = ["scrutinize"]   # pruned wherever they lead an instruction
2035/// add_gate = ["smoke"]           # pruned only when terminal
2036/// remove = ["review"]            # e.g. a repo where "Review X" edits docs
2037/// ```
2038#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2039pub struct PlanPruneConfig {
2040    /// Turn the prune off entirely (the pre-#803 behavior).
2041    #[serde(default)]
2042    pub disabled: bool,
2043    /// Extra leading verbs classified Inspect (case-insensitive).
2044    #[serde(default)]
2045    pub add_inspect: Vec<String>,
2046    /// Extra leading verbs classified Gate (case-insensitive).
2047    #[serde(default)]
2048    pub add_gate: Vec<String>,
2049    /// Verbs to REMOVE from the effective lexicon (builtin or added).
2050    #[serde(default)]
2051    pub remove: Vec<String>,
2052}
2053
2054#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2055pub struct CrewPolicyConfig {
2056    /// The authority **clamp** dispatched crews are met against
2057    /// (`child = session ⊓ clamp`). Defaults to `Caveats::top()` (identity meet —
2058    /// behavior unchanged). Tighten an axis here to bound every crew below the
2059    /// session ceiling; later steps (#749 step 8) compose a per-subtask clamp on
2060    /// top of this at the same `dispatch` seam.
2061    #[serde(default)]
2062    pub clamp: crate::caveats::Caveats,
2063}
2064
2065/// Budgets + review gates for a crew's control loop (`crew-loadout.md` §budgets).
2066/// Consumed by the front door; an honest cap-exit at `max_attempts` returns
2067/// `NeedsHumanReview`, never a false success.
2068#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2069pub struct CrewBudgets {
2070    #[serde(default, skip_serializing_if = "Option::is_none")]
2071    pub max_attempts: Option<u32>,
2072    #[serde(default, skip_serializing_if = "Option::is_none")]
2073    pub max_files_touched: Option<u32>,
2074    #[serde(default, skip_serializing_if = "Option::is_none")]
2075    pub max_lines_changed: Option<u32>,
2076    /// Topics that force a human-review pause (e.g. `["auth","crypto","migrations"]`).
2077    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2078    pub require_human_review_on: Vec<String>,
2079}
2080
2081impl Crew {
2082    /// Validate the crew's role references against `cfg`: each named role
2083    /// (`planner`/`navigator`/`triage`) must name a known `[loadouts.<name>]`,
2084    /// and that loadout must itself validate (so a crew transitively checks the
2085    /// whole `crew → loadout → {backend,bundle,profile}` chain). A dangling role
2086    /// is a hard error — a crew that silently dropped a worker would be a false
2087    /// claim.
2088    ///
2089    /// # Errors
2090    /// The first dangling or invalid role reference, as a message.
2091    pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
2092        let check = |label: &str, name: &str| -> std::result::Result<(), String> {
2093            let loadout = cfg.loadouts.get(name).ok_or_else(|| {
2094                let known = if cfg.loadouts.is_empty() {
2095                    "none defined".to_string()
2096                } else {
2097                    cfg.loadouts.keys().cloned().collect::<Vec<_>>().join(", ")
2098                };
2099                format!(
2100                    "crew {label} '{name}': no [loadouts] entry named '{name}' (known: {known})"
2101                )
2102            })?;
2103            loadout
2104                .validate(cfg)
2105                .map_err(|e| format!("crew {label} '{name}': {e}"))
2106        };
2107        check("planner", &self.planner)?;
2108        if let Some(nav) = &self.navigator {
2109            check("navigator", nav)?;
2110        }
2111        if let Some(tri) = &self.triage {
2112            check("triage", tri)?;
2113        }
2114        Ok(())
2115    }
2116}
2117
2118// ---------------------------------------------------------------------------
2119// Tool permissions — preset policies, lowered to attenuated capabilities
2120// ---------------------------------------------------------------------------
2121
2122/// A named tool-permission preset for the TUI tool loop.
2123///
2124/// Each preset selects a [`crate::Caveats`] *policy* via
2125/// [`ToolPermissions::to_caveats`]; the host (`newt-identity`) then lowers that
2126/// policy into a signed, attenuation-only capability for enforcement. A preset
2127/// is a name-based convenience, **not** a capability itself — the unforgeable
2128/// authority is the signed `AgentKey` delegation. `Custom` means the user has
2129/// added commands beyond a canned preset; it carries `WorkspaceDev` authority
2130/// plus those extras (it does **not** grant full access).
2131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2132#[serde(rename_all = "snake_case")]
2133pub enum PermissionPreset {
2134    /// Read files and list dirs only; no writes, no commands.
2135    ReadOnly,
2136    /// Read + write within the workspace; no shell commands.
2137    WorkspaceEdit,
2138    /// Read, write workspace, run a conservative set of dev tools.
2139    /// See [`ToolPermissions::to_caveats`] for the exact allowlist.
2140    #[default]
2141    WorkspaceDev,
2142    /// Unrestricted — `Caveats::top()`. `write_file` still prompts y/N.
2143    FullAccess,
2144    /// User has added commands beyond a canned preset; carries `WorkspaceDev`
2145    /// authority plus those `extra_exec` entries — **not** full access.
2146    Custom,
2147}
2148
2149/// Which shell **engine** interprets `run_command` — the ADR 0005 D2 seam. This
2150/// is the *engine* axis (what parses/runs the command line); the *L3 backend*
2151/// axis (Landlock/Seatbelt/AppContainer, the kernel fence) is auto-selected
2152/// per-OS and is **not** chosen here. "Landlock vs brush" is really "the `host`
2153/// engine (guarantee rests entirely on the kernel fence) vs the `brush` engine
2154/// (L2 interceptor confines in-process, with the fence as an added backstop)."
2155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2156#[serde(rename_all = "kebab-case")]
2157pub enum ShellEngine {
2158    /// bridle's argv + safe-subset parser: refuses `$(...)`/backticks/dynamic
2159    /// constructs by design and spawns argv directly. Portable default.
2160    #[default]
2161    SafeSubset,
2162    /// The sandboxed-host engine (ADR 0019): real `/bin/sh -c` with the whole
2163    /// process tree in the L3 jail. Full grammar; the guarantee rests entirely
2164    /// on the kernel fence. Refuses a *restricted* `exec`/`net` grant. Needs a
2165    /// host `/bin/sh`. `--full-access` auto-selects this.
2166    Host,
2167    /// The carried brush engine (bash-in-Rust + the L2 `CommandInterceptor`):
2168    /// full grammar, in-process, cross-platform, and the only engine that also
2169    /// confines a *restricted* `exec`/`net` grant. Requires the `brush` build
2170    /// (agent-bridle#20 / Track 2); until that ships, selecting `brush` falls
2171    /// back to `host` with a warning.
2172    Brush,
2173}
2174
2175impl ShellEngine {
2176    /// The canonical config/flag token (`kebab-case`).
2177    #[must_use]
2178    pub fn as_str(&self) -> &'static str {
2179        match self {
2180            Self::SafeSubset => "safe-subset",
2181            Self::Host => "host",
2182            Self::Brush => "brush",
2183        }
2184    }
2185}
2186
2187impl std::fmt::Display for ShellEngine {
2188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2189        f.write_str(self.as_str())
2190    }
2191}
2192
2193impl std::str::FromStr for ShellEngine {
2194    type Err = String;
2195
2196    /// Parse a shell-engine token. Accepts the canonical names plus intuitive
2197    /// aliases (`subset`/`safe`, `sandbox-host`/`landlock`, `brush-ocap`), so a
2198    /// user thinking in "landlock vs brush-ocap" terms still resolves correctly.
2199    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
2200        match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
2201            "safe-subset" | "subset" | "safe" => Ok(Self::SafeSubset),
2202            "host" | "sandbox-host" | "landlock" | "seatbelt" => Ok(Self::Host),
2203            "brush" | "brush-ocap" => Ok(Self::Brush),
2204            other => Err(format!(
2205                "unknown shell engine '{other}' (expected one of: safe-subset, host, brush)"
2206            )),
2207        }
2208    }
2209}
2210
2211/// `[shell]` — engine selection for `run_command`. `engine = None` (the field
2212/// unset) is deliberately distinct from an explicit choice: an unset engine lets
2213/// `--full-access` auto-upgrade to `host` (see [`resolve_shell_engine`]).
2214#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2215#[serde(default)]
2216pub struct ShellConfig {
2217    /// The selected engine, or `None` to accept the context default.
2218    #[serde(default, skip_serializing_if = "Option::is_none")]
2219    pub engine: Option<ShellEngine>,
2220}
2221
2222/// The engine `--full-access` auto-selects when none is set explicitly: `host`
2223/// on unix (a real `/bin/sh` in the kernel jail), but **`brush` on Windows** —
2224/// host-shell spawns `/bin/sh -c`, which Windows lacks, so the cross-platform
2225/// carried brush engine is the full-grammar option there (with a Windows-usage
2226/// warning surfaced at selection).
2227#[must_use]
2228pub fn full_access_default_engine() -> ShellEngine {
2229    #[cfg(windows)]
2230    {
2231        ShellEngine::Brush
2232    }
2233    #[cfg(not(windows))]
2234    {
2235        ShellEngine::Host
2236    }
2237}
2238
2239/// Resolve the effective shell engine in precedence order: an explicit CLI
2240/// `--shell-engine` wins, then the `[shell] engine` config key, then — only when
2241/// neither was set — `--full-access` auto-upgrades (to `host` on unix, `brush`
2242/// on Windows), otherwise the `safe-subset` default. Keeping the auto-upgrade
2243/// *last* means an explicit choice is never silently overridden.
2244#[must_use]
2245pub fn resolve_shell_engine(
2246    cli: Option<ShellEngine>,
2247    configured: Option<ShellEngine>,
2248    full_access: bool,
2249) -> ShellEngine {
2250    cli.or(configured).unwrap_or(if full_access {
2251        full_access_default_engine()
2252    } else {
2253        ShellEngine::SafeSubset
2254    })
2255}
2256
2257/// The OCAP **L3 backend** (the kernel fence) for this platform, and whether it
2258/// is active on this host. This is the axis *separate* from the shell engine
2259/// ([`ShellEngine`]): the engine parses/runs the command line (L2), the backend
2260/// confines it in the kernel (L3). Surfaced by `newt doctor` (#926) so the
2261/// operator can see what actually enforces a restricted grant here. A restricted
2262/// `fs` grant is only real when the backend is available; otherwise it is
2263/// honestly advisory (agent-bridle reports `sandbox_kind = None`).
2264#[must_use]
2265pub fn ocap_l3_backend() -> (&'static str, bool) {
2266    #[cfg(target_os = "linux")]
2267    {
2268        ("Landlock", agent_bridle::landlock_is_supported())
2269    }
2270    #[cfg(target_os = "macos")]
2271    {
2272        ("Seatbelt", agent_bridle::seatbelt_is_supported())
2273    }
2274    #[cfg(target_os = "windows")]
2275    {
2276        ("AppContainer", true)
2277    }
2278    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
2279    {
2280        ("none", false)
2281    }
2282}
2283
2284#[cfg(test)]
2285mod shell_engine_tests {
2286    use super::{full_access_default_engine, resolve_shell_engine, ShellConfig, ShellEngine};
2287
2288    #[test]
2289    fn from_str_canonical_and_aliases() {
2290        assert_eq!(
2291            "safe-subset".parse::<ShellEngine>().unwrap(),
2292            ShellEngine::SafeSubset
2293        );
2294        assert_eq!(
2295            "subset".parse::<ShellEngine>().unwrap(),
2296            ShellEngine::SafeSubset
2297        );
2298        assert_eq!("host".parse::<ShellEngine>().unwrap(), ShellEngine::Host);
2299        // A user thinking "landlock vs brush" resolves `landlock` → the host
2300        // engine (whose guarantee rests on the Landlock/Seatbelt fence).
2301        assert_eq!(
2302            "landlock".parse::<ShellEngine>().unwrap(),
2303            ShellEngine::Host
2304        );
2305        assert_eq!("BRUSH".parse::<ShellEngine>().unwrap(), ShellEngine::Brush);
2306        assert_eq!(
2307            "brush-ocap".parse::<ShellEngine>().unwrap(),
2308            ShellEngine::Brush
2309        );
2310        assert!("bogus".parse::<ShellEngine>().is_err());
2311    }
2312
2313    #[test]
2314    fn as_str_roundtrips() {
2315        for e in [
2316            ShellEngine::SafeSubset,
2317            ShellEngine::Host,
2318            ShellEngine::Brush,
2319        ] {
2320            assert_eq!(e.as_str().parse::<ShellEngine>().unwrap(), e);
2321        }
2322    }
2323
2324    #[test]
2325    fn resolve_flag_wins_over_everything() {
2326        assert_eq!(
2327            resolve_shell_engine(
2328                Some(ShellEngine::SafeSubset),
2329                Some(ShellEngine::Brush),
2330                true
2331            ),
2332            ShellEngine::SafeSubset,
2333            "explicit --shell-engine wins even over config and --full-access"
2334        );
2335    }
2336
2337    #[test]
2338    fn resolve_config_wins_over_full_access_auto_upgrade() {
2339        assert_eq!(
2340            resolve_shell_engine(None, Some(ShellEngine::SafeSubset), true),
2341            ShellEngine::SafeSubset,
2342            "an explicit [shell] engine is not overridden by --full-access"
2343        );
2344    }
2345
2346    #[test]
2347    fn resolve_full_access_auto_upgrades_when_unset() {
2348        // `host` on unix, `brush` on Windows (host-shell needs `/bin/sh`).
2349        assert_eq!(
2350            resolve_shell_engine(None, None, true),
2351            full_access_default_engine()
2352        );
2353        #[cfg(not(windows))]
2354        assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Host);
2355        #[cfg(windows)]
2356        assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Brush);
2357    }
2358
2359    #[test]
2360    fn resolve_defaults_to_safe_subset() {
2361        assert_eq!(
2362            resolve_shell_engine(None, None, false),
2363            ShellEngine::SafeSubset
2364        );
2365    }
2366
2367    #[test]
2368    fn shell_config_deserializes_kebab_case() {
2369        let cfg: ShellConfig = toml::from_str("engine = \"host\"").unwrap();
2370        assert_eq!(cfg.engine, Some(ShellEngine::Host));
2371        let cfg: ShellConfig = toml::from_str("engine = \"safe-subset\"").unwrap();
2372        assert_eq!(cfg.engine, Some(ShellEngine::SafeSubset));
2373    }
2374}
2375
2376impl PermissionPreset {
2377    pub const ALL: [Self; 4] = [
2378        Self::ReadOnly,
2379        Self::WorkspaceEdit,
2380        Self::WorkspaceDev,
2381        Self::FullAccess,
2382    ];
2383
2384    pub fn as_str(&self) -> &'static str {
2385        match self {
2386            Self::ReadOnly => "read_only",
2387            Self::WorkspaceEdit => "workspace_edit",
2388            Self::WorkspaceDev => "workspace_dev",
2389            Self::FullAccess => "full_access",
2390            Self::Custom => "custom",
2391        }
2392    }
2393
2394    /// Cycle through the four user-visible presets (skips `Custom`).
2395    pub fn toggle(&self) -> Self {
2396        let idx = Self::ALL.iter().position(|p| p == self).unwrap_or(2);
2397        Self::ALL[(idx + 1) % Self::ALL.len()].clone()
2398    }
2399
2400    pub fn description(&self) -> &'static str {
2401        match self {
2402            Self::ReadOnly => "read files + list dirs; no writes, no commands",
2403            Self::WorkspaceEdit => "read + write workspace; no shell commands",
2404            Self::WorkspaceDev => "read, write workspace, run: cargo just git grep rg fd ...",
2405            Self::FullAccess => "unrestricted (prompts y/N before each write)",
2406            Self::Custom => "workspace-dev tools plus your extra commands",
2407        }
2408    }
2409}
2410
2411/// Permission configuration stored under `[tui.permissions]` in `newt.toml`.
2412///
2413/// Call [`ToolPermissions::to_caveats`] to obtain the runtime [`crate::Caveats`]
2414/// enforced by every `execute_tool` dispatch.
2415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2416#[serde(default)]
2417pub struct ToolPermissions {
2418    /// The active preset.
2419    pub preset: PermissionPreset,
2420
2421    /// Extra commands allowed beyond the `WorkspaceDev` built-in set.
2422    /// Only consulted when `preset == WorkspaceDev` or `Custom`.
2423    /// Stored as leading tokens, e.g. `["bacon", "make"]`.
2424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2425    pub extra_exec: Vec<String>,
2426
2427    /// Hosts the agent may reach with `web_fetch` (the `net` capability axis).
2428    ///
2429    /// Empty (the default) = **no network** — `web_fetch` is denied. A single
2430    /// `"*"` grants **all** hosts (still SSRF-screened + DNS-rebind-pinned by the
2431    /// web tool). Otherwise an exact host allowlist, e.g.
2432    /// `["docs.rs", "raw.githubusercontent.com"]`. Applies to every preset
2433    /// except `FullAccess` (which is already unrestricted).
2434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2435    pub net: Vec<String>,
2436
2437    /// Prompt the human when a tool call is denied by the session's caveats
2438    /// (issue #263): allow once / allow for this session / deny. Default
2439    /// **false** — a denial fails the call exactly as before (deny-by-default
2440    /// stays the posture). Interactive TUI only; headless paths (ACP worker,
2441    /// `newt-eval`) never prompt regardless. Also enabled per-run via the
2442    /// `--prompt-for-permissions` CLI flag. Every prompted decision is
2443    /// recorded to `~/.newt/permission-log.jsonl` for later review.
2444    #[serde(default)]
2445    pub prompt: bool,
2446}
2447
2448impl Default for ToolPermissions {
2449    fn default() -> Self {
2450        Self {
2451            preset: PermissionPreset::WorkspaceDev,
2452            extra_exec: Vec::new(),
2453            net: Vec::new(),
2454            prompt: false,
2455        }
2456    }
2457}
2458
2459impl ToolPermissions {
2460    /// Built-in exec allowlist for the `WorkspaceDev` preset.
2461    const WORKSPACE_DEV_EXEC: &'static [&'static str] = &[
2462        "cargo",
2463        // rustc must be here: cargo spawns it as a subprocess to compile and
2464        // test. Without it, `cargo test` fails with "could not execute rustc".
2465        // rustfmt and clippy-driver are already present; this was an oversight.
2466        "rustc",
2467        "just",
2468        "git",
2469        "grep",
2470        "rg",
2471        "ripgrep",
2472        "fd",
2473        "find",
2474        "cat",
2475        "ls",
2476        "echo",
2477        "pwd",
2478        "true",
2479        "false",
2480        "head",
2481        "tail",
2482        "wc",
2483        "sort",
2484        "uniq",
2485        "diff",
2486        "patch",
2487        "rustfmt",
2488        "clippy-driver",
2489        "rustup",
2490        // Polyglot dev tools reached for routinely in a mixed workspace. Same
2491        // risk tier as cargo/git — WorkspaceDev already grants workspace write
2492        // and the full Rust toolchain. Anything outside this set can still be
2493        // opted in per-config via `[tui.permissions] extra_exec = [...]`.
2494        "gh",
2495        "python",
2496        "python3",
2497        "pip",
2498        "npm",
2499        "node",
2500        "make",
2501        "jq",
2502        "curl",
2503        "awk",
2504        "sed",
2505        "cut",
2506        "xargs",
2507        "which",
2508        "env",
2509    ];
2510
2511    /// Build the runtime `Caveats` for this permission configuration.
2512    ///
2513    /// `workspace` is the absolute path to the current workspace directory;
2514    /// it is stored in `Scope::Only` so the TUI enforcement layer can do
2515    /// prefix matching (path within workspace → permitted).
2516    ///
2517    /// Note: the `Caveats` lattice uses exact-set semantics; prefix matching
2518    /// is the responsibility of the enforcement site (`tui_permits_path` in
2519    /// newt-tui), not this algebra. This is an intentional layer separation.
2520    pub fn to_caveats(&self, workspace: &str) -> crate::caveats::Caveats {
2521        use crate::caveats::{Caveats, CountBound, Scope};
2522
2523        let ws = workspace.to_string();
2524        let net = self.net_scope();
2525
2526        match self.preset {
2527            PermissionPreset::ReadOnly => Caveats {
2528                fs_read: Scope::All,
2529                fs_write: Scope::none(),
2530                exec: Scope::none(),
2531                net,
2532                max_calls: CountBound::Unlimited,
2533                valid_for_generation: Scope::All,
2534            },
2535
2536            PermissionPreset::WorkspaceEdit => Caveats {
2537                fs_read: Scope::All,
2538                fs_write: Scope::only([ws]),
2539                exec: Scope::none(),
2540                net,
2541                max_calls: CountBound::Unlimited,
2542                valid_for_generation: Scope::All,
2543            },
2544
2545            // `Custom` shares this arm: editing `extra_exec` keeps WorkspaceDev
2546            // authority plus the added commands. It must NOT escalate to
2547            // `top()` — adding one command to an allowlist should never grant
2548            // full access.
2549            PermissionPreset::WorkspaceDev | PermissionPreset::Custom => {
2550                let mut allowed: std::collections::BTreeSet<String> = Self::WORKSPACE_DEV_EXEC
2551                    .iter()
2552                    .map(|s| s.to_string())
2553                    .collect();
2554                for cmd in &self.extra_exec {
2555                    allowed.insert(cmd.clone());
2556                }
2557                Caveats {
2558                    fs_read: Scope::All,
2559                    fs_write: Scope::only([ws]),
2560                    exec: Scope::Only(allowed),
2561                    net,
2562                    max_calls: CountBound::Unlimited,
2563                    valid_for_generation: Scope::All,
2564                }
2565            }
2566
2567            PermissionPreset::FullAccess => Caveats::top(),
2568        }
2569    }
2570
2571    /// Lower the configured `net` allowlist into a capability [`Scope`].
2572    ///
2573    /// Empty → `none` (no network). A `"*"` entry → `All` (every host, still
2574    /// SSRF-screened by the web tool). Otherwise an exact host allowlist.
2575    fn net_scope(&self) -> crate::caveats::Scope<String> {
2576        use crate::caveats::Scope;
2577        if self.net.is_empty() {
2578            Scope::none()
2579        } else if self.net.iter().any(|h| h == "*") {
2580            Scope::All
2581        } else {
2582            Scope::only(self.net.iter().cloned())
2583        }
2584    }
2585}
2586
2587/// Key binding style for the chat REPL input line.
2588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2589#[serde(rename_all = "lowercase")]
2590pub enum EditMode {
2591    /// Readline / emacs-style bindings.
2592    Emacs,
2593    /// Vi / vim-style bindings — Esc for normal mode, i for insert.
2594    Vi,
2595    /// Nano-style: modeless, emacs-like bindings (the **default** — the most
2596    /// broadly approachable). Behaves like `Emacs` on the lean surface; it is a
2597    /// distinct, selectable label, and the rich-tui surface shows the nano `^G`
2598    /// help hint for it.
2599    #[default]
2600    Nano,
2601}
2602
2603impl EditMode {
2604    pub fn as_str(&self) -> &'static str {
2605        match self {
2606            Self::Emacs => "emacs",
2607            Self::Vi => "vi",
2608            Self::Nano => "nano",
2609        }
2610    }
2611
2612    /// Cycle through the modes (used by a single-key toggle): emacs → vi →
2613    /// nano → emacs.
2614    pub fn toggle(&self) -> Self {
2615        match self {
2616            Self::Emacs => Self::Vi,
2617            Self::Vi => Self::Nano,
2618            Self::Nano => Self::Emacs,
2619        }
2620    }
2621}
2622
2623impl Default for TuiConfig {
2624    fn default() -> Self {
2625        Self {
2626            chat_style: ChatStyle::Compact,
2627            prompt: None,
2628            no_splash: false,
2629            edit_mode: EditMode::Nano,
2630            gutter: None,
2631            footer: FooterMode::Auto,
2632            color: ColorMode::Auto,
2633            thinking: ThinkingMode::Stream,
2634            tool_output_lines: default_tool_output_lines(),
2635            max_tool_rounds: default_max_tool_rounds(),
2636            workflow_grace_rounds: default_workflow_grace_rounds(),
2637            permissions: ToolPermissions::default(),
2638            debug: None,
2639            trace: None,
2640            build_check_cmd: None,
2641            num_ctx: None,
2642            real_context_discovery: None,
2643            connect_timeout_secs: default_connect_timeout_secs(),
2644            inference_timeout_secs: default_inference_timeout_secs(),
2645            keep_alive: default_keep_alive(),
2646            markdown: MarkdownMode::default(),
2647            mid_loop_trim_threshold: default_mid_loop_trim_threshold(),
2648            mid_loop_trim_tokens: None,
2649            sanitize_mcp_server_names: default_sanitize_mcp_server_names(),
2650            mcp_allow_insecure_hosts: Vec::new(),
2651            allow_bang_escape: default_allow_bang_escape(),
2652        }
2653    }
2654}
2655
2656/// Chat REPL display density.
2657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2658#[serde(rename_all = "lowercase")]
2659pub enum ChatStyle {
2660    /// Just the caret symbol — no "newt" / "you" labels.
2661    #[default]
2662    Compact,
2663    /// Full "newt ▸" / "you $" labels before each message.
2664    Verbose,
2665}
2666
2667impl ChatStyle {
2668    pub fn as_str(&self) -> &'static str {
2669        match self {
2670            Self::Compact => "compact",
2671            Self::Verbose => "verbose",
2672        }
2673    }
2674
2675    pub fn toggle(&self) -> Self {
2676        match self {
2677            Self::Compact => Self::Verbose,
2678            Self::Verbose => Self::Compact,
2679        }
2680    }
2681}
2682
2683/// The wire protocol an inference backend speaks.
2684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2685#[serde(rename_all = "lowercase")]
2686pub enum BackendKind {
2687    /// Ollama's native `POST /api/chat` API (the historical default).
2688    #[default]
2689    Ollama,
2690    /// An OpenAI-compatible HTTP API (`POST /v1/chat/completions`,
2691    /// `GET /v1/models`): vLLM, llama.cpp's server, or any hosted
2692    /// OpenAI-compatible endpoint. Optionally authenticated with a
2693    /// bearer token (see [`BackendConfig::api_key_file`] /
2694    /// [`BackendConfig::api_key_env`]).
2695    #[serde(alias = "vllm", alias = "openai-compatible")]
2696    Openai,
2697    /// An **in-process** inference backend — no HTTP, no external server. Loads a
2698    /// small quantized (GGUF) model and runs it in-tree (Metal-accelerated on
2699    /// Apple Silicon). Opt-in behind the `embedded` cargo feature (default-off);
2700    /// when the feature is absent, selecting it is a clear build-time-off error,
2701    /// never a silent fallback. Intended for the summarizer + small auxiliary
2702    /// calls so they never contend with the primary model (#639).
2703    Embedded,
2704}
2705
2706impl BackendKind {
2707    /// Short human label for the wire protocol — shown in the ready preamble and
2708    /// the `/backends` list. Note newt models the *protocol*, so vLLM, llama.cpp,
2709    /// and hosted OpenAI all read as `openai` (vLLM has no distinct wire form).
2710    pub fn label(self) -> &'static str {
2711        match self {
2712            Self::Ollama => "ollama",
2713            Self::Openai => "openai",
2714            Self::Embedded => "embedded",
2715        }
2716    }
2717}
2718
2719/// Which OpenAI HTTP surface a `kind = "openai"` backend speaks.
2720///
2721/// `chat_completions` (the default) is the classic `POST /v1/chat/completions`.
2722/// `responses` is the newer `POST /v1/responses` — required by models that
2723/// OpenAI serves *only* there (e.g. `gpt-5-codex`, which 404s on
2724/// chat/completions with "only supported in v1/responses"). Ignored for
2725/// `kind = "ollama"`.
2726#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2727#[serde(rename_all = "snake_case")]
2728pub enum OpenAiApi {
2729    /// `POST /v1/chat/completions` (the historical default).
2730    #[default]
2731    #[serde(alias = "chat", alias = "completions")]
2732    ChatCompletions,
2733    /// `POST /v1/responses` (the newer Responses API).
2734    Responses,
2735}
2736
2737/// A single inference backend entry.
2738///
2739/// Two ways to define one: an inline `[[backends]]` array element in
2740/// `config.toml`, or a per-file drop-in `~/.newt/backends/<name>.toml` (the
2741/// filename stem is the `name`, so a drop-in omits it). `name` and `tiers`
2742/// therefore default — a minimal drop-in is just `endpoint` + `model`.
2743#[derive(Debug, Clone, Serialize, Deserialize)]
2744pub struct BackendConfig {
2745    /// Backend name. For a per-file drop-in this is overwritten by the filename
2746    /// stem, so the file body may omit it.
2747    #[serde(default)]
2748    pub name: String,
2749    /// HTTP endpoint URL (Ollama / OpenAI). Defaulted so a `kind = "embedded"`
2750    /// backend — which runs in-process and has no URL — can omit it.
2751    #[serde(default)]
2752    pub endpoint: String,
2753    pub model: String,
2754    /// For `kind = "embedded"`: the local GGUF model file (the in-process engine
2755    /// has no `endpoint`). `~/` is expanded at use. Ignored for HTTP backends.
2756    #[serde(default, skip_serializing_if = "Option::is_none")]
2757    pub model_path: Option<String>,
2758    #[serde(default)]
2759    pub tiers: Vec<Tier>,
2760    /// Which wire protocol this backend speaks. Defaults to `ollama`
2761    /// so configs written before this field existed keep working.
2762    #[serde(default)]
2763    pub kind: BackendKind,
2764    /// For `kind = "openai"`: which OpenAI HTTP surface to use
2765    /// (`chat_completions` default, or `responses` for models served only on
2766    /// `/v1/responses`). Ignored for Ollama. The agent loop also auto-falls-back
2767    /// to `responses` if chat/completions 404s with the responses-only error.
2768    #[serde(default)]
2769    pub api: OpenAiApi,
2770    /// Optional path to a file whose first non-empty line is a bearer
2771    /// token, sent as `Authorization: Bearer <token>` by
2772    /// OpenAI-compatible backends. A leading `~/` is expanded to the
2773    /// home directory. Keeping the secret in a file (rather than inline
2774    /// in the config) keeps tokens out of version control.
2775    #[serde(default, skip_serializing_if = "Option::is_none")]
2776    pub api_key_file: Option<String>,
2777    /// Optional environment variable name holding a bearer token. Takes
2778    /// precedence over [`api_key_file`](Self::api_key_file) when both
2779    /// resolve to a non-empty value.
2780    #[serde(default, skip_serializing_if = "Option::is_none")]
2781    pub api_key_env: Option<String>,
2782}
2783
2784impl BackendConfig {
2785    /// Resolve this backend's bearer token, if any.
2786    ///
2787    /// Checks [`api_key_env`](Self::api_key_env) first (environment
2788    /// variable), then [`api_key_file`](Self::api_key_file) (first
2789    /// non-empty line of the file, trimmed). Returns `None` when neither
2790    /// is configured or neither resolves to a non-empty value.
2791    pub fn resolve_api_key(&self) -> Option<String> {
2792        if let Some(var) = &self.api_key_env {
2793            if let Ok(val) = std::env::var(var) {
2794                let val = val.trim();
2795                if !val.is_empty() {
2796                    return Some(val.to_string());
2797                }
2798            }
2799        }
2800        if let Some(path) = &self.api_key_file {
2801            let expanded = expand_tilde(path);
2802            if let Ok(contents) = std::fs::read_to_string(&expanded) {
2803                if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
2804                    return Some(token.to_string());
2805                }
2806            }
2807        }
2808        None
2809    }
2810}
2811
2812/// Dedicated configuration for the compression summarizer, loaded from
2813/// `~/.newt/summarizer.toml` (Step 24.10, #559). An absent file means
2814/// `SummarizerConfig::default()` — every field falls back to the session
2815/// backend, so behavior is unchanged from "summarizer reuses the session
2816/// model".
2817///
2818/// The point of the separate file is the **own-backend** fields
2819/// (`endpoint`/`model`/`kind`/`api_key_file`): a summarizer can run on a
2820/// different, fast box than the session model instead of contending with it
2821/// (the #548 field incident — a slow primary summarizer stalled ~189s before
2822/// the static marker). `timeout_secs` / `retries` / `fallback_model` are the
2823/// knobs that used to live under `[tui]` (moved here in 24.10).
2824///
2825/// Example `~/.newt/summarizer.toml`:
2826/// ```toml
2827/// endpoint = "http://REDACTED-HOST:11434"  # default: session backend URL
2828/// model    = "qwen2.5-coder:3b"            # default: session model
2829/// kind     = "ollama"                      # "ollama" | "openai"
2830/// timeout_secs   = 45
2831/// retries        = 1
2832/// fallback_model = "nemotron-mini:4b"      # else preference-list auto-pick (24.9)
2833/// ```
2834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2835#[serde(default)]
2836pub struct SummarizerConfig {
2837    /// Summarizer endpoint URL. `None` ⇒ reuse the session backend's URL.
2838    pub endpoint: Option<String>,
2839    /// Summarizer model. `None` ⇒ reuse the session backend's model.
2840    pub model: Option<String>,
2841    /// Backend protocol. `None` ⇒ reuse the session backend's kind.
2842    pub kind: Option<BackendKind>,
2843    /// For `kind = "embedded"` (#661 group C): the local GGUF model file for the
2844    /// in-process candle summarizer. Ignored for HTTP backends.
2845    #[serde(default, skip_serializing_if = "Option::is_none")]
2846    pub model_path: Option<String>,
2847    /// Bearer-token file (first non-empty line). `None` ⇒ reuse the session key.
2848    pub api_key_file: Option<String>,
2849    /// Bearer-token environment variable (checked before `api_key_file`).
2850    pub api_key_env: Option<String>,
2851    /// Per-request timeout (seconds). Default 60 — cold-loading a big model can
2852    /// legitimately exceed it; raise on a slow box that falls back to the marker.
2853    #[serde(default = "default_summarizer_timeout_secs")]
2854    pub timeout_secs: u64,
2855    /// Retry attempts before the static marker. Default 1 — each attempt can
2856    /// cost the full `timeout_secs` (the #548 189s incident was 3 × 60s).
2857    #[serde(default = "default_summarizer_retries")]
2858    pub retries: u32,
2859    /// Explicit fallback model. `None` ⇒ for an Ollama summarizer backend, the
2860    /// first installed small-model-preference-list entry is auto-picked (24.9).
2861    pub fallback_model: Option<String>,
2862    /// `keep_alive` for the warm + summary requests. `None` ⇒ inherit
2863    /// `[tui].keep_alive`.
2864    pub keep_alive: Option<String>,
2865}
2866
2867impl Default for SummarizerConfig {
2868    fn default() -> Self {
2869        Self {
2870            endpoint: None,
2871            model: None,
2872            kind: None,
2873            model_path: None,
2874            api_key_file: None,
2875            api_key_env: None,
2876            timeout_secs: default_summarizer_timeout_secs(),
2877            retries: default_summarizer_retries(),
2878            fallback_model: None,
2879            keep_alive: None,
2880        }
2881    }
2882}
2883
2884impl SummarizerConfig {
2885    /// Parse a `summarizer.toml` body. Pure — fully unit-testable without disk.
2886    pub fn from_toml_str(text: &str) -> Result<Self> {
2887        toml::from_str(text).map_err(|e| NewtError::Config(e.to_string()))
2888    }
2889
2890    /// Load `~/.newt/summarizer.toml` (or `$NEWT_SUMMARIZER_CONFIG`). A missing
2891    /// file is not an error — it yields [`SummarizerConfig::default`] (reuse the
2892    /// session backend). Only a present-but-malformed file errors.
2893    pub fn resolve() -> Result<Self> {
2894        for path in Self::candidate_paths() {
2895            if path.is_file() {
2896                let text = std::fs::read_to_string(&path)?;
2897                return Self::from_toml_str(&text);
2898            }
2899        }
2900        Ok(Self::default())
2901    }
2902
2903    /// Ordered candidate paths for `summarizer.toml`.
2904    fn candidate_paths() -> Vec<PathBuf> {
2905        let mut paths = Vec::new();
2906        if let Ok(p) = std::env::var("NEWT_SUMMARIZER_CONFIG") {
2907            paths.push(PathBuf::from(p));
2908        }
2909        if let Some(dir) = Config::user_config_dir() {
2910            paths.push(dir.join("summarizer.toml"));
2911        }
2912        paths
2913    }
2914
2915    /// Resolve this summarizer's bearer token (env var first, then file), or
2916    /// `None` — mirrors [`BackendConfig::resolve_api_key`].
2917    pub fn resolve_api_key(&self) -> Option<String> {
2918        if let Some(var) = &self.api_key_env {
2919            if let Ok(val) = std::env::var(var) {
2920                let val = val.trim();
2921                if !val.is_empty() {
2922                    return Some(val.to_string());
2923                }
2924            }
2925        }
2926        if let Some(path) = &self.api_key_file {
2927            let expanded = expand_tilde(path);
2928            if let Ok(contents) = std::fs::read_to_string(&expanded) {
2929                if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
2930                    return Some(token.to_string());
2931                }
2932            }
2933        }
2934        None
2935    }
2936}
2937
2938/// A subprocess provider-plugin entry.
2939#[derive(Debug, Clone, Serialize, Deserialize)]
2940pub struct ProviderConfig {
2941    pub name: String,
2942    pub command: String,
2943    #[serde(default, skip_serializing_if = "Option::is_none")]
2944    pub model: Option<String>,
2945    #[serde(default)]
2946    pub env_pass: Vec<String>,
2947    pub tiers: Vec<Tier>,
2948}
2949
2950// ---------------------------------------------------------------------------
2951// Default
2952// ---------------------------------------------------------------------------
2953
2954/// The last-resort localhost Ollama backend: used both as `Config::default()`'s
2955/// sole backend (no config file at all) and as the [`Config::resolve`] fallback
2956/// when neither inline `[[backends]]` nor per-file drop-ins supply any, so a
2957/// bare install still talks to a local Ollama.
2958fn fallback_localhost_backend() -> BackendConfig {
2959    BackendConfig {
2960        name: "ollama".into(),
2961        endpoint: "http://127.0.0.1:11434".into(),
2962        model: "llama3.1:8b".into(),
2963        model_path: None,
2964        tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
2965        kind: BackendKind::Ollama,
2966        api: Default::default(),
2967        api_key_file: None,
2968        api_key_env: None,
2969    }
2970}
2971
2972impl Default for Config {
2973    fn default() -> Self {
2974        Self {
2975            backends: vec![fallback_localhost_backend()],
2976            providers: Vec::new(),
2977            scratch: None,
2978            default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
2979            lifecycle: None,
2980            dgx: None,
2981            tui: None,
2982            shell: None,
2983            context: None,
2984            tools: None,
2985            pricing: None,
2986            memory: None,
2987            agents: AgentsConfig::default(),
2988            mcp_servers: Vec::new(),
2989            logs: None,
2990            skills: None,
2991            model_tuning: Vec::new(),
2992            conversations: None,
2993            merge: None,
2994            permission_presets: std::collections::BTreeMap::new(),
2995            modes: std::collections::BTreeMap::new(),
2996            profiles: std::collections::BTreeMap::new(),
2997            bundles: std::collections::BTreeMap::new(),
2998            loadouts: std::collections::BTreeMap::new(),
2999            crews: std::collections::BTreeMap::new(),
3000            crew: None,
3001            plan: None,
3002        }
3003    }
3004}
3005
3006// ---------------------------------------------------------------------------
3007// Loading
3008// ---------------------------------------------------------------------------
3009
3010impl Config {
3011    /// Load configuration from an explicit file path.
3012    pub fn load(path: &Path) -> Result<Self> {
3013        let text = std::fs::read_to_string(path)?;
3014        toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
3015    }
3016
3017    /// Resolve configuration by searching well-known locations, then layering a
3018    /// project-local override on top.
3019    ///
3020    /// Base search order (first match wins):
3021    /// 1. `$NEWT_CONFIG` environment variable
3022    /// 2. `./newt.toml`
3023    /// 3. `$NEWT_CONFIG_DIR/config.toml` or `~/.newt/config.toml`
3024    /// 4. `/etc/newt/config.toml`
3025    ///
3026    /// Then, if a project-local `.newt/config.toml` is found by walking up from
3027    /// the current directory (see [`Config::project_config_path`]), it is
3028    /// deep-merged **over** the base so a repo can pin its own models, endpoints,
3029    /// rules, and local stdio MCP services without copying the whole global
3030    /// config. Tables merge recursively (project keys win) and scalars are
3031    /// replaced by the project value. Arrays follow `[merge] arrays` —
3032    /// `"replace"` (default) or `"append"` (see [`ArrayMergeStrategy`]). The
3033    /// project config's `[merge]` setting takes precedence, then the base's.
3034    /// See issue #222.
3035    ///
3036    /// When no project override exists this is byte-for-byte the legacy
3037    /// first-match behavior. Returns `Config::default()` if nothing is found.
3038    pub fn resolve() -> Result<Self> {
3039        let base_path = Self::candidate_paths().into_iter().find(|p| p.is_file());
3040        // A project-local config that *is* the base (e.g. cwd is the project and
3041        // its `.newt/config.toml` already matched) must not be merged onto itself.
3042        let project_path =
3043            Self::project_config_path().filter(|p| Some(p.as_path()) != base_path.as_deref());
3044
3045        let mut cfg = match (&base_path, &project_path) {
3046            // Fast path: no project override → exact legacy behavior.
3047            (Some(p), None) => Self::load(p)?,
3048            (None, None) => Self::default(),
3049            // Project override present → layer it over the base (or the default
3050            // config when there is no base file).
3051            (base, Some(proj)) => {
3052                let mut merged = match base {
3053                    Some(p) => Self::load_value(p)?,
3054                    None => toml::Value::try_from(Self::default())
3055                        .map_err(|e| NewtError::Config(e.to_string()))?,
3056                };
3057                let project_val = Self::load_value(proj)?;
3058                // The merge strategy is itself config: the project declares how
3059                // it wants to be merged (`[merge] arrays = ...`), else the global
3060                // config's setting, else the built-in default (Replace).
3061                let strategy = array_merge_strategy(&project_val, &merged);
3062                merge_toml(&mut merged, project_val, strategy);
3063                merged
3064                    .try_into()
3065                    .map_err(|e| NewtError::Config(e.to_string()))?
3066            }
3067        };
3068        // Per-file backends (the endpoint control surface): drop a
3069        // `~/.newt/backends/<name>.toml` to add/override a backend — no
3070        // `config.toml` edit, and no overlapping inline `[[backends]]` to
3071        // hand-deconflict. Runs first so disk loadouts/crews can name a disk
3072        // backend's provider.
3073        cfg.merge_disk_backends();
3074        // Localhost fallback: a config that declared no inline `[[backends]]`
3075        // deserializes to empty (see the field doc); if no drop-in supplied one
3076        // either, restore the bare-install localhost Ollama so newt still has a
3077        // backend to talk to.
3078        if cfg.backends.is_empty() {
3079            cfg.backends.push(fallback_localhost_backend());
3080        }
3081        // Per-file bundles (the model-support-kit control surface): drop a
3082        // `~/.newt/bundles/<name>.toml` to add a bundle — no `config.toml` edit.
3083        cfg.merge_disk_bundles();
3084        // Per-file loadouts (the shareable composition control surface): drop a
3085        // `~/.newt/loadouts/<name>.toml` to add a loadout — no `config.toml` edit.
3086        // Runs after bundles so a disk loadout may name a disk bundle.
3087        cfg.merge_disk_loadouts();
3088        // Per-file crews (the role-ensemble control surface): drop a
3089        // `~/.newt/crews/<name>.toml` to add a crew — no `config.toml` edit.
3090        // Runs after loadouts so a disk crew may name a disk loadout.
3091        cfg.merge_disk_crews();
3092        // Per-file DGX nodes (the per-host control surface): drop a
3093        // `~/.newt/dgx/<name>.toml` to add/override a DGX node — each host its
3094        // own file, no inline `[[dgx.nodes]]`. The active selection
3095        // (active_node/active_endpoint/active_model) stays in `[dgx]`.
3096        cfg.merge_disk_dgx_nodes();
3097        // #726: push the resolved `[tools] max_output_tokens` into the
3098        // process-wide model-facing output budget. `Config::resolve` is the
3099        // single canonical config-application entry, so every consumer (TUI,
3100        // cowork driver, eval) gets the override here without threading a new
3101        // `usize` through `ChatCtx` + `execute_tool` + every call site. Idempotent.
3102        crate::agentic::set_max_output_tokens(cfg.max_output_tokens());
3103        crate::agentic::set_output_head_tokens(cfg.output_head_tokens());
3104        // #880: publish the repo `[lifecycle]` overrides the same way — the single
3105        // canonical config-application entry — so the crew's normalize (and future
3106        // phase consumers) honor `.newt/config.toml`.
3107        if let Some(lc) = &cfg.lifecycle {
3108            crate::tooling::set_lifecycle_override(lc.clone());
3109        }
3110        // #844: publish `[scratch] dir` the same way — so crew worktrees / the crew
3111        // target / session plans honor it. `NEWT_SCRATCH_DIR` still overrides.
3112        if let Some(dir) = cfg.scratch.as_ref().and_then(|s| s.dir.as_deref()) {
3113            crate::scratch::set_scratch_dir(dir);
3114        }
3115        Ok(cfg)
3116    }
3117
3118    /// The configured model-facing output token budget (`[tools]
3119    /// max_output_tokens`), or the built-in default when `[tools]` is absent
3120    /// (#726). `0` means "no cap". See [`ToolsConfig`].
3121    pub fn max_output_tokens(&self) -> usize {
3122        self.tools
3123            .as_ref()
3124            .map(|t| t.max_output_tokens)
3125            .unwrap_or_else(default_max_output_tokens)
3126    }
3127
3128    /// The configured head allocation for oversized `run_command` output
3129    /// (`[tools] output_head_tokens`), or the built-in tail-biased default.
3130    pub fn output_head_tokens(&self) -> usize {
3131        self.tools
3132            .as_ref()
3133            .map(|t| t.output_head_tokens)
3134            .unwrap_or_else(default_output_head_tokens)
3135    }
3136
3137    /// Merge per-file backends from the `backends/` dirs next to the config:
3138    /// `~/.newt/backends/*.toml` first, then the project `.newt/backends/` (so
3139    /// project overrides home overrides inline `[[backends]]`). Filename stem =
3140    /// backend name. A malformed drop-in is skipped with a warning; it must not
3141    /// break startup.
3142    fn merge_disk_backends(&mut self) {
3143        if let Some(dir) = Self::user_config_dir() {
3144            self.merge_backends_from_dir(&dir.join("backends"));
3145        }
3146        if let Some(proj) = Self::project_config_path() {
3147            if let Some(parent) = proj.parent() {
3148                self.merge_backends_from_dir(&parent.join("backends"));
3149            }
3150        }
3151    }
3152
3153    /// Load `<dir>/*.toml` as backends (filename stem = name) into
3154    /// `self.backends`. A drop-in **replaces** an existing backend of the same
3155    /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
3156    /// an inline `[[backends]]` named `dgx1` without a duplicate. A malformed
3157    /// file is skipped with a warning.
3158    fn merge_backends_from_dir(&mut self, dir: &Path) {
3159        let Ok(entries) = std::fs::read_dir(dir) else {
3160            return; // no backends dir — fine
3161        };
3162        let mut paths: Vec<PathBuf> = entries
3163            .flatten()
3164            .map(|e| e.path())
3165            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3166            .collect();
3167        paths.sort();
3168        for path in paths {
3169            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3170                continue;
3171            };
3172            match std::fs::read_to_string(&path).map(|t| toml::from_str::<BackendConfig>(&t)) {
3173                Ok(Ok(mut backend)) => {
3174                    // A backend needs a destination: an HTTP `endpoint`, or — for
3175                    // `kind = "embedded"` — a local `model_path`. Skip those with
3176                    // neither (the "malformed → skip, not fatal" contract; before
3177                    // `endpoint` became defaultable, the missing-endpoint case was
3178                    // a parse error).
3179                    if backend.endpoint.is_empty() && backend.model_path.is_none() {
3180                        tracing::warn!(
3181                            path = %path.display(),
3182                            "skipping backend with neither endpoint nor model_path"
3183                        );
3184                        continue;
3185                    }
3186                    // The filename is authoritative for the name (collision-free).
3187                    backend.name = stem.to_string();
3188                    match self.backends.iter_mut().find(|b| b.name == backend.name) {
3189                        Some(existing) => *existing = backend,
3190                        None => self.backends.push(backend),
3191                    }
3192                }
3193                Ok(Err(e)) => {
3194                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed backend file");
3195                }
3196                Err(_) => {}
3197            }
3198        }
3199    }
3200
3201    /// Merge per-file DGX nodes from the `dgx/` dirs next to the config:
3202    /// `~/.newt/dgx/*.toml` first, then the project `.newt/dgx/` (so project
3203    /// overrides home overrides inline `[[dgx.nodes]]`). Filename stem = node
3204    /// name. A malformed drop-in is skipped with a warning; it must not break
3205    /// startup.
3206    fn merge_disk_dgx_nodes(&mut self) {
3207        if let Some(dir) = Self::user_config_dir() {
3208            self.merge_dgx_nodes_from_dir(&dir.join("dgx"));
3209        }
3210        if let Some(proj) = Self::project_config_path() {
3211            if let Some(parent) = proj.parent() {
3212                self.merge_dgx_nodes_from_dir(&parent.join("dgx"));
3213            }
3214        }
3215    }
3216
3217    /// Load `<dir>/*.toml` as DGX nodes (filename stem = name) into
3218    /// `self.dgx.nodes`. A drop-in **replaces** an existing node of the same
3219    /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
3220    /// an inline `[[dgx.nodes]]` named `dgx1` without a duplicate. The `[dgx]`
3221    /// table is created (default selection) if it was absent. A malformed file
3222    /// is skipped with a warning.
3223    fn merge_dgx_nodes_from_dir(&mut self, dir: &Path) {
3224        let Ok(entries) = std::fs::read_dir(dir) else {
3225            return; // no dgx dir — fine
3226        };
3227        let mut paths: Vec<PathBuf> = entries
3228            .flatten()
3229            .map(|e| e.path())
3230            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3231            .collect();
3232        paths.sort();
3233        for path in paths {
3234            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3235                continue;
3236            };
3237            match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::dgx::DgxNode>(&t))
3238            {
3239                Ok(Ok(mut node)) => {
3240                    // The filename is authoritative for the name (collision-free).
3241                    node.name = stem.to_string();
3242                    let dgx = self.dgx.get_or_insert_with(Default::default);
3243                    match dgx.nodes.iter_mut().find(|n| n.name == node.name) {
3244                        Some(existing) => *existing = node,
3245                        None => dgx.nodes.push(node),
3246                    }
3247                }
3248                Ok(Err(e)) => {
3249                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed dgx node file");
3250                }
3251                Err(_) => {}
3252            }
3253        }
3254    }
3255
3256    /// Merge per-file crews from the `crews/` dirs next to the config:
3257    /// `~/.newt/crews/*.toml` first, then the project `.newt/crews/` (so project
3258    /// overrides home overrides inline `[crews.*]`). Filename stem = crew name. A
3259    /// malformed drop-in is skipped with a warning; references inside a crew are
3260    /// validated when it is selected (`newt crew --crew <name>`), mirroring the
3261    /// inline `[crews.*]` and disk-loadout paths.
3262    fn merge_disk_crews(&mut self) {
3263        if let Some(dir) = Self::user_config_dir() {
3264            self.merge_crews_from_dir(&dir.join("crews"));
3265        }
3266        if let Some(proj) = Self::project_config_path() {
3267            if let Some(parent) = proj.parent() {
3268                self.merge_crews_from_dir(&parent.join("crews"));
3269            }
3270        }
3271    }
3272
3273    /// Load `<dir>/*.toml` as crews (filename stem = name) into `self.crews`,
3274    /// last-wins on a name clash. A malformed file is skipped with a warning.
3275    fn merge_crews_from_dir(&mut self, dir: &Path) {
3276        let Ok(entries) = std::fs::read_dir(dir) else {
3277            return; // no crews dir — fine
3278        };
3279        let mut paths: Vec<PathBuf> = entries
3280            .flatten()
3281            .map(|e| e.path())
3282            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3283            .collect();
3284        paths.sort();
3285        for path in paths {
3286            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3287                continue;
3288            };
3289            match std::fs::read_to_string(&path).map(|t| toml::from_str::<Crew>(&t)) {
3290                Ok(Ok(crew)) => {
3291                    self.crews.insert(stem.to_string(), crew);
3292                }
3293                Ok(Err(e)) => {
3294                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed crew file");
3295                }
3296                Err(_) => {}
3297            }
3298        }
3299    }
3300
3301    /// Merge per-file bundles from the well-known `bundles/` dirs next to the
3302    /// config: `~/.newt/bundles/*.toml` first, then the project `.newt/bundles/`
3303    /// (so project overrides home overrides inline `[bundles.*]`). The filename
3304    /// stem is the bundle name. A malformed drop-in is skipped with a warning — it
3305    /// must not break startup.
3306    fn merge_disk_bundles(&mut self) {
3307        if let Some(dir) = Self::user_config_dir() {
3308            self.merge_bundles_from_dir(&dir.join("bundles"));
3309        }
3310        if let Some(proj) = Self::project_config_path() {
3311            if let Some(parent) = proj.parent() {
3312                self.merge_bundles_from_dir(&parent.join("bundles"));
3313            }
3314        }
3315    }
3316
3317    /// Load `<dir>/*.toml` as bundles (filename stem = name) into `self.bundles`,
3318    /// last-wins on a name clash. A malformed file is skipped with a warning.
3319    fn merge_bundles_from_dir(&mut self, dir: &Path) {
3320        let Ok(entries) = std::fs::read_dir(dir) else {
3321            return; // no bundles dir — fine
3322        };
3323        let mut paths: Vec<PathBuf> = entries
3324            .flatten()
3325            .map(|e| e.path())
3326            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3327            .collect();
3328        paths.sort();
3329        for path in paths {
3330            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3331                continue;
3332            };
3333            match std::fs::read_to_string(&path).map(|t| toml::from_str::<BundleConfig>(&t)) {
3334                Ok(Ok(bundle)) => {
3335                    self.bundles.insert(stem.to_string(), bundle);
3336                }
3337                Ok(Err(e)) => {
3338                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed bundle file");
3339                }
3340                Err(_) => {}
3341            }
3342        }
3343    }
3344
3345    /// Merge per-file loadouts from the well-known `loadouts/` dirs next to the
3346    /// config: `~/.newt/loadouts/*.toml` first, then the project `.newt/loadouts/`
3347    /// (so project overrides home overrides inline `[loadouts.*]`). The filename
3348    /// stem is the loadout name. A malformed drop-in is skipped with a warning — it
3349    /// must not break startup. References *inside* a loadout are validated when it
3350    /// is selected (`--loadout`), not at load, mirroring the inline `[loadouts.*]`
3351    /// path.
3352    fn merge_disk_loadouts(&mut self) {
3353        if let Some(dir) = Self::user_config_dir() {
3354            self.merge_loadouts_from_dir(&dir.join("loadouts"));
3355        }
3356        if let Some(proj) = Self::project_config_path() {
3357            if let Some(parent) = proj.parent() {
3358                self.merge_loadouts_from_dir(&parent.join("loadouts"));
3359            }
3360        }
3361    }
3362
3363    /// Load `<dir>/*.toml` as loadouts (filename stem = name) into `self.loadouts`,
3364    /// last-wins on a name clash. A malformed file is skipped with a warning.
3365    fn merge_loadouts_from_dir(&mut self, dir: &Path) {
3366        let Ok(entries) = std::fs::read_dir(dir) else {
3367            return; // no loadouts dir — fine
3368        };
3369        let mut paths: Vec<PathBuf> = entries
3370            .flatten()
3371            .map(|e| e.path())
3372            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3373            .collect();
3374        paths.sort();
3375        for path in paths {
3376            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3377                continue;
3378            };
3379            match std::fs::read_to_string(&path).map(|t| toml::from_str::<Loadout>(&t)) {
3380                Ok(Ok(loadout)) => {
3381                    self.loadouts.insert(stem.to_string(), loadout);
3382                }
3383                Ok(Err(e)) => {
3384                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed loadout file");
3385                }
3386                Err(_) => {}
3387            }
3388        }
3389    }
3390
3391    /// Load a config file as a raw `toml::Value` (for layered merging).
3392    fn load_value(path: &Path) -> Result<toml::Value> {
3393        let text = std::fs::read_to_string(path)?;
3394        toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
3395    }
3396
3397    /// Locate a project-local `.newt/config.toml` by walking up from the current
3398    /// directory toward the filesystem root, stopping before `$HOME` so the
3399    /// global `~/.newt/config.toml` is never mistaken for a project override.
3400    /// Returns the nearest match (innermost project wins). See issue #222.
3401    pub fn project_config_path() -> Option<PathBuf> {
3402        let cwd = std::env::current_dir().ok()?;
3403        find_project_config_from(&cwd, home_dir().as_deref())
3404    }
3405
3406    /// The user-writable config root: `$NEWT_CONFIG_DIR` or `~/.newt`.
3407    pub fn user_config_dir() -> Option<PathBuf> {
3408        if let Some(path) = std::env::var_os(NEWT_CONFIG_DIR_ENV)
3409            .filter(|value| !value.is_empty())
3410            .map(PathBuf::from)
3411        {
3412            return Some(path);
3413        }
3414        home_dir().map(|h| h.join(".newt"))
3415    }
3416
3417    /// The user-writable config path: `$NEWT_CONFIG_DIR/config.toml` or
3418    /// `~/.newt/config.toml`.
3419    /// This is the first path `resolve()` reads and the target for `save()`.
3420    pub fn user_config_path() -> Option<PathBuf> {
3421        Self::user_config_dir().map(|dir| dir.join("config.toml"))
3422    }
3423
3424    /// Serialize the config to pretty TOML for **audit**, with inline secret
3425    /// material redacted. The values of every `[[mcp_servers]]` `env` and
3426    /// `headers` entry are replaced with [`Self::REDACTED`] — those maps are the
3427    /// only place `Config` can carry a raw secret inline (e.g. an
3428    /// `Authorization: Bearer …` header or an `API_KEY=…` child env var). Keys
3429    /// are kept so an auditor sees *which* variables/headers are set without the
3430    /// values. Secret *references* (`api_key_file` / `api_key_env`) are left as-is
3431    /// — they name where a secret lives, not the secret itself.
3432    ///
3433    /// # Errors
3434    /// A TOML serialization failure (should not happen for a valid `Config`).
3435    pub fn to_redacted_toml(&self) -> Result<String> {
3436        let mut redacted = self.clone();
3437        for server in &mut redacted.mcp_servers {
3438            for v in server.env.values_mut() {
3439                *v = Self::REDACTED.to_string();
3440            }
3441            for v in server.headers.values_mut() {
3442                *v = Self::REDACTED.to_string();
3443            }
3444        }
3445        toml::to_string_pretty(&redacted).map_err(|e| NewtError::Config(e.to_string()))
3446    }
3447
3448    /// Placeholder substituted for redacted secret values in [`Self::to_redacted_toml`].
3449    pub const REDACTED: &'static str = "<redacted>";
3450
3451    /// The ordered skill-discovery search path, with `~/` expanded.
3452    ///
3453    /// Resolves `[skills].search` when configured; otherwise defaults to the
3454    /// single host-scoped `~/.newt/skills`. Order is preserved — earlier
3455    /// directories win on a name collision (see `newt_skills::discover_paths`).
3456    /// The default falls back to a relative `.newt/skills` only when `$HOME`
3457    /// can't be resolved, so the list is never empty.
3458    #[must_use]
3459    pub fn skill_search_dirs(&self) -> Vec<PathBuf> {
3460        let configured = self
3461            .skills
3462            .as_ref()
3463            .map(|s| s.search.as_slice())
3464            .unwrap_or(&[]);
3465        if configured.is_empty() {
3466            let default = Self::user_config_dir()
3467                .map(|dir| dir.join("skills"))
3468                .unwrap_or_else(|| PathBuf::from(".newt/skills"));
3469            return vec![default];
3470        }
3471        configured.iter().map(|s| expand_tilde(s)).collect()
3472    }
3473
3474    /// Serialize this config and write it to `path`, creating parent dirs if needed.
3475    pub fn save(&self, path: &Path) -> Result<()> {
3476        if let Some(parent) = path.parent() {
3477            std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
3478        }
3479        let text = toml::to_string_pretty(self).map_err(|e| NewtError::Config(e.to_string()))?;
3480        std::fs::write(path, text).map_err(NewtError::Io)
3481    }
3482
3483    /// #904: append `host` to `[tui.permissions] net` in the TOML `text`,
3484    /// **preserving comments and formatting** — unlike [`Config::save`], which
3485    /// re-serializes the whole typed struct and drops the user's comments,
3486    /// ordering, and any keys newt does not model. Creates the
3487    /// `[tui.permissions]` table and its `net` array if absent; a no-op if the
3488    /// host is already listed. PURE (no I/O), so it unit-tests without a
3489    /// filesystem. This is the durable "allow permanently" grant path — it is
3490    /// only ever driven by an explicit human keypress at the permission prompt.
3491    pub fn with_net_host(text: &str, host: &str) -> Result<String> {
3492        let mut doc = text
3493            .parse::<toml_edit::DocumentMut>()
3494            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
3495        let tui = doc
3496            .as_table_mut()
3497            .entry("tui")
3498            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
3499        let tui_tbl = tui
3500            .as_table_mut()
3501            .ok_or_else(|| NewtError::Config("[tui] is not a table".to_string()))?;
3502        let perms = tui_tbl
3503            .entry("permissions")
3504            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
3505        let perms_tbl = perms
3506            .as_table_mut()
3507            .ok_or_else(|| NewtError::Config("[tui.permissions] is not a table".to_string()))?;
3508        let net =
3509            perms_tbl
3510                .entry("net")
3511                .or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
3512                    toml_edit::Array::new(),
3513                )));
3514        let arr = net.as_array_mut().ok_or_else(|| {
3515            NewtError::Config("[tui.permissions] net is not an array".to_string())
3516        })?;
3517        if !arr.iter().any(|v| v.as_str() == Some(host)) {
3518            arr.push(host);
3519        }
3520        Ok(doc.to_string())
3521    }
3522
3523    /// Durably grant a net host by appending it to `[tui.permissions] net` in the
3524    /// config file at `path`, comment-preserving (see [`Config::with_net_host`]).
3525    /// A missing file is treated as empty (the table is created). Creates parent
3526    /// dirs as needed. Used by the interactive gate's "allow permanently" choice.
3527    pub fn append_permission_net_host(path: &Path, host: &str) -> Result<()> {
3528        let text = std::fs::read_to_string(path).unwrap_or_default();
3529        let updated = Self::with_net_host(&text, host)?;
3530        if let Some(parent) = path.parent() {
3531            std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
3532        }
3533        std::fs::write(path, updated).map_err(NewtError::Io)
3534    }
3535
3536    /// Build the ordered list of candidate config file paths.
3537    fn candidate_paths() -> Vec<PathBuf> {
3538        let mut paths = Vec::new();
3539
3540        if let Ok(p) = std::env::var("NEWT_CONFIG") {
3541            paths.push(PathBuf::from(p));
3542        }
3543
3544        paths.push(PathBuf::from("./newt.toml"));
3545
3546        if let Some(path) = Self::user_config_path() {
3547            paths.push(path);
3548        }
3549
3550        paths.push(PathBuf::from("/etc/newt/config.toml"));
3551        paths
3552    }
3553}
3554
3555// ---------------------------------------------------------------------------
3556// Helpers
3557// ---------------------------------------------------------------------------
3558
3559/// Best-effort home directory lookup without pulling in the `dirs` crate.
3560pub(crate) fn home_dir() -> Option<PathBuf> {
3561    std::env::var("HOME")
3562        .or_else(|_| std::env::var("USERPROFILE"))
3563        .ok()
3564        .map(PathBuf::from)
3565}
3566
3567/// Deep-merge `overlay` into `base`. Tables always merge recursively (overlay
3568/// keys win on collision). Arrays follow `arrays`: [`ArrayMergeStrategy::Replace`]
3569/// swaps the base array for the overlay's, [`ArrayMergeStrategy::Append`]
3570/// concatenates (base entries first). Scalars are always replaced by the
3571/// overlay. Used to layer a project-local `.newt/config.toml` over the global
3572/// config. See issue #222.
3573pub(crate) fn merge_toml(base: &mut toml::Value, overlay: toml::Value, arrays: ArrayMergeStrategy) {
3574    match (base, overlay) {
3575        (toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
3576            for (key, val) in overlay_tbl {
3577                match base_tbl.get_mut(&key) {
3578                    Some(existing) => merge_toml(existing, val, arrays),
3579                    None => {
3580                        base_tbl.insert(key, val);
3581                    }
3582                }
3583            }
3584        }
3585        // Append mode: concatenate two arrays (global entries first).
3586        (toml::Value::Array(base_arr), toml::Value::Array(overlay_arr))
3587            if arrays == ArrayMergeStrategy::Append =>
3588        {
3589            base_arr.extend(overlay_arr);
3590        }
3591        // Replace mode (and any scalar): the overlay replaces the base outright.
3592        (slot, overlay) => *slot = overlay,
3593    }
3594}
3595
3596/// Determine the array-merge strategy from the raw config values, before they
3597/// are deserialized. The project config expresses how *it* wants to be merged,
3598/// so it is consulted first; then the base config; else the built-in default.
3599fn array_merge_strategy(project: &toml::Value, base: &toml::Value) -> ArrayMergeStrategy {
3600    read_array_strategy(project)
3601        .or_else(|| read_array_strategy(base))
3602        .unwrap_or_default()
3603}
3604
3605/// Read `[merge] arrays = "replace" | "append"` from a raw config value.
3606/// Returns `None` when the key is absent or unrecognized (caller falls back).
3607fn read_array_strategy(value: &toml::Value) -> Option<ArrayMergeStrategy> {
3608    match value.get("merge")?.get("arrays")?.as_str()? {
3609        "append" => Some(ArrayMergeStrategy::Append),
3610        "replace" => Some(ArrayMergeStrategy::Replace),
3611        _ => None,
3612    }
3613}
3614
3615/// Walk up from `start` looking for a project-local `.newt/config.toml`,
3616/// stopping before `home` (so the global `~/.newt/config.toml` is never
3617/// returned) and at the filesystem root. Returns the innermost match.
3618///
3619/// Split out from [`Config::project_config_path`] so it can be unit-tested
3620/// against temp directories without mutating the process environment.
3621pub(crate) fn find_project_config_from(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
3622    let mut dir = Some(start);
3623    while let Some(current) = dir {
3624        // Never treat the home directory's `.newt` as a project override.
3625        if home == Some(current) {
3626            break;
3627        }
3628        let candidate = current.join(".newt").join("config.toml");
3629        if candidate.is_file() {
3630            return Some(candidate);
3631        }
3632        dir = current.parent();
3633    }
3634    None
3635}
3636
3637/// Expand a leading `~/` (or a bare `~`) to the home directory. Paths
3638/// without a leading tilde are returned unchanged.
3639pub(crate) fn expand_tilde(path: &str) -> PathBuf {
3640    if let Some(rest) = path.strip_prefix("~/") {
3641        if let Some(home) = home_dir() {
3642            return home.join(rest);
3643        }
3644    } else if path == "~" {
3645        if let Some(home) = home_dir() {
3646            return home;
3647        }
3648    }
3649    PathBuf::from(path)
3650}
3651
3652// ---------------------------------------------------------------------------
3653// Tests
3654// ---------------------------------------------------------------------------
3655
3656#[cfg(test)]
3657mod tests {
3658    use super::*;
3659    // The `permits_*` adaptors live on `CaveatsExt` (post-#95 the
3660    // upstream `agent-mesh-protocol::Caveats` ships algebra only).
3661    use crate::caveats::CaveatsExt;
3662    use std::io::Write;
3663
3664    // ── input-footer mode ──────────────────────────────────────────────
3665
3666    #[test]
3667    fn footer_mode_defaults_to_auto_and_round_trips() {
3668        // Absent key → Auto (the amphibious default).
3669        let cfg: TuiConfig = toml::from_str("").unwrap();
3670        assert_eq!(cfg.footer, FooterMode::Auto);
3671        // Each variant parses from its snake_case key.
3672        for (key, want) in [
3673            ("auto", FooterMode::Auto),
3674            ("on", FooterMode::On),
3675            ("off", FooterMode::Off),
3676        ] {
3677            let cfg: TuiConfig = toml::from_str(&format!("footer = \"{key}\"")).unwrap();
3678            assert_eq!(cfg.footer, want, "footer = {key}");
3679        }
3680    }
3681
3682    // ── color / theme mode (issue #527) ─────────────────────────────────
3683
3684    #[test]
3685    fn color_mode_defaults_to_auto_and_round_trips() {
3686        // Absent key → Auto (color on a TTY, none off one).
3687        let cfg: TuiConfig = toml::from_str("").unwrap();
3688        assert_eq!(cfg.color, ColorMode::Auto);
3689        // Every keyword parses from its serde (lowercase) key.
3690        for (key, want) in [
3691            ("auto", ColorMode::Auto),
3692            ("always", ColorMode::Always),
3693            ("never", ColorMode::Never),
3694            ("minimal", ColorMode::Minimal),
3695            ("inverted", ColorMode::Inverted),
3696            ("dark", ColorMode::Dark),
3697            ("light", ColorMode::Light),
3698            ("mono", ColorMode::Mono),
3699        ] {
3700            let cfg: TuiConfig = toml::from_str(&format!("color = \"{key}\"")).unwrap();
3701            assert_eq!(cfg.color, want, "color = {key}");
3702        }
3703    }
3704
3705    #[test]
3706    fn color_mode_keyword_round_trips_and_aliases_parse() {
3707        // keyword() is the inverse of from_keyword() for every canonical variant.
3708        for m in [
3709            ColorMode::Auto,
3710            ColorMode::Always,
3711            ColorMode::Never,
3712            ColorMode::Minimal,
3713            ColorMode::Inverted,
3714            ColorMode::Dark,
3715            ColorMode::Light,
3716            ColorMode::Mono,
3717        ] {
3718            assert_eq!(ColorMode::from_keyword(m.keyword()), Some(m));
3719        }
3720        // Case-insensitive + aliases.
3721        assert_eq!(ColorMode::from_keyword("ALWAYS"), Some(ColorMode::Always));
3722        assert_eq!(ColorMode::from_keyword(" on "), Some(ColorMode::Always));
3723        assert_eq!(ColorMode::from_keyword("off"), Some(ColorMode::Never));
3724        assert_eq!(ColorMode::from_keyword("monochrome"), Some(ColorMode::Mono));
3725        // Unknown keyword is rejected (the CLI value_parser surfaces this).
3726        assert_eq!(ColorMode::from_keyword("rainbow"), None);
3727    }
3728
3729    #[test]
3730    fn color_mode_forced_and_is_mono() {
3731        // forced(): Some(true) = color on, Some(false) = off, None = defer to TTY.
3732        assert_eq!(ColorMode::Always.forced(), Some(true));
3733        assert_eq!(ColorMode::Dark.forced(), Some(true));
3734        assert_eq!(ColorMode::Light.forced(), Some(true));
3735        assert_eq!(ColorMode::Inverted.forced(), Some(true));
3736        assert_eq!(ColorMode::Minimal.forced(), Some(true));
3737        assert_eq!(ColorMode::Never.forced(), Some(false));
3738        assert_eq!(ColorMode::Mono.forced(), Some(false));
3739        assert_eq!(ColorMode::Auto.forced(), None);
3740        // is_mono distinguishes the ASCII-fallback mode from plain Never.
3741        assert!(ColorMode::Mono.is_mono());
3742        assert!(!ColorMode::Never.is_mono());
3743        assert!(!ColorMode::Auto.is_mono());
3744    }
3745
3746    #[test]
3747    fn markdown_mode_defaults_to_auto_round_trips_and_forces() {
3748        assert_eq!(MarkdownMode::default(), MarkdownMode::Auto);
3749        for m in [MarkdownMode::Auto, MarkdownMode::On, MarkdownMode::Off] {
3750            assert_eq!(MarkdownMode::from_keyword(m.keyword()), Some(m));
3751        }
3752        // Case-insensitive + always/never aliases.
3753        assert_eq!(MarkdownMode::from_keyword("ON"), Some(MarkdownMode::On));
3754        assert_eq!(
3755            MarkdownMode::from_keyword(" always "),
3756            Some(MarkdownMode::On)
3757        );
3758        assert_eq!(MarkdownMode::from_keyword("never"), Some(MarkdownMode::Off));
3759        assert_eq!(MarkdownMode::from_keyword("rainbow"), None);
3760        // forced(): On = Some(true), Off = Some(false), Auto = defer.
3761        assert_eq!(MarkdownMode::On.forced(), Some(true));
3762        assert_eq!(MarkdownMode::Off.forced(), Some(false));
3763        assert_eq!(MarkdownMode::Auto.forced(), None);
3764    }
3765
3766    #[test]
3767    fn tui_markdown_parses_from_toml_and_defaults_to_auto() {
3768        let cfg: TuiConfig = toml::from_str("markdown = \"off\"").unwrap();
3769        assert_eq!(cfg.markdown, MarkdownMode::Off);
3770        let default: TuiConfig = toml::from_str("").unwrap();
3771        assert_eq!(default.markdown, MarkdownMode::Auto);
3772    }
3773
3774    /// Step 24.10 (#559): summarizer knobs live in `summarizer.toml` now.
3775    /// Defaults (absent file) reuse the session backend; timeout 60 / retries 1.
3776    #[test]
3777    fn backend_kind_embedded_parses_and_labels() {
3778        // #639: the config accepts `kind = "embedded"` so the summarizer (and a
3779        // backend) can select the in-process backend.
3780        #[derive(serde::Deserialize)]
3781        struct K {
3782            kind: BackendKind,
3783        }
3784        let k: K = toml::from_str("kind = \"embedded\"").unwrap();
3785        assert_eq!(k.kind, BackendKind::Embedded);
3786        assert_eq!(k.kind.label(), "embedded");
3787    }
3788
3789    #[test]
3790    fn summarizer_config_defaults_and_parse() {
3791        let d = SummarizerConfig::default();
3792        assert_eq!(d.endpoint, None);
3793        assert_eq!(d.model, None);
3794        assert_eq!(d.kind, None);
3795        assert_eq!(d.timeout_secs, 60);
3796        assert_eq!(d.retries, 1);
3797        assert_eq!(d.fallback_model, None);
3798
3799        let cfg = SummarizerConfig::from_toml_str(
3800            "endpoint = \"http://REDACTED-HOST:11434\"\n\
3801             model = \"qwen2.5-coder:3b\"\n\
3802             kind = \"openai\"\n\
3803             timeout_secs = 45\n\
3804             retries = 2\n\
3805             fallback_model = \"nemotron-mini:4b\"\n\
3806             keep_alive = \"10m\"",
3807        )
3808        .unwrap();
3809        assert_eq!(cfg.endpoint.as_deref(), Some("http://REDACTED-HOST:11434"));
3810        assert_eq!(cfg.model.as_deref(), Some("qwen2.5-coder:3b"));
3811        assert_eq!(cfg.kind, Some(BackendKind::Openai));
3812        assert_eq!(cfg.timeout_secs, 45);
3813        assert_eq!(cfg.retries, 2);
3814        assert_eq!(cfg.fallback_model.as_deref(), Some("nemotron-mini:4b"));
3815        assert_eq!(cfg.keep_alive.as_deref(), Some("10m"));
3816    }
3817
3818    /// A partial file fills only the keys present; the rest stay at defaults
3819    /// (so an `endpoint`-only file reuses the session model but a fast box).
3820    #[test]
3821    fn summarizer_config_partial_keeps_defaults() {
3822        let cfg = SummarizerConfig::from_toml_str("endpoint = \"http://fast.box:11434\"").unwrap();
3823        assert_eq!(cfg.endpoint.as_deref(), Some("http://fast.box:11434"));
3824        assert_eq!(cfg.model, None); // reuse session model
3825        assert_eq!(cfg.timeout_secs, 60); // default
3826        assert_eq!(cfg.retries, 1); // default
3827    }
3828
3829    #[test]
3830    fn context_manager_keyword_roundtrip_and_availability() {
3831        for m in [
3832            ContextManager::Standard,
3833            ContextManager::Progressive,
3834            ContextManager::Distributed,
3835        ] {
3836            assert_eq!(ContextManager::from_keyword(m.keyword()), Some(m));
3837        }
3838        assert_eq!(
3839            ContextManager::from_keyword("  STANDARD "),
3840            Some(ContextManager::Standard),
3841            "case/space-insensitive"
3842        );
3843        assert_eq!(ContextManager::from_keyword("nope"), None);
3844        // Only standard is implemented today; the others are pending #546.
3845        assert!(ContextManager::Standard.available());
3846        assert!(!ContextManager::Progressive.available());
3847        assert!(!ContextManager::Distributed.available());
3848        assert_eq!(ContextManager::default(), ContextManager::Standard);
3849    }
3850
3851    #[test]
3852    fn context_section_defaults_and_parses() {
3853        // Absent [context] → None on Config; the resolver falls back to standard.
3854        let cfg: Config = toml::from_str("").unwrap();
3855        assert!(cfg.context.is_none());
3856        let c: ContextConfig = toml::from_str("manager = \"progressive\"").unwrap();
3857        assert_eq!(c.manager, ContextManager::Progressive);
3858        assert_eq!(ContextConfig::default().manager, ContextManager::Standard);
3859    }
3860
3861    #[test]
3862    fn scratch_section_defaults_and_parses() {
3863        // #844: `[scratch] dir` parses onto Config; absent → None (the `.scratch`
3864        // default applies at resolution). Uses `from_str` (not `resolve`) so this
3865        // does NOT publish a process-global scratch dir.
3866        let bare: Config = toml::from_str("").unwrap();
3867        assert!(bare.scratch.is_none());
3868        let cfg: Config = toml::from_str("[scratch]\ndir = \"/tmp/newt-scratch\"\n").unwrap();
3869        assert_eq!(
3870            cfg.scratch.and_then(|s| s.dir).as_deref(),
3871            Some("/tmp/newt-scratch")
3872        );
3873    }
3874
3875    #[test]
3876    fn semantic_config_defaults_and_parses() {
3877        // Defaults (Step 26.5.4): nomic-embed-text, top_k 5, no decoupled
3878        // endpoint, and on_embed_failure = disable (the safe default).
3879        let d = SemanticConfig::default();
3880        assert_eq!(d.embedding_model, "nomic-embed-text");
3881        assert_eq!(d.top_k, 5);
3882        assert_eq!(d.embeddings_endpoint, None);
3883        assert_eq!(d.embeddings_api, None);
3884        assert_eq!(d.on_embed_failure, OnEmbedFailure::Disable);
3885        // #720: the embedded-embedder local model dir defaults to None.
3886        assert_eq!(d.embedding_model_path, None);
3887        // `[context.semantic]` parses + overrides, incl. the new fields.
3888        let c: ContextConfig = toml::from_str(
3889            "[semantic]\nembedding_model = \"mxbai-embed-large\"\ntop_k = 8\n\
3890             embedding_model_path = \"/models/bge-small-en-v1.5\"\n\
3891             embeddings_endpoint = \"http://REDACTED-HOST:11434\"\n\
3892             embeddings_api = \"ollama\"\non_embed_failure = \"warn\"",
3893        )
3894        .unwrap();
3895        assert_eq!(c.semantic.embedding_model, "mxbai-embed-large");
3896        assert_eq!(
3897            c.semantic.embedding_model_path.as_deref(),
3898            Some("/models/bge-small-en-v1.5")
3899        );
3900        assert_eq!(c.semantic.top_k, 8);
3901        assert_eq!(
3902            c.semantic.embeddings_endpoint.as_deref(),
3903            Some("http://REDACTED-HOST:11434")
3904        );
3905        assert_eq!(c.semantic.embeddings_api, Some(BackendKind::Ollama));
3906        assert_eq!(c.semantic.on_embed_failure, OnEmbedFailure::Warn);
3907        // `embeddings_api = "vllm"` aliases to the OpenAI protocol.
3908        let v: ContextConfig = toml::from_str("[semantic]\nembeddings_api = \"vllm\"").unwrap();
3909        assert_eq!(v.semantic.embeddings_api, Some(BackendKind::Openai));
3910        // an absent [context.semantic] still yields the defaults
3911        let bare: ContextConfig = toml::from_str("manager = \"standard\"").unwrap();
3912        assert_eq!(bare.semantic, SemanticConfig::default());
3913    }
3914
3915    #[test]
3916    fn context_feature_keyword_alias_availability_and_issue() {
3917        // canonical keyword round-trips
3918        for f in ContextFeature::ALL {
3919            assert_eq!(ContextFeature::from_keyword(f.keyword()), Some(f));
3920        }
3921        // aliases + hyphen/underscore/case
3922        assert_eq!(
3923            ContextFeature::from_keyword("TOOL-OFFLOAD"),
3924            Some(ContextFeature::ToolOffload)
3925        );
3926        assert_eq!(
3927            ContextFeature::from_keyword("offload"),
3928            Some(ContextFeature::ToolOffload)
3929        );
3930        assert_eq!(
3931            ContextFeature::from_keyword(" state "),
3932            Some(ContextFeature::Scratchpad)
3933        );
3934        assert_eq!(ContextFeature::from_keyword("nope"), None);
3935        // tool_offload (26.3), scratchpad (26.4), semantic (26.5), experiential
3936        // (26.6a), scheduled (26.6b) shipped; only provenance is still pending.
3937        assert!(ContextFeature::ToolOffload.available());
3938        assert!(ContextFeature::Scratchpad.available());
3939        assert!(ContextFeature::Semantic.available());
3940        assert!(ContextFeature::Experiential.available());
3941        assert!(ContextFeature::Scheduled.available());
3942        assert!(
3943            !ContextFeature::Provenance.available(),
3944            "provenance still pending"
3945        );
3946        assert!(ContextFeature::ALL
3947            .iter()
3948            .filter(|f| !matches!(f, ContextFeature::Provenance))
3949            .all(|f| f.available()));
3950        // issues route to the right tracking ticket
3951        assert_eq!(ContextFeature::Semantic.issue(), 582);
3952        assert_eq!(ContextFeature::Scratchpad.issue(), 583);
3953        assert_eq!(ContextFeature::ToolOffload.issue(), 584);
3954        assert_eq!(ContextFeature::Provenance.issue(), 584);
3955        assert_eq!(ContextFeature::Experiential.issue(), 585);
3956        assert_eq!(ContextFeature::Scheduled.issue(), 586);
3957    }
3958
3959    #[test]
3960    fn context_features_override_layering_and_parse() {
3961        use ContextFeature as F;
3962        // Every preset resolves to all-off today (standard behavior).
3963        let base = ContextManager::Standard.base_features();
3964        assert!(base.enabled().is_empty());
3965        // An override layers on top of the base, leaving others untouched.
3966        let mut ov = ContextFeatures::default();
3967        ov.set(F::Scratchpad, Some(true));
3968        let resolved = ov.apply_to(base);
3969        assert!(resolved.get(F::Scratchpad));
3970        assert!(!resolved.get(F::Semantic));
3971        assert_eq!(resolved.enabled(), vec![F::Scratchpad]);
3972        // None override = inherit (no change); Some(false) = force off.
3973        let mut ov2 = ContextFeatures::default();
3974        ov2.set(F::Scratchpad, Some(false));
3975        assert!(!ov2.apply_to(resolved).get(F::Scratchpad));
3976        // [context.features] parses keyed by canonical keyword.
3977        let c: ContextConfig = toml::from_str(
3978            "manager = \"standard\"\n[features]\nsemantic = true\nscratchpad = false",
3979        )
3980        .unwrap();
3981        assert_eq!(c.features.get(F::Semantic), Some(true));
3982        assert_eq!(c.features.get(F::Scratchpad), Some(false));
3983        assert_eq!(c.features.get(F::ToolOffload), None);
3984    }
3985
3986    #[test]
3987    fn base_for_defaults_tool_offload_on_and_local_assist_on_for_ollama() {
3988        use ContextFeature as F;
3989        // #945: tool offload is local spill storage and defaults ON for every
3990        // backend. Step 27.4: local (Ollama) backends additionally default
3991        // scratchpad + scheduled ON; semantic also defaults ON but degrades to a
3992        // one-shot no-op until an embedder is configured.
3993        let local = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Ollama);
3994        assert!(local.get(F::ToolOffload));
3995        assert!(local.get(F::Scratchpad));
3996        assert!(local.get(F::Semantic));
3997        assert!(local.get(F::Scheduled));
3998        // Cloud (OpenAI-compatible) gets offload, but not local-assist features.
3999        let cloud = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Openai);
4000        assert!(cloud.get(F::ToolOffload));
4001        assert!(!cloud.get(F::Scratchpad));
4002        assert!(!cloud.get(F::Semantic));
4003        assert!(!cloud.get(F::Scheduled));
4004        // An explicit override still wins over the local default (force off).
4005        let mut ov = ContextFeatures::default();
4006        ov.set(F::Scheduled, Some(false));
4007        ov.set(F::ToolOffload, Some(false));
4008        assert!(!ov.apply_to(local).get(F::Scheduled));
4009        assert!(!ov.apply_to(local).get(F::ToolOffload));
4010        assert!(ov.apply_to(local).get(F::Scratchpad)); // untouched feature stays on
4011    }
4012
4013    #[test]
4014    fn allow_bang_escape_defaults_to_true_and_round_trips() {
4015        // Absent key → enabled (the human's host shell-out is on by default).
4016        let cfg: TuiConfig = toml::from_str("").unwrap();
4017        assert!(cfg.allow_bang_escape);
4018        // Explicit opt-out parses.
4019        let cfg: TuiConfig = toml::from_str("allow_bang_escape = false").unwrap();
4020        assert!(!cfg.allow_bang_escape);
4021    }
4022
4023    #[test]
4024    fn thinking_mode_defaults_to_stream_and_round_trips() {
4025        let cfg: TuiConfig = toml::from_str("").unwrap();
4026        assert_eq!(cfg.thinking, ThinkingMode::Stream);
4027        let cfg: TuiConfig = toml::from_str("thinking = \"off\"").unwrap();
4028        assert_eq!(cfg.thinking, ThinkingMode::Off);
4029        let cfg: TuiConfig = toml::from_str("thinking = \"stream\"").unwrap();
4030        assert_eq!(cfg.thinking, ThinkingMode::Stream);
4031    }
4032
4033    // ── profile composition (technique library) ────────────────────────
4034
4035    #[test]
4036    fn profile_parses_techniques_and_knobs() {
4037        let cfg: Config = toml::from_str(
4038            r#"
4039            [profiles.nemotron]
4040            techniques = ["knowledge_base", "verify_gate", "retry"]
4041
4042            [profiles.nemotron.verify_gate]
4043            surface_match = "exact"
4044
4045            [profiles.nemotron.retry]
4046            max_retries = 3
4047            "#,
4048        )
4049        .unwrap();
4050        let p = &cfg.profiles["nemotron"];
4051        assert!(p.validate().is_ok());
4052        assert!(p.enables("verify_gate") && p.enables("retry"));
4053        assert_eq!(
4054            p.verify_gate_knobs().surface_match,
4055            crate::verify_gate::SurfaceMatch::Exact
4056        );
4057        assert_eq!(p.retry_knobs().max_retries, 3);
4058    }
4059
4060    #[test]
4061    fn profile_knobs_default_when_unset() {
4062        // techniques named but no knob tables → defaults apply
4063        let p: ProfileConfig = toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
4064        assert_eq!(
4065            p.verify_gate_knobs().surface_match,
4066            crate::verify_gate::SurfaceMatch::Exact // the complete-gate default
4067        );
4068        assert_eq!(p.retry_knobs().max_retries, 2);
4069    }
4070
4071    #[test]
4072    fn profile_rejects_unknown_technique() {
4073        let p: ProfileConfig =
4074            toml::from_str("techniques = [\"knowledge_base\", \"teleport\"]").unwrap();
4075        let err = p.validate().unwrap_err();
4076        assert!(err.contains("teleport"), "err: {err}");
4077    }
4078
4079    #[test]
4080    fn profile_rejects_unmet_presupposition() {
4081        // retry presupposes verify_gate — listing retry alone is now a load-time error.
4082        let p: ProfileConfig = toml::from_str("techniques = [\"retry\"]").unwrap();
4083        let err = p.validate().unwrap_err();
4084        assert!(
4085            err.contains("retry") && err.contains("verify_gate") && err.contains("presupposes"),
4086            "err: {err}"
4087        );
4088        // …and adding verify_gate satisfies it.
4089        let ok: ProfileConfig =
4090            toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
4091        assert!(ok.validate().is_ok());
4092    }
4093
4094    #[test]
4095    fn registry_does_not_alter_the_resolved_technique_set() {
4096        // Golden: validate() accepts the nemotron set and the resolved order/membership
4097        // is byte-identical to the input — the registry adds checks, not behavior.
4098        let p: ProfileConfig =
4099            toml::from_str("techniques = [\"knowledge_base\", \"verify_gate\", \"retry\"]")
4100                .unwrap();
4101        assert!(p.validate().is_ok());
4102        assert_eq!(p.techniques, vec!["knowledge_base", "verify_gate", "retry"]);
4103        for t in ["knowledge_base", "verify_gate", "retry"] {
4104            assert!(p.enables(t));
4105        }
4106    }
4107
4108    #[test]
4109    fn empty_profiles_is_the_default() {
4110        // no [profiles] table → empty map, behavior unchanged
4111        let cfg: Config = toml::from_str("").unwrap();
4112        assert!(cfg.profiles.is_empty());
4113        assert!(cfg.bundles.is_empty());
4114    }
4115
4116    // ── bundles (the loadable kit unit) ────────────────────────────────
4117
4118    fn bundle_cfg() -> Config {
4119        toml::from_str(
4120            r#"
4121            [profiles.nemotron]
4122            techniques = ["knowledge_base", "verify_gate", "retry"]
4123            [profiles.qwen-coder]
4124            techniques = []
4125
4126            [bundles.nemotron]
4127            about = "nemotron family support"
4128            applies_to = ["nemotron"]
4129            default_profile = "nemotron"
4130            families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }
4131
4132            [bundles.review-heavy]              # use-case bundle: no applies_to
4133            default_profile = "nemotron"
4134            "#,
4135        )
4136        .unwrap()
4137    }
4138
4139    #[test]
4140    fn resolve_bundle_errors_on_unknown() {
4141        let cfg = bundle_cfg();
4142        assert!(cfg.resolve_bundle("nemotron").is_ok());
4143        let err = cfg.resolve_bundle("ghost").unwrap_err();
4144        assert!(err.contains("no such bundle"), "{err}");
4145    }
4146
4147    #[test]
4148    fn bundle_profile_for_model_longest_prefix_then_default() {
4149        let cfg = bundle_cfg();
4150        let b = cfg.resolve_bundle("nemotron").unwrap();
4151        // family-prefix match
4152        assert_eq!(
4153            cfg.bundle_profile_for_model(b, "nemotron3:33b"),
4154            Some("nemotron")
4155        );
4156        assert_eq!(
4157            cfg.bundle_profile_for_model(b, "qwen2.5-coder"),
4158            Some("qwen-coder")
4159        );
4160        // no family match → default_profile
4161        assert_eq!(
4162            cfg.bundle_profile_for_model(b, "llama3.1:8b"),
4163            Some("nemotron")
4164        );
4165    }
4166
4167    #[test]
4168    fn infer_bundle_only_from_applies_to() {
4169        let cfg = bundle_cfg();
4170        // nemotron model → the nemotron bundle (applies_to match)
4171        assert_eq!(
4172            cfg.infer_bundle("nemotron3:33b").map(|(n, _)| n),
4173            Some("nemotron")
4174        );
4175        // a model no applies_to matches → no inference (the use-case bundle is never inferred)
4176        assert!(cfg.infer_bundle("gpt-4.1").is_none());
4177    }
4178
4179    #[test]
4180    fn pick_active_profile_precedence() {
4181        let cfg = bundle_cfg();
4182        // 1. explicit --profile wins over everything
4183        let p = cfg
4184            .pick_active_profile(Some("qwen-coder"), Some("nemotron"), "nemotron3:33b")
4185            .unwrap()
4186            .unwrap();
4187        assert_eq!(p.name, "qwen-coder");
4188        assert_eq!(p.via, PickVia::Profile);
4189        // 2. --bundle resolves to its profile for the model
4190        let p = cfg
4191            .pick_active_profile(None, Some("nemotron"), "nemotron3:33b")
4192            .unwrap()
4193            .unwrap();
4194        assert_eq!(
4195            (p.name.as_str(), p.via),
4196            ("nemotron", PickVia::Bundle("nemotron".into()))
4197        );
4198        // 3. inferred from the model when neither flag is set
4199        let p = cfg
4200            .pick_active_profile(None, None, "nemotron3:33b")
4201            .unwrap()
4202            .unwrap();
4203        assert_eq!(p.via, PickVia::InferredBundle("nemotron".into()));
4204        // 4. nothing matches → None (today's behavior)
4205        assert!(cfg
4206            .pick_active_profile(None, None, "gpt-4.1")
4207            .unwrap()
4208            .is_none());
4209        // an unknown explicit bundle is a hard error
4210        assert!(cfg.pick_active_profile(None, Some("ghost"), "x").is_err());
4211    }
4212
4213    // ── loadouts (the top-level composition; inert until Slice 1) ───────
4214
4215    #[test]
4216    fn loadout_parses_inline_and_validates_references() {
4217        let cfg: Config = toml::from_str(
4218            r#"
4219            [[backends]]
4220            name = "dgx"
4221            endpoint = "http://dgx.local:11434"
4222            model = "nemotron-3:33b"
4223            tiers = []
4224
4225            [profiles.nemotron]
4226            techniques = ["knowledge_base", "verify_gate", "retry"]
4227            [bundles.nemotron]
4228            default_profile = "nemotron"
4229
4230            [loadouts.dev-nemotron]
4231            provider = "dgx"
4232            model    = "nemotron@deep"
4233            kit      = "nemotron"
4234            profile  = "nemotron"
4235            role     = "python-developer"
4236            [loadouts.dev-nemotron.settings]
4237            num_ctx = 24576
4238            framing = "Ship small, verify."
4239            "#,
4240        )
4241        .unwrap();
4242        let l = &cfg.loadouts["dev-nemotron"];
4243        assert_eq!(l.provider.as_deref(), Some("dgx"));
4244        assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
4245        assert_eq!(l.role.as_deref(), Some("python-developer"));
4246        assert_eq!(l.settings.as_ref().unwrap().num_ctx, Some(24576));
4247        // references resolve
4248        assert!(l.validate(&cfg).is_ok());
4249    }
4250
4251    #[test]
4252    fn loadout_rejects_dangling_references() {
4253        let cfg: Config = toml::from_str(
4254            r#"
4255            [[backends]]
4256            name = "real-box"
4257            endpoint = "http://h:11434"
4258            model = "m"
4259
4260            [profiles.nemotron]
4261            techniques = ["verify_gate"]
4262            "#,
4263        )
4264        .unwrap();
4265        // dangling kit
4266        let bad_kit = Loadout {
4267            kit: Some("ghost-bundle".into()),
4268            ..Default::default()
4269        };
4270        let e = bad_kit.validate(&cfg).unwrap_err();
4271        assert!(
4272            e.contains("kit 'ghost-bundle'") && e.contains("no such bundle"),
4273            "{e}"
4274        );
4275        // dangling profile
4276        let bad_profile = Loadout {
4277            profile: Some("ghost-profile".into()),
4278            ..Default::default()
4279        };
4280        let e = bad_profile.validate(&cfg).unwrap_err();
4281        assert!(
4282            e.contains("profile 'ghost-profile'") && e.contains("no such profile"),
4283            "{e}"
4284        );
4285        // dangling provider — must name a [backends] entry (Slice 2). The error
4286        // lists the known backends, here the explicit `real-box`.
4287        let bad_provider = Loadout {
4288            provider: Some("ghost-provider".into()),
4289            ..Default::default()
4290        };
4291        let e = bad_provider.validate(&cfg).unwrap_err();
4292        assert!(
4293            e.contains("provider 'ghost-provider'")
4294                && e.contains("no [backends] entry")
4295                && e.contains("real-box"),
4296            "{e}"
4297        );
4298        // an empty loadout is valid (no references)
4299        assert!(Loadout::default().validate(&cfg).is_ok());
4300    }
4301
4302    #[test]
4303    fn disk_bundles_load_per_file_by_stem() {
4304        let dir = tempfile::tempdir().unwrap();
4305        std::fs::write(
4306            dir.path().join("nemotron.toml"),
4307            "applies_to = [\"nemotron\"]\ndefault_profile = \"nemotron\"\n",
4308        )
4309        .unwrap();
4310        // a malformed drop-in must be skipped, not break loading
4311        std::fs::write(
4312            dir.path().join("broken.toml"),
4313            "applies_to = \"not-a-list\"\n",
4314        )
4315        .unwrap();
4316        // a non-toml file is ignored
4317        std::fs::write(dir.path().join("README.md"), "not a bundle").unwrap();
4318
4319        let mut cfg = Config::default();
4320        cfg.merge_bundles_from_dir(dir.path());
4321        assert_eq!(cfg.bundles.len(), 1, "only the valid .toml loads");
4322        let b = cfg
4323            .bundles
4324            .get("nemotron")
4325            .expect("loaded by filename stem");
4326        assert_eq!(b.applies_to, vec!["nemotron"]);
4327        assert_eq!(b.default_profile.as_deref(), Some("nemotron"));
4328        // a disk file overrides an inline bundle of the same name (last-wins)
4329        cfg.bundles.insert("x".into(), BundleConfig::default());
4330        std::fs::write(dir.path().join("x.toml"), "about = \"from disk\"\n").unwrap();
4331        cfg.merge_bundles_from_dir(dir.path());
4332        assert_eq!(cfg.bundles["x"].about.as_deref(), Some("from disk"));
4333    }
4334
4335    #[test]
4336    fn disk_loadouts_load_per_file_by_stem() {
4337        let dir = tempfile::tempdir().unwrap();
4338        std::fs::write(
4339            dir.path().join("dev-nemotron.toml"),
4340            "provider = \"dgx\"\nmodel = \"nemotron@deep\"\nkit = \"nemotron\"\n",
4341        )
4342        .unwrap();
4343        // a malformed drop-in must be skipped, not break loading
4344        std::fs::write(
4345            dir.path().join("broken.toml"),
4346            "provider = [\"not-a-string\"]\n",
4347        )
4348        .unwrap();
4349        // a non-toml file is ignored
4350        std::fs::write(dir.path().join("README.md"), "not a loadout").unwrap();
4351
4352        let mut cfg = Config::default();
4353        cfg.merge_loadouts_from_dir(dir.path());
4354        assert_eq!(cfg.loadouts.len(), 1, "only the valid .toml loads");
4355        let l = cfg
4356            .loadouts
4357            .get("dev-nemotron")
4358            .expect("loaded by filename stem");
4359        assert_eq!(l.provider.as_deref(), Some("dgx"));
4360        assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
4361        assert_eq!(l.kit.as_deref(), Some("nemotron"));
4362        // a disk file overrides an inline loadout of the same name (last-wins)
4363        cfg.loadouts.insert("x".into(), Loadout::default());
4364        std::fs::write(dir.path().join("x.toml"), "role = \"from-disk\"\n").unwrap();
4365        cfg.merge_loadouts_from_dir(dir.path());
4366        assert_eq!(cfg.loadouts["x"].role.as_deref(), Some("from-disk"));
4367    }
4368
4369    #[test]
4370    fn crew_parses_inline_and_validates_role_references() {
4371        let cfg: Config = toml::from_str(
4372            r#"
4373            [[backends]]
4374            name = "dgx"
4375            endpoint = "http://dgx.local:11434"
4376            model = "qwen3-coder:30b"
4377            tiers = []
4378            [[backends]]
4379            name = "gnuc"
4380            endpoint = "http://localhost:11434"
4381            model = "qwen2.5-coder:3b"
4382            tiers = []
4383
4384            [loadouts.planner]
4385            provider = "dgx"
4386            [loadouts.navigator]
4387            provider = "dgx"
4388            [loadouts.triage]
4389            provider = "gnuc"
4390
4391            [crews.coder]
4392            planner = "planner"
4393            navigator = "navigator"
4394            triage = "triage"
4395            loop = "patch-revise"
4396            [crews.coder.budgets]
4397            max_attempts = 4
4398            require_human_review_on = ["auth", "crypto"]
4399            "#,
4400        )
4401        .unwrap();
4402        let c = &cfg.crews["coder"];
4403        assert_eq!(c.planner, "planner");
4404        assert_eq!(c.navigator.as_deref(), Some("navigator"));
4405        assert_eq!(c.loop_program.as_deref(), Some("patch-revise"));
4406        assert_eq!(c.budgets.as_ref().unwrap().max_attempts, Some(4));
4407        // each role names a known loadout, and each loadout validates
4408        assert!(c.validate(&cfg).is_ok());
4409    }
4410
4411    #[test]
4412    fn crew_rejects_dangling_and_invalid_roles() {
4413        let cfg: Config = toml::from_str(
4414            r#"
4415            [[backends]]
4416            name = "dgx"
4417            endpoint = "http://dgx.local:11434"
4418            model = "m"
4419            tiers = []
4420            [loadouts.planner]
4421            provider = "dgx"
4422            "#,
4423        )
4424        .unwrap();
4425        // dangling role: triage names no loadout
4426        let dangling = Crew {
4427            planner: "planner".into(),
4428            triage: Some("ghost".into()),
4429            ..Default::default()
4430        };
4431        let e = dangling.validate(&cfg).unwrap_err();
4432        assert!(e.contains("triage 'ghost'"), "{e}");
4433        assert!(e.contains("no [loadouts]"), "{e}");
4434        // transitive: a role's loadout has a dangling provider
4435        let mut cfg2 = cfg.clone();
4436        cfg2.loadouts.insert(
4437            "bad".into(),
4438            Loadout {
4439                provider: Some("nope".into()),
4440                ..Default::default()
4441            },
4442        );
4443        let transitive = Crew {
4444            planner: "bad".into(),
4445            ..Default::default()
4446        };
4447        let e = transitive.validate(&cfg2).unwrap_err();
4448        assert!(
4449            e.contains("planner 'bad'") && e.contains("provider 'nope'"),
4450            "{e}"
4451        );
4452    }
4453
4454    #[test]
4455    fn disk_crews_load_per_file_by_stem() {
4456        let dir = tempfile::tempdir().unwrap();
4457        std::fs::write(
4458            dir.path().join("coder.toml"),
4459            "planner = \"planner\"\nnavigator = \"navigator\"\n",
4460        )
4461        .unwrap();
4462        // malformed (missing required `planner`) is skipped, not fatal
4463        std::fs::write(dir.path().join("broken.toml"), "navigator = \"x\"\n").unwrap();
4464        std::fs::write(dir.path().join("README.md"), "not a crew").unwrap();
4465
4466        let mut cfg = Config::default();
4467        cfg.merge_crews_from_dir(dir.path());
4468        assert_eq!(cfg.crews.len(), 1, "only the valid .toml loads");
4469        let c = cfg.crews.get("coder").expect("loaded by filename stem");
4470        assert_eq!(c.planner, "planner");
4471        // disk overrides inline of the same name (last-wins)
4472        cfg.crews.insert(
4473            "coder".into(),
4474            Crew {
4475                planner: "inline".into(),
4476                ..Default::default()
4477            },
4478        );
4479        cfg.merge_crews_from_dir(dir.path());
4480        assert_eq!(cfg.crews["coder"].planner, "planner", "disk wins");
4481    }
4482
4483    #[test]
4484    fn backend_api_axis_defaults_and_parses() {
4485        // Absent → chat/completions (back-compat).
4486        let def: BackendConfig =
4487            toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\nkind=\"openai\"\n").unwrap();
4488        assert_eq!(def.api, OpenAiApi::ChatCompletions);
4489        // Explicit responses opt-in.
4490        let resp: BackendConfig = toml::from_str(
4491            "endpoint=\"http://h:1\"\nmodel=\"gpt-5-codex\"\nkind=\"openai\"\napi=\"responses\"\n",
4492        )
4493        .unwrap();
4494        assert_eq!(resp.api, OpenAiApi::Responses);
4495        // `chat` is an accepted alias for the default.
4496        let alias: BackendConfig =
4497            toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\napi=\"chat\"\n").unwrap();
4498        assert_eq!(alias.api, OpenAiApi::ChatCompletions);
4499    }
4500
4501    #[test]
4502    fn disk_backends_load_per_file_by_stem_and_override_inline() {
4503        let dir = tempfile::tempdir().unwrap();
4504        // A minimal drop-in: name omitted (filename is authoritative), tiers
4505        // omitted (defaults empty), kind omitted (defaults ollama).
4506        std::fs::write(
4507            dir.path().join("dgx1.toml"),
4508            "endpoint = \"http://REDACTED-HOST:11434\"\nmodel = \"qwen3:30b\"\n",
4509        )
4510        .unwrap();
4511        // Malformed (missing required `endpoint`) is skipped, not fatal.
4512        std::fs::write(dir.path().join("broken.toml"), "model = \"x\"\n").unwrap();
4513        std::fs::write(dir.path().join("README.md"), "not a backend").unwrap();
4514
4515        let mut cfg = Config {
4516            // An inline backend of the same name that the drop-in should replace,
4517            // plus an unrelated one that must survive untouched.
4518            backends: vec![
4519                BackendConfig {
4520                    name: "dgx1".into(),
4521                    endpoint: "http://stale:11434".into(),
4522                    model: "old-model".into(),
4523                    model_path: None,
4524                    tiers: vec![],
4525                    kind: BackendKind::Ollama,
4526                    api: Default::default(),
4527                    api_key_file: None,
4528                    api_key_env: None,
4529                },
4530                BackendConfig {
4531                    name: "gnuc".into(),
4532                    endpoint: "http://gnuc:11434".into(),
4533                    model: "qwen2.5-coder:14b".into(),
4534                    model_path: None,
4535                    tiers: vec![],
4536                    kind: BackendKind::Ollama,
4537                    api: Default::default(),
4538                    api_key_file: None,
4539                    api_key_env: None,
4540                },
4541            ],
4542            ..Default::default()
4543        };
4544        cfg.merge_backends_from_dir(dir.path());
4545
4546        // The drop-in replaced the inline dgx1 in place (no duplicate), gnuc kept.
4547        assert_eq!(cfg.backends.len(), 2, "only the valid .toml loads, no dup");
4548        let dgx1 = cfg.backends.iter().find(|b| b.name == "dgx1").unwrap();
4549        assert_eq!(dgx1.endpoint, "http://REDACTED-HOST:11434", "disk wins");
4550        assert_eq!(dgx1.model, "qwen3:30b");
4551        assert_eq!(dgx1.kind, BackendKind::Ollama, "kind defaults to ollama");
4552        assert!(cfg.backends.iter().any(|b| b.name == "gnuc"), "gnuc kept");
4553    }
4554
4555    #[test]
4556    fn disk_dgx_nodes_load_per_file_by_stem_and_override_inline() {
4557        let dir = tempfile::tempdir().unwrap();
4558        // A minimal drop-in: name omitted (filename is authoritative), carries
4559        // the multi-endpoint info a [[backends]] entry can't (vllm + ssh_host).
4560        std::fs::write(
4561            dir.path().join("dgx1.toml"),
4562            "ollama = \"http://REDACTED-HOST:11434\"\n\
4563             vllm = \"http://REDACTED-HOST:8000\"\n\
4564             ssh_host = \"REDACTED-HOST\"\n",
4565        )
4566        .unwrap();
4567        std::fs::write(dir.path().join("README.md"), "not a node").unwrap();
4568
4569        // [dgx] absent → created on first drop-in, with the node populated.
4570        let mut cfg = Config::default();
4571        assert!(cfg.dgx.is_none());
4572        cfg.merge_dgx_nodes_from_dir(dir.path());
4573        let dgx = cfg.dgx.as_ref().expect("[dgx] created from drop-ins");
4574        assert_eq!(dgx.nodes.len(), 1);
4575        let node = &dgx.nodes[0];
4576        assert_eq!(node.name, "dgx1", "name comes from the filename stem");
4577        assert_eq!(node.ollama.as_deref(), Some("http://REDACTED-HOST:11434"));
4578        assert_eq!(node.vllm.as_deref(), Some("http://REDACTED-HOST:8000"));
4579        assert_eq!(node.ssh_host.as_deref(), Some("REDACTED-HOST"));
4580        // A single node resolves as active without an explicit active_node.
4581        assert_eq!(dgx.active_node().unwrap().name, "dgx1");
4582
4583        // Disk replaces an inline node of the same name in place (no duplicate).
4584        cfg.dgx.as_mut().unwrap().nodes[0].ollama = Some("http://stale:1".into());
4585        cfg.merge_dgx_nodes_from_dir(dir.path());
4586        assert_eq!(cfg.dgx.as_ref().unwrap().nodes.len(), 1, "no duplicate");
4587        assert_eq!(
4588            cfg.dgx.unwrap().nodes[0].ollama.as_deref(),
4589            Some("http://REDACTED-HOST:11434"),
4590            "disk wins"
4591        );
4592    }
4593
4594    #[test]
4595    fn backendless_config_deserializes_empty_but_default_keeps_fallback() {
4596        // A config.toml with no [[backends]] must NOT inherit the struct-default
4597        // localhost Ollama — otherwise a drop-in-only setup gets a spurious
4598        // 'ollama' entry alongside its real backends (the migration regression).
4599        let cfg: Config = toml::from_str("providers = []\n").unwrap();
4600        assert!(
4601            cfg.backends.is_empty(),
4602            "absent [[backends]] deserializes to empty, got {:?}",
4603            cfg.backends
4604        );
4605        // But the no-config-file path (Config::default) keeps the fallback.
4606        assert_eq!(Config::default().backends.len(), 1);
4607        assert_eq!(Config::default().backends[0].name, "ollama");
4608        // Inline backends still load normally.
4609        let inline: Config =
4610            toml::from_str("[[backends]]\nname=\"x\"\nendpoint=\"http://h:1\"\nmodel=\"m\"\n")
4611                .unwrap();
4612        assert_eq!(inline.backends.len(), 1);
4613        assert_eq!(inline.backends[0].name, "x");
4614    }
4615
4616    #[test]
4617    fn surface_match_round_trips_lowercase() {
4618        let k: VerifyGateKnobs = toml::from_str("surface_match = \"prefix\"").unwrap();
4619        assert_eq!(k.surface_match, crate::verify_gate::SurfaceMatch::Prefix);
4620    }
4621
4622    #[test]
4623    fn resolve_profile_looks_up_validates_and_errors() {
4624        let cfg: Config = toml::from_str(
4625            r#"
4626            [profiles.nemotron]
4627            techniques = ["verify_gate"]
4628            [profiles.bad]
4629            techniques = ["teleport"]
4630            "#,
4631        )
4632        .unwrap();
4633        // known + valid → the profile
4634        assert!(cfg
4635            .resolve_profile("nemotron")
4636            .unwrap()
4637            .enables("verify_gate"));
4638        // known name but invalid technique → validation error
4639        assert!(cfg.resolve_profile("bad").unwrap_err().contains("teleport"));
4640        // unknown name → no-such-profile error, listing the known ones
4641        let err = cfg.resolve_profile("ghost").unwrap_err();
4642        assert!(
4643            err.contains("no such profile") && err.contains("nemotron"),
4644            "err: {err}"
4645        );
4646    }
4647
4648    #[test]
4649    fn memory_note_nudge_interval_defaults_and_parses() {
4650        // Default: 10 — via Default and when `[memory]` omits the key.
4651        assert_eq!(MemoryConfig::default().note_nudge_interval, 10);
4652        let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
4653        assert_eq!(cfg.note_nudge_interval, 10);
4654        // 0 = nudge off.
4655        let cfg: MemoryConfig = toml::from_str("note_nudge_interval = 0").unwrap();
4656        assert_eq!(cfg.note_nudge_interval, 0);
4657    }
4658
4659    #[test]
4660    fn memory_extract_notes_on_close_defaults_off_and_parses() {
4661        // Default OFF (Step 19.4, #248): the close-time extraction pass is
4662        // optional and costs a completion — nobody pays for it unasked.
4663        assert!(!MemoryConfig::default().extract_notes_on_close);
4664        let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
4665        assert!(!cfg.extract_notes_on_close);
4666        // `[memory] extract_notes_on_close = true` is the opt-in.
4667        let cfg: MemoryConfig = toml::from_str("extract_notes_on_close = true").unwrap();
4668        assert!(cfg.extract_notes_on_close);
4669    }
4670
4671    #[test]
4672    fn memory_disclosure_defaults_to_frozen_and_parses_index() {
4673        // INERT BY DEFAULT (#319): the disclosure facet defaults to Frozen —
4674        // today's behavior, the memory_fetch tool unwired — and only `index`
4675        // opts in to progressive disclosure.
4676        assert_eq!(MemoryConfig::default().disclosure, MemoryDisclosure::Frozen);
4677        let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
4678        assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
4679        let cfg: MemoryConfig = toml::from_str("disclosure = \"index\"").unwrap();
4680        assert_eq!(cfg.disclosure, MemoryDisclosure::Index);
4681        let cfg: MemoryConfig = toml::from_str("disclosure = \"frozen\"").unwrap();
4682        assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
4683    }
4684
4685    #[test]
4686    fn skill_search_dirs_defaults_to_single_newt_dir() {
4687        let cfg = Config::default();
4688        let dirs = cfg.skill_search_dirs();
4689        assert_eq!(dirs.len(), 1);
4690        assert!(dirs[0].ends_with("skills"));
4691        // The parent component is `.newt`.
4692        assert_eq!(
4693            dirs[0].parent().and_then(|p| p.file_name()),
4694            Some(".newt".as_ref())
4695        );
4696    }
4697
4698    #[test]
4699    fn skill_search_dirs_preserves_configured_order() {
4700        let cfg = Config {
4701            skills: Some(SkillsConfig {
4702                search: vec!["/abs/one".into(), "/abs/two".into()],
4703            }),
4704            ..Config::default()
4705        };
4706        assert_eq!(
4707            cfg.skill_search_dirs(),
4708            vec![PathBuf::from("/abs/one"), PathBuf::from("/abs/two")]
4709        );
4710    }
4711
4712    #[test]
4713    fn skill_search_dirs_expands_tilde() {
4714        let cfg = Config {
4715            skills: Some(SkillsConfig {
4716                search: vec!["~/skills-x".into()],
4717            }),
4718            ..Config::default()
4719        };
4720        let dirs = cfg.skill_search_dirs();
4721        // The final component survives expansion regardless of whether $HOME
4722        // was set; when set, the leading `~` must be gone.
4723        assert!(dirs[0].ends_with("skills-x"));
4724        assert!(!dirs[0].starts_with("~"));
4725    }
4726
4727    #[test]
4728    fn skills_search_round_trips_through_toml() {
4729        let cfg = Config {
4730            skills: Some(SkillsConfig {
4731                search: vec!["~/.newt/skills".into(), "~/.claude/skills".into()],
4732            }),
4733            ..Config::default()
4734        };
4735        let text = toml::to_string_pretty(&cfg).unwrap();
4736        let back: Config = toml::from_str(&text).unwrap();
4737        assert_eq!(
4738            back.skills.unwrap().search,
4739            vec!["~/.newt/skills".to_string(), "~/.claude/skills".to_string()]
4740        );
4741    }
4742    use tempfile::NamedTempFile;
4743
4744    #[test]
4745    fn defaults_are_sensible() {
4746        let cfg = Config::default();
4747        assert_eq!(cfg.backends.len(), 1);
4748        assert_eq!(cfg.providers.len(), 0);
4749        assert_eq!(cfg.default_tier_order.len(), 4);
4750    }
4751
4752    #[test]
4753    fn conversations_config_defaults_to_count_cap() {
4754        let cfg = Config::default();
4755        let conversations = cfg.conversations.unwrap_or_default();
4756        assert_eq!(conversations.max_per_workspace, 100);
4757        // 17.7: auto-resume defaults ON; `resume = false` is the off-switch.
4758        assert!(conversations.resume);
4759    }
4760
4761    #[test]
4762    fn conversations_config_roundtrips_through_toml() {
4763        let cfg: Config = toml::from_str(
4764            r#"
4765[conversations]
4766max_per_workspace = 25
4767"#,
4768        )
4769        .unwrap();
4770
4771        let conversations = cfg.conversations.unwrap_or_default();
4772        assert_eq!(conversations.max_per_workspace, 25);
4773        // Partial [conversations] table: unset keys keep their defaults.
4774        assert!(conversations.resume);
4775    }
4776
4777    #[test]
4778    fn conversations_resume_off_switch_parses() {
4779        let cfg: Config = toml::from_str(
4780            r#"
4781[conversations]
4782resume = false
4783"#,
4784        )
4785        .unwrap();
4786
4787        assert!(!cfg.conversations.unwrap_or_default().resume);
4788    }
4789
4790    #[test]
4791    fn agents_config_default_enabled() {
4792        let cfg = AgentsConfig::default();
4793        assert!(cfg.enabled);
4794        assert_eq!(cfg.path, None);
4795        // A bare Config defaults agents to enabled too.
4796        assert!(Config::default().agents.enabled);
4797    }
4798
4799    #[test]
4800    fn agents_config_roundtrips_with_path() {
4801        let cfg: Config = toml::from_str(
4802            r#"
4803[agents]
4804path = "docs/instructions"
4805"#,
4806        )
4807        .unwrap();
4808        assert!(cfg.agents.enabled);
4809        assert_eq!(cfg.agents.path.as_deref(), Some("docs/instructions"));
4810
4811        // Serialize back out and confirm the path survives.
4812        let text = toml::to_string(&cfg).unwrap();
4813        assert!(text.contains("docs/instructions"));
4814    }
4815
4816    #[test]
4817    fn agents_config_can_be_disabled() {
4818        let cfg: Config = toml::from_str(
4819            r#"
4820[agents]
4821enabled = false
4822"#,
4823        )
4824        .unwrap();
4825        assert!(!cfg.agents.enabled);
4826        assert_eq!(cfg.agents.path, None);
4827    }
4828
4829    #[test]
4830    fn load_happy_path() {
4831        let toml_text = r#"
4832[[backends]]
4833name = "local-ollama"
4834endpoint = "http://localhost:11434"
4835model = "mistral:7b"
4836tiers = ["FAST", "STANDARD"]
4837
4838[[providers]]
4839name = "cloud"
4840command = "newt-cloud-shim"
4841model = "gpt-4.1-mini"
4842env_pass = ["CLOUD_TOKEN"]
4843tiers = ["COMPLEX", "REVIEW"]
4844
4845default_tier_order = ["FAST", "STANDARD", "COMPLEX", "REVIEW"]
4846"#;
4847        let mut f = NamedTempFile::new().unwrap();
4848        f.write_all(toml_text.as_bytes()).unwrap();
4849        f.flush().unwrap();
4850
4851        let cfg = Config::load(f.path()).unwrap();
4852        assert_eq!(cfg.backends.len(), 1);
4853        assert_eq!(cfg.backends[0].name, "local-ollama");
4854        assert_eq!(cfg.backends[0].model, "mistral:7b");
4855        assert_eq!(cfg.backends[0].tiers, vec![Tier::Fast, Tier::Standard]);
4856        assert_eq!(cfg.providers.len(), 1);
4857        assert_eq!(cfg.providers[0].name, "cloud");
4858        assert_eq!(cfg.providers[0].model.as_deref(), Some("gpt-4.1-mini"));
4859        assert_eq!(cfg.providers[0].env_pass, vec!["CLOUD_TOKEN".to_string()]);
4860    }
4861
4862    #[test]
4863    fn provider_model_is_optional_for_legacy_configs() {
4864        let cfg: Config = toml::from_str(
4865            r#"
4866[[providers]]
4867name = "legacy-cloud"
4868command = "newt-cloud-shim"
4869env_pass = ["CLOUD_TOKEN"]
4870tiers = ["COMPLEX"]
4871"#,
4872        )
4873        .unwrap();
4874
4875        assert_eq!(cfg.providers.len(), 1);
4876        assert_eq!(cfg.providers[0].model, None);
4877    }
4878
4879    #[test]
4880    fn missing_file_returns_io_error() {
4881        let result = Config::load(Path::new("/tmp/newt-does-not-exist-12345.toml"));
4882        assert!(result.is_err());
4883        let err = result.unwrap_err();
4884        assert!(
4885            matches!(err, NewtError::Io(_)),
4886            "expected Io error, got: {err:?}"
4887        );
4888    }
4889
4890    #[test]
4891    fn malformed_toml_returns_config_error() {
4892        let mut f = NamedTempFile::new().unwrap();
4893        f.write_all(b"{{{{").unwrap();
4894        f.flush().unwrap();
4895
4896        let result = Config::load(f.path());
4897        assert!(result.is_err());
4898        let err = result.unwrap_err();
4899        assert!(
4900            matches!(err, NewtError::Config(_)),
4901            "expected Config error, got: {err:?}"
4902        );
4903    }
4904
4905    #[test]
4906    fn resolve_returns_default_when_no_file() {
4907        // Use a temp dir as cwd and clear env to ensure no candidates match.
4908        let dir = tempfile::tempdir().unwrap();
4909
4910        // Save & clear environment to isolate the test.
4911        let saved_config = std::env::var("NEWT_CONFIG").ok();
4912        let saved_home = std::env::var("HOME").ok();
4913        std::env::remove_var("NEWT_CONFIG");
4914        std::env::set_var("HOME", dir.path());
4915
4916        // Run resolve from inside the temp dir so ./newt.toml won't exist.
4917        let prev_dir = std::env::current_dir().unwrap();
4918        std::env::set_current_dir(dir.path()).unwrap();
4919
4920        let cfg = Config::resolve().unwrap();
4921
4922        // Restore environment.
4923        std::env::set_current_dir(prev_dir).unwrap();
4924        if let Some(v) = saved_home {
4925            std::env::set_var("HOME", v);
4926        }
4927        if let Some(v) = saved_config {
4928            std::env::set_var("NEWT_CONFIG", v);
4929        }
4930
4931        assert_eq!(cfg.backends.len(), 1);
4932        assert_eq!(cfg.backends[0].name, "ollama");
4933    }
4934
4935    // --- Project-local `.newt/config.toml` layering (issue #222) ---
4936
4937    #[test]
4938    fn merge_toml_recurses_tables_and_replaces_scalars() {
4939        let mut base: toml::Value = toml::from_str(
4940            "a = 1\nb = 2\n[tui]\nmid_loop_trim_threshold = 40\nmax_tool_rounds = 25\n",
4941        )
4942        .unwrap();
4943        let overlay: toml::Value =
4944            toml::from_str("b = 99\nc = 3\n[tui]\nmax_tool_rounds = 5\n").unwrap();
4945        merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
4946        // Scalar overridden, untouched scalar kept, new scalar added.
4947        assert_eq!(base["a"].as_integer(), Some(1));
4948        assert_eq!(base["b"].as_integer(), Some(99));
4949        assert_eq!(base["c"].as_integer(), Some(3));
4950        // Table merged recursively: overridden key wins, sibling preserved.
4951        assert_eq!(base["tui"]["max_tool_rounds"].as_integer(), Some(5));
4952        assert_eq!(
4953            base["tui"]["mid_loop_trim_threshold"].as_integer(),
4954            Some(40)
4955        );
4956    }
4957
4958    #[test]
4959    fn merge_toml_replaces_arrays_wholesale_by_default() {
4960        let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\", \"c\"]").unwrap();
4961        let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
4962        merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
4963        let arr = base["models"].as_array().unwrap();
4964        assert_eq!(arr.len(), 1, "replace strategy swaps the array");
4965        assert_eq!(arr[0].as_str(), Some("x"));
4966    }
4967
4968    #[test]
4969    fn merge_toml_appends_arrays_when_strategy_is_append() {
4970        let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\"]").unwrap();
4971        let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
4972        merge_toml(&mut base, overlay, ArrayMergeStrategy::Append);
4973        let arr = base["models"].as_array().unwrap();
4974        // Global entries first, then the project's appended.
4975        let got: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
4976        assert_eq!(got, vec!["a", "b", "x"]);
4977    }
4978
4979    #[test]
4980    fn array_merge_strategy_project_wins_then_base_then_default() {
4981        let append: toml::Value = toml::from_str("[merge]\narrays = \"append\"\n").unwrap();
4982        let replace: toml::Value = toml::from_str("[merge]\narrays = \"replace\"\n").unwrap();
4983        let none: toml::Value = toml::from_str("x = 1").unwrap();
4984        // Project setting wins over the base.
4985        assert_eq!(
4986            array_merge_strategy(&append, &replace),
4987            ArrayMergeStrategy::Append
4988        );
4989        // Falls back to the base when the project is silent.
4990        assert_eq!(
4991            array_merge_strategy(&none, &append),
4992            ArrayMergeStrategy::Append
4993        );
4994        // Defaults to Replace when neither sets it.
4995        assert_eq!(
4996            array_merge_strategy(&none, &none),
4997            ArrayMergeStrategy::Replace
4998        );
4999        // Unrecognized values are ignored (fall through to default).
5000        let bogus: toml::Value = toml::from_str("[merge]\narrays = \"sideways\"\n").unwrap();
5001        assert_eq!(
5002            array_merge_strategy(&bogus, &none),
5003            ArrayMergeStrategy::Replace
5004        );
5005    }
5006
5007    #[test]
5008    fn append_strategy_adds_project_mcp_server_to_global() {
5009        // The motivating case from issue #222: a project registers an extra
5010        // local stdio MCP server without redefining the global one.
5011        let global = "\
5012[merge]
5013arrays = \"append\"
5014
5015[[mcp_servers]]
5016name = \"global-fs\"
5017command = \"mcp-fs\"
5018";
5019        let project = "\
5020[[mcp_servers]]
5021name = \"project-fs\"
5022command = \"mcp-fs\"
5023args = [\"--root\", \".\"]
5024";
5025        let mut merged: toml::Value = toml::from_str(global).unwrap();
5026        let proj_val: toml::Value = toml::from_str(project).unwrap();
5027        let strategy = array_merge_strategy(&proj_val, &merged);
5028        assert_eq!(strategy, ArrayMergeStrategy::Append);
5029        merge_toml(&mut merged, proj_val, strategy);
5030        let cfg: Config = merged.try_into().unwrap();
5031        let names: Vec<&str> = cfg.mcp_servers.iter().map(|m| m.name.as_str()).collect();
5032        assert_eq!(names, vec!["global-fs", "project-fs"]);
5033    }
5034
5035    #[test]
5036    fn find_project_config_walks_up_and_stops_before_home() {
5037        let home = tempfile::tempdir().unwrap();
5038        // home/proj/sub  with a project config at home/proj/.newt/config.toml
5039        let proj = home.path().join("proj");
5040        let sub = proj.join("sub");
5041        std::fs::create_dir_all(&sub).unwrap();
5042        std::fs::create_dir_all(proj.join(".newt")).unwrap();
5043        std::fs::write(proj.join(".newt").join("config.toml"), "x = 1").unwrap();
5044        // Also place a (global) config at home/.newt to prove it's NOT returned.
5045        std::fs::create_dir_all(home.path().join(".newt")).unwrap();
5046        std::fs::write(home.path().join(".newt").join("config.toml"), "x = 9").unwrap();
5047
5048        let found = find_project_config_from(&sub, Some(home.path()));
5049        assert_eq!(found, Some(proj.join(".newt").join("config.toml")));
5050
5051        // From a dir with no project config above it (but under home), nothing.
5052        let bare = home.path().join("empty");
5053        std::fs::create_dir_all(&bare).unwrap();
5054        assert_eq!(find_project_config_from(&bare, Some(home.path())), None);
5055    }
5056
5057    #[test]
5058    fn project_config_deep_merges_over_global() {
5059        // global config: a backend + a tui block.
5060        let global = "\
5061[[backends]]
5062name = \"ollama\"
5063endpoint = \"http://localhost:11434\"
5064model = \"llama3\"
5065tiers = []
5066kind = \"ollama\"
5067
5068[tui]
5069mid_loop_trim_threshold = 40
5070max_tool_rounds = 25
5071";
5072        // project override: change max_tool_rounds only.
5073        let project = "[tui]\nmax_tool_rounds = 7\n";
5074
5075        let mut merged: toml::Value = toml::from_str(global).unwrap();
5076        merge_toml(
5077            &mut merged,
5078            toml::from_str(project).unwrap(),
5079            ArrayMergeStrategy::Replace,
5080        );
5081        let cfg: Config = merged.try_into().unwrap();
5082
5083        // Overridden value wins…
5084        assert_eq!(cfg.tui.as_ref().unwrap().max_tool_rounds, 7);
5085        // …sibling key preserved from global…
5086        assert_eq!(cfg.tui.as_ref().unwrap().mid_loop_trim_threshold, 40);
5087        // …and the global backend survived (not in the override).
5088        assert_eq!(cfg.backends.len(), 1);
5089        assert_eq!(cfg.backends[0].name, "ollama");
5090    }
5091
5092    #[test]
5093    fn config_default_has_no_dgx() {
5094        assert!(Config::default().dgx.is_none());
5095    }
5096
5097    #[test]
5098    fn to_redacted_toml_hides_mcp_secrets_but_keeps_shape() {
5099        let cfg: Config = toml::from_str(
5100            r#"
5101            [[backends]]
5102            name = "remote"
5103            endpoint = "http://remote:8000"
5104            model = "qwen3:32b"
5105            tiers = []
5106            kind = "openai"
5107            api_key_file = "~/.newt/openai.key"
5108
5109            [[mcp_servers]]
5110            name = "gh"
5111            type = "http"
5112            url = "https://api.example/mcp"
5113            [mcp_servers.headers]
5114            Authorization = "Bearer sk-super-secret-token"
5115            [mcp_servers.env]
5116            GH_TOKEN = "ghp_rawsecretvalue"
5117            RUST_LOG = "debug"
5118            "#,
5119        )
5120        .unwrap();
5121
5122        let dump = cfg.to_redacted_toml().unwrap();
5123        // The raw secret VALUES never appear…
5124        assert!(
5125            !dump.contains("sk-super-secret-token"),
5126            "header secret leaked:\n{dump}"
5127        );
5128        assert!(
5129            !dump.contains("ghp_rawsecretvalue"),
5130            "env secret leaked:\n{dump}"
5131        );
5132        // …but the KEYS and the placeholder do, so the audit shows the shape.
5133        assert!(dump.contains("Authorization"));
5134        assert!(dump.contains("GH_TOKEN"));
5135        assert!(dump.contains(Config::REDACTED));
5136        // Secret *references* (a path) are kept — they name where a secret lives.
5137        assert!(
5138            dump.contains("~/.newt/openai.key"),
5139            "api_key_file reference kept"
5140        );
5141        // Non-secret structure is intact.
5142        assert!(dump.contains("http://remote:8000"));
5143    }
5144
5145    #[test]
5146    fn config_with_dgx_roundtrips() {
5147        let cfg = Config {
5148            dgx: Some(crate::dgx::DgxConfig::home_template()),
5149            ..Config::default()
5150        };
5151        let text = toml::to_string_pretty(&cfg).unwrap();
5152        let back = toml::from_str::<Config>(&text).unwrap();
5153        let dgx = back.dgx.expect("dgx should round-trip");
5154        assert_eq!(dgx.active_node.as_deref(), Some("home"));
5155        assert_eq!(dgx.nodes.len(), 1);
5156        assert_eq!(dgx.formations.len(), 2);
5157    }
5158
5159    // --- ToolPermissions / to_caveats ---
5160
5161    #[test]
5162    fn workspace_dev_allows_cargo_and_just() {
5163        let perms = ToolPermissions::default(); // WorkspaceDev
5164        let cav = perms.to_caveats("/workspace");
5165        assert!(cav.permits_exec("cargo"), "cargo must be allowed");
5166        assert!(cav.permits_exec("just"), "just must be allowed");
5167        assert!(cav.permits_exec("git"), "git must be allowed");
5168    }
5169
5170    #[test]
5171    fn workspace_dev_blocks_rm_and_mv() {
5172        let perms = ToolPermissions::default();
5173        let cav = perms.to_caveats("/workspace");
5174        assert!(!cav.permits_exec("rm"), "rm must be blocked");
5175        assert!(!cav.permits_exec("mv"), "mv must be blocked");
5176        assert!(!cav.permits_exec("sudo"), "sudo must be blocked");
5177    }
5178
5179    #[test]
5180    fn workspace_dev_allows_common_dev_tools() {
5181        // Regression: these were denied under the default preset even though
5182        // they're the same risk tier as cargo/git (issue #149). `gh` in
5183        // particular is authenticated outside but was blocked in-agent.
5184        let cav = ToolPermissions::default().to_caveats("/workspace");
5185        for tool in [
5186            "gh", "python", "python3", "pip", "npm", "node", "make", "jq", "curl", "awk", "sed",
5187            "cut", "xargs", "which", "env",
5188        ] {
5189            assert!(cav.permits_exec(tool), "`{tool}` must be allowed");
5190        }
5191        // Adding tools must NOT escalate to full access — destructive commands
5192        // outside the allowlist stay blocked.
5193        assert!(!cav.permits_exec("rm"), "rm must still be blocked");
5194        assert!(!cav.permits_exec("sudo"), "sudo must still be blocked");
5195    }
5196
5197    #[test]
5198    fn workspace_dev_allows_extra_exec() {
5199        let perms = ToolPermissions {
5200            preset: PermissionPreset::WorkspaceDev,
5201            extra_exec: vec!["bacon".into(), "make".into()],
5202            net: vec![],
5203            prompt: false,
5204        };
5205        let cav = perms.to_caveats("/workspace");
5206        assert!(cav.permits_exec("bacon"));
5207        assert!(cav.permits_exec("make"));
5208        assert!(!cav.permits_exec("rm")); // extra_exec does not weaken the block
5209    }
5210
5211    #[test]
5212    fn read_only_blocks_writes_and_exec() {
5213        let perms = ToolPermissions {
5214            preset: PermissionPreset::ReadOnly,
5215            extra_exec: vec![],
5216            net: vec![],
5217            prompt: false,
5218        };
5219        let cav = perms.to_caveats("/workspace");
5220        assert!(!cav.permits_fs_write("/workspace/src/main.rs"));
5221        assert!(!cav.permits_exec("cargo"));
5222        assert!(cav.permits_fs_read("/workspace/src/main.rs"));
5223    }
5224
5225    #[test]
5226    fn workspace_edit_allows_write_blocks_exec() {
5227        let perms = ToolPermissions {
5228            preset: PermissionPreset::WorkspaceEdit,
5229            extra_exec: vec![],
5230            net: vec![],
5231            prompt: false,
5232        };
5233        let cav = perms.to_caveats("/workspace");
5234        assert!(!cav.permits_exec("cargo"));
5235        // The caveat stores workspace root; prefix matching is in the TUI layer.
5236        // Here we just verify the lattice is set up correctly (not All, not none).
5237        use crate::caveats::Scope;
5238        assert!(matches!(cav.fs_write, Scope::Only(_)));
5239    }
5240
5241    #[test]
5242    fn full_access_is_top() {
5243        let perms = ToolPermissions {
5244            preset: PermissionPreset::FullAccess,
5245            extra_exec: vec![],
5246            net: vec![],
5247            prompt: false,
5248        };
5249        let cav = perms.to_caveats("/workspace");
5250        assert_eq!(cav, crate::caveats::Caveats::top());
5251    }
5252
5253    #[test]
5254    fn net_allowlist_controls_the_net_axis() {
5255        use crate::caveats::Scope;
5256
5257        // Default (empty `net`) => no network: web_fetch is denied.
5258        let none = ToolPermissions::default().to_caveats("/ws");
5259        assert!(
5260            matches!(none.net, Scope::Only(ref s) if s.is_empty()),
5261            "empty net config must yield an empty (deny-all) net scope"
5262        );
5263
5264        // Explicit host allowlist — works under ANY preset (here ReadOnly), so
5265        // web access does not require granting writes/exec.
5266        let hosts = ToolPermissions {
5267            preset: PermissionPreset::ReadOnly,
5268            extra_exec: vec![],
5269            net: vec!["docs.rs".into(), "github.com".into()],
5270            prompt: false,
5271        }
5272        .to_caveats("/ws");
5273        assert!(
5274            matches!(hosts.net, Scope::Only(ref s) if s.contains("docs.rs") && s.contains("github.com")),
5275            "explicit hosts must populate the net allowlist"
5276        );
5277
5278        // A single "*" grants all hosts (still SSRF-screened by the web tool).
5279        let all = ToolPermissions {
5280            preset: PermissionPreset::WorkspaceDev,
5281            extra_exec: vec![],
5282            net: vec!["*".into()],
5283            prompt: false,
5284        }
5285        .to_caveats("/ws");
5286        assert!(
5287            matches!(all.net, Scope::All),
5288            "a `*` entry must grant the whole net axis"
5289        );
5290    }
5291
5292    #[test]
5293    fn custom_is_workspace_dev_not_top() {
5294        // Regression: editing the exec allowlist auto-flips the preset to
5295        // `Custom`, which used to map to `Caveats::top()` — a silent escalation
5296        // from "add one command" to "full access". `Custom` must now carry
5297        // WorkspaceDev authority plus the extra commands, never `top()`.
5298        let custom = ToolPermissions {
5299            preset: PermissionPreset::Custom,
5300            extra_exec: vec!["bacon".into()],
5301            net: vec![],
5302            prompt: false,
5303        }
5304        .to_caveats("/workspace");
5305        assert_ne!(
5306            custom,
5307            crate::caveats::Caveats::top(),
5308            "Custom must not be full access"
5309        );
5310        assert!(custom.permits_exec("cargo"), "workspace-dev tools allowed");
5311        assert!(custom.permits_exec("bacon"), "extra_exec command allowed");
5312        assert!(!custom.permits_exec("rm"), "non-allowlisted command denied");
5313        // Identical to WorkspaceDev with the same extras.
5314        let workspace_dev = ToolPermissions {
5315            preset: PermissionPreset::WorkspaceDev,
5316            extra_exec: vec!["bacon".into()],
5317            net: vec![],
5318            prompt: false,
5319        }
5320        .to_caveats("/workspace");
5321        assert_eq!(
5322            custom, workspace_dev,
5323            "Custom carries WorkspaceDev authority + extras"
5324        );
5325    }
5326
5327    #[test]
5328    fn preset_toggle_cycles() {
5329        assert_eq!(
5330            PermissionPreset::ReadOnly.toggle(),
5331            PermissionPreset::WorkspaceEdit
5332        );
5333        assert_eq!(
5334            PermissionPreset::WorkspaceEdit.toggle(),
5335            PermissionPreset::WorkspaceDev
5336        );
5337        assert_eq!(
5338            PermissionPreset::WorkspaceDev.toggle(),
5339            PermissionPreset::FullAccess
5340        );
5341        assert_eq!(
5342            PermissionPreset::FullAccess.toggle(),
5343            PermissionPreset::ReadOnly
5344        );
5345    }
5346
5347    #[test]
5348    fn tool_permissions_toml_roundtrip() {
5349        let perms = ToolPermissions {
5350            preset: PermissionPreset::WorkspaceDev,
5351            extra_exec: vec!["bacon".into()],
5352            net: vec![],
5353            prompt: false,
5354        };
5355        let toml = toml::to_string(&perms).unwrap();
5356        assert!(toml.contains("workspace_dev"));
5357        assert!(toml.contains("bacon"));
5358        let back: ToolPermissions = toml::from_str(&toml).unwrap();
5359        assert_eq!(back, perms);
5360    }
5361
5362    // ---- #904: comment-preserving "allow permanently" net writer ----
5363
5364    #[test]
5365    fn with_net_host_creates_table_from_empty_and_scope_includes_host() {
5366        let out = Config::with_net_host("", "github.com").unwrap();
5367        // The written TOML parses back and its net scope now permits the host.
5368        let cfg: Config = toml::from_str(&out).unwrap();
5369        let perms = cfg.tui.unwrap().permissions;
5370        assert!(perms.net.contains(&"github.com".to_string()));
5371        assert!(
5372            matches!(perms.net_scope(), crate::caveats::Scope::Only(ref s) if s.contains("github.com")),
5373            "net_scope must permit the granted host"
5374        );
5375    }
5376
5377    #[test]
5378    fn with_net_host_preserves_comments_and_other_keys() {
5379        let original = "\
5380# my hand-authored config — keep this comment
5381[tui.permissions]
5382preset = \"workspace_dev\"  # inline comment
5383net = [\"already.example.com\"]
5384";
5385        let out = Config::with_net_host(original, "github.com").unwrap();
5386        // Comments survive (the whole point vs Config::save).
5387        assert!(
5388            out.contains("# my hand-authored config"),
5389            "top comment lost: {out}"
5390        );
5391        assert!(
5392            out.contains("# inline comment"),
5393            "inline comment lost: {out}"
5394        );
5395        // The pre-existing host is kept and the new one appended.
5396        assert!(out.contains("already.example.com"));
5397        assert!(out.contains("github.com"));
5398        // preset key untouched.
5399        assert!(out.contains("workspace_dev"));
5400    }
5401
5402    #[test]
5403    fn with_net_host_is_idempotent_no_duplicate() {
5404        let once = Config::with_net_host("", "github.com").unwrap();
5405        let twice = Config::with_net_host(&once, "github.com").unwrap();
5406        assert_eq!(
5407            twice.matches("github.com").count(),
5408            1,
5409            "duplicated host: {twice}"
5410        );
5411    }
5412
5413    #[test]
5414    fn with_net_host_rejects_invalid_toml() {
5415        assert!(Config::with_net_host("this = = not toml", "github.com").is_err());
5416    }
5417
5418    fn openai_backend(api_key_file: Option<String>, api_key_env: Option<String>) -> BackendConfig {
5419        BackendConfig {
5420            name: "remote".into(),
5421            endpoint: "https://example.test".into(),
5422            model: "some-model".into(),
5423            model_path: None,
5424            tiers: vec![Tier::Fast],
5425            kind: BackendKind::Openai,
5426            api: Default::default(),
5427            api_key_file,
5428            api_key_env,
5429        }
5430    }
5431
5432    #[test]
5433    fn backend_kind_defaults_to_ollama_when_absent() {
5434        let toml = r#"
5435            [[backends]]
5436            name = "local"
5437            endpoint = "http://localhost:8000"
5438            model = "m"
5439            tiers = ["FAST"]
5440        "#;
5441        let cfg: Config = toml::from_str(toml).unwrap();
5442        assert_eq!(cfg.backends[0].kind, BackendKind::Ollama);
5443        assert!(cfg.backends[0].api_key_file.is_none());
5444        assert!(cfg.backends[0].api_key_env.is_none());
5445    }
5446
5447    #[test]
5448    fn backend_kind_parses_openai_and_aliases() {
5449        for kind_str in ["openai", "vllm", "openai-compatible"] {
5450            let toml = format!(
5451                "[[backends]]\nname=\"x\"\nendpoint=\"http://e\"\nmodel=\"m\"\ntiers=[\"FAST\"]\nkind=\"{kind_str}\"\n"
5452            );
5453            let cfg: Config = toml::from_str(&toml).unwrap();
5454            assert_eq!(cfg.backends[0].kind, BackendKind::Openai, "kind={kind_str}");
5455        }
5456    }
5457
5458    #[test]
5459    fn backend_kind_label_is_protocol_name() {
5460        assert_eq!(BackendKind::Ollama.label(), "ollama");
5461        assert_eq!(BackendKind::Openai.label(), "openai");
5462    }
5463
5464    #[test]
5465    fn backend_config_roundtrips_auth_fields() {
5466        let cfg = openai_backend(Some("~/.newt/token".into()), Some("MY_TOKEN".into()));
5467        let toml = toml::to_string(&cfg).unwrap();
5468        assert!(toml.contains("kind = \"openai\""));
5469        assert!(toml.contains("api_key_file"));
5470        assert!(toml.contains("api_key_env"));
5471        let back: BackendConfig = toml::from_str(&toml).unwrap();
5472        assert_eq!(back.kind, BackendKind::Openai);
5473        assert_eq!(back.api_key_file.as_deref(), Some("~/.newt/token"));
5474        assert_eq!(back.api_key_env.as_deref(), Some("MY_TOKEN"));
5475    }
5476
5477    #[test]
5478    fn resolve_api_key_reads_first_nonempty_line_of_file() {
5479        let mut f = tempfile::NamedTempFile::new().unwrap();
5480        // Leading blank line + surrounding whitespace must be skipped/trimmed.
5481        write!(f, "\n  secret-token-123  \nignored-second-line\n").unwrap();
5482        let cfg = openai_backend(Some(f.path().to_string_lossy().into_owned()), None);
5483        assert_eq!(cfg.resolve_api_key().as_deref(), Some("secret-token-123"));
5484    }
5485
5486    #[test]
5487    fn resolve_api_key_env_takes_precedence_over_file() {
5488        let var = "NEWT_TEST_API_KEY_PRECEDENCE";
5489        std::env::set_var(var, "  from-env  ");
5490        let mut f = tempfile::NamedTempFile::new().unwrap();
5491        writeln!(f, "from-file").unwrap();
5492        let cfg = openai_backend(
5493            Some(f.path().to_string_lossy().into_owned()),
5494            Some(var.into()),
5495        );
5496        assert_eq!(cfg.resolve_api_key().as_deref(), Some("from-env"));
5497        std::env::remove_var(var);
5498    }
5499
5500    #[test]
5501    fn resolve_api_key_none_when_unconfigured() {
5502        assert_eq!(openai_backend(None, None).resolve_api_key(), None);
5503    }
5504
5505    #[test]
5506    fn resolve_api_key_none_for_missing_file() {
5507        let cfg = openai_backend(Some("/no/such/newt/token/file".into()), None);
5508        assert_eq!(cfg.resolve_api_key(), None);
5509    }
5510
5511    #[test]
5512    fn expand_tilde_expands_home_and_passes_through() {
5513        let home = home_dir().expect("HOME set in test env");
5514        assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
5515        assert_eq!(expand_tilde("~"), home);
5516        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
5517        assert_eq!(
5518            expand_tilde("relative/path"),
5519            PathBuf::from("relative/path")
5520        );
5521    }
5522
5523    #[test]
5524    fn default_max_tool_rounds_is_40() {
5525        // #<issue>: raised from 25 — a modest safety margin alongside
5526        // workflow_grace_rounds and the diagnose_failure delegate hint, not a
5527        // substitute for either. The function default and the struct default
5528        // agree on 40.
5529        assert_eq!(default_max_tool_rounds(), 40);
5530        assert_eq!(TuiConfig::default().max_tool_rounds, 40);
5531        assert_eq!(default_workflow_grace_rounds(), 5);
5532        assert_eq!(TuiConfig::default().workflow_grace_rounds, 5);
5533    }
5534
5535    #[test]
5536    fn tui_max_tool_rounds_defaults_when_field_absent() {
5537        // An empty `[tui]` table => serde default kicks in => 40.
5538        let toml = r#"
5539            [tui]
5540        "#;
5541        let cfg: Config = toml::from_str(toml).unwrap();
5542        assert_eq!(cfg.tui.unwrap().max_tool_rounds, 40);
5543    }
5544
5545    #[test]
5546    fn tui_max_tool_rounds_can_be_overridden() {
5547        let toml = r#"
5548            [tui]
5549            max_tool_rounds = 7
5550        "#;
5551        let cfg: Config = toml::from_str(toml).unwrap();
5552        assert_eq!(cfg.tui.unwrap().max_tool_rounds, 7);
5553    }
5554
5555    #[test]
5556    fn tui_workflow_grace_rounds_can_be_overridden_or_disabled() {
5557        let cfg: Config = toml::from_str(
5558            r#"
5559            [tui]
5560            workflow_grace_rounds = 9
5561        "#,
5562        )
5563        .unwrap();
5564        assert_eq!(cfg.tui.unwrap().workflow_grace_rounds, 9);
5565
5566        let disabled: Config = toml::from_str(
5567            r#"
5568            [tui]
5569            workflow_grace_rounds = 0
5570        "#,
5571        )
5572        .unwrap();
5573        assert_eq!(disabled.tui.unwrap().workflow_grace_rounds, 0);
5574    }
5575
5576    #[test]
5577    fn model_tuning_parses_from_toml() {
5578        let toml = r#"
5579            [[model_tuning]]
5580            model = "nemotron3:33b"
5581            num_ctx = 24576
5582            mid_loop_trim_threshold = 12
5583            max_tool_rounds = 20
5584            workflow_grace_rounds = 8
5585
5586            [[model_tuning]]
5587            model = "qwen3-coder:30b"
5588            num_ctx = 65536
5589        "#;
5590        let cfg: Config = toml::from_str(toml).unwrap();
5591        assert_eq!(cfg.model_tuning.len(), 2);
5592
5593        let nemo = cfg.find_model_tuning("nemotron3:33b").unwrap();
5594        assert_eq!(nemo.num_ctx, Some(24576));
5595        assert_eq!(nemo.mid_loop_trim_threshold, Some(12));
5596        assert_eq!(nemo.max_tool_rounds, Some(20));
5597        assert_eq!(nemo.workflow_grace_rounds, Some(8));
5598
5599        let qwen = cfg.find_model_tuning("qwen3-coder:30b").unwrap();
5600        assert_eq!(qwen.num_ctx, Some(65536));
5601        assert_eq!(qwen.mid_loop_trim_threshold, None);
5602        assert_eq!(qwen.workflow_grace_rounds, None);
5603    }
5604
5605    #[test]
5606    fn model_tuning_find_returns_none_for_unknown_model() {
5607        let cfg = Config::default();
5608        assert!(cfg.find_model_tuning("nonexistent:7b").is_none());
5609    }
5610
5611    #[test]
5612    fn model_tuning_partial_fields_are_optional() {
5613        let toml = r#"
5614            [[model_tuning]]
5615            model = "llama3.1:8b"
5616        "#;
5617        let cfg: Config = toml::from_str(toml).unwrap();
5618        let entry = cfg.find_model_tuning("llama3.1:8b").unwrap();
5619        assert_eq!(entry.num_ctx, None);
5620        assert_eq!(entry.mid_loop_trim_threshold, None);
5621        assert_eq!(entry.max_tool_rounds, None);
5622        assert_eq!(entry.workflow_grace_rounds, None);
5623    }
5624
5625    // ---- #726: [tools] max_output_tokens ----
5626
5627    #[test]
5628    fn tools_max_output_tokens_defaults_to_10k_when_absent() {
5629        // No `[tools]` section ⇒ the built-in default budget.
5630        let cfg: Config = toml::from_str("").unwrap();
5631        assert!(cfg.tools.is_none());
5632        assert_eq!(cfg.max_output_tokens(), 10_000);
5633        assert_eq!(cfg.output_head_tokens(), 1_500);
5634        assert_eq!(Config::default().max_output_tokens(), 10_000);
5635        assert_eq!(Config::default().output_head_tokens(), 1_500);
5636    }
5637
5638    #[test]
5639    fn tools_max_output_tokens_parses_an_override() {
5640        let cfg: Config = toml::from_str(
5641            r#"
5642            [tools]
5643            max_output_tokens = 4096
5644            output_head_tokens = 512
5645        "#,
5646        )
5647        .unwrap();
5648        assert_eq!(cfg.tools.as_ref().unwrap().max_output_tokens, 4096);
5649        assert_eq!(cfg.tools.as_ref().unwrap().output_head_tokens, 512);
5650        assert_eq!(cfg.max_output_tokens(), 4096);
5651        assert_eq!(cfg.output_head_tokens(), 512);
5652    }
5653
5654    #[test]
5655    fn tools_config_default_field_is_the_shared_default() {
5656        // A `[tools]` table that omits the key falls back to the default fn.
5657        let cfg: Config = toml::from_str("[tools]\n").unwrap();
5658        assert_eq!(cfg.max_output_tokens(), 10_000);
5659        assert_eq!(cfg.output_head_tokens(), 1_500);
5660    }
5661
5662    #[test]
5663    fn tools_max_output_tokens_zero_is_a_valid_no_cap() {
5664        let cfg: Config = toml::from_str("[tools]\nmax_output_tokens = 0\n").unwrap();
5665        assert_eq!(cfg.max_output_tokens(), 0);
5666    }
5667}