init
This commit is contained in:
commit
9b6124ec6d
15 changed files with 1387 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
target/*
|
||||||
7
Cargo.lock
generated
Normal file
7
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.1.0"
|
||||||
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "yoke"
|
||||||
|
path = "src/main.rs"
|
||||||
17
loop/ci.conf
Normal file
17
loop/ci.conf
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 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
|
||||||
0
loop/guard-results.md
Normal file
0
loop/guard-results.md
Normal file
0
loop/notes.md
Normal file
0
loop/notes.md
Normal file
145
loop/plan.md
Normal file
145
loop/plan.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
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
|
||||||
64
loop/protocol.md
Normal file
64
loop/protocol.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# 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.
|
||||||
147
src/boundary.rs
Normal file
147
src/boundary.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::config::{Config, ScopeTag};
|
||||||
|
|
||||||
|
pub struct BoundaryResult {
|
||||||
|
pub passed: bool,
|
||||||
|
pub violations: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all changed files from git (staged, unstaged, and untracked).
|
||||||
|
/// Returns (changed_files, deleted_files, modified_files).
|
||||||
|
fn collect_changes() -> Result<(Vec<String>, Vec<String>, Vec<String>), String> {
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
let mut deleted = Vec::new();
|
||||||
|
let mut modified = Vec::new();
|
||||||
|
|
||||||
|
// git diff --name-status HEAD
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["diff", "--name-status", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run git diff: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = line.split('\t');
|
||||||
|
let status = match parts.next() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let file = match parts.next() {
|
||||||
|
Some(f) => f.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match status {
|
||||||
|
"D" => {
|
||||||
|
deleted.push(file.clone());
|
||||||
|
changed.push(file);
|
||||||
|
}
|
||||||
|
"M" => {
|
||||||
|
modified.push(file.clone());
|
||||||
|
changed.push(file);
|
||||||
|
}
|
||||||
|
s if s.starts_with('R') => {
|
||||||
|
// Rename: old path treated as delete, new path as add
|
||||||
|
deleted.push(file.clone());
|
||||||
|
changed.push(file);
|
||||||
|
if let Some(new_file) = parts.next() {
|
||||||
|
changed.push(new_file.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// A (added) or other statuses
|
||||||
|
changed.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// git ls-files --others --exclude-standard (untracked files)
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["ls-files", "--others", "--exclude-standard"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run git ls-files: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let file = line.trim();
|
||||||
|
if !file.is_empty() {
|
||||||
|
changed.push(file.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((changed, deleted, modified))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a file's diff contains removed lines (lines starting with '-' but not '---').
|
||||||
|
fn has_removed_lines(file: &str) -> bool {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["diff", "HEAD", "--", file])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(o) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if line.starts_with('-') && !line.starts_with("---") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the diff boundary check against the config's scope rules.
|
||||||
|
pub fn check(config: &Config) -> BoundaryResult {
|
||||||
|
let (changed, deleted, modified) = match collect_changes() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return BoundaryResult {
|
||||||
|
passed: false,
|
||||||
|
violations: vec![format!("failed to collect changes: {}", e)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut violations = Vec::new();
|
||||||
|
|
||||||
|
for file in &changed {
|
||||||
|
let tag = config.resolve_tag(file);
|
||||||
|
|
||||||
|
match tag {
|
||||||
|
None => {
|
||||||
|
violations.push(format!("DENY: {} (no matching rule)", file));
|
||||||
|
}
|
||||||
|
Some(ScopeTag::NoModify) => {
|
||||||
|
violations.push(format!("NO-MODIFY: {} (protected by no-modify)", file));
|
||||||
|
}
|
||||||
|
Some(ScopeTag::AddOnly) => {
|
||||||
|
// Deleted files are never add-only safe
|
||||||
|
if deleted.iter().any(|d| d == file) {
|
||||||
|
violations.push(format!("ADD-ONLY: {} (deletion not allowed)", file));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Modified tracked files: check for removed lines
|
||||||
|
if modified.iter().any(|m| m == file) && has_removed_lines(file) {
|
||||||
|
violations.push(format!("ADD-ONLY: {} (removed lines detected)", file));
|
||||||
|
}
|
||||||
|
// New/untracked files are purely additive — always OK
|
||||||
|
}
|
||||||
|
Some(ScopeTag::Allow) => {
|
||||||
|
// anything goes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoundaryResult {
|
||||||
|
passed: violations.is_empty(),
|
||||||
|
violations,
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/config.rs
Normal file
132
src/config.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ScopeTag {
|
||||||
|
Allow,
|
||||||
|
AddOnly,
|
||||||
|
NoModify,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScopeRule {
|
||||||
|
pub tag: ScopeTag,
|
||||||
|
pub prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub claude_bin: String,
|
||||||
|
pub max_tail: usize,
|
||||||
|
pub log_dir: Option<String>,
|
||||||
|
pub scope_rules: Vec<ScopeRule>,
|
||||||
|
pub guards: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: &Path) -> Result<Config, String> {
|
||||||
|
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 scope_rules = Vec::new();
|
||||||
|
let mut guards = Vec::new();
|
||||||
|
|
||||||
|
for (line_num, raw_line) in content.lines().enumerate() {
|
||||||
|
// Strip comments
|
||||||
|
let line = match raw_line.find('#') {
|
||||||
|
Some(pos) => &raw_line[..pos],
|
||||||
|
None => raw_line,
|
||||||
|
};
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into directive and value at first whitespace
|
||||||
|
let (directive, value) = match line.find(char::is_whitespace) {
|
||||||
|
Some(pos) => (&line[..pos], line[pos..].trim_start()),
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"{}:{}: directive '{}' has no value",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
line
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match directive {
|
||||||
|
"claude" => claude_bin = value.to_string(),
|
||||||
|
"max-tail" => {
|
||||||
|
max_tail = value.parse::<usize>().map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"{}:{}: invalid max-tail value '{}'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"log-dir" => {
|
||||||
|
log_dir = Some(value.to_string());
|
||||||
|
}
|
||||||
|
"allow" => scope_rules.push(ScopeRule {
|
||||||
|
tag: ScopeTag::Allow,
|
||||||
|
prefix: value.to_string(),
|
||||||
|
}),
|
||||||
|
"add-only" => scope_rules.push(ScopeRule {
|
||||||
|
tag: ScopeTag::AddOnly,
|
||||||
|
prefix: value.to_string(),
|
||||||
|
}),
|
||||||
|
"no-modify" => scope_rules.push(ScopeRule {
|
||||||
|
tag: ScopeTag::NoModify,
|
||||||
|
prefix: value.to_string(),
|
||||||
|
}),
|
||||||
|
"guard" => guards.push(value.to_string()),
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"{}:{}: unknown directive '{}'",
|
||||||
|
path.display(),
|
||||||
|
line_num + 1,
|
||||||
|
other
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
claude_bin,
|
||||||
|
max_tail,
|
||||||
|
log_dir,
|
||||||
|
scope_rules,
|
||||||
|
guards,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the most-specific scope tag for a file path.
|
||||||
|
/// Returns None if no rule matches.
|
||||||
|
pub fn resolve_tag(&self, file: &str) -> Option<&ScopeTag> {
|
||||||
|
let mut best_tag: Option<&ScopeTag> = None;
|
||||||
|
let mut best_len: usize = 0;
|
||||||
|
|
||||||
|
for rule in &self.scope_rules {
|
||||||
|
let matches = if rule.prefix == "." {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
file.starts_with(&rule.prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches && rule.prefix.len() > best_len {
|
||||||
|
best_len = rule.prefix.len();
|
||||||
|
best_tag = Some(&rule.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "." has length 1 but should match everything — already handled above.
|
||||||
|
// If only "." matched, best_len is 1, which is correct.
|
||||||
|
best_tag
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/guard.rs
Normal file
91
src/guard.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub struct GuardResult {
|
||||||
|
pub name: String,
|
||||||
|
pub passed: bool,
|
||||||
|
pub output: String,
|
||||||
|
pub skipped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only the last `max` lines of text.
|
||||||
|
fn tail_lines(text: &str, max: usize) -> String {
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
if lines.len() <= max {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
lines[lines.len() - max..].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all configured guard commands in order (fail-fast).
|
||||||
|
/// Writes results to `results_path` in markdown format.
|
||||||
|
pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec<GuardResult> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut markdown = String::new();
|
||||||
|
let mut failed = false;
|
||||||
|
|
||||||
|
for cmd in guards {
|
||||||
|
if failed {
|
||||||
|
let result = GuardResult {
|
||||||
|
name: cmd.clone(),
|
||||||
|
passed: false,
|
||||||
|
output: String::new(),
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
markdown.push_str(&format!("## {} — SKIPPED\n\n", cmd));
|
||||||
|
markdown.push_str("```\nSkipped due to earlier guard failure.\n```\n\n");
|
||||||
|
results.push(result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(cmd)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let (exit_ok, raw_output) = match output {
|
||||||
|
Ok(o) => {
|
||||||
|
let mut combined = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||||
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
if !combined.is_empty() && !combined.ends_with('\n') {
|
||||||
|
combined.push('\n');
|
||||||
|
}
|
||||||
|
combined.push_str(&stderr);
|
||||||
|
}
|
||||||
|
(o.status.success(), combined)
|
||||||
|
}
|
||||||
|
Err(e) => (false, format!("failed to execute: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let truncated = tail_lines(&raw_output, max_tail);
|
||||||
|
let status_label = if exit_ok { "PASS" } else { "FAIL" };
|
||||||
|
|
||||||
|
markdown.push_str(&format!("## {} — {}\n\n", cmd, status_label));
|
||||||
|
markdown.push_str("```\n");
|
||||||
|
markdown.push_str(&truncated);
|
||||||
|
if !truncated.is_empty() && !truncated.ends_with('\n') {
|
||||||
|
markdown.push('\n');
|
||||||
|
}
|
||||||
|
markdown.push_str("```\n\n");
|
||||||
|
|
||||||
|
if !exit_ok {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(GuardResult {
|
||||||
|
name: cmd.clone(),
|
||||||
|
passed: exit_ok,
|
||||||
|
output: truncated,
|
||||||
|
skipped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write results file
|
||||||
|
if let Err(e) = fs::write(results_path, &markdown) {
|
||||||
|
eprintln!("[ci] WARNING: failed to write guard results: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
116
src/json.rs
Normal file
116
src/json.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/// 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.
|
||||||
|
pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
||||||
|
let needle = {
|
||||||
|
let mut pat = String::with_capacity(key.len() + 3);
|
||||||
|
pat.push('"');
|
||||||
|
pat.push_str(key);
|
||||||
|
pat.push('"');
|
||||||
|
pat
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut search_from = 0;
|
||||||
|
loop {
|
||||||
|
let rel_pos = line[search_from..].find(&needle)?;
|
||||||
|
let key_start = search_from + rel_pos;
|
||||||
|
let after_key = key_start + needle.len();
|
||||||
|
let rest = &line[after_key..];
|
||||||
|
|
||||||
|
// Skip whitespace and colon
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let rest = match rest.strip_prefix(':') {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
// This was a value, not a key — advance and retry
|
||||||
|
search_from = after_key;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
|
||||||
|
// Expect opening quote
|
||||||
|
let rest = match rest.strip_prefix('"') {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
search_from = after_key;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find closing quote (handle escaped quotes)
|
||||||
|
let mut end = 0;
|
||||||
|
let bytes = rest.as_bytes();
|
||||||
|
while end < bytes.len() {
|
||||||
|
if bytes[end] == b'\\' {
|
||||||
|
end += 2;
|
||||||
|
} else if bytes[end] == b'"' {
|
||||||
|
return Some(&rest[..end]);
|
||||||
|
} else {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a numeric value for a given key from a flat JSON line.
|
||||||
|
/// Looks for `"key": 123.45` and returns the number.
|
||||||
|
/// Returns `None` if the key is not found or the value is not a number.
|
||||||
|
pub fn extract_num(line: &str, key: &str) -> Option<f64> {
|
||||||
|
let needle = {
|
||||||
|
let mut pat = String::with_capacity(key.len() + 3);
|
||||||
|
pat.push('"');
|
||||||
|
pat.push_str(key);
|
||||||
|
pat.push('"');
|
||||||
|
pat
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_start = line.find(&needle)?;
|
||||||
|
let after_key = key_start + needle.len();
|
||||||
|
let rest = &line[after_key..];
|
||||||
|
|
||||||
|
// Skip whitespace and colon
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let rest = rest.strip_prefix(':')?;
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
|
||||||
|
// Collect numeric chars: digits, '.', '-', '+', 'e', 'E'
|
||||||
|
let num_end = rest
|
||||||
|
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+' && c != 'e' && c != 'E')
|
||||||
|
.unwrap_or(rest.len());
|
||||||
|
|
||||||
|
if num_end == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
rest[..num_end].parse::<f64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_str_basic() {
|
||||||
|
let line = r#"{"type": "assistant", "subtype": "text"}"#;
|
||||||
|
assert_eq!(extract_str(line, "type"), Some("assistant"));
|
||||||
|
assert_eq!(extract_str(line, "subtype"), Some("text"));
|
||||||
|
assert_eq!(extract_str(line, "missing"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_str_escaped() {
|
||||||
|
let line = r#"{"path": "foo\"bar"}"#;
|
||||||
|
assert_eq!(extract_str(line, "path"), Some(r#"foo\"bar"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_num_basic() {
|
||||||
|
let line = r#"{"cost_usd": 0.42, "num_turns": 5}"#;
|
||||||
|
assert!((extract_num(line, "cost_usd").unwrap() - 0.42).abs() < 1e-10);
|
||||||
|
assert!((extract_num(line, "num_turns").unwrap() - 5.0).abs() < 1e-10);
|
||||||
|
assert_eq!(extract_num(line, "missing"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
472
src/main.rs
Normal file
472
src/main.rs
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
mod boundary;
|
||||||
|
mod config;
|
||||||
|
mod guard;
|
||||||
|
mod json;
|
||||||
|
mod signal;
|
||||||
|
mod stream;
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{self, Child, Command, Stdio};
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
|
const CONF_PATH: &str = "loop/ci.conf";
|
||||||
|
const NOTES_PATH: &str = "loop/notes.md";
|
||||||
|
const GUARD_RESULTS_PATH: &str = "loop/guard-results.md";
|
||||||
|
const PROTOCOL_PATH: &str = "loop/protocol.md";
|
||||||
|
const PLAN_PATH: &str = "loop/plan.md";
|
||||||
|
|
||||||
|
/// Files that Claude must not be allowed to permanently alter.
|
||||||
|
const PROTECTED_FILES: &[&str] = &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH];
|
||||||
|
|
||||||
|
// ANSI helpers
|
||||||
|
const RESET: &str = "\x1b[0m";
|
||||||
|
const BOLD: &str = "\x1b[1m";
|
||||||
|
const CYAN: &str = "\x1b[36m";
|
||||||
|
const GREEN: &str = "\x1b[32m";
|
||||||
|
const RED: &str = "\x1b[31m";
|
||||||
|
const YELLOW: &str = "\x1b[33m";
|
||||||
|
const DIM: &str = "\x1b[2m";
|
||||||
|
|
||||||
|
fn log(msg: &str) {
|
||||||
|
eprintln!("{}{}[ci]{} {}", BOLD, CYAN, RESET, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_error(msg: &str) {
|
||||||
|
eprintln!("{}{}[ci] ERROR:{} {}", BOLD, RED, RESET, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_usage() {
|
||||||
|
eprintln!(
|
||||||
|
"{}{}ci{} — LLM loop harness{}",
|
||||||
|
BOLD, CYAN, RESET, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}USAGE:{} ci <command> [options]",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}COMMANDS:{}", BOLD, RESET);
|
||||||
|
eprintln!(
|
||||||
|
" {}run{} Launch the CI loop (invoke Claude, run guards, iterate)",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
" {}run --dry-run{} Single iteration: boundary check + guards only, no Claude",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}OPTIONS:{}", BOLD, RESET);
|
||||||
|
eprintln!(" --help, -h Show this help message");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}CONFIG:{} {}{}",
|
||||||
|
BOLD, RESET, DIM, CONF_PATH
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}EXIT:{} 0=success 1=guard failure 2=usage error",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_run_help() {
|
||||||
|
eprintln!(
|
||||||
|
"{}{}ci run{} — execute the CI loop{}",
|
||||||
|
BOLD, CYAN, RESET, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}USAGE:{} ci run [--dry-run]",
|
||||||
|
BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}OPTIONS:{}", BOLD, RESET);
|
||||||
|
eprintln!(" --dry-run Run one iteration without invoking Claude");
|
||||||
|
eprintln!(" (boundary check + configured guards only)");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("{}WORKFLOW:{}", BOLD, RESET);
|
||||||
|
eprintln!(" 1. Load config from {}", CONF_PATH);
|
||||||
|
eprintln!(" 2. Backup protected files (protocol.md, plan.md, ci.conf)");
|
||||||
|
eprintln!(" 3. Per iteration:");
|
||||||
|
eprintln!(" a. Restore protected files");
|
||||||
|
eprintln!(" b. Invoke Claude with stream-json output");
|
||||||
|
eprintln!(" c. Run diff boundary check");
|
||||||
|
eprintln!(" d. Run configured guard commands");
|
||||||
|
eprintln!(" 4. Exit when STATUS: DONE and all guards pass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds loop state and cleans up on drop.
|
||||||
|
///
|
||||||
|
/// Drop kills any running Claude child process and removes the backup
|
||||||
|
/// directory. Note: Drop does not run on raw SIGINT — the child process
|
||||||
|
/// shares the process group and receives the signal directly, and the
|
||||||
|
/// backup temp dir is small and inconsequential.
|
||||||
|
struct LoopRunner {
|
||||||
|
backup_dir: PathBuf,
|
||||||
|
child: Option<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoopRunner {
|
||||||
|
fn new(backup_dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
backup_dir,
|
||||||
|
child: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LoopRunner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Kill any running child process
|
||||||
|
if let Some(ref mut child) = self.child {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
// Remove backup directory
|
||||||
|
let _ = fs::remove_dir_all(&self.backup_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that required loop files exist, create notes if missing.
|
||||||
|
fn preflight() {
|
||||||
|
for path in &[PROTOCOL_PATH, PLAN_PATH, CONF_PATH] {
|
||||||
|
if !Path::new(path).exists() {
|
||||||
|
log_error(&format!("required file not found: {}", path));
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure notes file exists
|
||||||
|
if !Path::new(NOTES_PATH).exists() {
|
||||||
|
if 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, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy protected files to a temp directory. Returns the backup path.
|
||||||
|
fn backup_protected() -> PathBuf {
|
||||||
|
let backup_dir = std::env::temp_dir().join(format!("ci-backup-{}", process::id()));
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backup_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore protected files from backup.
|
||||||
|
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) {
|
||||||
|
eprintln!(
|
||||||
|
"{}{}[ci] WARNING:{} failed to restore {}: {}",
|
||||||
|
BOLD, YELLOW, RESET, path, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check the first line of notes.md for STATUS: DONE.
|
||||||
|
fn is_done() -> bool {
|
||||||
|
match fs::read_to_string(NOTES_PATH) {
|
||||||
|
Ok(content) => content.starts_with("STATUS: DONE"),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn invoke_claude(runner: &mut LoopRunner, config: &Config, iteration: u32) -> bool {
|
||||||
|
log(&format!("Launching Claude (iteration {})...", iteration));
|
||||||
|
|
||||||
|
let mut child = match Command::new(&config.claude_bin)
|
||||||
|
.args([
|
||||||
|
"--verbose",
|
||||||
|
"--output-format",
|
||||||
|
"stream-json",
|
||||||
|
"--include-partial-messages",
|
||||||
|
"-p",
|
||||||
|
"Read loop/protocol.md and follow its instructions.",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(&format!(
|
||||||
|
"failed to spawn '{}': {}",
|
||||||
|
config.claude_bin, e
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute log path
|
||||||
|
let log_path = config.log_dir.as_ref().map(|dir| {
|
||||||
|
std::path::PathBuf::from(dir).join(format!("iteration-{}.jsonl", iteration))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Take stdout and feed through stream filter
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
// Store child in runner before blocking on stream filter,
|
||||||
|
// so Drop can kill it if we're interrupted.
|
||||||
|
runner.child = Some(child);
|
||||||
|
stream::filter_stream(stdout, log_path.as_deref());
|
||||||
|
if signal::interrupted() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runner.child = Some(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the child to exit
|
||||||
|
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 claude process: {}", e));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Child has exited; clear it from runner
|
||||||
|
runner.child = None;
|
||||||
|
|
||||||
|
log(&format!(
|
||||||
|
"Claude exited ({})",
|
||||||
|
if status { "success" } else { "failure" }
|
||||||
|
));
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run boundary check and all configured guards.
|
||||||
|
/// Returns true if everything passed.
|
||||||
|
fn run_all_guards(config: &Config) -> bool {
|
||||||
|
// Boundary check first
|
||||||
|
log("Running diff boundary check...");
|
||||||
|
let boundary = boundary::check(config);
|
||||||
|
|
||||||
|
if boundary.passed {
|
||||||
|
log(&format!("{} Boundary check: PASS{}", GREEN, RESET));
|
||||||
|
} else {
|
||||||
|
log(&format!(
|
||||||
|
"{} Boundary check: FAIL ({} violation(s)){}",
|
||||||
|
RED,
|
||||||
|
boundary.violations.len(),
|
||||||
|
RESET
|
||||||
|
));
|
||||||
|
for v in &boundary.violations {
|
||||||
|
eprintln!(" {}", v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write boundary failure to guard results, mark configured guards as skipped
|
||||||
|
let mut md = String::new();
|
||||||
|
md.push_str("## diff boundary check \u{2014} FAIL\n\n```\n");
|
||||||
|
for v in &boundary.violations {
|
||||||
|
md.push_str(v);
|
||||||
|
md.push('\n');
|
||||||
|
}
|
||||||
|
md.push_str("```\n\n");
|
||||||
|
for g in &config.guards {
|
||||||
|
md.push_str(&format!("## {} \u{2014} SKIPPED\n\n", g));
|
||||||
|
md.push_str("```\nSkipped due to diff boundary violation.\n```\n\n");
|
||||||
|
}
|
||||||
|
let _ = fs::write(GUARD_RESULTS_PATH, &md);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundary passed — write that to results then run configured guards
|
||||||
|
let results_path = Path::new(GUARD_RESULTS_PATH);
|
||||||
|
|
||||||
|
let boundary_md = "## diff boundary check \u{2014} PASS\n\n```\nAll changed files are within allowed boundaries.\n```\n\n";
|
||||||
|
|
||||||
|
log("Running configured guards...");
|
||||||
|
let guard_results = guard::run_guards(&config.guards, config.max_tail, results_path);
|
||||||
|
|
||||||
|
// Prepend boundary result to whatever guard::run_guards wrote
|
||||||
|
let existing = fs::read_to_string(results_path).unwrap_or_default();
|
||||||
|
let combined = format!("{}{}", boundary_md, existing);
|
||||||
|
let _ = fs::write(results_path, combined);
|
||||||
|
|
||||||
|
let all_passed = guard_results.iter().all(|r| r.passed);
|
||||||
|
for r in &guard_results {
|
||||||
|
let status = if r.skipped {
|
||||||
|
format!("{}SKIPPED{}", YELLOW, RESET)
|
||||||
|
} else if r.passed {
|
||||||
|
format!("{}PASS{}", GREEN, RESET)
|
||||||
|
} else {
|
||||||
|
format!("{}FAIL{}", RED, RESET)
|
||||||
|
};
|
||||||
|
log(&format!(" {}: {}", r.name, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
all_passed
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Preflight
|
||||||
|
preflight();
|
||||||
|
log("Preflight OK");
|
||||||
|
|
||||||
|
// Backup protected files and create the runner (Drop handles cleanup)
|
||||||
|
let backup_dir = backup_protected();
|
||||||
|
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!(
|
||||||
|
"{}{}║ Iteration {:>4} ║{}",
|
||||||
|
BOLD, CYAN, iteration, RESET
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}{}╚══════════════════════════════════════╝{}",
|
||||||
|
BOLD, CYAN, RESET
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore protected files
|
||||||
|
restore_protected(&runner.backup_dir);
|
||||||
|
|
||||||
|
// Clear guard results
|
||||||
|
let _ = fs::write(GUARD_RESULTS_PATH, "");
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
log("(dry-run) Skipping Claude invocation");
|
||||||
|
} else {
|
||||||
|
invoke_claude(&mut runner, &config, iteration);
|
||||||
|
}
|
||||||
|
|
||||||
|
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!(
|
||||||
|
"{}{}All guards passed{}",
|
||||||
|
GREEN, BOLD, RESET
|
||||||
|
));
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
log("(dry-run) Guards passed \u{2014} exiting after one iteration");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_done() {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
"{}{} STATUS: DONE and all guards pass \u{2014} loop complete {}",
|
||||||
|
GREEN, BOLD, RESET
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
log("Guards passed but STATUS is not DONE \u{2014} continuing");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(&format!(
|
||||||
|
"{}Some guards failed \u{2014} Claude will see results next iteration{}",
|
||||||
|
YELLOW, RESET
|
||||||
|
));
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
log("(dry-run) Exiting after one iteration");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
signal::install();
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
if args.len() < 2 {
|
||||||
|
print_usage();
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
match args[1].as_str() {
|
||||||
|
"run" => {
|
||||||
|
// Check for --help before other flags
|
||||||
|
if args.get(2).is_some_and(|a| a == "--help" || a == "-h") {
|
||||||
|
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]));
|
||||||
|
print_run_help();
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
process::exit(run_loop(dry_run));
|
||||||
|
}
|
||||||
|
"--help" | "-h" | "help" => {
|
||||||
|
print_usage();
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
log_error(&format!("unknown command '{}'", other));
|
||||||
|
print_usage();
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/signal.rs
Normal file
19
src/signal.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
extern "C" fn sigint_handler(_sig: i32) {
|
||||||
|
INTERRUPTED.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
safe fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install() {
|
||||||
|
signal(2, sigint_handler); // SIGINT = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interrupted() -> bool {
|
||||||
|
INTERRUPTED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
168
src/stream.rs
Normal file
168
src/stream.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::ChildStdout;
|
||||||
|
|
||||||
|
use crate::json::{extract_num, extract_str};
|
||||||
|
|
||||||
|
// ANSI escape codes
|
||||||
|
const RESET: &str = "\x1b[0m";
|
||||||
|
const BOLD: &str = "\x1b[1m";
|
||||||
|
const DIM: &str = "\x1b[2m";
|
||||||
|
const CYAN: &str = "\x1b[36m";
|
||||||
|
const YELLOW: &str = "\x1b[33m";
|
||||||
|
const GREEN: &str = "\x1b[32m";
|
||||||
|
|
||||||
|
struct StreamState {
|
||||||
|
turn_num: u32,
|
||||||
|
current_msg_id: Option<String>,
|
||||||
|
seen_init: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
turn_num: 0,
|
||||||
|
current_msg_id: None,
|
||||||
|
seen_init: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a tool_use event into a human-readable string.
|
||||||
|
fn format_tool_call(line: &str) -> String {
|
||||||
|
let tool_name = extract_str(line, "name").unwrap_or("?");
|
||||||
|
|
||||||
|
match tool_name {
|
||||||
|
"Read" => {
|
||||||
|
let path = extract_str(line, "file_path").unwrap_or("?");
|
||||||
|
format!("Read: {}", path)
|
||||||
|
}
|
||||||
|
"Edit" => {
|
||||||
|
let path = extract_str(line, "file_path").unwrap_or("?");
|
||||||
|
format!("Edit: {}", path)
|
||||||
|
}
|
||||||
|
"Write" => {
|
||||||
|
let path = extract_str(line, "file_path").unwrap_or("?");
|
||||||
|
format!("Write: {}", path)
|
||||||
|
}
|
||||||
|
"Bash" => {
|
||||||
|
let cmd = extract_str(line, "command").unwrap_or("?");
|
||||||
|
if cmd.len() > 80 {
|
||||||
|
format!("Bash: {}...", &cmd[..77])
|
||||||
|
} else {
|
||||||
|
format!("Bash: {}", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Glob" => {
|
||||||
|
let pat = extract_str(line, "pattern").unwrap_or("?");
|
||||||
|
format!("Glob: {}", pat)
|
||||||
|
}
|
||||||
|
"Grep" => {
|
||||||
|
let pat = extract_str(line, "pattern").unwrap_or("?");
|
||||||
|
format!("Grep: {}", pat)
|
||||||
|
}
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout.
|
||||||
|
/// Consumes the stream entirely — raw NDJSON is not written to disk.
|
||||||
|
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
let mut state = StreamState::new();
|
||||||
|
let mut log_file = log_path.and_then(|p| {
|
||||||
|
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
|
||||||
|
std::fs::File::create(p).ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
for line_result in reader.lines() {
|
||||||
|
if crate::signal::interrupted() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = match line_result {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tee raw NDJSON to log file
|
||||||
|
if let Some(ref mut f) = log_file {
|
||||||
|
let _ = writeln!(f, "{}", line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for turn boundary (message_id change)
|
||||||
|
if let Some(msg_id) = extract_str(&line, "message_id") {
|
||||||
|
let changed = match &state.current_msg_id {
|
||||||
|
Some(prev) => prev != msg_id,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
state.current_msg_id = Some(msg_id.to_string());
|
||||||
|
state.turn_num += 1;
|
||||||
|
println!(
|
||||||
|
"{}{}━━━ Turn {} ━━━{}",
|
||||||
|
BOLD, CYAN, state.turn_num, RESET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ev_type = extract_str(&line, "type");
|
||||||
|
|
||||||
|
match ev_type {
|
||||||
|
// system → check subtype for init
|
||||||
|
Some("system") => {
|
||||||
|
let ev_subtype = extract_str(&line, "subtype");
|
||||||
|
if ev_subtype == Some("init") && !state.seen_init {
|
||||||
|
state.seen_init = true;
|
||||||
|
let sid = extract_str(&line, "session_id").unwrap_or("?");
|
||||||
|
let sid_short = if sid.len() > 12 { &sid[..12] } else { sid };
|
||||||
|
let model = extract_str(&line, "model").unwrap_or("?");
|
||||||
|
println!(
|
||||||
|
"{}{}[stream] session {}... model={}{}",
|
||||||
|
CYAN, BOLD, sid_short, model, RESET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// assistant → probe content for tool_use or text
|
||||||
|
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().take(3) {
|
||||||
|
let truncated = if text_line.len() > 120 {
|
||||||
|
&text_line[..120]
|
||||||
|
} else {
|
||||||
|
text_line
|
||||||
|
};
|
||||||
|
println!("{} {}{}", DIM, truncated, RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// result → green bold summary
|
||||||
|
Some("result") => {
|
||||||
|
let cost = extract_num(&line, "cost_usd").unwrap_or(0.0);
|
||||||
|
let turns = extract_num(&line, "num_turns").unwrap_or(0.0) as u32;
|
||||||
|
let duration = extract_num(&line, "duration_ms").unwrap_or(0.0);
|
||||||
|
let dur_secs = duration / 1000.0;
|
||||||
|
println!(
|
||||||
|
"{}{}[stream] done cost=${:.2} turns={} duration={:.1}s{}",
|
||||||
|
GREEN, BOLD, cost, turns, dur_secs, RESET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Non-JSON or unrecognized — dim passthrough
|
||||||
|
_ => {
|
||||||
|
if ev_type.is_none() && !line.trim().is_empty() {
|
||||||
|
println!("{} {}{}", DIM, line.trim(), RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue