feat: initial yoke grind scenario utilizing our code complexity tool
This commit is contained in:
parent
fa65ddb605
commit
5197d677ba
26 changed files with 1866 additions and 1936 deletions
|
|
@ -115,8 +115,8 @@ pub fn check(config: &Config) -> BoundaryResult {
|
|||
|
||||
for file in &changed {
|
||||
// Skip .loop/ files — they are harness infrastructure, not user code.
|
||||
// The protocol requires Claude to write notes.md, and the harness
|
||||
// itself writes guard-results.md and verdict.md.
|
||||
// The agent writes notes.md, and the harness itself writes
|
||||
// guard-results.md and verdict.md.
|
||||
if file.starts_with(".loop/") {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
181
src/config.rs
181
src/config.rs
|
|
@ -1,28 +1,24 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Backend {
|
||||
Claude,
|
||||
OpenCode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Thinking {
|
||||
Off,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
}
|
||||
|
||||
impl Thinking {
|
||||
/// MAX_THINKING_TOKENS value to forward to the Claude CLI.
|
||||
pub fn max_tokens(self) -> u32 {
|
||||
/// Value to forward to `omp --thinking`.
|
||||
pub fn as_omp_arg(self) -> &'static str {
|
||||
match self {
|
||||
Thinking::Off => 0,
|
||||
Thinking::Low => 2000,
|
||||
Thinking::Medium => 10000,
|
||||
Thinking::High => 32000,
|
||||
Thinking::Off => "off",
|
||||
Thinking::Low => "low",
|
||||
Thinking::Medium => "medium",
|
||||
Thinking::High => "high",
|
||||
Thinking::XHigh => "xhigh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,8 +38,8 @@ pub struct ScopeRule {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Periodic {
|
||||
pub path: String, // e.g., ".loop/cleaner.md"
|
||||
pub name: String, // derived from filename stem: "cleaner"
|
||||
pub path: String, // e.g., ".loop/cleaner.md"
|
||||
pub name: String, // derived from filename stem: "cleaner"
|
||||
pub cadence: u32,
|
||||
pub guards: Vec<String>, // from guard-after directives
|
||||
}
|
||||
|
|
@ -54,7 +50,6 @@ pub struct Config {
|
|||
pub log_dir: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub claude_model: Option<String>,
|
||||
pub thinking: Option<Thinking>,
|
||||
pub scope_rules: Vec<ScopeRule>,
|
||||
pub guards: Vec<String>,
|
||||
|
|
@ -73,7 +68,6 @@ struct ConfigBuilder {
|
|||
log_dir: Option<String>,
|
||||
image: Option<String>,
|
||||
model: Option<String>,
|
||||
claude_model: Option<String>,
|
||||
thinking: Option<Thinking>,
|
||||
scope_rules: Vec<ScopeRule>,
|
||||
guards: Vec<String>,
|
||||
|
|
@ -90,9 +84,19 @@ fn cfg_err(path: &Path, line_num: usize, msg: &str) -> String {
|
|||
format!("{}:{}: {}", path.display(), line_num, msg)
|
||||
}
|
||||
|
||||
fn parse_positive_u32(value: &str, path: &Path, line_num: usize, label: &str) -> Result<u32, String> {
|
||||
let n = value.parse::<u32>()
|
||||
.map_err(|_| cfg_err(path, line_num, &format!("invalid {} value '{}'", label, value)))?;
|
||||
fn parse_positive_u32(
|
||||
value: &str,
|
||||
path: &Path,
|
||||
line_num: usize,
|
||||
label: &str,
|
||||
) -> Result<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)));
|
||||
}
|
||||
|
|
@ -102,23 +106,49 @@ fn parse_positive_u32(value: &str, path: &Path, line_num: usize, label: &str) ->
|
|||
#[allow(clippy::string_slice)]
|
||||
fn parse_periodic(value: &str, path: &Path, line_num: usize) -> Result<Periodic, String> {
|
||||
let trimmed = value.trim();
|
||||
let split_pos = trimmed.rfind(char::is_whitespace)
|
||||
let split_pos = trimmed
|
||||
.rfind(char::is_whitespace)
|
||||
.ok_or_else(|| cfg_err(path, line_num, "periodic requires '<path> <cadence>'"))?;
|
||||
let ppath = trimmed[..split_pos].trim();
|
||||
let cadence = parse_positive_u32(trimmed[split_pos..].trim(), path, line_num, "periodic cadence")?;
|
||||
let cadence = parse_positive_u32(
|
||||
trimmed[split_pos..].trim(),
|
||||
path,
|
||||
line_num,
|
||||
"periodic cadence",
|
||||
)?;
|
||||
let name = Path::new(ppath)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| cfg_err(path, line_num, &format!("cannot derive name from periodic path '{}'", ppath)))?
|
||||
.ok_or_else(|| {
|
||||
cfg_err(
|
||||
path,
|
||||
line_num,
|
||||
&format!("cannot derive name from periodic path '{}'", ppath),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
Ok(Periodic { path: ppath.to_string(), name, cadence, guards: Vec::new() })
|
||||
Ok(Periodic {
|
||||
path: ppath.to_string(),
|
||||
name,
|
||||
cadence,
|
||||
guards: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::string_slice)]
|
||||
fn parse_guard_after(value: &str, path: &Path, line_num: usize) -> Result<(String, String, usize), String> {
|
||||
fn parse_guard_after(
|
||||
value: &str,
|
||||
path: &Path,
|
||||
line_num: usize,
|
||||
) -> Result<(String, String, usize), String> {
|
||||
let trimmed = value.trim();
|
||||
let split_pos = trimmed.find(char::is_whitespace)
|
||||
.ok_or_else(|| cfg_err(path, line_num, "guard-after requires '<periodic-name> <command>'"))?;
|
||||
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))
|
||||
|
|
@ -131,7 +161,6 @@ impl ConfigBuilder {
|
|||
log_dir: None,
|
||||
image: None,
|
||||
model: None,
|
||||
claude_model: None,
|
||||
thinking: None,
|
||||
scope_rules: Vec::new(),
|
||||
guards: Vec::new(),
|
||||
|
|
@ -146,39 +175,69 @@ impl ConfigBuilder {
|
|||
}
|
||||
|
||||
#[allow(clippy::string_slice)]
|
||||
fn parse_line(&mut self, directive: &str, value: &str, path: &Path, line_num: usize) -> Result<(), String> {
|
||||
fn parse_line(
|
||||
&mut self,
|
||||
directive: &str,
|
||||
value: &str,
|
||||
path: &Path,
|
||||
line_num: usize,
|
||||
) -> Result<(), String> {
|
||||
match directive {
|
||||
"max-tail" => {
|
||||
self.max_tail = value.parse::<usize>()
|
||||
.map_err(|_| cfg_err(path, line_num, &format!("invalid max-tail value '{}'", value)))?;
|
||||
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()),
|
||||
"claude-model" => self.claude_model = Some(value.to_string()),
|
||||
"thinking" => {
|
||||
self.thinking = Some(match value.trim() {
|
||||
"off" => Thinking::Off,
|
||||
"low" => Thinking::Low,
|
||||
"medium" => Thinking::Medium,
|
||||
"high" => Thinking::High,
|
||||
"xhigh" => Thinking::XHigh,
|
||||
other => {
|
||||
return Err(cfg_err(
|
||||
path,
|
||||
line_num,
|
||||
&format!("thinking must be 'off', 'low', 'medium', or 'high', got '{}'", other),
|
||||
&format!(
|
||||
"thinking must be 'off', 'low', 'medium', 'high', or 'xhigh', got '{}'",
|
||||
other
|
||||
),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
"allow" => self.scope_rules.push(ScopeRule { tag: ScopeTag::Allow, prefix: value.to_string() }),
|
||||
"add-only" => self.scope_rules.push(ScopeRule { tag: ScopeTag::AddOnly, prefix: value.to_string() }),
|
||||
"no-modify" => self.scope_rules.push(ScopeRule { tag: ScopeTag::NoModify, prefix: value.to_string() }),
|
||||
"allow" => self.scope_rules.push(ScopeRule {
|
||||
tag: ScopeTag::Allow,
|
||||
prefix: value.to_string(),
|
||||
}),
|
||||
"add-only" => self.scope_rules.push(ScopeRule {
|
||||
tag: ScopeTag::AddOnly,
|
||||
prefix: value.to_string(),
|
||||
}),
|
||||
"no-modify" => self.scope_rules.push(ScopeRule {
|
||||
tag: ScopeTag::NoModify,
|
||||
prefix: value.to_string(),
|
||||
}),
|
||||
"guard" => self.guards.push(value.to_string()),
|
||||
"judge-every" => self.judge_every = Some(parse_positive_u32(value, path, line_num, "judge-every")?),
|
||||
"max-judge-failures" => self.max_judge_failures = parse_positive_u32(value, path, line_num, "max-judge-failures")?,
|
||||
"judge-every" => {
|
||||
self.judge_every = Some(parse_positive_u32(value, path, line_num, "judge-every")?)
|
||||
}
|
||||
"max-judge-failures" => {
|
||||
self.max_judge_failures =
|
||||
parse_positive_u32(value, path, line_num, "max-judge-failures")?
|
||||
}
|
||||
"periodic" => self.periodics.push(parse_periodic(value, path, line_num)?),
|
||||
"guard-after" => self.pending_guard_afters.push(parse_guard_after(value, path, line_num)?),
|
||||
"guard-after" => self
|
||||
.pending_guard_afters
|
||||
.push(parse_guard_after(value, path, line_num)?),
|
||||
"hook" => self.hooks.push(value.to_string()),
|
||||
"metrics-dir" => self.metrics_dir = Some(value.to_string()),
|
||||
"metrics" => match value.trim() {
|
||||
|
|
@ -192,30 +251,29 @@ impl ConfigBuilder {
|
|||
));
|
||||
}
|
||||
},
|
||||
other => return Err(cfg_err(path, line_num, &format!("unknown directive '{}'", other))),
|
||||
other => {
|
||||
return Err(cfg_err(
|
||||
path,
|
||||
line_num,
|
||||
&format!("unknown directive '{}'", other),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build(self, path: &Path) -> Result<Config, String> {
|
||||
if self.model.is_some() && self.claude_model.is_some() {
|
||||
return Err(cfg_err(
|
||||
path,
|
||||
0,
|
||||
"'model' (OpenCode backend) and 'claude-model' (Claude CLI backend) are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if self.model.is_some() && self.thinking.is_some() {
|
||||
eprintln!(
|
||||
"warning: {}: 'thinking' directive is only honored by the Claude CLI backend; ignored when 'model' (OpenCode) is set",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
let mut periodics = self.periodics;
|
||||
for (pname, cmd, ln) in self.pending_guard_afters {
|
||||
match periodics.iter_mut().find(|p| p.name == pname) {
|
||||
Some(p) => p.guards.push(cmd),
|
||||
None => return Err(cfg_err(path, ln, &format!("guard-after references unknown periodic '{}'", pname))),
|
||||
None => {
|
||||
return Err(cfg_err(
|
||||
path,
|
||||
ln,
|
||||
&format!("guard-after references unknown periodic '{}'", pname),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let metrics_dir = match self.metrics_dir {
|
||||
|
|
@ -227,7 +285,6 @@ impl ConfigBuilder {
|
|||
log_dir: self.log_dir,
|
||||
image: self.image,
|
||||
model: self.model,
|
||||
claude_model: self.claude_model,
|
||||
thinking: self.thinking,
|
||||
scope_rules: self.scope_rules,
|
||||
guards: self.guards,
|
||||
|
|
@ -261,7 +318,11 @@ impl Config {
|
|||
let (directive, value) = match line.find(char::is_whitespace) {
|
||||
Some(pos) => (&line[..pos], line[pos..].trim_start()),
|
||||
None => {
|
||||
return Err(cfg_err(path, line_num + 1, &format!("directive '{}' has no value", line)));
|
||||
return Err(cfg_err(
|
||||
path,
|
||||
line_num + 1,
|
||||
&format!("directive '{}' has no value", line),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -294,14 +355,4 @@ impl Config {
|
|||
// If only "." matched, best_len is 1, which is correct.
|
||||
best_tag
|
||||
}
|
||||
|
||||
/// Determine which backend to use based on config.
|
||||
/// If `model` is set, use OpenCode; otherwise default to Claude CLI.
|
||||
pub fn backend(&self) -> Backend {
|
||||
if self.model.is_some() {
|
||||
Backend::OpenCode
|
||||
} else {
|
||||
Backend::Claude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/guard.rs
13
src/guard.rs
|
|
@ -31,10 +31,7 @@ fn tail_lines(text: &str, max: usize) -> String {
|
|||
/// Returns (passed, raw_output, elapsed_secs).
|
||||
fn run_one(cmd: &str) -> (bool, String, f64) {
|
||||
let start = Instant::now();
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output();
|
||||
let output = Command::new("sh").arg("-c").arg(cmd).output();
|
||||
let elapsed_secs = start.elapsed().as_secs_f64();
|
||||
|
||||
let (exit_ok, raw_output) = match output {
|
||||
|
|
@ -91,7 +88,13 @@ pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Ve
|
|||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
// Thread panicked — treat as failure
|
||||
(String::from("(unknown)"), false, String::from("guard thread panicked"), false, 0.0)
|
||||
(
|
||||
String::from("(unknown)"),
|
||||
false,
|
||||
String::from("guard thread panicked"),
|
||||
false,
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,9 @@ pub fn extract_num(line: &str, key: &str) -> Option<f64> {
|
|||
|
||||
// Collect numeric chars: digits, '.', '-', '+', 'e', 'E'
|
||||
let num_end = rest
|
||||
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+' && c != 'e' && c != 'E')
|
||||
.find(|c: char| {
|
||||
!c.is_ascii_digit() && c != '.' && c != '-' && c != '+' && c != 'e' && c != 'E'
|
||||
})
|
||||
.unwrap_or(rest.len());
|
||||
|
||||
if num_end == 0 {
|
||||
|
|
|
|||
801
src/main.rs
801
src/main.rs
File diff suppressed because it is too large
Load diff
|
|
@ -197,7 +197,9 @@ pub fn unix_now() -> u64 {
|
|||
/// six-digit sub-second microsecond count. Lexicographic sort matches
|
||||
/// chronological order. No external time crate dep.
|
||||
pub fn new_run_id() -> String {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = now.as_secs();
|
||||
let micros = now.subsec_micros();
|
||||
let (y, m, d, hh, mm, ss) = unix_to_utc(secs);
|
||||
|
|
@ -217,7 +219,11 @@ fn unix_to_utc(secs: u64) -> (i32, u32, u32, u32, u32, u32) {
|
|||
let ss = (tod % 60) as u32;
|
||||
|
||||
let z = days + 719468;
|
||||
let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 };
|
||||
let era = if z >= 0 {
|
||||
z / 146097
|
||||
} else {
|
||||
(z - 146096) / 146097
|
||||
};
|
||||
let doe = (z - era * 146097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
|
|
@ -246,7 +252,13 @@ pub fn project_slug() -> String {
|
|||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -729,8 +741,12 @@ mod tests {
|
|||
cost_usd: 0.42,
|
||||
num_turns: 7,
|
||||
thinking_secs: 4.1,
|
||||
tool_counts: [("Edit".to_string(), 3u32), ("Bash".to_string(), 1)].into_iter().collect(),
|
||||
tool_durations_secs: [("Edit".to_string(), 1.8), ("Bash".to_string(), 12.3)].into_iter().collect(),
|
||||
tool_counts: [("Edit".to_string(), 3u32), ("Bash".to_string(), 1)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
tool_durations_secs: [("Edit".to_string(), 1.8), ("Bash".to_string(), 12.3)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
guards: vec![GuardRow {
|
||||
name: "cargo test".to_string(),
|
||||
passed: true,
|
||||
|
|
|
|||
|
|
@ -1,620 +0,0 @@
|
|||
//! Session-JSONL trimming for Claude Code prompt-cache reuse across rounds.
|
||||
//!
|
||||
//! Yoke uses `claude --resume <sid>` to carry a worker's conversation across
|
||||
//! iterations so Anthropic's prefix cache stays warm. Naive resume grows the
|
||||
//! session monotonically — bash outputs, thinking blocks, intermediate Reads
|
||||
//! that are no longer relevant — all of it stays in the prefix and is paid
|
||||
//! for at cache-read rates every round.
|
||||
//!
|
||||
//! This module trims the session file between rounds. The agent declares a
|
||||
//! `KEEP: <path> <path> ...` line in `.loop/notes.md` listing the file Reads
|
||||
//! whose results should stay in conversation history. Everything else is
|
||||
//! dropped, the parent-uuid chain is re-linked across the gaps, and the file
|
||||
//! is atomically rewritten in place.
|
||||
//!
|
||||
//! Safety: if the trim's own validation fails (broken parent chain, orphaned
|
||||
//! tool_use without tool_result, parse error), the original session is kept
|
||||
//! untouched and we log a warning. `YOKE_DISABLE_SESSION_TRIM=1` skips the
|
||||
//! whole pass.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrimStats {
|
||||
pub records_total: usize,
|
||||
pub records_kept: usize,
|
||||
pub records_dropped: usize,
|
||||
pub keep_paths: Vec<String>,
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
/// Parse a `KEEP:` line from notes.md (or any text). Returns absolute paths
|
||||
/// resolved against `cwd`. Multiple `KEEP:` lines are unioned. Missing or
|
||||
/// `*` token is treated as "keep nothing" — the caller decides what that
|
||||
/// means, but this fn just returns the explicit paths.
|
||||
pub fn parse_keep_list(notes_text: &str, cwd: &Path) -> HashSet<PathBuf> {
|
||||
let mut out = HashSet::new();
|
||||
for line in notes_text.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
let rest = match trimmed.strip_prefix("KEEP:") {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
for tok in rest.split_whitespace() {
|
||||
if tok == "*" {
|
||||
continue;
|
||||
}
|
||||
let p = PathBuf::from(tok);
|
||||
let abs = if p.is_absolute() { p } else { cwd.join(p) };
|
||||
// Best-effort canonicalize so symlinks / .. don't cause mismatches.
|
||||
let final_path = fs::canonicalize(&abs).unwrap_or(abs);
|
||||
out.insert(final_path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Locate Claude Code's session file on disk.
|
||||
/// Format: `<home>/.claude/projects/<cwd-with-/-replaced-by->/<sid>.jsonl`.
|
||||
pub fn session_file_path(home: &Path, cwd: &Path, session_id: &str) -> PathBuf {
|
||||
let cwd_str = cwd.to_string_lossy();
|
||||
// Claude Code's slug: replace `/` with `-`. A leading slash becomes a
|
||||
// leading dash. `.` characters in path components are preserved.
|
||||
let slug = cwd_str.replace('/', "-");
|
||||
home.join(".claude")
|
||||
.join("projects")
|
||||
.join(slug)
|
||||
.join(format!("{}.jsonl", session_id))
|
||||
}
|
||||
|
||||
/// Trim a session file in place. Returns stats. If the trim aborts safely
|
||||
/// (escape hatch / no kept content / validation failure), the original file
|
||||
/// is untouched.
|
||||
pub fn trim_session(session_path: &Path, keep_paths: &HashSet<PathBuf>) -> Result<TrimStats, String> {
|
||||
let mut stats = TrimStats {
|
||||
keep_paths: keep_paths
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect(),
|
||||
..TrimStats::default()
|
||||
};
|
||||
|
||||
if std::env::var_os("YOKE_DISABLE_SESSION_TRIM").is_some() {
|
||||
stats.skipped = true;
|
||||
return Ok(stats);
|
||||
}
|
||||
if !session_path.exists() {
|
||||
return Err(format!("session file not found: {}", session_path.display()));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(session_path)
|
||||
.map_err(|e| format!("read {}: {}", session_path.display(), e))?;
|
||||
let raw_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
stats.records_total = raw_lines.len();
|
||||
|
||||
let mut records: Vec<Value> = Vec::with_capacity(raw_lines.len());
|
||||
for (i, line) in raw_lines.iter().enumerate() {
|
||||
let v: Value = serde_json::from_str(line)
|
||||
.map_err(|e| format!("parse line {}: {}", i + 1, e))?;
|
||||
records.push(v);
|
||||
}
|
||||
|
||||
let decisions = classify(&records, keep_paths);
|
||||
|
||||
let trimmed = relink_and_emit(&records, &raw_lines, &decisions)?;
|
||||
stats.records_kept = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
stats.records_dropped = stats.records_total.saturating_sub(stats.records_kept);
|
||||
|
||||
validate(&trimmed)?;
|
||||
|
||||
// Atomic write: tmp → rename. Keep one .bak for recovery / debugging.
|
||||
let bak_path = session_path.with_extension("jsonl.bak");
|
||||
let _ = fs::copy(session_path, &bak_path);
|
||||
let tmp_path = session_path.with_extension("jsonl.tmp");
|
||||
{
|
||||
let mut f = fs::File::create(&tmp_path)
|
||||
.map_err(|e| format!("create tmp: {}", e))?;
|
||||
f.write_all(trimmed.as_bytes())
|
||||
.map_err(|e| format!("write tmp: {}", e))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp_path, session_path)
|
||||
.map_err(|e| format!("rename: {}", e))?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Per-record decision: keep as-is, drop entirely, or keep with a rewritten
|
||||
/// parentUuid.
|
||||
#[derive(Debug, Clone)]
|
||||
enum Decision {
|
||||
Keep,
|
||||
Drop,
|
||||
}
|
||||
|
||||
fn classify(records: &[Value], keep_paths: &HashSet<PathBuf>) -> Vec<Decision> {
|
||||
// Two-pass: first identify which tool_use ids we keep, then decide each record.
|
||||
let mut kept_tool_use_ids: HashSet<String> = HashSet::new();
|
||||
for r in records {
|
||||
if !is_assistant(r) {
|
||||
continue;
|
||||
}
|
||||
let Some(block) = first_content_block(r) else { continue };
|
||||
if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
|
||||
continue;
|
||||
}
|
||||
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name != "Read" {
|
||||
continue;
|
||||
}
|
||||
let path_str = block
|
||||
.get("input")
|
||||
.and_then(|v| v.get("file_path"))
|
||||
.and_then(|v| v.as_str());
|
||||
let Some(path_str) = path_str else { continue };
|
||||
let p = PathBuf::from(path_str);
|
||||
let canonical = fs::canonicalize(&p).unwrap_or(p);
|
||||
if keep_paths.contains(&canonical)
|
||||
&& let Some(id) = block.get("id").and_then(|v| v.as_str())
|
||||
{
|
||||
kept_tool_use_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
records
|
||||
.iter()
|
||||
.map(|r| decide(r, &kept_tool_use_ids))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decide(record: &Value, kept_tool_use_ids: &HashSet<String>) -> Decision {
|
||||
// Metadata records (no top-level uuid OR parentUuid is absent and type is bookkeeping):
|
||||
// always keep.
|
||||
let typ = record.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match typ {
|
||||
// Non-conversation bookkeeping — keep unchanged.
|
||||
"permission-mode"
|
||||
| "file-history-snapshot"
|
||||
| "queue-operation"
|
||||
| "ai-title"
|
||||
| "last-prompt"
|
||||
| "attachment" => return Decision::Keep,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// user / assistant: examine content.
|
||||
let Some(msg) = record.get("message") else {
|
||||
return Decision::Keep;
|
||||
};
|
||||
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Initial user prompt: content is a plain string, not an array. Always keep.
|
||||
if role == "user" {
|
||||
match msg.get("content") {
|
||||
Some(Value::String(_)) => return Decision::Keep,
|
||||
Some(Value::Array(arr)) => {
|
||||
// tool_result wrapper. Keep only if its tool_use_id was kept.
|
||||
if arr.is_empty() {
|
||||
return Decision::Keep;
|
||||
}
|
||||
let block = &arr[0];
|
||||
let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if btyp == "tool_result" {
|
||||
let id = block.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if kept_tool_use_ids.contains(id) {
|
||||
return Decision::Keep;
|
||||
} else {
|
||||
return Decision::Drop;
|
||||
}
|
||||
}
|
||||
// Other user content (unusual): keep defensively.
|
||||
return Decision::Keep;
|
||||
}
|
||||
_ => return Decision::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
if role == "assistant" {
|
||||
let Some(block) = first_content_block(record) else {
|
||||
return Decision::Drop;
|
||||
};
|
||||
let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match btyp {
|
||||
"thinking" => Decision::Drop,
|
||||
"text" => Decision::Drop,
|
||||
"tool_use" => {
|
||||
let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if kept_tool_use_ids.contains(id) {
|
||||
Decision::Keep
|
||||
} else {
|
||||
Decision::Drop
|
||||
}
|
||||
}
|
||||
_ => Decision::Drop,
|
||||
}
|
||||
} else {
|
||||
// Unknown role — keep, don't make things worse.
|
||||
Decision::Keep
|
||||
}
|
||||
}
|
||||
|
||||
fn is_assistant(record: &Value) -> bool {
|
||||
record.get("type").and_then(|v| v.as_str()) == Some("assistant")
|
||||
}
|
||||
|
||||
fn first_content_block(record: &Value) -> Option<&Value> {
|
||||
record
|
||||
.get("message")?
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.first()
|
||||
}
|
||||
|
||||
/// Build the trimmed JSONL output. For surviving records whose parentUuid
|
||||
/// points to a dropped record, walks up the parent chain to find the nearest
|
||||
/// surviving ancestor and rewrites the field.
|
||||
fn relink_and_emit(records: &[Value], raw: &[&str], decisions: &[Decision]) -> Result<String, String> {
|
||||
// uuid → parentUuid index for ALL records that have a uuid. Used to walk
|
||||
// up the chain when re-linking.
|
||||
let mut parent_of: HashMap<String, Option<String>> = HashMap::new();
|
||||
let mut kept_uuids: HashSet<String> = HashSet::new();
|
||||
for (i, r) in records.iter().enumerate() {
|
||||
let Some(uuid) = r.get("uuid").and_then(|v| v.as_str()) else { continue };
|
||||
let parent = r
|
||||
.get("parentUuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
parent_of.insert(uuid.to_string(), parent);
|
||||
if matches!(decisions[i], Decision::Keep) {
|
||||
kept_uuids.insert(uuid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// For each kept uuid, compute the rewritten parentUuid (nearest kept
|
||||
// ancestor or null).
|
||||
let mut rewritten_parent: HashMap<String, Option<String>> = HashMap::new();
|
||||
for uuid in &kept_uuids {
|
||||
let mut cur = parent_of.get(uuid).cloned().flatten();
|
||||
while let Some(p) = cur {
|
||||
if kept_uuids.contains(&p) {
|
||||
rewritten_parent.insert(uuid.clone(), Some(p));
|
||||
break;
|
||||
}
|
||||
cur = parent_of.get(&p).cloned().flatten();
|
||||
}
|
||||
if !rewritten_parent.contains_key(uuid) {
|
||||
rewritten_parent.insert(uuid.clone(), None);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(raw.iter().map(|l| l.len() + 1).sum());
|
||||
for (i, r) in records.iter().enumerate() {
|
||||
if matches!(decisions[i], Decision::Drop) {
|
||||
continue;
|
||||
}
|
||||
let uuid_opt = r.get("uuid").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
// If this record has a uuid AND its rewritten parent differs from
|
||||
// the on-disk parent, re-serialize. Otherwise emit raw.
|
||||
let needs_rewrite = match &uuid_opt {
|
||||
Some(uuid) => {
|
||||
let orig = parent_of.get(uuid).cloned().flatten();
|
||||
let new = rewritten_parent.get(uuid).cloned().flatten();
|
||||
orig != new
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if needs_rewrite {
|
||||
let mut v = r.clone();
|
||||
let uuid = uuid_opt.unwrap();
|
||||
let new_parent = rewritten_parent.get(&uuid).cloned().flatten();
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
match new_parent {
|
||||
Some(p) => {
|
||||
obj.insert("parentUuid".to_string(), Value::String(p));
|
||||
}
|
||||
None => {
|
||||
obj.insert("parentUuid".to_string(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
let s = serde_json::to_string(&v)
|
||||
.map_err(|e| format!("serialize: {}", e))?;
|
||||
out.push_str(&s);
|
||||
out.push('\n');
|
||||
} else {
|
||||
out.push_str(raw[i]);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Validate that the trimmed JSONL is internally consistent:
|
||||
/// every tool_use has a matching tool_result downstream.
|
||||
fn validate(trimmed: &str) -> Result<(), String> {
|
||||
let mut tool_use_ids: HashSet<String> = HashSet::new();
|
||||
let mut tool_result_ids: HashSet<String> = HashSet::new();
|
||||
for (i, line) in trimmed.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: Value = serde_json::from_str(line)
|
||||
.map_err(|e| format!("validate parse line {}: {}", i + 1, e))?;
|
||||
let Some(arr) = v.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
for block in arr {
|
||||
match block.get("type").and_then(|v| v.as_str()) {
|
||||
Some("tool_use") => {
|
||||
if let Some(id) = block.get("id").and_then(|v| v.as_str()) {
|
||||
tool_use_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
Some("tool_result") => {
|
||||
if let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) {
|
||||
tool_result_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &tool_use_ids {
|
||||
if !tool_result_ids.contains(id) {
|
||||
return Err(format!("orphan tool_use {}: no matching tool_result", id));
|
||||
}
|
||||
}
|
||||
for id in &tool_result_ids {
|
||||
if !tool_use_ids.contains(id) {
|
||||
return Err(format!("orphan tool_result {}: no matching tool_use", id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Serializes tests that read/mutate the `YOKE_DISABLE_SESSION_TRIM` env
|
||||
/// var. cargo runs tests in parallel within a binary; without this they
|
||||
/// race on a process-global.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Clear the env var before running the closure, then drop the guard.
|
||||
fn with_clean_env<F: FnOnce()>(f: F) {
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
// SAFETY: tests are serialized via ENV_LOCK; no other thread will
|
||||
// observe a partial write to environ.
|
||||
unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") };
|
||||
f();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_keep_list_basic() {
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
let s = "STATUS: IN_PROGRESS\nKEEP: src/a.rs /abs/b.rs\nfoo\n";
|
||||
let got = parse_keep_list(s, &cwd);
|
||||
assert!(got.iter().any(|p| p.ends_with("a.rs")));
|
||||
assert!(got.iter().any(|p| p == &PathBuf::from("/abs/b.rs")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_keep_list_missing() {
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
let s = "STATUS: DONE\n";
|
||||
assert!(parse_keep_list(s, &cwd).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_keep_list_star_means_nothing() {
|
||||
let cwd = PathBuf::from("/tmp");
|
||||
let s = "KEEP: *\n";
|
||||
assert!(parse_keep_list(s, &cwd).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_file_path_slug() {
|
||||
let p = session_file_path(
|
||||
Path::new("/home/u"),
|
||||
Path::new("/workspace"),
|
||||
"abc-123",
|
||||
);
|
||||
assert_eq!(p, PathBuf::from("/home/u/.claude/projects/-workspace/abc-123.jsonl"));
|
||||
}
|
||||
|
||||
/// Tiny realistic session: bootstrap + initial prompt + 2 Reads + a Bash
|
||||
/// + a thinking block. Used by scenario tests below.
|
||||
fn write_fixture(dir: &Path, paths: &[&str]) -> PathBuf {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
// queue-operation (metadata) — has no uuid/parentUuid.
|
||||
lines.push(r#"{"type":"queue-operation","sessionId":"s1"}"#.to_string());
|
||||
// initial user prompt (root of conversation)
|
||||
lines.push(r#"{"type":"user","uuid":"u-root","parentUuid":null,"message":{"role":"user","content":"Read .loop/protocol.md and follow its instructions."}}"#.to_string());
|
||||
// assistant thinking — should always be dropped
|
||||
lines.push(r#"{"type":"assistant","uuid":"u-think","parentUuid":"u-root","message":{"role":"assistant","content":[{"type":"thinking","thinking":"..."}]}}"#.to_string());
|
||||
// Read tool_use for paths[0]
|
||||
let p0 = paths.first().copied().unwrap_or("/tmp/a.rs");
|
||||
lines.push(format!(
|
||||
r#"{{"type":"assistant","uuid":"u-read-a","parentUuid":"u-think","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-a","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#,
|
||||
p0
|
||||
));
|
||||
// tool_result for the Read
|
||||
lines.push(r#"{"type":"user","uuid":"u-res-a","parentUuid":"u-read-a","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-a","content":"file A contents"}]}}"#.to_string());
|
||||
// Bash tool_use — should always be dropped
|
||||
lines.push(r#"{"type":"assistant","uuid":"u-bash","parentUuid":"u-res-a","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-bash","name":"Bash","input":{"command":"ls"}}]}}"#.to_string());
|
||||
lines.push(r#"{"type":"user","uuid":"u-res-bash","parentUuid":"u-bash","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-bash","content":"a\nb\nc"}]}}"#.to_string());
|
||||
// Read tool_use for paths[1] (a second file)
|
||||
let p1 = paths.get(1).copied().unwrap_or("/tmp/b.rs");
|
||||
lines.push(format!(
|
||||
r#"{{"type":"assistant","uuid":"u-read-b","parentUuid":"u-res-bash","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-b","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#,
|
||||
p1
|
||||
));
|
||||
lines.push(r#"{"type":"user","uuid":"u-res-b","parentUuid":"u-read-b","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-b","content":"file B contents"}]}}"#.to_string());
|
||||
|
||||
let path = dir.join("s1.jsonl");
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
for l in &lines {
|
||||
writeln!(f, "{}", l).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn read_records(path: &Path) -> Vec<Value> {
|
||||
fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_with_one_keep_drops_other_reads_and_bash_and_thinking() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let a_path = tmp.path().join("a.rs");
|
||||
let b_path = tmp.path().join("b.rs");
|
||||
fs::write(&a_path, "fn a(){}").unwrap();
|
||||
fs::write(&b_path, "fn b(){}").unwrap();
|
||||
let session = write_fixture(
|
||||
tmp.path(),
|
||||
&[a_path.to_str().unwrap(), b_path.to_str().unwrap()],
|
||||
);
|
||||
|
||||
let mut keep = HashSet::new();
|
||||
keep.insert(fs::canonicalize(&a_path).unwrap());
|
||||
|
||||
let mut stats_opt = None;
|
||||
with_clean_env(|| {
|
||||
stats_opt = Some(trim_session(&session, &keep).expect("trim ok"));
|
||||
});
|
||||
let stats = stats_opt.unwrap();
|
||||
assert!(!stats.skipped);
|
||||
assert!(stats.records_dropped >= 4, "should drop thinking + bash pair + b read pair, got {:?}", stats);
|
||||
|
||||
let recs = read_records(&session);
|
||||
// Surviving tool_use ids: only tu-a; tu-b and tu-bash are gone.
|
||||
let tool_use_ids: Vec<String> = recs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
r.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_use"))
|
||||
.and_then(|b| b.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tool_use_ids, vec!["tu-a".to_string()]);
|
||||
|
||||
// Every tool_use has a paired tool_result (validate() enforces this on write;
|
||||
// re-check here for the behavior we promise).
|
||||
let tool_result_ids: Vec<String> = recs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
r.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
|
||||
.and_then(|b| b.get("tool_use_id").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tool_result_ids, vec!["tu-a".to_string()]);
|
||||
|
||||
// No thinking blocks survive.
|
||||
for r in &recs {
|
||||
let typ_opt = r
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|b| b.get("type"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_ne!(typ_opt, Some("thinking"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_relinks_parent_uuids_to_nearest_surviving_ancestor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let a_path = tmp.path().join("a.rs");
|
||||
let b_path = tmp.path().join("b.rs");
|
||||
fs::write(&a_path, "x").unwrap();
|
||||
fs::write(&b_path, "y").unwrap();
|
||||
let session = write_fixture(
|
||||
tmp.path(),
|
||||
&[a_path.to_str().unwrap(), b_path.to_str().unwrap()],
|
||||
);
|
||||
|
||||
// Keep only b. Records dropped between root and b-read should result
|
||||
// in u-read-b's new parent being u-root (the only surviving ancestor).
|
||||
let mut keep = HashSet::new();
|
||||
keep.insert(fs::canonicalize(&b_path).unwrap());
|
||||
|
||||
with_clean_env(|| {
|
||||
trim_session(&session, &keep).expect("trim ok");
|
||||
});
|
||||
|
||||
let recs = read_records(&session);
|
||||
let read_b = recs
|
||||
.iter()
|
||||
.find(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-read-b"))
|
||||
.expect("u-read-b survives");
|
||||
let new_parent = read_b.get("parentUuid").and_then(|v| v.as_str());
|
||||
// u-think, u-read-a, u-res-a, u-bash, u-res-bash all dropped → parent
|
||||
// walks up to u-root.
|
||||
assert_eq!(new_parent, Some("u-root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_skipped_when_env_var_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let session = write_fixture(tmp.path(), &["/tmp/a.rs"]);
|
||||
let original = fs::read_to_string(&session).unwrap();
|
||||
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
// SAFETY: serialized by ENV_LOCK above.
|
||||
unsafe { std::env::set_var("YOKE_DISABLE_SESSION_TRIM", "1") };
|
||||
let stats = trim_session(&session, &HashSet::new()).expect("trim ok");
|
||||
unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") };
|
||||
assert!(stats.skipped);
|
||||
assert_eq!(original, fs::read_to_string(&session).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_empty_keep_drops_all_tool_use_pairs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let session = write_fixture(tmp.path(), &["/tmp/a.rs", "/tmp/b.rs"]);
|
||||
|
||||
with_clean_env(|| {
|
||||
trim_session(&session, &HashSet::new()).expect("trim ok");
|
||||
});
|
||||
|
||||
let recs = read_records(&session);
|
||||
for r in &recs {
|
||||
let block = r
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first());
|
||||
if let Some(b) = block {
|
||||
let btyp = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
assert_ne!(btyp, "tool_use", "no tool_use should survive empty keep");
|
||||
assert_ne!(btyp, "tool_result", "no tool_result should survive empty keep");
|
||||
assert_ne!(btyp, "thinking", "no thinking should survive");
|
||||
}
|
||||
}
|
||||
// queue-operation, initial user prompt should survive.
|
||||
assert!(recs.iter().any(|r| r.get("type").and_then(|v| v.as_str()) == Some("queue-operation")));
|
||||
assert!(recs.iter().any(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-root")));
|
||||
}
|
||||
}
|
||||
20
src/stash.rs
20
src/stash.rs
|
|
@ -2,7 +2,7 @@ use std::fs;
|
|||
use std::path::Path;
|
||||
|
||||
use crate::ansi::{BLUE, BOLD, ORANGE, RESET};
|
||||
use crate::{log, log_error, STASH_DIR};
|
||||
use crate::{STASH_DIR, log, log_error};
|
||||
|
||||
// ── Stash helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -139,13 +139,7 @@ pub(crate) fn stash_snapshot(mode: &str) -> Result<String, String> {
|
|||
|
||||
// Append to index
|
||||
let index_path = format!("{}/index", STASH_DIR);
|
||||
let line = format!(
|
||||
"{}|{}|{}|{}\n",
|
||||
hash,
|
||||
timestamp,
|
||||
mode,
|
||||
file_names.join(",")
|
||||
);
|
||||
let line = format!("{}|{}|{}|{}\n", hash, timestamp, mode, file_names.join(","));
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
|
|
@ -258,9 +252,8 @@ fn swap_loop_files(entry_dir: &Path, mode: &str) -> Result<(), String> {
|
|||
for entry in dir_entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let dest = Path::new(".loop").join(&name);
|
||||
fs::copy(entry.path(), &dest).map_err(|e| {
|
||||
format!("failed to restore {}: {}", name.to_string_lossy(), e)
|
||||
})?;
|
||||
fs::copy(entry.path(), &dest)
|
||||
.map_err(|e| format!("failed to restore {}: {}", name.to_string_lossy(), e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -317,10 +310,7 @@ pub(crate) fn print_stash_help() {
|
|||
ORANGE, BOLD, RESET
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"{}USAGE:{} yoke stash [subcommand]",
|
||||
BOLD, RESET
|
||||
);
|
||||
eprintln!("{}USAGE:{} yoke stash [subcommand]", BOLD, RESET);
|
||||
eprintln!();
|
||||
eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET);
|
||||
eprintln!(
|
||||
|
|
|
|||
501
src/stream.rs
501
src/stream.rs
|
|
@ -1,494 +1,30 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use std::process::ChildStdout;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW};
|
||||
use crate::json::{extract_num, extract_str, unescape_json};
|
||||
|
||||
const BG_RED: &str = "\x1b[48;2;80;30;30m";
|
||||
const BG_GREEN: &str = "\x1b[48;2;30;60;30m";
|
||||
|
||||
/// Aggregated, persistable view of one agent invocation's stream.
|
||||
///
|
||||
/// Produced by `filter_stream` after the child's stdout closes. Carries
|
||||
/// everything the metrics layer wants: cost, agent-reported wall time,
|
||||
/// thinking total, and per-tool durations + counts.
|
||||
/// Produced after the child's stdout closes. Carries everything the metrics
|
||||
/// layer wants: cost, agent-reported wall time, thinking total, and per-tool
|
||||
/// durations + counts.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StreamSummary {
|
||||
pub cost_usd: f64,
|
||||
/// Wall clock as reported by the agent's `result` event (Claude only).
|
||||
/// Wall clock as reported by the agent's stream, when available.
|
||||
pub agent_reported_secs: Option<f64>,
|
||||
pub num_turns: u32,
|
||||
pub thinking_secs: f64,
|
||||
pub tool_counts: BTreeMap<String, u32>,
|
||||
pub tool_durations_secs: BTreeMap<String, f64>,
|
||||
/// Claude session ID extracted from the init event. Used by the harness
|
||||
/// to `--resume` the same session on the next iteration, preserving the
|
||||
/// prompt cache. `None` for OpenCode or if the init event was missed.
|
||||
/// OMP session ID extracted from the `session` event. Used by the harness
|
||||
/// to resume the same worker session on the next iteration.
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
struct StreamState {
|
||||
turn_num: u32,
|
||||
current_msg_id: Option<String>,
|
||||
seen_init: bool,
|
||||
in_thinking: bool,
|
||||
thinking_start: Option<Instant>,
|
||||
thinking_total_secs: f64,
|
||||
iteration_cost: f64,
|
||||
iteration_duration_secs: f64,
|
||||
/// Maps tool_use id → (tool name, start instant), so tool_result can
|
||||
/// look up its origin and elapsed time.
|
||||
tool_use_starts: HashMap<String, (String, Instant)>,
|
||||
/// Counts of tool_use events by tool name (for iteration summary strip).
|
||||
tool_counts: HashMap<String, u32>,
|
||||
/// Wall-clock duration accumulated per tool name across the iteration.
|
||||
tool_durations: HashMap<String, f64>,
|
||||
/// Captured from the `system`/`init` event so the harness can `--resume`.
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
turn_num: 0,
|
||||
current_msg_id: None,
|
||||
seen_init: false,
|
||||
in_thinking: false,
|
||||
thinking_start: None,
|
||||
thinking_total_secs: 0.0,
|
||||
iteration_cost: 0.0,
|
||||
iteration_duration_secs: 0.0,
|
||||
tool_use_starts: HashMap::new(),
|
||||
tool_counts: HashMap::new(),
|
||||
tool_durations: HashMap::new(),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_summary(self) -> StreamSummary {
|
||||
let agent_reported_secs = if self.iteration_duration_secs > 0.0 {
|
||||
Some(self.iteration_duration_secs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
StreamSummary {
|
||||
cost_usd: self.iteration_cost,
|
||||
agent_reported_secs,
|
||||
num_turns: self.turn_num,
|
||||
thinking_secs: self.thinking_total_secs,
|
||||
tool_counts: self.tool_counts.into_iter().collect(),
|
||||
tool_durations_secs: self.tool_durations.into_iter().collect(),
|
||||
session_id: self.session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a mini-diff from old_string/new_string extracted from an Edit tool_use.
|
||||
/// Shows red `−` lines for removed and green `+` lines for added, truncated to ~5 lines.
|
||||
fn format_edit_diff(line: &str) -> String {
|
||||
let old = extract_str(line, "old_string").map(|s| unescape_json(s));
|
||||
let new = extract_str(line, "new_string").map(|s| unescape_json(s));
|
||||
|
||||
let (old, new) = match (old, new) {
|
||||
(Some(o), Some(n)) => (o, n),
|
||||
_ => return String::new(),
|
||||
};
|
||||
|
||||
let old_lines: Vec<&str> = old.lines().collect();
|
||||
let new_lines: Vec<&str> = new.lines().collect();
|
||||
|
||||
let mut diff_lines: Vec<String> = Vec::new();
|
||||
for ol in &old_lines {
|
||||
diff_lines.push(format!(" {}{}{}− {}{}", BG_RED, RED, DIM, ol, RESET));
|
||||
}
|
||||
for nl in &new_lines {
|
||||
diff_lines.push(format!(" {}{}{}+ {}{}", BG_GREEN, GREEN, DIM, nl, RESET));
|
||||
}
|
||||
|
||||
let max_display = 5;
|
||||
let total = diff_lines.len();
|
||||
if total <= max_display {
|
||||
diff_lines.join("\n")
|
||||
} else {
|
||||
let mut out: Vec<String> = diff_lines[..max_display].to_vec();
|
||||
out.push(format!(" {}… +{} more lines{}", DIM, total - max_display, RESET));
|
||||
out.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the last ~3 lines of error content from a Bash tool_result for display.
|
||||
fn format_bash_error_tail(line: &str) -> String {
|
||||
let content = match extract_str(line, "content") {
|
||||
Some(s) => unescape_json(s),
|
||||
None => return String::new(),
|
||||
};
|
||||
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let max_tail = 3;
|
||||
let start = if lines.len() > max_tail { lines.len() - max_tail } else { 0 };
|
||||
let tail: Vec<String> = lines[start..]
|
||||
.iter()
|
||||
.map(|l| format!(" {}{}{}", RED, l, RESET))
|
||||
.collect();
|
||||
tail.join("\n")
|
||||
}
|
||||
|
||||
/// Render a preview for Write tool_use: first ~3 lines of content + line count badge.
|
||||
fn format_write_preview(line: &str) -> String {
|
||||
let content = match extract_str(line, "content") {
|
||||
Some(s) => unescape_json(s),
|
||||
None => return String::new(),
|
||||
};
|
||||
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
let badge = format!(" {}({} lines){}", DIM, total, RESET);
|
||||
|
||||
let max_preview = 3;
|
||||
let preview_lines: Vec<String> = lines.iter()
|
||||
.take(max_preview)
|
||||
.map(|l| format!(" {}{}{}", DIM, l, RESET))
|
||||
.collect();
|
||||
|
||||
let mut out = vec![badge];
|
||||
out.extend(preview_lines);
|
||||
if total > max_preview {
|
||||
out.push(format!(" {}…{}", DIM, RESET));
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// Format a badge for Grep/Glob tool_result content.
|
||||
/// For Grep: tries to count matches/files from the content.
|
||||
/// For Glob: counts the number of file paths returned.
|
||||
fn format_grep_glob_badge(tool_name: &str, line: &str) -> String {
|
||||
let content = match extract_str(line, "content") {
|
||||
Some(s) => unescape_json(s),
|
||||
None => return String::new(),
|
||||
};
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return format!("{}0 results{}", DIM, RESET);
|
||||
}
|
||||
|
||||
match tool_name {
|
||||
"Grep" => {
|
||||
// Grep results are typically one file path per line (files_with_matches mode)
|
||||
// or content lines. Count non-empty lines as results.
|
||||
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let count = lines.len();
|
||||
if count == 1 {
|
||||
format!("{} match", count)
|
||||
} else {
|
||||
format!("{} matches", count)
|
||||
}
|
||||
}
|
||||
"Glob" => {
|
||||
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let count = lines.len();
|
||||
if count == 1 {
|
||||
format!("{} file", count)
|
||||
} else {
|
||||
format!("{} files", count)
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a file extension to a human-readable language/type label.
|
||||
fn ext_to_label(ext: &str) -> Option<&'static str> {
|
||||
match ext {
|
||||
"rs" => Some("rust"),
|
||||
"py" => Some("python"),
|
||||
"js" => Some("javascript"),
|
||||
"ts" => Some("typescript"),
|
||||
"tsx" => Some("tsx"),
|
||||
"jsx" => Some("jsx"),
|
||||
"json" => Some("json"),
|
||||
"toml" => Some("toml"),
|
||||
"yaml" | "yml" => Some("yaml"),
|
||||
"md" => Some("markdown"),
|
||||
"sh" | "bash" | "zsh" => Some("shell"),
|
||||
"html" => Some("html"),
|
||||
"css" => Some("css"),
|
||||
"sql" => Some("sql"),
|
||||
"go" => Some("go"),
|
||||
"java" => Some("java"),
|
||||
"c" => Some("c"),
|
||||
"cpp" | "cc" | "cxx" => Some("c++"),
|
||||
"h" | "hpp" => Some("header"),
|
||||
"rb" => Some("ruby"),
|
||||
"lua" => Some("lua"),
|
||||
"zig" => Some("zig"),
|
||||
"lock" => Some("lock"),
|
||||
"xml" => Some("xml"),
|
||||
"txt" => Some("text"),
|
||||
"csv" => Some("csv"),
|
||||
"dockerfile" => Some("docker"),
|
||||
"tf" => Some("terraform"),
|
||||
"ex" | "exs" => Some("elixir"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a tool_use event into a human-readable string.
|
||||
fn format_tool_call(line: &str) -> String {
|
||||
let tool_name = extract_str(line, "name").unwrap_or("?");
|
||||
|
||||
match tool_name {
|
||||
"Read" => {
|
||||
let path = extract_str(line, "file_path").unwrap_or("?");
|
||||
let badge = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.and_then(ext_to_label)
|
||||
.map(|label| format!(" {}[{}]{}", DIM, label, RESET))
|
||||
.unwrap_or_default();
|
||||
format!("{}{}Read:{} {}{}{}{}", BOLD, CYAN, RESET, DIM, path, RESET, badge)
|
||||
}
|
||||
"Edit" => {
|
||||
let path = extract_str(line, "file_path").unwrap_or("?");
|
||||
let header = format!("{}{}Edit:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET);
|
||||
let diff = format_edit_diff(line);
|
||||
if diff.is_empty() {
|
||||
header
|
||||
} else {
|
||||
format!("{}\n{}", header, diff)
|
||||
}
|
||||
}
|
||||
"Write" => {
|
||||
let path = extract_str(line, "file_path").unwrap_or("?");
|
||||
let header = format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET);
|
||||
let preview = format_write_preview(line);
|
||||
if preview.is_empty() {
|
||||
header
|
||||
} else {
|
||||
format!("{}\n{}", header, preview)
|
||||
}
|
||||
}
|
||||
"Bash" => {
|
||||
let cmd = extract_str(line, "command").unwrap_or("?");
|
||||
if cmd.len() > 80 {
|
||||
let truncated: String = cmd.chars().take(77).collect();
|
||||
format!("{}{}Bash:{} {}{}...{}", BOLD, MAGENTA, RESET, DIM, truncated, RESET)
|
||||
} else {
|
||||
format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET)
|
||||
}
|
||||
}
|
||||
"Glob" => {
|
||||
let pat = extract_str(line, "pattern").unwrap_or("?");
|
||||
format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||||
}
|
||||
"Grep" => {
|
||||
let pat = extract_str(line, "pattern").unwrap_or("?");
|
||||
format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||||
}
|
||||
other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle "assistant" events: render tool_use summaries and track tool counts.
|
||||
fn handle_assistant(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
|
||||
if !line.contains("\"tool_use\"") {
|
||||
return Ok(());
|
||||
}
|
||||
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) {
|
||||
state.tool_use_starts.insert(id.to_string(), (name.to_string(), Instant::now()));
|
||||
*state.tool_counts.entry(name.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
let desc = format_tool_call(line);
|
||||
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)
|
||||
}
|
||||
|
||||
/// Handle "stream_event" events: render thinking timer, text deltas, and block boundaries.
|
||||
fn handle_stream_event(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
|
||||
if line.contains("\"content_block_delta\"") {
|
||||
if line.contains("\"thinking_delta\"") {
|
||||
if let Some(start) = state.thinking_start {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
|
||||
out.flush()?;
|
||||
}
|
||||
} else if line.contains("\"text_delta\"")
|
||||
&& let Some(text) = extract_str(line, "text")
|
||||
{
|
||||
let text = unescape_json(text);
|
||||
write!(out, "{}{}{}", DIM, text, RESET)?;
|
||||
out.flush()?;
|
||||
}
|
||||
} else if line.contains("\"content_block_start\"") {
|
||||
if line.contains("\"thinking\"") {
|
||||
state.thinking_start = Some(Instant::now());
|
||||
write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?;
|
||||
out.flush()?;
|
||||
state.in_thinking = true;
|
||||
} else if !line.contains("\"tool_use\"") {
|
||||
writeln!(out)?;
|
||||
}
|
||||
} else if line.contains("\"content_block_stop\"") {
|
||||
if state.in_thinking {
|
||||
if let Some(start) = state.thinking_start {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
state.thinking_total_secs += elapsed;
|
||||
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
|
||||
}
|
||||
state.in_thinking = false;
|
||||
state.thinking_start = None;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle "user" events: render tool_result success/error badges and
|
||||
/// accumulate the wall-clock duration of each tool call by name.
|
||||
fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
|
||||
if !line.contains("\"tool_result\"") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Pair this result with its tool_use; drop the entry and accumulate elapsed.
|
||||
// Done for both success and error so failed tools still show up in metrics.
|
||||
let tool_use_id = extract_str(line, "tool_use_id").map(|s| s.to_string());
|
||||
let tool_name_owned: Option<String> = tool_use_id.and_then(|id| {
|
||||
state.tool_use_starts.remove(&id).map(|(name, start)| {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
*state.tool_durations.entry(name.clone()).or_insert(0.0) += elapsed;
|
||||
name
|
||||
})
|
||||
});
|
||||
|
||||
let is_error = line.contains("\"is_error\":true") || line.contains("\"is_error\": true");
|
||||
if is_error {
|
||||
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
|
||||
let tail = format_bash_error_tail(line);
|
||||
if !tail.is_empty() {
|
||||
writeln!(out, "{}", tail)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let badge = match tool_name_owned.as_deref() {
|
||||
Some(name @ ("Grep" | "Glob")) => format_grep_glob_badge(name, line),
|
||||
_ => String::new(),
|
||||
};
|
||||
if badge.is_empty() {
|
||||
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)
|
||||
} else {
|
||||
writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single NDJSON line, writing formatted output to `out`.
|
||||
/// `prior_total` is the accumulated cost from previous iterations, used to display a running total.
|
||||
/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop.
|
||||
fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> {
|
||||
// Check for turn boundary (message_id change)
|
||||
if let Some(msg_id) = extract_str(line, "message_id") {
|
||||
let is_new = state.current_msg_id.as_deref() != Some(msg_id);
|
||||
if is_new {
|
||||
state.current_msg_id = Some(msg_id.to_string());
|
||||
state.turn_num += 1;
|
||||
writeln!(out, "{}{}━━━ Turn {} ━━━{}", BOLD, ORANGE, state.turn_num, RESET)?;
|
||||
}
|
||||
}
|
||||
|
||||
match extract_str(line, "type") {
|
||||
Some("system") => {
|
||||
if extract_str(line, "subtype") == Some("init") && !state.seen_init {
|
||||
state.seen_init = true;
|
||||
let sid = extract_str(line, "session_id").unwrap_or("?");
|
||||
if sid != "?" {
|
||||
state.session_id = Some(sid.to_string());
|
||||
}
|
||||
let sid_short: String = sid.chars().take(12).collect();
|
||||
let model = extract_str(line, "model").unwrap_or("?");
|
||||
writeln!(out, "{}{}[stream]{} session {}… model={}", ORANGE, BOLD, RESET, sid_short, model)?;
|
||||
}
|
||||
}
|
||||
Some("assistant") => handle_assistant(out, line, state)?,
|
||||
Some("stream_event") => handle_stream_event(out, line, state)?,
|
||||
Some("user") => handle_tool_result(out, line, state)?,
|
||||
Some("result") => {
|
||||
let cost = extract_num(line, "cost_usd").unwrap_or(0.0);
|
||||
state.iteration_cost = cost;
|
||||
let total = prior_total + cost;
|
||||
let turns = extract_num(line, "num_turns").unwrap_or(0.0) as u32;
|
||||
let duration = extract_num(line, "duration_ms").unwrap_or(0.0);
|
||||
let dur_secs = duration / 1000.0;
|
||||
state.iteration_duration_secs = dur_secs;
|
||||
writeln!(
|
||||
out,
|
||||
"{}{}[stream]{} done cost=${:.2} (total=${:.2}) turns={} duration={:.1}s",
|
||||
ORANGE, BOLD, RESET, cost, total, turns, dur_secs
|
||||
)?;
|
||||
}
|
||||
None if !line.trim().is_empty() => {
|
||||
writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a compact one-line iteration summary strip from accumulated state.
|
||||
/// Format: `⟪ 6 turns │ 3 edit 1.8s │ 1 bash 12.3s │ thinking 4.1s │ 42s │ $0.38 ⟫`
|
||||
///
|
||||
/// Tool entries show count + elapsed for the top-3 tools by wall time, then
|
||||
/// count-only for the rest (stops the strip overflowing 80 cols).
|
||||
fn format_summary_strip(state: &StreamState) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
parts.push(format!("{} turn{}", state.turn_num, if state.turn_num == 1 { "" } else { "s" }));
|
||||
|
||||
// Rank tools by elapsed time; top-3 get the "Ns" suffix, the rest are
|
||||
// count-only. Tools with no recorded duration (e.g. tool_result never
|
||||
// arrived) sort to the end of the timed list.
|
||||
let mut ranked: Vec<(&String, u32, f64)> = state
|
||||
.tool_counts
|
||||
.iter()
|
||||
.map(|(name, &count)| {
|
||||
let secs = state.tool_durations.get(name).copied().unwrap_or(0.0);
|
||||
(name, count, secs)
|
||||
})
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
for (i, (name, count, secs)) in ranked.iter().enumerate() {
|
||||
let label = name.to_lowercase();
|
||||
if i < 3 && *secs > 0.0 {
|
||||
parts.push(format!("{} {} {:.1}s", count, label, secs));
|
||||
} else {
|
||||
parts.push(format!("{} {}", count, label));
|
||||
}
|
||||
}
|
||||
|
||||
if state.thinking_total_secs > 0.0 {
|
||||
parts.push(format!("thinking {:.1}s", state.thinking_total_secs));
|
||||
}
|
||||
|
||||
// Duration
|
||||
parts.push(format!("{:.0}s", state.iteration_duration_secs));
|
||||
|
||||
// Cost
|
||||
parts.push(format!("${:.2}", state.iteration_cost));
|
||||
|
||||
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
|
||||
}
|
||||
|
||||
/// Shared stream loop: reads lines from stdout, tees to log, calls processor per line,
|
||||
/// prints a summary strip, and finalizes state into a caller-defined return type.
|
||||
///
|
||||
/// Used by both Claude and OpenCode stream filters to avoid duplicating the
|
||||
/// BufReader/signal-check/log-tee boilerplate. `finalize` consumes the state
|
||||
/// so the caller can move out of it (e.g. into a `StreamSummary`).
|
||||
/// Shared stream loop: reads lines from stdout, tees to log, calls processor per
|
||||
/// line, prints a summary strip, and finalizes state into a caller-defined
|
||||
/// return type.
|
||||
pub fn run_stream_loop<S, R>(
|
||||
stdout: ChildStdout,
|
||||
log_path: Option<&Path>,
|
||||
|
|
@ -519,7 +55,7 @@ pub fn run_stream_loop<S, R>(
|
|||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref mut f) = log_file {
|
||||
if let Some(f) = &mut log_file {
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
|
||||
|
|
@ -535,18 +71,3 @@ pub fn run_stream_loop<S, R>(
|
|||
let _ = out.flush();
|
||||
finalize(state)
|
||||
}
|
||||
|
||||
/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout.
|
||||
/// `prior_total` is the accumulated cost from previous iterations.
|
||||
/// Returns a `StreamSummary` carrying cost, thinking time, tool durations, etc.
|
||||
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> StreamSummary {
|
||||
let state = StreamState::new();
|
||||
run_stream_loop(
|
||||
stdout,
|
||||
log_path,
|
||||
state,
|
||||
|out, line, st| process_line(out, line, st, prior_total),
|
||||
|st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None },
|
||||
|st| st.into_summary(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
274
src/stream_omp.rs
Normal file
274
src/stream_omp.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::process::ChildStdout;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW};
|
||||
use crate::json::{extract_bool, extract_num, extract_str, unescape_json};
|
||||
use crate::stream::StreamSummary;
|
||||
|
||||
struct StreamState {
|
||||
turn_num: u32,
|
||||
iteration_cost: f64,
|
||||
iteration_duration_secs: f64,
|
||||
tool_counts: HashMap<String, u32>,
|
||||
tool_durations: HashMap<String, f64>,
|
||||
tool_starts: HashMap<String, (String, Instant)>,
|
||||
session_id: Option<String>,
|
||||
saw_done: bool,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
turn_num: 0,
|
||||
iteration_cost: 0.0,
|
||||
iteration_duration_secs: 0.0,
|
||||
tool_counts: HashMap::new(),
|
||||
tool_durations: HashMap::new(),
|
||||
tool_starts: HashMap::new(),
|
||||
session_id: None,
|
||||
saw_done: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_summary(self) -> StreamSummary {
|
||||
StreamSummary {
|
||||
cost_usd: self.iteration_cost,
|
||||
agent_reported_secs: if self.iteration_duration_secs > 0.0 {
|
||||
Some(self.iteration_duration_secs)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
num_turns: self.turn_num,
|
||||
thinking_secs: 0.0,
|
||||
tool_counts: self.tool_counts.into_iter().collect(),
|
||||
tool_durations_secs: self.tool_durations.into_iter().collect(),
|
||||
session_id: self.session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tool_call(tool_name: &str, line: &str) -> String {
|
||||
match tool_name {
|
||||
"read" => {
|
||||
let path = extract_str(line, "path")
|
||||
.or_else(|| extract_str(line, "filePath"))
|
||||
.unwrap_or("?");
|
||||
format!("{}{}Read:{} {}{}{}", BOLD, CYAN, RESET, DIM, path, RESET)
|
||||
}
|
||||
"write" => {
|
||||
let path = extract_str(line, "path")
|
||||
.or_else(|| extract_str(line, "filePath"))
|
||||
.unwrap_or("?");
|
||||
format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET)
|
||||
}
|
||||
"edit" | "apply_patch" => {
|
||||
format!("{}{}Edit{}{}", BOLD, YELLOW, RESET, RESET)
|
||||
}
|
||||
"bash" => {
|
||||
let cmd = extract_str(line, "command").unwrap_or("?");
|
||||
let cmd = unescape_json(cmd);
|
||||
let mut chars = cmd.chars();
|
||||
let preview: String = chars.by_ref().take(77).collect();
|
||||
if chars.next().is_some() {
|
||||
format!(
|
||||
"{}{}Bash:{} {}{}...{}",
|
||||
BOLD, MAGENTA, RESET, DIM, preview, RESET
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}{}Bash:{} {}{}{}",
|
||||
BOLD, MAGENTA, RESET, DIM, preview, RESET
|
||||
)
|
||||
}
|
||||
}
|
||||
"glob" => {
|
||||
let pat = extract_str(line, "pattern").unwrap_or("?");
|
||||
format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||||
}
|
||||
"grep" => {
|
||||
let pat = extract_str(line, "pattern").unwrap_or("?");
|
||||
format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||||
}
|
||||
other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tool_start(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
line: &str,
|
||||
state: &mut StreamState,
|
||||
) -> io::Result<()> {
|
||||
let tool_name = extract_str(line, "toolName").unwrap_or("?");
|
||||
let tool_id = extract_str(line, "toolCallId").unwrap_or("");
|
||||
*state.tool_counts.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||
if !tool_id.is_empty() {
|
||||
state
|
||||
.tool_starts
|
||||
.insert(tool_id.to_string(), (tool_name.to_string(), Instant::now()));
|
||||
}
|
||||
let desc = format_tool_call(tool_name, line);
|
||||
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)
|
||||
}
|
||||
|
||||
fn handle_tool_end(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
line: &str,
|
||||
state: &mut StreamState,
|
||||
) -> io::Result<()> {
|
||||
let tool_name = extract_str(line, "toolName").unwrap_or("?").to_string();
|
||||
let elapsed = extract_num(line, "wallTimeMs")
|
||||
.map(|ms| ms / 1000.0)
|
||||
.or_else(|| {
|
||||
extract_str(line, "toolCallId").and_then(|id| {
|
||||
state
|
||||
.tool_starts
|
||||
.remove(id)
|
||||
.map(|(_, start)| start.elapsed().as_secs_f64())
|
||||
})
|
||||
});
|
||||
if let Some(secs) = elapsed {
|
||||
*state.tool_durations.entry(tool_name).or_insert(0.0) += secs;
|
||||
}
|
||||
|
||||
let is_error = extract_bool(line, "isError").unwrap_or(false);
|
||||
if is_error {
|
||||
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)
|
||||
} else {
|
||||
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_message_end(line: &str, state: &mut StreamState) {
|
||||
if extract_str(line, "role") != Some("assistant") {
|
||||
return;
|
||||
}
|
||||
state.iteration_cost += extract_num(line, "total").unwrap_or(0.0);
|
||||
state.iteration_duration_secs += extract_num(line, "duration").unwrap_or(0.0) / 1000.0;
|
||||
}
|
||||
|
||||
fn print_done(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
state: &mut StreamState,
|
||||
prior_total: f64,
|
||||
) -> io::Result<()> {
|
||||
if state.saw_done {
|
||||
return Ok(());
|
||||
}
|
||||
state.saw_done = true;
|
||||
writeln!(
|
||||
out,
|
||||
"{}{}[stream]{} done cost=${:.2} (total=${:.2}) turns={} duration={:.1}s",
|
||||
ORANGE,
|
||||
BOLD,
|
||||
RESET,
|
||||
state.iteration_cost,
|
||||
prior_total + state.iteration_cost,
|
||||
state.turn_num,
|
||||
state.iteration_duration_secs
|
||||
)
|
||||
}
|
||||
|
||||
fn process_line(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
line: &str,
|
||||
state: &mut StreamState,
|
||||
prior_total: f64,
|
||||
) -> io::Result<()> {
|
||||
match extract_str(line, "type") {
|
||||
Some("session") => {
|
||||
if let Some(id) = extract_str(line, "id") {
|
||||
state.session_id = Some(id.to_string());
|
||||
let sid_short: String = id.chars().take(12).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}{}[stream]{} session {}…",
|
||||
ORANGE, BOLD, RESET, sid_short
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Some("turn_start") => {
|
||||
state.turn_num += 1;
|
||||
writeln!(
|
||||
out,
|
||||
"{}{}━━━ Turn {} ━━━{}",
|
||||
BOLD, ORANGE, state.turn_num, RESET
|
||||
)?;
|
||||
}
|
||||
Some("message_update") => {
|
||||
if line.contains("\"type\":\"text_delta\"") || line.contains("\"type\": \"text_delta\"")
|
||||
{
|
||||
if let Some(delta) = extract_str(line, "delta") {
|
||||
write!(out, "{}{}{}", DIM, unescape_json(delta), RESET)?;
|
||||
out.flush()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("tool_execution_start") => handle_tool_start(out, line, state)?,
|
||||
Some("tool_execution_end") => handle_tool_end(out, line, state)?,
|
||||
Some("message_end") => handle_message_end(line, state),
|
||||
Some("agent_end") => print_done(out, state, prior_total)?,
|
||||
None if !line.trim().is_empty() => {
|
||||
writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_summary_strip(state: &StreamState) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(format!(
|
||||
"{} turn{}",
|
||||
state.turn_num,
|
||||
if state.turn_num == 1 { "" } else { "s" }
|
||||
));
|
||||
|
||||
let mut ranked: Vec<(&String, u32, f64)> = state
|
||||
.tool_counts
|
||||
.iter()
|
||||
.map(|(name, &count)| {
|
||||
let secs = state.tool_durations.get(name).copied().unwrap_or(0.0);
|
||||
(name, count, secs)
|
||||
})
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
for (i, (name, count, secs)) in ranked.iter().enumerate() {
|
||||
if i < 3 && *secs > 0.0 {
|
||||
parts.push(format!("{} {} {:.1}s", count, name, secs));
|
||||
} else {
|
||||
parts.push(format!("{} {}", count, name));
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(format!("{:.0}s", state.iteration_duration_secs));
|
||||
parts.push(format!("${:.2}", state.iteration_cost));
|
||||
|
||||
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
|
||||
}
|
||||
|
||||
/// Filter OMP NDJSON and format it as rich ANSI output on stdout.
|
||||
pub fn filter_stream(
|
||||
stdout: ChildStdout,
|
||||
log_path: Option<&Path>,
|
||||
prior_total: f64,
|
||||
) -> StreamSummary {
|
||||
let state = StreamState::new();
|
||||
crate::stream::run_stream_loop(
|
||||
stdout,
|
||||
log_path,
|
||||
state,
|
||||
|out, line, st| process_line(out, line, st, prior_total),
|
||||
|st| {
|
||||
if st.turn_num > 0 {
|
||||
Some(format_summary_strip(st))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|st| st.into_summary(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::process::ChildStdout;
|
||||
|
||||
use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW};
|
||||
use crate::json::{extract_num, extract_str, unescape_json};
|
||||
use crate::stream::StreamSummary;
|
||||
|
||||
struct StreamState {
|
||||
turn_num: u32,
|
||||
iteration_cost: f64,
|
||||
tool_counts: HashMap<String, u32>,
|
||||
total_tokens: u64,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
turn_num: 0,
|
||||
iteration_cost: 0.0,
|
||||
tool_counts: HashMap::new(),
|
||||
total_tokens: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tool_call(tool_name: &str, input: &str) -> String {
|
||||
match tool_name {
|
||||
"read" => {
|
||||
let path = extract_str(input, "filePath").unwrap_or("?");
|
||||
format!("{}{}Read:{} {}{}{}", BOLD, CYAN, RESET, DIM, path, RESET)
|
||||
}
|
||||
"write" => {
|
||||
let path = extract_str(input, "filePath").unwrap_or("?");
|
||||
format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET)
|
||||
}
|
||||
"apply_patch" => {
|
||||
format!("{}{}ApplyPatch{}{}", BOLD, YELLOW, RESET, RESET)
|
||||
}
|
||||
"bash" => {
|
||||
let cmd = extract_str(input, "command").unwrap_or("?");
|
||||
if cmd.len() > 80 {
|
||||
format!(
|
||||
"{}{}Bash:{} {}{}...{}",
|
||||
BOLD,
|
||||
MAGENTA,
|
||||
RESET,
|
||||
DIM,
|
||||
&cmd.chars().take(77).collect::<String>(),
|
||||
RESET
|
||||
)
|
||||
} else {
|
||||
format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET)
|
||||
}
|
||||
}
|
||||
"glob" => {
|
||||
let pat = extract_str(input, "pattern").unwrap_or("?");
|
||||
format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||||
}
|
||||
"grep" => {
|
||||
let pat = extract_str(input, "pattern").unwrap_or("?");
|
||||
format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||||
}
|
||||
other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
|
||||
let ev_type = extract_str(line, "type");
|
||||
|
||||
match ev_type {
|
||||
Some("step_start") => {
|
||||
state.turn_num += 1;
|
||||
writeln!(
|
||||
out,
|
||||
"{}{}━━━ Turn {} ━━━{}",
|
||||
BOLD, ORANGE, state.turn_num, RESET
|
||||
)?;
|
||||
}
|
||||
Some("text") => {
|
||||
if let Some(text) = extract_str(line, "text") {
|
||||
let text = unescape_json(text);
|
||||
write!(out, "{}{}{}", DIM, text, RESET)?;
|
||||
out.flush()?;
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
let tool_name = extract_str(line, "tool").unwrap_or("?");
|
||||
|
||||
let status = if line.contains("\"status\":\"error\"")
|
||||
|| line.contains("\"status\": \"error\"")
|
||||
{
|
||||
"error"
|
||||
} else if line.contains("\"status\":\"completed\"")
|
||||
|| line.contains("\"status\": \"completed\"")
|
||||
{
|
||||
"completed"
|
||||
} else {
|
||||
"pending"
|
||||
};
|
||||
|
||||
*state.tool_counts.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||
|
||||
if status == "error" {
|
||||
if let Some(error) = extract_str(line, "error") {
|
||||
let error = unescape_json(error);
|
||||
writeln!(
|
||||
out,
|
||||
" {}>> {}{}{} {}{}✗{}",
|
||||
GRAY, RESET, BOLD, tool_name, RESET, RED, RESET
|
||||
)?;
|
||||
writeln!(out, " {}{}{}", RED, error, RESET)?;
|
||||
} else {
|
||||
writeln!(
|
||||
out,
|
||||
" {}>> {}{}{} {}{}✗{}",
|
||||
GRAY, RESET, BOLD, tool_name, RESET, RED, RESET
|
||||
)?;
|
||||
}
|
||||
} else if status == "completed" {
|
||||
let input = extract_str(line, "input").unwrap_or("");
|
||||
let desc = format_tool_call(tool_name, input);
|
||||
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
|
||||
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
|
||||
} else {
|
||||
let input = extract_str(line, "input").unwrap_or("");
|
||||
let desc = format_tool_call(tool_name, input);
|
||||
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
|
||||
}
|
||||
}
|
||||
Some("step_finish") => {
|
||||
let cost = extract_num(line, "cost").unwrap_or(0.0);
|
||||
state.iteration_cost += cost;
|
||||
|
||||
let tokens = extract_num(line, "total").unwrap_or(0.0) as u64;
|
||||
state.total_tokens = tokens;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_summary_strip(state: &StreamState) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
parts.push(format!(
|
||||
"{} turn{}",
|
||||
state.turn_num,
|
||||
if state.turn_num == 1 { "" } else { "s" }
|
||||
));
|
||||
|
||||
let tool_order = ["bash", "read", "write", "apply_patch", "glob", "grep"];
|
||||
for tool in &tool_order {
|
||||
if let Some(&count) = state.tool_counts.get(*tool) {
|
||||
parts.push(format!("{} {}", count, tool));
|
||||
}
|
||||
}
|
||||
for (name, &count) in &state.tool_counts {
|
||||
if !tool_order.contains(&name.as_str()) {
|
||||
parts.push(format!("{} {}", count, name));
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(format!("${:.2}", state.iteration_cost));
|
||||
|
||||
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
|
||||
}
|
||||
|
||||
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, _prior_total: f64) -> StreamSummary {
|
||||
let state = StreamState::new();
|
||||
crate::stream::run_stream_loop(
|
||||
stdout,
|
||||
log_path,
|
||||
state,
|
||||
|out, line, st| process_line(out, line, st),
|
||||
|st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None },
|
||||
|st| StreamSummary {
|
||||
cost_usd: st.iteration_cost,
|
||||
agent_reported_secs: None,
|
||||
num_turns: st.turn_num,
|
||||
thinking_secs: 0.0,
|
||||
tool_counts: st.tool_counts.into_iter().collect(),
|
||||
tool_durations_secs: std::collections::BTreeMap::new(),
|
||||
session_id: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -43,39 +43,20 @@ The first line of `.loop/notes.md` must be one of:
|
|||
- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures).
|
||||
- `STATUS: DONE` — All stages are implemented and you believe guards will pass.
|
||||
|
||||
## KEEP: Carrying File Context Across Iterations
|
||||
## Session Continuity
|
||||
|
||||
Your conversation history persists across worker iterations via `--resume`.
|
||||
To keep Claude's prompt cache warm without ballooning, the outer loop trims
|
||||
your session between rounds: it drops `Bash` output, thinking, and any file
|
||||
`Read` results that aren't on your KEEP list. Everything else (text turns,
|
||||
intermediate `Edit`/`Grep`/`Glob` results) is also dropped.
|
||||
Yoke resumes the same worker OMP session across worker iterations.
|
||||
`.loop/notes.md`, `.loop/plan.md`, `.loop/protocol.md`, `.loop/verdict.md`,
|
||||
and `.loop/guard-results.md` also live on disk, so re-read them every
|
||||
iteration instead of trusting stale context.
|
||||
|
||||
After STATUS, on its own line in `.loop/notes.md`, list the file paths you
|
||||
want to keep cached for the next iteration:
|
||||
|
||||
```
|
||||
STATUS: IN_PROGRESS
|
||||
KEEP: src/foo.rs src/bar.rs tests/baz.rs
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Space-separated repo-relative paths (or absolute).
|
||||
- List files you read **this iteration** and will still need next iteration.
|
||||
- Don't list `.loop/*` files — those live on disk and are re-read fresh.
|
||||
- Keep the list tight. Every kept file is paid for at cache-read rates
|
||||
every round it stays. Drop a file once it's no longer relevant.
|
||||
- Omit `KEEP:` (or `KEEP: *`) to keep nothing.
|
||||
|
||||
Note: the judge always runs in a fresh session — your KEEP list does not
|
||||
affect the judge.
|
||||
The judge always runs in a fresh OMP session.
|
||||
|
||||
## What Happens After You Exit
|
||||
|
||||
1. Guards run (diff boundary check + configured guard commands).
|
||||
2. If guards pass and STATUS is DONE, the plan loop ends.
|
||||
3. Then the judge (a fresh Claude with zero implementation context) verifies the feature.
|
||||
3. Then the judge (a fresh OMP session with zero implementation context) verifies the feature.
|
||||
4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback.
|
||||
|
||||
## Rules
|
||||
|
|
|
|||
|
|
@ -24,46 +24,32 @@
|
|||
# 6. Run hooks
|
||||
# 7. Check: STATUS: DONE + all guards pass → exit inner loop
|
||||
|
||||
# ── Backend ────────────────────────────────────────────────────────────
|
||||
# Which LLM backend to use. Leave commented for Claude CLI (default).
|
||||
# Setting `model` switches to the OpenCode backend, which supports
|
||||
# OpenRouter, OpenAI, Anthropic API, and other providers.
|
||||
# ── OMP model ──────────────────────────────────────────────────────────
|
||||
# Yoke invokes `omp` by default. Leave commented to use OMP's configured
|
||||
# default model, or set a model name exactly as you would pass to
|
||||
# `omp --model`.
|
||||
#
|
||||
# model openrouter/anthropic/claude-sonnet-4
|
||||
# model openai/gpt-4o
|
||||
# model anthropic/claude-sonnet-4
|
||||
|
||||
# ── Claude model (optional) ────────────────────────────────────────────
|
||||
# Override the model the Claude CLI uses for each iteration. Leave
|
||||
# commented to use Claude Code's default (Opus). Useful for trading some
|
||||
# reasoning depth for faster, cheaper iterations.
|
||||
#
|
||||
# claude-model claude-sonnet-4-6
|
||||
# claude-model claude-haiku-4-5
|
||||
# model gpt-5.5
|
||||
# model openai/gpt-5.2
|
||||
# model gemini-2.5-pro
|
||||
|
||||
# ── Thinking budget (optional) ─────────────────────────────────────────
|
||||
# Cap extended-thinking tokens per turn for the Claude CLI backend.
|
||||
# Useful when running smaller/faster models (sonnet, haiku) and you'd
|
||||
# rather they spend the iteration acting than reasoning. Sets the
|
||||
# MAX_THINKING_TOKENS env var on the agent invocation.
|
||||
# Forwarded to `omp --thinking`.
|
||||
#
|
||||
# thinking off # disable extended thinking entirely (0 tokens)
|
||||
# thinking low # 2k tokens
|
||||
# thinking medium # 10k tokens
|
||||
# thinking high # 32k tokens
|
||||
#
|
||||
# Ignored by the OpenCode backend (warns at config load).
|
||||
# thinking off
|
||||
# thinking low
|
||||
# thinking medium
|
||||
# thinking high
|
||||
# thinking xhigh
|
||||
#
|
||||
# thinking low
|
||||
|
||||
# ── Sandbox ────────────────────────────────────────────────────────────
|
||||
# Docker image to run the agent inside. Your working directory is
|
||||
# bind-mounted into the container at /workspace. Required unless you
|
||||
# pass --no-sandbox on the command line.
|
||||
# ── Sandbox (optional) ─────────────────────────────────────────────────
|
||||
# By default OMP runs on the host. Set `image` only if the image contains
|
||||
# an `omp` executable; yoke bind-mounts the working directory at /workspace
|
||||
# and uses `omp` as the container entrypoint.
|
||||
#
|
||||
# Note: sandbox is not currently supported with the `model` directive.
|
||||
|
||||
image claude-code-sandbox:latest
|
||||
# image omp-sandbox:latest
|
||||
|
||||
# ── Output ─────────────────────────────────────────────────────────────
|
||||
# max-tail: max lines of output kept *per guard* in guard-results.md.
|
||||
|
|
|
|||
116
src/templates/grind/grind-gate.py
Executable file
116
src/templates/grind/grind-gate.py
Executable file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASELINE_PATH = Path(".loop/cstat-baseline.json")
|
||||
CURRENT_PATH = Path(".loop/cstat-current.json")
|
||||
ORACLE_CMD = os.environ.get("GRIND_ORACLE", "cargo test")
|
||||
BENCH_CMD = os.environ.get("GRIND_BENCH", "")
|
||||
CSTAT_CMD = os.environ.get("GRIND_CSTAT", "cstat --path . --json scorecard")
|
||||
|
||||
|
||||
def emit(text, stream=sys.stdout):
|
||||
if text:
|
||||
print(text, end="" if text.endswith("\n") else "\n", file=stream)
|
||||
|
||||
|
||||
def fail(message):
|
||||
print("GRIND: FAIL")
|
||||
print(message)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_json_file(path, label):
|
||||
if not path.exists():
|
||||
fail(f"{label} missing: {path}")
|
||||
text = path.read_text()
|
||||
if not text.strip():
|
||||
fail(f"{label} is empty: {path}")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
fail(f"{label} is invalid JSON: {exc}")
|
||||
|
||||
|
||||
def numeric_cost(data, label):
|
||||
value = data.get("code_complexity_cost")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
fail(f"{label} missing numeric code_complexity_cost")
|
||||
return float(value)
|
||||
|
||||
|
||||
def run_shell(label, command):
|
||||
if not command.strip():
|
||||
return
|
||||
print(f"$ {command}")
|
||||
proc = subprocess.run(command, shell=True, text=True, capture_output=True)
|
||||
emit(proc.stdout)
|
||||
emit(proc.stderr, sys.stderr)
|
||||
if proc.returncode != 0:
|
||||
fail(f"{label} failed: exit {proc.returncode}")
|
||||
|
||||
|
||||
def run_cstat():
|
||||
print(f"$ {CSTAT_CMD}")
|
||||
proc = subprocess.run(CSTAT_CMD, shell=True, text=True, capture_output=True)
|
||||
CURRENT_PATH.write_text(proc.stdout)
|
||||
emit(proc.stdout)
|
||||
emit(proc.stderr, sys.stderr)
|
||||
if proc.returncode != 0:
|
||||
fail(f"cstat scorecard failed: exit {proc.returncode}")
|
||||
try:
|
||||
return json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
fail(f"cstat scorecard output is invalid JSON: {exc}")
|
||||
|
||||
|
||||
def print_top_contributors(data):
|
||||
top = data.get("top_contributors")
|
||||
if not isinstance(top, list) or not top:
|
||||
return
|
||||
print("top current contributors:")
|
||||
for idx, item in enumerate(top[:10], 1):
|
||||
if not isinstance(item, dict):
|
||||
print(f"{idx}. {item}")
|
||||
continue
|
||||
kind = item.get("kind", "?")
|
||||
file = item.get("file", "?")
|
||||
cost = item.get("cost", "?")
|
||||
function = item.get("function")
|
||||
if function:
|
||||
print(f"{idx}. {kind} {file} function={function} cost={cost}")
|
||||
else:
|
||||
print(f"{idx}. {kind} {file} cost={cost}")
|
||||
|
||||
|
||||
def main():
|
||||
baseline = load_json_file(BASELINE_PATH, "cstat baseline")
|
||||
baseline_cost = numeric_cost(baseline, "cstat baseline")
|
||||
|
||||
run_shell("behavior oracle", ORACLE_CMD)
|
||||
run_shell("benchmark gate", BENCH_CMD)
|
||||
|
||||
current = run_cstat()
|
||||
current_cost = numeric_cost(current, "current cstat scorecard")
|
||||
|
||||
if current_cost < baseline_cost:
|
||||
print(
|
||||
f"GRIND: PASS cstat scorecard improved: "
|
||||
f"baseline={baseline_cost:g} current={current_cost:g}"
|
||||
)
|
||||
return
|
||||
|
||||
print("GRIND: FAIL")
|
||||
print(
|
||||
f"cstat scorecard did not improve: "
|
||||
f"baseline={baseline_cost:g} current={current_cost:g}"
|
||||
)
|
||||
print_top_contributors(current)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
94
src/templates/grind/protocol.md
Normal file
94
src/templates/grind/protocol.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Protocol: Grind
|
||||
|
||||
You are operating inside an automated grind loop — not a conversation. A harness launched you, and will run the grind gate after you exit.
|
||||
|
||||
## Files
|
||||
|
||||
| File | You can | Purpose |
|
||||
|------|---------|---------|
|
||||
| `.loop/protocol.md` | read | This document. Your instructions. |
|
||||
| `.loop/notes.md` | read + write | Your scratchpad across iterations. |
|
||||
| `.loop/guard-results.md` | read | The previous grind gate output. |
|
||||
| `.loop/cstat-baseline.json` | read | Baseline `cstat --path . --json scorecard` captured before edits. |
|
||||
| `.loop/cstat-current.json` | read | Last gate's current scorecard JSON, when present. |
|
||||
| `.loop/grind-gate.py` | read | The guard harness. It runs the behavior oracle, optional benchmark, and cstat comparison. |
|
||||
| `.loop/yoke.conf` | read | Scope rules and guard settings. |
|
||||
|
||||
All paths are relative to the repository root.
|
||||
|
||||
## Objective
|
||||
|
||||
Drive `code_complexity_cost` down aggressively while preserving observable behavior.
|
||||
|
||||
The gate passes only when the behavior oracle succeeds and current `cstat --path . --json scorecard` reports a lower `code_complexity_cost` than `.loop/cstat-baseline.json`.
|
||||
|
||||
## Using cstat
|
||||
|
||||
Primary objective command:
|
||||
|
||||
```text
|
||||
cstat --path . scorecard --top 20
|
||||
```
|
||||
|
||||
Machine-readable gate command:
|
||||
|
||||
```text
|
||||
cstat --path . --json scorecard
|
||||
```
|
||||
|
||||
Global options from `cstat help`:
|
||||
|
||||
- `--path <PATH>` — Rust project directory or Rust source file to analyze; default is `.`.
|
||||
- `--json` — output structured JSON instead of human-readable text.
|
||||
- `--verbose` — print educational explanations for each section.
|
||||
- `--no-color` — disable colored output.
|
||||
|
||||
Commands from `cstat help`:
|
||||
|
||||
- `scorecard` — deterministic structural code complexity scorecard. This is the objective. Lower `code_complexity_cost` is cleaner. Use `--top <N>` to show the highest contributors.
|
||||
- `branching` — function decision/path complexity rankings. Use it to simplify high-branching functions and collapse duplicated control flow.
|
||||
- `signature` — function API boundary complexity rankings. Use it to reduce public surface, parameter sprawl, generic noise, and unnecessary result shapes.
|
||||
- `span` — function implementation span rankings. Use it to find long functions that should be deleted, flattened, or split only when splitting reduces real complexity.
|
||||
- `deps` — module dependency connectome. Use it to remove coupling, merge needless modules, or move code when it reduces dependency pressure.
|
||||
- `dead-code` — static dead-code candidates. Use it to delete unused items, stale exports, and obsolete branches.
|
||||
- `test-reachability` — static test/benchmark reachability. Use it to understand what behavior is protected before cutting.
|
||||
- `call-trace` — tree view of calls from the Cargo binary entrypoint or `--entry <PATH::FUNCTION>`. Use it to understand runtime paths before deleting or moving code.
|
||||
- `coverage` — dynamic line and branch coverage from Rust source-based coverage data. Use `--no-run` with `CSTAT_LLVM_COV_EXPORT_JSON` when parsing existing coverage data.
|
||||
- `cluster` — value-cluster transcript from AST def-use structure. Use it to find code that belongs together or abstractions that should collapse.
|
||||
- `loc` — lines-of-code size-shape analysis. Use it to find file concentration and large generated-looking surfaces.
|
||||
- `symbols` — Rust AST symbol counts by kind, including trait impl blocks. Use it to find abstraction surface and symbol sprawl.
|
||||
|
||||
## Per-Iteration Steps
|
||||
|
||||
1. Read `.loop/notes.md`, `.loop/guard-results.md`, `.loop/cstat-baseline.json`, and `.loop/cstat-current.json` if it exists. If the previous gate failed on behavior, benchmark, boundary, or invalid cstat output, repair that first.
|
||||
2. Run `cstat --path . scorecard --top 20`. Compare current cost to the baseline and identify the biggest contributors.
|
||||
3. Use the relevant cstat diagnostics above. Do not sample one random command; pick the commands that explain the top contributors and the scorecard dimensions they affect.
|
||||
4. Push a coherent cleanup batch. Do not stop after one simplification. Delete dead code, collapse unnecessary abstractions, simplify branching, reduce signatures, shrink spans, and cut coupling while observable behavior remains the same.
|
||||
5. Treat guards as the safety net. Do not weaken behavior checks, but do not be timid because checks exist. If a cleanup is plausible, behavior-preserving, and aimed at measured complexity, do it.
|
||||
6. Re-run `cstat --path . scorecard --top 20` when practical. Run targeted behavior checks when they are cheap and directly cover risky edits; the grind gate will run the configured oracle after you exit.
|
||||
7. Update `.loop/notes.md`. The first line must be `STATUS: IN_PROGRESS` or `STATUS: DONE`. Include starting cost, ending cost if measured, files changed, complexity contributors attacked, guard failures repaired, and concrete next targets.
|
||||
8. Exit. Do not loop manually; Yoke handles the next iteration.
|
||||
|
||||
## STATUS Signaling
|
||||
|
||||
Use `STATUS: IN_PROGRESS` when more cleanup or repair is needed.
|
||||
Use `STATUS: DONE` only when current `code_complexity_cost` is lower than the baseline and you expect the behavior oracle plus optional benchmark to pass.
|
||||
|
||||
## What the Grind Gate Checks
|
||||
|
||||
The guard command runs `python3 .loop/grind-gate.py`. It fails unless:
|
||||
|
||||
1. the behavior oracle command succeeds;
|
||||
2. the optional benchmark command succeeds, when configured;
|
||||
3. current `cstat --path . --json scorecard` has lower `code_complexity_cost` than the baseline.
|
||||
|
||||
If the gate fails, read `.loop/guard-results.md` on the next iteration and repair exactly that failure before pushing more complexity reduction.
|
||||
|
||||
## Rules
|
||||
|
||||
- No git operations. Do not commit, push, branch, reset, stash, or modify git config.
|
||||
- Do not modify `.loop/protocol.md`, `.loop/yoke.conf`, `.loop/grind-gate.py`, or `.loop/cstat-baseline.json`.
|
||||
- Do not weaken, delete, skip, or rewrite behavior checks to make the gate pass.
|
||||
- Do not edit behavior-defining tests, benches, benchmarks, examples, snapshots, or fixtures unless the user explicitly made behavior change part of the task. If the generated `yoke.conf` has no-modify rules commented out, infer the freeze rule from this protocol.
|
||||
- Prefer deletion, simplification, and surface-area reduction over new abstractions.
|
||||
- Push aggressively within the iteration; the default failure mode to avoid is timid under-cleanup.
|
||||
27
src/templates/grind/yoke.conf
Normal file
27
src/templates/grind/yoke.conf
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ Yoke configuration — grind profile ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
#
|
||||
# Grind reuses the normal Yoke loop. The guard is the harness: it runs the
|
||||
# behavior oracle, runs cstat scorecard, and fails unless scorecard cost is
|
||||
# lower than the baseline captured by `yoke init grind`.
|
||||
|
||||
max-tail 200
|
||||
|
||||
# Scope defaults: allow the repository by default because Yoke cannot know
|
||||
# each project's oracle layout. Uncomment and tune these after identifying
|
||||
# behavior-defining files for the target repository. Most-specific prefix wins.
|
||||
allow .
|
||||
# no-modify tests/
|
||||
# no-modify benches/
|
||||
# no-modify benchmarks/
|
||||
# no-modify examples/
|
||||
# no-modify snapshots/
|
||||
# no-modify fixtures/
|
||||
|
||||
# The gate reads .loop/cstat-baseline.json and compares it to current cstat.
|
||||
# Override commands for quick experiments by setting env vars before `yoke run`:
|
||||
# GRIND_ORACLE="cargo check" # default: cargo test
|
||||
# GRIND_BENCH="cargo bench --no-run" # default: empty / disabled
|
||||
# GRIND_CSTAT="cstat --path . --json scorecard"
|
||||
guard python3 .loop/grind-gate.py
|
||||
|
|
@ -37,36 +37,11 @@ The first line of `.loop/notes.md` must be one of:
|
|||
|
||||
The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass.
|
||||
|
||||
## KEEP: Carrying File Context Across Iterations
|
||||
## Session Continuity
|
||||
|
||||
Your conversation history persists across iterations via `--resume`. To keep
|
||||
Claude's prompt cache warm without ballooning, the outer loop trims your
|
||||
session between rounds: it drops `Bash` output, thinking, and any file
|
||||
`Read` results that aren't on your KEEP list. Everything else (text turns,
|
||||
intermediate `Edit`/`Grep`/`Glob` results) is also dropped.
|
||||
|
||||
After STATUS, on its own line in `.loop/notes.md`, list the file paths you
|
||||
want to keep cached for the next iteration:
|
||||
|
||||
```
|
||||
STATUS: IN_PROGRESS
|
||||
KEEP: src/foo.rs src/bar.rs tests/baz.rs
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Space-separated repo-relative paths (or absolute — both work).
|
||||
- List files you read **this iteration** and will still need next iteration.
|
||||
- Don't list `.loop/notes.md`, `.loop/plan.md`, `.loop/protocol.md`,
|
||||
`.loop/guard-results.md`, or `.loop/verdict.md` — those live on disk and
|
||||
you re-read them fresh every iteration. Listing them is harmless but wastes
|
||||
a slot.
|
||||
- Keep the list tight. Every kept file is paid for (at cache-read rates,
|
||||
~10% of fresh) every round it stays kept. Drop a file once you're confident
|
||||
you won't need it again.
|
||||
- Omit the `KEEP:` line entirely (or `KEEP: *`) to keep nothing — your
|
||||
conversation prefix shrinks to just the bootstrap. Use this after major
|
||||
refactors or when you've moved to an unrelated area of the code.
|
||||
Yoke resumes the same worker OMP session across iterations. `.loop/notes.md`,
|
||||
`.loop/plan.md`, `.loop/protocol.md`, and `.loop/guard-results.md` also live on
|
||||
disk, so re-read them every iteration instead of trusting stale context.
|
||||
|
||||
## What the Guards Check
|
||||
|
||||
|
|
|
|||
|
|
@ -15,46 +15,32 @@
|
|||
# Protected files are backed up at start and restored every iteration,
|
||||
# so the agent can never permanently corrupt its own instructions.
|
||||
|
||||
# ── Backend ────────────────────────────────────────────────────────────
|
||||
# Which LLM backend to use. Leave commented for Claude CLI (default).
|
||||
# Setting `model` switches to the OpenCode backend, which supports
|
||||
# OpenRouter, OpenAI, Anthropic API, and other providers.
|
||||
# ── OMP model ──────────────────────────────────────────────────────────
|
||||
# Yoke invokes `omp` by default. Leave commented to use OMP's configured
|
||||
# default model, or set a model name exactly as you would pass to
|
||||
# `omp --model`.
|
||||
#
|
||||
# model openrouter/anthropic/claude-sonnet-4
|
||||
# model openai/gpt-4o
|
||||
# model anthropic/claude-sonnet-4
|
||||
|
||||
# ── Claude model (optional) ────────────────────────────────────────────
|
||||
# Override the model the Claude CLI uses for each iteration. Leave
|
||||
# commented to use Claude Code's default (Opus). Useful for trading some
|
||||
# reasoning depth for faster, cheaper iterations.
|
||||
#
|
||||
# claude-model claude-sonnet-4-6
|
||||
# claude-model claude-haiku-4-5
|
||||
# model gpt-5.5
|
||||
# model openai/gpt-5.2
|
||||
# model gemini-2.5-pro
|
||||
|
||||
# ── Thinking budget (optional) ─────────────────────────────────────────
|
||||
# Cap extended-thinking tokens per turn for the Claude CLI backend.
|
||||
# Useful when running smaller/faster models (sonnet, haiku) and you'd
|
||||
# rather they spend the iteration acting than reasoning. Sets the
|
||||
# MAX_THINKING_TOKENS env var on the agent invocation.
|
||||
# Forwarded to `omp --thinking`.
|
||||
#
|
||||
# thinking off # disable extended thinking entirely (0 tokens)
|
||||
# thinking low # 2k tokens
|
||||
# thinking medium # 10k tokens
|
||||
# thinking high # 32k tokens
|
||||
#
|
||||
# Ignored by the OpenCode backend (warns at config load).
|
||||
# thinking off
|
||||
# thinking low
|
||||
# thinking medium
|
||||
# thinking high
|
||||
# thinking xhigh
|
||||
#
|
||||
# thinking low
|
||||
|
||||
# ── Sandbox ────────────────────────────────────────────────────────────
|
||||
# Docker image to run the agent inside. Your working directory is
|
||||
# bind-mounted into the container at /workspace. Required unless you
|
||||
# pass --no-sandbox on the command line.
|
||||
# ── Sandbox (optional) ─────────────────────────────────────────────────
|
||||
# By default OMP runs on the host. Set `image` only if the image contains
|
||||
# an `omp` executable; yoke bind-mounts the working directory at /workspace
|
||||
# and uses `omp` as the container entrypoint.
|
||||
#
|
||||
# Note: sandbox is not currently supported with the `model` directive.
|
||||
|
||||
image claude-code-sandbox:latest
|
||||
# image omp-sandbox:latest
|
||||
|
||||
# ── Output ─────────────────────────────────────────────────────────────
|
||||
# max-tail: max lines of output kept *per guard* in guard-results.md.
|
||||
|
|
@ -131,18 +117,9 @@ allow .
|
|||
|
||||
# guard cargo test
|
||||
|
||||
# ── Session continuity (KEEP) ─────────────────────────────────────────
|
||||
# The agent's Claude session is resumed across iterations to preserve the
|
||||
# prompt cache. Between iterations, yoke trims the session JSONL down to
|
||||
# the files the agent declares on a `KEEP:` line in .loop/notes.md, e.g.:
|
||||
#
|
||||
# STATUS: IN_PROGRESS
|
||||
# KEEP: src/foo.rs tests/bar.rs
|
||||
#
|
||||
# Bash output, thinking, and other tool results are dropped. Only kept
|
||||
# file Reads survive. .loop/notes.md, .loop/plan.md, .loop/protocol.md,
|
||||
# .loop/guard-results.md are re-read fresh each iteration and don't need
|
||||
# to be listed. Set YOKE_DISABLE_SESSION_TRIM=1 to skip trimming.
|
||||
# ── Session continuity ─────────────────────────────────────────────────
|
||||
# Yoke resumes the same worker OMP session across iterations. The .loop/
|
||||
# files live on disk and should be re-read every iteration.
|
||||
|
||||
# ── Periodic agents ───────────────────────────────────────────────────
|
||||
# Supplementary agents invoked at a fixed cadence (every N iterations).
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ The first line of `.loop/notes.md` must be one of:
|
|||
|
||||
1. Guards run (diff boundary check + configured guard commands).
|
||||
2. If guards pass and STATUS is DONE, the plan loop ends.
|
||||
3. Then the judge (a fresh Claude with zero implementation context) verifies the feature.
|
||||
3. Then the judge (a fresh OMP session with zero implementation context) verifies the feature.
|
||||
4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback.
|
||||
|
||||
## Rules
|
||||
|
|
|
|||
|
|
@ -29,46 +29,32 @@
|
|||
# Worker notes are appended to saga-log.md between chunks so the scoper
|
||||
# has full context of what has been accomplished so far.
|
||||
|
||||
# ── Backend ────────────────────────────────────────────────────────────
|
||||
# Which LLM backend to use. Leave commented for Claude CLI (default).
|
||||
# Setting `model` switches to the OpenCode backend, which supports
|
||||
# OpenRouter, OpenAI, Anthropic API, and other providers.
|
||||
# ── OMP model ──────────────────────────────────────────────────────────
|
||||
# Yoke invokes `omp` by default. Leave commented to use OMP's configured
|
||||
# default model, or set a model name exactly as you would pass to
|
||||
# `omp --model`.
|
||||
#
|
||||
# model openrouter/anthropic/claude-sonnet-4
|
||||
# model openai/gpt-4o
|
||||
# model anthropic/claude-sonnet-4
|
||||
|
||||
# ── Claude model (optional) ────────────────────────────────────────────
|
||||
# Override the model the Claude CLI uses for each iteration. Leave
|
||||
# commented to use Claude Code's default (Opus). Useful for trading some
|
||||
# reasoning depth for faster, cheaper iterations.
|
||||
#
|
||||
# claude-model claude-sonnet-4-6
|
||||
# claude-model claude-haiku-4-5
|
||||
# model gpt-5.5
|
||||
# model openai/gpt-5.2
|
||||
# model gemini-2.5-pro
|
||||
|
||||
# ── Thinking budget (optional) ─────────────────────────────────────────
|
||||
# Cap extended-thinking tokens per turn for the Claude CLI backend.
|
||||
# Useful when running smaller/faster models (sonnet, haiku) and you'd
|
||||
# rather they spend the iteration acting than reasoning. Sets the
|
||||
# MAX_THINKING_TOKENS env var on the agent invocation.
|
||||
# Forwarded to `omp --thinking`.
|
||||
#
|
||||
# thinking off # disable extended thinking entirely (0 tokens)
|
||||
# thinking low # 2k tokens
|
||||
# thinking medium # 10k tokens
|
||||
# thinking high # 32k tokens
|
||||
#
|
||||
# Ignored by the OpenCode backend (warns at config load).
|
||||
# thinking off
|
||||
# thinking low
|
||||
# thinking medium
|
||||
# thinking high
|
||||
# thinking xhigh
|
||||
#
|
||||
# thinking low
|
||||
|
||||
# ── Sandbox ────────────────────────────────────────────────────────────
|
||||
# Docker image to run the agent inside. Your working directory is
|
||||
# bind-mounted into the container at /workspace. Required unless you
|
||||
# pass --no-sandbox on the command line.
|
||||
# ── Sandbox (optional) ─────────────────────────────────────────────────
|
||||
# By default OMP runs on the host. Set `image` only if the image contains
|
||||
# an `omp` executable; yoke bind-mounts the working directory at /workspace
|
||||
# and uses `omp` as the container entrypoint.
|
||||
#
|
||||
# Note: sandbox is not currently supported with the `model` directive.
|
||||
|
||||
image claude-code-sandbox:latest
|
||||
# image omp-sandbox:latest
|
||||
|
||||
# ── Output ─────────────────────────────────────────────────────────────
|
||||
# max-tail: max lines of output kept *per guard* in guard-results.md.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! Verifies that after a judge writes VERDICT: FAIL, the verdict.md content
|
||||
//! survives into the next brute iteration so the agent can read the feedback.
|
||||
//!
|
||||
//! Uses a mock `claude` bash script to simulate both agent and judge,
|
||||
//! Uses a mock `omp` bash script to simulate both agent and judge,
|
||||
//! recording what the agent sees in verdict.md at each invocation.
|
||||
|
||||
use std::fs;
|
||||
|
|
@ -75,7 +75,7 @@ const CONF: &str = "\
|
|||
allow .
|
||||
";
|
||||
|
||||
/// Mock claude script that distinguishes agent vs judge by the -p prompt.
|
||||
/// Mock OMP script that distinguishes agent vs judge by the -p prompt.
|
||||
///
|
||||
/// Agent mode (prompt contains "protocol.md"):
|
||||
/// - Increments .loop/.agent-calls counter
|
||||
|
|
@ -86,9 +86,16 @@ allow .
|
|||
/// - Increments .loop/.judge-calls counter
|
||||
/// - Call 1: writes VERDICT: FAIL + feedback to verdict.md
|
||||
/// - Call 2+: writes VERDICT: PASS to verdict.md
|
||||
const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash
|
||||
const MOCK_OMP: &str = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
{
|
||||
echo "CALL"
|
||||
for arg in "$@"; do
|
||||
echo "$arg"
|
||||
done
|
||||
} >> .loop/.omp-argv
|
||||
|
||||
# Extract the prompt from -p argument
|
||||
PROMPT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
|
@ -162,13 +169,21 @@ fn brute_verdict_preserved_across_iterations() {
|
|||
fs::write(loop_dir.join("verdict.md"), "").unwrap();
|
||||
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
||||
|
||||
// Set up mock claude script on PATH
|
||||
// Set up mock OMP script on PATH
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).expect("create mock-bin");
|
||||
|
||||
let mock_claude_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap();
|
||||
fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mock_omp_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_omp_path, MOCK_OMP).unwrap();
|
||||
fs::set_permissions(&mock_omp_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let legacy_bin_path = mock_bin_dir.join(["cl", "aude"].concat());
|
||||
fs::write(
|
||||
&legacy_bin_path,
|
||||
"#!/usr/bin/env bash\necho 'legacy agent must not be invoked' >&2\nexit 99\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&legacy_bin_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
// Set up git repo (boundary checker needs `git diff HEAD` to work)
|
||||
let git = |args: &[&str]| {
|
||||
|
|
@ -193,9 +208,17 @@ fn brute_verdict_preserved_across_iterations() {
|
|||
git(&["init"]);
|
||||
fs::write(project.join("dummy.txt"), "seed\n").unwrap();
|
||||
git(&["add", "dummy.txt"]);
|
||||
git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]);
|
||||
git(&[
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@test",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
|
||||
// Build PATH: mock-bin first so our mock claude shadows the real one
|
||||
// Build PATH: mock-bin first so our mock OMP shadows the real one
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
|
||||
|
||||
|
|
@ -210,6 +233,14 @@ fn brute_verdict_preserved_across_iterations() {
|
|||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// ── Assertions ──
|
||||
let argv = fs::read_to_string(loop_dir.join(".omp-argv")).expect("mock omp argv");
|
||||
assert!(
|
||||
argv.contains("--mode\njson")
|
||||
&& argv.contains("--auto-approve")
|
||||
&& argv.contains("-p\nRead .loop/protocol.md and follow its instructions."),
|
||||
"default backend should invoke omp in JSON print mode with the worker prompt.\nargv:\n{}",
|
||||
argv
|
||||
);
|
||||
|
||||
// 1. witness-1 should be empty: no verdict exists before first agent run
|
||||
let witness_1 = fs::read_to_string(loop_dir.join(".witness-1"))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
#
|
||||
# Prerequisites:
|
||||
# - docker daemon running
|
||||
# - claude-code-sandbox:latest image available
|
||||
# - omp-sandbox:latest image available
|
||||
# - cargo (to build yoke)
|
||||
#
|
||||
# Usage:
|
||||
|
|
@ -33,8 +33,8 @@ if ! command -v docker &>/dev/null; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
if ! docker image inspect claude-code-sandbox:latest &>/dev/null; then
|
||||
echo "SKIP: claude-code-sandbox:latest image not found"
|
||||
if ! docker image inspect omp-sandbox:latest &>/dev/null; then
|
||||
echo "SKIP: omp-sandbox:latest image not found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -60,9 +60,9 @@ TMPDIR_ROOT="$(mktemp -d)"
|
|||
PROJECT="$TMPDIR_ROOT/project"
|
||||
mkdir -p "$PROJECT"
|
||||
|
||||
# ── Write mock-claude script ──
|
||||
# ── Write mock-omp script ──
|
||||
|
||||
cat > "$TMPDIR_ROOT/mock-claude" <<'MOCK'
|
||||
cat > "$TMPDIR_ROOT/mock-omp" <<'MOCK'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -110,14 +110,14 @@ fi
|
|||
|
||||
exit 0
|
||||
MOCK
|
||||
chmod +x "$TMPDIR_ROOT/mock-claude"
|
||||
chmod +x "$TMPDIR_ROOT/mock-omp"
|
||||
|
||||
# ── Build test Docker image ──
|
||||
|
||||
echo "Building test image $TEST_IMAGE..."
|
||||
docker build -t "$TEST_IMAGE" -f- "$TMPDIR_ROOT" <<'DOCKERFILE'
|
||||
FROM claude-code-sandbox:latest
|
||||
COPY --chmod=755 mock-claude /usr/local/bin/claude
|
||||
FROM omp-sandbox:latest
|
||||
COPY --chmod=755 mock-omp /usr/local/bin/omp
|
||||
DOCKERFILE
|
||||
|
||||
# ── Set up project directory ──
|
||||
|
|
|
|||
304
tests/grind.rs
Normal file
304
tests/grind.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn yoke_bin() -> PathBuf {
|
||||
let mut path = std::env::current_exe()
|
||||
.expect("current_exe")
|
||||
.parent()
|
||||
.expect("parent of test binary")
|
||||
.parent()
|
||||
.expect("parent of deps dir")
|
||||
.to_path_buf();
|
||||
path.push("yoke");
|
||||
path
|
||||
}
|
||||
|
||||
fn build_yoke() {
|
||||
let status = Command::new("cargo")
|
||||
.args(["build", "--quiet"])
|
||||
.status()
|
||||
.expect("cargo build");
|
||||
assert!(status.success(), "cargo build failed");
|
||||
}
|
||||
|
||||
fn git(project: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(project)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn seed_project(project: &Path) {
|
||||
fs::write(
|
||||
project.join("Cargo.toml"),
|
||||
"[package]\nname = \"grind-subject\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\npath = \"src/lib.rs\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(project.join("src")).unwrap();
|
||||
fs::write(project.join("src/lib.rs"), "pub fn hot() -> u32 { 1 }\n").unwrap();
|
||||
|
||||
git(project, &["init"]);
|
||||
git(project, &["add", "Cargo.toml", "src/lib.rs"]);
|
||||
git(
|
||||
project,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@test",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn write_executable(path: &Path, content: &str) {
|
||||
fs::write(path, content).unwrap();
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
fn mock_path(mock_bin_dir: &Path) -> String {
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
format!("{}:{}", mock_bin_dir.display(), original_path)
|
||||
}
|
||||
|
||||
fn constant_cstat(cost: f64) -> String {
|
||||
let score = format!("{cost:.1}");
|
||||
format!(
|
||||
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '{{\"cstat_version\":\"fake\",\"score_version\":\"code_complexity_cost_v0\",\"target\":\".\",\"code_complexity_cost\":{score},\"top_contributors\":[{{\"kind\":\"function\",\"file\":\"src/lib.rs\",\"function\":\"hot\",\"cost\":{score}}}]}}\\n'\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn run_yoke_init_grind(yoke: &Path, project: &Path, test_path: &str) -> std::process::Output {
|
||||
Command::new(yoke)
|
||||
.args(["init", "grind"])
|
||||
.current_dir(project)
|
||||
.env("PATH", test_path)
|
||||
.output()
|
||||
.expect("yoke init grind")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_grind_creates_profile_and_captures_baseline() {
|
||||
build_yoke();
|
||||
let yoke = yoke_bin();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let project = tmp.path();
|
||||
seed_project(project);
|
||||
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
write_executable(&mock_bin_dir.join("cstat"), &constant_cstat(5.0));
|
||||
let test_path = mock_path(&mock_bin_dir);
|
||||
|
||||
let output = run_yoke_init_grind(&yoke, project, &test_path);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"yoke init grind should exit 0. exit={:?}\nstderr:\n{}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
);
|
||||
|
||||
let loop_dir = project.join(".loop");
|
||||
for name in [
|
||||
"grind-gate.py",
|
||||
"cstat-baseline.json",
|
||||
"protocol.md",
|
||||
"yoke.conf",
|
||||
"notes.md",
|
||||
"guard-results.md",
|
||||
] {
|
||||
assert!(loop_dir.join(name).exists(), "missing .loop/{name}");
|
||||
}
|
||||
for name in ["grind.md", "plan.md"] {
|
||||
assert!(!loop_dir.join(name).exists(), "unexpected .loop/{name}");
|
||||
}
|
||||
|
||||
let baseline = fs::read_to_string(loop_dir.join("cstat-baseline.json")).unwrap();
|
||||
let compact: String = baseline.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
assert!(
|
||||
compact.contains("\"code_complexity_cost\":5.0"),
|
||||
"baseline should contain captured scorecard cost. baseline:\n{}",
|
||||
baseline
|
||||
);
|
||||
|
||||
let conf = fs::read_to_string(loop_dir.join("yoke.conf")).unwrap();
|
||||
assert!(
|
||||
conf.contains("guard python3 .loop/grind-gate.py"),
|
||||
"grind guard must be configured. yoke.conf:\n{}",
|
||||
conf
|
||||
);
|
||||
assert!(
|
||||
conf.contains("# no-modify tests/"),
|
||||
"tests no-modify example should be commented. yoke.conf:\n{}",
|
||||
conf
|
||||
);
|
||||
assert!(
|
||||
!conf
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("no-modify tests/")),
|
||||
"tests no-modify rule must not be active by default. yoke.conf:\n{}",
|
||||
conf
|
||||
);
|
||||
|
||||
let protocol = fs::read_to_string(loop_dir.join("protocol.md")).unwrap();
|
||||
assert!(
|
||||
!protocol.contains(".loop/plan.md"),
|
||||
"grind protocol must not reference plan.md. protocol:\n{}",
|
||||
protocol
|
||||
);
|
||||
for command in [
|
||||
"loc",
|
||||
"symbols",
|
||||
"branching",
|
||||
"signature",
|
||||
"span",
|
||||
"scorecard",
|
||||
"deps",
|
||||
"dead-code",
|
||||
"test-reachability",
|
||||
"call-trace",
|
||||
"coverage",
|
||||
"cluster",
|
||||
] {
|
||||
assert!(
|
||||
protocol.contains(&format!("`{command}`")),
|
||||
"grind protocol should document cstat {command}. protocol:\n{}",
|
||||
protocol
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
protocol.contains("Do not stop after one simplification"),
|
||||
"grind protocol should push agents past timid one-edit cleanup. protocol:\n{}",
|
||||
protocol
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grind_dry_run_rejects_without_cstat_improvement() {
|
||||
build_yoke();
|
||||
let yoke = yoke_bin();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let project = tmp.path();
|
||||
seed_project(project);
|
||||
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
write_executable(&mock_bin_dir.join("cstat"), &constant_cstat(5.0));
|
||||
let test_path = mock_path(&mock_bin_dir);
|
||||
|
||||
let init = run_yoke_init_grind(&yoke, project, &test_path);
|
||||
assert!(
|
||||
init.status.success(),
|
||||
"init failed: {}",
|
||||
String::from_utf8_lossy(&init.stderr)
|
||||
);
|
||||
|
||||
let output = Command::new(&yoke)
|
||||
.args(["run", "--dry-run"])
|
||||
.current_dir(project)
|
||||
.env("PATH", &test_path)
|
||||
.env("GRIND_ORACLE", "true")
|
||||
.output()
|
||||
.expect("yoke run --dry-run");
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"dry run should reject unchanged cstat score"
|
||||
);
|
||||
|
||||
let guard_results = fs::read_to_string(project.join(".loop/guard-results.md")).unwrap();
|
||||
assert!(
|
||||
guard_results.contains("GRIND: FAIL"),
|
||||
"guard results should show grind failure. guard-results:\n{}",
|
||||
guard_results
|
||||
);
|
||||
assert!(
|
||||
guard_results.contains("cstat scorecard did not improve"),
|
||||
"guard results should explain unchanged score. guard-results:\n{}",
|
||||
guard_results
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grind_restores_gate_and_baseline_before_guard() {
|
||||
build_yoke();
|
||||
let yoke = yoke_bin();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let project = tmp.path();
|
||||
seed_project(project);
|
||||
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
write_executable(
|
||||
&mock_bin_dir.join("cstat"),
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
count_file=".cstat-count"
|
||||
count=0
|
||||
if [ -f "$count_file" ]; then
|
||||
count=$(cat "$count_file")
|
||||
fi
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$count" > "$count_file"
|
||||
if [ "$count" -eq 1 ]; then
|
||||
cost=10.0
|
||||
else
|
||||
cost=8.0
|
||||
fi
|
||||
printf '{"cstat_version":"fake","score_version":"code_complexity_cost_v0","target":".","code_complexity_cost":%s,"top_contributors":[{"kind":"function","file":"src/lib.rs","function":"hot","cost":%s}]}\n' "$cost" "$cost"
|
||||
"#,
|
||||
);
|
||||
write_executable(
|
||||
&mock_bin_dir.join("omp"),
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'STATUS: DONE\n' > .loop/notes.md
|
||||
cat > .loop/grind-gate.py <<'PY'
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
sys.exit(99)
|
||||
PY
|
||||
printf '{"code_complexity_cost":1.0}\n' > .loop/cstat-baseline.json
|
||||
exit 0
|
||||
"#,
|
||||
);
|
||||
let test_path = mock_path(&mock_bin_dir);
|
||||
|
||||
let init = run_yoke_init_grind(&yoke, project, &test_path);
|
||||
assert!(
|
||||
init.status.success(),
|
||||
"init failed: {}",
|
||||
String::from_utf8_lossy(&init.stderr)
|
||||
);
|
||||
|
||||
let output = Command::new(&yoke)
|
||||
.args(["run"])
|
||||
.current_dir(project)
|
||||
.env("PATH", &test_path)
|
||||
.env("GRIND_ORACLE", "true")
|
||||
.output()
|
||||
.expect("yoke run");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"yoke run should pass after restoring protected grind files. exit={:?}\nstderr:\n{}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
|
@ -40,12 +40,25 @@ fn git_init(project: &std::path::Path) {
|
|||
.env("GIT_COMMITTER_EMAIL", "test@test")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
git(&["init"]);
|
||||
fs::write(project.join("dummy.txt"), "seed\n").unwrap();
|
||||
git(&["add", "dummy.txt"]);
|
||||
git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]);
|
||||
git(&[
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@test",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Test 1: stash + clean round-trip after extraction ──────────────────
|
||||
|
|
@ -73,12 +86,24 @@ fn stash_roundtrip_after_extraction() {
|
|||
.current_dir(project)
|
||||
.output()
|
||||
.expect("yoke init brute");
|
||||
assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"yoke init brute failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Write distinctive content into plan.md and notes.md
|
||||
let loop_dir = project.join(".loop");
|
||||
fs::write(loop_dir.join("plan.md"), "## Stage 1 — Build the widget\n\nDo the thing.\n").unwrap();
|
||||
fs::write(loop_dir.join("notes.md"), "STATUS: IN_PROGRESS\n\nSome important notes here.\n").unwrap();
|
||||
fs::write(
|
||||
loop_dir.join("plan.md"),
|
||||
"## Stage 1 — Build the widget\n\nDo the thing.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
loop_dir.join("notes.md"),
|
||||
"STATUS: IN_PROGRESS\n\nSome important notes here.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Stash the current state
|
||||
let out = Command::new(&yoke)
|
||||
|
|
@ -86,7 +111,11 @@ fn stash_roundtrip_after_extraction() {
|
|||
.current_dir(project)
|
||||
.output()
|
||||
.expect("yoke stash");
|
||||
assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"yoke stash failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Verify stash log shows an entry
|
||||
let out = Command::new(&yoke)
|
||||
|
|
@ -95,12 +124,25 @@ fn stash_roundtrip_after_extraction() {
|
|||
.output()
|
||||
.expect("yoke stash log");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("mode=brute"), "stash log should show mode=brute, got:\n{}", stderr);
|
||||
assert!(
|
||||
stderr.contains("mode=brute"),
|
||||
"stash log should show mode=brute, got:\n{}",
|
||||
stderr
|
||||
);
|
||||
|
||||
// Verify stash cleared .loop/ working files
|
||||
assert!(!loop_dir.join("plan.md").exists(), "plan.md should be gone after stash");
|
||||
assert!(!loop_dir.join("notes.md").exists(), "notes.md should be gone after stash");
|
||||
assert!(loop_dir.join(".stash").exists(), ".stash/ should survive stash clear");
|
||||
assert!(
|
||||
!loop_dir.join("plan.md").exists(),
|
||||
"plan.md should be gone after stash"
|
||||
);
|
||||
assert!(
|
||||
!loop_dir.join("notes.md").exists(),
|
||||
"notes.md should be gone after stash"
|
||||
);
|
||||
assert!(
|
||||
loop_dir.join(".stash").exists(),
|
||||
".stash/ should survive stash clear"
|
||||
);
|
||||
|
||||
// Pop — should restore the stashed state with our distinctive content
|
||||
let out = Command::new(&yoke)
|
||||
|
|
@ -108,11 +150,19 @@ fn stash_roundtrip_after_extraction() {
|
|||
.current_dir(project)
|
||||
.output()
|
||||
.expect("yoke stash pop");
|
||||
assert!(out.status.success(), "yoke stash pop failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"yoke stash pop failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Verify round-trip: files restored with original content
|
||||
let plan = fs::read_to_string(loop_dir.join("plan.md")).unwrap();
|
||||
assert!(plan.contains("Build the widget"), "plan.md should be restored after pop, got: {:?}", plan);
|
||||
assert!(
|
||||
plan.contains("Build the widget"),
|
||||
"plan.md should be restored after pop, got: {:?}",
|
||||
plan
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 2: plan loop exits on STATUS: DONE with generalized is_status_done ──
|
||||
|
|
@ -153,18 +203,18 @@ Read plan.md, implement it, then set STATUS: DONE in notes.md.
|
|||
fs::write(loop_dir.join("notes.md"), "").unwrap();
|
||||
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
||||
|
||||
// Mock claude: immediately writes STATUS: DONE and exits
|
||||
// Mock OMP: immediately writes STATUS: DONE and exits
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
|
||||
let mock_claude = r#"#!/usr/bin/env bash
|
||||
let mock_omp = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Always signal done immediately
|
||||
printf 'STATUS: DONE\n' > .loop/notes.md
|
||||
exit 0
|
||||
"#;
|
||||
let mock_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_path, mock_claude).unwrap();
|
||||
let mock_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_path, mock_omp).unwrap();
|
||||
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -237,8 +287,8 @@ Read plan, implement, set STATUS: DONE in notes.md.
|
|||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
|
||||
// Mock claude: agent writes STATUS: DONE, judge FAILs once then PASSes
|
||||
let mock_claude = r#"#!/usr/bin/env bash
|
||||
// Mock OMP: agent writes STATUS: DONE, judge FAILs once then PASSes
|
||||
let mock_omp = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROMPT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
|
@ -264,8 +314,8 @@ elif echo "$PROMPT" | grep -q "judge.md"; then
|
|||
fi
|
||||
exit 0
|
||||
"#;
|
||||
let mock_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_path, mock_claude).unwrap();
|
||||
let mock_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_path, mock_omp).unwrap();
|
||||
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -291,7 +341,8 @@ exit 0
|
|||
// Judge should have been called exactly 2 times
|
||||
let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap();
|
||||
assert_eq!(
|
||||
judge_count.trim(), "2",
|
||||
judge_count.trim(),
|
||||
"2",
|
||||
"judge should be called exactly twice (FAIL then PASS), got: {:?}",
|
||||
judge_count.trim(),
|
||||
);
|
||||
|
|
@ -344,13 +395,13 @@ fn saga_exits_on_saga_notes_done() {
|
|||
fs::write(loop_dir.join("verdict.md"), "").unwrap();
|
||||
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
||||
|
||||
// Mock claude: scoper writes STATUS: DONE to saga-notes.md immediately.
|
||||
// Mock OMP: scoper writes STATUS: DONE to saga-notes.md immediately.
|
||||
// Critically: notes.md is left empty — if yoke checks notes.md instead of
|
||||
// saga-notes.md, it would NOT see DONE and would spin forever.
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
|
||||
let mock_claude = r#"#!/usr/bin/env bash
|
||||
let mock_omp = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROMPT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
|
@ -366,8 +417,8 @@ if echo "$PROMPT" | grep -q "saga-protocol.md"; then
|
|||
fi
|
||||
exit 0
|
||||
"#;
|
||||
let mock_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_path, mock_claude).unwrap();
|
||||
let mock_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_path, mock_omp).unwrap();
|
||||
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -447,7 +498,7 @@ guard echo SENTINEL_GUARD_OUTPUT && exit 1
|
|||
fs::write(loop_dir.join("notes.md"), "").unwrap();
|
||||
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
||||
|
||||
// Dry-run: no Claude invocation, but guards still execute
|
||||
// Dry-run: no OMP invocation, but guards still execute
|
||||
let output = Command::new(&yoke)
|
||||
.args(["run", "--no-sandbox", "--dry-run"])
|
||||
.current_dir(project)
|
||||
|
|
@ -525,9 +576,9 @@ max-judge-failures 2
|
|||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
|
||||
// Mock claude: agent always writes STATUS: DONE, judge always FAILs.
|
||||
// Mock OMP: agent always writes STATUS: DONE, judge always FAILs.
|
||||
// Tracks call counts so we can assert the exact number of iterations.
|
||||
let mock_claude = r#"#!/usr/bin/env bash
|
||||
let mock_omp = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROMPT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
|
@ -555,8 +606,8 @@ elif echo "$PROMPT" | grep -q "judge.md"; then
|
|||
fi
|
||||
exit 0
|
||||
"#;
|
||||
let mock_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_path, mock_claude).unwrap();
|
||||
let mock_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_path, mock_omp).unwrap();
|
||||
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -589,7 +640,8 @@ exit 0
|
|||
// Judge should have been called exactly 2 times (matching max-judge-failures)
|
||||
let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap();
|
||||
assert_eq!(
|
||||
judge_count.trim(), "2",
|
||||
judge_count.trim(),
|
||||
"2",
|
||||
"judge should be called exactly 2 times (max-judge-failures=2), got: {:?}\nStderr:\n{}",
|
||||
judge_count.trim(),
|
||||
stderr,
|
||||
|
|
@ -648,8 +700,8 @@ judge-every 5
|
|||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
|
||||
// Mock claude: agent immediately signals DONE, judge immediately returns PASS
|
||||
let mock_claude = r#"#!/usr/bin/env bash
|
||||
// Mock OMP: agent immediately signals DONE, judge immediately returns PASS
|
||||
let mock_omp = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROMPT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
|
@ -666,8 +718,8 @@ elif echo "$PROMPT" | grep -q "judge.md"; then
|
|||
fi
|
||||
exit 0
|
||||
"#;
|
||||
let mock_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_path, mock_claude).unwrap();
|
||||
let mock_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_path, mock_omp).unwrap();
|
||||
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -737,7 +789,11 @@ fn stash_records_correct_mode_after_extraction() {
|
|||
.current_dir(project)
|
||||
.output()
|
||||
.expect("yoke init brute");
|
||||
assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"yoke init brute failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Write some content so stash has something to snapshot
|
||||
fs::write(project.join(".loop/plan.md"), "## Stage 1\nDo it.\n").unwrap();
|
||||
|
|
@ -748,15 +804,25 @@ fn stash_records_correct_mode_after_extraction() {
|
|||
.current_dir(project)
|
||||
.output()
|
||||
.expect("yoke stash");
|
||||
assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"yoke stash failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Read the stash index directly and verify mode=brute
|
||||
let index_path = project.join(".loop/.stash/index");
|
||||
assert!(index_path.exists(), "stash index should exist after stashing");
|
||||
assert!(
|
||||
index_path.exists(),
|
||||
"stash index should exist after stashing"
|
||||
);
|
||||
|
||||
let index = fs::read_to_string(&index_path).unwrap();
|
||||
// Index format: hash|timestamp|mode|file1,file2,...
|
||||
let first_line = index.lines().next().expect("index should have at least one line");
|
||||
let first_line = index
|
||||
.lines()
|
||||
.next()
|
||||
.expect("index should have at least one line");
|
||||
let parts: Vec<&str> = first_line.splitn(4, '|').collect();
|
||||
assert!(
|
||||
parts.len() >= 3,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! metrics rows under the user's home directory (`~/.yoke/metrics/`) and the
|
||||
//! rows survive `yoke clean`.
|
||||
//!
|
||||
//! Uses a mock `claude` script so no real agent invocation happens. The
|
||||
//! Uses a mock `omp` script so no real agent invocation happens. The
|
||||
//! subprocess gets HOME pointed at the test tempdir so writes don't escape
|
||||
//! the test sandbox.
|
||||
|
||||
|
|
@ -38,10 +38,17 @@ const CONF: &str = "\
|
|||
allow .
|
||||
";
|
||||
|
||||
const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash
|
||||
const MOCK_OMP: &str = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Always: write STATUS: DONE so the loop exits after one iteration.
|
||||
printf 'STATUS: DONE\n\n## Stage 1\nDone.\n' > .loop/notes.md
|
||||
cat <<'JSON'
|
||||
{"type":"session","version":3,"id":"sess-metrics","timestamp":"2026-01-01T00:00:00Z","cwd":"/tmp/project"}
|
||||
{"type":"turn_start"}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"done"}}
|
||||
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"model":"gpt-test","usage":{"input":10,"output":2,"cacheRead":0,"cacheWrite":0,"totalTokens":12,"cost":{"input":0.1,"output":0.15,"cacheRead":0,"cacheWrite":0,"total":0.25}},"duration":1500}}
|
||||
{"type":"agent_end","messages":[]}
|
||||
JSON
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
|
|
@ -72,12 +79,12 @@ fn metrics_rows_persist_under_home_and_survive_clean() {
|
|||
fs::write(loop_dir.join("notes.md"), "").unwrap();
|
||||
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
||||
|
||||
// Mock claude on PATH
|
||||
// Mock OMP on PATH
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).expect("create mock-bin");
|
||||
let mock_claude_path = mock_bin_dir.join("claude");
|
||||
fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap();
|
||||
fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mock_omp_path = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_omp_path, MOCK_OMP).unwrap();
|
||||
fs::set_permissions(&mock_omp_path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
// Boundary check needs a git repo
|
||||
let git = |args: &[&str]| {
|
||||
|
|
@ -101,7 +108,15 @@ fn metrics_rows_persist_under_home_and_survive_clean() {
|
|||
git(&["init"]);
|
||||
fs::write(project.join("seed.txt"), "x\n").unwrap();
|
||||
git(&["add", "seed.txt"]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"]);
|
||||
git(&[
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
|
||||
|
|
@ -184,6 +199,16 @@ fn metrics_rows_persist_under_home_and_survive_clean() {
|
|||
"iter row should record restore_ms.\ncontent: {}",
|
||||
iter_content
|
||||
);
|
||||
assert!(
|
||||
iter_content.contains("\"cost_usd\":0.25"),
|
||||
"iter row should record OMP stream cost.\ncontent: {}",
|
||||
iter_content
|
||||
);
|
||||
assert!(
|
||||
iter_content.contains("\"num_turns\":1"),
|
||||
"iter row should record OMP turn count.\ncontent: {}",
|
||||
iter_content
|
||||
);
|
||||
|
||||
// Survive `yoke clean`
|
||||
let clean_output = Command::new(&yoke)
|
||||
|
|
|
|||
148
tests/omp_invocation.rs
Normal file
148
tests/omp_invocation.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
fn yoke_bin() -> std::path::PathBuf {
|
||||
let mut path = std::env::current_exe()
|
||||
.expect("current_exe")
|
||||
.parent()
|
||||
.expect("parent of test binary")
|
||||
.parent()
|
||||
.expect("parent of deps dir")
|
||||
.to_path_buf();
|
||||
path.push("yoke");
|
||||
path
|
||||
}
|
||||
|
||||
fn git_init(project: &std::path::Path) {
|
||||
let git = |args: &[&str]| {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(project)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_AUTHOR_NAME", "test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@test")
|
||||
.env("GIT_COMMITTER_NAME", "test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@test")
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
git(&["init"]);
|
||||
fs::write(project.join("seed.txt"), "seed\n").unwrap();
|
||||
git(&["add", "seed.txt"]);
|
||||
git(&[
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@test",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
}
|
||||
|
||||
const MOCK_OMP: &str = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "CALL"
|
||||
for arg in "$@"; do
|
||||
echo "$arg"
|
||||
done
|
||||
} >> .loop/.omp-argv
|
||||
printf 'STATUS: DONE\n' > .loop/notes.md
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn default_backend_invokes_omp_with_model_and_thinking() {
|
||||
let status = Command::new("cargo")
|
||||
.args(["build", "--quiet"])
|
||||
.status()
|
||||
.expect("cargo build");
|
||||
assert!(status.success(), "cargo build failed");
|
||||
|
||||
let yoke = yoke_bin();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let project = tmp.path();
|
||||
git_init(project);
|
||||
|
||||
let loop_dir = project.join(".loop");
|
||||
fs::create_dir(&loop_dir).unwrap();
|
||||
fs::write(
|
||||
loop_dir.join("protocol.md"),
|
||||
"# Protocol\nSet STATUS done.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(loop_dir.join("plan.md"), "## Stage 1\nDone.\n").unwrap();
|
||||
fs::write(
|
||||
loop_dir.join("yoke.conf"),
|
||||
"model gpt-5.5\nthinking xhigh\nallow .\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(loop_dir.join("notes.md"), "").unwrap();
|
||||
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
||||
|
||||
let mock_bin_dir = project.join("mock-bin");
|
||||
fs::create_dir(&mock_bin_dir).unwrap();
|
||||
let mock_omp = mock_bin_dir.join("omp");
|
||||
fs::write(&mock_omp, MOCK_OMP).unwrap();
|
||||
fs::set_permissions(&mock_omp, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let legacy_bin = mock_bin_dir.join(["cl", "aude"].concat());
|
||||
fs::write(
|
||||
&legacy_bin,
|
||||
"#!/usr/bin/env bash\necho 'legacy agent must not be invoked' >&2\nexit 99\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&legacy_bin, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
|
||||
let output = Command::new(&yoke)
|
||||
.args(["run"])
|
||||
.current_dir(project)
|
||||
.env("PATH", &test_path)
|
||||
.output()
|
||||
.expect("yoke run");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"yoke should exit 0 using mock omp. exit={:?}\nstderr:\n{}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
);
|
||||
|
||||
let argv = fs::read_to_string(loop_dir.join(".omp-argv")).expect("mock omp argv");
|
||||
assert!(
|
||||
argv.contains("--mode\njson"),
|
||||
"OMP must run in JSON mode.\nargv:\n{}",
|
||||
argv
|
||||
);
|
||||
assert!(
|
||||
argv.contains("--auto-approve"),
|
||||
"OMP must auto-approve tools.\nargv:\n{}",
|
||||
argv
|
||||
);
|
||||
assert!(
|
||||
argv.contains("--model\ngpt-5.5"),
|
||||
"model directive must pass through to OMP.\nargv:\n{}",
|
||||
argv
|
||||
);
|
||||
assert!(
|
||||
argv.contains("--thinking\nxhigh"),
|
||||
"thinking directive must pass through to OMP.\nargv:\n{}",
|
||||
argv
|
||||
);
|
||||
assert!(
|
||||
argv.contains("-p\nRead .loop/protocol.md and follow its instructions."),
|
||||
"worker prompt must be passed after -p for OMP print mode.\nargv:\n{}",
|
||||
argv
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue