stash changes

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-03-04 22:38:23 +07:00
parent 8718ee3a36
commit 20abe1108c
24 changed files with 2946 additions and 676 deletions

View file

@ -1,6 +1,12 @@
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Backend {
Claude,
OpenCode,
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ScopeTag { pub enum ScopeTag {
Allow, Allow,
@ -19,11 +25,13 @@ pub struct Config {
pub max_tail: usize, pub max_tail: usize,
pub log_dir: Option<String>, pub log_dir: Option<String>,
pub image: Option<String>, pub image: Option<String>,
pub model: Option<String>,
pub scope_rules: Vec<ScopeRule>, pub scope_rules: Vec<ScopeRule>,
pub guards: Vec<String>, pub guards: Vec<String>,
} }
impl Config { impl Config {
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn load(path: &Path) -> Result<Config, String> { pub fn load(path: &Path) -> Result<Config, String> {
let content = fs::read_to_string(path) let content = fs::read_to_string(path)
.map_err(|e| format!("failed to read config {}: {}", path.display(), e))?; .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
@ -31,6 +39,7 @@ impl Config {
let mut max_tail: usize = 200; let mut max_tail: usize = 200;
let mut log_dir: Option<String> = None; let mut log_dir: Option<String> = None;
let mut image: Option<String> = None; let mut image: Option<String> = None;
let mut model: Option<String> = None;
let mut scope_rules = Vec::new(); let mut scope_rules = Vec::new();
let mut guards = Vec::new(); let mut guards = Vec::new();
for (line_num, raw_line) in content.lines().enumerate() { for (line_num, raw_line) in content.lines().enumerate() {
@ -74,6 +83,9 @@ impl Config {
"image" => { "image" => {
image = Some(value.to_string()); image = Some(value.to_string());
} }
"model" => {
model = Some(value.to_string());
}
"allow" => scope_rules.push(ScopeRule { "allow" => scope_rules.push(ScopeRule {
tag: ScopeTag::Allow, tag: ScopeTag::Allow,
prefix: value.to_string(), prefix: value.to_string(),
@ -102,6 +114,7 @@ impl Config {
max_tail, max_tail,
log_dir, log_dir,
image, image,
model,
scope_rules, scope_rules,
guards, guards,
}) })
@ -130,4 +143,14 @@ impl Config {
// If only "." matched, best_len is 1, which is correct. // If only "." matched, best_len is 1, which is correct.
best_tag 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

@ -1,13 +1,22 @@
//! Guard Orchestra — parallel guard runner that executes configured shell
//! commands, captures stdout/stderr, measures elapsed time, and produces
//! structured results for the box-drawn table in main.rs.
//!
//! All guards are spawned concurrently via threads. Results are collected
//! and returned in the original command order.
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
use std::thread;
use std::time::Instant;
#[allow(dead_code)]
pub struct GuardResult { pub struct GuardResult {
pub name: String, pub name: String,
pub passed: bool, pub passed: bool,
pub output: String, pub output: String,
pub skipped: bool, pub skipped: bool,
pub elapsed_secs: f64,
} }
/// Keep only the last `max` lines of text. /// Keep only the last `max` lines of text.
@ -19,31 +28,15 @@ fn tail_lines(text: &str, max: usize) -> String {
lines[lines.len() - max..].join("\n") lines[lines.len() - max..].join("\n")
} }
/// Run all configured guard commands in order (fail-fast). /// Execute a single guard command synchronously.
/// Writes results to `results_path` in markdown format. /// Returns (passed, raw_output, elapsed_secs).
pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec<GuardResult> { fn run_one(cmd: &str) -> (bool, String, f64) {
let mut results = Vec::new(); let start = Instant::now();
let mut markdown = String::new();
let mut failed = false;
for cmd in guards {
if failed {
let result = GuardResult {
name: cmd.clone(),
passed: false,
output: String::new(),
skipped: true,
};
markdown.push_str(&format!("## {} — SKIPPED\n\n", cmd));
markdown.push_str("```\nSkipped due to earlier guard failure.\n```\n\n");
results.push(result);
continue;
}
let output = Command::new("sh") let output = Command::new("sh")
.arg("-c") .arg("-c")
.arg(cmd) .arg(cmd)
.output(); .output();
let elapsed_secs = start.elapsed().as_secs_f64();
let (exit_ok, raw_output) = match output { let (exit_ok, raw_output) = match output {
Ok(o) => { Ok(o) => {
@ -60,32 +53,77 @@ pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Ve
Err(e) => (false, format!("failed to execute: {}", e)), Err(e) => (false, format!("failed to execute: {}", e)),
}; };
let truncated = tail_lines(&raw_output, max_tail); (exit_ok, raw_output, elapsed_secs)
let status_label = if exit_ok { "PASS" } else { "FAIL" }; }
markdown.push_str(&format!("## {} — {}\n\n", cmd, status_label)); /// Run all configured guard commands in parallel.
/// Each guard is spawned on its own thread. Results are collected in the
/// original command order and written to `results_path` in markdown format.
///
/// The rendered box-drawn table with PASS/FAIL/SKIPPED status and timing
/// is handled by the caller in main.rs.
pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec<GuardResult> {
if guards.is_empty() {
let _ = fs::write(results_path, "");
return Vec::new();
}
// Spawn all guards in parallel
let handles: Vec<_> = guards
.iter()
.map(|cmd| {
let cmd = cmd.clone();
thread::spawn(move || {
if crate::signal::interrupted() {
return (cmd, false, String::new(), true, 0.0);
}
let (passed, raw_output, elapsed) = run_one(&cmd);
(cmd, passed, raw_output, false, elapsed)
})
})
.collect();
// Collect results in order
let mut results = Vec::with_capacity(guards.len());
let mut markdown = String::new();
for handle in handles {
let (name, passed, raw_output, skipped, elapsed_secs) = match handle.join() {
Ok(r) => r,
Err(_) => {
// Thread panicked — treat as failure
(String::from("(unknown)"), false, String::from("guard thread panicked"), false, 0.0)
}
};
let truncated = tail_lines(&raw_output, max_tail);
if skipped {
markdown.push_str(&format!("## {} \u{2014} SKIPPED\n\n", name));
markdown.push_str("```\nSkipped due to interrupt.\n```\n\n");
} else {
let status_label = if passed { "PASS" } else { "FAIL" };
markdown.push_str(&format!("## {} \u{2014} {}\n\n", name, status_label));
markdown.push_str("```\n"); markdown.push_str("```\n");
markdown.push_str(&truncated); markdown.push_str(&truncated);
if !truncated.is_empty() && !truncated.ends_with('\n') { if !truncated.is_empty() && !truncated.ends_with('\n') {
markdown.push('\n'); markdown.push('\n');
} }
markdown.push_str("```\n\n"); markdown.push_str("```\n\n");
if !exit_ok {
failed = true;
} }
results.push(GuardResult { results.push(GuardResult {
name: cmd.clone(), name,
passed: exit_ok, passed,
output: truncated, output: truncated,
skipped: false, skipped,
elapsed_secs,
}); });
} }
// Write results file // Write results file
if let Err(e) = fs::write(results_path, &markdown) { if let Err(e) = fs::write(results_path, &markdown) {
eprintln!("[ci] WARNING: failed to write guard results: {}", e); eprintln!("[yoke] WARNING: failed to write guard results: {}", e);
} }
results results

148
src/heartbeat.rs Normal file
View file

@ -0,0 +1,148 @@
//! Pulse Heartbeat — background tick that fires every 500ms and emits
//! structured events to registered listeners.
//!
//! The heartbeat is stoppable and restartable without losing listener
//! subscriptions. Listeners receive a `HeartbeatEvent` on each tick.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
/// Structured event emitted on each heartbeat tick.
#[derive(Debug, Clone)]
pub struct HeartbeatEvent {
/// Monotonically increasing tick number (starts at 1).
pub tick: u64,
/// Wall-clock instant when this tick fired.
pub timestamp: Instant,
/// Elapsed time since the heartbeat was (re)started.
pub elapsed: Duration,
}
/// Callback type for heartbeat listeners.
pub type Listener = Box<dyn Fn(&HeartbeatEvent) + Send + 'static>;
/// A named listener entry so subscriptions survive stop/start cycles.
struct ListenerEntry {
name: String,
callback: Listener,
}
/// Background heartbeat that ticks at a fixed interval and notifies listeners.
///
/// Listeners are registered once and persist across stop/start cycles.
/// The heartbeat thread is spawned on `start()` and joined on `stop()`.
pub struct Heartbeat {
interval: Duration,
running: Arc<AtomicBool>,
listeners: Arc<Mutex<Vec<ListenerEntry>>>,
handle: Option<thread::JoinHandle<()>>,
tick_count: Arc<Mutex<u64>>,
}
impl Heartbeat {
/// Create a new heartbeat with the given tick interval.
pub fn new(interval: Duration) -> Self {
Self {
interval,
running: Arc::new(AtomicBool::new(false)),
listeners: Arc::new(Mutex::new(Vec::new())),
handle: None,
tick_count: Arc::new(Mutex::new(0)),
}
}
/// Create a heartbeat with the default 500ms interval.
pub fn default_pulse() -> Self {
Self::new(Duration::from_millis(500))
}
/// Register a named listener. The listener persists across stop/start cycles.
pub fn on_tick(&self, name: impl Into<String>, callback: impl Fn(&HeartbeatEvent) + Send + 'static) {
let mut listeners = self.listeners.lock().unwrap();
let name = name.into();
// Replace existing listener with the same name
listeners.retain(|e| e.name != name);
listeners.push(ListenerEntry {
name,
callback: Box::new(callback),
});
}
/// Remove a listener by name.
pub fn remove_listener(&self, name: &str) {
let mut listeners = self.listeners.lock().unwrap();
listeners.retain(|e| e.name != name);
}
/// Start the heartbeat. If already running, this is a no-op.
pub fn start(&mut self) {
if self.running.load(Ordering::SeqCst) {
return;
}
self.running.store(true, Ordering::SeqCst);
let running = Arc::clone(&self.running);
let listeners = Arc::clone(&self.listeners);
let tick_count = Arc::clone(&self.tick_count);
let interval = self.interval;
let handle = thread::spawn(move || {
let start_time = Instant::now();
while running.load(Ordering::SeqCst) {
thread::sleep(interval);
if !running.load(Ordering::SeqCst) {
break;
}
let tick = {
let mut count = tick_count.lock().unwrap();
*count += 1;
*count
};
let event = HeartbeatEvent {
tick,
timestamp: Instant::now(),
elapsed: start_time.elapsed(),
};
let listeners = listeners.lock().unwrap();
for entry in listeners.iter() {
(entry.callback)(&event);
}
}
});
self.handle = Some(handle);
}
/// Stop the heartbeat and join the background thread.
/// Listener subscriptions are preserved for a subsequent `start()`.
pub fn stop(&mut self) {
self.running.store(false, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
/// Whether the heartbeat is currently running.
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
/// Total number of ticks emitted (across all start/stop cycles).
pub fn total_ticks(&self) -> u64 {
*self.tick_count.lock().unwrap()
}
}
impl Drop for Heartbeat {
fn drop(&mut self) {
self.stop();
}
}

View file

@ -27,6 +27,7 @@ pub fn unescape_json(s: &str) -> String {
/// Extract the string value for a given key from a flat JSON line. /// Extract the string value for a given key from a flat JSON line.
/// Looks for `"key": "value"` and returns the value (unescaped basic sequences). /// Looks for `"key": "value"` and returns the value (unescaped basic sequences).
/// Returns `None` if the key is not found or the value is not a string. /// Returns `None` if the key is not found or the value is not a string.
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> { pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let needle = { let needle = {
let mut pat = String::with_capacity(key.len() + 3); let mut pat = String::with_capacity(key.len() + 3);
@ -84,6 +85,7 @@ pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> {
/// Extract a numeric value for a given key from a flat JSON line. /// Extract a numeric value for a given key from a flat JSON line.
/// Looks for `"key": 123.45` and returns the number. /// Looks for `"key": 123.45` and returns the number.
/// Returns `None` if the key is not found or the value is not a number. /// Returns `None` if the key is not found or the value is not a number.
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn extract_num(line: &str, key: &str) -> Option<f64> { pub fn extract_num(line: &str, key: &str) -> Option<f64> {
let needle = { let needle = {
let mut pat = String::with_capacity(key.len() + 3); let mut pat = String::with_capacity(key.len() + 3);

File diff suppressed because it is too large Load diff

86
src/registry.rs Normal file
View file

@ -0,0 +1,86 @@
//! Widget Registry — central namespace for tracking UI widgets by name and type.
//!
//! Supports insertion, lookup by name, and iteration over all registered widgets.
//! This is the foundation that later stages (heartbeat listeners, guard display,
//! progress bar, verdict banner) hang off.
use std::collections::HashMap;
/// The kind of visual element a widget represents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WidgetType {
ProgressBar,
Table,
Banner,
Heartbeat,
Tree,
}
/// A single registered widget.
#[derive(Debug, Clone)]
pub struct Widget {
pub name: String,
pub wtype: WidgetType,
pub enabled: bool,
}
/// Central registry that tracks all UI widgets by name and type.
pub struct WidgetRegistry {
widgets: HashMap<String, Widget>,
/// Insertion order preserved for deterministic iteration.
order: Vec<String>,
}
impl WidgetRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
widgets: HashMap::new(),
order: Vec::new(),
}
}
/// Insert a widget. If a widget with the same name exists, it is replaced
/// (its position in iteration order is preserved).
pub fn insert(&mut self, name: impl Into<String>, wtype: WidgetType) {
let name = name.into();
let widget = Widget {
name: name.clone(),
wtype,
enabled: true,
};
if self.widgets.insert(name.clone(), widget).is_none() {
self.order.push(name);
}
}
/// Look up a widget by name.
pub fn get(&self, name: &str) -> Option<&Widget> {
self.widgets.get(name)
}
/// Iterate over all widgets in insertion order.
pub fn iter(&self) -> impl Iterator<Item = &Widget> {
self.order.iter().filter_map(|n| self.widgets.get(n))
}
/// Number of registered widgets.
pub fn len(&self) -> usize {
self.widgets.len()
}
/// Whether the registry is empty.
pub fn is_empty(&self) -> bool {
self.widgets.is_empty()
}
/// Remove a widget by name. Returns the removed widget, if any.
pub fn remove(&mut self, name: &str) -> Option<Widget> {
if let Some(w) = self.widgets.remove(name) {
self.order.retain(|n| n != name);
Some(w)
} else {
None
}
}
}

21
src/scratch/config.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "yoke-visual-test",
"version": "0.1.0",
"settings": {
"max_retries": 3,
"timeout_ms": 5000,
"verbose": true,
"colors": {
"primary": "#46FF00",
"secondary": "#4B80FF",
"error": "#FF3232"
}
},
"features": [
"stream-filter",
"edit-diff",
"write-preview",
"bash-error-tail",
"thinking-timer"
]
}

44
src/scratch/demo.rs Normal file
View file

@ -0,0 +1,44 @@
/// A small demo module for exercising stream visuals.
/// This file exists only to generate interesting diffs.
pub fn greet(name: &str) -> String {
format!("Hey there, {}! Welcome to the stream.", name)
}
pub fn farewell(name: &str) -> String {
format!("Goodbye, {}. See you next time!", name)
}
pub fn fibonacci(n: u32) -> u64 {
match n {
0 => 0,
1 => 1,
_ => {
let mut a: u64 = 0;
let mut b: u64 = 1;
for _ in 2..=n {
let tmp = a + b;
a = b;
b = tmp;
}
b
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
assert_eq!(greet("world"), "Hello, world!");
}
#[test]
fn test_fibonacci() {
assert_eq!(fibonacci(0), 0);
assert_eq!(fibonacci(1), 1);
assert_eq!(fibonacci(10), 55);
}
}

11
src/scratch/extra.toml Normal file
View file

@ -0,0 +1,11 @@
# Extra scratch config for visual testing
[display]
theme = "dark"
line_numbers = true
word_wrap = false
[limits]
max_diff_lines = 50
max_badge_width = 40
truncate_at = 120

17
src/scratch/notes.md Normal file
View file

@ -0,0 +1,17 @@
# Scratch Notes
These are dummy notes for exercising the yoke stream output.
## Features Tested
- **Edit diffs**: red/green line-level changes
- **Write previews**: line count badge + first 3 lines
- **Bash errors**: last 3 lines of stderr in red
- **Thinking timer**: live elapsed seconds display
- **Grep/Glob badges**: match/file count badges
## Open Questions
1. Should the progress bar use unicode block characters?
2. What is the optimal truncation length for edit diffs?
3. How should we handle binary file diffs?

24
src/scratch/setup.sh Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Scratch setup script for visual testing
set -euo pipefail
echo "Setting up scratch environment..."
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SCRATCH_DIR="${PROJECT_ROOT}/src/scratch"
echo "Project root: ${PROJECT_ROOT}"
echo "Scratch dir: ${SCRATCH_DIR}"
# Count files
FILE_COUNT=$(find "${SCRATCH_DIR}" -type f | wc -l)
echo "Found ${FILE_COUNT} scratch files"
# List them
for f in "${SCRATCH_DIR}"/*; do
if [ -f "$f" ]; then
echo " - $(basename "$f") ($(wc -l < "$f") lines)"
fi
done
echo "Done."

View file

@ -1,6 +1,8 @@
use std::io::{BufRead, BufReader, Write}; use std::collections::HashMap;
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path; use std::path::Path;
use std::process::ChildStdout; use std::process::ChildStdout;
use std::time::Instant;
use crate::json::{extract_num, extract_str, unescape_json}; use crate::json::{extract_num, extract_str, unescape_json};
@ -8,15 +10,30 @@ use crate::json::{extract_num, extract_str, unescape_json};
const RESET: &str = "\x1b[0m"; const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m"; const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m"; const DIM: &str = "\x1b[2m";
const CYAN: &str = "\x1b[36m"; const GREEN: &str = "\x1b[38;5;46m";
const YELLOW: &str = "\x1b[33m"; const ORANGE: &str = "\x1b[38;5;208m";
const GREEN: &str = "\x1b[32m"; const BLUE: &str = "\x1b[38;5;75m";
const CYAN: &str = "\x1b[38;5;80m";
const YELLOW: &str = "\x1b[38;5;222m";
const MAGENTA: &str = "\x1b[38;5;183m";
const RED: &str = "\x1b[38;5;196m";
const GRAY: &str = "\x1b[38;5;245m";
// VS Code-style diff background colors
const BG_RED: &str = "\x1b[48;2;80;30;30m"; // dark red/pink background for removed lines
const BG_GREEN: &str = "\x1b[48;2;30;60;30m"; // dark green background for added lines
struct StreamState { struct StreamState {
turn_num: u32, turn_num: u32,
current_msg_id: Option<String>, current_msg_id: Option<String>,
seen_init: bool, seen_init: bool,
in_thinking: bool, in_thinking: bool,
thinking_start: Option<Instant>,
iteration_cost: f64,
iteration_duration_secs: f64,
/// Maps tool_use id → tool name, so tool_result can look up its origin.
tool_use_names: HashMap<String, String>,
/// Counts of tool_use events by tool name (for iteration summary strip).
tool_counts: HashMap<String, u32>,
} }
impl StreamState { impl StreamState {
@ -26,10 +43,168 @@ impl StreamState {
current_msg_id: None, current_msg_id: None,
seen_init: false, seen_init: false,
in_thinking: false, in_thinking: false,
thinking_start: None,
iteration_cost: 0.0,
iteration_duration_secs: 0.0,
tool_use_names: HashMap::new(),
tool_counts: HashMap::new(),
} }
} }
} }
/// 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. /// Format a tool_use event into a human-readable string.
fn format_tool_call(line: &str) -> String { fn format_tool_call(line: &str) -> String {
let tool_name = extract_str(line, "name").unwrap_or("?"); let tool_name = extract_str(line, "name").unwrap_or("?");
@ -37,39 +212,245 @@ fn format_tool_call(line: &str) -> String {
match tool_name { match tool_name {
"Read" => { "Read" => {
let path = extract_str(line, "file_path").unwrap_or("?"); let path = extract_str(line, "file_path").unwrap_or("?");
format!("Read: {}", path) 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" => { "Edit" => {
let path = extract_str(line, "file_path").unwrap_or("?"); let path = extract_str(line, "file_path").unwrap_or("?");
format!("Edit: {}", path) 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" => { "Write" => {
let path = extract_str(line, "file_path").unwrap_or("?"); let path = extract_str(line, "file_path").unwrap_or("?");
format!("Write: {}", path) 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" => { "Bash" => {
let cmd = extract_str(line, "command").unwrap_or("?"); let cmd = extract_str(line, "command").unwrap_or("?");
if cmd.len() > 80 { if cmd.len() > 80 {
format!("Bash: {}...", &cmd[..77]) let truncated: String = cmd.chars().take(77).collect();
format!("{}{}Bash:{} {}{}...{}", BOLD, MAGENTA, RESET, DIM, truncated, RESET)
} else { } else {
format!("Bash: {}", cmd) format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET)
} }
} }
"Glob" => { "Glob" => {
let pat = extract_str(line, "pattern").unwrap_or("?"); let pat = extract_str(line, "pattern").unwrap_or("?");
format!("Glob: {}", pat) format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
} }
"Grep" => { "Grep" => {
let pat = extract_str(line, "pattern").unwrap_or("?"); let pat = extract_str(line, "pattern").unwrap_or("?");
format!("Grep: {}", pat) format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
} }
other => other.to_string(), other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
} }
} }
/// Process a single NDJSON line, writing formatted output to `out`.
/// `prior_total` is the accumulated cost from previous iterations, used to display a running total.
/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop.
fn process_line(out: &mut impl Write, 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 changed = match &state.current_msg_id {
Some(prev) => prev != msg_id,
None => true,
};
if changed {
state.current_msg_id = Some(msg_id.to_string());
state.turn_num += 1;
writeln!(
out,
"{}{}━━━ Turn {} ━━━{}",
BOLD, ORANGE, state.turn_num, RESET
)?;
}
}
let ev_type = extract_str(line, "type");
match ev_type {
// system → check subtype for init
Some("system") => {
let ev_subtype = extract_str(line, "subtype");
if ev_subtype == Some("init") && !state.seen_init {
state.seen_init = true;
let sid = extract_str(line, "session_id").unwrap_or("?");
let sid_short: String = sid.chars().take(12).collect();
let sid_short = sid_short.as_str();
let model = extract_str(line, "model").unwrap_or("?");
writeln!(
out,
"{}{}[stream]{} session {}… model={}",
ORANGE, BOLD, RESET, sid_short, model
)?;
}
}
// assistant → only tool_use summaries (text already shown via stream_event deltas)
Some("assistant") => {
if line.contains("\"tool_use\"") {
// Track tool_use id → name for badge display on tool_result
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) {
state.tool_use_names.insert(id.to_string(), name.to_string());
// Increment tool-use counter for iteration summary
*state.tool_counts.entry(name.to_string()).or_insert(0) += 1;
}
let desc = format_tool_call(line);
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
}
}
// stream_event → streaming deltas
Some("stream_event") => {
if line.contains("\"content_block_delta\"") {
if line.contains("\"thinking_delta\"") {
// Show elapsed timer during extended thinking, rewriting in-place
if let Some(start) = state.thinking_start {
let elapsed = start.elapsed().as_secs_f64();
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
out.flush()?;
}
} else if line.contains("\"text_delta\"")
&& let Some(text) = extract_str(line, "text")
{
let text = unescape_json(text);
write!(out, "{}{}{}", DIM, text, RESET)?;
out.flush()?;
}
// input_json_delta → skip silently
} else if line.contains("\"content_block_start\"") {
if line.contains("\"thinking\"") {
state.thinking_start = Some(Instant::now());
write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?;
out.flush()?;
state.in_thinking = true;
} else if !line.contains("\"tool_use\"") {
writeln!(out)?;
}
} else if line.contains("\"content_block_stop\"") {
if state.in_thinking {
// Print final elapsed time and end the line
if let Some(start) = state.thinking_start {
let elapsed = start.elapsed().as_secs_f64();
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
}
state.in_thinking = false;
state.thinking_start = None;
}
writeln!(out)?;
}
// message_start, message_delta, message_stop → skip
}
// user → tool result summaries
Some("user") => {
if line.contains("\"tool_result\"") {
let is_error = line.contains("\"is_error\":true")
|| line.contains("\"is_error\": true");
if is_error {
let tail = format_bash_error_tail(line);
if tail.is_empty() {
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
} else {
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
writeln!(out, "{}", tail)?;
}
} else {
// Check if this is a Grep or Glob result for badge display
let tool_name = extract_str(line, "tool_use_id")
.and_then(|id| state.tool_use_names.get(id))
.map(|s| s.as_str());
match tool_name {
Some("Grep") | Some("Glob") => {
let badge = format_grep_glob_badge(tool_name.unwrap(), line);
if badge.is_empty() {
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
} else {
writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)?;
}
}
_ => {
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
}
}
}
}
}
// result → green bold summary with running total
Some("result") => {
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
)?;
}
// Non-JSON or unrecognized — dim passthrough
_ => {
if ev_type.is_none() && !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 edits │ 1 bash │ 42s │ $0.38 ⟫`
fn format_summary_strip(state: &StreamState) -> String {
let mut parts: Vec<String> = Vec::new();
// Turns
parts.push(format!("{} turn{}", state.turn_num, if state.turn_num == 1 { "" } else { "s" }));
// Tool counts — show the most interesting tools in a stable order
let tool_order = ["Edit", "Write", "Read", "Bash", "Grep", "Glob"];
for tool in &tool_order {
if let Some(&count) = state.tool_counts.get(*tool) {
let label = tool.to_lowercase();
parts.push(format!("{} {}", count, label));
}
}
// Any tools not in the predefined order
for (name, &count) in &state.tool_counts {
if !tool_order.contains(&name.as_str()) {
parts.push(format!("{} {}", count, name.to_lowercase()));
}
}
// Duration
parts.push(format!("{:.0}s", state.iteration_duration_secs));
// Cost
parts.push(format!("${:.2}", state.iteration_cost));
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
}
/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. /// Filter NDJSON stream from Claude and format as rich ANSI output on stdout.
/// Consumes the stream entirely — raw NDJSON is not written to disk. /// Consumes the stream entirely — raw NDJSON is not written to disk.
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { /// `prior_total` is the accumulated cost from previous iterations.
/// Returns the cost of this iteration (from the `result` event).
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> f64 {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
let mut state = StreamState::new(); let mut state = StreamState::new();
let mut log_file = log_path.and_then(|p| { let mut log_file = log_path.and_then(|p| {
@ -77,6 +458,8 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) {
std::fs::File::create(p).ok() std::fs::File::create(p).ok()
}); });
let mut out = io::stdout().lock();
for line_result in reader.lines() { for line_result in reader.lines() {
if crate::signal::interrupted() { if crate::signal::interrupted() {
break; break;
@ -96,109 +479,17 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) {
let _ = writeln!(f, "{}", line); let _ = writeln!(f, "{}", line);
} }
// Check for turn boundary (message_id change) if process_line(&mut out, &line, &mut state, prior_total).is_err() {
if let Some(msg_id) = extract_str(&line, "message_id") { break; // stdout broken (e.g. pipe closed) — stop gracefully
let changed = match &state.current_msg_id {
Some(prev) => prev != msg_id,
None => true,
};
if changed {
state.current_msg_id = Some(msg_id.to_string());
state.turn_num += 1;
println!(
"{}{}━━━ Turn {} ━━━{}",
BOLD, CYAN, state.turn_num, RESET
);
} }
} }
let ev_type = extract_str(&line, "type"); // Print iteration summary strip after streaming ends (before guards)
if state.turn_num > 0 {
let strip = format_summary_strip(&state);
let _ = writeln!(out, "{}", strip);
}
match ev_type { let _ = out.flush();
// system → check subtype for init state.iteration_cost
Some("system") => {
let ev_subtype = extract_str(&line, "subtype");
if ev_subtype == Some("init") && !state.seen_init {
state.seen_init = true;
let sid = extract_str(&line, "session_id").unwrap_or("?");
let sid_short = if sid.len() > 12 { &sid[..12] } else { sid };
let model = extract_str(&line, "model").unwrap_or("?");
println!(
"{}{}[stream] session {}... model={}{}",
CYAN, BOLD, sid_short, model, RESET
);
}
}
// assistant → only tool_use summaries (text already shown via stream_event deltas)
Some("assistant") => {
if line.contains("\"tool_use\"") {
let desc = format_tool_call(&line);
println!("{}{}>>{} {}{}", YELLOW, BOLD, RESET, desc, RESET);
}
}
// stream_event → streaming deltas
Some("stream_event") => {
if line.contains("\"content_block_delta\"") {
if line.contains("\"thinking_delta\"") {
// Show activity during extended thinking
print!("{}·{}", DIM, RESET);
std::io::stdout().flush().ok();
} else if line.contains("\"text_delta\"")
&& let Some(text) = extract_str(&line, "text")
{
let text = unescape_json(text);
print!("{}{}{}", DIM, text, RESET);
std::io::stdout().flush().ok();
}
// input_json_delta → skip silently
} else if line.contains("\"content_block_start\"") {
if line.contains("\"thinking\"") {
print!("{}{}thinking {}", DIM, CYAN, RESET);
std::io::stdout().flush().ok();
state.in_thinking = true;
} else if !line.contains("\"tool_use\"") {
println!();
}
} else if line.contains("\"content_block_stop\"") {
if state.in_thinking {
println!();
state.in_thinking = false;
} else {
println!();
}
}
// message_start, message_delta, message_stop → skip
}
// user → tool result summaries
Some("user") => {
if line.contains("\"tool_result\"") {
// Estimate content length from the line
let content_len = if let Some(start) = line.find("\"content\":\"") {
let after = &line[start + 11..];
after.find('"').unwrap_or(after.len())
} else {
0
};
println!("{} \u{2190} result ({}b){}", DIM, content_len, RESET);
}
}
// result → green bold summary
Some("result") => {
let cost = extract_num(&line, "cost_usd").unwrap_or(0.0);
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;
println!(
"{}{}[stream] done cost=${:.2} turns={} duration={:.1}s{}",
GREEN, BOLD, cost, turns, dur_secs, RESET
);
}
// Non-JSON or unrecognized — dim passthrough
_ => {
if ev_type.is_none() && !line.trim().is_empty() {
println!("{} {}{}", DIM, line.trim(), RESET);
}
}
}
}
} }

222
src/stream_opencode.rs Normal file
View file

@ -0,0 +1,222 @@
use std::collections::HashMap;
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;
use std::process::ChildStdout;
use crate::json::{extract_num, extract_str, unescape_json};
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[38;5;46m";
const ORANGE: &str = "\x1b[38;5;208m";
const BLUE: &str = "\x1b[38;5;75m";
const CYAN: &str = "\x1b[38;5;80m";
const YELLOW: &str = "\x1b[38;5;222m";
const MAGENTA: &str = "\x1b[38;5;183m";
const RED: &str = "\x1b[38;5;196m";
const GRAY: &str = "\x1b[38;5;245m";
struct StreamState {
turn_num: u32,
iteration_cost: f64,
iteration_duration_ms: f64,
tool_counts: HashMap<String, u32>,
total_tokens: u64,
}
impl StreamState {
fn new() -> Self {
Self {
turn_num: 0,
iteration_cost: 0.0,
iteration_duration_ms: 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, 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) -> f64 {
let reader = BufReader::new(stdout);
let mut state = StreamState::new();
let mut log_file = log_path.and_then(|p| {
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
std::fs::File::create(p).ok()
});
let mut out = io::stdout().lock();
for line_result in reader.lines() {
if crate::signal::interrupted() {
break;
}
let line = match line_result {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
if let Some(ref mut f) = log_file {
let _ = writeln!(f, "{}", line);
}
if process_line(&mut out, &line, &mut state).is_err() {
break;
}
}
if state.turn_num > 0 {
let strip = format_summary_strip(&state);
let _ = writeln!(out, "{}", strip);
}
let _ = out.flush();
state.iteration_cost
}

View file

@ -0,0 +1,31 @@
# Judge
You are the last line of defense before a human sees this work. You serve two roles: adversary and advocate. You are rough on the implementation so the human who receives it gets something solid and pleasant. A PASS from you means you would stake your reputation on this code.
Read `.loop/plan.md`. For each stage defined in the plan:
## 1. Break it
Try to make the code fail. Do not trust that anything works just because it looks correct. Build it, run it, and feed it inputs designed to expose problems.
- **Boundary inputs** — zeroes, empty strings, max values, negative numbers, Unicode, special characters.
- **Error paths** — missing files, invalid config, network down, permission denied. Does it fail gracefully or crash?
- **Malformed input** — truncated data, wrong types, extra fields, duplicate keys.
- **Concurrency and timing** — if applicable, can you trigger race conditions or ordering bugs?
- **State edges** — what happens on first run vs. repeated runs? Empty state vs. populated state?
You have full shell access. Use it. Build the project, run its tests, then write your own commands to probe beyond what the test suite covers. If you cannot build or run it, that is a FAIL.
## 2. Judge it for the human
Now put on the hat of a senior developer receiving this in a pull request. Would you be pleased or annoyed?
- **Naming** — are functions, variables, and files named so a stranger can read them without a glossary?
- **Error messages** — when something goes wrong, does the user get a message that helps them fix it, or a stack trace and a shrug?
- **API ergonomics** — is the interface (CLI flags, function signatures, config format) intuitive or surprising?
- **Readability** — can you follow the logic without running a debugger in your head?
- **No dead weight** — no leftover TODOs, commented-out code, placeholder text, or debug prints that shipped.
## Verdict
PASS only if both halves hold: nothing you threw at it broke it in a way that matters, AND you would be genuinely happy to receive this code. FAIL with specifics — what broke, what command you ran, what you expected vs. what happened, or what about the code quality fell short.

View file

@ -0,0 +1,62 @@
# Protocol: Brute + Plan Runner (Triple Loop)
You are operating inside an automated triple loop — not a conversation.
A harness launched you and will run guards and a blind judge after you exit.
The outer brute loop retries until a judge says PASS.
Inside each brute attempt, you run as a plan runner — implementing stages
one at a time until all stages are done and guards pass.
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/protocol.md` | read | These instructions. |
| `.loop/plan.md` | read | The feature plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. |
All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
6. **Implement**. Make the code changes for exactly one stage.
7. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
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.
## 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.
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
- **No git operations.** Do not commit, push, branch, or modify git config.
- **Do not modify `protocol.md`, `plan.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One stage per iteration.** Implement a single stage, update notes, and exit.
- **Study judge.md.** Knowing the test helps you pass it.
- **The judge's feedback is ground truth.** Fix what they say is broken.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,41 @@
# Yoke configuration (brute mode)
# Lines starting with # are comments. Blank lines are ignored.
# ── Backend ────────────────────────────────────────────────────────────
# Model to use for the agent. If unset, defaults to Claude CLI.
# Use provider/model format for OpenRouter or other opencode providers.
# model openrouter/anthropic/claude-sonnet-4
# model openai/gpt-4o
# model anthropic/claude-sonnet-4
# ── Sandbox ──────────────────────────────────────────────────────────
# Docker image to run the agent inside. Required unless you pass --no-sandbox.
# Note: sandbox is not currently supported with the 'model' directive.
image claude-code-sandbox:latest
# ── Output ───────────────────────────────────────────────────────────
# Max lines of tail output kept per guard in guard-results.md.
max-tail 200
# Uncomment to save raw stream-json output per iteration.
# log-dir .loop/logs
# ── Scope rules (diff boundary enforcement) ──────────────────────────
# Controls what files the agent is allowed to change. Most-specific
# (longest prefix) match wins.
#
# allow <prefix> — any change permitted (add, modify, delete)
# add-only <prefix> — new files only; existing files cannot be modified
# no-modify <prefix> — no changes at all (adds or modifications rejected)
allow .
# ── Guards (run after each plan stage, fail-fast) ────────────────────
# Shell commands executed after each agent iteration. If any guard
# exits non-zero the iteration fails and results are fed back.
#
# NOTE: avoid "cargo check" as the sole guard — its type-error output
# can confuse the agent into chasing compiler noise instead of finishing
# the task. Prefer a test suite or linter that validates behaviour.
# guard cargo check

View file

@ -0,0 +1,10 @@
## REPL probing
If the code exposes REPL-accessible boundaries (CLI commands, HTTP endpoints, library APIs, shell scripts), open an interactive session and use it to probe the implementation directly.
- **Exercise every boundary** — call each exposed function/endpoint/command with normal inputs first, then adversarial ones.
- **Chain operations** — does state from one call corrupt the next? Try create→read→update→delete sequences and variations.
- **Interrupt mid-flow** — Ctrl-C during an operation, kill a session mid-transaction. Does it recover?
- **Explore discoverability** — can you figure out how to use the interface without reading the source? Are help/usage messages accurate?
Document the REPL session. If something broke, paste the exact input and output.

View file

@ -0,0 +1,20 @@
# Briefing: Planning Agent
You are helping a user write a **plan** for an automated execution harness.
## What is `.loop/`?
The `.loop/` directory contains an automated loop system. After you and the user finish writing `plan.md`, a separate agent (not you) will be launched to execute it — iterating automatically until the plan is complete and all guards pass.
## Your role
Help the user write `plan.md` — a design-level outline broken into stages.
## Guidelines
- **Stages should be goal-oriented.** Describe *what* should be achieved, not *how* at the code level.
- **Stay abstract.** No exact line numbers, function signatures, or copy-paste code snippets. The executing agent will figure out the concrete details.
- **Each stage should be a meaningful unit of work** that can be implemented and verified independently.
- **Only drill into specifics if the user asks.** Default to high-level design intent.
The executing agent has full access to the codebase and will make its own implementation decisions. Your plan is a design reference, not a step-by-step tutorial.

View file

@ -0,0 +1,65 @@
# Protocol: Automated CI Loop
You are operating inside an automated loop — not a conversation. A bash script launched you, and will run guard checks after you exit. You do not interact with a human during this session.
## Files
| File | You can | Purpose |
|------|---------|---------|
| `.loop/protocol.md` | read | This document. Your instructions. |
| `.loop/plan.md` | read | The feature plan. Stages to implement. |
| `.loop/notes.md` | read + write | Your scratchpad. Persists across iterations. |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Loop configuration. Scope rules, guards, settings. |
All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory across iterations — check which stage you are on, what you tried, and what you learned.
3. **Read guard results** (`.loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage.
4. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan.
5. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree.
6. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
7. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
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 in the plan are implemented and you believe guards will pass.
The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass.
## What the Guards Check
After you exit, the outer loop runs guards defined in `.loop/yoke.conf`.
1. **Diff boundary check** — Always runs first. Verifies every file you changed
or created is within the scope rules defined in `.loop/yoke.conf`. The rules:
- `allow PREFIX` — anything goes: add, modify, delete.
- `add-only PREFIX` — may only add lines; no removing existing lines.
- `no-modify PREFIX` — zero modifications allowed.
- No matching rule — change is denied.
- Most-specific (longest) prefix wins when rules overlap.
If the boundary check fails, all subsequent guards are skipped.
2. **Configured guards** — Read the `guard` lines in `.loop/yoke.conf` to see
what commands run. Guards execute in order, fail-fast (first failure skips
the rest).
You may run any commands you find useful during implementation.
## Rules
- **No git operations.** Do not commit, push, branch, or modify git config. The outer loop owns git.
- **Do not modify `protocol.md`, `plan.md`, or `yoke.conf`.** These are read-only to you.
- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix.
- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,61 @@
# Yoke configuration
# Lines starting with # are comments. Blank lines are ignored.
# ── Backend ────────────────────────────────────────────────────────────
# Model to use for the agent. If unset, defaults to Claude CLI.
# Use provider/model format for OpenRouter or other opencode providers.
# model openrouter/anthropic/claude-sonnet-4
# model openai/gpt-4o
# model anthropic/claude-sonnet-4
# ── Sandbox ──────────────────────────────────────────────────────────
# Docker image to run the agent inside. Required unless you pass --no-sandbox.
# Note: sandbox is not currently supported with the 'model' directive.
image claude-code-sandbox:latest
# ── Output ───────────────────────────────────────────────────────────
# Max lines of tail output kept per guard in guard-results.md.
# Keeps the results file from exploding on verbose commands.
max-tail 200
# Uncomment to save raw stream-json output per iteration.
# Each iteration writes to <log-dir>/iteration-<N>.jsonl.
# log-dir .loop/logs
# ── Scope rules (diff boundary enforcement) ──────────────────────────
# Controls what files the agent is allowed to change. After each iteration
# yoke diffs the working tree and checks every changed file against
# these rules. Most-specific (longest prefix) match wins.
#
# Directives:
# allow <prefix> — any change permitted (add, modify, delete)
# add-only <prefix> — new files only; existing files cannot be modified
# no-modify <prefix> — no changes at all (adds or modifications rejected)
#
# The prefix "." matches every path (root catch-all).
#
# Examples:
# allow src/ # full access under src/
# add-only tests/ # can create new test files, not edit existing
# no-modify .github/ # CI config is off-limits
# allow . # fallback: everything else is allowed
allow .
# ── Guards (run in order, fail-fast) ─────────────────────────────────
# Shell commands executed after each agent iteration. If any guard
# exits non-zero the iteration is marked failed, remaining guards are
# skipped, and the results are fed back on the next pass.
#
# Common examples:
# guard cargo check
# guard cargo test
# guard npm run lint
# guard python -m pytest tests/ -x
# guard make test
#
# NOTE: avoid "cargo check" as the sole guard — its type-error output
# can confuse the agent into chasing compiler noise instead of finishing
# the task. Prefer a test suite or linter that validates behaviour.
# guard cargo check

View file

@ -0,0 +1,31 @@
# Judge
You are the last line of defense before a human sees this work. You serve two roles: adversary and advocate. You are rough on the implementation so the human who receives it gets something solid and pleasant. A PASS from you means you would stake your reputation on this code.
Read `.loop/sub-plan.md`. For each stage defined in the plan:
## 1. Break it
Try to make the code fail. Do not trust that anything works just because it looks correct. Build it, run it, and feed it inputs designed to expose problems.
- **Boundary inputs** — zeroes, empty strings, max values, negative numbers, Unicode, special characters.
- **Error paths** — missing files, invalid config, network down, permission denied. Does it fail gracefully or crash?
- **Malformed input** — truncated data, wrong types, extra fields, duplicate keys.
- **Concurrency and timing** — if applicable, can you trigger race conditions or ordering bugs?
- **State edges** — what happens on first run vs. repeated runs? Empty state vs. populated state?
You have full shell access. Use it. Build the project, run its tests, then write your own commands to probe beyond what the test suite covers. If you cannot build or run it, that is a FAIL.
## 2. Judge it for the human
Now put on the hat of a senior developer receiving this in a pull request. Would you be pleased or annoyed?
- **Naming** — are functions, variables, and files named so a stranger can read them without a glossary?
- **Error messages** — when something goes wrong, does the user get a message that helps them fix it, or a stack trace and a shrug?
- **API ergonomics** — is the interface (CLI flags, function signatures, config format) intuitive or surprising?
- **Readability** — can you follow the logic without running a debugger in your head?
- **No dead weight** — no leftover TODOs, commented-out code, placeholder text, or debug prints that shipped.
## Verdict
PASS only if both halves hold: nothing you threw at it broke it in a way that matters, AND you would be genuinely happy to receive this code. FAIL with specifics — what broke, what command you ran, what you expected vs. what happened, or what about the code quality fell short.

View file

@ -0,0 +1,62 @@
# Protocol: Brute + Plan Runner (Triple Loop)
You are operating inside an automated triple loop — not a conversation.
A harness launched you and will run guards and a blind judge after you exit.
The outer brute loop retries until a judge says PASS.
Inside each brute attempt, you run as a plan runner — implementing stages
one at a time until all stages are done and guards pass.
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/protocol.md` | read | These instructions. |
| `.loop/sub-plan.md` | read | The sub-plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. |
All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/sub-plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
6. **Implement**. Make the code changes for exactly one stage.
7. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
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.
## 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.
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
- **No git operations.** Do not commit, push, branch, or modify git config.
- **Do not modify `protocol.md`, `sub-plan.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One stage per iteration.** Implement a single stage, update notes, and exit.
- **Study judge.md.** Knowing the test helps you pass it.
- **The judge's feedback is ground truth.** Fix what they say is broken.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,73 @@
# Protocol: Saga Scoper (Agent 1)
You are the planning agent in a saga loop — not a conversation.
A harness launched you. Your job is to decompose a specification into scoped
sub-plans and feed them one at a time to an inner brute loop (Agent 2 + Agent 3).
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/saga-protocol.md` | read | These instructions. |
| `.loop/specification.md` | read | The full feature specification. User-authored, read-only. |
| `.loop/saga-notes.md` | read+write | Your memory across saga cycles. |
| `.loop/decisions.md` | read+write | Implementation decisions not covered by the spec. |
| `.loop/sub-plan.md` | write | The sub-plan for the next brute cycle. Overwritten each cycle. |
| `.loop/notes.md` | read | The implementer's notes from the last brute cycle. |
| `.loop/verdict.md` | read | The judge's last verdict (from the last brute cycle). |
All paths are relative to the repository root.
## Per-Cycle Steps
1. **Read the specification** (`.loop/specification.md`). Understand the full feature.
2. **Read your notes** (`.loop/saga-notes.md`). Check what you have already scoped, what was completed, and what remains.
3. **Read the implementer's notes** (`.loop/notes.md`). Understand what the last brute cycle accomplished or struggled with.
4. **Read the verdict** (`.loop/verdict.md`). If the last sub-plan was judged, check whether it passed or failed. If the brute loop bailed out (3 consecutive judge failures), understand what went wrong.
5. **Determine the next chunk**. Based on the spec, your notes, and the last cycle's outcome:
- If the previous sub-plan passed, scope the next logical chunk.
- If the previous sub-plan bailed out, re-scope — break the work into smaller pieces, try a different approach, or address the root cause of failure.
- If the full spec is covered, signal DONE.
6. **Write `sub-plan.md`**. Use the same `## Stage` format the plan runner expects. Each stage should be a concrete, implementable unit. The sub-plan overwrites the previous one — no archiving.
7. **Update `saga-notes.md`**. Record:
- What you scoped and why
- What has been completed so far
- What remains
- A `STATUS` line at the **top** of the file (see below)
8. **Update `decisions.md`**. If you made implementation decisions not explicitly covered by the specification, record them here. Append — do not overwrite previous decisions.
9. **Exit**. Stop. The harness handles the next step.
## STATUS Signaling
The first line of `.loop/saga-notes.md` must be one of:
- `STATUS: IN_PROGRESS` — More sub-plans remain to cover the full specification.
- `STATUS: DONE` — The full specification has been realized. All sub-plans have passed.
## Sub-Plan Format
Write `.loop/sub-plan.md` using the same format the plan runner expects:
```markdown
# Plan: <short title>
<brief context>
## Stage 1 — <title>
<what to implement>
## Stage 2 — <title>
<what to implement>
```
Keep sub-plans focused. 2–5 stages per sub-plan is ideal. Smaller chunks are easier for the implementer to get right and for the judge to verify.
## Rules
- **No git operations.** Do not commit, push, branch, or modify git config.
- **Do not modify `specification.md`, `saga-protocol.md`, `protocol.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One sub-plan per cycle.** Write a single sub-plan, update your notes, and exit.
- **Re-scope on bailout.** If the brute loop bailed out, do not re-issue the same sub-plan. Break it down further or try a different approach.
- **Be concise in notes.** Future-you needs signal, not noise.

View file

@ -0,0 +1,41 @@
# Yoke configuration (saga mode)
# Lines starting with # are comments. Blank lines are ignored.
# ── Backend ────────────────────────────────────────────────────────────
# Model to use for the agent. If unset, defaults to Claude CLI.
# Use provider/model format for OpenRouter or other opencode providers.
# model openrouter/anthropic/claude-sonnet-4
# model openai/gpt-4o
# model anthropic/claude-sonnet-4
# ── Sandbox ──────────────────────────────────────────────────────────
# Docker image to run the agent inside. Required unless you pass --no-sandbox.
# Note: sandbox is not currently supported with the 'model' directive.
image claude-code-sandbox:latest
# ── Output ───────────────────────────────────────────────────────────
# Max lines of tail output kept per guard in guard-results.md.
max-tail 200
# Uncomment to save raw stream-json output per iteration.
# log-dir .loop/logs
# ── Scope rules (diff boundary enforcement) ──────────────────────────
# Controls what files the agent is allowed to change. Most-specific
# (longest prefix) match wins.
#
# allow <prefix> — any change permitted (add, modify, delete)
# add-only <prefix> — new files only; existing files cannot be modified
# no-modify <prefix> — no changes at all (adds or modifications rejected)
allow .
# ── Guards (run after each plan stage, fail-fast) ────────────────────
# Shell commands executed after each agent iteration. If any guard
# exits non-zero the iteration fails and results are fed back.
#
# NOTE: avoid "cargo check" as the sole guard — its type-error output
# can confuse the agent into chasing compiler noise instead of finishing
# the task. Prefer a test suite or linter that validates behaviour.
# guard cargo check