Skip to main content

supercode_harness/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use supercode_runtime::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(&supercode_interchange::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    /// (`supercode_interchange::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 [`supercode_interchange::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    /// [`supercode_interchange::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    /// [`supercode_interchange::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 (`supercode_runtime::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: supercode_runtime::background::DEFAULT_MAX_CONCURRENT,
1628            tools_background_max_output_bytes:
1629                supercode_runtime::background::DEFAULT_MAX_OUTPUT_BYTES,
1630            checkpoint_enabled: false,
1631            checkpoint_retain: crate::checkpoint::DEFAULT_RETAIN,
1632            checkpoint_dir: None,
1633            checkpoint_restore: true,
1634            lsp_enabled: false,
1635            lsp_servers: Vec::new(),
1636            lsp_max_diagnostics: crate::lsp::DEFAULT_LSP_MAX_DIAGNOSTICS,
1637            lsp_timeout_secs: crate::lsp::DEFAULT_LSP_TIMEOUT_SECS,
1638            formatters_enabled: false,
1639            formatters: Vec::new(),
1640            formatters_diff_back: true,
1641            formatters_timeout_secs: crate::formatters::DEFAULT_FORMATTER_TIMEOUT_SECS,
1642            trust_enabled: false,
1643            trust_default: crate::plugins::TrustDecision::Ask,
1644            plugins_enabled: false,
1645            plugins_dirs: Vec::new(),
1646        }
1647    }
1648}
1649
1650impl Config {
1651    /// Start building a [`Config`] from defaults.
1652    pub fn builder() -> ConfigBuilder {
1653        ConfigBuilder {
1654            config: Config::default(),
1655        }
1656    }
1657
1658    /// Whether a tool is enabled given the overrides (defaults to enabled).
1659    pub fn tool_enabled(&self, name: &str) -> bool {
1660        self.tool_overrides
1661            .get(name)
1662            .and_then(|o| o.enabled)
1663            .unwrap_or(true)
1664    }
1665
1666    /// Whether a tool call requires approval before it runs, given the
1667    /// policy, the auto-approve allowlist, and (P4) the deny/allow glob
1668    /// PATTERN lists — see [`Self::tool_deny_patterns`]/
1669    /// [`Self::tool_allow_patterns`]'s doc comments for the exact
1670    /// semantics. Both are empty by default, so this is byte-identical to
1671    /// pre-P4 behavior for any `Config` that doesn't set them.
1672    pub fn needs_approval(&self, tool: &str) -> bool {
1673        // Deny wins unconditionally, even under `Never` — a deny pattern is
1674        // a hard floor, not just another allowlist entry.
1675        if self.tool_deny_patterns.iter().any(|p| glob_match(p, tool)) {
1676            return true;
1677        }
1678        match self.approval {
1679            ApprovalPolicy::Never => false,
1680            // P5-1: this coarse, tool-name-only gate has no model-escalation
1681            // signal to consult (that requires the canonicalized-command
1682            // context only `crate::permissions`'s richer gate has), so
1683            // `ModelRequested` is treated the same, conservative way
1684            // `OnRequest` is here — the safe simplification documented on
1685            // `ApprovalPolicy::ModelRequested` itself. The P5-1 engine
1686            // (active when `Self::permissions_enabled` is `true`) is where
1687            // Codex's real "mostly silent, escalation asks" posture is
1688            // approximated instead.
1689            ApprovalPolicy::OnRequest | ApprovalPolicy::ModelRequested => {
1690                !self.auto_approved_tools.contains(tool)
1691                    && !self.tool_allow_patterns.iter().any(|p| glob_match(p, tool))
1692            }
1693            // BP-8 DEFECT-FIX (see the same change in the P5-1 engine's own
1694            // default, `Agent::tool_decision`): an EXPLICIT
1695            // `auto_approved_tools` entry is CC's read-only tier, which
1696            // never prompts (cc§4 "Tiered defaults"). A glob ALLOW pattern
1697            // still exempts nothing here — that stays `OnRequest`-only, so
1698            // `*` cannot quietly turn `untrusted` into `never`.
1699            ApprovalPolicy::Untrusted => !self.auto_approved_tools.contains(tool),
1700        }
1701    }
1702
1703    /// The effective description for a tool, applying any override.
1704    pub fn tool_description<'a>(&'a self, name: &str, builtin: &'a str) -> &'a str {
1705        self.tool_overrides
1706            .get(name)
1707            .and_then(|o| o.description.as_deref())
1708            .unwrap_or(builtin)
1709    }
1710
1711    /// The effective schema tier for a tool (TR-8/T5): a per-tool override if
1712    /// set, else the global [`Self::tool_schema_tier`].
1713    pub fn schema_tier_for(&self, name: &str) -> crate::tools::SchemaTier {
1714        self.tool_overrides
1715            .get(name)
1716            .and_then(|o| o.schema_tier)
1717            .unwrap_or(self.tool_schema_tier)
1718    }
1719}
1720
1721/// A single tool's file-settable overrides — the `ConfigProfile` mirror of
1722/// [`ToolOverride`] (COMPOSABLE-HARNESS-DESIGN.md §3.1 `[core.tools.<name>]`,
1723/// §3.2 mapping row `core.tools.enabled` + `[core.tools.<n>].*`).
1724#[derive(Debug, Clone, Default, serde::Deserialize)]
1725pub struct ToolOverrideProfile {
1726    /// `Some(false)` hides the tool from the model entirely.
1727    pub enabled: Option<bool>,
1728    /// Replaces the tool's built-in description.
1729    pub description: Option<String>,
1730    /// Per-tool schema tier: `full` | `medium` | `minimal`.
1731    pub schema_tier: Option<String>,
1732    /// P4e (§3.1 `core.tools.bash.timeout_secs`, S14) -- see
1733    /// `ToolOverride::timeout_secs`. Only meaningful on the `bash` entry.
1734    pub timeout_secs: Option<u64>,
1735}
1736
1737/// The serializable subset of a [`Config`] that can live in a config file.
1738/// (Callbacks/handlers are code-only and are not represented here.)
1739///
1740/// Grown from its original 9 fields to the P1 §3.2 surface
1741/// (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P1" migration step): every `[core]`
1742/// scalar/table/array `Config` maps to lives here so it becomes file-settable
1743/// for the first time, per the design's stated framing gap (§3.0).
1744#[derive(Debug, Clone, Default, serde::Deserialize)]
1745pub struct ConfigProfile {
1746    /// Model id.
1747    pub model: Option<String>,
1748    /// Endpoint base URL.
1749    pub base_url: Option<String>,
1750    /// Environment variable to read the API key from (§3.1 `core.api_key_env`;
1751    /// §3.2: "today absent from BOTH files").
1752    pub api_key_env: Option<String>,
1753    /// Credential-helper command (§3.1 `core.api_key_cmd`, D6 row) — see
1754    /// [`Config::api_key_cmd`].
1755    pub api_key_cmd: Option<String>,
1756    /// Argv credential helper (§3.1 `core.api_key_command`, D6 row) — see
1757    /// [`Config::api_key_command`]; arrays replace.
1758    pub api_key_command: Option<Vec<String>>,
1759    /// Startup release check (§3.1 `core.update_check`, D6 row) — see
1760    /// [`Config::update_check`].
1761    pub update_check: Option<bool>,
1762    /// System prompt.
1763    pub system_prompt: Option<String>,
1764    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): an additive suffix
1765    /// composed onto whatever [`Self::system_prompt`] resolves to (the
1766    /// profile's own value if set, else whatever the builder already had —
1767    /// see [`ConfigBuilder::apply_profile`]'s composition order), distinct
1768    /// from REPLACING it. `[project-forbidden]`, same trust boundary as
1769    /// `system_prompt` (§3.3: prompt injection).
1770    pub append_system_prompt: Option<String>,
1771    /// Sampling temperature.
1772    pub temperature: Option<f32>,
1773    /// Output token cap per request.
1774    pub max_tokens: Option<u32>,
1775    /// Reasoning/effort level.
1776    pub effort: Option<String>,
1777    /// Sandbox policy: `read_only` | `workspace_write` | `danger_full_access`.
1778    pub sandbox: Option<String>,
1779    /// Approval policy: `never` | `on_request` | `untrusted`.
1780    pub approval: Option<String>,
1781    /// Auto-load CLAUDE.md / AGENTS.md.
1782    pub project_context: Option<bool>,
1783    /// Per-`send` iteration budget (§3.1 `core.max_iterations`).
1784    pub max_iterations: Option<usize>,
1785    /// Extra roots beyond `cwd` (§3.1 `core.additional_dirs`); arrays
1786    /// replace wholesale on overlay (§3.3).
1787    pub additional_dirs: Option<Vec<String>>,
1788    /// Compact once the conversation exceeds this many messages (§3.1
1789    /// `core.compaction.after_messages`; `0` = trigger off).
1790    pub compact_after_messages: Option<usize>,
1791    /// Prompt-caching plan: `off` | `imported_prefix` (§3.1
1792    /// `capabilities.cache.plan`).
1793    pub cache_plan: Option<String>,
1794    /// BP-4 (§3.1 `capabilities.cache.warnings`): whether cache-churn
1795    /// warnings are surfaced in-session -- see `Config::cache_warnings`.
1796    /// `None` leaves the default (on) in place.
1797    pub cache_warnings: Option<bool>,
1798    /// How tools are advertised: `full` | `deferred` (§3.1
1799    /// `capabilities.deferred_tools`).
1800    pub tool_advertising: Option<String>,
1801    /// The eagerly-advertised core allowlist when `tool_advertising =
1802    /// "deferred"` (§3.1 `capabilities.deferred_tools.core`); arrays replace.
1803    pub tool_advertising_core: Option<Vec<String>>,
1804    /// Global tool-schema tier: `full` | `medium` | `minimal` (§3.1
1805    /// `core.tools.schema_tier`).
1806    pub schema_tier: Option<String>,
1807    /// Tools that never require approval under `ApprovalPolicy::OnRequest`
1808    /// (§3.1 `capabilities.permissions.auto_approved_tools`); arrays replace.
1809    pub auto_approved_tools: Option<Vec<String>>,
1810    /// P4: deny-pattern generalization of `auto_approved_tools` (§3.1
1811    /// `capabilities.permissions.rules.deny`) — see
1812    /// [`Config::tool_deny_patterns`]. Arrays replace.
1813    pub tool_deny_patterns: Option<Vec<String>>,
1814    /// P4: allow-pattern generalization of `auto_approved_tools` (§3.1
1815    /// `capabilities.permissions.rules.allow`) — see
1816    /// [`Config::tool_allow_patterns`]. Arrays replace.
1817    pub tool_allow_patterns: Option<Vec<String>>,
1818    /// Extra HTTP headers merged in (§3.1 `core.extra_headers`); a table,
1819    /// merged key-wise on overlay (§3.3).
1820    pub extra_headers: Option<HashMap<String, String>>,
1821    /// Extra request-body fields merged in (§3.1 `core.extra_body`); a
1822    /// table, merged key-wise on overlay (§3.3).
1823    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
1824    /// Max bytes of a single tool result (§3.1 `core.max_tool_output_bytes`).
1825    pub max_tool_output_bytes: Option<usize>,
1826    /// Cap on cumulative output tokens per `send` loop (§3.1
1827    /// `core.max_total_output_tokens`).
1828    pub max_total_output_tokens: Option<u64>,
1829    /// BP-7 (`core.max_budget_usd`) — see [`Config::max_budget_usd`].
1830    pub max_budget_usd: Option<f64>,
1831    /// BP-7 (`core.max_steps`) — see [`Config::max_steps`].
1832    pub max_steps: Option<usize>,
1833    /// BP-7 (`core.price_input_per_mtok`) — see
1834    /// [`Config::price_input_per_mtok`].
1835    pub price_input_per_mtok: Option<f64>,
1836    /// BP-7 (`core.price_output_per_mtok`) — see
1837    /// [`Config::price_output_per_mtok`].
1838    pub price_output_per_mtok: Option<f64>,
1839    /// Named prompt templates (§3.1 `[core.prompts]`); a table, merged
1840    /// key-wise (new/overridden names layer onto the built-ins, they don't
1841    /// wholesale-replace them).
1842    pub prompts: Option<HashMap<String, String>>,
1843    /// Per-tool enable/disable + description/schema-tier overrides, keyed
1844    /// by tool name (§3.1 `[core.tools.<name>]`, §3.2 "today in no file");
1845    /// a table, merged key-wise per tool.
1846    pub tool_overrides: Option<HashMap<String, ToolOverrideProfile>>,
1847
1848    /// P4b (S3.1 `core.env_context`) -- see `Config::env_context`.
1849    pub env_context: Option<bool>,
1850    /// P4b (S3.1 `core.project_root_markers`) -- see
1851    /// `Config::project_root_markers`; arrays replace.
1852    pub project_root_markers: Option<Vec<String>>,
1853    /// P4b (S3.1 `core.project_doc_max_bytes`) -- see
1854    /// `Config::project_doc_max_bytes`.
1855    pub project_doc_max_bytes: Option<usize>,
1856    /// BP-4 (`core.project_doc_excludes`) -- see
1857    /// `Config::project_doc_excludes`; arrays replace.
1858    pub project_doc_excludes: Option<Vec<String>>,
1859    /// BP-4 (`core.project_doc_strip_comments`) -- see
1860    /// `Config::project_doc_strip_comments`.
1861    pub project_doc_strip_comments: Option<bool>,
1862    /// P4b (S3.1 `core.instruction_imports`) -- see
1863    /// `Config::instruction_imports`.
1864    pub instruction_imports: Option<bool>,
1865    /// P4b (S3.1 `core.retry.enabled`) -- see `Config::retry_enabled`.
1866    pub retry_enabled: Option<bool>,
1867    /// P4b (S3.1 `core.retry.max_retries`) -- see `Config::retry_max_retries`.
1868    pub retry_max_retries: Option<u32>,
1869    /// P4b (S3.1 `core.retry.base_delay_ms`) -- see
1870    /// `Config::retry_base_delay_ms`.
1871    pub retry_base_delay_ms: Option<u64>,
1872    /// P4b (S3.1 `core.compaction.reserve_tokens`) -- see
1873    /// `Config::compaction_reserve_tokens`.
1874    pub compaction_reserve_tokens: Option<u64>,
1875    /// P4b (S3.1 `core.compaction.keep_recent_tokens`) -- see
1876    /// `Config::compaction_keep_recent_tokens`.
1877    pub compaction_keep_recent_tokens: Option<u64>,
1878    /// P4b (S3.1 `core.compaction.focus_instructions`) -- see
1879    /// `Config::compaction_focus_instructions`.
1880    pub compaction_focus_instructions: Option<String>,
1881    /// P4b (S3.1 `core.session.auto_title`) -- see `Config::auto_title`.
1882    pub auto_title: Option<bool>,
1883    /// P4b (S3.1 `core.steering.steering_mode`) -- see
1884    /// `Config::steering_mode`.
1885    pub steering_mode: Option<String>,
1886    /// P4b (S3.1 `core.steering.follow_up_mode`) -- see
1887    /// `Config::follow_up_mode`.
1888    pub follow_up_mode: Option<String>,
1889
1890    /// P4c (S3.1 `core.tools.read_file.multimodal`) -- see
1891    /// `Config::read_file_multimodal`.
1892    pub read_file_multimodal: Option<bool>,
1893    /// BP-2 (S3.1 `core.tools.read_file.line_numbers`) -- see
1894    /// `Config::read_file_line_numbers`.
1895    pub read_file_line_numbers: Option<bool>,
1896    /// BP-2 (S3.1 `core.tool_output_spill`) -- see
1897    /// `Config::tool_output_spill`.
1898    pub tool_output_spill: Option<bool>,
1899    /// P4c (S3.1 `core.tools.edit_file.require_read_before_edit`) -- see
1900    /// `Config::edit_file_require_read_before_edit`.
1901    pub edit_file_require_read_before_edit: Option<bool>,
1902    /// P4c (S3.1 `core.tools.edit_file.notebook_aware`) -- see
1903    /// `Config::edit_file_notebook_aware`.
1904    pub edit_file_notebook_aware: Option<bool>,
1905    /// P4c (S3.1 `core.shell_env_snapshot`) -- see
1906    /// `Config::shell_env_snapshot`.
1907    pub shell_env_snapshot: Option<bool>,
1908    /// P4c (S3.1 `core.doom_loop_threshold`) -- see
1909    /// `Config::doom_loop_threshold`.
1910    pub doom_loop_threshold: Option<u32>,
1911    /// P4c (S3.1 `core.nested_instructions`) -- see
1912    /// `Config::nested_instructions`.
1913    pub nested_instructions: Option<bool>,
1914    /// P4c (S3.1 `core.model_switch.allow_switch`) -- see
1915    /// `Config::model_switch_allow_switch`.
1916    pub model_switch_allow_switch: Option<bool>,
1917    /// BP-13 (S3.1 `core.model_switch.notice`) -- see
1918    /// `Config::model_switch_notice`.
1919    pub model_switch_notice: Option<bool>,
1920
1921    /// P4e (S3.1 `core.context_injections`) -- see
1922    /// `Config::context_injections`. Only the boolean gate is
1923    /// file/profile-settable; `Config::context_injection_blocks`' actual
1924    /// content is code-only (see its doc comment).
1925    pub context_injections: Option<bool>,
1926    /// P4e (S3.1 `core.compaction.enabled`) -- see
1927    /// `Config::compaction_enabled`.
1928    pub compaction_enabled: Option<bool>,
1929    /// BP-1 (S3.1 `core.compaction.summarize`) -- see
1930    /// `Config::compaction_summarize`.
1931    pub compaction_summarize: Option<bool>,
1932    /// P4e (S3.1 `core.parallel_tool_calls`) -- see
1933    /// `Config::parallel_tool_calls`.
1934    pub parallel_tool_calls: Option<bool>,
1935    /// P4e (S3.1 `core.session.git_metadata`) -- see
1936    /// `Config::session_git_metadata`.
1937    pub session_git_metadata: Option<bool>,
1938    /// P4e (S3.1 `core.session.dir`) -- see `Config::session_dir`.
1939    pub session_dir: Option<String>,
1940    /// P4e (S3.1 `core.session.persist`) -- see `Config::session_persist`.
1941    pub session_persist: Option<bool>,
1942    /// P4e (S3.1 `core.session.name`) -- see `Config::session_name`.
1943    pub session_name: Option<String>,
1944    /// P4e (S3.1 `core.session.retention_days`) -- see
1945    /// `Config::session_retention_days`.
1946    pub session_retention_days: Option<u32>,
1947    /// P4e (S3.1 `core.session.export_format`) -- see
1948    /// `Config::session_export_format`. `"text"` | `"html"`; an
1949    /// unrecognized string is a no-op warning, like `steering_mode`.
1950    pub session_export_format: Option<String>,
1951    /// BP-8 (S3.1 `core.session.append_only`) -- see
1952    /// `Config::session_append_only`.
1953    pub session_append_only: Option<bool>,
1954    /// BP-8 (S3.1 `core.session.queue_persist`) -- see
1955    /// `Config::session_queue_persist`.
1956    pub session_queue_persist: Option<bool>,
1957}
1958
1959/// BP-9 (§3.1 `core.project_root_markers`, catalog:232 "Project-root
1960/// detection markers", cx§6, oc§6): the project root for `cwd` — the
1961/// NEAREST ancestor (starting at `cwd` itself) that directly contains any
1962/// entry named by `markers`. `None` when no ancestor carries a marker, or
1963/// when `markers` is empty (an empty marker list is an explicit "don't do
1964/// root detection", not an invitation to walk to `/`).
1965///
1966/// This is the ONE walk every marker consumer shares — the git-status probe
1967/// in `env_context`, the CLI's `.supercode.toml` discovery, and prompt
1968/// assembly's instruction walk. Sharing it is the point: "where does the
1969/// project stop?" must have exactly one answer per config, or the same
1970/// `project_root_markers` value would mean three different things.
1971///
1972/// A marker matches whether it is a file or a directory (`.git` is a
1973/// directory in a normal checkout and a FILE in a worktree/submodule —
1974/// both are the root). Symlinks are followed by `exists()`, and the walk is
1975/// purely lexical on `cwd` as given: no canonicalization, so a caller that
1976/// wants symlink-resolved semantics canonicalizes before calling.
1977pub fn project_root_for(cwd: &std::path::Path, markers: &[String]) -> Option<std::path::PathBuf> {
1978    if markers.is_empty() {
1979        return None;
1980    }
1981    let mut dir = Some(cwd);
1982    while let Some(d) = dir {
1983        if markers.iter().any(|m| !m.is_empty() && d.join(m).exists()) {
1984            return Some(d.to_path_buf());
1985        }
1986        dir = d.parent();
1987    }
1988    None
1989}
1990
1991/// A config file: a set of named profiles (the analog of Codex `-p/--profile`).
1992/// This is the **SDK/embedder** config surface (JSON, via
1993/// [`Config::from_profile_file`]). BP-9 gave the same named-bundle mechanism
1994/// a launch-time selector on the CLI side: `supercode --profile <name>`
1995/// selects a `[profiles.<name>]` bundle out of the user's TOML
1996/// `config.toml` (`userconfig::FileConfig`), applied as its own layer
1997/// between the user layer and the project layer (cx§6's
1998/// `user → profile → project` order).
1999#[derive(Debug, Clone, Default, serde::Deserialize)]
2000pub struct ConfigFile {
2001    /// Profiles keyed by name.
2002    #[serde(default)]
2003    pub profiles: HashMap<String, ConfigProfile>,
2004}
2005
2006impl Config {
2007    /// Load a named profile from a JSON config file into a builder. Layered:
2008    /// start from defaults, then apply the named profile's set fields.
2009    pub fn from_profile_file(
2010        path: impl AsRef<std::path::Path>,
2011        profile: &str,
2012    ) -> crate::Result<ConfigBuilder> {
2013        let text = std::fs::read_to_string(path.as_ref())?;
2014        let file: ConfigFile = serde_json::from_str(&text).map_err(crate::Error::Decode)?;
2015        let p = file
2016            .profiles
2017            .get(profile)
2018            .ok_or_else(|| crate::Error::Other(format!("no profile `{profile}` in config file")))?;
2019        Ok(ConfigBuilder::default().apply_profile(p))
2020    }
2021}
2022
2023impl ConfigBuilder {
2024    /// Apply the set fields of a [`ConfigProfile`] over the current builder.
2025    pub fn apply_profile(mut self, p: &ConfigProfile) -> Self {
2026        if let Some(m) = &p.model {
2027            self.config.model = m.clone();
2028        }
2029        if let Some(u) = &p.base_url {
2030            self.config.base_url = u.clone();
2031        }
2032        if let Some(s) = &p.system_prompt {
2033            self.config.system_prompt = s.clone();
2034        }
2035        if let Some(extra) = &p.append_system_prompt {
2036            // P4 (§3.1 `core.append_system_prompt`): additive, composed onto
2037            // whatever `system_prompt` is on the builder AT THIS POINT —
2038            // either the value just applied above, or whatever the caller
2039            // already set/left at its `Config::default()` — never a
2040            // replacement. This intentionally runs regardless of whether
2041            // `p.system_prompt` was set, so an append-only profile still
2042            // composes onto the existing base.
2043            self.config.system_prompt = format!("{}\n\n{extra}", self.config.system_prompt);
2044        }
2045        self.config.temperature = p.temperature.or(self.config.temperature);
2046        self.config.max_tokens = p.max_tokens.or(self.config.max_tokens);
2047        if p.effort.is_some() {
2048            self.config.effort = p.effort.clone();
2049        }
2050        if let Some(sb) = &p.sandbox {
2051            // Fail *safe*: an unrecognized value (typo, future variant) must not
2052            // silently grant full filesystem access. Only the explicit
2053            // danger string opts out of confinement.
2054            self.config.sandbox = match sb.as_str() {
2055                "read_only" | "read-only" | "readonly" => crate::tools::SandboxPolicy::ReadOnly,
2056                "workspace_write" | "workspace-write" => {
2057                    crate::tools::SandboxPolicy::WorkspaceWrite
2058                }
2059                "danger_full_access" | "danger-full-access" => {
2060                    crate::tools::SandboxPolicy::DangerFullAccess
2061                }
2062                other => {
2063                    tracing::warn!(
2064                        "unknown sandbox policy `{other}` in profile; defaulting to read_only"
2065                    );
2066                    crate::tools::SandboxPolicy::ReadOnly
2067                }
2068            };
2069        }
2070        if let Some(ap) = &p.approval {
2071            // Fail safe: an unrecognized value defaults to the most-prompting
2072            // policy, never to `never`.
2073            self.config.approval = match ap.as_str() {
2074                "on_request" | "on-request" => ApprovalPolicy::OnRequest,
2075                "untrusted" => ApprovalPolicy::Untrusted,
2076                "never" => ApprovalPolicy::Never,
2077                // P5-1 (§3.2 S8): cx-parity's real intended posture — see
2078                // `ApprovalPolicy::ModelRequested`'s doc comment.
2079                "model_requested" | "model-requested" => ApprovalPolicy::ModelRequested,
2080                other => {
2081                    tracing::warn!(
2082                        "unknown approval policy `{other}` in profile; defaulting to untrusted"
2083                    );
2084                    ApprovalPolicy::Untrusted
2085                }
2086            };
2087        }
2088        if let Some(pc) = p.project_context {
2089            self.config.load_project_context = pc;
2090        }
2091        if let Some(env) = &p.api_key_env {
2092            self.config.api_key_env = env.clone();
2093        }
2094        if let Some(cmd) = &p.api_key_cmd {
2095            self.config.api_key_cmd = Some(cmd.clone());
2096        }
2097        if let Some(argv) = &p.api_key_command {
2098            self.config.api_key_command = Some(argv.clone());
2099        }
2100        if let Some(v) = p.update_check {
2101            self.config.update_check = v;
2102        }
2103        // Scalars replace (§3.3).
2104        if let Some(n) = p.max_iterations {
2105            self.config.max_iterations = n;
2106        }
2107        // Arrays replace wholesale (§3.3), not append — predictable overlay.
2108        if let Some(dirs) = &p.additional_dirs {
2109            self.config.additional_dirs = dirs.iter().map(std::path::PathBuf::from).collect();
2110        }
2111        if let Some(n) = p.compact_after_messages {
2112            self.config.compact_after_messages = Some(n);
2113        }
2114        if let Some(plan) = &p.cache_plan {
2115            // Fail safe: an unrecognized value never silently opts into
2116            // caching behavior the operator didn't ask for.
2117            self.config.cache_plan = match plan.as_str() {
2118                "off" => CachePlan::Off,
2119                "imported_prefix" | "imported-prefix" => CachePlan::ImportedPrefix,
2120                other => {
2121                    tracing::warn!("unknown cache plan `{other}` in profile; defaulting to off");
2122                    CachePlan::Off
2123                }
2124            };
2125        }
2126        if let Some(v) = p.cache_warnings {
2127            self.config.cache_warnings = v;
2128        }
2129        if p.tool_advertising.is_some() || p.tool_advertising_core.is_some() {
2130            // F6 fix: per-key replace (§3.3) — neither key alone may clobber
2131            // the other's current value. Compute the effective core list
2132            // FIRST (the profile's new value if given, else whatever's
2133            // already active) so an explicit `"deferred"` mode with no
2134            // `_core` doesn't wipe an existing list, then only change the
2135            // MODE if the profile actually set one — setting `_core` alone
2136            // must not silently reset the mode to `Full` (which previously
2137            // discarded the array outright, since `Full` ignores it).
2138            let existing_core = match &self.config.tool_advertising {
2139                ToolAdvertising::Deferred { core } => core.clone(),
2140                ToolAdvertising::Full => Vec::new(),
2141            };
2142            let core = p.tool_advertising_core.clone().unwrap_or(existing_core);
2143            self.config.tool_advertising = match p.tool_advertising.as_deref() {
2144                Some("deferred") => ToolAdvertising::Deferred { core },
2145                Some("full") => ToolAdvertising::Full,
2146                Some(other) => {
2147                    tracing::warn!(
2148                        "unknown tool_advertising mode `{other}` in profile; defaulting to full"
2149                    );
2150                    ToolAdvertising::Full
2151                }
2152                None => match &self.config.tool_advertising {
2153                    // Mode untouched — only refresh the core list if
2154                    // already `Deferred` (`Full` has nowhere to put one).
2155                    ToolAdvertising::Deferred { .. } => ToolAdvertising::Deferred { core },
2156                    ToolAdvertising::Full => ToolAdvertising::Full,
2157                },
2158            };
2159        }
2160        if let Some(tier) = &p.schema_tier {
2161            // Fail safe: unrecognized value keeps the verbose (never
2162            // under-informative) default rather than guessing a shrink tier.
2163            self.config.tool_schema_tier =
2164                crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
2165                    tracing::warn!("unknown schema tier `{tier}` in profile; defaulting to full");
2166                    crate::tools::SchemaTier::Full
2167                });
2168        }
2169        // Arrays replace wholesale (§3.3).
2170        if let Some(tools) = &p.auto_approved_tools {
2171            self.config.auto_approved_tools = tools.iter().cloned().collect();
2172        }
2173        if let Some(patterns) = &p.tool_deny_patterns {
2174            self.config.tool_deny_patterns = patterns.clone();
2175        }
2176        if let Some(patterns) = &p.tool_allow_patterns {
2177            self.config.tool_allow_patterns = patterns.clone();
2178        }
2179        // Tables merge key-wise (§3.3), not wholesale replace.
2180        if let Some(headers) = &p.extra_headers {
2181            for (k, v) in headers {
2182                self.config.extra_headers.insert(k.clone(), v.clone());
2183            }
2184        }
2185        if let Some(body) = &p.extra_body {
2186            for (k, v) in body {
2187                self.config.extra_body.insert(k.clone(), v.clone());
2188            }
2189        }
2190        if let Some(n) = p.max_tool_output_bytes {
2191            self.config.max_tool_output_bytes = Some(n);
2192        }
2193        if let Some(n) = p.max_total_output_tokens {
2194            self.config.max_total_output_tokens = Some(n);
2195        }
2196        if let Some(n) = p.max_budget_usd {
2197            self.config.max_budget_usd = Some(n);
2198        }
2199        if let Some(n) = p.max_steps {
2200            self.config.max_steps = Some(n);
2201        }
2202        if let Some(n) = p.price_input_per_mtok {
2203            self.config.price_input_per_mtok = Some(n);
2204        }
2205        if let Some(n) = p.price_output_per_mtok {
2206            self.config.price_output_per_mtok = Some(n);
2207        }
2208        if let Some(prompts) = &p.prompts {
2209            for (k, v) in prompts {
2210                self.config.prompts.insert(k.clone(), v.clone());
2211            }
2212        }
2213        if let Some(overrides) = &p.tool_overrides {
2214            for (name, o) in overrides {
2215                let entry = self.config.tool_overrides.entry(name.clone()).or_default();
2216                if let Some(en) = o.enabled {
2217                    entry.enabled = Some(en);
2218                }
2219                if let Some(desc) = &o.description {
2220                    entry.description = Some(desc.clone());
2221                }
2222                if let Some(tier) = &o.schema_tier {
2223                    entry.schema_tier = Some(crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
2224                        tracing::warn!(
2225                            "unknown schema tier `{tier}` in tool override `{name}`; defaulting to full"
2226                        );
2227                        crate::tools::SchemaTier::Full
2228                    }));
2229                }
2230                if let Some(t) = o.timeout_secs {
2231                    entry.timeout_secs = Some(t);
2232                }
2233            }
2234        }
2235        // P4b: scalars replace (S3.3).
2236        if let Some(v) = p.env_context {
2237            self.config.env_context = v;
2238        }
2239        if let Some(v) = &p.project_root_markers {
2240            self.config.project_root_markers = v.clone();
2241        }
2242        if let Some(v) = p.project_doc_max_bytes {
2243            self.config.project_doc_max_bytes = Some(v);
2244        }
2245        if let Some(v) = &p.project_doc_excludes {
2246            self.config.project_doc_excludes = v.clone();
2247        }
2248        if let Some(v) = p.project_doc_strip_comments {
2249            self.config.project_doc_strip_comments = v;
2250        }
2251        if let Some(v) = p.instruction_imports {
2252            self.config.instruction_imports = v;
2253        }
2254        if let Some(v) = p.retry_enabled {
2255            self.config.retry_enabled = v;
2256        }
2257        if let Some(v) = p.retry_max_retries {
2258            self.config.retry_max_retries = Some(v);
2259        }
2260        if let Some(v) = p.retry_base_delay_ms {
2261            self.config.retry_base_delay_ms = Some(v);
2262        }
2263        if let Some(v) = p.compaction_reserve_tokens {
2264            self.config.compaction_reserve_tokens = Some(v);
2265        }
2266        if let Some(v) = p.compaction_keep_recent_tokens {
2267            self.config.compaction_keep_recent_tokens = Some(v);
2268        }
2269        if let Some(v) = &p.compaction_focus_instructions {
2270            self.config.compaction_focus_instructions = Some(v.clone());
2271        }
2272        if let Some(v) = p.auto_title {
2273            self.config.auto_title = v;
2274        }
2275        if let Some(mode) = &p.steering_mode {
2276            // Fail safe: an unrecognized value keeps the current setting
2277            // rather than guessing.
2278            match SteeringMode::parse(mode) {
2279                Some(m) => self.config.steering_mode = m,
2280                None => tracing::warn!("unknown steering_mode `{mode}` in profile; ignoring"),
2281            }
2282        }
2283        if let Some(mode) = &p.follow_up_mode {
2284            match SteeringMode::parse(mode) {
2285                Some(m) => self.config.follow_up_mode = m,
2286                None => tracing::warn!("unknown follow_up_mode `{mode}` in profile; ignoring"),
2287            }
2288        }
2289        // P4c: scalars replace (S3.3).
2290        if let Some(v) = p.read_file_multimodal {
2291            self.config.read_file_multimodal = v;
2292        }
2293        // BP-2: `cat -n` gutter + recoverable tool-output spill (S3.3).
2294        if let Some(v) = p.read_file_line_numbers {
2295            self.config.read_file_line_numbers = v;
2296        }
2297        if let Some(v) = p.tool_output_spill {
2298            self.config.tool_output_spill = v;
2299        }
2300        if let Some(v) = p.edit_file_require_read_before_edit {
2301            self.config.edit_file_require_read_before_edit = v;
2302        }
2303        if let Some(v) = p.edit_file_notebook_aware {
2304            self.config.edit_file_notebook_aware = v;
2305        }
2306        if let Some(v) = p.shell_env_snapshot {
2307            self.config.shell_env_snapshot = v;
2308        }
2309        if let Some(v) = p.doom_loop_threshold {
2310            self.config.doom_loop_threshold = Some(v);
2311        }
2312        if let Some(v) = p.nested_instructions {
2313            self.config.nested_instructions = v;
2314        }
2315        if let Some(v) = p.model_switch_notice {
2316            self.config.model_switch_notice = v;
2317        }
2318        if let Some(v) = p.model_switch_allow_switch {
2319            self.config.model_switch_allow_switch = v;
2320        }
2321        // P4e: scalars replace (S3.3).
2322        if let Some(v) = p.context_injections {
2323            self.config.context_injections = v;
2324        }
2325        if let Some(v) = p.compaction_enabled {
2326            self.config.compaction_enabled = v;
2327        }
2328        if let Some(v) = p.compaction_summarize {
2329            self.config.compaction_summarize = v;
2330        }
2331        if let Some(v) = p.parallel_tool_calls {
2332            self.config.parallel_tool_calls = v;
2333        }
2334        if let Some(v) = p.session_git_metadata {
2335            self.config.session_git_metadata = v;
2336        }
2337        if let Some(v) = &p.session_dir {
2338            self.config.session_dir = Some(v.clone());
2339        }
2340        if let Some(v) = p.session_append_only {
2341            self.config.session_append_only = v;
2342        }
2343        if let Some(v) = p.session_queue_persist {
2344            self.config.session_queue_persist = v;
2345        }
2346        if let Some(v) = p.session_persist {
2347            self.config.session_persist = v;
2348        }
2349        if let Some(v) = &p.session_name {
2350            self.config.session_name = Some(v.clone());
2351        }
2352        if let Some(v) = p.session_retention_days {
2353            self.config.session_retention_days = Some(v);
2354        }
2355        if let Some(fmt) = &p.session_export_format {
2356            match crate::human_export::HumanExportFormat::parse(fmt) {
2357                Some(f) => self.config.session_export_format = f,
2358                None => {
2359                    tracing::warn!("unknown session export_format `{fmt}` in profile; ignoring")
2360                }
2361            }
2362        }
2363        self
2364    }
2365}
2366
2367/// Fluent builder for [`Config`].
2368#[derive(Default)]
2369pub struct ConfigBuilder {
2370    config: Config,
2371}
2372
2373impl ConfigBuilder {
2374    /// P4d (design §5.2 P1 CLI-adapter): resume building from an
2375    /// ALREADY-constructed [`Config`] rather than [`Config::default`] — lets
2376    /// a caller that assembled a `Config` with its own precedence logic
2377    /// (e.g. the CLI's `build_config`: flag > env > project > user >
2378    /// interactive-default) layer a narrowly-scoped [`ConfigProfile`] on top
2379    /// via [`Self::apply_profile`] afterward, reusing that method's correct
2380    /// per-key merge semantics (tables merge key-wise, e.g.
2381    /// `tool_overrides`/`prompts`/`extra_headers`/`extra_body`) instead of a
2382    /// second hand-rolled copy of the same merge logic at the call site.
2383    pub fn from_config(config: Config) -> Self {
2384        ConfigBuilder { config }
2385    }
2386
2387    /// Set the model identifier.
2388    pub fn model(mut self, model: impl Into<String>) -> Self {
2389        self.config.model = model.into();
2390        self
2391    }
2392
2393    /// Set the OpenAI-compatible base URL (defaults to OpenRouter).
2394    pub fn base_url(mut self, url: impl Into<String>) -> Self {
2395        self.config.base_url = url.into();
2396        self
2397    }
2398
2399    /// Provide the API key explicitly.
2400    pub fn api_key(mut self, key: impl Into<String>) -> Self {
2401        self.config.api_key = Some(key.into());
2402        self
2403    }
2404
2405    /// Change which environment variable the key is read from.
2406    pub fn api_key_env(mut self, var: impl Into<String>) -> Self {
2407        self.config.api_key_env = var.into();
2408        self
2409    }
2410
2411    /// Set the credential-helper command — see [`Config::api_key_cmd`].
2412    pub fn api_key_cmd(mut self, cmd: impl Into<String>) -> Self {
2413        self.config.api_key_cmd = Some(cmd.into());
2414        self
2415    }
2416
2417    /// Set the argv credential helper — see [`Config::api_key_command`].
2418    pub fn api_key_command(mut self, argv: Vec<String>) -> Self {
2419        self.config.api_key_command = Some(argv);
2420        self
2421    }
2422
2423    /// Enable/disable the startup release check — see
2424    /// [`Config::update_check`].
2425    pub fn update_check(mut self, on: bool) -> Self {
2426        self.config.update_check = on;
2427        self
2428    }
2429
2430    /// Replace the system prompt.
2431    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
2432        self.config.system_prompt = prompt.into();
2433        self
2434    }
2435
2436    /// Set the sampling temperature.
2437    pub fn temperature(mut self, t: f32) -> Self {
2438        self.config.temperature = Some(t);
2439        self
2440    }
2441
2442    /// Set the max output tokens.
2443    pub fn max_tokens(mut self, n: u32) -> Self {
2444        self.config.max_tokens = Some(n);
2445        self
2446    }
2447
2448    /// Set the per-`send` iteration budget.
2449    pub fn max_iterations(mut self, n: usize) -> Self {
2450        self.config.max_iterations = n;
2451        self
2452    }
2453
2454    /// Set the reasoning/effort level (`reasoning_effort`).
2455    pub fn effort(mut self, level: impl Into<String>) -> Self {
2456        self.config.effort = Some(level.into());
2457        self
2458    }
2459
2460    /// Constrain output to a JSON schema (`response_format`).
2461    pub fn response_format(mut self, format: serde_json::Value) -> Self {
2462        self.config.response_format = Some(format);
2463        self
2464    }
2465
2466    /// Merge an extra request-body field (provider-native passthrough).
2467    pub fn extra_body_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
2468        self.config.extra_body.insert(key.into(), value);
2469        self
2470    }
2471
2472    /// Cap cumulative output tokens across one `send` loop.
2473    /// Cap a single tool result at `n` bytes (see
2474    /// [`Config::max_tool_output_bytes`]). Pass `0` only via the field to disable.
2475    pub fn max_tool_output_bytes(mut self, n: usize) -> Self {
2476        self.config.max_tool_output_bytes = Some(n);
2477        self
2478    }
2479
2480    /// Cap on cumulative output tokens across one send loop. Output tokens
2481    /// only; input/prompt tokens are not counted, so this is not a cost cap.
2482    pub fn max_total_output_tokens(mut self, n: u64) -> Self {
2483        self.config.max_total_output_tokens = Some(n);
2484        self
2485    }
2486
2487    /// BP-7 — see [`Config::max_budget_usd`].
2488    pub fn max_budget_usd(mut self, usd: f64) -> Self {
2489        self.config.max_budget_usd = Some(usd);
2490        self
2491    }
2492
2493    /// BP-7 — see [`Config::max_steps`].
2494    pub fn max_steps(mut self, n: usize) -> Self {
2495        self.config.max_steps = Some(n);
2496        self
2497    }
2498
2499    /// BP-7 — see [`Config::price_input_per_mtok`] /
2500    /// [`Config::price_output_per_mtok`]. Both halves at once, because
2501    /// half a price bills the other half at zero.
2502    pub fn model_price(mut self, input_per_mtok: f64, output_per_mtok: f64) -> Self {
2503        self.config.price_input_per_mtok = Some(input_per_mtok);
2504        self.config.price_output_per_mtok = Some(output_per_mtok);
2505        self
2506    }
2507
2508    /// Set the working directory tools operate in.
2509    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
2510        self.config.cwd = dir.into();
2511        self
2512    }
2513
2514    /// Add an extra root directory (`--add-dir` / multi-root / worktree).
2515    pub fn add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2516        self.config.additional_dirs.push(dir.into());
2517        self
2518    }
2519
2520    /// Enable auto-loading of `CLAUDE.md` / `AGENTS.md` into the system prompt.
2521    pub fn project_context(mut self, enabled: bool) -> Self {
2522        self.config.load_project_context = enabled;
2523        self
2524    }
2525
2526    /// Register a named prompt template (skill / slash command).
2527    pub fn prompt(mut self, name: impl Into<String>, template: impl Into<String>) -> Self {
2528        self.config.prompts.insert(name.into(), template.into());
2529        self
2530    }
2531
2532    /// Compact the conversation once it exceeds `n` messages.
2533    pub fn compact_after_messages(mut self, n: usize) -> Self {
2534        self.config.compact_after_messages = Some(n);
2535        self
2536    }
2537
2538    /// Set the filesystem sandbox policy for write-capable tools.
2539    pub fn sandbox(mut self, policy: crate::tools::SandboxPolicy) -> Self {
2540        self.config.sandbox = policy;
2541        self
2542    }
2543
2544    /// Set the tool-approval policy.
2545    pub fn approval(mut self, policy: ApprovalPolicy) -> Self {
2546        self.config.approval = policy;
2547        self
2548    }
2549
2550    /// Add a tool to the auto-approve allowlist (no approval under `OnRequest`).
2551    pub fn auto_approve_tool(mut self, name: impl Into<String>) -> Self {
2552        self.config.auto_approved_tools.insert(name.into());
2553        self
2554    }
2555
2556    /// P4: add a glob pattern to [`Config::tool_deny_patterns`] — a match
2557    /// forces approval unconditionally, even under `ApprovalPolicy::Never`.
2558    pub fn deny_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
2559        self.config.tool_deny_patterns.push(pattern.into());
2560        self
2561    }
2562
2563    /// P4: add a glob pattern to [`Config::tool_allow_patterns`] — the
2564    /// pattern generalization of [`Self::auto_approve_tool`].
2565    pub fn allow_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
2566        self.config.tool_allow_patterns.push(pattern.into());
2567        self
2568    }
2569
2570    /// Set the handler consulted when a tool call needs approval.
2571    pub fn approval_handler(mut self, handler: ApprovalHandler) -> Self {
2572        self.config.approval_handler = Some(handler);
2573        self
2574    }
2575
2576    /// Set the pre-tool hook (may block a call by returning `Some(reason)`).
2577    pub fn pre_tool_hook(mut self, hook: PreToolHook) -> Self {
2578        self.config.pre_tool_hook = Some(hook);
2579        self
2580    }
2581
2582    /// Set the post-tool hook (observational).
2583    pub fn post_tool_hook(mut self, hook: PostToolHook) -> Self {
2584        self.config.post_tool_hook = Some(hook);
2585        self
2586    }
2587
2588    /// Disable a tool by name.
2589    pub fn disable_tool(mut self, name: impl Into<String>) -> Self {
2590        self.config
2591            .tool_overrides
2592            .entry(name.into())
2593            .or_default()
2594            .enabled = Some(false);
2595        self
2596    }
2597
2598    /// Enable a tool by name (overriding a prior disable).
2599    pub fn enable_tool(mut self, name: impl Into<String>) -> Self {
2600        self.config
2601            .tool_overrides
2602            .entry(name.into())
2603            .or_default()
2604            .enabled = Some(true);
2605        self
2606    }
2607
2608    /// Override the description the model sees for a tool.
2609    pub fn tool_description(
2610        mut self,
2611        name: impl Into<String>,
2612        description: impl Into<String>,
2613    ) -> Self {
2614        self.config
2615            .tool_overrides
2616            .entry(name.into())
2617            .or_default()
2618            .description = Some(description.into());
2619        self
2620    }
2621
2622    /// Set how tools are advertised to the model (B6).
2623    pub fn tool_advertising(mut self, advertising: ToolAdvertising) -> Self {
2624        self.config.tool_advertising = advertising;
2625        self
2626    }
2627
2628    /// Set the global tool-schema tier (TR-8/T5): how verbose ADVERTISED
2629    /// tool schemas are. Per-tool overrides ([`Self::tool_schema_tier`])
2630    /// still win for the specific tools they name.
2631    pub fn schema_tier(mut self, tier: crate::tools::SchemaTier) -> Self {
2632        self.config.tool_schema_tier = tier;
2633        self
2634    }
2635
2636    /// Override the schema tier for a single tool (TR-8/T5), regardless of
2637    /// the global knob — e.g. keep one load-bearing tool at `Full` while
2638    /// everything else shrinks to `Minimal`.
2639    pub fn tool_schema_tier(
2640        mut self,
2641        name: impl Into<String>,
2642        tier: crate::tools::SchemaTier,
2643    ) -> Self {
2644        self.config
2645            .tool_overrides
2646            .entry(name.into())
2647            .or_default()
2648            .schema_tier = Some(tier);
2649        self
2650    }
2651
2652    /// Add an extra HTTP header sent on every request.
2653    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
2654        self.config.extra_headers.insert(key.into(), value.into());
2655        self
2656    }
2657
2658    /// Attach a streaming event sink.
2659    pub fn event_sink(mut self, sink: EventSink) -> Self {
2660        self.config.event_sink = Some(sink);
2661        self
2662    }
2663
2664    /// Set the prompt-caching plan (B7).
2665    pub fn cache_plan(mut self, plan: CachePlan) -> Self {
2666        self.config.cache_plan = plan;
2667        self
2668    }
2669
2670    /// UX-26 (B7-warn): enable/disable the cache-cold warning (default on).
2671    pub fn cache_warnings(mut self, enabled: bool) -> Self {
2672        self.config.cache_warnings = enabled;
2673        self
2674    }
2675
2676    /// P3: turn on the `[experimental] module_registry` gate — see
2677    /// [`Config::module_registry`].
2678    pub fn module_registry(mut self, enabled: bool) -> Self {
2679        self.config.module_registry = enabled;
2680        self
2681    }
2682
2683    /// P3: set the resolved module-activation set — see
2684    /// [`Config::module_activation`].
2685    pub fn module_activation(mut self, activation: crate::modules::ModuleActivation) -> Self {
2686        self.config.module_activation = activation;
2687        self
2688    }
2689
2690    /// P3: set the effective `[core.tools] enabled` list — see
2691    /// [`Config::core_tools_enabled`].
2692    pub fn core_tools_enabled(mut self, tools: Vec<String>) -> Self {
2693        self.config.core_tools_enabled = tools;
2694        self
2695    }
2696
2697    /// P3: set `[core.skills].enabled` — see [`Config::skills_enabled`].
2698    pub fn skills_enabled(mut self, enabled: bool) -> Self {
2699        self.config.skills_enabled = enabled;
2700        self
2701    }
2702
2703    /// BP-6: set `[core.skills].harness` — see [`Config::skills_harness`].
2704    pub fn skills_harness(mut self, harness: impl Into<String>) -> Self {
2705        self.config.skills_harness = Some(harness.into());
2706        self
2707    }
2708
2709    /// BP-6: set `[core.skills].dirs` — see [`Config::skills_dirs`].
2710    pub fn skills_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
2711        self.config.skills_dirs = dirs;
2712        self
2713    }
2714
2715    /// BP-6: set `[core.skills].implicit_match` — see
2716    /// [`Config::skills_implicit_match`].
2717    pub fn skills_implicit_match(mut self, enabled: bool) -> Self {
2718        self.config.skills_implicit_match = enabled;
2719        self
2720    }
2721
2722    /// BP-5: set `[core.skills].shell_injection` — see
2723    /// [`Config::skills_shell_injection`].
2724    pub fn skills_shell_injection(mut self, enabled: bool) -> Self {
2725        self.config.skills_shell_injection = enabled;
2726        self
2727    }
2728
2729    /// BP-5: set `[core.file_mentions]` — see [`Config::file_mentions`].
2730    pub fn file_mentions(mut self, enabled: bool) -> Self {
2731        self.config.file_mentions = enabled;
2732        self
2733    }
2734
2735    /// BP-5: set `[core.output_style]` — see [`Config::output_style`].
2736    pub fn output_style(mut self, name: impl Into<String>) -> Self {
2737        self.config.output_style = name.into();
2738        self
2739    }
2740
2741    /// BP-5: set `[core.path_rules]` — see [`Config::path_rules`].
2742    pub fn path_rules(mut self, enabled: bool) -> Self {
2743        self.config.path_rules = enabled;
2744        self
2745    }
2746
2747    /// BP-5: set `[capabilities.model_catalog].base_prompts` — see
2748    /// [`Config::model_family_prompts`].
2749    pub fn model_family_prompts(
2750        mut self,
2751        prompts: std::collections::BTreeMap<String, String>,
2752    ) -> Self {
2753        self.config.model_family_prompts = prompts;
2754        self
2755    }
2756
2757    /// P4: set the small/utility model id — see `Config::small_model`.
2758    pub fn small_model(mut self, model: impl Into<String>) -> Self {
2759        self.config.small_model = Some(model.into());
2760        self
2761    }
2762
2763    /// BP-13: set the resolved model-routing table — see
2764    /// `Config::model_routing`.
2765    pub fn model_routing(mut self, routing: crate::model_catalog::Routing) -> Self {
2766        self.config.model_routing = routing;
2767        self
2768    }
2769
2770    /// BP-13: set the session-level service tier (`/fast`) — see
2771    /// `Config::service_tier`.
2772    pub fn service_tier(mut self, tier: impl Into<String>) -> Self {
2773        self.config.service_tier = Some(tier.into());
2774        self
2775    }
2776
2777    /// P4: set the model failure-fallback chain — see `Config::model_fallback`.
2778    pub fn model_fallback(mut self, chain: Vec<String>) -> Self {
2779        self.config.model_fallback = chain;
2780        self
2781    }
2782
2783    /// P4b: turn on the environment-context block — see [`Config::env_context`].
2784    pub fn env_context(mut self, enabled: bool) -> Self {
2785        self.config.env_context = enabled;
2786        self
2787    }
2788
2789    /// P4b: set the project-root marker filenames — see
2790    /// [`Config::project_root_markers`].
2791    pub fn project_root_markers(mut self, markers: Vec<String>) -> Self {
2792        self.config.project_root_markers = markers;
2793        self
2794    }
2795
2796    /// P4b: cap the total bytes of assembled instruction-file content — see
2797    /// [`Config::project_doc_max_bytes`].
2798    pub fn project_doc_max_bytes(mut self, n: usize) -> Self {
2799        self.config.project_doc_max_bytes = Some(n);
2800        self
2801    }
2802
2803    /// P4b: turn on `@path` instruction imports — see
2804    /// [`Config::instruction_imports`].
2805    pub fn instruction_imports(mut self, enabled: bool) -> Self {
2806        self.config.instruction_imports = enabled;
2807        self
2808    }
2809
2810    /// P4b: configure request retry with backoff — see
2811    /// [`Config::retry_enabled`]. `max_retries`/`base_delay_ms` override the
2812    /// transport's built-in defaults when `Some`.
2813    pub fn retry(
2814        mut self,
2815        enabled: bool,
2816        max_retries: Option<u32>,
2817        base_delay_ms: Option<u64>,
2818    ) -> Self {
2819        self.config.retry_enabled = enabled;
2820        self.config.retry_max_retries = max_retries;
2821        self.config.retry_base_delay_ms = base_delay_ms;
2822        self
2823    }
2824
2825    /// P4b: turn on the compaction token-pressure trigger — see
2826    /// [`Config::compaction_reserve_tokens`].
2827    pub fn compaction_pressure(mut self, reserve_tokens: u64, keep_recent_tokens: u64) -> Self {
2828        self.config.compaction_reserve_tokens = Some(reserve_tokens);
2829        self.config.compaction_keep_recent_tokens = Some(keep_recent_tokens);
2830        self
2831    }
2832
2833    /// P4b: set the compaction focus instructions — see
2834    /// [`Config::compaction_focus_instructions`].
2835    pub fn compaction_focus_instructions(mut self, text: impl Into<String>) -> Self {
2836        self.config.compaction_focus_instructions = Some(text.into());
2837        self
2838    }
2839
2840    /// P4b: turn on the auto-title gate — see [`Config::auto_title`].
2841    pub fn auto_title(mut self, enabled: bool) -> Self {
2842        self.config.auto_title = enabled;
2843        self
2844    }
2845
2846    /// P4b: set the mid-turn steering delivery mode — see
2847    /// [`Config::steering_mode`].
2848    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
2849        self.config.steering_mode = mode;
2850        self
2851    }
2852
2853    /// P4b: set the idle follow-up delivery mode — see
2854    /// [`Config::follow_up_mode`].
2855    pub fn follow_up_mode(mut self, mode: SteeringMode) -> Self {
2856        self.config.follow_up_mode = mode;
2857        self
2858    }
2859
2860    /// P4b: install a stop-gate hook — see [`Config::stop_gate`].
2861    pub fn stop_gate(mut self, hook: StopGateHook) -> Self {
2862        self.config.stop_gate = Some(hook);
2863        self
2864    }
2865
2866    /// BP-2: number `read_file` output `cat -n` style — see
2867    /// [`Config::read_file_line_numbers`].
2868    pub fn read_file_line_numbers(mut self, enabled: bool) -> Self {
2869        self.config.read_file_line_numbers = enabled;
2870        self
2871    }
2872
2873    /// BP-2: spill capped tool output to a per-session file the model can
2874    /// read back — see [`Config::tool_output_spill`].
2875    pub fn tool_output_spill(mut self, enabled: bool) -> Self {
2876        self.config.tool_output_spill = enabled;
2877        self
2878    }
2879
2880    /// P4c: turn on multimodal `read_file` — see [`Config::read_file_multimodal`].
2881    pub fn read_file_multimodal(mut self, enabled: bool) -> Self {
2882        self.config.read_file_multimodal = enabled;
2883        self
2884    }
2885
2886    /// P4c: require a prior read before `edit_file` accepts an edit — see
2887    /// [`Config::edit_file_require_read_before_edit`].
2888    pub fn edit_file_require_read_before_edit(mut self, enabled: bool) -> Self {
2889        self.config.edit_file_require_read_before_edit = enabled;
2890        self
2891    }
2892
2893    /// P4c: turn on notebook-cell-aware `edit_file` — see
2894    /// [`Config::edit_file_notebook_aware`].
2895    pub fn edit_file_notebook_aware(mut self, enabled: bool) -> Self {
2896        self.config.edit_file_notebook_aware = enabled;
2897        self
2898    }
2899
2900    /// P4c: turn on shell-environment snapshotting — see
2901    /// [`Config::shell_env_snapshot`].
2902    pub fn shell_env_snapshot(mut self, enabled: bool) -> Self {
2903        self.config.shell_env_snapshot = enabled;
2904        self
2905    }
2906
2907    /// P4c: set the doom-loop repetition threshold — see
2908    /// [`Config::doom_loop_threshold`].
2909    pub fn doom_loop_threshold(mut self, n: u32) -> Self {
2910        self.config.doom_loop_threshold = Some(n);
2911        self
2912    }
2913
2914    /// P4c: turn on on-demand nested instruction loading — see
2915    /// [`Config::nested_instructions`].
2916    pub fn nested_instructions(mut self, enabled: bool) -> Self {
2917        self.config.nested_instructions = enabled;
2918        self
2919    }
2920
2921    /// P4c: turn on mid-session model switch's persisted-record +
2922    /// reasoning-filter behavior — see [`Config::model_switch_allow_switch`].
2923    pub fn model_switch_allow_switch(mut self, enabled: bool) -> Self {
2924        self.config.model_switch_allow_switch = enabled;
2925        self
2926    }
2927
2928    /// BP-13: inject a switch notice on a mid-session model change — see
2929    /// [`Config::model_switch_notice`].
2930    pub fn model_switch_notice(mut self, enabled: bool) -> Self {
2931        self.config.model_switch_notice = enabled;
2932        self
2933    }
2934
2935    /// BP-13: set the effort level plan mode runs at — see
2936    /// [`Config::plan_mode_effort`].
2937    pub fn plan_mode_effort(mut self, effort: impl Into<String>) -> Self {
2938        self.config.plan_mode_effort = Some(effort.into());
2939        self
2940    }
2941
2942    /// P4e: turn on ambient context-injection blocks — see
2943    /// [`Config::context_injections`].
2944    pub fn context_injections(mut self, enabled: bool) -> Self {
2945        self.config.context_injections = enabled;
2946        self
2947    }
2948
2949    /// P4e: append one named ambient context block — see
2950    /// [`Config::context_injection_blocks`].
2951    pub fn context_injection_block(
2952        mut self,
2953        name: impl Into<String>,
2954        content: impl Into<String>,
2955    ) -> Self {
2956        self.config
2957            .context_injection_blocks
2958            .push(ContextInjectionBlock::new(name, content));
2959        self
2960    }
2961
2962    /// P4e: master gate for all auto-compaction — see
2963    /// [`Config::compaction_enabled`].
2964    pub fn compaction_enabled(mut self, enabled: bool) -> Self {
2965        self.config.compaction_enabled = enabled;
2966        self
2967    }
2968
2969    /// P4e: run independent tool calls concurrently — see
2970    /// [`Config::parallel_tool_calls`].
2971    pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
2972        self.config.parallel_tool_calls = enabled;
2973        self
2974    }
2975
2976    /// P4e: capture git branch/sha/dirty at construction — see
2977    /// [`Config::session_git_metadata`].
2978    pub fn session_git_metadata(mut self, enabled: bool) -> Self {
2979        self.config.session_git_metadata = enabled;
2980        self
2981    }
2982
2983    /// P4e DEFECT-FIX: ephemeral vs. persisted session gate — see
2984    /// [`Config::session_persist`].
2985    pub fn session_persist(mut self, enabled: bool) -> Self {
2986        self.config.session_persist = enabled;
2987        self
2988    }
2989
2990    /// BP-8: arm the append-only session journal — see
2991    /// [`Config::session_append_only`].
2992    pub fn session_append_only(mut self, enabled: bool) -> Self {
2993        self.config.session_append_only = enabled;
2994        self
2995    }
2996
2997    /// BP-8: record pending inputs as journal queue operations — see
2998    /// [`Config::session_queue_persist`].
2999    pub fn session_queue_persist(mut self, enabled: bool) -> Self {
3000        self.config.session_queue_persist = enabled;
3001        self
3002    }
3003
3004    /// BP-8: persist the `update_plan` checklist with the session — see
3005    /// [`Config::todos_persist`].
3006    pub fn todos_persist(mut self, enabled: bool) -> Self {
3007        self.config.todos_persist = enabled;
3008        self
3009    }
3010
3011    /// P4e DEFECT-FIX: a caller-configured session name — see
3012    /// [`Config::session_name`].
3013    pub fn session_name(mut self, name: impl Into<String>) -> Self {
3014        self.config.session_name = Some(name.into());
3015        self
3016    }
3017
3018    /// P5-3 (§3.1 `capabilities.subagents.enabled`) — see
3019    /// [`Config::subagents_enabled`].
3020    pub fn subagents_enabled(mut self, enabled: bool) -> Self {
3021        self.config.subagents_enabled = enabled;
3022        self
3023    }
3024
3025    /// P5-3 (§3.1 `capabilities.subagents.max_depth`) — see
3026    /// [`Config::subagents_max_depth`].
3027    pub fn subagents_max_depth(mut self, n: usize) -> Self {
3028        self.config.subagents_max_depth = n;
3029        self
3030    }
3031
3032    /// P5-3 (resource bound) — see [`Config::subagents_max_concurrent`].
3033    pub fn subagents_max_concurrent(mut self, n: usize) -> Self {
3034        self.config.subagents_max_concurrent = n;
3035        self
3036    }
3037
3038    /// P5-3 (§3.1 `capabilities.subagents.background`) — see
3039    /// [`Config::subagents_background`].
3040    pub fn subagents_background(mut self, enabled: bool) -> Self {
3041        self.config.subagents_background = enabled;
3042        self
3043    }
3044
3045    /// P5-3 (§2.2 C6) — see [`Config::subagents_background_prompts`].
3046    pub fn subagents_background_prompts(
3047        mut self,
3048        policy: crate::subagents::BackgroundPromptsPolicy,
3049    ) -> Self {
3050        self.config.subagents_background_prompts = Some(policy);
3051        self
3052    }
3053
3054    /// Enable Claude Code's `Agent` compatibility alias for named subagents.
3055    pub fn subagents_claude_agent_alias(mut self, enabled: bool) -> Self {
3056        self.config.subagents_claude_agent_alias = enabled;
3057        self
3058    }
3059
3060    /// Enable Claude's paused runtime-state compatibility intrinsics.
3061    pub fn claude_runtime_tools_enabled(mut self, enabled: bool) -> Self {
3062        self.config.claude_runtime_tools_enabled = enabled;
3063        self
3064    }
3065
3066    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`) — register one
3067    /// named agent definition, keyed by [`crate::subagents::NamedAgentDefinition::name`].
3068    pub fn subagent_definition(mut self, def: crate::subagents::NamedAgentDefinition) -> Self {
3069        self.config
3070            .subagents_definitions
3071            .insert(def.name.clone(), def);
3072        self
3073    }
3074
3075    /// P5-3 (runtime-only) — see [`Config::subagent_depth`]. Not something
3076    /// an ordinary caller sets by hand; `Agent::run_spawn_subagent` sets it
3077    /// on the CHILD config it builds.
3078    pub fn subagent_depth(mut self, depth: usize) -> Self {
3079        self.config.subagent_depth = depth;
3080        self
3081    }
3082
3083    /// P5-6 (§3.1 `capabilities.tools_background.enabled`) — see
3084    /// [`Config::tools_background_enabled`].
3085    pub fn tools_background_enabled(mut self, enabled: bool) -> Self {
3086        self.config.tools_background_enabled = enabled;
3087        self
3088    }
3089
3090    /// P5-6 (resource bound) — see [`Config::tools_background_max_concurrent`].
3091    pub fn tools_background_max_concurrent(mut self, n: usize) -> Self {
3092        self.config.tools_background_max_concurrent = n;
3093        self
3094    }
3095
3096    /// P5-6 (resource bound) — see [`Config::tools_background_max_output_bytes`].
3097    pub fn tools_background_max_output_bytes(mut self, n: usize) -> Self {
3098        self.config.tools_background_max_output_bytes = n;
3099        self
3100    }
3101
3102    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`) — see
3103    /// [`Config::checkpoint_enabled`].
3104    pub fn checkpoint_enabled(mut self, enabled: bool) -> Self {
3105        self.config.checkpoint_enabled = enabled;
3106        self
3107    }
3108
3109    /// P5-9 (bounded-disk requirement) — see [`Config::checkpoint_retain`].
3110    pub fn checkpoint_retain(mut self, n: usize) -> Self {
3111        self.config.checkpoint_retain = n;
3112        self
3113    }
3114
3115    /// BP-10 — see [`Config::trust_handler`]. Installing the door is what
3116    /// turns `[capabilities.trust] default = "ask"` from "behaves like
3117    /// never" into a real question.
3118    pub fn trust_handler(
3119        mut self,
3120        handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>,
3121    ) -> Self {
3122        self.config.trust_handler = Some(handler);
3123        self
3124    }
3125
3126    /// BP-10 (embedder/test override) — see [`Config::trust_store`].
3127    pub fn trust_store(mut self, path: impl Into<PathBuf>) -> Self {
3128        self.config.trust_store = Some(path.into());
3129        self
3130    }
3131
3132    /// BP-10 — see [`Config::permissions_approvals_persist`]. Turning this
3133    /// on WITHOUT also setting [`Self::permissions_approval_store`] uses
3134    /// the per-project default under `$SUPERCODE_HOME`.
3135    pub fn permissions_approvals_persist(mut self, persist: bool) -> Self {
3136        self.config.permissions_approvals_persist = persist;
3137        self
3138    }
3139
3140    /// BP-10 (embedder/test override) — see
3141    /// [`Config::permissions_approval_store`].
3142    pub fn permissions_approval_store(mut self, path: impl Into<PathBuf>) -> Self {
3143        self.config.permissions_approval_store = Some(path.into());
3144        self
3145    }
3146
3147    /// P5-9 (embedder/test override) — see [`Config::checkpoint_dir`].
3148    pub fn checkpoint_dir(mut self, dir: impl Into<PathBuf>) -> Self {
3149        self.config.checkpoint_dir = Some(dir.into());
3150        self
3151    }
3152
3153    /// P5-11 (§3.1 `capabilities.lsp.enabled`) — see [`Config::lsp_enabled`].
3154    pub fn lsp_enabled(mut self, enabled: bool) -> Self {
3155        self.config.lsp_enabled = enabled;
3156        self
3157    }
3158
3159    /// P5-11 (`capabilities.lsp.servers`) — see [`Config::lsp_servers`].
3160    pub fn lsp_servers(mut self, servers: Vec<(String, crate::lsp::LspServerSpec)>) -> Self {
3161        self.config.lsp_servers = servers;
3162        self
3163    }
3164
3165    /// P5-11 (bounded-context requirement) — see [`Config::lsp_max_diagnostics`].
3166    pub fn lsp_max_diagnostics(mut self, n: usize) -> Self {
3167        self.config.lsp_max_diagnostics = n;
3168        self
3169    }
3170
3171    /// P5-11 (bounded-latency requirement) — see [`Config::lsp_timeout_secs`].
3172    pub fn lsp_timeout_secs(mut self, secs: u64) -> Self {
3173        self.config.lsp_timeout_secs = secs;
3174        self
3175    }
3176
3177    /// P5-11 (§3.1 `capabilities.formatters.enabled`) — see
3178    /// [`Config::formatters_enabled`].
3179    pub fn formatters_enabled(mut self, enabled: bool) -> Self {
3180        self.config.formatters_enabled = enabled;
3181        self
3182    }
3183
3184    /// P5-11 (`capabilities.formatters.<name>`) — see [`Config::formatters`].
3185    pub fn formatters(
3186        mut self,
3187        formatters: Vec<(String, crate::formatters::FormatterSpec)>,
3188    ) -> Self {
3189        self.config.formatters = formatters;
3190        self
3191    }
3192
3193    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10) — see
3194    /// [`Config::formatters_diff_back`].
3195    pub fn formatters_diff_back(mut self, diff_back: bool) -> Self {
3196        self.config.formatters_diff_back = diff_back;
3197        self
3198    }
3199
3200    /// P5-11 (bounded-latency requirement) — see [`Config::formatters_timeout_secs`].
3201    pub fn formatters_timeout_secs(mut self, secs: u64) -> Self {
3202        self.config.formatters_timeout_secs = secs;
3203        self
3204    }
3205
3206    /// P5-12 (§3.1 `capabilities.trust.enabled`) — see [`Config::trust_enabled`].
3207    pub fn trust_enabled(mut self, enabled: bool) -> Self {
3208        self.config.trust_enabled = enabled;
3209        self
3210    }
3211
3212    /// P5-12 (`capabilities.trust.default`) — see [`Config::trust_default`].
3213    pub fn trust_default(mut self, default: crate::plugins::TrustDecision) -> Self {
3214        self.config.trust_default = default;
3215        self
3216    }
3217
3218    /// P5-12 (§3.1 `capabilities.plugins.enabled`) — see [`Config::plugins_enabled`].
3219    pub fn plugins_enabled(mut self, enabled: bool) -> Self {
3220        self.config.plugins_enabled = enabled;
3221        self
3222    }
3223
3224    /// P5-12 (`capabilities.plugins.dirs`) — see [`Config::plugins_dirs`].
3225    pub fn plugins_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
3226        self.config.plugins_dirs = dirs;
3227        self
3228    }
3229
3230    /// Finalize the configuration.
3231    pub fn build(self) -> Config {
3232        self.config
3233    }
3234}