This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-03-05 19:36:45 +07:00
parent c4facd1562
commit d065009aba
14 changed files with 1321 additions and 987 deletions

11
src/ansi.rs Normal file
View file

@ -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";

View file

@ -14,7 +14,6 @@ use std::time::Instant;
pub struct GuardResult { pub struct GuardResult {
pub name: String, pub name: String,
pub passed: bool, pub passed: bool,
pub output: String,
pub skipped: bool, pub skipped: bool,
pub elapsed_secs: f64, pub elapsed_secs: f64,
} }
@ -115,7 +114,6 @@ pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Ve
results.push(GuardResult { results.push(GuardResult {
name, name,
passed, passed,
output: truncated,
skipped, skipped,
elapsed_secs, elapsed_secs,
}); });

View file

@ -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<dyn Fn(&HeartbeatEvent) + Send + 'static>;
/// 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<AtomicBool>,
listeners: Arc<Mutex<Vec<ListenerEntry>>>,
handle: Option<thread::JoinHandle<()>>,
tick_count: Arc<Mutex<u64>>,
}
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<String>, 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();
}
}

View file

@ -1,12 +1,12 @@
#![warn(clippy::string_slice)] #![warn(clippy::string_slice)]
mod ansi;
mod boundary; mod boundary;
mod config; mod config;
mod guard; mod guard;
mod heartbeat;
mod json; mod json;
mod registry;
mod signal; mod signal;
mod stash;
mod stream; mod stream;
mod stream_opencode; mod stream_opencode;
@ -43,7 +43,7 @@ const SPECIFICATION_PATH: &str = ".loop/specification.md";
const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md"; const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md";
const DECISIONS_PATH: &str = ".loop/decisions.md"; const DECISIONS_PATH: &str = ".loop/decisions.md";
const SUB_PLAN_PATH: &str = ".loop/sub-plan.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. /// Parse the plan file and notes.md to determine stage progress.
/// Returns `Some((completed, total))` if the plan has parseable `## Stage` headers. /// 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 use ansi::{BLUE, BOLD, DIM, GREEN, ORANGE, RED, RESET};
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";
const MAGENTA: &str = "\x1b[38;5;207m"; const MAGENTA: &str = "\x1b[38;5;207m";
fn capitalize_first(s: &str) -> String { 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); 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); 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. /// Check the first line of a notes file for STATUS: DONE.
fn is_done() -> bool { fn is_status_done(path: &str) -> bool {
match fs::read_to_string(NOTES_PATH) { match fs::read_to_string(path) {
Ok(content) => content.starts_with("STATUS: DONE"), Ok(content) => content.starts_with("STATUS: DONE"),
Err(_) => false, Err(_) => false,
} }
@ -677,6 +670,63 @@ fn invoke_periodic(runner: &mut LoopRunner, config: &Config, periodic: &Periodic
status 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!("{:<w$}", r.name, w = cmd_w);
let status_cell = if r.skipped {
format!("{}SKIPPED {}", ORANGE, RESET)
} else if r.passed {
format!(" {} PASS {} ", GREEN, RESET)
} else {
format!(" {} FAIL {} ", RED, RESET)
};
let time_cell = if r.skipped {
format!(" - ")
} else {
let secs = r.elapsed_secs.round() as u64;
format!("{:>3}s ", secs)
};
eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell);
}
eprintln!("{}", bottom);
}
/// Run guard-after commands for a periodic agent. /// Run guard-after commands for a periodic agent.
/// Results are written to a separate file so the worker isn't confused. /// Results are written to a separate file so the worker isn't confused.
fn run_periodic_guards(periodic: &Periodic, max_tail: usize) { 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_path = PathBuf::from(format!(".loop/periodic-{}-results.md", periodic.name));
let results = guard::run_guards(&periodic.guards, max_tail, &results_path); let results = guard::run_guards(&periodic.guards, max_tail, &results_path);
// Render a compact table for periodic guard results render_guard_table(&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!("{:<w$}", r.name, w = cmd_w);
let status_cell = if r.skipped {
format!("{}SKIPPED {}", ORANGE, RESET)
} else if r.passed {
format!(" {} PASS {} ", GREEN, RESET)
} else {
format!(" {} FAIL {} ", RED, RESET)
};
let time_cell = if r.skipped {
format!(" - ")
} else {
let secs = r.elapsed_secs.round() as u64;
format!("{:>3}s ", secs)
};
eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell);
}
eprintln!("{}", bottom);
let all_passed = results.iter().all(|r| r.passed); let all_passed = results.iter().all(|r| r.passed);
if !all_passed { if !all_passed {
log(&format!( log(&format!(
"{}WARNING: periodic '{}' guard(s) failed — results in {}{}", "{}WARNING: periodic '{}' guard(s) failed — results in {}{}",
ORANGE, periodic.name, format!(".loop/periodic-{}-results.md", periodic.name), RESET 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); let all_passed = guard_results.iter().all(|r| r.passed);
// Render guard results as a box-drawn table with timing render_guard_table(&guard_results);
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!("{:<w$}", r.name, w = cmd_w);
// Status column: colored, centered in stat_w characters
let status_cell = if r.skipped {
format!("{}SKIPPED {}", ORANGE, RESET)
} else if r.passed {
format!(" {} PASS {} ", GREEN, RESET)
} else {
format!(" {} FAIL {} ", RED, RESET)
};
// Time column
let time_cell = if r.skipped {
format!(" - ")
} else {
let secs = r.elapsed_secs.round() as u64;
format!("{:>3}s ", secs)
};
eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell);
}
eprintln!("{}", bottom);
}
all_passed all_passed
} }
@ -1020,7 +1009,8 @@ fn clean() -> i32 {
} }
// Stash non-empty working files before wiping // 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..."); log("Cleaning .loop/ working files...");
@ -1061,347 +1051,6 @@ fn clean() -> i32 {
0 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<u8>)]) -> 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<u8>)> {
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<String>,
}
/// Parse `.loop/.stash/index` into a list of stash entries (oldest first).
fn parse_stash_index() -> Vec<StashEntry> {
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<String, String> {
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(&timestamp, &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 <hash>` — 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 <hash>{} 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. /// Return the list of (path, default_content) pairs for a given mode.
fn mode_files(mode: &str) -> Option<Vec<(&'static str, &'static str)>> { fn mode_files(mode: &str) -> Option<Vec<(&'static str, &'static str)>> {
@ -1620,6 +1269,80 @@ enum PlanLoopOutcome {
Error, 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. /// Core plan-loop runner that can be called standalone or nested inside brute.
/// ///
/// - `config`: already-loaded Config /// - `config`: already-loaded Config
@ -1685,33 +1408,8 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool)
} }
iteration += 1; iteration += 1;
eprintln!(); eprintln!();
if nested { let label = if nested { "Plan Iteration" } else { "Iteration" };
eprintln!( render_iteration_banner(label, iteration, nested);
"{}{}┌──────────────────────────────────────┐{}",
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
);
}
// Show stage progress bar if plan has parseable stages // Show stage progress bar if plan has parseable stages
if let Some((completed, total)) = stage_progress(plan_path) { 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 { if iteration % periodic.cadence == 0 {
eprintln!(); eprintln!();
let upper_name = capitalize_first(&periodic.name); let upper_name = capitalize_first(&periodic.name);
eprintln!( render_section_banner(
"{}{}┌─ {} {}┐{}", &upper_name,
BOLD, MAGENTA, &format!("Periodic agent (iteration {:>4})", iteration),
upper_name, MAGENTA,
"─".repeat(37usize.saturating_sub(upper_name.len() + 2)),
RESET
);
eprintln!(
"{}{}│ Periodic agent (iteration {:>4}) │{}",
BOLD, MAGENTA, iteration, RESET
);
eprintln!(
"{}{}└────────────────────────────────────────┘{}",
BOLD, MAGENTA, RESET
); );
let ok = invoke_periodic(&mut runner, config, periodic, iteration); let ok = invoke_periodic(&mut runner, config, periodic, iteration);
if !ok { 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 // Judge-every logic: embedded judge checks at configured cadence
if let Some(judge_every) = config.judge_every { if let Some(judge_every) = config.judge_every {
if guards_passed && is_done() { match evaluate_judge_every(
// Always fire judge when worker signals DONE &mut runner, config, iteration, guards_passed,
eprintln!(); &mut consecutive_judge_failures, judge_every,
eprintln!( ) {
"{}{}┌─ Judge (DONE) ─────────────────────────┐{}", JudgeEveryAction::Pass => return PlanLoopOutcome::JudgePass,
BOLD, BLUE, RESET JudgeEveryAction::Bailout => return PlanLoopOutcome::JudgeBailout,
); JudgeEveryAction::Continue => continue,
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
} }
// 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) // 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!();
eprintln!( eprintln!(
"{}{} STATUS: DONE and all guards pass \u{2014} loop complete {}", "{}{} 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; iteration += 1;
eprintln!(); eprintln!();
eprintln!( render_iteration_banner("Brute Iteration", iteration, false);
"{}{}╔══════════════════════════════════════╗{}",
BOLD, ORANGE, RESET
);
eprintln!(
"{}{}║ Brute Iteration {:>4} ║{}",
BOLD, ORANGE, iteration, RESET
);
eprintln!(
"{}{}╚══════════════════════════════════════╝{}",
BOLD, ORANGE, RESET
);
// Restore protected files // Restore protected files
restore_files(&runner.backup_dir, BRUTE_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 // Guards passed — invoke judge
eprintln!(); eprintln!();
eprintln!( render_section_banner(
"{}{}┌─ Judge ────────────────────────────────┐{}", "Judge",
BOLD, BLUE, RESET &format!("Invoking judge (attempt {:>4})", iteration),
); BLUE,
eprintln!(
"{}{}│ Invoking judge (attempt {:>4}) │{}",
BOLD, BLUE, iteration, RESET
);
eprintln!(
"{}{}└────────────────────────────────────────┘{}",
BOLD, BLUE, RESET
); );
let pass = invoke_judge(&mut runner, config, iteration); 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 RESET
)); ));
if consecutive_failures >= max_failures { if is_judge_bailout(consecutive_failures, max_failures) {
eprintln!();
eprintln!(
"{}{} {} consecutive judge FAILs \u{2014} bailing out {}",
RED, BOLD, max_failures, RESET
);
eprintln!();
return BruteResult::Bailout; 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, /// Invoke the scoper (Agent 1) — a fresh LLM session that reads the spec,
/// writes sub-plan.md, and updates saga-notes.md. /// writes sub-plan.md, and updates saga-notes.md.
@ -2275,18 +1870,7 @@ fn run_saga(dry_run: bool) -> i32 {
} }
cycle += 1; cycle += 1;
eprintln!(); eprintln!();
eprintln!( render_iteration_banner("Saga Cycle", cycle, false);
"{}{}╔══════════════════════════════════════╗{}",
BOLD, ORANGE, RESET
);
eprintln!(
"{}{}║ Saga Cycle {:>4} ║{}",
BOLD, ORANGE, cycle, RESET
);
eprintln!(
"{}{}╚══════════════════════════════════════╝{}",
BOLD, ORANGE, RESET
);
// Restore protected files // Restore protected files
restore_files(&runner.backup_dir, &saga_protected); restore_files(&runner.backup_dir, &saga_protected);
@ -2296,17 +1880,10 @@ fn run_saga(dry_run: bool) -> i32 {
} else { } else {
// Invoke Agent 1 (scoper) // Invoke Agent 1 (scoper)
eprintln!(); eprintln!();
eprintln!( render_section_banner(
"{}{}┌\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}┐{}", "Scoper",
BOLD, BLUE, RESET &format!("Invoking scoper (cycle {:>4})", cycle),
); BLUE,
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
); );
let scoper_ok = invoke_scoper(&mut runner, &config, cycle); 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 // Check if scoper signaled DONE
if is_saga_done() { if is_status_done(SAGA_NOTES_PATH) {
eprintln!(); eprintln!();
eprintln!( eprintln!(
"{}{} Scoper signals DONE \u{2014} saga complete {}", "{}{} Scoper signals DONE \u{2014} saga complete {}",
@ -2433,29 +2010,30 @@ fn main() {
} }
"stash" => { "stash" => {
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
print_stash_help(); stash::print_stash_help();
return; return;
} }
let mode = detect_mode().unwrap_or("unknown");
match args.get(2).map(|a| a.as_str()) { match args.get(2).map(|a| a.as_str()) {
None => { None => {
process::exit(stash_create()); process::exit(stash::stash_create(mode));
} }
Some("log") => { Some("log") => {
process::exit(stash_log_cmd()); process::exit(stash::stash_log_cmd());
} }
Some("checkout") => match args.get(3) { Some("checkout") => match args.get(3) {
Some(hash) => process::exit(stash_checkout(hash)), Some(hash) => process::exit(stash::stash_checkout(hash, mode)),
None => { None => {
log_error("missing hash — usage: yoke stash checkout <hash>"); log_error("missing hash — usage: yoke stash checkout <hash>");
process::exit(2); process::exit(2);
} }
}, },
Some("pop") => { Some("pop") => {
process::exit(stash_pop()); process::exit(stash::stash_pop(mode));
} }
Some(other) => { Some(other) => {
log_error(&format!("unknown subcommand '{}'", other)); log_error(&format!("unknown subcommand '{}'", other));
print_stash_help(); stash::print_stash_help();
process::exit(2); process::exit(2);
} }
} }

View file

@ -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<String, Widget>,
/// Insertion order preserved for deterministic iteration.
order: Vec<String>,
}
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<String>, 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<Item = &Widget> {
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<Widget> {
if let Some(w) = self.widgets.remove(name) {
self.order.retain(|n| n != name);
Some(w)
} else {
None
}
}
}

View file

@ -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"
]
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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?

View file

@ -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."

346
src/stash.rs Normal file
View file

@ -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<u8>)]) -> 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<u8>)> {
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<String>,
}
/// Parse `.loop/.stash/index` into a list of stash entries (oldest first).
pub(crate) fn parse_stash_index() -> Vec<StashEntry> {
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<String, String> {
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(&timestamp, &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 <hash>` — 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 <hash>{} 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");
}

View file

@ -4,23 +4,11 @@ use std::path::Path;
use std::process::ChildStdout; use std::process::ChildStdout;
use std::time::Instant; 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}; use crate::json::{extract_num, extract_str, unescape_json};
// ANSI escape codes const BG_RED: &str = "\x1b[48;2;80;30;30m";
const RESET: &str = "\x1b[0m"; const BG_GREEN: &str = "\x1b[48;2;30;60;30m";
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
struct StreamState { struct StreamState {
turn_num: u32, turn_num: u32,

View file

@ -3,24 +3,12 @@ use std::io::{self, BufRead, BufReader, Write};
use std::path::Path; use std::path::Path;
use std::process::ChildStdout; 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}; 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 { struct StreamState {
turn_num: u32, turn_num: u32,
iteration_cost: f64, iteration_cost: f64,
iteration_duration_ms: f64,
tool_counts: HashMap<String, u32>, tool_counts: HashMap<String, u32>,
total_tokens: u64, total_tokens: u64,
} }
@ -30,7 +18,6 @@ impl StreamState {
Self { Self {
turn_num: 0, turn_num: 0,
iteration_cost: 0.0, iteration_cost: 0.0,
iteration_duration_ms: 0.0,
tool_counts: HashMap::new(), tool_counts: HashMap::new(),
total_tokens: 0, total_tokens: 0,
} }

777
tests/judge_adversarial.rs Normal file
View file

@ -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,
);
}