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.
|
||||||
|
|
|
||||||
985
src/main.rs
985
src/main.rs
File diff suppressed because it is too large
Load diff
133
src/stash.rs
133
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,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() {
|
let entry_dir = Path::new(STASH_DIR).join(&target.hash);
|
||||||
0 => {
|
if !entry_dir.exists() {
|
||||||
log_error(&format!("no stash entry matching '{}'", target_hash));
|
log_error("stash entry directory missing — index is corrupt");
|
||||||
1
|
return 1;
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
let target = matches[0];
|
|
||||||
let entry_dir = Path::new(STASH_DIR).join(&target.hash);
|
|
||||||
if !entry_dir.exists() {
|
|
||||||
log_error("stash entry directory missing — index is corrupt");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-stash current state (skip if .loop/ has no stashable files)
|
|
||||||
let current_files = collect_stashable_files();
|
|
||||||
if !current_files.is_empty() {
|
|
||||||
match stash_snapshot(mode) {
|
|
||||||
Ok(hash) => {
|
|
||||||
log(&format!(
|
|
||||||
"auto-stashed current state → {}{}{}",
|
|
||||||
BLUE, hash, RESET
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(msg) => {
|
|
||||||
log_error(&format!("failed to auto-stash: {}", msg));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove all stashable files from .loop/
|
|
||||||
for (name, _) in &collect_stashable_files() {
|
|
||||||
let path = Path::new(".loop").join(name);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore files from the target entry
|
|
||||||
if let Ok(dir_entries) = fs::read_dir(&entry_dir) {
|
|
||||||
for entry in dir_entries.flatten() {
|
|
||||||
let name = entry.file_name();
|
|
||||||
let dest = Path::new(".loop").join(&name);
|
|
||||||
if let Err(e) = fs::copy(entry.path(), &dest) {
|
|
||||||
log_error(&format!(
|
|
||||||
"failed to restore {}: {}",
|
|
||||||
name.to_string_lossy(),
|
|
||||||
e
|
|
||||||
));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(&format!("checked out {}{}{}", BLUE, target.hash, RESET));
|
|
||||||
0
|
|
||||||
}
|
|
||||||
n => {
|
|
||||||
log_error(&format!(
|
|
||||||
"ambiguous hash '{}' — matches {} entries",
|
|
||||||
target_hash, n
|
|
||||||
));
|
|
||||||
1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(msg) = swap_loop_files(&entry_dir, mode) {
|
||||||
|
log_error(&msg);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(&format!("checked out {}{}{}", BLUE, target.hash, RESET));
|
||||||
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `yoke stash pop` — checkout the most recent stash entry.
|
/// `yoke stash pop` — checkout the most recent stash entry.
|
||||||
|
|
|
||||||
255
src/stream.rs
255
src/stream.rs
|
|
@ -249,135 +249,113 @@ fn format_tool_call(line: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle "assistant" events: render tool_use summaries and track tool counts.
|
||||||
|
fn handle_assistant(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
|
||||||
|
if !line.contains("\"tool_use\"") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) {
|
||||||
|
state.tool_use_names.insert(id.to_string(), name.to_string());
|
||||||
|
*state.tool_counts.entry(name.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
let desc = format_tool_call(line);
|
||||||
|
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle "stream_event" events: render thinking timer, text deltas, and block boundaries.
|
||||||
|
fn handle_stream_event(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
|
||||||
|
if line.contains("\"content_block_delta\"") {
|
||||||
|
if line.contains("\"thinking_delta\"") {
|
||||||
|
if let Some(start) = state.thinking_start {
|
||||||
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
|
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
|
||||||
|
out.flush()?;
|
||||||
|
}
|
||||||
|
} else if line.contains("\"text_delta\"")
|
||||||
|
&& let Some(text) = extract_str(line, "text")
|
||||||
|
{
|
||||||
|
let text = unescape_json(text);
|
||||||
|
write!(out, "{}{}{}", DIM, text, RESET)?;
|
||||||
|
out.flush()?;
|
||||||
|
}
|
||||||
|
} else if line.contains("\"content_block_start\"") {
|
||||||
|
if line.contains("\"thinking\"") {
|
||||||
|
state.thinking_start = Some(Instant::now());
|
||||||
|
write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?;
|
||||||
|
out.flush()?;
|
||||||
|
state.in_thinking = true;
|
||||||
|
} else if !line.contains("\"tool_use\"") {
|
||||||
|
writeln!(out)?;
|
||||||
|
}
|
||||||
|
} else if line.contains("\"content_block_stop\"") {
|
||||||
|
if state.in_thinking {
|
||||||
|
if let Some(start) = state.thinking_start {
|
||||||
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
|
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
|
||||||
|
}
|
||||||
|
state.in_thinking = false;
|
||||||
|
state.thinking_start = None;
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle "user" events: render tool_result success/error badges.
|
||||||
|
fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &StreamState) -> io::Result<()> {
|
||||||
|
if !line.contains("\"tool_result\"") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let is_error = line.contains("\"is_error\":true") || line.contains("\"is_error\": true");
|
||||||
|
if is_error {
|
||||||
|
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
|
||||||
|
let tail = format_bash_error_tail(line);
|
||||||
|
if !tail.is_empty() {
|
||||||
|
writeln!(out, "{}", tail)?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let tool_name = extract_str(line, "tool_use_id")
|
||||||
|
.and_then(|id| state.tool_use_names.get(id))
|
||||||
|
.map(|s| s.as_str());
|
||||||
|
let badge = match tool_name {
|
||||||
|
Some(name @ ("Grep" | "Glob")) => format_grep_glob_badge(name, line),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
if badge.is_empty() {
|
||||||
|
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)
|
||||||
|
} else {
|
||||||
|
writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a single NDJSON line, writing formatted output to `out`.
|
/// 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.
|
/// `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.
|
/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop.
|
||||||
fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> {
|
fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> {
|
||||||
// Check for turn boundary (message_id change)
|
// Check for turn boundary (message_id change)
|
||||||
if let Some(msg_id) = extract_str(line, "message_id") {
|
if let Some(msg_id) = extract_str(line, "message_id") {
|
||||||
let changed = match &state.current_msg_id {
|
let is_new = state.current_msg_id.as_deref() != Some(msg_id);
|
||||||
Some(prev) => prev != msg_id,
|
if is_new {
|
||||||
None => true,
|
|
||||||
};
|
|
||||||
if changed {
|
|
||||||
state.current_msg_id = Some(msg_id.to_string());
|
state.current_msg_id = Some(msg_id.to_string());
|
||||||
state.turn_num += 1;
|
state.turn_num += 1;
|
||||||
writeln!(
|
writeln!(out, "{}{}━━━ Turn {} ━━━{}", BOLD, ORANGE, state.turn_num, RESET)?;
|
||||||
out,
|
|
||||||
"{}{}━━━ Turn {} ━━━{}",
|
|
||||||
BOLD, ORANGE, state.turn_num, RESET
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ev_type = extract_str(line, "type");
|
match extract_str(line, "type") {
|
||||||
|
|
||||||
match ev_type {
|
|
||||||
// system → check subtype for init
|
|
||||||
Some("system") => {
|
Some("system") => {
|
||||||
let ev_subtype = extract_str(line, "subtype");
|
if extract_str(line, "subtype") == Some("init") && !state.seen_init {
|
||||||
if ev_subtype == Some("init") && !state.seen_init {
|
|
||||||
state.seen_init = true;
|
state.seen_init = true;
|
||||||
let sid = extract_str(line, "session_id").unwrap_or("?");
|
let sid = extract_str(line, "session_id").unwrap_or("?");
|
||||||
let sid_short: String = sid.chars().take(12).collect();
|
let sid_short: String = sid.chars().take(12).collect();
|
||||||
let sid_short = sid_short.as_str();
|
|
||||||
let model = extract_str(line, "model").unwrap_or("?");
|
let model = extract_str(line, "model").unwrap_or("?");
|
||||||
writeln!(
|
writeln!(out, "{}{}[stream]{} session {}… model={}", ORANGE, BOLD, RESET, sid_short, model)?;
|
||||||
out,
|
|
||||||
"{}{}[stream]{} session {}… model={}",
|
|
||||||
ORANGE, BOLD, RESET, sid_short, model
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// assistant → only tool_use summaries (text already shown via stream_event deltas)
|
Some("assistant") => handle_assistant(out, line, state)?,
|
||||||
Some("assistant") => {
|
Some("stream_event") => handle_stream_event(out, line, state)?,
|
||||||
if line.contains("\"tool_use\"") {
|
Some("user") => handle_tool_result(out, line, state)?,
|
||||||
// Track tool_use id → name for badge display on tool_result
|
|
||||||
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) {
|
|
||||||
state.tool_use_names.insert(id.to_string(), name.to_string());
|
|
||||||
// Increment tool-use counter for iteration summary
|
|
||||||
*state.tool_counts.entry(name.to_string()).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
let desc = format_tool_call(line);
|
|
||||||
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// stream_event → streaming deltas
|
|
||||||
Some("stream_event") => {
|
|
||||||
if line.contains("\"content_block_delta\"") {
|
|
||||||
if line.contains("\"thinking_delta\"") {
|
|
||||||
// Show elapsed timer during extended thinking, rewriting in-place
|
|
||||||
if let Some(start) = state.thinking_start {
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
|
|
||||||
out.flush()?;
|
|
||||||
}
|
|
||||||
} else if line.contains("\"text_delta\"")
|
|
||||||
&& let Some(text) = extract_str(line, "text")
|
|
||||||
{
|
|
||||||
let text = unescape_json(text);
|
|
||||||
write!(out, "{}{}{}", DIM, text, RESET)?;
|
|
||||||
out.flush()?;
|
|
||||||
}
|
|
||||||
// input_json_delta → skip silently
|
|
||||||
} else if line.contains("\"content_block_start\"") {
|
|
||||||
if line.contains("\"thinking\"") {
|
|
||||||
state.thinking_start = Some(Instant::now());
|
|
||||||
write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?;
|
|
||||||
out.flush()?;
|
|
||||||
state.in_thinking = true;
|
|
||||||
} else if !line.contains("\"tool_use\"") {
|
|
||||||
writeln!(out)?;
|
|
||||||
}
|
|
||||||
} else if line.contains("\"content_block_stop\"") {
|
|
||||||
if state.in_thinking {
|
|
||||||
// Print final elapsed time and end the line
|
|
||||||
if let Some(start) = state.thinking_start {
|
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
|
||||||
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
|
|
||||||
}
|
|
||||||
state.in_thinking = false;
|
|
||||||
state.thinking_start = None;
|
|
||||||
}
|
|
||||||
writeln!(out)?;
|
|
||||||
}
|
|
||||||
// message_start, message_delta, message_stop → skip
|
|
||||||
}
|
|
||||||
// user → tool result summaries
|
|
||||||
Some("user") => {
|
|
||||||
if line.contains("\"tool_result\"") {
|
|
||||||
let is_error = line.contains("\"is_error\":true")
|
|
||||||
|| line.contains("\"is_error\": true");
|
|
||||||
if is_error {
|
|
||||||
let tail = format_bash_error_tail(line);
|
|
||||||
if tail.is_empty() {
|
|
||||||
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
|
|
||||||
} else {
|
|
||||||
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
|
|
||||||
writeln!(out, "{}", tail)?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if this is a Grep or Glob result for badge display
|
|
||||||
let tool_name = extract_str(line, "tool_use_id")
|
|
||||||
.and_then(|id| state.tool_use_names.get(id))
|
|
||||||
.map(|s| s.as_str());
|
|
||||||
match tool_name {
|
|
||||||
Some("Grep") | Some("Glob") => {
|
|
||||||
let badge = format_grep_glob_badge(tool_name.unwrap(), line);
|
|
||||||
if badge.is_empty() {
|
|
||||||
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
|
|
||||||
} else {
|
|
||||||
writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// result → green bold summary with running total
|
|
||||||
Some("result") => {
|
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() => {
|
||||||
_ => {
|
writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?;
|
||||||
if ev_type.is_none() && !line.trim().is_empty() {
|
|
||||||
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