From ab135d9442e522870900b2d3683d196f8ec062e5 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Sun, 8 Mar 2026 22:51:26 +0700 Subject: [PATCH] stash --- behavioral-specification.md | 189 +++++++ src/config.rs | 300 +++++------ src/main.rs | 985 ++++++++++++++++++------------------ src/stash.rs | 133 +++-- src/stream.rs | 255 +++++----- src/stream_opencode.rs | 50 +- 6 files changed, 992 insertions(+), 920 deletions(-) create mode 100644 behavioral-specification.md diff --git a/behavioral-specification.md b/behavioral-specification.md new file mode 100644 index 0000000..28e5104 --- /dev/null +++ b/behavioral-specification.md @@ -0,0 +1,189 @@ +# Yoke Behavioral Specification + +This document defines what yoke promises to its users. Every statement here is +a testable invariant over observables — files, exit codes, process behavior. +No statement references internal functions, line numbers, or implementation +details. These invariants survive refactors and rewrites. + +--- + +## 1. The Loop + +**Story:** You write a plan, a protocol, and some guards. You run `yoke run`. +An agent executes your plan iteratively. Each iteration, it reads the protocol, +does work, and updates notes. Guards check the work. When the agent writes +`STATUS: DONE` and all guards pass, yoke exits. + +### Invariants + +**1.1 — The spec is immutable from the agent's perspective.** +`protocol.md`, `plan.md`, and `yoke.conf` are backed up before the loop and +restored before every iteration. The agent can overwrite them during its turn, +but those changes do not persist to the next iteration. + +**1.2 — Termination requires both signals.** +The loop only exits when `STATUS: DONE` appears in `notes.md` AND all guards +pass. Neither condition alone is sufficient. If guards fail but status is DONE, +the loop continues with feedback. If guards pass but status is not DONE, the +loop continues. + +**1.3 — Guard feedback is visible.** +`guard-results.md` is written after every iteration. The agent sees it on its +next turn. No guard result is silently swallowed. + +**1.4 — Boundary violations block guards.** +If the diff boundary check fails, all configured guards are skipped (not run). +The agent gets boundary feedback only. Guards do not run on invalid state. + +**1.5 — Interrupts are clean.** +SIGINT kills the child process immediately. The loop does not exit +mid-iteration leaving partial state — it completes the signal check and exits +at the next safe point with code 130. + +--- + +## 2. The Judge + +**Story:** In brute mode, after the worker says DONE and guards pass, a +separate fresh agent (the judge) runs. It reads `judge.md`, tests the feature, +and writes `VERDICT: PASS` or `VERDICT: FAIL` to `verdict.md`. On PASS, yoke +exits successfully. On FAIL, the worker retries. + +### Invariants + +**2.1 — The judge is independent.** +It is a fresh agent invocation with no shared context from the worker. Its only +input is `judge.md` and the codebase state. + +**2.2 — Verdict survives retries.** +On judge FAIL, `verdict.md` is NOT cleared. The worker sees the judge's +feedback on its next iteration. This is how the worker knows what went wrong. + +**2.3 — Guard results survive retries.** +Same as verdict — `guard-results.md` persists across brute retries so the +worker sees what the guards reported. + +**2.4 — Notes status reset on retry, nothing else.** +On judge FAIL, only the first line of `notes.md` is overwritten to +`STATUS: IN_PROGRESS`. The rest of the file — the agent's prior iteration +notes — is preserved. All other files remain as-is. The worker starts with a +clean status but full context from both its own notes and the judge's verdict. + +**2.5 — Bailout is exact.** +If `max-judge-failures` consecutive judge FAILs occur, yoke exits non-zero. +The count is exact — `max-judge-failures 2` means bailout on the 2nd +consecutive FAIL, not the 3rd. + +**2.6 — Judge-every overrides cadence on DONE.** +If `judge-every` is configured and the worker signals DONE, the judge fires +immediately regardless of whether the iteration is on the cadence boundary. +DONE always triggers judgment. + +--- + +## 3. The Stash + +**Story:** `yoke stash` saves the current `.loop/` state. `yoke stash pop` +restores the most recent snapshot. `yoke stash checkout ` restores a +specific snapshot. `yoke clean` auto-stashes before wiping. + +### Invariants + +**3.1 — Stash is a lossless round-trip.** +`stash` then `pop` produces identical `.loop/` contents. No file is lost, +truncated, or corrupted. + +**3.2 — Auto-stash before destructive operations.** +Both `clean` and `checkout` auto-stash current state before modifying it. You +can always recover what was there before. + +**3.3 — Mode tag is recorded.** +Each stash entry records the mode (loop/brute/saga) that was active when it +was created. This tag is preserved in the index and survives restore +operations. + +**3.4 — Index is append-only.** +Stash never modifies or deletes existing index lines. New entries are appended. +The index is a history, not a mutable pointer. + +**3.5 — Prefix matching is unambiguous.** +`checkout abc` matches any entry starting with `abc`. If multiple entries +match, yoke errors instead of guessing. No silent wrong restore. + +--- + +## 4. The Saga + +**Story:** Saga mode has a scoper agent that reads `specification.md`, +decomposes it into chunks, writes each chunk to `sub-plan.md`, and a brute +loop implements and verifies each chunk. When all chunks are done, the scoper +writes `STATUS: DONE` to `saga-notes.md`. + +### Invariants + +**4.1 — Saga completion checks saga-notes, not notes.** +The saga loop checks `saga-notes.md` for DONE. `notes.md` is local to each +brute chunk and is cleared between chunks. Checking `notes.md` would be +checking the wrong file. + +**4.2 — Brute bailout triggers re-scoping, not abort.** +If brute fails `max-judge-failures` times on a chunk, control returns to the +scoper. The scoper can re-scope the same chunk differently. The saga does not +abort on a single chunk failure. + +**4.3 — Sub-plan must be non-empty.** +If the scoper produces an empty `sub-plan.md`, the saga aborts. This prevents +a brute loop from running with no plan. + +**4.4 — Chunk state is isolated but logged.** `notes.md`, `verdict.md`, and +`guard-results.md` are cleared between chunks. Each brute run starts fresh. +Previous chunk state does not leak into the next chunk. Before clearing, +the contents of `notes.md` are appended to `saga-log.md`. + +**4.5 — Saga log is append-only.** `saga-log.md` accumulates the worker's +notes from every completed chunk. It is never cleared or truncated during a +saga run. Each entry is labeled with its chunk number. + +--- + +## 5. Config + +**Story:** `yoke.conf` defines the rules of the loop — what files are +protected, what guards run, how the judge behaves. It is parsed once at +startup and applied consistently throughout the run. + +### Invariants + +**5.1 — Valid configs parse.** +Every legal combination of directives parses without error. + +**5.2 — Invalid configs fail loudly.** +Unknown directives, malformed values, and missing required fields produce clear +errors — not silent defaults. + +**5.3 — Scope rules resolve most-specific-wins.** +If `allow src/` and `no-modify src/main.rs` are both configured, `src/main.rs` +is protected and `src/other.rs` is allowed. Longer prefix wins. + +**5.4 — Guard-after requires its periodic.** +A `guard-after` referencing a periodic that does not exist is a config error, +not a silent no-op. + +--- + +## 6. Mode Switching + +**Story:** You can switch between loop, brute, and saga without losing +progress. Each mode's state is snapshotted when you leave it and restored +when you return. + +### Invariants + +**6.1 — Mode switch stashes current state.** +Switching from mode A to B stashes all of A's files via `yoke stash`. The +stash entry is tagged with mode A. Current state is always recoverable. + +**6.2 — Mode switch always fresh-inits.** +After stashing, the target mode is initialized with fresh template files. +Previous sessions are not auto-restored. Use `yoke stash checkout` to +restore a prior session. diff --git a/src/config.rs b/src/config.rs index 135ed0a..f8cefe7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,24 +41,126 @@ pub struct Config { pub periodics: Vec, } +struct ConfigBuilder { + max_tail: usize, + log_dir: Option, + image: Option, + model: Option, + scope_rules: Vec, + guards: Vec, + judge_every: Option, + max_judge_failures: u32, + periodics: Vec, + pending_guard_afters: Vec<(String, String, usize)>, +} + +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)))?; + if n == 0 { + return Err(cfg_err(path, line_num, &format!("{} must be > 0", label))); + } + Ok(n) +} + +#[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) + .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 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)))? + .to_string(); + 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> { + let trimmed = value.trim(); + 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)) +} + +impl ConfigBuilder { + fn new() -> Self { + Self { + max_tail: 200, + log_dir: None, + image: None, + model: None, + scope_rules: Vec::new(), + guards: Vec::new(), + judge_every: None, + max_judge_failures: 3, + periodics: Vec::new(), + pending_guard_afters: Vec::new(), + } + } + + #[allow(clippy::string_slice)] + 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)))?; + } + "log-dir" => self.log_dir = Some(value.to_string()), + "image" => self.image = Some(value.to_string()), + "model" => self.model = Some(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")?, + "periodic" => self.periodics.push(parse_periodic(value, path, line_num)?), + "guard-after" => self.pending_guard_afters.push(parse_guard_after(value, path, line_num)?), + other => return Err(cfg_err(path, line_num, &format!("unknown directive '{}'", other))), + } + Ok(()) + } + + fn build(self, path: &Path) -> Result { + 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))), + } + } + Ok(Config { + max_tail: self.max_tail, + log_dir: self.log_dir, + image: self.image, + model: self.model, + scope_rules: self.scope_rules, + guards: self.guards, + judge_every: self.judge_every, + max_judge_failures: self.max_judge_failures, + periodics, + }) + } +} + impl Config { #[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find() pub fn load(path: &Path) -> Result { let content = fs::read_to_string(path) .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?; - let mut max_tail: usize = 200; - let mut log_dir: Option = None; - let mut image: Option = None; - let mut model: Option = None; - let mut scope_rules = Vec::new(); - let mut guards = Vec::new(); - let mut judge_every: Option = None; - let mut max_judge_failures: u32 = 3; - let mut periodics = Vec::new(); - let mut pending_guard_afters: Vec<(String, String, usize)> = Vec::new(); // (periodic_name, command, line_num) + let mut builder = ConfigBuilder::new(); for (line_num, raw_line) in content.lines().enumerate() { - // Strip comments let line = match raw_line.find('#') { Some(pos) => &raw_line[..pos], None => raw_line, @@ -68,187 +170,17 @@ impl Config { continue; } - // Split into directive and value at first whitespace let (directive, value) = match line.find(char::is_whitespace) { Some(pos) => (&line[..pos], line[pos..].trim_start()), None => { - return Err(format!( - "{}:{}: directive '{}' has no value", - path.display(), - line_num + 1, - line - )); + return Err(cfg_err(path, line_num + 1, &format!("directive '{}' has no value", line))); } }; - match directive { - "max-tail" => { - max_tail = value.parse::().map_err(|_| { - format!( - "{}:{}: invalid max-tail value '{}'", - path.display(), - line_num + 1, - value - ) - })?; - } - "log-dir" => { - log_dir = Some(value.to_string()); - } - "image" => { - image = Some(value.to_string()); - } - "model" => { - model = Some(value.to_string()); - } - "allow" => scope_rules.push(ScopeRule { - tag: ScopeTag::Allow, - prefix: value.to_string(), - }), - "add-only" => scope_rules.push(ScopeRule { - tag: ScopeTag::AddOnly, - prefix: value.to_string(), - }), - "no-modify" => scope_rules.push(ScopeRule { - tag: ScopeTag::NoModify, - prefix: value.to_string(), - }), - "guard" => guards.push(value.to_string()), - "judge-every" => { - let n = value.parse::().map_err(|_| { - format!( - "{}:{}: invalid judge-every value '{}'", - path.display(), - line_num + 1, - value - ) - })?; - if n == 0 { - return Err(format!( - "{}:{}: judge-every must be > 0", - path.display(), - line_num + 1 - )); - } - judge_every = Some(n); - } - "max-judge-failures" => { - let n = value.parse::().map_err(|_| { - format!( - "{}:{}: invalid max-judge-failures value '{}'", - path.display(), - line_num + 1, - value - ) - })?; - if n == 0 { - return Err(format!( - "{}:{}: max-judge-failures must be > 0", - path.display(), - line_num + 1 - )); - } - max_judge_failures = n; - } - "periodic" => { - // Split value at last whitespace → path + cadence - let trimmed = value.trim(); - let split_pos = trimmed.rfind(char::is_whitespace).ok_or_else(|| { - format!( - "{}:{}: periodic requires ' '", - path.display(), - line_num + 1 - ) - })?; - let ppath = trimmed[..split_pos].trim(); - let cadence_str = trimmed[split_pos..].trim(); - let cadence = cadence_str.parse::().map_err(|_| { - format!( - "{}:{}: invalid periodic cadence '{}'", - path.display(), - line_num + 1, - cadence_str - ) - })?; - if cadence == 0 { - return Err(format!( - "{}:{}: periodic cadence must be > 0", - path.display(), - line_num + 1 - )); - } - // Derive name from filename stem - let name = Path::new(ppath) - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| { - format!( - "{}:{}: cannot derive name from periodic path '{}'", - path.display(), - line_num + 1, - ppath - ) - })? - .to_string(); - periodics.push(Periodic { - path: ppath.to_string(), - name, - cadence, - guards: Vec::new(), - }); - } - "guard-after" => { - // Split at first whitespace → periodic name + command - let trimmed = value.trim(); - let split_pos = trimmed.find(char::is_whitespace).ok_or_else(|| { - format!( - "{}:{}: guard-after requires ' '", - path.display(), - line_num + 1 - ) - })?; - let pname = trimmed[..split_pos].trim().to_string(); - let cmd = trimmed[split_pos..].trim().to_string(); - pending_guard_afters.push((pname, cmd, line_num + 1)); - } - other => { - return Err(format!( - "{}:{}: unknown directive '{}'", - path.display(), - line_num + 1, - other - )); - } - } + builder.parse_line(directive, value, path, line_num + 1)?; } - // Attach guard-after commands to their matching periodics - for (pname, cmd, ln) in pending_guard_afters { - let found = periodics.iter_mut().find(|p| p.name == pname); - match found { - Some(p) => p.guards.push(cmd), - None => { - return Err(format!( - "{}:{}: guard-after references unknown periodic '{}'", - path.display(), - ln, - pname - )); - } - } - } - - Ok(Config { - max_tail, - log_dir, - image, - model, - scope_rules, - guards, - judge_every, - max_judge_failures, - periodics, - }) + builder.build(path) } /// Resolve the most-specific scope tag for a file path. diff --git a/src/main.rs b/src/main.rs index f5a98c0..acf1976 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ const BRIEFING_PATH: &str = ".loop/briefing.md"; const SAGA_PROTOCOL_PATH: &str = ".loop/saga-protocol.md"; const SPECIFICATION_PATH: &str = ".loop/specification.md"; const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md"; +const SAGA_LOG_PATH: &str = ".loop/saga-log.md"; const DECISIONS_PATH: &str = ".loop/decisions.md"; const SUB_PLAN_PATH: &str = ".loop/sub-plan.md"; pub(crate) const STASH_DIR: &str = ".loop/.stash"; @@ -333,9 +334,8 @@ fn print_init_help() { eprintln!("Existing files are never overwritten."); eprintln!(); eprintln!("{}MODE SWITCHING:{}", BOLD, RESET); - eprintln!(" Switching modes automatically snapshots the current mode's files"); - eprintln!(" into .loop/.modes// and restores any prior snapshot of the"); - eprintln!(" target mode. This lets you bounce between modes without losing work."); + eprintln!(" Switching modes stashes the current .loop/ state and creates fresh"); + eprintln!(" target mode files. Restore a previous session with: yoke stash checkout"); } /// Holds loop state and cleans up on drop. @@ -466,9 +466,56 @@ fn reset_notes_status() { } } +/// Build a docker command that runs the claude CLI inside a container. +fn build_docker_claude_command(image: &str, claude_args: &[&str]) -> Command { + let workdir = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .to_string_lossy() + .to_string(); + + let mut c = Command::new("docker"); + c.args([ + "run", + "-i", + "--rm", + "--network=host", + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW", + ]); + c.args(["--entrypoint", "claude"]); + c.arg("-v").arg(format!("{}:/workspace", workdir)); + c.arg("-w").arg("/workspace"); + + for (key, val) in std::env::vars() { + if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { + c.arg("-e").arg(format!("{}={}", key, val)); + } + } + + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + let claude_dir = home_path.join(".claude"); + if claude_dir.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude", + claude_dir.display() + )); + } + let claude_json = home_path.join(".claude.json"); + if claude_json.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude.json", + claude_json.display() + )); + } + } + + c.arg(image); + c.args(claude_args); + c +} + /// Build a Command for invoking the agent backend. -/// For Claude backend: uses `claude` CLI, optionally via docker. -/// For OpenCode backend: uses `opencode run --format json --model `. fn build_command(config: &Config, prompt: &str) -> Command { match config.backend() { Backend::Claude => { @@ -481,56 +528,13 @@ fn build_command(config: &Config, prompt: &str) -> Command { "-p", prompt, ]; - if let Some(ref image) = config.image { - let workdir = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .to_string_lossy() - .to_string(); - - let mut c = Command::new("docker"); - c.args([ - "run", - "-i", - "--rm", - "--network=host", - "--cap-add=NET_ADMIN", - "--cap-add=NET_RAW", - ]); - c.args(["--entrypoint", "claude"]); - c.arg("-v").arg(format!("{}:/workspace", workdir)); - c.arg("-w").arg("/workspace"); - - for (key, val) in std::env::vars() { - if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { - c.arg("-e").arg(format!("{}={}", key, val)); - } + match config.image { + Some(ref image) => build_docker_claude_command(image, &claude_args), + None => { + let mut c = Command::new("claude"); + c.args(claude_args); + c } - - if let Some(home) = std::env::var_os("HOME") { - let home_path = PathBuf::from(&home); - let claude_dir = home_path.join(".claude"); - if claude_dir.exists() { - c.arg("-v").arg(format!( - "{}:/home/node/.claude", - claude_dir.display() - )); - } - let claude_json = home_path.join(".claude.json"); - if claude_json.exists() { - c.arg("-v").arg(format!( - "{}:/home/node/.claude.json", - claude_json.display() - )); - } - } - - c.arg(image.as_str()); - c.args(claude_args); - c - } else { - let mut c = Command::new("claude"); - c.args(claude_args); - c } } Backend::OpenCode => { @@ -995,8 +999,6 @@ fn print_clean_help() { eprintln!("Structural files are left untouched:"); eprintln!(" protocol.md, saga-protocol.md, yoke.conf, briefing.md, specification.md"); eprintln!(); - eprintln!("Mode snapshots in .loop/.modes/ are preserved by clean."); - eprintln!(); eprintln!("Non-empty working files are auto-stashed to .loop/.stash/ before wiping."); eprintln!("Browse with: {}yoke stash log{} Recover with: {}yoke stash pop{}", BOLD, RESET, BOLD, RESET); } @@ -1080,6 +1082,7 @@ fn mode_files(mode: &str) -> Option> { (JUDGE_PATH, DEFAULT_SAGA_JUDGE), (SPECIFICATION_PATH, ""), (SAGA_NOTES_PATH, ""), + (SAGA_LOG_PATH, ""), (DECISIONS_PATH, ""), (SUB_PLAN_PATH, ""), (NOTES_PATH, ""), @@ -1106,104 +1109,36 @@ fn detect_mode() -> Option<&'static str> { } } -/// Backup current mode's files into `.loop/.modes//`, then either -/// restore from a prior snapshot of the target mode or do a fresh init. +/// Stash current mode's files, clear `.loop/`, and fresh-init the target mode. fn switch_mode(current: &str, target: &str) -> i32 { - use std::collections::HashSet; - - let current_files = mode_files(current).unwrap(); - let target_files = mode_files(target).unwrap(); - - let current_paths: HashSet<&str> = current_files.iter().map(|(p, _)| *p).collect(); - let target_paths: HashSet<&str> = target_files.iter().map(|(p, _)| *p).collect(); - - // 1. Snapshot current mode's files into .loop/.modes// - let snapshot_dir = PathBuf::from(format!(".loop/.modes/{}", current)); - if let Err(e) = fs::create_dir_all(&snapshot_dir) { - log_error(&format!("failed to create {}: {}", snapshot_dir.display(), e)); - return 1; - } - - for path in ¤t_paths { - let src = Path::new(path); - if src.exists() { - let dest = snapshot_dir.join(src.file_name().unwrap()); - if let Err(e) = fs::copy(src, &dest) { - log_error(&format!("failed to snapshot {}: {}", path, e)); - return 1; - } - log(&format!("snapshot: {} → {}", path, dest.display())); + // 1. Stash current state + match stash::stash_snapshot(current) { + Ok(hash) => { + log(&format!( + "stashed {} state \u{2192} {}{}{}", + current, BLUE, hash, RESET + )); } - } - - // 2. Delete files exclusive to current mode (not in target) - for path in current_paths.difference(&target_paths) { - let p = Path::new(path); - if p.exists() { - if let Err(e) = fs::remove_file(p) { - log_error(&format!("failed to remove {}: {}", path, e)); - return 1; - } - log(&format!("removed: {}", path)); - } - } - - // 3. Check for a prior snapshot of the target mode - let restore_dir = PathBuf::from(format!(".loop/.modes/{}", target)); - if restore_dir.exists() { - log(&format!("Restoring '{}' mode from snapshot...", target)); - for (path, _) in &target_files { - let file_name = Path::new(path).file_name().unwrap(); - let src = restore_dir.join(file_name); - if src.exists() { - if let Err(e) = fs::copy(&src, path) { - log_error(&format!("failed to restore {}: {}", path, e)); - return 1; - } - log(&format!("restored: {}", path)); - } else { - // File wasn't in the snapshot — create from template if missing - let p = Path::new(path); - if !p.exists() { - let (_, content) = target_files.iter().find(|(tp, _)| tp == path).unwrap(); - if let Err(e) = fs::write(p, content) { - log_error(&format!("failed to write {}: {}", path, e)); - return 1; - } - log(&format!("created: {}", path)); - } - } - } - // Remove the restored snapshot directory - if let Err(e) = fs::remove_dir_all(&restore_dir) { - log_error(&format!("failed to remove {}: {}", restore_dir.display(), e)); + Err(msg) => { + log_error(&format!("failed to stash current state: {}", msg)); return 1; } - } else { - // 4. Fresh init for the target mode - log(&format!("Fresh init for '{}' mode...", target)); - for (path, content) in &target_files { - let p = Path::new(path); - if p.exists() { - // Shared file that wasn't deleted — overwrite with target template - // (e.g. protocol.md, yoke.conf have different content per mode) - if current_paths.contains(path) { - if let Err(e) = fs::write(p, content) { - log_error(&format!("failed to write {}: {}", path, e)); - return 1; - } - log(&format!("overwritten: {} (new mode template)", path)); - } else { - log(&format!("skip (exists): {}", path)); - } - } else { - if let Err(e) = fs::write(p, content) { - log_error(&format!("failed to write {}: {}", path, e)); - return 1; - } - log(&format!("created: {}", path)); - } + } + + // 2. Remove all non-dot files from .loop/ + let current_files = stash::collect_stashable_files(); + for (name, _) in ¤t_files { + let _ = fs::remove_file(Path::new(".loop").join(name)); + } + + // 3. Fresh-init target mode + let target_files = mode_files(target).unwrap(); + for (path, content) in &target_files { + if let Err(e) = fs::write(path, content) { + log_error(&format!("failed to write {}: {}", path, e)); + return 1; } + log(&format!("created: {}", path)); } log(&format!("Switched from '{}' to '{}' mode", current, target)); @@ -1343,23 +1278,13 @@ fn evaluate_judge_every( JudgeEveryAction::Continue } -/// Core plan-loop runner that can be called standalone or nested inside brute. -/// -/// - `config`: already-loaded Config -/// - `plan_path`: path to the plan file (e.g. PLAN_PATH or SUB_PLAN_PATH) -/// - `dry_run`: if true, skip Claude invocation (one iteration only) -/// - `nested`: if true, running inside brute (adjusts output banners) -fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) -> PlanLoopOutcome { - // Preflight — skip guard-results clear when nested (brute handles it) - preflight(plan_path, !nested); - - // Validate judge-every requires judge.md +/// Validate config preconditions for the plan loop. Exits on failure. +fn validate_loop_config(config: &Config) { if config.judge_every.is_some() && !Path::new(JUDGE_PATH).exists() { log_error("judge-every is set but .loop/judge.md not found"); process::exit(1); } - // Validate periodic protocol paths exist and are non-empty for periodic in &config.periodics { let p = Path::new(&periodic.path); if !p.exists() { @@ -1380,25 +1305,121 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) _ => {} } } +} +/// Fire any periodic agents whose cadence matches this iteration. +fn fire_periodic_agents( + runner: &mut LoopRunner, + config: &Config, + iteration: u32, +) { + for periodic in &config.periodics { + if iteration % periodic.cadence == 0 { + eprintln!(); + let upper_name = capitalize_first(&periodic.name); + render_section_banner( + &upper_name, + &format!("Periodic agent (iteration {:>4})", iteration), + MAGENTA, + ); + let ok = invoke_periodic(runner, config, periodic, iteration); + if !ok { + log(&format!( + "{}WARNING: periodic '{}' invocation failed — continuing{}", + ORANGE, periodic.name, RESET + )); + } + run_periodic_guards(periodic, config.max_tail); + } + } +} + +/// Result of running the worker + guards for a single plan-loop iteration. +enum IterationStepResult { + /// Worker + guards succeeded, loop should continue. + Continue { guards_passed: bool, cost: f64 }, + /// Terminal outcome — return immediately from the loop. + Terminal(PlanLoopOutcome), +} + +/// Run the worker invocation, interrupt check, and guard evaluation for one iteration. +/// Returns `Continue` with guard results when the loop should proceed, or `Terminal` +/// when an early exit is needed. +fn run_iteration_step( + runner: &mut LoopRunner, + config: &Config, + iteration: u32, + prior_total: f64, + dry_run: bool, +) -> IterationStepResult { + if dry_run { + log(&format!( + "(dry-run) Skipping {} invocation", + match config.backend() { + Backend::Claude => "Claude", + Backend::OpenCode => "OpenCode", + } + )); + } else { + let (success, iter_cost) = invoke_agent(runner, config, iteration, prior_total); + if !success { + log_error("Claude invocation failed — aborting loop"); + return IterationStepResult::Terminal(PlanLoopOutcome::Error); + } + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return IterationStepResult::Terminal(PlanLoopOutcome::Interrupt); + } + let guards_passed = run_all_guards(config); + if guards_passed { + log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET)); + } else { + log(&format!( + "{}Some guards failed \u{2014} Claude will see results next iteration{}", + ORANGE, RESET + )); + } + return IterationStepResult::Continue { guards_passed, cost: iter_cost }; + } + + // Dry-run path: run guards and exit after one iteration. + let guards_passed = run_all_guards(config); + if guards_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("(dry-run) Exiting after one iteration"); + IterationStepResult::Terminal(PlanLoopOutcome::Error) + } +} + +/// Core plan-loop runner that can be called standalone or nested inside brute. +/// +/// - `config`: already-loaded Config +/// - `plan_path`: path to the plan file (e.g. PLAN_PATH or SUB_PLAN_PATH) +/// - `dry_run`: if true, skip Claude invocation (one iteration only) +/// - `nested`: if true, running inside brute (adjusts output banners) +fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) -> PlanLoopOutcome { + preflight(plan_path, !nested); + validate_loop_config(config); log("Preflight OK"); - // Build protected files list dynamically based on the plan path let mut protected: Vec<&str> = vec![PROTOCOL_PATH, plan_path, CONF_PATH]; - // Include periodic protocol paths in the protected set let periodic_paths: Vec = config.periodics.iter().map(|p| p.path.clone()).collect(); for pp in &periodic_paths { protected.push(pp.as_str()); } - // Backup protected files and create the runner (Drop handles cleanup) let backup_dir = backup_files(&protected); log(&format!("Backups in {}", backup_dir.display())); let mut runner = LoopRunner::new(backup_dir); let mut iteration: u32 = 0; let mut total_cost: f64 = 0.0; - let mut consecutive_judge_failures: u32 = 0; loop { @@ -1411,88 +1432,22 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) let label = if nested { "Plan Iteration" } else { "Iteration" }; render_iteration_banner(label, iteration, nested); - // Show stage progress bar if plan has parseable stages if let Some((completed, total)) = stage_progress(plan_path) { eprintln!("{}", format_progress_bar(completed, total)); } - // Restore protected files restore_files(&runner.backup_dir, &protected); - // NOTE: Do NOT clear guard-results.md here. The previous iteration's - // guard results must persist so Claude can read them and fix failures. - // The file is initialized to empty in preflight() (first iteration) - // and overwritten by run_all_guards() after Claude finishes. - - if dry_run { - log(&format!( - "(dry-run) Skipping {} invocation", - match config.backend() { - Backend::Claude => "Claude", - Backend::OpenCode => "OpenCode", - } - )); - } else { - let (success, iter_cost) = invoke_agent(&mut runner, config, iteration, total_cost); - total_cost += iter_cost; - if !success { - log_error("Claude invocation failed — aborting loop"); - return PlanLoopOutcome::Error; + let guards_passed = match run_iteration_step(&mut runner, config, iteration, total_cost, dry_run) { + IterationStepResult::Continue { guards_passed, cost } => { + total_cost += cost; + guards_passed } - } + IterationStepResult::Terminal(outcome) => return outcome, + }; - if signal::interrupted() { - log("Interrupted \u{2014} shutting down"); - return PlanLoopOutcome::Interrupt; - } + fire_periodic_agents(&mut runner, config, iteration); - // Run worker guards - let guards_passed = run_all_guards(config); - - if guards_passed { - log(&format!( - "{}{}All guards passed{}", - GREEN, BOLD, RESET - )); - } else { - log(&format!( - "{}Some guards failed \u{2014} Claude will see results next iteration{}", - ORANGE, RESET - )); - } - - if dry_run { - if guards_passed { - log("(dry-run) Guards passed \u{2014} exiting after one iteration"); - return PlanLoopOutcome::Done; - } else { - log("(dry-run) Exiting after one iteration"); - return PlanLoopOutcome::Error; - } - } - - // Fire periodic agents (if at cadence) - for periodic in &config.periodics { - if iteration % periodic.cadence == 0 { - eprintln!(); - let upper_name = capitalize_first(&periodic.name); - render_section_banner( - &upper_name, - &format!("Periodic agent (iteration {:>4})", iteration), - MAGENTA, - ); - let ok = invoke_periodic(&mut runner, config, periodic, iteration); - if !ok { - log(&format!( - "{}WARNING: periodic '{}' invocation failed — continuing{}", - ORANGE, periodic.name, RESET - )); - } - run_periodic_guards(periodic, config.max_tail); - } - } - - // Judge-every logic: embedded judge checks at configured cadence if let Some(judge_every) = config.judge_every { match evaluate_judge_every( &mut runner, config, iteration, guards_passed, @@ -1504,7 +1459,6 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) } } - // Original exit check (only reached when judge-every is NOT set) if guards_passed && is_status_done(NOTES_PATH) { eprintln!(); eprintln!( @@ -1514,7 +1468,6 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool) eprintln!(); return PlanLoopOutcome::Done; } - // Guards failed or not done — continue iterating } } @@ -1779,8 +1732,168 @@ fn invoke_scoper(runner: &mut LoopRunner, config: &Config, cycle: u32) -> bool { /// Run the saga loop: scoper (Agent 1) decomposes the spec into sub-plans, /// each sub-plan is run through the brute loop (Agent 2 + Agent 3). +/// Preflight checks for saga mode: verify required files exist, critical files +/// are non-empty, and create working files if missing. +fn saga_preflight() { + for path in &[ + SPECIFICATION_PATH, + SAGA_PROTOCOL_PATH, + PROTOCOL_PATH, + JUDGE_PATH, + CONF_PATH, + ] { + if !Path::new(path).exists() { + log_error(&format!("required file not found: {}", path)); + process::exit(1); + } + } + + match fs::read_to_string(SPECIFICATION_PATH) { + Ok(content) if content.trim().is_empty() => { + log_error(&format!( + "{} is empty — fill it in before running", + SPECIFICATION_PATH + )); + process::exit(1); + } + Err(e) => { + log_error(&format!("cannot read {}: {}", SPECIFICATION_PATH, e)); + process::exit(1); + } + _ => {} + } + + for path in &[ + SAGA_NOTES_PATH, + DECISIONS_PATH, + SUB_PLAN_PATH, + NOTES_PATH, + VERDICT_PATH, + GUARD_RESULTS_PATH, + ] { + if !Path::new(path).exists() { + if let Err(e) = fs::write(path, "") { + log_error(&format!("cannot create {}: {}", path, e)); + process::exit(1); + } + } + } + + log("Preflight OK"); +} + +/// Run one saga cycle: invoke the scoper, check for DONE, then run brute on the sub-plan. +/// Returns Some(exit_code) if the saga should terminate, None to continue looping. +fn run_saga_cycle( + runner: &mut LoopRunner, + config: &Config, + cycle: u32, + dry_run: bool, + saga_protected: &[&str], +) -> Option { + restore_files(&runner.backup_dir, saga_protected); + + if dry_run { + log("(dry-run) Skipping scoper invocation"); + } else { + eprintln!(); + render_section_banner( + "Scoper", + &format!("Invoking scoper (cycle {:>4})", cycle), + BLUE, + ); + + if !invoke_scoper(runner, config, cycle) { + log_error("Scoper invocation failed \u{2014} aborting saga"); + return Some(1); + } + + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return Some(130); + } + } + + if is_status_done(SAGA_NOTES_PATH) { + eprintln!(); + eprintln!( + "{}{} Scoper signals DONE \u{2014} saga complete {}", + GREEN, BOLD, RESET + ); + eprintln!(); + return Some(0); + } + + match fs::read_to_string(SUB_PLAN_PATH) { + Ok(content) if content.trim().is_empty() => { + log_error("Scoper produced an empty sub-plan.md \u{2014} aborting saga"); + return Some(1); + } + Err(e) => { + log_error(&format!("cannot read {}: {}", SUB_PLAN_PATH, e)); + return Some(1); + } + _ => {} + } + + if dry_run { + log("(dry-run) Skipping brute loop on sub-plan.md"); + log("(dry-run) Exiting after one cycle"); + return Some(0); + } + + log("Running brute loop on sub-plan.md..."); + + // Append worker notes to saga log before clearing + if let Ok(notes) = fs::read_to_string(NOTES_PATH) { + if !notes.trim().is_empty() { + let header = format!("\n## Chunk {}\n\n", cycle); + let _ = fs::OpenOptions::new() + .create(true) + .append(true) + .open(SAGA_LOG_PATH) + .and_then(|mut f| { + use std::io::Write; + f.write_all(header.as_bytes())?; + f.write_all(notes.as_bytes())?; + if !notes.ends_with('\n') { + f.write_all(b"\n")?; + } + Ok(()) + }); + } + } + + let _ = fs::write(NOTES_PATH, ""); + let _ = fs::write(VERDICT_PATH, ""); + let _ = fs::write(GUARD_RESULTS_PATH, ""); + + match run_brute_core(config, SUB_PLAN_PATH, false) { + BruteResult::Pass => { + log(&format!( + "{}Sub-plan passed \u{2014} looping back to scoper{}", + GREEN, RESET + )); + } + BruteResult::Bailout => { + log(&format!( + "{}Brute loop bailed out \u{2014} looping back to scoper for re-scoping{}", + ORANGE, RESET + )); + } + BruteResult::Interrupt => { + log("Interrupted \u{2014} shutting down"); + return Some(130); + } + BruteResult::Error => { + log_error("Brute loop encountered a hard error \u{2014} aborting saga"); + return Some(1); + } + } + None +} + fn run_saga(dry_run: bool) -> i32 { - // Load config let config = match Config::load(Path::new(CONF_PATH)) { Ok(c) => c, Err(e) => { @@ -1800,56 +1913,8 @@ fn run_saga(dry_run: bool) -> i32 { ) )); - // Preflight: verify required saga files exist - for path in &[ - SPECIFICATION_PATH, - SAGA_PROTOCOL_PATH, - PROTOCOL_PATH, - JUDGE_PATH, - CONF_PATH, - ] { - if !Path::new(path).exists() { - log_error(&format!("required file not found: {}", path)); - process::exit(1); - } - } + saga_preflight(); - // Fail fast if specification.md is empty - match fs::read_to_string(SPECIFICATION_PATH) { - Ok(content) if content.trim().is_empty() => { - log_error(&format!( - "{} is empty — fill it in before running", - SPECIFICATION_PATH - )); - process::exit(1); - } - Err(e) => { - log_error(&format!("cannot read {}: {}", SPECIFICATION_PATH, e)); - process::exit(1); - } - _ => {} - } - - // Ensure working files exist - for path in &[ - SAGA_NOTES_PATH, - DECISIONS_PATH, - SUB_PLAN_PATH, - NOTES_PATH, - VERDICT_PATH, - GUARD_RESULTS_PATH, - ] { - if !Path::new(path).exists() { - if let Err(e) = fs::write(path, "") { - log_error(&format!("cannot create {}: {}", path, e)); - process::exit(1); - } - } - } - - log("Preflight OK"); - - // Protected files for the saga runner let saga_protected: Vec<&str> = vec![ SAGA_PROTOCOL_PATH, PROTOCOL_PATH, @@ -1872,94 +1937,141 @@ fn run_saga(dry_run: bool) -> i32 { eprintln!(); render_iteration_banner("Saga Cycle", cycle, false); - // Restore protected files - restore_files(&runner.backup_dir, &saga_protected); + if let Some(exit_code) = run_saga_cycle(&mut runner, &config, cycle, dry_run, &saga_protected) { + return exit_code; + } + } +} - if dry_run { - log("(dry-run) Skipping scoper invocation"); - } else { - // Invoke Agent 1 (scoper) - eprintln!(); - render_section_banner( - "Scoper", - &format!("Invoking scoper (cycle {:>4})", cycle), - BLUE, - ); +fn cmd_init(args: &[String]) -> ! { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_init_help(); + process::exit(0); + } + let mode = match args.get(2).map(|a| a.as_str()) { + None => "loop", + Some("brute") => "brute", + Some("saga") => "saga", + Some(other) => { + log_error(&format!("unknown argument '{}'", other)); + print_init_help(); + process::exit(2); + } + }; + if args.len() > 3 { + log_error(&format!("unexpected argument '{}'", args[3])); + print_init_help(); + process::exit(2); + } + process::exit(init(mode)); +} - let scoper_ok = invoke_scoper(&mut runner, &config, cycle); - if !scoper_ok { - log_error("Scoper invocation failed \u{2014} aborting saga"); - return 1; +fn cmd_clean(args: &[String]) -> ! { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_clean_help(); + process::exit(0); + } + if args.len() > 2 { + log_error(&format!("unexpected argument '{}'", args[2])); + print_clean_help(); + process::exit(2); + } + process::exit(clean()); +} + +fn cmd_stash(args: &[String]) -> ! { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + stash::print_stash_help(); + process::exit(0); + } + let mode = detect_mode().unwrap_or("unknown"); + match args.get(2).map(|a| a.as_str()) { + None => { + process::exit(stash::stash_create(mode)); + } + Some("log") => { + process::exit(stash::stash_log_cmd()); + } + Some("checkout") => match args.get(3) { + Some(hash) => process::exit(stash::stash_checkout(hash, mode)), + None => { + log_error("missing hash — usage: yoke stash checkout "); + process::exit(2); } + }, + Some("pop") => { + process::exit(stash::stash_pop(mode)); + } + Some(other) => { + log_error(&format!("unknown subcommand '{}'", other)); + stash::print_stash_help(); + process::exit(2); + } + } +} - if signal::interrupted() { - log("Interrupted \u{2014} shutting down"); - return 130; +fn cmd_layer(args: &[String]) -> ! { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_layer_help(); + process::exit(0); + } + if args.get(2).is_some_and(|a| a == "--list") { + for name in list_layers() { + println!(" {}", name); + } + process::exit(0); + } + let names_arg = match args.get(2) { + Some(a) => a.clone(), + None => { + log_error("missing layer name"); + print_layer_help(); + process::exit(2); + } + }; + process::exit(apply_layers(&names_arg)); +} + +fn cmd_run(args: &[String]) -> ! { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_run_help(); + process::exit(0); + } + let mut dry_run = 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, + other => { + log_error(&format!("unknown flag '{}'", other)); + print_run_help(); + process::exit(2); } } - - // Check if scoper signaled DONE - if is_status_done(SAGA_NOTES_PATH) { - eprintln!(); - eprintln!( - "{}{} Scoper signals DONE \u{2014} saga complete {}", - GREEN, BOLD, RESET - ); - eprintln!(); - return 0; + } + let config = match Config::load(Path::new(CONF_PATH)) { + Ok(c) => c, + Err(e) => { + log_error(&e); + process::exit(1); } - - // Verify sub-plan.md is non-empty - match fs::read_to_string(SUB_PLAN_PATH) { - Ok(content) if content.trim().is_empty() => { - log_error("Scoper produced an empty sub-plan.md \u{2014} aborting saga"); - return 1; - } - Err(e) => { - log_error(&format!("cannot read {}: {}", SUB_PLAN_PATH, e)); - return 1; - } - _ => {} + }; + if 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); + } + match detect_mode() { + Some("saga") => { + log("Detected saga mode"); + process::exit(run_saga(dry_run)) } - - if dry_run { - log("(dry-run) Skipping brute loop on sub-plan.md"); - log("(dry-run) Exiting after one cycle"); - return 0; + Some("brute") => { + process::exit(run_brute(dry_run)) } - - // Run the brute loop on sub-plan.md - log("Running brute loop on sub-plan.md..."); - - // Clear notes.md, verdict.md, and guard-results.md for the inner brute cycle - let _ = fs::write(NOTES_PATH, ""); - let _ = fs::write(VERDICT_PATH, ""); - let _ = fs::write(GUARD_RESULTS_PATH, ""); - - let brute_result = run_brute_core(&config, SUB_PLAN_PATH, false); - - match brute_result { - BruteResult::Pass => { - log(&format!( - "{}Sub-plan passed \u{2014} looping back to scoper{}", - GREEN, RESET - )); - } - BruteResult::Bailout => { - log(&format!( - "{}Brute loop bailed out \u{2014} looping back to scoper for re-scoping{}", - ORANGE, RESET - )); - // verdict.md and notes.md are preserved — scoper will read them - } - BruteResult::Interrupt => { - log("Interrupted \u{2014} shutting down"); - return 130; - } - BruteResult::Error => { - log_error("Brute loop encountered a hard error \u{2014} aborting saga"); - return 1; - } + _ => { + process::exit(run_loop(dry_run)) } } } @@ -1974,136 +2086,11 @@ fn main() { } match args[1].as_str() { - "init" => { - if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { - print_init_help(); - return; - } - let mode = match args.get(2).map(|a| a.as_str()) { - None => "loop", - Some("brute") => "brute", - Some("saga") => "saga", - Some(other) => { - log_error(&format!("unknown argument '{}'", other)); - print_init_help(); - process::exit(2); - } - }; - if args.len() > 3 { - log_error(&format!("unexpected argument '{}'", args[3])); - print_init_help(); - process::exit(2); - } - process::exit(init(mode)); - } - "clean" => { - if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { - print_clean_help(); - return; - } - if args.len() > 2 { - log_error(&format!("unexpected argument '{}'", args[2])); - print_clean_help(); - process::exit(2); - } - process::exit(clean()); - } - "stash" => { - if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { - stash::print_stash_help(); - return; - } - let mode = detect_mode().unwrap_or("unknown"); - match args.get(2).map(|a| a.as_str()) { - None => { - process::exit(stash::stash_create(mode)); - } - Some("log") => { - process::exit(stash::stash_log_cmd()); - } - Some("checkout") => match args.get(3) { - Some(hash) => process::exit(stash::stash_checkout(hash, mode)), - None => { - log_error("missing hash — usage: yoke stash checkout "); - process::exit(2); - } - }, - Some("pop") => { - process::exit(stash::stash_pop(mode)); - } - Some(other) => { - log_error(&format!("unknown subcommand '{}'", other)); - stash::print_stash_help(); - process::exit(2); - } - } - } - "layer" => { - if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { - print_layer_help(); - return; - } - if args.get(2).is_some_and(|a| a == "--list") { - for name in list_layers() { - println!(" {}", name); - } - return; - } - let names_arg = match args.get(2) { - Some(a) => a.clone(), - None => { - log_error("missing layer name"); - print_layer_help(); - process::exit(2); - } - }; - process::exit(apply_layers(&names_arg)); - } - "run" => { - // Check for --help before other flags - if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { - print_run_help(); - return; - } - let mut dry_run = 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, - other => { - log_error(&format!("unknown flag '{}'", other)); - print_run_help(); - process::exit(2); - } - } - } - // Load config to determine runner type - 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); - } - 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)) - } - } - } + "init" => cmd_init(&args), + "clean" => cmd_clean(&args), + "stash" => cmd_stash(&args), + "layer" => cmd_layer(&args), + "run" => cmd_run(&args), "--help" | "-h" | "help" => { print_usage(); } diff --git a/src/stash.rs b/src/stash.rs index d2dbab9..5c18268 100644 --- a/src/stash.rs +++ b/src/stash.rs @@ -213,6 +213,56 @@ pub(crate) fn stash_log_cmd() -> i32 { 0 } +/// Resolve a hash prefix to a single stash entry. Returns the entry or logs an error. +fn resolve_stash_entry<'a>(entries: &'a [StashEntry], target_hash: &str) -> Option<&'a StashEntry> { + let matches: Vec<&StashEntry> = entries + .iter() + .filter(|e| e.hash == target_hash || e.hash.starts_with(target_hash)) + .collect(); + + match matches.len() { + 0 => { + log_error(&format!("no stash entry matching '{}'", target_hash)); + None + } + 1 => Some(matches[0]), + n => { + log_error(&format!( + "ambiguous hash '{}' — matches {} entries", + target_hash, n + )); + None + } + } +} + +/// Auto-stash current state, then replace `.loop/` files with those from `entry_dir`. +fn swap_loop_files(entry_dir: &Path, mode: &str) -> Result<(), String> { + let current_files = collect_stashable_files(); + if !current_files.is_empty() { + let hash = stash_snapshot(mode)?; + log(&format!( + "auto-stashed current state → {}{}{}", + BLUE, hash, RESET + )); + } + + for (name, _) in &collect_stashable_files() { + let _ = fs::remove_file(Path::new(".loop").join(name)); + } + + if let Ok(dir_entries) = fs::read_dir(entry_dir) { + for entry in dir_entries.flatten() { + let name = entry.file_name(); + let dest = Path::new(".loop").join(&name); + fs::copy(entry.path(), &dest).map_err(|e| { + format!("failed to restore {}: {}", name.to_string_lossy(), e) + })?; + } + } + Ok(()) +} + /// `yoke stash checkout ` — auto-stash current state, clear `.loop/` files, /// restore target entry. Supports prefix matching. pub(crate) fn stash_checkout(target_hash: &str, mode: &str) -> i32 { @@ -226,75 +276,24 @@ pub(crate) fn stash_checkout(target_hash: &str, mode: &str) -> i32 { return 1; } - // Find matching entries (exact or prefix) - let matches: Vec<&StashEntry> = entries - .iter() - .filter(|e| e.hash == target_hash || e.hash.starts_with(target_hash)) - .collect(); + let target = match resolve_stash_entry(&entries, target_hash) { + Some(t) => t, + None => return 1, + }; - match matches.len() { - 0 => { - log_error(&format!("no stash entry matching '{}'", target_hash)); - 1 - } - 1 => { - let target = matches[0]; - let entry_dir = Path::new(STASH_DIR).join(&target.hash); - if !entry_dir.exists() { - log_error("stash entry directory missing — index is corrupt"); - return 1; - } - - // Auto-stash current state (skip if .loop/ has no stashable files) - let current_files = collect_stashable_files(); - if !current_files.is_empty() { - match stash_snapshot(mode) { - Ok(hash) => { - log(&format!( - "auto-stashed current state → {}{}{}", - BLUE, hash, RESET - )); - } - Err(msg) => { - log_error(&format!("failed to auto-stash: {}", msg)); - return 1; - } - } - } - - // Remove all stashable files from .loop/ - for (name, _) in &collect_stashable_files() { - let path = Path::new(".loop").join(name); - let _ = fs::remove_file(&path); - } - - // Restore files from the target entry - if let Ok(dir_entries) = fs::read_dir(&entry_dir) { - for entry in dir_entries.flatten() { - let name = entry.file_name(); - let dest = Path::new(".loop").join(&name); - if let Err(e) = fs::copy(entry.path(), &dest) { - log_error(&format!( - "failed to restore {}: {}", - name.to_string_lossy(), - e - )); - return 1; - } - } - } - - log(&format!("checked out {}{}{}", BLUE, target.hash, RESET)); - 0 - } - n => { - log_error(&format!( - "ambiguous hash '{}' — matches {} entries", - target_hash, n - )); - 1 - } + let entry_dir = Path::new(STASH_DIR).join(&target.hash); + if !entry_dir.exists() { + log_error("stash entry directory missing — index is corrupt"); + return 1; } + + if let Err(msg) = swap_loop_files(&entry_dir, mode) { + log_error(&msg); + return 1; + } + + log(&format!("checked out {}{}{}", BLUE, target.hash, RESET)); + 0 } /// `yoke stash pop` — checkout the most recent stash entry. diff --git a/src/stream.rs b/src/stream.rs index 717de81..9a59ae8 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -249,135 +249,113 @@ fn format_tool_call(line: &str) -> String { } } +/// 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_names.insert(id.to_string(), name.to_string()); + *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(); + 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. +fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &StreamState) -> io::Result<()> { + if !line.contains("\"tool_result\"") { + return Ok(()); + } + 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 tool_name = extract_str(line, "tool_use_id") + .and_then(|id| state.tool_use_names.get(id)) + .map(|s| s.as_str()); + let badge = match tool_name { + 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, line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> { +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 changed = match &state.current_msg_id { - Some(prev) => prev != msg_id, - None => true, - }; - if changed { + 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 - )?; + writeln!(out, "{}{}━━━ Turn {} ━━━{}", BOLD, ORANGE, state.turn_num, RESET)?; } } - let ev_type = extract_str(line, "type"); - - match ev_type { - // system → check subtype for init + match extract_str(line, "type") { Some("system") => { - let ev_subtype = extract_str(line, "subtype"); - if ev_subtype == Some("init") && !state.seen_init { + if extract_str(line, "subtype") == Some("init") && !state.seen_init { state.seen_init = true; let sid = extract_str(line, "session_id").unwrap_or("?"); let sid_short: String = sid.chars().take(12).collect(); - let sid_short = sid_short.as_str(); let model = extract_str(line, "model").unwrap_or("?"); - writeln!( - out, - "{}{}[stream]{} session {}… model={}", - ORANGE, BOLD, RESET, sid_short, model - )?; + writeln!(out, "{}{}[stream]{} session {}… model={}", ORANGE, BOLD, RESET, sid_short, model)?; } } - // assistant → only tool_use summaries (text already shown via stream_event deltas) - Some("assistant") => { - if line.contains("\"tool_use\"") { - // Track tool_use id → name for badge display on tool_result - if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) { - state.tool_use_names.insert(id.to_string(), name.to_string()); - // Increment tool-use counter for iteration summary - *state.tool_counts.entry(name.to_string()).or_insert(0) += 1; - } - let desc = format_tool_call(line); - writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?; - } - } - // stream_event → streaming deltas - Some("stream_event") => { - if line.contains("\"content_block_delta\"") { - if line.contains("\"thinking_delta\"") { - // Show elapsed timer during extended thinking, rewriting in-place - if let Some(start) = state.thinking_start { - let elapsed = start.elapsed().as_secs_f64(); - write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; - out.flush()?; - } - } else if line.contains("\"text_delta\"") - && let Some(text) = extract_str(line, "text") - { - let text = unescape_json(text); - write!(out, "{}{}{}", DIM, text, RESET)?; - out.flush()?; - } - // input_json_delta → skip silently - } else if line.contains("\"content_block_start\"") { - if line.contains("\"thinking\"") { - state.thinking_start = Some(Instant::now()); - write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?; - out.flush()?; - state.in_thinking = true; - } else if !line.contains("\"tool_use\"") { - writeln!(out)?; - } - } else if line.contains("\"content_block_stop\"") { - if state.in_thinking { - // Print final elapsed time and end the line - if let Some(start) = state.thinking_start { - let elapsed = start.elapsed().as_secs_f64(); - write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; - } - state.in_thinking = false; - state.thinking_start = None; - } - writeln!(out)?; - } - // message_start, message_delta, message_stop → skip - } - // user → tool result summaries - Some("user") => { - if line.contains("\"tool_result\"") { - let is_error = line.contains("\"is_error\":true") - || line.contains("\"is_error\": true"); - if is_error { - let tail = format_bash_error_tail(line); - if tail.is_empty() { - writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?; - } else { - writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?; - writeln!(out, "{}", tail)?; - } - } else { - // Check if this is a Grep or Glob result for badge display - let tool_name = extract_str(line, "tool_use_id") - .and_then(|id| state.tool_use_names.get(id)) - .map(|s| s.as_str()); - match tool_name { - Some("Grep") | Some("Glob") => { - let badge = format_grep_glob_badge(tool_name.unwrap(), line); - if badge.is_empty() { - writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?; - } else { - writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)?; - } - } - _ => { - writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?; - } - } - } - } - } - // result → green bold summary with running total + Some("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; @@ -392,12 +370,10 @@ fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior ORANGE, BOLD, RESET, cost, total, turns, dur_secs )?; } - // Non-JSON or unrecognized — dim passthrough - _ => { - if ev_type.is_none() && !line.trim().is_empty() { - writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?; - } + None if !line.trim().is_empty() => { + writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?; } + _ => {} } Ok(()) } @@ -434,13 +410,20 @@ fn format_summary_strip(state: &StreamState) -> String { format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET) } -/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. -/// Consumes the stream entirely — raw NDJSON is not written to disk. -/// `prior_total` is the accumulated cost from previous iterations. -/// Returns the cost of this iteration (from the `result` event). -pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> f64 { +/// Shared stream loop: reads lines from stdout, tees to log, calls processor per line, +/// prints a summary strip, and returns the iteration cost. +/// +/// Used by both Claude and OpenCode stream filters to avoid duplicating the +/// BufReader/signal-check/log-tee boilerplate. +pub fn run_stream_loop( + stdout: ChildStdout, + log_path: Option<&Path>, + state: &mut S, + mut process: impl FnMut(&mut dyn Write, &str, &mut S) -> io::Result<()>, + summarize: impl FnOnce(&S) -> Option, + get_cost: impl FnOnce(&S) -> f64, +) -> f64 { let reader = BufReader::new(stdout); - let mut state = StreamState::new(); let mut log_file = log_path.and_then(|p| { std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok(); std::fs::File::create(p).ok() @@ -462,22 +445,34 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: continue; } - // Tee raw NDJSON to log file if let Some(ref mut f) = log_file { let _ = writeln!(f, "{}", line); } - if process_line(&mut out, &line, &mut state, prior_total).is_err() { - break; // stdout broken (e.g. pipe closed) — stop gracefully + if process(&mut out, &line, state).is_err() { + break; } } - // Print iteration summary strip after streaming ends (before guards) - if state.turn_num > 0 { - let strip = format_summary_strip(&state); + if let Some(strip) = summarize(state) { let _ = writeln!(out, "{}", strip); } let _ = out.flush(); - state.iteration_cost + get_cost(state) +} + +/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. +/// `prior_total` is the accumulated cost from previous iterations. +/// Returns the cost of this iteration (from the `result` event). +pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> f64 { + let mut state = StreamState::new(); + run_stream_loop( + stdout, + log_path, + &mut 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.iteration_cost, + ) } diff --git a/src/stream_opencode.rs b/src/stream_opencode.rs index 0cb9303..6866b24 100644 --- a/src/stream_opencode.rs +++ b/src/stream_opencode.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::io::{self, BufRead, BufReader, Write}; +use std::io::{self, Write}; use std::path::Path; use std::process::ChildStdout; @@ -65,7 +65,7 @@ fn format_tool_call(tool_name: &str, input: &str) -> String { } } -fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState) -> io::Result<()> { +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 { @@ -167,43 +167,13 @@ fn format_summary_strip(state: &StreamState) -> String { } pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, _prior_total: f64) -> f64 { - let reader = BufReader::new(stdout); let mut state = StreamState::new(); - let mut log_file = log_path.and_then(|p| { - std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok(); - std::fs::File::create(p).ok() - }); - - let mut out = io::stdout().lock(); - - for line_result in reader.lines() { - if crate::signal::interrupted() { - break; - } - - let line = match line_result { - Ok(l) => l, - Err(_) => break, - }; - - if line.trim().is_empty() { - continue; - } - - if let Some(ref mut f) = log_file { - let _ = writeln!(f, "{}", line); - } - - if process_line(&mut out, &line, &mut state).is_err() { - break; - } - } - - if state.turn_num > 0 { - let strip = format_summary_strip(&state); - let _ = writeln!(out, "{}", strip); - } - - let _ = out.flush(); - state.iteration_cost + crate::stream::run_stream_loop( + stdout, + log_path, + &mut state, + |out, line, st| process_line(out, line, st), + |st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None }, + |st| st.iteration_cost, + ) }