Skip to main content

newt_core/
config.rs

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