Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,12 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());

// Install any user prompt overrides from the config directory before an
// engine can compose a system prompt. The override cells are
// first-call-wins; doing this once here keeps every downstream turn
// consistent. Missing files are a no-op (bundled defaults). See #3638.
crate::prompts::load_prompt_overrides_from_config_home();

// Handle subcommands first
if let Some(command) = cli.command.clone() {
return match command {
Expand Down
183 changes: 183 additions & 0 deletions crates/tui/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,119 @@ pub fn set_static_prompt_composer_override(
set_static_prompt_composer(&STATIC_PROMPT_COMPOSER, f)
}

// ── Config-directory prompt overrides (issue #3638) ──
// Bridge the embedder override hooks above to a user-facing source: an
// optional file in the CodeWhale config directory. This lets users repurpose
// the TUI for non-software use cases (e.g. long-form writing) by swapping the
// constitutional base prompt, without editing in-tree files or shipping a
// custom embedder build.
//
// Scope is deliberately narrow: only the byte-stable base prompt segment is
// user-overridable. Mode deltas, approval policy, tool taxonomy, Context
// Management, and the Compaction Relay stay owned by the runtime assembly (see
// `StaticPromptCtx`), so an override cannot strip safety-relevant guidance.
// A missing or empty file is a no-op — the bundled constant is used — so this
// is fully backward compatible.
//
// Because replacing the base prompt is a trust-boundary action (per maintainer
// review on #3638), the override file alone is NOT sufficient: the user must
// also set an explicit opt-in flag (`CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE`).
// This keeps replacing the global Constitution a deliberate, auditable act
// rather than something a stray file can do.

/// Relative path, under the config directory, of the optional base-prompt
/// (constitution) override file.
pub const CONSTITUTION_OVERRIDE_FILE: &str = "prompts/constitution.md";

/// Env flag that must be set (`1`/`true`/`on`/`yes`) to enable config-dir base
/// prompt overrides. Required in addition to the override file so the global
/// base prompt can never be replaced by file presence alone.
pub const BASE_PROMPT_OVERRIDE_OPT_IN_ENV: &str = "CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE";

/// Whether the user has explicitly opted in to base-prompt overrides.
fn base_prompt_override_opt_in() -> bool {
match std::env::var(BASE_PROMPT_OVERRIDE_OPT_IN_ENV) {
Ok(v) => matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes"
),
Err(_) => false,
}
}

/// Read an optional prompt-override file rooted at `config_dir`.
///
/// Returns the file contents when it exists and is non-empty after trimming;
/// otherwise `None` so the caller falls back to the embedded default. Pure
/// over `config_dir`, so it is unit-testable without touching the global
/// override cells.
fn read_prompt_override_file(config_dir: &Path, relative: &str) -> Option<String> {
let path = config_dir.join(relative);
let raw = std::fs::read_to_string(&path).ok()?;
if raw.trim().is_empty() {
tracing::warn!(
target: "prompts",
"ignoring empty prompt override file {}",
path.display(),
);
return None;
}
tracing::info!(
target: "prompts",
"loaded prompt override from {}",
path.display(),
);
Some(raw)
}

/// Load user prompt overrides from `config_dir` and install them through the
/// existing override hooks. Returns the names of the overrides that were
/// applied (for logging/diagnostics).
///
/// Call once at startup, before any engine spawns, because the underlying
/// override cells are first-call-wins. Missing files are a no-op, preserving
/// the bundled defaults.
pub fn load_config_dir_prompt_overrides(config_dir: &Path) -> Vec<&'static str> {
let mut applied = Vec::new();
if let Some(text) = read_prompt_override_file(config_dir, CONSTITUTION_OVERRIDE_FILE) {
if !base_prompt_override_opt_in() {
// A file exists but the user hasn't opted in. Don't silently
// replace the base prompt — surface the gate instead.
tracing::warn!(
target: "prompts",
"found a base-prompt override at {}/{} but {} is not set; \
leaving the bundled Constitution in place. Set {}=1 to opt in.",
config_dir.display(),
CONSTITUTION_OVERRIDE_FILE,
BASE_PROMPT_OVERRIDE_OPT_IN_ENV,
BASE_PROMPT_OVERRIDE_OPT_IN_ENV,
);
} else if set_base_prompt_override(text).is_ok() {
applied.push("constitution");
}
}
applied
}

/// Resolve the CodeWhale config directory and load any prompt overrides found
/// there. Convenience wrapper around [`load_config_dir_prompt_overrides`] for
/// startup wiring; silently does nothing when the config home cannot be
/// resolved.
pub fn load_prompt_overrides_from_config_home() {
let Ok(home) = codewhale_config::codewhale_home() else {
return;
};
let applied = load_config_dir_prompt_overrides(&home);
if !applied.is_empty() {
tracing::info!(
target: "prompts",
"applied {} config-directory prompt override(s): {}",
applied.len(),
applied.join(", "),
);
}
}

fn set_prompt_override(cell: &std::sync::OnceLock<String>, s: String) -> Result<(), String> {
cell.set(s)
}
Expand Down Expand Up @@ -1285,6 +1398,76 @@ mod tests {
/// agent prompt's own discussion of the convention).
const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.codewhale/handoff.md`";

// Config-directory prompt override resolution (#3638). These exercise the
// pure file resolver only; the global install path is intentionally not
// unit-tested here because `set_base_prompt_override` writes a process-wide
// `OnceLock` that would leak into sibling tests (same reason
// `prompt_override_storage_reports_duplicate_sets` uses a local cell).

#[test]
fn config_override_reads_present_nonempty_file() {
let tmp = tempdir().expect("tempdir");
let prompts_dir = tmp.path().join("prompts");
std::fs::create_dir_all(&prompts_dir).expect("mkdir");
std::fs::write(
prompts_dir.join("constitution.md"),
"You are a long-form writing companion.\n",
)
.expect("write override");

let got = read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE);
assert_eq!(
got.as_deref(),
Some("You are a long-form writing companion.\n")
);
}

#[test]
fn config_override_absent_file_falls_back() {
let tmp = tempdir().expect("tempdir");
// No prompts/ directory at all → None so the embedded constant is used.
assert!(read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE).is_none());
}

#[test]
fn config_override_requires_explicit_opt_in() {
// A present, non-empty override file must NOT replace the base prompt
// unless the explicit opt-in flag is set. When the flag is unset
// `load_config_dir_prompt_overrides` applies nothing (and never touches
// the global override cell), so this assertion is safe to run in the
// shared test binary.
let tmp = tempdir().expect("tempdir");
let prompts_dir = tmp.path().join("prompts");
std::fs::create_dir_all(&prompts_dir).expect("mkdir");
std::fs::write(
prompts_dir.join("constitution.md"),
"You are a long-form writing companion.\n",
)
.expect("write override");

// The resolver still finds the file...
assert!(read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE).is_some());
// ...but without the opt-in flag, nothing is applied.
if std::env::var(BASE_PROMPT_OVERRIDE_OPT_IN_ENV).is_err() {
assert!(
load_config_dir_prompt_overrides(tmp.path()).is_empty(),
"override must require the explicit opt-in flag, not just a file"
);
}
}

#[test]
fn config_override_empty_file_is_ignored() {
let tmp = tempdir().expect("tempdir");
let prompts_dir = tmp.path().join("prompts");
std::fs::create_dir_all(&prompts_dir).expect("mkdir");
std::fs::write(prompts_dir.join("constitution.md"), " \n\t\n").expect("write blank");

// Whitespace-only overrides are treated as absent so a stray empty file
// can't silently blank the system prompt.
assert!(read_prompt_override_file(tmp.path(), CONSTITUTION_OVERRIDE_FILE).is_none());
}

#[test]
fn prompt_override_storage_reports_duplicate_sets() {
let cell = std::sync::OnceLock::new();
Expand Down
26 changes: 26 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,32 @@ Each repo can carry two distinct, complementary files:
> global CodeWhale Constitution shipped in the model prompt is a separate thing
> and is unaffected.)

### Overriding the global base prompt (#3638)

The global Constitution (the base system prompt, normally compiled in from
`prompts/constitution.md`) can be replaced per-user without rebuilding. Because
this is a prompt trust boundary, it takes **two deliberate steps** — a file
alone is not enough:

1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under
`$CODEWHALE_HOME` when set).
2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`
(`true`/`on`/`yes` also accepted).

If the file exists but the flag is unset, the override is **ignored** (with a
log line pointing to the flag) and the bundled Constitution stays in place.
This is intended for repurposing the TUI beyond software engineering — e.g.
long-form writing or document review — where the engineering-oriented base
prompt is a poor fit. It is loaded once at startup; a **missing or empty file
is a no-op**, so existing installs keep the bundled prompt.

Scope is deliberately narrow: only the byte-stable **base prompt segment** is
overridable. Mode deltas, the approval policy, the tool taxonomy, Context
Management, and the Compaction Relay are still owned by CodeWhale's runtime
assembly, so an override **cannot remove safety-relevant guidance** (sandbox,
approvals) — it only swaps the task/voice framing. To customize per-repo
behavior instead, prefer `AGENTS.md` + `.codewhale/constitution.json` above.

## Where It Looks

Default config path:
Expand Down
Loading