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).
|
||||
/// Returns (changed_files, deleted_files, modified_files).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn collect_changes() -> Result<(Vec<String>, Vec<String>, Vec<String>), String> {
|
||||
let mut changed = Vec::new();
|
||||
let mut deleted = Vec::new();
|
||||
|
|
@ -113,6 +114,13 @@ pub fn check(config: &Config) -> BoundaryResult {
|
|||
let mut violations = Vec::new();
|
||||
|
||||
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);
|
||||
|
||||
match tag {
|
||||
|
|
|
|||
|
|
@ -16,14 +16,11 @@ pub struct ScopeRule {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub claude_bin: String,
|
||||
pub max_tail: usize,
|
||||
pub log_dir: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub scope_rules: Vec<ScopeRule>,
|
||||
pub guards: Vec<String>,
|
||||
pub runner: String,
|
||||
pub judge: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -31,15 +28,11 @@ impl Config {
|
|||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
|
||||
|
||||
let mut claude_bin = String::from("claude");
|
||||
let mut max_tail: usize = 200;
|
||||
let mut log_dir: Option<String> = None;
|
||||
let mut image: Option<String> = None;
|
||||
let mut scope_rules = 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() {
|
||||
// Strip comments
|
||||
let line = match raw_line.find('#') {
|
||||
|
|
@ -65,7 +58,6 @@ impl Config {
|
|||
};
|
||||
|
||||
match directive {
|
||||
"claude" => claude_bin = value.to_string(),
|
||||
"max-tail" => {
|
||||
max_tail = value.parse::<usize>().map_err(|_| {
|
||||
format!(
|
||||
|
|
@ -95,8 +87,6 @@ impl Config {
|
|||
prefix: value.to_string(),
|
||||
}),
|
||||
"guard" => guards.push(value.to_string()),
|
||||
"runner" => runner = value.to_string(),
|
||||
"judge" => judge = Some(value.to_string()),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"{}:{}: unknown directive '{}'",
|
||||
|
|
@ -109,14 +99,11 @@ impl Config {
|
|||
}
|
||||
|
||||
Ok(Config {
|
||||
claude_bin,
|
||||
max_tail,
|
||||
log_dir,
|
||||
image,
|
||||
scope_rules,
|
||||
guards,
|
||||
runner,
|
||||
judge,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::fs;
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct GuardResult {
|
||||
pub name: String,
|
||||
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.
|
||||
/// 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.
|
||||
|
|
|
|||
431
src/main.rs
431
src/main.rs
|
|
@ -16,7 +16,6 @@ const NOTES_PATH: &str = ".loop/notes.md";
|
|||
const GUARD_RESULTS_PATH: &str = ".loop/guard-results.md";
|
||||
const PROTOCOL_PATH: &str = ".loop/protocol.md";
|
||||
const PLAN_PATH: &str = ".loop/plan.md";
|
||||
const TASK_PATH: &str = ".loop/task.md";
|
||||
const JUDGE_PATH: &str = ".loop/judge.md";
|
||||
const VERDICT_PATH: &str = ".loop/verdict.md";
|
||||
|
||||
|
|
@ -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.
|
||||
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix.
|
||||
- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next.
|
||||
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.
|
||||
"#;
|
||||
|
||||
const DEFAULT_GUARD_CONF: &str = "\
|
||||
# Guard configuration
|
||||
|
||||
claude claude
|
||||
image claude-code-sandbox:latest
|
||||
|
||||
# Max lines of guard output to keep
|
||||
|
|
@ -104,57 +103,80 @@ allow .
|
|||
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
|
||||
in `.loop/task.md`. You iterate until a blind judge (a separate Claude instance
|
||||
with zero implementation context) confirms the task is complete.
|
||||
You are operating inside an automated triple loop — not a conversation.
|
||||
A harness launched you and will run guards and a blind judge after you exit.
|
||||
|
||||
The outer brute loop retries until a judge says PASS.
|
||||
Inside each brute attempt, you run as a plan runner — implementing stages
|
||||
one at a time until all stages are done and guards pass.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Access | Purpose |
|
||||
|---|---|---|
|
||||
| `.loop/protocol.md` | read | These instructions. |
|
||||
| `.loop/task.md` | read | The goal. |
|
||||
| `.loop/judge.md` | read | What the judge will test. Study this. |
|
||||
| `.loop/plan.md` | read | The feature plan with stages to implement. |
|
||||
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
|
||||
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
|
||||
| `.loop/verdict.md` | read | The judge's last verdict. |
|
||||
| `.loop/ci.conf` | read | Configuration. |
|
||||
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
|
||||
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
|
||||
| `.loop/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.
|
||||
2. Read `.loop/notes.md` — recall what you have tried.
|
||||
3. Read `.loop/verdict.md` — the judge's exact complaints from last iteration.
|
||||
4. Make changes to accomplish the task.
|
||||
5. Update `.loop/notes.md` with what you changed and why.
|
||||
6. Exit — the harness runs guards then the judge.
|
||||
## Per-Iteration Steps
|
||||
|
||||
1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
|
||||
2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
|
||||
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
|
||||
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
|
||||
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
|
||||
6. **Implement**. Make the code changes for exactly one stage.
|
||||
7. **Update notes**. Write to `.loop/notes.md`:
|
||||
- Which stage you just worked on
|
||||
- What you changed and why
|
||||
- Any issues or observations for your future self
|
||||
- A `STATUS` line at the **top** of the file (see below)
|
||||
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
|
||||
|
||||
## STATUS Signaling
|
||||
|
||||
The first line of `.loop/notes.md` must be one of:
|
||||
|
||||
- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures).
|
||||
- `STATUS: DONE` — All stages are implemented and you believe guards will pass.
|
||||
|
||||
## What Happens After You Exit
|
||||
|
||||
1. Guards run (diff boundary check + configured guard commands).
|
||||
2. If guards pass and STATUS is DONE, the plan loop ends.
|
||||
3. Then the judge (a fresh Claude with zero implementation context) verifies the feature.
|
||||
4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback.
|
||||
|
||||
## Rules
|
||||
|
||||
- No git operations.
|
||||
- Do not modify protocol.md, task.md, judge.md, or ci.conf.
|
||||
- Study judge.md — knowing the test helps you pass it.
|
||||
- The judge's feedback is ground truth. Fix what they say is broken.
|
||||
- After 3 similar verdicts, try a fundamentally different approach.
|
||||
- Be concise in notes.
|
||||
- **No git operations.** Do not commit, push, branch, or modify git config.
|
||||
- **Do not modify `protocol.md`, `plan.md`, `judge.md`, or `guard.conf`.** These are read-only.
|
||||
- **One stage per iteration.** Implement a single stage, update notes, and exit.
|
||||
- **Study judge.md.** Knowing the test helps you pass it.
|
||||
- **The judge's feedback is ground truth.** Fix what they say is broken.
|
||||
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
|
||||
- **Be concise in notes.** Future-you needs signal, not noise.
|
||||
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.
|
||||
"#;
|
||||
|
||||
const DEFAULT_BRUTE_CONF: &str = "\
|
||||
# Brute runner configuration
|
||||
|
||||
runner brute
|
||||
claude claude
|
||||
image claude-code-sandbox:latest
|
||||
max-tail 200
|
||||
|
||||
allow .
|
||||
|
||||
# Guards (run before judge, fail-fast)
|
||||
# Guards (run after each plan stage, fail-fast)
|
||||
guard cargo check
|
||||
|
||||
# Judge — a fresh Claude that verifies the feature blind.
|
||||
# The value is the Claude binary (same format as the claude directive).
|
||||
judge claude
|
||||
";
|
||||
|
||||
/// Files that Claude must not be allowed to permanently alter.
|
||||
|
|
@ -190,15 +212,15 @@ fn print_usage() {
|
|||
eprintln!();
|
||||
eprintln!("{}COMMANDS:{}", BOLD, RESET);
|
||||
eprintln!(
|
||||
" {}init{} Initialize .loop/ directory with default files",
|
||||
" {}init{} Initialize .loop/ for staged plan loop (default)",
|
||||
BOLD, RESET
|
||||
);
|
||||
eprintln!(
|
||||
" {}init --runner <type>{} Initialize for a specific runner (loop or brute)",
|
||||
" {}init brute{} Initialize .loop/ for brute mode (plan stages + judge)",
|
||||
BOLD, RESET
|
||||
);
|
||||
eprintln!(
|
||||
" {}run{} Launch the CI loop (invoke Claude, run guards, iterate)",
|
||||
" {}run{} Launch the loop (invoke Claude, run guards, iterate)",
|
||||
BOLD, RESET
|
||||
);
|
||||
eprintln!(
|
||||
|
|
@ -221,7 +243,7 @@ fn print_usage() {
|
|||
|
||||
fn print_run_help() {
|
||||
eprintln!(
|
||||
"{}{}yoke run{} — execute the CI loop{}",
|
||||
"{}{}yoke run{} — execute the loop{}",
|
||||
BOLD, CYAN, RESET, RESET
|
||||
);
|
||||
eprintln!();
|
||||
|
|
@ -233,11 +255,12 @@ fn print_run_help() {
|
|||
eprintln!("{}OPTIONS:{}", BOLD, RESET);
|
||||
eprintln!(" --dry-run Run one iteration without invoking Claude");
|
||||
eprintln!(" (boundary check + configured guards only)");
|
||||
eprintln!(" --no-sandbox Allow running without a container image");
|
||||
eprintln!();
|
||||
eprintln!("{}RUNNER TYPES:{}", BOLD, RESET);
|
||||
eprintln!(" The runner type is determined by the 'runner' directive in {}.", CONF_PATH);
|
||||
eprintln!(" {}loop{} (default) Staged plan runner — iterates until STATUS: DONE and guards pass.", BOLD, RESET);
|
||||
eprintln!(" {}brute{} Blind-judge runner — iterates until an independent judge says PASS.", BOLD, RESET);
|
||||
eprintln!("{}MODES:{}", BOLD, RESET);
|
||||
eprintln!(" Detected automatically from .loop/ contents.");
|
||||
eprintln!(" {}loop{} (default) Staged plan loop — iterates until STATUS: DONE and guards pass.", BOLD, RESET);
|
||||
eprintln!(" {}brute{} Plan stages + judge — detected when {} exists.", BOLD, RESET, JUDGE_PATH);
|
||||
eprintln!();
|
||||
eprintln!("{}WORKFLOW (loop):{}", BOLD, RESET);
|
||||
eprintln!(" 1. Load config from {}", CONF_PATH);
|
||||
|
|
@ -251,12 +274,12 @@ fn print_run_help() {
|
|||
eprintln!();
|
||||
eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET);
|
||||
eprintln!(" 1. Load config from {}", CONF_PATH);
|
||||
eprintln!(" 2. Backup protected files (protocol.md, task.md, judge.md, ci.conf)");
|
||||
eprintln!(" 3. Per iteration:");
|
||||
eprintln!(" 2. Backup protected files (protocol.md, plan.md, task.md, judge.md, guard.conf)");
|
||||
eprintln!(" 3. Per brute attempt:");
|
||||
eprintln!(" a. Restore protected files, clear verdict");
|
||||
eprintln!(" b. Invoke worker Claude");
|
||||
eprintln!(" c. Run diff boundary check + configured guards");
|
||||
eprintln!(" d. If guards pass, invoke judge (fresh Claude, zero context)");
|
||||
eprintln!(" b. Run plan loop (stages + guards until DONE)");
|
||||
eprintln!(" c. Invoke judge (fresh Claude, zero context)");
|
||||
eprintln!(" d. If judge FAILs, reset STATUS and retry");
|
||||
eprintln!(" 4. Exit when judge returns VERDICT: PASS");
|
||||
}
|
||||
|
||||
|
|
@ -267,19 +290,17 @@ fn print_init_help() {
|
|||
);
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"{}USAGE:{} yoke init [--runner <type>]",
|
||||
"{}USAGE:{} yoke init [brute]",
|
||||
BOLD, RESET
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("{}OPTIONS:{}", BOLD, RESET);
|
||||
eprintln!(" --runner <type> Runner type: loop (default) or brute");
|
||||
eprintln!("{}MODES:{}", BOLD, RESET);
|
||||
eprintln!(" {}(default){} Staged plan loop. Creates:", BOLD, RESET);
|
||||
eprintln!(" guard.conf, protocol.md, plan.md, notes.md, guard-results.md");
|
||||
eprintln!();
|
||||
eprintln!("{}RUNNER TYPES:{}", BOLD, RESET);
|
||||
eprintln!(" {}loop{} (default) Staged plan runner. Creates:", BOLD, RESET);
|
||||
eprintln!(" ci.conf, protocol.md, plan.md, notes.md, guard-results.md");
|
||||
eprintln!();
|
||||
eprintln!(" {}brute{} Blind-judge runner. Creates:", BOLD, RESET);
|
||||
eprintln!(" ci.conf, protocol.md, task.md, judge.md, notes.md, verdict.md");
|
||||
eprintln!(" {}brute{} Plan stages + judge. Creates:", BOLD, RESET);
|
||||
eprintln!(" guard.conf, protocol.md, plan.md, task.md, judge.md,");
|
||||
eprintln!(" notes.md, verdict.md, guard-results.md");
|
||||
eprintln!();
|
||||
eprintln!("Existing files are never overwritten.");
|
||||
}
|
||||
|
|
@ -326,12 +347,12 @@ fn preflight() {
|
|||
}
|
||||
}
|
||||
// Ensure notes file exists
|
||||
if !Path::new(NOTES_PATH).exists() {
|
||||
if let Err(e) = fs::write(NOTES_PATH, "") {
|
||||
if !Path::new(NOTES_PATH).exists()
|
||||
&& let Err(e) = fs::write(NOTES_PATH, "")
|
||||
{
|
||||
log_error(&format!("cannot create {}: {}", NOTES_PATH, e));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
// Clear guard results
|
||||
let _ = fs::write(GUARD_RESULTS_PATH, "");
|
||||
}
|
||||
|
|
@ -361,8 +382,9 @@ fn restore_protected(backup_dir: &Path) {
|
|||
for path in PROTECTED_FILES {
|
||||
let src_name = Path::new(path).file_name().unwrap();
|
||||
let backup_file = backup_dir.join(src_name);
|
||||
if backup_file.exists() {
|
||||
if let Err(e) = fs::copy(&backup_file, path) {
|
||||
if backup_file.exists()
|
||||
&& let Err(e) = fs::copy(&backup_file, path)
|
||||
{
|
||||
eprintln!(
|
||||
"{}{}[yoke] WARNING:{} failed to restore {}: {}",
|
||||
BOLD, YELLOW, RESET, path, e
|
||||
|
|
@ -370,7 +392,6 @@ fn restore_protected(backup_dir: &Path) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the first line of notes.md for STATUS: DONE.
|
||||
fn is_done() -> bool {
|
||||
|
|
@ -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.
|
||||
/// The Child is stored in `runner` for cleanup-on-drop safety.
|
||||
/// Returns the child's exit status success.
|
||||
|
|
@ -409,7 +454,6 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo
|
|||
c.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--network=host",
|
||||
"--cap-add=NET_ADMIN",
|
||||
"--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(&config.claude_bin);
|
||||
c.arg("claude");
|
||||
c.args(claude_args);
|
||||
c
|
||||
} else {
|
||||
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
|
||||
};
|
||||
|
||||
let mut child = match cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
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));
|
||||
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.
|
||||
/// Returns true if the judge's verdict is PASS.
|
||||
fn invoke_judge(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool {
|
||||
let judge_bin = match &config.judge {
|
||||
Some(bin) => bin.clone(),
|
||||
None => {
|
||||
log_error("no 'judge' directive in config — cannot invoke judge");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let judge_prompt = "\
|
||||
Read .loop/judge.md. It describes what to verify.
|
||||
Test the feature exactly as described. You have full shell access.
|
||||
|
|
@ -547,9 +584,10 @@ VERDICT: FAIL
|
|||
<what you tested, what went wrong, and what the correct behavior should be>";
|
||||
|
||||
let claude_args = [
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"-p",
|
||||
judge_prompt,
|
||||
];
|
||||
|
|
@ -569,7 +607,6 @@ VERDICT: FAIL
|
|||
c.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--network=host",
|
||||
"--cap-add=NET_ADMIN",
|
||||
"--cap-add=NET_RAW",
|
||||
|
|
@ -602,24 +639,25 @@ VERDICT: FAIL
|
|||
}
|
||||
|
||||
c.arg(image.as_str());
|
||||
c.arg(&judge_bin);
|
||||
c.arg("claude");
|
||||
c.args(claude_args);
|
||||
c
|
||||
} else {
|
||||
log(&format!("Launching judge (iteration {})...", iteration));
|
||||
let mut c = Command::new(&judge_bin);
|
||||
let mut c = Command::new("claude");
|
||||
c.args(claude_args);
|
||||
c
|
||||
};
|
||||
|
||||
let mut child = match cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
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));
|
||||
return false;
|
||||
}
|
||||
|
|
@ -748,7 +786,7 @@ fn run_all_guards(config: &Config) -> bool {
|
|||
all_passed
|
||||
}
|
||||
|
||||
fn init(runner: &str) -> i32 {
|
||||
fn init(mode: &str) -> i32 {
|
||||
let loop_dir = Path::new(".loop");
|
||||
if !loop_dir.exists() {
|
||||
if let Err(e) = fs::create_dir(loop_dir) {
|
||||
|
|
@ -758,7 +796,7 @@ fn init(runner: &str) -> i32 {
|
|||
log("Created .loop/");
|
||||
}
|
||||
|
||||
let files: Vec<(&str, &str)> = match runner {
|
||||
let files: Vec<(&str, &str)> = match mode {
|
||||
"loop" => vec![
|
||||
(CONF_PATH, DEFAULT_GUARD_CONF),
|
||||
(PROTOCOL_PATH, DEFAULT_PROTOCOL),
|
||||
|
|
@ -769,18 +807,19 @@ fn init(runner: &str) -> i32 {
|
|||
"brute" => vec![
|
||||
(CONF_PATH, DEFAULT_BRUTE_CONF),
|
||||
(PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL),
|
||||
(TASK_PATH, ""),
|
||||
(PLAN_PATH, ""),
|
||||
(JUDGE_PATH, ""),
|
||||
(NOTES_PATH, ""),
|
||||
(VERDICT_PATH, ""),
|
||||
(GUARD_RESULTS_PATH, ""),
|
||||
],
|
||||
other => {
|
||||
log_error(&format!("unknown runner type '{}'", other));
|
||||
log_error(&format!("unknown mode '{}'", other));
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
log(&format!("Initializing '{}' runner...", runner));
|
||||
log(&format!("Initializing '{}' mode...", mode));
|
||||
|
||||
for (path, content) in &files {
|
||||
let p = Path::new(path);
|
||||
|
|
@ -798,28 +837,14 @@ fn init(runner: &str) -> i32 {
|
|||
0
|
||||
}
|
||||
|
||||
fn run_loop(dry_run: bool) -> i32 {
|
||||
// Load config
|
||||
let config = match Config::load(Path::new(CONF_PATH)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log_error(&e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
log(&format!(
|
||||
"Config loaded: claude={}, max_tail={}, {} scope rules, {} guards{}",
|
||||
config.claude_bin,
|
||||
config.max_tail,
|
||||
config.scope_rules.len(),
|
||||
config.guards.len(),
|
||||
config.image.as_ref().map_or(
|
||||
String::from(", sandbox=off"),
|
||||
|img| format!(", image={}", img)
|
||||
)
|
||||
));
|
||||
|
||||
/// Core plan-loop runner that can be called standalone or nested inside brute.
|
||||
///
|
||||
/// - `config`: already-loaded Config
|
||||
/// - `dry_run`: if true, skip Claude invocation (one iteration only)
|
||||
/// - `nested`: if true, running inside brute (adjusts output banners)
|
||||
///
|
||||
/// 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> {
|
||||
// Preflight
|
||||
preflight();
|
||||
log("Preflight OK");
|
||||
|
|
@ -834,10 +859,24 @@ fn run_loop(dry_run: bool) -> i32 {
|
|||
loop {
|
||||
if signal::interrupted() {
|
||||
log("Interrupted \u{2014} shutting down");
|
||||
return 130;
|
||||
return Err(130);
|
||||
}
|
||||
iteration += 1;
|
||||
eprintln!();
|
||||
if nested {
|
||||
eprintln!(
|
||||
"{}{}┌──────────────────────────────────────┐{}",
|
||||
BOLD, CYAN, RESET
|
||||
);
|
||||
eprintln!(
|
||||
"{}{}│ Plan Iteration {:>4} │{}",
|
||||
BOLD, CYAN, iteration, RESET
|
||||
);
|
||||
eprintln!(
|
||||
"{}{}└──────────────────────────────────────┘{}",
|
||||
BOLD, CYAN, RESET
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}{}╔══════════════════════════════════════╗{}",
|
||||
BOLD, CYAN, RESET
|
||||
|
|
@ -850,6 +889,7 @@ fn run_loop(dry_run: bool) -> i32 {
|
|||
"{}{}╚══════════════════════════════════════╝{}",
|
||||
BOLD, CYAN, RESET
|
||||
);
|
||||
}
|
||||
|
||||
// Restore protected files
|
||||
restore_protected(&runner.backup_dir);
|
||||
|
|
@ -859,18 +899,18 @@ fn run_loop(dry_run: bool) -> i32 {
|
|||
|
||||
if dry_run {
|
||||
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");
|
||||
return 1;
|
||||
return Err(1);
|
||||
}
|
||||
|
||||
if signal::interrupted() {
|
||||
log("Interrupted \u{2014} shutting down");
|
||||
return 130;
|
||||
return Err(130);
|
||||
}
|
||||
|
||||
// Run guards
|
||||
let guards_passed = run_all_guards(&config);
|
||||
let guards_passed = run_all_guards(config);
|
||||
|
||||
if guards_passed {
|
||||
log(&format!(
|
||||
|
|
@ -880,7 +920,7 @@ fn run_loop(dry_run: bool) -> i32 {
|
|||
|
||||
if dry_run {
|
||||
log("(dry-run) Guards passed \u{2014} exiting after one iteration");
|
||||
return 0;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_done() {
|
||||
|
|
@ -890,7 +930,7 @@ fn run_loop(dry_run: bool) -> i32 {
|
|||
GREEN, BOLD, RESET
|
||||
);
|
||||
eprintln!();
|
||||
return 0;
|
||||
return Ok(());
|
||||
} else {
|
||||
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 {
|
||||
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.
|
||||
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 {
|
||||
// Load config
|
||||
|
|
@ -922,9 +989,7 @@ fn run_brute(dry_run: bool) -> i32 {
|
|||
};
|
||||
|
||||
log(&format!(
|
||||
"Config loaded: runner=brute, claude={}, judge={}, max_tail={}, {} scope rules, {} guards{}",
|
||||
config.claude_bin,
|
||||
config.judge.as_deref().unwrap_or("(none)"),
|
||||
"Config loaded: mode=brute, max_tail={}, {} scope rules, {} guards{}",
|
||||
config.max_tail,
|
||||
config.scope_rules.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
|
||||
for path in &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH] {
|
||||
// Preflight: require protocol.md, judge.md, guard.conf
|
||||
for path in &[PROTOCOL_PATH, JUDGE_PATH, CONF_PATH] {
|
||||
if !Path::new(path).exists() {
|
||||
log_error(&format!("required file not found: {}", path));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Create notes.md and verdict.md if missing
|
||||
for path in &[NOTES_PATH, VERDICT_PATH] {
|
||||
if !Path::new(path).exists() {
|
||||
if let Err(e) = fs::write(path, "") {
|
||||
log_error(&format!("cannot create {}: {}", path, e));
|
||||
// Fail fast if critical content files are empty
|
||||
for path in &[JUDGE_PATH] {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(content) if content.trim().is_empty() => {
|
||||
log_error(&format!("{} is empty — fill it in before running", path));
|
||||
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,55 +1078,70 @@ fn run_brute(dry_run: bool) -> i32 {
|
|||
for path in BRUTE_PROTECTED_FILES {
|
||||
let src_name = Path::new(path).file_name().unwrap();
|
||||
let backup_file = runner.backup_dir.join(src_name);
|
||||
if backup_file.exists() {
|
||||
if let Err(e) = fs::copy(&backup_file, path) {
|
||||
if backup_file.exists()
|
||||
&& let Err(e) = fs::copy(&backup_file, path)
|
||||
{
|
||||
eprintln!(
|
||||
"{}{}[yoke] WARNING:{} failed to restore {}: {}",
|
||||
BOLD, YELLOW, RESET, path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear verdict
|
||||
let _ = fs::write(VERDICT_PATH, "");
|
||||
|
||||
if dry_run {
|
||||
log("(dry-run) Skipping Claude invocation");
|
||||
} else if !invoke_claude(&mut runner, &config, iteration) {
|
||||
log_error("Claude invocation failed — aborting loop");
|
||||
log("(dry-run) Skipping worker invocation");
|
||||
} else {
|
||||
log("Using plan runner as worker...");
|
||||
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() {
|
||||
log("Interrupted \u{2014} shutting down");
|
||||
return 130;
|
||||
}
|
||||
|
||||
// Run guards
|
||||
let guards_passed = run_all_guards(&config);
|
||||
|
||||
if !guards_passed {
|
||||
log(&format!(
|
||||
"{}Guards failed \u{2014} worker will see results next iteration{}",
|
||||
YELLOW, RESET
|
||||
));
|
||||
// Run guards (skip when not dry-run — the plan loop already ran them)
|
||||
if dry_run {
|
||||
let passed = run_all_guards(&config);
|
||||
if !passed {
|
||||
log(&format!(
|
||||
"{}Guards failed{}", YELLOW, RESET
|
||||
));
|
||||
log("(dry-run) Exiting after one iteration");
|
||||
return 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET));
|
||||
|
||||
if dry_run {
|
||||
log("(dry-run) Guards passed \u{2014} exiting after one iteration");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Guards passed — invoke judge
|
||||
log("Guards passed \u{2014} invoking judge...");
|
||||
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);
|
||||
|
||||
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{}",
|
||||
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();
|
||||
return;
|
||||
}
|
||||
let mut runner = "loop";
|
||||
let mut i = 2;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--runner" => {
|
||||
i += 1;
|
||||
if i >= args.len() {
|
||||
log_error("--runner requires a value (loop or brute)");
|
||||
process::exit(2);
|
||||
}
|
||||
runner = &args[i];
|
||||
}
|
||||
other => {
|
||||
log_error(&format!("unknown flag '{}'", other));
|
||||
let mode = match args.get(2).map(|a| a.as_str()) {
|
||||
None => "loop",
|
||||
Some("brute") => "brute",
|
||||
Some(other) => {
|
||||
log_error(&format!("unknown argument '{}'", other));
|
||||
print_init_help();
|
||||
process::exit(2);
|
||||
}
|
||||
};
|
||||
if args.len() > 3 {
|
||||
log_error(&format!("unexpected argument '{}'", args[3]));
|
||||
print_init_help();
|
||||
process::exit(2);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
process::exit(init(runner));
|
||||
process::exit(init(mode));
|
||||
}
|
||||
"run" => {
|
||||
// Check for --help before other flags
|
||||
|
|
@ -1118,12 +1207,19 @@ fn main() {
|
|||
print_run_help();
|
||||
return;
|
||||
}
|
||||
let dry_run = args.get(2).is_some_and(|a| a == "--dry-run");
|
||||
if args.len() > 2 && !dry_run {
|
||||
log_error(&format!("unknown flag '{}'", args[2]));
|
||||
let mut dry_run = false;
|
||||
let mut no_sandbox = false;
|
||||
for arg in &args[2..] {
|
||||
match arg.as_str() {
|
||||
"--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
|
||||
let config = match Config::load(Path::new(CONF_PATH)) {
|
||||
Ok(c) => c,
|
||||
|
|
@ -1132,13 +1228,16 @@ fn main() {
|
|||
process::exit(1);
|
||||
}
|
||||
};
|
||||
match config.runner.as_str() {
|
||||
"loop" => process::exit(run_loop(dry_run)),
|
||||
"brute" => process::exit(run_brute(dry_run)),
|
||||
other => {
|
||||
log_error(&format!("unknown runner type '{}' in config", other));
|
||||
if config.image.is_none() && !no_sandbox {
|
||||
log_error("no 'image' directive in config — refusing to run without sandbox");
|
||||
eprintln!(" Add 'image <name>' to {} or pass --no-sandbox to override.", CONF_PATH);
|
||||
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" => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
|||
static CHILD_PID: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
extern "C" fn sigint_handler(_sig: i32) {
|
||||
INTERRUPTED.store(true, Ordering::Relaxed);
|
||||
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().
|
||||
// Rust's BufReader retries on EINTR internally, so the only way
|
||||
// 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" {
|
||||
safe fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize;
|
||||
safe fn kill(pid: i32, sig: i32) -> i32;
|
||||
safe fn _exit(status: i32) -> !;
|
||||
}
|
||||
|
||||
pub fn install() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::io::{BufRead, BufReader, Write};
|
|||
use std::path::Path;
|
||||
use std::process::ChildStdout;
|
||||
|
||||
use crate::json::{extract_num, extract_str};
|
||||
use crate::json::{extract_num, extract_str, unescape_json};
|
||||
|
||||
// ANSI escape codes
|
||||
const RESET: &str = "\x1b[0m";
|
||||
|
|
@ -16,6 +16,7 @@ struct StreamState {
|
|||
turn_num: u32,
|
||||
current_msg_id: Option<String>,
|
||||
seen_init: bool,
|
||||
in_thinking: bool,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
|
|
@ -24,6 +25,7 @@ impl StreamState {
|
|||
turn_num: 0,
|
||||
current_msg_id: None,
|
||||
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") => {
|
||||
if line.contains("\"tool_use\"") {
|
||||
let desc = format_tool_call(&line);
|
||||
println!("{}{}>>{} {}{}", YELLOW, BOLD, RESET, desc, RESET);
|
||||
} else if let Some(text) = extract_str(&line, "text") {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
for text_line in trimmed.lines() {
|
||||
println!("{} {}{}", DIM, text_line, RESET);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// stream_event → streaming deltas
|
||||
Some("stream_event") => {
|
||||
if line.contains("\"content_block_delta\"") {
|
||||
if line.contains("\"text_delta\"") {
|
||||
if let Some(text) = extract_str(&line, "text") {
|
||||
if line.contains("\"thinking_delta\"") {
|
||||
// Show activity during extended thinking
|
||||
print!("{}·{}", DIM, RESET);
|
||||
std::io::stdout().flush().ok();
|
||||
} else if line.contains("\"text_delta\"")
|
||||
&& let Some(text) = extract_str(&line, "text")
|
||||
{
|
||||
let text = unescape_json(text);
|
||||
print!("{}{}{}", DIM, text, RESET);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
}
|
||||
// input_json_delta → skip silently
|
||||
} else if line.contains("\"content_block_start\"") {
|
||||
if !line.contains("\"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!();
|
||||
}
|
||||
} else if line.contains("\"content_block_stop\"") {
|
||||
if state.in_thinking {
|
||||
println!();
|
||||
state.in_thinking = false;
|
||||
} else {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
// message_start, message_delta, message_stop → skip
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue