From da982c094fcde22c62eb8986e11f544afafc727c Mon Sep 17 00:00:00 2001 From: findshan <224246733+findshan@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:11:54 +0800 Subject: [PATCH 1/2] feat(prompts): allow overriding the base prompt from the config dir Issue #3638 asks to make the hard-loaded base prompt repurposable (e.g. for long-form writing) without editing in-tree files or building a custom embedder. The prompt-override hooks already exist for embedders (set_base_prompt_override + the OnceLock cells), but there was no user-facing source that feeds them. Bridge those hooks to a config-directory file: at startup, if `~/.codewhale/prompts/constitution.md` (under $CODEWHALE_HOME) exists and is non-empty, install it via the existing set_base_prompt_override path; otherwise fall back to the bundled constant. Loaded once before any engine spawns (first-call-wins cells). Scope is deliberately narrow and safe: 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 (sandbox/approvals). - prompts.rs: pure, unit-tested resolver read_prompt_override_file + load_config_dir_prompt_overrides / load_prompt_overrides_from_config_home. - main.rs: wire the loader in once after CLI parse, before subcommand dispatch. - docs/CONFIGURATION.md: document the override file, its scope, and that it cannot remove safety layers. - 3 unit tests (present/absent/empty-file). Empty/whitespace files are ignored so a stray file can't blank the system prompt. Refs #3638 Signed-off-by: findshan <224246733+findshan@users.noreply.github.com> --- crates/tui/src/main.rs | 6 ++ crates/tui/src/prompts.rs | 122 ++++++++++++++++++++++++++++++++++++++ docs/CONFIGURATION.md | 23 +++++++ 3 files changed, 151 insertions(+) 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..6df07667c 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -432,6 +432,85 @@ 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. + +/// Relative path, under the config directory, of the optional base-prompt +/// (constitution) override file. +pub const CONSTITUTION_OVERRIDE_FILE: &str = "prompts/constitution.md"; + +/// 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) + && 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 +1364,49 @@ 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_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..4378515a9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -63,6 +63,29 @@ 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, by +dropping a file at: + +``` +~/.codewhale/prompts/constitution.md +``` + +(under `$CODEWHALE_HOME` when set). 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: From 59259eb4dbe4805c47cf13b03fef4b93b90592e2 Mon Sep 17 00:00:00 2001 From: findshan <224246733+findshan@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:08:39 +0800 Subject: [PATCH 2/2] feat(prompts): require explicit opt-in flag for base-prompt override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses maintainer review on #3638: replacing the global Constitution is a prompt trust boundary, so the override file alone must not be enough. Gate it behind an explicit CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE flag — the file is ignored (with a log line pointing to the flag) unless the user has deliberately opted in. Makes replacing the base prompt a two-step, auditable action. - prompts.rs: BASE_PROMPT_OVERRIDE_OPT_IN_ENV + base_prompt_override_opt_in(); load_config_dir_prompt_overrides now requires it. Added a test that a present file without the flag applies nothing (safe: no global cell mutation). - docs/CONFIGURATION.md: document the two-step opt-in. Refs #3638 Signed-off-by: findshan <224246733+findshan@users.noreply.github.com> --- crates/tui/src/prompts.rs | 69 ++++++++++++++++++++++++++++++++++++--- docs/CONFIGURATION.md | 27 ++++++++------- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 6df07667c..b323f36de 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -445,11 +445,33 @@ pub fn set_static_prompt_composer_override( // `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; @@ -484,10 +506,22 @@ fn read_prompt_override_file(config_dir: &Path, relative: &str) -> Option Vec<&'static str> { let mut applied = Vec::new(); - if let Some(text) = read_prompt_override_file(config_dir, CONSTITUTION_OVERRIDE_FILE) - && set_base_prompt_override(text).is_ok() - { - applied.push("constitution"); + 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 } @@ -1395,6 +1429,33 @@ mod tests { 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"); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4378515a9..a38204cec 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -66,18 +66,21 @@ Each repo can carry two distinct, complementary files: ### 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, by -dropping a file at: - -``` -~/.codewhale/prompts/constitution.md -``` - -(under `$CODEWHALE_HOME` when set). 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. +`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