diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index b5083247f..f917b20a0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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 { diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 22224c29e..b323f36de 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -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 { + 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, s: String) -> Result<(), String> { cell.set(s) } @@ -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(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3778ae860..a38204cec 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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: