This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-02-27 01:02:32 +07:00
parent 30bb2bdb8d
commit 8718ee3a36
7 changed files with 365 additions and 230 deletions

View file

@ -9,6 +9,7 @@ pub struct BoundaryResult {
/// Collect all changed files from git (staged, unstaged, and untracked). /// Collect all changed files from git (staged, unstaged, and untracked).
/// Returns (changed_files, deleted_files, modified_files). /// Returns (changed_files, deleted_files, modified_files).
#[allow(clippy::type_complexity)]
fn collect_changes() -> Result<(Vec<String>, Vec<String>, Vec<String>), String> { fn collect_changes() -> Result<(Vec<String>, Vec<String>, Vec<String>), String> {
let mut changed = Vec::new(); let mut changed = Vec::new();
let mut deleted = Vec::new(); let mut deleted = Vec::new();
@ -113,6 +114,13 @@ pub fn check(config: &Config) -> BoundaryResult {
let mut violations = Vec::new(); let mut violations = Vec::new();
for file in &changed { for file in &changed {
// Skip .loop/ files — they are harness infrastructure, not user code.
// The protocol requires Claude to write notes.md, and the harness
// itself writes guard-results.md and verdict.md.
if file.starts_with(".loop/") {
continue;
}
let tag = config.resolve_tag(file); let tag = config.resolve_tag(file);
match tag { match tag {

View file

@ -16,14 +16,11 @@ pub struct ScopeRule {
#[derive(Debug)] #[derive(Debug)]
pub struct Config { pub struct Config {
pub claude_bin: String,
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 scope_rules: Vec<ScopeRule>, pub scope_rules: Vec<ScopeRule>,
pub guards: Vec<String>, pub guards: Vec<String>,
pub runner: String,
pub judge: Option<String>,
} }
impl Config { impl Config {
@ -31,15 +28,11 @@ impl Config {
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))?;
let mut claude_bin = String::from("claude");
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 scope_rules = Vec::new(); let mut scope_rules = Vec::new();
let mut guards = Vec::new(); let mut guards = Vec::new();
let mut runner = String::from("loop");
let mut judge: Option<String> = None;
for (line_num, raw_line) in content.lines().enumerate() { for (line_num, raw_line) in content.lines().enumerate() {
// Strip comments // Strip comments
let line = match raw_line.find('#') { let line = match raw_line.find('#') {
@ -65,7 +58,6 @@ impl Config {
}; };
match directive { match directive {
"claude" => claude_bin = value.to_string(),
"max-tail" => { "max-tail" => {
max_tail = value.parse::<usize>().map_err(|_| { max_tail = value.parse::<usize>().map_err(|_| {
format!( format!(
@ -95,8 +87,6 @@ impl Config {
prefix: value.to_string(), prefix: value.to_string(),
}), }),
"guard" => guards.push(value.to_string()), "guard" => guards.push(value.to_string()),
"runner" => runner = value.to_string(),
"judge" => judge = Some(value.to_string()),
other => { other => {
return Err(format!( return Err(format!(
"{}:{}: unknown directive '{}'", "{}:{}: unknown directive '{}'",
@ -109,14 +99,11 @@ impl Config {
} }
Ok(Config { Ok(Config {
claude_bin,
max_tail, max_tail,
log_dir, log_dir,
image, image,
scope_rules, scope_rules,
guards, guards,
runner,
judge,
}) })
} }

View file

@ -2,6 +2,7 @@ use std::fs;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
#[allow(dead_code)]
pub struct GuardResult { pub struct GuardResult {
pub name: String, pub name: String,
pub passed: bool, pub passed: bool,

View file

@ -1,3 +1,29 @@
/// Unescape basic JSON string escape sequences into real characters.
pub fn unescape_json(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('/') => out.push('/'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
/// 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.

View file

@ -16,7 +16,6 @@ const NOTES_PATH: &str = ".loop/notes.md";
const GUARD_RESULTS_PATH: &str = ".loop/guard-results.md"; const GUARD_RESULTS_PATH: &str = ".loop/guard-results.md";
const PROTOCOL_PATH: &str = ".loop/protocol.md"; const PROTOCOL_PATH: &str = ".loop/protocol.md";
const PLAN_PATH: &str = ".loop/plan.md"; const PLAN_PATH: &str = ".loop/plan.md";
const TASK_PATH: &str = ".loop/task.md";
const JUDGE_PATH: &str = ".loop/judge.md"; const JUDGE_PATH: &str = ".loop/judge.md";
const VERDICT_PATH: &str = ".loop/verdict.md"; const VERDICT_PATH: &str = ".loop/verdict.md";
@ -84,12 +83,12 @@ You may run any commands you find useful during implementation.
- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages. - **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. - **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. - **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.
"#; "#;
const DEFAULT_GUARD_CONF: &str = "\ const DEFAULT_GUARD_CONF: &str = "\
# Guard configuration # Guard configuration
claude claude
image claude-code-sandbox:latest image claude-code-sandbox:latest
# Max lines of guard output to keep # Max lines of guard output to keep
@ -104,57 +103,80 @@ allow .
guard cargo check guard cargo check
"; ";
const DEFAULT_BRUTE_PROTOCOL: &str = r#"# Brute Protocol const DEFAULT_BRUTE_PROTOCOL: &str = r#"# Protocol: Brute + Plan Runner (Triple Loop)
You are a worker in an automated brute-force loop. Your single goal is described You are operating inside an automated triple loop — not a conversation.
in `.loop/task.md`. You iterate until a blind judge (a separate Claude instance A harness launched you and will run guards and a blind judge after you exit.
with zero implementation context) confirms the task is complete.
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 ## Files
| File | Access | Purpose | | File | Access | Purpose |
|---|---|---| |---|---|---|
| `.loop/protocol.md` | read | These instructions. | | `.loop/protocol.md` | read | These instructions. |
| `.loop/task.md` | read | The goal. | | `.loop/plan.md` | read | The feature plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this. | | `.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/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/verdict.md` | read | The judge's last verdict. | | `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/ci.conf` | read | Configuration. | | `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/guard.conf` | read | Configuration. Scope rules, guards, settings. |
## Per-Iteration All paths are relative to the repository root.
1. Read `.loop/task.md` — understand the goal. ## Per-Iteration Steps
2. Read `.loop/notes.md` — recall what you have tried.
3. Read `.loop/verdict.md` — the judge's exact complaints from last iteration. 1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
4. Make changes to accomplish the task. 2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
5. Update `.loop/notes.md` with what you changed and why. 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.
6. Exit — the harness runs guards then the judge. 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 ## Rules
- No git operations. - **No git operations.** Do not commit, push, branch, or modify git config.
- Do not modify protocol.md, task.md, judge.md, or ci.conf. - **Do not modify `protocol.md`, `plan.md`, `judge.md`, or `guard.conf`.** These are read-only.
- Study judge.md — knowing the test helps you pass it. - **One stage per iteration.** Implement a single stage, update notes, and exit.
- The judge's feedback is ground truth. Fix what they say is broken. - **Study judge.md.** Knowing the test helps you pass it.
- After 3 similar verdicts, try a fundamentally different approach. - **The judge's feedback is ground truth.** Fix what they say is broken.
- Be concise in notes. - **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.
"#; "#;
const DEFAULT_BRUTE_CONF: &str = "\ const DEFAULT_BRUTE_CONF: &str = "\
# Brute runner configuration # Brute runner configuration
runner brute image claude-code-sandbox:latest
claude claude
max-tail 200 max-tail 200
allow . allow .
# Guards (run before judge, fail-fast) # Guards (run after each plan stage, fail-fast)
guard cargo check 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. /// Files that Claude must not be allowed to permanently alter.
@ -190,19 +212,19 @@ fn print_usage() {
eprintln!(); eprintln!();
eprintln!("{}COMMANDS:{}", BOLD, RESET); eprintln!("{}COMMANDS:{}", BOLD, RESET);
eprintln!( eprintln!(
" {}init{} Initialize .loop/ directory with default files", " {}init{} Initialize .loop/ for staged plan loop (default)",
BOLD, RESET BOLD, RESET
); );
eprintln!( eprintln!(
" {}init --runner <type>{} Initialize for a specific runner (loop or brute)", " {}init brute{} Initialize .loop/ for brute mode (plan stages + judge)",
BOLD, RESET BOLD, RESET
); );
eprintln!( eprintln!(
" {}run{} Launch the CI loop (invoke Claude, run guards, iterate)", " {}run{} Launch the loop (invoke Claude, run guards, iterate)",
BOLD, RESET BOLD, RESET
); );
eprintln!( eprintln!(
" {}run --dry-run{} Single iteration: boundary check + guards only, no Claude", " {}run --dry-run{} Single iteration: boundary check + guards only, no Claude",
BOLD, RESET BOLD, RESET
); );
eprintln!(); eprintln!();
@ -221,7 +243,7 @@ fn print_usage() {
fn print_run_help() { fn print_run_help() {
eprintln!( eprintln!(
"{}{}yoke run{} — execute the CI loop{}", "{}{}yoke run{} — execute the loop{}",
BOLD, CYAN, RESET, RESET BOLD, CYAN, RESET, RESET
); );
eprintln!(); eprintln!();
@ -231,13 +253,14 @@ fn print_run_help() {
); );
eprintln!(); eprintln!();
eprintln!("{}OPTIONS:{}", BOLD, RESET); eprintln!("{}OPTIONS:{}", BOLD, RESET);
eprintln!(" --dry-run Run one iteration without invoking Claude"); eprintln!(" --dry-run Run one iteration without invoking Claude");
eprintln!(" (boundary check + configured guards only)"); eprintln!(" (boundary check + configured guards only)");
eprintln!(" --no-sandbox Allow running without a container image");
eprintln!(); eprintln!();
eprintln!("{}RUNNER TYPES:{}", BOLD, RESET); eprintln!("{}MODES:{}", BOLD, RESET);
eprintln!(" The runner type is determined by the 'runner' directive in {}.", CONF_PATH); eprintln!(" Detected automatically from .loop/ contents.");
eprintln!(" {}loop{} (default) Staged plan runner — iterates until STATUS: DONE and guards pass.", BOLD, RESET); eprintln!(" {}loop{} (default) Staged plan loop — iterates until STATUS: DONE and guards pass.", BOLD, RESET);
eprintln!(" {}brute{} Blind-judge runner — iterates until an independent judge says PASS.", BOLD, RESET); eprintln!(" {}brute{} Plan stages + judge — detected when {} exists.", BOLD, RESET, JUDGE_PATH);
eprintln!(); eprintln!();
eprintln!("{}WORKFLOW (loop):{}", BOLD, RESET); eprintln!("{}WORKFLOW (loop):{}", BOLD, RESET);
eprintln!(" 1. Load config from {}", CONF_PATH); eprintln!(" 1. Load config from {}", CONF_PATH);
@ -251,12 +274,12 @@ fn print_run_help() {
eprintln!(); eprintln!();
eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET); eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET);
eprintln!(" 1. Load config from {}", CONF_PATH); eprintln!(" 1. Load config from {}", CONF_PATH);
eprintln!(" 2. Backup protected files (protocol.md, task.md, judge.md, ci.conf)"); eprintln!(" 2. Backup protected files (protocol.md, plan.md, task.md, judge.md, guard.conf)");
eprintln!(" 3. Per iteration:"); eprintln!(" 3. Per brute attempt:");
eprintln!(" a. Restore protected files, clear verdict"); eprintln!(" a. Restore protected files, clear verdict");
eprintln!(" b. Invoke worker Claude"); eprintln!(" b. Run plan loop (stages + guards until DONE)");
eprintln!(" c. Run diff boundary check + configured guards"); eprintln!(" c. Invoke judge (fresh Claude, zero context)");
eprintln!(" d. If guards pass, invoke judge (fresh Claude, zero context)"); eprintln!(" d. If judge FAILs, reset STATUS and retry");
eprintln!(" 4. Exit when judge returns VERDICT: PASS"); eprintln!(" 4. Exit when judge returns VERDICT: PASS");
} }
@ -267,19 +290,17 @@ fn print_init_help() {
); );
eprintln!(); eprintln!();
eprintln!( eprintln!(
"{}USAGE:{} yoke init [--runner <type>]", "{}USAGE:{} yoke init [brute]",
BOLD, RESET BOLD, RESET
); );
eprintln!(); eprintln!();
eprintln!("{}OPTIONS:{}", BOLD, RESET); eprintln!("{}MODES:{}", BOLD, RESET);
eprintln!(" --runner <type> Runner type: loop (default) or brute"); eprintln!(" {}(default){} Staged plan loop. Creates:", BOLD, RESET);
eprintln!(" guard.conf, protocol.md, plan.md, notes.md, guard-results.md");
eprintln!(); eprintln!();
eprintln!("{}RUNNER TYPES:{}", BOLD, RESET); eprintln!(" {}brute{} Plan stages + judge. Creates:", BOLD, RESET);
eprintln!(" {}loop{} (default) Staged plan runner. Creates:", BOLD, RESET); eprintln!(" guard.conf, protocol.md, plan.md, task.md, judge.md,");
eprintln!(" ci.conf, protocol.md, plan.md, notes.md, guard-results.md"); eprintln!(" notes.md, verdict.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!();
eprintln!("Existing files are never overwritten."); eprintln!("Existing files are never overwritten.");
} }
@ -326,11 +347,11 @@ fn preflight() {
} }
} }
// Ensure notes file exists // Ensure notes file exists
if !Path::new(NOTES_PATH).exists() { if !Path::new(NOTES_PATH).exists()
if let Err(e) = fs::write(NOTES_PATH, "") { && let Err(e) = fs::write(NOTES_PATH, "")
log_error(&format!("cannot create {}: {}", NOTES_PATH, e)); {
process::exit(1); log_error(&format!("cannot create {}: {}", NOTES_PATH, e));
} process::exit(1);
} }
// Clear guard results // Clear guard results
let _ = fs::write(GUARD_RESULTS_PATH, ""); let _ = fs::write(GUARD_RESULTS_PATH, "");
@ -361,13 +382,13 @@ fn restore_protected(backup_dir: &Path) {
for path in PROTECTED_FILES { for path in PROTECTED_FILES {
let src_name = Path::new(path).file_name().unwrap(); let src_name = Path::new(path).file_name().unwrap();
let backup_file = backup_dir.join(src_name); let backup_file = backup_dir.join(src_name);
if backup_file.exists() { if backup_file.exists()
if let Err(e) = fs::copy(&backup_file, path) { && let Err(e) = fs::copy(&backup_file, path)
eprintln!( {
"{}{}[yoke] WARNING:{} failed to restore {}: {}", eprintln!(
BOLD, YELLOW, RESET, path, e "{}{}[yoke] WARNING:{} failed to restore {}: {}",
); BOLD, YELLOW, RESET, path, e
} );
} }
} }
} }
@ -380,6 +401,30 @@ fn is_done() -> bool {
} }
} }
/// Reset the STATUS line in notes.md back to IN_PROGRESS, preserving all other content.
fn reset_notes_status() {
match fs::read_to_string(NOTES_PATH) {
Ok(content) => {
let new_content = if content.starts_with("STATUS: DONE") {
content.replacen("STATUS: DONE", "STATUS: IN_PROGRESS", 1)
} else if content.starts_with("STATUS: IN_PROGRESS") {
content // already in progress
} else {
// No STATUS line at top — prepend one
format!("STATUS: IN_PROGRESS\n{}", content)
};
if let Err(e) = fs::write(NOTES_PATH, new_content) {
log_error(&format!("failed to reset STATUS in {}: {}", NOTES_PATH, e));
} else {
log("Reset STATUS to IN_PROGRESS for next brute attempt");
}
}
Err(e) => {
log_error(&format!("failed to read {}: {}", NOTES_PATH, e));
}
}
}
/// Invoke Claude, piping stdout through the stream filter. /// Invoke Claude, piping stdout through the stream filter.
/// The Child is stored in `runner` for cleanup-on-drop safety. /// The Child is stored in `runner` for cleanup-on-drop safety.
/// Returns the child's exit status success. /// Returns the child's exit status success.
@ -409,7 +454,6 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo
c.args([ c.args([
"run", "run",
"--rm", "--rm",
"-i",
"--network=host", "--network=host",
"--cap-add=NET_ADMIN", "--cap-add=NET_ADMIN",
"--cap-add=NET_RAW", "--cap-add=NET_RAW",
@ -447,24 +491,25 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo
} }
c.arg(image.as_str()); c.arg(image.as_str());
c.arg(&config.claude_bin); c.arg("claude");
c.args(claude_args); c.args(claude_args);
c c
} else { } else {
log(&format!("Launching Claude (iteration {})...", iteration)); log(&format!("Launching Claude (iteration {})...", iteration));
let mut c = Command::new(&config.claude_bin); let mut c = Command::new("claude");
c.args(claude_args); c.args(claude_args);
c c
}; };
let mut child = match cmd let mut child = match cmd
.stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.spawn() .spawn()
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
let bin = if config.image.is_some() { "docker" } else { &config.claude_bin }; let bin = if config.image.is_some() { "docker" } else { "claude" };
log_error(&format!("failed to spawn '{}': {}", bin, e)); log_error(&format!("failed to spawn '{}': {}", bin, e));
return false; return false;
} }
@ -523,14 +568,6 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo
/// Invoke the judge — a fresh Claude with zero implementation context. /// Invoke the judge — a fresh Claude with zero implementation context.
/// Returns true if the judge's verdict is PASS. /// Returns true if the judge's verdict is PASS.
fn invoke_judge(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool { 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 = "\ let judge_prompt = "\
Read .loop/judge.md. It describes what to verify. Read .loop/judge.md. It describes what to verify.
Test the feature exactly as described. You have full shell access. Test the feature exactly as described. You have full shell access.
@ -547,9 +584,10 @@ VERDICT: FAIL
<what you tested, what went wrong, and what the correct behavior should be>"; <what you tested, what went wrong, and what the correct behavior should be>";
let claude_args = [ let claude_args = [
"--dangerously-skip-permissions", "--verbose",
"--output-format", "--output-format",
"stream-json", "stream-json",
"--dangerously-skip-permissions",
"-p", "-p",
judge_prompt, judge_prompt,
]; ];
@ -569,7 +607,6 @@ VERDICT: FAIL
c.args([ c.args([
"run", "run",
"--rm", "--rm",
"-i",
"--network=host", "--network=host",
"--cap-add=NET_ADMIN", "--cap-add=NET_ADMIN",
"--cap-add=NET_RAW", "--cap-add=NET_RAW",
@ -602,24 +639,25 @@ VERDICT: FAIL
} }
c.arg(image.as_str()); c.arg(image.as_str());
c.arg(&judge_bin); c.arg("claude");
c.args(claude_args); c.args(claude_args);
c c
} else { } else {
log(&format!("Launching judge (iteration {})...", iteration)); log(&format!("Launching judge (iteration {})...", iteration));
let mut c = Command::new(&judge_bin); let mut c = Command::new("claude");
c.args(claude_args); c.args(claude_args);
c c
}; };
let mut child = match cmd let mut child = match cmd
.stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.spawn() .spawn()
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
let bin = if config.image.is_some() { "docker" } else { &judge_bin }; let bin = if config.image.is_some() { "docker" } else { "claude" };
log_error(&format!("failed to spawn judge '{}': {}", bin, e)); log_error(&format!("failed to spawn judge '{}': {}", bin, e));
return false; return false;
} }
@ -748,7 +786,7 @@ fn run_all_guards(config: &Config) -> bool {
all_passed all_passed
} }
fn init(runner: &str) -> i32 { fn init(mode: &str) -> i32 {
let loop_dir = Path::new(".loop"); let loop_dir = Path::new(".loop");
if !loop_dir.exists() { if !loop_dir.exists() {
if let Err(e) = fs::create_dir(loop_dir) { if let Err(e) = fs::create_dir(loop_dir) {
@ -758,7 +796,7 @@ fn init(runner: &str) -> i32 {
log("Created .loop/"); log("Created .loop/");
} }
let files: Vec<(&str, &str)> = match runner { let files: Vec<(&str, &str)> = match mode {
"loop" => vec![ "loop" => vec![
(CONF_PATH, DEFAULT_GUARD_CONF), (CONF_PATH, DEFAULT_GUARD_CONF),
(PROTOCOL_PATH, DEFAULT_PROTOCOL), (PROTOCOL_PATH, DEFAULT_PROTOCOL),
@ -769,18 +807,19 @@ fn init(runner: &str) -> i32 {
"brute" => vec![ "brute" => vec![
(CONF_PATH, DEFAULT_BRUTE_CONF), (CONF_PATH, DEFAULT_BRUTE_CONF),
(PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL), (PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL),
(TASK_PATH, ""), (PLAN_PATH, ""),
(JUDGE_PATH, ""), (JUDGE_PATH, ""),
(NOTES_PATH, ""), (NOTES_PATH, ""),
(VERDICT_PATH, ""), (VERDICT_PATH, ""),
(GUARD_RESULTS_PATH, ""),
], ],
other => { other => {
log_error(&format!("unknown runner type '{}'", other)); log_error(&format!("unknown mode '{}'", other));
return 2; return 2;
} }
}; };
log(&format!("Initializing '{}' runner...", runner)); log(&format!("Initializing '{}' mode...", mode));
for (path, content) in &files { for (path, content) in &files {
let p = Path::new(path); let p = Path::new(path);
@ -798,28 +837,14 @@ fn init(runner: &str) -> i32 {
0 0
} }
fn run_loop(dry_run: bool) -> i32 { /// Core plan-loop runner that can be called standalone or nested inside brute.
// Load config ///
let config = match Config::load(Path::new(CONF_PATH)) { /// - `config`: already-loaded Config
Ok(c) => c, /// - `dry_run`: if true, skip Claude invocation (one iteration only)
Err(e) => { /// - `nested`: if true, running inside brute (adjusts output banners)
log_error(&e); ///
process::exit(1); /// Returns Ok(()) on success (STATUS: DONE + guards pass), Err(i32) with exit code on failure.
} fn run_plan_loop(config: &Config, dry_run: bool, nested: bool) -> Result<(), i32> {
};
log(&format!(
"Config loaded: claude={}, max_tail={}, {} scope rules, {} guards{}",
config.claude_bin,
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 // Preflight
preflight(); preflight();
log("Preflight OK"); log("Preflight OK");
@ -834,22 +859,37 @@ fn run_loop(dry_run: bool) -> i32 {
loop { loop {
if signal::interrupted() { if signal::interrupted() {
log("Interrupted \u{2014} shutting down"); log("Interrupted \u{2014} shutting down");
return 130; return Err(130);
} }
iteration += 1; iteration += 1;
eprintln!(); eprintln!();
eprintln!( if nested {
"{}{}╔══════════════════════════════════════╗{}", eprintln!(
BOLD, CYAN, RESET "{}{}┌──────────────────────────────────────┐{}",
); BOLD, CYAN, RESET
eprintln!( );
"{}{}║ Iteration {:>4} ║{}", eprintln!(
BOLD, CYAN, iteration, RESET "{}{}│ Plan Iteration {:>4} │{}",
); BOLD, CYAN, iteration, RESET
eprintln!( );
"{}{}╚══════════════════════════════════════╝{}", eprintln!(
BOLD, CYAN, RESET "{}{}└──────────────────────────────────────┘{}",
); BOLD, CYAN, RESET
);
} else {
eprintln!(
"{}{}╔══════════════════════════════════════╗{}",
BOLD, CYAN, RESET
);
eprintln!(
"{}{}║ Iteration {:>4} ║{}",
BOLD, CYAN, iteration, RESET
);
eprintln!(
"{}{}╚══════════════════════════════════════╝{}",
BOLD, CYAN, RESET
);
}
// Restore protected files // Restore protected files
restore_protected(&runner.backup_dir); restore_protected(&runner.backup_dir);
@ -859,18 +899,18 @@ fn run_loop(dry_run: bool) -> i32 {
if dry_run { if dry_run {
log("(dry-run) Skipping Claude invocation"); log("(dry-run) Skipping Claude invocation");
} else if !invoke_claude(&mut runner, &config, iteration) { } else if !invoke_claude(&mut runner, config, iteration) {
log_error("Claude invocation failed — aborting loop"); log_error("Claude invocation failed — aborting loop");
return 1; return Err(1);
} }
if signal::interrupted() { if signal::interrupted() {
log("Interrupted \u{2014} shutting down"); log("Interrupted \u{2014} shutting down");
return 130; return Err(130);
} }
// Run guards // Run guards
let guards_passed = run_all_guards(&config); let guards_passed = run_all_guards(config);
if guards_passed { if guards_passed {
log(&format!( log(&format!(
@ -880,7 +920,7 @@ fn run_loop(dry_run: bool) -> i32 {
if dry_run { if dry_run {
log("(dry-run) Guards passed \u{2014} exiting after one iteration"); log("(dry-run) Guards passed \u{2014} exiting after one iteration");
return 0; return Ok(());
} }
if is_done() { if is_done() {
@ -890,7 +930,7 @@ fn run_loop(dry_run: bool) -> i32 {
GREEN, BOLD, RESET GREEN, BOLD, RESET
); );
eprintln!(); eprintln!();
return 0; return Ok(());
} else { } else {
log("Guards passed but STATUS is not DONE \u{2014} continuing"); log("Guards passed but STATUS is not DONE \u{2014} continuing");
} }
@ -902,14 +942,41 @@ fn run_loop(dry_run: bool) -> i32 {
if dry_run { if dry_run {
log("(dry-run) Exiting after one iteration"); log("(dry-run) Exiting after one iteration");
return 1; return Err(1);
} }
} }
} }
} }
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: max_tail={}, {} scope rules, {} guards{}",
config.max_tail,
config.scope_rules.len(),
config.guards.len(),
config.image.as_ref().map_or(
String::from(", sandbox=off"),
|img| format!(", image={}", img)
)
));
match run_plan_loop(&config, dry_run, false) {
Ok(()) => 0,
Err(code) => code,
}
}
/// Protected files for the brute runner. /// Protected files for the brute runner.
const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH]; const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH];
fn run_brute(dry_run: bool) -> i32 { fn run_brute(dry_run: bool) -> i32 {
// Load config // Load config
@ -922,9 +989,7 @@ fn run_brute(dry_run: bool) -> i32 {
}; };
log(&format!( log(&format!(
"Config loaded: runner=brute, claude={}, judge={}, max_tail={}, {} scope rules, {} guards{}", "Config loaded: mode=brute, max_tail={}, {} scope rules, {} guards{}",
config.claude_bin,
config.judge.as_deref().unwrap_or("(none)"),
config.max_tail, config.max_tail,
config.scope_rules.len(), config.scope_rules.len(),
config.guards.len(), config.guards.len(),
@ -934,21 +999,32 @@ fn run_brute(dry_run: bool) -> i32 {
) )
)); ));
// Preflight: require protocol.md, task.md, judge.md, ci.conf // Preflight: require protocol.md, judge.md, guard.conf
for path in &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH] { for path in &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH] {
if !Path::new(path).exists() { if !Path::new(path).exists() {
log_error(&format!("required file not found: {}", path)); log_error(&format!("required file not found: {}", path));
process::exit(1); process::exit(1);
} }
} }
// Create notes.md and verdict.md if missing // Fail fast if critical content files are empty
for path in &[NOTES_PATH, VERDICT_PATH] { for path in &[JUDGE_PATH] {
if !Path::new(path).exists() { match fs::read_to_string(path) {
if let Err(e) = fs::write(path, "") { Ok(content) if content.trim().is_empty() => {
log_error(&format!("cannot create {}: {}", path, e)); log_error(&format!("{} is empty — fill it in before running", path));
process::exit(1); process::exit(1);
} }
_ => {}
}
}
// Create notes.md and verdict.md if missing
for path in &[NOTES_PATH, VERDICT_PATH] {
if !Path::new(path).exists()
&& let Err(e) = fs::write(path, "")
{
log_error(&format!("cannot create {}: {}", path, e));
process::exit(1);
} }
} }
@ -1002,13 +1078,13 @@ fn run_brute(dry_run: bool) -> i32 {
for path in BRUTE_PROTECTED_FILES { for path in BRUTE_PROTECTED_FILES {
let src_name = Path::new(path).file_name().unwrap(); let src_name = Path::new(path).file_name().unwrap();
let backup_file = runner.backup_dir.join(src_name); let backup_file = runner.backup_dir.join(src_name);
if backup_file.exists() { if backup_file.exists()
if let Err(e) = fs::copy(&backup_file, path) { && let Err(e) = fs::copy(&backup_file, path)
eprintln!( {
"{}{}[yoke] WARNING:{} failed to restore {}: {}", eprintln!(
BOLD, YELLOW, RESET, path, e "{}{}[yoke] WARNING:{} failed to restore {}: {}",
); BOLD, YELLOW, RESET, path, e
} );
} }
} }
@ -1016,10 +1092,20 @@ fn run_brute(dry_run: bool) -> i32 {
let _ = fs::write(VERDICT_PATH, ""); let _ = fs::write(VERDICT_PATH, "");
if dry_run { if dry_run {
log("(dry-run) Skipping Claude invocation"); log("(dry-run) Skipping worker invocation");
} else if !invoke_claude(&mut runner, &config, iteration) { } else {
log_error("Claude invocation failed — aborting loop"); log("Using plan runner as worker...");
return 1; match run_plan_loop(&config, false, true) {
Ok(()) => log("Plan runner completed successfully"),
Err(130) => {
log("Interrupted \u{2014} shutting down");
return 130;
}
Err(_) => {
log_error("Plan runner failed — aborting brute loop");
return 1;
}
}
} }
if signal::interrupted() { if signal::interrupted() {
@ -1027,30 +1113,35 @@ fn run_brute(dry_run: bool) -> i32 {
return 130; return 130;
} }
// Run guards // Run guards (skip when not dry-run — the plan loop already ran them)
let guards_passed = run_all_guards(&config); if dry_run {
let passed = run_all_guards(&config);
if !guards_passed { if !passed {
log(&format!( log(&format!(
"{}Guards failed \u{2014} worker will see results next iteration{}", "{}Guards failed{}", YELLOW, RESET
YELLOW, RESET ));
));
if dry_run {
log("(dry-run) Exiting after one iteration"); log("(dry-run) Exiting after one iteration");
return 1; return 1;
} }
continue; log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET));
}
log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET));
if dry_run {
log("(dry-run) Guards passed \u{2014} exiting after one iteration"); log("(dry-run) Guards passed \u{2014} exiting after one iteration");
return 0; return 0;
} }
// Guards passed — invoke judge // Guards passed — invoke judge
log("Guards passed \u{2014} invoking judge..."); eprintln!();
eprintln!(
"{}{}┌─ Judge ────────────────────────────────┐{}",
BOLD, YELLOW, RESET
);
eprintln!(
"{}{}│ Invoking judge (attempt {:>4}) │{}",
BOLD, YELLOW, iteration, RESET
);
eprintln!(
"{}{}└────────────────────────────────────────┘{}",
BOLD, YELLOW, RESET
);
let pass = invoke_judge(&mut runner, &config, iteration); let pass = invoke_judge(&mut runner, &config, iteration);
if signal::interrupted() { if signal::interrupted() {
@ -1072,6 +1163,10 @@ fn run_brute(dry_run: bool) -> i32 {
"{}Judge says FAIL \u{2014} worker will see verdict next iteration{}", "{}Judge says FAIL \u{2014} worker will see verdict next iteration{}",
YELLOW, RESET YELLOW, RESET
)); ));
// Reset STATUS to IN_PROGRESS so the plan runner re-executes on the
// next brute attempt, but preserve all notes.
reset_notes_status();
} }
} }
@ -1090,27 +1185,21 @@ fn main() {
print_init_help(); print_init_help();
return; return;
} }
let mut runner = "loop"; let mode = match args.get(2).map(|a| a.as_str()) {
let mut i = 2; None => "loop",
while i < args.len() { Some("brute") => "brute",
match args[i].as_str() { Some(other) => {
"--runner" => { log_error(&format!("unknown argument '{}'", other));
i += 1; print_init_help();
if i >= args.len() { process::exit(2);
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; };
if args.len() > 3 {
log_error(&format!("unexpected argument '{}'", args[3]));
print_init_help();
process::exit(2);
} }
process::exit(init(runner)); process::exit(init(mode));
} }
"run" => { "run" => {
// Check for --help before other flags // Check for --help before other flags
@ -1118,11 +1207,18 @@ fn main() {
print_run_help(); print_run_help();
return; return;
} }
let dry_run = args.get(2).is_some_and(|a| a == "--dry-run"); let mut dry_run = false;
if args.len() > 2 && !dry_run { let mut no_sandbox = false;
log_error(&format!("unknown flag '{}'", args[2])); for arg in &args[2..] {
print_run_help(); match arg.as_str() {
process::exit(2); "--dry-run" => dry_run = true,
"--no-sandbox" => no_sandbox = true,
other => {
log_error(&format!("unknown flag '{}'", other));
print_run_help();
process::exit(2);
}
}
} }
// Load config to determine runner type // Load config to determine runner type
let config = match Config::load(Path::new(CONF_PATH)) { let config = match Config::load(Path::new(CONF_PATH)) {
@ -1132,13 +1228,16 @@ fn main() {
process::exit(1); process::exit(1);
} }
}; };
match config.runner.as_str() { if config.image.is_none() && !no_sandbox {
"loop" => process::exit(run_loop(dry_run)), log_error("no 'image' directive in config — refusing to run without sandbox");
"brute" => process::exit(run_brute(dry_run)), eprintln!(" Add 'image <name>' to {} or pass --no-sandbox to override.", CONF_PATH);
other => { process::exit(2);
log_error(&format!("unknown runner type '{}' in config", other)); }
process::exit(2); let is_brute = Path::new(JUDGE_PATH).exists();
} if is_brute {
process::exit(run_brute(dry_run))
} else {
process::exit(run_loop(dry_run))
} }
} }
"--help" | "-h" | "help" => { "--help" | "-h" | "help" => {

View file

@ -4,7 +4,11 @@ static INTERRUPTED: AtomicBool = AtomicBool::new(false);
static CHILD_PID: AtomicI32 = AtomicI32::new(0); static CHILD_PID: AtomicI32 = AtomicI32::new(0);
extern "C" fn sigint_handler(_sig: i32) { extern "C" fn sigint_handler(_sig: i32) {
INTERRUPTED.store(true, Ordering::Relaxed); if INTERRUPTED.swap(true, Ordering::Relaxed) {
// Second Ctrl+C — force exit immediately.
// _exit is async-signal-safe (unlike std::process::exit).
_exit(130);
}
// Kill the child to close the pipe and unblock reader.lines(). // Kill the child to close the pipe and unblock reader.lines().
// Rust's BufReader retries on EINTR internally, so the only way // Rust's BufReader retries on EINTR internally, so the only way
// to break out of the blocking read is to close the write end. // to break out of the blocking read is to close the write end.
@ -17,6 +21,7 @@ extern "C" fn sigint_handler(_sig: i32) {
unsafe extern "C" { unsafe extern "C" {
safe fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize; safe fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize;
safe fn kill(pid: i32, sig: i32) -> i32; safe fn kill(pid: i32, sig: i32) -> i32;
safe fn _exit(status: i32) -> !;
} }
pub fn install() { pub fn install() {

View file

@ -2,7 +2,7 @@ use std::io::{BufRead, BufReader, Write};
use std::path::Path; use std::path::Path;
use std::process::ChildStdout; use std::process::ChildStdout;
use crate::json::{extract_num, extract_str}; use crate::json::{extract_num, extract_str, unescape_json};
// ANSI escape codes // ANSI escape codes
const RESET: &str = "\x1b[0m"; const RESET: &str = "\x1b[0m";
@ -16,6 +16,7 @@ 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,
} }
impl StreamState { impl StreamState {
@ -24,6 +25,7 @@ impl StreamState {
turn_num: 0, turn_num: 0,
current_msg_id: None, current_msg_id: None,
seen_init: false, seen_init: false,
in_thinking: false,
} }
} }
} }
@ -127,36 +129,43 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) {
); );
} }
} }
// assistant → probe content for tool_use or text // assistant → only tool_use summaries (text already shown via stream_event deltas)
Some("assistant") => { Some("assistant") => {
if line.contains("\"tool_use\"") { if line.contains("\"tool_use\"") {
let desc = format_tool_call(&line); let desc = format_tool_call(&line);
println!("{}{}>>{} {}{}", YELLOW, BOLD, RESET, desc, RESET); 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() {
println!("{} {}{}", DIM, text_line, RESET);
}
}
} }
} }
// stream_event → streaming deltas // stream_event → streaming deltas
Some("stream_event") => { Some("stream_event") => {
if line.contains("\"content_block_delta\"") { if line.contains("\"content_block_delta\"") {
if line.contains("\"text_delta\"") { if line.contains("\"thinking_delta\"") {
if let Some(text) = extract_str(&line, "text") { // Show activity during extended thinking
print!("{}{}{}", DIM, text, RESET); print!("{}·{}", DIM, RESET);
std::io::stdout().flush().ok(); 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 // input_json_delta → skip silently
} else if line.contains("\"content_block_start\"") { } else if line.contains("\"content_block_start\"") {
if !line.contains("\"tool_use\"") { 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!(); println!();
} }
} else if line.contains("\"content_block_stop\"") { } else if line.contains("\"content_block_stop\"") {
println!(); if state.in_thinking {
println!();
state.in_thinking = false;
} else {
println!();
}
} }
// message_start, message_delta, message_stop → skip // message_start, message_delta, message_stop → skip
} }