//! Configuration loading for Newt-Agent.
//!
//! Base resolution order: `$NEWT_CONFIG` env var, then `./newt.toml`,
//! `$NEWT_CONFIG_DIR/config.toml` (or `~/.newt/config.toml`), then
//! `/etc/newt/config.toml`. If none exist the built-in defaults are used
//! (a single Ollama backend on localhost).
//!
//! A project-local `.newt/config.toml` (found by walking up from the current
//! directory) is then deep-merged **over** that base, so a git repo can pin its
//! own models, endpoints, rules, and local stdio MCP services without copying
//! the whole global config. See [`Config::resolve`] and issue #222.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::{NewtError, Result};
use crate::router::Tier;

/// Process-scoped user config root override, set by the CLI's `--config-dir`.
pub const NEWT_CONFIG_DIR_ENV: &str = "NEWT_CONFIG_DIR";

// ---------------------------------------------------------------------------
// Config types
// ---------------------------------------------------------------------------

/// `[scratch]` — the ephemeral-state location (#844). See [`Config::scratch`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScratchConfig {
    /// The scratch dir: relative (under the repo, default `.scratch`) or absolute
    /// (`/tmp`, a PVC mount) for a read-only checkout. `NEWT_SCRATCH_DIR` wins.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dir: Option<String>,
}

/// Top-level Newt-Agent configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Inference backends (Ollama, vLLM, etc.).
    ///
    /// Absent `[[backends]]` deserializes to **empty** (not the struct-level
    /// default's localhost fallback) so a config that defines its backends as
    /// per-file `~/.newt/backends/*.toml` drop-ins does NOT also pick up a
    /// spurious synthesized `ollama` entry. The localhost fallback is restored
    /// in [`Config::resolve`] only if backends are still empty after the disk
    /// merge (so a truly bare setup still talks to a local Ollama).
    #[serde(default = "Vec::new")]
    pub backends: Vec<BackendConfig>,

    /// External provider-plugin definitions.
    pub providers: Vec<ProviderConfig>,

    /// `[scratch]` — where ephemeral state (crew worktrees, the crew cargo
    /// target, per-session plans) lives (#844). `dir` may be relative (under the
    /// repo, default `.scratch`) or absolute (`/tmp`, a k8s PVC mount) for
    /// read-only checkouts. `NEWT_SCRATCH_DIR` overrides it. Applied in
    /// [`Config::resolve`] via [`crate::scratch::set_scratch_dir`].
    #[serde(default)]
    pub scratch: Option<ScratchConfig>,

    /// Default tier ordering used by the router when no per-backend
    /// override is specified.
    pub default_tier_order: Vec<Tier>,

    /// `[lifecycle]` — the repo's build/dev commands per lifecycle phase
    /// (`format`, `check`, `clean`, …), #880. Overrides the per-ecosystem tooling
    /// packs. Applied in [`Config::resolve`] via
    /// [`crate::tooling::set_lifecycle_override`].
    #[serde(default)]
    pub lifecycle: Option<crate::tooling::PhaseCommands>,

    /// Optional NVIDIA DGX endpoint-management config powering the
    /// `newt dgx` command suite. `None` when unconfigured — newt never
    /// dials a DGX endpoint unless this (or a `NEWT_DGX_*` env var) is set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dgx: Option<crate::dgx::DgxConfig>,

    /// TUI appearance and behaviour. `None` → built-in defaults apply.
    /// Overridable at runtime via `NEWT_CHAT_STYLE` and `NEWT_PROMPT`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tui: Option<TuiConfig>,

    /// `[shell]` — which engine runs `run_command` (ADR 0005 D2 seam). `None` /
    /// unset → the `safe-subset` default, except `--full-access` auto-upgrades to
    /// `host`. Overridable per-session by `--shell-engine`. The L3 backend
    /// (Landlock/Seatbelt/AppContainer) is a separate, auto-selected axis.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<ShellConfig>,

    /// `[context]` — context-management strategy selection (Step 24.8, #559).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context: Option<ContextConfig>,

    /// `[tools]` — tool-execution behaviour (#726). `None` → built-in defaults
    /// (notably `max_output_tokens` = 10000). See [`ToolsConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tools: Option<ToolsConfig>,

    /// Inference cost modeling. `None` → built-in rate table only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pricing: Option<crate::pricing::PricingConfig>,

    /// Memory / context-window management. `None` → RollingWindow(20).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory: Option<MemoryConfig>,

    /// Project-instruction loading (`AGENTS.md` / `CLAUDE.md`) into the system
    /// prompt. Enabled by default. Overridable via `--agents-file` /
    /// `--no-agents-file`.
    #[serde(default)]
    pub agents: AgentsConfig,

    /// newt-native MCP servers (`[[mcp_servers]]`). Merged with the servers
    /// discovered from Claude Code's config by [`crate::mcp::discover`]; these
    /// take precedence on a name clash. Empty by default.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mcp_servers: Vec<crate::mcp::McpServerEntry>,

    /// Usage-log rotation policy. `None` → built-in defaults apply
    /// (keep last 7 sessions, no size/age limit).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub logs: Option<LogConfig>,

    /// Skill discovery search path — the ordered list of directories newt
    /// reads `SKILL.md` folders from. `None` → just `~/.newt/skills`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skills: Option<SkillsConfig>,

    /// Per-model inference tuning overrides (`[[model_tuning]]`).
    ///
    /// Each entry locks specific parameters for a named model. Values here
    /// take precedence over empirically derived values from
    /// `model-capabilities.json` and over global `[tui]` defaults.
    ///
    /// Example `~/.newt/config.toml`:
    /// ```toml
    /// [[model_tuning]]
    /// model = "nemotron3:33b"
    /// num_ctx = 24576            # explicit Ollama context window
    /// mid_loop_trim_threshold = 12
    /// max_tool_rounds = 20
    /// ```
    ///
    /// Human-authored entries are never overwritten by the auto-tuner.
    /// Auto-tuned entries are **appended** by the harness when
    /// `tune_confidence` reaches `High`; delete or edit them freely.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub model_tuning: Vec<ModelTuning>,

    /// Durable conversation save/restore policy. `None` uses built-in defaults.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversations: Option<ConversationsConfig>,

    /// How a project-local `.newt/config.toml` is layered over the global
    /// config (issue #222). `None` → built-in default (arrays replace).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub merge: Option<MergeConfig>,

    /// Named permission presets (`[permission_presets.<name>]`, issue #307).
    /// Each maps onto the role-profile caveat mechanism (a
    /// [`crate::NamedPermissionPreset`]) and, when applied via `/mode`, clamps
    /// the session's authority as a hard floor. Empty by default — no preset,
    /// behavior unchanged.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub permission_presets: std::collections::BTreeMap<String, crate::NamedPermissionPreset>,

    /// Named modes (`[modes.<name>]`, issue #307) for the `/mode` command. Each
    /// mode atomically binds a skill body to preload, a permission preset to
    /// apply as an authority floor, and a one-line system-prompt framing. Empty
    /// by default. See [`ModeConfig`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub modes: std::collections::BTreeMap<String, ModeConfig>,

    /// Named profiles (`[profiles.<name>]`) — a composition of harness
    /// *techniques* plus each technique's tunable knob settings (the technique
    /// library, `docs/design/technique-library.md`). A profile is selected by
    /// `--profile <name>` and tunes the harness per model family / context.
    /// Empty by default — no profile, behavior unchanged. See [`ProfileConfig`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub profiles: std::collections::BTreeMap<String, ProfileConfig>,

    /// Named bundles (`[bundles.<name>]`) — the loadable unit of the model support
    /// kit (`docs/design/model-support-kit.md`). A bundle pins which model families
    /// it applies to and which profile each resolves to. Selected by `--bundle
    /// <name>` or inferred from the model via `applies_to`. Empty by default — no
    /// bundle, behavior unchanged. See [`BundleConfig`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub bundles: std::collections::BTreeMap<String, BundleConfig>,

    /// Named loadouts (`[loadouts.<name>]` or `~/.newt/loadouts/<name>.toml`) — the
    /// top-level composition of `provider → model → kit → role → settings`
    /// (`docs/design/loadout-composition.md`). Inert until the resolver is wired
    /// (Slice 1): this carries the data model + reference validation + `/loadout`
    /// show. Empty by default. See [`Loadout`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub loadouts: std::collections::BTreeMap<String, Loadout>,

    /// Named crews (`[crews.<name>]` or `crews/<name>.toml`) — role-specialized
    /// ensembles over the backend pool (`docs/design/crew-loadout.md`). Each role
    /// names a `[loadouts.<name>]`. Empty by default. See [`Crew`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub crews: std::collections::BTreeMap<String, Crew>,

    /// `[crew]` — crew/team **dispatch policy** (#749). Carries the authority
    /// *clamp* every dispatched crew is met against, so a crew's effective
    /// authority is `session ⊓ clamp` — never above the session ceiling, and as
    /// tight as the operator configures. `None` (and the default clamp) is
    /// `Caveats::top()`, i.e. the meet is the identity and behavior is unchanged.
    /// This is the structural tightening point the per-subtask `team_clamp`
    /// (#749 step 8) plugs into. See [`CrewPolicyConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub crew: Option<CrewPolicyConfig>,

    /// `[plan]` — plan-authoring policy. Today: the `[plan.prune]` droppable
    /// override for the decompose prune's anti-pattern lexicon (#801/#803 →
    /// #819). `None` = compiled defaults, behavior unchanged. See
    /// [`PlanPruneConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub plan: Option<PlanConfig>,
}

/// One named mode (`[modes.<name>]`, issue #307): the atomic binding the
/// `/mode <name>` command applies in a single invocation.
///
/// ```toml
/// [modes.triage]
/// skill   = "oncall-triage"        # skill body to preload (use_skill path)
/// preset  = "readonly-triage"      # [permission_presets.<name>] to clamp to
/// framing = "On-call triage: investigate, do not change production."
/// ```
///
/// Every field is optional so a mode can do any subset (e.g. preset-only, or
/// framing-only). A `skill`/`preset` that names a missing entry is reported as
/// an error by the command rather than silently ignored — a mode that claims a
/// clamp it never applied would be a false security claim.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModeConfig {
    /// Skill name to preload (the same `use_skill` / `load_body_from` path).
    /// `None` ⇒ no skill is loaded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skill: Option<String>,
    /// `[permission_presets.<name>]` to apply as the session authority floor.
    /// `None` ⇒ authority unchanged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preset: Option<String>,
    /// One-line framing injected into the system prompt. `None` ⇒ no framing.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub framing: Option<String>,
}

/// The known harness techniques a profile may compose — the registry the
/// validator checks against. A profile naming a technique outside this set is
/// rejected (an unknown technique a profile claims but cannot apply would be a
/// false claim). Extend this as techniques land (R3 `fact_preserving_compression`,
/// R4 `self_grounding`, …).
pub const KNOWN_TECHNIQUES: &[&str] = &[
    "knowledge_base", // R1 — inject the authoritative import surface (#74)
    "verify_gate",    // R2 — revert files with fabricated imports (#73)
    "retry",          // revert-retry loop over the gate's revert set
];

/// One named profile (`[profiles.<name>]`): the harness techniques to compose for
/// a model family / context, plus each technique's knob settings.
///
/// ```toml
/// [profiles.nemotron]
/// techniques = ["knowledge_base", "verify_gate", "retry"]
///
/// [profiles.nemotron.verify_gate]
/// surface_match = "exact"        # SurfaceMatch — leaf-exact (the complete-gate default)
///
/// [profiles.nemotron.retry]
/// max_retries = 2
/// ```
///
/// A knob table only takes effect when its technique is enabled. An unknown
/// technique name is an error ([`ProfileConfig::validate`]).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ProfileConfig {
    /// The ordered set of techniques this profile composes. Empty ⇒ the profile
    /// applies no techniques (equivalent to the `default`/light profile).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub techniques: Vec<String>,
    /// Knobs for the `verify_gate` technique (applied iff it is enabled).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub verify_gate: Option<VerifyGateKnobs>,
    /// Knobs for the `retry` technique (applied iff it is enabled).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry: Option<RetryKnobs>,
}

/// Tunable knobs for the `verify_gate` technique.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct VerifyGateKnobs {
    /// How strictly the project surface is matched. Default `Exact` — the
    /// adversarially-complete setting (the retry-Goodhart finding).
    #[serde(default)]
    pub surface_match: crate::verify_gate::SurfaceMatch,
    /// How strictly the gate ACTS on flagged output — the tier. Default
    /// `RevertRetry` (today's behavior when the `retry` technique is on); lower
    /// tiers (`off`/`advisory`/`revert_once`) trade enforcement for latitude.
    #[serde(default)]
    pub tier: crate::verify_gate::VerifyTier,
}

/// Tunable knobs for the `retry` technique.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryKnobs {
    /// Maximum revert-retry attempts. Default 2.
    #[serde(default = "default_max_retries")]
    pub max_retries: u32,
}

const fn default_max_retries() -> u32 {
    2
}

impl Default for RetryKnobs {
    fn default() -> Self {
        Self {
            max_retries: default_max_retries(),
        }
    }
}

impl ProfileConfig {
    /// Validate the profile against the [component registry](crate::kit): every
    /// named technique must be a known component, and every component's
    /// `presupposes` must also be enabled (e.g. `retry` presupposes `verify_gate`).
    /// A presupposition gap is a **load-time** error, not a silent partial apply.
    ///
    /// # Errors
    /// Returns the first unknown-technique or unmet-presupposition as a message.
    pub fn validate(&self) -> std::result::Result<(), String> {
        for t in &self.techniques {
            let Some(entry) = crate::kit::component(t) else {
                return Err(format!(
                    "unknown technique '{t}' in profile (known: {})",
                    KNOWN_TECHNIQUES.join(", ")
                ));
            };
            for pre in entry.presupposes {
                if !self.techniques.iter().any(|x| x == pre) {
                    return Err(format!(
                        "technique '{t}' presupposes '{pre}', which the profile does not enable"
                    ));
                }
            }
        }
        Ok(())
    }

    /// Whether this profile enables `technique`.
    #[must_use]
    pub fn enables(&self, technique: &str) -> bool {
        self.techniques.iter().any(|t| t == technique)
    }

    /// The effective `verify_gate` knobs (defaults when unset).
    #[must_use]
    pub fn verify_gate_knobs(&self) -> VerifyGateKnobs {
        self.verify_gate.unwrap_or_default()
    }

    /// The effective `retry` knobs (defaults when unset).
    #[must_use]
    pub fn retry_knobs(&self) -> RetryKnobs {
        self.retry.unwrap_or_default()
    }
}

// ---------------------------------------------------------------------------
// Project-local config layering (issue #222)
// ---------------------------------------------------------------------------

/// How arrays (`[[backends]]`, `[[providers]]`, `[[mcp_servers]]`,
/// `[[model_tuning]]`) are combined when a project-local `.newt/config.toml`
/// is layered over the global config.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArrayMergeStrategy {
    /// The project array replaces the global array wholesale. Predictable and
    /// safe — the project fully owns that list. **Default.**
    #[default]
    Replace,
    /// The project array is appended to the global array (global entries first,
    /// then the project's). Additive — e.g. register an extra local stdio MCP
    /// server without redefining the global ones.
    Append,
}

/// Controls how a project-local `.newt/config.toml` is merged over the global
/// config. Tables always merge recursively (project keys win); this only
/// governs array handling. See issue #222.
///
/// Example project `.newt/config.toml`:
/// ```toml
/// [merge]
/// arrays = "append"     # add to the global lists instead of replacing them
///
/// [[mcp_servers]]
/// name = "project-fs"
/// command = "mcp-fs"
/// args = ["--root", "."]
/// ```
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MergeConfig {
    /// Array-combination strategy. Default: [`ArrayMergeStrategy::Replace`].
    #[serde(default)]
    pub arrays: ArrayMergeStrategy,
}

// ---------------------------------------------------------------------------
// Durable conversation config
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConversationsConfig {
    /// Maximum saved conversations per workspace. Default: 100. 0 = no pruning.
    #[serde(default = "default_conversations_max_per_workspace")]
    pub max_per_workspace: usize,

    /// Auto-resume this workspace's most recently active conversation at TUI
    /// session start (Step 17.7, issue #246). "Most recently active" means
    /// the highest §6 activity tick — never a wall-clock comparison.
    ///
    /// **Default: true.** The off-switch:
    ///
    /// ```toml
    /// [conversations]
    /// resume = false      # always start fresh
    /// ```
    ///
    /// Per-session overrides win over this key either way: `--ephemeral`
    /// (no persistence at all) and `NEWT_CONVERSATION_ID=<id>` (resume
    /// exactly that conversation).
    #[serde(default = "default_conversations_resume")]
    pub resume: bool,
}

fn default_conversations_max_per_workspace() -> usize {
    100
}

fn default_conversations_resume() -> bool {
    true
}

impl Default for ConversationsConfig {
    fn default() -> Self {
        Self {
            max_per_workspace: default_conversations_max_per_workspace(),
            resume: default_conversations_resume(),
        }
    }
}

// ---------------------------------------------------------------------------
// Skill search path
// ---------------------------------------------------------------------------

/// The skill discovery **search path**: an ordered list of directories newt
/// scans for agentskills.io-format `SKILL.md` folders.
///
/// A skill is the same folder in every harness, so cross-harness use is just a
/// matter of *pointing newt at the directories* — list `~/.claude/skills`,
/// `~/.codex/skills`, a project-local `.skills/`, whatever — and their skills
/// become visible with no copying. The list is open-ended on purpose: there is
/// no hard-coded knowledge of any particular harness. Earlier entries win on a
/// name collision.
///
/// Example `~/.newt/config.toml`:
/// ```toml
/// [skills]
/// search = ["~/.newt/skills", "~/.claude/skills", "~/.codex/skills"]
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillsConfig {
    /// Ordered directories to scan for skills. Empty → `~/.newt/skills`.
    /// `~/` is expanded to `$HOME`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub search: Vec<String>,
}

// ---------------------------------------------------------------------------
// Log rotation config
// ---------------------------------------------------------------------------

/// Rotation policy for `~/.newt/usage.jsonl`.
///
/// All limits default to the values shown. Set a field to `0` to disable
/// that particular limit. Multiple active limits compose — the most
/// restrictive one wins after each append.
///
/// Example `newt.toml`:
/// ```toml
/// [logs]
/// max_sessions = 100   # keep the last 100 turns
/// max_size_mb  = 5     # also cap at 5 MiB
/// max_age_days = 14    # and drop anything older than 2 weeks
/// keep_rotated = 2     # keep usage.jsonl.1 and .2 as backup
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
    /// Keep at most this many JSONL entries (most recent). Default: 7. 0 = no limit.
    #[serde(default = "default_log_max_sessions")]
    pub max_sessions: usize,

    /// Rotate when the file exceeds this size in MiB. Default: 0 (no size limit).
    #[serde(default)]
    pub max_size_mb: u64,

    /// Drop entries older than this many days. Default: 0 (no age limit).
    /// Requires a `recorded_at` field in the log entry; entries without it
    /// are kept.
    #[serde(default)]
    pub max_age_days: u64,

    /// How many rotated copies to keep alongside the live log
    /// (`usage.jsonl.1`, `.2`, …). Default: 3. 0 = overwrite silently.
    #[serde(default = "default_log_keep_rotated")]
    pub keep_rotated: usize,
}

fn default_log_max_sessions() -> usize {
    7
}

fn default_log_keep_rotated() -> usize {
    3
}

impl Default for LogConfig {
    fn default() -> Self {
        Self {
            max_sessions: default_log_max_sessions(),
            max_size_mb: 0,
            max_age_days: 0,
            keep_rotated: default_log_keep_rotated(),
        }
    }
}

// ---------------------------------------------------------------------------
// Memory config
// ---------------------------------------------------------------------------

/// Memory management stored under `[memory]` in `newt.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
    /// Which memory provider to activate.
    #[serde(default)]
    pub provider: MemoryProviderKind,
    /// Turns retained by `RollingWindow`. Default: 20.
    #[serde(default = "default_memory_window")]
    pub window: usize,
    /// Explicit context-token budget for `TokenBudget` / `Summarizing` — a
    /// deliberate user override that wins over everything else (Step 18.2,
    /// #247). When unset, the budget derives from the empirical capability
    /// cache (`max_ok_input` else `safe_context` in
    /// `model-capabilities.json`); the static default
    /// (`DEFAULT_CONTEXT_TOKENS`, 8,192) applies only when neither exists.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_tokens: Option<u32>,

    /// Explicit path to a soul file (overrides workspace + global resolution).
    /// Default: auto-resolve from `.newt/soul.md` → `~/.newt/soul.md`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub soul_file: Option<String>,

    /// User turns without an organic `save_note` call before the in-band
    /// memory nudge is appended to the next user message (Step 19.3, #248).
    /// `0` disables the nudge. Default: 10.
    #[serde(default = "default_note_nudge_interval")]
    pub note_nudge_interval: usize,

    /// End-of-conversation note extraction (Step 19.4, #248): when `true`,
    /// closing a conversation (`/new` or a clean exit) runs ONE synchronous
    /// tools-disabled completion that distills at most 3 durable facts into
    /// NOTES.md through the scanned `save_note` write path. Default: `false`
    /// — the pass is optional and costs one completion per close.
    #[serde(default)]
    pub extract_notes_on_close: bool,

    /// How memory is disclosed to the model (progressive-disclosure memory,
    /// Workstream A MVP, #319). `Frozen` (the default) is today's behavior
    /// exactly: NOTES are frozen verbatim into the system prompt and the
    /// `memory_fetch` tool is not wired. `Index` opts in to the budgeted
    /// memory INDEX (note titles/ids instead of full bodies) plus the
    /// `memory_fetch` tool that pulls a body on demand. This is a context-cost
    /// facet, never an authorization knob.
    #[serde(default)]
    pub disclosure: MemoryDisclosure,
}

/// Memory disclosure mode — the `[memory] disclosure` key (#319).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryDisclosure {
    /// Today's behavior: NOTES frozen verbatim into the system prompt, no
    /// `memory_fetch` tool. The MVP default — inert unless opted in.
    #[default]
    Frozen,
    /// Progressive disclosure: a budgeted memory INDEX in the prompt plus the
    /// `memory_fetch` tool to pull bodies on demand.
    Index,
}

/// Prompt richness — the `[tui] footer` key. Selects the *default* prompt
/// template when `[tui] prompt` is unset; an explicit `[tui] prompt` always
/// wins. The rich default folds a timestamp + status into the prompt line
/// itself (`[<ts> · <model> · <ws> · <mode> ] ❯ `), so the input surface floats
/// it at the bottom while idle (like cargo's progress line) and it doubles as a
/// greppable per-turn log marker — no region, no cursor games.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FooterMode {
    /// Rich default prompt on a TTY, plain `\w $ ` otherwise (the default).
    /// The amphibious choice: decorated on a human terminal, bare in pipes /
    /// `newt worker` / the wyvern deep-cut.
    #[default]
    Auto,
    /// Always use the rich default prompt (even off a TTY — screenshots, tests).
    On,
    /// Always use the plain bare prompt. Equivalent to `--plain`.
    Off,
}

/// Color / theme mode — the `[tui] color` key and the `--color` CLI flag
/// (issue #527). Selects whether — and eventually how — ANSI color is emitted
/// for the interactive prompt and chat surface. The default is `auto`: color on
/// a TTY, none in pipes / under `NO_COLOR` / `TERM=dumb`.
///
/// `dark`/`light`/`inverted`/`minimal` are accepted and parse today; their
/// palettes are initial mappings (currently the chromatic default) tuned in a
/// later pass. The terminal-aware *resolution* lives in the TUI layer — newt-core
/// has no business probing the terminal — so this enum only exposes the pure
/// pieces ([`from_keyword`](Self::from_keyword) / [`keyword`](Self::keyword) /
/// [`forced`](Self::forced) / [`is_mono`](Self::is_mono)).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ColorMode {
    /// Color on a TTY; none off one or under `NO_COLOR`/`TERM=dumb` (default).
    #[default]
    Auto,
    /// Always emit color — even off a TTY (screenshots, captured logs). An
    /// explicit `--color=always` also overrides `NO_COLOR` (documented deviation).
    Always,
    /// Never emit color.
    Never,
    /// Reduced color: structure only, no bright accents. (Initial mapping:
    /// chromatic; tuned later.)
    Minimal,
    /// Swapped foreground/background accents for high-contrast terminals.
    /// (Initial mapping: chromatic; tuned later.)
    Inverted,
    /// Palette tuned for a dark background — the current chromatic default.
    Dark,
    /// Palette tuned for a light background. (Initial mapping: chromatic; tuned later.)
    Light,
    /// Force monochrome — no color, ASCII glyph fallbacks. Equivalent to `--mono`.
    Mono,
}

