Skip to main content

supercode_harness/
output_style.rs

1//! BP-5 (catalog D2 "Output style / personality module": *swappable
2//! response-style instruction layer*; cc§7 "Output styles"; cx§2
3//! "Personality layer") — the named response-style layer.
4//!
5//! **This is a prompt-assembly INPUT, not a module.** It has no state, no
6//! tools, no capability table and nothing in the loop consults it after
7//! construction: `[core.output_style]` names a style, this file resolves the
8//! name to text, and `Agent::with_parts` appends one section to the system
9//! prompt. That is the whole mechanism, and it is exactly what both
10//! harnesses do — Claude Code's output styles "append custom instructions to
11//! the end of the system prompt" (`docs:output-styles#how-output-styles-work`)
12//! and Codex's personality is one more spliced instruction block
13//! (`codex-rs/core/src/context/personality_spec_instructions.rs`).
14//!
15//! **Two resolution sources, in this order.**
16//! 1. A markdown file under the style roots of the harness `[core.skills]
17//!    harness` names — for Claude Code, `<CLAUDE_CONFIG_DIR>/output-styles/`
18//!    then `<cwd>/.claude/output-styles/` (`docs:output-styles`). A user's
19//!    own file WINS over a built-in of the same name, which is the only
20//!    ordering that lets someone replace a shipped style.
21//! 2. A [`BUILTIN_STYLES`] entry.
22//!
23//! **The built-in texts are supercode's own.** Each is a short transcription
24//! of what the named style is DOCUMENTED to do, written here; none is a copy
25//! of a harness's shipped prompt. `default` (cc) and `none` (cx) are the
26//! neutral selections both harnesses ship with — they resolve, and
27//! contribute no text, which is precisely their documented behavior.
28//!
29//! **`keep-coding-instructions`.** A custom cc style may DROP the built-in
30//! software-engineering instructions unless its frontmatter says
31//! `keep-coding-instructions: true` (`docs:output-styles`). That is the one
32//! place a style does more than append: [`Resolved::replaces_base`] reports
33//! it, and prompt assembly then uses the style text as the base prompt
34//! instead of appending it.
35
36use std::path::{Path, PathBuf};
37
38use crate::config::Config;
39
40/// The named styles this build ships, `name → instruction text`.
41///
42/// `default` (Claude Code's own default selection) and `none` (Codex's)
43/// carry empty text on purpose: selecting them is a real, resolvable
44/// selection that adds nothing to the prompt.
45pub const BUILTIN_STYLES: &[(&str, &str)] = &[
46    ("default", ""),
47    ("none", ""),
48    (
49        "proactive",
50        "Work ahead of the request: when a task obviously implies adjacent work \
51         (a test for the code you just wrote, the caller you just broke), do it in \
52         the same turn and say you did. Never stop to ask whether to continue.",
53    ),
54    (
55        "explanatory",
56        "Explain as you go. Before a non-obvious change, state in one or two \
57         sentences what you are about to do and why that approach over the \
58         alternative. After it, name what the reader should look at to check it. \
59         Keep the explanations inline with the work, never as a separate essay.",
60    ),
61    (
62        "learning",
63        "Teach while you work. Prefer showing the reasoning over showing only the \
64         result: name the concept behind each decision, and leave the smallest \
65         well-marked piece of the work for the user to finish themselves, marked \
66         `TODO(human)` with the context they need to do it.",
67    ),
68    (
69        "friendly",
70        "Write like a warm, plain-spoken colleague: short sentences, no jargon \
71         where a common word will do, and a sentence of context before anything \
72         surprising. Never perform enthusiasm you do not have.",
73    ),
74    (
75        "pragmatic",
76        "Answer directly and stop. Lead with the result, then only the detail \
77         needed to act on it. No preamble, no summary of what you are about to \
78         say, no restating the request.",
79    ),
80];
81
82/// A resolved output style.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Resolved {
85    /// The name as the config asked for it.
86    pub name: String,
87    /// The instruction text (possibly empty, for a neutral built-in).
88    pub text: String,
89    /// The markdown file it came from, when it came from disk.
90    pub source: Option<PathBuf>,
91    /// Whether this style REPLACES the base coding instructions rather than
92    /// appending to them (cc: a custom style without
93    /// `keep-coding-instructions: true`).
94    pub replaces_base: bool,
95}
96
97impl Resolved {
98    /// The prompt section this style contributes — empty for a neutral
99    /// style, so an agent that selects `default`/`none` keeps exactly the
100    /// prompt it had before this existed.
101    pub fn section(&self) -> String {
102        if self.text.trim().is_empty() {
103            return String::new();
104        }
105        format!("\n\n# Output style: {}\n{}", self.name, self.text.trim())
106    }
107}
108
109/// Resolve `[core.output_style]` for `config`, or `None` when it names
110/// nothing (the default) or nothing by that name exists.
111///
112/// An unknown name resolves to `None` rather than an error: a style is a
113/// presentation layer, and a typo in it must never stop a session from
114/// starting.
115pub fn resolve(config: &Config) -> Option<Resolved> {
116    let name = config.output_style.trim();
117    if name.is_empty() {
118        return None;
119    }
120    if let Some(found) = style_roots(config)
121        .into_iter()
122        .find_map(|root| read_style_file(&root, name))
123    {
124        return Some(found);
125    }
126    BUILTIN_STYLES
127        .iter()
128        .find(|(builtin, _)| builtin.eq_ignore_ascii_case(name))
129        .map(|(builtin, text)| Resolved {
130            name: (*builtin).to_string(),
131            text: (*text).to_string(),
132            source: None,
133            replaces_base: false,
134        })
135}
136
137/// The directories a style file is looked for in, most specific LAST is not
138/// the rule here — cc's own precedence is personal before project, the same
139/// order its skill roots use, so the first hit wins.
140///
141/// Only Claude Code publishes a file-based style root; Codex's personality
142/// is a config value with no on-disk form (cx§6 `personality`), so a
143/// `codex` config resolves built-ins only.
144fn style_roots(config: &Config) -> Vec<PathBuf> {
145    if config.skills_harness.as_deref() != Some(crate::HarnessId::CLAUDE_CODE) {
146        return Vec::new();
147    }
148    let homes = crate::skills::SkillHomes::default();
149    vec![
150        homes.claude_code.join("output-styles"),
151        config.cwd.join(".claude").join("output-styles"),
152    ]
153}
154
155/// Read `<root>/<name>.md` as a style, if it is there.
156fn read_style_file(root: &Path, name: &str) -> Option<Resolved> {
157    let path = root.join(format!("{name}.md"));
158    let text = std::fs::read_to_string(&path).ok()?;
159    let front = crate::skills::read_frontmatter(&path);
160    let keep = front
161        .get("keep-coding-instructions")
162        .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "yes" | "1"))
163        .unwrap_or(false);
164    Some(Resolved {
165        name: front
166            .get("name")
167            .cloned()
168            .unwrap_or_else(|| name.to_string()),
169        text: crate::skills::strip_frontmatter(&text),
170        source: Some(path),
171        replaces_base: !keep,
172    })
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn nothing_selected_resolves_to_nothing() {
181        let config = Config::builder().build();
182        assert!(resolve(&config).is_none());
183    }
184
185    #[test]
186    fn a_neutral_builtin_resolves_and_contributes_no_text() {
187        let config = Config::builder().output_style("default").build();
188        let style = resolve(&config).expect("`default` is a built-in style");
189        assert!(style.section().is_empty());
190        assert!(!style.replaces_base);
191    }
192
193    #[test]
194    fn a_named_builtin_contributes_a_section() {
195        let config = Config::builder().output_style("explanatory").build();
196        let style = resolve(&config).expect("`explanatory` is a built-in style");
197        assert!(style
198            .section()
199            .starts_with("\n\n# Output style: explanatory\n"));
200        assert!(style.section().contains("Explain as you go"));
201    }
202
203    #[test]
204    fn an_unknown_name_never_fails_a_session() {
205        let config = Config::builder().output_style("no-such-style").build();
206        assert!(resolve(&config).is_none());
207    }
208}