feat: initial yoke grind scenario utilizing our code complexity tool

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-25 11:20:10 +04:00
parent fa65ddb605
commit 5197d677ba
26 changed files with 1866 additions and 1936 deletions

View file

@ -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;
}

View file

@ -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
}
}
}

View file

@ -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,
)
}
};

View file

@ -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 {

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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")));
}
}

View file

@ -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!(

View file

@ -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
View 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(),
)
}

View file

@ -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,
},
)
}

View file

@ -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

View file

@ -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
View 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()

View 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.

View 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

View file

@ -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

View file

@ -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).

View file

@ -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

View file

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

View file

@ -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"))

View file

@ -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
View 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
);
}

View file

@ -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,

View file

@ -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
View 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
);
}