impl ColorMode {
    /// Parse a CLI/config keyword (case-insensitive) into a mode. `on`/`off` are
    /// accepted as aliases of `always`/`never`; `monochrome` aliases `mono`.
    pub fn from_keyword(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "auto" => Some(Self::Auto),
            "always" | "on" => Some(Self::Always),
            "never" | "off" => Some(Self::Never),
            "minimal" => Some(Self::Minimal),
            "inverted" => Some(Self::Inverted),
            "dark" => Some(Self::Dark),
            "light" => Some(Self::Light),
            "mono" | "monochrome" => Some(Self::Mono),
            _ => None,
        }
    }

    /// The canonical lowercase keyword for this mode (round-trips `from_keyword`
    /// and matches the serde representation).
    pub fn keyword(self) -> &'static str {
        match self {
            Self::Auto => "auto",
            Self::Always => "always",
            Self::Never => "never",
            Self::Minimal => "minimal",
            Self::Inverted => "inverted",
            Self::Dark => "dark",
            Self::Light => "light",
            Self::Mono => "mono",
        }
    }

    /// Whether this mode forces a color decision regardless of the terminal:
    /// `Some(true)` = force color on, `Some(false)` = force off, `None` = defer
    /// to terminal detection (`Auto`).
    pub fn forced(self) -> Option<bool> {
        match self {
            Self::Always | Self::Minimal | Self::Inverted | Self::Dark | Self::Light => Some(true),
            Self::Never | Self::Mono => Some(false),
            Self::Auto => None,
        }
    }

    /// Whether color is fully disabled in monochrome form. `Mono` additionally
    /// signals ASCII-glyph fallbacks (`>` for `❯`) to callers; `Never` just
    /// drops color.
    pub fn is_mono(self) -> bool {
        matches!(self, Self::Mono)
    }
}

/// Markdown rendering mode — the `[tui] markdown` key and the `/markdown`
/// command (Step 25.4, #568). `Auto` renders Markdown whenever color is active;
/// `On`/`Off` force the choice (`On` still needs color to emit ANSI). The
/// effective decision is `mode.forced().unwrap_or(color_on) && color_on`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MarkdownMode {
    /// Render Markdown whenever color is active (default).
    #[default]
    Auto,
    /// Force Markdown rendering on (still gated by color support).
    On,
    /// Disable Markdown rendering — stream raw text.
    Off,
}

impl MarkdownMode {
    /// Parse a CLI/config/command keyword (case-insensitive). `always`/`never`
    /// alias `on`/`off`.
    pub fn from_keyword(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "auto" => Some(Self::Auto),
            "on" | "always" => Some(Self::On),
            "off" | "never" => Some(Self::Off),
            _ => None,
        }
    }

    /// The canonical lowercase keyword (round-trips `from_keyword` + serde).
    pub fn keyword(self) -> &'static str {
        match self {
            Self::Auto => "auto",
            Self::On => "on",
            Self::Off => "off",
        }
    }

    /// `Some(true)`/`Some(false)` force the decision; `None` (`Auto`) defers to
    /// color detection.
    pub fn forced(self) -> Option<bool> {
        match self {
            Self::On => Some(true),
            Self::Off => Some(false),
            Self::Auto => None,
        }
    }
}

/// Context-management strategy — the `[context] manager` key and the
/// `/context manager <name>` command (Step 24.8, #559). `standard` is the
/// current prune → summary → static-marker pipeline. `progressive` and
/// `distributed` are the retrievable-card managers **owned by #546** and not
/// yet available — selecting them reports that and stays on `standard`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ContextManager {
    /// Prune → summarize → static-marker (today's behavior). The only one
    /// implemented; the selector seam for the others.
    #[default]
    Standard,
    /// Leave a lookup marker; retrieve cards on demand (ephemeral → local DB).
    /// Owned by #546 — not yet available.
    Progressive,
    /// Agent-mesh-shared card store across a swarm. Owned by #546 — not yet
    /// available.
    Distributed,
}

impl ContextManager {
    /// Parse a CLI/config/command keyword (case-insensitive).
    pub fn from_keyword(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "standard" => Some(Self::Standard),
            "progressive" => Some(Self::Progressive),
            "distributed" => Some(Self::Distributed),
            _ => None,
        }
    }

    /// The canonical lowercase keyword (round-trips `from_keyword` + serde).
    pub fn keyword(self) -> &'static str {
        match self {
            Self::Standard => "standard",
            Self::Progressive => "progressive",
            Self::Distributed => "distributed",
        }
    }

    /// Whether this manager is implemented. Only `standard` today; the others
    /// are owned by #546 (the selector reports "not yet available").
    pub fn available(self) -> bool {
        matches!(self, Self::Standard)
    }

    /// The default feature bundle this preset turns on (Phase 26, #588). A
    /// preset is a named bundle of composable [`ContextFeature`]s; config and
    /// `/context feature` overrides layer on top (see [`ContextFeatures`]).
    /// Every preset currently resolves to the all-off baseline (today's
    /// `standard` behavior) because no composable feature is implemented yet;
    /// presets gain features as 26.3–26.6 land.
    pub fn base_features(self) -> ContextFeatureSet {
        ContextFeatureSet::default()
    }
}

/// A composable context-management feature (Phase 26, #588) — an independent
/// on/off technique under `[context.features]` and the `/context feature <name>
/// on|off` command. None are implemented yet (`available()` is false for all);
/// they land in 26.3–26.6 and report "not yet available" until then (same
/// pattern as the `ContextManager` presets under #546).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextFeature {
    /// Cap oversized tool outputs; spill the full payload to a re-readable store (#584).
    ToolOffload,
    /// Structured `<state>` store mutated via tools, kept out of the log (#583).
    Scratchpad,
    /// Structure-aligned retrieval of repo evidence (#582).
    Semantic,
    /// Provenance-preserving compaction with retrievable handles (#584).
    Provenance,
    /// Write-gated cross-task experience memory (#585).
    Experiential,
    /// Per-step compiled context view instead of a rolling buffer (#586).
    Scheduled,
}

impl ContextFeature {
    /// Every feature, in display order.
    pub const ALL: [Self; 6] = [
        Self::ToolOffload,
        Self::Scratchpad,
        Self::Semantic,
        Self::Provenance,
        Self::Experiential,
        Self::Scheduled,
    ];

    /// Parse a keyword (case-insensitive; `-`/`_` interchangeable; short aliases).
    pub fn from_keyword(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
            "tool_offload" | "tooloffload" | "offload" => Some(Self::ToolOffload),
            "scratchpad" | "state" => Some(Self::Scratchpad),
            "semantic" | "retrieval" => Some(Self::Semantic),
            "provenance" | "handles" => Some(Self::Provenance),
            "experiential" | "experience" => Some(Self::Experiential),
            "scheduled" | "compiled" => Some(Self::Scheduled),
            _ => None,
        }
    }

    /// The canonical lowercase keyword (matches the `[context.features]` key).
    pub fn keyword(self) -> &'static str {
        match self {
            Self::ToolOffload => "tool_offload",
            Self::Scratchpad => "scratchpad",
            Self::Semantic => "semantic",
            Self::Provenance => "provenance",
            Self::Experiential => "experiential",
            Self::Scheduled => "scheduled",
        }
    }

    /// Whether this feature is implemented yet. Flips true per feature as it
    /// lands (26.3–26.6). `tool_offload` 26.3 (#584); `scratchpad` 26.4 (#583);
    /// `semantic` 26.5 (#582); `experiential` 26.6a (#585); `scheduled` 26.6b
    /// (#586). Only `provenance` (#584, a later compaction-handle feature) is
    /// still pending.
    pub fn available(self) -> bool {
        match self {
            Self::ToolOffload
            | Self::Scratchpad
            | Self::Semantic
            | Self::Experiential
            | Self::Scheduled => true,
            Self::Provenance => false,
        }
    }

    /// The tracking issue for this feature (cited in "not yet available").
    pub fn issue(self) -> u32 {
        match self {
            Self::ToolOffload | Self::Provenance => 584,
            Self::Scratchpad => 583,
            Self::Semantic => 582,
            Self::Experiential => 585,
            Self::Scheduled => 586,
        }
    }
}

/// The resolved on/off state of every context feature (Phase 26, #588) — the
/// effective set after a `manager` preset's defaults and config/session
/// overrides are applied.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ContextFeatureSet {
    pub tool_offload: bool,
    pub scratchpad: bool,
    pub semantic: bool,
    pub provenance: bool,
    pub experiential: bool,
    pub scheduled: bool,
}

impl ContextFeatureSet {
    /// Read one feature's resolved state.
    pub fn get(&self, f: ContextFeature) -> bool {
        match f {
            ContextFeature::ToolOffload => self.tool_offload,
            ContextFeature::Scratchpad => self.scratchpad,
            ContextFeature::Semantic => self.semantic,
            ContextFeature::Provenance => self.provenance,
            ContextFeature::Experiential => self.experiential,
            ContextFeature::Scheduled => self.scheduled,
        }
    }

    /// Set one feature's resolved state.
    pub fn set(&mut self, f: ContextFeature, on: bool) {
        match f {
            ContextFeature::ToolOffload => self.tool_offload = on,
            ContextFeature::Scratchpad => self.scratchpad = on,
            ContextFeature::Semantic => self.semantic = on,
            ContextFeature::Provenance => self.provenance = on,
            ContextFeature::Experiential => self.experiential = on,
            ContextFeature::Scheduled => self.scheduled = on,
        }
    }

    /// The features currently on, in display order.
    pub fn enabled(self) -> Vec<ContextFeature> {
        ContextFeature::ALL
            .into_iter()
            .filter(|&f| self.get(f))
            .collect()
    }

    /// The base feature set *before* `[context.features]` / session overrides:
    /// the `manager` preset's bundle, with `tool_offload` defaulted ON because
    /// it is local, deterministic spill storage and should not depend on
    /// semantic embedding availability. Local-assist features (`scratchpad`,
    /// `semantic`, and `scheduled`) additionally default ON for local
    /// (`BackendKind::Ollama`) backends. A weak local model needs the `<plan>` /
    /// `<state>` ledger to carry a checklist across tool-call rounds instead of
    /// re-deriving state each round (Step 27.4), and semantic retrieval should
    /// be ready without a live toggle once an embedder is configured. Explicit
    /// overrides still win — they layer on top via [`ContextFeatures::apply_to`].
    ///
    /// Note: a *local* vLLM / llama.cpp server reports as `Openai` (that's the
    /// wire protocol, not the host), so those users opt in via
    /// `[context.features]` rather than getting it by default.
    pub fn base_for(manager: ContextManager, kind: BackendKind) -> Self {
        let mut base = manager.base_features();
        base.tool_offload = true;
        if matches!(kind, BackendKind::Ollama) {
            base.scratchpad = true;
            base.semantic = true;
            base.scheduled = true;
        }
        base
    }
}

/// Per-feature overrides under `[context.features]` (config) and `/context
/// feature` (session) (Phase 26, #588). `None` inherits the `manager` preset's
/// default; `Some(b)` forces the feature on/off.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextFeatures {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_offload: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scratchpad: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub semantic: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provenance: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub experiential: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scheduled: Option<bool>,
}

impl ContextFeatures {
    /// Read one feature's override (`None` = inherit the preset).
    pub fn get(&self, f: ContextFeature) -> Option<bool> {
        match f {
            ContextFeature::ToolOffload => self.tool_offload,
            ContextFeature::Scratchpad => self.scratchpad,
            ContextFeature::Semantic => self.semantic,
            ContextFeature::Provenance => self.provenance,
            ContextFeature::Experiential => self.experiential,
            ContextFeature::Scheduled => self.scheduled,
        }
    }

    /// Set one feature's override.
    pub fn set(&mut self, f: ContextFeature, v: Option<bool>) {
        match f {
            ContextFeature::ToolOffload => self.tool_offload = v,
            ContextFeature::Scratchpad => self.scratchpad = v,
            ContextFeature::Semantic => self.semantic = v,
            ContextFeature::Provenance => self.provenance = v,
            ContextFeature::Experiential => self.experiential = v,
            ContextFeature::Scheduled => self.scheduled = v,
        }
    }

    /// Layer these overrides onto a base set (a preset's defaults).
    pub fn apply_to(&self, mut base: ContextFeatureSet) -> ContextFeatureSet {
        for f in ContextFeature::ALL {
            if let Some(v) = self.get(f) {
                base.set(f, v);
            }
        }
        base
    }
}

/// `[context]` config section (Step 24.8, #559; features added Phase 26, #588).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextConfig {
    /// Context-management strategy preset. Default `standard`.
    #[serde(default)]
    pub manager: ContextManager,

    /// Per-feature overrides under `[context.features]` — each inherits the
    /// `manager` preset's default unless explicitly set (Phase 26, #588).
    #[serde(default)]
    pub features: ContextFeatures,

    /// `[context.semantic]` — settings for the `semantic` RAG feature (Step
    /// 26.5, #582).
    #[serde(default)]
    pub semantic: SemanticConfig,

    /// `[context.estimation]` — the cheap token-estimation heuristic
    /// (`chars_per_token`, default 4). Threaded through the estimators; the
    /// per-model calibration ratio scales the result on top.
    #[serde(default)]
    pub estimation: crate::tokens::TokenEstimation,

    /// Floor (chars) for the whole-middle summarizer input cap. The cap is
    /// normally the compression budget converted to chars, but a tight budget
    /// would starve the summarizer of material — never give it less than this.
    #[serde(default = "default_summary_input_cap_floor_chars")]
    pub summary_input_cap_floor_chars: usize,

    /// `[context.api_surface]` — the workspace-API-surface knowledge_base
    /// technique + its pluggable language packs (#669).
    #[serde(default)]
    pub api_surface: ApiSurfaceConfig,
}

fn default_summary_input_cap_floor_chars() -> usize {
    8_192
}

impl Default for ContextConfig {
    fn default() -> Self {
        Self {
            manager: ContextManager::default(),
            features: ContextFeatures::default(),
            semantic: SemanticConfig::default(),
            estimation: crate::tokens::TokenEstimation::default(),
            summary_input_cap_floor_chars: default_summary_input_cap_floor_chars(),
            api_surface: ApiSurfaceConfig::default(),
        }
    }
}

/// One symbol-extraction rule in a [`LanguagePack`]: a regex over a single source
/// line whose **first capture group is the public symbol's name**, plus a
/// free-form kind label. Free-form so a pack is not locked to one language's
/// vocabulary (`fn`/`struct` for Rust, `class`/`def` for Python, `func` for Go…).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolRule {
    /// Regex; capture group 1 = the symbol name.
    pub pattern: String,
    /// Kind shown in the surface (e.g. `"fn"`, `"struct"`, `"class"`, `"func"`).
    pub kind: String,
}

/// A **language pack** for the workspace API surface (#669): how to recognize a
/// language's files, which files expose its public API, and how to extract its
/// public symbols — entirely as DATA, so a new language is config, not code.
///
/// Built-in packs cover Rust, Python, Bash, and C/C++. A project ships more by
/// dropping a `<name>.toml` into `~/.newt/language-packs/` (global) or
/// `.newt/language-packs/` (project-local), or inline under
/// `[[context.api_surface.language_packs]]`. Packs merge **by `name`** (a custom
/// pack with a built-in's name replaces it), so anyone can add Java, Ruby, Swift,
/// Objective-C, … without touching the binary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LanguagePack {
    /// Stable id (a config pack with a built-in's name replaces that built-in).
    pub name: String,
    /// File extensions this pack claims, no dot — `["rs"]`, `["h", "hpp", "cpp"]`.
    pub extensions: Vec<String>,
    /// Entry-point filename globs (the public-API files, listed first in the
    /// surface). Supported globs: exact (`lib.rs`), suffix (`*.h`), or all (`*`).
    /// Empty ⇒ no file is prioritized for this pack.
    #[serde(default)]
    pub entry_points: Vec<String>,
    /// Public-symbol extraction rules, applied per source line.
    pub symbols: Vec<SymbolRule>,
}

/// `[context.api_surface]` — the workspace-API-surface knowledge_base technique.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiSurfaceConfig {
    /// Inline language packs, merged by `name` over the built-ins and the
    /// drop-in directories (the highest-precedence layer).
    #[serde(default)]
    pub language_packs: Vec<LanguagePack>,
    /// Char ceiling on the injected surface block (it rides every turn).
    #[serde(default = "default_api_surface_max_block_chars")]
    pub max_block_chars: usize,
    /// Per-file symbol cap, so one huge file can't crowd out the surface.
    #[serde(default = "default_api_surface_max_symbols_per_file")]
    pub max_symbols_per_file: usize,
}

impl Default for ApiSurfaceConfig {
    fn default() -> Self {
        Self {
            language_packs: Vec::new(),
            max_block_chars: default_api_surface_max_block_chars(),
            max_symbols_per_file: default_api_surface_max_symbols_per_file(),
        }
    }
}

fn default_api_surface_max_block_chars() -> usize {
    3_000
}

fn default_api_surface_max_symbols_per_file() -> usize {
    12
}

/// `[context.semantic]` — the embedding RAG-for-code feature's settings (Step
/// 26.5.4, #582).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticConfig {
    /// Embedding model used to index the repo + embed queries. Default
    /// `nomic-embed-text` (the HTTP path). The model must exist on the embeddings
    /// endpoint (see `embeddings_endpoint`); when it can't be reached the feature
    /// follows `on_embed_failure`.
    ///
    /// For the **embedded backend** (`embeddings_api = "embedded"`, #720) this is
    /// only a label — the model is loaded from `embedding_model_path` — and it
    /// should name a **candle-clean standard-BERT** model (e.g.
    /// `bge-small-en-v1.5`), NOT `nomic-embed-text`, which candle 0.8 cannot load.
    #[serde(default = "default_embedding_model")]
    pub embedding_model: String,
    /// Local model **directory** for the embedded embedder (#720): a
    /// candle-clean standard-BERT model dir holding
    /// `config.json` + `tokenizer.json` + `model.safetensors` (e.g. a fetched
    /// `BAAI/bge-small-en-v1.5`). `None` (default) ⇒ the embedded path can't
    /// load and reports a clear error. When `embeddings_api` and
    /// `embeddings_endpoint` are unset, a configured path selects embedded
    /// embeddings automatically. Ignored by explicit HTTP embeddings targets.
    /// Mirrors the summarizer's `model_path`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub embedding_model_path: Option<String>,
    /// How many code chunks to retrieve per turn. Default 5.
    #[serde(default = "default_semantic_top_k")]
    pub top_k: usize,
    /// Dedicated endpoint that serves embeddings (e.g. an Ollama
    /// `http://host:11434`). `None` (default) leaves semantic retrieval on the
    /// embedded path unless `embeddings_api` explicitly selects an HTTP protocol.
    /// Set this to a real embeddings host when remote/vector-server embeddings
    /// are a deliberate performance choice.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub embeddings_endpoint: Option<String>,
    /// Wire protocol of `embeddings_endpoint` — `ollama` (`/api/embeddings`) or
    /// `openai` (`/v1/embeddings`). `embedded` selects the in-process embedder.
    /// `None` (default) selects embedded embeddings when `embeddings_endpoint`
    /// is also unset; with an explicit endpoint, `None` assumes `ollama`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub embeddings_api: Option<BackendKind>,
    /// What to do when embedding fails structurally (wrong endpoint / model
    /// absent): `disable` (default) stops indexing after the first failure with
    /// one actionable message; `warn` logs per-chunk and keeps trying.
    #[serde(default)]
    pub on_embed_failure: OnEmbedFailure,
}

impl Default for SemanticConfig {
    fn default() -> Self {
        Self {
            embedding_model: default_embedding_model(),
            embedding_model_path: None,
            top_k: default_semantic_top_k(),
            embeddings_endpoint: None,
            embeddings_api: None,
            on_embed_failure: OnEmbedFailure::default(),
        }
    }
}

/// Policy when an embedding request fails structurally during indexing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OnEmbedFailure {
    /// Stop indexing on the first failure and log one actionable error — a
    /// structural failure (wrong endpoint / missing model) is total, not
    /// transient, so degrading per-chunk just produces an empty index quietly.
    #[default]
    Disable,
    /// Log every failed chunk and keep going (the historical behaviour).
    Warn,
}

fn default_embedding_model() -> String {
    "nomic-embed-text".to_string()
}

fn default_semantic_top_k() -> usize {
    5
}

/// How a thinking model's streamed reasoning is surfaced — the `[tui] thinking`
/// key. Newt strips `<think>…</think>` from the reply regardless (#385); this
/// only controls the live human display.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingMode {
    /// Cargo-style: reasoning streams as dim scrolled lines (kept in
    /// scrollback) with an ephemeral spinner line pinned at the bottom. The
    /// default — but only on a TTY; a pipe / `newt worker` shows nothing.
    #[default]
    Stream,
    /// No reasoning display at all (the answer still streams normally).
    Off,
}

fn default_memory_window() -> usize {
    20
}

fn default_note_nudge_interval() -> usize {
    10
}

impl Default for MemoryConfig {
    fn default() -> Self {
        Self {
            provider: MemoryProviderKind::RollingWindow,
            window: 20,
            context_tokens: None,
            soul_file: None,
            note_nudge_interval: 10,
            extract_notes_on_close: false,
            disclosure: MemoryDisclosure::Frozen,
        }
    }
}

/// Which built-in memory strategy to use.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryProviderKind {
    #[default]
    RollingWindow,
    TokenBudget,
    Summarizing,
}

// ---------------------------------------------------------------------------
// Project-instruction (AGENTS.md / CLAUDE.md) config
// ---------------------------------------------------------------------------

/// Project-instruction loading stored under `[agents]` in `newt.toml`.
///
/// When enabled (the default), newt reads `AGENTS.md` / `CLAUDE.md` from the
/// workspace and injects them into the agent's system prompt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentsConfig {
    /// Whether to load project instructions into the system prompt. Default: true.
    pub enabled: bool,
    /// Directory to search for `AGENTS.md` / `CLAUDE.md`, or a specific
    /// instructions file. Relative paths are resolved against the workspace.
    /// Default: the workspace root.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

impl Default for AgentsConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            path: None,
        }
    }
}

// ---------------------------------------------------------------------------
// Tools config
// ---------------------------------------------------------------------------

/// Tool-execution behaviour stored under `[tools]` in `newt.toml` (#726).
///
/// Tool-output knobs: the **token budget** that caps every tool's model-facing
/// output, plus the head/tail split for oversized shell output. This bounds
/// what a single tool result can add to the context window — applied to both
/// `read_file` (its char backstop) and `run_command` (its shell envelope) — so a
/// verbose command or a huge file can't saturate a small local model's window
/// and abandon the task. Mirrors Codex's `exec_command.max_output_tokens`.
/// Distinct from `[tui] tool_output_lines`, which caps the on-screen DISPLAY by
/// lines.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolsConfig {
    /// Maximum tokens of model-facing output any single tool may return before
    /// it is truncated (with a marker steering the model to a narrower command /
    /// a paginated read). Default: 10000. `0` disables the cap. Tokens are
    /// estimated with the shared chars/token heuristic (see [`crate::tokens`]).
    #[serde(default = "default_max_output_tokens")]
    pub max_output_tokens: usize,

    /// Tokens reserved for the head of an oversized `run_command` result. The
    /// remaining budget is spent on the tail, so failures and summaries at the
    /// end survive by default. `0` is pure-tail; values greater than
    /// `max_output_tokens` are clamped to pure-head.
    #[serde(default = "default_output_head_tokens")]
    pub output_head_tokens: usize,
}

impl Default for ToolsConfig {
    fn default() -> Self {
        Self {
            max_output_tokens: default_max_output_tokens(),
            output_head_tokens: default_output_head_tokens(),
        }
    }
}

fn default_max_output_tokens() -> usize {
    10_000
}

fn default_output_head_tokens() -> usize {
    1_500
}

// ---------------------------------------------------------------------------
// TUI config
// ---------------------------------------------------------------------------

/// TUI appearance preferences stored under `[tui]` in `newt.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiConfig {
    /// Whether to show "newt" / "you" labels before the carets.
    pub chat_style: ChatStyle,

    /// PS1-style prompt template.
    ///
    /// Tokens: `\w` workspace basename, `\W` full path, `\h` hostname,
    /// `\v` newt version.  Default: `"\\w $ "` (compact) / `"you $ "` (verbose).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,

    /// Skip the full-screen ANSI art splash and show a compact header instead.
    /// Equivalent to the `--no-splash` CLI flag.
    #[serde(default)]
    pub no_splash: bool,

    /// Key binding mode for the chat input line.
    /// `"emacs"` (default), `"vi"`, or `"nano"`. Also overridable via
    /// `NEWT_EDIT_MODE`. (`nano` is emacs-style/modeless — it differs from
    /// `emacs` only in label today; the rich-tui surface honors it.)
    #[serde(default)]
    pub edit_mode: EditMode,

    /// Rich-tui (issue #416) input gutter width, in columns. Unset = `auto`
    /// (the responsive default: a prompt gutter when it fits under ~1/3 of the
    /// width, else a stacked prompt row). `0` turns the gutter off (prompt on
    /// its own row, input flush-left); a positive value indents the input that
    /// many columns (a value wide enough to hold the prompt renders it inline).
    /// No effect on the lean surface.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub gutter: Option<u16>,

    /// Input-footer mode: the transient multi-line `❯` input block with a
    /// status header. `"auto"` (default) shows it on a TTY and degrades to a
    /// plain scroller otherwise; `"on"` always shows it; `"off"` never does
    /// (the `--plain` CLI flag, or `NEWT_FOOTER=off`).
    #[serde(default)]
    pub footer: FooterMode,

    /// Color / theme mode (issue #527): `"auto"` (default), `"always"`,
    /// `"never"`, `"minimal"`, `"inverted"`, `"dark"`, `"light"`, `"mono"`.
    /// The `--color` CLI flag and `--mono` override this; `NO_COLOR` /
    /// `TERM=dumb` force it off unless an explicit `--color` is given. This is
    /// the on/off + theme *mode*; `[tui.colors]` below is the palette.
    #[serde(default)]
    pub color: ColorMode,

    /// How a thinking model's streamed reasoning is shown: `"stream"` (default
    /// — dim reasoning + a cargo-style spinner, TTY only) or `"off"`.
    #[serde(default)]
    pub thinking: ThinkingMode,

    /// Maximum lines of tool output shown inline before offering "show all?".
    /// Default: 20. Set to 0 to always show everything.
    #[serde(default = "default_tool_output_lines")]
    pub tool_output_lines: usize,

    /// Maximum number of tool-call rounds the model may take within a single
    /// turn before the agent forces a final, tools-disabled completion. Each
    /// round is one model response that may emit tool calls; once this many
    /// rounds have run without a tool-free answer, newt asks the model once
    /// more with tools disabled so the user still gets a real (partial)
    /// answer instead of a placeholder. Default: 40 (raised from 25 — a
    /// modest safety margin alongside `workflow_grace_rounds` and the
    /// workflow-classifier delegate hint; genuinely open-ended diagnostic work
    /// should reach for `crew`/`team` delegation rather than depend on an
    /// unbounded cap here).
    #[serde(default = "default_max_tool_rounds")]
    pub max_tool_rounds: usize,

    /// Additional progress-aware rounds available after `max_tool_rounds` when
    /// an active workflow still has incomplete steps and the recent rounds show
    /// repair progress or actionable evidence. Default: 5. Set to 0 to make the
    /// normal round cap hard again.
    #[serde(default = "default_workflow_grace_rounds")]
    pub workflow_grace_rounds: usize,

    /// Tool-call permission policy for the interactive TUI: which tools the
    /// model may invoke and over which targets. This is a *preset that selects
    /// an attenuation* — the host (`newt-identity`) lowers it into a signed,
    /// attenuation-only capability that enforcement consults. Default:
    /// `WorkspaceDev`.
    #[serde(default)]
    pub permissions: ToolPermissions,

    /// Enable per-round agent-loop diagnostics printed to the TUI. Shows each
    /// round's content excerpt, tool-call count, token usage, and flags empty
    /// model responses before they become silent failures. Also set via the
    /// `NEWT_DEBUG=1` environment variable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub debug: Option<bool>,

    /// Enable deep backend/inference diagnostics. Intended for issue reports
    /// and compatibility debugging; also set via `NEWT_TRACE=1`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trace: Option<bool>,

    /// Shell command to run after every successful file write or edit, to
    /// give the agent immediate ground-truth feedback on whether its change
    /// compiled / passed basic checks. Output is appended to the tool result
    /// so the model sees it without needing to ask.
    ///
    /// Set this per-workspace in `.newt/config.toml` — not globally — because
    /// the right command depends on the project's build system:
    ///
    /// ```toml
    /// [tui]
    /// build_check_cmd = "cargo check -q --workspace"  # Rust
    /// # build_check_cmd = "npm run build --silent"    # Node
    /// # build_check_cmd = "python -m py_compile"      # Python
    /// ```
    ///
    /// `None` (default) disables auto-checking — no extra command is run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_check_cmd: Option<String>,

    // -----------------------------------------------------------------------
    // DGX / inference endpoint resource management
    // -----------------------------------------------------------------------
    /// Ollama context-window cap sent as `options.num_ctx` on every request.
    /// Limits the KV-cache allocation so a large model can't exhaust VRAM
    /// mid-session. `None` → newt trusts the model's declared context window
    /// from Ollama `/api/show` and sends ~80 % of it as `num_ctx` (see
    /// `real_context_discovery`). Set an explicit cap here (e.g. 8192 / 16384)
    /// on VRAM-constrained hosts; this always takes precedence over the
    /// declared window.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub num_ctx: Option<u32>,

    /// Opt into **empirical** context-window discovery (the conserve-for-tiny-
    /// hardware mode). When `false`/unset (the default), newt **trusts the
    /// declared** `/api/show` window: `safe_context` tracks ~80 % of it and is
    /// raised back to it each session, so a model is never permanently capped
    /// by a past overflow. When `true`, newt instead keeps the conservative
    /// behaviour — bootstrap `safe_context` once, never auto-raise it, and let
    /// runtime context-window 400s ratchet it down and persist (for hardware
    /// that genuinely can't serve the full declared window). Overridable
    /// per-model via `[[model_tuning]] real_context_discovery`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub real_context_discovery: Option<bool>,

    /// TCP connect timeout in seconds for inference requests (default: 5).
    /// A fast failure here means the endpoint is down (connection refused),
    /// distinguishing it from a slow-but-alive endpoint that needs the full
    /// `inference_timeout_secs` to respond. Keep this short.
    #[serde(default = "default_connect_timeout_secs")]
    pub connect_timeout_secs: u64,

    /// Total inference request timeout in seconds (default: 120). This is the
    /// wall-clock budget for the model to generate a complete response —
    /// large models on a busy DGX may need the full window.
    #[serde(default = "default_inference_timeout_secs")]
    pub inference_timeout_secs: u64,

    /// How long Ollama keeps a model resident in VRAM after the last request,
    /// as an Ollama duration string (e.g. `"5m"`, `"0"`, `"-1"`).
    /// Default: `"5m"`. Use `"0"` to unload immediately after each turn
    /// (maximum headroom for multi-model or multi-agent workloads at the cost
    /// of a reload on each turn). Use `"-1"` to keep forever.
    #[serde(default = "default_keep_alive")]
    pub keep_alive: String,

    // Summarizer knobs (timeout / retries / fallback model) moved to the
    // dedicated `~/.newt/summarizer.toml` ([`SummarizerConfig`]) in Step 24.10
    // (#559), so the summarizer can run on its own backend. Old `[tui]` keys
    // (`summarizer_timeout_secs` / `summarizer_retries` / `summarizer_model`)
    // are no longer read — `#[serde(default)]` ignores them in stale configs.
    /// Markdown rendering of assistant output (Step 25.4, #568). `auto`
    /// (default) renders whenever color is active; `on`/`off` force it. The
    /// `/markdown [on|off]` command overrides this for the session.
    #[serde(default)]
    pub markdown: MarkdownMode,

    /// Maximum number of messages in the in-progress tool-call message list
    /// before the agent trims the middle to prevent context overflow.
    /// Default: 40 (≈ 20 tool-call rounds). Set lower on memory-constrained
    /// endpoints or when `num_ctx` is small.
    #[serde(default = "default_mid_loop_trim_threshold")]
    pub mid_loop_trim_threshold: usize,

    /// Estimated-token threshold that triggers a mid-loop context trim,
    /// independent of `mid_loop_trim_threshold` (which counts *messages*).
    /// A single tool round can return a multi-KB file listing or JSON payload
    /// that adds hundreds of thousands of tokens in one message — far below the
    /// message-count threshold but well past the model's context window. When
    /// set, trimming fires as soon as the estimated token count (chars / 4)
    /// exceeds this value. `None` disables token-based trimming.
    /// Default: `None` (message-count trimming only). See issue #223.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mid_loop_trim_tokens: Option<usize>,

    /// Normalise hyphens to underscores in MCP server names when advertising
    /// tool definitions and routing tool calls.  Some API proxies (e.g. those
    /// that wrap the Anthropic backend) replace hyphens with underscores in
    /// tool names; advertising the sanitised form ensures the model's tool
    /// calls round-trip back unchanged and routes correctly.  Default: `true`.
    /// Set to `false` only when every connected MCP server is behind a proxy
    /// that preserves hyphens verbatim.
    #[serde(default = "default_sanitize_mcp_server_names")]
    pub sanitize_mcp_server_names: bool,

    /// Hosts (IP or hostname) for which newt may send an MCP OAuth Bearer token
    /// over an UNENCRYPTED (non-`https`) connection. Empty by default: a stored
    /// Bearer is sent only over `https` or to loopback (`localhost`/`127.0.0.1`/
    /// `::1`). newt WARNs on every non-loopback unencrypted MCP connection
    /// regardless; an allow-listed host still warns but the token is sent.
    /// This is the explicit opt-out of the secure-by-default transport policy —
    /// see `docs/decisions/mcp_transport_security.md`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mcp_allow_insecure_hosts: Vec<String>,

    /// Whether the interactive `!` bang-escape is available — typing `! <cmd>`
    /// at the prompt runs a host command with the user's own authority (not the
    /// agent's OCAP leash). Default: `true`. Set `false` for locked-down or
    /// shared deployments where the human at the keyboard should not have an
    /// unconfined host shell-out. The model can never invoke `!` either way;
    /// this only governs the human. See `docs/decisions/plain_scroller_tui.md`.
    #[serde(default = "default_allow_bang_escape")]
    pub allow_bang_escape: bool,
}

fn default_tool_output_lines() -> usize {
    20
}

fn default_max_tool_rounds() -> usize {
    40
}

fn default_workflow_grace_rounds() -> usize {
    5
}

fn default_connect_timeout_secs() -> u64 {
    5
}

fn default_inference_timeout_secs() -> u64 {
    120
}

fn default_keep_alive() -> String {
    "5m".to_string()
}

fn default_summarizer_timeout_secs() -> u64 {
    60
}

fn default_summarizer_retries() -> u32 {
    1
}

fn default_mid_loop_trim_threshold() -> usize {
    40
}

fn default_sanitize_mcp_server_names() -> bool {
    true
}

fn default_allow_bang_escape() -> bool {
    true
}

// ---------------------------------------------------------------------------
// Per-model tuning
// ---------------------------------------------------------------------------

/// Inference-parameter overrides for a specific model name.
///
/// Matched against the active model by exact string equality.  Add entries
/// under `[[model_tuning]]` in `~/.newt/config.toml` to pin parameters
/// for models whose defaults cause problems (e.g. context overflow).
///
/// Human-authored entries are never touched by the auto-tuner.  Auto-tuned
/// entries are appended (not modified) when the harness gains high confidence
/// in its empirical measurements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelTuning {
    /// Model name as it appears in Ollama (e.g. `"nemotron3:33b"`).
    pub model: String,

    /// Ollama `options.num_ctx` — hard cap on KV-cache allocation.
    /// Overrides both the global `[tui].num_ctx` and the empirically
    /// derived `safe_context` from `model-capabilities.json`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub num_ctx: Option<u32>,

    /// Per-model override of `[tui].real_context_discovery`. `Some(true)` opts
    /// this model into empirical conserve-and-ratchet discovery; `Some(false)`
    /// forces trust-the-declared-window even when the global default is
    /// empirical; `None` inherits the global setting.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub real_context_discovery: Option<bool>,

    /// Per-model `mid_loop_trim_threshold` override.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mid_loop_trim_threshold: Option<usize>,

    /// Per-model `mid_loop_trim_tokens` override (estimated-token trim trigger).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mid_loop_trim_tokens: Option<usize>,

    /// Per-model `max_tool_rounds` override.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_tool_rounds: Option<usize>,

    /// Per-model `[tui].workflow_grace_rounds` override.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workflow_grace_rounds: Option<usize>,
}

impl Config {
    /// Find the first `[[model_tuning]]` entry whose `model` field matches
    /// `name` exactly.  Returns `None` when no entry exists.
    pub fn find_model_tuning(&self, name: &str) -> Option<&ModelTuning> {
        self.model_tuning.iter().find(|t| t.model == name)
    }

    /// Look up and validate a named profile (`[profiles.<name>]`). The caller
    /// selects it via `--profile <name>` / `NEWT_PROFILE`.
    ///
    /// # Errors
    /// `no such profile` when the name is undefined; the validation error when
    /// the profile names an unknown technique — a `--profile` that silently did
    /// nothing would be a false claim, so both fail loudly.
    pub fn resolve_profile(&self, name: &str) -> std::result::Result<&ProfileConfig, String> {
        let profile = self.profiles.get(name).ok_or_else(|| {
            let known = if self.profiles.is_empty() {
                "none defined".to_string()
            } else {
                self.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
            };
            format!("no such profile (known: {known})")
        })?;
        profile.validate()?;
        Ok(profile)
    }

    /// Look up a named bundle (`[bundles.<name>]`).
    ///
    /// # Errors
    /// `no such bundle` when undefined — a `--bundle` that silently did nothing
    /// would be a false claim.
    pub fn resolve_bundle(&self, name: &str) -> std::result::Result<&BundleConfig, String> {
        self.bundles.get(name).ok_or_else(|| {
            let known = if self.bundles.is_empty() {
                "none defined".to_string()
            } else {
                self.bundles.keys().cloned().collect::<Vec<_>>().join(", ")
            };
            format!("no such bundle (known: {known})")
        })
    }

    /// The profile name `bundle` yields for `model`: the longest-prefix `families`
    /// match, else `default_profile`. `None` ⇒ the bundle applies no profile here.
    #[must_use]
    pub fn bundle_profile_for_model<'a>(
        &self,
        bundle: &'a BundleConfig,
        model: &str,
    ) -> Option<&'a str> {
        bundle
            .families
            .iter()
            .filter(|(prefix, _)| model.starts_with(prefix.as_str()))
            .max_by_key(|(prefix, _)| prefix.len()) // longest-prefix-wins
            .map(|(_, p)| p.as_str())
            .or(bundle.default_profile.as_deref())
    }

    /// Infer the bundle for `model` from `applies_to` (longest-prefix-wins). Only
    /// bundles with a non-empty `applies_to` participate — a use-case bundle (empty
    /// `applies_to`) is never auto-inferred, only chosen explicitly via `--bundle`.
    #[must_use]
    pub fn infer_bundle(&self, model: &str) -> Option<(&str, &BundleConfig)> {
        self.bundles
            .iter()
            .filter_map(|(name, b)| {
                b.applies_to
                    .iter()
                    .filter(|p| model.starts_with(p.as_str()))
                    .map(String::len)
                    .max()
                    .map(|best| (best, name.as_str(), b))
            })
            .max_by_key(|(best, _, _)| *best)
            .map(|(_, name, b)| (name, b))
    }

    /// Resolve the active profile from the selectors + the active `model`:
    /// `--profile` (explicit) > `--bundle` (its profile for this model) > an
    /// inferred bundle (`applies_to`) > `None` (today's no-profile behavior).
    /// Returns the profile NAME + how it was chosen (for the banner).
    ///
    /// # Errors
    /// An unknown explicit `--bundle` is a hard error. An unknown explicit
    /// `--profile` is left for the caller's [`resolve_profile`](Self::resolve_profile)
    /// so the message stays profile-specific.
    pub fn pick_active_profile(
        &self,
        profile_flag: Option<&str>,
        bundle_flag: Option<&str>,
        model: &str,
    ) -> std::result::Result<Option<ProfilePick>, String> {
        if let Some(p) = profile_flag.filter(|s| !s.is_empty()) {
            return Ok(Some(ProfilePick {
                name: p.to_string(),
                via: PickVia::Profile,
            }));
        }
        if let Some(b) = bundle_flag.filter(|s| !s.is_empty()) {
            let bundle = self.resolve_bundle(b)?;
            return Ok(self
                .bundle_profile_for_model(bundle, model)
                .map(|p| ProfilePick {
                    name: p.to_string(),
                    via: PickVia::Bundle(b.to_string()),
                }));
        }
        if let Some((name, bundle)) = self.infer_bundle(model) {
            return Ok(self
                .bundle_profile_for_model(bundle, model)
                .map(|p| ProfilePick {
                    name: p.to_string(),
                    via: PickVia::InferredBundle(name.to_string()),
                }));
        }
        Ok(None)
    }
}

/// One named bundle (`[bundles.<name>]`) — the loadable unit of the model support
/// kit. It pins which model families it applies to and which profile each resolves
/// to, shipping the `[profiles.*]` it references.
///
/// ```toml
/// [bundles.nemotron]
/// about = "Support bundle for the nemotron family"
/// applies_to = ["nemotron"]                 # longest-prefix-wins; "nemotron3:33b" matches
/// default_profile = "nemotron"
/// families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }
/// ```
///
/// A bundle carries **no authority** — there is deliberately no caveats/preset
/// field; it recombines vetted parts, it cannot grant (`docs/design/model-support-kit.md`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BundleConfig {
    /// One-line provenance, shown in the startup banner.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub about: Option<String>,
    /// Model-id prefixes this bundle auto-applies to (longest-prefix-wins). Empty ⇒
    /// a use-case bundle: chosen only via explicit `--bundle`, never auto-inferred.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub applies_to: Vec<String>,
    /// Profile applied when this bundle is selected and no `families` entry matches.
    /// Must name a key in `[profiles.*]`. `None` ⇒ no profile (the light path).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_profile: Option<String>,
    /// model-family-prefix → profile name (longest-prefix-wins over `default_profile`).
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub families: std::collections::BTreeMap<String, String>,
}

/// The active-profile selection + how it was chosen (for honest banner output).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfilePick {
    /// The chosen profile name (to feed [`Config::resolve_profile`]).
    pub name: String,
    /// Which selector won.
    pub via: PickVia,
}

/// How a [`ProfilePick`] was selected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickVia {
    /// An explicit `--profile` / `NEWT_PROFILE`.
    Profile,
    /// An explicit `--bundle <name>`.
    Bundle(String),
    /// A bundle inferred from the model via `applies_to`.
    InferredBundle(String),
}

/// One named loadout (`[loadouts.<name>]` / `~/.newt/loadouts/<name>.toml`) — the
/// top-level composition the user *loads* (`docs/design/loadout-composition.md`).
/// Every field is optional and is a **name reference** into the surface that owns
/// that axis; the loadout itself stores nothing but the selection + per-axis
/// overrides. It carries **no authority** — `settings` cannot widen caveats.
///
/// ```toml
/// [loadouts.dev-nemotron]
/// provider = "dgx"          # → the catalog/provider card (#387)
/// model    = "nemotron@deep"
/// kit      = "nemotron"     # → a [bundles.<name>] (the loadable kit unit)
/// profile  = "nemotron"     # → a [profiles.<name>] (optional; the bundle implies it)
/// role     = "python-developer"   # → ~/.newt/personas/<name>.md
///   [loadouts.dev-nemotron.settings]
///   num_ctx = 24576
///   framing = "Ship small, verify."
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Loadout {
    /// Provider id (→ the catalog/provider card). Resolution is Slice 2.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    /// Model id, optionally `model@variant`. Resolution is Slice 2.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Bundle name (the loadable kit unit) — must name a `[bundles.<name>]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kit: Option<String>,
    /// Profile name — must name a `[profiles.<name>]`. Omitted ⇒ the bundle/model
    /// implies it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<String>,
    /// Role/persona name (`~/.newt/personas/<name>.md`). Not validated against the
    /// filesystem here — personas are resolved at session start.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    /// Per-axis overrides (parameters / prompt). Never authority.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub settings: Option<LoadoutSettings>,
}

/// Per-axis overrides a loadout may pin. **No authority axis** — a loadout cannot
/// widen caveats (`docs/design/loadout-composition.md` §Authority safety).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct LoadoutSettings {
    /// Parameter axis: KV-cache window override (top of the `ModelTuning` chain).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub num_ctx: Option<u32>,
    /// Prompt axis: a one-line system-prompt framing (the `ModeConfig.framing` shape).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub framing: Option<String>,
}

impl Loadout {
    /// Validate the loadout's name references against `cfg`: a named `kit` must be a
    /// known bundle, a named `profile` must be a known, valid profile, and a named
    /// `provider` must name a `[backends]` entry (Slice 2 — the provider/model axis).
    /// A dangling reference is a hard error — a loadout that silently did nothing
    /// would be a false claim. The `@variant` half of `model` and `role` are resolved
    /// by their own surfaces later and are not checked here.
    ///
    /// # Errors
    /// The first dangling `kit`, `profile`, or `provider` reference, as a message.
    pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
        if let Some(kit) = &self.kit {
            cfg.resolve_bundle(kit)
                .map_err(|e| format!("loadout kit '{kit}': {e}"))?;
        }
        if let Some(profile) = &self.profile {
            cfg.resolve_profile(profile)
                .map_err(|e| format!("loadout profile '{profile}': {e}"))?;
        }
        if let Some(provider) = &self.provider {
            if !cfg.backends.iter().any(|b| &b.name == provider) {
                let known = if cfg.backends.is_empty() {
                    "none defined".to_string()
                } else {
                    cfg.backends
                        .iter()
                        .map(|b| b.name.as_str())
                        .collect::<Vec<_>>()
                        .join(", ")
                };
                return Err(format!(
                    "loadout provider '{provider}': no [backends] entry named '{provider}' (known: {known})"
                ));
            }
        }
        Ok(())
    }
}

/// A named crew (`[crews.<name>]` or `crews/<name>.toml`): a role-specialized
/// ensemble over the heterogeneous backend pool. Each role names a `[loadouts.*]`
/// (so a crew is a *composition of loadouts* — the canonical example routes the
/// planner/triage to frontier models and bulk work to cheap local inference,
/// `docs/design/config-scaling-deployment-and-trust.md`). The harness owns the
/// control loop (`run_crew`); these fields pin the workers + budgets.
///
/// ```toml
/// [crews.coder]
/// planner = "planner"          # → [loadouts.planner]  (required)
/// navigator = "navigator"      # → [loadouts.navigator]
/// triage = "triage"            # → [loadouts.triage]
/// loop = "patch-revise"        # control program (default)
///   [crews.coder.budgets]
///   max_attempts = 4
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Crew {
    /// Planner/editor role — must name a `[loadouts.<name>]`. Required (a crew
    /// with no planner can't make edits).
    pub planner: String,
    /// Repo-navigator role — names a `[loadouts.<name>]`. Optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub navigator: Option<String>,
    /// Test-triage role — names a `[loadouts.<name>]`. Optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub triage: Option<String>,
    /// Control program (e.g. `"patch-revise"`). Omitted ⇒ the default loop.
    #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
    pub loop_program: Option<String>,
    /// Per-role dispatch wall-clock bound, seconds (#698). Omitted ⇒ the
    /// env/default (`NEWT_ROLE_TIMEOUT_SECS` → 600s). Widen it here for a slow
    /// loadout instead of relying on the env var.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role_timeout_secs: Option<u64>,
    /// Verification command override (e.g. `"just check"`). Omitted ⇒ inferred
    /// from the repo (justfile → `just check`, Cargo → `cargo test`, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub test: Option<String>,
    /// Budgets / safety gates for the control loop.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub budgets: Option<CrewBudgets>,
}

/// `[crew]` dispatch policy (#749 step 2): the operator's structural tightening
/// point for crews/teams the overseer fields.
///
/// A model that fields a crew is the recursion / Confused-Deputy case. Dispatch
/// hands each crew `session ⊓ clamp` (the [`crate::Caveats`] meet), so the crew's
/// authority is **always `≤ session`** (the overseer cannot escalate by
/// dispatching) and **`≤ clamp`** (the operator's bound). With the default
/// `clamp = Caveats::top()` the meet is the identity — today's behavior is
/// unchanged — while the seam exists for tighter clamps (and the per-subtask
/// `team_clamp`, #749 step 8) to plug into.
///
/// ```toml
/// [crew]
/// # crews may reach only this host, even if the session's net grant is wider
/// [crew.clamp]
/// net = { only = ["registry.internal"] }
/// ```
/// `[plan]` — plan-authoring policy (#819).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanConfig {
    /// `[plan.prune]` — the decompose-prune lexicon override.
    #[serde(default)]
    pub prune: PlanPruneConfig,
}

/// `[plan.prune]` — droppable, three-Cs override for the #801/#803 planner-
/// decomposition prune. The compiled `ACTION_MARKERS` lexicon is the default;
/// this table composes with it (remove first, then additions), so a new
/// anti-pattern — or un-marking a verb your domain uses for real work — is
/// CONFIG, not code. The prune itself stays grade-neutral either way: it only
/// removes no-diff leaves before any authority grant (#803's n=5 A/B measured
/// no grade lift; it removes a failure *mechanism*).
///
/// ```toml
/// [plan.prune]
/// disabled = false
/// add_inspect = ["scrutinize"]   # pruned wherever they lead an instruction
/// add_gate = ["smoke"]           # pruned only when terminal
/// remove = ["review"]            # e.g. a repo where "Review X" edits docs
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanPruneConfig {
    /// Turn the prune off entirely (the pre-#803 behavior).
    #[serde(default)]
    pub disabled: bool,
    /// Extra leading verbs classified Inspect (case-insensitive).
    #[serde(default)]
    pub add_inspect: Vec<String>,
    /// Extra leading verbs classified Gate (case-insensitive).
    #[serde(default)]
    pub add_gate: Vec<String>,
    /// Verbs to REMOVE from the effective lexicon (builtin or added).
    #[serde(default)]
    pub remove: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrewPolicyConfig {
    /// The authority **clamp** dispatched crews are met against
    /// (`child = session ⊓ clamp`). Defaults to `Caveats::top()` (identity meet —
    /// behavior unchanged). Tighten an axis here to bound every crew below the
    /// session ceiling; later steps (#749 step 8) compose a per-subtask clamp on
    /// top of this at the same `dispatch` seam.
    #[serde(default)]
    pub clamp: crate::caveats::Caveats,
}

/// Budgets + review gates for a crew's control loop (`crew-loadout.md` §budgets).
/// Consumed by the front door; an honest cap-exit at `max_attempts` returns
/// `NeedsHumanReview`, never a false success.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrewBudgets {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_attempts: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_files_touched: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_lines_changed: Option<u32>,
    /// Topics that force a human-review pause (e.g. `["auth","crypto","migrations"]`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub require_human_review_on: Vec<String>,
}

impl Crew {
    /// Validate the crew's role references against `cfg`: each named role
    /// (`planner`/`navigator`/`triage`) must name a known `[loadouts.<name>]`,
    /// and that loadout must itself validate (so a crew transitively checks the
    /// whole `crew → loadout → {backend,bundle,profile}` chain). A dangling role
    /// is a hard error — a crew that silently dropped a worker would be a false
    /// claim.
    ///
    /// # Errors
    /// The first dangling or invalid role reference, as a message.
    pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
        let check = |label: &str, name: &str| -> std::result::Result<(), String> {
            let loadout = cfg.loadouts.get(name).ok_or_else(|| {
                let known = if cfg.loadouts.is_empty() {
                    "none defined".to_string()
                } else {
                    cfg.loadouts.keys().cloned().collect::<Vec<_>>().join(", ")
                };
                format!(
                    "crew {label} '{name}': no [loadouts] entry named '{name}' (known: {known})"
                )
            })?;
            loadout
                .validate(cfg)
                .map_err(|e| format!("crew {label} '{name}': {e}"))
        };
        check("planner", &self.planner)?;
        if let Some(nav) = &self.navigator {
            check("navigator", nav)?;
        }
        if let Some(tri) = &self.triage {
            check("triage", tri)?;
        }
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Tool permissions — preset policies, lowered to attenuated capabilities
// ---------------------------------------------------------------------------

/// A named tool-permission preset for the TUI tool loop.
///
/// Each preset selects a [`crate::Caveats`] *policy* via
/// [`ToolPermissions::to_caveats`]; the host (`newt-identity`) then lowers that
/// policy into a signed, attenuation-only capability for enforcement. A preset
/// is a name-based convenience, **not** a capability itself — the unforgeable
/// authority is the signed `AgentKey` delegation. `Custom` means the user has
/// added commands beyond a canned preset; it carries `WorkspaceDev` authority
/// plus those extras (it does **not** grant full access).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PermissionPreset {
    /// Read files and list dirs only; no writes, no commands.
    ReadOnly,
    /// Read + write within the workspace; no shell commands.
    WorkspaceEdit,
    /// Read, write workspace, run a conservative set of dev tools.
    /// See [`ToolPermissions::to_caveats`] for the exact allowlist.
    #[default]
    WorkspaceDev,
    /// Unrestricted — `Caveats::top()`. `write_file` still prompts y/N.
    FullAccess,
    /// User has added commands beyond a canned preset; carries `WorkspaceDev`
    /// authority plus those `extra_exec` entries — **not** full access.
    Custom,
}

/// Which shell **engine** interprets `run_command` — the ADR 0005 D2 seam. This
/// is the *engine* axis (what parses/runs the command line); the *L3 backend*
/// axis (Landlock/Seatbelt/AppContainer, the kernel fence) is auto-selected
/// per-OS and is **not** chosen here. "Landlock vs brush" is really "the `host`
/// engine (guarantee rests entirely on the kernel fence) vs the `brush` engine
/// (L2 interceptor confines in-process, with the fence as an added backstop)."
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEngine {
    /// bridle's argv + safe-subset parser: refuses `$(...)`/backticks/dynamic
    /// constructs by design and spawns argv directly. Portable default.
    #[default]
    SafeSubset,
    /// The sandboxed-host engine (ADR 0019): real `/bin/sh -c` with the whole
    /// process tree in the L3 jail. Full grammar; the guarantee rests entirely
    /// on the kernel fence. Refuses a *restricted* `exec`/`net` grant. Needs a
    /// host `/bin/sh`. `--full-access` auto-selects this.
    Host,
    /// The carried brush engine (bash-in-Rust + the L2 `CommandInterceptor`):
    /// full grammar, in-process, cross-platform, and the only engine that also
    /// confines a *restricted* `exec`/`net` grant. Requires the `brush` build
    /// (agent-bridle#20 / Track 2); until that ships, selecting `brush` falls
    /// back to `host` with a warning.
    Brush,
}

impl ShellEngine {
    /// The canonical config/flag token (`kebab-case`).
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::SafeSubset => "safe-subset",
            Self::Host => "host",
            Self::Brush => "brush",
        }
    }
}

impl std::fmt::Display for ShellEngine {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

impl std::str::FromStr for ShellEngine {
    type Err = String;

    /// Parse a shell-engine token. Accepts the canonical names plus intuitive
    /// aliases (`subset`/`safe`, `sandbox-host`/`landlock`, `brush-ocap`), so a
    /// user thinking in "landlock vs brush-ocap" terms still resolves correctly.
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
            "safe-subset" | "subset" | "safe" => Ok(Self::SafeSubset),
            "host" | "sandbox-host" | "landlock" | "seatbelt" => Ok(Self::Host),
            "brush" | "brush-ocap" => Ok(Self::Brush),
            other => Err(format!(
                "unknown shell engine '{other}' (expected one of: safe-subset, host, brush)"
            )),
        }
    }
}

/// `[shell]` — engine selection for `run_command`. `engine = None` (the field
/// unset) is deliberately distinct from an explicit choice: an unset engine lets
/// `--full-access` auto-upgrade to `host` (see [`resolve_shell_engine`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ShellConfig {
    /// The selected engine, or `None` to accept the context default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub engine: Option<ShellEngine>,
}

/// The engine `--full-access` auto-selects when none is set explicitly: `host`
/// on unix (a real `/bin/sh` in the kernel jail), but **`brush` on Windows** —
/// host-shell spawns `/bin/sh -c`, which Windows lacks, so the cross-platform
/// carried brush engine is the full-grammar option there (with a Windows-usage
/// warning surfaced at selection).
#[must_use]
pub fn full_access_default_engine() -> ShellEngine {
    #[cfg(windows)]
    {
        ShellEngine::Brush
    }
    #[cfg(not(windows))]
    {
        ShellEngine::Host
    }
}

/// Resolve the effective shell engine in precedence order: an explicit CLI
/// `--shell-engine` wins, then the `[shell] engine` config key, then — only when
/// neither was set — `--full-access` auto-upgrades (to `host` on unix, `brush`
/// on Windows), otherwise the `safe-subset` default. Keeping the auto-upgrade
/// *last* means an explicit choice is never silently overridden.
#[must_use]
pub fn resolve_shell_engine(
    cli: Option<ShellEngine>,
    configured: Option<ShellEngine>,
    full_access: bool,
) -> ShellEngine {
    cli.or(configured).unwrap_or(if full_access {
        full_access_default_engine()
    } else {
        ShellEngine::SafeSubset
    })
}

/// The OCAP **L3 backend** (the kernel fence) for this platform, and whether it
/// is active on this host. This is the axis *separate* from the shell engine
/// ([`ShellEngine`]): the engine parses/runs the command line (L2), the backend
/// confines it in the kernel (L3). Surfaced by `newt doctor` (#926) so the
/// operator can see what actually enforces a restricted grant here. A restricted
/// `fs` grant is only real when the backend is available; otherwise it is
/// honestly advisory (agent-bridle reports `sandbox_kind = None`).
#[must_use]
pub fn ocap_l3_backend() -> (&'static str, bool) {
    #[cfg(target_os = "linux")]
    {
        ("Landlock", agent_bridle::landlock_is_supported())
    }
    #[cfg(target_os = "macos")]
    {
        ("Seatbelt", agent_bridle::seatbelt_is_supported())
    }
    #[cfg(target_os = "windows")]
    {
        ("AppContainer", true)
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        ("none", false)
    }
}

#[cfg(test)]
mod shell_engine_tests {
    use super::{full_access_default_engine, resolve_shell_engine, ShellConfig, ShellEngine};

    #[test]
    fn from_str_canonical_and_aliases() {
        assert_eq!(
            "safe-subset".parse::<ShellEngine>().unwrap(),
            ShellEngine::SafeSubset
        );
        assert_eq!(
            "subset".parse::<ShellEngine>().unwrap(),
            ShellEngine::SafeSubset
        );
        assert_eq!("host".parse::<ShellEngine>().unwrap(), ShellEngine::Host);
        // A user thinking "landlock vs brush" resolves `landlock` → the host
        // engine (whose guarantee rests on the Landlock/Seatbelt fence).
        assert_eq!(
            "landlock".parse::<ShellEngine>().unwrap(),
            ShellEngine::Host
        );
        assert_eq!("BRUSH".parse::<ShellEngine>().unwrap(), ShellEngine::Brush);
        assert_eq!(
            "brush-ocap".parse::<ShellEngine>().unwrap(),
            ShellEngine::Brush
        );
        assert!("bogus".parse::<ShellEngine>().is_err());
    }

    #[test]
    fn as_str_roundtrips() {
        for e in [
            ShellEngine::SafeSubset,
            ShellEngine::Host,
            ShellEngine::Brush,
        ] {
            assert_eq!(e.as_str().parse::<ShellEngine>().unwrap(), e);
        }
    }

    #[test]
    fn resolve_flag_wins_over_everything() {
        assert_eq!(
            resolve_shell_engine(
                Some(ShellEngine::SafeSubset),
                Some(ShellEngine::Brush),
                true
            ),
            ShellEngine::SafeSubset,
            "explicit --shell-engine wins even over config and --full-access"
        );
    }

    #[test]
    fn resolve_config_wins_over_full_access_auto_upgrade() {
        assert_eq!(
            resolve_shell_engine(None, Some(ShellEngine::SafeSubset), true),
            ShellEngine::SafeSubset,
            "an explicit [shell] engine is not overridden by --full-access"
        );
    }

    #[test]
    fn resolve_full_access_auto_upgrades_when_unset() {
        // `host` on unix, `brush` on Windows (host-shell needs `/bin/sh`).
        assert_eq!(
            resolve_shell_engine(None, None, true),
            full_access_default_engine()
        );
        #[cfg(not(windows))]
        assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Host);
        #[cfg(windows)]
        assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Brush);
    }

    #[test]
    fn resolve_defaults_to_safe_subset() {
        assert_eq!(
            resolve_shell_engine(None, None, false),
            ShellEngine::SafeSubset
        );
    }

    #[test]
    fn shell_config_deserializes_kebab_case() {
        let cfg: ShellConfig = toml::from_str("engine = \"host\"").unwrap();
        assert_eq!(cfg.engine, Some(ShellEngine::Host));
        let cfg: ShellConfig = toml::from_str("engine = \"safe-subset\"").unwrap();
        assert_eq!(cfg.engine, Some(ShellEngine::SafeSubset));
    }
}

impl PermissionPreset {
    pub const ALL: [Self; 4] = [
        Self::ReadOnly,
        Self::WorkspaceEdit,
        Self::WorkspaceDev,
        Self::FullAccess,
    ];

    pub fn as_str(&self) -> &'static str {
        match self {
            Self::ReadOnly => "read_only",
            Self::WorkspaceEdit => "workspace_edit",
            Self::WorkspaceDev => "workspace_dev",
            Self::FullAccess => "full_access",
            Self::Custom => "custom",
        }
    }

    /// Cycle through the four user-visible presets (skips `Custom`).
    pub fn toggle(&self) -> Self {
        let idx = Self::ALL.iter().position(|p| p == self).unwrap_or(2);
        Self::ALL[(idx + 1) % Self::ALL.len()].clone()
    }

    pub fn description(&self) -> &'static str {
        match self {
            Self::ReadOnly => "read files + list dirs; no writes, no commands",
            Self::WorkspaceEdit => "read + write workspace; no shell commands",
            Self::WorkspaceDev => "read, write workspace, run: cargo just git grep rg fd ...",
            Self::FullAccess => "unrestricted (prompts y/N before each write)",
            Self::Custom => "workspace-dev tools plus your extra commands",
        }
    }
}

/// Permission configuration stored under `[tui.permissions]` in `newt.toml`.
///
/// Call [`ToolPermissions::to_caveats`] to obtain the runtime [`crate::Caveats`]
/// enforced by every `execute_tool` dispatch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolPermissions {
    /// The active preset.
    pub preset: PermissionPreset,

    /// Extra commands allowed beyond the `WorkspaceDev` built-in set.
    /// Only consulted when `preset == WorkspaceDev` or `Custom`.
    /// Stored as leading tokens, e.g. `["bacon", "make"]`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extra_exec: Vec<String>,

    /// Hosts the agent may reach with `web_fetch` (the `net` capability axis).
    ///
    /// Empty (the default) = **no network** — `web_fetch` is denied. A single
    /// `"*"` grants **all** hosts (still SSRF-screened + DNS-rebind-pinned by the
    /// web tool). Otherwise an exact host allowlist, e.g.
    /// `["docs.rs", "raw.githubusercontent.com"]`. Applies to every preset
    /// except `FullAccess` (which is already unrestricted).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub net: Vec<String>,

    /// Prompt the human when a tool call is denied by the session's caveats
    /// (issue #263): allow once / allow for this session / deny. Default
    /// **false** — a denial fails the call exactly as before (deny-by-default
    /// stays the posture). Interactive TUI only; headless paths (ACP worker,
    /// `newt-eval`) never prompt regardless. Also enabled per-run via the
    /// `--prompt-for-permissions` CLI flag. Every prompted decision is
    /// recorded to `~/.newt/permission-log.jsonl` for later review.
    #[serde(default)]
    pub prompt: bool,
}

impl Default for ToolPermissions {
    fn default() -> Self {
        Self {
            preset: PermissionPreset::WorkspaceDev,
            extra_exec: Vec::new(),
            net: Vec::new(),
            prompt: false,
        }
    }
}

impl ToolPermissions {
    /// Built-in exec allowlist for the `WorkspaceDev` preset.
    const WORKSPACE_DEV_EXEC: &'static [&'static str] = &[
        "cargo",
        // rustc must be here: cargo spawns it as a subprocess to compile and
        // test. Without it, `cargo test` fails with "could not execute rustc".
        // rustfmt and clippy-driver are already present; this was an oversight.
        "rustc",
        "just",
        "git",
        "grep",
        "rg",
        "ripgrep",
        "fd",
        "find",
        "cat",
        "ls",
        "echo",
        "pwd",
        "true",
        "false",
        "head",
        "tail",
        "wc",
        "sort",
        "uniq",
        "diff",
        "patch",
        "rustfmt",
        "clippy-driver",
        "rustup",
        // Polyglot dev tools reached for routinely in a mixed workspace. Same
        // risk tier as cargo/git — WorkspaceDev already grants workspace write
        // and the full Rust toolchain. Anything outside this set can still be
        // opted in per-config via `[tui.permissions] extra_exec = [...]`.
        "gh",
        "python",
        "python3",
        "pip",
        "npm",
        "node",
        "make",
        "jq",
        "curl",
        "awk",
        "sed",
        "cut",
        "xargs",
        "which",
        "env",
    ];

    /// Build the runtime `Caveats` for this permission configuration.
    ///
    /// `workspace` is the absolute path to the current workspace directory;
    /// it is stored in `Scope::Only` so the TUI enforcement layer can do
    /// prefix matching (path within workspace → permitted).
    ///
    /// Note: the `Caveats` lattice uses exact-set semantics; prefix matching
    /// is the responsibility of the enforcement site (`tui_permits_path` in
    /// newt-tui), not this algebra. This is an intentional layer separation.
    pub fn to_caveats(&self, workspace: &str) -> crate::caveats::Caveats {
        use crate::caveats::{Caveats, CountBound, Scope};

        let ws = workspace.to_string();
        let net = self.net_scope();

        match self.preset {
            PermissionPreset::ReadOnly => Caveats {
                fs_read: Scope::All,
                fs_write: Scope::none(),
                exec: Scope::none(),
                net,
                max_calls: CountBound::Unlimited,
                valid_for_generation: Scope::All,
            },

            PermissionPreset::WorkspaceEdit => Caveats {
                fs_read: Scope::All,
                fs_write: Scope::only([ws]),
                exec: Scope::none(),
                net,
                max_calls: CountBound::Unlimited,
                valid_for_generation: Scope::All,
            },

            // `Custom` shares this arm: editing `extra_exec` keeps WorkspaceDev
            // authority plus the added commands. It must NOT escalate to
            // `top()` — adding one command to an allowlist should never grant
            // full access.
            PermissionPreset::WorkspaceDev | PermissionPreset::Custom => {
                let mut allowed: std::collections::BTreeSet<String> = Self::WORKSPACE_DEV_EXEC
                    .iter()
                    .map(|s| s.to_string())
                    .collect();
                for cmd in &self.extra_exec {
                    allowed.insert(cmd.clone());
                }
                Caveats {
                    fs_read: Scope::All,
                    fs_write: Scope::only([ws]),
                    exec: Scope::Only(allowed),
                    net,
                    max_calls: CountBound::Unlimited,
                    valid_for_generation: Scope::All,
                }
            }

            PermissionPreset::FullAccess => Caveats::top(),
        }
    }

    /// Lower the configured `net` allowlist into a capability [`Scope`].
    ///
    /// Empty → `none` (no network). A `"*"` entry → `All` (every host, still
    /// SSRF-screened by the web tool). Otherwise an exact host allowlist.
    fn net_scope(&self) -> crate::caveats::Scope<String> {
        use crate::caveats::Scope;
        if self.net.is_empty() {
            Scope::none()
        } else if self.net.iter().any(|h| h == "*") {
            Scope::All
        } else {
            Scope::only(self.net.iter().cloned())
        }
    }
}

/// Key binding style for the chat REPL input line.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EditMode {
    /// Readline / emacs-style bindings.
    Emacs,
    /// Vi / vim-style bindings — Esc for normal mode, i for insert.
    Vi,
    /// Nano-style: modeless, emacs-like bindings (the **default** — the most
    /// broadly approachable). Behaves like `Emacs` on the lean surface; it is a
    /// distinct, selectable label, and the rich-tui surface shows the nano `^G`
    /// help hint for it.
    #[default]
    Nano,
}

impl EditMode {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Emacs => "emacs",
            Self::Vi => "vi",
            Self::Nano => "nano",
        }
    }

    /// Cycle through the modes (used by a single-key toggle): emacs → vi →
    /// nano → emacs.
    pub fn toggle(&self) -> Self {
        match self {
            Self::Emacs => Self::Vi,
            Self::Vi => Self::Nano,
            Self::Nano => Self::Emacs,
        }
    }
}

impl Default for TuiConfig {
    fn default() -> Self {
        Self {
            chat_style: ChatStyle::Compact,
            prompt: None,
            no_splash: false,
            edit_mode: EditMode::Nano,
            gutter: None,
            footer: FooterMode::Auto,
            color: ColorMode::Auto,
            thinking: ThinkingMode::Stream,
            tool_output_lines: default_tool_output_lines(),
            max_tool_rounds: default_max_tool_rounds(),
            workflow_grace_rounds: default_workflow_grace_rounds(),
            permissions: ToolPermissions::default(),
            debug: None,
            trace: None,
            build_check_cmd: None,
            num_ctx: None,
            real_context_discovery: None,
            connect_timeout_secs: default_connect_timeout_secs(),
            inference_timeout_secs: default_inference_timeout_secs(),
            keep_alive: default_keep_alive(),
            markdown: MarkdownMode::default(),
            mid_loop_trim_threshold: default_mid_loop_trim_threshold(),
            mid_loop_trim_tokens: None,
            sanitize_mcp_server_names: default_sanitize_mcp_server_names(),
            mcp_allow_insecure_hosts: Vec::new(),
            allow_bang_escape: default_allow_bang_escape(),
        }
    }
}

/// Chat REPL display density.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ChatStyle {
    /// Just the caret symbol — no "newt" / "you" labels.
    #[default]
    Compact,
    /// Full "newt ▸" / "you $" labels before each message.
    Verbose,
}

impl ChatStyle {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Compact => "compact",
            Self::Verbose => "verbose",
        }
    }

    pub fn toggle(&self) -> Self {
        match self {
            Self::Compact => Self::Verbose,
            Self::Verbose => Self::Compact,
        }
    }
}

/// The wire protocol an inference backend speaks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BackendKind {
    /// Ollama's native `POST /api/chat` API (the historical default).
    #[default]
    Ollama,
    /// An OpenAI-compatible HTTP API (`POST /v1/chat/completions`,
    /// `GET /v1/models`): vLLM, llama.cpp's server, or any hosted
    /// OpenAI-compatible endpoint. Optionally authenticated with a
    /// bearer token (see [`BackendConfig::api_key_file`] /
    /// [`BackendConfig::api_key_env`]).
    #[serde(alias = "vllm", alias = "openai-compatible")]
    Openai,
    /// An **in-process** inference backend — no HTTP, no external server. Loads a
    /// small quantized (GGUF) model and runs it in-tree (Metal-accelerated on
    /// Apple Silicon). Opt-in behind the `embedded` cargo feature (default-off);
    /// when the feature is absent, selecting it is a clear build-time-off error,
    /// never a silent fallback. Intended for the summarizer + small auxiliary
    /// calls so they never contend with the primary model (#639).
    Embedded,
}

impl BackendKind {
    /// Short human label for the wire protocol — shown in the ready preamble and
    /// the `/backends` list. Note newt models the *protocol*, so vLLM, llama.cpp,
    /// and hosted OpenAI all read as `openai` (vLLM has no distinct wire form).
    pub fn label(self) -> &'static str {
        match self {
            Self::Ollama => "ollama",
            Self::Openai => "openai",
            Self::Embedded => "embedded",
        }
    }
}

/// Which OpenAI HTTP surface a `kind = "openai"` backend speaks.
///
/// `chat_completions` (the default) is the classic `POST /v1/chat/completions`.
/// `responses` is the newer `POST /v1/responses` — required by models that
/// OpenAI serves *only* there (e.g. `gpt-5-codex`, which 404s on
/// chat/completions with "only supported in v1/responses"). Ignored for
/// `kind = "ollama"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OpenAiApi {
    /// `POST /v1/chat/completions` (the historical default).
    #[default]
    #[serde(alias = "chat", alias = "completions")]
    ChatCompletions,
    /// `POST /v1/responses` (the newer Responses API).
    Responses,
}

/// A single inference backend entry.
///
/// Two ways to define one: an inline `[[backends]]` array element in
/// `config.toml`, or a per-file drop-in `~/.newt/backends/<name>.toml` (the
/// filename stem is the `name`, so a drop-in omits it). `name` and `tiers`
/// therefore default — a minimal drop-in is just `endpoint` + `model`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendConfig {
    /// Backend name. For a per-file drop-in this is overwritten by the filename
    /// stem, so the file body may omit it.
    #[serde(default)]
    pub name: String,
    /// HTTP endpoint URL (Ollama / OpenAI). Defaulted so a `kind = "embedded"`
    /// backend — which runs in-process and has no URL — can omit it.
    #[serde(default)]
    pub endpoint: String,
    pub model: String,
    /// For `kind = "embedded"`: the local GGUF model file (the in-process engine
    /// has no `endpoint`). `~/` is expanded at use. Ignored for HTTP backends.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_path: Option<String>,
    #[serde(default)]
    pub tiers: Vec<Tier>,
    /// Which wire protocol this backend speaks. Defaults to `ollama`
    /// so configs written before this field existed keep working.
    #[serde(default)]
    pub kind: BackendKind,
    /// For `kind = "openai"`: which OpenAI HTTP surface to use
    /// (`chat_completions` default, or `responses` for models served only on
    /// `/v1/responses`). Ignored for Ollama. The agent loop also auto-falls-back
    /// to `responses` if chat/completions 404s with the responses-only error.
    #[serde(default)]
    pub api: OpenAiApi,
    /// Optional path to a file whose first non-empty line is a bearer
    /// token, sent as `Authorization: Bearer <token>` by
    /// OpenAI-compatible backends. A leading `~/` is expanded to the
    /// home directory. Keeping the secret in a file (rather than inline
    /// in the config) keeps tokens out of version control.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_file: Option<String>,
    /// Optional environment variable name holding a bearer token. Takes
    /// precedence over [`api_key_file`](Self::api_key_file) when both
    /// resolve to a non-empty value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_env: Option<String>,
}

impl BackendConfig {
    /// Resolve this backend's bearer token, if any.
    ///
    /// Checks [`api_key_env`](Self::api_key_env) first (environment
    /// variable), then [`api_key_file`](Self::api_key_file) (first
    /// non-empty line of the file, trimmed). Returns `None` when neither
    /// is configured or neither resolves to a non-empty value.
    pub fn resolve_api_key(&self) -> Option<String> {
        if let Some(var) = &self.api_key_env {
            if let Ok(val) = std::env::var(var) {
                let val = val.trim();
                if !val.is_empty() {
                    return Some(val.to_string());
                }
            }
        }
        if let Some(path) = &self.api_key_file {
            let expanded = expand_tilde(path);
            if let Ok(contents) = std::fs::read_to_string(&expanded) {
                if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
                    return Some(token.to_string());
                }
            }
        }
        None
    }
}

/// Dedicated configuration for the compression summarizer, loaded from
/// `~/.newt/summarizer.toml` (Step 24.10, #559). An absent file means
/// `SummarizerConfig::default()` — every field falls back to the session
/// backend, so behavior is unchanged from "summarizer reuses the session
/// model".
///
/// The point of the separate file is the **own-backend** fields
/// (`endpoint`/`model`/`kind`/`api_key_file`): a summarizer can run on a
/// different, fast box than the session model instead of contending with it
/// (the #548 field incident — a slow primary summarizer stalled ~189s before
/// the static marker). `timeout_secs` / `retries` / `fallback_model` are the
/// knobs that used to live under `[tui]` (moved here in 24.10).
///
/// Example `~/.newt/summarizer.toml`:
/// ```toml
/// endpoint = "http://REDACTED-HOST:11434"  # default: session backend URL
/// model    = "qwen2.5-coder:3b"            # default: session model
/// kind     = "ollama"                      # "ollama" | "openai"
/// timeout_secs   = 45
/// retries        = 1
/// fallback_model = "nemotron-mini:4b"      # else preference-list auto-pick (24.9)
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SummarizerConfig {
    /// Summarizer endpoint URL. `None` ⇒ reuse the session backend's URL.
    pub endpoint: Option<String>,
    /// Summarizer model. `None` ⇒ reuse the session backend's model.
    pub model: Option<String>,
    /// Backend protocol. `None` ⇒ reuse the session backend's kind.
    pub kind: Option<BackendKind>,
    /// For `kind = "embedded"` (#661 group C): the local GGUF model file for the
    /// in-process candle summarizer. Ignored for HTTP backends.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_path: Option<String>,
    /// Bearer-token file (first non-empty line). `None` ⇒ reuse the session key.
    pub api_key_file: Option<String>,
    /// Bearer-token environment variable (checked before `api_key_file`).
    pub api_key_env: Option<String>,
    /// Per-request timeout (seconds). Default 60 — cold-loading a big model can
    /// legitimately exceed it; raise on a slow box that falls back to the marker.
    #[serde(default = "default_summarizer_timeout_secs")]
    pub timeout_secs: u64,
    /// Retry attempts before the static marker. Default 1 — each attempt can
    /// cost the full `timeout_secs` (the #548 189s incident was 3 × 60s).
    #[serde(default = "default_summarizer_retries")]
    pub retries: u32,
    /// Explicit fallback model. `None` ⇒ for an Ollama summarizer backend, the
    /// first installed small-model-preference-list entry is auto-picked (24.9).
    pub fallback_model: Option<String>,
    /// `keep_alive` for the warm + summary requests. `None` ⇒ inherit
    /// `[tui].keep_alive`.
    pub keep_alive: Option<String>,
}

impl Default for SummarizerConfig {
    fn default() -> Self {
        Self {
            endpoint: None,
            model: None,
            kind: None,
            model_path: None,
            api_key_file: None,
            api_key_env: None,
            timeout_secs: default_summarizer_timeout_secs(),
            retries: default_summarizer_retries(),
            fallback_model: None,
            keep_alive: None,
        }
    }
}

impl SummarizerConfig {
    /// Parse a `summarizer.toml` body. Pure — fully unit-testable without disk.
    pub fn from_toml_str(text: &str) -> Result<Self> {
        toml::from_str(text).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Load `~/.newt/summarizer.toml` (or `$NEWT_SUMMARIZER_CONFIG`). A missing
    /// file is not an error — it yields [`SummarizerConfig::default`] (reuse the
    /// session backend). Only a present-but-malformed file errors.
    pub fn resolve() -> Result<Self> {
        for path in Self::candidate_paths() {
            if path.is_file() {
                let text = std::fs::read_to_string(&path)?;
                return Self::from_toml_str(&text);
            }
        }
        Ok(Self::default())
    }

    /// Ordered candidate paths for `summarizer.toml`.
    fn candidate_paths() -> Vec<PathBuf> {
        let mut paths = Vec::new();
        if let Ok(p) = std::env::var("NEWT_SUMMARIZER_CONFIG") {
            paths.push(PathBuf::from(p));
        }
        if let Some(dir) = Config::user_config_dir() {
            paths.push(dir.join("summarizer.toml"));
        }
        paths
    }

    /// Resolve this summarizer's bearer token (env var first, then file), or
    /// `None` — mirrors [`BackendConfig::resolve_api_key`].
    pub fn resolve_api_key(&self) -> Option<String> {
        if let Some(var) = &self.api_key_env {
            if let Ok(val) = std::env::var(var) {
                let val = val.trim();
                if !val.is_empty() {
                    return Some(val.to_string());
                }
            }
        }
        if let Some(path) = &self.api_key_file {
            let expanded = expand_tilde(path);
            if let Ok(contents) = std::fs::read_to_string(&expanded) {
                if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
                    return Some(token.to_string());
                }
            }
        }
        None
    }
}

/// A subprocess provider-plugin entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
    pub name: String,
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default)]
    pub env_pass: Vec<String>,
    pub tiers: Vec<Tier>,
}

// ---------------------------------------------------------------------------
// Default
// ---------------------------------------------------------------------------

/// The last-resort localhost Ollama backend: used both as `Config::default()`'s
/// sole backend (no config file at all) and as the [`Config::resolve`] fallback
/// when neither inline `[[backends]]` nor per-file drop-ins supply any, so a
/// bare install still talks to a local Ollama.
fn fallback_localhost_backend() -> BackendConfig {
    BackendConfig {
        name: "ollama".into(),
        endpoint: "http://127.0.0.1:11434".into(),
        model: "llama3.1:8b".into(),
        model_path: None,
        tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
        kind: BackendKind::Ollama,
        api: Default::default(),
        api_key_file: None,
        api_key_env: None,
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            backends: vec![fallback_localhost_backend()],
            providers: Vec::new(),
            scratch: None,
            default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
            lifecycle: None,
            dgx: None,
            tui: None,
            shell: None,
            context: None,
            tools: None,
            pricing: None,
            memory: None,
            agents: AgentsConfig::default(),
            mcp_servers: Vec::new(),
            logs: None,
            skills: None,
            model_tuning: Vec::new(),
            conversations: None,
            merge: None,
            permission_presets: std::collections::BTreeMap::new(),
            modes: std::collections::BTreeMap::new(),
            profiles: std::collections::BTreeMap::new(),
            bundles: std::collections::BTreeMap::new(),
            loadouts: std::collections::BTreeMap::new(),
            crews: std::collections::BTreeMap::new(),
            crew: None,
            plan: None,
        }
    }
}

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

impl Config {
    /// Load configuration from an explicit file path.
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)?;
        toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Resolve configuration by searching well-known locations, then layering a
    /// project-local override on top.
    ///
    /// Base search order (first match wins):
    /// 1. `$NEWT_CONFIG` environment variable
    /// 2. `./newt.toml`
    /// 3. `$NEWT_CONFIG_DIR/config.toml` or `~/.newt/config.toml`
    /// 4. `/etc/newt/config.toml`
    ///
    /// Then, if a project-local `.newt/config.toml` is found by walking up from
    /// the current directory (see [`Config::project_config_path`]), it is
    /// deep-merged **over** the base so a repo can pin its own models, endpoints,
    /// rules, and local stdio MCP services without copying the whole global
    /// config. Tables merge recursively (project keys win) and scalars are
    /// replaced by the project value. Arrays follow `[merge] arrays` —
    /// `"replace"` (default) or `"append"` (see [`ArrayMergeStrategy`]). The
    /// project config's `[merge]` setting takes precedence, then the base's.
    /// See issue #222.
    ///
    /// When no project override exists this is byte-for-byte the legacy
    /// first-match behavior. Returns `Config::default()` if nothing is found.
    pub fn resolve() -> Result<Self> {
        let base_path = Self::candidate_paths().into_iter().find(|p| p.is_file());
        // A project-local config that *is* the base (e.g. cwd is the project and
        // its `.newt/config.toml` already matched) must not be merged onto itself.
        let project_path =
            Self::project_config_path().filter(|p| Some(p.as_path()) != base_path.as_deref());

        let mut cfg = match (&base_path, &project_path) {
            // Fast path: no project override → exact legacy behavior.
            (Some(p), None) => Self::load(p)?,
            (None, None) => Self::default(),
            // Project override present → layer it over the base (or the default
            // config when there is no base file).
            (base, Some(proj)) => {
                let mut merged = match base {
                    Some(p) => Self::load_value(p)?,
                    None => toml::Value::try_from(Self::default())
                        .map_err(|e| NewtError::Config(e.to_string()))?,
                };
                let project_val = Self::load_value(proj)?;
                // The merge strategy is itself config: the project declares how
                // it wants to be merged (`[merge] arrays = ...`), else the global
                // config's setting, else the built-in default (Replace).
                let strategy = array_merge_strategy(&project_val, &merged);
                merge_toml(&mut merged, project_val, strategy);
                merged
                    .try_into()
                    .map_err(|e| NewtError::Config(e.to_string()))?
            }
        };
        // Per-file backends (the endpoint control surface): drop a
        // `~/.newt/backends/<name>.toml` to add/override a backend — no
        // `config.toml` edit, and no overlapping inline `[[backends]]` to
        // hand-deconflict. Runs first so disk loadouts/crews can name a disk
        // backend's provider.
        cfg.merge_disk_backends();
        // Localhost fallback: a config that declared no inline `[[backends]]`
        // deserializes to empty (see the field doc); if no drop-in supplied one
        // either, restore the bare-install localhost Ollama so newt still has a
        // backend to talk to.
        if cfg.backends.is_empty() {
            cfg.backends.push(fallback_localhost_backend());
        }
        // Per-file bundles (the model-support-kit control surface): drop a
        // `~/.newt/bundles/<name>.toml` to add a bundle — no `config.toml` edit.
        cfg.merge_disk_bundles();
        // Per-file loadouts (the shareable composition control surface): drop a
        // `~/.newt/loadouts/<name>.toml` to add a loadout — no `config.toml` edit.
        // Runs after bundles so a disk loadout may name a disk bundle.
        cfg.merge_disk_loadouts();
        // Per-file crews (the role-ensemble control surface): drop a
        // `~/.newt/crews/<name>.toml` to add a crew — no `config.toml` edit.
        // Runs after loadouts so a disk crew may name a disk loadout.
        cfg.merge_disk_crews();
        // Per-file DGX nodes (the per-host control surface): drop a
        // `~/.newt/dgx/<name>.toml` to add/override a DGX node — each host its
        // own file, no inline `[[dgx.nodes]]`. The active selection
        // (active_node/active_endpoint/active_model) stays in `[dgx]`.
        cfg.merge_disk_dgx_nodes();
        // #726: push the resolved `[tools] max_output_tokens` into the
        // process-wide model-facing output budget. `Config::resolve` is the
        // single canonical config-application entry, so every consumer (TUI,
        // cowork driver, eval) gets the override here without threading a new
        // `usize` through `ChatCtx` + `execute_tool` + every call site. Idempotent.
        crate::agentic::set_max_output_tokens(cfg.max_output_tokens());
        crate::agentic::set_output_head_tokens(cfg.output_head_tokens());
        // #880: publish the repo `[lifecycle]` overrides the same way — the single
        // canonical config-application entry — so the crew's normalize (and future
        // phase consumers) honor `.newt/config.toml`.
        if let Some(lc) = &cfg.lifecycle {
            crate::tooling::set_lifecycle_override(lc.clone());
        }
        // #844: publish `[scratch] dir` the same way — so crew worktrees / the crew
        // target / session plans honor it. `NEWT_SCRATCH_DIR` still overrides.
        if let Some(dir) = cfg.scratch.as_ref().and_then(|s| s.dir.as_deref()) {
            crate::scratch::set_scratch_dir(dir);
        }
        Ok(cfg)
    }

    /// The configured model-facing output token budget (`[tools]
    /// max_output_tokens`), or the built-in default when `[tools]` is absent
    /// (#726). `0` means "no cap". See [`ToolsConfig`].
    pub fn max_output_tokens(&self) -> usize {
        self.tools
            .as_ref()
            .map(|t| t.max_output_tokens)
            .unwrap_or_else(default_max_output_tokens)
    }

    /// The configured head allocation for oversized `run_command` output
    /// (`[tools] output_head_tokens`), or the built-in tail-biased default.
    pub fn output_head_tokens(&self) -> usize {
        self.tools
            .as_ref()
            .map(|t| t.output_head_tokens)
            .unwrap_or_else(default_output_head_tokens)
    }

    /// Merge per-file backends from the `backends/` dirs next to the config:
    /// `~/.newt/backends/*.toml` first, then the project `.newt/backends/` (so
    /// project overrides home overrides inline `[[backends]]`). Filename stem =
    /// backend name. A malformed drop-in is skipped with a warning; it must not
    /// break startup.
    fn merge_disk_backends(&mut self) {
        if let Some(dir) = Self::user_config_dir() {
            self.merge_backends_from_dir(&dir.join("backends"));
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                self.merge_backends_from_dir(&parent.join("backends"));
            }
        }
    }

    /// Load `<dir>/*.toml` as backends (filename stem = name) into
    /// `self.backends`. A drop-in **replaces** an existing backend of the same
    /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
    /// an inline `[[backends]]` named `dgx1` without a duplicate. A malformed
    /// file is skipped with a warning.
    fn merge_backends_from_dir(&mut self, dir: &Path) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return; // no backends dir — fine
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
            .collect();
        paths.sort();
        for path in paths {
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            match std::fs::read_to_string(&path).map(|t| toml::from_str::<BackendConfig>(&t)) {
                Ok(Ok(mut backend)) => {
                    // A backend needs a destination: an HTTP `endpoint`, or — for
                    // `kind = "embedded"` — a local `model_path`. Skip those with
                    // neither (the "malformed → skip, not fatal" contract; before
                    // `endpoint` became defaultable, the missing-endpoint case was
                    // a parse error).
                    if backend.endpoint.is_empty() && backend.model_path.is_none() {
                        tracing::warn!(
                            path = %path.display(),
                            "skipping backend with neither endpoint nor model_path"
                        );
                        continue;
                    }
                    // The filename is authoritative for the name (collision-free).
                    backend.name = stem.to_string();
                    match self.backends.iter_mut().find(|b| b.name == backend.name) {
                        Some(existing) => *existing = backend,
                        None => self.backends.push(backend),
                    }
                }
                Ok(Err(e)) => {
                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed backend file");
                }
                Err(_) => {}
            }
        }
    }

    /// Merge per-file DGX nodes from the `dgx/` dirs next to the config:
    /// `~/.newt/dgx/*.toml` first, then the project `.newt/dgx/` (so project
    /// overrides home overrides inline `[[dgx.nodes]]`). Filename stem = node
    /// name. A malformed drop-in is skipped with a warning; it must not break
    /// startup.
    fn merge_disk_dgx_nodes(&mut self) {
        if let Some(dir) = Self::user_config_dir() {
            self.merge_dgx_nodes_from_dir(&dir.join("dgx"));
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                self.merge_dgx_nodes_from_dir(&parent.join("dgx"));
            }
        }
    }

    /// Load `<dir>/*.toml` as DGX nodes (filename stem = name) into
    /// `self.dgx.nodes`. A drop-in **replaces** an existing node of the same
    /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
    /// an inline `[[dgx.nodes]]` named `dgx1` without a duplicate. The `[dgx]`
    /// table is created (default selection) if it was absent. A malformed file
    /// is skipped with a warning.
    fn merge_dgx_nodes_from_dir(&mut self, dir: &Path) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return; // no dgx dir — fine
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
            .collect();
        paths.sort();
        for path in paths {
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::dgx::DgxNode>(&t))
            {
                Ok(Ok(mut node)) => {
                    // The filename is authoritative for the name (collision-free).
                    node.name = stem.to_string();
                    let dgx = self.dgx.get_or_insert_with(Default::default);
                    match dgx.nodes.iter_mut().find(|n| n.name == node.name) {
                        Some(existing) => *existing = node,
                        None => dgx.nodes.push(node),
                    }
                }
                Ok(Err(e)) => {
                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed dgx node file");
                }
                Err(_) => {}
            }
        }
    }

    /// Merge per-file crews from the `crews/` dirs next to the config:
    /// `~/.newt/crews/*.toml` first, then the project `.newt/crews/` (so project
    /// overrides home overrides inline `[crews.*]`). Filename stem = crew name. A
    /// malformed drop-in is skipped with a warning; references inside a crew are
    /// validated when it is selected (`newt crew --crew <name>`), mirroring the
    /// inline `[crews.*]` and disk-loadout paths.
    fn merge_disk_crews(&mut self) {
        if let Some(dir) = Self::user_config_dir() {
            self.merge_crews_from_dir(&dir.join("crews"));
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                self.merge_crews_from_dir(&parent.join("crews"));
            }
        }
    }

    /// Load `<dir>/*.toml` as crews (filename stem = name) into `self.crews`,
    /// last-wins on a name clash. A malformed file is skipped with a warning.
    fn merge_crews_from_dir(&mut self, dir: &Path) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return; // no crews dir — fine
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
            .collect();
        paths.sort();
        for path in paths {
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            match std::fs::read_to_string(&path).map(|t| toml::from_str::<Crew>(&t)) {
                Ok(Ok(crew)) => {
                    self.crews.insert(stem.to_string(), crew);
                }
                Ok(Err(e)) => {
                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed crew file");
                }
                Err(_) => {}
            }
        }
    }

    /// Merge per-file bundles from the well-known `bundles/` dirs next to the
    /// config: `~/.newt/bundles/*.toml` first, then the project `.newt/bundles/`
    /// (so project overrides home overrides inline `[bundles.*]`). The filename
    /// stem is the bundle name. A malformed drop-in is skipped with a warning — it
    /// must not break startup.
    fn merge_disk_bundles(&mut self) {
        if let Some(dir) = Self::user_config_dir() {
            self.merge_bundles_from_dir(&dir.join("bundles"));
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                self.merge_bundles_from_dir(&parent.join("bundles"));
            }
        }
    }

    /// Load `<dir>/*.toml` as bundles (filename stem = name) into `self.bundles`,
    /// last-wins on a name clash. A malformed file is skipped with a warning.
    fn merge_bundles_from_dir(&mut self, dir: &Path) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return; // no bundles dir — fine
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
            .collect();
        paths.sort();
        for path in paths {
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            match std::fs::read_to_string(&path).map(|t| toml::from_str::<BundleConfig>(&t)) {
                Ok(Ok(bundle)) => {
                    self.bundles.insert(stem.to_string(), bundle);
                }
                Ok(Err(e)) => {
                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed bundle file");
                }
                Err(_) => {}
            }
        }
    }

    /// Merge per-file loadouts from the well-known `loadouts/` dirs next to the
    /// config: `~/.newt/loadouts/*.toml` first, then the project `.newt/loadouts/`
    /// (so project overrides home overrides inline `[loadouts.*]`). The filename
    /// stem is the loadout name. A malformed drop-in is skipped with a warning — it
    /// must not break startup. References *inside* a loadout are validated when it
    /// is selected (`--loadout`), not at load, mirroring the inline `[loadouts.*]`
    /// path.
    fn merge_disk_loadouts(&mut self) {
        if let Some(dir) = Self::user_config_dir() {
            self.merge_loadouts_from_dir(&dir.join("loadouts"));
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                self.merge_loadouts_from_dir(&parent.join("loadouts"));
            }
        }
    }

    /// Load `<dir>/*.toml` as loadouts (filename stem = name) into `self.loadouts`,
    /// last-wins on a name clash. A malformed file is skipped with a warning.
    fn merge_loadouts_from_dir(&mut self, dir: &Path) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return; // no loadouts dir — fine
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
            .collect();
        paths.sort();
        for path in paths {
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            match std::fs::read_to_string(&path).map(|t| toml::from_str::<Loadout>(&t)) {
                Ok(Ok(loadout)) => {
                    self.loadouts.insert(stem.to_string(), loadout);
                }
                Ok(Err(e)) => {
                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed loadout file");
                }
                Err(_) => {}
            }
        }
    }

    /// Load a config file as a raw `toml::Value` (for layered merging).
    fn load_value(path: &Path) -> Result<toml::Value> {
        let text = std::fs::read_to_string(path)?;
        toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Locate a project-local `.newt/config.toml` by walking up from the current
    /// directory toward the filesystem root, stopping before `$HOME` so the
    /// global `~/.newt/config.toml` is never mistaken for a project override.
    /// Returns the nearest match (innermost project wins). See issue #222.
    pub fn project_config_path() -> Option<PathBuf> {
        let cwd = std::env::current_dir().ok()?;
        find_project_config_from(&cwd, home_dir().as_deref())
    }

    /// The user-writable config root: `$NEWT_CONFIG_DIR` or `~/.newt`.
    pub fn user_config_dir() -> Option<PathBuf> {
        if let Some(path) = std::env::var_os(NEWT_CONFIG_DIR_ENV)
            .filter(|value| !value.is_empty())
            .map(PathBuf::from)
        {
            return Some(path);
        }
        home_dir().map(|h| h.join(".newt"))
    }

    /// The user-writable config path: `$NEWT_CONFIG_DIR/config.toml` or
    /// `~/.newt/config.toml`.
    /// This is the first path `resolve()` reads and the target for `save()`.
    pub fn user_config_path() -> Option<PathBuf> {
        Self::user_config_dir().map(|dir| dir.join("config.toml"))
    }

    /// Serialize the config to pretty TOML for **audit**, with inline secret
    /// material redacted. The values of every `[[mcp_servers]]` `env` and
    /// `headers` entry are replaced with [`Self::REDACTED`] — those maps are the
    /// only place `Config` can carry a raw secret inline (e.g. an
    /// `Authorization: Bearer …` header or an `API_KEY=…` child env var). Keys
    /// are kept so an auditor sees *which* variables/headers are set without the
    /// values. Secret *references* (`api_key_file` / `api_key_env`) are left as-is
    /// — they name where a secret lives, not the secret itself.
    ///
    /// # Errors
    /// A TOML serialization failure (should not happen for a valid `Config`).
    pub fn to_redacted_toml(&self) -> Result<String> {
        let mut redacted = self.clone();
        for server in &mut redacted.mcp_servers {
            for v in server.env.values_mut() {
                *v = Self::REDACTED.to_string();
            }
            for v in server.headers.values_mut() {
                *v = Self::REDACTED.to_string();
            }
        }
        toml::to_string_pretty(&redacted).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Placeholder substituted for redacted secret values in [`Self::to_redacted_toml`].
    pub const REDACTED: &'static str = "<redacted>";

    /// The ordered skill-discovery search path, with `~/` expanded.
    ///
    /// Resolves `[skills].search` when configured; otherwise defaults to the
    /// single host-scoped `~/.newt/skills`. Order is preserved — earlier
    /// directories win on a name collision (see `newt_skills::discover_paths`).
    /// The default falls back to a relative `.newt/skills` only when `$HOME`
    /// can't be resolved, so the list is never empty.
    #[must_use]
    pub fn skill_search_dirs(&self) -> Vec<PathBuf> {
        let configured = self
            .skills
            .as_ref()
            .map(|s| s.search.as_slice())
            .unwrap_or(&[]);
        if configured.is_empty() {
            let default = Self::user_config_dir()
                .map(|dir| dir.join("skills"))
                .unwrap_or_else(|| PathBuf::from(".newt/skills"));
            return vec![default];
        }
        configured.iter().map(|s| expand_tilde(s)).collect()
    }

    /// Serialize this config and write it to `path`, creating parent dirs if needed.
    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
        }
        let text = toml::to_string_pretty(self).map_err(|e| NewtError::Config(e.to_string()))?;
        std::fs::write(path, text).map_err(NewtError::Io)
    }

    /// #904: append `host` to `[tui.permissions] net` in the TOML `text`,
    /// **preserving comments and formatting** — unlike [`Config::save`], which
    /// re-serializes the whole typed struct and drops the user's comments,
    /// ordering, and any keys newt does not model. Creates the
    /// `[tui.permissions]` table and its `net` array if absent; a no-op if the
    /// host is already listed. PURE (no I/O), so it unit-tests without a
    /// filesystem. This is the durable "allow permanently" grant path — it is
    /// only ever driven by an explicit human keypress at the permission prompt.
    pub fn with_net_host(text: &str, host: &str) -> Result<String> {
        let mut doc = text
            .parse::<toml_edit::DocumentMut>()
            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
        let tui = doc
            .as_table_mut()
            .entry("tui")
            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
        let tui_tbl = tui
            .as_table_mut()
            .ok_or_else(|| NewtError::Config("[tui] is not a table".to_string()))?;
        let perms = tui_tbl
            .entry("permissions")
            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
        let perms_tbl = perms
            .as_table_mut()
            .ok_or_else(|| NewtError::Config("[tui.permissions] is not a table".to_string()))?;
        let net =
            perms_tbl
                .entry("net")
                .or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
                    toml_edit::Array::new(),
                )));
        let arr = net.as_array_mut().ok_or_else(|| {
            NewtError::Config("[tui.permissions] net is not an array".to_string())
        })?;
        if !arr.iter().any(|v| v.as_str() == Some(host)) {
            arr.push(host);
        }
        Ok(doc.to_string())
    }

    /// Durably grant a net host by appending it to `[tui.permissions] net` in the
    /// config file at `path`, comment-preserving (see [`Config::with_net_host`]).
    /// A missing file is treated as empty (the table is created). Creates parent
    /// dirs as needed. Used by the interactive gate's "allow permanently" choice.
    pub fn append_permission_net_host(path: &Path, host: &str) -> Result<()> {
        let text = std::fs::read_to_string(path).unwrap_or_default();
        let updated = Self::with_net_host(&text, host)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
        }
        std::fs::write(path, updated).map_err(NewtError::Io)
    }

    /// Build the ordered list of candidate config file paths.
    fn candidate_paths() -> Vec<PathBuf> {
        let mut paths = Vec::new();

        if let Ok(p) = std::env::var("NEWT_CONFIG") {
            paths.push(PathBuf::from(p));
        }

        paths.push(PathBuf::from("./newt.toml"));

        if let Some(path) = Self::user_config_path() {
            paths.push(path);
        }

        paths.push(PathBuf::from("/etc/newt/config.toml"));
        paths
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Best-effort home directory lookup without pulling in the `dirs` crate.
pub(crate) fn home_dir() -> Option<PathBuf> {
    std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .ok()
        .map(PathBuf::from)
}

/// Deep-merge `overlay` into `base`. Tables always merge recursively (overlay
/// keys win on collision). Arrays follow `arrays`: [`ArrayMergeStrategy::Replace`]
/// swaps the base array for the overlay's, [`ArrayMergeStrategy::Append`]
/// concatenates (base entries first). Scalars are always replaced by the
/// overlay. Used to layer a project-local `.newt/config.toml` over the global
/// config. See issue #222.
pub(crate) fn merge_toml(base: &mut toml::Value, overlay: toml::Value, arrays: ArrayMergeStrategy) {
    match (base, overlay) {
        (toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
            for (key, val) in overlay_tbl {
                match base_tbl.get_mut(&key) {
                    Some(existing) => merge_toml(existing, val, arrays),
                    None => {
                        base_tbl.insert(key, val);
                    }
                }
            }
        }
        // Append mode: concatenate two arrays (global entries first).
        (toml::Value::Array(base_arr), toml::Value::Array(overlay_arr))
            if arrays == ArrayMergeStrategy::Append =>
        {
            base_arr.extend(overlay_arr);
        }
        // Replace mode (and any scalar): the overlay replaces the base outright.
        (slot, overlay) => *slot = overlay,
    }
}

/// Determine the array-merge strategy from the raw config values, before they
/// are deserialized. The project config expresses how *it* wants to be merged,
/// so it is consulted first; then the base config; else the built-in default.
fn array_merge_strategy(project: &toml::Value, base: &toml::Value) -> ArrayMergeStrategy {
    read_array_strategy(project)
        .or_else(|| read_array_strategy(base))
        .unwrap_or_default()
}

/// Read `[merge] arrays = "replace" | "append"` from a raw config value.
/// Returns `None` when the key is absent or unrecognized (caller falls back).
fn read_array_strategy(value: &toml::Value) -> Option<ArrayMergeStrategy> {
    match value.get("merge")?.get("arrays")?.as_str()? {
        "append" => Some(ArrayMergeStrategy::Append),
        "replace" => Some(ArrayMergeStrategy::Replace),
        _ => None,
    }
}

/// Walk up from `start` looking for a project-local `.newt/config.toml`,
/// stopping before `home` (so the global `~/.newt/config.toml` is never
/// returned) and at the filesystem root. Returns the innermost match.
///
/// Split out from [`Config::project_config_path`] so it can be unit-tested
/// against temp directories without mutating the process environment.
pub(crate) fn find_project_config_from(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
    let mut dir = Some(start);
    while let Some(current) = dir {
        // Never treat the home directory's `.newt` as a project override.
        if home == Some(current) {
            break;
        }
        let candidate = current.join(".newt").join("config.toml");
        if candidate.is_file() {
            return Some(candidate);
        }
        dir = current.parent();
    }
    None
}

/// Expand a leading `~/` (or a bare `~`) to the home directory. Paths
/// without a leading tilde are returned unchanged.
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
    if let Some(rest) = path.strip_prefix("~/") {
        if let Some(home) = home_dir() {
            return home.join(rest);
        }
    } else if path == "~" {
        if let Some(home) = home_dir() {
            return home;
        }
    }
    PathBuf::from(path)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    // The `permits_*` adaptors live on `CaveatsExt` (post-#95 the
    // upstream `agent-mesh-protocol::Caveats` ships algebra only).
    use crate::caveats::CaveatsExt;
    use std::io::Write;

    // ── input-footer mode ──────────────────────────────────────────────

    #[test]
    fn footer_mode_defaults_to_auto_and_round_trips() {
        // Absent key → Auto (the amphibious default).
        let cfg: TuiConfig = toml::from_str("").unwrap();
        assert_eq!(cfg.footer, FooterMode::Auto);
        // Each variant parses from its snake_case key.
        for (key, want) in [
            ("auto", FooterMode::Auto),
            ("on", FooterMode::On),
            ("off", FooterMode::Off),
        ] {
            let cfg: TuiConfig = toml::from_str(&format!("footer = \"{key}\"")).unwrap();
            assert_eq!(cfg.footer, want, "footer = {key}");
        }
    }

    // ── color / theme mode (issue #527) ─────────────────────────────────

    #[test]
    fn color_mode_defaults_to_auto_and_round_trips() {
        // Absent key → Auto (color on a TTY, none off one).
        let cfg: TuiConfig = toml::from_str("").unwrap();
        assert_eq!(cfg.color, ColorMode::Auto);
        // Every keyword parses from its serde (lowercase) key.
        for (key, want) in [
            ("auto", ColorMode::Auto),
            ("always", ColorMode::Always),
            ("never", ColorMode::Never),
            ("minimal", ColorMode::Minimal),
            ("inverted", ColorMode::Inverted),
            ("dark", ColorMode::Dark),
            ("light", ColorMode::Light),
            ("mono", ColorMode::Mono),
        ] {
            let cfg: TuiConfig = toml::from_str(&format!("color = \"{key}\"")).unwrap();
            assert_eq!(cfg.color, want, "color = {key}");
        }
    }

    #[test]
    fn color_mode_keyword_round_trips_and_aliases_parse() {
        // keyword() is the inverse of from_keyword() for every canonical variant.
        for m in [
            ColorMode::Auto,
            ColorMode::Always,
            ColorMode::Never,
            ColorMode::Minimal,
            ColorMode::Inverted,
            ColorMode::Dark,
            ColorMode::Light,
            ColorMode::Mono,
        ] {
            assert_eq!(ColorMode::from_keyword(m.keyword()), Some(m));
        }
        // Case-insensitive + aliases.
        assert_eq!(ColorMode::from_keyword("ALWAYS"), Some(ColorMode::Always));
        assert_eq!(ColorMode::from_keyword(" on "), Some(ColorMode::Always));
        assert_eq!(ColorMode::from_keyword("off"), Some(ColorMode::Never));
        assert_eq!(ColorMode::from_keyword("monochrome"), Some(ColorMode::Mono));
        // Unknown keyword is rejected (the CLI value_parser surfaces this).
        assert_eq!(ColorMode::from_keyword("rainbow"), None);
    }

    #[test]
    fn color_mode_forced_and_is_mono() {
        // forced(): Some(true) = color on, Some(false) = off, None = defer to TTY.
        assert_eq!(ColorMode::Always.forced(), Some(true));
        assert_eq!(ColorMode::Dark.forced(), Some(true));
        assert_eq!(ColorMode::Light.forced(), Some(true));
        assert_eq!(ColorMode::Inverted.forced(), Some(true));
        assert_eq!(ColorMode::Minimal.forced(), Some(true));
        assert_eq!(ColorMode::Never.forced(), Some(false));
        assert_eq!(ColorMode::Mono.forced(), Some(false));
        assert_eq!(ColorMode::Auto.forced(), None);
        // is_mono distinguishes the ASCII-fallback mode from plain Never.
        assert!(ColorMode::Mono.is_mono());
        assert!(!ColorMode::Never.is_mono());
        assert!(!ColorMode::Auto.is_mono());
    }

    #[test]
    fn markdown_mode_defaults_to_auto_round_trips_and_forces() {
        assert_eq!(MarkdownMode::default(), MarkdownMode::Auto);
        for m in [MarkdownMode::Auto, MarkdownMode::On, MarkdownMode::Off] {
            assert_eq!(MarkdownMode::from_keyword(m.keyword()), Some(m));
        }
        // Case-insensitive + always/never aliases.
        assert_eq!(MarkdownMode::from_keyword("ON"), Some(MarkdownMode::On));
        assert_eq!(
            MarkdownMode::from_keyword(" always "),
            Some(MarkdownMode::On)
        );
        assert_eq!(MarkdownMode::from_keyword("never"), Some(MarkdownMode::Off));
        assert_eq!(MarkdownMode::from_keyword("rainbow"), None);
        // forced(): On = Some(true), Off = Some(false), Auto = defer.
        assert_eq!(MarkdownMode::On.forced(), Some(true));
        assert_eq!(MarkdownMode::Off.forced(), Some(false));
        assert_eq!(MarkdownMode::Auto.forced(), None);
    }

    #[test]
    fn tui_markdown_parses_from_toml_and_defaults_to_auto() {
        let cfg: TuiConfig = toml::from_str("markdown = \"off\"").unwrap();
        assert_eq!(cfg.markdown, MarkdownMode::Off);
        let default: TuiConfig = toml::from_str("").unwrap();
        assert_eq!(default.markdown, MarkdownMode::Auto);
    }

    /// Step 24.10 (#559): summarizer knobs live in `summarizer.toml` now.
    /// Defaults (absent file) reuse the session backend; timeout 60 / retries 1.
    #[test]
    fn backend_kind_embedded_parses_and_labels() {
        // #639: the config accepts `kind = "embedded"` so the summarizer (and a
        // backend) can select the in-process backend.
        #[derive(serde::Deserialize)]
        struct K {
            kind: BackendKind,
        }
        let k: K = toml::from_str("kind = \"embedded\"").unwrap();
        assert_eq!(k.kind, BackendKind::Embedded);
        assert_eq!(k.kind.label(), "embedded");
    }

    #[test]
    fn summarizer_config_defaults_and_parse() {
        let d = SummarizerConfig::default();
        assert_eq!(d.endpoint, None);
        assert_eq!(d.model, None);
        assert_eq!(d.kind, None);
        assert_eq!(d.timeout_secs, 60);
        assert_eq!(d.retries, 1);
        assert_eq!(d.fallback_model, None);

        let cfg = SummarizerConfig::from_toml_str(
            "endpoint = \"http://REDACTED-HOST:11434\"\n\
             model = \"qwen2.5-coder:3b\"\n\
             kind = \"openai\"\n\
             timeout_secs = 45\n\
             retries = 2\n\
             fallback_model = \"nemotron-mini:4b\"\n\
             keep_alive = \"10m\"",
        )
        .unwrap();
        assert_eq!(cfg.endpoint.as_deref(), Some("http://REDACTED-HOST:11434"));
        assert_eq!(cfg.model.as_deref(), Some("qwen2.5-coder:3b"));
        assert_eq!(cfg.kind, Some(BackendKind::Openai));
        assert_eq!(cfg.timeout_secs, 45);
        assert_eq!(cfg.retries, 2);
        assert_eq!(cfg.fallback_model.as_deref(), Some("nemotron-mini:4b"));
        assert_eq!(cfg.keep_alive.as_deref(), Some("10m"));
    }

    /// A partial file fills only the keys present; the rest stay at defaults
    /// (so an `endpoint`-only file reuses the session model but a fast box).
    #[test]
    fn summarizer_config_partial_keeps_defaults() {
        let cfg = SummarizerConfig::from_toml_str("endpoint = \"http://fast.box:11434\"").unwrap();
        assert_eq!(cfg.endpoint.as_deref(), Some("http://fast.box:11434"));
        assert_eq!(cfg.model, None); // reuse session model
        assert_eq!(cfg.timeout_secs, 60); // default
        assert_eq!(cfg.retries, 1); // default
    }

    #[test]
    fn context_manager_keyword_roundtrip_and_availability() {
        for m in [
            ContextManager::Standard,
            ContextManager::Progressive,
            ContextManager::Distributed,
        ] {
            assert_eq!(ContextManager::from_keyword(m.keyword()), Some(m));
        }
        assert_eq!(
            ContextManager::from_keyword("  STANDARD "),
            Some(ContextManager::Standard),
            "case/space-insensitive"
        );
        assert_eq!(ContextManager::from_keyword("nope"), None);
        // Only standard is implemented today; the others are pending #546.
        assert!(ContextManager::Standard.available());
        assert!(!ContextManager::Progressive.available());
        assert!(!ContextManager::Distributed.available());
        assert_eq!(ContextManager::default(), ContextManager::Standard);
    }

    #[test]
    fn context_section_defaults_and_parses() {
        // Absent [context] → None on Config; the resolver falls back to standard.
        let cfg: Config = toml::from_str("").unwrap();
        assert!(cfg.context.is_none());
        let c: ContextConfig = toml::from_str("manager = \"progressive\"").unwrap();
        assert_eq!(c.manager, ContextManager::Progressive);
        assert_eq!(ContextConfig::default().manager, ContextManager::Standard);
    }

    #[test]
    fn scratch_section_defaults_and_parses() {
        // #844: `[scratch] dir` parses onto Config; absent → None (the `.scratch`
        // default applies at resolution). Uses `from_str` (not `resolve`) so this
        // does NOT publish a process-global scratch dir.
        let bare: Config = toml::from_str("").unwrap();
        assert!(bare.scratch.is_none());
        let cfg: Config = toml::from_str("[scratch]\ndir = \"/tmp/newt-scratch\"\n").unwrap();
        assert_eq!(
            cfg.scratch.and_then(|s| s.dir).as_deref(),
            Some("/tmp/newt-scratch")
        );
    }

    #[test]
    fn semantic_config_defaults_and_parses() {
        // Defaults (Step 26.5.4): nomic-embed-text, top_k 5, no decoupled
        // endpoint, and on_embed_failure = disable (the safe default).
        let d = SemanticConfig::default();
        assert_eq!(d.embedding_model, "nomic-embed-text");
        assert_eq!(d.top_k, 5);
        assert_eq!(d.embeddings_endpoint, None);
        assert_eq!(d.embeddings_api, None);
        assert_eq!(d.on_embed_failure, OnEmbedFailure::Disable);
        // #720: the embedded-embedder local model dir defaults to None.
        assert_eq!(d.embedding_model_path, None);
        // `[context.semantic]` parses + overrides, incl. the new fields.
        let c: ContextConfig = toml::from_str(
            "[semantic]\nembedding_model = \"mxbai-embed-large\"\ntop_k = 8\n\
             embedding_model_path = \"/models/bge-small-en-v1.5\"\n\
             embeddings_endpoint = \"http://REDACTED-HOST:11434\"\n\
             embeddings_api = \"ollama\"\non_embed_failure = \"warn\"",
        )
        .unwrap();
        assert_eq!(c.semantic.embedding_model, "mxbai-embed-large");
        assert_eq!(
            c.semantic.embedding_model_path.as_deref(),
            Some("/models/bge-small-en-v1.5")
        );
        assert_eq!(c.semantic.top_k, 8);
        assert_eq!(
            c.semantic.embeddings_endpoint.as_deref(),
            Some("http://REDACTED-HOST:11434")
        );
        assert_eq!(c.semantic.embeddings_api, Some(BackendKind::Ollama));
        assert_eq!(c.semantic.on_embed_failure, OnEmbedFailure::Warn);
        // `embeddings_api = "vllm"` aliases to the OpenAI protocol.
        let v: ContextConfig = toml::from_str("[semantic]\nembeddings_api = \"vllm\"").unwrap();
        assert_eq!(v.semantic.embeddings_api, Some(BackendKind::Openai));
        // an absent [context.semantic] still yields the defaults
        let bare: ContextConfig = toml::from_str("manager = \"standard\"").unwrap();
        assert_eq!(bare.semantic, SemanticConfig::default());
    }

    #[test]
    fn context_feature_keyword_alias_availability_and_issue() {
        // canonical keyword round-trips
        for f in ContextFeature::ALL {
            assert_eq!(ContextFeature::from_keyword(f.keyword()), Some(f));
        }
        // aliases + hyphen/underscore/case
        assert_eq!(
            ContextFeature::from_keyword("TOOL-OFFLOAD"),
            Some(ContextFeature::ToolOffload)
        );
        assert_eq!(
            ContextFeature::from_keyword("offload"),
            Some(ContextFeature::ToolOffload)
        );
        assert_eq!(
            ContextFeature::from_keyword(" state "),
            Some(ContextFeature::Scratchpad)
        );
        assert_eq!(ContextFeature::from_keyword("nope"), None);
        // tool_offload (26.3), scratchpad (26.4), semantic (26.5), experiential
        // (26.6a), scheduled (26.6b) shipped; only provenance is still pending.
        assert!(ContextFeature::ToolOffload.available());
        assert!(ContextFeature::Scratchpad.available());
        assert!(ContextFeature::Semantic.available());
        assert!(ContextFeature::Experiential.available());
        assert!(ContextFeature::Scheduled.available());
        assert!(
            !ContextFeature::Provenance.available(),
            "provenance still pending"
        );
        assert!(ContextFeature::ALL
            .iter()
            .filter(|f| !matches!(f, ContextFeature::Provenance))
            .all(|f| f.available()));
        // issues route to the right tracking ticket
        assert_eq!(ContextFeature::Semantic.issue(), 582);
        assert_eq!(ContextFeature::Scratchpad.issue(), 583);
        assert_eq!(ContextFeature::ToolOffload.issue(), 584);
        assert_eq!(ContextFeature::Provenance.issue(), 584);
        assert_eq!(ContextFeature::Experiential.issue(), 585);
        assert_eq!(ContextFeature::Scheduled.issue(), 586);
    }

    #[test]
    fn context_features_override_layering_and_parse() {
        use ContextFeature as F;
        // Every preset resolves to all-off today (standard behavior).
        let base = ContextManager::Standard.base_features();
        assert!(base.enabled().is_empty());
        // An override layers on top of the base, leaving others untouched.
        let mut ov = ContextFeatures::default();
        ov.set(F::Scratchpad, Some(true));
        let resolved = ov.apply_to(base);
        assert!(resolved.get(F::Scratchpad));
        assert!(!resolved.get(F::Semantic));
        assert_eq!(resolved.enabled(), vec![F::Scratchpad]);
        // None override = inherit (no change); Some(false) = force off.
        let mut ov2 = ContextFeatures::default();
        ov2.set(F::Scratchpad, Some(false));
        assert!(!ov2.apply_to(resolved).get(F::Scratchpad));
        // [context.features] parses keyed by canonical keyword.
        let c: ContextConfig = toml::from_str(
            "manager = \"standard\"\n[features]\nsemantic = true\nscratchpad = false",
        )
        .unwrap();
        assert_eq!(c.features.get(F::Semantic), Some(true));
        assert_eq!(c.features.get(F::Scratchpad), Some(false));
        assert_eq!(c.features.get(F::ToolOffload), None);
    }

    #[test]
    fn base_for_defaults_tool_offload_on_and_local_assist_on_for_ollama() {
        use ContextFeature as F;
        // #945: tool offload is local spill storage and defaults ON for every
        // backend. Step 27.4: local (Ollama) backends additionally default
        // scratchpad + scheduled ON; semantic also defaults ON but degrades to a
        // one-shot no-op until an embedder is configured.
        let local = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Ollama);
        assert!(local.get(F::ToolOffload));
        assert!(local.get(F::Scratchpad));
        assert!(local.get(F::Semantic));
        assert!(local.get(F::Scheduled));
        // Cloud (OpenAI-compatible) gets offload, but not local-assist features.
        let cloud = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Openai);
        assert!(cloud.get(F::ToolOffload));
        assert!(!cloud.get(F::Scratchpad));
        assert!(!cloud.get(F::Semantic));
        assert!(!cloud.get(F::Scheduled));
        // An explicit override still wins over the local default (force off).
        let mut ov = ContextFeatures::default();
        ov.set(F::Scheduled, Some(false));
        ov.set(F::ToolOffload, Some(false));
        assert!(!ov.apply_to(local).get(F::Scheduled));
        assert!(!ov.apply_to(local).get(F::ToolOffload));
        assert!(ov.apply_to(local).get(F::Scratchpad)); // untouched feature stays on
    }

    #[test]
    fn allow_bang_escape_defaults_to_true_and_round_trips() {
        // Absent key → enabled (the human's host shell-out is on by default).
        let cfg: TuiConfig = toml::from_str("").unwrap();
        assert!(cfg.allow_bang_escape);
        // Explicit opt-out parses.
        let cfg: TuiConfig = toml::from_str("allow_bang_escape = false").unwrap();
        assert!(!cfg.allow_bang_escape);
    }

    #[test]
    fn thinking_mode_defaults_to_stream_and_round_trips() {
        let cfg: TuiConfig = toml::from_str("").unwrap();
        assert_eq!(cfg.thinking, ThinkingMode::Stream);
        let cfg: TuiConfig = toml::from_str("thinking = \"off\"").unwrap();
        assert_eq!(cfg.thinking, ThinkingMode::Off);
        let cfg: TuiConfig = toml::from_str("thinking = \"stream\"").unwrap();
        assert_eq!(cfg.thinking, ThinkingMode::Stream);
    }

    // ── profile composition (technique library) ────────────────────────

    #[test]
    fn profile_parses_techniques_and_knobs() {
        let cfg: Config = toml::from_str(
            r#"
            [profiles.nemotron]
            techniques = ["knowledge_base", "verify_gate", "retry"]

            [profiles.nemotron.verify_gate]
            surface_match = "exact"

            [profiles.nemotron.retry]
            max_retries = 3
            "#,
        )
        .unwrap();
        let p = &cfg.profiles["nemotron"];
        assert!(p.validate().is_ok());
        assert!(p.enables("verify_gate") && p.enables("retry"));
        assert_eq!(
            p.verify_gate_knobs().surface_match,
            crate::verify_gate::SurfaceMatch::Exact
        );
        assert_eq!(p.retry_knobs().max_retries, 3);
    }

    #[test]
    fn profile_knobs_default_when_unset() {
        // techniques named but no knob tables → defaults apply
        let p: ProfileConfig = toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
        assert_eq!(
            p.verify_gate_knobs().surface_match,
            crate::verify_gate::SurfaceMatch::Exact // the complete-gate default
        );
        assert_eq!(p.retry_knobs().max_retries, 2);
    }

    #[test]
    fn profile_rejects_unknown_technique() {
        let p: ProfileConfig =
            toml::from_str("techniques = [\"knowledge_base\", \"teleport\"]").unwrap();
        let err = p.validate().unwrap_err();
        assert!(err.contains("teleport"), "err: {err}");
    }

    #[test]
    fn profile_rejects_unmet_presupposition() {
        // retry presupposes verify_gate — listing retry alone is now a load-time error.
        let p: ProfileConfig = toml::from_str("techniques = [\"retry\"]").unwrap();
        let err = p.validate().unwrap_err();
        assert!(
            err.contains("retry") && err.contains("verify_gate") && err.contains("presupposes"),
            "err: {err}"
        );
        // …and adding verify_gate satisfies it.
        let ok: ProfileConfig =
            toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
        assert!(ok.validate().is_ok());
    }

    #[test]
    fn registry_does_not_alter_the_resolved_technique_set() {
        // Golden: validate() accepts the nemotron set and the resolved order/membership
        // is byte-identical to the input — the registry adds checks, not behavior.
        let p: ProfileConfig =
            toml::from_str("techniques = [\"knowledge_base\", \"verify_gate\", \"retry\"]")
                .unwrap();
        assert!(p.validate().is_ok());
        assert_eq!(p.techniques, vec!["knowledge_base", "verify_gate", "retry"]);
        for t in ["knowledge_base", "verify_gate", "retry"] {
            assert!(p.enables(t));
        }
    }

    #[test]
    fn empty_profiles_is_the_default() {
        // no [profiles] table → empty map, behavior unchanged
        let cfg: Config = toml::from_str("").unwrap();
        assert!(cfg.profiles.is_empty());
        assert!(cfg.bundles.is_empty());
    }

    // ── bundles (the loadable kit unit) ────────────────────────────────

    fn bundle_cfg() -> Config {
        toml::from_str(
            r#"
            [profiles.nemotron]
            techniques = ["knowledge_base", "verify_gate", "retry"]
            [profiles.qwen-coder]
            techniques = []

            [bundles.nemotron]
            about = "nemotron family support"
            applies_to = ["nemotron"]
            default_profile = "nemotron"
            families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }

            [bundles.review-heavy]              # use-case bundle: no applies_to
            default_profile = "nemotron"
            "#,
        )
        .unwrap()
    }

    #[test]
    fn resolve_bundle_errors_on_unknown() {
        let cfg = bundle_cfg();
        assert!(cfg.resolve_bundle("nemotron").is_ok());
        let err = cfg.resolve_bundle("ghost").unwrap_err();
        assert!(err.contains("no such bundle"), "{err}");
    }

    #[test]
    fn bundle_profile_for_model_longest_prefix_then_default() {
        let cfg = bundle_cfg();
        let b = cfg.resolve_bundle("nemotron").unwrap();
        // family-prefix match
        assert_eq!(
            cfg.bundle_profile_for_model(b, "nemotron3:33b"),
            Some("nemotron")
        );
        assert_eq!(
            cfg.bundle_profile_for_model(b, "qwen2.5-coder"),
            Some("qwen-coder")
        );
        // no family match → default_profile
        assert_eq!(
            cfg.bundle_profile_for_model(b, "llama3.1:8b"),
            Some("nemotron")
        );
    }

    #[test]
    fn infer_bundle_only_from_applies_to() {
        let cfg = bundle_cfg();
        // nemotron model → the nemotron bundle (applies_to match)
        assert_eq!(
            cfg.infer_bundle("nemotron3:33b").map(|(n, _)| n),
            Some("nemotron")
        );
        // a model no applies_to matches → no inference (the use-case bundle is never inferred)
        assert!(cfg.infer_bundle("gpt-4.1").is_none());
    }

    #[test]
    fn pick_active_profile_precedence() {
        let cfg = bundle_cfg();
        // 1. explicit --profile wins over everything
        let p = cfg
            .pick_active_profile(Some("qwen-coder"), Some("nemotron"), "nemotron3:33b")
            .unwrap()
            .unwrap();
        assert_eq!(p.name, "qwen-coder");
        assert_eq!(p.via, PickVia::Profile);
        // 2. --bundle resolves to its profile for the model
        let p = cfg
            .pick_active_profile(None, Some("nemotron"), "nemotron3:33b")
            .unwrap()
            .unwrap();
        assert_eq!(
            (p.name.as_str(), p.via),
            ("nemotron", PickVia::Bundle("nemotron".into()))
        );
        // 3. inferred from the model when neither flag is set
        let p = cfg
            .pick_active_profile(None, None, "nemotron3:33b")
            .unwrap()
            .unwrap();
        assert_eq!(p.via, PickVia::InferredBundle("nemotron".into()));
        // 4. nothing matches → None (today's behavior)
        assert!(cfg
            .pick_active_profile(None, None, "gpt-4.1")
            .unwrap()
            .is_none());
        // an unknown explicit bundle is a hard error
        assert!(cfg.pick_active_profile(None, Some("ghost"), "x").is_err());
    }

    // ── loadouts (the top-level composition; inert until Slice 1) ───────

    #[test]
    fn loadout_parses_inline_and_validates_references() {
        let cfg: Config = toml::from_str(
            r#"
            [[backends]]
            name = "dgx"
            endpoint = "http://dgx.local:11434"
            model = "nemotron-3:33b"
            tiers = []

            [profiles.nemotron]
            techniques = ["knowledge_base", "verify_gate", "retry"]
            [bundles.nemotron]
            default_profile = "nemotron"

            [loadouts.dev-nemotron]
            provider = "dgx"
            model    = "nemotron@deep"
            kit      = "nemotron"
            profile  = "nemotron"
            role     = "python-developer"
            [loadouts.dev-nemotron.settings]
            num_ctx = 24576
            framing = "Ship small, verify."
            "#,
        )
        .unwrap();
        let l = &cfg.loadouts["dev-nemotron"];
        assert_eq!(l.provider.as_deref(), Some("dgx"));
        assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
        assert_eq!(l.role.as_deref(), Some("python-developer"));
        assert_eq!(l.settings.as_ref().unwrap().num_ctx, Some(24576));
        // references resolve
        assert!(l.validate(&cfg).is_ok());
    }

    #[test]
    fn loadout_rejects_dangling_references() {
        let cfg: Config = toml::from_str(
            r#"
            [[backends]]
            name = "real-box"
            endpoint = "http://h:11434"
            model = "m"

            [profiles.nemotron]
            techniques = ["verify_gate"]
            "#,
        )
        .unwrap();
        // dangling kit
        let bad_kit = Loadout {
            kit: Some("ghost-bundle".into()),
            ..Default::default()
        };
        let e = bad_kit.validate(&cfg).unwrap_err();
        assert!(
            e.contains("kit 'ghost-bundle'") && e.contains("no such bundle"),
            "{e}"
        );
        // dangling profile
        let bad_profile = Loadout {
            profile: Some("ghost-profile".into()),
            ..Default::default()
        };
        let e = bad_profile.validate(&cfg).unwrap_err();
        assert!(
            e.contains("profile 'ghost-profile'") && e.contains("no such profile"),
            "{e}"
        );
        // dangling provider — must name a [backends] entry (Slice 2). The error
        // lists the known backends, here the explicit `real-box`.
        let bad_provider = Loadout {
            provider: Some("ghost-provider".into()),
            ..Default::default()
        };
        let e = bad_provider.validate(&cfg).unwrap_err();
        assert!(
            e.contains("provider 'ghost-provider'")
                && e.contains("no [backends] entry")
                && e.contains("real-box"),
            "{e}"
        );
        // an empty loadout is valid (no references)
        assert!(Loadout::default().validate(&cfg).is_ok());
    }

    #[test]
    fn disk_bundles_load_per_file_by_stem() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("nemotron.toml"),
            "applies_to = [\"nemotron\"]\ndefault_profile = \"nemotron\"\n",
        )
        .unwrap();
        // a malformed drop-in must be skipped, not break loading
        std::fs::write(
            dir.path().join("broken.toml"),
            "applies_to = \"not-a-list\"\n",
        )
        .unwrap();
        // a non-toml file is ignored
        std::fs::write(dir.path().join("README.md"), "not a bundle").unwrap();

        let mut cfg = Config::default();
        cfg.merge_bundles_from_dir(dir.path());
        assert_eq!(cfg.bundles.len(), 1, "only the valid .toml loads");
        let b = cfg
            .bundles
            .get("nemotron")
            .expect("loaded by filename stem");
        assert_eq!(b.applies_to, vec!["nemotron"]);
        assert_eq!(b.default_profile.as_deref(), Some("nemotron"));
        // a disk file overrides an inline bundle of the same name (last-wins)
        cfg.bundles.insert("x".into(), BundleConfig::default());
        std::fs::write(dir.path().join("x.toml"), "about = \"from disk\"\n").unwrap();
        cfg.merge_bundles_from_dir(dir.path());
        assert_eq!(cfg.bundles["x"].about.as_deref(), Some("from disk"));
    }

    #[test]
    fn disk_loadouts_load_per_file_by_stem() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("dev-nemotron.toml"),
            "provider = \"dgx\"\nmodel = \"nemotron@deep\"\nkit = \"nemotron\"\n",
        )
        .unwrap();
        // a malformed drop-in must be skipped, not break loading
        std::fs::write(
            dir.path().join("broken.toml"),
            "provider = [\"not-a-string\"]\n",
        )
        .unwrap();
        // a non-toml file is ignored
        std::fs::write(dir.path().join("README.md"), "not a loadout").unwrap();

        let mut cfg = Config::default();
        cfg.merge_loadouts_from_dir(dir.path());
        assert_eq!(cfg.loadouts.len(), 1, "only the valid .toml loads");
        let l = cfg
            .loadouts
            .get("dev-nemotron")
            .expect("loaded by filename stem");
        assert_eq!(l.provider.as_deref(), Some("dgx"));
        assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
        assert_eq!(l.kit.as_deref(), Some("nemotron"));
        // a disk file overrides an inline loadout of the same name (last-wins)
        cfg.loadouts.insert("x".into(), Loadout::default());
        std::fs::write(dir.path().join("x.toml"), "role = \"from-disk\"\n").unwrap();
        cfg.merge_loadouts_from_dir(dir.path());
        assert_eq!(cfg.loadouts["x"].role.as_deref(), Some("from-disk"));
    }

    #[test]
    fn crew_parses_inline_and_validates_role_references() {
        let cfg: Config = toml::from_str(
            r#"
            [[backends]]
            name = "dgx"
            endpoint = "http://dgx.local:11434"
            model = "qwen3-coder:30b"
            tiers = []
            [[backends]]
            name = "gnuc"
            endpoint = "http://localhost:11434"
            model = "qwen2.5-coder:3b"
            tiers = []

            [loadouts.planner]
            provider = "dgx"
            [loadouts.navigator]
            provider = "dgx"
            [loadouts.triage]
            provider = "gnuc"

            [crews.coder]
            planner = "planner"
            navigator = "navigator"
            triage = "triage"
            loop = "patch-revise"
            [crews.coder.budgets]
            max_attempts = 4
            require_human_review_on = ["auth", "crypto"]
            "#,
        )
        .unwrap();
        let c = &cfg.crews["coder"];
        assert_eq!(c.planner, "planner");
        assert_eq!(c.navigator.as_deref(), Some("navigator"));
        assert_eq!(c.loop_program.as_deref(), Some("patch-revise"));
        assert_eq!(c.budgets.as_ref().unwrap().max_attempts, Some(4));
        // each role names a known loadout, and each loadout validates
        assert!(c.validate(&cfg).is_ok());
    }

    #[test]
    fn crew_rejects_dangling_and_invalid_roles() {
        let cfg: Config = toml::from_str(
            r#"
            [[backends]]
            name = "dgx"
            endpoint = "http://dgx.local:11434"
            model = "m"
            tiers = []
            [loadouts.planner]
            provider = "dgx"
            "#,
        )
        .unwrap();
        // dangling role: triage names no loadout
        let dangling = Crew {
            planner: "planner".into(),
            triage: Some("ghost".into()),
            ..Default::default()
        };
        let e = dangling.validate(&cfg).unwrap_err();
        assert!(e.contains("triage 'ghost'"), "{e}");
        assert!(e.contains("no [loadouts]"), "{e}");
        // transitive: a role's loadout has a dangling provider
        let mut cfg2 = cfg.clone();
        cfg2.loadouts.insert(
            "bad".into(),
            Loadout {
                provider: Some("nope".into()),
                ..Default::default()
            },
        );
        let transitive = Crew {
            planner: "bad".into(),
            ..Default::default()
        };
        let e = transitive.validate(&cfg2).unwrap_err();
        assert!(
            e.contains("planner 'bad'") && e.contains("provider 'nope'"),
            "{e}"
        );
    }

    #[test]
    fn disk_crews_load_per_file_by_stem() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("coder.toml"),
            "planner = \"planner\"\nnavigator = \"navigator\"\n",
        )
        .unwrap();
        // malformed (missing required `planner`) is skipped, not fatal
        std::fs::write(dir.path().join("broken.toml"), "navigator = \"x\"\n").unwrap();
        std::fs::write(dir.path().join("README.md"), "not a crew").unwrap();

        let mut cfg = Config::default();
        cfg.merge_crews_from_dir(dir.path());
        assert_eq!(cfg.crews.len(), 1, "only the valid .toml loads");
        let c = cfg.crews.get("coder").expect("loaded by filename stem");
        assert_eq!(c.planner, "planner");
        // disk overrides inline of the same name (last-wins)
        cfg.crews.insert(
            "coder".into(),
            Crew {
                planner: "inline".into(),
                ..Default::default()
            },
        );
        cfg.merge_crews_from_dir(dir.path());
        assert_eq!(cfg.crews["coder"].planner, "planner", "disk wins");
    }

    #[test]
    fn backend_api_axis_defaults_and_parses() {
        // Absent → chat/completions (back-compat).
        let def: BackendConfig =
            toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\nkind=\"openai\"\n").unwrap();
        assert_eq!(def.api, OpenAiApi::ChatCompletions);
        // Explicit responses opt-in.
        let resp: BackendConfig = toml::from_str(
            "endpoint=\"http://h:1\"\nmodel=\"gpt-5-codex\"\nkind=\"openai\"\napi=\"responses\"\n",
        )
        .unwrap();
        assert_eq!(resp.api, OpenAiApi::Responses);
        // `chat` is an accepted alias for the default.
        let alias: BackendConfig =
            toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\napi=\"chat\"\n").unwrap();
        assert_eq!(alias.api, OpenAiApi::ChatCompletions);
    }

    #[test]
    fn disk_backends_load_per_file_by_stem_and_override_inline() {
        let dir = tempfile::tempdir().unwrap();
        // A minimal drop-in: name omitted (filename is authoritative), tiers
        // omitted (defaults empty), kind omitted (defaults ollama).
        std::fs::write(
            dir.path().join("dgx1.toml"),
            "endpoint = \"http://REDACTED-HOST:11434\"\nmodel = \"qwen3:30b\"\n",
        )
        .unwrap();
        // Malformed (missing required `endpoint`) is skipped, not fatal.
        std::fs::write(dir.path().join("broken.toml"), "model = \"x\"\n").unwrap();
        std::fs::write(dir.path().join("README.md"), "not a backend").unwrap();

        let mut cfg = Config {
            // An inline backend of the same name that the drop-in should replace,
            // plus an unrelated one that must survive untouched.
            backends: vec![
                BackendConfig {
                    name: "dgx1".into(),
                    endpoint: "http://stale:11434".into(),
                    model: "old-model".into(),
                    model_path: None,
                    tiers: vec![],
                    kind: BackendKind::Ollama,
                    api: Default::default(),
                    api_key_file: None,
                    api_key_env: None,
                },
                BackendConfig {
                    name: "gnuc".into(),
                    endpoint: "http://gnuc:11434".into(),
                    model: "qwen2.5-coder:14b".into(),
                    model_path: None,
                    tiers: vec![],
                    kind: BackendKind::Ollama,
                    api: Default::default(),
                    api_key_file: None,
                    api_key_env: None,
                },
            ],
            ..Default::default()
        };
        cfg.merge_backends_from_dir(dir.path());

        // The drop-in replaced the inline dgx1 in place (no duplicate), gnuc kept.
        assert_eq!(cfg.backends.len(), 2, "only the valid .toml loads, no dup");
        let dgx1 = cfg.backends.iter().find(|b| b.name == "dgx1").unwrap();
        assert_eq!(dgx1.endpoint, "http://REDACTED-HOST:11434", "disk wins");
        assert_eq!(dgx1.model, "qwen3:30b");
        assert_eq!(dgx1.kind, BackendKind::Ollama, "kind defaults to ollama");
        assert!(cfg.backends.iter().any(|b| b.name == "gnuc"), "gnuc kept");
    }

    #[test]
    fn disk_dgx_nodes_load_per_file_by_stem_and_override_inline() {
        let dir = tempfile::tempdir().unwrap();
        // A minimal drop-in: name omitted (filename is authoritative), carries
        // the multi-endpoint info a [[backends]] entry can't (vllm + ssh_host).
        std::fs::write(
            dir.path().join("dgx1.toml"),
            "ollama = \"http://REDACTED-HOST:11434\"\n\
             vllm = \"http://REDACTED-HOST:8000\"\n\
             ssh_host = \"REDACTED-HOST\"\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("README.md"), "not a node").unwrap();

        // [dgx] absent → created on first drop-in, with the node populated.
        let mut cfg = Config::default();
        assert!(cfg.dgx.is_none());
        cfg.merge_dgx_nodes_from_dir(dir.path());
        let dgx = cfg.dgx.as_ref().expect("[dgx] created from drop-ins");
        assert_eq!(dgx.nodes.len(), 1);
        let node = &dgx.nodes[0];
        assert_eq!(node.name, "dgx1", "name comes from the filename stem");
        assert_eq!(node.ollama.as_deref(), Some("http://REDACTED-HOST:11434"));
        assert_eq!(node.vllm.as_deref(), Some("http://REDACTED-HOST:8000"));
        assert_eq!(node.ssh_host.as_deref(), Some("REDACTED-HOST"));
        // A single node resolves as active without an explicit active_node.
        assert_eq!(dgx.active_node().unwrap().name, "dgx1");

        // Disk replaces an inline node of the same name in place (no duplicate).
        cfg.dgx.as_mut().unwrap().nodes[0].ollama = Some("http://stale:1".into());
        cfg.merge_dgx_nodes_from_dir(dir.path());
        assert_eq!(cfg.dgx.as_ref().unwrap().nodes.len(), 1, "no duplicate");
        assert_eq!(
            cfg.dgx.unwrap().nodes[0].ollama.as_deref(),
            Some("http://REDACTED-HOST:11434"),
            "disk wins"
        );
    }

    #[test]
    fn backendless_config_deserializes_empty_but_default_keeps_fallback() {
        // A config.toml with no [[backends]] must NOT inherit the struct-default
        // localhost Ollama — otherwise a drop-in-only setup gets a spurious
        // 'ollama' entry alongside its real backends (the migration regression).
        let cfg: Config = toml::from_str("providers = []\n").unwrap();
        assert!(
            cfg.backends.is_empty(),
            "absent [[backends]] deserializes to empty, got {:?}",
            cfg.backends
        );
        // But the no-config-file path (Config::default) keeps the fallback.
        assert_eq!(Config::default().backends.len(), 1);
        assert_eq!(Config::default().backends[0].name, "ollama");
        // Inline backends still load normally.
        let inline: Config =
            toml::from_str("[[backends]]\nname=\"x\"\nendpoint=\"http://h:1\"\nmodel=\"m\"\n")
                .unwrap();
        assert_eq!(inline.backends.len(), 1);
        assert_eq!(inline.backends[0].name, "x");
    }

    #[test]
    fn surface_match_round_trips_lowercase() {
        let k: VerifyGateKnobs = toml::from_str("surface_match = \"prefix\"").unwrap();
        assert_eq!(k.surface_match, crate::verify_gate::SurfaceMatch::Prefix);
    }

    #[test]
    fn resolve_profile_looks_up_validates_and_errors() {
        let cfg: Config = toml::from_str(
            r#"
            [profiles.nemotron]
            techniques = ["verify_gate"]
            [profiles.bad]
            techniques = ["teleport"]
            "#,
        )
        .unwrap();
        // known + valid → the profile
        assert!(cfg
            .resolve_profile("nemotron")
            .unwrap()
            .enables("verify_gate"));
        // known name but invalid technique → validation error
        assert!(cfg.resolve_profile("bad").unwrap_err().contains("teleport"));
        // unknown name → no-such-profile error, listing the known ones
        let err = cfg.resolve_profile("ghost").unwrap_err();
        assert!(
            err.contains("no such profile") && err.contains("nemotron"),
            "err: {err}"
        );
    }

    #[test]
    fn memory_note_nudge_interval_defaults_and_parses() {
        // Default: 10 — via Default and when `[memory]` omits the key.
        assert_eq!(MemoryConfig::default().note_nudge_interval, 10);
        let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
        assert_eq!(cfg.note_nudge_interval, 10);
        // 0 = nudge off.
        let cfg: MemoryConfig = toml::from_str("note_nudge_interval = 0").unwrap();
        assert_eq!(cfg.note_nudge_interval, 0);
    }

    #[test]
    fn memory_extract_notes_on_close_defaults_off_and_parses() {
        // Default OFF (Step 19.4, #248): the close-time extraction pass is
        // optional and costs a completion — nobody pays for it unasked.
        assert!(!MemoryConfig::default().extract_notes_on_close);
        let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
        assert!(!cfg.extract_notes_on_close);
        // `[memory] extract_notes_on_close = true` is the opt-in.
        let cfg: MemoryConfig = toml::from_str("extract_notes_on_close = true").unwrap();
        assert!(cfg.extract_notes_on_close);
    }

    #[test]
    fn memory_disclosure_defaults_to_frozen_and_parses_index() {
        // INERT BY DEFAULT (#319): the disclosure facet defaults to Frozen —
        // today's behavior, the memory_fetch tool unwired — and only `index`
        // opts in to progressive disclosure.
        assert_eq!(MemoryConfig::default().disclosure, MemoryDisclosure::Frozen);
        let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
        assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
        let cfg: MemoryConfig = toml::from_str("disclosure = \"index\"").unwrap();
        assert_eq!(cfg.disclosure, MemoryDisclosure::Index);
        let cfg: MemoryConfig = toml::from_str("disclosure = \"frozen\"").unwrap();
        assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
    }

    #[test]
    fn skill_search_dirs_defaults_to_single_newt_dir() {
        let cfg = Config::default();
        let dirs = cfg.skill_search_dirs();
        assert_eq!(dirs.len(), 1);
        assert!(dirs[0].ends_with("skills"));
        // The parent component is `.newt`.
        assert_eq!(
            dirs[0].parent().and_then(|p| p.file_name()),
            Some(".newt".as_ref())
        );
    }

    #[test]
    fn skill_search_dirs_preserves_configured_order() {
        let cfg = Config {
            skills: Some(SkillsConfig {
                search: vec!["/abs/one".into(), "/abs/two".into()],
            }),
            ..Config::default()
        };
        assert_eq!(
            cfg.skill_search_dirs(),
            vec![PathBuf::from("/abs/one"), PathBuf::from("/abs/two")]
        );
    }

    #[test]
    fn skill_search_dirs_expands_tilde() {
        let cfg = Config {
            skills: Some(SkillsConfig {
                search: vec!["~/skills-x".into()],
            }),
            ..Config::default()
        };
        let dirs = cfg.skill_search_dirs();
        // The final component survives expansion regardless of whether $HOME
        // was set; when set, the leading `~` must be gone.
        assert!(dirs[0].ends_with("skills-x"));
        assert!(!dirs[0].starts_with("~"));
    }

    #[test]
    fn skills_search_round_trips_through_toml() {
        let cfg = Config {
            skills: Some(SkillsConfig {
                search: vec!["~/.newt/skills".into(), "~/.claude/skills".into()],
            }),
            ..Config::default()
        };
        let text = toml::to_string_pretty(&cfg).unwrap();
        let back: Config = toml::from_str(&text).unwrap();
        assert_eq!(
            back.skills.unwrap().search,
            vec!["~/.newt/skills".to_string(), "~/.claude/skills".to_string()]
        );
    }
    use tempfile::NamedTempFile;

    #[test]
    fn defaults_are_sensible() {
        let cfg = Config::default();
        assert_eq!(cfg.backends.len(), 1);
        assert_eq!(cfg.providers.len(), 0);
        assert_eq!(cfg.default_tier_order.len(), 4);
    }

    #[test]
    fn conversations_config_defaults_to_count_cap() {
        let cfg = Config::default();
        let conversations = cfg.conversations.unwrap_or_default();
        assert_eq!(conversations.max_per_workspace, 100);
        // 17.7: auto-resume defaults ON; `resume = false` is the off-switch.
        assert!(conversations.resume);
    }

    #[test]
    fn conversations_config_roundtrips_through_toml() {
        let cfg: Config = toml::from_str(
            r#"
[conversations]
max_per_workspace = 25
"#,
        )
        .unwrap();

        let conversations = cfg.conversations.unwrap_or_default();
        assert_eq!(conversations.max_per_workspace, 25);
        // Partial [conversations] table: unset keys keep their defaults.
        assert!(conversations.resume);
    }

    #[test]
    fn conversations_resume_off_switch_parses() {
        let cfg: Config = toml::from_str(
            r#"
[conversations]
resume = false
"#,
        )
        .unwrap();

        assert!(!cfg.conversations.unwrap_or_default().resume);
    }

    #[test]
    fn agents_config_default_enabled() {
        let cfg = AgentsConfig::default();
        assert!(cfg.enabled);
        assert_eq!(cfg.path, None);
        // A bare Config defaults agents to enabled too.
        assert!(Config::default().agents.enabled);
    }

    #[test]
    fn agents_config_roundtrips_with_path() {
        let cfg: Config = toml::from_str(
            r#"
[agents]
path = "docs/instructions"
"#,
        )
        .unwrap();
        assert!(cfg.agents.enabled);
        assert_eq!(cfg.agents.path.as_deref(), Some("docs/instructions"));

        // Serialize back out and confirm the path survives.
        let text = toml::to_string(&cfg).unwrap();
        assert!(text.contains("docs/instructions"));
    }

    #[test]
    fn agents_config_can_be_disabled() {
        let cfg: Config = toml::from_str(
            r#"
[agents]
enabled = false
"#,
        )
        .unwrap();
        assert!(!cfg.agents.enabled);
        assert_eq!(cfg.agents.path, None);
    }

    #[test]
    fn load_happy_path() {
        let toml_text = r#"
[[backends]]
name = "local-ollama"
endpoint = "http://localhost:11434"
model = "mistral:7b"
tiers = ["FAST", "STANDARD"]

[[providers]]
name = "cloud"
command = "newt-cloud-shim"
model = "gpt-4.1-mini"
env_pass = ["CLOUD_TOKEN"]
tiers = ["COMPLEX", "REVIEW"]

default_tier_order = ["FAST", "STANDARD", "COMPLEX", "REVIEW"]
"#;
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(toml_text.as_bytes()).unwrap();
        f.flush().unwrap();

        let cfg = Config::load(f.path()).unwrap();
        assert_eq!(cfg.backends.len(), 1);
        assert_eq!(cfg.backends[0].name, "local-ollama");
        assert_eq!(cfg.backends[0].model, "mistral:7b");
        assert_eq!(cfg.backends[0].tiers, vec![Tier::Fast, Tier::Standard]);
        assert_eq!(cfg.providers.len(), 1);
        assert_eq!(cfg.providers[0].name, "cloud");
        assert_eq!(cfg.providers[0].model.as_deref(), Some("gpt-4.1-mini"));
        assert_eq!(cfg.providers[0].env_pass, vec!["CLOUD_TOKEN".to_string()]);
    }

    #[test]
    fn provider_model_is_optional_for_legacy_configs() {
        let cfg: Config = toml::from_str(
            r#"
[[providers]]
name = "legacy-cloud"
command = "newt-cloud-shim"
env_pass = ["CLOUD_TOKEN"]
tiers = ["COMPLEX"]
"#,
        )
        .unwrap();

        assert_eq!(cfg.providers.len(), 1);
        assert_eq!(cfg.providers[0].model, None);
    }

    #[test]
    fn missing_file_returns_io_error() {
        let result = Config::load(Path::new("/tmp/newt-does-not-exist-12345.toml"));
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            matches!(err, NewtError::Io(_)),
            "expected Io error, got: {err:?}"
        );
    }

    #[test]
    fn malformed_toml_returns_config_error() {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(b"{{{{").unwrap();
        f.flush().unwrap();

        let result = Config::load(f.path());
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            matches!(err, NewtError::Config(_)),
            "expected Config error, got: {err:?}"
        );
    }

    #[test]
    fn resolve_returns_default_when_no_file() {
        // Use a temp dir as cwd and clear env to ensure no candidates match.
        let dir = tempfile::tempdir().unwrap();

        // Save & clear environment to isolate the test.
        let saved_config = std::env::var("NEWT_CONFIG").ok();
        let saved_home = std::env::var("HOME").ok();
        std::env::remove_var("NEWT_CONFIG");
        std::env::set_var("HOME", dir.path());

        // Run resolve from inside the temp dir so ./newt.toml won't exist.
        let prev_dir = std::env::current_dir().unwrap();
        std::env::set_current_dir(dir.path()).unwrap();

        let cfg = Config::resolve().unwrap();

        // Restore environment.
        std::env::set_current_dir(prev_dir).unwrap();
        if let Some(v) = saved_home {
            std::env::set_var("HOME", v);
        }
        if let Some(v) = saved_config {
            std::env::set_var("NEWT_CONFIG", v);
        }

        assert_eq!(cfg.backends.len(), 1);
        assert_eq!(cfg.backends[0].name, "ollama");
    }

    // --- Project-local `.newt/config.toml` layering (issue #222) ---

    #[test]
    fn merge_toml_recurses_tables_and_replaces_scalars() {
        let mut base: toml::Value = toml::from_str(
            "a = 1\nb = 2\n[tui]\nmid_loop_trim_threshold = 40\nmax_tool_rounds = 25\n",
        )
        .unwrap();
        let overlay: toml::Value =
            toml::from_str("b = 99\nc = 3\n[tui]\nmax_tool_rounds = 5\n").unwrap();
        merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
        // Scalar overridden, untouched scalar kept, new scalar added.
        assert_eq!(base["a"].as_integer(), Some(1));
        assert_eq!(base["b"].as_integer(), Some(99));
        assert_eq!(base["c"].as_integer(), Some(3));
        // Table merged recursively: overridden key wins, sibling preserved.
        assert_eq!(base["tui"]["max_tool_rounds"].as_integer(), Some(5));
        assert_eq!(
            base["tui"]["mid_loop_trim_threshold"].as_integer(),
            Some(40)
        );
    }

    #[test]
    fn merge_toml_replaces_arrays_wholesale_by_default() {
        let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\", \"c\"]").unwrap();
        let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
        merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
        let arr = base["models"].as_array().unwrap();
        assert_eq!(arr.len(), 1, "replace strategy swaps the array");
        assert_eq!(arr[0].as_str(), Some("x"));
    }

    #[test]
    fn merge_toml_appends_arrays_when_strategy_is_append() {
        let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\"]").unwrap();
        let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
        merge_toml(&mut base, overlay, ArrayMergeStrategy::Append);
        let arr = base["models"].as_array().unwrap();
        // Global entries first, then the project's appended.
        let got: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
        assert_eq!(got, vec!["a", "b", "x"]);
    }

    #[test]
    fn array_merge_strategy_project_wins_then_base_then_default() {
        let append: toml::Value = toml::from_str("[merge]\narrays = \"append\"\n").unwrap();
        let replace: toml::Value = toml::from_str("[merge]\narrays = \"replace\"\n").unwrap();
        let none: toml::Value = toml::from_str("x = 1").unwrap();
        // Project setting wins over the base.
        assert_eq!(
            array_merge_strategy(&append, &replace),
            ArrayMergeStrategy::Append
        );
        // Falls back to the base when the project is silent.
        assert_eq!(
            array_merge_strategy(&none, &append),
            ArrayMergeStrategy::Append
        );
        // Defaults to Replace when neither sets it.
        assert_eq!(
            array_merge_strategy(&none, &none),
            ArrayMergeStrategy::Replace
        );
        // Unrecognized values are ignored (fall through to default).
        let bogus: toml::Value = toml::from_str("[merge]\narrays = \"sideways\"\n").unwrap();
        assert_eq!(
            array_merge_strategy(&bogus, &none),
            ArrayMergeStrategy::Replace
        );
    }

    #[test]
    fn append_strategy_adds_project_mcp_server_to_global() {
        // The motivating case from issue #222: a project registers an extra
        // local stdio MCP server without redefining the global one.
        let global = "\
[merge]
arrays = \"append\"

[[mcp_servers]]
name = \"global-fs\"
command = \"mcp-fs\"
";
        let project = "\
[[mcp_servers]]
name = \"project-fs\"
command = \"mcp-fs\"
args = [\"--root\", \".\"]
";
        let mut merged: toml::Value = toml::from_str(global).unwrap();
        let proj_val: toml::Value = toml::from_str(project).unwrap();
        let strategy = array_merge_strategy(&proj_val, &merged);
        assert_eq!(strategy, ArrayMergeStrategy::Append);
        merge_toml(&mut merged, proj_val, strategy);
        let cfg: Config = merged.try_into().unwrap();
        let names: Vec<&str> = cfg.mcp_servers.iter().map(|m| m.name.as_str()).collect();
        assert_eq!(names, vec!["global-fs", "project-fs"]);
    }

    #[test]
    fn find_project_config_walks_up_and_stops_before_home() {
        let home = tempfile::tempdir().unwrap();
        // home/proj/sub  with a project config at home/proj/.newt/config.toml
        let proj = home.path().join("proj");
        let sub = proj.join("sub");
        std::fs::create_dir_all(&sub).unwrap();
        std::fs::create_dir_all(proj.join(".newt")).unwrap();
        std::fs::write(proj.join(".newt").join("config.toml"), "x = 1").unwrap();
        // Also place a (global) config at home/.newt to prove it's NOT returned.
        std::fs::create_dir_all(home.path().join(".newt")).unwrap();
        std::fs::write(home.path().join(".newt").join("config.toml"), "x = 9").unwrap();

        let found = find_project_config_from(&sub, Some(home.path()));
        assert_eq!(found, Some(proj.join(".newt").join("config.toml")));

        // From a dir with no project config above it (but under home), nothing.
        let bare = home.path().join("empty");
        std::fs::create_dir_all(&bare).unwrap();
        assert_eq!(find_project_config_from(&bare, Some(home.path())), None);
    }

    #[test]
    fn project_config_deep_merges_over_global() {
        // global config: a backend + a tui block.
        let global = "\
[[backends]]
name = \"ollama\"
endpoint = \"http://localhost:11434\"
model = \"llama3\"
tiers = []
kind = \"ollama\"

[tui]
mid_loop_trim_threshold = 40
max_tool_rounds = 25
";
        // project override: change max_tool_rounds only.
        let project = "[tui]\nmax_tool_rounds = 7\n";

        let mut merged: toml::Value = toml::from_str(global).unwrap();
        merge_toml(
            &mut merged,
            toml::from_str(project).unwrap(),
            ArrayMergeStrategy::Replace,
        );
        let cfg: Config = merged.try_into().unwrap();

        // Overridden value wins…
        assert_eq!(cfg.tui.as_ref().unwrap().max_tool_rounds, 7);
        // …sibling key preserved from global…
        assert_eq!(cfg.tui.as_ref().unwrap().mid_loop_trim_threshold, 40);
        // …and the global backend survived (not in the override).
        assert_eq!(cfg.backends.len(), 1);
        assert_eq!(cfg.backends[0].name, "ollama");
    }

    #[test]
    fn config_default_has_no_dgx() {
        assert!(Config::default().dgx.is_none());
    }

    #[test]
    fn to_redacted_toml_hides_mcp_secrets_but_keeps_shape() {
        let cfg: Config = toml::from_str(
            r#"
            [[backends]]
            name = "remote"
            endpoint = "http://remote:8000"
            model = "qwen3:32b"
            tiers = []
            kind = "openai"
            api_key_file = "~/.newt/openai.key"

            [[mcp_servers]]
            name = "gh"
            type = "http"
            url = "https://api.example/mcp"
            [mcp_servers.headers]
            Authorization = "Bearer sk-super-secret-token"
            [mcp_servers.env]
            GH_TOKEN = "ghp_rawsecretvalue"
            RUST_LOG = "debug"
            "#,
        )
        .unwrap();

        let dump = cfg.to_redacted_toml().unwrap();
        // The raw secret VALUES never appear…
        assert!(
            !dump.contains("sk-super-secret-token"),
            "header secret leaked:\n{dump}"
        );
        assert!(
            !dump.contains("ghp_rawsecretvalue"),
            "env secret leaked:\n{dump}"
        );
        // …but the KEYS and the placeholder do, so the audit shows the shape.
        assert!(dump.contains("Authorization"));
        assert!(dump.contains("GH_TOKEN"));
        assert!(dump.contains(Config::REDACTED));
        // Secret *references* (a path) are kept — they name where a secret lives.
        assert!(
            dump.contains("~/.newt/openai.key"),
            "api_key_file reference kept"
        );
        // Non-secret structure is intact.
        assert!(dump.contains("http://remote:8000"));
    }

    #[test]
    fn config_with_dgx_roundtrips() {
        let cfg = Config {
            dgx: Some(crate::dgx::DgxConfig::home_template()),
            ..Config::default()
        };
        let text = toml::to_string_pretty(&cfg).unwrap();
        let back = toml::from_str::<Config>(&text).unwrap();
        let dgx = back.dgx.expect("dgx should round-trip");
        assert_eq!(dgx.active_node.as_deref(), Some("home"));
        assert_eq!(dgx.nodes.len(), 1);
        assert_eq!(dgx.formations.len(), 2);
    }

    // --- ToolPermissions / to_caveats ---

    #[test]
    fn workspace_dev_allows_cargo_and_just() {
        let perms = ToolPermissions::default(); // WorkspaceDev
        let cav = perms.to_caveats("/workspace");
        assert!(cav.permits_exec("cargo"), "cargo must be allowed");
        assert!(cav.permits_exec("just"), "just must be allowed");
        assert!(cav.permits_exec("git"), "git must be allowed");
    }

    #[test]
    fn workspace_dev_blocks_rm_and_mv() {
        let perms = ToolPermissions::default();
        let cav = perms.to_caveats("/workspace");
        assert!(!cav.permits_exec("rm"), "rm must be blocked");
        assert!(!cav.permits_exec("mv"), "mv must be blocked");
        assert!(!cav.permits_exec("sudo"), "sudo must be blocked");
    }

    #[test]
    fn workspace_dev_allows_common_dev_tools() {
        // Regression: these were denied under the default preset even though
        // they're the same risk tier as cargo/git (issue #149). `gh` in
        // particular is authenticated outside but was blocked in-agent.
        let cav = ToolPermissions::default().to_caveats("/workspace");
        for tool in [
            "gh", "python", "python3", "pip", "npm", "node", "make", "jq", "curl", "awk", "sed",
            "cut", "xargs", "which", "env",
        ] {
            assert!(cav.permits_exec(tool), "`{tool}` must be allowed");
        }
        // Adding tools must NOT escalate to full access — destructive commands
        // outside the allowlist stay blocked.
        assert!(!cav.permits_exec("rm"), "rm must still be blocked");
        assert!(!cav.permits_exec("sudo"), "sudo must still be blocked");
    }

    #[test]
    fn workspace_dev_allows_extra_exec() {
        let perms = ToolPermissions {
            preset: PermissionPreset::WorkspaceDev,
            extra_exec: vec!["bacon".into(), "make".into()],
            net: vec![],
            prompt: false,
        };
        let cav = perms.to_caveats("/workspace");
        assert!(cav.permits_exec("bacon"));
        assert!(cav.permits_exec("make"));
        assert!(!cav.permits_exec("rm")); // extra_exec does not weaken the block
    }

    #[test]
    fn read_only_blocks_writes_and_exec() {
        let perms = ToolPermissions {
            preset: PermissionPreset::ReadOnly,
            extra_exec: vec![],
            net: vec![],
            prompt: false,
        };
        let cav = perms.to_caveats("/workspace");
        assert!(!cav.permits_fs_write("/workspace/src/main.rs"));
        assert!(!cav.permits_exec("cargo"));
        assert!(cav.permits_fs_read("/workspace/src/main.rs"));
    }

    #[test]
    fn workspace_edit_allows_write_blocks_exec() {
        let perms = ToolPermissions {
            preset: PermissionPreset::WorkspaceEdit,
            extra_exec: vec![],
            net: vec![],
            prompt: false,
        };
        let cav = perms.to_caveats("/workspace");
        assert!(!cav.permits_exec("cargo"));
        // The caveat stores workspace root; prefix matching is in the TUI layer.
        // Here we just verify the lattice is set up correctly (not All, not none).
        use crate::caveats::Scope;
        assert!(matches!(cav.fs_write, Scope::Only(_)));
    }

    #[test]
    fn full_access_is_top() {
        let perms = ToolPermissions {
            preset: PermissionPreset::FullAccess,
            extra_exec: vec![],
            net: vec![],
            prompt: false,
        };
        let cav = perms.to_caveats("/workspace");
        assert_eq!(cav, crate::caveats::Caveats::top());
    }

    #[test]
    fn net_allowlist_controls_the_net_axis() {
        use crate::caveats::Scope;

        // Default (empty `net`) => no network: web_fetch is denied.
        let none = ToolPermissions::default().to_caveats("/ws");
        assert!(
            matches!(none.net, Scope::Only(ref s) if s.is_empty()),
            "empty net config must yield an empty (deny-all) net scope"
        );

        // Explicit host allowlist — works under ANY preset (here ReadOnly), so
        // web access does not require granting writes/exec.
        let hosts = ToolPermissions {
            preset: PermissionPreset::ReadOnly,
            extra_exec: vec![],
            net: vec!["docs.rs".into(), "github.com".into()],
            prompt: false,
        }
        .to_caveats("/ws");
        assert!(
            matches!(hosts.net, Scope::Only(ref s) if s.contains("docs.rs") && s.contains("github.com")),
            "explicit hosts must populate the net allowlist"
        );

        // A single "*" grants all hosts (still SSRF-screened by the web tool).
        let all = ToolPermissions {
            preset: PermissionPreset::WorkspaceDev,
            extra_exec: vec![],
            net: vec!["*".into()],
            prompt: false,
        }
        .to_caveats("/ws");
        assert!(
            matches!(all.net, Scope::All),
            "a `*` entry must grant the whole net axis"
        );
    }

    #[test]
    fn custom_is_workspace_dev_not_top() {
        // Regression: editing the exec allowlist auto-flips the preset to
        // `Custom`, which used to map to `Caveats::top()` — a silent escalation
        // from "add one command" to "full access". `Custom` must now carry
        // WorkspaceDev authority plus the extra commands, never `top()`.
        let custom = ToolPermissions {
            preset: PermissionPreset::Custom,
            extra_exec: vec!["bacon".into()],
            net: vec![],
            prompt: false,
        }
        .to_caveats("/workspace");
        assert_ne!(
            custom,
            crate::caveats::Caveats::top(),
            "Custom must not be full access"
        );
        assert!(custom.permits_exec("cargo"), "workspace-dev tools allowed");
        assert!(custom.permits_exec("bacon"), "extra_exec command allowed");
        assert!(!custom.permits_exec("rm"), "non-allowlisted command denied");
        // Identical to WorkspaceDev with the same extras.
        let workspace_dev = ToolPermissions {
            preset: PermissionPreset::WorkspaceDev,
            extra_exec: vec!["bacon".into()],
            net: vec![],
            prompt: false,
        }
        .to_caveats("/workspace");
        assert_eq!(
            custom, workspace_dev,
            "Custom carries WorkspaceDev authority + extras"
        );
    }

    #[test]
    fn preset_toggle_cycles() {
        assert_eq!(
            PermissionPreset::ReadOnly.toggle(),
            PermissionPreset::WorkspaceEdit
        );
        assert_eq!(
            PermissionPreset::WorkspaceEdit.toggle(),
            PermissionPreset::WorkspaceDev
        );
        assert_eq!(
            PermissionPreset::WorkspaceDev.toggle(),
            PermissionPreset::FullAccess
        );
        assert_eq!(
            PermissionPreset::FullAccess.toggle(),
            PermissionPreset::ReadOnly
        );
    }

    #[test]
    fn tool_permissions_toml_roundtrip() {
        let perms = ToolPermissions {
            preset: PermissionPreset::WorkspaceDev,
            extra_exec: vec!["bacon".into()],
            net: vec![],
            prompt: false,
        };
        let toml = toml::to_string(&perms).unwrap();
        assert!(toml.contains("workspace_dev"));
        assert!(toml.contains("bacon"));
        let back: ToolPermissions = toml::from_str(&toml).unwrap();
        assert_eq!(back, perms);
    }

    // ---- #904: comment-preserving "allow permanently" net writer ----

    #[test]
    fn with_net_host_creates_table_from_empty_and_scope_includes_host() {
        let out = Config::with_net_host("", "github.com").unwrap();
        // The written TOML parses back and its net scope now permits the host.
        let cfg: Config = toml::from_str(&out).unwrap();
        let perms = cfg.tui.unwrap().permissions;
        assert!(perms.net.contains(&"github.com".to_string()));
        assert!(
            matches!(perms.net_scope(), crate::caveats::Scope::Only(ref s) if s.contains("github.com")),
            "net_scope must permit the granted host"
        );
    }

    #[test]
    fn with_net_host_preserves_comments_and_other_keys() {
        let original = "\
# my hand-authored config — keep this comment
[tui.permissions]
preset = \"workspace_dev\"  # inline comment
net = [\"already.example.com\"]
";
        let out = Config::with_net_host(original, "github.com").unwrap();
        // Comments survive (the whole point vs Config::save).
        assert!(
            out.contains("# my hand-authored config"),
            "top comment lost: {out}"
        );
        assert!(
            out.contains("# inline comment"),
            "inline comment lost: {out}"
        );
        // The pre-existing host is kept and the new one appended.
        assert!(out.contains("already.example.com"));
        assert!(out.contains("github.com"));
        // preset key untouched.
        assert!(out.contains("workspace_dev"));
    }

    #[test]
    fn with_net_host_is_idempotent_no_duplicate() {
        let once = Config::with_net_host("", "github.com").unwrap();
        let twice = Config::with_net_host(&once, "github.com").unwrap();
        assert_eq!(
            twice.matches("github.com").count(),
            1,
            "duplicated host: {twice}"
        );
    }

    #[test]
    fn with_net_host_rejects_invalid_toml() {
        assert!(Config::with_net_host("this = = not toml", "github.com").is_err());
    }

    fn openai_backend(api_key_file: Option<String>, api_key_env: Option<String>) -> BackendConfig {
        BackendConfig {
            name: "remote".into(),
            endpoint: "https://example.test".into(),
            model: "some-model".into(),
            model_path: None,
            tiers: vec![Tier::Fast],
            kind: BackendKind::Openai,
            api: Default::default(),
            api_key_file,
            api_key_env,
        }
    }

    #[test]
    fn backend_kind_defaults_to_ollama_when_absent() {
        let toml = r#"
            [[backends]]
            name = "local"
            endpoint = "http://localhost:8000"
            model = "m"
            tiers = ["FAST"]
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.backends[0].kind, BackendKind::Ollama);
        assert!(cfg.backends[0].api_key_file.is_none());
        assert!(cfg.backends[0].api_key_env.is_none());
    }

    #[test]
    fn backend_kind_parses_openai_and_aliases() {
        for kind_str in ["openai", "vllm", "openai-compatible"] {
            let toml = format!(
                "[[backends]]\nname=\"x\"\nendpoint=\"http://e\"\nmodel=\"m\"\ntiers=[\"FAST\"]\nkind=\"{kind_str}\"\n"
            );
            let cfg: Config = toml::from_str(&toml).unwrap();
            assert_eq!(cfg.backends[0].kind, BackendKind::Openai, "kind={kind_str}");
        }
    }

    #[test]
    fn backend_kind_label_is_protocol_name() {
        assert_eq!(BackendKind::Ollama.label(), "ollama");
        assert_eq!(BackendKind::Openai.label(), "openai");
    }

    #[test]
    fn backend_config_roundtrips_auth_fields() {
        let cfg = openai_backend(Some("~/.newt/token".into()), Some("MY_TOKEN".into()));
        let toml = toml::to_string(&cfg).unwrap();
        assert!(toml.contains("kind = \"openai\""));
        assert!(toml.contains("api_key_file"));
        assert!(toml.contains("api_key_env"));
        let back: BackendConfig = toml::from_str(&toml).unwrap();
        assert_eq!(back.kind, BackendKind::Openai);
        assert_eq!(back.api_key_file.as_deref(), Some("~/.newt/token"));
        assert_eq!(back.api_key_env.as_deref(), Some("MY_TOKEN"));
    }

    #[test]
    fn resolve_api_key_reads_first_nonempty_line_of_file() {
        let mut f = tempfile::NamedTempFile::new().unwrap();
        // Leading blank line + surrounding whitespace must be skipped/trimmed.
        write!(f, "\n  secret-token-123  \nignored-second-line\n").unwrap();
        let cfg = openai_backend(Some(f.path().to_string_lossy().into_owned()), None);
        assert_eq!(cfg.resolve_api_key().as_deref(), Some("secret-token-123"));
    }

    #[test]
    fn resolve_api_key_env_takes_precedence_over_file() {
        let var = "NEWT_TEST_API_KEY_PRECEDENCE";
        std::env::set_var(var, "  from-env  ");
        let mut f = tempfile::NamedTempFile::new().unwrap();
        writeln!(f, "from-file").unwrap();
        let cfg = openai_backend(
            Some(f.path().to_string_lossy().into_owned()),
            Some(var.into()),
        );
        assert_eq!(cfg.resolve_api_key().as_deref(), Some("from-env"));
        std::env::remove_var(var);
    }

    #[test]
    fn resolve_api_key_none_when_unconfigured() {
        assert_eq!(openai_backend(None, None).resolve_api_key(), None);
    }

    #[test]
    fn resolve_api_key_none_for_missing_file() {
        let cfg = openai_backend(Some("/no/such/newt/token/file".into()), None);
        assert_eq!(cfg.resolve_api_key(), None);
    }

    #[test]
    fn expand_tilde_expands_home_and_passes_through() {
        let home = home_dir().expect("HOME set in test env");
        assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
        assert_eq!(expand_tilde("~"), home);
        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
        assert_eq!(
            expand_tilde("relative/path"),
            PathBuf::from("relative/path")
        );
    }

    #[test]
    fn default_max_tool_rounds_is_40() {
        // #<issue>: raised from 25 — a modest safety margin alongside
        // workflow_grace_rounds and the diagnose_failure delegate hint, not a
        // substitute for either. The function default and the struct default
        // agree on 40.
        assert_eq!(default_max_tool_rounds(), 40);
        assert_eq!(TuiConfig::default().max_tool_rounds, 40);
        assert_eq!(default_workflow_grace_rounds(), 5);
        assert_eq!(TuiConfig::default().workflow_grace_rounds, 5);
    }

    #[test]
    fn tui_max_tool_rounds_defaults_when_field_absent() {
        // An empty `[tui]` table => serde default kicks in => 40.
        let toml = r#"
            [tui]
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.tui.unwrap().max_tool_rounds, 40);
    }

    #[test]
    fn tui_max_tool_rounds_can_be_overridden() {
        let toml = r#"
            [tui]
            max_tool_rounds = 7
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.tui.unwrap().max_tool_rounds, 7);
    }

    #[test]
    fn tui_workflow_grace_rounds_can_be_overridden_or_disabled() {
        let cfg: Config = toml::from_str(
            r#"
            [tui]
            workflow_grace_rounds = 9
        "#,
        )
        .unwrap();
        assert_eq!(cfg.tui.unwrap().workflow_grace_rounds, 9);

        let disabled: Config = toml::from_str(
            r#"
            [tui]
            workflow_grace_rounds = 0
        "#,
        )
        .unwrap();
        assert_eq!(disabled.tui.unwrap().workflow_grace_rounds, 0);
    }

    #[test]
    fn model_tuning_parses_from_toml() {
        let toml = r#"
            [[model_tuning]]
            model = "nemotron3:33b"
            num_ctx = 24576
            mid_loop_trim_threshold = 12
            max_tool_rounds = 20
            workflow_grace_rounds = 8

            [[model_tuning]]
            model = "qwen3-coder:30b"
            num_ctx = 65536
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.model_tuning.len(), 2);

        let nemo = cfg.find_model_tuning("nemotron3:33b").unwrap();
        assert_eq!(nemo.num_ctx, Some(24576));
        assert_eq!(nemo.mid_loop_trim_threshold, Some(12));
        assert_eq!(nemo.max_tool_rounds, Some(20));
        assert_eq!(nemo.workflow_grace_rounds, Some(8));

        let qwen = cfg.find_model_tuning("qwen3-coder:30b").unwrap();
        assert_eq!(qwen.num_ctx, Some(65536));
        assert_eq!(qwen.mid_loop_trim_threshold, None);
        assert_eq!(qwen.workflow_grace_rounds, None);
    }

    #[test]
    fn model_tuning_find_returns_none_for_unknown_model() {
        let cfg = Config::default();
        assert!(cfg.find_model_tuning("nonexistent:7b").is_none());
    }

    #[test]
    fn model_tuning_partial_fields_are_optional() {
        let toml = r#"
            [[model_tuning]]
            model = "llama3.1:8b"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let entry = cfg.find_model_tuning("llama3.1:8b").unwrap();
        assert_eq!(entry.num_ctx, None);
        assert_eq!(entry.mid_loop_trim_threshold, None);
        assert_eq!(entry.max_tool_rounds, None);
        assert_eq!(entry.workflow_grace_rounds, None);
    }

    // ---- #726: [tools] max_output_tokens ----

    #[test]
    fn tools_max_output_tokens_defaults_to_10k_when_absent() {
        // No `[tools]` section ⇒ the built-in default budget.
        let cfg: Config = toml::from_str("").unwrap();
        assert!(cfg.tools.is_none());
        assert_eq!(cfg.max_output_tokens(), 10_000);
        assert_eq!(cfg.output_head_tokens(), 1_500);
        assert_eq!(Config::default().max_output_tokens(), 10_000);
        assert_eq!(Config::default().output_head_tokens(), 1_500);
    }

    #[test]
    fn tools_max_output_tokens_parses_an_override() {
        let cfg: Config = toml::from_str(
            r#"
            [tools]
            max_output_tokens = 4096
            output_head_tokens = 512
        "#,
        )
        .unwrap();
        assert_eq!(cfg.tools.as_ref().unwrap().max_output_tokens, 4096);
        assert_eq!(cfg.tools.as_ref().unwrap().output_head_tokens, 512);
        assert_eq!(cfg.max_output_tokens(), 4096);
        assert_eq!(cfg.output_head_tokens(), 512);
    }

    #[test]
    fn tools_config_default_field_is_the_shared_default() {
        // A `[tools]` table that omits the key falls back to the default fn.
        let cfg: Config = toml::from_str("[tools]\n").unwrap();
        assert_eq!(cfg.max_output_tokens(), 10_000);
        assert_eq!(cfg.output_head_tokens(), 1_500);
    }

    #[test]
    fn tools_max_output_tokens_zero_is_a_valid_no_cap() {
        let cfg: Config = toml::from_str("[tools]\nmax_output_tokens = 0\n").unwrap();
        assert_eq!(cfg.max_output_tokens(), 0);
    }
}
