Skip to main content

supercode_harness/
config_schema.rs

1//! BP-9 (D6 row "Published JSON schema for config", cc§6): the published
2//! JSON Schema for the supercode config file — `.supercode.toml`,
3//! `~/.config/supercode/config.toml`, and the JSON mirror
4//! ([`HarnessConfig::from_json_str`]).
5//!
6//! **Why a table and not a derive.** The schema is generated from
7//! [`CONFIG_SCHEMA_FIELDS`], a flat list of `(dotted path, kind,
8//! description)`. That table would rot silently — except that
9//! [`tests::schema_covers_exactly_the_parsed_keys`] compares it against the
10//! keys serde ITSELF emits for a default [`HarnessConfig`], in both
11//! directions, and [`tests::every_schema_key_parses_with_its_declared_type`]
12//! feeds each declared path back through the real parser at its declared
13//! type. Adding a field to `CoreSection` without touching this table fails
14//! the first test; declaring a wrong type fails the second. So the schema is
15//! bound to the serde types by the test suite rather than by a derive macro —
16//! no new dependency, same guarantee, and the descriptions are written for a
17//! human reading a tooltip in their editor.
18//!
19//! The generated document is checked in at
20//! `docs/schema/supercode-config.schema.json` (regenerate with
21//! `supercode config schema --write`); a test asserts the committed file is
22//! byte-identical to what this module generates.
23
24use serde_json::{json, Map, Value};
25
26use crate::configfile::{HarnessConfig, MODULE_NAMES};
27
28/// Where the committed schema lives, workspace-relative.
29pub const CONFIG_SCHEMA_PATH: &str = "docs/schema/supercode-config.schema.json";
30
31/// Canonical URL for the published schema — what a config file's `$schema`
32/// key should point at.
33pub const CONFIG_SCHEMA_URL: &str =
34    "https://raw.githubusercontent.com/volter-ai/supercode/main/docs/schema/supercode-config.schema.json";
35
36/// The JSON type a config key carries.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Kind {
39    /// A string.
40    Str,
41    /// An integer.
42    Int,
43    /// A float.
44    Num,
45    /// A boolean.
46    Bool,
47    /// An array of strings.
48    StrArray,
49    /// An object whose values are strings (a free-form table).
50    StrMap,
51    /// An object whose values may be anything (a free-form table).
52    AnyMap,
53    /// `[capabilities.*]` — module name → capability table.
54    CapabilityMap,
55}
56
57impl Kind {
58    fn schema(self) -> Value {
59        match self {
60            Kind::Str => json!({ "type": "string" }),
61            Kind::Int => json!({ "type": "integer" }),
62            Kind::Num => json!({ "type": "number" }),
63            Kind::Bool => json!({ "type": "boolean" }),
64            Kind::StrArray => json!({ "type": "array", "items": { "type": "string" } }),
65            Kind::StrMap => {
66                json!({ "type": "object", "additionalProperties": { "type": "string" } })
67            }
68            Kind::AnyMap => json!({ "type": "object" }),
69            Kind::CapabilityMap => json!({
70                "type": "object",
71                "propertyNames": { "enum": MODULE_NAMES },
72                "additionalProperties": {
73                    "type": "object",
74                    "properties": {
75                        "enabled": {
76                            "type": "boolean",
77                            "description": "Module master switch (§3.0: every capability table has `enabled`)."
78                        }
79                    },
80                    "description": "A §2 capability module's table: `enabled` plus that module's own settings."
81                }
82            }),
83        }
84    }
85}
86
87/// One config key: its dotted path, its JSON type, and the one-line
88/// description an editor shows.
89#[derive(Debug, Clone, Copy)]
90pub struct Field {
91    /// Dotted path from the document root (`core.tools.bash.timeout_secs`).
92    pub path: &'static str,
93    /// The value's JSON type.
94    pub kind: Kind,
95    /// Editor-facing description.
96    pub description: &'static str,
97}
98
99const fn f(path: &'static str, kind: Kind, description: &'static str) -> Field {
100    Field {
101        path,
102        kind,
103        description,
104    }
105}
106
107/// Every key the config parser accepts, in document order. Kept honest by
108/// the tests at the bottom of this file — see the module doc comment.
109pub const CONFIG_SCHEMA_FIELDS: &[Field] = &[
110    f(
111        "$schema",
112        Kind::Str,
113        "Pointer to this JSON Schema, so editors validate the file. Declarative only — Volter Harness never fetches it.",
114    ),
115    f(
116        "schema_version",
117        Kind::Int,
118        "Config schema version. `1` is the only version this build understands; anything else is rejected rather than reinterpreted.",
119    ),
120    f(
121        "extends",
122        Kind::Str,
123        "A built-in preset name (`cc-parity`, `cx-parity`, `supercode-default`, …) or, in the user/global layer only, a path to another config file.",
124    ),
125    f(
126        "core.model",
127        Kind::Str,
128        "Model id or alias for the main loop.",
129    ),
130    f(
131        "core.base_url",
132        Kind::Str,
133        "OpenAI-compatible endpoint. Supports `${VAR}` / `${VAR:-default}` / `{file:…}` substitution. [project-forbidden]",
134    ),
135    f(
136        "core.api_key_env",
137        Kind::Str,
138        "Environment variable name the API key is read from. [project-forbidden]",
139    ),
140    f(
141        "core.api_key_cmd",
142        Kind::Str,
143        "Credential helper: a shell command whose trimmed stdout is the API key. [project-forbidden]",
144    ),
145    f(
146        "core.api_key_command",
147        Kind::StrArray,
148        "Credential helper as argv (exec'd directly, no shell); its trimmed stdout is the API key. Consulted before `api_key_cmd`. [project-forbidden]",
149    ),
150    f(
151        "core.update_check",
152        Kind::Bool,
153        "Check for a newer release at startup. Opt-in: absent/false means no startup network access.",
154    ),
155    f(
156        "core.effort",
157        Kind::Str,
158        "Reasoning-effort level passed to the provider (`low` | `medium` | `high`).",
159    ),
160    f(
161        "core.temperature",
162        Kind::Num,
163        "Sampling temperature.",
164    ),
165    f(
166        "core.max_tokens",
167        Kind::Int,
168        "Max output tokens per model turn.",
169    ),
170    f(
171        "core.max_iterations",
172        Kind::Int,
173        "Per-run tool-use iteration budget (must be >= 1).",
174    ),
175    f(
176        "core.max_total_output_tokens",
177        Kind::Int,
178        "Cap on cumulative completion tokens across one run; 0/absent = off.",
179    ),
180    f(
181        "core.max_budget_usd",
182        Kind::Num,
183        "Cap on the cumulative dollar cost of one run; 0/absent = off. Refused at startup for a model this build cannot price.",
184    ),
185    f(
186        "core.max_steps",
187        Kind::Int,
188        "Cap on the number of tool calls executed across one run; 0/absent = off. Distinct from `max_iterations` (model round-trips).",
189    ),
190    f(
191        "core.price_input_per_mtok",
192        Kind::Num,
193        "Dollars per million input tokens for this model, overriding the built-in price table. Set together with `price_output_per_mtok`.",
194    ),
195    f(
196        "core.price_output_per_mtok",
197        Kind::Num,
198        "Dollars per million output tokens for this model.",
199    ),
200    f(
201        "core.max_tool_output_bytes",
202        Kind::Int,
203        "Truncation cap on a single tool result.",
204    ),
205    f(
206        "core.parallel_tool_calls",
207        Kind::Bool,
208        "Execute independent tool calls from one turn concurrently.",
209    ),
210    f(
211        "core.tool_output_spill",
212        Kind::Bool,
213        "Write a truncated tool result's full bytes to a per-session spill file the model can read back.",
214    ),
215    f(
216        "core.shell_env_snapshot",
217        Kind::Bool,
218        "Snapshot the login shell's environment for shell tool calls.",
219    ),
220    f(
221        "core.system_prompt",
222        Kind::Str,
223        "Replace the system prompt. Supports `${VAR}` / `{file:…}` substitution. [project-forbidden]",
224    ),
225    f(
226        "core.append_system_prompt",
227        Kind::Str,
228        "Append to the system prompt rather than replacing it. [project-forbidden]",
229    ),
230    f(
231        "core.project_context",
232        Kind::Bool,
233        "Auto-load CLAUDE.md / AGENTS.md instruction files.",
234    ),
235    f(
236        "core.env_context",
237        Kind::Bool,
238        "Append an `# Environment` block (cwd, platform, date, git branch at the project root).",
239    ),
240    f(
241        "core.context_injections",
242        Kind::Bool,
243        "Append the configured synthetic context blocks to the system prompt.",
244    ),
245    f(
246        "core.nested_instructions",
247        Kind::Bool,
248        "Load instruction files from subdirectories on demand.",
249    ),
250    f(
251        "core.instruction_imports",
252        Kind::Bool,
253        "Expand `@relative/path` imports inside instruction files.",
254    ),
255    f(
256        "core.project_root_markers",
257        Kind::StrArray,
258        "Filenames/directories that mark the project root; every root walk stops at the first one. Defaults to [\".git\"].",
259    ),
260    f(
261        "core.hot_reload",
262        Kind::Bool,
263        "Reserved: live-apply config edits without restart. Parsed and round-tripped, with no consumer in this build.",
264    ),
265    f(
266        "core.project_doc_max_bytes",
267        Kind::Int,
268        "Hygiene cap on the total bytes of assembled instruction-file content.",
269    ),
270    f(
271        "core.project_doc_excludes",
272        Kind::StrArray,
273        "Glob/path patterns naming instruction files to skip when assembling project context.",
274    ),
275    f(
276        "core.project_doc_strip_comments",
277        Kind::Bool,
278        "Drop `<!-- … -->` spans from instruction files before injecting them.",
279    ),
280    f(
281        "core.file_mentions",
282        Kind::Bool,
283        "Expand `@path` tokens in a prompt into that file's contents, subject to the \
284         permission engine's read rules.",
285    ),
286    f(
287        "core.output_style",
288        Kind::Str,
289        "Named response-style layer appended to the system prompt (a built-in style, or a \
290         markdown file under the harness's own output-style roots).",
291    ),
292    f(
293        "core.path_rules",
294        Kind::Bool,
295        "Load `.claude/rules/*.md` rule files; a rule with `paths:` frontmatter is injected \
296         only when a tool touches a matching file.",
297    ),
298    f(
299        "core.additional_dirs",
300        Kind::StrArray,
301        "Extra roots tools may access. A project layer may only add contained relative paths.",
302    ),
303    f(
304        "core.extra_headers",
305        Kind::StrMap,
306        "Extra HTTP headers on every provider request. Values support substitution. [project-forbidden]",
307    ),
308    f(
309        "core.extra_body",
310        Kind::AnyMap,
311        "Extra JSON merged into every provider request body. [project-forbidden]",
312    ),
313    f(
314        "core.doom_loop_threshold",
315        Kind::Int,
316        "Break the run after this many identical repeated tool calls; absent = off.",
317    ),
318    f(
319        "core.model_switch.allow_switch",
320        Kind::Bool,
321        "Allow switching models mid-session (recorded as a `model_change` event).",
322    ),
323    f(
324        "core.model_switch.notice",
325        Kind::Bool,
326        "On a mid-session model change, splice a notice into the conversation so the incoming model reads the handoff.",
327    ),
328    f(
329        "core.retry.enabled",
330        Kind::Bool,
331        "Retry failed provider requests.",
332    ),
333    f(
334        "core.retry.max_retries",
335        Kind::Int,
336        "Maximum retry attempts.",
337    ),
338    f(
339        "core.retry.base_delay_ms",
340        Kind::Int,
341        "Base backoff delay in milliseconds (doubles per attempt).",
342    ),
343    f(
344        "core.tools.enabled",
345        Kind::StrArray,
346        "The default-active built-in tool names.",
347    ),
348    f(
349        "core.tools.schema_tier",
350        Kind::Str,
351        "Global advertised-schema tier (`full` | `medium` | `minimal`).",
352    ),
353    f(
354        "core.tools.read_file.multimodal",
355        Kind::Bool,
356        "Allow `read_file` to return image, PDF and notebook content as model-visible content.",
357    ),
358    f(
359        "core.tools.read_file.line_numbers",
360        Kind::Bool,
361        "Number `read_file` output `cat -n` style, from the requested offset.",
362    ),
363    f(
364        "core.tools.edit_file.require_read_before_edit",
365        Kind::Bool,
366        "Reject an edit to a path this session has not read.",
367    ),
368    f(
369        "core.tools.edit_file.notebook_aware",
370        Kind::Bool,
371        "Edit notebook cells as cells rather than as raw JSON.",
372    ),
373    f(
374        "core.tools.edit_file.schema_tier",
375        Kind::Str,
376        "Per-tool schema-tier override for `edit_file`.",
377    ),
378    f(
379        "core.tools.bash.enabled",
380        Kind::Bool,
381        "Register the `bash` tool.",
382    ),
383    f(
384        "core.tools.bash.description",
385        Kind::Str,
386        "Override the `bash` tool's advertised description.",
387    ),
388    f(
389        "core.tools.bash.schema_tier",
390        Kind::Str,
391        "Per-tool schema-tier override for `bash`.",
392    ),
393    f(
394        "core.tools.bash.timeout_secs",
395        Kind::Int,
396        "Per-command timeout for the `bash` tool.",
397    ),
398    f(
399        "core.skills.enabled",
400        Kind::Bool,
401        "Enable the skills subsystem.",
402    ),
403    f(
404        "core.skills.dirs",
405        Kind::StrArray,
406        "Extra skill roots, merged over the user + project defaults.",
407    ),
408    f(
409        "core.skills.harness",
410        Kind::Str,
411        "Whose documented skill-root table the loop discovers SKILL.md packages from \
412         (`claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`).",
413    ),
414    f(
415        "core.skills.implicit_match",
416        Kind::Bool,
417        "Also load a skill's body when a message merely describes it, not only on an \
418         explicit `$slug` mention or `/name` invocation.",
419    ),
420    f(
421        "core.skills.shell_injection",
422        Kind::Bool,
423        "Execute `` !`cmd` `` inside a skill/command body when the body is loaded, through \
424         the permissions engine. Off leaves the token as literal text.",
425    ),
426    f(
427        "core.prompts",
428        Kind::StrMap,
429        "Named prompt/skill templates, merged key-wise onto the built-ins. [project-forbidden]",
430    ),
431    f(
432        "core.compaction.enabled",
433        Kind::Bool,
434        "Master switch for automatic history compaction.",
435    ),
436    f(
437        "core.compaction.after_messages",
438        Kind::Int,
439        "Compact once the history exceeds this many messages.",
440    ),
441    f(
442        "core.compaction.reserve_tokens",
443        Kind::Int,
444        "Token headroom compaction aims to leave free.",
445    ),
446    f(
447        "core.compaction.keep_recent_tokens",
448        Kind::Int,
449        "Recent-history tokens compaction never touches.",
450    ),
451    f(
452        "core.compaction.summarize",
453        Kind::Bool,
454        "Summarize compacted spans with a side model call instead of dropping them.",
455    ),
456    f(
457        "core.compaction.focus_instructions",
458        Kind::Str,
459        "Instructions steering what a compaction summary keeps. [project-forbidden]",
460    ),
461    f(
462        "core.session.dir",
463        Kind::Str,
464        "Session-store location. [project-forbidden]",
465    ),
466    f(
467        "core.session.name",
468        Kind::Str,
469        "Default session name. [project-forbidden]",
470    ),
471    f(
472        "core.session.persist",
473        Kind::Bool,
474        "Persist sessions; false = ephemeral. [project-forbidden]",
475    ),
476    f(
477        "core.session.retention_days",
478        Kind::Int,
479        "Retention window `sessions prune` enforces. [project-forbidden]",
480    ),
481    f(
482        "core.session.export_format",
483        Kind::Str,
484        "Human transcript export format (`text` | `html`). [project-forbidden]",
485    ),
486    f(
487        "core.session.auto_title",
488        Kind::Bool,
489        "Title a session automatically after the first exchange.",
490    ),
491    f(
492        "core.session.git_metadata",
493        Kind::Bool,
494        "Record git branch/sha with each session write. [project-forbidden]",
495    ),
496    f(
497        "core.session.append_only",
498        Kind::Bool,
499        "Flush every message to the session journal as it is produced. [project-forbidden]",
500    ),
501    f(
502        "core.session.queue_persist",
503        Kind::Bool,
504        "Record pending steering/follow-up inputs in the journal so they survive a restart. [project-forbidden]",
505    ),
506    f(
507        "core.steering.steering_mode",
508        Kind::Str,
509        "How queued steering input is delivered (`all` | `one-at-a-time`).",
510    ),
511    f(
512        "core.steering.follow_up_mode",
513        Kind::Str,
514        "How queued follow-up turns are delivered (`all` | `one-at-a-time`).",
515    ),
516    f(
517        "core.output.format",
518        Kind::Str,
519        "Default output format (`text` | `json`).",
520    ),
521    f(
522        "capabilities",
523        Kind::CapabilityMap,
524        "The §2 capability modules, keyed by module name.",
525    ),
526    f(
527        "experimental",
528        Kind::AnyMap,
529        "Staged feature-flag gates. `supercode features list` shows every flag this build knows and its stage.",
530    ),
531];
532
533/// Generate the published JSON Schema.
534pub fn config_schema() -> Value {
535    let mut root = Map::new();
536    for field in CONFIG_SCHEMA_FIELDS {
537        let mut leaf = field.kind.schema();
538        if let Some(obj) = leaf.as_object_mut() {
539            obj.insert(
540                "description".into(),
541                Value::String(field.description.into()),
542            );
543        }
544        insert_at(&mut root, field.path, leaf);
545    }
546    json!({
547        "$schema": "https://json-schema.org/draft/2020-12/schema",
548        "$id": CONFIG_SCHEMA_URL,
549        "title": "supercode config",
550        "description":
551            "The single supercode config file (COMPOSABLE-HARNESS-DESIGN.md §3.1): \
552             `.supercode.toml`, `.supercode.local.toml`, \
553             `~/.config/supercode/config.toml`, or the JSON mirror. \
554             Keys marked [project-forbidden] are stripped from a project-layer file \
555             (§3.3 monotonic tightening): a repo may narrow the harness, never widen \
556             or redirect it.",
557        "type": "object",
558        "additionalProperties": false,
559        "properties": Value::Object(root),
560    })
561}
562
563/// The schema as it is written to disk (pretty JSON, trailing newline).
564pub fn config_schema_json() -> String {
565    format!(
566        "{}\n",
567        serde_json::to_string_pretty(&config_schema()).expect("schema serializes")
568    )
569}
570
571/// Place `leaf` at the dotted `path`, creating intermediate object schemas
572/// (`type: object`, `additionalProperties: false`) as it goes.
573fn insert_at(root: &mut Map<String, Value>, path: &str, leaf: Value) {
574    let parts: Vec<&str> = path.split('.').collect();
575    let (last, parents) = parts.split_last().expect("non-empty path");
576    let mut cursor = root;
577    for part in parents {
578        let entry = cursor.entry((*part).to_string()).or_insert_with(
579            || json!({ "type": "object", "additionalProperties": false, "properties": {} }),
580        );
581        cursor = entry
582            .as_object_mut()
583            .expect("intermediate schema node is an object")
584            .entry("properties".to_string())
585            .or_insert_with(|| Value::Object(Map::new()))
586            .as_object_mut()
587            .expect("properties is an object");
588    }
589    cursor.insert((*last).to_string(), leaf);
590}
591
592/// Every dotted key path serde emits for a default [`HarnessConfig`] — the
593/// parser's own view of what the document contains. An empty object is a
594/// free-form table (`capabilities`, `experimental`, `core.prompts`) and
595/// therefore a leaf.
596pub fn parsed_key_paths() -> Vec<String> {
597    let value = serde_json::to_value(HarnessConfig::default()).expect("default config serializes");
598    let mut out = Vec::new();
599    collect_paths("", &value, &mut out);
600    out.sort();
601    out
602}
603
604fn collect_paths(prefix: &str, value: &Value, out: &mut Vec<String>) {
605    match value {
606        Value::Object(map) if !map.is_empty() => {
607            for (k, v) in map {
608                let path = if prefix.is_empty() {
609                    k.clone()
610                } else {
611                    format!("{prefix}.{k}")
612                };
613                collect_paths(&path, v, out);
614            }
615        }
616        _ if !prefix.is_empty() => out.push(prefix.to_string()),
617        _ => {}
618    }
619}