Skip to content
Open
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: 3 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> # 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 <name> # remove a specific override file
boi overrides clear --all # remove all override files
```

### Iteration breakdown
Expand Down
1 change: 1 addition & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions src/cli/overrides_cmd.rs
Original file line number Diff line number Diff line change
@@ -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" });
}
35 changes: 35 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -147,6 +148,11 @@ enum Commands {
#[arg(long)]
full: bool,
},
/// Manage phase override files in ~/.boi/phases/
Overrides {
#[command(subcommand)]
action: Option<OverridesAction>,
},
/// Manage and inspect runtime providers
Providers {
#[command(subcommand)]
Expand Down Expand Up @@ -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<String>,
/// Remove all override files
#[arg(long)]
all: bool,
},
}

#[derive(Subcommand)]
enum DaemonAction {
/// Start the daemon in the background
Expand Down Expand Up @@ -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(),
Expand Down
20 changes: 16 additions & 4 deletions src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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).
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions tests/test_phase_override_startup_warning.rs
Original file line number Diff line number Diff line change
@@ -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(&registry);
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);
}
3 changes: 3 additions & 0 deletions tests/test_task_phases_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
Loading