Skip to main content

supercode_harness/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::event::EventSink;
5
6pub(crate) use supercode_runtime::glob_match;
7pub use supercode_runtime::CachePlan;
8
9/// The default OpenRouter base URL. Any OpenAI-compatible endpoint works too.
10pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
11
12/// Environment variable consulted for the API key when none is set explicitly.
13pub const DEFAULT_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
14
15/// Built-in prompt templates (slash commands), e.g. `/code-review`.
16fn default_prompts() -> std::collections::HashMap<String, String> {
17    let mut m = std::collections::HashMap::new();
18    m.insert(
19        "code-review".to_string(),
20        "Review the current code changes for correctness bugs, then for \
21reuse/simplification/efficiency cleanups. {args}\nUse the available tools to \
22inspect the diff and files. Report findings grouped by severity."
23            .to_string(),
24    );
25    m
26}
27
28/// A default, deliberately small system prompt. Override it freely.
29pub const DEFAULT_SYSTEM_PROMPT: &str = "\
30You are supercode, a precise and efficient AI coding agent operating in a user's \
31working directory. Use the available tools to inspect and modify files and run \
32commands. Prefer reading before writing. Make minimal, correct changes and explain \
33what you did concisely.";
34
35/// When the agent must seek approval before running a tool — the analog of
36/// Codex's `-a untrusted|on-request|never` and Claude's permission modes.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum ApprovalPolicy {
39    /// Never ask — every tool call runs automatically (default).
40    #[default]
41    Never,
42    /// Ask for tools not on the auto-approve allowlist.
43    OnRequest,
44    /// Ask for every tool call.
45    Untrusted,
46    /// P5-1 (COMPOSABLE-HARNESS-DESIGN.md §3.2 S8, §4.3 cx-parity): Codex's
47    /// `-a on-request` default — escalation is INITIATED BY THE MODEL, not
48    /// decided by a client-side allowlist check the way [`Self::OnRequest`]
49    /// is (`Config::needs_approval`'s `OnRequest` arm consults
50    /// `Config::auto_approved_tools`/`tool_allow_patterns`; Codex's
51    /// `on-request` instead runs sandboxed writes/reads silently and only
52    /// asks when the MODEL itself requests to leave the sandbox —
53    /// `protocol.rs:921-924`). Using [`Self::OnRequest`] for cx-parity would
54    /// prompt on every non-allowlisted call, where stock Codex prompts
55    /// almost never — a materially different (over-prompting, but not
56    /// unsafe) posture, which is why `configfile::parse_approval_str`
57    /// previously fell back to [`Self::Untrusted`] rather than silently
58    /// picking the wrong existing variant (S8's original fail-safe). This
59    /// variant now exists so cx-parity resolves to its INTENDED posture
60    /// instead of that fail-safe. `Config::needs_approval` (the coarse,
61    /// tool-name-only legacy gate — no model-escalation signal reaches it)
62    /// treats this conservatively, the same as [`Self::OnRequest`]; the P5-1
63    /// permissions engine (`crate::permissions`, the richer canonicalized-
64    /// command-aware gate `crate::agent::Agent` consults when
65    /// `Config::permissions_enabled` is on) treats it per Codex's real
66    /// posture — see that gate's doc comment.
67    ModelRequested,
68}
69
70/// A callback consulted when a tool call needs approval. Returns `true` to allow.
71pub type ApprovalHandler = Box<dyn Fn(&crate::message::ToolCall) -> bool + Send + Sync>;
72
73/// BP-10 (catalog row "Hook/plugin permission veto", semantics
74/// "Programmatic allow/deny/**rewrite** before the user sees it"): what a
75/// pre-tool hook decided about one call.
76///
77/// The three non-`Pass` values are TIERS OF THE ONE PERMISSIONS ENGINE, not
78/// a second gate beside it — `crate::agent::Agent`'s
79/// `permissions_gate_denial_impl` folds this in and the engine's own
80/// deny→ask→allow priority decides. In particular [`Self::Allow`] answers
81/// an `Ask` on the user's behalf (CC's `PermissionRequest`-class reply) but
82/// can never override a `deny` rule: a hard floor stays a hard floor.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum HookDecision {
85    /// The hook expressed no opinion — the engine decides alone. The
86    /// default, and what a silent (exit-0, no output) hook means.
87    #[default]
88    Pass,
89    /// Answer an `Ask` on the user's behalf: the call proceeds WITHOUT a
90    /// prompt, unless a `deny` rule (or a protected path) already refused
91    /// it, in which case the refusal stands.
92    Allow,
93    /// Force the call to the `Ask` tier even if the rules would have
94    /// allowed it — "let the user see this one".
95    Ask,
96    /// Refuse the call outright.
97    Deny,
98}
99
100/// BP-10: everything a pre-tool hook can say about one call — a decision,
101/// a human-readable reason, and (the rewrite half of the row) REPLACEMENT
102/// arguments.
103#[derive(Debug, Clone, Default)]
104pub struct PreToolOutcome {
105    /// The hook's verdict — see [`HookDecision`].
106    pub decision: HookDecision,
107    /// Why, in words. Fed back to the model on a denial; carried to the
108    /// approval door otherwise.
109    pub reason: Option<String>,
110    /// BP-10 (the "rewrite" half): arguments to run the tool with INSTEAD
111    /// of the model's own. `None` (the default) leaves the call untouched.
112    /// The rewritten arguments are what the permissions engine then
113    /// evaluates and what the tool finally receives — a hook cannot
114    /// launder a denied command by rewriting it past the gate, because the
115    /// gate runs on the REWRITTEN args.
116    pub updated_args: Option<serde_json::Value>,
117}
118
119impl PreToolOutcome {
120    /// No opinion — the pre-BP-10 `None` return.
121    pub fn pass() -> Self {
122        PreToolOutcome::default()
123    }
124
125    /// Refuse the call — the pre-BP-10 `Some(reason)` return.
126    pub fn deny(reason: impl Into<String>) -> Self {
127        PreToolOutcome {
128            decision: HookDecision::Deny,
129            reason: Some(reason.into()),
130            updated_args: None,
131        }
132    }
133
134    /// Answer an `Ask` on the user's behalf.
135    pub fn allow(reason: Option<String>) -> Self {
136        PreToolOutcome {
137            decision: HookDecision::Allow,
138            reason,
139            updated_args: None,
140        }
141    }
142
143    /// Force the call to the `Ask` tier.
144    pub fn ask(reason: Option<String>) -> Self {
145        PreToolOutcome {
146            decision: HookDecision::Ask,
147            reason,
148            updated_args: None,
149        }
150    }
151
152    /// Run the tool with `args` instead of the model's own.
153    pub fn rewrite(args: serde_json::Value) -> Self {
154        PreToolOutcome {
155            decision: HookDecision::Pass,
156            reason: None,
157            updated_args: Some(args),
158        }
159    }
160}
161
162/// A pre-tool hook: receives the tool name and parsed arguments before
163/// execution, and returns a [`PreToolOutcome`] — allow, deny, ask, and/or a
164/// rewrite of the arguments. BP-10 widened this from the earlier
165/// `Option<String>` ("`Some(reason)` blocks"): the deny half is
166/// [`PreToolOutcome::deny`], and the three new answers are what the catalog
167/// row's "programmatic allow/deny/rewrite" and CC's `PermissionRequest`
168/// hooks actually need.
169pub type PreToolHook = Box<dyn Fn(&str, &serde_json::Value) -> PreToolOutcome + Send + Sync>;
170
171/// A post-tool hook: receives the tool name, its output, and whether it errored,
172/// after execution (observational — logging, metrics, side effects).
173pub type PostToolHook = Box<dyn Fn(&str, &str, bool) + Send + Sync>;
174
175/// A loop lifecycle moment an embedder may observe (BP-11, catalog
176/// "Lifecycle hooks, config-registered"): the compaction and subagent
177/// boundaries the CLI's `pre_compact`/`post_compact`/`subagent_start`/
178/// `subagent_stop` hook events are fired from. Observational only — a
179/// lifecycle hook can never veto the moment it observes.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum LifecycleEvent {
182    /// The live window is about to be compacted: `dropped` messages in
183    /// `[first, cut)` collapse into one marker; `manual` is a `/compact`.
184    PreCompact {
185        /// Messages in the window before compaction.
186        messages: usize,
187        /// Messages about to be collapsed into the marker.
188        dropped: usize,
189        /// `true` for `/compact`, `false` for an automatic trigger.
190        manual: bool,
191    },
192    /// Compaction finished; the window now holds `messages` entries.
193    PostCompact {
194        /// Messages in the window after compaction.
195        messages: usize,
196        /// Messages that were collapsed.
197        dropped: usize,
198    },
199    /// A `spawn_subagent` call passed validation and is about to run.
200    SubagentStart {
201        /// The task text the child was given.
202        task: String,
203    },
204    /// The `spawn_subagent` call returned (`is_error` is the tool result's flag).
205    SubagentStop {
206        /// The task text the child was given.
207        task: String,
208        /// Whether the child's result was an error.
209        is_error: bool,
210        /// Byte length of the result handed back to the parent.
211        output_len: usize,
212    },
213}
214
215/// A lifecycle observer: receives every [`LifecycleEvent`] (observational).
216pub type LifecycleHook = Box<dyn Fn(&LifecycleEvent) + Send + Sync>;
217
218/// How tools are advertised to the model (B6, D16).
219///
220/// `Full` sends every enabled tool's schema on every request (today's
221/// behavior). `Deferred` advertises only a `core` allowlist plus a synthetic
222/// `tool_search` meta-tool; everything else — the MCP surface above all,
223/// since `McpTool::from_client` eagerly wraps every remote tool with its full
224/// `input_schema` — is discoverable via `tool_search` and only advertised
225/// (on the *next* request) once activated.
226#[derive(Debug, Clone, Default)]
227pub enum ToolAdvertising {
228    /// All enabled tools every request (today's behavior).
229    #[default]
230    Full,
231    /// Only `core` tools + the `tool_search` meta-tool; everything else is
232    /// discoverable via `tool_search` and advertised only after activation.
233    Deferred {
234        /// Tool names advertised eagerly on every request.
235        core: Vec<String>,
236    },
237}
238
239/// Optional-policy gates resolved from `[capabilities.reduction]`.
240///
241/// `None` preserves [`crate::reduce::ReductionPolicy`]'s established
242/// default for callers that use reduced mode without the composable module
243/// surface. A preset or direct capability setting supplies only the gates it
244/// names; the CLI applies them when it constructs the live policy.
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub struct ReductionPolicySettings {
247    pub stale_reads: Option<bool>,
248    pub diff_reads: Option<bool>,
249    pub duplicates: Option<bool>,
250    pub tool_input_elision: Option<bool>,
251    pub supersede: Option<bool>,
252    pub normalize_output: Option<bool>,
253    pub image_redaction: Option<bool>,
254    pub span_summaries: Option<bool>,
255}
256
257/// Per-tool customization: enable/disable a tool and/or override the description
258/// the model sees for it.
259#[derive(Debug, Clone, Default)]
260pub struct ToolOverride {
261    /// If `Some(false)`, the tool is hidden from the model entirely.
262    pub enabled: Option<bool>,
263    /// If `Some`, replaces the tool's built-in description in the schema.
264    pub description: Option<String>,
265    /// If `Some`, overrides [`Config::tool_schema_tier`] (TR-8/T5) for this
266    /// specific tool — e.g. keep one fat MCP tool at `Full` while the global
267    /// knob shrinks everything else to `Minimal`.
268    pub schema_tier: Option<crate::tools::SchemaTier>,
269    /// P4e (design §3.1/§S14 `core.tools.bash.timeout_secs`): the DEFAULT
270    /// execution timeout (seconds) for the `bash` tool when a model-issued
271    /// call doesn't supply its own `timeout_ms` argument — see
272    /// `tools::builtins::BashTool::execute`'s precedence (an explicit
273    /// per-call `timeout_ms` always wins; this only replaces the BUILT-IN
274    /// `DEFAULT_BASH_TIMEOUT_MS` fallback). Only meaningful on the `bash`
275    /// entry; other tools ignore it. `None` (the default) is byte-identical
276    /// to today's behavior — `BashTool`'s internal 120s default stands.
277    pub timeout_secs: Option<u64>,
278}
279
280/// Everything that shapes an [`crate::Agent`]: the model and endpoint, the
281/// credentials, sampling parameters, the system prompt, and per-tool overrides.
282///
283/// Build one with [`Config::builder`].
284#[non_exhaustive]
285pub struct Config {
286    /// Model identifier as understood by the endpoint, e.g.
287    /// `anthropic/claude-opus-4-8` or `openai/gpt-5` on OpenRouter.
288    pub model: String,
289
290    /// Base URL of the OpenAI-compatible endpoint (no trailing `/chat/...`).
291    pub base_url: String,
292
293    /// Explicit API key. If `None`, [`Self::api_key_env`] is consulted.
294    pub api_key: Option<String>,
295
296    /// Environment variable to read the API key from when [`Self::api_key`] is unset.
297    pub api_key_env: String,
298
299    /// P4 (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.8/§3.1
300    /// `core.api_key_cmd`, D6 row): a credential-helper command (pi§6
301    /// `!command` form). Consulted by `Agent::new` when [`Self::api_key`]
302    /// is unset: the command is run through the shell, its trimmed stdout
303    /// becomes the key, and a non-zero exit or empty output falls through to
304    /// [`Self::api_key_env`] rather than failing outright. `None` (the
305    /// default) means this is never consulted — byte-identical to today's
306    /// behavior. SECURITY: this is a command string, never a secret value —
307    /// [`Self::api_key`] itself must never be file-plaintext (§3.2 S13);
308    /// `api_key_cmd` is `[project-forbidden]` at every config-file layer
309    /// (§3.3), same trust boundary as `base_url`/`api_key_env`.
310    pub api_key_cmd: Option<String>,
311
312    /// BP-9 (§3.1 `core.api_key_command`, D6 row "Credential helpers /
313    /// keyring", cc§6 `apiKeyHelper`, cx§6 `auth{command}`): an ARGV
314    /// credential helper, exec'd directly (no shell), whose trimmed stdout
315    /// becomes the key. Consulted by `Agent::new` BEFORE
316    /// [`Self::api_key_cmd`] — it is the safer of the two forms (no
317    /// word-splitting, no `$(…)`), so a config that sets both gets the one
318    /// with fewer ways to surprise its author. Same fall-through posture as
319    /// `api_key_cmd`: a failing/empty helper moves on to the next source
320    /// rather than erroring. `None` (the default) is never consulted.
321    /// `[project-forbidden]` (§3.3).
322    pub api_key_command: Option<Vec<String>>,
323
324    /// BP-9 (§3.1 `core.update_check`, D6 row "Auto-update + channels",
325    /// cx§10): whether a startup release check is performed. `false` (the
326    /// default) means no startup network access at all — see
327    /// `CoreSection::update_check` for why opt-in is the only defensible
328    /// default here.
329    pub update_check: bool,
330
331    /// System prompt prepended to every conversation.
332    pub system_prompt: String,
333
334    /// Optional sampling temperature.
335    pub temperature: Option<f32>,
336
337    /// Optional output token cap.
338    pub max_tokens: Option<u32>,
339
340    /// Maximum number of model/tool iterations per [`crate::Agent::send`] call.
341    pub max_iterations: usize,
342
343    /// Reasoning/effort level sent to the model (`reasoning_effort`).
344    pub effort: Option<String>,
345
346    /// Structured-output constraint (`response_format`), e.g. a json_schema.
347    pub response_format: Option<serde_json::Value>,
348
349    /// Extra request-body fields merged in (provider-native passthrough:
350    /// prompt-cache controls, provider-specific knobs).
351    pub extra_body: serde_json::Map<String, serde_json::Value>,
352
353    /// Optional cap on cumulative output tokens across one [`crate::Agent::send`]
354    /// loop; the loop stops once exceeded. Output tokens only; input/prompt
355    /// tokens are not counted, so this is not a cost cap.
356    pub max_total_output_tokens: Option<u64>,
357
358    /// BP-7 (catalog §4a "Turn/budget caps", cc's `--max-budget-usd`): cap
359    /// on the cumulative DOLLAR cost of one [`crate::Agent::send`] loop.
360    /// The loop stops spawning further model turns once the accumulated
361    /// per-turn cost reaches this figure.
362    ///
363    /// Arming this against a model [`crate::pricing::resolve`] cannot price
364    /// is refused at [`crate::Agent::new`] rather than accepted and
365    /// silently ignored — a spend cap that cannot bite is worse than none,
366    /// because the caller believes they are protected. Set
367    /// [`Self::price_input_per_mtok`]/[`Self::price_output_per_mtok`] to
368    /// price an unknown model.
369    pub max_budget_usd: Option<f64>,
370
371    /// BP-7 (catalog §4a "Turn/budget caps" — the STEP cap the semantics
372    /// name alongside turns, spend and output tokens): cap on the number of
373    /// TOOL CALLS executed across one [`crate::Agent::send`] loop.
374    ///
375    /// Distinct from [`Self::max_iterations`], which bounds model
376    /// round-trips: one round-trip can carry a whole batch of parallel
377    /// tool calls, so a step cap and a turn cap bound different things.
378    pub max_steps: Option<usize>,
379
380    /// BP-7: dollars per million INPUT tokens for [`Self::model`],
381    /// overriding [`crate::pricing`]'s built-in table. Only takes effect
382    /// together with [`Self::price_output_per_mtok`] — half an override
383    /// would bill completions at zero.
384    pub price_input_per_mtok: Option<f64>,
385
386    /// BP-7: dollars per million OUTPUT tokens for [`Self::model`].
387    pub price_output_per_mtok: Option<f64>,
388
389    /// Max bytes of a single tool result fed back into the conversation. Output
390    /// beyond this is truncated with a notice, so one runaway command (a huge
391    /// log, a binary dump) can't explode the context window. `None` disables the
392    /// cap. Defaults to 100 KB.
393    pub max_tool_output_bytes: Option<usize>,
394
395    /// BP-2 (§3.1 `core.tool_output_spill`, catalog:58 "Oversized output
396    /// truncated; full content kept reachable"): when `true`, an output
397    /// capped by [`Self::max_tool_output_bytes`] is first written IN FULL
398    /// to a per-session spill file, and the cap notice names that path so
399    /// the model can read it back with an ordinary read (`read_file`, or
400    /// `cat` under a shell-only preset) — CC's own "Bash overflow → session
401    /// file" recovery door, available without `capabilities.reduction`.
402    /// `false` (the default) protects an embedder that never asked for
403    /// disk writes: the cap notice stays exactly as it is today and no
404    /// spill file is created. Both parity presets turn it on.
405    pub tool_output_spill: bool,
406
407    /// Working directory tools operate within.
408    pub cwd: PathBuf,
409
410    /// Additional roots beyond `cwd` (the analog of `--add-dir` / multi-root):
411    /// searched for project-context files and available to tools.
412    pub additional_dirs: Vec<PathBuf>,
413
414    /// Whether to auto-load `CLAUDE.md` / `AGENTS.md` into the system prompt.
415    pub load_project_context: bool,
416
417    /// Filesystem confinement applied to write-capable tools.
418    pub sandbox: crate::tools::SandboxPolicy,
419
420    /// When the agent must seek approval before running a tool.
421    pub approval: ApprovalPolicy,
422
423    /// Tools that never require approval under [`ApprovalPolicy::OnRequest`].
424    pub auto_approved_tools: std::collections::HashSet<String>,
425
426    /// P4 (design §5.2 "P4": "deny-rule patterns generalizing
427    /// auto_approved_tools" — the S-sized generalization, NOT the full P5
428    /// `capabilities.permissions.rules` deny→ask→allow engine, §2.1
429    /// dependency 3's command-canonicalization prerequisite is P5-only).
430    /// Glob patterns (`*` wildcard, see [`glob_match`]) matched against a
431    /// tool's NAME — no argument/command-level matching. Any match forces
432    /// [`Config::needs_approval`] to `true` UNCONDITIONALLY, even under
433    /// [`ApprovalPolicy::Never`] — the entire point of a deny rule is a
434    /// hard floor `--yes`/`Never` can't bypass. Sourced from
435    /// `capabilities.permissions.rules.deny` (§3.1 module 11); empty by
436    /// default (today's behavior, byte-identical).
437    pub tool_deny_patterns: Vec<String>,
438
439    /// P4: the ALLOW-pattern generalization of [`Self::auto_approved_tools`]
440    /// — glob patterns matched against a tool's NAME, exempting a match from
441    /// approval under [`ApprovalPolicy::OnRequest`] exactly like an exact
442    /// `auto_approved_tools` entry does (never consulted under `Untrusted`,
443    /// same as `auto_approved_tools`). Sourced from
444    /// `capabilities.permissions.rules.allow`; empty by default.
445    pub tool_allow_patterns: Vec<String>,
446
447    /// Consulted when a tool call needs approval; `None` denies by default.
448    pub approval_handler: Option<ApprovalHandler>,
449
450    /// Runs before each tool executes; may block the call.
451    pub pre_tool_hook: Option<PreToolHook>,
452
453    /// Runs after each tool executes (observational).
454    pub post_tool_hook: Option<PostToolHook>,
455
456    /// Observes compaction and subagent lifecycle moments (observational).
457    pub lifecycle_hook: Option<LifecycleHook>,
458
459    /// Named prompt templates (skills / slash commands). A user message of the
460    /// form `/<name> <args>` is expanded to the template with `{args}` filled.
461    pub prompts: HashMap<String, String>,
462
463    /// If set, the conversation is compacted once it grows beyond this many
464    /// messages (older middle turns are summarized into one marker), keeping the
465    /// system prompt and the most recent turns.
466    pub compact_after_messages: Option<usize>,
467
468    /// Per-tool enable/disable + description overrides, keyed by tool name.
469    pub tool_overrides: HashMap<String, ToolOverride>,
470
471    /// How tools are advertised to the model (B6). Defaults to [`ToolAdvertising::Full`].
472    pub tool_advertising: ToolAdvertising,
473
474    /// Extra HTTP headers sent with every request (e.g. OpenRouter's
475    /// `HTTP-Referer` / `X-Title` attribution headers).
476    pub extra_headers: HashMap<String, String>,
477
478    /// Optional sink for streaming [`crate::AgentEvent`]s.
479    pub event_sink: Option<EventSink>,
480
481    /// Prompt-caching plan (B7). Defaults to [`CachePlan::Off`]; reduced mode
482    /// (`--reduced`, D5/D14) defaults it to [`CachePlan::ImportedPrefix`]
483    /// (wired at the CLI's reduced-mode assembly point, `crates/cli/src/main.rs`).
484    pub cache_plan: CachePlan,
485
486    /// Resolved optional reduction gates. These are kept separate from the
487    /// live policy because freshness probes and prepared summaries are
488    /// per-request data, not configuration.
489    pub reduction_policy: ReductionPolicySettings,
490
491    /// Whether the explicit reversible handoff projection is available.
492    /// This is separate from [`Self::reduction_policy`] because handoff is
493    /// an offline command over an existing sidecar, not a per-request
494    /// projection pass. Defaults to `true`; only an explicit composable
495    /// `capabilities.reduction.handoff = false` disables it.
496    pub handoff_enabled: bool,
497
498    /// Global tool-schema tier (TR-8/T5): how verbose ADVERTISED tool
499    /// schemas are. Defaults to [`crate::tools::SchemaTier::Full`] (today's
500    /// behavior — byte-identical schemas). A per-tool override in
501    /// [`ToolOverride::schema_tier`] wins over this for that tool. Tool
502    /// definitions are config, never session content, so this never affects
503    /// what's stored or exported — only what's advertised on the wire.
504    pub tool_schema_tier: crate::tools::SchemaTier,
505
506    /// UX-26 (B7-warn): whether [`crate::Agent`] emits
507    /// [`crate::AgentEvent::CacheWarning`] when a turn under
508    /// [`CachePlan::ImportedPrefix`] likely paid a full-price prompt-cache
509    /// miss despite reuse being expected (idle past the provider's TTL, or
510    /// usage reporting a near-zero cache-read ratio). Defaults to `true`
511    /// (on-brand token-economics feedback, on by default like the savings
512    /// figures `inspect stats` already surfaces); the CLI's
513    /// `--no-cache-warnings` flag / `cache_warnings = false` config / the
514    /// `SUPERCODE_CACHE_WARNINGS=0` env var turn it off. A no-op — never
515    /// checked — for any caller not using `CachePlan::ImportedPrefix`, so
516    /// this changes nothing under `CachePlan::Off` (today's default outside
517    /// reduced mode).
518    pub cache_warnings: bool,
519
520    /// P3 (COMPOSABLE-HARNESS-DESIGN.md §5.2 phase P3, mandatory risk-2
521    /// mitigation, §5.3 risk 2): the `[experimental] module_registry` flag.
522    /// `false` (the default) means [`crate::tools::ToolRegistry::from_config`]
523    /// returns EXACTLY [`crate::tools::ToolRegistry::with_builtins`] — the
524    /// runtime path is byte-for-byte today's behavior. Only when explicitly
525    /// turned on does [`Self::module_activation`] start shaping the
526    /// registry/prompt assembly.
527    pub module_registry: bool,
528
529    /// P3: the resolved §2 module-activation set (pure config → set,
530    /// computed by [`crate::configfile::resolve`]/[`crate::modules::ModuleActivation::from_harness`]
531    /// with no agent loop required). Only consulted when
532    /// [`Self::module_registry`] is `true`.
533    pub module_activation: crate::modules::ModuleActivation,
534
535    /// P3: the effective `[core.tools] enabled` list (§3.1) — which of the
536    /// core four (`read_file`/`bash`/`edit_file`/`write_file`, plus any
537    /// future core tool name) are present at all. Defaults to the §1.2
538    /// default-active four, matching [`crate::tools::ToolRegistry::with_builtins`]'s
539    /// unconditional registration. Only consulted when
540    /// [`Self::module_registry`] is `true`.
541    pub core_tools_enabled: Vec<String>,
542
543    /// P3: `[core.skills].enabled` (§1.4 obligation 4, D-7) — whether the
544    /// skills prompt section may appear at all. Still gated by D-7's read
545    /// pathway (`read_file` or `bash` present in [`Self::core_tools_enabled`])
546    /// at the assembly site. Only consulted when [`Self::module_registry`]
547    /// is `true`.
548    pub skills_enabled: bool,
549
550    /// BP-6 (`[core.skills].harness`, catalog D7 "Skill discovery from
551    /// multiple roots"): whose documented skill-root table the LOOP reads
552    /// SKILL.md packages from — a [`crate::HarnessId`] spelling
553    /// (`claude-code`, `codex`, `opencode`, `pi`, …), resolved by
554    /// [`crate::skills::skill_roots`]. `None` (the default) means the loop
555    /// discovers no packages at all and [`Self::skills_enabled`] can only
556    /// index `[core.prompts]` templates, exactly as before BP-6. Only
557    /// consulted when [`Self::module_registry`] is `true`.
558    pub skills_harness: Option<String>,
559
560    /// BP-6 (`[core.skills].dirs`): extra skill roots, merged OVER the
561    /// harness's own defaults — i.e. they win a name collision, since a
562    /// root a config names explicitly is more specific than a discovered
563    /// one. Scanned as `project` scope.
564    pub skills_dirs: Vec<std::path::PathBuf>,
565
566    /// BP-6 (`[core.skills].implicit_match`, cx§7 "implicit
567    /// (description-matched) invocation"): whether a user message that
568    /// merely DESCRIBES a skill loads its body, in addition to the explicit
569    /// `$slug` mention. `false` (the default) is the safe posture: only an
570    /// explicit mention, `/name`, or a `skill` tool call ever spends a
571    /// body's tokens.
572    pub skills_implicit_match: bool,
573
574    /// BP-5 (`[core.skills].shell_injection`, cc§7 "Dynamic context
575    /// injection", `docs:skills#inject-dynamic-context`): whether
576    /// `` !`cmd` `` (and the ```` ```! ```` block form) inside a skill or
577    /// command body is EXECUTED when the body is loaded, its stdout
578    /// replacing the token. `false` (the default) leaves the token as
579    /// literal text — Claude Code's own `disableSkillShellExecution`
580    /// posture, stated positively.
581    ///
582    /// Never an unconditional shell: every extracted command is evaluated
583    /// through the ONE permissions engine ([`crate::permissions`]) with
584    /// this config's own rules, protected paths and approval default, plus
585    /// whatever the body's `allowed-tools` frontmatter pre-approves for
586    /// itself (cc§7 "pre-approved tools while active"). Anything short of
587    /// [`crate::permissions::Decision::Allow`] is refused in place, with
588    /// the reason inlined where the output would have gone.
589    pub skills_shell_injection: bool,
590
591    /// BP-5 (`[core.file_mentions]`, catalog D2 "@-file mentions /
592    /// attachments", cc§2 "`@`-file mentions", cx§2 "`@`-mentions
593    /// (files)"): whether an `@path` token in a user prompt is expanded
594    /// into that file's contents before the turn is sent. `false` (the
595    /// default) leaves `@path` as literal text.
596    ///
597    /// Deny-rule aware (cc§2: "Read deny rules best-effort apply to
598    /// `@file` mentions"): each mention is resolved through the same
599    /// permissions engine a `read_file` call goes through, so a mention of
600    /// a protected path is refused in place rather than silently inlined.
601    pub file_mentions: bool,
602
603    /// BP-5 (`[core.output_style]`, catalog D2 "Output style / personality
604    /// module", cc§7 "Output styles", cx§2 "Personality layer"): the NAME
605    /// of the response-style layer this session runs under — a built-in
606    /// ([`crate::output_style::BUILTIN_STYLES`]) or a markdown file
607    /// discovered from the style roots of the harness
608    /// [`Self::skills_harness`] names. Empty (the default) appends
609    /// nothing.
610    ///
611    /// A style is a prompt-assembly INPUT, not a module with state: it
612    /// contributes one section to the system prompt at construction, and
613    /// nothing else in the loop consults it.
614    pub output_style: String,
615
616    /// BP-5 (`[core.path_rules]`, catalog D2 "Path-scoped rules", cc§2
617    /// "`.claude/rules/*.md`"): whether `<root>/.claude/rules/*.md` rule
618    /// files are loaded. A rule file with no `paths:` frontmatter joins the
619    /// instruction blob at construction; one WITH `paths:` is held back and
620    /// injected only when a tool touches a file matching one of its globs
621    /// (the same on-demand door `core.nested_instructions` uses).
622    /// `false` (the default) reads no rule directory at all.
623    pub path_rules: bool,
624
625    /// BP-5 (`[capabilities.model_catalog].base_prompts`, catalog D2
626    /// "Per-model-family base-prompt selection", cx§2 "Per-model base
627    /// instructions"): model-id GLOB → the base system prompt that family
628    /// gets, replacing [`Self::system_prompt`] when it matches.
629    ///
630    /// The most SPECIFIC match wins (longest pattern), so the table is
631    /// order-independent — a TOML table has no order to rely on. No match
632    /// (and an empty table, the default) leaves `system_prompt` exactly as
633    /// it was, so this is a no-op for every config that doesn't set it.
634    /// Re-selected on [`crate::Agent::set_model`], the way cx re-selects
635    /// `base_instructions` when the model changes.
636    pub model_family_prompts: std::collections::BTreeMap<String, String>,
637
638    /// P4 (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §3.1
639    /// `capabilities.model_catalog.small_model`, catalog §4a "Small/utility
640    /// model routing knob"): a cheaper/faster model id a caller (e.g. a
641    /// [`crate::reduce::summarize::SpanSummarizer`] implementation, or an
642    /// auto-title side-call) MAY use instead of [`Self::model`] for
643    /// low-stakes side-calls. `None` (the default) means every such
644    /// consumer falls back to the main model — the exact §2.1 D-9 fallback
645    /// behavior — since nothing in this crate resolves this field on its
646    /// own; it is a knob a caller reads, not a routing loop this crate runs.
647    pub small_model: Option<String>,
648
649    /// P4 (§3.1 `capabilities.model_catalog.fallback`, catalog §4a "Model
650    /// aliases + failure fallback chain"): an ordered list of full model
651    /// slugs a caller MAY retry against, in order, if [`Self::model`] fails.
652    /// Empty (the default) means no fallback chain is configured. Like
653    /// [`Self::small_model`], this is the resolved TABLE only — see
654    /// [`crate::model_catalog`]'s module doc for the scope boundary between
655    /// "a resolved list of slugs" (this field, S-sized) and an actual
656    /// retry/failover loop that consumes it (BP-13's
657    /// `Agent::run_loop` fallback pass, which consumes exactly this list).
658    pub model_fallback: Vec<String>,
659
660    /// BP-13 (catalog Domain 9): the resolved MODEL-ROUTING table —
661    /// aliases (including patterns and provider/account scopes), per-model
662    /// effort levels, thinking budgets, service tiers, tool-shape
663    /// capability bits, and the config-layer allow/deny lists. Every
664    /// routing decision in the product asks this one value:
665    /// [`crate::Agent`]'s request build (effort, budget, tier), its
666    /// fallback pass, [`crate::tools::ToolRegistry::from_config`]'s
667    /// write-surface selection, and the CLI's `--model`/`/model` alias
668    /// expansion. Default-empty, which resolves exactly like the built-in
669    /// alias table alone did before this field existed.
670    pub model_routing: crate::model_catalog::Routing,
671
672    /// BP-13 (catalog D9 "Fast mode / service tiers"): a session-level
673    /// service-tier override — what `/fast` sets. It WINS over the
674    /// per-model `[capabilities.model_catalog] service_tier` rule, because
675    /// it is the live toggle the user just pulled; `None` (the default)
676    /// leaves the configured rule in force, and if there is no rule either
677    /// the request carries no `service_tier` field at all.
678    pub service_tier: Option<String>,
679
680    /// P4b (COMPOSABLE-HARNESS-DESIGN.md design doc S5.2 "P4", S1.4/S3.1
681    /// `core.env_context`, catalog S4a "Environment context block
682    /// injection"): when `true`, `Agent::with_parts` appends a short
683    /// `# Environment` block (cwd, platform, date, best-effort git branch)
684    /// to the system prompt, alongside `Self::load_project_context`'s
685    /// instruction files. `false` (the default) is byte-identical to
686    /// today's behavior.
687    pub env_context: bool,
688
689    /// P4b (S1.4/S3.1 `core.project_root_markers`, catalog:232): filenames
690    /// (or directory names) that mark a directory as the project root.
691    /// Defaults to `[".git"]`.
692    ///
693    /// BP-9 gave this knob its reader: [`project_root_for`] is the single
694    /// shared ancestor walk every consumer goes through — the
695    /// `Self::env_context` git-status probe (which now reports the ROOT's
696    /// branch, not a subdirectory's), the CLI's `.supercode.toml`
697    /// discovery walk (which stops at the root instead of climbing to `/`),
698    /// and (BP-4) prompt assembly's instruction walk, whose climb from
699    /// `Self::cwd` ends at the first directory carrying one of these
700    /// (`agent::instruction_walk_roots`) — cx's own `project_root_markers`
701    /// semantics (cx§2). Adding a marker
702    /// (`project_root_markers = [".git", ".hg", "package.json"]`) therefore
703    /// changes where all of them stop, which is the catalog's semantics
704    /// ("configurable markers defining the project root").
705    pub project_root_markers: Vec<String>,
706
707    /// P4b (S1.4/S3.1 `core.project_doc_max_bytes`, cx2 "project_doc_max_bytes"
708    /// analog, S5.2 P4 "instruction-walk nuances"): a hygiene cap on
709    /// instruction-file content (`Self::load_project_context`'s global +
710    /// project tiers) appended to the system prompt. `None` (the default) is
711    /// uncapped -- byte-identical to today's behavior; only an explicit
712    /// `Some(n)` truncates (with a trailing notice), mirroring
713    /// `Self::max_tool_output_bytes`'s cap-with-notice shape. BP-4: the cap
714    /// binds TWICE -- per FILE (no single instruction file may consume the
715    /// whole budget and starve the nearer files that win precedence by
716    /// coming after it) and then over the assembled AGGREGATE, which is the
717    /// total-bytes reading cx documents (default 32 KiB).
718    pub project_doc_max_bytes: Option<usize>,
719
720    /// BP-4 (catalog:87 "Instruction-file hygiene controls", cc2
721    /// `claudeMdExcludes`): glob/absolute-path patterns naming instruction
722    /// files to SKIP (monorepo hygiene). A pattern is matched against the
723    /// file's bare name, its full path, and its path relative to the tier
724    /// root it was discovered under. Empty (the default) excludes nothing.
725    pub project_doc_excludes: Vec<String>,
726
727    /// BP-4 (catalog:87, cc2 "HTML comment stripping"): when `true`,
728    /// block-level `<!-- ... -->` spans are dropped from every instruction
729    /// file before injection, so maintainer notes cost no tokens. `false`
730    /// (the default, and what cx does -- Codex strips nothing) is
731    /// byte-identical to today's behavior.
732    pub project_doc_strip_comments: bool,
733
734    /// P4b (S1.4/S3.1 `core.instruction_imports`, catalog:85): when `true`,
735    /// an instruction file may reference another file via an `@relative/path`
736    /// token (CC's import syntax) -- the referenced file's contents are
737    /// inlined in its place, resolved relative to the IMPORTING file's own
738    /// directory, to a max depth of 4 (CC's own default) to bound cycles.
739    /// `false` (the default) leaves `@` tokens as plain literal text --
740    /// byte-identical to today's behavior.
741    pub instruction_imports: bool,
742
743    /// P4b (S1.1/S3.1 `core.retry`, pi3 shape): whether a transient
744    /// (connection failure / 5xx) provider error is retried at all. This
745    /// EXTENDS a pre-existing, always-on transport-layer mechanism
746    /// (`provider::OpenAiProvider`'s internal `HttpOptions` retry — 2
747    /// attempts / 500ms base backoff, hardcoded, not previously
748    /// config-file-settable) rather than adding a second one: `true` (the
749    /// default, matching today's always-on behavior byte-for-byte when
750    /// `Self::retry_max_retries`/`Self::retry_base_delay_ms` are also both
751    /// unset) keeps retrying; an explicit `false` is a NEW capability —
752    /// disabling the transport retry entirely.
753    pub retry_enabled: bool,
754    /// Override the transport retry's attempt count. `None` (the default)
755    /// keeps the pre-existing built-in default (2).
756    pub retry_max_retries: Option<u32>,
757    /// Override the transport retry's base backoff delay in milliseconds
758    /// (doubles per attempt). `None` (the default) keeps the pre-existing
759    /// built-in default (500ms).
760    pub retry_base_delay_ms: Option<u64>,
761
762    /// P4b (S1.5/S3.1 `core.compaction.reserve_tokens`, pi2 shape): once
763    /// set, `Agent::maybe_compact` ALSO triggers when the estimated token
764    /// size of the live history is within `reserve_tokens` of the model's
765    /// context window -- in addition to (not instead of)
766    /// `Self::compact_after_messages`'s message-count trigger. `None` (the
767    /// default) leaves the pressure trigger off -- byte-identical to today's
768    /// message-count-only behavior.
769    pub compaction_reserve_tokens: Option<u64>,
770    /// P4b (S1.5/S3.1 `core.compaction.keep_recent_tokens`): when the
771    /// PRESSURE trigger (not the message-count one) fires, how many of the
772    /// most recent tokens (estimated) to keep verbatim instead of a fixed
773    /// message count. Only consulted when `Self::compaction_reserve_tokens`
774    /// is `Some` and the pressure trigger is what fired.
775    pub compaction_keep_recent_tokens: Option<u64>,
776    /// P4b (S1.5/S3.1 `core.compaction.focus_instructions`, catalog D2 "no
777    /// instruction steering" gap): free text appended to the synthetic
778    /// compaction marker message every time compaction fires (either
779    /// trigger), steering the model on what to keep focusing on
780    /// post-compaction (CC's manual-compact `/compact <focus>` analog).
781    /// `None` (the default) leaves the marker text byte-identical to
782    /// today's.
783    pub compaction_focus_instructions: Option<String>,
784
785    /// P4b (S1.6/S3.1 `core.session.auto_title`, catalog:150, D-9): whether
786    /// `crate::session_title::auto_title` may be invoked at all by a caller
787    /// (the caller still supplies the `SessionTitler` side-call itself --
788    /// this is only the gate, mirroring `Self::small_model`'s "a knob a
789    /// caller reads" framing). `false` (the default): callers should treat
790    /// auto-title as off.
791    pub auto_title: bool,
792
793    /// P4b (S1.7/S3.1 `core.steering`, pi3 semantics): how queued mid-turn
794    /// steering messages (`Agent::queue_steer`) are drained -- `All`
795    /// delivers every queued message at once, `OneAtATime` (the default)
796    /// delivers one per drain point.
797    pub steering_mode: SteeringMode,
798    /// P4b (S1.7/S3.1 `core.steering.follow_up_mode`): how queued follow-up
799    /// messages (`Agent::queue_follow_up`) are drained once the loop is
800    /// otherwise idle (no more tool calls pending).
801    pub follow_up_mode: SteeringMode,
802
803    /// P4b (S1.9/S3.1 `[core] stop_gate`, D3 "stop/completion gating", CC
804    /// Stop-hook semantics cc3): consulted exactly once per `run_loop`
805    /// iteration that would otherwise return a final answer (no more tool
806    /// calls pending, and the follow-up queue is empty). Receives the
807    /// would-be-final assistant message; `Some(reason)` VETOES termination
808    /// -- `reason` is injected as a new user message and the loop continues
809    /// (still bounded by `Self::max_iterations`); `None` allows the stop.
810    /// Code-only, like `Self::pre_tool_hook`/`Self::post_tool_hook` --
811    /// the CLI's declarative `[hooks] stop = "cmd"` form (module 17)
812    /// populates this SAME single slot rather than adding a second call
813    /// site, so the two can never double-fire (S2 module 17's "hooks layer
814    /// on core's gate" note). `None` (the default) is byte-identical to
815    /// today's behavior.
816    pub stop_gate: Option<StopGateHook>,
817
818    /// P4c (COMPOSABLE-HARNESS-DESIGN.md S1.2/S3.1 `core.tools.read_file
819    /// multimodal`, catalog S4a "Multimodal read (image passthrough on
820    /// `read_file`)"): when `true`, `read_file` returns a recognized image
821    /// file (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`/`.bmp`) as a model-visible
822    /// image content block instead of decoding it as (garbled) UTF-8 text.
823    /// `false` (the default) is byte-identical to today's behavior.
824    pub read_file_multimodal: bool,
825
826    /// BP-2 (S1.2/S3.1 `core.tools.read_file.line_numbers`, catalog:26
827    /// "Dedicated read with offset/limit, `cat -n` style output"): when
828    /// `true`, every line `read_file` returns carries a right-aligned
829    /// 1-based line number and a tab, numbered from the requested `offset`
830    /// so the model can cite real file line numbers. `false` (the default)
831    /// is byte-identical to today's raw-slice behavior -- the harnesses
832    /// whose presets do NOT number lines (Codex reads through `cat`) must
833    /// keep the unnumbered output their models were trained on.
834    pub read_file_line_numbers: bool,
835
836    /// P4c (S1.2/S3.1 `core.tools.edit_file.require_read_before_edit`,
837    /// UNIQUE CC row, catalog:32): when `true`, `edit_file` refuses unless
838    /// the target path was read (via `read_file`) earlier in this same
839    /// conversation -- tracked in `ToolContext`. `false` (the default) is
840    /// byte-identical to today's behavior.
841    pub edit_file_require_read_before_edit: bool,
842
843    /// P4c (S1.2/S3.1 `core.tools.edit_file.notebook_aware`, UNIQUE CC row
844    /// "NotebookEdit", catalog:40): when `true`, `edit_file` additionally
845    /// accepts Jupyter cell replace/insert/delete operations against a
846    /// `.ipynb` target (see `tools::builtins::EditFileTool`'s cell-op args)
847    /// instead of only the exact-string replace it always supports. `false`
848    /// (the default) is byte-identical to today's behavior.
849    pub edit_file_notebook_aware: bool,
850
851    /// P4c (S1.2/S3.1 `core.shell_env_snapshot`, SPLIT CC+CX row,
852    /// catalog:338): when `true`, `Agent::new`/`with_parts` captures the
853    /// user's interactive login-shell environment ONCE at construction
854    /// (`$SHELL -lc env`, best-effort) and every `bash` call inherits it
855    /// directly instead of needing to re-source shell rc files per call.
856    /// `false` (the default) is byte-identical to today's behavior -- no
857    /// snapshot is captured, and `bash` sees only the ambient process
858    /// environment, exactly as before this landed.
859    pub shell_env_snapshot: bool,
860
861    /// P4c (S5.2 P4 "doom-loop breaker", oc `doom_loop` UNIQUE row,
862    /// catalog D3): when `Some(n)` with `n >= 2`, a tool call whose name AND
863    /// arguments are byte-identical to the previous `n - 1` consecutive
864    /// calls is refused (fed back to the model as an error) instead of
865    /// executed -- the counter resets the moment a call differs. `None`
866    /// (the default) is byte-identical to today's behavior: no repetition
867    /// tracking, no call is ever refused on this basis.
868    pub doom_loop_threshold: Option<u32>,
869
870    /// P4c (S1.4/S3.1 `core.nested_instructions`, catalog:84, deferred from
871    /// P4b): when `true`, a `read_file`/`edit_file` call that touches a path
872    /// inside a subdirectory carrying its OWN `CLAUDE.md`/`AGENTS.md` (a
873    /// directory other than `Config.cwd` itself, which
874    /// `Self::load_project_context` already loads once at session start)
875    /// appends that subdirectory's instructions to the tool's OWN result the
876    /// FIRST time a path under it is touched this conversation (deduped
877    /// thereafter -- tracked in `ToolContext`, mirrors CC/OC's "auto-attach
878    /// on read, deduped" semantics, catalog:84). Reuses the same
879    /// canonicalize+containment safety check P4b's `@`-import expansion
880    /// uses (`agent::import_target_is_contained`) so a symlink cannot walk
881    /// the injection outside `Config.cwd`. `false` (the default) is
882    /// byte-identical to today's behavior.
883    pub nested_instructions: bool,
884
885    /// P4c (S1.10/S3.1 `core.model_switch.allow_switch`, D9 row, dep 8):
886    /// gates whether `Agent::switch_model` does more than the pre-existing
887    /// `Agent::set_model` mechanics (design's "UX-30 dev/02" -- swap
888    /// `Config.model` for the next request, nothing else touched). `false`
889    /// (the default) makes `switch_model` byte-identical to calling
890    /// `set_model` directly: no persisted `model_change` record, no
891    /// reasoning-artifact filtering. `true` additionally (1) appends a
892    /// typed `model_change::ModelChangeRecord` to
893    /// `Agent::model_change_records`, and (2) runs
894    /// `reduce::rehydrate::filter_reasoning_artifacts` over `Agent::history`
895    /// so model-A's reasoning/thinking artifacts (`ChatMessage::metadata`
896    /// keys and any `content_parts` reasoning blocks) never reach
897    /// model-B's context (S1.13, dep 8).
898    pub model_switch_allow_switch: bool,
899
900    /// BP-13 (§3.1 `core.model_switch.notice`, D9 "Mid-session model
901    /// switching"): when the model changes mid-session, splice a short
902    /// user-role notice naming the old and new model (and, for an automatic
903    /// fallback hop, the failure that caused it) into the live
904    /// conversation, so the incoming model reads the handoff rather than
905    /// inferring it from a style break. This is Codex's behavior (cx§9
906    /// "switch instructions injected"); Claude Code switches silently, so
907    /// `false` (the default) is byte-identical to pre-BP-13 behavior and
908    /// each preset states which harness it imitates.
909    pub model_switch_notice: bool,
910
911    /// BP-13 (§3.1 `capabilities.plan_mode.effort`, catalog D9 "Reasoning
912    /// effort / thinking budgets" — the plan-mode half): the reasoning
913    /// effort to send WHILE plan mode is active. Codex's `/plan` is
914    /// effort-tier steering (cx§6), so planning and executing are not
915    /// obliged to think at the same level. `None` (the default) leaves plan
916    /// mode with no effort of its own, byte-identical to pre-BP-13
917    /// behaviour. Applied by `Agent::apply_routing`, which asks the live
918    /// `PlanModeState` — so it turns itself on and off with the mode, and
919    /// is still clamped by whatever effort cap applies.
920    pub plan_mode_effort: Option<String>,
921
922    /// P4e (§1.4/§3.1 `core.context_injections`, catalog:91 "Synthetic
923    /// context-injection blocks"): the master gate for
924    /// [`Self::context_injection_blocks`] -- when `false` (the default),
925    /// `Agent::with_parts` never appends any of them, byte-identical to
926    /// today's behavior. `true` splices in whatever named blocks are set,
927    /// at the same assembly site P4b's `env_context` block uses, right
928    /// after it.
929    pub context_injections: bool,
930    /// P4e: named ambient context blocks a caller/embedder populates
931    /// programmatically (mirrors `Self::prompts`/`Self::stop_gate`'s
932    /// code-extensible shape) -- there is no `[core.context_injections.*]`
933    /// FILE table because the §3.1 schema's `core.context_injections` key
934    /// is already a scalar boolean gate, and TOML forbids a key being both
935    /// scalar and table (the same S-fix documented on
936    /// `[core.model_switch]`). Consulted only when
937    /// [`Self::context_injections`] is `true`; empty (the default) is a
938    /// no-op even then. Each block is appended verbatim as `\n\n# {name}\n{content}`,
939    /// in list order.
940    pub context_injection_blocks: Vec<ContextInjectionBlock>,
941
942    /// P4e (§1.5/§3.1 `core.compaction.enabled`, "no master gate exists
943    /// yet"): the master on/off switch for ALL auto-compaction
944    /// (`Agent::maybe_compact`), composing with -- not replacing -- the
945    /// existing `Self::compact_after_messages`/`Self::compaction_reserve_tokens`/
946    /// `Self::compaction_keep_recent_tokens` triggers: `false` disables
947    /// every trigger unconditionally; `true` (the default, matching
948    /// today's behavior, where nothing has ever gated compaction) changes
949    /// nothing -- whichever triggers are configured still fire exactly as
950    /// before.
951    pub compaction_enabled: bool,
952
953    /// BP-1 (§1.5/§3.1 `core.compaction.summarize`): whether a compacted
954    /// span is replaced by a marker that states it was SUMMARIZED, or by
955    /// one that only states it was cleared. `true` (the default, and what
956    /// every built-in preset sets) is today's marker text, byte-identical.
957    /// Scope note, so this key cannot be over-read: the model-written
958    /// summary side-call itself is `capabilities.reduction.span_summaries`'
959    /// installed `SpanSummarizer` (D-9, `Agent::set_span_summarizer`) and
960    /// is NOT armed by this key -- `core.compaction.summarize` is the
961    /// core-compaction statement about what the compaction marker claims,
962    /// which is exactly what `Agent::maybe_compact` writes.
963    pub compaction_summarize: bool,
964
965    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59 "Independent
966    /// sibling calls run concurrently"): when `true` and an assistant turn
967    /// requests more than one tool call, `Agent::run_loop` runs their
968    /// `Tool::execute` futures CONCURRENTLY via `Self::run_tools_concurrently`
969    /// instead of one at a time -- see that method's doc comment for
970    /// exactly which part of dispatch stays strictly sequential (approval /
971    /// doom-loop / pre-tool-hook checks, and every `record`/`history`
972    /// append, which the lossless sidecar's append-order invariant, S1.13,
973    /// requires to stay deterministic). `false` (the default) is
974    /// byte-identical to today's sequential-await-per-call loop.
975    pub parallel_tool_calls: bool,
976
977    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331 "Git branch/
978    /// sha captured … closes the loop" -- the WRITE half; supercode already
979    /// preserves a foreign session's own `gitBranch`-shaped fields
980    /// verbatim on IMPORT via `Session::raw`'s byte-for-byte capture).
981    /// When `true`, `Agent::with_parts` captures a
982    /// `git_metadata::GitMetadataRecord` (best-effort branch/sha/dirty,
983    /// like `Self::env_context`'s git probe) once at construction, readable
984    /// via `Agent::git_metadata` and persistable via
985    /// `Agent::save_git_metadata`. `false` (the default) is byte-identical
986    /// to today's behavior: no capture, `Agent::git_metadata()` is always
987    /// `None`.
988    pub session_git_metadata: bool,
989
990    /// P4e (§1.6/§3.1 `core.session.dir`): overrides the session store's
991    /// root directory. A caller-read knob (like `Self::small_model`) --
992    /// the CLI's `session_store()` (main.rs) is the consumer. `None` (the
993    /// default) leaves the CLI's own default (`$SUPERCODE_HOME/sessions`)
994    /// untouched.
995    pub session_dir: Option<String>,
996    /// P4e (§1.6/§3.1 `core.session.persist`, D5 row): whether a caller
997    /// should persist this session to the store at all. A caller-read gate
998    /// only -- `Agent`/`Config` never call `SessionStore` directly (no
999    /// `SessionStore` handle lives on `Config`); a caller checks this
1000    /// field directly before calling `store.save(...)`, the same
1001    /// "mechanism vs. gate" split `Self::auto_title` established. `true`
1002    /// (the default) matches today's behavior: every caller that already
1003    /// calls `store.save(...)` keeps doing so unconditionally.
1004    pub session_persist: bool,
1005    /// P4e (§1.6/§3.1 `core.session.name`): an explicit session name a
1006    /// caller should use instead of auto-minting one (the CLI's
1007    /// `mint_session_name`). A caller-read knob, same posture as
1008    /// `Self::session_dir`. `None` (the default) leaves auto-naming
1009    /// untouched.
1010    pub session_name: Option<String>,
1011    /// P4e (§1.6/§3.1 `core.session.retention_days`): the archive-pruning
1012    /// window `store::SessionStore::prune_expired` consults. `None` (the
1013    /// default) means "never prune" -- byte-identical to today's behavior
1014    /// (nothing ever prunes automatically).
1015    pub session_retention_days: Option<u32>,
1016    /// P4e (§1.6/§3.1 `core.session.export_format`, catalog:283 "transcript
1017    /// export for humans"): `text` | `html`, consumed by
1018    /// `human_export::render_transcript`. Defaults to
1019    /// [`crate::human_export::HumanExportFormat::Text`].
1020    pub session_export_format: crate::human_export::HumanExportFormat,
1021    /// BP-8 (§3.1 `core.session.append_only`, catalog:150 "Append-only
1022    /// durable transcript"): whether the caller arms a
1023    /// `crate::session_journal::SessionJournal` on this agent, so every
1024    /// message is written and FLUSHED the instant it exists rather than at
1025    /// the end of the turn. A caller-read gate, the same "mechanism vs.
1026    /// gate" split `Self::session_persist` established — `Agent` owns the
1027    /// journal once one is installed (`Agent::set_journal`), but never
1028    /// opens a store itself. `false` (the default) is byte-identical to
1029    /// pre-BP-8 behavior: no journal file is ever created.
1030    pub session_append_only: bool,
1031    /// BP-8 (§3.1 `core.session.queue_persist`, catalog:154
1032    /// "Queued-prompt persistence"): whether pending steering / follow-up
1033    /// inputs are recorded in the journal as queue operations, so a
1034    /// prompt typed while the agent was busy survives a crash or restart.
1035    /// Meaningless without [`Self::session_append_only`] (the journal is
1036    /// the only place a queue operation is written). `false` (the default)
1037    /// is byte-identical to pre-BP-8 behavior: both queues stay purely
1038    /// in-memory.
1039    pub session_queue_persist: bool,
1040    /// BP-8 (§2 module `todos` `persist`, catalog:156 "Todos/plan persisted
1041    /// per session"): whether the `update_plan` checklist is written to the
1042    /// session store (and restored on resume) rather than living only in
1043    /// the tool's own mutex for the lifetime of the process. `false` (the
1044    /// default) is byte-identical to pre-BP-8 behavior.
1045    pub todos_persist: bool,
1046
1047    /// P5-1 (§3.1 `capabilities.permissions.enabled`, module 10/11
1048    /// activation): the master gate for `crate::permissions` — when `false`
1049    /// (the default), `Agent::prepare_tool_call`'s tool-dispatch gate uses
1050    /// EXACTLY the pre-P5-1 [`Self::needs_approval`] path, byte-for-byte —
1051    /// no behavior change. `true` switches the gate to the richer
1052    /// canonicalized-command-aware [`crate::permissions::rules`] engine
1053    /// (deny→ask→allow first-match, C5), consulting
1054    /// [`Self::permissions_ask_patterns`] (together with the pre-existing
1055    /// [`Self::tool_deny_patterns`]/[`Self::tool_allow_patterns`] as the
1056    /// engine's deny/allow tiers) and [`Self::permissions_protected_paths`].
1057    pub permissions_enabled: bool,
1058
1059    /// P5-1 (§3.1 `capabilities.permissions.rules.ask`, module 11): the
1060    /// engine's `ask` tier — the sibling of the pre-existing
1061    /// [`Self::tool_deny_patterns`]/[`Self::tool_allow_patterns`] (P4),
1062    /// which become the engine's `deny`/`allow` tiers respectively when
1063    /// [`Self::permissions_enabled`] is on (see
1064    /// `crate::permissions::rules::RuleSet`). Empty by default. Only
1065    /// consulted when [`Self::permissions_enabled`] is `true`.
1066    pub permissions_ask_patterns: Vec<String>,
1067
1068    /// P5-1 (§3.1 `capabilities.permissions.protected_paths.paths`, module
1069    /// 13): glob patterns that are an unconditional DENY floor for both
1070    /// read and write access (cc§4 "never auto-approved… `.git/**`,
1071    /// `.env*`, …"), expanded via
1072    /// [`crate::permissions::rules::protected_path_deny_rules`] into the
1073    /// engine's `deny` tier. Empty by default. Only consulted when
1074    /// [`Self::permissions_enabled`] is `true`.
1075    ///
1076    /// **Honesty note on coverage (F4, Fable-5 adversarial review):** at
1077    /// the rule-engine layer this floor is enforced for (a) `read_file`/
1078    /// `write_file`/`edit_file`-shaped path calls, (b) a `bash`/`shell`
1079    /// command's direct output/input redirect targets (`>`, `>>`, `&>`,
1080    /// `>|`, `&>>`, `<`), (c) `apply_patch`'s target path(s), and (d) a
1081    /// best-effort set of known argv-writers (`tee`, `dd of=`, `cp`/`mv`/
1082    /// `install`, `sed -i`, `truncate`, `ln`) — see
1083    /// `crate::permissions::canon::known_writer_targets`'s doc comment for
1084    /// that heuristic's named gaps. A write this rule layer genuinely
1085    /// cannot statically resolve (an opaque wrapper — `eval`, `sh -c`, …
1086    /// — or a dynamic `$VAR`/`` `cmd` `` target) is forced to at least
1087    /// `Ask`, never silently `Allow`. What this layer does NOT provide is
1088    /// COMPLETE OS-level write confinement of arbitrary bash — that is
1089    /// `capabilities.permissions.sandbox`'s job (P5 module 10, a later
1090    /// unit), not this one's.
1091    pub permissions_protected_paths: Vec<String>,
1092
1093    /// P5-1 (§3.1 `capabilities.permissions.sandbox.network.*`, module 12
1094    /// carry-forward): the domain allow/deny policy `crate::tools::WebFetchTool`/
1095    /// `WebSearchTool` enforce via `crate::tools::ToolContext::check_network`
1096    /// — the enforcement POINT already existed (P4c); this is its real
1097    /// config source (`crate::configfile::materialize_config`). `None` (the
1098    /// default) is byte-identical to today's behavior: no policy is
1099    /// enforced, exactly the honest gap `NetworkPolicy`'s own doc comment
1100    /// (`crate::tools`) already names.
1101    pub network_policy: Option<crate::tools::NetworkPolicy>,
1102
1103    /// BP-10 (`capabilities.permissions.approvals.persist`, catalog row
1104    /// "Session approval caching"): whether an `AllowForSession` grant is
1105    /// remembered ACROSS processes, in a per-project store beside the
1106    /// session's other records (`crate::permissions::default_approval_store`).
1107    /// `false` (the default, and every config that never sets the key)
1108    /// keeps the pre-BP-10 in-memory cache: nothing is written, nothing is
1109    /// read, a new process re-asks.
1110    pub permissions_approvals_persist: bool,
1111
1112    /// BP-10 (§2 module 14 `trust`, catalog row "Project/workspace trust
1113    /// gate"): the door the workspace-trust question is asked on — the
1114    /// SAME [`crate::permissions::PermissionsApprovalHandler`] every other
1115    /// `Ask` in this crate uses. `None` (the default) means no interactive
1116    /// trust door is attached, which `crate::trust::is_trusted` resolves
1117    /// per surface: config-declared CODE is refused, project instruction
1118    /// TEXT is loaded (see that module's doc comment).
1119    ///
1120    /// Lives on `Config` rather than on `Agent` because every surface trust
1121    /// gates is decided before or during `Agent` construction — a handler
1122    /// installed afterwards could never be asked.
1123    pub trust_handler: Option<std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>>,
1124
1125    /// BP-10 (embedder/test override): where this project's recorded trust
1126    /// decision lives. `None` (the default) uses
1127    /// `crate::trust::default_trust_store` — the same
1128    /// `$SUPERCODE_HOME`/project-tag layout the checkpoint store and the
1129    /// persisted approval cache use.
1130    pub trust_store: Option<PathBuf>,
1131
1132    /// BP-10 (embedder/test override): where the persisted approval cache
1133    /// lives, when [`Self::permissions_approvals_persist`] is on. `None`
1134    /// (the default) uses `crate::permissions::default_approval_store` —
1135    /// the same `$SUPERCODE_HOME`-derived, per-project-tag layout
1136    /// [`Self::checkpoint_dir`] falls back to.
1137    pub permissions_approval_store: Option<PathBuf>,
1138
1139    /// P5-10 (§3.1 `capabilities.permissions.sandbox.enabled`, module 12):
1140    /// whether the OS-level backstop (Landlock on Linux, seatbelt on
1141    /// macOS) is engaged for the `bash`/`shell` subprocess. `None` (the
1142    /// default — unset by the bare `sandbox = "<tier>"` shorthand, or a
1143    /// CLI `--sandbox` flag, neither of which touch this table key) keeps
1144    /// the PRE-P5-10 trigger byte-identical: `crate::sandbox::
1145    /// os_sandbox_active` falls back to "confine whenever the tier isn't
1146    /// `DangerFullAccess`", exactly what the macOS seatbelt path already
1147    /// did off `Self::sandbox` alone. `Some(false)` (the table form's
1148    /// explicit opt-out — `cc-parity`'s posture) turns the OS backstop off
1149    /// even for a confining tier; `Some(true)` forces it on.
1150    pub sandbox_os_enabled: Option<bool>,
1151
1152    /// P5-10 (§3.1 `capabilities.permissions.sandbox.escalation`, module
1153    /// 12): what happens when a confining fs tier is requested but this
1154    /// platform/kernel can't enforce it — see
1155    /// [`crate::sandbox::SandboxEscalation`]. Defaults to `Deny`
1156    /// (fail-closed), matching `capabilities.permissions.sandbox`'s own
1157    /// `escalation = "deny"` config default.
1158    pub sandbox_escalation: crate::sandbox::SandboxEscalation,
1159
1160    /// P5-10 (§3.1 `capabilities.permissions.sandbox.env_policy`, module
1161    /// 12): child-process environment sanitization for the spawned
1162    /// `bash`/`shell` subprocess — see
1163    /// [`crate::sandbox::SandboxEnvPolicy`]. Defaults to `Inherit`
1164    /// (byte-identical to pre-P5-10 behavior: the full environment passes
1165    /// through unchanged).
1166    pub sandbox_env_policy: crate::sandbox::SandboxEnvPolicy,
1167
1168    /// P5-3 (§3.1 `capabilities.subagents.enabled`, module 9 activation):
1169    /// the master gate for the `spawn_subagent`/`subagent_status` agent
1170    /// the master gate for the `spawn_subagent`/`subagent_status` agent
1171    /// intrinsics — when `false` (the default), `Agent::tool_schemas` never
1172    /// advertises them and `Agent::run_tool`'s interception is a pure
1173    /// pass-through to the pre-P5-3 dispatch, byte-for-byte unchanged.
1174    pub subagents_enabled: bool,
1175
1176    /// BP-7 (§2 module 7 `todos`, §3.1 `capabilities.todos.goals`, catalog
1177    /// §4a "Goals (persistent objective across turns)"): whether the
1178    /// session carries a standing objective — `/goal`, persisted as
1179    /// `<session>.goal.json`, restated at the tail of every request while
1180    /// it stands. Default `false`: an agent that never turns the knob on
1181    /// behaves exactly as before.
1182    pub goals_enabled: bool,
1183    /// P5-3 (§3.1 `capabilities.subagents.max_depth`, resource bound): the
1184    /// maximum spawn-tree depth — a depth-`max_depth` agent may not spawn
1185    /// (its child would land at `max_depth + 1`). Only consulted when
1186    /// [`Self::subagents_enabled`] is `true`.
1187    pub subagents_max_depth: usize,
1188    /// P5-3 (resource bound, NOT in the §3.1 illustrative schema snippet —
1189    /// added per the build brief's explicit "max concurrent subagents…
1190    /// cap, fail-closed... configurable"): the maximum number of subagents
1191    /// in flight anywhere in one spawn tree at once (root-to-leaf, shared
1192    /// via [`crate::agent::Agent`]'s concurrency gauge). Only consulted
1193    /// when [`Self::subagents_enabled`] is `true`.
1194    pub subagents_max_concurrent: usize,
1195    /// P5-3 (§3.1 `capabilities.subagents.background`): whether
1196    /// `spawn_subagent`'s `background: true` argument is honored at all —
1197    /// `false` (the default) refuses every background spawn regardless of
1198    /// [`Self::subagents_background_prompts`].
1199    pub subagents_background: bool,
1200    /// P5-3 (§2.2 C6, §3.1 `capabilities.subagents.background_prompts`):
1201    /// the auto-policy a background child's tool approvals route through.
1202    /// `None` (the default) means a background spawn is refused
1203    /// (`Error::SubagentBackgroundPolicyMissing`) — a detached child must
1204    /// never reach an interactive prompt it can't answer.
1205    pub subagents_background_prompts: Option<crate::subagents::BackgroundPromptsPolicy>,
1206    /// Claude Code emulation: advertise and accept its `Agent` tool name and
1207    /// argument vocabulary in addition to Supercode's native
1208    /// `spawn_subagent` intrinsic. Default `false`; enabled only for an
1209    /// explicitly imported Claude continuation.
1210    pub subagents_claude_agent_alias: bool,
1211    /// Claude Code resume compatibility for the scheduler-shaped
1212    /// `CronCreate`/`CronDelete`/`CronList`/`ScheduleWakeup` intrinsics.
1213    /// The imported manifest is always paused and these tools only mutate
1214    /// that inert state; no timer is started. Default `false` so ordinary
1215    /// agents do not gain a harness-specific tool surface.
1216    pub claude_runtime_tools_enabled: bool,
1217    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`, D3 "named-defs"):
1218    /// named subagent types, keyed by the name the model passes as
1219    /// `spawn_subagent`'s `agent_type` argument.
1220    pub subagents_definitions: HashMap<String, crate::subagents::NamedAgentDefinition>,
1221    /// P5-3 (runtime-only, NEVER set from a config file — only
1222    /// `Agent::run_spawn_subagent` sets it on a freshly-built CHILD
1223    /// `Config` before constructing that child): how deep in the spawn
1224    /// tree the agent built from this `Config` is. `0` is a top-level
1225    /// agent; a config file / [`ConfigBuilder`] caller that never spawns
1226    /// leaves this at its `0` default.
1227    pub subagent_depth: usize,
1228
1229    /// P5-4 (§3.1 `capabilities.tui.enabled`, module 30 activation, §1.9
1230    /// recorded deviation): the master gate for the full-screen TUI —
1231    /// when `false` (the default), `crates/cli`'s `chat()` runs the
1232    /// pre-P5-4 rustyline REPL loop byte-for-byte, and every P5-4 seam
1233    /// below (`Agent::set_permissions_approval_handler`/
1234    /// `Agent::set_child_approval_handler_factory`/
1235    /// `crate::mcp::McpClient::set_elicitation_handler`) is simply never
1236    /// invoked with a TUI-backed implementation. `crates/cli`'s TUI runner
1237    /// additionally requires stdin/stdout/stderr all be a real tty before
1238    /// activating even when this is `true` — see that crate's
1239    /// `tui::should_activate` doc comment.
1240    pub tui_enabled: bool,
1241    /// P5-4 (§3.1 `capabilities.tui.theme`): `"dark"` | `"light"` — which
1242    /// built-in [`crate::tui::Theme`] the renderer starts with. Unknown or
1243    /// unset values fall back to `"dark"` (`crate::tui::Theme::default()`).
1244    pub tui_theme: String,
1245    /// P5-4 (§3.1 `capabilities.tui.vim_mode`, D8 "vim"): whether the
1246    /// input buffer starts in vim-style modal editing (normal/insert)
1247    /// rather than plain single-mode editing. See
1248    /// `crate::tui::InputMode`'s doc comment for the (deliberately
1249    /// basic — hjkl/i/a/o/dd/x) scope of what's implemented.
1250    pub tui_vim_mode: bool,
1251    /// P5-4 (§3.1 `capabilities.tui.keymap.<action> = "<key>"`,
1252    /// "configurable keybindings"): per-action key overrides layered on
1253    /// top of [`crate::tui::Keymap::default()`] — see that type's doc
1254    /// comment for the action names and key-spec syntax understood.
1255    pub tui_keymap: HashMap<String, String>,
1256
1257    /// P5-5 (§3.1 `capabilities.session_tree.enabled`, design §2 module 21
1258    /// activation): the master gate for the native in-place session tree
1259    /// (`crate::session_tree`) — a pure "does the harness advertise/prefer
1260    /// tree-mode session semantics" signal for a caller (CLI/TUI) to consult.
1261    /// `false` (the default, matching every `HarnessConfig` that never sets
1262    /// this table) changes nothing about [`crate::session_tree::SessionTree`]
1263    /// itself, which has no runtime dependency on this flag (a caller can
1264    /// always construct/use one directly, exactly like
1265    /// [`crate::store::SessionStore::fork`] isn't gated on any capability
1266    /// either) — this field exists purely so a future integration point has
1267    /// a resolved config signal to read, matching every other P5 module's
1268    /// "carried on `Config`, pure config → set" convention.
1269    pub session_tree_enabled: bool,
1270    /// P5-5 (§3.1 `capabilities.session_tree.branch_summaries`, module 21
1271    /// "branch summaries"): whether a caller wiring
1272    /// [`crate::session_tree::SessionTree::splice_for_linear_export`] into a
1273    /// C7 linear-export path should generate/attach summaries for off-path
1274    /// branches at all, vs. leaving them unsummarized (still fully present
1275    /// in the sidecar either way — this only controls the human-readable
1276    /// digest, never the underlying lossless data). Defaults `true` (the
1277    /// §3.1 schema's own default) when [`Self::session_tree_enabled`] is
1278    /// `true` and this key is unset.
1279    pub session_tree_branch_summaries: bool,
1280    /// P5-5 (§3.1 `capabilities.session_tree.labels`, module 21 "entry
1281    /// labels"): whether a caller's UI/CLI surface should expose
1282    /// [`crate::session_tree::SessionTree::label`]/`clear_label` at all.
1283    /// Defaults `true` (the §3.1 schema's own default) when
1284    /// [`Self::session_tree_enabled`] is `true` and this key is unset. Like
1285    /// [`Self::session_tree_branch_summaries`], this is advisory — the
1286    /// underlying `SessionTree` API always supports labeling regardless.
1287    pub session_tree_labels: bool,
1288    /// P5-6 (§3.1 `capabilities.tools_background.enabled`, module 4
1289    /// activation): the master gate for the `background_exec`/
1290    /// `background_status`/`background_list`/`background_kill` agent
1291    /// intrinsics — when `false` (the default), `Agent::tool_schemas`
1292    /// never advertises them and `Agent::prepare_tool_call`'s interception
1293    /// is a pure pass-through to the pre-P5-6 dispatch, byte-for-byte
1294    /// unchanged (a hallucinated call falls through to the ordinary
1295    /// unknown-tool error, exactly like `spawn_subagent`'s own disabled
1296    /// posture).
1297    pub tools_background_enabled: bool,
1298    /// P5-6 (resource bound, NOT in the §3.1 illustrative schema snippet —
1299    /// added per the build brief's explicit "max-concurrent cap,
1300    /// fail-closed", mirroring [`Self::subagents_max_concurrent`]'s own
1301    /// precedent): the maximum number of background jobs this agent may
1302    /// have running at once. Only consulted when
1303    /// [`Self::tools_background_enabled`] is `true`.
1304    pub tools_background_max_concurrent: usize,
1305    /// P5-6 (resource bound, "must not OOM" — mirrors
1306    /// `crate::mcp::MCP_MAX_RESPONSE_BYTES`'s hardening-cap precedent): the
1307    /// maximum number of bytes of combined stdout/stderr retained per
1308    /// background job — output beyond this is truncated-with-marker, never
1309    /// buffered further (`crate::background::CapturedOutput::append`).
1310    /// Only consulted when [`Self::tools_background_enabled`] is `true`.
1311    pub tools_background_max_output_bytes: usize,
1312    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`, module 20 activation):
1313    /// the master gate for file checkpointing — when `false` (the
1314    /// default), `crate::agent::build_tool_context` never touches disk for
1315    /// this at all: no `crate::checkpoint::CheckpointStore` is opened, no
1316    /// shadow directory is created, `ToolContext::write_observer` stays
1317    /// `None`, and every write-tool call site's observer branch is a
1318    /// pure no-op — byte-identical to before this module existed. See
1319    /// `crate::checkpoint`'s module doc comment for the full design.
1320    pub checkpoint_enabled: bool,
1321    /// P5-9 (bounded-disk requirement, NOT in the §3.1 illustrative schema
1322    /// snippet — added per the build brief's explicit "bounded... no
1323    /// unbounded disk growth", mirroring [`Self::tools_background_max_concurrent`]'s
1324    /// own precedent): the maximum number of checkpoints retained per
1325    /// project before the oldest are pruned. Only consulted when
1326    /// [`Self::checkpoint_enabled`] is `true`.
1327    pub checkpoint_retain: usize,
1328    /// P5-9 (embedder/test override, NOT a `[capabilities.checkpoint]`
1329    /// schema key — this is a Rust-only knob, the same class as
1330    /// [`Self::pre_tool_hook`]/[`Self::post_tool_hook`]): where the shadow
1331    /// store lives. `None` (the default) means
1332    /// `crate::checkpoint::observer_for_config` derives the location from
1333    /// `crate::agent::global_instructions_dir()` + a hash of [`Self::cwd`]
1334    /// (mirroring the CLI's own `cwd_tag` precedent) — set this to make the
1335    /// location hermetic/deterministic (tests; embedders that want a
1336    /// specific on-disk layout) without touching process-global env vars.
1337    pub checkpoint_dir: Option<PathBuf>,
1338
1339    /// BP-7 (§3.1 `capabilities.checkpoint.restore`, catalog §4a "Turn diff
1340    /// tracking"): whether this harness may RESTORE from a checkpoint, as
1341    /// opposed to only tracking each turn's diff. `true` (the default, and
1342    /// cc-parity's posture) is CC's `/rewind`. `false` is Codex's shape: a
1343    /// real `turn_diff_tracker` with no code restore behind it.
1344    pub checkpoint_restore: bool,
1345    /// P5-11 (§3.1 `capabilities.lsp.enabled`, module 28 activation): the
1346    /// master gate for LSP server lifecycle + edit-path diagnostics (D1).
1347    /// `false` (the default) means `crate::agent::build_tool_context` never
1348    /// touches `crate::lsp::manager_for_config` at all — no child process
1349    /// is ever spawned, `ToolContext::write_observer`'s chain never gains
1350    /// an LSP entry — byte-identical to before this module existed. See
1351    /// `crate::lsp`'s module doc comment for the accepted gaps (no
1352    /// auto-provisioned server fleet, no symbol-indexing query tool).
1353    pub lsp_enabled: bool,
1354    /// P5-11 (`capabilities.lsp.servers.<name>`): the configured language
1355    /// servers, in alphabetical order by server name (a TOML table has no
1356    /// inherent ordering — `configfile::materialize_config` sorts
1357    /// explicitly for reproducibility) — first extension match wins. Only
1358    /// consulted when [`Self::lsp_enabled`] is `true`. An empty `Vec` with
1359    /// `lsp_enabled = true` is legal but warns once
1360    /// (`crate::lsp::manager_for_config`) — very likely a config mistake.
1361    pub lsp_servers: Vec<(String, crate::lsp::LspServerSpec)>,
1362    /// P5-11 (bounded-context requirement, NOT in the §3.1 illustrative
1363    /// schema snippet — added per the build brief's explicit "a flood
1364    /// mustn't blow context", mirroring [`Self::tools_background_max_output_bytes`]'s
1365    /// own precedent): the maximum number of diagnostics rendered into a
1366    /// single tool result. Only consulted when [`Self::lsp_enabled`] is
1367    /// `true`.
1368    pub lsp_max_diagnostics: usize,
1369    /// P5-11 (bounded-latency requirement): how long to wait for a
1370    /// configured server to publish diagnostics after a
1371    /// `didOpen`/`didChange` before giving up gracefully. Only consulted
1372    /// when [`Self::lsp_enabled`] is `true`.
1373    pub lsp_timeout_secs: u64,
1374    /// P5-11 (§3.1 `capabilities.formatters.enabled`, module 29
1375    /// activation): the master gate for format-on-write. `false` (the
1376    /// default) means the shared D-5 write-observer chain never gains a
1377    /// `crate::formatters::FormatObserver` entry — byte-identical to
1378    /// before this module existed.
1379    pub formatters_enabled: bool,
1380    /// P5-11 (`capabilities.formatters.<name>`): the configured formatter
1381    /// commands, in alphabetical order by formatter name (same "TOML has
1382    /// no inherent ordering" rationale as [`Self::lsp_servers`]) — first
1383    /// extension match wins. Only consulted when
1384    /// [`Self::formatters_enabled`] is `true`.
1385    pub formatters: Vec<(String, crate::formatters::FormatterSpec)>,
1386    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10): whether a
1387    /// formatter's rewrite is diffed back into the calling tool's result
1388    /// so the model's file-memory stays truthful (design line 534, "must
1389    /// diff-back into the result"). `true` is the C10-SAFE default; `false`
1390    /// still runs the formatter but withholds the annotation — legal, but
1391    /// the model then has a stale belief about the file's exact bytes
1392    /// until it re-reads it.
1393    pub formatters_diff_back: bool,
1394    /// P5-11 (bounded-latency requirement, "a hanging formatter can't hang
1395    /// the loop — timeout + kill like hooks"): how long a single formatter
1396    /// invocation may run before it's treated as failed (the file is left
1397    /// untouched). Only consulted when [`Self::formatters_enabled`] is
1398    /// `true`.
1399    pub formatters_timeout_secs: u64,
1400    /// P5-12 (§2 module 14 `trust`, D-10): the master gate for the
1401    /// project/workspace trust concept — `false` (the default) means
1402    /// [`Self::trust_default`] is never consulted and [`crate::plugins`]
1403    /// treats every plugin as untrusted (see
1404    /// [`crate::plugins::is_trusted`]'s doc comment). `[capabilities.trust]`
1405    /// is project-forbidden (`configfile::PROJECT_FORBIDDEN_CAPABILITY_TABLES`
1406    /// / `userconfig`'s own copy): only the user/global layer — or a
1407    /// preset extended from it — may ever set this, exactly like
1408    /// `hooks`/`plugins`/`server` (a project asserting its OWN trust would
1409    /// defeat the entire point of the gate).
1410    pub trust_enabled: bool,
1411    /// P5-12 (`capabilities.trust.default`): the workspace-trust decision —
1412    /// see [`crate::plugins::TrustDecision`]'s doc comment for why, absent a
1413    /// wired interactive upgrade flow, only [`crate::plugins::TrustDecision::Always`]
1414    /// actually unlocks plugin loading in this build (an honest,
1415    /// documented gap — not a silent no-op: `ask`/`never` both cleanly
1416    /// refuse, they don't pretend to prompt). Only consulted when
1417    /// [`Self::trust_enabled`] is `true`.
1418    pub trust_default: crate::plugins::TrustDecision,
1419    /// P5-12 (§2 module 18 `plugins`, §3.1 `capabilities.plugins.enabled`):
1420    /// the master gate for out-of-process, manifest-declared plugins (see
1421    /// [`crate::plugins`]'s module doc comment for the ABI). `false` (the
1422    /// default) means `crate::agent`'s tool-registration path never touches
1423    /// [`crate::plugins::discover_and_load`] at all — no directory read, no
1424    /// manifest parse, no subprocess — byte-identical to before this module
1425    /// existed.
1426    pub plugins_enabled: bool,
1427    /// P5-12 (`capabilities.plugins.dirs`): EXTRA directories to scan for
1428    /// `<plugin-name>/plugin.toml` manifests, on top of the always-scanned
1429    /// `$SUPERCODE_HOME/plugins` (see [`crate::plugins::discover_manifests`]).
1430    /// `[capabilities.plugins]` (this field included) is project-forbidden,
1431    /// so this can only ever come from the trusted user/global layer or a
1432    /// preset. Only consulted when [`Self::plugins_enabled`] is `true` AND
1433    /// the workspace is trusted (see [`crate::plugins::is_trusted`]).
1434    pub plugins_dirs: Vec<PathBuf>,
1435}
1436
1437/// P4e (§1.4/§3.1 `core.context_injections`): one named ambient context
1438/// block -- see [`Config::context_injection_blocks`].
1439#[derive(Debug, Clone, PartialEq, Eq)]
1440pub struct ContextInjectionBlock {
1441    /// The block's heading, rendered as `# {name}`.
1442    pub name: String,
1443    /// The block's body text, appended verbatim under the heading.
1444    pub content: String,
1445}
1446
1447impl ContextInjectionBlock {
1448    /// Build a named block.
1449    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
1450        ContextInjectionBlock {
1451            name: name.into(),
1452            content: content.into(),
1453        }
1454    }
1455}
1456
1457/// How queued steering/follow-up messages are drained (S1.7, pi3
1458/// `steeringMode`/`followUpMode`).
1459#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1460pub enum SteeringMode {
1461    /// Deliver every queued message at once.
1462    All,
1463    /// Deliver exactly one queued message per drain point (the default).
1464    #[default]
1465    OneAtATime,
1466}
1467
1468impl SteeringMode {
1469    /// Parse the `"all"` / `"one-at-a-time"` config strings (S3.1).
1470    pub fn parse(s: &str) -> Option<SteeringMode> {
1471        match s {
1472            "all" => Some(SteeringMode::All),
1473            "one-at-a-time" | "one_at_a_time" => Some(SteeringMode::OneAtATime),
1474            _ => None,
1475        }
1476    }
1477}
1478
1479/// A stop-gate hook: receives the would-be-final assistant message; returns
1480/// `Some(reason)` to veto termination and continue the loop (the reason is
1481/// injected as a new user message), or `None` to allow the stop. See
1482/// `Config::stop_gate`.
1483pub type StopGateHook = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
1484
1485impl Default for Config {
1486    fn default() -> Self {
1487        Config {
1488            model: "anthropic/claude-opus-4-8".to_string(),
1489            base_url: OPENROUTER_BASE_URL.to_string(),
1490            api_key: None,
1491            api_key_env: DEFAULT_API_KEY_ENV.to_string(),
1492            api_key_cmd: None,
1493            api_key_command: None,
1494            update_check: false,
1495            system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
1496            temperature: None,
1497            max_tokens: None,
1498            max_iterations: 25,
1499            effort: None,
1500            response_format: None,
1501            extra_body: serde_json::Map::new(),
1502            max_total_output_tokens: None,
1503            max_budget_usd: None,
1504            max_steps: None,
1505            price_input_per_mtok: None,
1506            price_output_per_mtok: None,
1507            max_tool_output_bytes: Some(100_000),
1508            tool_output_spill: false,
1509            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
1510            additional_dirs: Vec::new(),
1511            load_project_context: false,
1512            sandbox: crate::tools::SandboxPolicy::default(),
1513            approval: ApprovalPolicy::default(),
1514            auto_approved_tools: std::collections::HashSet::new(),
1515            tool_deny_patterns: Vec::new(),
1516            tool_allow_patterns: Vec::new(),
1517            approval_handler: None,
1518            pre_tool_hook: None,
1519            post_tool_hook: None,
1520            lifecycle_hook: None,
1521            prompts: default_prompts(),
1522            compact_after_messages: None,
1523            tool_overrides: HashMap::new(),
1524            tool_advertising: ToolAdvertising::default(),
1525            extra_headers: HashMap::new(),
1526            event_sink: None,
1527            cache_plan: CachePlan::default(),
1528            reduction_policy: ReductionPolicySettings::default(),
1529            handoff_enabled: true,
1530            tool_schema_tier: crate::tools::SchemaTier::default(),
1531            cache_warnings: true,
1532            module_registry: false,
1533            module_activation: crate::modules::ModuleActivation::default(),
1534            core_tools_enabled: ["read_file", "bash", "edit_file", "write_file"]
1535                .iter()
1536                .map(|s| s.to_string())
1537                .collect(),
1538            skills_enabled: false,
1539            skills_harness: None,
1540            skills_dirs: Vec::new(),
1541            skills_implicit_match: false,
1542            skills_shell_injection: false,
1543            file_mentions: false,
1544            output_style: String::new(),
1545            path_rules: false,
1546            model_family_prompts: std::collections::BTreeMap::new(),
1547            small_model: None,
1548            model_fallback: Vec::new(),
1549            model_routing: crate::model_catalog::Routing::default(),
1550            service_tier: None,
1551            env_context: false,
1552            project_root_markers: vec![".git".to_string()],
1553            project_doc_max_bytes: None,
1554            project_doc_excludes: Vec::new(),
1555            project_doc_strip_comments: false,
1556            instruction_imports: false,
1557            retry_enabled: true,
1558            retry_max_retries: None,
1559            retry_base_delay_ms: None,
1560            compaction_reserve_tokens: None,
1561            compaction_keep_recent_tokens: None,
1562            compaction_focus_instructions: None,
1563            auto_title: false,
1564            steering_mode: SteeringMode::default(),
1565            follow_up_mode: SteeringMode::default(),
1566            stop_gate: None,
1567            read_file_multimodal: false,
1568            read_file_line_numbers: false,
1569            edit_file_require_read_before_edit: false,
1570            edit_file_notebook_aware: false,
1571            shell_env_snapshot: false,
1572            doom_loop_threshold: None,
1573            nested_instructions: false,
1574            model_switch_allow_switch: false,
1575            model_switch_notice: false,
1576            plan_mode_effort: None,
1577            context_injections: false,
1578            context_injection_blocks: Vec::new(),
1579            // P4e: `true` because today's behavior (before this master gate
1580            // existed) is "compaction fires whenever a trigger is
1581            // configured" -- a default of `true` preserves that exactly;
1582            // only an explicit `false` newly suppresses it.
1583            compaction_enabled: true,
1584            // BP-1: `true` because today's compaction marker has always
1585            // said "summarized"; only an explicit `summarize = false`
1586            // changes the text.
1587            compaction_summarize: true,
1588            parallel_tool_calls: false,
1589            session_git_metadata: false,
1590            session_dir: None,
1591            session_persist: true,
1592            session_name: None,
1593            session_retention_days: None,
1594            session_append_only: false,
1595            session_queue_persist: false,
1596            todos_persist: false,
1597            session_export_format: crate::human_export::HumanExportFormat::default(),
1598            permissions_enabled: false,
1599            permissions_ask_patterns: Vec::new(),
1600            permissions_protected_paths: Vec::new(),
1601            network_policy: None,
1602            trust_handler: None,
1603            trust_store: None,
1604            permissions_approvals_persist: false,
1605            permissions_approval_store: None,
1606            sandbox_os_enabled: None,
1607            sandbox_escalation: crate::sandbox::SandboxEscalation::default(),
1608            sandbox_env_policy: crate::sandbox::SandboxEnvPolicy::default(),
1609            subagents_enabled: false,
1610            goals_enabled: false,
1611            subagents_max_depth: 2,
1612            subagents_max_concurrent: 4,
1613            subagents_background: false,
1614            subagents_background_prompts: None,
1615            subagents_claude_agent_alias: false,
1616            claude_runtime_tools_enabled: false,
1617            subagents_definitions: HashMap::new(),
1618            subagent_depth: 0,
1619            tui_enabled: false,
1620            tui_theme: "dark".to_string(),
1621            tui_vim_mode: false,
1622            tui_keymap: HashMap::new(),
1623            session_tree_enabled: false,
1624            session_tree_branch_summaries: false,
1625            session_tree_labels: false,
1626            tools_background_enabled: false,
1627            tools_background_max_concurrent: crate::background::DEFAULT_MAX_CONCURRENT,
1628            tools_background_max_output_bytes: crate::background::DEFAULT_MAX_OUTPUT_BYTES,
1629            checkpoint_enabled: false,
1630            checkpoint_retain: crate::checkpoint::DEFAULT_RETAIN,
1631            checkpoint_dir: None,
1632            checkpoint_restore: true,
1633            lsp_enabled: false,
1634            lsp_servers: Vec::new(),
1635            lsp_max_diagnostics: crate::lsp::DEFAULT_LSP_MAX_DIAGNOSTICS,
1636            lsp_timeout_secs: crate::lsp::DEFAULT_LSP_TIMEOUT_SECS,
1637            formatters_enabled: false,
1638            formatters: Vec::new(),
1639            formatters_diff_back: true,
1640            formatters_timeout_secs: crate::formatters::DEFAULT_FORMATTER_TIMEOUT_SECS,
1641            trust_enabled: false,
1642            trust_default: crate::plugins::TrustDecision::Ask,
1643            plugins_enabled: false,
1644            plugins_dirs: Vec::new(),
1645        }
1646    }
1647}
1648
1649impl Config {
1650    /// Start building a [`Config`] from defaults.
1651    pub fn builder() -> ConfigBuilder {
1652        ConfigBuilder {
1653            config: Config::default(),
1654        }
1655    }
1656
1657    /// Whether a tool is enabled given the overrides (defaults to enabled).
1658    pub fn tool_enabled(&self, name: &str) -> bool {
1659        self.tool_overrides
1660            .get(name)
1661            .and_then(|o| o.enabled)
1662            .unwrap_or(true)
1663    }
1664
1665    /// Whether a tool call requires approval before it runs, given the
1666    /// policy, the auto-approve allowlist, and (P4) the deny/allow glob
1667    /// PATTERN lists — see [`Self::tool_deny_patterns`]/
1668    /// [`Self::tool_allow_patterns`]'s doc comments for the exact
1669    /// semantics. Both are empty by default, so this is byte-identical to
1670    /// pre-P4 behavior for any `Config` that doesn't set them.
1671    pub fn needs_approval(&self, tool: &str) -> bool {
1672        // Deny wins unconditionally, even under `Never` — a deny pattern is
1673        // a hard floor, not just another allowlist entry.
1674        if self.tool_deny_patterns.iter().any(|p| glob_match(p, tool)) {
1675            return true;
1676        }
1677        match self.approval {
1678            ApprovalPolicy::Never => false,
1679            // P5-1: this coarse, tool-name-only gate has no model-escalation
1680            // signal to consult (that requires the canonicalized-command
1681            // context only `crate::permissions`'s richer gate has), so
1682            // `ModelRequested` is treated the same, conservative way
1683            // `OnRequest` is here — the safe simplification documented on
1684            // `ApprovalPolicy::ModelRequested` itself. The P5-1 engine
1685            // (active when `Self::permissions_enabled` is `true`) is where
1686            // Codex's real "mostly silent, escalation asks" posture is
1687            // approximated instead.
1688            ApprovalPolicy::OnRequest | ApprovalPolicy::ModelRequested => {
1689                !self.auto_approved_tools.contains(tool)
1690                    && !self.tool_allow_patterns.iter().any(|p| glob_match(p, tool))
1691            }
1692            // BP-8 DEFECT-FIX (see the same change in the P5-1 engine's own
1693            // default, `Agent::tool_decision`): an EXPLICIT
1694            // `auto_approved_tools` entry is CC's read-only tier, which
1695            // never prompts (cc§4 "Tiered defaults"). A glob ALLOW pattern
1696            // still exempts nothing here — that stays `OnRequest`-only, so
1697            // `*` cannot quietly turn `untrusted` into `never`.
1698            ApprovalPolicy::Untrusted => !self.auto_approved_tools.contains(tool),
1699        }
1700    }
1701
1702    /// The effective description for a tool, applying any override.
1703    pub fn tool_description<'a>(&'a self, name: &str, builtin: &'a str) -> &'a str {
1704        self.tool_overrides
1705            .get(name)
1706            .and_then(|o| o.description.as_deref())
1707            .unwrap_or(builtin)
1708    }
1709
1710    /// The effective schema tier for a tool (TR-8/T5): a per-tool override if
1711    /// set, else the global [`Self::tool_schema_tier`].
1712    pub fn schema_tier_for(&self, name: &str) -> crate::tools::SchemaTier {
1713        self.tool_overrides
1714            .get(name)
1715            .and_then(|o| o.schema_tier)
1716            .unwrap_or(self.tool_schema_tier)
1717    }
1718}
1719
1720/// A single tool's file-settable overrides — the `ConfigProfile` mirror of
1721/// [`ToolOverride`] (COMPOSABLE-HARNESS-DESIGN.md §3.1 `[core.tools.<name>]`,
1722/// §3.2 mapping row `core.tools.enabled` + `[core.tools.<n>].*`).
1723#[derive(Debug, Clone, Default, serde::Deserialize)]
1724pub struct ToolOverrideProfile {
1725    /// `Some(false)` hides the tool from the model entirely.
1726    pub enabled: Option<bool>,
1727    /// Replaces the tool's built-in description.
1728    pub description: Option<String>,
1729    /// Per-tool schema tier: `full` | `medium` | `minimal`.
1730    pub schema_tier: Option<String>,
1731    /// P4e (§3.1 `core.tools.bash.timeout_secs`, S14) -- see
1732    /// `ToolOverride::timeout_secs`. Only meaningful on the `bash` entry.
1733    pub timeout_secs: Option<u64>,
1734}
1735
1736/// The serializable subset of a [`Config`] that can live in a config file.
1737/// (Callbacks/handlers are code-only and are not represented here.)
1738///
1739/// Grown from its original 9 fields to the P1 §3.2 surface
1740/// (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P1" migration step): every `[core]`
1741/// scalar/table/array `Config` maps to lives here so it becomes file-settable
1742/// for the first time, per the design's stated framing gap (§3.0).
1743#[derive(Debug, Clone, Default, serde::Deserialize)]
1744pub struct ConfigProfile {
1745    /// Model id.
1746    pub model: Option<String>,
1747    /// Endpoint base URL.
1748    pub base_url: Option<String>,
1749    /// Environment variable to read the API key from (§3.1 `core.api_key_env`;
1750    /// §3.2: "today absent from BOTH files").
1751    pub api_key_env: Option<String>,
1752    /// Credential-helper command (§3.1 `core.api_key_cmd`, D6 row) — see
1753    /// [`Config::api_key_cmd`].
1754    pub api_key_cmd: Option<String>,
1755    /// Argv credential helper (§3.1 `core.api_key_command`, D6 row) — see
1756    /// [`Config::api_key_command`]; arrays replace.
1757    pub api_key_command: Option<Vec<String>>,
1758    /// Startup release check (§3.1 `core.update_check`, D6 row) — see
1759    /// [`Config::update_check`].
1760    pub update_check: Option<bool>,
1761    /// System prompt.
1762    pub system_prompt: Option<String>,
1763    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): an additive suffix
1764    /// composed onto whatever [`Self::system_prompt`] resolves to (the
1765    /// profile's own value if set, else whatever the builder already had —
1766    /// see [`ConfigBuilder::apply_profile`]'s composition order), distinct
1767    /// from REPLACING it. `[project-forbidden]`, same trust boundary as
1768    /// `system_prompt` (§3.3: prompt injection).
1769    pub append_system_prompt: Option<String>,
1770    /// Sampling temperature.
1771    pub temperature: Option<f32>,
1772    /// Output token cap per request.
1773    pub max_tokens: Option<u32>,
1774    /// Reasoning/effort level.
1775    pub effort: Option<String>,
1776    /// Sandbox policy: `read_only` | `workspace_write` | `danger_full_access`.
1777    pub sandbox: Option<String>,
1778    /// Approval policy: `never` | `on_request` | `untrusted`.
1779    pub approval: Option<String>,
1780    /// Auto-load CLAUDE.md / AGENTS.md.
1781    pub project_context: Option<bool>,
1782    /// Per-`send` iteration budget (§3.1 `core.max_iterations`).
1783    pub max_iterations: Option<usize>,
1784    /// Extra roots beyond `cwd` (§3.1 `core.additional_dirs`); arrays
1785    /// replace wholesale on overlay (§3.3).
1786    pub additional_dirs: Option<Vec<String>>,
1787    /// Compact once the conversation exceeds this many messages (§3.1
1788    /// `core.compaction.after_messages`; `0` = trigger off).
1789    pub compact_after_messages: Option<usize>,
1790    /// Prompt-caching plan: `off` | `imported_prefix` (§3.1
1791    /// `capabilities.cache.plan`).
1792    pub cache_plan: Option<String>,
1793    /// BP-4 (§3.1 `capabilities.cache.warnings`): whether cache-churn
1794    /// warnings are surfaced in-session -- see `Config::cache_warnings`.
1795    /// `None` leaves the default (on) in place.
1796    pub cache_warnings: Option<bool>,
1797    /// How tools are advertised: `full` | `deferred` (§3.1
1798    /// `capabilities.deferred_tools`).
1799    pub tool_advertising: Option<String>,
1800    /// The eagerly-advertised core allowlist when `tool_advertising =
1801    /// "deferred"` (§3.1 `capabilities.deferred_tools.core`); arrays replace.
1802    pub tool_advertising_core: Option<Vec<String>>,
1803    /// Global tool-schema tier: `full` | `medium` | `minimal` (§3.1
1804    /// `core.tools.schema_tier`).
1805    pub schema_tier: Option<String>,
1806    /// Tools that never require approval under `ApprovalPolicy::OnRequest`
1807    /// (§3.1 `capabilities.permissions.auto_approved_tools`); arrays replace.
1808    pub auto_approved_tools: Option<Vec<String>>,
1809    /// P4: deny-pattern generalization of `auto_approved_tools` (§3.1
1810    /// `capabilities.permissions.rules.deny`) — see
1811    /// [`Config::tool_deny_patterns`]. Arrays replace.
1812    pub tool_deny_patterns: Option<Vec<String>>,
1813    /// P4: allow-pattern generalization of `auto_approved_tools` (§3.1
1814    /// `capabilities.permissions.rules.allow`) — see
1815    /// [`Config::tool_allow_patterns`]. Arrays replace.
1816    pub tool_allow_patterns: Option<Vec<String>>,
1817    /// Extra HTTP headers merged in (§3.1 `core.extra_headers`); a table,
1818    /// merged key-wise on overlay (§3.3).
1819    pub extra_headers: Option<HashMap<String, String>>,
1820    /// Extra request-body fields merged in (§3.1 `core.extra_body`); a
1821    /// table, merged key-wise on overlay (§3.3).
1822    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
1823    /// Max bytes of a single tool result (§3.1 `core.max_tool_output_bytes`).
1824    pub max_tool_output_bytes: Option<usize>,
1825    /// Cap on cumulative output tokens per `send` loop (§3.1
1826    /// `core.max_total_output_tokens`).
1827    pub max_total_output_tokens: Option<u64>,
1828    /// BP-7 (`core.max_budget_usd`) — see [`Config::max_budget_usd`].
1829    pub max_budget_usd: Option<f64>,
1830    /// BP-7 (`core.max_steps`) — see [`Config::max_steps`].
1831    pub max_steps: Option<usize>,
1832    /// BP-7 (`core.price_input_per_mtok`) — see
1833    /// [`Config::price_input_per_mtok`].
1834    pub price_input_per_mtok: Option<f64>,
1835    /// BP-7 (`core.price_output_per_mtok`) — see
1836    /// [`Config::price_output_per_mtok`].
1837    pub price_output_per_mtok: Option<f64>,
1838    /// Named prompt templates (§3.1 `[core.prompts]`); a table, merged
1839    /// key-wise (new/overridden names layer onto the built-ins, they don't
1840    /// wholesale-replace them).
1841    pub prompts: Option<HashMap<String, String>>,
1842    /// Per-tool enable/disable + description/schema-tier overrides, keyed
1843    /// by tool name (§3.1 `[core.tools.<name>]`, §3.2 "today in no file");
1844    /// a table, merged key-wise per tool.
1845    pub tool_overrides: Option<HashMap<String, ToolOverrideProfile>>,
1846
1847    /// P4b (S3.1 `core.env_context`) -- see `Config::env_context`.
1848    pub env_context: Option<bool>,
1849    /// P4b (S3.1 `core.project_root_markers`) -- see
1850    /// `Config::project_root_markers`; arrays replace.
1851    pub project_root_markers: Option<Vec<String>>,
1852    /// P4b (S3.1 `core.project_doc_max_bytes`) -- see
1853    /// `Config::project_doc_max_bytes`.
1854    pub project_doc_max_bytes: Option<usize>,
1855    /// BP-4 (`core.project_doc_excludes`) -- see
1856    /// `Config::project_doc_excludes`; arrays replace.
1857    pub project_doc_excludes: Option<Vec<String>>,
1858    /// BP-4 (`core.project_doc_strip_comments`) -- see
1859    /// `Config::project_doc_strip_comments`.
1860    pub project_doc_strip_comments: Option<bool>,
1861    /// P4b (S3.1 `core.instruction_imports`) -- see
1862    /// `Config::instruction_imports`.
1863    pub instruction_imports: Option<bool>,
1864    /// P4b (S3.1 `core.retry.enabled`) -- see `Config::retry_enabled`.
1865    pub retry_enabled: Option<bool>,
1866    /// P4b (S3.1 `core.retry.max_retries`) -- see `Config::retry_max_retries`.
1867    pub retry_max_retries: Option<u32>,
1868    /// P4b (S3.1 `core.retry.base_delay_ms`) -- see
1869    /// `Config::retry_base_delay_ms`.
1870    pub retry_base_delay_ms: Option<u64>,
1871    /// P4b (S3.1 `core.compaction.reserve_tokens`) -- see
1872    /// `Config::compaction_reserve_tokens`.
1873    pub compaction_reserve_tokens: Option<u64>,
1874    /// P4b (S3.1 `core.compaction.keep_recent_tokens`) -- see
1875    /// `Config::compaction_keep_recent_tokens`.
1876    pub compaction_keep_recent_tokens: Option<u64>,
1877    /// P4b (S3.1 `core.compaction.focus_instructions`) -- see
1878    /// `Config::compaction_focus_instructions`.
1879    pub compaction_focus_instructions: Option<String>,
1880    /// P4b (S3.1 `core.session.auto_title`) -- see `Config::auto_title`.
1881    pub auto_title: Option<bool>,
1882    /// P4b (S3.1 `core.steering.steering_mode`) -- see
1883    /// `Config::steering_mode`.
1884    pub steering_mode: Option<String>,
1885    /// P4b (S3.1 `core.steering.follow_up_mode`) -- see
1886    /// `Config::follow_up_mode`.
1887    pub follow_up_mode: Option<String>,
1888
1889    /// P4c (S3.1 `core.tools.read_file.multimodal`) -- see
1890    /// `Config::read_file_multimodal`.
1891    pub read_file_multimodal: Option<bool>,
1892    /// BP-2 (S3.1 `core.tools.read_file.line_numbers`) -- see
1893    /// `Config::read_file_line_numbers`.
1894    pub read_file_line_numbers: Option<bool>,
1895    /// BP-2 (S3.1 `core.tool_output_spill`) -- see
1896    /// `Config::tool_output_spill`.
1897    pub tool_output_spill: Option<bool>,
1898    /// P4c (S3.1 `core.tools.edit_file.require_read_before_edit`) -- see
1899    /// `Config::edit_file_require_read_before_edit`.
1900    pub edit_file_require_read_before_edit: Option<bool>,
1901    /// P4c (S3.1 `core.tools.edit_file.notebook_aware`) -- see
1902    /// `Config::edit_file_notebook_aware`.
1903    pub edit_file_notebook_aware: Option<bool>,
1904    /// P4c (S3.1 `core.shell_env_snapshot`) -- see
1905    /// `Config::shell_env_snapshot`.
1906    pub shell_env_snapshot: Option<bool>,
1907    /// P4c (S3.1 `core.doom_loop_threshold`) -- see
1908    /// `Config::doom_loop_threshold`.
1909    pub doom_loop_threshold: Option<u32>,
1910    /// P4c (S3.1 `core.nested_instructions`) -- see
1911    /// `Config::nested_instructions`.
1912    pub nested_instructions: Option<bool>,
1913    /// P4c (S3.1 `core.model_switch.allow_switch`) -- see
1914    /// `Config::model_switch_allow_switch`.
1915    pub model_switch_allow_switch: Option<bool>,
1916    /// BP-13 (S3.1 `core.model_switch.notice`) -- see
1917    /// `Config::model_switch_notice`.
1918    pub model_switch_notice: Option<bool>,
1919
1920    /// P4e (S3.1 `core.context_injections`) -- see
1921    /// `Config::context_injections`. Only the boolean gate is
1922    /// file/profile-settable; `Config::context_injection_blocks`' actual
1923    /// content is code-only (see its doc comment).
1924    pub context_injections: Option<bool>,
1925    /// P4e (S3.1 `core.compaction.enabled`) -- see
1926    /// `Config::compaction_enabled`.
1927    pub compaction_enabled: Option<bool>,
1928    /// BP-1 (S3.1 `core.compaction.summarize`) -- see
1929    /// `Config::compaction_summarize`.
1930    pub compaction_summarize: Option<bool>,
1931    /// P4e (S3.1 `core.parallel_tool_calls`) -- see
1932    /// `Config::parallel_tool_calls`.
1933    pub parallel_tool_calls: Option<bool>,
1934    /// P4e (S3.1 `core.session.git_metadata`) -- see
1935    /// `Config::session_git_metadata`.
1936    pub session_git_metadata: Option<bool>,
1937    /// P4e (S3.1 `core.session.dir`) -- see `Config::session_dir`.
1938    pub session_dir: Option<String>,
1939    /// P4e (S3.1 `core.session.persist`) -- see `Config::session_persist`.
1940    pub session_persist: Option<bool>,
1941    /// P4e (S3.1 `core.session.name`) -- see `Config::session_name`.
1942    pub session_name: Option<String>,
1943    /// P4e (S3.1 `core.session.retention_days`) -- see
1944    /// `Config::session_retention_days`.
1945    pub session_retention_days: Option<u32>,
1946    /// P4e (S3.1 `core.session.export_format`) -- see
1947    /// `Config::session_export_format`. `"text"` | `"html"`; an
1948    /// unrecognized string is a no-op warning, like `steering_mode`.
1949    pub session_export_format: Option<String>,
1950    /// BP-8 (S3.1 `core.session.append_only`) -- see
1951    /// `Config::session_append_only`.
1952    pub session_append_only: Option<bool>,
1953    /// BP-8 (S3.1 `core.session.queue_persist`) -- see
1954    /// `Config::session_queue_persist`.
1955    pub session_queue_persist: Option<bool>,
1956}
1957
1958/// BP-9 (§3.1 `core.project_root_markers`, catalog:232 "Project-root
1959/// detection markers", cx§6, oc§6): the project root for `cwd` — the
1960/// NEAREST ancestor (starting at `cwd` itself) that directly contains any
1961/// entry named by `markers`. `None` when no ancestor carries a marker, or
1962/// when `markers` is empty (an empty marker list is an explicit "don't do
1963/// root detection", not an invitation to walk to `/`).
1964///
1965/// This is the ONE walk every marker consumer shares — the git-status probe
1966/// in `env_context`, the CLI's `.supercode.toml` discovery, and prompt
1967/// assembly's instruction walk. Sharing it is the point: "where does the
1968/// project stop?" must have exactly one answer per config, or the same
1969/// `project_root_markers` value would mean three different things.
1970///
1971/// A marker matches whether it is a file or a directory (`.git` is a
1972/// directory in a normal checkout and a FILE in a worktree/submodule —
1973/// both are the root). Symlinks are followed by `exists()`, and the walk is
1974/// purely lexical on `cwd` as given: no canonicalization, so a caller that
1975/// wants symlink-resolved semantics canonicalizes before calling.
1976pub fn project_root_for(cwd: &std::path::Path, markers: &[String]) -> Option<std::path::PathBuf> {
1977    if markers.is_empty() {
1978        return None;
1979    }
1980    let mut dir = Some(cwd);
1981    while let Some(d) = dir {
1982        if markers.iter().any(|m| !m.is_empty() && d.join(m).exists()) {
1983            return Some(d.to_path_buf());
1984        }
1985        dir = d.parent();
1986    }
1987    None
1988}
1989
1990/// A config file: a set of named profiles (the analog of Codex `-p/--profile`).
1991/// This is the **SDK/embedder** config surface (JSON, via
1992/// [`Config::from_profile_file`]). BP-9 gave the same named-bundle mechanism
1993/// a launch-time selector on the CLI side: `supercode --profile <name>`
1994/// selects a `[profiles.<name>]` bundle out of the user's TOML
1995/// `config.toml` (`userconfig::FileConfig`), applied as its own layer
1996/// between the user layer and the project layer (cx§6's
1997/// `user → profile → project` order).
1998#[derive(Debug, Clone, Default, serde::Deserialize)]
1999pub struct ConfigFile {
2000    /// Profiles keyed by name.
2001    #[serde(default)]
2002    pub profiles: HashMap<String, ConfigProfile>,
2003}
2004
2005impl Config {
2006    /// Load a named profile from a JSON config file into a builder. Layered:
2007    /// start from defaults, then apply the named profile's set fields.
2008    pub fn from_profile_file(
2009        path: impl AsRef<std::path::Path>,
2010        profile: &str,
2011    ) -> crate::Result<ConfigBuilder> {
2012        let text = std::fs::read_to_string(path.as_ref())?;
2013        let file: ConfigFile = serde_json::from_str(&text).map_err(crate::Error::Decode)?;
2014        let p = file
2015            .profiles
2016            .get(profile)
2017            .ok_or_else(|| crate::Error::Other(format!("no profile `{profile}` in config file")))?;
2018        Ok(ConfigBuilder::default().apply_profile(p))
2019    }
2020}
2021
2022impl ConfigBuilder {
2023    /// Apply the set fields of a [`ConfigProfile`] over the current builder.
2024    pub fn apply_profile(mut self, p: &ConfigProfile) -> Self {
2025        if let Some(m) = &p.model {
2026            self.config.model = m.clone();
2027        }
2028        if let Some(u) = &p.base_url {
2029            self.config.base_url = u.clone();
2030        }
2031        if let Some(s) = &p.system_prompt {
2032            self.config.system_prompt = s.clone();
2033        }
2034        if let Some(extra) = &p.append_system_prompt {
2035            // P4 (§3.1 `core.append_system_prompt`): additive, composed onto
2036            // whatever `system_prompt` is on the builder AT THIS POINT —
2037            // either the value just applied above, or whatever the caller
2038            // already set/left at its `Config::default()` — never a
2039            // replacement. This intentionally runs regardless of whether
2040            // `p.system_prompt` was set, so an append-only profile still
2041            // composes onto the existing base.
2042            self.config.system_prompt = format!("{}\n\n{extra}", self.config.system_prompt);
2043        }
2044        self.config.temperature = p.temperature.or(self.config.temperature);
2045        self.config.max_tokens = p.max_tokens.or(self.config.max_tokens);
2046        if p.effort.is_some() {
2047            self.config.effort = p.effort.clone();
2048        }
2049        if let Some(sb) = &p.sandbox {
2050            // Fail *safe*: an unrecognized value (typo, future variant) must not
2051            // silently grant full filesystem access. Only the explicit
2052            // danger string opts out of confinement.
2053            self.config.sandbox = match sb.as_str() {
2054                "read_only" | "read-only" | "readonly" => crate::tools::SandboxPolicy::ReadOnly,
2055                "workspace_write" | "workspace-write" => {
2056                    crate::tools::SandboxPolicy::WorkspaceWrite
2057                }
2058                "danger_full_access" | "danger-full-access" => {
2059                    crate::tools::SandboxPolicy::DangerFullAccess
2060                }
2061                other => {
2062                    tracing::warn!(
2063                        "unknown sandbox policy `{other}` in profile; defaulting to read_only"
2064                    );
2065                    crate::tools::SandboxPolicy::ReadOnly
2066                }
2067            };
2068        }
2069        if let Some(ap) = &p.approval {
2070            // Fail safe: an unrecognized value defaults to the most-prompting
2071            // policy, never to `never`.
2072            self.config.approval = match ap.as_str() {
2073                "on_request" | "on-request" => ApprovalPolicy::OnRequest,
2074                "untrusted" => ApprovalPolicy::Untrusted,
2075                "never" => ApprovalPolicy::Never,
2076                // P5-1 (§3.2 S8): cx-parity's real intended posture — see
2077                // `ApprovalPolicy::ModelRequested`'s doc comment.
2078                "model_requested" | "model-requested" => ApprovalPolicy::ModelRequested,
2079                other => {
2080                    tracing::warn!(
2081                        "unknown approval policy `{other}` in profile; defaulting to untrusted"
2082                    );
2083                    ApprovalPolicy::Untrusted
2084                }
2085            };
2086        }
2087        if let Some(pc) = p.project_context {
2088            self.config.load_project_context = pc;
2089        }
2090        if let Some(env) = &p.api_key_env {
2091            self.config.api_key_env = env.clone();
2092        }
2093        if let Some(cmd) = &p.api_key_cmd {
2094            self.config.api_key_cmd = Some(cmd.clone());
2095        }
2096        if let Some(argv) = &p.api_key_command {
2097            self.config.api_key_command = Some(argv.clone());
2098        }
2099        if let Some(v) = p.update_check {
2100            self.config.update_check = v;
2101        }
2102        // Scalars replace (§3.3).
2103        if let Some(n) = p.max_iterations {
2104            self.config.max_iterations = n;
2105        }
2106        // Arrays replace wholesale (§3.3), not append — predictable overlay.
2107        if let Some(dirs) = &p.additional_dirs {
2108            self.config.additional_dirs = dirs.iter().map(std::path::PathBuf::from).collect();
2109        }
2110        if let Some(n) = p.compact_after_messages {
2111            self.config.compact_after_messages = Some(n);
2112        }
2113        if let Some(plan) = &p.cache_plan {
2114            // Fail safe: an unrecognized value never silently opts into
2115            // caching behavior the operator didn't ask for.
2116            self.config.cache_plan = match plan.as_str() {
2117                "off" => CachePlan::Off,
2118                "imported_prefix" | "imported-prefix" => CachePlan::ImportedPrefix,
2119                other => {
2120                    tracing::warn!("unknown cache plan `{other}` in profile; defaulting to off");
2121                    CachePlan::Off
2122                }
2123            };
2124        }
2125        if let Some(v) = p.cache_warnings {
2126            self.config.cache_warnings = v;
2127        }
2128        if p.tool_advertising.is_some() || p.tool_advertising_core.is_some() {
2129            // F6 fix: per-key replace (§3.3) — neither key alone may clobber
2130            // the other's current value. Compute the effective core list
2131            // FIRST (the profile's new value if given, else whatever's
2132            // already active) so an explicit `"deferred"` mode with no
2133            // `_core` doesn't wipe an existing list, then only change the
2134            // MODE if the profile actually set one — setting `_core` alone
2135            // must not silently reset the mode to `Full` (which previously
2136            // discarded the array outright, since `Full` ignores it).
2137            let existing_core = match &self.config.tool_advertising {
2138                ToolAdvertising::Deferred { core } => core.clone(),
2139                ToolAdvertising::Full => Vec::new(),
2140            };
2141            let core = p.tool_advertising_core.clone().unwrap_or(existing_core);
2142            self.config.tool_advertising = match p.tool_advertising.as_deref() {
2143                Some("deferred") => ToolAdvertising::Deferred { core },
2144                Some("full") => ToolAdvertising::Full,
2145                Some(other) => {
2146                    tracing::warn!(
2147                        "unknown tool_advertising mode `{other}` in profile; defaulting to full"
2148                    );
2149                    ToolAdvertising::Full
2150                }
2151                None => match &self.config.tool_advertising {
2152                    // Mode untouched — only refresh the core list if
2153                    // already `Deferred` (`Full` has nowhere to put one).
2154                    ToolAdvertising::Deferred { .. } => ToolAdvertising::Deferred { core },
2155                    ToolAdvertising::Full => ToolAdvertising::Full,
2156                },
2157            };
2158        }
2159        if let Some(tier) = &p.schema_tier {
2160            // Fail safe: unrecognized value keeps the verbose (never
2161            // under-informative) default rather than guessing a shrink tier.
2162            self.config.tool_schema_tier =
2163                crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
2164                    tracing::warn!("unknown schema tier `{tier}` in profile; defaulting to full");
2165                    crate::tools::SchemaTier::Full
2166                });
2167        }
2168        // Arrays replace wholesale (§3.3).
2169        if let Some(tools) = &p.auto_approved_tools {
2170            self.config.auto_approved_tools = tools.iter().cloned().collect();
2171        }
2172        if let Some(patterns) = &p.tool_deny_patterns {
2173            self.config.tool_deny_patterns = patterns.clone();
2174        }
2175        if let Some(patterns) = &p.tool_allow_patterns {
2176            self.config.tool_allow_patterns = patterns.clone();
2177        }
2178        // Tables merge key-wise (§3.3), not wholesale replace.
2179        if let Some(headers) = &p.extra_headers {
2180            for (k, v) in headers {
2181                self.config.extra_headers.insert(k.clone(), v.clone());
2182            }
2183        }
2184        if let Some(body) = &p.extra_body {
2185            for (k, v) in body {
2186                self.config.extra_body.insert(k.clone(), v.clone());
2187            }
2188        }
2189        if let Some(n) = p.max_tool_output_bytes {
2190            self.config.max_tool_output_bytes = Some(n);
2191        }
2192        if let Some(n) = p.max_total_output_tokens {
2193            self.config.max_total_output_tokens = Some(n);
2194        }
2195        if let Some(n) = p.max_budget_usd {
2196            self.config.max_budget_usd = Some(n);
2197        }
2198        if let Some(n) = p.max_steps {
2199            self.config.max_steps = Some(n);
2200        }
2201        if let Some(n) = p.price_input_per_mtok {
2202            self.config.price_input_per_mtok = Some(n);
2203        }
2204        if let Some(n) = p.price_output_per_mtok {
2205            self.config.price_output_per_mtok = Some(n);
2206        }
2207        if let Some(prompts) = &p.prompts {
2208            for (k, v) in prompts {
2209                self.config.prompts.insert(k.clone(), v.clone());
2210            }
2211        }
2212        if let Some(overrides) = &p.tool_overrides {
2213            for (name, o) in overrides {
2214                let entry = self.config.tool_overrides.entry(name.clone()).or_default();
2215                if let Some(en) = o.enabled {
2216                    entry.enabled = Some(en);
2217                }
2218                if let Some(desc) = &o.description {
2219                    entry.description = Some(desc.clone());
2220                }
2221                if let Some(tier) = &o.schema_tier {
2222                    entry.schema_tier = Some(crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
2223                        tracing::warn!(
2224                            "unknown schema tier `{tier}` in tool override `{name}`; defaulting to full"
2225                        );
2226                        crate::tools::SchemaTier::Full
2227                    }));
2228                }
2229                if let Some(t) = o.timeout_secs {
2230                    entry.timeout_secs = Some(t);
2231                }
2232            }
2233        }
2234        // P4b: scalars replace (S3.3).
2235        if let Some(v) = p.env_context {
2236            self.config.env_context = v;
2237        }
2238        if let Some(v) = &p.project_root_markers {
2239            self.config.project_root_markers = v.clone();
2240        }
2241        if let Some(v) = p.project_doc_max_bytes {
2242            self.config.project_doc_max_bytes = Some(v);
2243        }
2244        if let Some(v) = &p.project_doc_excludes {
2245            self.config.project_doc_excludes = v.clone();
2246        }
2247        if let Some(v) = p.project_doc_strip_comments {
2248            self.config.project_doc_strip_comments = v;
2249        }
2250        if let Some(v) = p.instruction_imports {
2251            self.config.instruction_imports = v;
2252        }
2253        if let Some(v) = p.retry_enabled {
2254            self.config.retry_enabled = v;
2255        }
2256        if let Some(v) = p.retry_max_retries {
2257            self.config.retry_max_retries = Some(v);
2258        }
2259        if let Some(v) = p.retry_base_delay_ms {
2260            self.config.retry_base_delay_ms = Some(v);
2261        }
2262        if let Some(v) = p.compaction_reserve_tokens {
2263            self.config.compaction_reserve_tokens = Some(v);
2264        }
2265        if let Some(v) = p.compaction_keep_recent_tokens {
2266            self.config.compaction_keep_recent_tokens = Some(v);
2267        }
2268        if let Some(v) = &p.compaction_focus_instructions {
2269            self.config.compaction_focus_instructions = Some(v.clone());
2270        }
2271        if let Some(v) = p.auto_title {
2272            self.config.auto_title = v;
2273        }
2274        if let Some(mode) = &p.steering_mode {
2275            // Fail safe: an unrecognized value keeps the current setting
2276            // rather than guessing.
2277            match SteeringMode::parse(mode) {
2278                Some(m) => self.config.steering_mode = m,
2279                None => tracing::warn!("unknown steering_mode `{mode}` in profile; ignoring"),
2280            }
2281        }
2282        if let Some(mode) = &p.follow_up_mode {
2283            match SteeringMode::parse(mode) {
2284                Some(m) => self.config.follow_up_mode = m,
2285                None => tracing::warn!("unknown follow_up_mode `{mode}` in profile; ignoring"),
2286            }
2287        }
2288        // P4c: scalars replace (S3.3).
2289        if let Some(v) = p.read_file_multimodal {
2290            self.config.read_file_multimodal = v;
2291        }
2292        // BP-2: `cat -n` gutter + recoverable tool-output spill (S3.3).
2293        if let Some(v) = p.read_file_line_numbers {
2294            self.config.read_file_line_numbers = v;
2295        }
2296        if let Some(v) = p.tool_output_spill {
2297            self.config.tool_output_spill = v;
2298        }
2299        if let Some(v) = p.edit_file_require_read_before_edit {
2300            self.config.edit_file_require_read_before_edit = v;
2301        }
2302        if let Some(v) = p.edit_file_notebook_aware {
2303            self.config.edit_file_notebook_aware = v;
2304        }
2305        if let Some(v) = p.shell_env_snapshot {
2306            self.config.shell_env_snapshot = v;
2307        }
2308        if let Some(v) = p.doom_loop_threshold {
2309            self.config.doom_loop_threshold = Some(v);
2310        }
2311        if let Some(v) = p.nested_instructions {
2312            self.config.nested_instructions = v;
2313        }
2314        if let Some(v) = p.model_switch_notice {
2315            self.config.model_switch_notice = v;
2316        }
2317        if let Some(v) = p.model_switch_allow_switch {
2318            self.config.model_switch_allow_switch = v;
2319        }
2320        // P4e: scalars replace (S3.3).
2321        if let Some(v) = p.context_injections {
2322            self.config.context_injections = v;
2323        }
2324        if let Some(v) = p.compaction_enabled {
2325            self.config.compaction_enabled = v;
2326        }
2327        if let Some(v) = p.compaction_summarize {
2328            self.config.compaction_summarize = v;
2329        }
2330        if let Some(v) = p.parallel_tool_calls {
2331            self.config.parallel_tool_calls = v;
2332        }
2333        if let Some(v) = p.session_git_metadata {
2334            self.config.session_git_metadata = v;
2335        }
2336        if let Some(v) = &p.session_dir {
2337            self.config.session_dir = Some(v.clone());
2338        }
2339        if let Some(v) = p.session_append_only {
2340            self.config.session_append_only = v;
2341        }
2342        if let Some(v) = p.session_queue_persist {
2343            self.config.session_queue_persist = v;
2344        }
2345        if let Some(v) = p.session_persist {
2346            self.config.session_persist = v;
2347        }
2348        if let Some(v) = &p.session_name {
2349            self.config.session_name = Some(v.clone());
2350        }
2351        if let Some(v) = p.session_retention_days {
2352            self.config.session_retention_days = Some(v);
2353        }
2354        if let Some(fmt) = &p.session_export_format {
2355            match crate::human_export::HumanExportFormat::parse(fmt) {
2356                Some(f) => self.config.session_export_format = f,
2357                None => {
2358                    tracing::warn!("unknown session export_format `{fmt}` in profile; ignoring")
2359                }
2360            }
2361        }
2362        self
2363    }
2364}
2365
2366/// Fluent builder for [`Config`].
2367#[derive(Default)]
2368pub struct ConfigBuilder {
2369    config: Config,
2370}
2371
2372impl ConfigBuilder {
2373    /// P4d (design §5.2 P1 CLI-adapter): resume building from an
2374    /// ALREADY-constructed [`Config`] rather than [`Config::default`] — lets
2375    /// a caller that assembled a `Config` with its own precedence logic
2376    /// (e.g. the CLI's `build_config`: flag > env > project > user >
2377    /// interactive-default) layer a narrowly-scoped [`ConfigProfile`] on top
2378    /// via [`Self::apply_profile`] afterward, reusing that method's correct
2379    /// per-key merge semantics (tables merge key-wise, e.g.
2380    /// `tool_overrides`/`prompts`/`extra_headers`/`extra_body`) instead of a
2381    /// second hand-rolled copy of the same merge logic at the call site.
2382    pub fn from_config(config: Config) -> Self {
2383        ConfigBuilder { config }
2384    }
2385
2386    /// Set the model identifier.
2387    pub fn model(mut self, model: impl Into<String>) -> Self {
2388        self.config.model = model.into();
2389        self
2390    }
2391
2392    /// Set the OpenAI-compatible base URL (defaults to OpenRouter).
2393    pub fn base_url(mut self, url: impl Into<String>) -> Self {
2394        self.config.base_url = url.into();
2395        self
2396    }
2397
2398    /// Provide the API key explicitly.
2399    pub fn api_key(mut self, key: impl Into<String>) -> Self {
2400        self.config.api_key = Some(key.into());
2401        self
2402    }
2403
2404    /// Change which environment variable the key is read from.
2405    pub fn api_key_env(mut self, var: impl Into<String>) -> Self {
2406        self.config.api_key_env = var.into();
2407        self
2408    }
2409
2410    /// Set the credential-helper command — see [`Config::api_key_cmd`].
2411    pub fn api_key_cmd(mut self, cmd: impl Into<String>) -> Self {
2412        self.config.api_key_cmd = Some(cmd.into());
2413        self
2414    }
2415
2416    /// Set the argv credential helper — see [`Config::api_key_command`].
2417    pub fn api_key_command(mut self, argv: Vec<String>) -> Self {
2418        self.config.api_key_command = Some(argv);
2419        self
2420    }
2421
2422    /// Enable/disable the startup release check — see
2423    /// [`Config::update_check`].
2424    pub fn update_check(mut self, on: bool) -> Self {
2425        self.config.update_check = on;
2426        self
2427    }
2428
2429    /// Replace the system prompt.
2430    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
2431        self.config.system_prompt = prompt.into();
2432        self
2433    }
2434
2435    /// Set the sampling temperature.
2436    pub fn temperature(mut self, t: f32) -> Self {
2437        self.config.temperature = Some(t);
2438        self
2439    }
2440
2441    /// Set the max output tokens.
2442    pub fn max_tokens(mut self, n: u32) -> Self {
2443        self.config.max_tokens = Some(n);
2444        self
2445    }
2446
2447    /// Set the per-`send` iteration budget.
2448    pub fn max_iterations(mut self, n: usize) -> Self {
2449        self.config.max_iterations = n;
2450        self
2451    }
2452
2453    /// Set the reasoning/effort level (`reasoning_effort`).
2454    pub fn effort(mut self, level: impl Into<String>) -> Self {
2455        self.config.effort = Some(level.into());
2456        self
2457    }
2458
2459    /// Constrain output to a JSON schema (`response_format`).
2460    pub fn response_format(mut self, format: serde_json::Value) -> Self {
2461        self.config.response_format = Some(format);
2462        self
2463    }
2464
2465    /// Merge an extra request-body field (provider-native passthrough).
2466    pub fn extra_body_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
2467        self.config.extra_body.insert(key.into(), value);
2468        self
2469    }
2470
2471    /// Cap cumulative output tokens across one `send` loop.
2472    /// Cap a single tool result at `n` bytes (see
2473    /// [`Config::max_tool_output_bytes`]). Pass `0` only via the field to disable.
2474    pub fn max_tool_output_bytes(mut self, n: usize) -> Self {
2475        self.config.max_tool_output_bytes = Some(n);
2476        self
2477    }
2478
2479    /// Cap on cumulative output tokens across one send loop. Output tokens
2480    /// only; input/prompt tokens are not counted, so this is not a cost cap.
2481    pub fn max_total_output_tokens(mut self, n: u64) -> Self {
2482        self.config.max_total_output_tokens = Some(n);
2483        self
2484    }
2485
2486    /// BP-7 — see [`Config::max_budget_usd`].
2487    pub fn max_budget_usd(mut self, usd: f64) -> Self {
2488        self.config.max_budget_usd = Some(usd);
2489        self
2490    }
2491
2492    /// BP-7 — see [`Config::max_steps`].
2493    pub fn max_steps(mut self, n: usize) -> Self {
2494        self.config.max_steps = Some(n);
2495        self
2496    }
2497
2498    /// BP-7 — see [`Config::price_input_per_mtok`] /
2499    /// [`Config::price_output_per_mtok`]. Both halves at once, because
2500    /// half a price bills the other half at zero.
2501    pub fn model_price(mut self, input_per_mtok: f64, output_per_mtok: f64) -> Self {
2502        self.config.price_input_per_mtok = Some(input_per_mtok);
2503        self.config.price_output_per_mtok = Some(output_per_mtok);
2504        self
2505    }
2506
2507    /// Set the working directory tools operate in.
2508    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
2509        self.config.cwd = dir.into();
2510        self
2511    }
2512
2513    /// Add an extra root directory (`--add-dir` / multi-root / worktree).
2514    pub fn add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2515        self.config.additional_dirs.push(dir.into());
2516        self
2517    }
2518
2519    /// Enable auto-loading of `CLAUDE.md` / `AGENTS.md` into the system prompt.
2520    pub fn project_context(mut self, enabled: bool) -> Self {
2521        self.config.load_project_context = enabled;
2522        self
2523    }
2524
2525    /// Register a named prompt template (skill / slash command).
2526    pub fn prompt(mut self, name: impl Into<String>, template: impl Into<String>) -> Self {
2527        self.config.prompts.insert(name.into(), template.into());
2528        self
2529    }
2530
2531    /// Compact the conversation once it exceeds `n` messages.
2532    pub fn compact_after_messages(mut self, n: usize) -> Self {
2533        self.config.compact_after_messages = Some(n);
2534        self
2535    }
2536
2537    /// Set the filesystem sandbox policy for write-capable tools.
2538    pub fn sandbox(mut self, policy: crate::tools::SandboxPolicy) -> Self {
2539        self.config.sandbox = policy;
2540        self
2541    }
2542
2543    /// Set the tool-approval policy.
2544    pub fn approval(mut self, policy: ApprovalPolicy) -> Self {
2545        self.config.approval = policy;
2546        self
2547    }
2548
2549    /// Add a tool to the auto-approve allowlist (no approval under `OnRequest`).
2550    pub fn auto_approve_tool(mut self, name: impl Into<String>) -> Self {
2551        self.config.auto_approved_tools.insert(name.into());
2552        self
2553    }
2554
2555    /// P4: add a glob pattern to [`Config::tool_deny_patterns`] — a match
2556    /// forces approval unconditionally, even under `ApprovalPolicy::Never`.
2557    pub fn deny_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
2558        self.config.tool_deny_patterns.push(pattern.into());
2559        self
2560    }
2561
2562    /// P4: add a glob pattern to [`Config::tool_allow_patterns`] — the
2563    /// pattern generalization of [`Self::auto_approve_tool`].
2564    pub fn allow_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
2565        self.config.tool_allow_patterns.push(pattern.into());
2566        self
2567    }
2568
2569    /// Set the handler consulted when a tool call needs approval.
2570    pub fn approval_handler(mut self, handler: ApprovalHandler) -> Self {
2571        self.config.approval_handler = Some(handler);
2572        self
2573    }
2574
2575    /// Set the pre-tool hook (may block a call by returning `Some(reason)`).
2576    pub fn pre_tool_hook(mut self, hook: PreToolHook) -> Self {
2577        self.config.pre_tool_hook = Some(hook);
2578        self
2579    }
2580
2581    /// Set the post-tool hook (observational).
2582    pub fn post_tool_hook(mut self, hook: PostToolHook) -> Self {
2583        self.config.post_tool_hook = Some(hook);
2584        self
2585    }
2586
2587    /// Disable a tool by name.
2588    pub fn disable_tool(mut self, name: impl Into<String>) -> Self {
2589        self.config
2590            .tool_overrides
2591            .entry(name.into())
2592            .or_default()
2593            .enabled = Some(false);
2594        self
2595    }
2596
2597    /// Enable a tool by name (overriding a prior disable).
2598    pub fn enable_tool(mut self, name: impl Into<String>) -> Self {
2599        self.config
2600            .tool_overrides
2601            .entry(name.into())
2602            .or_default()
2603            .enabled = Some(true);
2604        self
2605    }
2606
2607    /// Override the description the model sees for a tool.
2608    pub fn tool_description(
2609        mut self,
2610        name: impl Into<String>,
2611        description: impl Into<String>,
2612    ) -> Self {
2613        self.config
2614            .tool_overrides
2615            .entry(name.into())
2616            .or_default()
2617            .description = Some(description.into());
2618        self
2619    }
2620
2621    /// Set how tools are advertised to the model (B6).
2622    pub fn tool_advertising(mut self, advertising: ToolAdvertising) -> Self {
2623        self.config.tool_advertising = advertising;
2624        self
2625    }
2626
2627    /// Set the global tool-schema tier (TR-8/T5): how verbose ADVERTISED
2628    /// tool schemas are. Per-tool overrides ([`Self::tool_schema_tier`])
2629    /// still win for the specific tools they name.
2630    pub fn schema_tier(mut self, tier: crate::tools::SchemaTier) -> Self {
2631        self.config.tool_schema_tier = tier;
2632        self
2633    }
2634
2635    /// Override the schema tier for a single tool (TR-8/T5), regardless of
2636    /// the global knob — e.g. keep one load-bearing tool at `Full` while
2637    /// everything else shrinks to `Minimal`.
2638    pub fn tool_schema_tier(
2639        mut self,
2640        name: impl Into<String>,
2641        tier: crate::tools::SchemaTier,
2642    ) -> Self {
2643        self.config
2644            .tool_overrides
2645            .entry(name.into())
2646            .or_default()
2647            .schema_tier = Some(tier);
2648        self
2649    }
2650
2651    /// Add an extra HTTP header sent on every request.
2652    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
2653        self.config.extra_headers.insert(key.into(), value.into());
2654        self
2655    }
2656
2657    /// Attach a streaming event sink.
2658    pub fn event_sink(mut self, sink: EventSink) -> Self {
2659        self.config.event_sink = Some(sink);
2660        self
2661    }
2662
2663    /// Set the prompt-caching plan (B7).
2664    pub fn cache_plan(mut self, plan: CachePlan) -> Self {
2665        self.config.cache_plan = plan;
2666        self
2667    }
2668
2669    /// UX-26 (B7-warn): enable/disable the cache-cold warning (default on).
2670    pub fn cache_warnings(mut self, enabled: bool) -> Self {
2671        self.config.cache_warnings = enabled;
2672        self
2673    }
2674
2675    /// P3: turn on the `[experimental] module_registry` gate — see
2676    /// [`Config::module_registry`].
2677    pub fn module_registry(mut self, enabled: bool) -> Self {
2678        self.config.module_registry = enabled;
2679        self
2680    }
2681
2682    /// P3: set the resolved module-activation set — see
2683    /// [`Config::module_activation`].
2684    pub fn module_activation(mut self, activation: crate::modules::ModuleActivation) -> Self {
2685        self.config.module_activation = activation;
2686        self
2687    }
2688
2689    /// P3: set the effective `[core.tools] enabled` list — see
2690    /// [`Config::core_tools_enabled`].
2691    pub fn core_tools_enabled(mut self, tools: Vec<String>) -> Self {
2692        self.config.core_tools_enabled = tools;
2693        self
2694    }
2695
2696    /// P3: set `[core.skills].enabled` — see [`Config::skills_enabled`].
2697    pub fn skills_enabled(mut self, enabled: bool) -> Self {
2698        self.config.skills_enabled = enabled;
2699        self
2700    }
2701
2702    /// BP-6: set `[core.skills].harness` — see [`Config::skills_harness`].
2703    pub fn skills_harness(mut self, harness: impl Into<String>) -> Self {
2704        self.config.skills_harness = Some(harness.into());
2705        self
2706    }
2707
2708    /// BP-6: set `[core.skills].dirs` — see [`Config::skills_dirs`].
2709    pub fn skills_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
2710        self.config.skills_dirs = dirs;
2711        self
2712    }
2713
2714    /// BP-6: set `[core.skills].implicit_match` — see
2715    /// [`Config::skills_implicit_match`].
2716    pub fn skills_implicit_match(mut self, enabled: bool) -> Self {
2717        self.config.skills_implicit_match = enabled;
2718        self
2719    }
2720
2721    /// BP-5: set `[core.skills].shell_injection` — see
2722    /// [`Config::skills_shell_injection`].
2723    pub fn skills_shell_injection(mut self, enabled: bool) -> Self {
2724        self.config.skills_shell_injection = enabled;
2725        self
2726    }
2727
2728    /// BP-5: set `[core.file_mentions]` — see [`Config::file_mentions`].
2729    pub fn file_mentions(mut self, enabled: bool) -> Self {
2730        self.config.file_mentions = enabled;
2731        self
2732    }
2733
2734    /// BP-5: set `[core.output_style]` — see [`Config::output_style`].
2735    pub fn output_style(mut self, name: impl Into<String>) -> Self {
2736        self.config.output_style = name.into();
2737        self
2738    }
2739
2740    /// BP-5: set `[core.path_rules]` — see [`Config::path_rules`].
2741    pub fn path_rules(mut self, enabled: bool) -> Self {
2742        self.config.path_rules = enabled;
2743        self
2744    }
2745
2746    /// BP-5: set `[capabilities.model_catalog].base_prompts` — see
2747    /// [`Config::model_family_prompts`].
2748    pub fn model_family_prompts(
2749        mut self,
2750        prompts: std::collections::BTreeMap<String, String>,
2751    ) -> Self {
2752        self.config.model_family_prompts = prompts;
2753        self
2754    }
2755
2756    /// P4: set the small/utility model id — see `Config::small_model`.
2757    pub fn small_model(mut self, model: impl Into<String>) -> Self {
2758        self.config.small_model = Some(model.into());
2759        self
2760    }
2761
2762    /// BP-13: set the resolved model-routing table — see
2763    /// `Config::model_routing`.
2764    pub fn model_routing(mut self, routing: crate::model_catalog::Routing) -> Self {
2765        self.config.model_routing = routing;
2766        self
2767    }
2768
2769    /// BP-13: set the session-level service tier (`/fast`) — see
2770    /// `Config::service_tier`.
2771    pub fn service_tier(mut self, tier: impl Into<String>) -> Self {
2772        self.config.service_tier = Some(tier.into());
2773        self
2774    }
2775
2776    /// P4: set the model failure-fallback chain — see `Config::model_fallback`.
2777    pub fn model_fallback(mut self, chain: Vec<String>) -> Self {
2778        self.config.model_fallback = chain;
2779        self
2780    }
2781
2782    /// P4b: turn on the environment-context block — see [`Config::env_context`].
2783    pub fn env_context(mut self, enabled: bool) -> Self {
2784        self.config.env_context = enabled;
2785        self
2786    }
2787
2788    /// P4b: set the project-root marker filenames — see
2789    /// [`Config::project_root_markers`].
2790    pub fn project_root_markers(mut self, markers: Vec<String>) -> Self {
2791        self.config.project_root_markers = markers;
2792        self
2793    }
2794
2795    /// P4b: cap the total bytes of assembled instruction-file content — see
2796    /// [`Config::project_doc_max_bytes`].
2797    pub fn project_doc_max_bytes(mut self, n: usize) -> Self {
2798        self.config.project_doc_max_bytes = Some(n);
2799        self
2800    }
2801
2802    /// P4b: turn on `@path` instruction imports — see
2803    /// [`Config::instruction_imports`].
2804    pub fn instruction_imports(mut self, enabled: bool) -> Self {
2805        self.config.instruction_imports = enabled;
2806        self
2807    }
2808
2809    /// P4b: configure request retry with backoff — see
2810    /// [`Config::retry_enabled`]. `max_retries`/`base_delay_ms` override the
2811    /// transport's built-in defaults when `Some`.
2812    pub fn retry(
2813        mut self,
2814        enabled: bool,
2815        max_retries: Option<u32>,
2816        base_delay_ms: Option<u64>,
2817    ) -> Self {
2818        self.config.retry_enabled = enabled;
2819        self.config.retry_max_retries = max_retries;
2820        self.config.retry_base_delay_ms = base_delay_ms;
2821        self
2822    }
2823
2824    /// P4b: turn on the compaction token-pressure trigger — see
2825    /// [`Config::compaction_reserve_tokens`].
2826    pub fn compaction_pressure(mut self, reserve_tokens: u64, keep_recent_tokens: u64) -> Self {
2827        self.config.compaction_reserve_tokens = Some(reserve_tokens);
2828        self.config.compaction_keep_recent_tokens = Some(keep_recent_tokens);
2829        self
2830    }
2831
2832    /// P4b: set the compaction focus instructions — see
2833    /// [`Config::compaction_focus_instructions`].
2834    pub fn compaction_focus_instructions(mut self, text: impl Into<String>) -> Self {
2835        self.config.compaction_focus_instructions = Some(text.into());
2836        self
2837    }
2838
2839    /// P4b: turn on the auto-title gate — see [`Config::auto_title`].
2840    pub fn auto_title(mut self, enabled: bool) -> Self {
2841        self.config.auto_title = enabled;
2842        self
2843    }
2844
2845    /// P4b: set the mid-turn steering delivery mode — see
2846    /// [`Config::steering_mode`].
2847    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
2848        self.config.steering_mode = mode;
2849        self
2850    }
2851
2852    /// P4b: set the idle follow-up delivery mode — see
2853    /// [`Config::follow_up_mode`].
2854    pub fn follow_up_mode(mut self, mode: SteeringMode) -> Self {
2855        self.config.follow_up_mode = mode;
2856        self
2857    }
2858
2859    /// P4b: install a stop-gate hook — see [`Config::stop_gate`].
2860    pub fn stop_gate(mut self, hook: StopGateHook) -> Self {
2861        self.config.stop_gate = Some(hook);
2862        self
2863    }
2864
2865    /// BP-2: number `read_file` output `cat -n` style — see
2866    /// [`Config::read_file_line_numbers`].
2867    pub fn read_file_line_numbers(mut self, enabled: bool) -> Self {
2868        self.config.read_file_line_numbers = enabled;
2869        self
2870    }
2871
2872    /// BP-2: spill capped tool output to a per-session file the model can
2873    /// read back — see [`Config::tool_output_spill`].
2874    pub fn tool_output_spill(mut self, enabled: bool) -> Self {
2875        self.config.tool_output_spill = enabled;
2876        self
2877    }
2878
2879    /// P4c: turn on multimodal `read_file` — see [`Config::read_file_multimodal`].
2880    pub fn read_file_multimodal(mut self, enabled: bool) -> Self {
2881        self.config.read_file_multimodal = enabled;
2882        self
2883    }
2884
2885    /// P4c: require a prior read before `edit_file` accepts an edit — see
2886    /// [`Config::edit_file_require_read_before_edit`].
2887    pub fn edit_file_require_read_before_edit(mut self, enabled: bool) -> Self {
2888        self.config.edit_file_require_read_before_edit = enabled;
2889        self
2890    }
2891
2892    /// P4c: turn on notebook-cell-aware `edit_file` — see
2893    /// [`Config::edit_file_notebook_aware`].
2894    pub fn edit_file_notebook_aware(mut self, enabled: bool) -> Self {
2895        self.config.edit_file_notebook_aware = enabled;
2896        self
2897    }
2898
2899    /// P4c: turn on shell-environment snapshotting — see
2900    /// [`Config::shell_env_snapshot`].
2901    pub fn shell_env_snapshot(mut self, enabled: bool) -> Self {
2902        self.config.shell_env_snapshot = enabled;
2903        self
2904    }
2905
2906    /// P4c: set the doom-loop repetition threshold — see
2907    /// [`Config::doom_loop_threshold`].
2908    pub fn doom_loop_threshold(mut self, n: u32) -> Self {
2909        self.config.doom_loop_threshold = Some(n);
2910        self
2911    }
2912
2913    /// P4c: turn on on-demand nested instruction loading — see
2914    /// [`Config::nested_instructions`].
2915    pub fn nested_instructions(mut self, enabled: bool) -> Self {
2916        self.config.nested_instructions = enabled;
2917        self
2918    }
2919
2920    /// P4c: turn on mid-session model switch's persisted-record +
2921    /// reasoning-filter behavior — see [`Config::model_switch_allow_switch`].
2922    pub fn model_switch_allow_switch(mut self, enabled: bool) -> Self {
2923        self.config.model_switch_allow_switch = enabled;
2924        self
2925    }
2926
2927    /// BP-13: inject a switch notice on a mid-session model change — see
2928    /// [`Config::model_switch_notice`].
2929    pub fn model_switch_notice(mut self, enabled: bool) -> Self {
2930        self.config.model_switch_notice = enabled;
2931        self
2932    }
2933
2934    /// BP-13: set the effort level plan mode runs at — see
2935    /// [`Config::plan_mode_effort`].
2936    pub fn plan_mode_effort(mut self, effort: impl Into<String>) -> Self {
2937        self.config.plan_mode_effort = Some(effort.into());
2938        self
2939    }
2940
2941    /// P4e: turn on ambient context-injection blocks — see
2942    /// [`Config::context_injections`].
2943    pub fn context_injections(mut self, enabled: bool) -> Self {
2944        self.config.context_injections = enabled;
2945        self
2946    }
2947
2948    /// P4e: append one named ambient context block — see
2949    /// [`Config::context_injection_blocks`].
2950    pub fn context_injection_block(
2951        mut self,
2952        name: impl Into<String>,
2953        content: impl Into<String>,
2954    ) -> Self {
2955        self.config
2956            .context_injection_blocks
2957            .push(ContextInjectionBlock::new(name, content));
2958        self
2959    }
2960
2961    /// P4e: master gate for all auto-compaction — see
2962    /// [`Config::compaction_enabled`].
2963    pub fn compaction_enabled(mut self, enabled: bool) -> Self {
2964        self.config.compaction_enabled = enabled;
2965        self
2966    }
2967
2968    /// P4e: run independent tool calls concurrently — see
2969    /// [`Config::parallel_tool_calls`].
2970    pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
2971        self.config.parallel_tool_calls = enabled;
2972        self
2973    }
2974
2975    /// P4e: capture git branch/sha/dirty at construction — see
2976    /// [`Config::session_git_metadata`].
2977    pub fn session_git_metadata(mut self, enabled: bool) -> Self {
2978        self.config.session_git_metadata = enabled;
2979        self
2980    }
2981
2982    /// P4e DEFECT-FIX: ephemeral vs. persisted session gate — see
2983    /// [`Config::session_persist`].
2984    pub fn session_persist(mut self, enabled: bool) -> Self {
2985        self.config.session_persist = enabled;
2986        self
2987    }
2988
2989    /// BP-8: arm the append-only session journal — see
2990    /// [`Config::session_append_only`].
2991    pub fn session_append_only(mut self, enabled: bool) -> Self {
2992        self.config.session_append_only = enabled;
2993        self
2994    }
2995
2996    /// BP-8: record pending inputs as journal queue operations — see
2997    /// [`Config::session_queue_persist`].
2998    pub fn session_queue_persist(mut self, enabled: bool) -> Self {
2999        self.config.session_queue_persist = enabled;
3000        self
3001    }
3002
3003    /// BP-8: persist the `update_plan` checklist with the session — see
3004    /// [`Config::todos_persist`].
3005    pub fn todos_persist(mut self, enabled: bool) -> Self {
3006        self.config.todos_persist = enabled;
3007        self
3008    }
3009
3010    /// P4e DEFECT-FIX: a caller-configured session name — see
3011    /// [`Config::session_name`].
3012    pub fn session_name(mut self, name: impl Into<String>) -> Self {
3013        self.config.session_name = Some(name.into());
3014        self
3015    }
3016
3017    /// P5-3 (§3.1 `capabilities.subagents.enabled`) — see
3018    /// [`Config::subagents_enabled`].
3019    pub fn subagents_enabled(mut self, enabled: bool) -> Self {
3020        self.config.subagents_enabled = enabled;
3021        self
3022    }
3023
3024    /// P5-3 (§3.1 `capabilities.subagents.max_depth`) — see
3025    /// [`Config::subagents_max_depth`].
3026    pub fn subagents_max_depth(mut self, n: usize) -> Self {
3027        self.config.subagents_max_depth = n;
3028        self
3029    }
3030
3031    /// P5-3 (resource bound) — see [`Config::subagents_max_concurrent`].
3032    pub fn subagents_max_concurrent(mut self, n: usize) -> Self {
3033        self.config.subagents_max_concurrent = n;
3034        self
3035    }
3036
3037    /// P5-3 (§3.1 `capabilities.subagents.background`) — see
3038    /// [`Config::subagents_background`].
3039    pub fn subagents_background(mut self, enabled: bool) -> Self {
3040        self.config.subagents_background = enabled;
3041        self
3042    }
3043
3044    /// P5-3 (§2.2 C6) — see [`Config::subagents_background_prompts`].
3045    pub fn subagents_background_prompts(
3046        mut self,
3047        policy: crate::subagents::BackgroundPromptsPolicy,
3048    ) -> Self {
3049        self.config.subagents_background_prompts = Some(policy);
3050        self
3051    }
3052
3053    /// Enable Claude Code's `Agent` compatibility alias for named subagents.
3054    pub fn subagents_claude_agent_alias(mut self, enabled: bool) -> Self {
3055        self.config.subagents_claude_agent_alias = enabled;
3056        self
3057    }
3058
3059    /// Enable Claude's paused runtime-state compatibility intrinsics.
3060    pub fn claude_runtime_tools_enabled(mut self, enabled: bool) -> Self {
3061        self.config.claude_runtime_tools_enabled = enabled;
3062        self
3063    }
3064
3065    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`) — register one
3066    /// named agent definition, keyed by [`crate::subagents::NamedAgentDefinition::name`].
3067    pub fn subagent_definition(mut self, def: crate::subagents::NamedAgentDefinition) -> Self {
3068        self.config
3069            .subagents_definitions
3070            .insert(def.name.clone(), def);
3071        self
3072    }
3073
3074    /// P5-3 (runtime-only) — see [`Config::subagent_depth`]. Not something
3075    /// an ordinary caller sets by hand; `Agent::run_spawn_subagent` sets it
3076    /// on the CHILD config it builds.
3077    pub fn subagent_depth(mut self, depth: usize) -> Self {
3078        self.config.subagent_depth = depth;
3079        self
3080    }
3081
3082    /// P5-6 (§3.1 `capabilities.tools_background.enabled`) — see
3083    /// [`Config::tools_background_enabled`].
3084    pub fn tools_background_enabled(mut self, enabled: bool) -> Self {
3085        self.config.tools_background_enabled = enabled;
3086        self
3087    }
3088
3089    /// P5-6 (resource bound) — see [`Config::tools_background_max_concurrent`].
3090    pub fn tools_background_max_concurrent(mut self, n: usize) -> Self {
3091        self.config.tools_background_max_concurrent = n;
3092        self
3093    }
3094
3095    /// P5-6 (resource bound) — see [`Config::tools_background_max_output_bytes`].
3096    pub fn tools_background_max_output_bytes(mut self, n: usize) -> Self {
3097        self.config.tools_background_max_output_bytes = n;
3098        self
3099    }
3100
3101    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`) — see
3102    /// [`Config::checkpoint_enabled`].
3103    pub fn checkpoint_enabled(mut self, enabled: bool) -> Self {
3104        self.config.checkpoint_enabled = enabled;
3105        self
3106    }
3107
3108    /// P5-9 (bounded-disk requirement) — see [`Config::checkpoint_retain`].
3109    pub fn checkpoint_retain(mut self, n: usize) -> Self {
3110        self.config.checkpoint_retain = n;
3111        self
3112    }
3113
3114    /// BP-10 — see [`Config::trust_handler`]. Installing the door is what
3115    /// turns `[capabilities.trust] default = "ask"` from "behaves like
3116    /// never" into a real question.
3117    pub fn trust_handler(
3118        mut self,
3119        handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>,
3120    ) -> Self {
3121        self.config.trust_handler = Some(handler);
3122        self
3123    }
3124
3125    /// BP-10 (embedder/test override) — see [`Config::trust_store`].
3126    pub fn trust_store(mut self, path: impl Into<PathBuf>) -> Self {
3127        self.config.trust_store = Some(path.into());
3128        self
3129    }
3130
3131    /// BP-10 — see [`Config::permissions_approvals_persist`]. Turning this
3132    /// on WITHOUT also setting [`Self::permissions_approval_store`] uses
3133    /// the per-project default under `$SUPERCODE_HOME`.
3134    pub fn permissions_approvals_persist(mut self, persist: bool) -> Self {
3135        self.config.permissions_approvals_persist = persist;
3136        self
3137    }
3138
3139    /// BP-10 (embedder/test override) — see
3140    /// [`Config::permissions_approval_store`].
3141    pub fn permissions_approval_store(mut self, path: impl Into<PathBuf>) -> Self {
3142        self.config.permissions_approval_store = Some(path.into());
3143        self
3144    }
3145
3146    /// P5-9 (embedder/test override) — see [`Config::checkpoint_dir`].
3147    pub fn checkpoint_dir(mut self, dir: impl Into<PathBuf>) -> Self {
3148        self.config.checkpoint_dir = Some(dir.into());
3149        self
3150    }
3151
3152    /// P5-11 (§3.1 `capabilities.lsp.enabled`) — see [`Config::lsp_enabled`].
3153    pub fn lsp_enabled(mut self, enabled: bool) -> Self {
3154        self.config.lsp_enabled = enabled;
3155        self
3156    }
3157
3158    /// P5-11 (`capabilities.lsp.servers`) — see [`Config::lsp_servers`].
3159    pub fn lsp_servers(mut self, servers: Vec<(String, crate::lsp::LspServerSpec)>) -> Self {
3160        self.config.lsp_servers = servers;
3161        self
3162    }
3163
3164    /// P5-11 (bounded-context requirement) — see [`Config::lsp_max_diagnostics`].
3165    pub fn lsp_max_diagnostics(mut self, n: usize) -> Self {
3166        self.config.lsp_max_diagnostics = n;
3167        self
3168    }
3169
3170    /// P5-11 (bounded-latency requirement) — see [`Config::lsp_timeout_secs`].
3171    pub fn lsp_timeout_secs(mut self, secs: u64) -> Self {
3172        self.config.lsp_timeout_secs = secs;
3173        self
3174    }
3175
3176    /// P5-11 (§3.1 `capabilities.formatters.enabled`) — see
3177    /// [`Config::formatters_enabled`].
3178    pub fn formatters_enabled(mut self, enabled: bool) -> Self {
3179        self.config.formatters_enabled = enabled;
3180        self
3181    }
3182
3183    /// P5-11 (`capabilities.formatters.<name>`) — see [`Config::formatters`].
3184    pub fn formatters(
3185        mut self,
3186        formatters: Vec<(String, crate::formatters::FormatterSpec)>,
3187    ) -> Self {
3188        self.config.formatters = formatters;
3189        self
3190    }
3191
3192    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10) — see
3193    /// [`Config::formatters_diff_back`].
3194    pub fn formatters_diff_back(mut self, diff_back: bool) -> Self {
3195        self.config.formatters_diff_back = diff_back;
3196        self
3197    }
3198
3199    /// P5-11 (bounded-latency requirement) — see [`Config::formatters_timeout_secs`].
3200    pub fn formatters_timeout_secs(mut self, secs: u64) -> Self {
3201        self.config.formatters_timeout_secs = secs;
3202        self
3203    }
3204
3205    /// P5-12 (§3.1 `capabilities.trust.enabled`) — see [`Config::trust_enabled`].
3206    pub fn trust_enabled(mut self, enabled: bool) -> Self {
3207        self.config.trust_enabled = enabled;
3208        self
3209    }
3210
3211    /// P5-12 (`capabilities.trust.default`) — see [`Config::trust_default`].
3212    pub fn trust_default(mut self, default: crate::plugins::TrustDecision) -> Self {
3213        self.config.trust_default = default;
3214        self
3215    }
3216
3217    /// P5-12 (§3.1 `capabilities.plugins.enabled`) — see [`Config::plugins_enabled`].
3218    pub fn plugins_enabled(mut self, enabled: bool) -> Self {
3219        self.config.plugins_enabled = enabled;
3220        self
3221    }
3222
3223    /// P5-12 (`capabilities.plugins.dirs`) — see [`Config::plugins_dirs`].
3224    pub fn plugins_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
3225        self.config.plugins_dirs = dirs;
3226        self
3227    }
3228
3229    /// Finalize the configuration.
3230    pub fn build(self) -> Config {
3231        self.config
3232    }
3233}
3234
3235#[cfg(test)]
3236mod tests {
3237    use super::*;
3238
3239    /// Every NEW `ConfigProfile` field the P1 migration adds (design
3240    /// §5.2/§3.2's explicit unblock list) actually reaches the built
3241    /// `Config` through `apply_profile`.
3242    #[test]
3243    fn apply_profile_applies_every_new_p1_field() {
3244        let mut tool_overrides = HashMap::new();
3245        tool_overrides.insert(
3246            "write_file".to_string(),
3247            ToolOverrideProfile {
3248                enabled: Some(false),
3249                description: Some("custom".to_string()),
3250                schema_tier: Some("minimal".to_string()),
3251                timeout_secs: None,
3252            },
3253        );
3254        let mut extra_headers = HashMap::new();
3255        extra_headers.insert("X-Title".to_string(), "supercode".to_string());
3256        let mut extra_body = serde_json::Map::new();
3257        extra_body.insert("provider_flag".to_string(), serde_json::json!(true));
3258        let mut prompts = HashMap::new();
3259        prompts.insert("standup".to_string(), "Summarize {args}".to_string());
3260
3261        let profile = ConfigProfile {
3262            api_key_env: Some("MY_KEY".to_string()),
3263            max_iterations: Some(40),
3264            additional_dirs: Some(vec!["../sibling".to_string()]),
3265            compact_after_messages: Some(50),
3266            cache_plan: Some("imported_prefix".to_string()),
3267            tool_advertising: Some("deferred".to_string()),
3268            tool_advertising_core: Some(vec!["bash".to_string()]),
3269            schema_tier: Some("medium".to_string()),
3270            auto_approved_tools: Some(vec!["read_file".to_string()]),
3271            extra_headers: Some(extra_headers),
3272            extra_body: Some(extra_body),
3273            max_tool_output_bytes: Some(4096),
3274            max_total_output_tokens: Some(8192),
3275            prompts: Some(prompts),
3276            tool_overrides: Some(tool_overrides),
3277            ..Default::default()
3278        };
3279
3280        let config = ConfigBuilder::default().apply_profile(&profile).build();
3281
3282        assert_eq!(config.api_key_env, "MY_KEY");
3283        assert_eq!(config.max_iterations, 40);
3284        assert_eq!(
3285            config.additional_dirs,
3286            vec![std::path::PathBuf::from("../sibling")]
3287        );
3288        assert_eq!(config.compact_after_messages, Some(50));
3289        assert_eq!(config.cache_plan, CachePlan::ImportedPrefix);
3290        match &config.tool_advertising {
3291            ToolAdvertising::Deferred { core } => assert_eq!(core, &vec!["bash".to_string()]),
3292            ToolAdvertising::Full => panic!("expected Deferred"),
3293        }
3294        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Medium);
3295        assert!(config.auto_approved_tools.contains("read_file"));
3296        assert_eq!(
3297            config.extra_headers.get("X-Title").map(String::as_str),
3298            Some("supercode")
3299        );
3300        assert_eq!(
3301            config.extra_body.get("provider_flag"),
3302            Some(&serde_json::json!(true))
3303        );
3304        assert_eq!(config.max_tool_output_bytes, Some(4096));
3305        assert_eq!(config.max_total_output_tokens, Some(8192));
3306        assert_eq!(
3307            config.prompts.get("standup").map(String::as_str),
3308            Some("Summarize {args}")
3309        );
3310        assert!(!config.tool_enabled("write_file"));
3311        assert_eq!(config.tool_description("write_file", "builtin"), "custom");
3312        assert_eq!(
3313            config.schema_tier_for("write_file"),
3314            crate::tools::SchemaTier::Minimal
3315        );
3316        // Built-in prompts survive — `prompts` is a table merge, not a
3317        // wholesale replace (§3.3).
3318        assert!(config.prompts.contains_key("code-review"));
3319    }
3320
3321    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): additive, composed
3322    /// onto the DEFAULT system prompt when no `system_prompt` override is
3323    /// set — distinct from replacing it.
3324    #[test]
3325    fn apply_profile_append_system_prompt_composes_onto_the_default() {
3326        let profile = ConfigProfile {
3327            append_system_prompt: Some("Always run tests before committing.".to_string()),
3328            ..Default::default()
3329        };
3330        let config = ConfigBuilder::default().apply_profile(&profile).build();
3331        assert_eq!(
3332            config.system_prompt,
3333            format!("{DEFAULT_SYSTEM_PROMPT}\n\nAlways run tests before committing.")
3334        );
3335    }
3336
3337    /// Composed onto an EXPLICIT `system_prompt` override in the SAME
3338    /// profile, not the default — replacement then append, in that order.
3339    #[test]
3340    fn apply_profile_append_system_prompt_composes_onto_an_explicit_override() {
3341        let profile = ConfigProfile {
3342            system_prompt: Some("You are terse.".to_string()),
3343            append_system_prompt: Some("Always run tests before committing.".to_string()),
3344            ..Default::default()
3345        };
3346        let config = ConfigBuilder::default().apply_profile(&profile).build();
3347        assert_eq!(
3348            config.system_prompt,
3349            "You are terse.\n\nAlways run tests before committing."
3350        );
3351    }
3352
3353    /// Default-off: no `append_system_prompt` set leaves `system_prompt`
3354    /// completely untouched (byte-identical to today's behavior).
3355    #[test]
3356    fn apply_profile_no_append_system_prompt_leaves_system_prompt_untouched() {
3357        let profile = ConfigProfile {
3358            system_prompt: Some("You are terse.".to_string()),
3359            ..Default::default()
3360        };
3361        let config = ConfigBuilder::default().apply_profile(&profile).build();
3362        assert_eq!(config.system_prompt, "You are terse.");
3363    }
3364
3365    /// Unknown enum strings fail SAFE (existing precedent, config.rs
3366    /// `sandbox`/`approval` parsing) — extended to the two NEW enum-shaped
3367    /// fields this migration adds.
3368    #[test]
3369    fn apply_profile_fails_safe_on_unknown_new_enums() {
3370        let profile = ConfigProfile {
3371            cache_plan: Some("bogus".to_string()),
3372            schema_tier: Some("bogus".to_string()),
3373            tool_advertising: Some("bogus".to_string()),
3374            ..Default::default()
3375        };
3376        let config = ConfigBuilder::default().apply_profile(&profile).build();
3377        assert_eq!(config.cache_plan, CachePlan::Off);
3378        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Full);
3379        // F5 fix: this was a bare `matches!(...)` with no `assert!` around
3380        // it, so the expression's bool result was silently discarded — the
3381        // fail-safe behavior it names was never actually checked.
3382        assert!(matches!(config.tool_advertising, ToolAdvertising::Full));
3383    }
3384
3385    /// F6: setting only `tool_advertising_core` (mode absent) must not
3386    /// silently reset the mode to `Full`, discarding the array — and
3387    /// setting `tool_advertising = "deferred"` with no `_core` must not wipe
3388    /// an already-set core list. Each key replaces independently (§3.3).
3389    #[test]
3390    fn apply_profile_tool_advertising_mode_and_core_replace_independently() {
3391        // Only `_core` set on top of an already-`Deferred` config: the mode
3392        // must stay `Deferred`, with the NEW core list — not reset to
3393        // `Full` (the pre-fix bug).
3394        let builder = ConfigBuilder::default().tool_advertising(ToolAdvertising::Deferred {
3395            core: vec!["bash".to_string()],
3396        });
3397        let profile = ConfigProfile {
3398            tool_advertising_core: Some(vec!["read_file".to_string(), "bash".to_string()]),
3399            ..Default::default()
3400        };
3401        let config = builder.apply_profile(&profile).build();
3402        match &config.tool_advertising {
3403            ToolAdvertising::Deferred { core } => {
3404                assert_eq!(core, &vec!["read_file".to_string(), "bash".to_string()])
3405            }
3406            ToolAdvertising::Full => panic!("mode must not reset to Full when only _core is set"),
3407        }
3408
3409        // Mode = "deferred" set with no `_core`: must keep the existing
3410        // core list, not wipe it to empty.
3411        let builder2 = ConfigBuilder::default().tool_advertising(ToolAdvertising::Deferred {
3412            core: vec!["bash".to_string()],
3413        });
3414        let profile2 = ConfigProfile {
3415            tool_advertising: Some("deferred".to_string()),
3416            ..Default::default()
3417        };
3418        let config2 = builder2.apply_profile(&profile2).build();
3419        match &config2.tool_advertising {
3420            ToolAdvertising::Deferred { core } => assert_eq!(core, &vec!["bash".to_string()]),
3421            ToolAdvertising::Full => panic!("expected Deferred to survive"),
3422        }
3423    }
3424
3425    /// Tables merge key-wise (§3.3): applying a profile with one
3426    /// `tool_overrides` entry must not blow away a different tool's
3427    /// override already on the builder.
3428    #[test]
3429    fn apply_profile_merges_tool_overrides_key_wise() {
3430        let builder = ConfigBuilder::default().disable_tool("bash");
3431        let mut overrides = HashMap::new();
3432        overrides.insert(
3433            "read_file".to_string(),
3434            ToolOverrideProfile {
3435                enabled: Some(false),
3436                description: None,
3437                schema_tier: None,
3438                timeout_secs: None,
3439            },
3440        );
3441        let profile = ConfigProfile {
3442            tool_overrides: Some(overrides),
3443            ..Default::default()
3444        };
3445        let config = builder.apply_profile(&profile).build();
3446        assert!(!config.tool_enabled("bash"));
3447        assert!(!config.tool_enabled("read_file"));
3448    }
3449
3450    // -----------------------------------------------------------------
3451    // P4: deny-rule PATTERNS generalizing auto_approved_tools (§5.2 "P4").
3452    // -----------------------------------------------------------------
3453
3454    #[test]
3455    fn glob_match_exact_and_wildcard_forms() {
3456        assert!(glob_match("bash", "bash"));
3457        assert!(!glob_match("bash", "bash2"));
3458        assert!(glob_match("bash*", "bash"));
3459        assert!(glob_match("bash*", "bash_tool"));
3460        assert!(!glob_match("bash*", "not_bash"));
3461        assert!(glob_match("*_write", "edit_write"));
3462        assert!(!glob_match("*_write", "write_edit"));
3463        assert!(glob_match("mcp__*__search", "mcp__github__search"));
3464        assert!(glob_match("*", "anything at all"));
3465        assert!(glob_match("*", ""));
3466        assert!(glob_match("", ""));
3467        assert!(!glob_match("", "x"));
3468        // Multiple `*`s in one pattern (the iterative two-pointer rewrite's
3469        // main new surface area vs. the old single-recursion-site matcher).
3470        assert!(glob_match("*a*a*a*", "aaaa"));
3471        assert!(glob_match("*a*b*c*", "xaxbxcx"));
3472        assert!(!glob_match("*a*b*c*", "xbxax"));
3473        assert!(glob_match("a*b*c", "aXbXc"));
3474        assert!(glob_match("a*b*c", "abc"));
3475        assert!(!glob_match("a*b*c", "acb"));
3476    }
3477
3478    /// LOW-2 (Fable-5 P4a review): `tool_deny_patterns`/`tool_allow_patterns`
3479    /// can be project-controlled (a project may only ADD to `rules.deny`,
3480    /// never replace it — see `configfile::merge_permissions_capability` —
3481    /// but an ADDED pattern is still attacker-chosen content), so a crafted
3482    /// pattern must not be able to make `glob_match` itself a self-DoS on
3483    /// every tool call. The old naive recursive matcher
3484    /// (`Some(b'*') => inner(&p[1..], t) || (!t.is_empty() &&
3485    /// inner(p, &t[1..]))`) backtracks exponentially on a pattern with many
3486    /// `*`s against a text with no matching suffix; this proves the
3487    /// iterative rewrite returns promptly on exactly that shape.
3488    #[test]
3489    fn glob_match_pathological_pattern_returns_promptly() {
3490        let pattern = "*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*b";
3491        let text = "a".repeat(40);
3492        let start = std::time::Instant::now();
3493        let result = glob_match(pattern, &text);
3494        let elapsed = start.elapsed();
3495        assert!(!result, "text has no trailing 'b', so this must not match");
3496        assert!(
3497            elapsed < std::time::Duration::from_millis(200),
3498            "glob_match took {elapsed:?} on a pathological pattern — exponential backtracking regressed"
3499        );
3500    }
3501
3502    /// Default-off: empty deny/allow patterns leave `needs_approval`
3503    /// byte-identical to pre-P4 behavior (the existing `auto_approved_tools`
3504    /// contract, unaffected).
3505    #[test]
3506    fn needs_approval_default_unaffected_by_empty_patterns() {
3507        let config = Config::builder()
3508            .approval(ApprovalPolicy::OnRequest)
3509            .auto_approve_tool("read_file")
3510            .build();
3511        assert!(config.tool_deny_patterns.is_empty());
3512        assert!(config.tool_allow_patterns.is_empty());
3513        assert!(!config.needs_approval("read_file"));
3514        assert!(config.needs_approval("bash"));
3515    }
3516
3517    /// Happy path: a deny pattern forces approval even under
3518    /// `ApprovalPolicy::Never` — the entire point of a deny rule is a hard
3519    /// floor `--yes`/`Never` can't bypass.
3520    #[test]
3521    fn needs_approval_deny_pattern_forces_approval_even_under_never() {
3522        let config = Config::builder()
3523            .approval(ApprovalPolicy::Never)
3524            .deny_tool_pattern("bash*")
3525            .build();
3526        assert!(config.needs_approval("bash"));
3527        assert!(config.needs_approval("bash_tool"));
3528        // A non-matching tool is unaffected — still `Never`.
3529        assert!(!config.needs_approval("read_file"));
3530    }
3531
3532    /// Happy path: an allow pattern exempts a matching tool from approval
3533    /// under `OnRequest`, exactly like an exact `auto_approved_tools` entry.
3534    #[test]
3535    fn needs_approval_allow_pattern_exempts_under_on_request() {
3536        let config = Config::builder()
3537            .approval(ApprovalPolicy::OnRequest)
3538            .allow_tool_pattern("read_*")
3539            .build();
3540        assert!(!config.needs_approval("read_file"));
3541        assert!(!config.needs_approval("read_dir"));
3542        assert!(config.needs_approval("bash"));
3543    }
3544
3545    /// Deny wins over allow when a tool matches both — deny is checked
3546    /// first and returns unconditionally.
3547    #[test]
3548    fn needs_approval_deny_wins_over_allow_on_the_same_tool() {
3549        let config = Config::builder()
3550            .approval(ApprovalPolicy::OnRequest)
3551            .allow_tool_pattern("bash*")
3552            .deny_tool_pattern("bash*")
3553            .build();
3554        assert!(config.needs_approval("bash"));
3555    }
3556
3557    /// An allow pattern never exempts anything under `Untrusted` — same
3558    /// scoping `auto_approved_tools` already has (only consulted under
3559    /// `OnRequest`).
3560    #[test]
3561    fn needs_approval_allow_pattern_never_exempts_under_untrusted() {
3562        let config = Config::builder()
3563            .approval(ApprovalPolicy::Untrusted)
3564            .allow_tool_pattern("*")
3565            .build();
3566        assert!(config.needs_approval("read_file"));
3567    }
3568
3569    // ---- P4b: default-off / unchanged-unless-set for every new field -----
3570
3571    #[test]
3572    fn p4b_defaults_are_byte_identical_to_pre_p4b_behavior() {
3573        let config = Config::default();
3574        assert!(!config.env_context);
3575        assert_eq!(config.project_root_markers, vec![".git".to_string()]);
3576        assert_eq!(config.project_doc_max_bytes, None);
3577        assert!(!config.instruction_imports);
3578        // retry_enabled defaults TRUE (matches the pre-existing always-on
3579        // transport retry — see `provider::HttpOptions::from_retry_config`),
3580        // but the override knobs default unset, so the transport sees its
3581        // own untouched built-in defaults.
3582        assert!(config.retry_enabled);
3583        assert_eq!(config.retry_max_retries, None);
3584        assert_eq!(config.retry_base_delay_ms, None);
3585        assert_eq!(config.compaction_reserve_tokens, None);
3586        assert_eq!(config.compaction_keep_recent_tokens, None);
3587        assert_eq!(config.compaction_focus_instructions, None);
3588        assert!(!config.auto_title);
3589        assert_eq!(config.steering_mode, SteeringMode::OneAtATime);
3590        assert_eq!(config.follow_up_mode, SteeringMode::OneAtATime);
3591        assert!(config.stop_gate.is_none());
3592    }
3593
3594    #[test]
3595    fn apply_profile_applies_every_new_p4b_field() {
3596        let profile = ConfigProfile {
3597            env_context: Some(true),
3598            project_root_markers: Some(vec![".hg".to_string()]),
3599            project_doc_max_bytes: Some(16_384),
3600            instruction_imports: Some(true),
3601            retry_enabled: Some(false),
3602            retry_max_retries: Some(9),
3603            retry_base_delay_ms: Some(750),
3604            compaction_reserve_tokens: Some(8_000),
3605            compaction_keep_recent_tokens: Some(12_000),
3606            compaction_focus_instructions: Some("keep fixing the auth bug".to_string()),
3607            auto_title: Some(true),
3608            steering_mode: Some("all".to_string()),
3609            follow_up_mode: Some("one-at-a-time".to_string()),
3610            ..Default::default()
3611        };
3612        let config = ConfigBuilder::default().apply_profile(&profile).build();
3613        assert!(config.env_context);
3614        assert_eq!(config.project_root_markers, vec![".hg".to_string()]);
3615        assert_eq!(config.project_doc_max_bytes, Some(16_384));
3616        assert!(config.instruction_imports);
3617        assert!(!config.retry_enabled);
3618        assert_eq!(config.retry_max_retries, Some(9));
3619        assert_eq!(config.retry_base_delay_ms, Some(750));
3620        assert_eq!(config.compaction_reserve_tokens, Some(8_000));
3621        assert_eq!(config.compaction_keep_recent_tokens, Some(12_000));
3622        assert_eq!(
3623            config.compaction_focus_instructions.as_deref(),
3624            Some("keep fixing the auth bug")
3625        );
3626        assert!(config.auto_title);
3627        assert_eq!(config.steering_mode, SteeringMode::All);
3628        assert_eq!(config.follow_up_mode, SteeringMode::OneAtATime);
3629    }
3630
3631    #[test]
3632    fn apply_profile_unrecognized_steering_mode_is_ignored_not_defaulted_wrongly() {
3633        let profile = ConfigProfile {
3634            steering_mode: Some("bogus".to_string()),
3635            ..Default::default()
3636        };
3637        let config = ConfigBuilder::default().apply_profile(&profile).build();
3638        // Fail safe: an unrecognized value leaves the built-in default in
3639        // place rather than panicking or guessing.
3640        assert_eq!(config.steering_mode, SteeringMode::OneAtATime);
3641    }
3642
3643    // ---- P4c: default-off / unchanged-unless-set for every new field -----
3644
3645    #[test]
3646    fn p4c_defaults_are_byte_identical_to_pre_p4c_behavior() {
3647        let config = Config::default();
3648        assert!(!config.read_file_multimodal);
3649        assert!(!config.edit_file_require_read_before_edit);
3650        assert!(!config.edit_file_notebook_aware);
3651        assert!(!config.shell_env_snapshot);
3652        assert_eq!(config.doom_loop_threshold, None);
3653        assert!(!config.nested_instructions);
3654        assert!(!config.model_switch_allow_switch);
3655    }
3656
3657    #[test]
3658    fn apply_profile_applies_every_new_p4c_field() {
3659        let profile = ConfigProfile {
3660            read_file_multimodal: Some(true),
3661            edit_file_require_read_before_edit: Some(true),
3662            edit_file_notebook_aware: Some(true),
3663            shell_env_snapshot: Some(true),
3664            doom_loop_threshold: Some(3),
3665            nested_instructions: Some(true),
3666            model_switch_allow_switch: Some(true),
3667            ..Default::default()
3668        };
3669        let config = ConfigBuilder::default().apply_profile(&profile).build();
3670        assert!(config.read_file_multimodal);
3671        assert!(config.edit_file_require_read_before_edit);
3672        assert!(config.edit_file_notebook_aware);
3673        assert!(config.shell_env_snapshot);
3674        assert_eq!(config.doom_loop_threshold, Some(3));
3675        assert!(config.nested_instructions);
3676        assert!(config.model_switch_allow_switch);
3677    }
3678
3679    #[test]
3680    fn builder_methods_set_every_new_p4c_field() {
3681        let config = Config::builder()
3682            .read_file_multimodal(true)
3683            .edit_file_require_read_before_edit(true)
3684            .edit_file_notebook_aware(true)
3685            .shell_env_snapshot(true)
3686            .doom_loop_threshold(5)
3687            .nested_instructions(true)
3688            .model_switch_allow_switch(true)
3689            .build();
3690        assert!(config.read_file_multimodal);
3691        assert!(config.edit_file_require_read_before_edit);
3692        assert!(config.edit_file_notebook_aware);
3693        assert!(config.shell_env_snapshot);
3694        assert_eq!(config.doom_loop_threshold, Some(5));
3695        assert!(config.nested_instructions);
3696        assert!(config.model_switch_allow_switch);
3697    }
3698
3699    /// BP-9 (D6 `project-root-detection-markers`): the shared ancestor walk
3700    /// stops at the FIRST directory carrying any configured marker, and a
3701    /// different marker list moves the root — which is the whole point of
3702    /// the knob.
3703    #[test]
3704    fn project_root_for_stops_at_the_first_marker() {
3705        let base = std::env::temp_dir().join(format!(
3706            "bp9-roots-{}-{:?}",
3707            std::process::id(),
3708            std::thread::current().id()
3709        ));
3710        let _ = std::fs::remove_dir_all(&base);
3711        let outer = base.join("outer");
3712        let inner = outer.join("repo");
3713        let deep = inner.join("crates").join("thing");
3714        std::fs::create_dir_all(&deep).expect("tree");
3715        std::fs::create_dir_all(outer.join(".hg")).expect("outer marker");
3716        std::fs::create_dir_all(inner.join(".git")).expect("inner marker");
3717
3718        // Default markers: the nearer `.git` wins over the outer `.hg`.
3719        assert_eq!(
3720            project_root_for(&deep, &[".git".to_string()]).as_deref(),
3721            Some(inner.as_path())
3722        );
3723        // A different marker list moves the root outward.
3724        assert_eq!(
3725            project_root_for(&deep, &[".hg".to_string()]).as_deref(),
3726            Some(outer.as_path())
3727        );
3728        // First match wins regardless of the list's order.
3729        assert_eq!(
3730            project_root_for(&deep, &[".hg".to_string(), ".git".to_string()]).as_deref(),
3731            Some(inner.as_path())
3732        );
3733        // A marker FILE counts (a git worktree writes `.git` as a file).
3734        let worktree = base.join("worktree");
3735        std::fs::create_dir_all(&worktree).expect("worktree dir");
3736        std::fs::write(worktree.join(".git"), "gitdir: elsewhere\n").expect("marker file");
3737        assert_eq!(
3738            project_root_for(&worktree, &[".git".to_string()]).as_deref(),
3739            Some(worktree.as_path())
3740        );
3741        // No marker anywhere, and an empty marker list, both decline.
3742        assert_eq!(project_root_for(&deep, &["nope.marker".to_string()]), None);
3743        assert_eq!(project_root_for(&deep, &[]), None);
3744
3745        let _ = std::fs::remove_dir_all(&base);
3746    }
3747}