From 20abe1108c249ab96624474387bda22909e91a28 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Wed, 4 Mar 2026 22:38:23 +0700 Subject: [PATCH] stash changes --- src/config.rs | 23 + src/guard.rs | 142 +- src/heartbeat.rs | 148 +++ src/json.rs | 2 + src/main.rs | 1862 +++++++++++++++++++-------- src/registry.rs | 86 ++ src/scratch/config.json | 21 + src/scratch/demo.rs | 44 + src/scratch/extra.toml | 11 + src/scratch/notes.md | 17 + src/scratch/setup.sh | 24 + src/stream.rs | 523 ++++++-- src/stream_opencode.rs | 222 ++++ src/templates/brute/judge.md | 31 + src/templates/brute/protocol.md | 62 + src/templates/brute/yoke.conf | 41 + src/templates/layers/repl.md | 10 + src/templates/loop/briefing.md | 20 + src/templates/loop/protocol.md | 65 + src/templates/loop/yoke.conf | 61 + src/templates/saga/judge.md | 31 + src/templates/saga/protocol.md | 62 + src/templates/saga/saga-protocol.md | 73 ++ src/templates/saga/yoke.conf | 41 + 24 files changed, 2946 insertions(+), 676 deletions(-) create mode 100644 src/heartbeat.rs create mode 100644 src/registry.rs create mode 100644 src/scratch/config.json create mode 100644 src/scratch/demo.rs create mode 100644 src/scratch/extra.toml create mode 100644 src/scratch/notes.md create mode 100644 src/scratch/setup.sh create mode 100644 src/stream_opencode.rs create mode 100644 src/templates/brute/judge.md create mode 100644 src/templates/brute/protocol.md create mode 100644 src/templates/brute/yoke.conf create mode 100644 src/templates/layers/repl.md create mode 100644 src/templates/loop/briefing.md create mode 100644 src/templates/loop/protocol.md create mode 100644 src/templates/loop/yoke.conf create mode 100644 src/templates/saga/judge.md create mode 100644 src/templates/saga/protocol.md create mode 100644 src/templates/saga/saga-protocol.md create mode 100644 src/templates/saga/yoke.conf diff --git a/src/config.rs b/src/config.rs index 0226d2f..94a16f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,12 @@ use std::fs; use std::path::Path; +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Backend { + Claude, + OpenCode, +} + #[derive(Debug, Clone, PartialEq)] pub enum ScopeTag { Allow, @@ -19,11 +25,13 @@ pub struct Config { pub max_tail: usize, pub log_dir: Option, pub image: Option, + pub model: Option, pub scope_rules: Vec, pub guards: Vec, } impl Config { + #[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find() pub fn load(path: &Path) -> Result { let content = fs::read_to_string(path) .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?; @@ -31,6 +39,7 @@ impl Config { let mut max_tail: usize = 200; let mut log_dir: Option = None; let mut image: Option = None; + let mut model: Option = None; let mut scope_rules = Vec::new(); let mut guards = Vec::new(); for (line_num, raw_line) in content.lines().enumerate() { @@ -74,6 +83,9 @@ impl Config { "image" => { image = Some(value.to_string()); } + "model" => { + model = Some(value.to_string()); + } "allow" => scope_rules.push(ScopeRule { tag: ScopeTag::Allow, prefix: value.to_string(), @@ -102,6 +114,7 @@ impl Config { max_tail, log_dir, image, + model, scope_rules, guards, }) @@ -130,4 +143,14 @@ impl Config { // If only "." matched, best_len is 1, which is correct. best_tag } + + /// Determine which backend to use based on config. + /// If `model` is set, use OpenCode; otherwise default to Claude CLI. + pub fn backend(&self) -> Backend { + if self.model.is_some() { + Backend::OpenCode + } else { + Backend::Claude + } + } } diff --git a/src/guard.rs b/src/guard.rs index 140f44d..4efbfe4 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -1,13 +1,22 @@ +//! Guard Orchestra — parallel guard runner that executes configured shell +//! commands, captures stdout/stderr, measures elapsed time, and produces +//! structured results for the box-drawn table in main.rs. +//! +//! All guards are spawned concurrently via threads. Results are collected +//! and returned in the original command order. + use std::fs; use std::path::Path; use std::process::Command; +use std::thread; +use std::time::Instant; -#[allow(dead_code)] pub struct GuardResult { pub name: String, pub passed: bool, pub output: String, pub skipped: bool, + pub elapsed_secs: f64, } /// Keep only the last `max` lines of text. @@ -19,73 +28,102 @@ fn tail_lines(text: &str, max: usize) -> String { lines[lines.len() - max..].join("\n") } -/// Run all configured guard commands in order (fail-fast). -/// Writes results to `results_path` in markdown format. -pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec { - let mut results = Vec::new(); - let mut markdown = String::new(); - let mut failed = false; +/// Execute a single guard command synchronously. +/// Returns (passed, raw_output, elapsed_secs). +fn run_one(cmd: &str) -> (bool, String, f64) { + let start = Instant::now(); + let output = Command::new("sh") + .arg("-c") + .arg(cmd) + .output(); + let elapsed_secs = start.elapsed().as_secs_f64(); - for cmd in guards { - if failed { - let result = GuardResult { - name: cmd.clone(), - passed: false, - output: String::new(), - skipped: true, - }; - markdown.push_str(&format!("## {} — SKIPPED\n\n", cmd)); - markdown.push_str("```\nSkipped due to earlier guard failure.\n```\n\n"); - results.push(result); - continue; - } - - let output = Command::new("sh") - .arg("-c") - .arg(cmd) - .output(); - - let (exit_ok, raw_output) = match output { - Ok(o) => { - let mut combined = String::from_utf8_lossy(&o.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&o.stderr); - if !stderr.is_empty() { - if !combined.is_empty() && !combined.ends_with('\n') { - combined.push('\n'); - } - combined.push_str(&stderr); + let (exit_ok, raw_output) = match output { + Ok(o) => { + let mut combined = String::from_utf8_lossy(&o.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&o.stderr); + if !stderr.is_empty() { + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); } - (o.status.success(), combined) + combined.push_str(&stderr); + } + (o.status.success(), combined) + } + Err(e) => (false, format!("failed to execute: {}", e)), + }; + + (exit_ok, raw_output, elapsed_secs) +} + +/// Run all configured guard commands in parallel. +/// Each guard is spawned on its own thread. Results are collected in the +/// original command order and written to `results_path` in markdown format. +/// +/// The rendered box-drawn table with PASS/FAIL/SKIPPED status and timing +/// is handled by the caller in main.rs. +pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec { + if guards.is_empty() { + let _ = fs::write(results_path, ""); + return Vec::new(); + } + + // Spawn all guards in parallel + let handles: Vec<_> = guards + .iter() + .map(|cmd| { + let cmd = cmd.clone(); + thread::spawn(move || { + if crate::signal::interrupted() { + return (cmd, false, String::new(), true, 0.0); + } + let (passed, raw_output, elapsed) = run_one(&cmd); + (cmd, passed, raw_output, false, elapsed) + }) + }) + .collect(); + + // Collect results in order + let mut results = Vec::with_capacity(guards.len()); + let mut markdown = String::new(); + + for handle in handles { + let (name, passed, raw_output, skipped, elapsed_secs) = match handle.join() { + Ok(r) => r, + Err(_) => { + // Thread panicked — treat as failure + (String::from("(unknown)"), false, String::from("guard thread panicked"), false, 0.0) } - Err(e) => (false, format!("failed to execute: {}", e)), }; let truncated = tail_lines(&raw_output, max_tail); - let status_label = if exit_ok { "PASS" } else { "FAIL" }; - markdown.push_str(&format!("## {} — {}\n\n", cmd, status_label)); - markdown.push_str("```\n"); - markdown.push_str(&truncated); - if !truncated.is_empty() && !truncated.ends_with('\n') { - markdown.push('\n'); - } - markdown.push_str("```\n\n"); - - if !exit_ok { - failed = true; + if skipped { + markdown.push_str(&format!("## {} \u{2014} SKIPPED\n\n", name)); + markdown.push_str("```\nSkipped due to interrupt.\n```\n\n"); + } else { + let status_label = if passed { "PASS" } else { "FAIL" }; + markdown.push_str(&format!("## {} \u{2014} {}\n\n", name, status_label)); + markdown.push_str("```\n"); + markdown.push_str(&truncated); + if !truncated.is_empty() && !truncated.ends_with('\n') { + markdown.push('\n'); + } + markdown.push_str("```\n\n"); } results.push(GuardResult { - name: cmd.clone(), - passed: exit_ok, + name, + passed, output: truncated, - skipped: false, + skipped, + elapsed_secs, }); } // Write results file if let Err(e) = fs::write(results_path, &markdown) { - eprintln!("[ci] WARNING: failed to write guard results: {}", e); + eprintln!("[yoke] WARNING: failed to write guard results: {}", e); } results diff --git a/src/heartbeat.rs b/src/heartbeat.rs new file mode 100644 index 0000000..0717def --- /dev/null +++ b/src/heartbeat.rs @@ -0,0 +1,148 @@ +//! Pulse Heartbeat — background tick that fires every 500ms and emits +//! structured events to registered listeners. +//! +//! The heartbeat is stoppable and restartable without losing listener +//! subscriptions. Listeners receive a `HeartbeatEvent` on each tick. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Structured event emitted on each heartbeat tick. +#[derive(Debug, Clone)] +pub struct HeartbeatEvent { + /// Monotonically increasing tick number (starts at 1). + pub tick: u64, + /// Wall-clock instant when this tick fired. + pub timestamp: Instant, + /// Elapsed time since the heartbeat was (re)started. + pub elapsed: Duration, +} + +/// Callback type for heartbeat listeners. +pub type Listener = Box; + +/// A named listener entry so subscriptions survive stop/start cycles. +struct ListenerEntry { + name: String, + callback: Listener, +} + +/// Background heartbeat that ticks at a fixed interval and notifies listeners. +/// +/// Listeners are registered once and persist across stop/start cycles. +/// The heartbeat thread is spawned on `start()` and joined on `stop()`. +pub struct Heartbeat { + interval: Duration, + running: Arc, + listeners: Arc>>, + handle: Option>, + tick_count: Arc>, +} + +impl Heartbeat { + /// Create a new heartbeat with the given tick interval. + pub fn new(interval: Duration) -> Self { + Self { + interval, + running: Arc::new(AtomicBool::new(false)), + listeners: Arc::new(Mutex::new(Vec::new())), + handle: None, + tick_count: Arc::new(Mutex::new(0)), + } + } + + /// Create a heartbeat with the default 500ms interval. + pub fn default_pulse() -> Self { + Self::new(Duration::from_millis(500)) + } + + /// Register a named listener. The listener persists across stop/start cycles. + pub fn on_tick(&self, name: impl Into, callback: impl Fn(&HeartbeatEvent) + Send + 'static) { + let mut listeners = self.listeners.lock().unwrap(); + let name = name.into(); + // Replace existing listener with the same name + listeners.retain(|e| e.name != name); + listeners.push(ListenerEntry { + name, + callback: Box::new(callback), + }); + } + + /// Remove a listener by name. + pub fn remove_listener(&self, name: &str) { + let mut listeners = self.listeners.lock().unwrap(); + listeners.retain(|e| e.name != name); + } + + /// Start the heartbeat. If already running, this is a no-op. + pub fn start(&mut self) { + if self.running.load(Ordering::SeqCst) { + return; + } + + self.running.store(true, Ordering::SeqCst); + + let running = Arc::clone(&self.running); + let listeners = Arc::clone(&self.listeners); + let tick_count = Arc::clone(&self.tick_count); + let interval = self.interval; + + let handle = thread::spawn(move || { + let start_time = Instant::now(); + + while running.load(Ordering::SeqCst) { + thread::sleep(interval); + + if !running.load(Ordering::SeqCst) { + break; + } + + let tick = { + let mut count = tick_count.lock().unwrap(); + *count += 1; + *count + }; + + let event = HeartbeatEvent { + tick, + timestamp: Instant::now(), + elapsed: start_time.elapsed(), + }; + + let listeners = listeners.lock().unwrap(); + for entry in listeners.iter() { + (entry.callback)(&event); + } + } + }); + + self.handle = Some(handle); + } + + /// Stop the heartbeat and join the background thread. + /// Listener subscriptions are preserved for a subsequent `start()`. + pub fn stop(&mut self) { + self.running.store(false, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + + /// Whether the heartbeat is currently running. + pub fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + /// Total number of ticks emitted (across all start/stop cycles). + pub fn total_ticks(&self) -> u64 { + *self.tick_count.lock().unwrap() + } +} + +impl Drop for Heartbeat { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/src/json.rs b/src/json.rs index f18aa73..5fdbac1 100644 --- a/src/json.rs +++ b/src/json.rs @@ -27,6 +27,7 @@ pub fn unescape_json(s: &str) -> String { /// Extract the string value for a given key from a flat JSON line. /// Looks for `"key": "value"` and returns the value (unescaped basic sequences). /// Returns `None` if the key is not found or the value is not a string. +#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find() pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> { let needle = { let mut pat = String::with_capacity(key.len() + 3); @@ -84,6 +85,7 @@ pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> { /// Extract a numeric value for a given key from a flat JSON line. /// Looks for `"key": 123.45` and returns the number. /// Returns `None` if the key is not found or the value is not a number. +#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find() pub fn extract_num(line: &str, key: &str) -> Option { let needle = { let mut pat = String::with_capacity(key.len() + 3); diff --git a/src/main.rs b/src/main.rs index b3e5c3c..422d969 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,22 @@ +#![warn(clippy::string_slice)] + mod boundary; mod config; mod guard; +mod heartbeat; mod json; +mod registry; mod signal; mod stream; +mod stream_opencode; use std::fs; use std::path::{Path, PathBuf}; use std::process::{self, Child, Command, Stdio}; -use config::Config; +use config::{Backend, Config}; -const CONF_PATH: &str = ".loop/guard.conf"; +const CONF_PATH: &str = ".loop/yoke.conf"; const NOTES_PATH: &str = ".loop/notes.md"; const GUARD_RESULTS_PATH: &str = ".loop/guard-results.md"; const PROTOCOL_PATH: &str = ".loop/protocol.md"; @@ -19,190 +24,179 @@ const PLAN_PATH: &str = ".loop/plan.md"; const JUDGE_PATH: &str = ".loop/judge.md"; const VERDICT_PATH: &str = ".loop/verdict.md"; -const DEFAULT_PROTOCOL: &str = r#"# Protocol: Automated CI Loop +const DEFAULT_PROTOCOL: &str = include_str!("templates/loop/protocol.md"); +const DEFAULT_YOKE_CONF: &str = include_str!("templates/loop/yoke.conf"); +const DEFAULT_BRUTE_PROTOCOL: &str = include_str!("templates/brute/protocol.md"); +const DEFAULT_BRUTE_CONF: &str = include_str!("templates/brute/yoke.conf"); +const DEFAULT_JUDGE: &str = include_str!("templates/brute/judge.md"); +const DEFAULT_BRIEFING: &str = include_str!("templates/loop/briefing.md"); +const DEFAULT_SAGA_PROTOCOL: &str = include_str!("templates/saga/saga-protocol.md"); +const DEFAULT_SAGA_WORKER_PROTOCOL: &str = include_str!("templates/saga/protocol.md"); +const DEFAULT_SAGA_JUDGE: &str = include_str!("templates/saga/judge.md"); +const DEFAULT_SAGA_CONF: &str = include_str!("templates/saga/yoke.conf"); -You are operating inside an automated loop — not a conversation. A bash script launched you, and will run guard checks after you exit. You do not interact with a human during this session. +const LAYER_REPL: &str = include_str!("templates/layers/repl.md"); -## Files +const BRIEFING_PATH: &str = ".loop/briefing.md"; +const SAGA_PROTOCOL_PATH: &str = ".loop/saga-protocol.md"; +const SPECIFICATION_PATH: &str = ".loop/specification.md"; +const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md"; +const DECISIONS_PATH: &str = ".loop/decisions.md"; +const SUB_PLAN_PATH: &str = ".loop/sub-plan.md"; +const STASH_DIR: &str = ".loop/.stash"; -| File | You can | Purpose | -|------|---------|---------| -| `.loop/protocol.md` | read | This document. Your instructions. | -| `.loop/plan.md` | read | The feature plan. Stages to implement. | -| `.loop/notes.md` | read + write | Your scratchpad. Persists across iterations. | -| `.loop/guard-results.md` | read | Guard results from the last iteration. | -| `.loop/guard.conf` | read | Loop configuration. Scope rules, guards, settings. | +/// Parse the plan file and notes.md to determine stage progress. +/// Returns `Some((completed, total))` if the plan has parseable `## Stage` headers. +fn stage_progress(plan_path: &str) -> Option<(usize, usize)> { + let plan = fs::read_to_string(plan_path).ok()?; + let total = plan.lines().filter(|l| l.starts_with("## Stage")).count(); + if total == 0 { + return None; + } -All paths are relative to the repository root. + let notes = fs::read_to_string(NOTES_PATH).unwrap_or_default(); + // Count lines matching "## Stage N — DONE" pattern in notes + let completed = notes + .lines() + .filter(|l| { + l.starts_with("## Stage") + && (l.contains("DONE") || l.contains("done") || l.contains("Done")) + }) + .count(); -## Per-Iteration Steps + Some((completed, total)) +} -1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages. -2. **Read your notes** (`.loop/notes.md`). This is your memory across iterations — check which stage you are on, what you tried, and what you learned. -3. **Read guard results** (`.loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage. -4. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan. -5. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree. -6. **Update notes**. Write to `.loop/notes.md`: - - Which stage you just worked on - - What you changed and why - - Any issues or observations for your future self - - A `STATUS` line at the **top** of the file (see below) -7. **Exit**. Stop. Do not loop — the outer script handles iteration. - -## STATUS Signaling - -The first line of `.loop/notes.md` must be one of: - -- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures). -- `STATUS: DONE` — All stages in the plan are implemented and you believe guards will pass. - -The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass. - -## What the Guards Check - -After you exit, the outer loop runs guards defined in `.loop/guard.conf`. - -1. **Diff boundary check** — Always runs first. Verifies every file you changed - or created is within the scope rules defined in `.loop/guard.conf`. The rules: - - `allow PREFIX` — anything goes: add, modify, delete. - - `add-only PREFIX` — may only add lines; no removing existing lines. - - `no-modify PREFIX` — zero modifications allowed. - - No matching rule — change is denied. - - Most-specific (longest) prefix wins when rules overlap. - If the boundary check fails, all subsequent guards are skipped. -2. **Configured guards** — Read the `guard` lines in `.loop/guard.conf` to see - what commands run. Guards execute in order, fail-fast (first failure skips - the rest). - -You may run any commands you find useful during implementation. - -## Rules - -- **No git operations.** Do not commit, push, branch, or modify git config. The outer loop owns git. -- **Do not modify `protocol.md`, `plan.md`, or `guard.conf`.** These are read-only to you. -- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages. -- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix. -- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next. -- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway. -"#; - -const DEFAULT_GUARD_CONF: &str = "\ -# Guard configuration - -image claude-code-sandbox:latest - -# Max lines of guard output to keep -max-tail 200 - -# Scope rules (diff boundary enforcement) -# Tags: allow (any change), add-only (new lines only), no-modify (no changes) -# Most-specific (longest) prefix wins. -allow . - -# Guards (run in order, fail-fast) -guard cargo check -"; - -const DEFAULT_BRUTE_PROTOCOL: &str = r#"# Protocol: Brute + Plan Runner (Triple Loop) - -You are operating inside an automated triple loop — not a conversation. -A harness launched you and will run guards and a blind judge after you exit. - -The outer brute loop retries until a judge says PASS. -Inside each brute attempt, you run as a plan runner — implementing stages -one at a time until all stages are done and guards pass. - -## Files - -| File | Access | Purpose | -|---|---|---| -| `.loop/protocol.md` | read | These instructions. | -| `.loop/plan.md` | read | The feature plan with stages to implement. | -| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. | -| `.loop/notes.md` | read+write | Your scratchpad across iterations. | -| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). | -| `.loop/guard-results.md` | read | Guard results from the last iteration. | -| `.loop/guard.conf` | read | Configuration. Scope rules, guards, settings. | - -All paths are relative to the repository root. - -## Per-Iteration Steps - -1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages. -2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned. -3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing. -4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing. -5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage. -6. **Implement**. Make the code changes for exactly one stage. -7. **Update notes**. Write to `.loop/notes.md`: - - Which stage you just worked on - - What you changed and why - - Any issues or observations for your future self - - A `STATUS` line at the **top** of the file (see below) -8. **Exit**. Stop. Do not loop — the outer script handles iteration. - -## STATUS Signaling - -The first line of `.loop/notes.md` must be one of: - -- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures). -- `STATUS: DONE` — All stages are implemented and you believe guards will pass. - -## What Happens After You Exit - -1. Guards run (diff boundary check + configured guard commands). -2. If guards pass and STATUS is DONE, the plan loop ends. -3. Then the judge (a fresh Claude with zero implementation context) verifies the feature. -4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback. - -## Rules - -- **No git operations.** Do not commit, push, branch, or modify git config. -- **Do not modify `protocol.md`, `plan.md`, `judge.md`, or `guard.conf`.** These are read-only. -- **One stage per iteration.** Implement a single stage, update notes, and exit. -- **Study judge.md.** Knowing the test helps you pass it. -- **The judge's feedback is ground truth.** Fix what they say is broken. -- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach. -- **Be concise in notes.** Future-you needs signal, not noise. -- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway. -"#; - -const DEFAULT_BRUTE_CONF: &str = "\ -# Brute runner configuration - -image claude-code-sandbox:latest -max-tail 200 - -allow . - -# Guards (run after each plan stage, fail-fast) -guard cargo check -"; - -/// Files that Claude must not be allowed to permanently alter. -const PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH]; +/// Render a compact progress bar like `▐████░░░░▌ 3/7 stages`. +fn format_progress_bar(completed: usize, total: usize) -> String { + let bar_width = 10; + let filled = if total > 0 { + ((completed * bar_width + total / 2) / total).min(bar_width) + } else { + 0 + }; + let empty = bar_width - filled; + let bar: String = "█".repeat(filled) + &"░".repeat(empty); + format!( + "{}{} ▐{}▌ {}/{} stages{}", + DIM, GREEN, bar, completed, total, RESET + ) +} // ANSI helpers const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; -const CYAN: &str = "\x1b[36m"; -const GREEN: &str = "\x1b[32m"; -const RED: &str = "\x1b[31m"; -const YELLOW: &str = "\x1b[33m"; 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"; fn log(msg: &str) { - eprintln!("{}{}[yoke]{} {}", BOLD, CYAN, RESET, msg); + eprintln!("{}{}[yoke]{} {}", ORANGE, BOLD, RESET, msg); } fn log_error(msg: &str) { - eprintln!("{}{}[yoke] ERROR:{} {}", BOLD, RED, RESET, msg); + eprintln!("{}{}[yoke]{} {}{}ERROR:{} {}", ORANGE, BOLD, RESET, BOLD, RED, RESET, msg); +} + +fn get_layer(name: &str) -> Option<(&'static str, &'static str)> { + match name { + "repl" => Some(("REPL probing", LAYER_REPL)), + _ => None, + } +} + +fn list_layers() -> &'static [&'static str] { + &["repl"] +} + +#[allow(clippy::string_slice)] // slices after ASCII prefixes ("## ", ". ") +fn apply_layers(names_arg: &str) -> i32 { + let judge_path = Path::new(JUDGE_PATH); + if !judge_path.exists() { + log_error("no .loop/judge.md found — run 'yoke init brute' or 'yoke init saga' first"); + return 1; + } + + let mut content = match fs::read_to_string(judge_path) { + Ok(c) => c, + Err(e) => { + log_error(&format!("failed to read judge.md: {}", e)); + return 1; + } + }; + + let names: Vec<&str> = names_arg.split(',').map(|s| s.trim()).collect(); + + for name in &names { + let (heading, layer_content) = match get_layer(name) { + Some(v) => v, + None => { + log_error(&format!("unknown layer '{}' — use 'yoke layer --list'", name)); + return 2; + } + }; + + if content.contains(heading) { + log(&format!("skip (already present): {}", name)); + continue; + } + + match content.find("\n## Verdict") { + Some(pos) => { + content.insert_str(pos, &format!("\n{}\n", layer_content.trim())); + } + None => { + log_error("judge.md has no '## Verdict' section — cannot insert layer"); + return 1; + } + } + log(&format!("applied layer: {}", name)); + } + + // Re-number ## N. sections sequentially + let mut section_num = 1u32; + let mut result = String::with_capacity(content.len()); + for line in content.lines() { + if line.starts_with("## ") && line.len() > 4 { + let rest = &line[3..]; + if rest.starts_with(|c: char| c.is_ascii_digit()) { + let after_digits = rest.trim_start_matches(|c: char| c.is_ascii_digit()); + if after_digits.starts_with(". ") { + result.push_str(&format!("## {}. {}", section_num, &after_digits[2..])); + result.push('\n'); + section_num += 1; + continue; + } + } + } + result.push_str(line); + result.push('\n'); + } + + if let Err(e) = fs::write(judge_path, result.trim_end().to_string() + "\n") { + log_error(&format!("failed to write judge.md: {}", e)); + return 1; + } + + 0 +} + +fn print_layer_help() { + eprintln!("Usage: yoke layer [,,...]\n"); + eprintln!(" Append judge layers to .loop/judge.md\n"); + eprintln!(" --list Show available layers"); + eprintln!("\nAvailable layers:"); + for name in list_layers() { + eprintln!(" {}", name); + } } fn print_usage() { eprintln!( - "{}{}yoke{} — LLM loop harness{}", - BOLD, CYAN, RESET, RESET + "{}{}[yoke]{} LLM loop harness", + ORANGE, BOLD, RESET ); eprintln!(); eprintln!( @@ -219,6 +213,22 @@ fn print_usage() { " {}init brute{} Initialize .loop/ for brute mode (plan stages + judge)", BOLD, RESET ); + eprintln!( + " {}init saga{} Initialize .loop/ for saga mode (scoper + brute loop)", + BOLD, RESET + ); + eprintln!( + " {}clean{} Reset .loop/ working files to blank slate", + BOLD, RESET + ); + eprintln!( + " {}stash pop{} Restore files auto-stashed by clean", + BOLD, RESET + ); + eprintln!( + " {}layer{} Append judge layers to .loop/judge.md", + BOLD, RESET + ); eprintln!( " {}run{} Launch the loop (invoke Claude, run guards, iterate)", BOLD, RESET @@ -243,8 +253,8 @@ fn print_usage() { fn print_run_help() { eprintln!( - "{}{}yoke run{} — execute the loop{}", - BOLD, CYAN, RESET, RESET + "{}{}[yoke run]{} execute the loop", + ORANGE, BOLD, RESET ); eprintln!(); eprintln!( @@ -259,12 +269,22 @@ fn print_run_help() { eprintln!(); eprintln!("{}MODES:{}", BOLD, RESET); eprintln!(" Detected automatically from .loop/ contents."); - eprintln!(" {}loop{} (default) Staged plan loop — iterates until STATUS: DONE and guards pass.", BOLD, RESET); + eprintln!(" {}saga{} Scoper + brute loop — detected when {} exists.", BOLD, RESET, SPECIFICATION_PATH); eprintln!(" {}brute{} Plan stages + judge — detected when {} exists.", BOLD, RESET, JUDGE_PATH); + eprintln!(" {}loop{} (default) Staged plan loop — iterates until STATUS: DONE and guards pass.", BOLD, RESET); + eprintln!(); + eprintln!("{}WORKFLOW (saga):{}", BOLD, RESET); + eprintln!(" 1. Load config from {}", CONF_PATH); + eprintln!(" 2. Per saga cycle:"); + eprintln!(" a. Invoke scoper (Agent 1) — reads spec, writes sub-plan.md"); + eprintln!(" b. Run brute loop on sub-plan.md (Agent 2 + Agent 3)"); + eprintln!(" c. On pass: loop back to scoper for next chunk"); + eprintln!(" d. On bailout (3 judge fails): loop back to scoper to re-scope"); + eprintln!(" 3. Exit when scoper signals STATUS: DONE"); eprintln!(); eprintln!("{}WORKFLOW (loop):{}", BOLD, RESET); eprintln!(" 1. Load config from {}", CONF_PATH); - eprintln!(" 2. Backup protected files (protocol.md, plan.md, guard.conf)"); + eprintln!(" 2. Backup protected files (protocol.md, plan.md, yoke.conf)"); eprintln!(" 3. Per iteration:"); eprintln!(" a. Restore protected files"); eprintln!(" b. Invoke Claude with stream-json output"); @@ -274,7 +294,7 @@ fn print_run_help() { eprintln!(); eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET); eprintln!(" 1. Load config from {}", CONF_PATH); - eprintln!(" 2. Backup protected files (protocol.md, plan.md, task.md, judge.md, guard.conf)"); + eprintln!(" 2. Backup protected files (protocol.md, plan.md, task.md, judge.md, yoke.conf)"); eprintln!(" 3. Per brute attempt:"); eprintln!(" a. Restore protected files, clear verdict"); eprintln!(" b. Run plan loop (stages + guards until DONE)"); @@ -285,24 +305,35 @@ fn print_run_help() { fn print_init_help() { eprintln!( - "{}{}yoke init{} — initialize .loop/ directory{}", - BOLD, CYAN, RESET, RESET + "{}{}[yoke init]{} initialize .loop/ directory", + ORANGE, BOLD, RESET ); eprintln!(); eprintln!( - "{}USAGE:{} yoke init [brute]", + "{}USAGE:{} yoke init [brute|saga]", BOLD, RESET ); eprintln!(); eprintln!("{}MODES:{}", BOLD, RESET); eprintln!(" {}(default){} Staged plan loop. Creates:", BOLD, RESET); - eprintln!(" guard.conf, protocol.md, plan.md, notes.md, guard-results.md"); + eprintln!(" yoke.conf, protocol.md, briefing.md, plan.md, notes.md,"); + eprintln!(" guard-results.md"); eprintln!(); eprintln!(" {}brute{} Plan stages + judge. Creates:", BOLD, RESET); - eprintln!(" guard.conf, protocol.md, plan.md, task.md, judge.md,"); + eprintln!(" yoke.conf, protocol.md, briefing.md, plan.md, judge.md,"); eprintln!(" notes.md, verdict.md, guard-results.md"); eprintln!(); + eprintln!(" {}saga{} Scoper + brute loop. Creates:", BOLD, RESET); + eprintln!(" yoke.conf, saga-protocol.md, protocol.md, judge.md,"); + eprintln!(" specification.md, saga-notes.md, decisions.md,"); + eprintln!(" sub-plan.md, notes.md, verdict.md, guard-results.md"); + eprintln!(); eprintln!("Existing files are never overwritten."); + eprintln!(); + eprintln!("{}MODE SWITCHING:{}", BOLD, RESET); + eprintln!(" Switching modes automatically snapshots the current mode's files"); + eprintln!(" into .loop/.modes// and restores any prior snapshot of the"); + eprintln!(" target mode. This lets you bounce between modes without losing work."); } /// Holds loop state and cleans up on drop. @@ -339,8 +370,12 @@ impl Drop for LoopRunner { } /// Check that required loop files exist, create notes if missing. -fn preflight() { - for path in &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH] { +/// +/// When `clear_guard_results` is true (standalone loop), guard-results.md is +/// cleared on entry. When false (nested inside brute), the previous attempt's +/// guard feedback is preserved so the worker can read it. +fn preflight(plan_path: &str, clear_guard_results: bool) { + for path in &[PROTOCOL_PATH, plan_path, CONF_PATH] { if !Path::new(path).exists() { log_error(&format!("required file not found: {}", path)); process::exit(1); @@ -353,18 +388,22 @@ fn preflight() { log_error(&format!("cannot create {}: {}", NOTES_PATH, e)); process::exit(1); } - // Clear guard results - let _ = fs::write(GUARD_RESULTS_PATH, ""); + // Clear guard results only on standalone (non-nested) runs. + // In brute mode, run_brute_inner() handles the initial clear and + // retries preserve guard feedback for the worker. + if clear_guard_results { + let _ = fs::write(GUARD_RESULTS_PATH, ""); + } } -/// Copy protected files to a temp directory. Returns the backup path. -fn backup_protected() -> PathBuf { - let backup_dir = std::env::temp_dir().join(format!("yoke-backup-{}", process::id())); +/// Copy the given files to a backup directory inside .loop/. Returns the backup path. +fn backup_files(files: &[&str]) -> PathBuf { + let backup_dir = PathBuf::from(format!(".loop/backups-{}", process::id())); if let Err(e) = fs::create_dir_all(&backup_dir) { log_error(&format!("failed to create backup dir: {}", e)); process::exit(1); } - for path in PROTECTED_FILES { + for path in files { let src = Path::new(path); if src.exists() { let dest = backup_dir.join(src.file_name().unwrap()); @@ -377,17 +416,17 @@ fn backup_protected() -> PathBuf { backup_dir } -/// Restore protected files from backup. -fn restore_protected(backup_dir: &Path) { - for path in PROTECTED_FILES { +/// Restore files from backup. +fn restore_files(backup_dir: &Path, files: &[&str]) { + for path in files { let src_name = Path::new(path).file_name().unwrap(); let backup_file = backup_dir.join(src_name); if backup_file.exists() && let Err(e) = fs::copy(&backup_file, path) { eprintln!( - "{}{}[yoke] WARNING:{} failed to restore {}: {}", - BOLD, YELLOW, RESET, path, e + "{}{}[yoke]{} {}{}WARNING:{} failed to restore {}: {}", + ORANGE, BOLD, RESET, BOLD, ORANGE, RESET, path, e ); } } @@ -425,81 +464,116 @@ fn reset_notes_status() { } } -/// Invoke Claude, piping stdout through the stream filter. -/// The Child is stored in `runner` for cleanup-on-drop safety. -/// Returns the child's exit status success. -fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool { - let claude_args = [ - "--verbose", - "--output-format", - "stream-json", - "--include-partial-messages", - "--dangerously-skip-permissions", - "-p", - "Read .loop/protocol.md and follow its instructions.", - ]; +/// Build a Command for invoking the agent backend. +/// For Claude backend: uses `claude` CLI, optionally via docker. +/// For OpenCode backend: uses `opencode run --format json --model `. +fn build_command(config: &Config, prompt: &str) -> Command { + match config.backend() { + Backend::Claude => { + let claude_args = [ + "--verbose", + "--output-format", + "stream-json", + "--include-partial-messages", + "--dangerously-skip-permissions", + "-p", + prompt, + ]; + if let Some(ref image) = config.image { + let workdir = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .to_string_lossy() + .to_string(); - let mut cmd = if let Some(ref image) = config.image { - log(&format!( - "Launching Claude in container (iteration {})...", - iteration - )); + let mut c = Command::new("docker"); + c.args([ + "run", + "-i", + "--rm", + "--network=host", + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW", + ]); + c.args(["--entrypoint", "claude"]); + c.arg("-v").arg(format!("{}:/workspace", workdir)); + c.arg("-w").arg("/workspace"); - let workdir = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .to_string_lossy() - .to_string(); + for (key, val) in std::env::vars() { + if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { + c.arg("-e").arg(format!("{}={}", key, val)); + } + } - let mut c = Command::new("docker"); - c.args([ - "run", - "--rm", - "--network=host", - "--cap-add=NET_ADMIN", - "--cap-add=NET_RAW", - ]); + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + let claude_dir = home_path.join(".claude"); + if claude_dir.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude", + claude_dir.display() + )); + } + let claude_json = home_path.join(".claude.json"); + if claude_json.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude.json", + claude_json.display() + )); + } + } - // Bind-mount the workspace - c.arg("-v").arg(format!("{}:/workspace", workdir)); - c.arg("-w").arg("/workspace"); - - // Forward all CLAUDE_* and ANTHROPIC_* env vars - for (key, val) in std::env::vars() { - if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { - c.arg("-e").arg(format!("{}={}", key, val)); + c.arg(image.as_str()); + c.args(claude_args); + c + } else { + let mut c = Command::new("claude"); + c.args(claude_args); + c } } - - // Mount host claude config/auth directory (read-write — Claude needs to write state) - if let Some(home) = std::env::var_os("HOME") { - let home_path = PathBuf::from(&home); - let claude_dir = home_path.join(".claude"); - if claude_dir.exists() { - c.arg("-v").arg(format!( - "{}:/home/node/.claude", - claude_dir.display() - )); - } - // Mount .claude.json for onboarding/theme/user config - let claude_json = home_path.join(".claude.json"); - if claude_json.exists() { - c.arg("-v").arg(format!( - "{}:/home/node/.claude.json", - claude_json.display() - )); - } + Backend::OpenCode => { + let model = config.model.as_ref().expect("model must be set for OpenCode backend"); + let mut c = Command::new("opencode"); + c.args([ + "run", + "--format", + "json", + "--model", + model, + prompt, + ]); + c } + } +} - c.arg(image.as_str()); - c.arg("claude"); - c.args(claude_args); - c - } else { - log(&format!("Launching Claude (iteration {})...", iteration)); - let mut c = Command::new("claude"); - c.args(claude_args); - c - }; +/// Spawn an agent process, stream its output, kill it when streaming ends, +/// and wait for exit. Returns `(success, iteration_cost)`. +/// +/// The child is stored in `runner` for cleanup-on-drop safety. +/// After `filter_stream` returns (normally, broken pipe, or interrupt) +/// the child is killed before waiting — this is a no-op if it already +/// exited but prevents deadlock if it ignores SIGPIPE. +fn invoke_process( + runner: &mut LoopRunner, + config: &Config, + prompt: &str, + label: &str, + log_prefix: &str, + iteration: u32, + prior_total: f64, +) -> (bool, f64) { + let backend = config.backend(); + let in_container = config.image.is_some() && backend == Backend::Claude; + + log(&format!( + "Launching {} {}(iteration {})...", + label, + if in_container { "in container " } else { "" }, + iteration + )); + + let mut cmd = build_command(config, prompt); let mut child = match cmd .stdin(Stdio::null()) @@ -509,44 +583,51 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo { Ok(c) => c, Err(e) => { - let bin = if config.image.is_some() { "docker" } else { "claude" }; + let bin = match backend { + Backend::Claude if config.image.is_some() => "docker", + Backend::Claude => "claude", + Backend::OpenCode => "opencode", + }; log_error(&format!("failed to spawn '{}': {}", bin, e)); - return false; + return (false, 0.0); } }; - // Register child PID so signal handler can kill it to unblock pipe reads signal::set_child_pid(child.id() as i32); - // Compute log path let log_path = config.log_dir.as_ref().map(|dir| { - std::path::PathBuf::from(dir).join(format!("iteration-{}.jsonl", iteration)) + PathBuf::from(dir).join(format!("{}-{}.jsonl", log_prefix, iteration)) }); - // Take stdout and feed through stream filter + let mut iter_cost = 0.0; if let Some(stdout) = child.stdout.take() { - // Store child in runner before blocking on stream filter, - // so Drop can kill it if we're interrupted. runner.child = Some(child); - stream::filter_stream(stdout, log_path.as_deref()); + iter_cost = match backend { + Backend::Claude => stream::filter_stream(stdout, log_path.as_deref(), prior_total), + Backend::OpenCode => stream_opencode::filter_stream(stdout, log_path.as_deref(), prior_total), + }; + // Kill the child after streaming ends — prevents deadlock if it + // ignores SIGPIPE and keeps writing. No-op if already exited. + if let Some(ref mut ch) = runner.child { + let _ = ch.kill(); + } if signal::interrupted() { - return false; + return (false, iter_cost); } } else { runner.child = Some(child); } - // Wait for the child to exit let status = if let Some(ref mut child) = runner.child { match child.wait() { Ok(s) => { if signal::interrupted() { - return false; + return (false, iter_cost); } s.success() } Err(e) => { - log_error(&format!("failed to wait on claude process: {}", e)); + log_error(&format!("failed to wait on {} process: {}", label, e)); false } } @@ -554,18 +635,106 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo false }; - // Child has exited; clear it from runner signal::set_child_pid(0); runner.child = None; log(&format!( - "Claude exited ({})", + "{} exited ({})", + label, if status { "success" } else { "failure" } )); - status + (status, iter_cost) } -/// Invoke the judge — a fresh Claude with zero implementation context. +/// Invoke the agent, piping stdout through the stream filter. +/// Returns `(success, iteration_cost)`. +fn invoke_agent(runner: &mut LoopRunner, config: &Config, iteration: u32, prior_total: f64) -> (bool, f64) { + let prompt = "Read .loop/protocol.md and follow its instructions."; + let label = match config.backend() { + Backend::Claude => "Claude", + Backend::OpenCode => "OpenCode", + }; + invoke_process(runner, config, prompt, label, "iteration", iteration, prior_total) +} + +/// Render a full-width box-drawn banner for the judge verdict. +/// Includes the first ~2 lines of the verdict reason text. +/// +/// ```text +/// ╔══════════════════════════════════════╗ +/// ║ JUDGE VERDICT: PASS ║ +/// ╠══════════════════════════════════════╣ +/// ║ "Tests pass and all features are ║ +/// ║ present in the source code" ║ +/// ╚══════════════════════════════════════╝ +/// ``` +fn render_judge_banner(pass: bool, content: &str) { + let inner_w = 38; // inner width of the box (characters between ║ borders) + let verdict_label = if pass { "PASS" } else { "FAIL" }; + let color = if pass { GREEN } else { RED }; + + // Build header line: center "JUDGE VERDICT: PASS/FAIL" + let title = format!("JUDGE VERDICT: {}", verdict_label); + let padding = if inner_w > title.len() { + inner_w - title.len() + } else { + 0 + }; + let left_pad = padding / 2; + let right_pad = padding - left_pad; + let title_line = format!( + "{:>w_left$}{}{:>w_right$}", + "", + title, + "", + w_left = left_pad, + w_right = right_pad + ); + + // Extract reason text: skip the first line (VERDICT: ...) and take up to 2 non-empty lines + let reason_lines: Vec<&str> = content + .lines() + .skip(1) + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .take(2) + .collect(); + + let bar = "═".repeat(inner_w); + + eprintln!(); + eprintln!("{}{}╔{}╗{}", BOLD, color, bar, RESET); + eprintln!("{}{}║{}║{}", BOLD, color, title_line, RESET); + + if !reason_lines.is_empty() { + eprintln!("{}{}╠{}╣{}", BOLD, color, bar, RESET); + for reason in &reason_lines { + // Truncate and pad reason to fit within inner_w, with 2-char left margin + let max_text = inner_w - 4; // 2 left margin + 2 right margin + let text: std::borrow::Cow = if reason.chars().count() > max_text { + std::borrow::Cow::Owned(reason.chars().take(max_text).collect()) + } else { + std::borrow::Cow::Borrowed(reason) + }; + let text_right_pad = inner_w - 2 - text.chars().count(); + eprintln!( + "{}{}║ {}{}{}{}║{}", + BOLD, + color, + RESET, + text, + " ".repeat(text_right_pad), + color, + RESET + ); + } + } + + eprintln!("{}{}╚{}╝{}", BOLD, color, bar, RESET); + eprintln!(); +} + +/// Invoke the judge — a fresh agent with zero implementation context. /// Returns true if the judge's verdict is PASS. fn invoke_judge(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool { let judge_prompt = "\ @@ -583,124 +752,8 @@ VERDICT: FAIL "; - let claude_args = [ - "--verbose", - "--output-format", - "stream-json", - "--dangerously-skip-permissions", - "-p", - judge_prompt, - ]; - - let mut cmd = if let Some(ref image) = config.image { - log(&format!( - "Launching judge in container (iteration {})...", - iteration - )); - - let workdir = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .to_string_lossy() - .to_string(); - - let mut c = Command::new("docker"); - c.args([ - "run", - "--rm", - "--network=host", - "--cap-add=NET_ADMIN", - "--cap-add=NET_RAW", - ]); - c.arg("-v").arg(format!("{}:/workspace", workdir)); - c.arg("-w").arg("/workspace"); - - for (key, val) in std::env::vars() { - if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { - c.arg("-e").arg(format!("{}={}", key, val)); - } - } - - if let Some(home) = std::env::var_os("HOME") { - let home_path = PathBuf::from(&home); - let claude_dir = home_path.join(".claude"); - if claude_dir.exists() { - c.arg("-v").arg(format!( - "{}:/home/node/.claude", - claude_dir.display() - )); - } - let claude_json = home_path.join(".claude.json"); - if claude_json.exists() { - c.arg("-v").arg(format!( - "{}:/home/node/.claude.json", - claude_json.display() - )); - } - } - - c.arg(image.as_str()); - c.arg("claude"); - c.args(claude_args); - c - } else { - log(&format!("Launching judge (iteration {})...", iteration)); - let mut c = Command::new("claude"); - c.args(claude_args); - c - }; - - let mut child = match cmd - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - { - Ok(c) => c, - Err(e) => { - let bin = if config.image.is_some() { "docker" } else { "claude" }; - log_error(&format!("failed to spawn judge '{}': {}", bin, e)); - return false; - } - }; - - signal::set_child_pid(child.id() as i32); - - let log_path = config.log_dir.as_ref().map(|dir| { - std::path::PathBuf::from(dir).join(format!("judge-{}.jsonl", iteration)) - }); - - if let Some(stdout) = child.stdout.take() { - runner.child = Some(child); - stream::filter_stream(stdout, log_path.as_deref()); - if signal::interrupted() { - return false; - } - } else { - runner.child = Some(child); - } - - let status = if let Some(ref mut child) = runner.child { - match child.wait() { - Ok(s) => { - if signal::interrupted() { - return false; - } - s.success() - } - Err(e) => { - log_error(&format!("failed to wait on judge process: {}", e)); - false - } - } - } else { - false - }; - - signal::set_child_pid(0); - runner.child = None; - + let (status, _) = invoke_process(runner, config, judge_prompt, "judge", "judge", iteration, 0.0); if !status { - log_error("Judge process exited with failure"); return false; } @@ -708,11 +761,7 @@ VERDICT: FAIL match fs::read_to_string(VERDICT_PATH) { Ok(content) => { let pass = content.starts_with("VERDICT: PASS"); - if pass { - log(&format!("{}{}Judge verdict: PASS{}", GREEN, BOLD, RESET)); - } else { - log(&format!("{}Judge verdict: FAIL{}", YELLOW, RESET)); - } + render_judge_banner(pass, &content); pass } Err(e) => { @@ -722,6 +771,67 @@ VERDICT: FAIL } } +/// Render boundary violations as a compact file tree with colored markers. +/// Violations are strings like "DENY: src/foo.rs (no matching rule)". +/// Allowed-scope files get a green ✓, violations get a red ✗. +#[allow(clippy::string_slice)] // slices at ASCII delimiter positions from .find() +fn render_violation_tree(violations: &[String]) { + use std::collections::BTreeMap; + + // Parse each violation into (file_path, reason) + let mut entries: Vec<(&str, &str)> = Vec::new(); + for v in violations { + // Format: "TAG: path (reason)" — extract path after first ": " and before " (" + if let Some(colon_pos) = v.find(": ") { + let rest = &v[colon_pos + 2..]; + let (path, reason) = if let Some(paren_pos) = rest.find(" (") { + (&rest[..paren_pos], &rest[paren_pos + 1..]) + } else { + (rest, "") + }; + entries.push((path.trim(), reason.trim())); + } + } + + if entries.is_empty() { + return; + } + + // Group files by their directory (or root "." for top-level files) + let mut tree: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new(); + for (path, reason) in &entries { + if let Some(slash_pos) = path.rfind('/') { + let dir = &path[..slash_pos]; + let file = &path[slash_pos + 1..]; + tree.entry(dir).or_default().push((file, reason)); + } else { + tree.entry(".").or_default().push((path, reason)); + } + } + + // Render tree + let dirs: Vec<_> = tree.keys().copied().collect(); + let dir_count = dirs.len(); + for (di, dir) in dirs.iter().enumerate() { + let is_last_dir = di + 1 == dir_count; + let dir_prefix = if is_last_dir { "└── " } else { "├── " }; + let child_prefix = if is_last_dir { " " } else { "│ " }; + + eprintln!(" {}{}{}/{}", dir_prefix, DIM, dir, RESET); + + let files = &tree[dir]; + let file_count = files.len(); + for (fi, (file, reason)) in files.iter().enumerate() { + let is_last_file = fi + 1 == file_count; + let file_conn = if is_last_file { "└── " } else { "├── " }; + eprintln!( + " {}{}{}✗{} {} {}{}{}", + child_prefix, file_conn, RED, RESET, file, DIM, reason, RESET + ); + } + } +} + /// Run boundary check and all configured guards. /// Returns true if everything passed. fn run_all_guards(config: &Config) -> bool { @@ -738,9 +848,7 @@ fn run_all_guards(config: &Config) -> bool { boundary.violations.len(), RESET )); - for v in &boundary.violations { - eprintln!(" {}", v); - } + render_violation_tree(&boundary.violations); // Write boundary failure to guard results, mark configured guards as skipped let mut md = String::new(); @@ -772,21 +880,431 @@ fn run_all_guards(config: &Config) -> bool { let _ = fs::write(results_path, combined); let all_passed = guard_results.iter().all(|r| r.passed); - for r in &guard_results { - let status = if r.skipped { - format!("{}SKIPPED{}", YELLOW, RESET) - } else if r.passed { - format!("{}PASS{}", GREEN, RESET) - } else { - format!("{}FAIL{}", RED, RESET) - }; - log(&format!(" {}: {}", r.name, status)); + + // Render guard results as a box-drawn table with timing + if !guard_results.is_empty() { + // Column widths (visual characters, not bytes) + let cmd_w = guard_results.iter().map(|r| r.name.len()).max().unwrap_or(10).max(10); + let stat_w = 8; // enough for "SKIPPED" + padding + let time_w = 6; // e.g. " 12s " + + let top = format!(" ┌{}┬{}┬{}┐", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); + let bottom = format!(" └{}┴{}┴{}┘", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); + + eprintln!("{}", top); + for r in &guard_results { + // Command column: left-aligned, padded to cmd_w + let cmd_padded = format!("{:3}s ", secs) + }; + + eprintln!(" │ {} │{}│ {} │", cmd_padded, status_cell, time_cell); + } + eprintln!("{}", bottom); } all_passed } +fn print_clean_help() { + eprintln!( + "{}{}[yoke clean]{} reset .loop/ working files", + ORANGE, BOLD, RESET + ); + eprintln!(); + eprintln!( + "{}USAGE:{} yoke clean", + BOLD, RESET + ); + eprintln!(); + eprintln!("Clears working files back to a blank slate:"); + eprintln!(" plan.md, notes.md, verdict.md, guard-results.md → emptied"); + eprintln!(" saga-notes.md, decisions.md, sub-plan.md → emptied (if present)"); + eprintln!(" judge.md → reset to default template"); + eprintln!(); + eprintln!("Structural files are left untouched:"); + eprintln!(" protocol.md, saga-protocol.md, yoke.conf, briefing.md, specification.md"); + eprintln!(); + eprintln!("Mode snapshots in .loop/.modes/ are preserved by clean."); + eprintln!(); + eprintln!("Non-empty working files are auto-stashed to .loop/.stash/ before wiping."); + eprintln!("Recover with: {}yoke stash pop{}", BOLD, RESET); +} + +fn clean() -> i32 { + let loop_dir = Path::new(".loop"); + if !loop_dir.exists() { + log_error(".loop/ directory not found — nothing to clean"); + return 1; + } + + // Stash non-empty working files before wiping + let stashed = stash_working_files(); + + log("Cleaning .loop/ working files..."); + + // Clear working files to empty + for path in &[ + PLAN_PATH, NOTES_PATH, VERDICT_PATH, GUARD_RESULTS_PATH, + SAGA_NOTES_PATH, DECISIONS_PATH, SUB_PLAN_PATH, + ] { + let p = Path::new(path); + if p.exists() { + if let Err(e) = fs::write(p, "") { + log_error(&format!("failed to clear {}: {}", path, e)); + return 1; + } + log(&format!("cleared: {}", path)); + } + } + + // Reset judge.md to default template (saga uses a different default) + let judge = Path::new(JUDGE_PATH); + if judge.exists() { + let is_saga = Path::new(SPECIFICATION_PATH).exists(); + let template = if is_saga { DEFAULT_SAGA_JUDGE } else { DEFAULT_JUDGE }; + if let Err(e) = fs::write(judge, template) { + log_error(&format!("failed to reset {}: {}", JUDGE_PATH, e)); + return 1; + } + log(&format!("reset: {} (default template)", JUDGE_PATH)); + } + + log("Clean complete"); + if stashed > 0 { + log(&format!( + "stashed {} file(s) — recover with: yoke stash pop", + stashed + )); + } + 0 +} + +/// Files that are candidates for stashing during `yoke clean`. +const STASH_CANDIDATES: &[&str] = &[ + PLAN_PATH, + NOTES_PATH, + VERDICT_PATH, + GUARD_RESULTS_PATH, + SAGA_NOTES_PATH, + DECISIONS_PATH, + SUB_PLAN_PATH, + JUDGE_PATH, +]; + +/// Copy non-empty working files to `.loop/.stash/`, removing any prior stash first. +/// Returns the count of files stashed. +fn stash_working_files() -> usize { + let stash = Path::new(STASH_DIR); + + // Remove prior stash (single-slot semantics) + if stash.exists() { + if let Err(e) = fs::remove_dir_all(stash) { + log_error(&format!("failed to remove prior stash: {}", e)); + return 0; + } + } + + // Collect non-empty candidates + let to_stash: Vec<&str> = STASH_CANDIDATES + .iter() + .copied() + .filter(|path| { + Path::new(path).exists() + && fs::read_to_string(path) + .map(|c| !c.trim().is_empty()) + .unwrap_or(false) + }) + .collect(); + + if to_stash.is_empty() { + return 0; + } + + if let Err(e) = fs::create_dir_all(stash) { + log_error(&format!("failed to create {}: {}", STASH_DIR, e)); + return 0; + } + + let mut count = 0; + for path in &to_stash { + let file_name = Path::new(path).file_name().unwrap(); + let dest = stash.join(file_name); + if let Err(e) = fs::copy(path, &dest) { + log_error(&format!("failed to stash {}: {}", path, e)); + } else { + count += 1; + } + } + + count +} + +/// Restore stashed files back to their canonical paths and remove the stash. +fn stash_pop() -> i32 { + let stash = Path::new(STASH_DIR); + if !stash.exists() { + log_error("no stash found — nothing to restore"); + return 1; + } + + let entries = match fs::read_dir(stash) { + Ok(e) => e, + Err(e) => { + log_error(&format!("failed to read {}: {}", STASH_DIR, e)); + return 1; + } + }; + + // Build a map from filename → canonical path + let canonical: std::collections::HashMap<&str, &str> = STASH_CANDIDATES + .iter() + .filter_map(|p| { + Path::new(p) + .file_name() + .and_then(|f| f.to_str()) + .map(|f| (f, *p)) + }) + .collect(); + + let mut restored = 0; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Some(&dest_path) = canonical.get(name_str.as_ref()) { + if let Err(e) = fs::copy(entry.path(), dest_path) { + log_error(&format!("failed to restore {}: {}", dest_path, e)); + } else { + log(&format!("restored: {}", dest_path)); + restored += 1; + } + } else { + log(&format!("skip (unknown): {}", name_str)); + } + } + + if let Err(e) = fs::remove_dir_all(stash) { + log_error(&format!("failed to remove stash: {}", e)); + } + + log(&format!("restored {} file(s)", restored)); + 0 +} + +fn print_stash_help() { + eprintln!( + "{}{}[yoke stash]{} recover auto-stashed working files", + ORANGE, BOLD, RESET + ); + eprintln!(); + eprintln!( + "{}USAGE:{} yoke stash pop", + BOLD, RESET + ); + eprintln!(); + eprintln!("Every 'yoke clean' automatically stashes non-empty working files"); + eprintln!("into .loop/.stash/ before wiping. Use 'yoke stash pop' to restore"); + eprintln!("them. Only one stash slot is kept — each clean overwrites the prior stash."); + eprintln!(); + eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET); + eprintln!(" {}pop{} Restore stashed files and remove the stash", BOLD, RESET); +} + +/// Return the list of (path, default_content) pairs for a given mode. +fn mode_files(mode: &str) -> Option> { + Some(match mode { + "loop" => vec![ + (CONF_PATH, DEFAULT_YOKE_CONF), + (PROTOCOL_PATH, DEFAULT_PROTOCOL), + (BRIEFING_PATH, DEFAULT_BRIEFING), + (PLAN_PATH, ""), + (NOTES_PATH, ""), + (GUARD_RESULTS_PATH, ""), + ], + "brute" => vec![ + (CONF_PATH, DEFAULT_BRUTE_CONF), + (PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL), + (BRIEFING_PATH, DEFAULT_BRIEFING), + (PLAN_PATH, ""), + (JUDGE_PATH, DEFAULT_JUDGE), + (NOTES_PATH, ""), + (VERDICT_PATH, ""), + (GUARD_RESULTS_PATH, ""), + ], + "saga" => vec![ + (CONF_PATH, DEFAULT_SAGA_CONF), + (SAGA_PROTOCOL_PATH, DEFAULT_SAGA_PROTOCOL), + (PROTOCOL_PATH, DEFAULT_SAGA_WORKER_PROTOCOL), + (JUDGE_PATH, DEFAULT_SAGA_JUDGE), + (SPECIFICATION_PATH, ""), + (SAGA_NOTES_PATH, ""), + (DECISIONS_PATH, ""), + (SUB_PLAN_PATH, ""), + (NOTES_PATH, ""), + (VERDICT_PATH, ""), + (GUARD_RESULTS_PATH, ""), + ], + _ => return None, + }) +} + +/// Detect the currently active mode from .loop/ contents. +fn detect_mode() -> Option<&'static str> { + if !Path::new(".loop").exists() { + return None; + } + if Path::new(SPECIFICATION_PATH).exists() { + Some("saga") + } else if Path::new(JUDGE_PATH).exists() { + Some("brute") + } else if Path::new(PROTOCOL_PATH).exists() { + Some("loop") + } else { + None + } +} + +/// Backup current mode's files into `.loop/.modes//`, then either +/// restore from a prior snapshot of the target mode or do a fresh init. +fn switch_mode(current: &str, target: &str) -> i32 { + use std::collections::HashSet; + + let current_files = mode_files(current).unwrap(); + let target_files = mode_files(target).unwrap(); + + let current_paths: HashSet<&str> = current_files.iter().map(|(p, _)| *p).collect(); + let target_paths: HashSet<&str> = target_files.iter().map(|(p, _)| *p).collect(); + + // 1. Snapshot current mode's files into .loop/.modes// + let snapshot_dir = PathBuf::from(format!(".loop/.modes/{}", current)); + if let Err(e) = fs::create_dir_all(&snapshot_dir) { + log_error(&format!("failed to create {}: {}", snapshot_dir.display(), e)); + return 1; + } + + for path in ¤t_paths { + let src = Path::new(path); + if src.exists() { + let dest = snapshot_dir.join(src.file_name().unwrap()); + if let Err(e) = fs::copy(src, &dest) { + log_error(&format!("failed to snapshot {}: {}", path, e)); + return 1; + } + log(&format!("snapshot: {} → {}", path, dest.display())); + } + } + + // 2. Delete files exclusive to current mode (not in target) + for path in current_paths.difference(&target_paths) { + let p = Path::new(path); + if p.exists() { + if let Err(e) = fs::remove_file(p) { + log_error(&format!("failed to remove {}: {}", path, e)); + return 1; + } + log(&format!("removed: {}", path)); + } + } + + // 3. Check for a prior snapshot of the target mode + let restore_dir = PathBuf::from(format!(".loop/.modes/{}", target)); + if restore_dir.exists() { + log(&format!("Restoring '{}' mode from snapshot...", target)); + for (path, _) in &target_files { + let file_name = Path::new(path).file_name().unwrap(); + let src = restore_dir.join(file_name); + if src.exists() { + if let Err(e) = fs::copy(&src, path) { + log_error(&format!("failed to restore {}: {}", path, e)); + return 1; + } + log(&format!("restored: {}", path)); + } else { + // File wasn't in the snapshot — create from template if missing + let p = Path::new(path); + if !p.exists() { + let (_, content) = target_files.iter().find(|(tp, _)| tp == path).unwrap(); + if let Err(e) = fs::write(p, content) { + log_error(&format!("failed to write {}: {}", path, e)); + return 1; + } + log(&format!("created: {}", path)); + } + } + } + // Remove the restored snapshot directory + if let Err(e) = fs::remove_dir_all(&restore_dir) { + log_error(&format!("failed to remove {}: {}", restore_dir.display(), e)); + return 1; + } + } else { + // 4. Fresh init for the target mode + log(&format!("Fresh init for '{}' mode...", target)); + for (path, content) in &target_files { + let p = Path::new(path); + if p.exists() { + // Shared file that wasn't deleted — overwrite with target template + // (e.g. protocol.md, yoke.conf have different content per mode) + if current_paths.contains(path) { + if let Err(e) = fs::write(p, content) { + log_error(&format!("failed to write {}: {}", path, e)); + return 1; + } + log(&format!("overwritten: {} (new mode template)", path)); + } else { + log(&format!("skip (exists): {}", path)); + } + } else { + if let Err(e) = fs::write(p, content) { + log_error(&format!("failed to write {}: {}", path, e)); + return 1; + } + log(&format!("created: {}", path)); + } + } + } + + log(&format!("Switched from '{}' to '{}' mode", current, target)); + 0 +} + fn init(mode: &str) -> i32 { + let files = match mode_files(mode) { + Some(f) => f, + None => { + log_error(&format!("unknown mode '{}'", mode)); + return 2; + } + }; + + // Detect current mode — if different, do a backup/restore switch + match detect_mode() { + Some(current) if current != mode => { + log(&format!("Switching from '{}' to '{}' mode...", current, mode)); + return switch_mode(current, mode); + } + _ => {} // fresh init or same-mode idempotent + } + let loop_dir = Path::new(".loop"); if !loop_dir.exists() { if let Err(e) = fs::create_dir(loop_dir) { @@ -796,29 +1314,6 @@ fn init(mode: &str) -> i32 { log("Created .loop/"); } - let files: Vec<(&str, &str)> = match mode { - "loop" => vec![ - (CONF_PATH, DEFAULT_GUARD_CONF), - (PROTOCOL_PATH, DEFAULT_PROTOCOL), - (PLAN_PATH, ""), - (NOTES_PATH, ""), - (GUARD_RESULTS_PATH, ""), - ], - "brute" => vec![ - (CONF_PATH, DEFAULT_BRUTE_CONF), - (PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL), - (PLAN_PATH, ""), - (JUDGE_PATH, ""), - (NOTES_PATH, ""), - (VERDICT_PATH, ""), - (GUARD_RESULTS_PATH, ""), - ], - other => { - log_error(&format!("unknown mode '{}'", other)); - return 2; - } - }; - log(&format!("Initializing '{}' mode...", mode)); for (path, content) in &files { @@ -840,21 +1335,26 @@ fn init(mode: &str) -> i32 { /// Core plan-loop runner that can be called standalone or nested inside brute. /// /// - `config`: already-loaded Config +/// - `plan_path`: path to the plan file (e.g. PLAN_PATH or SUB_PLAN_PATH) /// - `dry_run`: if true, skip Claude invocation (one iteration only) /// - `nested`: if true, running inside brute (adjusts output banners) /// /// Returns Ok(()) on success (STATUS: DONE + guards pass), Err(i32) with exit code on failure. -fn run_plan_loop(config: &Config, dry_run: bool, nested: bool) -> Result<(), i32> { - // Preflight - preflight(); +fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) -> Result<(), i32> { + // Preflight — skip guard-results clear when nested (brute handles it) + preflight(plan_path, !nested); log("Preflight OK"); + // Build protected files list dynamically based on the plan path + let protected: Vec<&str> = vec![PROTOCOL_PATH, plan_path, CONF_PATH]; + // Backup protected files and create the runner (Drop handles cleanup) - let backup_dir = backup_protected(); + let backup_dir = backup_files(&protected); log(&format!("Backups in {}", backup_dir.display())); let mut runner = LoopRunner::new(backup_dir); let mut iteration: u32 = 0; + let mut total_cost: f64 = 0.0; loop { if signal::interrupted() { @@ -866,42 +1366,59 @@ fn run_plan_loop(config: &Config, dry_run: bool, nested: bool) -> Result<(), i32 if nested { eprintln!( "{}{}┌──────────────────────────────────────┐{}", - BOLD, CYAN, RESET + BOLD, ORANGE, RESET ); eprintln!( "{}{}│ Plan Iteration {:>4} │{}", - BOLD, CYAN, iteration, RESET + BOLD, ORANGE, iteration, RESET ); eprintln!( "{}{}└──────────────────────────────────────┘{}", - BOLD, CYAN, RESET + BOLD, ORANGE, RESET ); } else { eprintln!( "{}{}╔══════════════════════════════════════╗{}", - BOLD, CYAN, RESET + BOLD, ORANGE, RESET ); eprintln!( "{}{}║ Iteration {:>4} ║{}", - BOLD, CYAN, iteration, RESET + BOLD, ORANGE, iteration, RESET ); eprintln!( "{}{}╚══════════════════════════════════════╝{}", - BOLD, CYAN, RESET + BOLD, ORANGE, RESET ); } - // Restore protected files - restore_protected(&runner.backup_dir); + // Show stage progress bar if plan has parseable stages + if let Some((completed, total)) = stage_progress(plan_path) { + eprintln!("{}", format_progress_bar(completed, total)); + } - // Clear guard results - let _ = fs::write(GUARD_RESULTS_PATH, ""); + // Restore protected files + restore_files(&runner.backup_dir, &protected); + + // NOTE: Do NOT clear guard-results.md here. The previous iteration's + // guard results must persist so Claude can read them and fix failures. + // The file is initialized to empty in preflight() (first iteration) + // and overwritten by run_all_guards() after Claude finishes. if dry_run { - log("(dry-run) Skipping Claude invocation"); - } else if !invoke_claude(&mut runner, config, iteration) { - log_error("Claude invocation failed — aborting loop"); - return Err(1); + log(&format!( + "(dry-run) Skipping {} invocation", + match config.backend() { + Backend::Claude => "Claude", + Backend::OpenCode => "OpenCode", + } + )); + } else { + let (success, iter_cost) = invoke_agent(&mut runner, config, iteration, total_cost); + total_cost += iter_cost; + if !success { + log_error("Claude invocation failed — aborting loop"); + return Err(1); + } } if signal::interrupted() { @@ -937,7 +1454,7 @@ fn run_plan_loop(config: &Config, dry_run: bool, nested: bool) -> Result<(), i32 } else { log(&format!( "{}Some guards failed \u{2014} Claude will see results next iteration{}", - YELLOW, RESET + ORANGE, RESET )); if dry_run { @@ -959,26 +1476,45 @@ fn run_loop(dry_run: bool) -> i32 { }; log(&format!( - "Config loaded: max_tail={}, {} scope rules, {} guards{}", + "Config loaded: max_tail={}, {} scope rules, {} guards, backend={}{}", config.max_tail, config.scope_rules.len(), config.guards.len(), + match config.backend() { + Backend::Claude => "claude", + Backend::OpenCode => config.model.as_ref().map_or("opencode", |m| m.as_str()), + }, config.image.as_ref().map_or( String::from(", sandbox=off"), |img| format!(", image={}", img) ) )); - match run_plan_loop(&config, dry_run, false) { + match run_plan_loop(&config, PLAN_PATH, dry_run, false) { Ok(()) => 0, Err(code) => code, } } +/// Result of a brute loop run. +enum BruteResult { + /// Judge said PASS — feature is complete. + Pass, + /// 3 consecutive judge FAILs — bailing out for re-scoping. + Bailout, + /// User interrupted (SIGINT). + Interrupt, + /// Hard error (spawn failure, missing files, etc.). + Error, +} + /// Protected files for the brute runner. const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH]; -fn run_brute(dry_run: bool) -> i32 { +/// Max consecutive judge failures before the brute loop bails out. +const MAX_JUDGE_FAILURES: u32 = 3; + +fn run_brute_inner(dry_run: bool) -> BruteResult { // Load config let config = match Config::load(Path::new(CONF_PATH)) { Ok(c) => c, @@ -999,7 +1535,7 @@ fn run_brute(dry_run: bool) -> i32 { ) )); - // Preflight: require protocol.md, judge.md, guard.conf + // Preflight: require protocol.md, judge.md, yoke.conf for path in &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH] { if !Path::new(path).exists() { log_error(&format!("required file not found: {}", path)); @@ -1028,125 +1564,116 @@ fn run_brute(dry_run: bool) -> i32 { } } - // Clear verdict + // Clear verdict and guard results for the first brute attempt. + // Subsequent retries preserve these so the worker can read feedback. let _ = fs::write(VERDICT_PATH, ""); + let _ = fs::write(GUARD_RESULTS_PATH, ""); log("Preflight OK"); + run_brute_core(&config, PLAN_PATH, dry_run) +} + +/// Core brute loop logic, parameterized by plan path. Used by both standalone +/// brute mode and saga mode (which passes SUB_PLAN_PATH). +/// +/// - `config`: already-loaded Config +/// - `plan_path`: path to the plan file +/// - `dry_run_inner`: if true, skip Claude invocation (one iteration only) +fn run_brute_core(config: &Config, plan_path: &str, dry_run: bool) -> BruteResult { // Backup protected files - let backup_dir = std::env::temp_dir().join(format!("yoke-backup-{}", process::id())); - if let Err(e) = fs::create_dir_all(&backup_dir) { - log_error(&format!("failed to create backup dir: {}", e)); - process::exit(1); - } - for path in BRUTE_PROTECTED_FILES { - let src = Path::new(path); - if src.exists() { - let dest = backup_dir.join(src.file_name().unwrap()); - if let Err(e) = fs::copy(src, &dest) { - log_error(&format!("failed to backup {}: {}", path, e)); - process::exit(1); - } - } - } + let backup_dir = backup_files(BRUTE_PROTECTED_FILES); log(&format!("Backups in {}", backup_dir.display())); let mut runner = LoopRunner::new(backup_dir); let mut iteration: u32 = 0; + let mut consecutive_failures: u32 = 0; loop { if signal::interrupted() { log("Interrupted \u{2014} shutting down"); - return 130; + return BruteResult::Interrupt; } iteration += 1; eprintln!(); eprintln!( "{}{}╔══════════════════════════════════════╗{}", - BOLD, CYAN, RESET + BOLD, ORANGE, RESET ); eprintln!( "{}{}║ Brute Iteration {:>4} ║{}", - BOLD, CYAN, iteration, RESET + BOLD, ORANGE, iteration, RESET ); eprintln!( "{}{}╚══════════════════════════════════════╝{}", - BOLD, CYAN, RESET + BOLD, ORANGE, RESET ); // Restore protected files - for path in BRUTE_PROTECTED_FILES { - let src_name = Path::new(path).file_name().unwrap(); - let backup_file = runner.backup_dir.join(src_name); - if backup_file.exists() - && let Err(e) = fs::copy(&backup_file, path) - { - eprintln!( - "{}{}[yoke] WARNING:{} failed to restore {}: {}", - BOLD, YELLOW, RESET, path, e - ); - } - } + restore_files(&runner.backup_dir, BRUTE_PROTECTED_FILES); - // Clear verdict - let _ = fs::write(VERDICT_PATH, ""); + // NOTE: Do NOT clear verdict.md or guard-results.md here. + // On retry after judge FAIL, the worker needs to see the previous + // verdict and guard feedback. Both files are cleared once in + // run_brute_inner() (first attempt) and overwritten by the judge / + // guards each iteration. if dry_run { log("(dry-run) Skipping worker invocation"); } else { log("Using plan runner as worker..."); - match run_plan_loop(&config, false, true) { + match run_plan_loop(config, plan_path, false, true) { Ok(()) => log("Plan runner completed successfully"), Err(130) => { log("Interrupted \u{2014} shutting down"); - return 130; + return BruteResult::Interrupt; } Err(_) => { log_error("Plan runner failed — aborting brute loop"); - return 1; + return BruteResult::Error; } } } if signal::interrupted() { log("Interrupted \u{2014} shutting down"); - return 130; + return BruteResult::Interrupt; } // Run guards (skip when not dry-run — the plan loop already ran them) if dry_run { - let passed = run_all_guards(&config); + let passed = run_all_guards(config); if !passed { log(&format!( - "{}Guards failed{}", YELLOW, RESET + "{}Guards failed{}", ORANGE, RESET )); log("(dry-run) Exiting after one iteration"); - return 1; + return BruteResult::Error; } log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET)); log("(dry-run) Guards passed \u{2014} exiting after one iteration"); - return 0; + return BruteResult::Pass; } // Guards passed — invoke judge eprintln!(); eprintln!( "{}{}┌─ Judge ────────────────────────────────┐{}", - BOLD, YELLOW, RESET + BOLD, BLUE, RESET ); eprintln!( "{}{}│ Invoking judge (attempt {:>4}) │{}", - BOLD, YELLOW, iteration, RESET + BOLD, BLUE, iteration, RESET ); eprintln!( "{}{}└────────────────────────────────────────┘{}", - BOLD, YELLOW, RESET + BOLD, BLUE, RESET ); - let pass = invoke_judge(&mut runner, &config, iteration); + let pass = invoke_judge(&mut runner, config, iteration); if signal::interrupted() { log("Interrupted \u{2014} shutting down"); - return 130; + return BruteResult::Interrupt; } if pass { @@ -1156,20 +1683,273 @@ fn run_brute(dry_run: bool) -> i32 { GREEN, BOLD, RESET ); eprintln!(); - return 0; + return BruteResult::Pass; } + consecutive_failures += 1; log(&format!( - "{}Judge says FAIL \u{2014} worker will see verdict next iteration{}", - YELLOW, RESET + "{}Judge says FAIL ({}/{}) \u{2014} {}{}", + ORANGE, + consecutive_failures, + MAX_JUDGE_FAILURES, + if consecutive_failures >= MAX_JUDGE_FAILURES { + "bailing out" + } else { + "worker will see verdict next iteration" + }, + RESET )); + if consecutive_failures >= MAX_JUDGE_FAILURES { + eprintln!(); + eprintln!( + "{}{} {} consecutive judge FAILs \u{2014} bailing out {}", + RED, BOLD, MAX_JUDGE_FAILURES, RESET + ); + eprintln!(); + return BruteResult::Bailout; + } + // Reset STATUS to IN_PROGRESS so the plan runner re-executes on the // next brute attempt, but preserve all notes. reset_notes_status(); } } +/// Top-level brute runner that maps BruteResult to an exit code. +fn run_brute(dry_run: bool) -> i32 { + match run_brute_inner(dry_run) { + BruteResult::Pass => 0, + BruteResult::Bailout => 1, + BruteResult::Interrupt => 130, + BruteResult::Error => 1, + } +} + +/// Check the first line of saga-notes.md for STATUS: DONE. +fn is_saga_done() -> bool { + match fs::read_to_string(SAGA_NOTES_PATH) { + Ok(content) => content.starts_with("STATUS: DONE"), + Err(_) => false, + } +} + +/// Invoke the scoper (Agent 1) — a fresh LLM session that reads the spec, +/// writes sub-plan.md, and updates saga-notes.md. +/// Returns true if the invocation succeeded. +fn invoke_scoper(runner: &mut LoopRunner, config: &Config, cycle: u32) -> bool { + let scoper_prompt = "Read .loop/saga-protocol.md and follow its instructions."; + let (status, _) = invoke_process( + runner, config, scoper_prompt, "scoper", "scoper", cycle, 0.0, + ); + status +} + +/// Run the saga loop: scoper (Agent 1) decomposes the spec into sub-plans, +/// each sub-plan is run through the brute loop (Agent 2 + Agent 3). +fn run_saga(dry_run: bool) -> i32 { + // Load config + let config = match Config::load(Path::new(CONF_PATH)) { + Ok(c) => c, + Err(e) => { + log_error(&e); + process::exit(1); + } + }; + + log(&format!( + "Config loaded: mode=saga, max_tail={}, {} scope rules, {} guards{}", + config.max_tail, + config.scope_rules.len(), + config.guards.len(), + config.image.as_ref().map_or( + String::from(", sandbox=off"), + |img| format!(", image={}", img) + ) + )); + + // Preflight: verify required saga files exist + for path in &[ + SPECIFICATION_PATH, + SAGA_PROTOCOL_PATH, + PROTOCOL_PATH, + JUDGE_PATH, + CONF_PATH, + ] { + if !Path::new(path).exists() { + log_error(&format!("required file not found: {}", path)); + process::exit(1); + } + } + + // Fail fast if specification.md is empty + match fs::read_to_string(SPECIFICATION_PATH) { + Ok(content) if content.trim().is_empty() => { + log_error(&format!( + "{} is empty — fill it in before running", + SPECIFICATION_PATH + )); + process::exit(1); + } + Err(e) => { + log_error(&format!("cannot read {}: {}", SPECIFICATION_PATH, e)); + process::exit(1); + } + _ => {} + } + + // Ensure working files exist + for path in &[ + SAGA_NOTES_PATH, + DECISIONS_PATH, + SUB_PLAN_PATH, + NOTES_PATH, + VERDICT_PATH, + GUARD_RESULTS_PATH, + ] { + if !Path::new(path).exists() { + if let Err(e) = fs::write(path, "") { + log_error(&format!("cannot create {}: {}", path, e)); + process::exit(1); + } + } + } + + log("Preflight OK"); + + // Protected files for the saga runner + let saga_protected: Vec<&str> = vec![ + SAGA_PROTOCOL_PATH, + PROTOCOL_PATH, + JUDGE_PATH, + CONF_PATH, + ]; + + let backup_dir = backup_files(&saga_protected); + log(&format!("Backups in {}", backup_dir.display())); + + let mut runner = LoopRunner::new(backup_dir); + let mut cycle: u32 = 0; + + loop { + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return 130; + } + cycle += 1; + eprintln!(); + eprintln!( + "{}{}╔══════════════════════════════════════╗{}", + BOLD, ORANGE, RESET + ); + eprintln!( + "{}{}║ Saga Cycle {:>4} ║{}", + BOLD, ORANGE, cycle, RESET + ); + eprintln!( + "{}{}╚══════════════════════════════════════╝{}", + BOLD, ORANGE, RESET + ); + + // Restore protected files + restore_files(&runner.backup_dir, &saga_protected); + + if dry_run { + log("(dry-run) Skipping scoper invocation"); + } else { + // Invoke Agent 1 (scoper) + eprintln!(); + eprintln!( + "{}{}┌\u{2500} Scoper \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}┐{}", + BOLD, BLUE, RESET + ); + eprintln!( + "{}{}│ Invoking scoper (cycle {:>4}) │{}", + BOLD, BLUE, cycle, RESET + ); + eprintln!( + "{}{}└\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}┘{}", + BOLD, BLUE, RESET + ); + + let scoper_ok = invoke_scoper(&mut runner, &config, cycle); + if !scoper_ok { + log_error("Scoper invocation failed \u{2014} aborting saga"); + return 1; + } + + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return 130; + } + } + + // Check if scoper signaled DONE + if is_saga_done() { + eprintln!(); + eprintln!( + "{}{} Scoper signals DONE \u{2014} saga complete {}", + GREEN, BOLD, RESET + ); + eprintln!(); + return 0; + } + + // Verify sub-plan.md is non-empty + match fs::read_to_string(SUB_PLAN_PATH) { + Ok(content) if content.trim().is_empty() => { + log_error("Scoper produced an empty sub-plan.md \u{2014} aborting saga"); + return 1; + } + Err(e) => { + log_error(&format!("cannot read {}: {}", SUB_PLAN_PATH, e)); + return 1; + } + _ => {} + } + + if dry_run { + log("(dry-run) Skipping brute loop on sub-plan.md"); + log("(dry-run) Exiting after one cycle"); + return 0; + } + + // Run the brute loop on sub-plan.md + log("Running brute loop on sub-plan.md..."); + + // Clear notes.md, verdict.md, and guard-results.md for the inner brute cycle + let _ = fs::write(NOTES_PATH, ""); + let _ = fs::write(VERDICT_PATH, ""); + let _ = fs::write(GUARD_RESULTS_PATH, ""); + + let brute_result = run_brute_core(&config, SUB_PLAN_PATH, false); + + match brute_result { + BruteResult::Pass => { + log(&format!( + "{}Sub-plan passed \u{2014} looping back to scoper{}", + GREEN, RESET + )); + } + BruteResult::Bailout => { + log(&format!( + "{}Brute loop bailed out \u{2014} looping back to scoper for re-scoping{}", + ORANGE, RESET + )); + // verdict.md and notes.md are preserved — scoper will read them + } + BruteResult::Interrupt => { + log("Interrupted \u{2014} shutting down"); + return 130; + } + BruteResult::Error => { + log_error("Brute loop encountered a hard error \u{2014} aborting saga"); + return 1; + } + } + } +} + fn main() { signal::install(); let args: Vec = std::env::args().collect(); @@ -1188,6 +1968,7 @@ fn main() { let mode = match args.get(2).map(|a| a.as_str()) { None => "loop", Some("brute") => "brute", + Some("saga") => "saga", Some(other) => { log_error(&format!("unknown argument '{}'", other)); print_init_help(); @@ -1201,6 +1982,65 @@ fn main() { } process::exit(init(mode)); } + "clean" => { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_clean_help(); + return; + } + if args.len() > 2 { + log_error(&format!("unexpected argument '{}'", args[2])); + print_clean_help(); + process::exit(2); + } + process::exit(clean()); + } + "stash" => { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_stash_help(); + return; + } + match args.get(2).map(|a| a.as_str()) { + Some("pop") => { + if args.len() > 3 { + log_error(&format!("unexpected argument '{}'", args[3])); + print_stash_help(); + process::exit(2); + } + process::exit(stash_pop()); + } + Some(other) => { + log_error(&format!("unknown subcommand '{}'", other)); + print_stash_help(); + process::exit(2); + } + None => { + log_error("missing subcommand — try 'yoke stash pop'"); + print_stash_help(); + process::exit(2); + } + } + } + "layer" => { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_layer_help(); + return; + } + if args.get(2).is_some_and(|a| a == "--list") { + for name in list_layers() { + println!(" {}", name); + } + return; + } + let names_arg = match args.get(2) { + Some(a) => a.clone(), + None => { + log_error("missing layer name"); + print_layer_help(); + process::exit(2); + } + }; + process::exit(apply_layers(&names_arg)); + } "run" => { // Check for --help before other flags if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { @@ -1233,11 +2073,17 @@ fn main() { eprintln!(" Add 'image ' to {} or pass --no-sandbox to override.", CONF_PATH); process::exit(2); } - let is_brute = Path::new(JUDGE_PATH).exists(); - if is_brute { - process::exit(run_brute(dry_run)) - } else { - process::exit(run_loop(dry_run)) + match detect_mode() { + Some("saga") => { + log("Detected saga mode"); + process::exit(run_saga(dry_run)) + } + Some("brute") => { + process::exit(run_brute(dry_run)) + } + _ => { + process::exit(run_loop(dry_run)) + } } } "--help" | "-h" | "help" => { diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..4120fb1 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,86 @@ +//! Widget Registry — central namespace for tracking UI widgets by name and type. +//! +//! Supports insertion, lookup by name, and iteration over all registered widgets. +//! This is the foundation that later stages (heartbeat listeners, guard display, +//! progress bar, verdict banner) hang off. + +use std::collections::HashMap; + +/// The kind of visual element a widget represents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WidgetType { + ProgressBar, + Table, + Banner, + Heartbeat, + Tree, +} + +/// A single registered widget. +#[derive(Debug, Clone)] +pub struct Widget { + pub name: String, + pub wtype: WidgetType, + pub enabled: bool, +} + +/// Central registry that tracks all UI widgets by name and type. +pub struct WidgetRegistry { + widgets: HashMap, + /// Insertion order preserved for deterministic iteration. + order: Vec, +} + +impl WidgetRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + widgets: HashMap::new(), + order: Vec::new(), + } + } + + /// Insert a widget. If a widget with the same name exists, it is replaced + /// (its position in iteration order is preserved). + pub fn insert(&mut self, name: impl Into, wtype: WidgetType) { + let name = name.into(); + let widget = Widget { + name: name.clone(), + wtype, + enabled: true, + }; + if self.widgets.insert(name.clone(), widget).is_none() { + self.order.push(name); + } + } + + /// Look up a widget by name. + pub fn get(&self, name: &str) -> Option<&Widget> { + self.widgets.get(name) + } + + /// Iterate over all widgets in insertion order. + pub fn iter(&self) -> impl Iterator { + self.order.iter().filter_map(|n| self.widgets.get(n)) + } + + /// Number of registered widgets. + pub fn len(&self) -> usize { + self.widgets.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.widgets.is_empty() + } + + /// Remove a widget by name. Returns the removed widget, if any. + pub fn remove(&mut self, name: &str) -> Option { + if let Some(w) = self.widgets.remove(name) { + self.order.retain(|n| n != name); + Some(w) + } else { + None + } + } +} diff --git a/src/scratch/config.json b/src/scratch/config.json new file mode 100644 index 0000000..bf64399 --- /dev/null +++ b/src/scratch/config.json @@ -0,0 +1,21 @@ +{ + "name": "yoke-visual-test", + "version": "0.1.0", + "settings": { + "max_retries": 3, + "timeout_ms": 5000, + "verbose": true, + "colors": { + "primary": "#46FF00", + "secondary": "#4B80FF", + "error": "#FF3232" + } + }, + "features": [ + "stream-filter", + "edit-diff", + "write-preview", + "bash-error-tail", + "thinking-timer" + ] +} diff --git a/src/scratch/demo.rs b/src/scratch/demo.rs new file mode 100644 index 0000000..abe3bd5 --- /dev/null +++ b/src/scratch/demo.rs @@ -0,0 +1,44 @@ +/// A small demo module for exercising stream visuals. +/// This file exists only to generate interesting diffs. + +pub fn greet(name: &str) -> String { + format!("Hey there, {}! Welcome to the stream.", name) +} + +pub fn farewell(name: &str) -> String { + format!("Goodbye, {}. See you next time!", name) +} + +pub fn fibonacci(n: u32) -> u64 { + match n { + 0 => 0, + 1 => 1, + _ => { + let mut a: u64 = 0; + let mut b: u64 = 1; + for _ in 2..=n { + let tmp = a + b; + a = b; + b = tmp; + } + b + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_greet() { + assert_eq!(greet("world"), "Hello, world!"); + } + + #[test] + fn test_fibonacci() { + assert_eq!(fibonacci(0), 0); + assert_eq!(fibonacci(1), 1); + assert_eq!(fibonacci(10), 55); + } +} diff --git a/src/scratch/extra.toml b/src/scratch/extra.toml new file mode 100644 index 0000000..7c59f61 --- /dev/null +++ b/src/scratch/extra.toml @@ -0,0 +1,11 @@ +# Extra scratch config for visual testing + +[display] +theme = "dark" +line_numbers = true +word_wrap = false + +[limits] +max_diff_lines = 50 +max_badge_width = 40 +truncate_at = 120 diff --git a/src/scratch/notes.md b/src/scratch/notes.md new file mode 100644 index 0000000..42834bd --- /dev/null +++ b/src/scratch/notes.md @@ -0,0 +1,17 @@ +# Scratch Notes + +These are dummy notes for exercising the yoke stream output. + +## Features Tested + +- **Edit diffs**: red/green line-level changes +- **Write previews**: line count badge + first 3 lines +- **Bash errors**: last 3 lines of stderr in red +- **Thinking timer**: live elapsed seconds display +- **Grep/Glob badges**: match/file count badges + +## Open Questions + +1. Should the progress bar use unicode block characters? +2. What is the optimal truncation length for edit diffs? +3. How should we handle binary file diffs? diff --git a/src/scratch/setup.sh b/src/scratch/setup.sh new file mode 100644 index 0000000..9d63975 --- /dev/null +++ b/src/scratch/setup.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Scratch setup script for visual testing +set -euo pipefail + +echo "Setting up scratch environment..." + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRATCH_DIR="${PROJECT_ROOT}/src/scratch" + +echo "Project root: ${PROJECT_ROOT}" +echo "Scratch dir: ${SCRATCH_DIR}" + +# Count files +FILE_COUNT=$(find "${SCRATCH_DIR}" -type f | wc -l) +echo "Found ${FILE_COUNT} scratch files" + +# List them +for f in "${SCRATCH_DIR}"/*; do + if [ -f "$f" ]; then + echo " - $(basename "$f") ($(wc -l < "$f") lines)" + fi +done + +echo "Done." diff --git a/src/stream.rs b/src/stream.rs index 9de8e9c..cdf0cec 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,6 +1,8 @@ -use std::io::{BufRead, BufReader, Write}; +use std::collections::HashMap; +use std::io::{self, BufRead, BufReader, Write}; use std::path::Path; use std::process::ChildStdout; +use std::time::Instant; use crate::json::{extract_num, extract_str, unescape_json}; @@ -8,15 +10,30 @@ 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 CYAN: &str = "\x1b[36m"; -const YELLOW: &str = "\x1b[33m"; -const GREEN: &str = "\x1b[32m"; +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 { turn_num: u32, current_msg_id: Option, seen_init: bool, in_thinking: bool, + thinking_start: Option, + iteration_cost: f64, + iteration_duration_secs: f64, + /// Maps tool_use id → tool name, so tool_result can look up its origin. + tool_use_names: HashMap, + /// Counts of tool_use events by tool name (for iteration summary strip). + tool_counts: HashMap, } impl StreamState { @@ -26,10 +43,168 @@ impl StreamState { current_msg_id: None, seen_init: false, in_thinking: false, + thinking_start: None, + iteration_cost: 0.0, + iteration_duration_secs: 0.0, + tool_use_names: HashMap::new(), + tool_counts: HashMap::new(), } } } +/// Render a mini-diff from old_string/new_string extracted from an Edit tool_use. +/// Shows red `−` lines for removed and green `+` lines for added, truncated to ~5 lines. +fn format_edit_diff(line: &str) -> String { + let old = extract_str(line, "old_string").map(|s| unescape_json(s)); + let new = extract_str(line, "new_string").map(|s| unescape_json(s)); + + let (old, new) = match (old, new) { + (Some(o), Some(n)) => (o, n), + _ => return String::new(), + }; + + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + + let mut diff_lines: Vec = Vec::new(); + for ol in &old_lines { + diff_lines.push(format!(" {}{}{}− {}{}", BG_RED, RED, DIM, ol, RESET)); + } + for nl in &new_lines { + diff_lines.push(format!(" {}{}{}+ {}{}", BG_GREEN, GREEN, DIM, nl, RESET)); + } + + let max_display = 5; + let total = diff_lines.len(); + if total <= max_display { + diff_lines.join("\n") + } else { + let mut out: Vec = diff_lines[..max_display].to_vec(); + out.push(format!(" {}… +{} more lines{}", DIM, total - max_display, RESET)); + out.join("\n") + } +} + +/// Extract the last ~3 lines of error content from a Bash tool_result for display. +fn format_bash_error_tail(line: &str) -> String { + let content = match extract_str(line, "content") { + Some(s) => unescape_json(s), + None => return String::new(), + }; + + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return String::new(); + } + + let max_tail = 3; + let start = if lines.len() > max_tail { lines.len() - max_tail } else { 0 }; + let tail: Vec = lines[start..] + .iter() + .map(|l| format!(" {}{}{}", RED, l, RESET)) + .collect(); + tail.join("\n") +} + +/// Render a preview for Write tool_use: first ~3 lines of content + line count badge. +fn format_write_preview(line: &str) -> String { + let content = match extract_str(line, "content") { + Some(s) => unescape_json(s), + None => return String::new(), + }; + + let lines: Vec<&str> = content.lines().collect(); + let total = lines.len(); + let badge = format!(" {}({} lines){}", DIM, total, RESET); + + let max_preview = 3; + let preview_lines: Vec = lines.iter() + .take(max_preview) + .map(|l| format!(" {}{}{}", DIM, l, RESET)) + .collect(); + + let mut out = vec![badge]; + out.extend(preview_lines); + if total > max_preview { + out.push(format!(" {}…{}", DIM, RESET)); + } + out.join("\n") +} + +/// Format a badge for Grep/Glob tool_result content. +/// For Grep: tries to count matches/files from the content. +/// For Glob: counts the number of file paths returned. +fn format_grep_glob_badge(tool_name: &str, line: &str) -> String { + let content = match extract_str(line, "content") { + Some(s) => unescape_json(s), + None => return String::new(), + }; + + if content.trim().is_empty() { + return format!("{}0 results{}", DIM, RESET); + } + + match tool_name { + "Grep" => { + // Grep results are typically one file path per line (files_with_matches mode) + // or content lines. Count non-empty lines as results. + let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); + let count = lines.len(); + if count == 1 { + format!("{} match", count) + } else { + format!("{} matches", count) + } + } + "Glob" => { + let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); + let count = lines.len(); + if count == 1 { + format!("{} file", count) + } else { + format!("{} files", count) + } + } + _ => String::new(), + } +} + +/// Map a file extension to a human-readable language/type label. +fn ext_to_label(ext: &str) -> Option<&'static str> { + match ext { + "rs" => Some("rust"), + "py" => Some("python"), + "js" => Some("javascript"), + "ts" => Some("typescript"), + "tsx" => Some("tsx"), + "jsx" => Some("jsx"), + "json" => Some("json"), + "toml" => Some("toml"), + "yaml" | "yml" => Some("yaml"), + "md" => Some("markdown"), + "sh" | "bash" | "zsh" => Some("shell"), + "html" => Some("html"), + "css" => Some("css"), + "sql" => Some("sql"), + "go" => Some("go"), + "java" => Some("java"), + "c" => Some("c"), + "cpp" | "cc" | "cxx" => Some("c++"), + "h" | "hpp" => Some("header"), + "rb" => Some("ruby"), + "lua" => Some("lua"), + "zig" => Some("zig"), + "lock" => Some("lock"), + "xml" => Some("xml"), + "txt" => Some("text"), + "csv" => Some("csv"), + "dockerfile" => Some("docker"), + "tf" => Some("terraform"), + "ex" | "exs" => Some("elixir"), + _ => None, + } +} + /// Format a tool_use event into a human-readable string. fn format_tool_call(line: &str) -> String { let tool_name = extract_str(line, "name").unwrap_or("?"); @@ -37,39 +212,245 @@ fn format_tool_call(line: &str) -> String { match tool_name { "Read" => { let path = extract_str(line, "file_path").unwrap_or("?"); - format!("Read: {}", path) + let badge = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .and_then(ext_to_label) + .map(|label| format!(" {}[{}]{}", DIM, label, RESET)) + .unwrap_or_default(); + format!("{}{}Read:{} {}{}{}{}", BOLD, CYAN, RESET, DIM, path, RESET, badge) } "Edit" => { let path = extract_str(line, "file_path").unwrap_or("?"); - format!("Edit: {}", path) + let header = format!("{}{}Edit:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET); + let diff = format_edit_diff(line); + if diff.is_empty() { + header + } else { + format!("{}\n{}", header, diff) + } } "Write" => { let path = extract_str(line, "file_path").unwrap_or("?"); - format!("Write: {}", path) + let header = format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET); + let preview = format_write_preview(line); + if preview.is_empty() { + header + } else { + format!("{}\n{}", header, preview) + } } "Bash" => { let cmd = extract_str(line, "command").unwrap_or("?"); if cmd.len() > 80 { - format!("Bash: {}...", &cmd[..77]) + let truncated: String = cmd.chars().take(77).collect(); + format!("{}{}Bash:{} {}{}...{}", BOLD, MAGENTA, RESET, DIM, truncated, RESET) } else { - format!("Bash: {}", cmd) + format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET) } } "Glob" => { let pat = extract_str(line, "pattern").unwrap_or("?"); - format!("Glob: {}", pat) + format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) } "Grep" => { let pat = extract_str(line, "pattern").unwrap_or("?"); - format!("Grep: {}", pat) + format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) } - other => other.to_string(), + other => format!("{}{}{}{}", BOLD, BLUE, other, RESET), } } +/// Process a single NDJSON line, writing formatted output to `out`. +/// `prior_total` is the accumulated cost from previous iterations, used to display a running total. +/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop. +fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> { + // Check for turn boundary (message_id change) + if let Some(msg_id) = extract_str(line, "message_id") { + let changed = match &state.current_msg_id { + Some(prev) => prev != msg_id, + None => true, + }; + if changed { + state.current_msg_id = Some(msg_id.to_string()); + state.turn_num += 1; + writeln!( + out, + "{}{}━━━ Turn {} ━━━{}", + BOLD, ORANGE, state.turn_num, RESET + )?; + } + } + + let ev_type = extract_str(line, "type"); + + match ev_type { + // system → check subtype for init + Some("system") => { + let ev_subtype = extract_str(line, "subtype"); + if ev_subtype == Some("init") && !state.seen_init { + state.seen_init = true; + let sid = extract_str(line, "session_id").unwrap_or("?"); + let sid_short: String = sid.chars().take(12).collect(); + let sid_short = sid_short.as_str(); + let model = extract_str(line, "model").unwrap_or("?"); + writeln!( + out, + "{}{}[stream]{} session {}… model={}", + ORANGE, BOLD, RESET, sid_short, model + )?; + } + } + // assistant → only tool_use summaries (text already shown via stream_event deltas) + Some("assistant") => { + if line.contains("\"tool_use\"") { + // Track tool_use id → name for badge display on tool_result + if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) { + state.tool_use_names.insert(id.to_string(), name.to_string()); + // Increment tool-use counter for iteration summary + *state.tool_counts.entry(name.to_string()).or_insert(0) += 1; + } + let desc = format_tool_call(line); + writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?; + } + } + // stream_event → streaming deltas + Some("stream_event") => { + if line.contains("\"content_block_delta\"") { + if line.contains("\"thinking_delta\"") { + // Show elapsed timer during extended thinking, rewriting in-place + if let Some(start) = state.thinking_start { + let elapsed = start.elapsed().as_secs_f64(); + write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; + out.flush()?; + } + } else if line.contains("\"text_delta\"") + && let Some(text) = extract_str(line, "text") + { + let text = unescape_json(text); + write!(out, "{}{}{}", DIM, text, RESET)?; + out.flush()?; + } + // input_json_delta → skip silently + } else if line.contains("\"content_block_start\"") { + if line.contains("\"thinking\"") { + state.thinking_start = Some(Instant::now()); + write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?; + out.flush()?; + state.in_thinking = true; + } else if !line.contains("\"tool_use\"") { + writeln!(out)?; + } + } else if line.contains("\"content_block_stop\"") { + if state.in_thinking { + // Print final elapsed time and end the line + if let Some(start) = state.thinking_start { + let elapsed = start.elapsed().as_secs_f64(); + write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; + } + state.in_thinking = false; + state.thinking_start = None; + } + writeln!(out)?; + } + // message_start, message_delta, message_stop → skip + } + // user → tool result summaries + Some("user") => { + if line.contains("\"tool_result\"") { + let is_error = line.contains("\"is_error\":true") + || line.contains("\"is_error\": true"); + if is_error { + let tail = format_bash_error_tail(line); + if tail.is_empty() { + writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?; + } else { + writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?; + writeln!(out, "{}", tail)?; + } + } else { + // Check if this is a Grep or Glob result for badge display + let tool_name = extract_str(line, "tool_use_id") + .and_then(|id| state.tool_use_names.get(id)) + .map(|s| s.as_str()); + match tool_name { + Some("Grep") | Some("Glob") => { + let badge = format_grep_glob_badge(tool_name.unwrap(), line); + if badge.is_empty() { + writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?; + } else { + writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)?; + } + } + _ => { + writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?; + } + } + } + } + } + // result → green bold summary with running total + Some("result") => { + let cost = extract_num(line, "cost_usd").unwrap_or(0.0); + state.iteration_cost = cost; + let total = prior_total + cost; + let turns = extract_num(line, "num_turns").unwrap_or(0.0) as u32; + let duration = extract_num(line, "duration_ms").unwrap_or(0.0); + let dur_secs = duration / 1000.0; + state.iteration_duration_secs = dur_secs; + writeln!( + out, + "{}{}[stream]{} done cost=${:.2} (total=${:.2}) turns={} duration={:.1}s", + ORANGE, BOLD, RESET, cost, total, turns, dur_secs + )?; + } + // Non-JSON or unrecognized — dim passthrough + _ => { + if ev_type.is_none() && !line.trim().is_empty() { + writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?; + } + } + } + Ok(()) +} + +/// Build a compact one-line iteration summary strip from accumulated state. +/// Format: `⟪ 6 turns │ 3 edits │ 1 bash │ 42s │ $0.38 ⟫` +fn format_summary_strip(state: &StreamState) -> String { + let mut parts: Vec = Vec::new(); + + // Turns + parts.push(format!("{} turn{}", state.turn_num, if state.turn_num == 1 { "" } else { "s" })); + + // Tool counts — show the most interesting tools in a stable order + let tool_order = ["Edit", "Write", "Read", "Bash", "Grep", "Glob"]; + for tool in &tool_order { + if let Some(&count) = state.tool_counts.get(*tool) { + let label = tool.to_lowercase(); + parts.push(format!("{} {}", count, label)); + } + } + // Any tools not in the predefined order + for (name, &count) in &state.tool_counts { + if !tool_order.contains(&name.as_str()) { + parts.push(format!("{} {}", count, name.to_lowercase())); + } + } + + // Duration + parts.push(format!("{:.0}s", state.iteration_duration_secs)); + + // Cost + parts.push(format!("${:.2}", state.iteration_cost)); + + format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET) +} + /// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. /// Consumes the stream entirely — raw NDJSON is not written to disk. -pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { +/// `prior_total` is the accumulated cost from previous iterations. +/// Returns the cost of this iteration (from the `result` event). +pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> f64 { let reader = BufReader::new(stdout); let mut state = StreamState::new(); let mut log_file = log_path.and_then(|p| { @@ -77,6 +458,8 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { std::fs::File::create(p).ok() }); + let mut out = io::stdout().lock(); + for line_result in reader.lines() { if crate::signal::interrupted() { break; @@ -96,109 +479,17 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { let _ = writeln!(f, "{}", line); } - // Check for turn boundary (message_id change) - if let Some(msg_id) = extract_str(&line, "message_id") { - let changed = match &state.current_msg_id { - Some(prev) => prev != msg_id, - None => true, - }; - if changed { - state.current_msg_id = Some(msg_id.to_string()); - state.turn_num += 1; - println!( - "{}{}━━━ Turn {} ━━━{}", - BOLD, CYAN, state.turn_num, RESET - ); - } - } - - let ev_type = extract_str(&line, "type"); - - match ev_type { - // system → check subtype for init - Some("system") => { - let ev_subtype = extract_str(&line, "subtype"); - if ev_subtype == Some("init") && !state.seen_init { - state.seen_init = true; - let sid = extract_str(&line, "session_id").unwrap_or("?"); - let sid_short = if sid.len() > 12 { &sid[..12] } else { sid }; - let model = extract_str(&line, "model").unwrap_or("?"); - println!( - "{}{}[stream] session {}... model={}{}", - CYAN, BOLD, sid_short, model, RESET - ); - } - } - // assistant → only tool_use summaries (text already shown via stream_event deltas) - Some("assistant") => { - if line.contains("\"tool_use\"") { - let desc = format_tool_call(&line); - println!("{}{}>>{} {}{}", YELLOW, BOLD, RESET, desc, RESET); - } - } - // stream_event → streaming deltas - Some("stream_event") => { - if line.contains("\"content_block_delta\"") { - if line.contains("\"thinking_delta\"") { - // Show activity during extended thinking - print!("{}·{}", DIM, RESET); - std::io::stdout().flush().ok(); - } else if line.contains("\"text_delta\"") - && let Some(text) = extract_str(&line, "text") - { - let text = unescape_json(text); - print!("{}{}{}", DIM, text, RESET); - std::io::stdout().flush().ok(); - } - // input_json_delta → skip silently - } else if line.contains("\"content_block_start\"") { - if line.contains("\"thinking\"") { - print!("{}{}thinking {}", DIM, CYAN, RESET); - std::io::stdout().flush().ok(); - state.in_thinking = true; - } else if !line.contains("\"tool_use\"") { - println!(); - } - } else if line.contains("\"content_block_stop\"") { - if state.in_thinking { - println!(); - state.in_thinking = false; - } else { - println!(); - } - } - // message_start, message_delta, message_stop → skip - } - // user → tool result summaries - Some("user") => { - if line.contains("\"tool_result\"") { - // Estimate content length from the line - let content_len = if let Some(start) = line.find("\"content\":\"") { - let after = &line[start + 11..]; - after.find('"').unwrap_or(after.len()) - } else { - 0 - }; - println!("{} \u{2190} result ({}b){}", DIM, content_len, RESET); - } - } - // result → green bold summary - Some("result") => { - let cost = extract_num(&line, "cost_usd").unwrap_or(0.0); - let turns = extract_num(&line, "num_turns").unwrap_or(0.0) as u32; - let duration = extract_num(&line, "duration_ms").unwrap_or(0.0); - let dur_secs = duration / 1000.0; - println!( - "{}{}[stream] done cost=${:.2} turns={} duration={:.1}s{}", - GREEN, BOLD, cost, turns, dur_secs, RESET - ); - } - // Non-JSON or unrecognized — dim passthrough - _ => { - if ev_type.is_none() && !line.trim().is_empty() { - println!("{} {}{}", DIM, line.trim(), RESET); - } - } + if process_line(&mut out, &line, &mut state, prior_total).is_err() { + break; // stdout broken (e.g. pipe closed) — stop gracefully } } + + // Print iteration summary strip after streaming ends (before guards) + if state.turn_num > 0 { + let strip = format_summary_strip(&state); + let _ = writeln!(out, "{}", strip); + } + + let _ = out.flush(); + state.iteration_cost } diff --git a/src/stream_opencode.rs b/src/stream_opencode.rs new file mode 100644 index 0000000..bdff9e7 --- /dev/null +++ b/src/stream_opencode.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; +use std::io::{self, BufRead, BufReader, Write}; +use std::path::Path; +use std::process::ChildStdout; + +use crate::json::{extract_num, extract_str, unescape_json}; + +const RESET: &str = "\x1b[0m"; +const BOLD: &str = "\x1b[1m"; +const DIM: &str = "\x1b[2m"; +const GREEN: &str = "\x1b[38;5;46m"; +const ORANGE: &str = "\x1b[38;5;208m"; +const BLUE: &str = "\x1b[38;5;75m"; +const CYAN: &str = "\x1b[38;5;80m"; +const YELLOW: &str = "\x1b[38;5;222m"; +const MAGENTA: &str = "\x1b[38;5;183m"; +const RED: &str = "\x1b[38;5;196m"; +const GRAY: &str = "\x1b[38;5;245m"; + +struct StreamState { + turn_num: u32, + iteration_cost: f64, + iteration_duration_ms: f64, + tool_counts: HashMap, + total_tokens: u64, +} + +impl StreamState { + fn new() -> Self { + Self { + turn_num: 0, + iteration_cost: 0.0, + iteration_duration_ms: 0.0, + tool_counts: HashMap::new(), + total_tokens: 0, + } + } +} + +fn format_tool_call(tool_name: &str, input: &str) -> String { + match tool_name { + "read" => { + let path = extract_str(input, "filePath").unwrap_or("?"); + format!("{}{}Read:{} {}{}{}", BOLD, CYAN, RESET, DIM, path, RESET) + } + "write" => { + let path = extract_str(input, "filePath").unwrap_or("?"); + format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET) + } + "apply_patch" => { + format!("{}{}ApplyPatch{}{}", BOLD, YELLOW, RESET, RESET) + } + "bash" => { + let cmd = extract_str(input, "command").unwrap_or("?"); + if cmd.len() > 80 { + format!( + "{}{}Bash:{} {}{}...{}", + BOLD, + MAGENTA, + RESET, + DIM, + &cmd.chars().take(77).collect::(), + RESET + ) + } else { + format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET) + } + } + "glob" => { + let pat = extract_str(input, "pattern").unwrap_or("?"); + format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) + } + "grep" => { + let pat = extract_str(input, "pattern").unwrap_or("?"); + format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) + } + other => format!("{}{}{}{}", BOLD, BLUE, other, RESET), + } +} + +fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState) -> io::Result<()> { + let ev_type = extract_str(line, "type"); + + match ev_type { + Some("step_start") => { + state.turn_num += 1; + writeln!( + out, + "{}{}━━━ Turn {} ━━━{}", + BOLD, ORANGE, state.turn_num, RESET + )?; + } + Some("text") => { + if let Some(text) = extract_str(line, "text") { + let text = unescape_json(text); + write!(out, "{}{}{}", DIM, text, RESET)?; + out.flush()?; + } + } + Some("tool_use") => { + let tool_name = extract_str(line, "tool").unwrap_or("?"); + + let status = if line.contains("\"status\":\"error\"") + || line.contains("\"status\": \"error\"") + { + "error" + } else if line.contains("\"status\":\"completed\"") + || line.contains("\"status\": \"completed\"") + { + "completed" + } else { + "pending" + }; + + *state.tool_counts.entry(tool_name.to_string()).or_insert(0) += 1; + + if status == "error" { + if let Some(error) = extract_str(line, "error") { + let error = unescape_json(error); + writeln!( + out, + " {}>> {}{}{} {}{}✗{}", + GRAY, RESET, BOLD, tool_name, RESET, RED, RESET + )?; + writeln!(out, " {}{}{}", RED, error, RESET)?; + } else { + writeln!( + out, + " {}>> {}{}{} {}{}✗{}", + GRAY, RESET, BOLD, tool_name, RESET, RED, RESET + )?; + } + } else if status == "completed" { + let input = extract_str(line, "input").unwrap_or(""); + let desc = format_tool_call(tool_name, input); + writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?; + writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?; + } else { + let input = extract_str(line, "input").unwrap_or(""); + let desc = format_tool_call(tool_name, input); + writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?; + } + } + Some("step_finish") => { + let cost = extract_num(line, "cost").unwrap_or(0.0); + state.iteration_cost += cost; + + let tokens = extract_num(line, "total").unwrap_or(0.0) as u64; + state.total_tokens = tokens; + } + _ => {} + } + Ok(()) +} + +fn format_summary_strip(state: &StreamState) -> String { + let mut parts: Vec = Vec::new(); + + parts.push(format!( + "{} turn{}", + state.turn_num, + if state.turn_num == 1 { "" } else { "s" } + )); + + let tool_order = ["bash", "read", "write", "apply_patch", "glob", "grep"]; + for tool in &tool_order { + if let Some(&count) = state.tool_counts.get(*tool) { + parts.push(format!("{} {}", count, tool)); + } + } + for (name, &count) in &state.tool_counts { + if !tool_order.contains(&name.as_str()) { + parts.push(format!("{} {}", count, name)); + } + } + + parts.push(format!("${:.2}", state.iteration_cost)); + + format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET) +} + +pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, _prior_total: f64) -> f64 { + let reader = BufReader::new(stdout); + let mut state = StreamState::new(); + let mut log_file = log_path.and_then(|p| { + std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok(); + std::fs::File::create(p).ok() + }); + + let mut out = io::stdout().lock(); + + for line_result in reader.lines() { + if crate::signal::interrupted() { + break; + } + + let line = match line_result { + Ok(l) => l, + Err(_) => break, + }; + + if line.trim().is_empty() { + continue; + } + + if let Some(ref mut f) = log_file { + let _ = writeln!(f, "{}", line); + } + + if process_line(&mut out, &line, &mut state).is_err() { + break; + } + } + + if state.turn_num > 0 { + let strip = format_summary_strip(&state); + let _ = writeln!(out, "{}", strip); + } + + let _ = out.flush(); + state.iteration_cost +} diff --git a/src/templates/brute/judge.md b/src/templates/brute/judge.md new file mode 100644 index 0000000..ddde2de --- /dev/null +++ b/src/templates/brute/judge.md @@ -0,0 +1,31 @@ +# Judge + +You are the last line of defense before a human sees this work. You serve two roles: adversary and advocate. You are rough on the implementation so the human who receives it gets something solid and pleasant. A PASS from you means you would stake your reputation on this code. + +Read `.loop/plan.md`. For each stage defined in the plan: + +## 1. Break it + +Try to make the code fail. Do not trust that anything works just because it looks correct. Build it, run it, and feed it inputs designed to expose problems. + +- **Boundary inputs** — zeroes, empty strings, max values, negative numbers, Unicode, special characters. +- **Error paths** — missing files, invalid config, network down, permission denied. Does it fail gracefully or crash? +- **Malformed input** — truncated data, wrong types, extra fields, duplicate keys. +- **Concurrency and timing** — if applicable, can you trigger race conditions or ordering bugs? +- **State edges** — what happens on first run vs. repeated runs? Empty state vs. populated state? + +You have full shell access. Use it. Build the project, run its tests, then write your own commands to probe beyond what the test suite covers. If you cannot build or run it, that is a FAIL. + +## 2. Judge it for the human + +Now put on the hat of a senior developer receiving this in a pull request. Would you be pleased or annoyed? + +- **Naming** — are functions, variables, and files named so a stranger can read them without a glossary? +- **Error messages** — when something goes wrong, does the user get a message that helps them fix it, or a stack trace and a shrug? +- **API ergonomics** — is the interface (CLI flags, function signatures, config format) intuitive or surprising? +- **Readability** — can you follow the logic without running a debugger in your head? +- **No dead weight** — no leftover TODOs, commented-out code, placeholder text, or debug prints that shipped. + +## Verdict + +PASS only if both halves hold: nothing you threw at it broke it in a way that matters, AND you would be genuinely happy to receive this code. FAIL with specifics — what broke, what command you ran, what you expected vs. what happened, or what about the code quality fell short. diff --git a/src/templates/brute/protocol.md b/src/templates/brute/protocol.md new file mode 100644 index 0000000..fec039a --- /dev/null +++ b/src/templates/brute/protocol.md @@ -0,0 +1,62 @@ +# Protocol: Brute + Plan Runner (Triple Loop) + +You are operating inside an automated triple loop — not a conversation. +A harness launched you and will run guards and a blind judge after you exit. + +The outer brute loop retries until a judge says PASS. +Inside each brute attempt, you run as a plan runner — implementing stages +one at a time until all stages are done and guards pass. + +## Files + +| File | Access | Purpose | +|---|---|---| +| `.loop/protocol.md` | read | These instructions. | +| `.loop/plan.md` | read | The feature plan with stages to implement. | +| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. | +| `.loop/notes.md` | read+write | Your scratchpad across iterations. | +| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). | +| `.loop/guard-results.md` | read | Guard results from the last iteration. | +| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. | + +All paths are relative to the repository root. + +## Per-Iteration Steps + +1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages. +2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned. +3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing. +4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing. +5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage. +6. **Implement**. Make the code changes for exactly one stage. +7. **Update notes**. Write to `.loop/notes.md`: + - Which stage you just worked on + - What you changed and why + - Any issues or observations for your future self + - A `STATUS` line at the **top** of the file (see below) +8. **Exit**. Stop. Do not loop — the outer script handles iteration. + +## STATUS Signaling + +The first line of `.loop/notes.md` must be one of: + +- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures). +- `STATUS: DONE` — All stages are implemented and you believe guards will pass. + +## What Happens After You Exit + +1. Guards run (diff boundary check + configured guard commands). +2. If guards pass and STATUS is DONE, the plan loop ends. +3. Then the judge (a fresh Claude with zero implementation context) verifies the feature. +4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback. + +## Rules + +- **No git operations.** Do not commit, push, branch, or modify git config. +- **Do not modify `protocol.md`, `plan.md`, `judge.md`, or `yoke.conf`.** These are read-only. +- **One stage per iteration.** Implement a single stage, update notes, and exit. +- **Study judge.md.** Knowing the test helps you pass it. +- **The judge's feedback is ground truth.** Fix what they say is broken. +- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach. +- **Be concise in notes.** Future-you needs signal, not noise. +- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway. diff --git a/src/templates/brute/yoke.conf b/src/templates/brute/yoke.conf new file mode 100644 index 0000000..714b50f --- /dev/null +++ b/src/templates/brute/yoke.conf @@ -0,0 +1,41 @@ +# Yoke configuration (brute mode) +# Lines starting with # are comments. Blank lines are ignored. + +# ── Backend ──────────────────────────────────────────────────────────── +# Model to use for the agent. If unset, defaults to Claude CLI. +# Use provider/model format for OpenRouter or other opencode providers. +# model openrouter/anthropic/claude-sonnet-4 +# model openai/gpt-4o +# model anthropic/claude-sonnet-4 + +# ── Sandbox ────────────────────────────────────────────────────────── +# Docker image to run the agent inside. Required unless you pass --no-sandbox. +# Note: sandbox is not currently supported with the 'model' directive. +image claude-code-sandbox:latest + +# ── Output ─────────────────────────────────────────────────────────── +# Max lines of tail output kept per guard in guard-results.md. +max-tail 200 + +# Uncomment to save raw stream-json output per iteration. +# log-dir .loop/logs + +# ── Scope rules (diff boundary enforcement) ────────────────────────── +# Controls what files the agent is allowed to change. Most-specific +# (longest prefix) match wins. +# +# allow — any change permitted (add, modify, delete) +# add-only — new files only; existing files cannot be modified +# no-modify — no changes at all (adds or modifications rejected) + +allow . + +# ── Guards (run after each plan stage, fail-fast) ──────────────────── +# Shell commands executed after each agent iteration. If any guard +# exits non-zero the iteration fails and results are fed back. +# +# NOTE: avoid "cargo check" as the sole guard — its type-error output +# can confuse the agent into chasing compiler noise instead of finishing +# the task. Prefer a test suite or linter that validates behaviour. + +# guard cargo check diff --git a/src/templates/layers/repl.md b/src/templates/layers/repl.md new file mode 100644 index 0000000..2cb8864 --- /dev/null +++ b/src/templates/layers/repl.md @@ -0,0 +1,10 @@ +## REPL probing + +If the code exposes REPL-accessible boundaries (CLI commands, HTTP endpoints, library APIs, shell scripts), open an interactive session and use it to probe the implementation directly. + +- **Exercise every boundary** — call each exposed function/endpoint/command with normal inputs first, then adversarial ones. +- **Chain operations** — does state from one call corrupt the next? Try create→read→update→delete sequences and variations. +- **Interrupt mid-flow** — Ctrl-C during an operation, kill a session mid-transaction. Does it recover? +- **Explore discoverability** — can you figure out how to use the interface without reading the source? Are help/usage messages accurate? + +Document the REPL session. If something broke, paste the exact input and output. diff --git a/src/templates/loop/briefing.md b/src/templates/loop/briefing.md new file mode 100644 index 0000000..7845d5b --- /dev/null +++ b/src/templates/loop/briefing.md @@ -0,0 +1,20 @@ +# Briefing: Planning Agent + +You are helping a user write a **plan** for an automated execution harness. + +## What is `.loop/`? + +The `.loop/` directory contains an automated loop system. After you and the user finish writing `plan.md`, a separate agent (not you) will be launched to execute it — iterating automatically until the plan is complete and all guards pass. + +## Your role + +Help the user write `plan.md` — a design-level outline broken into stages. + +## Guidelines + +- **Stages should be goal-oriented.** Describe *what* should be achieved, not *how* at the code level. +- **Stay abstract.** No exact line numbers, function signatures, or copy-paste code snippets. The executing agent will figure out the concrete details. +- **Each stage should be a meaningful unit of work** that can be implemented and verified independently. +- **Only drill into specifics if the user asks.** Default to high-level design intent. + +The executing agent has full access to the codebase and will make its own implementation decisions. Your plan is a design reference, not a step-by-step tutorial. diff --git a/src/templates/loop/protocol.md b/src/templates/loop/protocol.md new file mode 100644 index 0000000..166af9e --- /dev/null +++ b/src/templates/loop/protocol.md @@ -0,0 +1,65 @@ +# Protocol: Automated CI Loop + +You are operating inside an automated loop — not a conversation. A bash script launched you, and will run guard checks after you exit. You do not interact with a human during this session. + +## Files + +| File | You can | Purpose | +|------|---------|---------| +| `.loop/protocol.md` | read | This document. Your instructions. | +| `.loop/plan.md` | read | The feature plan. Stages to implement. | +| `.loop/notes.md` | read + write | Your scratchpad. Persists across iterations. | +| `.loop/guard-results.md` | read | Guard results from the last iteration. | +| `.loop/yoke.conf` | read | Loop configuration. Scope rules, guards, settings. | + +All paths are relative to the repository root. + +## Per-Iteration Steps + +1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages. +2. **Read your notes** (`.loop/notes.md`). This is your memory across iterations — check which stage you are on, what you tried, and what you learned. +3. **Read guard results** (`.loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage. +4. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan. +5. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree. +6. **Update notes**. Write to `.loop/notes.md`: + - Which stage you just worked on + - What you changed and why + - Any issues or observations for your future self + - A `STATUS` line at the **top** of the file (see below) +7. **Exit**. Stop. Do not loop — the outer script handles iteration. + +## STATUS Signaling + +The first line of `.loop/notes.md` must be one of: + +- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures). +- `STATUS: DONE` — All stages in the plan are implemented and you believe guards will pass. + +The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass. + +## What the Guards Check + +After you exit, the outer loop runs guards defined in `.loop/yoke.conf`. + +1. **Diff boundary check** — Always runs first. Verifies every file you changed + or created is within the scope rules defined in `.loop/yoke.conf`. The rules: + - `allow PREFIX` — anything goes: add, modify, delete. + - `add-only PREFIX` — may only add lines; no removing existing lines. + - `no-modify PREFIX` — zero modifications allowed. + - No matching rule — change is denied. + - Most-specific (longest) prefix wins when rules overlap. + If the boundary check fails, all subsequent guards are skipped. +2. **Configured guards** — Read the `guard` lines in `.loop/yoke.conf` to see + what commands run. Guards execute in order, fail-fast (first failure skips + the rest). + +You may run any commands you find useful during implementation. + +## Rules + +- **No git operations.** Do not commit, push, branch, or modify git config. The outer loop owns git. +- **Do not modify `protocol.md`, `plan.md`, or `yoke.conf`.** These are read-only to you. +- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages. +- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix. +- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next. +- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway. diff --git a/src/templates/loop/yoke.conf b/src/templates/loop/yoke.conf new file mode 100644 index 0000000..3d1c04a --- /dev/null +++ b/src/templates/loop/yoke.conf @@ -0,0 +1,61 @@ +# Yoke configuration +# Lines starting with # are comments. Blank lines are ignored. + +# ── Backend ──────────────────────────────────────────────────────────── +# Model to use for the agent. If unset, defaults to Claude CLI. +# Use provider/model format for OpenRouter or other opencode providers. +# model openrouter/anthropic/claude-sonnet-4 +# model openai/gpt-4o +# model anthropic/claude-sonnet-4 + +# ── Sandbox ────────────────────────────────────────────────────────── +# Docker image to run the agent inside. Required unless you pass --no-sandbox. +# Note: sandbox is not currently supported with the 'model' directive. +image claude-code-sandbox:latest + +# ── Output ─────────────────────────────────────────────────────────── +# Max lines of tail output kept per guard in guard-results.md. +# Keeps the results file from exploding on verbose commands. +max-tail 200 + +# Uncomment to save raw stream-json output per iteration. +# Each iteration writes to /iteration-.jsonl. +# log-dir .loop/logs + +# ── Scope rules (diff boundary enforcement) ────────────────────────── +# Controls what files the agent is allowed to change. After each iteration +# yoke diffs the working tree and checks every changed file against +# these rules. Most-specific (longest prefix) match wins. +# +# Directives: +# allow — any change permitted (add, modify, delete) +# add-only — new files only; existing files cannot be modified +# no-modify — no changes at all (adds or modifications rejected) +# +# The prefix "." matches every path (root catch-all). +# +# Examples: +# allow src/ # full access under src/ +# add-only tests/ # can create new test files, not edit existing +# no-modify .github/ # CI config is off-limits +# allow . # fallback: everything else is allowed + +allow . + +# ── Guards (run in order, fail-fast) ───────────────────────────────── +# Shell commands executed after each agent iteration. If any guard +# exits non-zero the iteration is marked failed, remaining guards are +# skipped, and the results are fed back on the next pass. +# +# Common examples: +# guard cargo check +# guard cargo test +# guard npm run lint +# guard python -m pytest tests/ -x +# guard make test +# +# NOTE: avoid "cargo check" as the sole guard — its type-error output +# can confuse the agent into chasing compiler noise instead of finishing +# the task. Prefer a test suite or linter that validates behaviour. + +# guard cargo check diff --git a/src/templates/saga/judge.md b/src/templates/saga/judge.md new file mode 100644 index 0000000..dd2a7fb --- /dev/null +++ b/src/templates/saga/judge.md @@ -0,0 +1,31 @@ +# Judge + +You are the last line of defense before a human sees this work. You serve two roles: adversary and advocate. You are rough on the implementation so the human who receives it gets something solid and pleasant. A PASS from you means you would stake your reputation on this code. + +Read `.loop/sub-plan.md`. For each stage defined in the plan: + +## 1. Break it + +Try to make the code fail. Do not trust that anything works just because it looks correct. Build it, run it, and feed it inputs designed to expose problems. + +- **Boundary inputs** — zeroes, empty strings, max values, negative numbers, Unicode, special characters. +- **Error paths** — missing files, invalid config, network down, permission denied. Does it fail gracefully or crash? +- **Malformed input** — truncated data, wrong types, extra fields, duplicate keys. +- **Concurrency and timing** — if applicable, can you trigger race conditions or ordering bugs? +- **State edges** — what happens on first run vs. repeated runs? Empty state vs. populated state? + +You have full shell access. Use it. Build the project, run its tests, then write your own commands to probe beyond what the test suite covers. If you cannot build or run it, that is a FAIL. + +## 2. Judge it for the human + +Now put on the hat of a senior developer receiving this in a pull request. Would you be pleased or annoyed? + +- **Naming** — are functions, variables, and files named so a stranger can read them without a glossary? +- **Error messages** — when something goes wrong, does the user get a message that helps them fix it, or a stack trace and a shrug? +- **API ergonomics** — is the interface (CLI flags, function signatures, config format) intuitive or surprising? +- **Readability** — can you follow the logic without running a debugger in your head? +- **No dead weight** — no leftover TODOs, commented-out code, placeholder text, or debug prints that shipped. + +## Verdict + +PASS only if both halves hold: nothing you threw at it broke it in a way that matters, AND you would be genuinely happy to receive this code. FAIL with specifics — what broke, what command you ran, what you expected vs. what happened, or what about the code quality fell short. diff --git a/src/templates/saga/protocol.md b/src/templates/saga/protocol.md new file mode 100644 index 0000000..b8119d0 --- /dev/null +++ b/src/templates/saga/protocol.md @@ -0,0 +1,62 @@ +# Protocol: Brute + Plan Runner (Triple Loop) + +You are operating inside an automated triple loop — not a conversation. +A harness launched you and will run guards and a blind judge after you exit. + +The outer brute loop retries until a judge says PASS. +Inside each brute attempt, you run as a plan runner — implementing stages +one at a time until all stages are done and guards pass. + +## Files + +| File | Access | Purpose | +|---|---|---| +| `.loop/protocol.md` | read | These instructions. | +| `.loop/sub-plan.md` | read | The sub-plan with stages to implement. | +| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. | +| `.loop/notes.md` | read+write | Your scratchpad across iterations. | +| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). | +| `.loop/guard-results.md` | read | Guard results from the last iteration. | +| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. | + +All paths are relative to the repository root. + +## Per-Iteration Steps + +1. **Read the plan** (`.loop/sub-plan.md`). Understand the full feature and all its stages. +2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned. +3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing. +4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing. +5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage. +6. **Implement**. Make the code changes for exactly one stage. +7. **Update notes**. Write to `.loop/notes.md`: + - Which stage you just worked on + - What you changed and why + - Any issues or observations for your future self + - A `STATUS` line at the **top** of the file (see below) +8. **Exit**. Stop. Do not loop — the outer script handles iteration. + +## STATUS Signaling + +The first line of `.loop/notes.md` must be one of: + +- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures). +- `STATUS: DONE` — All stages are implemented and you believe guards will pass. + +## What Happens After You Exit + +1. Guards run (diff boundary check + configured guard commands). +2. If guards pass and STATUS is DONE, the plan loop ends. +3. Then the judge (a fresh Claude with zero implementation context) verifies the feature. +4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback. + +## Rules + +- **No git operations.** Do not commit, push, branch, or modify git config. +- **Do not modify `protocol.md`, `sub-plan.md`, `judge.md`, or `yoke.conf`.** These are read-only. +- **One stage per iteration.** Implement a single stage, update notes, and exit. +- **Study judge.md.** Knowing the test helps you pass it. +- **The judge's feedback is ground truth.** Fix what they say is broken. +- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach. +- **Be concise in notes.** Future-you needs signal, not noise. +- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway. diff --git a/src/templates/saga/saga-protocol.md b/src/templates/saga/saga-protocol.md new file mode 100644 index 0000000..5e84c8a --- /dev/null +++ b/src/templates/saga/saga-protocol.md @@ -0,0 +1,73 @@ +# Protocol: Saga Scoper (Agent 1) + +You are the planning agent in a saga loop — not a conversation. +A harness launched you. Your job is to decompose a specification into scoped +sub-plans and feed them one at a time to an inner brute loop (Agent 2 + Agent 3). + +## Files + +| File | Access | Purpose | +|---|---|---| +| `.loop/saga-protocol.md` | read | These instructions. | +| `.loop/specification.md` | read | The full feature specification. User-authored, read-only. | +| `.loop/saga-notes.md` | read+write | Your memory across saga cycles. | +| `.loop/decisions.md` | read+write | Implementation decisions not covered by the spec. | +| `.loop/sub-plan.md` | write | The sub-plan for the next brute cycle. Overwritten each cycle. | +| `.loop/notes.md` | read | The implementer's notes from the last brute cycle. | +| `.loop/verdict.md` | read | The judge's last verdict (from the last brute cycle). | + +All paths are relative to the repository root. + +## Per-Cycle Steps + +1. **Read the specification** (`.loop/specification.md`). Understand the full feature. +2. **Read your notes** (`.loop/saga-notes.md`). Check what you have already scoped, what was completed, and what remains. +3. **Read the implementer's notes** (`.loop/notes.md`). Understand what the last brute cycle accomplished or struggled with. +4. **Read the verdict** (`.loop/verdict.md`). If the last sub-plan was judged, check whether it passed or failed. If the brute loop bailed out (3 consecutive judge failures), understand what went wrong. +5. **Determine the next chunk**. Based on the spec, your notes, and the last cycle's outcome: + - If the previous sub-plan passed, scope the next logical chunk. + - If the previous sub-plan bailed out, re-scope — break the work into smaller pieces, try a different approach, or address the root cause of failure. + - If the full spec is covered, signal DONE. +6. **Write `sub-plan.md`**. Use the same `## Stage` format the plan runner expects. Each stage should be a concrete, implementable unit. The sub-plan overwrites the previous one — no archiving. +7. **Update `saga-notes.md`**. Record: + - What you scoped and why + - What has been completed so far + - What remains + - A `STATUS` line at the **top** of the file (see below) +8. **Update `decisions.md`**. If you made implementation decisions not explicitly covered by the specification, record them here. Append — do not overwrite previous decisions. +9. **Exit**. Stop. The harness handles the next step. + +## STATUS Signaling + +The first line of `.loop/saga-notes.md` must be one of: + +- `STATUS: IN_PROGRESS` — More sub-plans remain to cover the full specification. +- `STATUS: DONE` — The full specification has been realized. All sub-plans have passed. + +## Sub-Plan Format + +Write `.loop/sub-plan.md` using the same format the plan runner expects: + +```markdown +# Plan: + + + +## Stage 1 — + +<what to implement> + +## Stage 2 — <title> + +<what to implement> +``` + +Keep sub-plans focused. 2–5 stages per sub-plan is ideal. Smaller chunks are easier for the implementer to get right and for the judge to verify. + +## Rules + +- **No git operations.** Do not commit, push, branch, or modify git config. +- **Do not modify `specification.md`, `saga-protocol.md`, `protocol.md`, `judge.md`, or `yoke.conf`.** These are read-only. +- **One sub-plan per cycle.** Write a single sub-plan, update your notes, and exit. +- **Re-scope on bailout.** If the brute loop bailed out, do not re-issue the same sub-plan. Break it down further or try a different approach. +- **Be concise in notes.** Future-you needs signal, not noise. diff --git a/src/templates/saga/yoke.conf b/src/templates/saga/yoke.conf new file mode 100644 index 0000000..d19ef95 --- /dev/null +++ b/src/templates/saga/yoke.conf @@ -0,0 +1,41 @@ +# Yoke configuration (saga mode) +# Lines starting with # are comments. Blank lines are ignored. + +# ── Backend ──────────────────────────────────────────────────────────── +# Model to use for the agent. If unset, defaults to Claude CLI. +# Use provider/model format for OpenRouter or other opencode providers. +# model openrouter/anthropic/claude-sonnet-4 +# model openai/gpt-4o +# model anthropic/claude-sonnet-4 + +# ── Sandbox ────────────────────────────────────────────────────────── +# Docker image to run the agent inside. Required unless you pass --no-sandbox. +# Note: sandbox is not currently supported with the 'model' directive. +image claude-code-sandbox:latest + +# ── Output ─────────────────────────────────────────────────────────── +# Max lines of tail output kept per guard in guard-results.md. +max-tail 200 + +# Uncomment to save raw stream-json output per iteration. +# log-dir .loop/logs + +# ── Scope rules (diff boundary enforcement) ────────────────────────── +# Controls what files the agent is allowed to change. Most-specific +# (longest prefix) match wins. +# +# allow <prefix> — any change permitted (add, modify, delete) +# add-only <prefix> — new files only; existing files cannot be modified +# no-modify <prefix> — no changes at all (adds or modifications rejected) + +allow . + +# ── Guards (run after each plan stage, fail-fast) ──────────────────── +# Shell commands executed after each agent iteration. If any guard +# exits non-zero the iteration fails and results are fed back. +# +# NOTE: avoid "cargo check" as the sole guard — its type-error output +# can confuse the agent into chasing compiler noise instead of finishing +# the task. Prefer a test suite or linter that validates behaviour. + +# guard cargo check