From d065009aba981bb5d44621de9ff1c2cd7ec592e7 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Thu, 5 Mar 2026 19:36:45 +0700 Subject: [PATCH] stash --- src/ansi.rs | 11 + src/guard.rs | 2 - src/heartbeat.rs | 148 ------- src/main.rs | 788 +++++++++---------------------------- src/registry.rs | 86 ---- src/scratch/config.json | 21 - src/scratch/demo.rs | 44 --- src/scratch/extra.toml | 11 - src/scratch/notes.md | 17 - src/scratch/setup.sh | 24 -- src/stash.rs | 346 ++++++++++++++++ src/stream.rs | 18 +- src/stream_opencode.rs | 15 +- tests/judge_adversarial.rs | 777 ++++++++++++++++++++++++++++++++++++ 14 files changed, 1321 insertions(+), 987 deletions(-) create mode 100644 src/ansi.rs delete mode 100644 src/heartbeat.rs delete mode 100644 src/registry.rs delete mode 100644 src/scratch/config.json delete mode 100644 src/scratch/demo.rs delete mode 100644 src/scratch/extra.toml delete mode 100644 src/scratch/notes.md delete mode 100644 src/scratch/setup.sh create mode 100644 src/stash.rs create mode 100644 tests/judge_adversarial.rs diff --git a/src/ansi.rs b/src/ansi.rs new file mode 100644 index 0000000..098e54d --- /dev/null +++ b/src/ansi.rs @@ -0,0 +1,11 @@ +pub const RESET: &str = "\x1b[0m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const GREEN: &str = "\x1b[38;5;46m"; +pub const ORANGE: &str = "\x1b[38;5;208m"; +pub const BLUE: &str = "\x1b[38;5;75m"; +pub const CYAN: &str = "\x1b[38;5;80m"; +pub const YELLOW: &str = "\x1b[38;5;222m"; +pub const MAGENTA: &str = "\x1b[38;5;183m"; +pub const RED: &str = "\x1b[38;5;196m"; +pub const GRAY: &str = "\x1b[38;5;245m"; diff --git a/src/guard.rs b/src/guard.rs index 4efbfe4..9afac8e 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -14,7 +14,6 @@ use std::time::Instant; pub struct GuardResult { pub name: String, pub passed: bool, - pub output: String, pub skipped: bool, pub elapsed_secs: f64, } @@ -115,7 +114,6 @@ pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Ve results.push(GuardResult { name, passed, - output: truncated, skipped, elapsed_secs, }); diff --git a/src/heartbeat.rs b/src/heartbeat.rs deleted file mode 100644 index 0717def..0000000 --- a/src/heartbeat.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Pulse Heartbeat — background tick that fires every 500ms and emits -//! structured events to registered listeners. -//! -//! The heartbeat is stoppable and restartable without losing listener -//! subscriptions. Listeners receive a `HeartbeatEvent` on each tick. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::{Duration, Instant}; - -/// Structured event emitted on each heartbeat tick. -#[derive(Debug, Clone)] -pub struct HeartbeatEvent { - /// Monotonically increasing tick number (starts at 1). - pub tick: u64, - /// Wall-clock instant when this tick fired. - pub timestamp: Instant, - /// Elapsed time since the heartbeat was (re)started. - pub elapsed: Duration, -} - -/// Callback type for heartbeat listeners. -pub type Listener = Box; - -/// A named listener entry so subscriptions survive stop/start cycles. -struct ListenerEntry { - name: String, - callback: Listener, -} - -/// Background heartbeat that ticks at a fixed interval and notifies listeners. -/// -/// Listeners are registered once and persist across stop/start cycles. -/// The heartbeat thread is spawned on `start()` and joined on `stop()`. -pub struct Heartbeat { - interval: Duration, - running: Arc, - listeners: Arc>>, - handle: Option>, - tick_count: Arc>, -} - -impl Heartbeat { - /// Create a new heartbeat with the given tick interval. - pub fn new(interval: Duration) -> Self { - Self { - interval, - running: Arc::new(AtomicBool::new(false)), - listeners: Arc::new(Mutex::new(Vec::new())), - handle: None, - tick_count: Arc::new(Mutex::new(0)), - } - } - - /// Create a heartbeat with the default 500ms interval. - pub fn default_pulse() -> Self { - Self::new(Duration::from_millis(500)) - } - - /// Register a named listener. The listener persists across stop/start cycles. - pub fn on_tick(&self, name: impl Into, callback: impl Fn(&HeartbeatEvent) + Send + 'static) { - let mut listeners = self.listeners.lock().unwrap(); - let name = name.into(); - // Replace existing listener with the same name - listeners.retain(|e| e.name != name); - listeners.push(ListenerEntry { - name, - callback: Box::new(callback), - }); - } - - /// Remove a listener by name. - pub fn remove_listener(&self, name: &str) { - let mut listeners = self.listeners.lock().unwrap(); - listeners.retain(|e| e.name != name); - } - - /// Start the heartbeat. If already running, this is a no-op. - pub fn start(&mut self) { - if self.running.load(Ordering::SeqCst) { - return; - } - - self.running.store(true, Ordering::SeqCst); - - let running = Arc::clone(&self.running); - let listeners = Arc::clone(&self.listeners); - let tick_count = Arc::clone(&self.tick_count); - let interval = self.interval; - - let handle = thread::spawn(move || { - let start_time = Instant::now(); - - while running.load(Ordering::SeqCst) { - thread::sleep(interval); - - if !running.load(Ordering::SeqCst) { - break; - } - - let tick = { - let mut count = tick_count.lock().unwrap(); - *count += 1; - *count - }; - - let event = HeartbeatEvent { - tick, - timestamp: Instant::now(), - elapsed: start_time.elapsed(), - }; - - let listeners = listeners.lock().unwrap(); - for entry in listeners.iter() { - (entry.callback)(&event); - } - } - }); - - self.handle = Some(handle); - } - - /// Stop the heartbeat and join the background thread. - /// Listener subscriptions are preserved for a subsequent `start()`. - pub fn stop(&mut self) { - self.running.store(false, Ordering::SeqCst); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); - } - } - - /// Whether the heartbeat is currently running. - pub fn is_running(&self) -> bool { - self.running.load(Ordering::SeqCst) - } - - /// Total number of ticks emitted (across all start/stop cycles). - pub fn total_ticks(&self) -> u64 { - *self.tick_count.lock().unwrap() - } -} - -impl Drop for Heartbeat { - fn drop(&mut self) { - self.stop(); - } -} diff --git a/src/main.rs b/src/main.rs index 4d4b1a0..f5a98c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,12 @@ #![warn(clippy::string_slice)] +mod ansi; mod boundary; mod config; mod guard; -mod heartbeat; mod json; -mod registry; mod signal; +mod stash; mod stream; mod stream_opencode; @@ -43,7 +43,7 @@ const SPECIFICATION_PATH: &str = ".loop/specification.md"; const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md"; const DECISIONS_PATH: &str = ".loop/decisions.md"; const SUB_PLAN_PATH: &str = ".loop/sub-plan.md"; -const STASH_DIR: &str = ".loop/.stash"; +pub(crate) const STASH_DIR: &str = ".loop/.stash"; /// Parse the plan file and notes.md to determine stage progress. /// Returns `Some((completed, total))` if the plan has parseable `## Stage` headers. @@ -83,14 +83,7 @@ fn format_progress_bar(completed: usize, total: usize) -> String { ) } -// ANSI helpers -const RESET: &str = "\x1b[0m"; -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const GREEN: &str = "\x1b[38;5;46m"; -const ORANGE: &str = "\x1b[38;5;208m"; -const RED: &str = "\x1b[38;5;196m"; -const BLUE: &str = "\x1b[38;5;75m"; +use ansi::{BLUE, BOLD, DIM, GREEN, ORANGE, RED, RESET}; const MAGENTA: &str = "\x1b[38;5;207m"; fn capitalize_first(s: &str) -> String { @@ -101,11 +94,11 @@ fn capitalize_first(s: &str) -> String { } } -fn log(msg: &str) { +pub(crate) fn log(msg: &str) { eprintln!("{}{}[yoke]{} {}", ORANGE, BOLD, RESET, msg); } -fn log_error(msg: &str) { +pub(crate) fn log_error(msg: &str) { eprintln!("{}{}[yoke]{} {}{}ERROR:{} {}", ORANGE, BOLD, RESET, BOLD, RED, RESET, msg); } @@ -441,9 +434,9 @@ fn restore_files(backup_dir: &Path, files: &[&str]) { } } -/// Check the first line of notes.md for STATUS: DONE. -fn is_done() -> bool { - match fs::read_to_string(NOTES_PATH) { +/// Check the first line of a notes file for STATUS: DONE. +fn is_status_done(path: &str) -> bool { + match fs::read_to_string(path) { Ok(content) => content.starts_with("STATUS: DONE"), Err(_) => false, } @@ -677,6 +670,63 @@ fn invoke_periodic(runner: &mut LoopRunner, config: &Config, periodic: &Periodic status } +/// Render a box-drawn iteration banner to stderr. +/// Uses double borders (╔/║/╚) at top level, single borders (┌/│/└) when nested. +fn render_iteration_banner(label: &str, iteration: u32, nested: bool) { + let content = format!("{} {:>4}", label, iteration); + // Pad content to fill inner width of 38 characters + let inner = format!("{:^38}", content); + if nested { + eprintln!("{}{}┌──────────────────────────────────────┐{}", BOLD, ORANGE, RESET); + eprintln!("{}{}│{}│{}", BOLD, ORANGE, inner, RESET); + eprintln!("{}{}└──────────────────────────────────────┘{}", BOLD, ORANGE, RESET); + } else { + eprintln!("{}{}╔══════════════════════════════════════╗{}", BOLD, ORANGE, RESET); + eprintln!("{}{}║{}║{}", BOLD, ORANGE, inner, RESET); + eprintln!("{}{}╚══════════════════════════════════════╝{}", BOLD, ORANGE, RESET); + } +} + +/// Render a named section banner to stderr (for judge, periodic, etc.). +fn render_section_banner(title: &str, subtitle: &str, color: &str) { + let fill_len = 40usize.saturating_sub(title.len() + 4); // "┌─ Title ──...──┐" + eprintln!("{}{}┌─ {} {}┐{}", BOLD, color, title, "─".repeat(fill_len), RESET); + let inner = format!(" {:<38}", subtitle); + eprintln!("{}{}│{}│{}", BOLD, color, inner, RESET); + eprintln!("{}{}└────────────────────────────────────────┘{}", BOLD, color, RESET); +} + +/// Render a box-drawn table of guard results to stderr. +fn render_guard_table(results: &[guard::GuardResult]) { + if results.is_empty() { + return; + } + let cmd_w = results.iter().map(|r| r.name.len()).max().unwrap_or(10).max(10); + let stat_w = 8; + let time_w = 6; + let top = format!(" ┌{}┬{}┬{}┐", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); + let bottom = format!(" └{}┴{}┴{}┘", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); + eprintln!("{}", top); + for r in results { + let cmd_padded = format!("{:3}s ", secs) + }; + eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell); + } + eprintln!("{}", bottom); +} + /// Run guard-after commands for a periodic agent. /// Results are written to a separate file so the worker isn't confused. fn run_periodic_guards(periodic: &Periodic, max_tail: usize) { @@ -686,40 +736,14 @@ fn run_periodic_guards(periodic: &Periodic, max_tail: usize) { let results_path = PathBuf::from(format!(".loop/periodic-{}-results.md", periodic.name)); let results = guard::run_guards(&periodic.guards, max_tail, &results_path); - // Render a compact table for periodic guard results - if !results.is_empty() { - let cmd_w = results.iter().map(|r| r.name.len()).max().unwrap_or(10).max(10); - let stat_w = 8; - let time_w = 6; - let top = format!(" ┌{}┬{}┬{}┐", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); - let bottom = format!(" └{}┴{}┴{}┘", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); - eprintln!("{}", top); - for r in &results { - let cmd_padded = format!("{:3}s ", secs) - }; - eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell); - } - eprintln!("{}", bottom); + render_guard_table(&results); - let all_passed = results.iter().all(|r| r.passed); - if !all_passed { - log(&format!( - "{}WARNING: periodic '{}' guard(s) failed — results in {}{}", - ORANGE, periodic.name, format!(".loop/periodic-{}-results.md", periodic.name), RESET - )); - } + let all_passed = results.iter().all(|r| r.passed); + if !all_passed { + log(&format!( + "{}WARNING: periodic '{}' guard(s) failed — results in {}{}", + ORANGE, periodic.name, format!(".loop/periodic-{}-results.md", periodic.name), RESET + )); } } @@ -947,42 +971,7 @@ fn run_all_guards(config: &Config) -> bool { let all_passed = guard_results.iter().all(|r| r.passed); - // Render guard results as a box-drawn table with timing - if !guard_results.is_empty() { - // Column widths (visual characters, not bytes) - let cmd_w = guard_results.iter().map(|r| r.name.len()).max().unwrap_or(10).max(10); - let stat_w = 8; // enough for "SKIPPED" + padding - let time_w = 6; // e.g. " 12s " - - let top = format!(" ┌{}┬{}┬{}┐", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); - let bottom = format!(" └{}┴{}┴{}┘", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); - - eprintln!("{}", top); - for r in &guard_results { - // Command column: left-aligned, padded to cmd_w - let cmd_padded = format!("{:3}s ", secs) - }; - - eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell); - } - eprintln!("{}", bottom); - } + render_guard_table(&guard_results); all_passed } @@ -1020,7 +1009,8 @@ fn clean() -> i32 { } // Stash non-empty working files before wiping - let stashed = stash_working_files(); + let mode = detect_mode().unwrap_or("unknown"); + let stashed = stash::stash_working_files(mode); log("Cleaning .loop/ working files..."); @@ -1061,347 +1051,6 @@ fn clean() -> i32 { 0 } -// ── Stash helpers ────────────────────────────────────────────────────── - -/// Format Unix epoch seconds as `YYYY-MM-DDThh:mm:ss` using Hinnant's algorithm. -fn format_unix_timestamp(secs: u64) -> String { - let z = (secs / 86400) as i64 + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = (z - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - let time_of_day = secs % 86400; - let h = time_of_day / 3600; - let min = (time_of_day % 3600) / 60; - let s = time_of_day % 60; - - format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", y, m, d, h, min, s) -} - -/// SipHash timestamp + file names/contents → lower 28 bits → 7-char hex. -fn generate_stash_hash(timestamp: &str, files: &[(String, Vec)]) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - timestamp.hash(&mut hasher); - for (name, contents) in files { - name.hash(&mut hasher); - contents.hash(&mut hasher); - } - format!("{:07x}", hasher.finish() & 0x0FFF_FFFF) -} - -/// Collect all regular files in `.loop/` excluding dotfile/dotdir entries. -/// Returns sorted `(filename, contents)` pairs for deterministic hashing. -fn collect_stashable_files() -> Vec<(String, Vec)> { - let mut files = Vec::new(); - if let Ok(entries) = fs::read_dir(".loop") { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with('.') { - continue; - } - let path = entry.path(); - if path.is_file() { - if let Ok(contents) = fs::read(&path) { - files.push((name, contents)); - } - } - } - } - files.sort_by(|a, b| a.0.cmp(&b.0)); - files -} - -struct StashEntry { - hash: String, - timestamp: String, - mode: String, - files: Vec, -} - -/// Parse `.loop/.stash/index` into a list of stash entries (oldest first). -fn parse_stash_index() -> Vec { - let index_path = format!("{}/index", STASH_DIR); - let content = match fs::read_to_string(&index_path) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - content - .lines() - .filter(|l| !l.trim().is_empty()) - .filter_map(|line| { - let parts: Vec<&str> = line.splitn(4, '|').collect(); - if parts.len() < 4 { - return None; - } - Some(StashEntry { - hash: parts[0].to_string(), - timestamp: parts[1].to_string(), - mode: parts[2].to_string(), - files: parts[3].split(',').map(|s| s.to_string()).collect(), - }) - }) - .collect() -} - -// ── Stash core ───────────────────────────────────────────────────────── - -/// Snapshot all stashable files in `.loop/` to a new log entry. -/// Returns `Ok(hash)` on success. -fn stash_snapshot() -> Result { - let files = collect_stashable_files(); - if files.is_empty() { - return Err("no files to stash in .loop/".to_string()); - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let timestamp = format_unix_timestamp(now); - let mode = detect_mode().unwrap_or("unknown").to_string(); - - let mut hash = generate_stash_hash(×tamp, &files); - - // Collision handling: rehash with counter suffix - let stash_base = Path::new(STASH_DIR); - for attempt in 0..100u32 { - if !stash_base.join(&hash).exists() { - break; - } - if attempt == 99 { - return Err("hash collision after 100 attempts".to_string()); - } - hash = generate_stash_hash(&format!("{}{}", timestamp, attempt + 1), &files); - } - - let entry_dir = stash_base.join(&hash); - fs::create_dir_all(&entry_dir) - .map_err(|e| format!("failed to create {}: {}", entry_dir.display(), e))?; - - let file_names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); - - for (name, contents) in &files { - let dest = entry_dir.join(name); - fs::write(&dest, contents) - .map_err(|e| format!("failed to write {}: {}", dest.display(), e))?; - } - - // Append to index - let index_path = format!("{}/index", STASH_DIR); - let line = format!( - "{}|{}|{}|{}\n", - hash, - timestamp, - mode, - file_names.join(",") - ); - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&index_path) - .map_err(|e| format!("failed to open index: {}", e))?; - std::io::Write::write_all(&mut f, line.as_bytes()) - .map_err(|e| format!("failed to write index: {}", e))?; - - Ok(hash) -} - -// ── Stash commands ───────────────────────────────────────────────────── - -/// Snapshot `.loop/` to a new stash log entry. Returns file count for `clean()`. -fn stash_working_files() -> usize { - let files = collect_stashable_files(); - if files.is_empty() { - return 0; - } - let count = files.len(); - match stash_snapshot() { - Ok(_) => count, - Err(_) => 0, - } -} - -/// `yoke stash` — create a new stash entry and print the hash. -fn stash_create() -> i32 { - if !Path::new(".loop").exists() { - log_error(".loop/ directory not found"); - return 1; - } - match stash_snapshot() { - Ok(hash) => { - log(&format!("stashed → {}{}{}", BLUE, hash, RESET)); - 0 - } - Err(msg) => { - log_error(&msg); - 1 - } - } -} - -/// `yoke stash log` — list all stash entries (newest first). -fn stash_log_cmd() -> i32 { - if !Path::new(".loop").exists() { - log_error(".loop/ directory not found"); - return 1; - } - let entries = parse_stash_index(); - if entries.is_empty() { - log("no stash entries"); - return 0; - } - for entry in entries.iter().rev() { - eprintln!( - "{}{}{}{} {} mode={}", - BOLD, BLUE, entry.hash, RESET, entry.timestamp, entry.mode - ); - for file in &entry.files { - eprintln!(" {}", file); - } - } - 0 -} - -/// `yoke stash checkout ` — auto-stash current state, clear `.loop/` files, -/// restore target entry. Supports prefix matching. -fn stash_checkout(target_hash: &str) -> i32 { - if !Path::new(".loop").exists() { - log_error(".loop/ directory not found"); - return 1; - } - let entries = parse_stash_index(); - if entries.is_empty() { - log_error("no stash entries"); - return 1; - } - - // Find matching entries (exact or prefix) - let matches: Vec<&StashEntry> = entries - .iter() - .filter(|e| e.hash == target_hash || e.hash.starts_with(target_hash)) - .collect(); - - match matches.len() { - 0 => { - log_error(&format!("no stash entry matching '{}'", target_hash)); - 1 - } - 1 => { - let target = matches[0]; - let entry_dir = Path::new(STASH_DIR).join(&target.hash); - if !entry_dir.exists() { - log_error("stash entry directory missing — index is corrupt"); - return 1; - } - - // Auto-stash current state (skip if .loop/ has no stashable files) - let current_files = collect_stashable_files(); - if !current_files.is_empty() { - match stash_snapshot() { - Ok(hash) => { - log(&format!( - "auto-stashed current state → {}{}{}", - BLUE, hash, RESET - )); - } - Err(msg) => { - log_error(&format!("failed to auto-stash: {}", msg)); - return 1; - } - } - } - - // Remove all stashable files from .loop/ - for (name, _) in &collect_stashable_files() { - let path = Path::new(".loop").join(name); - let _ = fs::remove_file(&path); - } - - // Restore files from the target entry - if let Ok(dir_entries) = fs::read_dir(&entry_dir) { - for entry in dir_entries.flatten() { - let name = entry.file_name(); - let dest = Path::new(".loop").join(&name); - if let Err(e) = fs::copy(entry.path(), &dest) { - log_error(&format!( - "failed to restore {}: {}", - name.to_string_lossy(), - e - )); - return 1; - } - } - } - - log(&format!("checked out {}{}{}", BLUE, target.hash, RESET)); - 0 - } - n => { - log_error(&format!( - "ambiguous hash '{}' — matches {} entries", - target_hash, n - )); - 1 - } - } -} - -/// `yoke stash pop` — checkout the most recent stash entry. -fn stash_pop() -> i32 { - let entries = parse_stash_index(); - match entries.last() { - Some(entry) => stash_checkout(&entry.hash), - None => { - log_error("no stash found — nothing to restore"); - 1 - } - } -} - -fn print_stash_help() { - eprintln!( - "{}{}[yoke stash]{} snapshot and restore .loop/ state", - ORANGE, BOLD, RESET - ); - eprintln!(); - eprintln!( - "{}USAGE:{} yoke stash [subcommand]", - BOLD, RESET - ); - eprintln!(); - eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET); - eprintln!( - " {}(none){} Snapshot all .loop/ files to a new stash entry", - BOLD, RESET - ); - eprintln!( - " {}log{} List all stash entries (hash, timestamp, mode, files)", - BOLD, RESET - ); - eprintln!( - " {}checkout {} Auto-stash current state, then restore target entry", - BOLD, RESET - ); - eprintln!( - " {}pop{} Alias for checkout of the most recent entry", - BOLD, RESET - ); - eprintln!(); - eprintln!("{}EXAMPLES:{}", BOLD, RESET); - eprintln!(" yoke stash # snapshot current .loop/ files"); - eprintln!(" yoke stash log # show all stash entries"); - eprintln!(" yoke stash checkout a1b2c3d # restore a specific snapshot"); - eprintln!(" yoke stash pop # restore the most recent snapshot"); -} /// Return the list of (path, default_content) pairs for a given mode. fn mode_files(mode: &str) -> Option> { @@ -1620,6 +1269,80 @@ enum PlanLoopOutcome { Error, } +/// Check if consecutive judge failures have hit the bail threshold. +/// Returns true if the loop should bail out. +fn is_judge_bailout(consecutive: u32, max: u32) -> bool { + if consecutive >= max { + eprintln!(); + eprintln!( + "{}{} {} consecutive judge FAILs \u{2014} bailing out {}", + RED, BOLD, max, RESET + ); + eprintln!(); + true + } else { + false + } +} + +/// Result of evaluating judge-every logic within a plan loop iteration. +enum JudgeEveryAction { + /// Judge passed on DONE — exit loop. + Pass, + /// Consecutive failures hit threshold — bail out. + Bailout, + /// Continue to next iteration (judge failed on DONE, or mid-loop check done). + Continue, +} + +/// Evaluate judge-every logic: fire judge when worker signals DONE or at cadence checkpoints. +fn evaluate_judge_every( + runner: &mut LoopRunner, + config: &Config, + iteration: u32, + guards_passed: bool, + consecutive_judge_failures: &mut u32, + judge_every: u32, +) -> JudgeEveryAction { + if guards_passed && is_status_done(NOTES_PATH) { + // Always fire judge when worker signals DONE + eprintln!(); + render_section_banner("Judge (DONE)", "Worker DONE — invoking judge", BLUE); + let pass = invoke_judge(runner, config, iteration); + if pass { + return JudgeEveryAction::Pass; + } + *consecutive_judge_failures += 1; + if is_judge_bailout(*consecutive_judge_failures, config.max_judge_failures) { + return JudgeEveryAction::Bailout; + } + log(&format!( + "{}Judge FAIL ({}/{}) \u{2014} resetting STATUS for retry{}", + ORANGE, *consecutive_judge_failures, config.max_judge_failures, RESET + )); + reset_notes_status(); + } else if guards_passed && iteration % judge_every == 0 { + // Mid-loop quality checkpoint + eprintln!(); + render_section_banner( + "Mid-loop Judge", + &format!("Quality checkpoint (iteration {:>4})", iteration), + BLUE, + ); + let pass = invoke_judge(runner, config, iteration); + if pass { + *consecutive_judge_failures = 0; + } else { + *consecutive_judge_failures += 1; + if is_judge_bailout(*consecutive_judge_failures, config.max_judge_failures) { + return JudgeEveryAction::Bailout; + } + } + // Either way, worker continues — verdict.md has feedback + } + JudgeEveryAction::Continue +} + /// Core plan-loop runner that can be called standalone or nested inside brute. /// /// - `config`: already-loaded Config @@ -1685,33 +1408,8 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) } iteration += 1; eprintln!(); - if nested { - eprintln!( - "{}{}┌──────────────────────────────────────┐{}", - BOLD, ORANGE, RESET - ); - eprintln!( - "{}{}│ Plan Iteration {:>4} │{}", - BOLD, ORANGE, iteration, RESET - ); - eprintln!( - "{}{}└──────────────────────────────────────┘{}", - BOLD, ORANGE, RESET - ); - } else { - eprintln!( - "{}{}╔══════════════════════════════════════╗{}", - BOLD, ORANGE, RESET - ); - eprintln!( - "{}{}║ Iteration {:>4} ║{}", - BOLD, ORANGE, iteration, RESET - ); - eprintln!( - "{}{}╚══════════════════════════════════════╝{}", - BOLD, ORANGE, RESET - ); - } + let label = if nested { "Plan Iteration" } else { "Iteration" }; + render_iteration_banner(label, iteration, nested); // Show stage progress bar if plan has parseable stages if let Some((completed, total)) = stage_progress(plan_path) { @@ -1778,20 +1476,10 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) if iteration % periodic.cadence == 0 { eprintln!(); let upper_name = capitalize_first(&periodic.name); - eprintln!( - "{}{}┌─ {} {}┐{}", - BOLD, MAGENTA, - upper_name, - "─".repeat(37usize.saturating_sub(upper_name.len() + 2)), - RESET - ); - eprintln!( - "{}{}│ Periodic agent (iteration {:>4}) │{}", - BOLD, MAGENTA, iteration, RESET - ); - eprintln!( - "{}{}└────────────────────────────────────────┘{}", - BOLD, MAGENTA, RESET + render_section_banner( + &upper_name, + &format!("Periodic agent (iteration {:>4})", iteration), + MAGENTA, ); let ok = invoke_periodic(&mut runner, config, periodic, iteration); if !ok { @@ -1806,80 +1494,18 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) // Judge-every logic: embedded judge checks at configured cadence if let Some(judge_every) = config.judge_every { - if guards_passed && is_done() { - // Always fire judge when worker signals DONE - eprintln!(); - eprintln!( - "{}{}┌─ Judge (DONE) ─────────────────────────┐{}", - BOLD, BLUE, RESET - ); - eprintln!( - "{}{}│ Worker DONE — invoking judge │{}", - BOLD, BLUE, RESET - ); - eprintln!( - "{}{}└────────────────────────────────────────┘{}", - BOLD, BLUE, RESET - ); - let pass = invoke_judge(&mut runner, config, iteration); - if pass { - return PlanLoopOutcome::JudgePass; - } - consecutive_judge_failures += 1; - if consecutive_judge_failures >= config.max_judge_failures { - eprintln!(); - eprintln!( - "{}{} {} consecutive judge FAILs \u{2014} bailing out {}", - RED, BOLD, config.max_judge_failures, RESET - ); - eprintln!(); - return PlanLoopOutcome::JudgeBailout; - } - log(&format!( - "{}Judge FAIL ({}/{}) \u{2014} resetting STATUS for retry{}", - ORANGE, consecutive_judge_failures, config.max_judge_failures, RESET - )); - reset_notes_status(); - continue; - } else if guards_passed && iteration % judge_every == 0 { - // Mid-loop quality checkpoint - eprintln!(); - eprintln!( - "{}{}┌─ Mid-loop Judge ───────────────────────┐{}", - BOLD, BLUE, RESET - ); - eprintln!( - "{}{}│ Quality checkpoint (iteration {:>4}) │{}", - BOLD, BLUE, iteration, RESET - ); - eprintln!( - "{}{}└────────────────────────────────────────┘{}", - BOLD, BLUE, RESET - ); - let pass = invoke_judge(&mut runner, config, iteration); - if pass { - consecutive_judge_failures = 0; - } else { - consecutive_judge_failures += 1; - if consecutive_judge_failures >= config.max_judge_failures { - eprintln!(); - eprintln!( - "{}{} {} consecutive judge FAILs \u{2014} bailing out {}", - RED, BOLD, config.max_judge_failures, RESET - ); - eprintln!(); - return PlanLoopOutcome::JudgeBailout; - } - } - // Either way, worker continues — verdict.md has feedback + match evaluate_judge_every( + &mut runner, config, iteration, guards_passed, + &mut consecutive_judge_failures, judge_every, + ) { + JudgeEveryAction::Pass => return PlanLoopOutcome::JudgePass, + JudgeEveryAction::Bailout => return PlanLoopOutcome::JudgeBailout, + JudgeEveryAction::Continue => continue, } - // When judge-every is set, the judge handles the DONE check above. - // If we reach here without DONE, just continue the loop. - continue; } // Original exit check (only reached when judge-every is NOT set) - if guards_passed && is_done() { + if guards_passed && is_status_done(NOTES_PATH) { eprintln!(); eprintln!( "{}{} STATUS: DONE and all guards pass \u{2014} loop complete {}", @@ -2021,18 +1647,7 @@ fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResul } iteration += 1; eprintln!(); - eprintln!( - "{}{}╔══════════════════════════════════════╗{}", - BOLD, ORANGE, RESET - ); - eprintln!( - "{}{}║ Brute Iteration {:>4} ║{}", - BOLD, ORANGE, iteration, RESET - ); - eprintln!( - "{}{}╚══════════════════════════════════════╝{}", - BOLD, ORANGE, RESET - ); + render_iteration_banner("Brute Iteration", iteration, false); // Restore protected files restore_files(&runner.backup_dir, BRUTE_PROTECTED_FILES); @@ -2093,17 +1708,10 @@ fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResul // Guards passed — invoke judge eprintln!(); - eprintln!( - "{}{}┌─ Judge ────────────────────────────────┐{}", - BOLD, BLUE, RESET - ); - eprintln!( - "{}{}│ Invoking judge (attempt {:>4}) │{}", - BOLD, BLUE, iteration, RESET - ); - eprintln!( - "{}{}└────────────────────────────────────────┘{}", - BOLD, BLUE, RESET + render_section_banner( + "Judge", + &format!("Invoking judge (attempt {:>4})", iteration), + BLUE, ); let pass = invoke_judge(&mut runner, config, iteration); @@ -2137,13 +1745,7 @@ fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResul RESET )); - if consecutive_failures >= max_failures { - eprintln!(); - eprintln!( - "{}{} {} consecutive judge FAILs \u{2014} bailing out {}", - RED, BOLD, max_failures, RESET - ); - eprintln!(); + if is_judge_bailout(consecutive_failures, max_failures) { return BruteResult::Bailout; } @@ -2163,13 +1765,6 @@ fn run_brute(dry_run: bool) -> i32 { } } -/// Check the first line of saga-notes.md for STATUS: DONE. -fn is_saga_done() -> bool { - match fs::read_to_string(SAGA_NOTES_PATH) { - Ok(content) => content.starts_with("STATUS: DONE"), - Err(_) => false, - } -} /// Invoke the scoper (Agent 1) — a fresh LLM session that reads the spec, /// writes sub-plan.md, and updates saga-notes.md. @@ -2275,18 +1870,7 @@ fn run_saga(dry_run: bool) -> i32 { } cycle += 1; eprintln!(); - eprintln!( - "{}{}╔══════════════════════════════════════╗{}", - BOLD, ORANGE, RESET - ); - eprintln!( - "{}{}║ Saga Cycle {:>4} ║{}", - BOLD, ORANGE, cycle, RESET - ); - eprintln!( - "{}{}╚══════════════════════════════════════╝{}", - BOLD, ORANGE, RESET - ); + render_iteration_banner("Saga Cycle", cycle, false); // Restore protected files restore_files(&runner.backup_dir, &saga_protected); @@ -2296,17 +1880,10 @@ fn run_saga(dry_run: bool) -> i32 { } else { // Invoke Agent 1 (scoper) eprintln!(); - eprintln!( - "{}{}┌\u{2500} Scoper \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}┐{}", - BOLD, BLUE, RESET - ); - eprintln!( - "{}{}│ Invoking scoper (cycle {:>4}) │{}", - BOLD, BLUE, cycle, RESET - ); - eprintln!( - "{}{}└\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}┘{}", - BOLD, BLUE, RESET + render_section_banner( + "Scoper", + &format!("Invoking scoper (cycle {:>4})", cycle), + BLUE, ); let scoper_ok = invoke_scoper(&mut runner, &config, cycle); @@ -2322,7 +1899,7 @@ fn run_saga(dry_run: bool) -> i32 { } // Check if scoper signaled DONE - if is_saga_done() { + if is_status_done(SAGA_NOTES_PATH) { eprintln!(); eprintln!( "{}{} Scoper signals DONE \u{2014} saga complete {}", @@ -2433,29 +2010,30 @@ fn main() { } "stash" => { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { - print_stash_help(); + stash::print_stash_help(); return; } + let mode = detect_mode().unwrap_or("unknown"); match args.get(2).map(|a| a.as_str()) { None => { - process::exit(stash_create()); + process::exit(stash::stash_create(mode)); } Some("log") => { - process::exit(stash_log_cmd()); + process::exit(stash::stash_log_cmd()); } Some("checkout") => match args.get(3) { - Some(hash) => process::exit(stash_checkout(hash)), + Some(hash) => process::exit(stash::stash_checkout(hash, mode)), None => { log_error("missing hash — usage: yoke stash checkout "); process::exit(2); } }, Some("pop") => { - process::exit(stash_pop()); + process::exit(stash::stash_pop(mode)); } Some(other) => { log_error(&format!("unknown subcommand '{}'", other)); - print_stash_help(); + stash::print_stash_help(); process::exit(2); } } diff --git a/src/registry.rs b/src/registry.rs deleted file mode 100644 index 4120fb1..0000000 --- a/src/registry.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Widget Registry — central namespace for tracking UI widgets by name and type. -//! -//! Supports insertion, lookup by name, and iteration over all registered widgets. -//! This is the foundation that later stages (heartbeat listeners, guard display, -//! progress bar, verdict banner) hang off. - -use std::collections::HashMap; - -/// The kind of visual element a widget represents. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WidgetType { - ProgressBar, - Table, - Banner, - Heartbeat, - Tree, -} - -/// A single registered widget. -#[derive(Debug, Clone)] -pub struct Widget { - pub name: String, - pub wtype: WidgetType, - pub enabled: bool, -} - -/// Central registry that tracks all UI widgets by name and type. -pub struct WidgetRegistry { - widgets: HashMap, - /// Insertion order preserved for deterministic iteration. - order: Vec, -} - -impl WidgetRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { - widgets: HashMap::new(), - order: Vec::new(), - } - } - - /// Insert a widget. If a widget with the same name exists, it is replaced - /// (its position in iteration order is preserved). - pub fn insert(&mut self, name: impl Into, wtype: WidgetType) { - let name = name.into(); - let widget = Widget { - name: name.clone(), - wtype, - enabled: true, - }; - if self.widgets.insert(name.clone(), widget).is_none() { - self.order.push(name); - } - } - - /// Look up a widget by name. - pub fn get(&self, name: &str) -> Option<&Widget> { - self.widgets.get(name) - } - - /// Iterate over all widgets in insertion order. - pub fn iter(&self) -> impl Iterator { - self.order.iter().filter_map(|n| self.widgets.get(n)) - } - - /// Number of registered widgets. - pub fn len(&self) -> usize { - self.widgets.len() - } - - /// Whether the registry is empty. - pub fn is_empty(&self) -> bool { - self.widgets.is_empty() - } - - /// Remove a widget by name. Returns the removed widget, if any. - pub fn remove(&mut self, name: &str) -> Option { - if let Some(w) = self.widgets.remove(name) { - self.order.retain(|n| n != name); - Some(w) - } else { - None - } - } -} diff --git a/src/scratch/config.json b/src/scratch/config.json deleted file mode 100644 index bf64399..0000000 --- a/src/scratch/config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "yoke-visual-test", - "version": "0.1.0", - "settings": { - "max_retries": 3, - "timeout_ms": 5000, - "verbose": true, - "colors": { - "primary": "#46FF00", - "secondary": "#4B80FF", - "error": "#FF3232" - } - }, - "features": [ - "stream-filter", - "edit-diff", - "write-preview", - "bash-error-tail", - "thinking-timer" - ] -} diff --git a/src/scratch/demo.rs b/src/scratch/demo.rs deleted file mode 100644 index abe3bd5..0000000 --- a/src/scratch/demo.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// A small demo module for exercising stream visuals. -/// This file exists only to generate interesting diffs. - -pub fn greet(name: &str) -> String { - format!("Hey there, {}! Welcome to the stream.", name) -} - -pub fn farewell(name: &str) -> String { - format!("Goodbye, {}. See you next time!", name) -} - -pub fn fibonacci(n: u32) -> u64 { - match n { - 0 => 0, - 1 => 1, - _ => { - let mut a: u64 = 0; - let mut b: u64 = 1; - for _ in 2..=n { - let tmp = a + b; - a = b; - b = tmp; - } - b - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_greet() { - assert_eq!(greet("world"), "Hello, world!"); - } - - #[test] - fn test_fibonacci() { - assert_eq!(fibonacci(0), 0); - assert_eq!(fibonacci(1), 1); - assert_eq!(fibonacci(10), 55); - } -} diff --git a/src/scratch/extra.toml b/src/scratch/extra.toml deleted file mode 100644 index 7c59f61..0000000 --- a/src/scratch/extra.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Extra scratch config for visual testing - -[display] -theme = "dark" -line_numbers = true -word_wrap = false - -[limits] -max_diff_lines = 50 -max_badge_width = 40 -truncate_at = 120 diff --git a/src/scratch/notes.md b/src/scratch/notes.md deleted file mode 100644 index 42834bd..0000000 --- a/src/scratch/notes.md +++ /dev/null @@ -1,17 +0,0 @@ -# Scratch Notes - -These are dummy notes for exercising the yoke stream output. - -## Features Tested - -- **Edit diffs**: red/green line-level changes -- **Write previews**: line count badge + first 3 lines -- **Bash errors**: last 3 lines of stderr in red -- **Thinking timer**: live elapsed seconds display -- **Grep/Glob badges**: match/file count badges - -## Open Questions - -1. Should the progress bar use unicode block characters? -2. What is the optimal truncation length for edit diffs? -3. How should we handle binary file diffs? diff --git a/src/scratch/setup.sh b/src/scratch/setup.sh deleted file mode 100644 index 9d63975..0000000 --- a/src/scratch/setup.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Scratch setup script for visual testing -set -euo pipefail - -echo "Setting up scratch environment..." - -PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -SCRATCH_DIR="${PROJECT_ROOT}/src/scratch" - -echo "Project root: ${PROJECT_ROOT}" -echo "Scratch dir: ${SCRATCH_DIR}" - -# Count files -FILE_COUNT=$(find "${SCRATCH_DIR}" -type f | wc -l) -echo "Found ${FILE_COUNT} scratch files" - -# List them -for f in "${SCRATCH_DIR}"/*; do - if [ -f "$f" ]; then - echo " - $(basename "$f") ($(wc -l < "$f") lines)" - fi -done - -echo "Done." diff --git a/src/stash.rs b/src/stash.rs new file mode 100644 index 0000000..d2dbab9 --- /dev/null +++ b/src/stash.rs @@ -0,0 +1,346 @@ +use std::fs; +use std::path::Path; + +use crate::ansi::{BLUE, BOLD, ORANGE, RESET}; +use crate::{log, log_error, STASH_DIR}; + +// ── Stash helpers ────────────────────────────────────────────────────── + +/// Format Unix epoch seconds as `YYYY-MM-DDThh:mm:ss` using Hinnant's algorithm. +fn format_unix_timestamp(secs: u64) -> String { + let z = (secs / 86400) as i64 + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + let time_of_day = secs % 86400; + let h = time_of_day / 3600; + let min = (time_of_day % 3600) / 60; + let s = time_of_day % 60; + + format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", y, m, d, h, min, s) +} + +/// SipHash timestamp + file names/contents → lower 28 bits → 7-char hex. +fn generate_stash_hash(timestamp: &str, files: &[(String, Vec)]) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + timestamp.hash(&mut hasher); + for (name, contents) in files { + name.hash(&mut hasher); + contents.hash(&mut hasher); + } + format!("{:07x}", hasher.finish() & 0x0FFF_FFFF) +} + +/// Collect all regular files in `.loop/` excluding dotfile/dotdir entries. +/// Returns sorted `(filename, contents)` pairs for deterministic hashing. +pub(crate) fn collect_stashable_files() -> Vec<(String, Vec)> { + let mut files = Vec::new(); + if let Ok(entries) = fs::read_dir(".loop") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + let path = entry.path(); + if path.is_file() { + if let Ok(contents) = fs::read(&path) { + files.push((name, contents)); + } + } + } + } + files.sort_by(|a, b| a.0.cmp(&b.0)); + files +} + +pub(crate) struct StashEntry { + pub hash: String, + pub timestamp: String, + pub mode: String, + pub files: Vec, +} + +/// Parse `.loop/.stash/index` into a list of stash entries (oldest first). +pub(crate) fn parse_stash_index() -> Vec { + let index_path = format!("{}/index", STASH_DIR); + let content = match fs::read_to_string(&index_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(4, '|').collect(); + if parts.len() < 4 { + return None; + } + Some(StashEntry { + hash: parts[0].to_string(), + timestamp: parts[1].to_string(), + mode: parts[2].to_string(), + files: parts[3].split(',').map(|s| s.to_string()).collect(), + }) + }) + .collect() +} + +// ── Stash core ───────────────────────────────────────────────────────── + +/// Snapshot all stashable files in `.loop/` to a new log entry. +/// Returns `Ok(hash)` on success. +pub(crate) fn stash_snapshot(mode: &str) -> Result { + let files = collect_stashable_files(); + if files.is_empty() { + return Err("no files to stash in .loop/".to_string()); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let timestamp = format_unix_timestamp(now); + + let mut hash = generate_stash_hash(×tamp, &files); + + // Collision handling: rehash with counter suffix + let stash_base = Path::new(STASH_DIR); + for attempt in 0..100u32 { + if !stash_base.join(&hash).exists() { + break; + } + if attempt == 99 { + return Err("hash collision after 100 attempts".to_string()); + } + hash = generate_stash_hash(&format!("{}{}", timestamp, attempt + 1), &files); + } + + let entry_dir = stash_base.join(&hash); + fs::create_dir_all(&entry_dir) + .map_err(|e| format!("failed to create {}: {}", entry_dir.display(), e))?; + + let file_names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); + + for (name, contents) in &files { + let dest = entry_dir.join(name); + fs::write(&dest, contents) + .map_err(|e| format!("failed to write {}: {}", dest.display(), e))?; + } + + // Append to index + let index_path = format!("{}/index", STASH_DIR); + let line = format!( + "{}|{}|{}|{}\n", + hash, + timestamp, + mode, + file_names.join(",") + ); + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&index_path) + .map_err(|e| format!("failed to open index: {}", e))?; + std::io::Write::write_all(&mut f, line.as_bytes()) + .map_err(|e| format!("failed to write index: {}", e))?; + + Ok(hash) +} + +// ── Stash commands ───────────────────────────────────────────────────── + +/// Snapshot `.loop/` to a new stash log entry. Returns file count for `clean()`. +pub(crate) fn stash_working_files(mode: &str) -> usize { + let files = collect_stashable_files(); + if files.is_empty() { + return 0; + } + let count = files.len(); + match stash_snapshot(mode) { + Ok(_) => count, + Err(_) => 0, + } +} + +/// `yoke stash` — create a new stash entry and print the hash. +pub(crate) fn stash_create(mode: &str) -> i32 { + if !Path::new(".loop").exists() { + log_error(".loop/ directory not found"); + return 1; + } + match stash_snapshot(mode) { + Ok(hash) => { + log(&format!("stashed → {}{}{}", BLUE, hash, RESET)); + 0 + } + Err(msg) => { + log_error(&msg); + 1 + } + } +} + +/// `yoke stash log` — list all stash entries (newest first). +pub(crate) fn stash_log_cmd() -> i32 { + if !Path::new(".loop").exists() { + log_error(".loop/ directory not found"); + return 1; + } + let entries = parse_stash_index(); + if entries.is_empty() { + log("no stash entries"); + return 0; + } + for entry in entries.iter().rev() { + eprintln!( + "{}{}{}{} {} mode={}", + BOLD, BLUE, entry.hash, RESET, entry.timestamp, entry.mode + ); + for file in &entry.files { + eprintln!(" {}", file); + } + } + 0 +} + +/// `yoke stash checkout ` — auto-stash current state, clear `.loop/` files, +/// restore target entry. Supports prefix matching. +pub(crate) fn stash_checkout(target_hash: &str, mode: &str) -> i32 { + if !Path::new(".loop").exists() { + log_error(".loop/ directory not found"); + return 1; + } + let entries = parse_stash_index(); + if entries.is_empty() { + log_error("no stash entries"); + return 1; + } + + // Find matching entries (exact or prefix) + let matches: Vec<&StashEntry> = entries + .iter() + .filter(|e| e.hash == target_hash || e.hash.starts_with(target_hash)) + .collect(); + + match matches.len() { + 0 => { + log_error(&format!("no stash entry matching '{}'", target_hash)); + 1 + } + 1 => { + let target = matches[0]; + let entry_dir = Path::new(STASH_DIR).join(&target.hash); + if !entry_dir.exists() { + log_error("stash entry directory missing — index is corrupt"); + return 1; + } + + // Auto-stash current state (skip if .loop/ has no stashable files) + let current_files = collect_stashable_files(); + if !current_files.is_empty() { + match stash_snapshot(mode) { + Ok(hash) => { + log(&format!( + "auto-stashed current state → {}{}{}", + BLUE, hash, RESET + )); + } + Err(msg) => { + log_error(&format!("failed to auto-stash: {}", msg)); + return 1; + } + } + } + + // Remove all stashable files from .loop/ + for (name, _) in &collect_stashable_files() { + let path = Path::new(".loop").join(name); + let _ = fs::remove_file(&path); + } + + // Restore files from the target entry + if let Ok(dir_entries) = fs::read_dir(&entry_dir) { + for entry in dir_entries.flatten() { + let name = entry.file_name(); + let dest = Path::new(".loop").join(&name); + if let Err(e) = fs::copy(entry.path(), &dest) { + log_error(&format!( + "failed to restore {}: {}", + name.to_string_lossy(), + e + )); + return 1; + } + } + } + + log(&format!("checked out {}{}{}", BLUE, target.hash, RESET)); + 0 + } + n => { + log_error(&format!( + "ambiguous hash '{}' — matches {} entries", + target_hash, n + )); + 1 + } + } +} + +/// `yoke stash pop` — checkout the most recent stash entry. +pub(crate) fn stash_pop(mode: &str) -> i32 { + let entries = parse_stash_index(); + match entries.last() { + Some(entry) => stash_checkout(&entry.hash, mode), + None => { + log_error("no stash found — nothing to restore"); + 1 + } + } +} + +pub(crate) fn print_stash_help() { + eprintln!( + "{}{}[yoke stash]{} snapshot and restore .loop/ state", + ORANGE, BOLD, RESET + ); + eprintln!(); + eprintln!( + "{}USAGE:{} yoke stash [subcommand]", + BOLD, RESET + ); + eprintln!(); + eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET); + eprintln!( + " {}(none){} Snapshot all .loop/ files to a new stash entry", + BOLD, RESET + ); + eprintln!( + " {}log{} List all stash entries (hash, timestamp, mode, files)", + BOLD, RESET + ); + eprintln!( + " {}checkout {} Auto-stash current state, then restore target entry", + BOLD, RESET + ); + eprintln!( + " {}pop{} Alias for checkout of the most recent entry", + BOLD, RESET + ); + eprintln!(); + eprintln!("{}EXAMPLES:{}", BOLD, RESET); + eprintln!(" yoke stash # snapshot current .loop/ files"); + eprintln!(" yoke stash log # show all stash entries"); + eprintln!(" yoke stash checkout a1b2c3d # restore a specific snapshot"); + eprintln!(" yoke stash pop # restore the most recent snapshot"); +} diff --git a/src/stream.rs b/src/stream.rs index cdf0cec..717de81 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -4,23 +4,11 @@ use std::path::Path; use std::process::ChildStdout; use std::time::Instant; +use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW}; use crate::json::{extract_num, extract_str, unescape_json}; -// ANSI escape codes -const RESET: &str = "\x1b[0m"; -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const GREEN: &str = "\x1b[38;5;46m"; -const ORANGE: &str = "\x1b[38;5;208m"; -const BLUE: &str = "\x1b[38;5;75m"; -const CYAN: &str = "\x1b[38;5;80m"; -const YELLOW: &str = "\x1b[38;5;222m"; -const MAGENTA: &str = "\x1b[38;5;183m"; -const RED: &str = "\x1b[38;5;196m"; -const GRAY: &str = "\x1b[38;5;245m"; -// VS Code-style diff background colors -const BG_RED: &str = "\x1b[48;2;80;30;30m"; // dark red/pink background for removed lines -const BG_GREEN: &str = "\x1b[48;2;30;60;30m"; // dark green background for added lines +const BG_RED: &str = "\x1b[48;2;80;30;30m"; +const BG_GREEN: &str = "\x1b[48;2;30;60;30m"; struct StreamState { turn_num: u32, diff --git a/src/stream_opencode.rs b/src/stream_opencode.rs index bdff9e7..0cb9303 100644 --- a/src/stream_opencode.rs +++ b/src/stream_opencode.rs @@ -3,24 +3,12 @@ use std::io::{self, BufRead, BufReader, Write}; use std::path::Path; use std::process::ChildStdout; +use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW}; use crate::json::{extract_num, extract_str, unescape_json}; -const RESET: &str = "\x1b[0m"; -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const GREEN: &str = "\x1b[38;5;46m"; -const ORANGE: &str = "\x1b[38;5;208m"; -const BLUE: &str = "\x1b[38;5;75m"; -const CYAN: &str = "\x1b[38;5;80m"; -const YELLOW: &str = "\x1b[38;5;222m"; -const MAGENTA: &str = "\x1b[38;5;183m"; -const RED: &str = "\x1b[38;5;196m"; -const GRAY: &str = "\x1b[38;5;245m"; - struct StreamState { turn_num: u32, iteration_cost: f64, - iteration_duration_ms: f64, tool_counts: HashMap, total_tokens: u64, } @@ -30,7 +18,6 @@ impl StreamState { Self { turn_num: 0, iteration_cost: 0.0, - iteration_duration_ms: 0.0, tool_counts: HashMap::new(), total_tokens: 0, } diff --git a/tests/judge_adversarial.rs b/tests/judge_adversarial.rs new file mode 100644 index 0000000..842a7da --- /dev/null +++ b/tests/judge_adversarial.rs @@ -0,0 +1,777 @@ +//! Integration tests: adversarial scenarios targeting the cleaner's refactoring. +//! +//! These tests focus on: +//! 1. Stash extraction: does `yoke stash` / `yoke clean` still correctly +//! snapshot and restore .loop/ files after stash code moved to stash.rs? +//! 2. is_status_done refactor: does the plan loop correctly detect STATUS: DONE +//! when the generalized function is used instead of the old hardcoded one? +//! 3. Saga mode: does the refactored is_status_done(SAGA_NOTES_PATH) correctly +//! detect scoper completion (vs the old hardcoded is_saga_done)? +//! 4. Guard results file: after removing GuardResult.output field, does the +//! guard results markdown file still get written with pass/fail content? + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +/// Build the yoke binary path (relies on `cargo test` putting it in target/). +fn yoke_bin() -> std::path::PathBuf { + let mut path = std::env::current_exe() + .expect("current_exe") + .parent() + .expect("parent of test binary") + .parent() + .expect("parent of deps dir") + .to_path_buf(); + path.push("yoke"); + path +} + +/// Set up a minimal git repo in the given directory. +fn git_init(project: &std::path::Path) { + let git = |args: &[&str]| { + let out = Command::new("git") + .args(args) + .current_dir(project) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test") + .output() + .unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e)); + assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); + }; + git(&["init"]); + fs::write(project.join("dummy.txt"), "seed\n").unwrap(); + git(&["add", "dummy.txt"]); + git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]); +} + +// ── Test 1: stash + clean round-trip after extraction ────────────────── + +/// After extracting stash code to stash.rs, verify that `yoke clean` +/// still auto-stashes working files and that `yoke stash pop` restores them. +/// A broken extraction could lose file data or corrupt the stash index. +#[test] +fn stash_roundtrip_after_extraction() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + // Initialize brute mode (creates .loop/ with judge.md, etc.) + let out = Command::new(&yoke) + .args(["init", "brute"]) + .current_dir(project) + .output() + .expect("yoke init brute"); + assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr)); + + // Write distinctive content into plan.md and notes.md + let loop_dir = project.join(".loop"); + fs::write(loop_dir.join("plan.md"), "## Stage 1 — Build the widget\n\nDo the thing.\n").unwrap(); + fs::write(loop_dir.join("notes.md"), "STATUS: IN_PROGRESS\n\nSome important notes here.\n").unwrap(); + + // Stash the current state + let out = Command::new(&yoke) + .args(["stash"]) + .current_dir(project) + .output() + .expect("yoke stash"); + assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr)); + + // Verify stash log shows an entry + let out = Command::new(&yoke) + .args(["stash", "log"]) + .current_dir(project) + .output() + .expect("yoke stash log"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("mode=brute"), "stash log should show mode=brute, got:\n{}", stderr); + + // Clean — this should auto-stash then wipe + let out = Command::new(&yoke) + .args(["clean"]) + .current_dir(project) + .output() + .expect("yoke clean"); + assert!(out.status.success(), "yoke clean failed: {}", String::from_utf8_lossy(&out.stderr)); + + // Verify plan.md was emptied by clean + let plan = fs::read_to_string(loop_dir.join("plan.md")).unwrap(); + assert!(plan.is_empty(), "plan.md should be empty after clean, got: {:?}", plan); + + // Pop — should restore the auto-stashed state (which is the post-clean state, + // but let's verify we can pop without error, meaning the index is intact) + let out = Command::new(&yoke) + .args(["stash", "pop"]) + .current_dir(project) + .output() + .expect("yoke stash pop"); + assert!(out.status.success(), "yoke stash pop failed: {}", String::from_utf8_lossy(&out.stderr)); +} + +// ── Test 2: plan loop exits on STATUS: DONE with generalized is_status_done ── + +/// The cleaner changed `is_done()` (hardcoded to NOTES_PATH) into +/// `is_status_done(path)`. If any call site mistakenly passes the wrong path, +/// the loop would spin forever or exit prematurely. +/// +/// This test verifies: agent writes STATUS: DONE → yoke exits 0. +#[test] +fn plan_loop_exits_on_status_done() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + + // Minimal loop-mode setup (no judge.md → loop mode, not brute) + let protocol = "\ +# Protocol +Read plan.md, implement it, then set STATUS: DONE in notes.md. +"; + let conf = "allow .\n"; + let plan = "## Stage 1 — Do something\nJust touch a file.\n"; + + fs::write(loop_dir.join("protocol.md"), protocol).unwrap(); + fs::write(loop_dir.join("yoke.conf"), conf).unwrap(); + fs::write(loop_dir.join("plan.md"), plan).unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + // Mock claude: immediately writes STATUS: DONE and exits + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + + let mock_claude = r#"#!/usr/bin/env bash +set -euo pipefail +# Always signal done immediately +printf 'STATUS: DONE\n' > .loop/notes.md +exit 0 +"#; + let mock_path = mock_bin_dir.join("claude"); + fs::write(&mock_path, mock_claude).unwrap(); + fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); + + let output = Command::new(&yoke) + .args(["run", "--no-sandbox"]) + .current_dir(project) + .env("PATH", &test_path) + .output() + .expect("yoke run"); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // yoke should exit 0 — the generalized is_status_done(NOTES_PATH) found DONE + assert!( + output.status.success(), + "yoke should exit 0 when agent signals STATUS: DONE.\n\ + Exit code: {:?}\nStderr:\n{}", + output.status.code(), + stderr, + ); + + // Verify the "loop complete" message appears + assert!( + stderr.contains("loop complete"), + "stderr should contain 'loop complete', got:\n{}", + stderr, + ); +} + +// ── Test 3: brute mode still invokes judge and handles FAIL→PASS correctly ── + +/// After the stash extraction and is_done→is_status_done refactor, verify +/// the brute loop still: runs agent → runs judge → on FAIL retries → on PASS exits. +/// This is the same scenario as brute_verdict but run against the refactored code. +#[test] +fn brute_judge_fail_then_pass() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + + let protocol = "\ +# Protocol +Read plan, implement, set STATUS: DONE in notes.md. +"; + let plan = "## Stage 1 — Implement\nDo the feature.\n"; + let judge = "# Judge\nVerify the feature.\n\n## Verdict\nWrite verdict.\n"; + let conf = "allow .\n"; + + fs::write(loop_dir.join("protocol.md"), protocol).unwrap(); + fs::write(loop_dir.join("plan.md"), plan).unwrap(); + fs::write(loop_dir.join("judge.md"), judge).unwrap(); + fs::write(loop_dir.join("yoke.conf"), conf).unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("verdict.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + + // Mock claude: agent writes STATUS: DONE, judge FAILs once then PASSes + let mock_claude = r#"#!/usr/bin/env bash +set -euo pipefail +PROMPT="" +while [[ $# -gt 0 ]]; do + case "$1" in + -p) PROMPT="$2"; shift 2 ;; + *) shift ;; + esac +done + +if echo "$PROMPT" | grep -q "protocol.md"; then + printf 'STATUS: DONE\n' > .loop/notes.md +elif echo "$PROMPT" | grep -q "judge.md"; then + COUNTER=".loop/.judge-count" + N=0 + if [[ -f "$COUNTER" ]]; then N=$(cat "$COUNTER"); fi + N=$((N + 1)) + echo "$N" > "$COUNTER" + if [[ "$N" -eq 1 ]]; then + printf 'VERDICT: FAIL\n\nNot good enough.' > .loop/verdict.md + else + printf 'VERDICT: PASS\n\nLooks great.' > .loop/verdict.md + fi +fi +exit 0 +"#; + let mock_path = mock_bin_dir.join("claude"); + fs::write(&mock_path, mock_claude).unwrap(); + fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); + + let output = Command::new(&yoke) + .args(["run", "--no-sandbox"]) + .current_dir(project) + .env("PATH", &test_path) + .output() + .expect("yoke run"); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // Should exit 0 — judge eventually said PASS + assert!( + output.status.success(), + "yoke should exit 0 after judge PASS.\nExit: {:?}\nStderr:\n{}", + output.status.code(), + stderr, + ); + + // Judge should have been called exactly 2 times + let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap(); + assert_eq!( + judge_count.trim(), "2", + "judge should be called exactly twice (FAIL then PASS), got: {:?}", + judge_count.trim(), + ); +} + +// ── Test 4: saga exits on STATUS: DONE in saga-notes.md (not notes.md) ── + +/// The cleaner replaced the hardcoded `is_saga_done()` (which read SAGA_NOTES_PATH) +/// with the generic `is_status_done(SAGA_NOTES_PATH)`. If a refactoring mistake +/// accidentally passes NOTES_PATH instead, the saga loop would either spin forever +/// (scoper keeps writing DONE to saga-notes.md but yoke checks notes.md) or +/// would exit prematurely based on the inner brute worker's notes.md. +/// +/// This test sets up saga mode, mocks the scoper to write STATUS: DONE to +/// saga-notes.md on the first cycle, and verifies yoke exits 0 with the +/// "saga complete" message. +#[test] +fn saga_exits_on_saga_notes_done() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + + // Saga mode files + let saga_protocol = "# Saga Protocol\nRead specification.md, decompose into sub-plans.\n"; + let protocol = "# Worker Protocol\nRead plan, implement, set STATUS: DONE.\n"; + let judge = "# Judge\nVerify the sub-plan.\n\n## Verdict\nWrite verdict.\n"; + let conf = "allow .\n"; + let specification = "# Spec\nBuild a widget that does X.\n"; + + fs::write(loop_dir.join("saga-protocol.md"), saga_protocol).unwrap(); + fs::write(loop_dir.join("protocol.md"), protocol).unwrap(); + fs::write(loop_dir.join("judge.md"), judge).unwrap(); + fs::write(loop_dir.join("yoke.conf"), conf).unwrap(); + fs::write(loop_dir.join("specification.md"), specification).unwrap(); + fs::write(loop_dir.join("saga-notes.md"), "").unwrap(); + fs::write(loop_dir.join("decisions.md"), "").unwrap(); + fs::write(loop_dir.join("sub-plan.md"), "").unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("verdict.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + // Mock claude: scoper writes STATUS: DONE to saga-notes.md immediately. + // Critically: notes.md is left empty — if yoke checks notes.md instead of + // saga-notes.md, it would NOT see DONE and would spin forever. + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + + let mock_claude = r#"#!/usr/bin/env bash +set -euo pipefail +PROMPT="" +while [[ $# -gt 0 ]]; do + case "$1" in + -p) PROMPT="$2"; shift 2 ;; + *) shift ;; + esac +done + +if echo "$PROMPT" | grep -q "saga-protocol.md"; then + # Scoper: signal DONE via saga-notes.md + printf 'STATUS: DONE\n\nAll chunks complete.\n' > .loop/saga-notes.md +fi +exit 0 +"#; + let mock_path = mock_bin_dir.join("claude"); + fs::write(&mock_path, mock_claude).unwrap(); + fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); + + let output = Command::new(&yoke) + .args(["run", "--no-sandbox"]) + .current_dir(project) + .env("PATH", &test_path) + .output() + .expect("yoke run"); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // yoke should exit 0 — is_status_done(SAGA_NOTES_PATH) found DONE + assert!( + output.status.success(), + "yoke should exit 0 when scoper signals DONE in saga-notes.md.\n\ + Exit code: {:?}\nStderr:\n{}", + output.status.code(), + stderr, + ); + + // Verify the "saga complete" message appears + assert!( + stderr.contains("saga complete"), + "stderr should contain 'saga complete', got:\n{}", + stderr, + ); + + // notes.md should still be empty — confirms yoke checked saga-notes.md, not notes.md + let notes = fs::read_to_string(loop_dir.join("notes.md")).unwrap(); + assert!( + notes.is_empty(), + "notes.md should be empty (saga checks saga-notes.md), got: {:?}", + notes, + ); +} + +// ── Test 5: guard results file written correctly after output field removal ── + +/// The cleaner removed the `output` field from `GuardResult`. If the guard +/// results markdown file writing was accidentally broken by this change, +/// the agent would lose visibility into guard pass/fail details on the next +/// iteration — a silent data loss that could cause infinite loops. +/// +/// This test uses dry-run mode with a guard that fails, and verifies the +/// guard-results.md file contains the expected FAIL section with output. +#[test] +fn guard_results_written_after_output_field_removal() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + + // Loop mode with a guard that deliberately fails with distinctive output + let protocol = "# Protocol\nDo the thing.\n"; + let conf = "\ +allow . +guard echo SENTINEL_GUARD_OUTPUT && exit 1 +"; + let plan = "## Stage 1\nDo it.\n"; + + fs::write(loop_dir.join("protocol.md"), protocol).unwrap(); + fs::write(loop_dir.join("yoke.conf"), conf).unwrap(); + fs::write(loop_dir.join("plan.md"), plan).unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + // Dry-run: no Claude invocation, but guards still execute + let output = Command::new(&yoke) + .args(["run", "--no-sandbox", "--dry-run"]) + .current_dir(project) + .output() + .expect("yoke run --dry-run"); + + // Guard failed so yoke exits non-zero in dry-run + assert!( + !output.status.success(), + "yoke should exit non-zero when guard fails in dry-run" + ); + + // Verify guard-results.md was written with the guard output + let results = fs::read_to_string(loop_dir.join("guard-results.md")).unwrap(); + + assert!( + results.contains("FAIL"), + "guard-results.md should contain 'FAIL' for the failing guard.\nGot:\n{}", + results, + ); + assert!( + results.contains("SENTINEL_GUARD_OUTPUT"), + "guard-results.md should contain the guard's stdout ('SENTINEL_GUARD_OUTPUT').\n\ + If this is missing, the output field removal broke results file writing.\nGot:\n{}", + results, + ); +} + +// ── Test 6: brute bailout fires at exactly max-judge-failures ─────────── + +/// The cleaner extracted the bailout threshold check into `is_judge_bailout()`. +/// If the comparison operator was changed (e.g. `>` instead of `>=`), the brute +/// loop would either bail one iteration too late (wasting an API call) or too +/// early (never giving the worker a fair chance). +/// +/// This test configures `max-judge-failures 2` and has the judge always FAIL. +/// Expects: yoke exits non-zero after exactly 2 judge failures (2 brute iterations). +#[test] +fn brute_bailout_at_max_judge_failures() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + + let protocol = "\ +# Protocol +Read plan, implement, set STATUS: DONE in notes.md. +"; + let plan = "## Stage 1 — Implement\nDo the feature.\n"; + let judge = "# Judge\nVerify the feature.\n\n## Verdict\nWrite verdict.\n"; + // max-judge-failures 2 — should bail after exactly 2 consecutive judge FAILs + let conf = "\ +allow . +max-judge-failures 2 +"; + + fs::write(loop_dir.join("protocol.md"), protocol).unwrap(); + fs::write(loop_dir.join("plan.md"), plan).unwrap(); + fs::write(loop_dir.join("judge.md"), judge).unwrap(); + fs::write(loop_dir.join("yoke.conf"), conf).unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("verdict.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + + // Mock claude: agent always writes STATUS: DONE, judge always FAILs. + // Tracks call counts so we can assert the exact number of iterations. + let mock_claude = r#"#!/usr/bin/env bash +set -euo pipefail +PROMPT="" +while [[ $# -gt 0 ]]; do + case "$1" in + -p) PROMPT="$2"; shift 2 ;; + *) shift ;; + esac +done + +if echo "$PROMPT" | grep -q "protocol.md"; then + COUNTER=".loop/.agent-count" + N=0 + if [[ -f "$COUNTER" ]]; then N=$(cat "$COUNTER"); fi + N=$((N + 1)) + echo "$N" > "$COUNTER" + printf 'STATUS: DONE\n' > .loop/notes.md +elif echo "$PROMPT" | grep -q "judge.md"; then + COUNTER=".loop/.judge-count" + N=0 + if [[ -f "$COUNTER" ]]; then N=$(cat "$COUNTER"); fi + N=$((N + 1)) + echo "$N" > "$COUNTER" + # Always FAIL + printf 'VERDICT: FAIL\n\nStill broken.\n' > .loop/verdict.md +fi +exit 0 +"#; + let mock_path = mock_bin_dir.join("claude"); + fs::write(&mock_path, mock_claude).unwrap(); + fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); + + let output = Command::new(&yoke) + .args(["run", "--no-sandbox"]) + .current_dir(project) + .env("PATH", &test_path) + .output() + .expect("yoke run"); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // yoke should exit non-zero (bailout) + assert!( + !output.status.success(), + "yoke should exit non-zero after max judge failures.\nExit: {:?}\nStderr:\n{}", + output.status.code(), + stderr, + ); + + // Verify stderr mentions bailing out + assert!( + stderr.contains("bailing out"), + "stderr should mention 'bailing out', got:\n{}", + stderr, + ); + + // Judge should have been called exactly 2 times (matching max-judge-failures) + let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap(); + assert_eq!( + judge_count.trim(), "2", + "judge should be called exactly 2 times (max-judge-failures=2), got: {:?}\nStderr:\n{}", + judge_count.trim(), + stderr, + ); +} + +// ── Test 7: judge-every fires judge on DONE and exits on PASS ────────── + +/// The cleaner extracted the judge-every logic into `evaluate_judge_every()`. +/// If the extraction broke the DONE→judge→PASS→exit path, the plan loop +/// would either: never invoke the judge (spinning forever), invoke it but +/// ignore the PASS (spinning forever), or skip the judge and exit without +/// verification (silent quality regression). +/// +/// This test configures `judge-every 5` in loop mode with a judge.md present. +/// The agent signals DONE on iteration 1 (before cadence 5), so the judge +/// should fire because DONE always triggers the judge regardless of cadence. +/// The judge returns PASS, so yoke should exit 0. +#[test] +fn judge_every_fires_on_done_and_exits() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + + let protocol = "\ +# Protocol +Read plan, implement, set STATUS: DONE in notes.md. +"; + let plan = "## Stage 1 — Implement\nDo the feature.\n"; + let judge = "# Judge\nVerify the feature.\n\n## Verdict\nWrite verdict.\n"; + // judge-every 5: cadence is 5, but DONE should fire judge immediately + let conf = "\ +allow . +judge-every 5 +"; + + fs::write(loop_dir.join("protocol.md"), protocol).unwrap(); + fs::write(loop_dir.join("yoke.conf"), conf).unwrap(); + fs::write(loop_dir.join("plan.md"), plan).unwrap(); + fs::write(loop_dir.join("judge.md"), judge).unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("verdict.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + + // Mock claude: agent immediately signals DONE, judge immediately returns PASS + let mock_claude = r#"#!/usr/bin/env bash +set -euo pipefail +PROMPT="" +while [[ $# -gt 0 ]]; do + case "$1" in + -p) PROMPT="$2"; shift 2 ;; + *) shift ;; + esac +done + +if echo "$PROMPT" | grep -q "protocol.md"; then + printf 'STATUS: DONE\n' > .loop/notes.md +elif echo "$PROMPT" | grep -q "judge.md"; then + printf 'VERDICT: PASS\n\nAll good.\n' > .loop/verdict.md +fi +exit 0 +"#; + let mock_path = mock_bin_dir.join("claude"); + fs::write(&mock_path, mock_claude).unwrap(); + fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); + + let output = Command::new(&yoke) + .args(["run", "--no-sandbox"]) + .current_dir(project) + .env("PATH", &test_path) + .output() + .expect("yoke run"); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // yoke should exit 0 — judge-every detected DONE and judge said PASS + assert!( + output.status.success(), + "yoke should exit 0 when judge-every fires on DONE and judge PASSes.\n\ + Exit code: {:?}\nStderr:\n{}", + output.status.code(), + stderr, + ); + + // Verify the judge was actually invoked (not skipped) + assert!( + stderr.contains("Judge (DONE)"), + "stderr should show 'Judge (DONE)' banner (judge fired on worker DONE), got:\n{}", + stderr, + ); + + // Verify verdict.md has PASS + let verdict = fs::read_to_string(loop_dir.join("verdict.md")).unwrap(); + assert!( + verdict.starts_with("VERDICT: PASS"), + "verdict.md should contain PASS, got: {:?}", + verdict, + ); +} + +// ── Test 8: stash mode tag preserved after module extraction ──────────── + +/// After stash code was extracted to stash.rs, the `mode` parameter is now +/// passed from main.rs rather than calling `detect_mode()` internally. +/// If the caller passes the wrong mode, stash entries would have incorrect +/// mode tags, making `yoke stash log` misleading and potentially breaking +/// mode-aware restoration logic. +/// +/// This test initializes brute mode, creates a stash, and verifies the +/// stash index records "brute" (not "unknown" or empty). +#[test] +fn stash_records_correct_mode_after_extraction() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + + git_init(project); + + // Initialize brute mode + let out = Command::new(&yoke) + .args(["init", "brute"]) + .current_dir(project) + .output() + .expect("yoke init brute"); + assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr)); + + // Write some content so stash has something to snapshot + fs::write(project.join(".loop/plan.md"), "## Stage 1\nDo it.\n").unwrap(); + + // Create a stash + let out = Command::new(&yoke) + .args(["stash"]) + .current_dir(project) + .output() + .expect("yoke stash"); + assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr)); + + // Read the stash index directly and verify mode=brute + let index_path = project.join(".loop/.stash/index"); + assert!(index_path.exists(), "stash index should exist after stashing"); + + let index = fs::read_to_string(&index_path).unwrap(); + // Index format: hash|timestamp|mode|file1,file2,... + let first_line = index.lines().next().expect("index should have at least one line"); + let parts: Vec<&str> = first_line.splitn(4, '|').collect(); + assert!( + parts.len() >= 3, + "index line should have at least 3 pipe-separated fields, got: {:?}", + first_line, + ); + assert_eq!( + parts[2], "brute", + "stash mode should be 'brute' (not 'unknown' or empty). \ + If this fails, the mode parameter is not being passed correctly \ + from main.rs to stash.rs after extraction.\nIndex line: {:?}", + first_line, + ); +}