stash
This commit is contained in:
parent
d065009aba
commit
ab135d9442
6 changed files with 992 additions and 920 deletions
189
behavioral-specification.md
Normal file
189
behavioral-specification.md
Normal 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.
|
||||||
300
src/config.rs
300
src/config.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
719
src/main.rs
719
src/main.rs
|
|
@ -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 ¤t_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 ¤t_paths {
|
|
||||||
let src = Path::new(path);
|
|
||||||
if src.exists() {
|
|
||||||
let dest = snapshot_dir.join(src.file_name().unwrap());
|
|
||||||
if let Err(e) = fs::copy(src, &dest) {
|
|
||||||
log_error(&format!("failed to snapshot {}: {}", path, e));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
log(&format!("snapshot: {} → {}", path, dest.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Delete files exclusive to current mode (not in target)
|
|
||||||
for path in current_paths.difference(&target_paths) {
|
|
||||||
let p = Path::new(path);
|
|
||||||
if p.exists() {
|
|
||||||
if let Err(e) = fs::remove_file(p) {
|
|
||||||
log_error(&format!("failed to remove {}: {}", path, e));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
log(&format!("removed: {}", path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check for a prior snapshot of the target mode
|
|
||||||
let restore_dir = PathBuf::from(format!(".loop/.modes/{}", target));
|
|
||||||
if restore_dir.exists() {
|
|
||||||
log(&format!("Restoring '{}' mode from snapshot...", target));
|
|
||||||
for (path, _) in &target_files {
|
|
||||||
let file_name = Path::new(path).file_name().unwrap();
|
|
||||||
let src = restore_dir.join(file_name);
|
|
||||||
if src.exists() {
|
|
||||||
if let Err(e) = fs::copy(&src, path) {
|
|
||||||
log_error(&format!("failed to restore {}: {}", path, e));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
log(&format!("restored: {}", path));
|
|
||||||
} else {
|
|
||||||
// File wasn't in the snapshot — create from template if missing
|
|
||||||
let p = Path::new(path);
|
|
||||||
if !p.exists() {
|
|
||||||
let (_, content) = target_files.iter().find(|(tp, _)| tp == path).unwrap();
|
|
||||||
if let Err(e) = fs::write(p, content) {
|
|
||||||
log_error(&format!("failed to write {}: {}", path, e));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
log(&format!("created: {}", path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove the restored snapshot directory
|
|
||||||
if let Err(e) = fs::remove_dir_all(&restore_dir) {
|
|
||||||
log_error(&format!("failed to remove {}: {}", restore_dir.display(), e));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 4. Fresh init for the target mode
|
|
||||||
log(&format!("Fresh init for '{}' mode...", target));
|
|
||||||
for (path, content) in &target_files {
|
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",
|
||||||
|
|
@ -1996,10 +1965,11 @@ fn main() {
|
||||||
}
|
}
|
||||||
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]));
|
||||||
|
|
@ -2008,10 +1978,11 @@ fn main() {
|
||||||
}
|
}
|
||||||
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()) {
|
||||||
|
|
@ -2038,16 +2009,17 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"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(),
|
||||||
|
|
@ -2059,11 +2031,11 @@ 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) => {
|
||||||
|
|
@ -2104,6 +2075,22 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
src/stash.rs
113
src/stash.rs
|
|
@ -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,76 +276,25 @@ 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.
|
||||||
pub(crate) fn stash_pop(mode: &str) -> i32 {
|
pub(crate) fn stash_pop(mode: &str) -> i32 {
|
||||||
|
|
|
||||||
173
src/stream.rs
173
src/stream.rs
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
// user → tool result summaries
|
|
||||||
Some("user") => {
|
/// Handle "user" events: render tool_result success/error badges.
|
||||||
if line.contains("\"tool_result\"") {
|
fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &StreamState) -> io::Result<()> {
|
||||||
let is_error = line.contains("\"is_error\":true")
|
if !line.contains("\"tool_result\"") {
|
||||||
|| line.contains("\"is_error\": true");
|
return Ok(());
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
|
/// Process a single NDJSON line, writing formatted output to `out`.
|
||||||
|
/// `prior_total` is the accumulated cost from previous iterations, used to display a running total.
|
||||||
|
/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop.
|
||||||
|
fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> {
|
||||||
|
// Check for turn boundary (message_id change)
|
||||||
|
if let Some(msg_id) = extract_str(line, "message_id") {
|
||||||
|
let is_new = state.current_msg_id.as_deref() != Some(msg_id);
|
||||||
|
if is_new {
|
||||||
|
state.current_msg_id = Some(msg_id.to_string());
|
||||||
|
state.turn_num += 1;
|
||||||
|
writeln!(out, "{}{}━━━ Turn {} ━━━{}", BOLD, ORANGE, state.turn_num, RESET)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match extract_str(line, "type") {
|
||||||
|
Some("system") => {
|
||||||
|
if extract_str(line, "subtype") == Some("init") && !state.seen_init {
|
||||||
|
state.seen_init = true;
|
||||||
|
let sid = extract_str(line, "session_id").unwrap_or("?");
|
||||||
|
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)?,
|
||||||
// result → green bold summary with running total
|
Some("stream_event") => handle_stream_event(out, line, state)?,
|
||||||
|
Some("user") => handle_tool_result(out, line, state)?,
|
||||||
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue