diff --git a/src/boundary.rs b/src/boundary.rs index ca7b2de..69b5363 100644 --- a/src/boundary.rs +++ b/src/boundary.rs @@ -115,8 +115,8 @@ pub fn check(config: &Config) -> BoundaryResult { for file in &changed { // Skip .loop/ files — they are harness infrastructure, not user code. - // The protocol requires Claude to write notes.md, and the harness - // itself writes guard-results.md and verdict.md. + // The agent writes notes.md, and the harness itself writes + // guard-results.md and verdict.md. if file.starts_with(".loop/") { continue; } diff --git a/src/config.rs b/src/config.rs index 7138619..abc763d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,28 +1,24 @@ use std::fs; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Backend { - Claude, - OpenCode, -} - #[derive(Debug, Clone, Copy, PartialEq)] pub enum Thinking { Off, Low, Medium, High, + XHigh, } impl Thinking { - /// MAX_THINKING_TOKENS value to forward to the Claude CLI. - pub fn max_tokens(self) -> u32 { + /// Value to forward to `omp --thinking`. + pub fn as_omp_arg(self) -> &'static str { match self { - Thinking::Off => 0, - Thinking::Low => 2000, - Thinking::Medium => 10000, - Thinking::High => 32000, + Thinking::Off => "off", + Thinking::Low => "low", + Thinking::Medium => "medium", + Thinking::High => "high", + Thinking::XHigh => "xhigh", } } } @@ -42,8 +38,8 @@ pub struct ScopeRule { #[derive(Debug, Clone)] pub struct Periodic { - pub path: String, // e.g., ".loop/cleaner.md" - pub name: String, // derived from filename stem: "cleaner" + pub path: String, // e.g., ".loop/cleaner.md" + pub name: String, // derived from filename stem: "cleaner" pub cadence: u32, pub guards: Vec, // from guard-after directives } @@ -54,7 +50,6 @@ pub struct Config { pub log_dir: Option, pub image: Option, pub model: Option, - pub claude_model: Option, pub thinking: Option, pub scope_rules: Vec, pub guards: Vec, @@ -73,7 +68,6 @@ struct ConfigBuilder { log_dir: Option, image: Option, model: Option, - claude_model: Option, thinking: Option, scope_rules: Vec, guards: Vec, @@ -90,9 +84,19 @@ fn cfg_err(path: &Path, line_num: usize, msg: &str) -> String { format!("{}:{}: {}", path.display(), line_num, msg) } -fn parse_positive_u32(value: &str, path: &Path, line_num: usize, label: &str) -> Result { - let n = value.parse::() - .map_err(|_| cfg_err(path, line_num, &format!("invalid {} value '{}'", label, value)))?; +fn parse_positive_u32( + value: &str, + path: &Path, + line_num: usize, + label: &str, +) -> Result { + let n = value.parse::().map_err(|_| { + cfg_err( + path, + line_num, + &format!("invalid {} value '{}'", label, value), + ) + })?; if n == 0 { return Err(cfg_err(path, line_num, &format!("{} must be > 0", label))); } @@ -102,23 +106,49 @@ fn parse_positive_u32(value: &str, path: &Path, line_num: usize, label: &str) -> #[allow(clippy::string_slice)] fn parse_periodic(value: &str, path: &Path, line_num: usize) -> Result { let trimmed = value.trim(); - let split_pos = trimmed.rfind(char::is_whitespace) + let split_pos = trimmed + .rfind(char::is_whitespace) .ok_or_else(|| cfg_err(path, line_num, "periodic requires ' '"))?; let ppath = trimmed[..split_pos].trim(); - let cadence = parse_positive_u32(trimmed[split_pos..].trim(), path, line_num, "periodic cadence")?; + let cadence = parse_positive_u32( + trimmed[split_pos..].trim(), + path, + line_num, + "periodic cadence", + )?; let name = Path::new(ppath) .file_stem() .and_then(|s| s.to_str()) - .ok_or_else(|| cfg_err(path, line_num, &format!("cannot derive name from periodic path '{}'", ppath)))? + .ok_or_else(|| { + cfg_err( + path, + line_num, + &format!("cannot derive name from periodic path '{}'", ppath), + ) + })? .to_string(); - Ok(Periodic { path: ppath.to_string(), name, cadence, guards: Vec::new() }) + Ok(Periodic { + path: ppath.to_string(), + name, + cadence, + guards: Vec::new(), + }) } #[allow(clippy::string_slice)] -fn parse_guard_after(value: &str, path: &Path, line_num: usize) -> Result<(String, String, usize), String> { +fn parse_guard_after( + value: &str, + path: &Path, + line_num: usize, +) -> Result<(String, String, usize), String> { let trimmed = value.trim(); - let split_pos = trimmed.find(char::is_whitespace) - .ok_or_else(|| cfg_err(path, line_num, "guard-after requires ' '"))?; + let split_pos = trimmed.find(char::is_whitespace).ok_or_else(|| { + cfg_err( + path, + line_num, + "guard-after requires ' '", + ) + })?; let pname = trimmed[..split_pos].trim().to_string(); let cmd = trimmed[split_pos..].trim().to_string(); Ok((pname, cmd, line_num)) @@ -131,7 +161,6 @@ impl ConfigBuilder { log_dir: None, image: None, model: None, - claude_model: None, thinking: None, scope_rules: Vec::new(), guards: Vec::new(), @@ -146,39 +175,69 @@ impl ConfigBuilder { } #[allow(clippy::string_slice)] - fn parse_line(&mut self, directive: &str, value: &str, path: &Path, line_num: usize) -> Result<(), String> { + fn parse_line( + &mut self, + directive: &str, + value: &str, + path: &Path, + line_num: usize, + ) -> Result<(), String> { match directive { "max-tail" => { - self.max_tail = value.parse::() - .map_err(|_| cfg_err(path, line_num, &format!("invalid max-tail value '{}'", value)))?; + self.max_tail = value.parse::().map_err(|_| { + cfg_err( + path, + line_num, + &format!("invalid max-tail value '{}'", value), + ) + })?; } "log-dir" => self.log_dir = Some(value.to_string()), "image" => self.image = Some(value.to_string()), "model" => self.model = Some(value.to_string()), - "claude-model" => self.claude_model = Some(value.to_string()), "thinking" => { self.thinking = Some(match value.trim() { "off" => Thinking::Off, "low" => Thinking::Low, "medium" => Thinking::Medium, "high" => Thinking::High, + "xhigh" => Thinking::XHigh, other => { return Err(cfg_err( path, line_num, - &format!("thinking must be 'off', 'low', 'medium', or 'high', got '{}'", other), + &format!( + "thinking must be 'off', 'low', 'medium', 'high', or 'xhigh', got '{}'", + other + ), )); } }); } - "allow" => self.scope_rules.push(ScopeRule { tag: ScopeTag::Allow, prefix: value.to_string() }), - "add-only" => self.scope_rules.push(ScopeRule { tag: ScopeTag::AddOnly, prefix: value.to_string() }), - "no-modify" => self.scope_rules.push(ScopeRule { tag: ScopeTag::NoModify, prefix: value.to_string() }), + "allow" => self.scope_rules.push(ScopeRule { + tag: ScopeTag::Allow, + prefix: value.to_string(), + }), + "add-only" => self.scope_rules.push(ScopeRule { + tag: ScopeTag::AddOnly, + prefix: value.to_string(), + }), + "no-modify" => self.scope_rules.push(ScopeRule { + tag: ScopeTag::NoModify, + prefix: value.to_string(), + }), "guard" => self.guards.push(value.to_string()), - "judge-every" => self.judge_every = Some(parse_positive_u32(value, path, line_num, "judge-every")?), - "max-judge-failures" => self.max_judge_failures = parse_positive_u32(value, path, line_num, "max-judge-failures")?, + "judge-every" => { + self.judge_every = Some(parse_positive_u32(value, path, line_num, "judge-every")?) + } + "max-judge-failures" => { + self.max_judge_failures = + parse_positive_u32(value, path, line_num, "max-judge-failures")? + } "periodic" => self.periodics.push(parse_periodic(value, path, line_num)?), - "guard-after" => self.pending_guard_afters.push(parse_guard_after(value, path, line_num)?), + "guard-after" => self + .pending_guard_afters + .push(parse_guard_after(value, path, line_num)?), "hook" => self.hooks.push(value.to_string()), "metrics-dir" => self.metrics_dir = Some(value.to_string()), "metrics" => match value.trim() { @@ -192,30 +251,29 @@ impl ConfigBuilder { )); } }, - other => return Err(cfg_err(path, line_num, &format!("unknown directive '{}'", other))), + other => { + return Err(cfg_err( + path, + line_num, + &format!("unknown directive '{}'", other), + )); + } } Ok(()) } fn build(self, path: &Path) -> Result { - if self.model.is_some() && self.claude_model.is_some() { - return Err(cfg_err( - path, - 0, - "'model' (OpenCode backend) and 'claude-model' (Claude CLI backend) are mutually exclusive", - )); - } - if self.model.is_some() && self.thinking.is_some() { - eprintln!( - "warning: {}: 'thinking' directive is only honored by the Claude CLI backend; ignored when 'model' (OpenCode) is set", - path.display(), - ); - } let mut periodics = self.periodics; for (pname, cmd, ln) in self.pending_guard_afters { match periodics.iter_mut().find(|p| p.name == pname) { Some(p) => p.guards.push(cmd), - None => return Err(cfg_err(path, ln, &format!("guard-after references unknown periodic '{}'", pname))), + None => { + return Err(cfg_err( + path, + ln, + &format!("guard-after references unknown periodic '{}'", pname), + )); + } } } let metrics_dir = match self.metrics_dir { @@ -227,7 +285,6 @@ impl ConfigBuilder { log_dir: self.log_dir, image: self.image, model: self.model, - claude_model: self.claude_model, thinking: self.thinking, scope_rules: self.scope_rules, guards: self.guards, @@ -261,7 +318,11 @@ impl Config { let (directive, value) = match line.find(char::is_whitespace) { Some(pos) => (&line[..pos], line[pos..].trim_start()), None => { - return Err(cfg_err(path, line_num + 1, &format!("directive '{}' has no value", line))); + return Err(cfg_err( + path, + line_num + 1, + &format!("directive '{}' has no value", line), + )); } }; @@ -294,14 +355,4 @@ 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 9afac8e..bb998fa 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -31,10 +31,7 @@ fn tail_lines(text: &str, max: usize) -> String { /// 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 output = Command::new("sh").arg("-c").arg(cmd).output(); let elapsed_secs = start.elapsed().as_secs_f64(); let (exit_ok, raw_output) = match output { @@ -91,7 +88,13 @@ pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Ve Ok(r) => r, Err(_) => { // Thread panicked — treat as failure - (String::from("(unknown)"), false, String::from("guard thread panicked"), false, 0.0) + ( + String::from("(unknown)"), + false, + String::from("guard thread panicked"), + false, + 0.0, + ) } }; diff --git a/src/json.rs b/src/json.rs index 61adca0..f8a94e2 100644 --- a/src/json.rs +++ b/src/json.rs @@ -132,7 +132,9 @@ pub fn extract_num(line: &str, key: &str) -> Option { // Collect numeric chars: digits, '.', '-', '+', 'e', 'E' let num_end = rest - .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+' && c != 'e' && c != 'E') + .find(|c: char| { + !c.is_ascii_digit() && c != '.' && c != '-' && c != '+' && c != 'e' && c != 'E' + }) .unwrap_or(rest.len()); if num_end == 0 { diff --git a/src/main.rs b/src/main.rs index 6cf204d..2ae8695 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,17 +6,16 @@ mod config; mod guard; mod json; mod metrics; -mod session_trim; mod signal; mod stash; mod stream; -mod stream_opencode; +mod stream_omp; use std::fs; use std::path::{Path, PathBuf}; use std::process::{self, Child, Command, Stdio}; -use config::{Backend, Config, Periodic}; +use config::{Config, Periodic}; const CONF_PATH: &str = ".loop/yoke.conf"; const NOTES_PATH: &str = ".loop/notes.md"; @@ -36,6 +35,9 @@ const DEFAULT_SAGA_PROTOCOL: &str = include_str!("templates/saga/saga-protocol.m 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"); +const DEFAULT_GRIND_CONF: &str = include_str!("templates/grind/yoke.conf"); +const DEFAULT_GRIND_PROTOCOL: &str = include_str!("templates/grind/protocol.md"); +const DEFAULT_GRIND_GATE: &str = include_str!("templates/grind/grind-gate.py"); const LAYER_REPL: &str = include_str!("templates/layers/repl.md"); @@ -46,6 +48,8 @@ const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md"; const SAGA_LOG_PATH: &str = ".loop/saga-log.md"; const DECISIONS_PATH: &str = ".loop/decisions.md"; const SUB_PLAN_PATH: &str = ".loop/sub-plan.md"; +const GRIND_GATE_PATH: &str = ".loop/grind-gate.py"; +const CSTAT_BASELINE_PATH: &str = ".loop/cstat-baseline.json"; pub(crate) const STASH_DIR: &str = ".loop/.stash"; /// Parse the plan file and notes.md to determine stage progress. @@ -102,7 +106,10 @@ pub(crate) fn log(msg: &str) { } pub(crate) fn log_error(msg: &str) { - eprintln!("{}{}[yoke]{} {}{}ERROR:{} {}", ORANGE, BOLD, RESET, BOLD, RED, RESET, msg); + eprintln!( + "{}{}[yoke]{} {}{}ERROR:{} {}", + ORANGE, BOLD, RESET, BOLD, RED, RESET, msg + ); } fn get_layer(name: &str) -> Option<(&'static str, &'static str)> { @@ -138,7 +145,10 @@ fn apply_layers(names_arg: &str) -> i32 { let (heading, layer_content) = match get_layer(name) { Some(v) => v, None => { - log_error(&format!("unknown layer '{}' — use 'yoke layer --list'", name)); + log_error(&format!( + "unknown layer '{}' — use 'yoke layer --list'", + name + )); return 2; } }; @@ -199,15 +209,9 @@ fn print_layer_help() { } fn print_usage() { - eprintln!( - "{}{}[yoke]{} LLM loop harness", - ORANGE, BOLD, RESET - ); + eprintln!("{}{}[yoke]{} LLM loop harness", ORANGE, BOLD, RESET); eprintln!(); - eprintln!( - "{}USAGE:{} yoke [options]", - BOLD, RESET - ); + eprintln!("{}USAGE:{} yoke [options]", BOLD, RESET); eprintln!(); eprintln!("{}COMMANDS:{}", BOLD, RESET); eprintln!( @@ -222,6 +226,10 @@ fn print_usage() { " {}init saga{} Initialize .loop/ for saga mode (scoper + brute loop)", BOLD, RESET ); + eprintln!( + " {}init grind{} Initialize .loop/ for grind mode (cstat cleanup loop)", + BOLD, RESET + ); eprintln!( " {}clean{} Reset .loop/ working files to blank slate", BOLD, RESET @@ -235,11 +243,11 @@ fn print_usage() { BOLD, RESET ); eprintln!( - " {}run{} Launch the loop (invoke Claude, run guards, iterate)", + " {}run{} Launch the loop (invoke OMP, run guards, iterate)", BOLD, RESET ); eprintln!( - " {}run --dry-run{} Single iteration: boundary check + guards only, no Claude", + " {}run --dry-run{} Single iteration: boundary check + guards only, no OMP", BOLD, RESET ); eprintln!( @@ -250,10 +258,7 @@ fn print_usage() { eprintln!("{}OPTIONS:{}", BOLD, RESET); eprintln!(" --help, -h Show this help message"); eprintln!(); - eprintln!( - "{}CONFIG:{} {}{}", - BOLD, RESET, DIM, CONF_PATH - ); + eprintln!("{}CONFIG:{} {}{}", BOLD, RESET, DIM, CONF_PATH); eprintln!( "{}EXIT:{} 0=success 1=guard failure 2=usage error", BOLD, RESET @@ -261,26 +266,36 @@ fn print_usage() { } fn print_run_help() { - eprintln!( - "{}{}[yoke run]{} execute the loop", - ORANGE, BOLD, RESET - ); + eprintln!("{}{}[yoke run]{} execute the loop", ORANGE, BOLD, RESET); eprintln!(); eprintln!( - "{}USAGE:{} yoke run [--dry-run]", + "{}USAGE:{} yoke run [--dry-run] [--no-sandbox]", BOLD, RESET ); eprintln!(); eprintln!("{}OPTIONS:{}", BOLD, RESET); - eprintln!(" --dry-run Run one iteration without invoking Claude"); + eprintln!(" --dry-run Run one iteration without invoking OMP"); eprintln!(" (boundary check + configured guards only)"); - eprintln!(" --no-sandbox Allow running without a container image"); + eprintln!(" --no-sandbox Accepted for compatibility; OMP runs locally when image is unset"); eprintln!(); eprintln!("{}MODES:{}", BOLD, RESET); eprintln!(" Detected automatically from .loop/ contents."); - 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!( + " {}saga{} Scoper + brute loop — detected when {} exists.", + BOLD, RESET, SPECIFICATION_PATH + ); + eprintln!( + " {}brute{} Plan stages + judge — detected when {} exists.", + BOLD, RESET, JUDGE_PATH + ); + eprintln!( + " {}grind{} cstat cleanup loop — detected when {} and {} exist.", + BOLD, RESET, GRIND_GATE_PATH, CSTAT_BASELINE_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); @@ -296,7 +311,7 @@ fn print_run_help() { 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"); + eprintln!(" b. Invoke OMP with JSON output"); eprintln!(" c. Run diff boundary check"); eprintln!(" d. Run configured guard commands"); eprintln!(" 4. Exit when STATUS: DONE and all guards pass"); @@ -307,7 +322,7 @@ fn print_run_help() { eprintln!(" 3. Per brute attempt:"); eprintln!(" a. Restore protected files, clear verdict"); eprintln!(" b. Run plan loop (stages + guards until DONE)"); - eprintln!(" c. Invoke judge (fresh Claude, zero context)"); + eprintln!(" c. Invoke judge (fresh OMP session, zero context)"); eprintln!(" d. If judge FAILs, reset STATUS and retry"); eprintln!(" 4. Exit when judge returns VERDICT: PASS"); } @@ -318,25 +333,32 @@ fn print_init_help() { ORANGE, BOLD, RESET ); eprintln!(); - eprintln!( - "{}USAGE:{} yoke init [brute|saga]", - BOLD, RESET - ); + eprintln!("{}USAGE:{} yoke init [brute|saga|grind]", BOLD, RESET); eprintln!(); eprintln!("{}MODES:{}", BOLD, RESET); eprintln!(" {}(default){} Staged plan loop. Creates:", BOLD, RESET); 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!( + " {}brute{} Plan stages + judge. Creates:", + BOLD, RESET + ); 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!( + " {}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!(" {}grind{} cstat cleanup loop. Creates:", BOLD, RESET); + eprintln!(" yoke.conf, protocol.md, grind-gate.py,"); + eprintln!(" cstat-baseline.json, notes.md, guard-results.md"); + eprintln!(); eprintln!("Existing files are never overwritten."); eprintln!(); eprintln!("{}MODE SWITCHING:{}", BOLD, RESET); @@ -346,17 +368,16 @@ fn print_init_help() { /// Holds loop state and cleans up on drop. /// -/// Drop kills any running Claude child process and removes the backup +/// Drop kills any running OMP child process and removes the backup /// directory. Note: Drop does not run on raw SIGINT — the child process /// shares the process group and receives the signal directly, and the /// backup temp dir is small and inconsequential. struct LoopRunner { backup_dir: PathBuf, child: Option, - /// Claude session id from the most recent agent invocation. When `Some`, - /// the next worker invocation will `--resume` it so the prompt cache and - /// the trimmed conversation prefix carry forward. Cleared on stash - /// checkout / mode switch / explicit reset. + /// OMP session id from the most recent worker invocation. When `Some`, + /// the next worker invocation will `--resume` it so conversation context + /// carries forward. Cleared on stash checkout / mode switch / explicit reset. last_session_id: Option, } @@ -384,17 +405,24 @@ impl Drop for LoopRunner { } /// Check that required loop files exist, create notes if missing. +/// `plan_path` is required for plan-driven modes and absent for grind. /// /// 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] { +fn preflight(plan_path: Option<&str>, clear_guard_results: bool) { + for path in [PROTOCOL_PATH, CONF_PATH] { if !Path::new(path).exists() { log_error(&format!("required file not found: {}", path)); process::exit(1); } } + if let Some(path) = plan_path + && !Path::new(path).exists() + { + log_error(&format!("required file not found: {}", path)); + process::exit(1); + } // Ensure notes file exists if !Path::new(NOTES_PATH).exists() && let Err(e) = fs::write(NOTES_PATH, "") @@ -481,19 +509,46 @@ fn reset_notes_status() { /// Detect the home directory of the default user inside a Docker image. fn container_home(image: &str) -> String { let output = Command::new("docker") - .args(["run", "--rm", "--entrypoint", "sh", image, "-c", "echo $HOME"]) + .args([ + "run", + "--rm", + "--entrypoint", + "sh", + image, + "-c", + "echo $HOME", + ]) .output(); match output { Ok(o) if o.status.success() => { let h = String::from_utf8_lossy(&o.stdout).trim().to_string(); - if h.is_empty() { "/home/sandbox".to_string() } else { h } + if h.is_empty() { + "/home/sandbox".to_string() + } else { + h + } } _ => "/home/sandbox".to_string(), } } -/// Build a docker command that runs the claude CLI inside a container. -fn build_docker_claude_command(image: &str, claude_args: &[&str], extra_env: &[(&str, String)]) -> Command { +fn should_forward_env(key: &str) -> bool { + key.starts_with("OMP_") + || key.starts_with("PI_") + || key.starts_with("ANTHROPIC_") + || key.starts_with("OPENAI_") + || key.starts_with("GEMINI_") + || key.starts_with("OPENROUTER_") + || key.starts_with("CLAUDE_CODE_") + || key.starts_with("FOUNDRY_") + || key == "NODE_EXTRA_CA_CERTS" + || key.ends_with("_API_KEY") + || key.ends_with("_OAUTH_TOKEN") + || key.ends_with("_ACCESS_TOKEN") +} + +/// Build a docker command that runs OMP inside a container. +fn build_docker_omp_command(image: &str, omp_args: &[String]) -> Command { let workdir = std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .to_string_lossy() @@ -510,7 +565,7 @@ fn build_docker_claude_command(image: &str, claude_args: &[&str], extra_env: &[( "--cap-add=NET_ADMIN", "--cap-add=NET_RAW", ]); - c.args(["--entrypoint", "claude"]); + c.args(["--entrypoint", "omp"]); c.arg("-v").arg(format!("{}:/workspace", workdir)); c.arg("-w").arg("/workspace"); @@ -525,92 +580,59 @@ fn build_docker_claude_command(image: &str, claude_args: &[&str], extra_env: &[( } for (key, val) in std::env::vars() { - if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { + if should_forward_env(&key) { c.arg("-e").arg(format!("{}={}", key, val)); } } - for (key, val) in extra_env { - 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!( - "{}:{}/.claude", - claude_dir.display(), - container_home - )); - } - let claude_json = home_path.join(".claude.json"); - if claude_json.exists() { - c.arg("-v").arg(format!( - "{}:{}/.claude.json", - claude_json.display(), - container_home - )); + let omp_dir = home_path.join(".omp"); + if omp_dir.exists() { + c.arg("-v") + .arg(format!("{}:{}/.omp", omp_dir.display(), container_home)); } } c.arg(image); - c.args(claude_args); + c.args(omp_args); c } -/// Build a Command for invoking the agent backend. +fn build_omp_args(config: &Config, prompt: &str, resume: Option<&str>) -> Vec { + let mut args = vec![ + "--mode".to_string(), + "json".to_string(), + "--auto-approve".to_string(), + ]; + if let Some(model) = config.model.as_deref() { + args.push("--model".to_string()); + args.push(model.to_string()); + } + if let Some(thinking) = config.thinking { + args.push("--thinking".to_string()); + args.push(thinking.as_omp_arg().to_string()); + } + if let Some(sid) = resume { + args.push("--resume".to_string()); + args.push(sid.to_string()); + } + args.push("-p".to_string()); + args.push(prompt.to_string()); + args +} + +/// Build a Command for invoking OMP. /// -/// `resume` carries a Claude session id when the harness wants to continue -/// the previous round's conversation (preserves prompt cache + the trimmed -/// JSONL prefix). `None` produces a fresh session. OpenCode ignores it. +/// `resume` carries an OMP session id when the harness wants to continue the +/// previous round's conversation. `None` produces a fresh session. fn build_command(config: &Config, prompt: &str, resume: Option<&str>) -> Command { - match config.backend() { - Backend::Claude => { - let mut claude_args: Vec<&str> = vec![ - "--verbose", - "--output-format", - "stream-json", - "--include-partial-messages", - "--dangerously-skip-permissions", - ]; - if let Some(m) = config.claude_model.as_deref() { - claude_args.push("--model"); - claude_args.push(m); - } - if let Some(sid) = resume { - claude_args.push("--resume"); - claude_args.push(sid); - } - claude_args.push("-p"); - claude_args.push(prompt); - let extra_env: Vec<(&str, String)> = match config.thinking { - Some(t) => vec![("MAX_THINKING_TOKENS", t.max_tokens().to_string())], - None => Vec::new(), - }; - match config.image { - Some(ref image) => build_docker_claude_command(image, &claude_args, &extra_env), - None => { - let mut c = Command::new("claude"); - c.args(&claude_args); - for (key, val) in &extra_env { - c.env(key, val); - } - c - } - } - } - 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, - ]); + let omp_args = build_omp_args(config, prompt, resume); + match &config.image { + Some(image) => build_docker_omp_command(image, &omp_args), + None => { + let mut c = Command::new("omp"); + c.args(&omp_args); c } } @@ -642,8 +664,7 @@ fn invoke_process( prior_total: f64, resume: Option<&str>, ) -> AgentRunStats { - let backend = config.backend(); - let in_container = config.image.is_some() && backend == Backend::Claude; + let in_container = config.image.is_some(); let wall_start = std::time::Instant::now(); log(&format!( @@ -667,10 +688,10 @@ fn invoke_process( { Ok(c) => c, Err(e) => { - let bin = match backend { - Backend::Claude if config.image.is_some() => "docker", - Backend::Claude => "claude", - Backend::OpenCode => "opencode", + let bin = if config.image.is_some() { + "docker" + } else { + "omp" }; log_error(&format!("failed to spawn '{}': {}", bin, e)); return AgentRunStats { @@ -683,20 +704,18 @@ fn invoke_process( signal::set_child_pid(child.id() as i32); - let log_path = config.log_dir.as_ref().map(|dir| { - PathBuf::from(dir).join(format!("{}-{}.jsonl", log_prefix, iteration)) - }); + let log_path = config + .log_dir + .as_ref() + .map(|dir| PathBuf::from(dir).join(format!("{}-{}.jsonl", log_prefix, iteration))); let mut summary = stream::StreamSummary::default(); if let Some(stdout) = child.stdout.take() { runner.child = Some(child); - summary = 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), - }; + summary = stream_omp::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 { + if let Some(ch) = &mut runner.child { let _ = ch.kill(); } if signal::interrupted() { @@ -710,7 +729,7 @@ fn invoke_process( runner.child = Some(child); } - let status = if let Some(ref mut child) = runner.child { + let status = if let Some(child) = &mut runner.child { match child.wait() { Ok(s) => { if signal::interrupted() { @@ -747,15 +766,23 @@ fn invoke_process( } /// Invoke the agent, piping stdout through the stream filter. -fn invoke_agent(runner: &mut LoopRunner, config: &Config, iteration: u32, prior_total: f64) -> AgentRunStats { +fn invoke_agent( + runner: &mut LoopRunner, + config: &Config, + iteration: u32, + prior_total: f64, +) -> AgentRunStats { let prompt = "Read .loop/protocol.md and follow its instructions."; - let label = match config.backend() { - Backend::Claude => "Claude", - Backend::OpenCode => "OpenCode", - }; + let label = "OMP"; let resume = runner.last_session_id.clone(); let stats = invoke_process( - runner, config, prompt, label, "iteration", iteration, prior_total, + runner, + config, + prompt, + label, + "iteration", + iteration, + prior_total, resume.as_deref(), ); if let Some(sid) = stats.stream.session_id.clone() { @@ -768,59 +795,27 @@ fn invoke_agent(runner: &mut LoopRunner, config: &Config, iteration: u32, prior_ /// Returns true if the invocation succeeded. /// /// Periodic agents always run in a fresh session: they have a different -/// protocol than the main worker and conflating their conversation prefix -/// with the worker's would poison the worker's prompt cache. Periodics -/// don't update `runner.last_session_id`. -fn invoke_periodic(runner: &mut LoopRunner, config: &Config, periodic: &Periodic, iteration: u32) -> bool { +/// protocol than the main worker. They don't update `runner.last_session_id`. +fn invoke_periodic( + runner: &mut LoopRunner, + config: &Config, + periodic: &Periodic, + iteration: u32, +) -> bool { let prompt = format!("Read {} and follow its instructions.", periodic.path); let stats = invoke_process( - runner, config, &prompt, &periodic.name, - &format!("periodic-{}", periodic.name), iteration, 0.0, + runner, + config, + &prompt, + &periodic.name, + &format!("periodic-{}", periodic.name), + iteration, + 0.0, None, ); stats.success } -/// Trim the worker's resumed Claude session in place. No-op on first -/// iteration (no prior session), on docker/sandboxed runs (session file is -/// inside the container — out of reach), or if the agent didn't declare a -/// KEEP list. Any failure is logged and swallowed: better to over-pay -/// than to break the resume. -fn trim_worker_session(runner: &LoopRunner) { - let Some(sid) = runner.last_session_id.as_deref() else { return }; - let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return }; - let Ok(cwd) = std::env::current_dir() else { return }; - let session_path = session_trim::session_file_path(&home, &cwd, sid); - if !session_path.exists() { - // Sandboxed (in-container) sessions live in the container's HOME, not the host's. - return; - } - let notes_text = match std::fs::read_to_string(NOTES_PATH) { - Ok(t) => t, - Err(_) => return, - }; - let keep = session_trim::parse_keep_list(¬es_text, &cwd); - match session_trim::trim_session(&session_path, &keep) { - Ok(stats) if stats.skipped => {} - Ok(stats) => { - log(&format!( - "Session trim: kept {}/{} records ({} dropped); KEEP={}", - stats.records_kept, - stats.records_total, - stats.records_dropped, - if stats.keep_paths.is_empty() { - "(none)".to_string() - } else { - stats.keep_paths.join(" ") - } - )); - } - Err(e) => { - log_error(&format!("Session trim failed (resuming anyway): {}", e)); - } - } -} - /// Render a box-drawn iteration banner to stderr. /// Uses double borders (╔/║/╚) at top level, single borders (┌/│/└) when nested. fn render_iteration_banner(label: &str, iteration: u32, nested: bool) { @@ -828,23 +823,45 @@ fn render_iteration_banner(label: &str, iteration: u32, nested: bool) { // Pad content to fill inner width of 38 characters let inner = format!("{:^38}", content); if nested { - eprintln!("{}{}┌──────────────────────────────────────┐{}", BOLD, ORANGE, RESET); + eprintln!( + "{}{}┌──────────────────────────────────────┐{}", + BOLD, ORANGE, RESET + ); eprintln!("{}{}│{}│{}", BOLD, ORANGE, inner, RESET); - eprintln!("{}{}└──────────────────────────────────────┘{}", BOLD, ORANGE, RESET); + eprintln!( + "{}{}└──────────────────────────────────────┘{}", + BOLD, ORANGE, RESET + ); } else { - eprintln!("{}{}╔══════════════════════════════════════╗{}", BOLD, ORANGE, RESET); + eprintln!( + "{}{}╔══════════════════════════════════════╗{}", + BOLD, ORANGE, RESET + ); eprintln!("{}{}║{}║{}", BOLD, ORANGE, inner, RESET); - eprintln!("{}{}╚══════════════════════════════════════╝{}", BOLD, ORANGE, RESET); + eprintln!( + "{}{}╚══════════════════════════════════════╝{}", + BOLD, ORANGE, RESET + ); } } /// Render a named section banner to stderr (for judge, periodic, etc.). fn render_section_banner(title: &str, subtitle: &str, color: &str) { let fill_len = 40usize.saturating_sub(title.len() + 4); // "┌─ Title ──...──┐" - eprintln!("{}{}┌─ {} {}┐{}", BOLD, color, title, "─".repeat(fill_len), RESET); + eprintln!( + "{}{}┌─ {} {}┐{}", + BOLD, + color, + title, + "─".repeat(fill_len), + RESET + ); let inner = format!(" {:<38}", subtitle); eprintln!("{}{}│{}│{}", BOLD, color, inner, RESET); - eprintln!("{}{}└────────────────────────────────────────┘{}", BOLD, color, RESET); + eprintln!( + "{}{}└────────────────────────────────────────┘{}", + BOLD, color, RESET + ); } /// Render a box-drawn table of guard results to stderr. @@ -852,11 +869,26 @@ fn render_guard_table(results: &[guard::GuardResult]) { if results.is_empty() { return; } - let cmd_w = results.iter().map(|r| r.name.len()).max().unwrap_or(10).max(10); + let cmd_w = results + .iter() + .map(|r| r.name.len()) + .max() + .unwrap_or(10) + .max(10); let stat_w = 8; let time_w = 6; - let top = format!(" ┌{}┬{}┬{}┐", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); - let bottom = format!(" └{}┴{}┴{}┘", "─".repeat(cmd_w + 2), "─".repeat(stat_w), "─".repeat(time_w + 1)); + let top = format!( + " ┌{}┬{}┬{}┐", + "─".repeat(cmd_w + 2), + "─".repeat(stat_w), + "─".repeat(time_w + 1) + ); + let bottom = format!( + " └{}┴{}┴{}┘", + "─".repeat(cmd_w + 2), + "─".repeat(stat_w), + "─".repeat(time_w + 1) + ); eprintln!("{}", top); for r in results { let cmd_padded = format!("{:"; // Judge is always a fresh session — independence per behavioral-specification §2.1. - let stats = invoke_process(runner, config, judge_prompt, "judge", "judge", iteration, 0.0, None); + let stats = invoke_process( + runner, + config, + judge_prompt, + "judge", + "judge", + iteration, + 0.0, + None, + ); if !stats.success { return false; } @@ -1056,7 +1100,11 @@ fn render_violation_tree(violations: &[String]) { 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 dir_prefix = if is_last_dir { + "└── " + } else { + "├── " + }; let child_prefix = if is_last_dir { " " } else { "│ " }; eprintln!(" {}{}{}/{}", dir_prefix, DIM, dir, RESET); @@ -1065,7 +1113,11 @@ fn render_violation_tree(violations: &[String]) { 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 { "├── " }; + let file_conn = if is_last_file { + "└── " + } else { + "├── " + }; eprintln!( " {}{}{}✗{} {} {}{}{}", child_prefix, file_conn, RED, RESET, file, DIM, reason, RESET @@ -1113,7 +1165,10 @@ fn run_all_guards(config: &Config) -> GuardOutcome { md.push_str("```\nSkipped due to diff boundary violation.\n```\n\n"); } let _ = fs::write(GUARD_RESULTS_PATH, &md); - return GuardOutcome { passed: false, guard_results: Vec::new() }; + return GuardOutcome { + passed: false, + guard_results: Vec::new(), + }; } // Boundary passed — write that to results then run configured guards @@ -1133,7 +1188,10 @@ fn run_all_guards(config: &Config) -> GuardOutcome { render_guard_table(&guard_results); - GuardOutcome { passed: all_passed, guard_results } + GuardOutcome { + passed: all_passed, + guard_results, + } } fn print_clean_help() { @@ -1142,10 +1200,7 @@ fn print_clean_help() { ORANGE, BOLD, RESET ); eprintln!(); - eprintln!( - "{}USAGE:{} yoke clean", - BOLD, RESET - ); + 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"); @@ -1156,7 +1211,10 @@ fn print_clean_help() { eprintln!(" protocol.md, saga-protocol.md, yoke.conf, briefing.md, specification.md"); eprintln!(); eprintln!("Non-empty working files are auto-stashed to .loop/.stash/ before wiping."); - eprintln!("Browse with: {}yoke stash log{} Recover with: {}yoke stash pop{}", BOLD, RESET, BOLD, RESET); + eprintln!( + "Browse with: {}yoke stash log{} Recover with: {}yoke stash pop{}", + BOLD, RESET, BOLD, RESET + ); } fn clean() -> i32 { @@ -1174,8 +1232,13 @@ fn clean() -> i32 { // 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, + 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() { @@ -1191,7 +1254,11 @@ fn clean() -> i32 { 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 }; + 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; @@ -1209,7 +1276,6 @@ fn clean() -> i32 { 0 } - /// Return the list of (path, default_content) pairs for a given mode. fn mode_files(mode: &str) -> Option> { Some(match mode { @@ -1245,6 +1311,14 @@ fn mode_files(mode: &str) -> Option> { (VERDICT_PATH, ""), (GUARD_RESULTS_PATH, ""), ], + "grind" => vec![ + (CONF_PATH, DEFAULT_GRIND_CONF), + (PROTOCOL_PATH, DEFAULT_GRIND_PROTOCOL), + (GRIND_GATE_PATH, DEFAULT_GRIND_GATE), + (CSTAT_BASELINE_PATH, ""), + (NOTES_PATH, ""), + (GUARD_RESULTS_PATH, ""), + ], _ => return None, }) } @@ -1258,6 +1332,8 @@ fn detect_mode() -> Option<&'static str> { Some("saga") } else if Path::new(JUDGE_PATH).exists() { Some("brute") + } else if Path::new(GRIND_GATE_PATH).exists() && Path::new(CSTAT_BASELINE_PATH).exists() { + Some("grind") } else if Path::new(PROTOCOL_PATH).exists() { Some("loop") } else { @@ -1296,11 +1372,54 @@ fn switch_mode(current: &str, target: &str) -> i32 { } log(&format!("created: {}", path)); } + if target == "grind" { + capture_grind_baseline_if_missing(); + } log(&format!("Switched from '{}' to '{}' mode", current, target)); 0 } +fn capture_grind_baseline_if_missing() { + let path = Path::new(CSTAT_BASELINE_PATH); + if path.exists() && fs::read_to_string(path).is_ok_and(|s| !s.trim().is_empty()) { + log(&format!("skip (exists): {}", CSTAT_BASELINE_PATH)); + return; + } + + let output = Command::new("cstat") + .args(["--path", ".", "--json", "scorecard"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + if let Err(e) = fs::write(path, out.stdout) { + log_error(&format!("failed to write {}: {}", CSTAT_BASELINE_PATH, e)); + } else { + log(&format!( + "captured cstat scorecard baseline: {}", + CSTAT_BASELINE_PATH + )); + } + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + log(&format!( + "WARNING: cstat baseline not captured; `cstat --path . --json scorecard` exited with {}{}{}", + out.status, + if stderr.trim().is_empty() { "" } else { ": " }, + stderr.trim() + )); + } + Err(e) => { + log(&format!( + "WARNING: cstat baseline not captured; failed to run cstat: {}", + e + )); + } + } +} + fn init(mode: &str) -> i32 { let files = match mode_files(mode) { Some(f) => f, @@ -1313,7 +1432,10 @@ fn init(mode: &str) -> i32 { // 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)); + log(&format!( + "Switching from '{}' to '{}' mode...", + current, mode + )); return switch_mode(current, mode); } _ => {} // fresh init or same-mode idempotent @@ -1343,6 +1465,9 @@ fn init(mode: &str) -> i32 { } } + if mode == "grind" { + capture_grind_baseline_if_missing(); + } 0 } @@ -1470,11 +1595,7 @@ fn validate_loop_config(config: &Config) { } /// Fire any periodic agents whose cadence matches this iteration. -fn fire_periodic_agents( - runner: &mut LoopRunner, - config: &Config, - iteration: u32, -) { +fn fire_periodic_agents(runner: &mut LoopRunner, config: &Config, iteration: u32) { for periodic in &config.periodics { if iteration % periodic.cadence == 0 { eprintln!(); @@ -1513,7 +1634,9 @@ fn run_hooks(config: &Config, iteration: u32) { log(&format!( "{}WARNING: hook exited with {}: {}{}", ORANGE, - status.code().map_or("signal".to_string(), |c| c.to_string()), + status + .code() + .map_or("signal".to_string(), |c| c.to_string()), cmd, RESET )); @@ -1555,25 +1678,21 @@ fn run_iteration_step( iteration: u32, prior_total: f64, dry_run: bool, + protected: &[&str], ) -> IterationStepResult { if dry_run { - log(&format!( - "(dry-run) Skipping {} invocation", - match config.backend() { - Backend::Claude => "Claude", - Backend::OpenCode => "OpenCode", - } - )); + log("(dry-run) Skipping OMP invocation"); } else { let stats = invoke_agent(runner, config, iteration, prior_total); if !stats.success { - log_error("Claude invocation failed — aborting loop"); + log_error("OMP invocation failed — aborting loop"); return IterationStepResult::Terminal(PlanLoopOutcome::Error); } if signal::interrupted() { log("Interrupted \u{2014} shutting down"); return IterationStepResult::Terminal(PlanLoopOutcome::Interrupt); } + restore_files(&runner.backup_dir, protected); let guards_start = std::time::Instant::now(); let guard_outcome = run_all_guards(config); let guards_secs = guards_start.elapsed().as_secs_f64(); @@ -1581,7 +1700,7 @@ fn run_iteration_step( log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET)); } else { log(&format!( - "{}Some guards failed \u{2014} Claude will see results next iteration{}", + "{}Some guards failed \u{2014} OMP will see results next iteration{}", ORANGE, RESET )); } @@ -1595,15 +1714,14 @@ fn run_iteration_step( } // Dry-run path: run guards and exit after one iteration. + restore_files(&runner.backup_dir, protected); let guard_outcome = run_all_guards(config); if guard_outcome.passed { log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET)); log("(dry-run) Guards passed \u{2014} exiting after one iteration"); IterationStepResult::Terminal(PlanLoopOutcome::Done) } else { - log(&format!( - "{}Some guards failed{}", ORANGE, RESET - )); + log(&format!("{}Some guards failed{}", ORANGE, RESET)); log("(dry-run) Exiting after one iteration"); IterationStepResult::Terminal(PlanLoopOutcome::Error) } @@ -1612,13 +1730,13 @@ fn run_iteration_step( /// 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) +/// - `plan_path`: path to the plan file, or `None` for planless modes such as grind +/// - `dry_run`: if true, skip OMP invocation (one iteration only) /// - `nested`: if true, running inside brute (adjusts output banners) -/// - `mctx`: metrics context carrying run_id + project + output dir +/// - `recorder`: metrics recorder carrying run_id + project + output dir fn run_plan_loop( config: &Config, - plan_path: &str, + plan_path: Option<&str>, dry_run: bool, nested: bool, recorder: &mut metrics::RunRecorder, @@ -1627,7 +1745,16 @@ fn run_plan_loop( validate_loop_config(config); log("Preflight OK"); - let mut protected: Vec<&str> = vec![PROTOCOL_PATH, plan_path, CONF_PATH]; + let mut protected: Vec<&str> = vec![PROTOCOL_PATH, CONF_PATH]; + if let Some(plan_path) = plan_path { + protected.push(plan_path); + } + if Path::new(GRIND_GATE_PATH).exists() { + protected.push(GRIND_GATE_PATH); + } + if Path::new(CSTAT_BASELINE_PATH).exists() { + protected.push(CSTAT_BASELINE_PATH); + } let periodic_paths: Vec = config.periodics.iter().map(|p| p.path.clone()).collect(); for pp in &periodic_paths { protected.push(pp.as_str()); @@ -1651,10 +1778,16 @@ fn run_plan_loop( let iter_wall_start = std::time::Instant::now(); eprintln!(); - let label = if nested { "Plan Iteration" } else { "Iteration" }; + let label = if nested { + "Plan Iteration" + } else { + "Iteration" + }; render_iteration_banner(label, iteration, nested); - if let Some((completed, total)) = stage_progress(plan_path) { + if let Some(plan_path) = plan_path + && let Some((completed, total)) = stage_progress(plan_path) + { eprintln!("{}", format_progress_bar(completed, total)); } @@ -1662,12 +1795,14 @@ fn run_plan_loop( restore_files(&runner.backup_dir, &protected); let restore_ms = restore_start.elapsed().as_millis() as u64; - // Trim the worker's resumed session, if any — drops bash output, - // thinking, and Reads not on the agent's KEEP list, so the prefix - // we re-send to Claude stays compact. - trim_worker_session(&runner); - - let step_stats = match run_iteration_step(&mut runner, config, iteration, total_cost, dry_run) { + let step_stats = match run_iteration_step( + &mut runner, + config, + iteration, + total_cost, + dry_run, + &protected, + ) { IterationStepResult::Continue(s) => s, IterationStepResult::Terminal(outcome) => return outcome, }; @@ -1685,8 +1820,13 @@ fn run_plan_loop( let mut early_return: Option = None; if let Some(judge_every) = config.judge_every { match evaluate_judge_every( - &mut runner, config, iteration, step_stats.guards_passed, - &mut consecutive_judge_failures, judge_every, &mut judge_secs, + &mut runner, + config, + iteration, + step_stats.guards_passed, + &mut consecutive_judge_failures, + judge_every, + &mut judge_secs, ) { JudgeEveryAction::Pass => early_return = Some(PlanLoopOutcome::JudgePass), JudgeEveryAction::Bailout => early_return = Some(PlanLoopOutcome::JudgeBailout), @@ -1717,12 +1857,16 @@ fn run_plan_loop( thinking_secs: step_stats.stream.thinking_secs, tool_counts: step_stats.stream.tool_counts.clone(), tool_durations_secs: step_stats.stream.tool_durations_secs.clone(), - guards: step_stats.guard_results.iter().map(|g| metrics::GuardRow { - name: g.name.clone(), - passed: g.passed, - skipped: g.skipped, - elapsed_secs: g.elapsed_secs, - }).collect(), + guards: step_stats + .guard_results + .iter() + .map(|g| metrics::GuardRow { + name: g.name.clone(), + passed: g.passed, + skipped: g.skipped, + elapsed_secs: g.elapsed_secs, + }) + .collect(), guards_passed: step_stats.guards_passed, status_done, }; @@ -1745,6 +1889,10 @@ fn run_plan_loop( } fn run_loop(dry_run: bool) -> i32 { + run_loop_with_label(dry_run, "loop") +} + +fn run_loop_with_label(dry_run: bool, mode_label: &'static str) -> i32 { // Load config let config = match Config::load(Path::new(CONF_PATH)) { Ok(c) => c, @@ -1755,24 +1903,28 @@ fn run_loop(dry_run: bool) -> i32 { }; log(&format!( - "Config loaded: max_tail={}, {} scope rules, {} guards, {} hooks, backend={}{}", + "Config loaded: max_tail={}, {} scope rules, {} guards, {} hooks, backend={}{}{}", config.max_tail, config.scope_rules.len(), config.guards.len(), config.hooks.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) - ) + "omp", + config + .model + .as_ref() + .map_or(String::new(), |m| format!(", model={}", m)), + config + .image + .as_ref() + .map_or(String::from(", sandbox=off"), |img| format!( + ", image={}", + img + )) )); let mctx = metrics::MetricsContext::new( config.metrics_dir.clone(), - "loop", + mode_label, config.metrics_enabled, ); if mctx.enabled { @@ -1785,7 +1937,12 @@ fn run_loop(dry_run: bool) -> i32 { let mut recorder = metrics::RunRecorder::new(mctx); recorder.on_finalize(|r| render_run_summary(r)); - let outcome = run_plan_loop(&config, PLAN_PATH, dry_run, false, &mut recorder); + let plan_path = if mode_label == "grind" { + None + } else { + Some(PLAN_PATH) + }; + let outcome = run_plan_loop(&config, plan_path, dry_run, false, &mut recorder); recorder.set_outcome(plan_outcome_label(&outcome)); drop(recorder); match outcome { @@ -1817,9 +1974,18 @@ fn brute_outcome_label(o: &BruteResult) -> &'static str { /// Render the end-of-run metrics table to stderr. fn render_run_summary(r: &metrics::RunMetrics) { eprintln!(); - eprintln!("{}{}╔══════════════════════════════════════════════════════════════════╗{}", BOLD, ORANGE, RESET); - eprintln!("{}{}║ Run summary ║{}", BOLD, ORANGE, RESET); - eprintln!("{}{}╚══════════════════════════════════════════════════════════════════╝{}", BOLD, ORANGE, RESET); + eprintln!( + "{}{}╔══════════════════════════════════════════════════════════════════╗{}", + BOLD, ORANGE, RESET + ); + eprintln!( + "{}{}║ Run summary ║{}", + BOLD, ORANGE, RESET + ); + eprintln!( + "{}{}╚══════════════════════════════════════════════════════════════════╝{}", + BOLD, ORANGE, RESET + ); let dim = DIM; let reset = RESET; eprintln!(" {}mode:{} {}", dim, reset, r.mode); @@ -1829,7 +1995,10 @@ fn render_run_summary(r: &metrics::RunMetrics) { eprintln!(" {}iterations:{} {}", dim, reset, r.iterations); eprintln!(" {}wall:{} {:.1}s", dim, reset, r.total_wall_secs); eprintln!(" {}agent:{} {:.1}s", dim, reset, r.total_agent_secs); - eprintln!(" {}thinking:{} {:.1}s", dim, reset, r.total_thinking_secs); + eprintln!( + " {}thinking:{} {:.1}s", + dim, reset, r.total_thinking_secs + ); eprintln!(" {}guards:{} {:.1}s", dim, reset, r.total_guards_secs); eprintln!(" {}cost:{} ${:.2}", dim, reset, r.total_cost_usd); eprintln!(); @@ -1865,10 +2034,13 @@ fn run_brute_inner(dry_run: bool) -> BruteResult { config.max_tail, config.scope_rules.len(), config.guards.len(), - config.image.as_ref().map_or( - String::from(", sandbox=off"), - |img| format!(", image={}", img) - ) + config + .image + .as_ref() + .map_or(String::from(", sandbox=off"), |img| format!( + ", image={}", + img + )) )); // Preflight: require protocol.md, judge.md, yoke.conf @@ -1902,11 +2074,8 @@ fn run_brute_inner(dry_run: bool) -> BruteResult { log("Preflight OK"); - let mctx = metrics::MetricsContext::new( - config.metrics_dir.clone(), - "brute", - config.metrics_enabled, - ); + let mctx = + metrics::MetricsContext::new(config.metrics_dir.clone(), "brute", config.metrics_enabled); if mctx.enabled { log(&format!( "Metrics: run_id={}, file={}", @@ -1926,8 +2095,8 @@ fn run_brute_inner(dry_run: bool) -> BruteResult { /// /// - `config`: already-loaded Config /// - `plan_path`: path to the plan file -/// - `dry_run_inner`: if true, skip Claude invocation (one iteration only) -/// - `mctx`: metrics context (shared with nested plan loop) +/// - `dry_run`: if true, skip OMP invocation (one iteration only) +/// - `recorder`: metrics context (shared with nested plan loop) fn run_brute_core( config: &Config, plan_path: &str, @@ -1964,7 +2133,7 @@ fn run_brute_core( log("(dry-run) Skipping worker invocation"); } else { log("Using plan runner as worker..."); - match run_plan_loop(config, plan_path, false, true, recorder) { + match run_plan_loop(config, Some(plan_path), false, true, recorder) { PlanLoopOutcome::JudgePass => { // Embedded judge already passed — skip standalone judge log("Plan runner completed with embedded judge PASS"); @@ -1997,9 +2166,7 @@ fn run_brute_core( if dry_run { let outcome = run_all_guards(config); if !outcome.passed { - log(&format!( - "{}Guards failed{}", ORANGE, RESET - )); + log(&format!("{}Guards failed{}", ORANGE, RESET)); log("(dry-run) Exiting after one iteration"); return BruteResult::Error; } @@ -2067,7 +2234,6 @@ fn run_brute(dry_run: bool) -> i32 { } } - /// 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. @@ -2075,7 +2241,14 @@ fn invoke_scoper(runner: &mut LoopRunner, config: &Config, cycle: u32) -> bool { let scoper_prompt = "Read .loop/saga-protocol.md and follow its instructions."; // Scoper is always a fresh session — saga cycles are independent by design. let stats = invoke_process( - runner, config, scoper_prompt, "scoper", "scoper", cycle, 0.0, None, + runner, + config, + scoper_prompt, + "scoper", + "scoper", + cycle, + 0.0, + None, ); stats.success } @@ -2258,29 +2431,24 @@ fn run_saga(dry_run: bool) -> i32 { config.max_tail, config.scope_rules.len(), config.guards.len(), - config.image.as_ref().map_or( - String::from(", sandbox=off"), - |img| format!(", image={}", img) - ) + config + .image + .as_ref() + .map_or(String::from(", sandbox=off"), |img| format!( + ", image={}", + img + )) )); saga_preflight(); - let saga_protected: Vec<&str> = vec![ - SAGA_PROTOCOL_PATH, - PROTOCOL_PATH, - JUDGE_PATH, - CONF_PATH, - ]; + 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 mctx = metrics::MetricsContext::new( - config.metrics_dir.clone(), - "saga", - config.metrics_enabled, - ); + let mctx = + metrics::MetricsContext::new(config.metrics_dir.clone(), "saga", config.metrics_enabled); if mctx.enabled { log(&format!( "Metrics: run_id={}, file={}", @@ -2304,7 +2472,14 @@ fn run_saga(dry_run: bool) -> i32 { eprintln!(); render_iteration_banner("Saga Cycle", cycle, false); - if let Some(exit_code) = run_saga_cycle(&mut runner, &config, cycle, dry_run, &saga_protected, &mut recorder) { + if let Some(exit_code) = run_saga_cycle( + &mut runner, + &config, + cycle, + dry_run, + &saga_protected, + &mut recorder, + ) { recorder.set_outcome(match exit_code { 0 => "done", 130 => "interrupt", @@ -2324,6 +2499,7 @@ fn cmd_init(args: &[String]) -> ! { None => "loop", Some("brute") => "brute", Some("saga") => "saga", + Some("grind") => "grind", Some(other) => { log_error(&format!("unknown argument '{}'", other)); print_init_help(); @@ -2589,11 +2765,11 @@ fn cmd_run(args: &[String]) -> ! { process::exit(0); } let mut dry_run = false; - let mut no_sandbox = false; + let mut _no_sandbox = false; for arg in &args[2..] { match arg.as_str() { "--dry-run" => dry_run = true, - "--no-sandbox" => no_sandbox = true, + "--no-sandbox" => _no_sandbox = true, other => { log_error(&format!("unknown flag '{}'", other)); print_run_help(); @@ -2601,29 +2777,18 @@ fn cmd_run(args: &[String]) -> ! { } } } - let config = match Config::load(Path::new(CONF_PATH)) { - Ok(c) => c, - Err(e) => { - log_error(&e); - process::exit(1); - } - }; - if config.image.is_none() && !no_sandbox { - log_error("no 'image' directive in config — refusing to run without sandbox"); - eprintln!(" Add 'image ' to {} or pass --no-sandbox to override.", CONF_PATH); - process::exit(2); + if let Err(e) = Config::load(Path::new(CONF_PATH)) { + log_error(&e); + process::exit(1); } 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)) - } + Some("grind") => process::exit(run_loop_with_label(dry_run, "grind")), + Some("brute") => process::exit(run_brute(dry_run)), + _ => process::exit(run_loop(dry_run)), } } diff --git a/src/metrics.rs b/src/metrics.rs index 7f53fb4..94eb0a8 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -197,7 +197,9 @@ pub fn unix_now() -> u64 { /// six-digit sub-second microsecond count. Lexicographic sort matches /// chronological order. No external time crate dep. pub fn new_run_id() -> String { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); let secs = now.as_secs(); let micros = now.subsec_micros(); let (y, m, d, hh, mm, ss) = unix_to_utc(secs); @@ -217,7 +219,11 @@ fn unix_to_utc(secs: u64) -> (i32, u32, u32, u32, u32, u32) { let ss = (tod % 60) as u32; let z = days + 719468; - let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 }; + let era = if z >= 0 { + z / 146097 + } else { + (z - 146096) / 146097 + }; let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400; @@ -246,7 +252,13 @@ pub fn project_slug() -> String { fn sanitize(s: &str) -> String { s.chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) .collect() } @@ -729,8 +741,12 @@ mod tests { cost_usd: 0.42, num_turns: 7, thinking_secs: 4.1, - tool_counts: [("Edit".to_string(), 3u32), ("Bash".to_string(), 1)].into_iter().collect(), - tool_durations_secs: [("Edit".to_string(), 1.8), ("Bash".to_string(), 12.3)].into_iter().collect(), + tool_counts: [("Edit".to_string(), 3u32), ("Bash".to_string(), 1)] + .into_iter() + .collect(), + tool_durations_secs: [("Edit".to_string(), 1.8), ("Bash".to_string(), 12.3)] + .into_iter() + .collect(), guards: vec![GuardRow { name: "cargo test".to_string(), passed: true, diff --git a/src/session_trim.rs b/src/session_trim.rs deleted file mode 100644 index b381fe1..0000000 --- a/src/session_trim.rs +++ /dev/null @@ -1,620 +0,0 @@ -//! Session-JSONL trimming for Claude Code prompt-cache reuse across rounds. -//! -//! Yoke uses `claude --resume ` to carry a worker's conversation across -//! iterations so Anthropic's prefix cache stays warm. Naive resume grows the -//! session monotonically — bash outputs, thinking blocks, intermediate Reads -//! that are no longer relevant — all of it stays in the prefix and is paid -//! for at cache-read rates every round. -//! -//! This module trims the session file between rounds. The agent declares a -//! `KEEP: ...` line in `.loop/notes.md` listing the file Reads -//! whose results should stay in conversation history. Everything else is -//! dropped, the parent-uuid chain is re-linked across the gaps, and the file -//! is atomically rewritten in place. -//! -//! Safety: if the trim's own validation fails (broken parent chain, orphaned -//! tool_use without tool_result, parse error), the original session is kept -//! untouched and we log a warning. `YOKE_DISABLE_SESSION_TRIM=1` skips the -//! whole pass. - -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use serde_json::Value; - -#[derive(Debug, Default)] -pub struct TrimStats { - pub records_total: usize, - pub records_kept: usize, - pub records_dropped: usize, - pub keep_paths: Vec, - pub skipped: bool, -} - -/// Parse a `KEEP:` line from notes.md (or any text). Returns absolute paths -/// resolved against `cwd`. Multiple `KEEP:` lines are unioned. Missing or -/// `*` token is treated as "keep nothing" — the caller decides what that -/// means, but this fn just returns the explicit paths. -pub fn parse_keep_list(notes_text: &str, cwd: &Path) -> HashSet { - let mut out = HashSet::new(); - for line in notes_text.lines() { - let trimmed = line.trim_start(); - let rest = match trimmed.strip_prefix("KEEP:") { - Some(r) => r, - None => continue, - }; - for tok in rest.split_whitespace() { - if tok == "*" { - continue; - } - let p = PathBuf::from(tok); - let abs = if p.is_absolute() { p } else { cwd.join(p) }; - // Best-effort canonicalize so symlinks / .. don't cause mismatches. - let final_path = fs::canonicalize(&abs).unwrap_or(abs); - out.insert(final_path); - } - } - out -} - -/// Locate Claude Code's session file on disk. -/// Format: `/.claude/projects//.jsonl`. -pub fn session_file_path(home: &Path, cwd: &Path, session_id: &str) -> PathBuf { - let cwd_str = cwd.to_string_lossy(); - // Claude Code's slug: replace `/` with `-`. A leading slash becomes a - // leading dash. `.` characters in path components are preserved. - let slug = cwd_str.replace('/', "-"); - home.join(".claude") - .join("projects") - .join(slug) - .join(format!("{}.jsonl", session_id)) -} - -/// Trim a session file in place. Returns stats. If the trim aborts safely -/// (escape hatch / no kept content / validation failure), the original file -/// is untouched. -pub fn trim_session(session_path: &Path, keep_paths: &HashSet) -> Result { - let mut stats = TrimStats { - keep_paths: keep_paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(), - ..TrimStats::default() - }; - - if std::env::var_os("YOKE_DISABLE_SESSION_TRIM").is_some() { - stats.skipped = true; - return Ok(stats); - } - if !session_path.exists() { - return Err(format!("session file not found: {}", session_path.display())); - } - - let content = fs::read_to_string(session_path) - .map_err(|e| format!("read {}: {}", session_path.display(), e))?; - let raw_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); - stats.records_total = raw_lines.len(); - - let mut records: Vec = Vec::with_capacity(raw_lines.len()); - for (i, line) in raw_lines.iter().enumerate() { - let v: Value = serde_json::from_str(line) - .map_err(|e| format!("parse line {}: {}", i + 1, e))?; - records.push(v); - } - - let decisions = classify(&records, keep_paths); - - let trimmed = relink_and_emit(&records, &raw_lines, &decisions)?; - stats.records_kept = trimmed.lines().filter(|l| !l.trim().is_empty()).count(); - stats.records_dropped = stats.records_total.saturating_sub(stats.records_kept); - - validate(&trimmed)?; - - // Atomic write: tmp → rename. Keep one .bak for recovery / debugging. - let bak_path = session_path.with_extension("jsonl.bak"); - let _ = fs::copy(session_path, &bak_path); - let tmp_path = session_path.with_extension("jsonl.tmp"); - { - let mut f = fs::File::create(&tmp_path) - .map_err(|e| format!("create tmp: {}", e))?; - f.write_all(trimmed.as_bytes()) - .map_err(|e| format!("write tmp: {}", e))?; - f.sync_all().ok(); - } - fs::rename(&tmp_path, session_path) - .map_err(|e| format!("rename: {}", e))?; - - Ok(stats) -} - -/// Per-record decision: keep as-is, drop entirely, or keep with a rewritten -/// parentUuid. -#[derive(Debug, Clone)] -enum Decision { - Keep, - Drop, -} - -fn classify(records: &[Value], keep_paths: &HashSet) -> Vec { - // Two-pass: first identify which tool_use ids we keep, then decide each record. - let mut kept_tool_use_ids: HashSet = HashSet::new(); - for r in records { - if !is_assistant(r) { - continue; - } - let Some(block) = first_content_block(r) else { continue }; - if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") { - continue; - } - let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); - if name != "Read" { - continue; - } - let path_str = block - .get("input") - .and_then(|v| v.get("file_path")) - .and_then(|v| v.as_str()); - let Some(path_str) = path_str else { continue }; - let p = PathBuf::from(path_str); - let canonical = fs::canonicalize(&p).unwrap_or(p); - if keep_paths.contains(&canonical) - && let Some(id) = block.get("id").and_then(|v| v.as_str()) - { - kept_tool_use_ids.insert(id.to_string()); - } - } - - records - .iter() - .map(|r| decide(r, &kept_tool_use_ids)) - .collect() -} - -fn decide(record: &Value, kept_tool_use_ids: &HashSet) -> Decision { - // Metadata records (no top-level uuid OR parentUuid is absent and type is bookkeeping): - // always keep. - let typ = record.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match typ { - // Non-conversation bookkeeping — keep unchanged. - "permission-mode" - | "file-history-snapshot" - | "queue-operation" - | "ai-title" - | "last-prompt" - | "attachment" => return Decision::Keep, - _ => {} - } - - // user / assistant: examine content. - let Some(msg) = record.get("message") else { - return Decision::Keep; - }; - let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or(""); - - // Initial user prompt: content is a plain string, not an array. Always keep. - if role == "user" { - match msg.get("content") { - Some(Value::String(_)) => return Decision::Keep, - Some(Value::Array(arr)) => { - // tool_result wrapper. Keep only if its tool_use_id was kept. - if arr.is_empty() { - return Decision::Keep; - } - let block = &arr[0]; - let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if btyp == "tool_result" { - let id = block.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or(""); - if kept_tool_use_ids.contains(id) { - return Decision::Keep; - } else { - return Decision::Drop; - } - } - // Other user content (unusual): keep defensively. - return Decision::Keep; - } - _ => return Decision::Keep, - } - } - - if role == "assistant" { - let Some(block) = first_content_block(record) else { - return Decision::Drop; - }; - let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match btyp { - "thinking" => Decision::Drop, - "text" => Decision::Drop, - "tool_use" => { - let id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); - if kept_tool_use_ids.contains(id) { - Decision::Keep - } else { - Decision::Drop - } - } - _ => Decision::Drop, - } - } else { - // Unknown role — keep, don't make things worse. - Decision::Keep - } -} - -fn is_assistant(record: &Value) -> bool { - record.get("type").and_then(|v| v.as_str()) == Some("assistant") -} - -fn first_content_block(record: &Value) -> Option<&Value> { - record - .get("message")? - .get("content")? - .as_array()? - .first() -} - -/// Build the trimmed JSONL output. For surviving records whose parentUuid -/// points to a dropped record, walks up the parent chain to find the nearest -/// surviving ancestor and rewrites the field. -fn relink_and_emit(records: &[Value], raw: &[&str], decisions: &[Decision]) -> Result { - // uuid → parentUuid index for ALL records that have a uuid. Used to walk - // up the chain when re-linking. - let mut parent_of: HashMap> = HashMap::new(); - let mut kept_uuids: HashSet = HashSet::new(); - for (i, r) in records.iter().enumerate() { - let Some(uuid) = r.get("uuid").and_then(|v| v.as_str()) else { continue }; - let parent = r - .get("parentUuid") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - parent_of.insert(uuid.to_string(), parent); - if matches!(decisions[i], Decision::Keep) { - kept_uuids.insert(uuid.to_string()); - } - } - - // For each kept uuid, compute the rewritten parentUuid (nearest kept - // ancestor or null). - let mut rewritten_parent: HashMap> = HashMap::new(); - for uuid in &kept_uuids { - let mut cur = parent_of.get(uuid).cloned().flatten(); - while let Some(p) = cur { - if kept_uuids.contains(&p) { - rewritten_parent.insert(uuid.clone(), Some(p)); - break; - } - cur = parent_of.get(&p).cloned().flatten(); - } - if !rewritten_parent.contains_key(uuid) { - rewritten_parent.insert(uuid.clone(), None); - } - } - - let mut out = String::with_capacity(raw.iter().map(|l| l.len() + 1).sum()); - for (i, r) in records.iter().enumerate() { - if matches!(decisions[i], Decision::Drop) { - continue; - } - let uuid_opt = r.get("uuid").and_then(|v| v.as_str()).map(|s| s.to_string()); - // If this record has a uuid AND its rewritten parent differs from - // the on-disk parent, re-serialize. Otherwise emit raw. - let needs_rewrite = match &uuid_opt { - Some(uuid) => { - let orig = parent_of.get(uuid).cloned().flatten(); - let new = rewritten_parent.get(uuid).cloned().flatten(); - orig != new - } - None => false, - }; - if needs_rewrite { - let mut v = r.clone(); - let uuid = uuid_opt.unwrap(); - let new_parent = rewritten_parent.get(&uuid).cloned().flatten(); - if let Some(obj) = v.as_object_mut() { - match new_parent { - Some(p) => { - obj.insert("parentUuid".to_string(), Value::String(p)); - } - None => { - obj.insert("parentUuid".to_string(), Value::Null); - } - } - } - let s = serde_json::to_string(&v) - .map_err(|e| format!("serialize: {}", e))?; - out.push_str(&s); - out.push('\n'); - } else { - out.push_str(raw[i]); - out.push('\n'); - } - } - - Ok(out) -} - -/// Validate that the trimmed JSONL is internally consistent: -/// every tool_use has a matching tool_result downstream. -fn validate(trimmed: &str) -> Result<(), String> { - let mut tool_use_ids: HashSet = HashSet::new(); - let mut tool_result_ids: HashSet = HashSet::new(); - for (i, line) in trimmed.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let v: Value = serde_json::from_str(line) - .map_err(|e| format!("validate parse line {}: {}", i + 1, e))?; - let Some(arr) = v.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) else { - continue; - }; - for block in arr { - match block.get("type").and_then(|v| v.as_str()) { - Some("tool_use") => { - if let Some(id) = block.get("id").and_then(|v| v.as_str()) { - tool_use_ids.insert(id.to_string()); - } - } - Some("tool_result") => { - if let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) { - tool_result_ids.insert(id.to_string()); - } - } - _ => {} - } - } - } - for id in &tool_use_ids { - if !tool_result_ids.contains(id) { - return Err(format!("orphan tool_use {}: no matching tool_result", id)); - } - } - for id in &tool_result_ids { - if !tool_use_ids.contains(id) { - return Err(format!("orphan tool_result {}: no matching tool_use", id)); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use std::sync::Mutex; - - /// Serializes tests that read/mutate the `YOKE_DISABLE_SESSION_TRIM` env - /// var. cargo runs tests in parallel within a binary; without this they - /// race on a process-global. - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - /// Clear the env var before running the closure, then drop the guard. - fn with_clean_env(f: F) { - let _g = ENV_LOCK.lock().unwrap(); - // SAFETY: tests are serialized via ENV_LOCK; no other thread will - // observe a partial write to environ. - unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") }; - f(); - } - - #[test] - fn parse_keep_list_basic() { - let cwd = PathBuf::from("/tmp"); - let s = "STATUS: IN_PROGRESS\nKEEP: src/a.rs /abs/b.rs\nfoo\n"; - let got = parse_keep_list(s, &cwd); - assert!(got.iter().any(|p| p.ends_with("a.rs"))); - assert!(got.iter().any(|p| p == &PathBuf::from("/abs/b.rs"))); - } - - #[test] - fn parse_keep_list_missing() { - let cwd = PathBuf::from("/tmp"); - let s = "STATUS: DONE\n"; - assert!(parse_keep_list(s, &cwd).is_empty()); - } - - #[test] - fn parse_keep_list_star_means_nothing() { - let cwd = PathBuf::from("/tmp"); - let s = "KEEP: *\n"; - assert!(parse_keep_list(s, &cwd).is_empty()); - } - - #[test] - fn session_file_path_slug() { - let p = session_file_path( - Path::new("/home/u"), - Path::new("/workspace"), - "abc-123", - ); - assert_eq!(p, PathBuf::from("/home/u/.claude/projects/-workspace/abc-123.jsonl")); - } - - /// Tiny realistic session: bootstrap + initial prompt + 2 Reads + a Bash - /// + a thinking block. Used by scenario tests below. - fn write_fixture(dir: &Path, paths: &[&str]) -> PathBuf { - let mut lines: Vec = Vec::new(); - // queue-operation (metadata) — has no uuid/parentUuid. - lines.push(r#"{"type":"queue-operation","sessionId":"s1"}"#.to_string()); - // initial user prompt (root of conversation) - lines.push(r#"{"type":"user","uuid":"u-root","parentUuid":null,"message":{"role":"user","content":"Read .loop/protocol.md and follow its instructions."}}"#.to_string()); - // assistant thinking — should always be dropped - lines.push(r#"{"type":"assistant","uuid":"u-think","parentUuid":"u-root","message":{"role":"assistant","content":[{"type":"thinking","thinking":"..."}]}}"#.to_string()); - // Read tool_use for paths[0] - let p0 = paths.first().copied().unwrap_or("/tmp/a.rs"); - lines.push(format!( - r#"{{"type":"assistant","uuid":"u-read-a","parentUuid":"u-think","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-a","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#, - p0 - )); - // tool_result for the Read - lines.push(r#"{"type":"user","uuid":"u-res-a","parentUuid":"u-read-a","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-a","content":"file A contents"}]}}"#.to_string()); - // Bash tool_use — should always be dropped - lines.push(r#"{"type":"assistant","uuid":"u-bash","parentUuid":"u-res-a","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-bash","name":"Bash","input":{"command":"ls"}}]}}"#.to_string()); - lines.push(r#"{"type":"user","uuid":"u-res-bash","parentUuid":"u-bash","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-bash","content":"a\nb\nc"}]}}"#.to_string()); - // Read tool_use for paths[1] (a second file) - let p1 = paths.get(1).copied().unwrap_or("/tmp/b.rs"); - lines.push(format!( - r#"{{"type":"assistant","uuid":"u-read-b","parentUuid":"u-res-bash","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-b","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#, - p1 - )); - lines.push(r#"{"type":"user","uuid":"u-res-b","parentUuid":"u-read-b","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-b","content":"file B contents"}]}}"#.to_string()); - - let path = dir.join("s1.jsonl"); - let mut f = fs::File::create(&path).unwrap(); - for l in &lines { - writeln!(f, "{}", l).unwrap(); - } - path - } - - fn read_records(path: &Path) -> Vec { - fs::read_to_string(path) - .unwrap() - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).unwrap()) - .collect() - } - - #[test] - fn trim_with_one_keep_drops_other_reads_and_bash_and_thinking() { - let tmp = tempfile::tempdir().unwrap(); - let a_path = tmp.path().join("a.rs"); - let b_path = tmp.path().join("b.rs"); - fs::write(&a_path, "fn a(){}").unwrap(); - fs::write(&b_path, "fn b(){}").unwrap(); - let session = write_fixture( - tmp.path(), - &[a_path.to_str().unwrap(), b_path.to_str().unwrap()], - ); - - let mut keep = HashSet::new(); - keep.insert(fs::canonicalize(&a_path).unwrap()); - - let mut stats_opt = None; - with_clean_env(|| { - stats_opt = Some(trim_session(&session, &keep).expect("trim ok")); - }); - let stats = stats_opt.unwrap(); - assert!(!stats.skipped); - assert!(stats.records_dropped >= 4, "should drop thinking + bash pair + b read pair, got {:?}", stats); - - let recs = read_records(&session); - // Surviving tool_use ids: only tu-a; tu-b and tu-bash are gone. - let tool_use_ids: Vec = recs - .iter() - .filter_map(|r| { - r.get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - .and_then(|a| a.first()) - .filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_use")) - .and_then(|b| b.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - }) - .collect(); - assert_eq!(tool_use_ids, vec!["tu-a".to_string()]); - - // Every tool_use has a paired tool_result (validate() enforces this on write; - // re-check here for the behavior we promise). - let tool_result_ids: Vec = recs - .iter() - .filter_map(|r| { - r.get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - .and_then(|a| a.first()) - .filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_result")) - .and_then(|b| b.get("tool_use_id").and_then(|v| v.as_str()).map(|s| s.to_string())) - }) - .collect(); - assert_eq!(tool_result_ids, vec!["tu-a".to_string()]); - - // No thinking blocks survive. - for r in &recs { - let typ_opt = r - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - .and_then(|a| a.first()) - .and_then(|b| b.get("type")) - .and_then(|v| v.as_str()); - assert_ne!(typ_opt, Some("thinking")); - } - } - - #[test] - fn trim_relinks_parent_uuids_to_nearest_surviving_ancestor() { - let tmp = tempfile::tempdir().unwrap(); - let a_path = tmp.path().join("a.rs"); - let b_path = tmp.path().join("b.rs"); - fs::write(&a_path, "x").unwrap(); - fs::write(&b_path, "y").unwrap(); - let session = write_fixture( - tmp.path(), - &[a_path.to_str().unwrap(), b_path.to_str().unwrap()], - ); - - // Keep only b. Records dropped between root and b-read should result - // in u-read-b's new parent being u-root (the only surviving ancestor). - let mut keep = HashSet::new(); - keep.insert(fs::canonicalize(&b_path).unwrap()); - - with_clean_env(|| { - trim_session(&session, &keep).expect("trim ok"); - }); - - let recs = read_records(&session); - let read_b = recs - .iter() - .find(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-read-b")) - .expect("u-read-b survives"); - let new_parent = read_b.get("parentUuid").and_then(|v| v.as_str()); - // u-think, u-read-a, u-res-a, u-bash, u-res-bash all dropped → parent - // walks up to u-root. - assert_eq!(new_parent, Some("u-root")); - } - - #[test] - fn trim_skipped_when_env_var_set() { - let tmp = tempfile::tempdir().unwrap(); - let session = write_fixture(tmp.path(), &["/tmp/a.rs"]); - let original = fs::read_to_string(&session).unwrap(); - - let _g = ENV_LOCK.lock().unwrap(); - // SAFETY: serialized by ENV_LOCK above. - unsafe { std::env::set_var("YOKE_DISABLE_SESSION_TRIM", "1") }; - let stats = trim_session(&session, &HashSet::new()).expect("trim ok"); - unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") }; - assert!(stats.skipped); - assert_eq!(original, fs::read_to_string(&session).unwrap()); - } - - #[test] - fn trim_empty_keep_drops_all_tool_use_pairs() { - let tmp = tempfile::tempdir().unwrap(); - let session = write_fixture(tmp.path(), &["/tmp/a.rs", "/tmp/b.rs"]); - - with_clean_env(|| { - trim_session(&session, &HashSet::new()).expect("trim ok"); - }); - - let recs = read_records(&session); - for r in &recs { - let block = r - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - .and_then(|a| a.first()); - if let Some(b) = block { - let btyp = b.get("type").and_then(|v| v.as_str()).unwrap_or(""); - assert_ne!(btyp, "tool_use", "no tool_use should survive empty keep"); - assert_ne!(btyp, "tool_result", "no tool_result should survive empty keep"); - assert_ne!(btyp, "thinking", "no thinking should survive"); - } - } - // queue-operation, initial user prompt should survive. - assert!(recs.iter().any(|r| r.get("type").and_then(|v| v.as_str()) == Some("queue-operation"))); - assert!(recs.iter().any(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-root"))); - } -} diff --git a/src/stash.rs b/src/stash.rs index eaf3ac5..8c04259 100644 --- a/src/stash.rs +++ b/src/stash.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::Path; use crate::ansi::{BLUE, BOLD, ORANGE, RESET}; -use crate::{log, log_error, STASH_DIR}; +use crate::{STASH_DIR, log, log_error}; // ── Stash helpers ────────────────────────────────────────────────────── @@ -139,13 +139,7 @@ pub(crate) fn stash_snapshot(mode: &str) -> Result { // Append to index let index_path = format!("{}/index", STASH_DIR); - let line = format!( - "{}|{}|{}|{}\n", - hash, - timestamp, - mode, - file_names.join(",") - ); + let line = format!("{}|{}|{}|{}\n", hash, timestamp, mode, file_names.join(",")); let mut f = std::fs::OpenOptions::new() .create(true) .append(true) @@ -258,9 +252,8 @@ fn swap_loop_files(entry_dir: &Path, mode: &str) -> Result<(), String> { for entry in dir_entries.flatten() { let name = entry.file_name(); let dest = Path::new(".loop").join(&name); - fs::copy(entry.path(), &dest).map_err(|e| { - format!("failed to restore {}: {}", name.to_string_lossy(), e) - })?; + fs::copy(entry.path(), &dest) + .map_err(|e| format!("failed to restore {}: {}", name.to_string_lossy(), e))?; } } Ok(()) @@ -317,10 +310,7 @@ pub(crate) fn print_stash_help() { ORANGE, BOLD, RESET ); eprintln!(); - eprintln!( - "{}USAGE:{} yoke stash [subcommand]", - BOLD, RESET - ); + eprintln!("{}USAGE:{} yoke stash [subcommand]", BOLD, RESET); eprintln!(); eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET); eprintln!( diff --git a/src/stream.rs b/src/stream.rs index ecbcb17..51ffb19 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,494 +1,30 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::io::{self, BufRead, BufReader, Write}; use std::path::Path; use std::process::ChildStdout; -use std::time::Instant; - -use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW}; -use crate::json::{extract_num, extract_str, unescape_json}; - -const BG_RED: &str = "\x1b[48;2;80;30;30m"; -const BG_GREEN: &str = "\x1b[48;2;30;60;30m"; /// Aggregated, persistable view of one agent invocation's stream. /// -/// Produced by `filter_stream` after the child's stdout closes. Carries -/// everything the metrics layer wants: cost, agent-reported wall time, -/// thinking total, and per-tool durations + counts. +/// Produced after the child's stdout closes. Carries everything the metrics +/// layer wants: cost, agent-reported wall time, thinking total, and per-tool +/// durations + counts. #[derive(Debug, Clone, Default)] pub struct StreamSummary { pub cost_usd: f64, - /// Wall clock as reported by the agent's `result` event (Claude only). + /// Wall clock as reported by the agent's stream, when available. pub agent_reported_secs: Option, pub num_turns: u32, pub thinking_secs: f64, pub tool_counts: BTreeMap, pub tool_durations_secs: BTreeMap, - /// Claude session ID extracted from the init event. Used by the harness - /// to `--resume` the same session on the next iteration, preserving the - /// prompt cache. `None` for OpenCode or if the init event was missed. + /// OMP session ID extracted from the `session` event. Used by the harness + /// to resume the same worker session on the next iteration. pub session_id: Option, } -struct StreamState { - turn_num: u32, - current_msg_id: Option, - seen_init: bool, - in_thinking: bool, - thinking_start: Option, - thinking_total_secs: f64, - iteration_cost: f64, - iteration_duration_secs: f64, - /// Maps tool_use id → (tool name, start instant), so tool_result can - /// look up its origin and elapsed time. - tool_use_starts: HashMap, - /// Counts of tool_use events by tool name (for iteration summary strip). - tool_counts: HashMap, - /// Wall-clock duration accumulated per tool name across the iteration. - tool_durations: HashMap, - /// Captured from the `system`/`init` event so the harness can `--resume`. - session_id: Option, -} - -impl StreamState { - fn new() -> Self { - Self { - turn_num: 0, - current_msg_id: None, - seen_init: false, - in_thinking: false, - thinking_start: None, - thinking_total_secs: 0.0, - iteration_cost: 0.0, - iteration_duration_secs: 0.0, - tool_use_starts: HashMap::new(), - tool_counts: HashMap::new(), - tool_durations: HashMap::new(), - session_id: None, - } - } - - fn into_summary(self) -> StreamSummary { - let agent_reported_secs = if self.iteration_duration_secs > 0.0 { - Some(self.iteration_duration_secs) - } else { - None - }; - StreamSummary { - cost_usd: self.iteration_cost, - agent_reported_secs, - num_turns: self.turn_num, - thinking_secs: self.thinking_total_secs, - tool_counts: self.tool_counts.into_iter().collect(), - tool_durations_secs: self.tool_durations.into_iter().collect(), - session_id: self.session_id, - } - } -} - -/// 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("?"); - - match tool_name { - "Read" => { - let path = extract_str(line, "file_path").unwrap_or("?"); - 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("?"); - 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("?"); - 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 { - let truncated: String = cmd.chars().take(77).collect(); - format!("{}{}Bash:{} {}{}...{}", BOLD, MAGENTA, RESET, DIM, truncated, RESET) - } else { - format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET) - } - } - "Glob" => { - let pat = extract_str(line, "pattern").unwrap_or("?"); - format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) - } - "Grep" => { - let pat = extract_str(line, "pattern").unwrap_or("?"); - format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) - } - other => format!("{}{}{}{}", BOLD, BLUE, other, RESET), - } -} - -/// Handle "assistant" events: render tool_use summaries and track tool counts. -fn handle_assistant(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> { - if !line.contains("\"tool_use\"") { - return Ok(()); - } - if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) { - state.tool_use_starts.insert(id.to_string(), (name.to_string(), Instant::now())); - *state.tool_counts.entry(name.to_string()).or_insert(0) += 1; - } - let desc = format_tool_call(line); - writeln!(out, " {}>>{} {}", GRAY, RESET, desc) -} - -/// Handle "stream_event" events: render thinking timer, text deltas, and block boundaries. -fn handle_stream_event(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> { - if line.contains("\"content_block_delta\"") { - if line.contains("\"thinking_delta\"") { - 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()?; - } - } 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 { - if let Some(start) = state.thinking_start { - let elapsed = start.elapsed().as_secs_f64(); - state.thinking_total_secs += elapsed; - write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; - } - state.in_thinking = false; - state.thinking_start = None; - } - writeln!(out)?; - } - Ok(()) -} - -/// Handle "user" events: render tool_result success/error badges and -/// accumulate the wall-clock duration of each tool call by name. -fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> { - if !line.contains("\"tool_result\"") { - return Ok(()); - } - - // Pair this result with its tool_use; drop the entry and accumulate elapsed. - // Done for both success and error so failed tools still show up in metrics. - let tool_use_id = extract_str(line, "tool_use_id").map(|s| s.to_string()); - let tool_name_owned: Option = tool_use_id.and_then(|id| { - state.tool_use_starts.remove(&id).map(|(name, start)| { - let elapsed = start.elapsed().as_secs_f64(); - *state.tool_durations.entry(name.clone()).or_insert(0.0) += elapsed; - name - }) - }); - - let is_error = line.contains("\"is_error\":true") || line.contains("\"is_error\": true"); - if is_error { - writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?; - let tail = format_bash_error_tail(line); - if !tail.is_empty() { - writeln!(out, "{}", tail)?; - } - return Ok(()); - } - let badge = match tool_name_owned.as_deref() { - Some(name @ ("Grep" | "Glob")) => format_grep_glob_badge(name, line), - _ => String::new(), - }; - if badge.is_empty() { - writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET) - } else { - writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge) - } -} - -/// 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 + ?Sized), 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 is_new = state.current_msg_id.as_deref() != Some(msg_id); - if is_new { - state.current_msg_id = Some(msg_id.to_string()); - state.turn_num += 1; - writeln!(out, "{}{}━━━ Turn {} ━━━{}", BOLD, ORANGE, state.turn_num, RESET)?; - } - } - - match extract_str(line, "type") { - Some("system") => { - if extract_str(line, "subtype") == Some("init") && !state.seen_init { - state.seen_init = true; - let sid = extract_str(line, "session_id").unwrap_or("?"); - if sid != "?" { - state.session_id = Some(sid.to_string()); - } - let sid_short: String = sid.chars().take(12).collect(); - let model = extract_str(line, "model").unwrap_or("?"); - writeln!(out, "{}{}[stream]{} session {}… model={}", ORANGE, BOLD, RESET, sid_short, model)?; - } - } - Some("assistant") => handle_assistant(out, line, state)?, - Some("stream_event") => handle_stream_event(out, line, state)?, - Some("user") => handle_tool_result(out, line, state)?, - 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 - )?; - } - None if !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 edit 1.8s │ 1 bash 12.3s │ thinking 4.1s │ 42s │ $0.38 ⟫` -/// -/// Tool entries show count + elapsed for the top-3 tools by wall time, then -/// count-only for the rest (stops the strip overflowing 80 cols). -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" })); - - // Rank tools by elapsed time; top-3 get the "Ns" suffix, the rest are - // count-only. Tools with no recorded duration (e.g. tool_result never - // arrived) sort to the end of the timed list. - let mut ranked: Vec<(&String, u32, f64)> = state - .tool_counts - .iter() - .map(|(name, &count)| { - let secs = state.tool_durations.get(name).copied().unwrap_or(0.0); - (name, count, secs) - }) - .collect(); - ranked.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); - - for (i, (name, count, secs)) in ranked.iter().enumerate() { - let label = name.to_lowercase(); - if i < 3 && *secs > 0.0 { - parts.push(format!("{} {} {:.1}s", count, label, secs)); - } else { - parts.push(format!("{} {}", count, label)); - } - } - - if state.thinking_total_secs > 0.0 { - parts.push(format!("thinking {:.1}s", state.thinking_total_secs)); - } - - // Duration - parts.push(format!("{:.0}s", state.iteration_duration_secs)); - - // Cost - parts.push(format!("${:.2}", state.iteration_cost)); - - format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET) -} - -/// Shared stream loop: reads lines from stdout, tees to log, calls processor per line, -/// prints a summary strip, and finalizes state into a caller-defined return type. -/// -/// Used by both Claude and OpenCode stream filters to avoid duplicating the -/// BufReader/signal-check/log-tee boilerplate. `finalize` consumes the state -/// so the caller can move out of it (e.g. into a `StreamSummary`). +/// Shared stream loop: reads lines from stdout, tees to log, calls processor per +/// line, prints a summary strip, and finalizes state into a caller-defined +/// return type. pub fn run_stream_loop( stdout: ChildStdout, log_path: Option<&Path>, @@ -519,7 +55,7 @@ pub fn run_stream_loop( continue; } - if let Some(ref mut f) = log_file { + if let Some(f) = &mut log_file { let _ = writeln!(f, "{}", line); } @@ -535,18 +71,3 @@ pub fn run_stream_loop( let _ = out.flush(); finalize(state) } - -/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. -/// `prior_total` is the accumulated cost from previous iterations. -/// Returns a `StreamSummary` carrying cost, thinking time, tool durations, etc. -pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> StreamSummary { - let state = StreamState::new(); - run_stream_loop( - stdout, - log_path, - state, - |out, line, st| process_line(out, line, st, prior_total), - |st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None }, - |st| st.into_summary(), - ) -} diff --git a/src/stream_omp.rs b/src/stream_omp.rs new file mode 100644 index 0000000..a02b89b --- /dev/null +++ b/src/stream_omp.rs @@ -0,0 +1,274 @@ +use std::collections::HashMap; +use std::io::{self, Write}; +use std::path::Path; +use std::process::ChildStdout; +use std::time::Instant; + +use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW}; +use crate::json::{extract_bool, extract_num, extract_str, unescape_json}; +use crate::stream::StreamSummary; + +struct StreamState { + turn_num: u32, + iteration_cost: f64, + iteration_duration_secs: f64, + tool_counts: HashMap, + tool_durations: HashMap, + tool_starts: HashMap, + session_id: Option, + saw_done: bool, +} + +impl StreamState { + fn new() -> Self { + Self { + turn_num: 0, + iteration_cost: 0.0, + iteration_duration_secs: 0.0, + tool_counts: HashMap::new(), + tool_durations: HashMap::new(), + tool_starts: HashMap::new(), + session_id: None, + saw_done: false, + } + } + + fn into_summary(self) -> StreamSummary { + StreamSummary { + cost_usd: self.iteration_cost, + agent_reported_secs: if self.iteration_duration_secs > 0.0 { + Some(self.iteration_duration_secs) + } else { + None + }, + num_turns: self.turn_num, + thinking_secs: 0.0, + tool_counts: self.tool_counts.into_iter().collect(), + tool_durations_secs: self.tool_durations.into_iter().collect(), + session_id: self.session_id, + } + } +} + +fn format_tool_call(tool_name: &str, line: &str) -> String { + match tool_name { + "read" => { + let path = extract_str(line, "path") + .or_else(|| extract_str(line, "filePath")) + .unwrap_or("?"); + format!("{}{}Read:{} {}{}{}", BOLD, CYAN, RESET, DIM, path, RESET) + } + "write" => { + let path = extract_str(line, "path") + .or_else(|| extract_str(line, "filePath")) + .unwrap_or("?"); + format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET) + } + "edit" | "apply_patch" => { + format!("{}{}Edit{}{}", BOLD, YELLOW, RESET, RESET) + } + "bash" => { + let cmd = extract_str(line, "command").unwrap_or("?"); + let cmd = unescape_json(cmd); + let mut chars = cmd.chars(); + let preview: String = chars.by_ref().take(77).collect(); + if chars.next().is_some() { + format!( + "{}{}Bash:{} {}{}...{}", + BOLD, MAGENTA, RESET, DIM, preview, RESET + ) + } else { + format!( + "{}{}Bash:{} {}{}{}", + BOLD, MAGENTA, RESET, DIM, preview, RESET + ) + } + } + "glob" => { + let pat = extract_str(line, "pattern").unwrap_or("?"); + format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) + } + "grep" => { + let pat = extract_str(line, "pattern").unwrap_or("?"); + format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET) + } + other => format!("{}{}{}{}", BOLD, BLUE, other, RESET), + } +} + +fn handle_tool_start( + out: &mut (impl Write + ?Sized), + line: &str, + state: &mut StreamState, +) -> io::Result<()> { + let tool_name = extract_str(line, "toolName").unwrap_or("?"); + let tool_id = extract_str(line, "toolCallId").unwrap_or(""); + *state.tool_counts.entry(tool_name.to_string()).or_insert(0) += 1; + if !tool_id.is_empty() { + state + .tool_starts + .insert(tool_id.to_string(), (tool_name.to_string(), Instant::now())); + } + let desc = format_tool_call(tool_name, line); + writeln!(out, " {}>>{} {}", GRAY, RESET, desc) +} + +fn handle_tool_end( + out: &mut (impl Write + ?Sized), + line: &str, + state: &mut StreamState, +) -> io::Result<()> { + let tool_name = extract_str(line, "toolName").unwrap_or("?").to_string(); + let elapsed = extract_num(line, "wallTimeMs") + .map(|ms| ms / 1000.0) + .or_else(|| { + extract_str(line, "toolCallId").and_then(|id| { + state + .tool_starts + .remove(id) + .map(|(_, start)| start.elapsed().as_secs_f64()) + }) + }); + if let Some(secs) = elapsed { + *state.tool_durations.entry(tool_name).or_insert(0.0) += secs; + } + + let is_error = extract_bool(line, "isError").unwrap_or(false); + if is_error { + writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET) + } else { + writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET) + } +} + +fn handle_message_end(line: &str, state: &mut StreamState) { + if extract_str(line, "role") != Some("assistant") { + return; + } + state.iteration_cost += extract_num(line, "total").unwrap_or(0.0); + state.iteration_duration_secs += extract_num(line, "duration").unwrap_or(0.0) / 1000.0; +} + +fn print_done( + out: &mut (impl Write + ?Sized), + state: &mut StreamState, + prior_total: f64, +) -> io::Result<()> { + if state.saw_done { + return Ok(()); + } + state.saw_done = true; + writeln!( + out, + "{}{}[stream]{} done cost=${:.2} (total=${:.2}) turns={} duration={:.1}s", + ORANGE, + BOLD, + RESET, + state.iteration_cost, + prior_total + state.iteration_cost, + state.turn_num, + state.iteration_duration_secs + ) +} + +fn process_line( + out: &mut (impl Write + ?Sized), + line: &str, + state: &mut StreamState, + prior_total: f64, +) -> io::Result<()> { + match extract_str(line, "type") { + Some("session") => { + if let Some(id) = extract_str(line, "id") { + state.session_id = Some(id.to_string()); + let sid_short: String = id.chars().take(12).collect(); + writeln!( + out, + "{}{}[stream]{} session {}…", + ORANGE, BOLD, RESET, sid_short + )?; + } + } + Some("turn_start") => { + state.turn_num += 1; + writeln!( + out, + "{}{}━━━ Turn {} ━━━{}", + BOLD, ORANGE, state.turn_num, RESET + )?; + } + Some("message_update") => { + if line.contains("\"type\":\"text_delta\"") || line.contains("\"type\": \"text_delta\"") + { + if let Some(delta) = extract_str(line, "delta") { + write!(out, "{}{}{}", DIM, unescape_json(delta), RESET)?; + out.flush()?; + } + } + } + Some("tool_execution_start") => handle_tool_start(out, line, state)?, + Some("tool_execution_end") => handle_tool_end(out, line, state)?, + Some("message_end") => handle_message_end(line, state), + Some("agent_end") => print_done(out, state, prior_total)?, + None if !line.trim().is_empty() => { + writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?; + } + _ => {} + } + 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 mut ranked: Vec<(&String, u32, f64)> = state + .tool_counts + .iter() + .map(|(name, &count)| { + let secs = state.tool_durations.get(name).copied().unwrap_or(0.0); + (name, count, secs) + }) + .collect(); + ranked.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); + + for (i, (name, count, secs)) in ranked.iter().enumerate() { + if i < 3 && *secs > 0.0 { + parts.push(format!("{} {} {:.1}s", count, name, secs)); + } else { + parts.push(format!("{} {}", count, name)); + } + } + + parts.push(format!("{:.0}s", state.iteration_duration_secs)); + parts.push(format!("${:.2}", state.iteration_cost)); + + format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET) +} + +/// Filter OMP NDJSON and format it as rich ANSI output on stdout. +pub fn filter_stream( + stdout: ChildStdout, + log_path: Option<&Path>, + prior_total: f64, +) -> StreamSummary { + let state = StreamState::new(); + crate::stream::run_stream_loop( + stdout, + log_path, + state, + |out, line, st| process_line(out, line, st, prior_total), + |st| { + if st.turn_num > 0 { + Some(format_summary_strip(st)) + } else { + None + } + }, + |st| st.into_summary(), + ) +} diff --git a/src/stream_opencode.rs b/src/stream_opencode.rs deleted file mode 100644 index a8d564c..0000000 --- a/src/stream_opencode.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::collections::HashMap; -use std::io::{self, Write}; -use std::path::Path; -use std::process::ChildStdout; - -use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW}; -use crate::json::{extract_num, extract_str, unescape_json}; -use crate::stream::StreamSummary; - -struct StreamState { - turn_num: u32, - iteration_cost: f64, - tool_counts: HashMap, - total_tokens: u64, -} - -impl StreamState { - fn new() -> Self { - Self { - turn_num: 0, - iteration_cost: 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 + ?Sized), 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) -> StreamSummary { - let state = StreamState::new(); - crate::stream::run_stream_loop( - stdout, - log_path, - state, - |out, line, st| process_line(out, line, st), - |st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None }, - |st| StreamSummary { - cost_usd: st.iteration_cost, - agent_reported_secs: None, - num_turns: st.turn_num, - thinking_secs: 0.0, - tool_counts: st.tool_counts.into_iter().collect(), - tool_durations_secs: std::collections::BTreeMap::new(), - session_id: None, - }, - ) -} diff --git a/src/templates/brute/protocol.md b/src/templates/brute/protocol.md index e4195b7..da92781 100644 --- a/src/templates/brute/protocol.md +++ b/src/templates/brute/protocol.md @@ -43,39 +43,20 @@ 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. -## KEEP: Carrying File Context Across Iterations +## Session Continuity -Your conversation history persists across worker iterations via `--resume`. -To keep Claude's prompt cache warm without ballooning, the outer loop trims -your session between rounds: it drops `Bash` output, thinking, and any file -`Read` results that aren't on your KEEP list. Everything else (text turns, -intermediate `Edit`/`Grep`/`Glob` results) is also dropped. +Yoke resumes the same worker OMP session across worker iterations. +`.loop/notes.md`, `.loop/plan.md`, `.loop/protocol.md`, `.loop/verdict.md`, +and `.loop/guard-results.md` also live on disk, so re-read them every +iteration instead of trusting stale context. -After STATUS, on its own line in `.loop/notes.md`, list the file paths you -want to keep cached for the next iteration: - -``` -STATUS: IN_PROGRESS -KEEP: src/foo.rs src/bar.rs tests/baz.rs -``` - -Rules: - -- Space-separated repo-relative paths (or absolute). -- List files you read **this iteration** and will still need next iteration. -- Don't list `.loop/*` files — those live on disk and are re-read fresh. -- Keep the list tight. Every kept file is paid for at cache-read rates - every round it stays. Drop a file once it's no longer relevant. -- Omit `KEEP:` (or `KEEP: *`) to keep nothing. - -Note: the judge always runs in a fresh session — your KEEP list does not -affect the judge. +The judge always runs in a fresh OMP session. ## 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. +3. Then the judge (a fresh OMP session 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 diff --git a/src/templates/brute/yoke.conf b/src/templates/brute/yoke.conf index 30eee3c..e1388e1 100644 --- a/src/templates/brute/yoke.conf +++ b/src/templates/brute/yoke.conf @@ -24,46 +24,32 @@ # 6. Run hooks # 7. Check: STATUS: DONE + all guards pass → exit inner loop -# ── Backend ──────────────────────────────────────────────────────────── -# Which LLM backend to use. Leave commented for Claude CLI (default). -# Setting `model` switches to the OpenCode backend, which supports -# OpenRouter, OpenAI, Anthropic API, and other providers. +# ── OMP model ────────────────────────────────────────────────────────── +# Yoke invokes `omp` by default. Leave commented to use OMP's configured +# default model, or set a model name exactly as you would pass to +# `omp --model`. # -# model openrouter/anthropic/claude-sonnet-4 -# model openai/gpt-4o -# model anthropic/claude-sonnet-4 - -# ── Claude model (optional) ──────────────────────────────────────────── -# Override the model the Claude CLI uses for each iteration. Leave -# commented to use Claude Code's default (Opus). Useful for trading some -# reasoning depth for faster, cheaper iterations. -# -# claude-model claude-sonnet-4-6 -# claude-model claude-haiku-4-5 +# model gpt-5.5 +# model openai/gpt-5.2 +# model gemini-2.5-pro # ── Thinking budget (optional) ───────────────────────────────────────── -# Cap extended-thinking tokens per turn for the Claude CLI backend. -# Useful when running smaller/faster models (sonnet, haiku) and you'd -# rather they spend the iteration acting than reasoning. Sets the -# MAX_THINKING_TOKENS env var on the agent invocation. +# Forwarded to `omp --thinking`. # -# thinking off # disable extended thinking entirely (0 tokens) -# thinking low # 2k tokens -# thinking medium # 10k tokens -# thinking high # 32k tokens -# -# Ignored by the OpenCode backend (warns at config load). +# thinking off +# thinking low +# thinking medium +# thinking high +# thinking xhigh # # thinking low -# ── Sandbox ──────────────────────────────────────────────────────────── -# Docker image to run the agent inside. Your working directory is -# bind-mounted into the container at /workspace. Required unless you -# pass --no-sandbox on the command line. +# ── Sandbox (optional) ───────────────────────────────────────────────── +# By default OMP runs on the host. Set `image` only if the image contains +# an `omp` executable; yoke bind-mounts the working directory at /workspace +# and uses `omp` as the container entrypoint. # -# Note: sandbox is not currently supported with the `model` directive. - -image claude-code-sandbox:latest +# image omp-sandbox:latest # ── Output ───────────────────────────────────────────────────────────── # max-tail: max lines of output kept *per guard* in guard-results.md. diff --git a/src/templates/grind/grind-gate.py b/src/templates/grind/grind-gate.py new file mode 100755 index 0000000..f4fa972 --- /dev/null +++ b/src/templates/grind/grind-gate.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +import json +import os +import subprocess +import sys +from pathlib import Path + +BASELINE_PATH = Path(".loop/cstat-baseline.json") +CURRENT_PATH = Path(".loop/cstat-current.json") +ORACLE_CMD = os.environ.get("GRIND_ORACLE", "cargo test") +BENCH_CMD = os.environ.get("GRIND_BENCH", "") +CSTAT_CMD = os.environ.get("GRIND_CSTAT", "cstat --path . --json scorecard") + + +def emit(text, stream=sys.stdout): + if text: + print(text, end="" if text.endswith("\n") else "\n", file=stream) + + +def fail(message): + print("GRIND: FAIL") + print(message) + sys.exit(1) + + +def load_json_file(path, label): + if not path.exists(): + fail(f"{label} missing: {path}") + text = path.read_text() + if not text.strip(): + fail(f"{label} is empty: {path}") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + fail(f"{label} is invalid JSON: {exc}") + + +def numeric_cost(data, label): + value = data.get("code_complexity_cost") + if isinstance(value, bool) or not isinstance(value, (int, float)): + fail(f"{label} missing numeric code_complexity_cost") + return float(value) + + +def run_shell(label, command): + if not command.strip(): + return + print(f"$ {command}") + proc = subprocess.run(command, shell=True, text=True, capture_output=True) + emit(proc.stdout) + emit(proc.stderr, sys.stderr) + if proc.returncode != 0: + fail(f"{label} failed: exit {proc.returncode}") + + +def run_cstat(): + print(f"$ {CSTAT_CMD}") + proc = subprocess.run(CSTAT_CMD, shell=True, text=True, capture_output=True) + CURRENT_PATH.write_text(proc.stdout) + emit(proc.stdout) + emit(proc.stderr, sys.stderr) + if proc.returncode != 0: + fail(f"cstat scorecard failed: exit {proc.returncode}") + try: + return json.loads(proc.stdout) + except json.JSONDecodeError as exc: + fail(f"cstat scorecard output is invalid JSON: {exc}") + + +def print_top_contributors(data): + top = data.get("top_contributors") + if not isinstance(top, list) or not top: + return + print("top current contributors:") + for idx, item in enumerate(top[:10], 1): + if not isinstance(item, dict): + print(f"{idx}. {item}") + continue + kind = item.get("kind", "?") + file = item.get("file", "?") + cost = item.get("cost", "?") + function = item.get("function") + if function: + print(f"{idx}. {kind} {file} function={function} cost={cost}") + else: + print(f"{idx}. {kind} {file} cost={cost}") + + +def main(): + baseline = load_json_file(BASELINE_PATH, "cstat baseline") + baseline_cost = numeric_cost(baseline, "cstat baseline") + + run_shell("behavior oracle", ORACLE_CMD) + run_shell("benchmark gate", BENCH_CMD) + + current = run_cstat() + current_cost = numeric_cost(current, "current cstat scorecard") + + if current_cost < baseline_cost: + print( + f"GRIND: PASS cstat scorecard improved: " + f"baseline={baseline_cost:g} current={current_cost:g}" + ) + return + + print("GRIND: FAIL") + print( + f"cstat scorecard did not improve: " + f"baseline={baseline_cost:g} current={current_cost:g}" + ) + print_top_contributors(current) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/templates/grind/protocol.md b/src/templates/grind/protocol.md new file mode 100644 index 0000000..197d497 --- /dev/null +++ b/src/templates/grind/protocol.md @@ -0,0 +1,94 @@ +# Protocol: Grind + +You are operating inside an automated grind loop — not a conversation. A harness launched you, and will run the grind gate after you exit. + +## Files + +| File | You can | Purpose | +|------|---------|---------| +| `.loop/protocol.md` | read | This document. Your instructions. | +| `.loop/notes.md` | read + write | Your scratchpad across iterations. | +| `.loop/guard-results.md` | read | The previous grind gate output. | +| `.loop/cstat-baseline.json` | read | Baseline `cstat --path . --json scorecard` captured before edits. | +| `.loop/cstat-current.json` | read | Last gate's current scorecard JSON, when present. | +| `.loop/grind-gate.py` | read | The guard harness. It runs the behavior oracle, optional benchmark, and cstat comparison. | +| `.loop/yoke.conf` | read | Scope rules and guard settings. | + +All paths are relative to the repository root. + +## Objective + +Drive `code_complexity_cost` down aggressively while preserving observable behavior. + +The gate passes only when the behavior oracle succeeds and current `cstat --path . --json scorecard` reports a lower `code_complexity_cost` than `.loop/cstat-baseline.json`. + +## Using cstat + +Primary objective command: + +```text +cstat --path . scorecard --top 20 +``` + +Machine-readable gate command: + +```text +cstat --path . --json scorecard +``` + +Global options from `cstat help`: + +- `--path ` — Rust project directory or Rust source file to analyze; default is `.`. +- `--json` — output structured JSON instead of human-readable text. +- `--verbose` — print educational explanations for each section. +- `--no-color` — disable colored output. + +Commands from `cstat help`: + +- `scorecard` — deterministic structural code complexity scorecard. This is the objective. Lower `code_complexity_cost` is cleaner. Use `--top ` to show the highest contributors. +- `branching` — function decision/path complexity rankings. Use it to simplify high-branching functions and collapse duplicated control flow. +- `signature` — function API boundary complexity rankings. Use it to reduce public surface, parameter sprawl, generic noise, and unnecessary result shapes. +- `span` — function implementation span rankings. Use it to find long functions that should be deleted, flattened, or split only when splitting reduces real complexity. +- `deps` — module dependency connectome. Use it to remove coupling, merge needless modules, or move code when it reduces dependency pressure. +- `dead-code` — static dead-code candidates. Use it to delete unused items, stale exports, and obsolete branches. +- `test-reachability` — static test/benchmark reachability. Use it to understand what behavior is protected before cutting. +- `call-trace` — tree view of calls from the Cargo binary entrypoint or `--entry `. Use it to understand runtime paths before deleting or moving code. +- `coverage` — dynamic line and branch coverage from Rust source-based coverage data. Use `--no-run` with `CSTAT_LLVM_COV_EXPORT_JSON` when parsing existing coverage data. +- `cluster` — value-cluster transcript from AST def-use structure. Use it to find code that belongs together or abstractions that should collapse. +- `loc` — lines-of-code size-shape analysis. Use it to find file concentration and large generated-looking surfaces. +- `symbols` — Rust AST symbol counts by kind, including trait impl blocks. Use it to find abstraction surface and symbol sprawl. + +## Per-Iteration Steps + +1. Read `.loop/notes.md`, `.loop/guard-results.md`, `.loop/cstat-baseline.json`, and `.loop/cstat-current.json` if it exists. If the previous gate failed on behavior, benchmark, boundary, or invalid cstat output, repair that first. +2. Run `cstat --path . scorecard --top 20`. Compare current cost to the baseline and identify the biggest contributors. +3. Use the relevant cstat diagnostics above. Do not sample one random command; pick the commands that explain the top contributors and the scorecard dimensions they affect. +4. Push a coherent cleanup batch. Do not stop after one simplification. Delete dead code, collapse unnecessary abstractions, simplify branching, reduce signatures, shrink spans, and cut coupling while observable behavior remains the same. +5. Treat guards as the safety net. Do not weaken behavior checks, but do not be timid because checks exist. If a cleanup is plausible, behavior-preserving, and aimed at measured complexity, do it. +6. Re-run `cstat --path . scorecard --top 20` when practical. Run targeted behavior checks when they are cheap and directly cover risky edits; the grind gate will run the configured oracle after you exit. +7. Update `.loop/notes.md`. The first line must be `STATUS: IN_PROGRESS` or `STATUS: DONE`. Include starting cost, ending cost if measured, files changed, complexity contributors attacked, guard failures repaired, and concrete next targets. +8. Exit. Do not loop manually; Yoke handles the next iteration. + +## STATUS Signaling + +Use `STATUS: IN_PROGRESS` when more cleanup or repair is needed. +Use `STATUS: DONE` only when current `code_complexity_cost` is lower than the baseline and you expect the behavior oracle plus optional benchmark to pass. + +## What the Grind Gate Checks + +The guard command runs `python3 .loop/grind-gate.py`. It fails unless: + +1. the behavior oracle command succeeds; +2. the optional benchmark command succeeds, when configured; +3. current `cstat --path . --json scorecard` has lower `code_complexity_cost` than the baseline. + +If the gate fails, read `.loop/guard-results.md` on the next iteration and repair exactly that failure before pushing more complexity reduction. + +## Rules + +- No git operations. Do not commit, push, branch, reset, stash, or modify git config. +- Do not modify `.loop/protocol.md`, `.loop/yoke.conf`, `.loop/grind-gate.py`, or `.loop/cstat-baseline.json`. +- Do not weaken, delete, skip, or rewrite behavior checks to make the gate pass. +- Do not edit behavior-defining tests, benches, benchmarks, examples, snapshots, or fixtures unless the user explicitly made behavior change part of the task. If the generated `yoke.conf` has no-modify rules commented out, infer the freeze rule from this protocol. +- Prefer deletion, simplification, and surface-area reduction over new abstractions. +- Push aggressively within the iteration; the default failure mode to avoid is timid under-cleanup. diff --git a/src/templates/grind/yoke.conf b/src/templates/grind/yoke.conf new file mode 100644 index 0000000..01e3068 --- /dev/null +++ b/src/templates/grind/yoke.conf @@ -0,0 +1,27 @@ +# ╔══════════════════════════════════════════════════════════════════════╗ +# ║ Yoke configuration — grind profile ║ +# ╚══════════════════════════════════════════════════════════════════════╝ +# +# Grind reuses the normal Yoke loop. The guard is the harness: it runs the +# behavior oracle, runs cstat scorecard, and fails unless scorecard cost is +# lower than the baseline captured by `yoke init grind`. + +max-tail 200 + +# Scope defaults: allow the repository by default because Yoke cannot know +# each project's oracle layout. Uncomment and tune these after identifying +# behavior-defining files for the target repository. Most-specific prefix wins. +allow . +# no-modify tests/ +# no-modify benches/ +# no-modify benchmarks/ +# no-modify examples/ +# no-modify snapshots/ +# no-modify fixtures/ + +# The gate reads .loop/cstat-baseline.json and compares it to current cstat. +# Override commands for quick experiments by setting env vars before `yoke run`: +# GRIND_ORACLE="cargo check" # default: cargo test +# GRIND_BENCH="cargo bench --no-run" # default: empty / disabled +# GRIND_CSTAT="cstat --path . --json scorecard" +guard python3 .loop/grind-gate.py diff --git a/src/templates/loop/protocol.md b/src/templates/loop/protocol.md index ca687eb..e966e20 100644 --- a/src/templates/loop/protocol.md +++ b/src/templates/loop/protocol.md @@ -37,36 +37,11 @@ The first line of `.loop/notes.md` must be one of: The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass. -## KEEP: Carrying File Context Across Iterations +## Session Continuity -Your conversation history persists across iterations via `--resume`. To keep -Claude's prompt cache warm without ballooning, the outer loop trims your -session between rounds: it drops `Bash` output, thinking, and any file -`Read` results that aren't on your KEEP list. Everything else (text turns, -intermediate `Edit`/`Grep`/`Glob` results) is also dropped. - -After STATUS, on its own line in `.loop/notes.md`, list the file paths you -want to keep cached for the next iteration: - -``` -STATUS: IN_PROGRESS -KEEP: src/foo.rs src/bar.rs tests/baz.rs -``` - -Rules: - -- Space-separated repo-relative paths (or absolute — both work). -- List files you read **this iteration** and will still need next iteration. -- Don't list `.loop/notes.md`, `.loop/plan.md`, `.loop/protocol.md`, - `.loop/guard-results.md`, or `.loop/verdict.md` — those live on disk and - you re-read them fresh every iteration. Listing them is harmless but wastes - a slot. -- Keep the list tight. Every kept file is paid for (at cache-read rates, - ~10% of fresh) every round it stays kept. Drop a file once you're confident - you won't need it again. -- Omit the `KEEP:` line entirely (or `KEEP: *`) to keep nothing — your - conversation prefix shrinks to just the bootstrap. Use this after major - refactors or when you've moved to an unrelated area of the code. +Yoke resumes the same worker OMP session across iterations. `.loop/notes.md`, +`.loop/plan.md`, `.loop/protocol.md`, and `.loop/guard-results.md` also live on +disk, so re-read them every iteration instead of trusting stale context. ## What the Guards Check diff --git a/src/templates/loop/yoke.conf b/src/templates/loop/yoke.conf index 8b4d399..c8212ad 100644 --- a/src/templates/loop/yoke.conf +++ b/src/templates/loop/yoke.conf @@ -15,46 +15,32 @@ # Protected files are backed up at start and restored every iteration, # so the agent can never permanently corrupt its own instructions. -# ── Backend ──────────────────────────────────────────────────────────── -# Which LLM backend to use. Leave commented for Claude CLI (default). -# Setting `model` switches to the OpenCode backend, which supports -# OpenRouter, OpenAI, Anthropic API, and other providers. +# ── OMP model ────────────────────────────────────────────────────────── +# Yoke invokes `omp` by default. Leave commented to use OMP's configured +# default model, or set a model name exactly as you would pass to +# `omp --model`. # -# model openrouter/anthropic/claude-sonnet-4 -# model openai/gpt-4o -# model anthropic/claude-sonnet-4 - -# ── Claude model (optional) ──────────────────────────────────────────── -# Override the model the Claude CLI uses for each iteration. Leave -# commented to use Claude Code's default (Opus). Useful for trading some -# reasoning depth for faster, cheaper iterations. -# -# claude-model claude-sonnet-4-6 -# claude-model claude-haiku-4-5 +# model gpt-5.5 +# model openai/gpt-5.2 +# model gemini-2.5-pro # ── Thinking budget (optional) ───────────────────────────────────────── -# Cap extended-thinking tokens per turn for the Claude CLI backend. -# Useful when running smaller/faster models (sonnet, haiku) and you'd -# rather they spend the iteration acting than reasoning. Sets the -# MAX_THINKING_TOKENS env var on the agent invocation. +# Forwarded to `omp --thinking`. # -# thinking off # disable extended thinking entirely (0 tokens) -# thinking low # 2k tokens -# thinking medium # 10k tokens -# thinking high # 32k tokens -# -# Ignored by the OpenCode backend (warns at config load). +# thinking off +# thinking low +# thinking medium +# thinking high +# thinking xhigh # # thinking low -# ── Sandbox ──────────────────────────────────────────────────────────── -# Docker image to run the agent inside. Your working directory is -# bind-mounted into the container at /workspace. Required unless you -# pass --no-sandbox on the command line. +# ── Sandbox (optional) ───────────────────────────────────────────────── +# By default OMP runs on the host. Set `image` only if the image contains +# an `omp` executable; yoke bind-mounts the working directory at /workspace +# and uses `omp` as the container entrypoint. # -# Note: sandbox is not currently supported with the `model` directive. - -image claude-code-sandbox:latest +# image omp-sandbox:latest # ── Output ───────────────────────────────────────────────────────────── # max-tail: max lines of output kept *per guard* in guard-results.md. @@ -131,18 +117,9 @@ allow . # guard cargo test -# ── Session continuity (KEEP) ───────────────────────────────────────── -# The agent's Claude session is resumed across iterations to preserve the -# prompt cache. Between iterations, yoke trims the session JSONL down to -# the files the agent declares on a `KEEP:` line in .loop/notes.md, e.g.: -# -# STATUS: IN_PROGRESS -# KEEP: src/foo.rs tests/bar.rs -# -# Bash output, thinking, and other tool results are dropped. Only kept -# file Reads survive. .loop/notes.md, .loop/plan.md, .loop/protocol.md, -# .loop/guard-results.md are re-read fresh each iteration and don't need -# to be listed. Set YOKE_DISABLE_SESSION_TRIM=1 to skip trimming. +# ── Session continuity ───────────────────────────────────────────────── +# Yoke resumes the same worker OMP session across iterations. The .loop/ +# files live on disk and should be re-read every iteration. # ── Periodic agents ─────────────────────────────────────────────────── # Supplementary agents invoked at a fixed cadence (every N iterations). diff --git a/src/templates/saga/protocol.md b/src/templates/saga/protocol.md index b8119d0..4003374 100644 --- a/src/templates/saga/protocol.md +++ b/src/templates/saga/protocol.md @@ -47,7 +47,7 @@ The first line of `.loop/notes.md` must be one of: 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. +3. Then the judge (a fresh OMP session 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 diff --git a/src/templates/saga/yoke.conf b/src/templates/saga/yoke.conf index 144f7e9..bf00934 100644 --- a/src/templates/saga/yoke.conf +++ b/src/templates/saga/yoke.conf @@ -29,46 +29,32 @@ # Worker notes are appended to saga-log.md between chunks so the scoper # has full context of what has been accomplished so far. -# ── Backend ──────────────────────────────────────────────────────────── -# Which LLM backend to use. Leave commented for Claude CLI (default). -# Setting `model` switches to the OpenCode backend, which supports -# OpenRouter, OpenAI, Anthropic API, and other providers. +# ── OMP model ────────────────────────────────────────────────────────── +# Yoke invokes `omp` by default. Leave commented to use OMP's configured +# default model, or set a model name exactly as you would pass to +# `omp --model`. # -# model openrouter/anthropic/claude-sonnet-4 -# model openai/gpt-4o -# model anthropic/claude-sonnet-4 - -# ── Claude model (optional) ──────────────────────────────────────────── -# Override the model the Claude CLI uses for each iteration. Leave -# commented to use Claude Code's default (Opus). Useful for trading some -# reasoning depth for faster, cheaper iterations. -# -# claude-model claude-sonnet-4-6 -# claude-model claude-haiku-4-5 +# model gpt-5.5 +# model openai/gpt-5.2 +# model gemini-2.5-pro # ── Thinking budget (optional) ───────────────────────────────────────── -# Cap extended-thinking tokens per turn for the Claude CLI backend. -# Useful when running smaller/faster models (sonnet, haiku) and you'd -# rather they spend the iteration acting than reasoning. Sets the -# MAX_THINKING_TOKENS env var on the agent invocation. +# Forwarded to `omp --thinking`. # -# thinking off # disable extended thinking entirely (0 tokens) -# thinking low # 2k tokens -# thinking medium # 10k tokens -# thinking high # 32k tokens -# -# Ignored by the OpenCode backend (warns at config load). +# thinking off +# thinking low +# thinking medium +# thinking high +# thinking xhigh # # thinking low -# ── Sandbox ──────────────────────────────────────────────────────────── -# Docker image to run the agent inside. Your working directory is -# bind-mounted into the container at /workspace. Required unless you -# pass --no-sandbox on the command line. +# ── Sandbox (optional) ───────────────────────────────────────────────── +# By default OMP runs on the host. Set `image` only if the image contains +# an `omp` executable; yoke bind-mounts the working directory at /workspace +# and uses `omp` as the container entrypoint. # -# Note: sandbox is not currently supported with the `model` directive. - -image claude-code-sandbox:latest +# image omp-sandbox:latest # ── Output ───────────────────────────────────────────────────────────── # max-tail: max lines of output kept *per guard* in guard-results.md. diff --git a/tests/brute_verdict.rs b/tests/brute_verdict.rs index ee11e64..121933e 100644 --- a/tests/brute_verdict.rs +++ b/tests/brute_verdict.rs @@ -3,7 +3,7 @@ //! Verifies that after a judge writes VERDICT: FAIL, the verdict.md content //! survives into the next brute iteration so the agent can read the feedback. //! -//! Uses a mock `claude` bash script to simulate both agent and judge, +//! Uses a mock `omp` bash script to simulate both agent and judge, //! recording what the agent sees in verdict.md at each invocation. use std::fs; @@ -75,7 +75,7 @@ const CONF: &str = "\ allow . "; -/// Mock claude script that distinguishes agent vs judge by the -p prompt. +/// Mock OMP script that distinguishes agent vs judge by the -p prompt. /// /// Agent mode (prompt contains "protocol.md"): /// - Increments .loop/.agent-calls counter @@ -86,9 +86,16 @@ allow . /// - Increments .loop/.judge-calls counter /// - Call 1: writes VERDICT: FAIL + feedback to verdict.md /// - Call 2+: writes VERDICT: PASS to verdict.md -const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash +const MOCK_OMP: &str = r#"#!/usr/bin/env bash set -euo pipefail +{ + echo "CALL" + for arg in "$@"; do + echo "$arg" + done +} >> .loop/.omp-argv + # Extract the prompt from -p argument PROMPT="" while [[ $# -gt 0 ]]; do @@ -162,13 +169,21 @@ fn brute_verdict_preserved_across_iterations() { fs::write(loop_dir.join("verdict.md"), "").unwrap(); fs::write(loop_dir.join("guard-results.md"), "").unwrap(); - // Set up mock claude script on PATH + // Set up mock OMP script on PATH let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).expect("create mock-bin"); - let mock_claude_path = mock_bin_dir.join("claude"); - fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap(); - fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap(); + let mock_omp_path = mock_bin_dir.join("omp"); + fs::write(&mock_omp_path, MOCK_OMP).unwrap(); + fs::set_permissions(&mock_omp_path, fs::Permissions::from_mode(0o755)).unwrap(); + + let legacy_bin_path = mock_bin_dir.join(["cl", "aude"].concat()); + fs::write( + &legacy_bin_path, + "#!/usr/bin/env bash\necho 'legacy agent must not be invoked' >&2\nexit 99\n", + ) + .unwrap(); + fs::set_permissions(&legacy_bin_path, fs::Permissions::from_mode(0o755)).unwrap(); // Set up git repo (boundary checker needs `git diff HEAD` to work) let git = |args: &[&str]| { @@ -193,9 +208,17 @@ fn brute_verdict_preserved_across_iterations() { git(&["init"]); fs::write(project.join("dummy.txt"), "seed\n").unwrap(); git(&["add", "dummy.txt"]); - git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]); + git(&[ + "-c", + "user.name=test", + "-c", + "user.email=test@test", + "commit", + "-m", + "init", + ]); - // Build PATH: mock-bin first so our mock claude shadows the real one + // Build PATH: mock-bin first so our mock OMP shadows the real one let original_path = std::env::var("PATH").unwrap_or_default(); let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); @@ -210,6 +233,14 @@ fn brute_verdict_preserved_across_iterations() { let stderr = String::from_utf8_lossy(&output.stderr); // ── Assertions ── + let argv = fs::read_to_string(loop_dir.join(".omp-argv")).expect("mock omp argv"); + assert!( + argv.contains("--mode\njson") + && argv.contains("--auto-approve") + && argv.contains("-p\nRead .loop/protocol.md and follow its instructions."), + "default backend should invoke omp in JSON print mode with the worker prompt.\nargv:\n{}", + argv + ); // 1. witness-1 should be empty: no verdict exists before first agent run let witness_1 = fs::read_to_string(loop_dir.join(".witness-1")) diff --git a/tests/brute_verdict_sandbox.sh b/tests/brute_verdict_sandbox.sh index b40931a..fc5cd65 100755 --- a/tests/brute_verdict_sandbox.sh +++ b/tests/brute_verdict_sandbox.sh @@ -7,7 +7,7 @@ # # Prerequisites: # - docker daemon running -# - claude-code-sandbox:latest image available +# - omp-sandbox:latest image available # - cargo (to build yoke) # # Usage: @@ -33,8 +33,8 @@ if ! command -v docker &>/dev/null; then exit 0 fi -if ! docker image inspect claude-code-sandbox:latest &>/dev/null; then - echo "SKIP: claude-code-sandbox:latest image not found" +if ! docker image inspect omp-sandbox:latest &>/dev/null; then + echo "SKIP: omp-sandbox:latest image not found" exit 0 fi @@ -60,9 +60,9 @@ TMPDIR_ROOT="$(mktemp -d)" PROJECT="$TMPDIR_ROOT/project" mkdir -p "$PROJECT" -# ── Write mock-claude script ── +# ── Write mock-omp script ── -cat > "$TMPDIR_ROOT/mock-claude" <<'MOCK' +cat > "$TMPDIR_ROOT/mock-omp" <<'MOCK' #!/usr/bin/env bash set -euo pipefail @@ -110,14 +110,14 @@ fi exit 0 MOCK -chmod +x "$TMPDIR_ROOT/mock-claude" +chmod +x "$TMPDIR_ROOT/mock-omp" # ── Build test Docker image ── echo "Building test image $TEST_IMAGE..." docker build -t "$TEST_IMAGE" -f- "$TMPDIR_ROOT" <<'DOCKERFILE' -FROM claude-code-sandbox:latest -COPY --chmod=755 mock-claude /usr/local/bin/claude +FROM omp-sandbox:latest +COPY --chmod=755 mock-omp /usr/local/bin/omp DOCKERFILE # ── Set up project directory ── diff --git a/tests/grind.rs b/tests/grind.rs new file mode 100644 index 0000000..d7f6ada --- /dev/null +++ b/tests/grind.rs @@ -0,0 +1,304 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn yoke_bin() -> PathBuf { + let mut path = std::env::current_exe() + .expect("current_exe") + .parent() + .expect("parent of test binary") + .parent() + .expect("parent of deps dir") + .to_path_buf(); + path.push("yoke"); + path +} + +fn build_yoke() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); +} + +fn git(project: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(project) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test") + .output() + .unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e)); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); +} + +fn seed_project(project: &Path) { + fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"grind-subject\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\npath = \"src/lib.rs\"\n", + ) + .unwrap(); + fs::create_dir(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn hot() -> u32 { 1 }\n").unwrap(); + + git(project, &["init"]); + git(project, &["add", "Cargo.toml", "src/lib.rs"]); + git( + project, + &[ + "-c", + "user.name=test", + "-c", + "user.email=test@test", + "commit", + "-m", + "init", + ], + ); +} + +fn write_executable(path: &Path, content: &str) { + fs::write(path, content).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +fn mock_path(mock_bin_dir: &Path) -> String { + let original_path = std::env::var("PATH").unwrap_or_default(); + format!("{}:{}", mock_bin_dir.display(), original_path) +} + +fn constant_cstat(cost: f64) -> String { + let score = format!("{cost:.1}"); + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf '{{\"cstat_version\":\"fake\",\"score_version\":\"code_complexity_cost_v0\",\"target\":\".\",\"code_complexity_cost\":{score},\"top_contributors\":[{{\"kind\":\"function\",\"file\":\"src/lib.rs\",\"function\":\"hot\",\"cost\":{score}}}]}}\\n'\n" + ) +} + +fn run_yoke_init_grind(yoke: &Path, project: &Path, test_path: &str) -> std::process::Output { + Command::new(yoke) + .args(["init", "grind"]) + .current_dir(project) + .env("PATH", test_path) + .output() + .expect("yoke init grind") +} + +#[test] +fn init_grind_creates_profile_and_captures_baseline() { + build_yoke(); + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + seed_project(project); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + write_executable(&mock_bin_dir.join("cstat"), &constant_cstat(5.0)); + let test_path = mock_path(&mock_bin_dir); + + let output = run_yoke_init_grind(&yoke, project, &test_path); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "yoke init grind should exit 0. exit={:?}\nstderr:\n{}", + output.status.code(), + stderr + ); + + let loop_dir = project.join(".loop"); + for name in [ + "grind-gate.py", + "cstat-baseline.json", + "protocol.md", + "yoke.conf", + "notes.md", + "guard-results.md", + ] { + assert!(loop_dir.join(name).exists(), "missing .loop/{name}"); + } + for name in ["grind.md", "plan.md"] { + assert!(!loop_dir.join(name).exists(), "unexpected .loop/{name}"); + } + + let baseline = fs::read_to_string(loop_dir.join("cstat-baseline.json")).unwrap(); + let compact: String = baseline.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains("\"code_complexity_cost\":5.0"), + "baseline should contain captured scorecard cost. baseline:\n{}", + baseline + ); + + let conf = fs::read_to_string(loop_dir.join("yoke.conf")).unwrap(); + assert!( + conf.contains("guard python3 .loop/grind-gate.py"), + "grind guard must be configured. yoke.conf:\n{}", + conf + ); + assert!( + conf.contains("# no-modify tests/"), + "tests no-modify example should be commented. yoke.conf:\n{}", + conf + ); + assert!( + !conf + .lines() + .any(|line| line.trim_start().starts_with("no-modify tests/")), + "tests no-modify rule must not be active by default. yoke.conf:\n{}", + conf + ); + + let protocol = fs::read_to_string(loop_dir.join("protocol.md")).unwrap(); + assert!( + !protocol.contains(".loop/plan.md"), + "grind protocol must not reference plan.md. protocol:\n{}", + protocol + ); + for command in [ + "loc", + "symbols", + "branching", + "signature", + "span", + "scorecard", + "deps", + "dead-code", + "test-reachability", + "call-trace", + "coverage", + "cluster", + ] { + assert!( + protocol.contains(&format!("`{command}`")), + "grind protocol should document cstat {command}. protocol:\n{}", + protocol + ); + } + assert!( + protocol.contains("Do not stop after one simplification"), + "grind protocol should push agents past timid one-edit cleanup. protocol:\n{}", + protocol + ); +} + +#[test] +fn grind_dry_run_rejects_without_cstat_improvement() { + build_yoke(); + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + seed_project(project); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + write_executable(&mock_bin_dir.join("cstat"), &constant_cstat(5.0)); + let test_path = mock_path(&mock_bin_dir); + + let init = run_yoke_init_grind(&yoke, project, &test_path); + assert!( + init.status.success(), + "init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + let output = Command::new(&yoke) + .args(["run", "--dry-run"]) + .current_dir(project) + .env("PATH", &test_path) + .env("GRIND_ORACLE", "true") + .output() + .expect("yoke run --dry-run"); + assert!( + !output.status.success(), + "dry run should reject unchanged cstat score" + ); + + let guard_results = fs::read_to_string(project.join(".loop/guard-results.md")).unwrap(); + assert!( + guard_results.contains("GRIND: FAIL"), + "guard results should show grind failure. guard-results:\n{}", + guard_results + ); + assert!( + guard_results.contains("cstat scorecard did not improve"), + "guard results should explain unchanged score. guard-results:\n{}", + guard_results + ); +} + +#[test] +fn grind_restores_gate_and_baseline_before_guard() { + build_yoke(); + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + seed_project(project); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + write_executable( + &mock_bin_dir.join("cstat"), + r#"#!/usr/bin/env bash +set -euo pipefail +count_file=".cstat-count" +count=0 +if [ -f "$count_file" ]; then + count=$(cat "$count_file") +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$count_file" +if [ "$count" -eq 1 ]; then + cost=10.0 +else + cost=8.0 +fi +printf '{"cstat_version":"fake","score_version":"code_complexity_cost_v0","target":".","code_complexity_cost":%s,"top_contributors":[{"kind":"function","file":"src/lib.rs","function":"hot","cost":%s}]}\n' "$cost" "$cost" +"#, + ); + write_executable( + &mock_bin_dir.join("omp"), + r#"#!/usr/bin/env bash +set -euo pipefail +printf 'STATUS: DONE\n' > .loop/notes.md +cat > .loop/grind-gate.py <<'PY' +#!/usr/bin/env python3 +import sys +sys.exit(99) +PY +printf '{"code_complexity_cost":1.0}\n' > .loop/cstat-baseline.json +exit 0 +"#, + ); + let test_path = mock_path(&mock_bin_dir); + + let init = run_yoke_init_grind(&yoke, project, &test_path); + assert!( + init.status.success(), + "init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + let output = Command::new(&yoke) + .args(["run"]) + .current_dir(project) + .env("PATH", &test_path) + .env("GRIND_ORACLE", "true") + .output() + .expect("yoke run"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "yoke run should pass after restoring protected grind files. exit={:?}\nstderr:\n{}", + output.status.code(), + stderr + ); +} diff --git a/tests/judge_adversarial.rs b/tests/judge_adversarial.rs index 81e51b4..8232eec 100644 --- a/tests/judge_adversarial.rs +++ b/tests/judge_adversarial.rs @@ -40,12 +40,25 @@ fn git_init(project: &std::path::Path) { .env("GIT_COMMITTER_EMAIL", "test@test") .output() .unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e)); - assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); }; git(&["init"]); fs::write(project.join("dummy.txt"), "seed\n").unwrap(); git(&["add", "dummy.txt"]); - git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]); + git(&[ + "-c", + "user.name=test", + "-c", + "user.email=test@test", + "commit", + "-m", + "init", + ]); } // ── Test 1: stash + clean round-trip after extraction ────────────────── @@ -73,12 +86,24 @@ fn stash_roundtrip_after_extraction() { .current_dir(project) .output() .expect("yoke init brute"); - assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "yoke init brute failed: {}", + String::from_utf8_lossy(&out.stderr) + ); // Write distinctive content into plan.md and notes.md let loop_dir = project.join(".loop"); - fs::write(loop_dir.join("plan.md"), "## Stage 1 — Build the widget\n\nDo the thing.\n").unwrap(); - fs::write(loop_dir.join("notes.md"), "STATUS: IN_PROGRESS\n\nSome important notes here.\n").unwrap(); + fs::write( + loop_dir.join("plan.md"), + "## Stage 1 — Build the widget\n\nDo the thing.\n", + ) + .unwrap(); + fs::write( + loop_dir.join("notes.md"), + "STATUS: IN_PROGRESS\n\nSome important notes here.\n", + ) + .unwrap(); // Stash the current state let out = Command::new(&yoke) @@ -86,7 +111,11 @@ fn stash_roundtrip_after_extraction() { .current_dir(project) .output() .expect("yoke stash"); - assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "yoke stash failed: {}", + String::from_utf8_lossy(&out.stderr) + ); // Verify stash log shows an entry let out = Command::new(&yoke) @@ -95,12 +124,25 @@ fn stash_roundtrip_after_extraction() { .output() .expect("yoke stash log"); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("mode=brute"), "stash log should show mode=brute, got:\n{}", stderr); + assert!( + stderr.contains("mode=brute"), + "stash log should show mode=brute, got:\n{}", + stderr + ); // Verify stash cleared .loop/ working files - assert!(!loop_dir.join("plan.md").exists(), "plan.md should be gone after stash"); - assert!(!loop_dir.join("notes.md").exists(), "notes.md should be gone after stash"); - assert!(loop_dir.join(".stash").exists(), ".stash/ should survive stash clear"); + assert!( + !loop_dir.join("plan.md").exists(), + "plan.md should be gone after stash" + ); + assert!( + !loop_dir.join("notes.md").exists(), + "notes.md should be gone after stash" + ); + assert!( + loop_dir.join(".stash").exists(), + ".stash/ should survive stash clear" + ); // Pop — should restore the stashed state with our distinctive content let out = Command::new(&yoke) @@ -108,11 +150,19 @@ fn stash_roundtrip_after_extraction() { .current_dir(project) .output() .expect("yoke stash pop"); - assert!(out.status.success(), "yoke stash pop failed: {}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "yoke stash pop failed: {}", + String::from_utf8_lossy(&out.stderr) + ); // Verify round-trip: files restored with original content let plan = fs::read_to_string(loop_dir.join("plan.md")).unwrap(); - assert!(plan.contains("Build the widget"), "plan.md should be restored after pop, got: {:?}", plan); + assert!( + plan.contains("Build the widget"), + "plan.md should be restored after pop, got: {:?}", + plan + ); } // ── Test 2: plan loop exits on STATUS: DONE with generalized is_status_done ── @@ -153,18 +203,18 @@ Read plan.md, implement it, then set STATUS: DONE in notes.md. fs::write(loop_dir.join("notes.md"), "").unwrap(); fs::write(loop_dir.join("guard-results.md"), "").unwrap(); - // Mock claude: immediately writes STATUS: DONE and exits + // Mock OMP: immediately writes STATUS: DONE and exits let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).unwrap(); - let mock_claude = r#"#!/usr/bin/env bash + let mock_omp = r#"#!/usr/bin/env bash set -euo pipefail # Always signal done immediately printf 'STATUS: DONE\n' > .loop/notes.md exit 0 "#; - let mock_path = mock_bin_dir.join("claude"); - fs::write(&mock_path, mock_claude).unwrap(); + let mock_path = mock_bin_dir.join("omp"); + fs::write(&mock_path, mock_omp).unwrap(); fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); let original_path = std::env::var("PATH").unwrap_or_default(); @@ -237,8 +287,8 @@ Read plan, implement, set STATUS: DONE in notes.md. let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).unwrap(); - // Mock claude: agent writes STATUS: DONE, judge FAILs once then PASSes - let mock_claude = r#"#!/usr/bin/env bash + // Mock OMP: agent writes STATUS: DONE, judge FAILs once then PASSes + let mock_omp = r#"#!/usr/bin/env bash set -euo pipefail PROMPT="" while [[ $# -gt 0 ]]; do @@ -264,8 +314,8 @@ elif echo "$PROMPT" | grep -q "judge.md"; then fi exit 0 "#; - let mock_path = mock_bin_dir.join("claude"); - fs::write(&mock_path, mock_claude).unwrap(); + let mock_path = mock_bin_dir.join("omp"); + fs::write(&mock_path, mock_omp).unwrap(); fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); let original_path = std::env::var("PATH").unwrap_or_default(); @@ -291,7 +341,8 @@ exit 0 // Judge should have been called exactly 2 times let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap(); assert_eq!( - judge_count.trim(), "2", + judge_count.trim(), + "2", "judge should be called exactly twice (FAIL then PASS), got: {:?}", judge_count.trim(), ); @@ -344,13 +395,13 @@ fn saga_exits_on_saga_notes_done() { fs::write(loop_dir.join("verdict.md"), "").unwrap(); fs::write(loop_dir.join("guard-results.md"), "").unwrap(); - // Mock claude: scoper writes STATUS: DONE to saga-notes.md immediately. + // Mock OMP: scoper writes STATUS: DONE to saga-notes.md immediately. // Critically: notes.md is left empty — if yoke checks notes.md instead of // saga-notes.md, it would NOT see DONE and would spin forever. let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).unwrap(); - let mock_claude = r#"#!/usr/bin/env bash + let mock_omp = r#"#!/usr/bin/env bash set -euo pipefail PROMPT="" while [[ $# -gt 0 ]]; do @@ -366,8 +417,8 @@ if echo "$PROMPT" | grep -q "saga-protocol.md"; then fi exit 0 "#; - let mock_path = mock_bin_dir.join("claude"); - fs::write(&mock_path, mock_claude).unwrap(); + let mock_path = mock_bin_dir.join("omp"); + fs::write(&mock_path, mock_omp).unwrap(); fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); let original_path = std::env::var("PATH").unwrap_or_default(); @@ -447,7 +498,7 @@ guard echo SENTINEL_GUARD_OUTPUT && exit 1 fs::write(loop_dir.join("notes.md"), "").unwrap(); fs::write(loop_dir.join("guard-results.md"), "").unwrap(); - // Dry-run: no Claude invocation, but guards still execute + // Dry-run: no OMP invocation, but guards still execute let output = Command::new(&yoke) .args(["run", "--no-sandbox", "--dry-run"]) .current_dir(project) @@ -525,9 +576,9 @@ max-judge-failures 2 let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).unwrap(); - // Mock claude: agent always writes STATUS: DONE, judge always FAILs. + // Mock OMP: agent always writes STATUS: DONE, judge always FAILs. // Tracks call counts so we can assert the exact number of iterations. - let mock_claude = r#"#!/usr/bin/env bash + let mock_omp = r#"#!/usr/bin/env bash set -euo pipefail PROMPT="" while [[ $# -gt 0 ]]; do @@ -555,8 +606,8 @@ elif echo "$PROMPT" | grep -q "judge.md"; then fi exit 0 "#; - let mock_path = mock_bin_dir.join("claude"); - fs::write(&mock_path, mock_claude).unwrap(); + let mock_path = mock_bin_dir.join("omp"); + fs::write(&mock_path, mock_omp).unwrap(); fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); let original_path = std::env::var("PATH").unwrap_or_default(); @@ -589,7 +640,8 @@ exit 0 // Judge should have been called exactly 2 times (matching max-judge-failures) let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap(); assert_eq!( - judge_count.trim(), "2", + judge_count.trim(), + "2", "judge should be called exactly 2 times (max-judge-failures=2), got: {:?}\nStderr:\n{}", judge_count.trim(), stderr, @@ -648,8 +700,8 @@ judge-every 5 let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).unwrap(); - // Mock claude: agent immediately signals DONE, judge immediately returns PASS - let mock_claude = r#"#!/usr/bin/env bash + // Mock OMP: agent immediately signals DONE, judge immediately returns PASS + let mock_omp = r#"#!/usr/bin/env bash set -euo pipefail PROMPT="" while [[ $# -gt 0 ]]; do @@ -666,8 +718,8 @@ elif echo "$PROMPT" | grep -q "judge.md"; then fi exit 0 "#; - let mock_path = mock_bin_dir.join("claude"); - fs::write(&mock_path, mock_claude).unwrap(); + let mock_path = mock_bin_dir.join("omp"); + fs::write(&mock_path, mock_omp).unwrap(); fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap(); let original_path = std::env::var("PATH").unwrap_or_default(); @@ -737,7 +789,11 @@ fn stash_records_correct_mode_after_extraction() { .current_dir(project) .output() .expect("yoke init brute"); - assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "yoke init brute failed: {}", + String::from_utf8_lossy(&out.stderr) + ); // Write some content so stash has something to snapshot fs::write(project.join(".loop/plan.md"), "## Stage 1\nDo it.\n").unwrap(); @@ -748,15 +804,25 @@ fn stash_records_correct_mode_after_extraction() { .current_dir(project) .output() .expect("yoke stash"); - assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "yoke stash failed: {}", + String::from_utf8_lossy(&out.stderr) + ); // Read the stash index directly and verify mode=brute let index_path = project.join(".loop/.stash/index"); - assert!(index_path.exists(), "stash index should exist after stashing"); + assert!( + index_path.exists(), + "stash index should exist after stashing" + ); let index = fs::read_to_string(&index_path).unwrap(); // Index format: hash|timestamp|mode|file1,file2,... - let first_line = index.lines().next().expect("index should have at least one line"); + let first_line = index + .lines() + .next() + .expect("index should have at least one line"); let parts: Vec<&str> = first_line.splitn(4, '|').collect(); assert!( parts.len() >= 3, diff --git a/tests/metrics_persistence.rs b/tests/metrics_persistence.rs index 8ff0121..cc0ae76 100644 --- a/tests/metrics_persistence.rs +++ b/tests/metrics_persistence.rs @@ -2,7 +2,7 @@ //! metrics rows under the user's home directory (`~/.yoke/metrics/`) and the //! rows survive `yoke clean`. //! -//! Uses a mock `claude` script so no real agent invocation happens. The +//! Uses a mock `omp` script so no real agent invocation happens. The //! subprocess gets HOME pointed at the test tempdir so writes don't escape //! the test sandbox. @@ -38,10 +38,17 @@ const CONF: &str = "\ allow . "; -const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash +const MOCK_OMP: &str = r#"#!/usr/bin/env bash set -euo pipefail # Always: write STATUS: DONE so the loop exits after one iteration. printf 'STATUS: DONE\n\n## Stage 1\nDone.\n' > .loop/notes.md +cat <<'JSON' +{"type":"session","version":3,"id":"sess-metrics","timestamp":"2026-01-01T00:00:00Z","cwd":"/tmp/project"} +{"type":"turn_start"} +{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"done"}} +{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"model":"gpt-test","usage":{"input":10,"output":2,"cacheRead":0,"cacheWrite":0,"totalTokens":12,"cost":{"input":0.1,"output":0.15,"cacheRead":0,"cacheWrite":0,"total":0.25}},"duration":1500}} +{"type":"agent_end","messages":[]} +JSON exit 0 "#; @@ -72,12 +79,12 @@ fn metrics_rows_persist_under_home_and_survive_clean() { fs::write(loop_dir.join("notes.md"), "").unwrap(); fs::write(loop_dir.join("guard-results.md"), "").unwrap(); - // Mock claude on PATH + // Mock OMP on PATH let mock_bin_dir = project.join("mock-bin"); fs::create_dir(&mock_bin_dir).expect("create mock-bin"); - let mock_claude_path = mock_bin_dir.join("claude"); - fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap(); - fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap(); + let mock_omp_path = mock_bin_dir.join("omp"); + fs::write(&mock_omp_path, MOCK_OMP).unwrap(); + fs::set_permissions(&mock_omp_path, fs::Permissions::from_mode(0o755)).unwrap(); // Boundary check needs a git repo let git = |args: &[&str]| { @@ -101,7 +108,15 @@ fn metrics_rows_persist_under_home_and_survive_clean() { git(&["init"]); fs::write(project.join("seed.txt"), "x\n").unwrap(); git(&["add", "seed.txt"]); - git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"]); + git(&[ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "commit", + "-m", + "init", + ]); let original_path = std::env::var("PATH").unwrap_or_default(); let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); @@ -184,6 +199,16 @@ fn metrics_rows_persist_under_home_and_survive_clean() { "iter row should record restore_ms.\ncontent: {}", iter_content ); + assert!( + iter_content.contains("\"cost_usd\":0.25"), + "iter row should record OMP stream cost.\ncontent: {}", + iter_content + ); + assert!( + iter_content.contains("\"num_turns\":1"), + "iter row should record OMP turn count.\ncontent: {}", + iter_content + ); // Survive `yoke clean` let clean_output = Command::new(&yoke) diff --git a/tests/omp_invocation.rs b/tests/omp_invocation.rs new file mode 100644 index 0000000..463fadb --- /dev/null +++ b/tests/omp_invocation.rs @@ -0,0 +1,148 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +fn yoke_bin() -> std::path::PathBuf { + let mut path = std::env::current_exe() + .expect("current_exe") + .parent() + .expect("parent of test binary") + .parent() + .expect("parent of deps dir") + .to_path_buf(); + path.push("yoke"); + path +} + +fn git_init(project: &std::path::Path) { + let git = |args: &[&str]| { + let out = Command::new("git") + .args(args) + .current_dir(project) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test") + .output() + .unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e)); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + }; + git(&["init"]); + fs::write(project.join("seed.txt"), "seed\n").unwrap(); + git(&["add", "seed.txt"]); + git(&[ + "-c", + "user.name=test", + "-c", + "user.email=test@test", + "commit", + "-m", + "init", + ]); +} + +const MOCK_OMP: &str = r#"#!/usr/bin/env bash +set -euo pipefail +{ + echo "CALL" + for arg in "$@"; do + echo "$arg" + done +} >> .loop/.omp-argv +printf 'STATUS: DONE\n' > .loop/notes.md +exit 0 +"#; + +#[test] +fn default_backend_invokes_omp_with_model_and_thinking() { + let status = Command::new("cargo") + .args(["build", "--quiet"]) + .status() + .expect("cargo build"); + assert!(status.success(), "cargo build failed"); + + let yoke = yoke_bin(); + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path(); + git_init(project); + + let loop_dir = project.join(".loop"); + fs::create_dir(&loop_dir).unwrap(); + fs::write( + loop_dir.join("protocol.md"), + "# Protocol\nSet STATUS done.\n", + ) + .unwrap(); + fs::write(loop_dir.join("plan.md"), "## Stage 1\nDone.\n").unwrap(); + fs::write( + loop_dir.join("yoke.conf"), + "model gpt-5.5\nthinking xhigh\nallow .\n", + ) + .unwrap(); + fs::write(loop_dir.join("notes.md"), "").unwrap(); + fs::write(loop_dir.join("guard-results.md"), "").unwrap(); + + let mock_bin_dir = project.join("mock-bin"); + fs::create_dir(&mock_bin_dir).unwrap(); + let mock_omp = mock_bin_dir.join("omp"); + fs::write(&mock_omp, MOCK_OMP).unwrap(); + fs::set_permissions(&mock_omp, fs::Permissions::from_mode(0o755)).unwrap(); + + let legacy_bin = mock_bin_dir.join(["cl", "aude"].concat()); + fs::write( + &legacy_bin, + "#!/usr/bin/env bash\necho 'legacy agent must not be invoked' >&2\nexit 99\n", + ) + .unwrap(); + fs::set_permissions(&legacy_bin, fs::Permissions::from_mode(0o755)).unwrap(); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", mock_bin_dir.display(), original_path); + let output = Command::new(&yoke) + .args(["run"]) + .current_dir(project) + .env("PATH", &test_path) + .output() + .expect("yoke run"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "yoke should exit 0 using mock omp. exit={:?}\nstderr:\n{}", + output.status.code(), + stderr + ); + + let argv = fs::read_to_string(loop_dir.join(".omp-argv")).expect("mock omp argv"); + assert!( + argv.contains("--mode\njson"), + "OMP must run in JSON mode.\nargv:\n{}", + argv + ); + assert!( + argv.contains("--auto-approve"), + "OMP must auto-approve tools.\nargv:\n{}", + argv + ); + assert!( + argv.contains("--model\ngpt-5.5"), + "model directive must pass through to OMP.\nargv:\n{}", + argv + ); + assert!( + argv.contains("--thinking\nxhigh"), + "thinking directive must pass through to OMP.\nargv:\n{}", + argv + ); + assert!( + argv.contains("-p\nRead .loop/protocol.md and follow its instructions."), + "worker prompt must be passed after -p for OMP print mode.\nargv:\n{}", + argv + ); +}