stash
This commit is contained in:
parent
c4facd1562
commit
d065009aba
14 changed files with 1321 additions and 987 deletions
11
src/ansi.rs
Normal file
11
src/ansi.rs
Normal 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";
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
148
src/heartbeat.rs
148
src/heartbeat.rs
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
744
src/main.rs
744
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,24 +670,44 @@ fn invoke_periodic(runner: &mut LoopRunner, config: &Config, periodic: &Periodic
|
|||
status
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if periodic.guards.is_empty() {
|
||||
/// 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 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 {
|
||||
for r in results {
|
||||
let cmd_padded = format!("{:<w$}", r.name, w = cmd_w);
|
||||
let status_cell = if r.skipped {
|
||||
format!("{}SKIPPED {}", ORANGE, RESET)
|
||||
|
|
@ -712,6 +725,18 @@ fn run_periodic_guards(periodic: &Periodic, max_tail: usize) {
|
|||
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) {
|
||||
if periodic.guards.is_empty() {
|
||||
return;
|
||||
}
|
||||
let results_path = PathBuf::from(format!(".loop/periodic-{}-results.md", periodic.name));
|
||||
let results = guard::run_guards(&periodic.guards, max_tail, &results_path);
|
||||
|
||||
render_guard_table(&results);
|
||||
|
||||
let all_passed = results.iter().all(|r| r.passed);
|
||||
if !all_passed {
|
||||
|
|
@ -721,7 +746,6 @@ fn run_periodic_guards(periodic: &Periodic, max_tail: usize) {
|
|||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a full-width box-drawn banner for the judge verdict.
|
||||
/// Includes the first ~2 lines of the verdict reason text.
|
||||
|
|
@ -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!("{:<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);
|
||||
}
|
||||
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<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(×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 <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.
|
||||
fn mode_files(mode: &str) -> Option<Vec<(&'static str, &'static str)>> {
|
||||
|
|
@ -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;
|
||||
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,
|
||||
}
|
||||
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)
|
||||
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 <hash>");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
346
src/stash.rs
Normal 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(×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 <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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String, u32>,
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
777
tests/judge_adversarial.rs
Normal file
777
tests/judge_adversarial.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue