This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-03-08 22:51:26 +07:00
parent d065009aba
commit ab135d9442
6 changed files with 992 additions and 920 deletions

189
behavioral-specification.md Normal file
View file

@ -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 <hash>` 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.

View file

@ -41,24 +41,126 @@ pub struct Config {
pub periodics: Vec<Periodic>, pub periodics: Vec<Periodic>,
} }
struct ConfigBuilder {
max_tail: usize,
log_dir: Option<String>,
image: Option<String>,
model: Option<String>,
scope_rules: Vec<ScopeRule>,
guards: Vec<String>,
judge_every: Option<u32>,
max_judge_failures: u32,
periodics: Vec<Periodic>,
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<u32, String> {
let n = value.parse::<u32>()
.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<Periodic, String> {
let trimmed = value.trim();
let split_pos = trimmed.rfind(char::is_whitespace)
.ok_or_else(|| cfg_err(path, line_num, "periodic requires '<path> <cadence>'"))?;
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 '<periodic-name> <command>'"))?;
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::<usize>()
.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<Config, String> {
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 { impl Config {
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find() #[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn load(path: &Path) -> Result<Config, String> { pub fn load(path: &Path) -> Result<Config, String> {
let content = fs::read_to_string(path) let content = fs::read_to_string(path)
.map_err(|e| format!("failed to read config {}: {}", path.display(), e))?; .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
let mut max_tail: usize = 200; let mut builder = ConfigBuilder::new();
let mut log_dir: Option<String> = None;
let mut image: Option<String> = None;
let mut model: Option<String> = None;
let mut scope_rules = Vec::new();
let mut guards = Vec::new();
let mut judge_every: Option<u32> = 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)
for (line_num, raw_line) in content.lines().enumerate() { for (line_num, raw_line) in content.lines().enumerate() {
// Strip comments
let line = match raw_line.find('#') { let line = match raw_line.find('#') {
Some(pos) => &raw_line[..pos], Some(pos) => &raw_line[..pos],
None => raw_line, None => raw_line,
@ -68,187 +170,17 @@ impl Config {
continue; continue;
} }
// Split into directive and value at first whitespace
let (directive, value) = match line.find(char::is_whitespace) { let (directive, value) = match line.find(char::is_whitespace) {
Some(pos) => (&line[..pos], line[pos..].trim_start()), Some(pos) => (&line[..pos], line[pos..].trim_start()),
None => { None => {
return Err(format!( return Err(cfg_err(path, line_num + 1, &format!("directive '{}' has no value", line)));
"{}:{}: directive '{}' has no value",
path.display(),
line_num + 1,
line
));
} }
}; };
match directive { builder.parse_line(directive, value, path, line_num + 1)?;
"max-tail" => {
max_tail = value.parse::<usize>().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::<u32>().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::<u32>().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> <cadence>'",
path.display(),
line_num + 1
)
})?;
let ppath = trimmed[..split_pos].trim();
let cadence_str = trimmed[split_pos..].trim();
let cadence = cadence_str.parse::<u32>().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 '<periodic-name> <command>'",
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
));
}
}
} }
// Attach guard-after commands to their matching periodics builder.build(path)
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,
})
} }
/// Resolve the most-specific scope tag for a file path. /// Resolve the most-specific scope tag for a file path.

View file

@ -41,6 +41,7 @@ const BRIEFING_PATH: &str = ".loop/briefing.md";
const SAGA_PROTOCOL_PATH: &str = ".loop/saga-protocol.md"; const SAGA_PROTOCOL_PATH: &str = ".loop/saga-protocol.md";
const SPECIFICATION_PATH: &str = ".loop/specification.md"; const SPECIFICATION_PATH: &str = ".loop/specification.md";
const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md"; const SAGA_NOTES_PATH: &str = ".loop/saga-notes.md";
const SAGA_LOG_PATH: &str = ".loop/saga-log.md";
const DECISIONS_PATH: &str = ".loop/decisions.md"; const DECISIONS_PATH: &str = ".loop/decisions.md";
const SUB_PLAN_PATH: &str = ".loop/sub-plan.md"; const SUB_PLAN_PATH: &str = ".loop/sub-plan.md";
pub(crate) const STASH_DIR: &str = ".loop/.stash"; pub(crate) const STASH_DIR: &str = ".loop/.stash";
@ -333,9 +334,8 @@ fn print_init_help() {
eprintln!("Existing files are never overwritten."); eprintln!("Existing files are never overwritten.");
eprintln!(); eprintln!();
eprintln!("{}MODE SWITCHING:{}", BOLD, RESET); eprintln!("{}MODE SWITCHING:{}", BOLD, RESET);
eprintln!(" Switching modes automatically snapshots the current mode's files"); eprintln!(" Switching modes stashes the current .loop/ state and creates fresh");
eprintln!(" into .loop/.modes/<mode>/ and restores any prior snapshot of the"); eprintln!(" target mode files. Restore a previous session with: yoke stash checkout");
eprintln!(" target mode. This lets you bounce between modes without losing work.");
} }
/// Holds loop state and cleans up on drop. /// Holds loop state and cleans up on drop.
@ -466,22 +466,8 @@ fn reset_notes_status() {
} }
} }
/// Build a Command for invoking the agent backend. /// Build a docker command that runs the claude CLI inside a container.
/// For Claude backend: uses `claude` CLI, optionally via docker. fn build_docker_claude_command(image: &str, claude_args: &[&str]) -> Command {
/// For OpenCode backend: uses `opencode run --format json --model <model>`.
fn build_command(config: &Config, prompt: &str) -> Command {
match config.backend() {
Backend::Claude => {
let claude_args = [
"--verbose",
"--output-format",
"stream-json",
"--include-partial-messages",
"--dangerously-skip-permissions",
"-p",
prompt,
];
if let Some(ref image) = config.image {
let workdir = std::env::current_dir() let workdir = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from(".")) .unwrap_or_else(|_| PathBuf::from("."))
.to_string_lossy() .to_string_lossy()
@ -524,15 +510,33 @@ fn build_command(config: &Config, prompt: &str) -> Command {
} }
} }
c.arg(image.as_str()); c.arg(image);
c.args(claude_args); c.args(claude_args);
c c
} else { }
/// Build a Command for invoking the agent backend.
fn build_command(config: &Config, prompt: &str) -> Command {
match config.backend() {
Backend::Claude => {
let claude_args = [
"--verbose",
"--output-format",
"stream-json",
"--include-partial-messages",
"--dangerously-skip-permissions",
"-p",
prompt,
];
match config.image {
Some(ref image) => build_docker_claude_command(image, &claude_args),
None => {
let mut c = Command::new("claude"); let mut c = Command::new("claude");
c.args(claude_args); c.args(claude_args);
c c
} }
} }
}
Backend::OpenCode => { Backend::OpenCode => {
let model = config.model.as_ref().expect("model must be set for OpenCode backend"); let model = config.model.as_ref().expect("model must be set for OpenCode backend");
let mut c = Command::new("opencode"); let mut c = Command::new("opencode");
@ -995,8 +999,6 @@ fn print_clean_help() {
eprintln!("Structural files are left untouched:"); eprintln!("Structural files are left untouched:");
eprintln!(" protocol.md, saga-protocol.md, yoke.conf, briefing.md, specification.md"); eprintln!(" protocol.md, saga-protocol.md, yoke.conf, briefing.md, specification.md");
eprintln!(); 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!("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);
} }
@ -1080,6 +1082,7 @@ fn mode_files(mode: &str) -> Option<Vec<(&'static str, &'static str)>> {
(JUDGE_PATH, DEFAULT_SAGA_JUDGE), (JUDGE_PATH, DEFAULT_SAGA_JUDGE),
(SPECIFICATION_PATH, ""), (SPECIFICATION_PATH, ""),
(SAGA_NOTES_PATH, ""), (SAGA_NOTES_PATH, ""),
(SAGA_LOG_PATH, ""),
(DECISIONS_PATH, ""), (DECISIONS_PATH, ""),
(SUB_PLAN_PATH, ""), (SUB_PLAN_PATH, ""),
(NOTES_PATH, ""), (NOTES_PATH, ""),
@ -1106,105 +1109,37 @@ fn detect_mode() -> Option<&'static str> {
} }
} }
/// Backup current mode's files into `.loop/.modes/<current>/`, then either /// Stash current mode's files, clear `.loop/`, and fresh-init the target mode.
/// restore from a prior snapshot of the target mode or do a fresh init.
fn switch_mode(current: &str, target: &str) -> i32 { fn switch_mode(current: &str, target: &str) -> i32 {
use std::collections::HashSet; // 1. Stash current state
match stash::stash_snapshot(current) {
Ok(hash) => {
log(&format!(
"stashed {} state \u{2192} {}{}{}",
current, BLUE, hash, RESET
));
}
Err(msg) => {
log_error(&format!("failed to stash current state: {}", msg));
return 1;
}
}
let current_files = mode_files(current).unwrap(); // 2. Remove all non-dot files from .loop/
let current_files = stash::collect_stashable_files();
for (name, _) in &current_files {
let _ = fs::remove_file(Path::new(".loop").join(name));
}
// 3. Fresh-init target mode
let target_files = mode_files(target).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/<current>/
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 &current_paths {
let src = Path::new(path);
if src.exists() {
let dest = snapshot_dir.join(src.file_name().unwrap());
if let Err(e) = fs::copy(src, &dest) {
log_error(&format!("failed to snapshot {}: {}", path, e));
return 1;
}
log(&format!("snapshot: {} → {}", path, dest.display()));
}
}
// 2. Delete files exclusive to current mode (not in target)
for path in current_paths.difference(&target_paths) {
let p = Path::new(path);
if p.exists() {
if let Err(e) = fs::remove_file(p) {
log_error(&format!("failed to remove {}: {}", path, e));
return 1;
}
log(&format!("removed: {}", path));
}
}
// 3. Check for a prior snapshot of the target mode
let restore_dir = PathBuf::from(format!(".loop/.modes/{}", target));
if restore_dir.exists() {
log(&format!("Restoring '{}' mode from snapshot...", target));
for (path, _) in &target_files {
let file_name = Path::new(path).file_name().unwrap();
let src = restore_dir.join(file_name);
if src.exists() {
if let Err(e) = fs::copy(&src, path) {
log_error(&format!("failed to restore {}: {}", path, e));
return 1;
}
log(&format!("restored: {}", path));
} else {
// File wasn't in the snapshot — create from template if missing
let p = Path::new(path);
if !p.exists() {
let (_, content) = target_files.iter().find(|(tp, _)| tp == path).unwrap();
if let Err(e) = fs::write(p, content) {
log_error(&format!("failed to write {}: {}", path, e));
return 1;
}
log(&format!("created: {}", path));
}
}
}
// Remove the restored snapshot directory
if let Err(e) = fs::remove_dir_all(&restore_dir) {
log_error(&format!("failed to remove {}: {}", restore_dir.display(), e));
return 1;
}
} else {
// 4. Fresh init for the target mode
log(&format!("Fresh init for '{}' mode...", target));
for (path, content) in &target_files { for (path, content) in &target_files {
let p = Path::new(path); if let Err(e) = fs::write(path, content) {
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)); log_error(&format!("failed to write {}: {}", path, e));
return 1; return 1;
} }
log(&format!("created: {}", path)); log(&format!("created: {}", path));
} }
}
}
log(&format!("Switched from '{}' to '{}' mode", current, target)); log(&format!("Switched from '{}' to '{}' mode", current, target));
0 0
@ -1343,23 +1278,13 @@ fn evaluate_judge_every(
JudgeEveryAction::Continue JudgeEveryAction::Continue
} }
/// Core plan-loop runner that can be called standalone or nested inside brute. /// Validate config preconditions for the plan loop. Exits on failure.
/// fn validate_loop_config(config: &Config) {
/// - `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
if config.judge_every.is_some() && !Path::new(JUDGE_PATH).exists() { if config.judge_every.is_some() && !Path::new(JUDGE_PATH).exists() {
log_error("judge-every is set but .loop/judge.md not found"); log_error("judge-every is set but .loop/judge.md not found");
process::exit(1); process::exit(1);
} }
// Validate periodic protocol paths exist and are non-empty
for periodic in &config.periodics { for periodic in &config.periodics {
let p = Path::new(&periodic.path); let p = Path::new(&periodic.path);
if !p.exists() { 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"); 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]; let mut protected: Vec<&str> = vec![PROTOCOL_PATH, plan_path, CONF_PATH];
// Include periodic protocol paths in the protected set
let periodic_paths: Vec<String> = config.periodics.iter().map(|p| p.path.clone()).collect(); let periodic_paths: Vec<String> = config.periodics.iter().map(|p| p.path.clone()).collect();
for pp in &periodic_paths { for pp in &periodic_paths {
protected.push(pp.as_str()); protected.push(pp.as_str());
} }
// Backup protected files and create the runner (Drop handles cleanup)
let backup_dir = backup_files(&protected); let backup_dir = backup_files(&protected);
log(&format!("Backups in {}", backup_dir.display())); log(&format!("Backups in {}", backup_dir.display()));
let mut runner = LoopRunner::new(backup_dir); let mut runner = LoopRunner::new(backup_dir);
let mut iteration: u32 = 0; let mut iteration: u32 = 0;
let mut total_cost: f64 = 0.0; let mut total_cost: f64 = 0.0;
let mut consecutive_judge_failures: u32 = 0; let mut consecutive_judge_failures: u32 = 0;
loop { 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" }; let label = if nested { "Plan Iteration" } else { "Iteration" };
render_iteration_banner(label, iteration, nested); render_iteration_banner(label, iteration, nested);
// Show stage progress bar if plan has parseable stages
if let Some((completed, total)) = stage_progress(plan_path) { if let Some((completed, total)) = stage_progress(plan_path) {
eprintln!("{}", format_progress_bar(completed, total)); eprintln!("{}", format_progress_bar(completed, total));
} }
// Restore protected files
restore_files(&runner.backup_dir, &protected); restore_files(&runner.backup_dir, &protected);
// NOTE: Do NOT clear guard-results.md here. The previous iteration's let guards_passed = match run_iteration_step(&mut runner, config, iteration, total_cost, dry_run) {
// guard results must persist so Claude can read them and fix failures. IterationStepResult::Continue { guards_passed, cost } => {
// The file is initialized to empty in preflight() (first iteration) total_cost += cost;
// and overwritten by run_all_guards() after Claude finishes. guards_passed
}
IterationStepResult::Terminal(outcome) => return outcome,
};
if dry_run { fire_periodic_agents(&mut runner, config, iteration);
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;
}
}
if signal::interrupted() {
log("Interrupted \u{2014} shutting down");
return PlanLoopOutcome::Interrupt;
}
// 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 { if let Some(judge_every) = config.judge_every {
match evaluate_judge_every( match evaluate_judge_every(
&mut runner, config, iteration, guards_passed, &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) { if guards_passed && is_status_done(NOTES_PATH) {
eprintln!(); eprintln!();
eprintln!( eprintln!(
@ -1514,7 +1468,6 @@ fn run_plan_loop(config: &Config, plan_path: &str, dry_run: bool, nested: bool)
eprintln!(); eprintln!();
return PlanLoopOutcome::Done; 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, /// 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). /// 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<i32> {
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 { fn run_saga(dry_run: bool) -> i32 {
// Load config
let config = match Config::load(Path::new(CONF_PATH)) { let config = match Config::load(Path::new(CONF_PATH)) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@ -1800,56 +1913,8 @@ fn run_saga(dry_run: bool) -> i32 {
) )
)); ));
// Preflight: verify required saga files exist 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);
}
}
// 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![ let saga_protected: Vec<&str> = vec![
SAGA_PROTOCOL_PATH, SAGA_PROTOCOL_PATH,
PROTOCOL_PATH, PROTOCOL_PATH,
@ -1872,112 +1937,16 @@ fn run_saga(dry_run: bool) -> i32 {
eprintln!(); eprintln!();
render_iteration_banner("Saga Cycle", cycle, false); render_iteration_banner("Saga Cycle", cycle, false);
// Restore protected files if let Some(exit_code) = run_saga_cycle(&mut runner, &config, cycle, dry_run, &saga_protected) {
restore_files(&runner.backup_dir, &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,
);
let scoper_ok = invoke_scoper(&mut runner, &config, cycle);
if !scoper_ok {
log_error("Scoper invocation failed \u{2014} aborting saga");
return 1;
}
if signal::interrupted() {
log("Interrupted \u{2014} shutting down");
return 130;
}
}
// Check if scoper signaled DONE
if is_status_done(SAGA_NOTES_PATH) {
eprintln!();
eprintln!(
"{}{} Scoper signals DONE \u{2014} saga complete {}",
GREEN, BOLD, RESET
);
eprintln!();
return 0;
}
// Verify sub-plan.md is non-empty
match fs::read_to_string(SUB_PLAN_PATH) {
Ok(content) if content.trim().is_empty() => {
log_error("Scoper produced an empty sub-plan.md \u{2014} aborting saga");
return 1;
}
Err(e) => {
log_error(&format!("cannot read {}: {}", SUB_PLAN_PATH, e));
return 1;
}
_ => {}
}
if dry_run {
log("(dry-run) Skipping brute loop on sub-plan.md");
log("(dry-run) Exiting after one cycle");
return 0;
}
// Run the brute loop on sub-plan.md
log("Running brute loop on sub-plan.md...");
// Clear notes.md, verdict.md, and guard-results.md for the inner brute cycle
let _ = fs::write(NOTES_PATH, "");
let _ = fs::write(VERDICT_PATH, "");
let _ = fs::write(GUARD_RESULTS_PATH, "");
let brute_result = run_brute_core(&config, SUB_PLAN_PATH, false);
match brute_result {
BruteResult::Pass => {
log(&format!(
"{}Sub-plan passed \u{2014} looping back to scoper{}",
GREEN, RESET
));
}
BruteResult::Bailout => {
log(&format!(
"{}Brute loop bailed out \u{2014} looping back to scoper for re-scoping{}",
ORANGE, RESET
));
// verdict.md and notes.md are preserved — scoper will read them
}
BruteResult::Interrupt => {
log("Interrupted \u{2014} shutting down");
return 130;
}
BruteResult::Error => {
log_error("Brute loop encountered a hard error \u{2014} aborting saga");
return 1;
}
} }
} }
} }
fn main() { fn cmd_init(args: &[String]) -> ! {
signal::install();
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(2);
}
match args[1].as_str() {
"init" => {
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
print_init_help(); print_init_help();
return; process::exit(0);
} }
let mode = match args.get(2).map(|a| a.as_str()) { let mode = match args.get(2).map(|a| a.as_str()) {
None => "loop", None => "loop",
@ -1995,11 +1964,12 @@ fn main() {
process::exit(2); process::exit(2);
} }
process::exit(init(mode)); process::exit(init(mode));
} }
"clean" => {
fn cmd_clean(args: &[String]) -> ! {
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
print_clean_help(); print_clean_help();
return; process::exit(0);
} }
if args.len() > 2 { if args.len() > 2 {
log_error(&format!("unexpected argument '{}'", args[2])); log_error(&format!("unexpected argument '{}'", args[2]));
@ -2007,11 +1977,12 @@ fn main() {
process::exit(2); process::exit(2);
} }
process::exit(clean()); process::exit(clean());
} }
"stash" => {
fn cmd_stash(args: &[String]) -> ! {
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
stash::print_stash_help(); stash::print_stash_help();
return; process::exit(0);
} }
let mode = detect_mode().unwrap_or("unknown"); let mode = detect_mode().unwrap_or("unknown");
match args.get(2).map(|a| a.as_str()) { match args.get(2).map(|a| a.as_str()) {
@ -2037,17 +2008,18 @@ fn main() {
process::exit(2); process::exit(2);
} }
} }
} }
"layer" => {
fn cmd_layer(args: &[String]) -> ! {
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
print_layer_help(); print_layer_help();
return; process::exit(0);
} }
if args.get(2).is_some_and(|a| a == "--list") { if args.get(2).is_some_and(|a| a == "--list") {
for name in list_layers() { for name in list_layers() {
println!(" {}", name); println!(" {}", name);
} }
return; process::exit(0);
} }
let names_arg = match args.get(2) { let names_arg = match args.get(2) {
Some(a) => a.clone(), Some(a) => a.clone(),
@ -2058,12 +2030,12 @@ fn main() {
} }
}; };
process::exit(apply_layers(&names_arg)); process::exit(apply_layers(&names_arg));
} }
"run" => {
// Check for --help before other flags fn cmd_run(args: &[String]) -> ! {
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
print_run_help(); print_run_help();
return; process::exit(0);
} }
let mut dry_run = false; let mut dry_run = false;
let mut no_sandbox = false; let mut no_sandbox = false;
@ -2078,7 +2050,6 @@ fn main() {
} }
} }
} }
// Load config to determine runner type
let config = match Config::load(Path::new(CONF_PATH)) { let config = match Config::load(Path::new(CONF_PATH)) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@ -2103,7 +2074,23 @@ fn main() {
process::exit(run_loop(dry_run)) process::exit(run_loop(dry_run))
} }
} }
}
fn main() {
signal::install();
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(2);
} }
match args[1].as_str() {
"init" => cmd_init(&args),
"clean" => cmd_clean(&args),
"stash" => cmd_stash(&args),
"layer" => cmd_layer(&args),
"run" => cmd_run(&args),
"--help" | "-h" | "help" => { "--help" | "-h" | "help" => {
print_usage(); print_usage();
} }

View file

@ -213,6 +213,56 @@ pub(crate) fn stash_log_cmd() -> i32 {
0 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 <hash>` — auto-stash current state, clear `.loop/` files, /// `yoke stash checkout <hash>` — auto-stash current state, clear `.loop/` files,
/// restore target entry. Supports prefix matching. /// restore target entry. Supports prefix matching.
pub(crate) fn stash_checkout(target_hash: &str, mode: &str) -> i32 { 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; return 1;
} }
// Find matching entries (exact or prefix) let target = match resolve_stash_entry(&entries, target_hash) {
let matches: Vec<&StashEntry> = entries Some(t) => t,
.iter() None => return 1,
.filter(|e| e.hash == target_hash || e.hash.starts_with(target_hash)) };
.collect();
match matches.len() {
0 => {
log_error(&format!("no stash entry matching '{}'", target_hash));
1
}
1 => {
let target = matches[0];
let entry_dir = Path::new(STASH_DIR).join(&target.hash); let entry_dir = Path::new(STASH_DIR).join(&target.hash);
if !entry_dir.exists() { if !entry_dir.exists() {
log_error("stash entry directory missing — index is corrupt"); log_error("stash entry directory missing — index is corrupt");
return 1; return 1;
} }
// Auto-stash current state (skip if .loop/ has no stashable files) if let Err(msg) = swap_loop_files(&entry_dir, mode) {
let current_files = collect_stashable_files(); log_error(&msg);
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; 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)); log(&format!("checked out {}{}{}", BLUE, target.hash, RESET));
0 0
}
n => {
log_error(&format!(
"ambiguous hash '{}' — matches {} entries",
target_hash, n
));
1
}
}
} }
/// `yoke stash pop` — checkout the most recent stash entry. /// `yoke stash pop` — checkout the most recent stash entry.

View file

@ -249,64 +249,23 @@ fn format_tool_call(line: &str) -> String {
} }
} }
/// Process a single NDJSON line, writing formatted output to `out`. /// Handle "assistant" events: render tool_use summaries and track tool counts.
/// `prior_total` is the accumulated cost from previous iterations, used to display a running total. fn handle_assistant(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop. if !line.contains("\"tool_use\"") {
fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> { return Ok(());
// Check for turn boundary (message_id change)
if let Some(msg_id) = extract_str(line, "message_id") {
let changed = match &state.current_msg_id {
Some(prev) => prev != msg_id,
None => true,
};
if changed {
state.current_msg_id = Some(msg_id.to_string());
state.turn_num += 1;
writeln!(
out,
"{}{}━━━ Turn {} ━━━{}",
BOLD, ORANGE, state.turn_num, RESET
)?;
} }
}
let ev_type = extract_str(line, "type");
match ev_type {
// system → check subtype for init
Some("system") => {
let ev_subtype = extract_str(line, "subtype");
if ev_subtype == Some("init") && !state.seen_init {
state.seen_init = true;
let sid = extract_str(line, "session_id").unwrap_or("?");
let sid_short: String = sid.chars().take(12).collect();
let sid_short = sid_short.as_str();
let model = extract_str(line, "model").unwrap_or("?");
writeln!(
out,
"{}{}[stream]{} session {}… model={}",
ORANGE, BOLD, RESET, sid_short, model
)?;
}
}
// assistant → only tool_use summaries (text already shown via stream_event deltas)
Some("assistant") => {
if line.contains("\"tool_use\"") {
// Track tool_use id → name for badge display on tool_result
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) { 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_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; *state.tool_counts.entry(name.to_string()).or_insert(0) += 1;
} }
let desc = format_tool_call(line); let desc = format_tool_call(line);
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?; writeln!(out, " {}>>{} {}", GRAY, RESET, desc)
} }
}
// stream_event → streaming deltas /// Handle "stream_event" events: render thinking timer, text deltas, and block boundaries.
Some("stream_event") => { 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("\"content_block_delta\"") {
if line.contains("\"thinking_delta\"") { if line.contains("\"thinking_delta\"") {
// Show elapsed timer during extended thinking, rewriting in-place
if let Some(start) = state.thinking_start { if let Some(start) = state.thinking_start {
let elapsed = start.elapsed().as_secs_f64(); let elapsed = start.elapsed().as_secs_f64();
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
@ -319,7 +278,6 @@ fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior
write!(out, "{}{}{}", DIM, text, RESET)?; write!(out, "{}{}{}", DIM, text, RESET)?;
out.flush()?; out.flush()?;
} }
// input_json_delta → skip silently
} else if line.contains("\"content_block_start\"") { } else if line.contains("\"content_block_start\"") {
if line.contains("\"thinking\"") { if line.contains("\"thinking\"") {
state.thinking_start = Some(Instant::now()); state.thinking_start = Some(Instant::now());
@ -331,7 +289,6 @@ fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior
} }
} else if line.contains("\"content_block_stop\"") { } else if line.contains("\"content_block_stop\"") {
if state.in_thinking { if state.in_thinking {
// Print final elapsed time and end the line
if let Some(start) = state.thinking_start { if let Some(start) = state.thinking_start {
let elapsed = start.elapsed().as_secs_f64(); let elapsed = start.elapsed().as_secs_f64();
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?; write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
@ -341,43 +298,64 @@ fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior
} }
writeln!(out)?; writeln!(out)?;
} }
// message_start, message_delta, message_stop → skip 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(());
} }
// user → tool result summaries let is_error = line.contains("\"is_error\":true") || line.contains("\"is_error\": true");
Some("user") => {
if line.contains("\"tool_result\"") {
let is_error = line.contains("\"is_error\":true")
|| line.contains("\"is_error\": true");
if is_error { if is_error {
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
let tail = format_bash_error_tail(line); let tail = format_bash_error_tail(line);
if tail.is_empty() { if !tail.is_empty() {
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
} else {
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
writeln!(out, "{}", tail)?; writeln!(out, "{}", tail)?;
} }
} else { return Ok(());
// Check if this is a Grep or Glob result for badge display }
let tool_name = extract_str(line, "tool_use_id") let tool_name = extract_str(line, "tool_use_id")
.and_then(|id| state.tool_use_names.get(id)) .and_then(|id| state.tool_use_names.get(id))
.map(|s| s.as_str()); .map(|s| s.as_str());
match tool_name { let badge = match tool_name {
Some("Grep") | Some("Glob") => { Some(name @ ("Grep" | "Glob")) => format_grep_glob_badge(name, line),
let badge = format_grep_glob_badge(tool_name.unwrap(), line); _ => String::new(),
};
if badge.is_empty() { if badge.is_empty() {
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?; writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)
} else { } else {
writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)?; 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)?;
} }
} }
_ => {
writeln!(out, " {}← {}✓{}", GRAY, GREEN, 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("?");
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)?,
// result → green bold summary with running total
Some("result") => { Some("result") => {
let cost = extract_num(line, "cost_usd").unwrap_or(0.0); let cost = extract_num(line, "cost_usd").unwrap_or(0.0);
state.iteration_cost = cost; 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 ORANGE, BOLD, RESET, cost, total, turns, dur_secs
)?; )?;
} }
// Non-JSON or unrecognized — dim passthrough None if !line.trim().is_empty() => {
_ => {
if ev_type.is_none() && !line.trim().is_empty() {
writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?; writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?;
} }
} _ => {}
} }
Ok(()) Ok(())
} }
@ -434,13 +410,20 @@ fn format_summary_strip(state: &StreamState) -> String {
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET) format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
} }
/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. /// Shared stream loop: reads lines from stdout, tees to log, calls processor per line,
/// Consumes the stream entirely — raw NDJSON is not written to disk. /// prints a summary strip, and returns the iteration cost.
/// `prior_total` is the accumulated cost from previous iterations. ///
/// Returns the cost of this iteration (from the `result` event). /// Used by both Claude and OpenCode stream filters to avoid duplicating the
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> f64 { /// BufReader/signal-check/log-tee boilerplate.
pub fn run_stream_loop<S>(
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<String>,
get_cost: impl FnOnce(&S) -> f64,
) -> f64 {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
let mut state = StreamState::new();
let mut log_file = log_path.and_then(|p| { let mut log_file = log_path.and_then(|p| {
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok(); std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
std::fs::File::create(p).ok() std::fs::File::create(p).ok()
@ -462,22 +445,34 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total:
continue; continue;
} }
// Tee raw NDJSON to log file
if let Some(ref mut f) = log_file { if let Some(ref mut f) = log_file {
let _ = writeln!(f, "{}", line); let _ = writeln!(f, "{}", line);
} }
if process_line(&mut out, &line, &mut state, prior_total).is_err() { if process(&mut out, &line, state).is_err() {
break; // stdout broken (e.g. pipe closed) — stop gracefully break;
} }
} }
// Print iteration summary strip after streaming ends (before guards) if let Some(strip) = summarize(state) {
if state.turn_num > 0 {
let strip = format_summary_strip(&state);
let _ = writeln!(out, "{}", strip); let _ = writeln!(out, "{}", strip);
} }
let _ = out.flush(); 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,
)
} }

View file

@ -1,5 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{self, BufRead, BufReader, Write}; use std::io::{self, Write};
use std::path::Path; use std::path::Path;
use std::process::ChildStdout; 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"); let ev_type = extract_str(line, "type");
match ev_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 { 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 state = StreamState::new();
let mut log_file = log_path.and_then(|p| { crate::stream::run_stream_loop(
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok(); stdout,
std::fs::File::create(p).ok() log_path,
}); &mut state,
|out, line, st| process_line(out, line, st),
let mut out = io::stdout().lock(); |st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None },
|st| st.iteration_cost,
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
} }