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 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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
788
src/main.rs
788
src/main.rs
|
|
@ -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(×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.
|
/// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::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,
|
||||||
|
|
|
||||||
|
|
@ -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
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