update
This commit is contained in:
parent
30bb2bdb8d
commit
8718ee3a36
7 changed files with 365 additions and 230 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
26
src/json.rs
26
src/json.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
499
src/main.rs
499
src/main.rs
|
|
@ -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" => {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue