feat: basic usage
This commit is contained in:
parent
9b6124ec6d
commit
30bb2bdb8d
11 changed files with 783 additions and 272 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
target/*
|
target/*
|
||||||
|
.loop/
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "LLM automation loop harness"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
|
|
|
||||||
17
loop/ci.conf
17
loop/ci.conf
|
|
@ -1,17 +0,0 @@
|
||||||
# CI loop configuration
|
|
||||||
|
|
||||||
# Claude binary path
|
|
||||||
claude claude
|
|
||||||
|
|
||||||
# Max lines of guard output to keep
|
|
||||||
max-tail 200
|
|
||||||
|
|
||||||
# Scope rules (diff boundary enforcement)
|
|
||||||
# Most-specific (longest) prefix wins.
|
|
||||||
allow .
|
|
||||||
allow src/
|
|
||||||
allow loop/guards/
|
|
||||||
add-only tests/
|
|
||||||
|
|
||||||
# Guards (run in order, fail-fast)
|
|
||||||
guard cargo check -p yoke
|
|
||||||
145
loop/plan.md
145
loop/plan.md
|
|
@ -1,145 +0,0 @@
|
||||||
Richer Stdout Display
|
|
||||||
|
|
||||||
Deliverable: Improve the NDJSON stream filter so the operator can follow Claude's
|
|
||||||
full reasoning. Currently assistant text is truncated to 3 lines / 120 chars and
|
|
||||||
many event types are silently dropped. No new crates — pure std only.
|
|
||||||
|
|
||||||
---
|
|
||||||
Context
|
|
||||||
|
|
||||||
The stream filter in src/stream.rs reads Claude's NDJSON output and formats it
|
|
||||||
as ANSI-colored terminal output. Current problems:
|
|
||||||
|
|
||||||
1. Assistant text is hard-capped at 3 lines and 120 chars per line. This cuts off
|
|
||||||
Claude's thinking and explanations — the operator has no idea what it's doing.
|
|
||||||
|
|
||||||
2. The `stream_event` wrapper type (the majority of NDJSON lines) is silently
|
|
||||||
dropped. These carry `content_block_delta` events with partial text and input
|
|
||||||
JSON, which are useful for streaming progress.
|
|
||||||
|
|
||||||
3. Tool results are invisible — the `user` event type (which carries tool_result
|
|
||||||
content) is never matched.
|
|
||||||
|
|
||||||
Goal: Show Claude's full text output, stream partial text deltas as they arrive,
|
|
||||||
and show a brief summary of tool results.
|
|
||||||
|
|
||||||
---
|
|
||||||
Implementation Stages
|
|
||||||
|
|
||||||
|
|
||||||
Stage A: Remove text truncation, show full assistant output
|
|
||||||
|
|
||||||
Objective: Stop cutting off assistant text so the operator sees everything.
|
|
||||||
|
|
||||||
src/stream.rs — in the `Some("assistant")` branch, the text display path:
|
|
||||||
|
|
||||||
Current code truncates:
|
|
||||||
for text_line in trimmed.lines().take(3) {
|
|
||||||
let truncated = if text_line.len() > 120 { &text_line[..120] } else { text_line };
|
|
||||||
println!("{} {}{}", DIM, truncated, RESET);
|
|
||||||
}
|
|
||||||
|
|
||||||
Change to print all lines with no length limit:
|
|
||||||
for text_line in trimmed.lines() {
|
|
||||||
println!("{} {}{}", DIM, text_line, RESET);
|
|
||||||
}
|
|
||||||
|
|
||||||
Verify: cargo check -p yoke passes.
|
|
||||||
|
|
||||||
|
|
||||||
Stage B: Display streaming text deltas
|
|
||||||
|
|
||||||
Objective: Show `content_block_delta` events so text streams to the terminal
|
|
||||||
as Claude thinks, rather than appearing only in the final `assistant` message.
|
|
||||||
|
|
||||||
src/stream.rs — add a `Some("stream_event")` match arm. These lines wrap inner
|
|
||||||
events in a `{"type":"stream_event","event":{...}}` envelope.
|
|
||||||
|
|
||||||
The inner event types to handle:
|
|
||||||
|
|
||||||
`content_block_delta` with `"text_delta"`:
|
|
||||||
The delta has `{"type":"text_delta","text":"..."}`. Extract the `text` field
|
|
||||||
from the inner delta and print it inline (no newline — use `print!` not
|
|
||||||
`println!`) so streaming text accumulates naturally:
|
|
||||||
print!("{}{}{}", DIM, text, RESET);
|
|
||||||
Flush stdout after each delta.
|
|
||||||
|
|
||||||
`content_block_delta` with `"input_json_delta"`:
|
|
||||||
These are partial tool input being streamed. Skip these silently — the
|
|
||||||
complete tool call will be shown when the full `assistant` event arrives.
|
|
||||||
|
|
||||||
`content_block_start`:
|
|
||||||
If it contains `"tool_use"`, print nothing (the full assistant event will
|
|
||||||
show the tool call). If it contains `"text"`, print a newline to start
|
|
||||||
a fresh line for streaming text.
|
|
||||||
|
|
||||||
`content_block_stop`:
|
|
||||||
Print a newline to terminate any streaming text on the current line.
|
|
||||||
|
|
||||||
`message_start`, `message_delta`, `message_stop`:
|
|
||||||
Skip silently — these are bookkeeping.
|
|
||||||
|
|
||||||
To detect the inner event type, use extract_str on the line for the `"type"`
|
|
||||||
field inside `"event"`. Since the line has a top-level `"type":"stream_event"`
|
|
||||||
and an inner `"type":"content_block_delta"` (etc.), and extract_str now retries
|
|
||||||
past value matches, you can search for the inner type by looking for specific
|
|
||||||
strings:
|
|
||||||
- line.contains("\"content_block_delta\"") → delta handling
|
|
||||||
- line.contains("\"content_block_start\"") → start handling
|
|
||||||
- line.contains("\"content_block_stop\"") → stop handling
|
|
||||||
- Otherwise → skip
|
|
||||||
|
|
||||||
For text_delta extraction: search for `"text_delta"` in the line, then extract
|
|
||||||
the `"text"` field. Since the line may have multiple `"text"` keys (the delta
|
|
||||||
type and the actual text content), extract_str's retry logic will handle this.
|
|
||||||
|
|
||||||
Important: flush stdout after each print! call so streaming text appears
|
|
||||||
immediately:
|
|
||||||
use std::io::stdout;
|
|
||||||
stdout().flush().ok();
|
|
||||||
|
|
||||||
Verify: cargo check -p yoke passes.
|
|
||||||
|
|
||||||
|
|
||||||
Stage C: Show tool result summaries
|
|
||||||
|
|
||||||
Objective: When Claude reads a file or runs a command, show a brief summary of
|
|
||||||
the tool result so the operator knows what happened.
|
|
||||||
|
|
||||||
src/stream.rs — add a `Some("user")` match arm. User events carry tool results
|
|
||||||
in the format:
|
|
||||||
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"...","type":"tool_result","content":"..."}]}}
|
|
||||||
|
|
||||||
For tool results:
|
|
||||||
- Extract the tool_use_id (not critical but nice)
|
|
||||||
- Check if the line contains `"tool_result"`
|
|
||||||
- Show a brief dim summary: the first 120 chars of the content, or just
|
|
||||||
"[tool result]" if content can't be extracted
|
|
||||||
- Format: `println!("{} ← result ({}b){}", DIM, content_len, RESET)`
|
|
||||||
where content_len is the approximate length of the result content
|
|
||||||
|
|
||||||
Keep it simple — just indicate a result came back and roughly how big it was.
|
|
||||||
The exact content is less important than knowing it happened.
|
|
||||||
|
|
||||||
Verify: cargo check -p yoke passes.
|
|
||||||
|
|
||||||
---
|
|
||||||
Files to modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| src/stream.rs | Remove truncation, add stream_event + user event handling |
|
|
||||||
|
|
||||||
---
|
|
||||||
Constraints
|
|
||||||
|
|
||||||
No external crates. Edition 2024. Pure std only.
|
|
||||||
Only modify src/stream.rs.
|
|
||||||
|
|
||||||
---
|
|
||||||
Success Criteria
|
|
||||||
|
|
||||||
1. cargo check -p yoke passes
|
|
||||||
2. Assistant text displays in full — no line count or character limit
|
|
||||||
3. Streaming text deltas appear as Claude thinks
|
|
||||||
4. Tool results show a brief acknowledgment line
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
# Protocol: Automated CI Loop
|
|
||||||
|
|
||||||
You are operating inside an automated loop — not a conversation. A bash script launched you, and will run guard checks after you exit. You do not interact with a human during this session.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | You can | Purpose |
|
|
||||||
|------|---------|---------|
|
|
||||||
| `loop/protocol.md` | read | This document. Your instructions. |
|
|
||||||
| `loop/plan.md` | read | The feature plan. Stages to implement. |
|
|
||||||
| `loop/notes.md` | read + write | Your scratchpad. Persists across iterations. |
|
|
||||||
| `loop/guard-results.md` | read | Guard results from the last iteration. |
|
|
||||||
| `loop/ci.conf` | read | Loop configuration. Scope rules, guards, settings. |
|
|
||||||
|
|
||||||
All paths are relative to the repository root.
|
|
||||||
|
|
||||||
## Per-Iteration Steps
|
|
||||||
|
|
||||||
1. **Read the plan** (`loop/plan.md`). Understand the full feature and all its stages.
|
|
||||||
2. **Read your notes** (`loop/notes.md`). This is your memory across iterations — check which stage you are on, what you tried, and what you learned.
|
|
||||||
3. **Read guard results** (`loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage.
|
|
||||||
4. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan.
|
|
||||||
5. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree.
|
|
||||||
6. **Update notes**. Write to `loop/notes.md`:
|
|
||||||
- Which stage you just worked on
|
|
||||||
- What you changed and why
|
|
||||||
- Any issues or observations for your future self
|
|
||||||
- A `STATUS` line at the **top** of the file (see below)
|
|
||||||
7. **Exit**. Stop. Do not loop — the outer script handles iteration.
|
|
||||||
|
|
||||||
## STATUS Signaling
|
|
||||||
|
|
||||||
The first line of `loop/notes.md` must be one of:
|
|
||||||
|
|
||||||
- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures).
|
|
||||||
- `STATUS: DONE` — All stages in the plan are implemented and you believe guards will pass.
|
|
||||||
|
|
||||||
The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass.
|
|
||||||
|
|
||||||
## What the Guards Check
|
|
||||||
|
|
||||||
After you exit, the outer loop runs guards defined in `loop/ci.conf`.
|
|
||||||
|
|
||||||
1. **Diff boundary check** — Always runs first. Verifies every file you changed
|
|
||||||
or created is within the scope rules defined in `loop/ci.conf`. The rules:
|
|
||||||
- `allow PREFIX` — anything goes: add, modify, delete.
|
|
||||||
- `add-only PREFIX` — may only add lines; no removing existing lines.
|
|
||||||
- `no-modify PREFIX` — zero modifications allowed.
|
|
||||||
- No matching rule — change is denied.
|
|
||||||
- Most-specific (longest) prefix wins when rules overlap.
|
|
||||||
If the boundary check fails, all subsequent guards are skipped.
|
|
||||||
2. **Configured guards** — Read the `guard` lines in `loop/ci.conf` to see
|
|
||||||
what commands run. Guards execute in order, fail-fast (first failure skips
|
|
||||||
the rest).
|
|
||||||
|
|
||||||
You may run any commands you find useful during implementation.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- **No git operations.** Do not commit, push, branch, or modify git config. The outer loop owns git.
|
|
||||||
- **Do not modify `protocol.md`, `plan.md`, or `scope.conf`.** These are read-only to you.
|
|
||||||
- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages.
|
|
||||||
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix.
|
|
||||||
- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next.
|
|
||||||
|
|
@ -19,8 +19,11 @@ pub struct Config {
|
||||||
pub claude_bin: String,
|
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 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,8 +34,11 @@ impl Config {
|
||||||
let mut claude_bin = String::from("claude");
|
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 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
|
||||||
|
|
@ -73,6 +79,9 @@ impl Config {
|
||||||
"log-dir" => {
|
"log-dir" => {
|
||||||
log_dir = Some(value.to_string());
|
log_dir = Some(value.to_string());
|
||||||
}
|
}
|
||||||
|
"image" => {
|
||||||
|
image = Some(value.to_string());
|
||||||
|
}
|
||||||
"allow" => scope_rules.push(ScopeRule {
|
"allow" => scope_rules.push(ScopeRule {
|
||||||
tag: ScopeTag::Allow,
|
tag: ScopeTag::Allow,
|
||||||
prefix: value.to_string(),
|
prefix: value.to_string(),
|
||||||
|
|
@ -86,6 +95,8 @@ 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 '{}'",
|
||||||
|
|
@ -101,8 +112,11 @@ impl Config {
|
||||||
claude_bin,
|
claude_bin,
|
||||||
max_tail,
|
max_tail,
|
||||||
log_dir,
|
log_dir,
|
||||||
|
image,
|
||||||
scope_rules,
|
scope_rules,
|
||||||
guards,
|
guards,
|
||||||
|
runner,
|
||||||
|
judge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
741
src/main.rs
741
src/main.rs
|
|
@ -11,11 +11,151 @@ use std::process::{self, Child, Command, Stdio};
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
|
||||||
const CONF_PATH: &str = "loop/ci.conf";
|
const CONF_PATH: &str = ".loop/guard.conf";
|
||||||
const NOTES_PATH: &str = "loop/notes.md";
|
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 VERDICT_PATH: &str = ".loop/verdict.md";
|
||||||
|
|
||||||
|
const DEFAULT_PROTOCOL: &str = r#"# Protocol: Automated CI Loop
|
||||||
|
|
||||||
|
You are operating inside an automated loop — not a conversation. A bash script launched you, and will run guard checks after you exit. You do not interact with a human during this session.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | You can | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `.loop/protocol.md` | read | This document. Your instructions. |
|
||||||
|
| `.loop/plan.md` | read | The feature plan. Stages to implement. |
|
||||||
|
| `.loop/notes.md` | read + write | Your scratchpad. Persists across iterations. |
|
||||||
|
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
|
||||||
|
| `.loop/guard.conf` | read | Loop configuration. Scope rules, guards, settings. |
|
||||||
|
|
||||||
|
All paths are relative to the repository root.
|
||||||
|
|
||||||
|
## Per-Iteration Steps
|
||||||
|
|
||||||
|
1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
|
||||||
|
2. **Read your notes** (`.loop/notes.md`). This is your memory across iterations — check which stage you are on, what you tried, and what you learned.
|
||||||
|
3. **Read guard results** (`.loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage.
|
||||||
|
4. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan.
|
||||||
|
5. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree.
|
||||||
|
6. **Update notes**. Write to `.loop/notes.md`:
|
||||||
|
- Which stage you just worked on
|
||||||
|
- What you changed and why
|
||||||
|
- Any issues or observations for your future self
|
||||||
|
- A `STATUS` line at the **top** of the file (see below)
|
||||||
|
7. **Exit**. Stop. Do not loop — the outer script handles iteration.
|
||||||
|
|
||||||
|
## STATUS Signaling
|
||||||
|
|
||||||
|
The first line of `.loop/notes.md` must be one of:
|
||||||
|
|
||||||
|
- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures).
|
||||||
|
- `STATUS: DONE` — All stages in the plan are implemented and you believe guards will pass.
|
||||||
|
|
||||||
|
The outer loop reads this line. It exits only when `STATUS: DONE` **and** all guards pass.
|
||||||
|
|
||||||
|
## What the Guards Check
|
||||||
|
|
||||||
|
After you exit, the outer loop runs guards defined in `.loop/guard.conf`.
|
||||||
|
|
||||||
|
1. **Diff boundary check** — Always runs first. Verifies every file you changed
|
||||||
|
or created is within the scope rules defined in `.loop/guard.conf`. The rules:
|
||||||
|
- `allow PREFIX` — anything goes: add, modify, delete.
|
||||||
|
- `add-only PREFIX` — may only add lines; no removing existing lines.
|
||||||
|
- `no-modify PREFIX` — zero modifications allowed.
|
||||||
|
- No matching rule — change is denied.
|
||||||
|
- Most-specific (longest) prefix wins when rules overlap.
|
||||||
|
If the boundary check fails, all subsequent guards are skipped.
|
||||||
|
2. **Configured guards** — Read the `guard` lines in `.loop/guard.conf` to see
|
||||||
|
what commands run. Guards execute in order, fail-fast (first failure skips
|
||||||
|
the rest).
|
||||||
|
|
||||||
|
You may run any commands you find useful during implementation.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **No git operations.** Do not commit, push, branch, or modify git config. The outer loop owns git.
|
||||||
|
- **Do not modify `protocol.md`, `plan.md`, or `guard.conf`.** These are read-only to you.
|
||||||
|
- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages.
|
||||||
|
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix.
|
||||||
|
- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next.
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const DEFAULT_GUARD_CONF: &str = "\
|
||||||
|
# Guard configuration
|
||||||
|
|
||||||
|
claude claude
|
||||||
|
image claude-code-sandbox:latest
|
||||||
|
|
||||||
|
# Max lines of guard output to keep
|
||||||
|
max-tail 200
|
||||||
|
|
||||||
|
# Scope rules (diff boundary enforcement)
|
||||||
|
# Tags: allow (any change), add-only (new lines only), no-modify (no changes)
|
||||||
|
# Most-specific (longest) prefix wins.
|
||||||
|
allow .
|
||||||
|
|
||||||
|
# Guards (run in order, fail-fast)
|
||||||
|
guard cargo check
|
||||||
|
";
|
||||||
|
|
||||||
|
const DEFAULT_BRUTE_PROTOCOL: &str = r#"# Brute Protocol
|
||||||
|
|
||||||
|
You are a worker in an automated brute-force loop. Your single goal is described
|
||||||
|
in `.loop/task.md`. You iterate until a blind judge (a separate Claude instance
|
||||||
|
with zero implementation context) confirms the task is complete.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Access | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `.loop/protocol.md` | read | These instructions. |
|
||||||
|
| `.loop/task.md` | read | The goal. |
|
||||||
|
| `.loop/judge.md` | read | What the judge will test. Study this. |
|
||||||
|
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
|
||||||
|
| `.loop/verdict.md` | read | The judge's last verdict. |
|
||||||
|
| `.loop/ci.conf` | read | Configuration. |
|
||||||
|
|
||||||
|
## Per-Iteration
|
||||||
|
|
||||||
|
1. Read `.loop/task.md` — understand the goal.
|
||||||
|
2. Read `.loop/notes.md` — recall what you have tried.
|
||||||
|
3. Read `.loop/verdict.md` — the judge's exact complaints from last iteration.
|
||||||
|
4. Make changes to accomplish the task.
|
||||||
|
5. Update `.loop/notes.md` with what you changed and why.
|
||||||
|
6. Exit — the harness runs guards then the judge.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- No git operations.
|
||||||
|
- Do not modify protocol.md, task.md, judge.md, or ci.conf.
|
||||||
|
- Study judge.md — knowing the test helps you pass it.
|
||||||
|
- The judge's feedback is ground truth. Fix what they say is broken.
|
||||||
|
- After 3 similar verdicts, try a fundamentally different approach.
|
||||||
|
- Be concise in notes.
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const DEFAULT_BRUTE_CONF: &str = "\
|
||||||
|
# Brute runner configuration
|
||||||
|
|
||||||
|
runner brute
|
||||||
|
claude claude
|
||||||
|
max-tail 200
|
||||||
|
|
||||||
|
allow .
|
||||||
|
|
||||||
|
# Guards (run before judge, fail-fast)
|
||||||
|
guard cargo check
|
||||||
|
|
||||||
|
# Judge — a fresh Claude that verifies the feature blind.
|
||||||
|
# The value is the Claude binary (same format as the claude directive).
|
||||||
|
judge claude
|
||||||
|
";
|
||||||
|
|
||||||
/// Files that Claude must not be allowed to permanently alter.
|
/// Files that Claude must not be allowed to permanently alter.
|
||||||
const PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH];
|
const PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH];
|
||||||
|
|
@ -30,25 +170,33 @@ const YELLOW: &str = "\x1b[33m";
|
||||||
const DIM: &str = "\x1b[2m";
|
const DIM: &str = "\x1b[2m";
|
||||||
|
|
||||||
fn log(msg: &str) {
|
fn log(msg: &str) {
|
||||||
eprintln!("{}{}[ci]{} {}", BOLD, CYAN, RESET, msg);
|
eprintln!("{}{}[yoke]{} {}", BOLD, CYAN, RESET, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_error(msg: &str) {
|
fn log_error(msg: &str) {
|
||||||
eprintln!("{}{}[ci] ERROR:{} {}", BOLD, RED, RESET, msg);
|
eprintln!("{}{}[yoke] ERROR:{} {}", BOLD, RED, RESET, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_usage() {
|
fn print_usage() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}{}ci{} — LLM loop harness{}",
|
"{}{}yoke{} — LLM loop harness{}",
|
||||||
BOLD, CYAN, RESET, RESET
|
BOLD, CYAN, RESET, RESET
|
||||||
);
|
);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}USAGE:{} ci <command> [options]",
|
"{}USAGE:{} yoke <command> [options]",
|
||||||
BOLD, RESET
|
BOLD, RESET
|
||||||
);
|
);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("{}COMMANDS:{}", BOLD, RESET);
|
eprintln!("{}COMMANDS:{}", BOLD, RESET);
|
||||||
|
eprintln!(
|
||||||
|
" {}init{} Initialize .loop/ directory with default files",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
" {}init --runner <type>{} Initialize for a specific runner (loop or brute)",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}run{} Launch the CI loop (invoke Claude, run guards, iterate)",
|
" {}run{} Launch the CI loop (invoke Claude, run guards, iterate)",
|
||||||
BOLD, RESET
|
BOLD, RESET
|
||||||
|
|
@ -73,12 +221,12 @@ fn print_usage() {
|
||||||
|
|
||||||
fn print_run_help() {
|
fn print_run_help() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}{}ci run{} — execute the CI loop{}",
|
"{}{}yoke run{} — execute the CI loop{}",
|
||||||
BOLD, CYAN, RESET, RESET
|
BOLD, CYAN, RESET, RESET
|
||||||
);
|
);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}USAGE:{} ci run [--dry-run]",
|
"{}USAGE:{} yoke run [--dry-run]",
|
||||||
BOLD, RESET
|
BOLD, RESET
|
||||||
);
|
);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
@ -86,15 +234,54 @@ fn print_run_help() {
|
||||||
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!();
|
eprintln!();
|
||||||
eprintln!("{}WORKFLOW:{}", BOLD, RESET);
|
eprintln!("{}RUNNER TYPES:{}", BOLD, RESET);
|
||||||
|
eprintln!(" The runner type is determined by the 'runner' directive in {}.", CONF_PATH);
|
||||||
|
eprintln!(" {}loop{} (default) Staged plan runner — iterates until STATUS: DONE and guards pass.", BOLD, RESET);
|
||||||
|
eprintln!(" {}brute{} Blind-judge runner — iterates until an independent judge says PASS.", BOLD, RESET);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}WORKFLOW (loop):{}", BOLD, RESET);
|
||||||
eprintln!(" 1. Load config from {}", CONF_PATH);
|
eprintln!(" 1. Load config from {}", CONF_PATH);
|
||||||
eprintln!(" 2. Backup protected files (protocol.md, plan.md, ci.conf)");
|
eprintln!(" 2. Backup protected files (protocol.md, plan.md, guard.conf)");
|
||||||
eprintln!(" 3. Per iteration:");
|
eprintln!(" 3. Per iteration:");
|
||||||
eprintln!(" a. Restore protected files");
|
eprintln!(" a. Restore protected files");
|
||||||
eprintln!(" b. Invoke Claude with stream-json output");
|
eprintln!(" b. Invoke Claude with stream-json output");
|
||||||
eprintln!(" c. Run diff boundary check");
|
eprintln!(" c. Run diff boundary check");
|
||||||
eprintln!(" d. Run configured guard commands");
|
eprintln!(" d. Run configured guard commands");
|
||||||
eprintln!(" 4. Exit when STATUS: DONE and all guards pass");
|
eprintln!(" 4. Exit when STATUS: DONE and all guards pass");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET);
|
||||||
|
eprintln!(" 1. Load config from {}", CONF_PATH);
|
||||||
|
eprintln!(" 2. Backup protected files (protocol.md, task.md, judge.md, ci.conf)");
|
||||||
|
eprintln!(" 3. Per iteration:");
|
||||||
|
eprintln!(" a. Restore protected files, clear verdict");
|
||||||
|
eprintln!(" b. Invoke worker Claude");
|
||||||
|
eprintln!(" c. Run diff boundary check + configured guards");
|
||||||
|
eprintln!(" d. If guards pass, invoke judge (fresh Claude, zero context)");
|
||||||
|
eprintln!(" 4. Exit when judge returns VERDICT: PASS");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_init_help() {
|
||||||
|
eprintln!(
|
||||||
|
"{}{}yoke init{} — initialize .loop/ directory{}",
|
||||||
|
BOLD, CYAN, RESET, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}USAGE:{} yoke init [--runner <type>]",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}OPTIONS:{}", BOLD, RESET);
|
||||||
|
eprintln!(" --runner <type> Runner type: loop (default) or brute");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}RUNNER TYPES:{}", BOLD, RESET);
|
||||||
|
eprintln!(" {}loop{} (default) Staged plan runner. Creates:", BOLD, RESET);
|
||||||
|
eprintln!(" ci.conf, protocol.md, plan.md, notes.md, guard-results.md");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" {}brute{} Blind-judge runner. Creates:", BOLD, RESET);
|
||||||
|
eprintln!(" ci.conf, protocol.md, task.md, judge.md, notes.md, verdict.md");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Existing files are never overwritten.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holds loop state and cleans up on drop.
|
/// Holds loop state and cleans up on drop.
|
||||||
|
|
@ -124,6 +311,7 @@ impl Drop for LoopRunner {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
signal::set_child_pid(0);
|
||||||
// Remove backup directory
|
// Remove backup directory
|
||||||
let _ = fs::remove_dir_all(&self.backup_dir);
|
let _ = fs::remove_dir_all(&self.backup_dir);
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +338,7 @@ fn preflight() {
|
||||||
|
|
||||||
/// Copy protected files to a temp directory. Returns the backup path.
|
/// Copy protected files to a temp directory. Returns the backup path.
|
||||||
fn backup_protected() -> PathBuf {
|
fn backup_protected() -> PathBuf {
|
||||||
let backup_dir = std::env::temp_dir().join(format!("ci-backup-{}", process::id()));
|
let backup_dir = std::env::temp_dir().join(format!("yoke-backup-{}", process::id()));
|
||||||
if let Err(e) = fs::create_dir_all(&backup_dir) {
|
if let Err(e) = fs::create_dir_all(&backup_dir) {
|
||||||
log_error(&format!("failed to create backup dir: {}", e));
|
log_error(&format!("failed to create backup dir: {}", e));
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
|
|
@ -176,7 +364,7 @@ fn restore_protected(backup_dir: &Path) {
|
||||||
if backup_file.exists() {
|
if backup_file.exists() {
|
||||||
if let Err(e) = fs::copy(&backup_file, path) {
|
if let Err(e) = fs::copy(&backup_file, path) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}{}[ci] WARNING:{} failed to restore {}: {}",
|
"{}{}[yoke] WARNING:{} failed to restore {}: {}",
|
||||||
BOLD, YELLOW, RESET, path, e
|
BOLD, YELLOW, RESET, path, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -196,31 +384,95 @@ fn is_done() -> bool {
|
||||||
/// 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.
|
||||||
fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool {
|
fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool {
|
||||||
log(&format!("Launching Claude (iteration {})...", iteration));
|
let claude_args = [
|
||||||
|
|
||||||
let mut child = match Command::new(&config.claude_bin)
|
|
||||||
.args([
|
|
||||||
"--verbose",
|
"--verbose",
|
||||||
"--output-format",
|
"--output-format",
|
||||||
"stream-json",
|
"stream-json",
|
||||||
"--include-partial-messages",
|
"--include-partial-messages",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
"-p",
|
"-p",
|
||||||
"Read loop/protocol.md and follow its instructions.",
|
"Read .loop/protocol.md and follow its instructions.",
|
||||||
])
|
];
|
||||||
|
|
||||||
|
let mut cmd = if let Some(ref image) = config.image {
|
||||||
|
log(&format!(
|
||||||
|
"Launching Claude in container (iteration {})...",
|
||||||
|
iteration
|
||||||
|
));
|
||||||
|
|
||||||
|
let workdir = std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut c = Command::new("docker");
|
||||||
|
c.args([
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--network=host",
|
||||||
|
"--cap-add=NET_ADMIN",
|
||||||
|
"--cap-add=NET_RAW",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bind-mount the workspace
|
||||||
|
c.arg("-v").arg(format!("{}:/workspace", workdir));
|
||||||
|
c.arg("-w").arg("/workspace");
|
||||||
|
|
||||||
|
// Forward all CLAUDE_* and ANTHROPIC_* env vars
|
||||||
|
for (key, val) in std::env::vars() {
|
||||||
|
if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") {
|
||||||
|
c.arg("-e").arg(format!("{}={}", key, val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount host claude config/auth directory (read-write — Claude needs to write state)
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let home_path = PathBuf::from(&home);
|
||||||
|
let claude_dir = home_path.join(".claude");
|
||||||
|
if claude_dir.exists() {
|
||||||
|
c.arg("-v").arg(format!(
|
||||||
|
"{}:/home/node/.claude",
|
||||||
|
claude_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Mount .claude.json for onboarding/theme/user config
|
||||||
|
let claude_json = home_path.join(".claude.json");
|
||||||
|
if claude_json.exists() {
|
||||||
|
c.arg("-v").arg(format!(
|
||||||
|
"{}:/home/node/.claude.json",
|
||||||
|
claude_json.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.arg(image.as_str());
|
||||||
|
c.arg(&config.claude_bin);
|
||||||
|
c.args(claude_args);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
log(&format!("Launching Claude (iteration {})...", iteration));
|
||||||
|
let mut c = Command::new(&config.claude_bin);
|
||||||
|
c.args(claude_args);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut child = match cmd
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
.spawn()
|
.spawn()
|
||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_error(&format!(
|
let bin = if config.image.is_some() { "docker" } else { &config.claude_bin };
|
||||||
"failed to spawn '{}': {}",
|
log_error(&format!("failed to spawn '{}': {}", bin, e));
|
||||||
config.claude_bin, e
|
|
||||||
));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Register child PID so signal handler can kill it to unblock pipe reads
|
||||||
|
signal::set_child_pid(child.id() as i32);
|
||||||
|
|
||||||
// Compute log path
|
// Compute log path
|
||||||
let log_path = config.log_dir.as_ref().map(|dir| {
|
let log_path = config.log_dir.as_ref().map(|dir| {
|
||||||
std::path::PathBuf::from(dir).join(format!("iteration-{}.jsonl", iteration))
|
std::path::PathBuf::from(dir).join(format!("iteration-{}.jsonl", iteration))
|
||||||
|
|
@ -258,6 +510,7 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo
|
||||||
};
|
};
|
||||||
|
|
||||||
// Child has exited; clear it from runner
|
// Child has exited; clear it from runner
|
||||||
|
signal::set_child_pid(0);
|
||||||
runner.child = None;
|
runner.child = None;
|
||||||
|
|
||||||
log(&format!(
|
log(&format!(
|
||||||
|
|
@ -267,6 +520,170 @@ fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bo
|
||||||
status
|
status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invoke the judge — a fresh Claude with zero implementation context.
|
||||||
|
/// Returns true if the judge's verdict is PASS.
|
||||||
|
fn invoke_judge(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool {
|
||||||
|
let judge_bin = match &config.judge {
|
||||||
|
Some(bin) => bin.clone(),
|
||||||
|
None => {
|
||||||
|
log_error("no 'judge' directive in config — cannot invoke judge");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let judge_prompt = "\
|
||||||
|
Read .loop/judge.md. It describes what to verify.
|
||||||
|
Test the feature exactly as described. You have full shell access.
|
||||||
|
When done, write your verdict to .loop/verdict.md in this exact format:
|
||||||
|
|
||||||
|
VERDICT: PASS
|
||||||
|
|
||||||
|
<what you tested and what happened>
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
VERDICT: FAIL
|
||||||
|
|
||||||
|
<what you tested, what went wrong, and what the correct behavior should be>";
|
||||||
|
|
||||||
|
let claude_args = [
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--output-format",
|
||||||
|
"stream-json",
|
||||||
|
"-p",
|
||||||
|
judge_prompt,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut cmd = if let Some(ref image) = config.image {
|
||||||
|
log(&format!(
|
||||||
|
"Launching judge in container (iteration {})...",
|
||||||
|
iteration
|
||||||
|
));
|
||||||
|
|
||||||
|
let workdir = std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut c = Command::new("docker");
|
||||||
|
c.args([
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--network=host",
|
||||||
|
"--cap-add=NET_ADMIN",
|
||||||
|
"--cap-add=NET_RAW",
|
||||||
|
]);
|
||||||
|
c.arg("-v").arg(format!("{}:/workspace", workdir));
|
||||||
|
c.arg("-w").arg("/workspace");
|
||||||
|
|
||||||
|
for (key, val) in std::env::vars() {
|
||||||
|
if key.starts_with("CLAUDE_") || key.starts_with("ANTHROPIC_") {
|
||||||
|
c.arg("-e").arg(format!("{}={}", key, val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let home_path = PathBuf::from(&home);
|
||||||
|
let claude_dir = home_path.join(".claude");
|
||||||
|
if claude_dir.exists() {
|
||||||
|
c.arg("-v").arg(format!(
|
||||||
|
"{}:/home/node/.claude",
|
||||||
|
claude_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let claude_json = home_path.join(".claude.json");
|
||||||
|
if claude_json.exists() {
|
||||||
|
c.arg("-v").arg(format!(
|
||||||
|
"{}:/home/node/.claude.json",
|
||||||
|
claude_json.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.arg(image.as_str());
|
||||||
|
c.arg(&judge_bin);
|
||||||
|
c.args(claude_args);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
log(&format!("Launching judge (iteration {})...", iteration));
|
||||||
|
let mut c = Command::new(&judge_bin);
|
||||||
|
c.args(claude_args);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut child = match cmd
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let bin = if config.image.is_some() { "docker" } else { &judge_bin };
|
||||||
|
log_error(&format!("failed to spawn judge '{}': {}", bin, e));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal::set_child_pid(child.id() as i32);
|
||||||
|
|
||||||
|
let log_path = config.log_dir.as_ref().map(|dir| {
|
||||||
|
std::path::PathBuf::from(dir).join(format!("judge-{}.jsonl", iteration))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
runner.child = Some(child);
|
||||||
|
stream::filter_stream(stdout, log_path.as_deref());
|
||||||
|
if signal::interrupted() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runner.child = Some(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = if let Some(ref mut child) = runner.child {
|
||||||
|
match child.wait() {
|
||||||
|
Ok(s) => {
|
||||||
|
if signal::interrupted() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s.success()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error(&format!("failed to wait on judge process: {}", e));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
signal::set_child_pid(0);
|
||||||
|
runner.child = None;
|
||||||
|
|
||||||
|
if !status {
|
||||||
|
log_error("Judge process exited with failure");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read verdict
|
||||||
|
match fs::read_to_string(VERDICT_PATH) {
|
||||||
|
Ok(content) => {
|
||||||
|
let pass = content.starts_with("VERDICT: PASS");
|
||||||
|
if pass {
|
||||||
|
log(&format!("{}{}Judge verdict: PASS{}", GREEN, BOLD, RESET));
|
||||||
|
} else {
|
||||||
|
log(&format!("{}Judge verdict: FAIL{}", YELLOW, RESET));
|
||||||
|
}
|
||||||
|
pass
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error(&format!("failed to read {}: {}", VERDICT_PATH, e));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run boundary check and all configured guards.
|
/// Run boundary check and all configured guards.
|
||||||
/// Returns true if everything passed.
|
/// Returns true if everything passed.
|
||||||
fn run_all_guards(config: &Config) -> bool {
|
fn run_all_guards(config: &Config) -> bool {
|
||||||
|
|
@ -331,6 +748,56 @@ fn run_all_guards(config: &Config) -> bool {
|
||||||
all_passed
|
all_passed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init(runner: &str) -> i32 {
|
||||||
|
let loop_dir = Path::new(".loop");
|
||||||
|
if !loop_dir.exists() {
|
||||||
|
if let Err(e) = fs::create_dir(loop_dir) {
|
||||||
|
log_error(&format!("failed to create .loop/: {}", e));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
log("Created .loop/");
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: Vec<(&str, &str)> = match runner {
|
||||||
|
"loop" => vec![
|
||||||
|
(CONF_PATH, DEFAULT_GUARD_CONF),
|
||||||
|
(PROTOCOL_PATH, DEFAULT_PROTOCOL),
|
||||||
|
(PLAN_PATH, ""),
|
||||||
|
(NOTES_PATH, ""),
|
||||||
|
(GUARD_RESULTS_PATH, ""),
|
||||||
|
],
|
||||||
|
"brute" => vec![
|
||||||
|
(CONF_PATH, DEFAULT_BRUTE_CONF),
|
||||||
|
(PROTOCOL_PATH, DEFAULT_BRUTE_PROTOCOL),
|
||||||
|
(TASK_PATH, ""),
|
||||||
|
(JUDGE_PATH, ""),
|
||||||
|
(NOTES_PATH, ""),
|
||||||
|
(VERDICT_PATH, ""),
|
||||||
|
],
|
||||||
|
other => {
|
||||||
|
log_error(&format!("unknown runner type '{}'", other));
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log(&format!("Initializing '{}' runner...", runner));
|
||||||
|
|
||||||
|
for (path, content) in &files {
|
||||||
|
let p = Path::new(path);
|
||||||
|
if p.exists() {
|
||||||
|
log(&format!("skip (exists): {}", path));
|
||||||
|
} else {
|
||||||
|
if let Err(e) = fs::write(p, content) {
|
||||||
|
log_error(&format!("failed to write {}: {}", path, e));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
log(&format!("created: {}", path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
fn run_loop(dry_run: bool) -> i32 {
|
fn run_loop(dry_run: bool) -> i32 {
|
||||||
// Load config
|
// Load config
|
||||||
let config = match Config::load(Path::new(CONF_PATH)) {
|
let config = match Config::load(Path::new(CONF_PATH)) {
|
||||||
|
|
@ -342,11 +809,15 @@ fn run_loop(dry_run: bool) -> i32 {
|
||||||
};
|
};
|
||||||
|
|
||||||
log(&format!(
|
log(&format!(
|
||||||
"Config loaded: claude={}, max_tail={}, {} scope rules, {} guards",
|
"Config loaded: claude={}, max_tail={}, {} scope rules, {} guards{}",
|
||||||
config.claude_bin,
|
config.claude_bin,
|
||||||
config.max_tail,
|
config.max_tail,
|
||||||
config.scope_rules.len(),
|
config.scope_rules.len(),
|
||||||
config.guards.len()
|
config.guards.len(),
|
||||||
|
config.image.as_ref().map_or(
|
||||||
|
String::from(", sandbox=off"),
|
||||||
|
|img| format!(", image={}", img)
|
||||||
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Preflight
|
// Preflight
|
||||||
|
|
@ -388,8 +859,9 @@ 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 {
|
} else if !invoke_claude(&mut runner, &config, iteration) {
|
||||||
invoke_claude(&mut runner, &config, iteration);
|
log_error("Claude invocation failed — aborting loop");
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if signal::interrupted() {
|
if signal::interrupted() {
|
||||||
|
|
@ -436,6 +908,173 @@ fn run_loop(dry_run: bool) -> i32 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Protected files for the brute runner.
|
||||||
|
const BRUTE_PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH];
|
||||||
|
|
||||||
|
fn run_brute(dry_run: bool) -> i32 {
|
||||||
|
// Load config
|
||||||
|
let config = match Config::load(Path::new(CONF_PATH)) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(&e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log(&format!(
|
||||||
|
"Config loaded: runner=brute, claude={}, judge={}, max_tail={}, {} scope rules, {} guards{}",
|
||||||
|
config.claude_bin,
|
||||||
|
config.judge.as_deref().unwrap_or("(none)"),
|
||||||
|
config.max_tail,
|
||||||
|
config.scope_rules.len(),
|
||||||
|
config.guards.len(),
|
||||||
|
config.image.as_ref().map_or(
|
||||||
|
String::from(", sandbox=off"),
|
||||||
|
|img| format!(", image={}", img)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Preflight: require protocol.md, task.md, judge.md, ci.conf
|
||||||
|
for path in &[PROTOCOL_PATH, TASK_PATH, JUDGE_PATH, CONF_PATH] {
|
||||||
|
if !Path::new(path).exists() {
|
||||||
|
log_error(&format!("required file not found: {}", path));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notes.md and verdict.md if missing
|
||||||
|
for path in &[NOTES_PATH, VERDICT_PATH] {
|
||||||
|
if !Path::new(path).exists() {
|
||||||
|
if let Err(e) = fs::write(path, "") {
|
||||||
|
log_error(&format!("cannot create {}: {}", path, e));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear verdict
|
||||||
|
let _ = fs::write(VERDICT_PATH, "");
|
||||||
|
|
||||||
|
log("Preflight OK");
|
||||||
|
|
||||||
|
// Backup protected files
|
||||||
|
let backup_dir = std::env::temp_dir().join(format!("yoke-backup-{}", process::id()));
|
||||||
|
if let Err(e) = fs::create_dir_all(&backup_dir) {
|
||||||
|
log_error(&format!("failed to create backup dir: {}", e));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
for path in BRUTE_PROTECTED_FILES {
|
||||||
|
let src = Path::new(path);
|
||||||
|
if src.exists() {
|
||||||
|
let dest = backup_dir.join(src.file_name().unwrap());
|
||||||
|
if let Err(e) = fs::copy(src, &dest) {
|
||||||
|
log_error(&format!("failed to backup {}: {}", path, e));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(&format!("Backups in {}", backup_dir.display()));
|
||||||
|
|
||||||
|
let mut runner = LoopRunner::new(backup_dir);
|
||||||
|
let mut iteration: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if signal::interrupted() {
|
||||||
|
log("Interrupted \u{2014} shutting down");
|
||||||
|
return 130;
|
||||||
|
}
|
||||||
|
iteration += 1;
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{}╔══════════════════════════════════════╗{}",
|
||||||
|
BOLD, CYAN, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}║ Brute Iteration {:>4} ║{}",
|
||||||
|
BOLD, CYAN, iteration, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}╚══════════════════════════════════════╝{}",
|
||||||
|
BOLD, CYAN, RESET
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore protected files
|
||||||
|
for path in BRUTE_PROTECTED_FILES {
|
||||||
|
let src_name = Path::new(path).file_name().unwrap();
|
||||||
|
let backup_file = runner.backup_dir.join(src_name);
|
||||||
|
if backup_file.exists() {
|
||||||
|
if let Err(e) = fs::copy(&backup_file, path) {
|
||||||
|
eprintln!(
|
||||||
|
"{}{}[yoke] WARNING:{} failed to restore {}: {}",
|
||||||
|
BOLD, YELLOW, RESET, path, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear verdict
|
||||||
|
let _ = fs::write(VERDICT_PATH, "");
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
log("(dry-run) Skipping Claude invocation");
|
||||||
|
} else if !invoke_claude(&mut runner, &config, iteration) {
|
||||||
|
log_error("Claude invocation failed — aborting loop");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if signal::interrupted() {
|
||||||
|
log("Interrupted \u{2014} shutting down");
|
||||||
|
return 130;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run guards
|
||||||
|
let guards_passed = run_all_guards(&config);
|
||||||
|
|
||||||
|
if !guards_passed {
|
||||||
|
log(&format!(
|
||||||
|
"{}Guards failed \u{2014} worker will see results next iteration{}",
|
||||||
|
YELLOW, RESET
|
||||||
|
));
|
||||||
|
if dry_run {
|
||||||
|
log("(dry-run) Exiting after one iteration");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(&format!("{}{}All guards passed{}", GREEN, BOLD, RESET));
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
log("(dry-run) Guards passed \u{2014} exiting after one iteration");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guards passed — invoke judge
|
||||||
|
log("Guards passed \u{2014} invoking judge...");
|
||||||
|
let pass = invoke_judge(&mut runner, &config, iteration);
|
||||||
|
|
||||||
|
if signal::interrupted() {
|
||||||
|
log("Interrupted \u{2014} shutting down");
|
||||||
|
return 130;
|
||||||
|
}
|
||||||
|
|
||||||
|
if pass {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{} Judge says PASS \u{2014} brute loop complete {}",
|
||||||
|
GREEN, BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(&format!(
|
||||||
|
"{}Judge says FAIL \u{2014} worker will see verdict next iteration{}",
|
||||||
|
YELLOW, RESET
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
signal::install();
|
signal::install();
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
@ -446,6 +1085,33 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
match args[1].as_str() {
|
match args[1].as_str() {
|
||||||
|
"init" => {
|
||||||
|
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
|
||||||
|
print_init_help();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut runner = "loop";
|
||||||
|
let mut i = 2;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--runner" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
log_error("--runner requires a value (loop or brute)");
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
runner = &args[i];
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
log_error(&format!("unknown flag '{}'", other));
|
||||||
|
print_init_help();
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
process::exit(init(runner));
|
||||||
|
}
|
||||||
"run" => {
|
"run" => {
|
||||||
// Check for --help before other flags
|
// Check for --help before other flags
|
||||||
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
|
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
|
||||||
|
|
@ -458,7 +1124,22 @@ fn main() {
|
||||||
print_run_help();
|
print_run_help();
|
||||||
process::exit(2);
|
process::exit(2);
|
||||||
}
|
}
|
||||||
process::exit(run_loop(dry_run));
|
// Load config to determine runner type
|
||||||
|
let config = match Config::load(Path::new(CONF_PATH)) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(&e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match config.runner.as_str() {
|
||||||
|
"loop" => process::exit(run_loop(dry_run)),
|
||||||
|
"brute" => process::exit(run_brute(dry_run)),
|
||||||
|
other => {
|
||||||
|
log_error(&format!("unknown runner type '{}' in config", other));
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"--help" | "-h" | "help" => {
|
"--help" | "-h" | "help" => {
|
||||||
print_usage();
|
print_usage();
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||||
|
|
||||||
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
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);
|
INTERRUPTED.store(true, Ordering::Relaxed);
|
||||||
|
// Kill the child to close the pipe and unblock reader.lines().
|
||||||
|
// Rust's BufReader retries on EINTR internally, so the only way
|
||||||
|
// to break out of the blocking read is to close the write end.
|
||||||
|
let pid = CHILD_PID.load(Ordering::Relaxed);
|
||||||
|
if pid > 0 {
|
||||||
|
kill(pid, 9); // SIGKILL — child already got SIGINT from the terminal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe extern "C" {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install() {
|
pub fn install() {
|
||||||
signal(2, sigint_handler); // SIGINT = 2
|
signal(2, sigint_handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_child_pid(pid: i32) {
|
||||||
|
CHILD_PID.store(pid, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn interrupted() -> bool {
|
pub fn interrupted() -> bool {
|
||||||
|
|
|
||||||
|
|
@ -135,15 +135,42 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) {
|
||||||
} else if let Some(text) = extract_str(&line, "text") {
|
} else if let Some(text) = extract_str(&line, "text") {
|
||||||
let trimmed = text.trim();
|
let trimmed = text.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
for text_line in trimmed.lines().take(3) {
|
for text_line in trimmed.lines() {
|
||||||
let truncated = if text_line.len() > 120 {
|
println!("{} {}{}", DIM, text_line, RESET);
|
||||||
&text_line[..120]
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// stream_event → streaming deltas
|
||||||
|
Some("stream_event") => {
|
||||||
|
if line.contains("\"content_block_delta\"") {
|
||||||
|
if line.contains("\"text_delta\"") {
|
||||||
|
if let Some(text) = extract_str(&line, "text") {
|
||||||
|
print!("{}{}{}", DIM, text, RESET);
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// input_json_delta → skip silently
|
||||||
|
} else if line.contains("\"content_block_start\"") {
|
||||||
|
if !line.contains("\"tool_use\"") {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
} else if line.contains("\"content_block_stop\"") {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
// message_start, message_delta, message_stop → skip
|
||||||
|
}
|
||||||
|
// user → tool result summaries
|
||||||
|
Some("user") => {
|
||||||
|
if line.contains("\"tool_result\"") {
|
||||||
|
// Estimate content length from the line
|
||||||
|
let content_len = if let Some(start) = line.find("\"content\":\"") {
|
||||||
|
let after = &line[start + 11..];
|
||||||
|
after.find('"').unwrap_or(after.len())
|
||||||
} else {
|
} else {
|
||||||
text_line
|
0
|
||||||
};
|
};
|
||||||
println!("{} {}{}", DIM, truncated, RESET);
|
println!("{} \u{2190} result ({}b){}", DIM, content_len, RESET);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// result → green bold summary
|
// result → green bold summary
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue