diff --git a/docs/getting-started.md b/docs/getting-started.md index 8946307..76bc5ac 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -164,9 +164,9 @@ boi phases runs SA7F3 --full # every field for every invocation ### Phase overrides ```bash -boi phases list # list override files in ~/.boi/phases/ -boi phases clear # remove a specific override file -boi phases clear --all # remove all override files +boi overrides list # list override files in ~/.boi/phases/ +boi overrides clear # remove a specific override file +boi overrides clear --all # remove all override files ``` ### Iteration breakdown diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 88c1769..ab5a935 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod dispatch; pub mod doctor; pub mod log; pub mod outputs; +pub mod overrides_cmd; pub mod phases_cmd; pub mod prune; pub mod providers; diff --git a/src/cli/overrides_cmd.rs b/src/cli/overrides_cmd.rs new file mode 100644 index 0000000..80a2154 --- /dev/null +++ b/src/cli/overrides_cmd.rs @@ -0,0 +1,64 @@ +use crate::phases::user_phases_dir; + +pub fn cmd_overrides_list() { + let dir = user_phases_dir(); + if !dir.is_dir() { + println!("No phase overrides active."); + return; + } + let entries: Vec<_> = match std::fs::read_dir(&dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .collect(), + Err(_) => { + println!("No phase overrides active."); + return; + } + }; + if entries.is_empty() { + println!("No phase overrides active."); + } else { + for entry in entries { + println!("{}", entry.file_name().to_string_lossy()); + } + } +} + +pub fn cmd_overrides_clear(name: &str) { + let path = user_phases_dir().join(name); + if !path.exists() { + eprintln!("error: override '{}' not found.", name); + std::process::exit(1); + } + if let Err(e) = std::fs::remove_file(&path) { + eprintln!("error: failed to remove '{}': {}", name, e); + std::process::exit(1); + } + println!("Removed {}.", name); +} + +pub fn cmd_overrides_clear_all() { + let dir = user_phases_dir(); + if !dir.is_dir() { + println!("Removed 0 overrides."); + return; + } + let entries: Vec<_> = match std::fs::read_dir(&dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .collect(), + Err(e) => { + eprintln!("error: could not read overrides directory: {}", e); + std::process::exit(1); + } + }; + let count = entries.len(); + for entry in entries { + if let Err(e) = std::fs::remove_file(entry.path()) { + eprintln!("warning: failed to remove {}: {}", entry.file_name().to_string_lossy(), e); + } + } + println!("Removed {} override{}.", count, if count == 1 { "" } else { "s" }); +} diff --git a/src/main.rs b/src/main.rs index ff6de87..fb30f2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use boi::cli::dispatch::cmd_dispatch; use boi::cli::doctor::cmd_doctor; use boi::cli::log::cmd_log; use boi::cli::outputs::cmd_outputs; +use boi::cli::overrides_cmd::{cmd_overrides_clear, cmd_overrides_clear_all, cmd_overrides_list}; use boi::cli::phases_cmd::{cmd_phase_runs, cmd_phases_list, cmd_phases_show}; use boi::cli::prune::{cmd_prune_orphans, PruneConfig}; use boi::cli::providers::cmd_providers_list; @@ -147,6 +148,11 @@ enum Commands { #[arg(long)] full: bool, }, + /// Manage phase override files in ~/.boi/phases/ + Overrides { + #[command(subcommand)] + action: Option, + }, /// Manage and inspect runtime providers Providers { #[command(subcommand)] @@ -227,6 +233,20 @@ enum ProvidersAction { List, } +#[derive(Subcommand)] +enum OverridesAction { + /// List active phase override files + List, + /// Remove one or all phase override files + Clear { + /// Name of the override file to remove + name: Option, + /// Remove all override files + #[arg(long)] + all: bool, + }, +} + #[derive(Subcommand)] enum DaemonAction { /// Start the daemon in the background @@ -384,6 +404,21 @@ fn main() { } } } + Commands::Overrides { action } => { + match action.unwrap_or(OverridesAction::List) { + OverridesAction::List => cmd_overrides_list(), + OverridesAction::Clear { name, all } => { + if all { + cmd_overrides_clear_all(); + } else if let Some(n) = name { + cmd_overrides_clear(&n); + } else { + eprintln!("error: specify a name or --all"); + std::process::exit(1); + } + } + } + } Commands::Providers { action } => { match action.unwrap_or(ProvidersAction::List) { ProvidersAction::List => cmd_providers_list(), diff --git a/src/worker.rs b/src/worker.rs index 5d88dcf..353347d 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -222,6 +222,20 @@ pub fn effective_timeout(phase: &phases::PhaseConfig, config_timeout_secs: u64) .unwrap_or(config_timeout_secs) } +/// Returns the startup phase-override warning message if any user overrides are active. +/// Extracted for testability — the caller logs it via boi_log!. +pub fn phase_override_warning_message(registry: &phases::PhaseRegistry) -> Option { + let user_phase_names = registry.user_names(); + if user_phase_names.is_empty() { + return None; + } + let names_list = user_phase_names.join(", "); + Some(format!( + "[WARN] Phase overrides active: {}. Source repo edits to these phases will NOT take effect until overrides are cleared.", + names_list + )) +} + #[allow(clippy::too_many_arguments)] /// EXP-010: deterministic arm assignment for spec-critique A/B test. /// Uses SHA-256 of spec_id; last bit gives arm (0=A/CLI, 1=B/API). @@ -371,10 +385,8 @@ pub fn run_worker_with_phases( })); // Warn if user phase overrides are active — edits to the source repo won't take effect. - let user_phase_names = registry.user_names(); - if !user_phase_names.is_empty() { - let names_list = user_phase_names.join(", "); - boi_log!("[WARN] Phase overrides active: {}. Source repo edits to these phases will NOT take effect until overrides are cleared.", names_list); + if let Some(msg) = phase_override_warning_message(registry) { + boi_log!("{}", msg); } // Load spec and task data from DB — YAML file is not read at runtime. diff --git a/tests/test_phase_override_startup_warning.rs b/tests/test_phase_override_startup_warning.rs new file mode 100644 index 0000000..9e1c472 --- /dev/null +++ b/tests/test_phase_override_startup_warning.rs @@ -0,0 +1,68 @@ +use boi::phases::PhaseRegistry; +use boi::worker::phase_override_warning_message; +use std::fs; +use std::sync::atomic::{AtomicU64, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +fn tmp_dir(label: &str) -> std::path::PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir() + .join(format!("boi-warn-test-{}-{}-{}", label, std::process::id(), n)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create tmp dir"); + dir +} + +const FAKE_USER_PHASE: &str = r#" +name = "execute" + +[phase] +level = "task" +can_add_tasks = false +can_fail_spec = false +requires_claude = true + +[worker] +runtime = "claude" +model = "claude-sonnet-4-6" +"#; + +#[test] +fn test_phase_override_startup_warning() { + let core_dir = tmp_dir("core"); + let user_dir = tmp_dir("user"); + + // Write a fake user override for the "execute" phase + fs::write(user_dir.join("execute.phase.toml"), FAKE_USER_PHASE) + .expect("write user phase file"); + + let mut registry = PhaseRegistry::from_dir(&core_dir); + registry.load_user_phases(&user_dir); + + let msg = phase_override_warning_message(®istry); + assert!(msg.is_some(), "expected a warning message when user overrides are active"); + + let msg = msg.unwrap(); + assert!( + msg.contains("[WARN] Phase overrides active:"), + "message should contain '[WARN] Phase overrides active:', got: {msg}" + ); + assert!( + msg.contains("execute"), + "message should list the overridden phase name, got: {msg}" + ); + + // No warning when no user overrides + let empty_dir = tmp_dir("empty"); + let mut clean_registry = PhaseRegistry::from_dir(&core_dir); + clean_registry.load_user_phases(&empty_dir); + assert!( + phase_override_warning_message(&clean_registry).is_none(), + "expected no warning when no user overrides are active" + ); + + let _ = fs::remove_dir_all(&core_dir); + let _ = fs::remove_dir_all(&user_dir); + let _ = fs::remove_dir_all(&empty_dir); +} diff --git a/tests/test_task_phases_persistence.rs b/tests/test_task_phases_persistence.rs index 1f26804..62970b5 100644 --- a/tests/test_task_phases_persistence.rs +++ b/tests/test_task_phases_persistence.rs @@ -11,6 +11,7 @@ fn make_spec_with_phases() -> BoiSpec { title: "phases-persistence-test".to_string(), mode: Some("execute".to_string()), workspace: None, + workspace_rationale: Some("test — no repo needed".to_string()), initiative: None, context: None, outcomes: None, @@ -19,6 +20,8 @@ fn make_spec_with_phases() -> BoiSpec { context_files: None, phase_overrides: std::collections::HashMap::new(), worker_pool: None, + max_cost_usd: None, + key_artifacts: None, tasks: vec![BoiTask { id: "t-1".to_string(), title: "dummy task".to_string(),