commit 9b6124ec6d99804cc3e0f99351e18d84ad9d1e25 Author: Zachery Aaron Shores-Chmielewski Date: Thu Feb 26 12:46:41 2026 +0700 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8b241f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/* \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b8d2e86 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "yoke" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3d3dc28 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "yoke" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "yoke" +path = "src/main.rs" diff --git a/loop/ci.conf b/loop/ci.conf new file mode 100644 index 0000000..190d372 --- /dev/null +++ b/loop/ci.conf @@ -0,0 +1,17 @@ +# CI loop configuration + +# Claude binary path +claude claude + +# Max lines of guard output to keep +max-tail 200 + +# Scope rules (diff boundary enforcement) +# Most-specific (longest) prefix wins. +allow . +allow src/ +allow loop/guards/ +add-only tests/ + +# Guards (run in order, fail-fast) +guard cargo check -p yoke diff --git a/loop/guard-results.md b/loop/guard-results.md new file mode 100644 index 0000000..e69de29 diff --git a/loop/notes.md b/loop/notes.md new file mode 100644 index 0000000..e69de29 diff --git a/loop/plan.md b/loop/plan.md new file mode 100644 index 0000000..beb3bd0 --- /dev/null +++ b/loop/plan.md @@ -0,0 +1,145 @@ +Richer Stdout Display + +Deliverable: Improve the NDJSON stream filter so the operator can follow Claude's +full reasoning. Currently assistant text is truncated to 3 lines / 120 chars and +many event types are silently dropped. No new crates — pure std only. + +--- +Context + +The stream filter in src/stream.rs reads Claude's NDJSON output and formats it +as ANSI-colored terminal output. Current problems: + +1. Assistant text is hard-capped at 3 lines and 120 chars per line. This cuts off + Claude's thinking and explanations — the operator has no idea what it's doing. + +2. The `stream_event` wrapper type (the majority of NDJSON lines) is silently + dropped. These carry `content_block_delta` events with partial text and input + JSON, which are useful for streaming progress. + +3. Tool results are invisible — the `user` event type (which carries tool_result + content) is never matched. + +Goal: Show Claude's full text output, stream partial text deltas as they arrive, +and show a brief summary of tool results. + +--- +Implementation Stages + + +Stage A: Remove text truncation, show full assistant output + +Objective: Stop cutting off assistant text so the operator sees everything. + +src/stream.rs — in the `Some("assistant")` branch, the text display path: + + Current code truncates: + for text_line in trimmed.lines().take(3) { + let truncated = if text_line.len() > 120 { &text_line[..120] } else { text_line }; + println!("{} {}{}", DIM, truncated, RESET); + } + + Change to print all lines with no length limit: + for text_line in trimmed.lines() { + println!("{} {}{}", DIM, text_line, RESET); + } + +Verify: cargo check -p yoke passes. + + +Stage B: Display streaming text deltas + +Objective: Show `content_block_delta` events so text streams to the terminal +as Claude thinks, rather than appearing only in the final `assistant` message. + +src/stream.rs — add a `Some("stream_event")` match arm. These lines wrap inner +events in a `{"type":"stream_event","event":{...}}` envelope. + +The inner event types to handle: + + `content_block_delta` with `"text_delta"`: + The delta has `{"type":"text_delta","text":"..."}`. Extract the `text` field + from the inner delta and print it inline (no newline — use `print!` not + `println!`) so streaming text accumulates naturally: + print!("{}{}{}", DIM, text, RESET); + Flush stdout after each delta. + + `content_block_delta` with `"input_json_delta"`: + These are partial tool input being streamed. Skip these silently — the + complete tool call will be shown when the full `assistant` event arrives. + + `content_block_start`: + If it contains `"tool_use"`, print nothing (the full assistant event will + show the tool call). If it contains `"text"`, print a newline to start + a fresh line for streaming text. + + `content_block_stop`: + Print a newline to terminate any streaming text on the current line. + + `message_start`, `message_delta`, `message_stop`: + Skip silently — these are bookkeeping. + +To detect the inner event type, use extract_str on the line for the `"type"` +field inside `"event"`. Since the line has a top-level `"type":"stream_event"` +and an inner `"type":"content_block_delta"` (etc.), and extract_str now retries +past value matches, you can search for the inner type by looking for specific +strings: + - line.contains("\"content_block_delta\"") → delta handling + - line.contains("\"content_block_start\"") → start handling + - line.contains("\"content_block_stop\"") → stop handling + - Otherwise → skip + +For text_delta extraction: search for `"text_delta"` in the line, then extract +the `"text"` field. Since the line may have multiple `"text"` keys (the delta +type and the actual text content), extract_str's retry logic will handle this. + +Important: flush stdout after each print! call so streaming text appears +immediately: + use std::io::stdout; + stdout().flush().ok(); + +Verify: cargo check -p yoke passes. + + +Stage C: Show tool result summaries + +Objective: When Claude reads a file or runs a command, show a brief summary of +the tool result so the operator knows what happened. + +src/stream.rs — add a `Some("user")` match arm. User events carry tool results +in the format: + {"type":"user","message":{"role":"user","content":[{"tool_use_id":"...","type":"tool_result","content":"..."}]}} + +For tool results: + - Extract the tool_use_id (not critical but nice) + - Check if the line contains `"tool_result"` + - Show a brief dim summary: the first 120 chars of the content, or just + "[tool result]" if content can't be extracted + - Format: `println!("{} ← result ({}b){}", DIM, content_len, RESET)` + where content_len is the approximate length of the result content + +Keep it simple — just indicate a result came back and roughly how big it was. +The exact content is less important than knowing it happened. + +Verify: cargo check -p yoke passes. + +--- +Files to modify + +| File | Change | +|------|--------| +| src/stream.rs | Remove truncation, add stream_event + user event handling | + +--- +Constraints + +No external crates. Edition 2024. Pure std only. +Only modify src/stream.rs. + +--- +Success Criteria + +1. cargo check -p yoke passes +2. Assistant text displays in full — no line count or character limit +3. Streaming text deltas appear as Claude thinks +4. Tool results show a brief acknowledgment line diff --git a/loop/protocol.md b/loop/protocol.md new file mode 100644 index 0000000..2c2d71a --- /dev/null +++ b/loop/protocol.md @@ -0,0 +1,64 @@ +# 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/ci.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/ci.conf`. + +1. **Diff boundary check** — Always runs first. Verifies every file you changed + or created is within the scope rules defined in `loop/ci.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/ci.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 `scope.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. diff --git a/src/boundary.rs b/src/boundary.rs new file mode 100644 index 0000000..d49001b --- /dev/null +++ b/src/boundary.rs @@ -0,0 +1,147 @@ +use std::process::Command; + +use crate::config::{Config, ScopeTag}; + +pub struct BoundaryResult { + pub passed: bool, + pub violations: Vec, +} + +/// Collect all changed files from git (staged, unstaged, and untracked). +/// Returns (changed_files, deleted_files, modified_files). +fn collect_changes() -> Result<(Vec, Vec, Vec), String> { + let mut changed = Vec::new(); + let mut deleted = Vec::new(); + let mut modified = Vec::new(); + + // git diff --name-status HEAD + let output = Command::new("git") + .args(["diff", "--name-status", "HEAD"]) + .output() + .map_err(|e| format!("failed to run git diff: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let mut parts = line.split('\t'); + let status = match parts.next() { + Some(s) => s, + None => continue, + }; + let file = match parts.next() { + Some(f) => f.to_string(), + None => continue, + }; + + match status { + "D" => { + deleted.push(file.clone()); + changed.push(file); + } + "M" => { + modified.push(file.clone()); + changed.push(file); + } + s if s.starts_with('R') => { + // Rename: old path treated as delete, new path as add + deleted.push(file.clone()); + changed.push(file); + if let Some(new_file) = parts.next() { + changed.push(new_file.to_string()); + } + } + _ => { + // A (added) or other statuses + changed.push(file); + } + } + } + + // git ls-files --others --exclude-standard (untracked files) + let output = Command::new("git") + .args(["ls-files", "--others", "--exclude-standard"]) + .output() + .map_err(|e| format!("failed to run git ls-files: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let file = line.trim(); + if !file.is_empty() { + changed.push(file.to_string()); + } + } + + Ok((changed, deleted, modified)) +} + +/// Check if a file's diff contains removed lines (lines starting with '-' but not '---'). +fn has_removed_lines(file: &str) -> bool { + let output = Command::new("git") + .args(["diff", "HEAD", "--", file]) + .output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + for line in stdout.lines() { + if line.starts_with('-') && !line.starts_with("---") { + return true; + } + } + false + } + Err(_) => false, + } +} + +/// Run the diff boundary check against the config's scope rules. +pub fn check(config: &Config) -> BoundaryResult { + let (changed, deleted, modified) = match collect_changes() { + Ok(c) => c, + Err(e) => { + return BoundaryResult { + passed: false, + violations: vec![format!("failed to collect changes: {}", e)], + }; + } + }; + + let mut violations = Vec::new(); + + for file in &changed { + let tag = config.resolve_tag(file); + + match tag { + None => { + violations.push(format!("DENY: {} (no matching rule)", file)); + } + Some(ScopeTag::NoModify) => { + violations.push(format!("NO-MODIFY: {} (protected by no-modify)", file)); + } + Some(ScopeTag::AddOnly) => { + // Deleted files are never add-only safe + if deleted.iter().any(|d| d == file) { + violations.push(format!("ADD-ONLY: {} (deletion not allowed)", file)); + continue; + } + // Modified tracked files: check for removed lines + if modified.iter().any(|m| m == file) && has_removed_lines(file) { + violations.push(format!("ADD-ONLY: {} (removed lines detected)", file)); + } + // New/untracked files are purely additive — always OK + } + Some(ScopeTag::Allow) => { + // anything goes + } + } + } + + BoundaryResult { + passed: violations.is_empty(), + violations, + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e8ac864 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,132 @@ +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq)] +pub enum ScopeTag { + Allow, + AddOnly, + NoModify, +} + +#[derive(Debug, Clone)] +pub struct ScopeRule { + pub tag: ScopeTag, + pub prefix: String, +} + +#[derive(Debug)] +pub struct Config { + pub claude_bin: String, + pub max_tail: usize, + pub log_dir: Option, + pub scope_rules: Vec, + pub guards: Vec, +} + +impl Config { + pub fn load(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?; + + let mut claude_bin = String::from("claude"); + let mut max_tail: usize = 200; + let mut log_dir: Option = None; + let mut scope_rules = Vec::new(); + let mut guards = Vec::new(); + + for (line_num, raw_line) in content.lines().enumerate() { + // Strip comments + let line = match raw_line.find('#') { + Some(pos) => &raw_line[..pos], + None => raw_line, + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Split into directive and value at first whitespace + let (directive, value) = match line.find(char::is_whitespace) { + Some(pos) => (&line[..pos], line[pos..].trim_start()), + None => { + return Err(format!( + "{}:{}: directive '{}' has no value", + path.display(), + line_num + 1, + line + )); + } + }; + + match directive { + "claude" => claude_bin = value.to_string(), + "max-tail" => { + max_tail = value.parse::().map_err(|_| { + format!( + "{}:{}: invalid max-tail value '{}'", + path.display(), + line_num + 1, + value + ) + })?; + } + "log-dir" => { + log_dir = Some(value.to_string()); + } + "allow" => scope_rules.push(ScopeRule { + tag: ScopeTag::Allow, + prefix: value.to_string(), + }), + "add-only" => scope_rules.push(ScopeRule { + tag: ScopeTag::AddOnly, + prefix: value.to_string(), + }), + "no-modify" => scope_rules.push(ScopeRule { + tag: ScopeTag::NoModify, + prefix: value.to_string(), + }), + "guard" => guards.push(value.to_string()), + other => { + return Err(format!( + "{}:{}: unknown directive '{}'", + path.display(), + line_num + 1, + other + )); + } + } + } + + Ok(Config { + claude_bin, + max_tail, + log_dir, + scope_rules, + guards, + }) + } + + /// Resolve the most-specific scope tag for a file path. + /// Returns None if no rule matches. + pub fn resolve_tag(&self, file: &str) -> Option<&ScopeTag> { + let mut best_tag: Option<&ScopeTag> = None; + let mut best_len: usize = 0; + + for rule in &self.scope_rules { + let matches = if rule.prefix == "." { + true + } else { + file.starts_with(&rule.prefix) + }; + + if matches && rule.prefix.len() > best_len { + best_len = rule.prefix.len(); + best_tag = Some(&rule.tag); + } + } + + // "." has length 1 but should match everything — already handled above. + // If only "." matched, best_len is 1, which is correct. + best_tag + } +} diff --git a/src/guard.rs b/src/guard.rs new file mode 100644 index 0000000..c45f513 --- /dev/null +++ b/src/guard.rs @@ -0,0 +1,91 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +pub struct GuardResult { + pub name: String, + pub passed: bool, + pub output: String, + pub skipped: bool, +} + +/// Keep only the last `max` lines of text. +fn tail_lines(text: &str, max: usize) -> String { + let lines: Vec<&str> = text.lines().collect(); + if lines.len() <= max { + return text.to_string(); + } + lines[lines.len() - max..].join("\n") +} + +/// Run all configured guard commands in order (fail-fast). +/// Writes results to `results_path` in markdown format. +pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec { + let mut results = Vec::new(); + 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") + .arg("-c") + .arg(cmd) + .output(); + + let (exit_ok, raw_output) = match output { + Ok(o) => { + let mut combined = String::from_utf8_lossy(&o.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&o.stderr); + if !stderr.is_empty() { + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&stderr); + } + (o.status.success(), combined) + } + Err(e) => (false, format!("failed to execute: {}", e)), + }; + + let truncated = tail_lines(&raw_output, max_tail); + let status_label = if exit_ok { "PASS" } else { "FAIL" }; + + markdown.push_str(&format!("## {} — {}\n\n", cmd, status_label)); + markdown.push_str("```\n"); + markdown.push_str(&truncated); + if !truncated.is_empty() && !truncated.ends_with('\n') { + markdown.push('\n'); + } + markdown.push_str("```\n\n"); + + if !exit_ok { + failed = true; + } + + results.push(GuardResult { + name: cmd.clone(), + passed: exit_ok, + output: truncated, + skipped: false, + }); + } + + // Write results file + if let Err(e) = fs::write(results_path, &markdown) { + eprintln!("[ci] WARNING: failed to write guard results: {}", e); + } + + results +} diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..34c121a --- /dev/null +++ b/src/json.rs @@ -0,0 +1,116 @@ +/// Extract the string value for a given key from a flat JSON line. +/// 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. +pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> { + let needle = { + let mut pat = String::with_capacity(key.len() + 3); + pat.push('"'); + pat.push_str(key); + pat.push('"'); + pat + }; + + let mut search_from = 0; + loop { + let rel_pos = line[search_from..].find(&needle)?; + let key_start = search_from + rel_pos; + let after_key = key_start + needle.len(); + let rest = &line[after_key..]; + + // Skip whitespace and colon + let rest = rest.trim_start(); + let rest = match rest.strip_prefix(':') { + Some(r) => r, + None => { + // This was a value, not a key — advance and retry + search_from = after_key; + continue; + } + }; + let rest = rest.trim_start(); + + // Expect opening quote + let rest = match rest.strip_prefix('"') { + Some(r) => r, + None => { + search_from = after_key; + continue; + } + }; + + // Find closing quote (handle escaped quotes) + let mut end = 0; + let bytes = rest.as_bytes(); + while end < bytes.len() { + if bytes[end] == b'\\' { + end += 2; + } else if bytes[end] == b'"' { + return Some(&rest[..end]); + } else { + end += 1; + } + } + + return None; + } +} + +/// Extract a numeric value for a given key from a flat JSON line. +/// Looks for `"key": 123.45` and returns the number. +/// Returns `None` if the key is not found or the value is not a number. +pub fn extract_num(line: &str, key: &str) -> Option { + let needle = { + let mut pat = String::with_capacity(key.len() + 3); + pat.push('"'); + pat.push_str(key); + pat.push('"'); + pat + }; + + let key_start = line.find(&needle)?; + let after_key = key_start + needle.len(); + let rest = &line[after_key..]; + + // Skip whitespace and colon + let rest = rest.trim_start(); + let rest = rest.strip_prefix(':')?; + let rest = rest.trim_start(); + + // Collect numeric chars: digits, '.', '-', '+', 'e', 'E' + let num_end = rest + .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+' && c != 'e' && c != 'E') + .unwrap_or(rest.len()); + + if num_end == 0 { + return None; + } + + rest[..num_end].parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_str_basic() { + let line = r#"{"type": "assistant", "subtype": "text"}"#; + assert_eq!(extract_str(line, "type"), Some("assistant")); + assert_eq!(extract_str(line, "subtype"), Some("text")); + assert_eq!(extract_str(line, "missing"), None); + } + + #[test] + fn test_extract_str_escaped() { + let line = r#"{"path": "foo\"bar"}"#; + assert_eq!(extract_str(line, "path"), Some(r#"foo\"bar"#)); + } + + #[test] + fn test_extract_num_basic() { + let line = r#"{"cost_usd": 0.42, "num_turns": 5}"#; + assert!((extract_num(line, "cost_usd").unwrap() - 0.42).abs() < 1e-10); + assert!((extract_num(line, "num_turns").unwrap() - 5.0).abs() < 1e-10); + assert_eq!(extract_num(line, "missing"), None); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4ee9f2a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,472 @@ +mod boundary; +mod config; +mod guard; +mod json; +mod signal; +mod stream; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{self, Child, Command, Stdio}; + +use config::Config; + +const CONF_PATH: &str = "loop/ci.conf"; +const NOTES_PATH: &str = "loop/notes.md"; +const GUARD_RESULTS_PATH: &str = "loop/guard-results.md"; +const PROTOCOL_PATH: &str = "loop/protocol.md"; +const PLAN_PATH: &str = "loop/plan.md"; + +/// Files that Claude must not be allowed to permanently alter. +const PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH]; + +// ANSI helpers +const RESET: &str = "\x1b[0m"; +const BOLD: &str = "\x1b[1m"; +const CYAN: &str = "\x1b[36m"; +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const YELLOW: &str = "\x1b[33m"; +const DIM: &str = "\x1b[2m"; + +fn log(msg: &str) { + eprintln!("{}{}[ci]{} {}", BOLD, CYAN, RESET, msg); +} + +fn log_error(msg: &str) { + eprintln!("{}{}[ci] ERROR:{} {}", BOLD, RED, RESET, msg); +} + +fn print_usage() { + eprintln!( + "{}{}ci{} — LLM loop harness{}", + BOLD, CYAN, RESET, RESET + ); + eprintln!(); + eprintln!( + "{}USAGE:{} ci [options]", + BOLD, RESET + ); + eprintln!(); + eprintln!("{}COMMANDS:{}", BOLD, RESET); + eprintln!( + " {}run{} Launch the CI loop (invoke Claude, run guards, iterate)", + BOLD, RESET + ); + eprintln!( + " {}run --dry-run{} Single iteration: boundary check + guards only, no Claude", + BOLD, RESET + ); + eprintln!(); + eprintln!("{}OPTIONS:{}", BOLD, RESET); + eprintln!(" --help, -h Show this help message"); + eprintln!(); + eprintln!( + "{}CONFIG:{} {}{}", + BOLD, RESET, DIM, CONF_PATH + ); + eprintln!( + "{}EXIT:{} 0=success 1=guard failure 2=usage error", + BOLD, RESET + ); +} + +fn print_run_help() { + eprintln!( + "{}{}ci run{} — execute the CI loop{}", + BOLD, CYAN, RESET, RESET + ); + eprintln!(); + eprintln!( + "{}USAGE:{} ci run [--dry-run]", + BOLD, RESET + ); + eprintln!(); + eprintln!("{}OPTIONS:{}", BOLD, RESET); + eprintln!(" --dry-run Run one iteration without invoking Claude"); + eprintln!(" (boundary check + configured guards only)"); + eprintln!(); + eprintln!("{}WORKFLOW:{}", BOLD, RESET); + eprintln!(" 1. Load config from {}", CONF_PATH); + eprintln!(" 2. Backup protected files (protocol.md, plan.md, ci.conf)"); + eprintln!(" 3. Per iteration:"); + eprintln!(" a. Restore protected files"); + eprintln!(" b. Invoke Claude with stream-json output"); + eprintln!(" c. Run diff boundary check"); + eprintln!(" d. Run configured guard commands"); + eprintln!(" 4. Exit when STATUS: DONE and all guards pass"); +} + +/// Holds loop state and cleans up on drop. +/// +/// Drop kills any running Claude child process and removes the backup +/// directory. Note: Drop does not run on raw SIGINT — the child process +/// shares the process group and receives the signal directly, and the +/// backup temp dir is small and inconsequential. +struct LoopRunner { + backup_dir: PathBuf, + child: Option, +} + +impl LoopRunner { + fn new(backup_dir: PathBuf) -> Self { + Self { + backup_dir, + child: None, + } + } +} + +impl Drop for LoopRunner { + fn drop(&mut self) { + // Kill any running child process + if let Some(ref mut child) = self.child { + let _ = child.kill(); + let _ = child.wait(); + } + // Remove backup directory + let _ = fs::remove_dir_all(&self.backup_dir); + } +} + +/// Check that required loop files exist, create notes if missing. +fn preflight() { + for path in &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH] { + if !Path::new(path).exists() { + log_error(&format!("required file not found: {}", path)); + process::exit(1); + } + } + // Ensure notes file exists + if !Path::new(NOTES_PATH).exists() { + if let Err(e) = fs::write(NOTES_PATH, "") { + log_error(&format!("cannot create {}: {}", NOTES_PATH, e)); + process::exit(1); + } + } + // Clear guard results + let _ = fs::write(GUARD_RESULTS_PATH, ""); +} + +/// Copy protected files to a temp directory. Returns the backup path. +fn backup_protected() -> PathBuf { + let backup_dir = std::env::temp_dir().join(format!("ci-backup-{}", process::id())); + if let Err(e) = fs::create_dir_all(&backup_dir) { + log_error(&format!("failed to create backup dir: {}", e)); + process::exit(1); + } + for path in PROTECTED_FILES { + let src = Path::new(path); + if src.exists() { + let dest = backup_dir.join(src.file_name().unwrap()); + if let Err(e) = fs::copy(src, &dest) { + log_error(&format!("failed to backup {}: {}", path, e)); + process::exit(1); + } + } + } + backup_dir +} + +/// Restore protected files from backup. +fn restore_protected(backup_dir: &Path) { + for path in PROTECTED_FILES { + let src_name = Path::new(path).file_name().unwrap(); + let backup_file = backup_dir.join(src_name); + if backup_file.exists() { + if let Err(e) = fs::copy(&backup_file, path) { + eprintln!( + "{}{}[ci] WARNING:{} failed to restore {}: {}", + BOLD, YELLOW, RESET, path, e + ); + } + } + } +} + +/// Check the first line of notes.md for STATUS: DONE. +fn is_done() -> bool { + match fs::read_to_string(NOTES_PATH) { + Ok(content) => content.starts_with("STATUS: DONE"), + Err(_) => false, + } +} + +/// Invoke Claude, piping stdout through the stream filter. +/// The Child is stored in `runner` for cleanup-on-drop safety. +/// Returns the child's exit status success. +fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool { + log(&format!("Launching Claude (iteration {})...", iteration)); + + let mut child = match Command::new(&config.claude_bin) + .args([ + "--verbose", + "--output-format", + "stream-json", + "--include-partial-messages", + "-p", + "Read loop/protocol.md and follow its instructions.", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(e) => { + log_error(&format!( + "failed to spawn '{}': {}", + config.claude_bin, e + )); + return false; + } + }; + + // Compute log path + let log_path = config.log_dir.as_ref().map(|dir| { + std::path::PathBuf::from(dir).join(format!("iteration-{}.jsonl", iteration)) + }); + + // Take stdout and feed through stream filter + if let Some(stdout) = child.stdout.take() { + // Store child in runner before blocking on stream filter, + // so Drop can kill it if we're interrupted. + runner.child = Some(child); + stream::filter_stream(stdout, log_path.as_deref()); + if signal::interrupted() { + return false; + } + } else { + runner.child = Some(child); + } + + // Wait for the child to exit + let status = if let Some(ref mut child) = runner.child { + match child.wait() { + Ok(s) => { + if signal::interrupted() { + return false; + } + s.success() + } + Err(e) => { + log_error(&format!("failed to wait on claude process: {}", e)); + false + } + } + } else { + false + }; + + // Child has exited; clear it from runner + runner.child = None; + + log(&format!( + "Claude exited ({})", + if status { "success" } else { "failure" } + )); + status +} + +/// Run boundary check and all configured guards. +/// Returns true if everything passed. +fn run_all_guards(config: &Config) -> bool { + // Boundary check first + log("Running diff boundary check..."); + let boundary = boundary::check(config); + + if boundary.passed { + log(&format!("{} Boundary check: PASS{}", GREEN, RESET)); + } else { + log(&format!( + "{} Boundary check: FAIL ({} violation(s)){}", + RED, + boundary.violations.len(), + RESET + )); + for v in &boundary.violations { + eprintln!(" {}", v); + } + + // Write boundary failure to guard results, mark configured guards as skipped + let mut md = String::new(); + md.push_str("## diff boundary check \u{2014} FAIL\n\n```\n"); + for v in &boundary.violations { + md.push_str(v); + md.push('\n'); + } + md.push_str("```\n\n"); + for g in &config.guards { + md.push_str(&format!("## {} \u{2014} SKIPPED\n\n", g)); + md.push_str("```\nSkipped due to diff boundary violation.\n```\n\n"); + } + let _ = fs::write(GUARD_RESULTS_PATH, &md); + return false; + } + + // Boundary passed — write that to results then run configured guards + let results_path = Path::new(GUARD_RESULTS_PATH); + + let boundary_md = "## diff boundary check \u{2014} PASS\n\n```\nAll changed files are within allowed boundaries.\n```\n\n"; + + log("Running configured guards..."); + let guard_results = guard::run_guards(&config.guards, config.max_tail, results_path); + + // Prepend boundary result to whatever guard::run_guards wrote + let existing = fs::read_to_string(results_path).unwrap_or_default(); + let combined = format!("{}{}", boundary_md, existing); + let _ = fs::write(results_path, combined); + + let all_passed = guard_results.iter().all(|r| r.passed); + for r in &guard_results { + let status = if r.skipped { + format!("{}SKIPPED{}", YELLOW, RESET) + } else if r.passed { + format!("{}PASS{}", GREEN, RESET) + } else { + format!("{}FAIL{}", RED, RESET) + }; + log(&format!(" {}: {}", r.name, status)); + } + + all_passed +} + +fn run_loop(dry_run: bool) -> i32 { + // Load config + let config = match Config::load(Path::new(CONF_PATH)) { + Ok(c) => c, + Err(e) => { + log_error(&e); + process::exit(1); + } + }; + + log(&format!( + "Config loaded: claude={}, max_tail={}, {} scope rules, {} guards", + config.claude_bin, + config.max_tail, + config.scope_rules.len(), + config.guards.len() + )); + + // Preflight + preflight(); + log("Preflight OK"); + + // Backup protected files and create the runner (Drop handles cleanup) + let backup_dir = backup_protected(); + log(&format!("Backups in {}", backup_dir.display())); + + let mut runner = LoopRunner::new(backup_dir); + let mut iteration: u32 = 0; + + loop { + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return 130; + } + iteration += 1; + eprintln!(); + eprintln!( + "{}{}╔══════════════════════════════════════╗{}", + BOLD, CYAN, RESET + ); + eprintln!( + "{}{}║ Iteration {:>4} ║{}", + BOLD, CYAN, iteration, RESET + ); + eprintln!( + "{}{}╚══════════════════════════════════════╝{}", + BOLD, CYAN, RESET + ); + + // Restore protected files + restore_protected(&runner.backup_dir); + + // Clear guard results + let _ = fs::write(GUARD_RESULTS_PATH, ""); + + if dry_run { + log("(dry-run) Skipping Claude invocation"); + } else { + invoke_claude(&mut runner, &config, iteration); + } + + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return 130; + } + + // Run guards + let guards_passed = run_all_guards(&config); + + if guards_passed { + log(&format!( + "{}{}All guards passed{}", + GREEN, BOLD, RESET + )); + + if dry_run { + log("(dry-run) Guards passed \u{2014} exiting after one iteration"); + return 0; + } + + if is_done() { + eprintln!(); + eprintln!( + "{}{} STATUS: DONE and all guards pass \u{2014} loop complete {}", + GREEN, BOLD, RESET + ); + eprintln!(); + return 0; + } else { + log("Guards passed but STATUS is not DONE \u{2014} continuing"); + } + } else { + log(&format!( + "{}Some guards failed \u{2014} Claude will see results next iteration{}", + YELLOW, RESET + )); + + if dry_run { + log("(dry-run) Exiting after one iteration"); + return 1; + } + } + } +} + +fn main() { + signal::install(); + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + print_usage(); + process::exit(2); + } + + match args[1].as_str() { + "run" => { + // Check for --help before other flags + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_run_help(); + return; + } + let dry_run = args.get(2).is_some_and(|a| a == "--dry-run"); + if args.len() > 2 && !dry_run { + log_error(&format!("unknown flag '{}'", args[2])); + print_run_help(); + process::exit(2); + } + process::exit(run_loop(dry_run)); + } + "--help" | "-h" | "help" => { + print_usage(); + } + other => { + log_error(&format!("unknown command '{}'", other)); + print_usage(); + process::exit(2); + } + } +} diff --git a/src/signal.rs b/src/signal.rs new file mode 100644 index 0000000..c3ea11f --- /dev/null +++ b/src/signal.rs @@ -0,0 +1,19 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +static INTERRUPTED: AtomicBool = AtomicBool::new(false); + +extern "C" fn sigint_handler(_sig: i32) { + INTERRUPTED.store(true, Ordering::Relaxed); +} + +unsafe extern "C" { + safe fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize; +} + +pub fn install() { + signal(2, sigint_handler); // SIGINT = 2 +} + +pub fn interrupted() -> bool { + INTERRUPTED.load(Ordering::Relaxed) +} diff --git a/src/stream.rs b/src/stream.rs new file mode 100644 index 0000000..d78787a --- /dev/null +++ b/src/stream.rs @@ -0,0 +1,168 @@ +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::ChildStdout; + +use crate::json::{extract_num, extract_str}; + +// ANSI escape codes +const RESET: &str = "\x1b[0m"; +const BOLD: &str = "\x1b[1m"; +const DIM: &str = "\x1b[2m"; +const CYAN: &str = "\x1b[36m"; +const YELLOW: &str = "\x1b[33m"; +const GREEN: &str = "\x1b[32m"; + +struct StreamState { + turn_num: u32, + current_msg_id: Option, + seen_init: bool, +} + +impl StreamState { + fn new() -> Self { + Self { + turn_num: 0, + current_msg_id: None, + seen_init: false, + } + } +} + +/// 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("?"); + format!("Read: {}", path) + } + "Edit" => { + let path = extract_str(line, "file_path").unwrap_or("?"); + format!("Edit: {}", path) + } + "Write" => { + let path = extract_str(line, "file_path").unwrap_or("?"); + format!("Write: {}", path) + } + "Bash" => { + let cmd = extract_str(line, "command").unwrap_or("?"); + if cmd.len() > 80 { + format!("Bash: {}...", &cmd[..77]) + } else { + format!("Bash: {}", cmd) + } + } + "Glob" => { + let pat = extract_str(line, "pattern").unwrap_or("?"); + format!("Glob: {}", pat) + } + "Grep" => { + let pat = extract_str(line, "pattern").unwrap_or("?"); + format!("Grep: {}", pat) + } + other => other.to_string(), + } +} + +/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. +/// Consumes the stream entirely — raw NDJSON is not written to disk. +pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { + 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() + }); + + 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; + } + + // Tee raw NDJSON to log file + if let Some(ref mut f) = log_file { + let _ = writeln!(f, "{}", line); + } + + // 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; + println!( + "{}{}━━━ Turn {} ━━━{}", + BOLD, CYAN, 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 = 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 → probe content for tool_use or text + Some("assistant") => { + if line.contains("\"tool_use\"") { + let desc = format_tool_call(&line); + println!("{}{}>>{} {}{}", YELLOW, BOLD, RESET, desc, RESET); + } else if let Some(text) = extract_str(&line, "text") { + let trimmed = text.trim(); + if !trimmed.is_empty() { + for text_line in trimmed.lines().take(3) { + let truncated = if text_line.len() > 120 { + &text_line[..120] + } else { + text_line + }; + println!("{} {}{}", DIM, truncated, 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); + } + } + } + } +}