diff --git a/.gitignore b/.gitignore index c8b241f..d75bc45 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -target/* \ No newline at end of file +target/* +.loop/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 3d3dc28..3ecd1fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "yoke" version = "0.1.0" edition = "2024" +description = "LLM automation loop harness" [[bin]] name = "yoke" diff --git a/loop/ci.conf b/loop/ci.conf deleted file mode 100644 index 190d372..0000000 --- a/loop/ci.conf +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 diff --git a/loop/notes.md b/loop/notes.md deleted file mode 100644 index e69de29..0000000 diff --git a/loop/plan.md b/loop/plan.md deleted file mode 100644 index beb3bd0..0000000 --- a/loop/plan.md +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index 2c2d71a..0000000 --- a/loop/protocol.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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/config.rs b/src/config.rs index e8ac864..7b39040 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,8 +19,11 @@ pub struct Config { pub claude_bin: String, pub max_tail: usize, pub log_dir: Option, + pub image: Option, pub scope_rules: Vec, pub guards: Vec, + pub runner: String, + pub judge: Option, } impl Config { @@ -31,8 +34,11 @@ impl Config { let mut claude_bin = String::from("claude"); let mut max_tail: usize = 200; let mut log_dir: Option = None; + let mut image: Option = None; let mut scope_rules = Vec::new(); let mut guards = Vec::new(); + let mut runner = String::from("loop"); + let mut judge: Option = None; for (line_num, raw_line) in content.lines().enumerate() { // Strip comments @@ -73,6 +79,9 @@ impl Config { "log-dir" => { log_dir = Some(value.to_string()); } + "image" => { + image = Some(value.to_string()); + } "allow" => scope_rules.push(ScopeRule { tag: ScopeTag::Allow, prefix: value.to_string(), @@ -86,6 +95,8 @@ impl Config { prefix: value.to_string(), }), "guard" => guards.push(value.to_string()), + "runner" => runner = value.to_string(), + "judge" => judge = Some(value.to_string()), other => { return Err(format!( "{}:{}: unknown directive '{}'", @@ -101,8 +112,11 @@ impl Config { claude_bin, max_tail, log_dir, + image, scope_rules, guards, + runner, + judge, }) } diff --git a/src/main.rs b/src/main.rs index 4ee9f2a..b269e4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,11 +11,151 @@ 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"; +const CONF_PATH: &str = ".loop/guard.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"; +const TASK_PATH: &str = ".loop/task.md"; +const JUDGE_PATH: &str = ".loop/judge.md"; +const VERDICT_PATH: &str = ".loop/verdict.md"; + +const DEFAULT_PROTOCOL: &str = r#"# 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/guard.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/guard.conf`. + +1. **Diff boundary check** — Always runs first. Verifies every file you changed + or created is within the scope rules defined in `.loop/guard.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/guard.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 `guard.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. +"#; + +const DEFAULT_GUARD_CONF: &str = "\ +# Guard configuration + +claude claude +image claude-code-sandbox:latest + +# Max lines of guard output to keep +max-tail 200 + +# Scope rules (diff boundary enforcement) +# Tags: allow (any change), add-only (new lines only), no-modify (no changes) +# Most-specific (longest) prefix wins. +allow . + +# Guards (run in order, fail-fast) +guard cargo check +"; + +const DEFAULT_BRUTE_PROTOCOL: &str = r#"# Brute Protocol + +You are a worker in an automated brute-force loop. Your single goal is described +in `.loop/task.md`. You iterate until a blind judge (a separate Claude instance +with zero implementation context) confirms the task is complete. + +## Files + +| File | Access | Purpose | +|---|---|---| +| `.loop/protocol.md` | read | These instructions. | +| `.loop/task.md` | read | The goal. | +| `.loop/judge.md` | read | What the judge will test. Study this. | +| `.loop/notes.md` | read+write | Your scratchpad across iterations. | +| `.loop/verdict.md` | read | The judge's last verdict. | +| `.loop/ci.conf` | read | Configuration. | + +## Per-Iteration + +1. Read `.loop/task.md` — understand the goal. +2. Read `.loop/notes.md` — recall what you have tried. +3. Read `.loop/verdict.md` — the judge's exact complaints from last iteration. +4. Make changes to accomplish the task. +5. Update `.loop/notes.md` with what you changed and why. +6. Exit — the harness runs guards then the judge. + +## Rules + +- No git operations. +- Do not modify protocol.md, task.md, judge.md, or ci.conf. +- Study judge.md — knowing the test helps you pass it. +- The judge's feedback is ground truth. Fix what they say is broken. +- After 3 similar verdicts, try a fundamentally different approach. +- Be concise in notes. +"#; + +const DEFAULT_BRUTE_CONF: &str = "\ +# Brute runner configuration + +runner brute +claude claude +max-tail 200 + +allow . + +# Guards (run before judge, fail-fast) +guard cargo check + +# Judge — a fresh Claude that verifies the feature blind. +# The value is the Claude binary (same format as the claude directive). +judge claude +"; /// Files that Claude must not be allowed to permanently alter. const PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH]; @@ -30,31 +170,39 @@ const YELLOW: &str = "\x1b[33m"; const DIM: &str = "\x1b[2m"; fn log(msg: &str) { - eprintln!("{}{}[ci]{} {}", BOLD, CYAN, RESET, msg); + eprintln!("{}{}[yoke]{} {}", BOLD, CYAN, RESET, msg); } fn log_error(msg: &str) { - eprintln!("{}{}[ci] ERROR:{} {}", BOLD, RED, RESET, msg); + eprintln!("{}{}[yoke] ERROR:{} {}", BOLD, RED, RESET, msg); } fn print_usage() { eprintln!( - "{}{}ci{} — LLM loop harness{}", + "{}{}yoke{} — LLM loop harness{}", BOLD, CYAN, RESET, RESET ); eprintln!(); eprintln!( - "{}USAGE:{} ci [options]", + "{}USAGE:{} yoke [options]", BOLD, RESET ); eprintln!(); eprintln!("{}COMMANDS:{}", BOLD, RESET); eprintln!( - " {}run{} Launch the CI loop (invoke Claude, run guards, iterate)", + " {}init{} Initialize .loop/ directory with default files", BOLD, RESET ); eprintln!( - " {}run --dry-run{} Single iteration: boundary check + guards only, no Claude", + " {}init --runner {} Initialize for a specific runner (loop or brute)", + 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!(); @@ -73,12 +221,12 @@ fn print_usage() { fn print_run_help() { eprintln!( - "{}{}ci run{} — execute the CI loop{}", + "{}{}yoke run{} — execute the CI loop{}", BOLD, CYAN, RESET, RESET ); eprintln!(); eprintln!( - "{}USAGE:{} ci run [--dry-run]", + "{}USAGE:{} yoke run [--dry-run]", BOLD, RESET ); eprintln!(); @@ -86,15 +234,54 @@ fn print_run_help() { eprintln!(" --dry-run Run one iteration without invoking Claude"); eprintln!(" (boundary check + configured guards only)"); eprintln!(); - eprintln!("{}WORKFLOW:{}", BOLD, RESET); + eprintln!("{}RUNNER TYPES:{}", BOLD, RESET); + eprintln!(" The runner type is determined by the 'runner' directive in {}.", CONF_PATH); + eprintln!(" {}loop{} (default) Staged plan runner — iterates until STATUS: DONE and guards pass.", BOLD, RESET); + eprintln!(" {}brute{} Blind-judge runner — iterates until an independent judge says PASS.", BOLD, RESET); + eprintln!(); + eprintln!("{}WORKFLOW (loop):{}", BOLD, RESET); eprintln!(" 1. Load config from {}", CONF_PATH); - eprintln!(" 2. Backup protected files (protocol.md, plan.md, ci.conf)"); + eprintln!(" 2. Backup protected files (protocol.md, plan.md, guard.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"); + eprintln!(); + eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET); + eprintln!(" 1. Load config from {}", CONF_PATH); + eprintln!(" 2. Backup protected files (protocol.md, task.md, judge.md, ci.conf)"); + eprintln!(" 3. Per iteration:"); + eprintln!(" a. Restore protected files, clear verdict"); + eprintln!(" b. Invoke worker Claude"); + eprintln!(" c. Run diff boundary check + configured guards"); + eprintln!(" d. If guards pass, invoke judge (fresh Claude, zero context)"); + eprintln!(" 4. Exit when judge returns VERDICT: PASS"); +} + +fn print_init_help() { + eprintln!( + "{}{}yoke init{} — initialize .loop/ directory{}", + BOLD, CYAN, RESET, RESET + ); + eprintln!(); + eprintln!( + "{}USAGE:{} yoke init [--runner ]", + BOLD, RESET + ); + eprintln!(); + eprintln!("{}OPTIONS:{}", BOLD, RESET); + eprintln!(" --runner Runner type: loop (default) or brute"); + eprintln!(); + eprintln!("{}RUNNER TYPES:{}", BOLD, RESET); + eprintln!(" {}loop{} (default) Staged plan runner. Creates:", BOLD, RESET); + eprintln!(" ci.conf, protocol.md, plan.md, notes.md, guard-results.md"); + eprintln!(); + eprintln!(" {}brute{} Blind-judge runner. Creates:", BOLD, RESET); + eprintln!(" ci.conf, protocol.md, task.md, judge.md, notes.md, verdict.md"); + eprintln!(); + eprintln!("Existing files are never overwritten."); } /// Holds loop state and cleans up on drop. @@ -124,6 +311,7 @@ impl Drop for LoopRunner { let _ = child.kill(); let _ = child.wait(); } + signal::set_child_pid(0); // Remove backup directory let _ = fs::remove_dir_all(&self.backup_dir); } @@ -150,7 +338,7 @@ fn preflight() { /// 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())); + let backup_dir = std::env::temp_dir().join(format!("yoke-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); @@ -176,7 +364,7 @@ fn restore_protected(backup_dir: &Path) { if backup_file.exists() { if let Err(e) = fs::copy(&backup_file, path) { eprintln!( - "{}{}[ci] WARNING:{} failed to restore {}: {}", + "{}{}[yoke] WARNING:{} failed to restore {}: {}", BOLD, YELLOW, RESET, path, e ); } @@ -196,31 +384,95 @@ fn is_done() -> bool { /// 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 claude_args = [ + "--verbose", + "--output-format", + "stream-json", + "--include-partial-messages", + "--dangerously-skip-permissions", + "-p", + "Read .loop/protocol.md and follow its instructions.", + ]; - 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.", - ]) + let mut cmd = if let Some(ref image) = config.image { + log(&format!( + "Launching Claude in container (iteration {})...", + iteration + )); + + let workdir = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .to_string_lossy() + .to_string(); + + let mut c = Command::new("docker"); + c.args([ + "run", + "--rm", + "-i", + "--network=host", + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW", + ]); + + // Bind-mount the workspace + c.arg("-v").arg(format!("{}:/workspace", workdir)); + c.arg("-w").arg("/workspace"); + + // Forward all CLAUDE_* and ANTHROPIC_* env vars + for (key, val) in std::env::vars() { + if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { + c.arg("-e").arg(format!("{}={}", key, val)); + } + } + + // Mount host claude config/auth directory (read-write — Claude needs to write state) + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + let claude_dir = home_path.join(".claude"); + if claude_dir.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude", + claude_dir.display() + )); + } + // Mount .claude.json for onboarding/theme/user config + let claude_json = home_path.join(".claude.json"); + if claude_json.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude.json", + claude_json.display() + )); + } + } + + c.arg(image.as_str()); + c.arg(&config.claude_bin); + c.args(claude_args); + c + } else { + log(&format!("Launching Claude (iteration {})...", iteration)); + let mut c = Command::new(&config.claude_bin); + c.args(claude_args); + c + }; + + let mut child = match cmd .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() { Ok(c) => c, Err(e) => { - log_error(&format!( - "failed to spawn '{}': {}", - config.claude_bin, e - )); + let bin = if config.image.is_some() { "docker" } else { &config.claude_bin }; + log_error(&format!("failed to spawn '{}': {}", bin, e)); return false; } }; + // Register child PID so signal handler can kill it to unblock pipe reads + signal::set_child_pid(child.id() as i32); + // Compute log path let log_path = config.log_dir.as_ref().map(|dir| { std::path::PathBuf::from(dir).join(format!("iteration-{}.jsonl", iteration)) @@ -258,6 +510,7 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo }; // Child has exited; clear it from runner + signal::set_child_pid(0); runner.child = None; log(&format!( @@ -267,6 +520,170 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo status } +/// Invoke the judge — a fresh Claude with zero implementation context. +/// Returns true if the judge's verdict is PASS. +fn invoke_judge(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool { + let judge_bin = match &config.judge { + Some(bin) => bin.clone(), + None => { + log_error("no 'judge' directive in config — cannot invoke judge"); + return false; + } + }; + + let judge_prompt = "\ +Read .loop/judge.md. It describes what to verify. +Test the feature exactly as described. You have full shell access. +When done, write your verdict to .loop/verdict.md in this exact format: + +VERDICT: PASS + + + +or + +VERDICT: FAIL + +"; + + let claude_args = [ + "--dangerously-skip-permissions", + "--output-format", + "stream-json", + "-p", + judge_prompt, + ]; + + let mut cmd = if let Some(ref image) = config.image { + log(&format!( + "Launching judge in container (iteration {})...", + iteration + )); + + let workdir = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .to_string_lossy() + .to_string(); + + let mut c = Command::new("docker"); + c.args([ + "run", + "--rm", + "-i", + "--network=host", + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW", + ]); + c.arg("-v").arg(format!("{}:/workspace", workdir)); + c.arg("-w").arg("/workspace"); + + for (key, val) in std::env::vars() { + if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") { + c.arg("-e").arg(format!("{}={}", key, val)); + } + } + + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + let claude_dir = home_path.join(".claude"); + if claude_dir.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude", + claude_dir.display() + )); + } + let claude_json = home_path.join(".claude.json"); + if claude_json.exists() { + c.arg("-v").arg(format!( + "{}:/home/node/.claude.json", + claude_json.display() + )); + } + } + + c.arg(image.as_str()); + c.arg(&judge_bin); + c.args(claude_args); + c + } else { + log(&format!("Launching judge (iteration {})...", iteration)); + let mut c = Command::new(&judge_bin); + c.args(claude_args); + c + }; + + let mut child = match cmd + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let bin = if config.image.is_some() { "docker" } else { &judge_bin }; + log_error(&format!("failed to spawn judge '{}': {}", bin, e)); + return false; + } + }; + + signal::set_child_pid(child.id() as i32); + + let log_path = config.log_dir.as_ref().map(|dir| { + std::path::PathBuf::from(dir).join(format!("judge-{}.jsonl", iteration)) + }); + + if let Some(stdout) = child.stdout.take() { + runner.child = Some(child); + stream::filter_stream(stdout, log_path.as_deref()); + if signal::interrupted() { + return false; + } + } else { + runner.child = Some(child); + } + + 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 judge process: {}", e)); + false + } + } + } else { + false + }; + + signal::set_child_pid(0); + runner.child = None; + + if !status { + log_error("Judge process exited with failure"); + return false; + } + + // Read verdict + match fs::read_to_string(VERDICT_PATH) { + Ok(content) => { + let pass = content.starts_with("VERDICT: PASS"); + if pass { + log(&format!("{}{}Judge verdict: PASS{}", GREEN, BOLD, RESET)); + } else { + log(&format!("{}Judge verdict: FAIL{}", YELLOW, RESET)); + } + pass + } + Err(e) => { + log_error(&format!("failed to read {}: {}", VERDICT_PATH, e)); + false + } + } +} + /// Run boundary check and all configured guards. /// Returns true if everything passed. fn run_all_guards(config: &Config) -> bool { @@ -331,6 +748,56 @@ fn run_all_guards(config: &Config) -> bool { all_passed } +fn init(runner: &str) -> i32 { + let loop_dir = Path::new(".loop"); + if !loop_dir.exists() { + if let Err(e) = fs::create_dir(loop_dir) { + log_error(&format!("failed to create .loop/: {}", e)); + return 1; + } + log("Created .loop/"); + } + + let files: Vec<(&str, &str)> = match runner { + "loop" => vec![ + (CONF_PATH, DEFAULT_GUARD_CONF), + (PROTOCOL_PATH, DEFAULT_PROTOCOL), + (PLAN_PATH, ""), + (NOTES_PATH, ""), + (GUARD_RESULTS_PATH, ""), + ], + "brute" => vec![ + (CONF_PATH, DEFAULT_BRUTE_CONF), + (PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL), + (TASK_PATH, ""), + (JUDGE_PATH, ""), + (NOTES_PATH, ""), + (VERDICT_PATH, ""), + ], + other => { + log_error(&format!("unknown runner type '{}'", other)); + return 2; + } + }; + + log(&format!("Initializing '{}' runner...", runner)); + + for (path, content) in &files { + let p = Path::new(path); + if p.exists() { + log(&format!("skip (exists): {}", path)); + } else { + if let Err(e) = fs::write(p, content) { + log_error(&format!("failed to write {}: {}", path, e)); + return 1; + } + log(&format!("created: {}", path)); + } + } + + 0 +} + fn run_loop(dry_run: bool) -> i32 { // Load config let config = match Config::load(Path::new(CONF_PATH)) { @@ -342,11 +809,15 @@ fn run_loop(dry_run: bool) -> i32 { }; log(&format!( - "Config loaded: claude={}, max_tail={}, {} scope rules, {} guards", + "Config loaded: claude={}, max_tail={}, {} scope rules, {} guards{}", config.claude_bin, config.max_tail, config.scope_rules.len(), - config.guards.len() + config.guards.len(), + config.image.as_ref().map_or( + String::from(", sandbox=off"), + |img| format!(", image={}", img) + ) )); // Preflight @@ -388,8 +859,9 @@ fn run_loop(dry_run: bool) -> i32 { if dry_run { log("(dry-run) Skipping Claude invocation"); - } else { - invoke_claude(&mut runner, &config, iteration); + } else if !invoke_claude(&mut runner, &config, iteration) { + log_error("Claude invocation failed — aborting loop"); + return 1; } if signal::interrupted() { @@ -436,6 +908,173 @@ fn run_loop(dry_run: bool) -> i32 { } } +/// Protected files for the brute runner. +const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH]; + +fn run_brute(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: runner=brute, claude={}, judge={}, max_tail={}, {} scope rules, {} guards{}", + config.claude_bin, + config.judge.as_deref().unwrap_or("(none)"), + config.max_tail, + config.scope_rules.len(), + config.guards.len(), + config.image.as_ref().map_or( + String::from(", sandbox=off"), + |img| format!(", image={}", img) + ) + )); + + // Preflight: require protocol.md, task.md, judge.md, ci.conf + for path in &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH] { + if !Path::new(path).exists() { + log_error(&format!("required file not found: {}", path)); + process::exit(1); + } + } + + // Create notes.md and verdict.md if missing + for path in &[NOTES_PATH, VERDICT_PATH] { + if !Path::new(path).exists() { + if let Err(e) = fs::write(path, "") { + log_error(&format!("cannot create {}: {}", path, e)); + process::exit(1); + } + } + } + + // Clear verdict + let _ = fs::write(VERDICT_PATH, ""); + + log("Preflight OK"); + + // Backup protected files + let backup_dir = std::env::temp_dir().join(format!("yoke-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 BRUTE_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); + } + } + } + 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!( + "{}{}║ Brute Iteration {:>4} ║{}", + BOLD, CYAN, iteration, RESET + ); + eprintln!( + "{}{}╚══════════════════════════════════════╝{}", + BOLD, CYAN, RESET + ); + + // Restore protected files + for path in BRUTE_PROTECTED_FILES { + let src_name = Path::new(path).file_name().unwrap(); + let backup_file = runner.backup_dir.join(src_name); + if backup_file.exists() { + if let Err(e) = fs::copy(&backup_file, path) { + eprintln!( + "{}{}[yoke] WARNING:{} failed to restore {}: {}", + BOLD, YELLOW, RESET, path, e + ); + } + } + } + + // Clear verdict + let _ = fs::write(VERDICT_PATH, ""); + + if dry_run { + log("(dry-run) Skipping Claude invocation"); + } else if !invoke_claude(&mut runner, &config, iteration) { + log_error("Claude invocation failed — aborting loop"); + return 1; + } + + 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!( + "{}Guards failed \u{2014} worker will see results next iteration{}", + YELLOW, RESET + )); + if dry_run { + log("(dry-run) Exiting after one iteration"); + return 1; + } + continue; + } + + log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET)); + + if dry_run { + log("(dry-run) Guards passed \u{2014} exiting after one iteration"); + return 0; + } + + // Guards passed — invoke judge + log("Guards passed \u{2014} invoking judge..."); + let pass = invoke_judge(&mut runner, &config, iteration); + + if signal::interrupted() { + log("Interrupted \u{2014} shutting down"); + return 130; + } + + if pass { + eprintln!(); + eprintln!( + "{}{} Judge says PASS \u{2014} brute loop complete {}", + GREEN, BOLD, RESET + ); + eprintln!(); + return 0; + } + + log(&format!( + "{}Judge says FAIL \u{2014} worker will see verdict next iteration{}", + YELLOW, RESET + )); + } +} + fn main() { signal::install(); let args: Vec = std::env::args().collect(); @@ -446,6 +1085,33 @@ fn main() { } match args[1].as_str() { + "init" => { + if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { + print_init_help(); + return; + } + let mut runner = "loop"; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--runner" => { + i += 1; + if i >= args.len() { + log_error("--runner requires a value (loop or brute)"); + process::exit(2); + } + runner = &args[i]; + } + other => { + log_error(&format!("unknown flag '{}'", other)); + print_init_help(); + process::exit(2); + } + } + i += 1; + } + process::exit(init(runner)); + } "run" => { // Check for --help before other flags if args.get(2).is_some_and(|a| a == "--help" || a == "-h") { @@ -458,7 +1124,22 @@ fn main() { print_run_help(); process::exit(2); } - process::exit(run_loop(dry_run)); + // Load config to determine runner type + let config = match Config::load(Path::new(CONF_PATH)) { + Ok(c) => c, + Err(e) => { + log_error(&e); + process::exit(1); + } + }; + match config.runner.as_str() { + "loop" => process::exit(run_loop(dry_run)), + "brute" => process::exit(run_brute(dry_run)), + other => { + log_error(&format!("unknown runner type '{}' in config", other)); + process::exit(2); + } + } } "--help" | "-h" | "help" => { print_usage(); diff --git a/src/signal.rs b/src/signal.rs index c3ea11f..5245241 100644 --- a/src/signal.rs +++ b/src/signal.rs @@ -1,17 +1,30 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; static INTERRUPTED: AtomicBool = AtomicBool::new(false); +static CHILD_PID: AtomicI32 = AtomicI32::new(0); extern "C" fn sigint_handler(_sig: i32) { INTERRUPTED.store(true, Ordering::Relaxed); + // Kill the child to close the pipe and unblock reader.lines(). + // Rust's BufReader retries on EINTR internally, so the only way + // to break out of the blocking read is to close the write end. + let pid = CHILD_PID.load(Ordering::Relaxed); + if pid > 0 { + kill(pid, 9); // SIGKILL — child already got SIGINT from the terminal + } } unsafe extern "C" { safe fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize; + safe fn kill(pid: i32, sig: i32) -> i32; } pub fn install() { - signal(2, sigint_handler); // SIGINT = 2 + signal(2, sigint_handler); +} + +pub fn set_child_pid(pid: i32) { + CHILD_PID.store(pid, Ordering::Relaxed); } pub fn interrupted() -> bool { diff --git a/src/stream.rs b/src/stream.rs index d78787a..6189bbe 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -135,17 +135,44 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { } 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); + for text_line in trimmed.lines() { + println!("{} {}{}", DIM, text_line, RESET); } } } } + // stream_event → streaming deltas + Some("stream_event") => { + if line.contains("\"content_block_delta\"") { + if line.contains("\"text_delta\"") { + if let Some(text) = extract_str(&line, "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("\"tool_use\"") { + println!(); + } + } else if line.contains("\"content_block_stop\"") { + 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);