Compare commits

...

10 commits

35 changed files with 8149 additions and 612 deletions

3
.gitignore vendored
View file

@ -1 +1,2 @@
target/* target/*
.loop/

437
Cargo.lock generated
View file

@ -2,6 +2,443 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.1.0" version = "0.1.0"
dependencies = [
"serde_json",
"tempfile",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -2,7 +2,14 @@
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"
path = "src/main.rs" path = "src/main.rs"
[dependencies]
serde_json = { version = "1", default-features = false, features = ["std", "preserve_order"] }
[dev-dependencies]
tempfile = "3"

View file

@ -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

View file

View file

View file

@ -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

View file

@ -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.

11
src/ansi.rs Normal file
View file

@ -0,0 +1,11 @@
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const GREEN: &str = "\x1b[38;5;46m";
pub const ORANGE: &str = "\x1b[38;5;208m";
pub const BLUE: &str = "\x1b[38;5;75m";
pub const CYAN: &str = "\x1b[38;5;80m";
pub const YELLOW: &str = "\x1b[38;5;222m";
pub const MAGENTA: &str = "\x1b[38;5;183m";
pub const RED: &str = "\x1b[38;5;196m";
pub const GRAY: &str = "\x1b[38;5;245m";

View file

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

View file

@ -1,5 +1,31 @@
use std::fs; use std::fs;
use std::path::Path; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Backend {
Claude,
OpenCode,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Thinking {
Off,
Low,
Medium,
High,
}
impl Thinking {
/// MAX_THINKING_TOKENS value to forward to the Claude CLI.
pub fn max_tokens(self) -> u32 {
match self {
Thinking::Off => 0,
Thinking::Low => 2000,
Thinking::Medium => 10000,
Thinking::High => 32000,
}
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ScopeTag { pub enum ScopeTag {
@ -14,28 +40,215 @@ pub struct ScopeRule {
pub prefix: String, pub prefix: String,
} }
#[derive(Debug, Clone)]
pub struct Periodic {
pub path: String, // e.g., ".loop/cleaner.md"
pub name: String, // derived from filename stem: "cleaner"
pub cadence: u32,
pub guards: Vec<String>, // from guard-after directives
}
#[derive(Debug)] #[derive(Debug)]
pub struct Config { pub struct Config {
pub claude_bin: String,
pub max_tail: usize, pub max_tail: usize,
pub log_dir: Option<String>, pub log_dir: Option<String>,
pub image: Option<String>,
pub model: Option<String>,
pub claude_model: Option<String>,
pub thinking: Option<Thinking>,
pub scope_rules: Vec<ScopeRule>, pub scope_rules: Vec<ScopeRule>,
pub guards: Vec<String>, pub guards: Vec<String>,
pub judge_every: Option<u32>,
pub max_judge_failures: u32,
pub periodics: Vec<Periodic>,
pub hooks: Vec<String>,
/// Resolved absolute path where NDJSON metrics rows are written.
/// Defaults to `~/.yoke/metrics` so rows survive `yoke clean` / project resets.
pub metrics_dir: PathBuf,
pub metrics_enabled: bool,
}
struct ConfigBuilder {
max_tail: usize,
log_dir: Option<String>,
image: Option<String>,
model: Option<String>,
claude_model: Option<String>,
thinking: Option<Thinking>,
scope_rules: Vec<ScopeRule>,
guards: Vec<String>,
judge_every: Option<u32>,
max_judge_failures: u32,
periodics: Vec<Periodic>,
pending_guard_afters: Vec<(String, String, usize)>,
hooks: Vec<String>,
metrics_dir: Option<String>,
metrics_enabled: bool,
}
fn cfg_err(path: &Path, line_num: usize, msg: &str) -> String {
format!("{}:{}: {}", path.display(), line_num, msg)
}
fn parse_positive_u32(value: &str, path: &Path, line_num: usize, label: &str) -> Result<u32, String> {
let n = value.parse::<u32>()
.map_err(|_| cfg_err(path, line_num, &format!("invalid {} value '{}'", label, value)))?;
if n == 0 {
return Err(cfg_err(path, line_num, &format!("{} must be > 0", label)));
}
Ok(n)
}
#[allow(clippy::string_slice)]
fn parse_periodic(value: &str, path: &Path, line_num: usize) -> Result<Periodic, String> {
let trimmed = value.trim();
let split_pos = trimmed.rfind(char::is_whitespace)
.ok_or_else(|| cfg_err(path, line_num, "periodic requires '<path> <cadence>'"))?;
let ppath = trimmed[..split_pos].trim();
let cadence = parse_positive_u32(trimmed[split_pos..].trim(), path, line_num, "periodic cadence")?;
let name = Path::new(ppath)
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| cfg_err(path, line_num, &format!("cannot derive name from periodic path '{}'", ppath)))?
.to_string();
Ok(Periodic { path: ppath.to_string(), name, cadence, guards: Vec::new() })
}
#[allow(clippy::string_slice)]
fn parse_guard_after(value: &str, path: &Path, line_num: usize) -> Result<(String, String, usize), String> {
let trimmed = value.trim();
let split_pos = trimmed.find(char::is_whitespace)
.ok_or_else(|| cfg_err(path, line_num, "guard-after requires '<periodic-name> <command>'"))?;
let pname = trimmed[..split_pos].trim().to_string();
let cmd = trimmed[split_pos..].trim().to_string();
Ok((pname, cmd, line_num))
}
impl ConfigBuilder {
fn new() -> Self {
Self {
max_tail: 200,
log_dir: None,
image: None,
model: None,
claude_model: None,
thinking: None,
scope_rules: Vec::new(),
guards: Vec::new(),
judge_every: None,
max_judge_failures: 3,
periodics: Vec::new(),
pending_guard_afters: Vec::new(),
hooks: Vec::new(),
metrics_dir: None,
metrics_enabled: true,
}
}
#[allow(clippy::string_slice)]
fn parse_line(&mut self, directive: &str, value: &str, path: &Path, line_num: usize) -> Result<(), String> {
match directive {
"max-tail" => {
self.max_tail = value.parse::<usize>()
.map_err(|_| cfg_err(path, line_num, &format!("invalid max-tail value '{}'", value)))?;
}
"log-dir" => self.log_dir = Some(value.to_string()),
"image" => self.image = Some(value.to_string()),
"model" => self.model = Some(value.to_string()),
"claude-model" => self.claude_model = Some(value.to_string()),
"thinking" => {
self.thinking = Some(match value.trim() {
"off" => Thinking::Off,
"low" => Thinking::Low,
"medium" => Thinking::Medium,
"high" => Thinking::High,
other => {
return Err(cfg_err(
path,
line_num,
&format!("thinking must be 'off', 'low', 'medium', or 'high', got '{}'", other),
));
}
});
}
"allow" => self.scope_rules.push(ScopeRule { tag: ScopeTag::Allow, prefix: value.to_string() }),
"add-only" => self.scope_rules.push(ScopeRule { tag: ScopeTag::AddOnly, prefix: value.to_string() }),
"no-modify" => self.scope_rules.push(ScopeRule { tag: ScopeTag::NoModify, prefix: value.to_string() }),
"guard" => self.guards.push(value.to_string()),
"judge-every" => self.judge_every = Some(parse_positive_u32(value, path, line_num, "judge-every")?),
"max-judge-failures" => self.max_judge_failures = parse_positive_u32(value, path, line_num, "max-judge-failures")?,
"periodic" => self.periodics.push(parse_periodic(value, path, line_num)?),
"guard-after" => self.pending_guard_afters.push(parse_guard_after(value, path, line_num)?),
"hook" => self.hooks.push(value.to_string()),
"metrics-dir" => self.metrics_dir = Some(value.to_string()),
"metrics" => match value.trim() {
"on" => self.metrics_enabled = true,
"off" => self.metrics_enabled = false,
other => {
return Err(cfg_err(
path,
line_num,
&format!("metrics must be 'on' or 'off', got '{}'", other),
));
}
},
other => return Err(cfg_err(path, line_num, &format!("unknown directive '{}'", other))),
}
Ok(())
}
fn build(self, path: &Path) -> Result<Config, String> {
if self.model.is_some() && self.claude_model.is_some() {
return Err(cfg_err(
path,
0,
"'model' (OpenCode backend) and 'claude-model' (Claude CLI backend) are mutually exclusive",
));
}
if self.model.is_some() && self.thinking.is_some() {
eprintln!(
"warning: {}: 'thinking' directive is only honored by the Claude CLI backend; ignored when 'model' (OpenCode) is set",
path.display(),
);
}
let mut periodics = self.periodics;
for (pname, cmd, ln) in self.pending_guard_afters {
match periodics.iter_mut().find(|p| p.name == pname) {
Some(p) => p.guards.push(cmd),
None => return Err(cfg_err(path, ln, &format!("guard-after references unknown periodic '{}'", pname))),
}
}
let metrics_dir = match self.metrics_dir {
Some(raw) => crate::metrics::expand_tilde(&raw),
None => crate::metrics::default_metrics_dir(),
};
Ok(Config {
max_tail: self.max_tail,
log_dir: self.log_dir,
image: self.image,
model: self.model,
claude_model: self.claude_model,
thinking: self.thinking,
scope_rules: self.scope_rules,
guards: self.guards,
judge_every: self.judge_every,
max_judge_failures: self.max_judge_failures,
periodics,
hooks: self.hooks,
metrics_dir,
metrics_enabled: self.metrics_enabled,
})
}
} }
impl Config { impl Config {
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn load(path: &Path) -> Result<Config, String> { pub fn load(path: &Path) -> Result<Config, String> {
let content = fs::read_to_string(path) let content = fs::read_to_string(path)
.map_err(|e| format!("failed to read config {}: {}", path.display(), e))?; .map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
let mut claude_bin = String::from("claude"); let mut builder = ConfigBuilder::new();
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() { for (line_num, raw_line) in content.lines().enumerate() {
// Strip comments
let line = match raw_line.find('#') { let line = match raw_line.find('#') {
Some(pos) => &raw_line[..pos], Some(pos) => &raw_line[..pos],
None => raw_line, None => raw_line,
@ -45,65 +258,17 @@ impl Config {
continue; continue;
} }
// Split into directive and value at first whitespace
let (directive, value) = match line.find(char::is_whitespace) { let (directive, value) = match line.find(char::is_whitespace) {
Some(pos) => (&line[..pos], line[pos..].trim_start()), Some(pos) => (&line[..pos], line[pos..].trim_start()),
None => { None => {
return Err(format!( return Err(cfg_err(path, line_num + 1, &format!("directive '{}' has no value", line)));
"{}:{}: directive '{}' has no value",
path.display(),
line_num + 1,
line
));
} }
}; };
match directive { builder.parse_line(directive, value, path, line_num + 1)?;
"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 { builder.build(path)
claude_bin,
max_tail,
log_dir,
scope_rules,
guards,
})
} }
/// Resolve the most-specific scope tag for a file path. /// Resolve the most-specific scope tag for a file path.
@ -129,4 +294,14 @@ impl Config {
// If only "." matched, best_len is 1, which is correct. // If only "." matched, best_len is 1, which is correct.
best_tag best_tag
} }
/// Determine which backend to use based on config.
/// If `model` is set, use OpenCode; otherwise default to Claude CLI.
pub fn backend(&self) -> Backend {
if self.model.is_some() {
Backend::OpenCode
} else {
Backend::Claude
}
}
} }

View file

@ -1,12 +1,21 @@
//! Guard Orchestra — parallel guard runner that executes configured shell
//! commands, captures stdout/stderr, measures elapsed time, and produces
//! structured results for the box-drawn table in main.rs.
//!
//! All guards are spawned concurrently via threads. Results are collected
//! and returned in the original command order.
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
use std::thread;
use std::time::Instant;
pub struct GuardResult { pub struct GuardResult {
pub name: String, pub name: String,
pub passed: bool, pub passed: bool,
pub output: String,
pub skipped: bool, pub skipped: bool,
pub elapsed_secs: f64,
} }
/// Keep only the last `max` lines of text. /// Keep only the last `max` lines of text.
@ -18,73 +27,101 @@ fn tail_lines(text: &str, max: usize) -> String {
lines[lines.len() - max..].join("\n") lines[lines.len() - max..].join("\n")
} }
/// Run all configured guard commands in order (fail-fast). /// Execute a single guard command synchronously.
/// Writes results to `results_path` in markdown format. /// Returns (passed, raw_output, elapsed_secs).
pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec<GuardResult> { fn run_one(cmd: &str) -> (bool, String, f64) {
let mut results = Vec::new(); let start = Instant::now();
let mut markdown = String::new(); let output = Command::new("sh")
let mut failed = false; .arg("-c")
.arg(cmd)
.output();
let elapsed_secs = start.elapsed().as_secs_f64();
for cmd in guards { let (exit_ok, raw_output) = match output {
if failed { Ok(o) => {
let result = GuardResult { let mut combined = String::from_utf8_lossy(&o.stdout).into_owned();
name: cmd.clone(), let stderr = String::from_utf8_lossy(&o.stderr);
passed: false, if !stderr.is_empty() {
output: String::new(), if !combined.is_empty() && !combined.ends_with('\n') {
skipped: true, combined.push('\n');
};
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) combined.push_str(&stderr);
}
(o.status.success(), combined)
}
Err(e) => (false, format!("failed to execute: {}", e)),
};
(exit_ok, raw_output, elapsed_secs)
}
/// Run all configured guard commands in parallel.
/// Each guard is spawned on its own thread. Results are collected in the
/// original command order and written to `results_path` in markdown format.
///
/// The rendered box-drawn table with PASS/FAIL/SKIPPED status and timing
/// is handled by the caller in main.rs.
pub fn run_guards(guards: &[String], max_tail: usize, results_path: &Path) -> Vec<GuardResult> {
if guards.is_empty() {
let _ = fs::write(results_path, "");
return Vec::new();
}
// Spawn all guards in parallel
let handles: Vec<_> = guards
.iter()
.map(|cmd| {
let cmd = cmd.clone();
thread::spawn(move || {
if crate::signal::interrupted() {
return (cmd, false, String::new(), true, 0.0);
}
let (passed, raw_output, elapsed) = run_one(&cmd);
(cmd, passed, raw_output, false, elapsed)
})
})
.collect();
// Collect results in order
let mut results = Vec::with_capacity(guards.len());
let mut markdown = String::new();
for handle in handles {
let (name, passed, raw_output, skipped, elapsed_secs) = match handle.join() {
Ok(r) => r,
Err(_) => {
// Thread panicked — treat as failure
(String::from("(unknown)"), false, String::from("guard thread panicked"), false, 0.0)
} }
Err(e) => (false, format!("failed to execute: {}", e)),
}; };
let truncated = tail_lines(&raw_output, max_tail); 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)); if skipped {
markdown.push_str("```\n"); markdown.push_str(&format!("## {} \u{2014} SKIPPED\n\n", name));
markdown.push_str(&truncated); markdown.push_str("```\nSkipped due to interrupt.\n```\n\n");
if !truncated.is_empty() && !truncated.ends_with('\n') { } else {
markdown.push('\n'); let status_label = if passed { "PASS" } else { "FAIL" };
} markdown.push_str(&format!("## {} \u{2014} {}\n\n", name, status_label));
markdown.push_str("```\n\n"); markdown.push_str("```\n");
markdown.push_str(&truncated);
if !exit_ok { if !truncated.is_empty() && !truncated.ends_with('\n') {
failed = true; markdown.push('\n');
}
markdown.push_str("```\n\n");
} }
results.push(GuardResult { results.push(GuardResult {
name: cmd.clone(), name,
passed: exit_ok, passed,
output: truncated, skipped,
skipped: false, elapsed_secs,
}); });
} }
// Write results file // Write results file
if let Err(e) = fs::write(results_path, &markdown) { if let Err(e) = fs::write(results_path, &markdown) {
eprintln!("[ci] WARNING: failed to write guard results: {}", e); eprintln!("[yoke] WARNING: failed to write guard results: {}", e);
} }
results results

View file

@ -1,6 +1,33 @@
/// Unescape basic JSON string escape sequences into real characters.
pub fn unescape_json(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('/') => out.push('/'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
/// Extract the string value for a given key from a flat JSON line. /// Extract the string value for a given key from a flat JSON line.
/// Looks for `"key": "value"` and returns the value (unescaped basic sequences). /// Looks for `"key": "value"` and returns the value (unescaped basic sequences).
/// Returns `None` if the key is not found or the value is not a string. /// Returns `None` if the key is not found or the value is not a string.
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> { pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let needle = { let needle = {
let mut pat = String::with_capacity(key.len() + 3); let mut pat = String::with_capacity(key.len() + 3);
@ -55,9 +82,36 @@ pub fn extract_str<'a>(line: &'a str, key: &str) -> Option<&'a str> {
} }
} }
/// Extract a boolean value for a given key from a flat JSON line.
/// Looks for `"key": true` or `"key": false`.
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn extract_bool(line: &str, key: &str) -> Option<bool> {
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..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
if rest.starts_with("true") {
Some(true)
} else if rest.starts_with("false") {
Some(false)
} else {
None
}
}
/// Extract a numeric value for a given key from a flat JSON line. /// Extract a numeric value for a given key from a flat JSON line.
/// Looks for `"key": 123.45` and returns the number. /// Looks for `"key": 123.45` and returns the number.
/// Returns `None` if the key is not found or the value is not a number. /// Returns `None` if the key is not found or the value is not a number.
#[allow(clippy::string_slice)] // all slices at ASCII delimiter positions from .find()
pub fn extract_num(line: &str, key: &str) -> Option<f64> { pub fn extract_num(line: &str, key: &str) -> Option<f64> {
let needle = { let needle = {
let mut pat = String::with_capacity(key.len() + 3); let mut pat = String::with_capacity(key.len() + 3);
@ -113,4 +167,12 @@ mod tests {
assert!((extract_num(line, "num_turns").unwrap() - 5.0).abs() < 1e-10); assert!((extract_num(line, "num_turns").unwrap() - 5.0).abs() < 1e-10);
assert_eq!(extract_num(line, "missing"), None); assert_eq!(extract_num(line, "missing"), None);
} }
#[test]
fn test_extract_bool() {
let line = r#"{"guards_passed":true,"status_done":false}"#;
assert_eq!(extract_bool(line, "guards_passed"), Some(true));
assert_eq!(extract_bool(line, "status_done"), Some(false));
assert_eq!(extract_bool(line, "missing"), None);
}
} }

File diff suppressed because it is too large Load diff

750
src/metrics.rs Normal file
View file

@ -0,0 +1,750 @@
//! Persistent metrics for yoke runs. Rows are written under `~/.yoke/metrics/`
//! (or the configured `metrics-dir`) as NDJSON — one row per iteration, one
//! row per completed run. Storage lives outside the project tree so it
//! survives `yoke clean`, `yoke stash`, branch resets, and project deletes.
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct GuardRow {
pub name: String,
pub passed: bool,
pub skipped: bool,
pub elapsed_secs: f64,
}
#[derive(Debug, Clone)]
pub struct IterationMetrics {
pub run_id: String,
pub project_slug: String,
pub mode: &'static str,
pub iteration: u32,
pub started_at: u64,
pub wall_secs: f64,
pub restore_ms: u64,
pub agent_secs: f64,
pub guards_secs: f64,
pub periodics_secs: f64,
pub hooks_secs: f64,
pub judge_secs: Option<f64>,
pub agent_reported_secs: Option<f64>,
pub cost_usd: f64,
pub num_turns: u32,
pub thinking_secs: f64,
pub tool_counts: BTreeMap<String, u32>,
pub tool_durations_secs: BTreeMap<String, f64>,
pub guards: Vec<GuardRow>,
pub guards_passed: bool,
pub status_done: bool,
}
#[derive(Debug, Clone)]
pub struct RunMetrics {
pub run_id: String,
pub project_slug: String,
pub mode: &'static str,
pub started_at: u64,
pub ended_at: u64,
pub iterations: u32,
pub outcome: &'static str,
pub total_wall_secs: f64,
pub total_agent_secs: f64,
pub total_thinking_secs: f64,
pub total_guards_secs: f64,
pub total_cost_usd: f64,
}
/// Run-scoped state that travels with the loop: identifies the run, knows
/// where to write, and tells the loop whether persistence is enabled.
#[derive(Debug, Clone)]
pub struct MetricsContext {
pub run_id: String,
pub project_slug: String,
pub mode: &'static str,
pub root_dir: PathBuf,
pub enabled: bool,
}
impl MetricsContext {
pub fn new(root_dir: PathBuf, mode: &'static str, enabled: bool) -> Self {
Self {
run_id: new_run_id(),
project_slug: project_slug(),
mode,
root_dir,
enabled,
}
}
pub fn iteration_path(&self) -> PathBuf {
self.root_dir
.join(&self.project_slug)
.join(format!("{}.ndjson", self.run_id))
}
pub fn runs_path(&self) -> PathBuf {
self.root_dir.join(&self.project_slug).join("runs.ndjson")
}
}
/// Accumulates per-iteration totals and writes a final `RunMetrics` row
/// on Drop. Drop runs on normal exit, on early-return, and on the first
/// SIGINT (which sets the interrupted flag and lets the loop unwind). A
/// second SIGINT calls `_exit` directly and bypasses Drop — accepted
/// tradeoff for a panic-button.
pub struct RunRecorder {
pub ctx: MetricsContext,
started_at: u64,
wall_start: std::time::Instant,
iterations: u32,
total_agent_secs: f64,
total_thinking_secs: f64,
total_guards_secs: f64,
total_cost_usd: f64,
outcome: &'static str,
/// Closure invoked with the finalized RunMetrics from Drop. Allows the
/// caller to render an end-of-run summary table without forcing Drop
/// to know about ANSI rendering or print to stderr unconditionally.
on_finalize: Option<Box<dyn FnMut(&RunMetrics) + Send>>,
}
impl RunRecorder {
pub fn new(ctx: MetricsContext) -> Self {
Self {
ctx,
started_at: unix_now(),
wall_start: std::time::Instant::now(),
iterations: 0,
total_agent_secs: 0.0,
total_thinking_secs: 0.0,
total_guards_secs: 0.0,
total_cost_usd: 0.0,
// Default to "interrupt" so an unwinding stack still records
// something meaningful — clean exit paths override this.
outcome: "interrupt",
on_finalize: None,
}
}
pub fn on_finalize<F>(&mut self, cb: F)
where
F: FnMut(&RunMetrics) + Send + 'static,
{
self.on_finalize = Some(Box::new(cb));
}
/// Write the iteration row and accumulate its totals in one step.
pub fn record_iteration(&mut self, m: &IterationMetrics) {
write_iteration_row(&self.ctx, m);
self.iterations += 1;
self.total_agent_secs += m.agent_secs;
self.total_thinking_secs += m.thinking_secs;
self.total_guards_secs += m.guards_secs;
self.total_cost_usd += m.cost_usd;
}
pub fn set_outcome(&mut self, outcome: &'static str) {
self.outcome = outcome;
}
fn finalize(&mut self) -> RunMetrics {
RunMetrics {
run_id: self.ctx.run_id.clone(),
project_slug: self.ctx.project_slug.clone(),
mode: self.ctx.mode,
started_at: self.started_at,
ended_at: unix_now(),
iterations: self.iterations,
outcome: self.outcome,
total_wall_secs: self.wall_start.elapsed().as_secs_f64(),
total_agent_secs: self.total_agent_secs,
total_thinking_secs: self.total_thinking_secs,
total_guards_secs: self.total_guards_secs,
total_cost_usd: self.total_cost_usd,
}
}
}
impl Drop for RunRecorder {
fn drop(&mut self) {
let run = self.finalize();
write_run_row(&self.ctx, &run);
if let Some(mut cb) = self.on_finalize.take() {
cb(&run);
}
}
}
pub fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Sortable run id: `YYYYMMDDTHHMMSS-NNNNNN`, where the suffix is the
/// six-digit sub-second microsecond count. Lexicographic sort matches
/// chronological order. No external time crate dep.
pub fn new_run_id() -> String {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = now.as_secs();
let micros = now.subsec_micros();
let (y, m, d, hh, mm, ss) = unix_to_utc(secs);
format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}-{:06}",
y, m, d, hh, mm, ss, micros
)
}
/// Convert unix-seconds-since-epoch (UTC) to (year, month, day, hour, minute, second).
/// Howard Hinnant's date algorithm — integer-only, no leap-second handling.
fn unix_to_utc(secs: u64) -> (i32, u32, u32, u32, u32, u32) {
let days = (secs / 86400) as i64;
let tod = secs % 86400;
let hh = (tod / 3600) as u32;
let mm = ((tod % 3600) / 60) as u32;
let ss = (tod % 60) as u32;
let z = days + 719468;
let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 };
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m as u32, d as u32, hh, mm, ss)
}
/// Stable identifier for the current working directory: basename + FNV-1a
/// hash of the absolute path. Two checkouts with the same basename get
/// different slugs.
pub fn project_slug() -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let abs = cwd.canonicalize().unwrap_or(cwd.clone());
let base = abs
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project");
let hash = fnv1a_32(abs.to_string_lossy().as_bytes());
format!("{}-{:08x}", sanitize(base), hash)
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect()
}
fn fnv1a_32(bytes: &[u8]) -> u32 {
let mut h: u32 = 0x811c9dc5;
for &b in bytes {
h ^= b as u32;
h = h.wrapping_mul(0x01000193);
}
h
}
/// Expand a leading `~` or `~/` against `$HOME`. Returns the input
/// unchanged if no tilde prefix or no HOME.
pub fn expand_tilde(s: &str) -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
if s == "~" {
return PathBuf::from(home);
}
if let Some(rest) = s.strip_prefix("~/") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(s)
}
pub fn default_metrics_dir() -> PathBuf {
expand_tilde("~/.yoke/metrics")
}
pub fn write_iteration_row(ctx: &MetricsContext, m: &IterationMetrics) {
if !ctx.enabled {
return;
}
append_line(&ctx.iteration_path(), &serialize_iteration(m));
}
pub fn write_run_row(ctx: &MetricsContext, r: &RunMetrics) {
if !ctx.enabled {
return;
}
append_line(&ctx.runs_path(), &serialize_run(r));
}
fn append_line(path: &Path, line: &str) {
if let Some(parent) = path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!(
"[yoke] WARNING: cannot create metrics dir {}: {}",
parent.display(),
e
);
return;
}
}
match OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => {
// Single write_all keeps the row atomic on POSIX for small lines.
let mut buf = line.to_string();
buf.push('\n');
if let Err(e) = f.write_all(buf.as_bytes()) {
eprintln!("[yoke] WARNING: failed to write metrics row: {}", e);
}
}
Err(e) => {
eprintln!(
"[yoke] WARNING: cannot open metrics file {}: {}",
path.display(),
e
);
}
}
}
// ─── JSON emission ────────────────────────────────────────────────────────
fn serialize_iteration(m: &IterationMetrics) -> String {
let mut s = String::with_capacity(512);
s.push('{');
push_str_field(&mut s, "run_id", &m.run_id, true);
push_str_field(&mut s, "project_slug", &m.project_slug, false);
push_str_field(&mut s, "mode", m.mode, false);
push_u32_field(&mut s, "iteration", m.iteration, false);
push_u64_field(&mut s, "started_at", m.started_at, false);
push_f64_field(&mut s, "wall_secs", m.wall_secs, false);
push_u64_field(&mut s, "restore_ms", m.restore_ms, false);
push_f64_field(&mut s, "agent_secs", m.agent_secs, false);
push_f64_field(&mut s, "guards_secs", m.guards_secs, false);
push_f64_field(&mut s, "periodics_secs", m.periodics_secs, false);
push_f64_field(&mut s, "hooks_secs", m.hooks_secs, false);
push_opt_f64_field(&mut s, "judge_secs", m.judge_secs);
push_opt_f64_field(&mut s, "agent_reported_secs", m.agent_reported_secs);
push_f64_field(&mut s, "cost_usd", m.cost_usd, false);
push_u32_field(&mut s, "num_turns", m.num_turns, false);
push_f64_field(&mut s, "thinking_secs", m.thinking_secs, false);
s.push_str(",\"tool_counts\":");
push_map_u32(&mut s, &m.tool_counts);
s.push_str(",\"tool_durations_secs\":");
push_map_f64(&mut s, &m.tool_durations_secs);
s.push_str(",\"guards\":[");
for (i, g) in m.guards.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push('{');
push_str_field(&mut s, "name", &g.name, true);
push_bool_field(&mut s, "passed", g.passed, false);
push_bool_field(&mut s, "skipped", g.skipped, false);
push_f64_field(&mut s, "elapsed_secs", g.elapsed_secs, false);
s.push('}');
}
s.push(']');
push_bool_field(&mut s, "guards_passed", m.guards_passed, false);
push_bool_field(&mut s, "status_done", m.status_done, false);
s.push('}');
s
}
fn serialize_run(r: &RunMetrics) -> String {
let mut s = String::with_capacity(256);
s.push('{');
push_str_field(&mut s, "run_id", &r.run_id, true);
push_str_field(&mut s, "project_slug", &r.project_slug, false);
push_str_field(&mut s, "mode", r.mode, false);
push_u64_field(&mut s, "started_at", r.started_at, false);
push_u64_field(&mut s, "ended_at", r.ended_at, false);
push_u32_field(&mut s, "iterations", r.iterations, false);
push_str_field(&mut s, "outcome", r.outcome, false);
push_f64_field(&mut s, "total_wall_secs", r.total_wall_secs, false);
push_f64_field(&mut s, "total_agent_secs", r.total_agent_secs, false);
push_f64_field(&mut s, "total_thinking_secs", r.total_thinking_secs, false);
push_f64_field(&mut s, "total_guards_secs", r.total_guards_secs, false);
push_f64_field(&mut s, "total_cost_usd", r.total_cost_usd, false);
s.push('}');
s
}
fn push_str_field(s: &mut String, k: &str, v: &str, first: bool) {
if !first {
s.push(',');
}
s.push('"');
s.push_str(k);
s.push_str("\":\"");
escape_str_into(s, v);
s.push('"');
}
fn push_u32_field(s: &mut String, k: &str, v: u32, first: bool) {
if !first {
s.push(',');
}
s.push('"');
s.push_str(k);
s.push_str("\":");
s.push_str(&v.to_string());
}
fn push_u64_field(s: &mut String, k: &str, v: u64, first: bool) {
if !first {
s.push(',');
}
s.push('"');
s.push_str(k);
s.push_str("\":");
s.push_str(&v.to_string());
}
fn push_f64_field(s: &mut String, k: &str, v: f64, first: bool) {
if !first {
s.push(',');
}
s.push('"');
s.push_str(k);
s.push_str("\":");
push_f64_value(s, v);
}
fn push_opt_f64_field(s: &mut String, k: &str, v: Option<f64>) {
s.push(',');
s.push('"');
s.push_str(k);
s.push_str("\":");
match v {
Some(x) => push_f64_value(s, x),
None => s.push_str("null"),
}
}
fn push_f64_value(s: &mut String, v: f64) {
if v.is_finite() {
// 4 dp is more than enough for second-scale measurements
s.push_str(&format!("{:.4}", v));
} else {
s.push_str("null");
}
}
fn push_bool_field(s: &mut String, k: &str, v: bool, first: bool) {
if !first {
s.push(',');
}
s.push('"');
s.push_str(k);
s.push_str("\":");
s.push_str(if v { "true" } else { "false" });
}
fn escape_str_into(s: &mut String, v: &str) {
for c in v.chars() {
match c {
'"' => s.push_str("\\\""),
'\\' => s.push_str("\\\\"),
'\n' => s.push_str("\\n"),
'\r' => s.push_str("\\r"),
'\t' => s.push_str("\\t"),
c if (c as u32) < 0x20 => s.push_str(&format!("\\u{:04x}", c as u32)),
c => s.push(c),
}
}
}
fn push_map_u32(s: &mut String, m: &BTreeMap<String, u32>) {
s.push('{');
for (i, (k, v)) in m.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push('"');
escape_str_into(s, k);
s.push_str("\":");
s.push_str(&v.to_string());
}
s.push('}');
}
fn push_map_f64(s: &mut String, m: &BTreeMap<String, f64>) {
s.push('{');
for (i, (k, v)) in m.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push('"');
escape_str_into(s, k);
s.push_str("\":");
push_f64_value(s, *v);
}
s.push('}');
}
// ─── Read side: parse rows for `yoke stats` ──────────────────────────────
//
// The write side uses `&'static str` for mode/outcome — small win on the
// hot path. The read side comes from runtime data so it uses owned
// `String` fields and a separate row type. Keeps both sides honest
// without forcing one to bend to the other.
/// One parsed row from `runs.ndjson`. Some fields aren't shown in the
/// current `yoke stats` output but are populated for callers that want
/// to filter or aggregate.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct RunRow {
pub run_id: String,
pub project_slug: String,
pub mode: String,
pub started_at: u64,
pub ended_at: u64,
pub iterations: u32,
pub outcome: String,
pub total_wall_secs: f64,
pub total_agent_secs: f64,
pub total_thinking_secs: f64,
pub total_guards_secs: f64,
pub total_cost_usd: f64,
}
/// One parsed row from `<run-id>.ndjson`.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct IterRow {
pub iteration: u32,
pub mode: String,
pub started_at: u64,
pub wall_secs: f64,
pub agent_secs: f64,
pub thinking_secs: f64,
pub guards_secs: f64,
pub num_turns: u32,
pub cost_usd: f64,
pub guards_passed: bool,
pub status_done: bool,
}
pub fn read_runs(slug_dir: &Path) -> Vec<RunRow> {
let path = slug_dir.join("runs.ndjson");
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
if let Some(r) = parse_run_row(line) {
out.push(r);
}
}
out
}
pub fn read_iterations(file: &Path) -> Vec<IterRow> {
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
if let Some(r) = parse_iter_row(line) {
out.push(r);
}
}
out
}
fn parse_run_row(line: &str) -> Option<RunRow> {
use crate::json::{extract_num, extract_str};
Some(RunRow {
run_id: extract_str(line, "run_id")?.to_string(),
project_slug: extract_str(line, "project_slug")?.to_string(),
mode: extract_str(line, "mode")?.to_string(),
started_at: extract_num(line, "started_at")? as u64,
ended_at: extract_num(line, "ended_at")? as u64,
iterations: extract_num(line, "iterations")? as u32,
outcome: extract_str(line, "outcome")?.to_string(),
total_wall_secs: extract_num(line, "total_wall_secs").unwrap_or(0.0),
total_agent_secs: extract_num(line, "total_agent_secs").unwrap_or(0.0),
total_thinking_secs: extract_num(line, "total_thinking_secs").unwrap_or(0.0),
total_guards_secs: extract_num(line, "total_guards_secs").unwrap_or(0.0),
total_cost_usd: extract_num(line, "total_cost_usd").unwrap_or(0.0),
})
}
fn parse_iter_row(line: &str) -> Option<IterRow> {
use crate::json::{extract_bool, extract_num, extract_str};
Some(IterRow {
iteration: extract_num(line, "iteration")? as u32,
mode: extract_str(line, "mode").unwrap_or("").to_string(),
started_at: extract_num(line, "started_at").unwrap_or(0.0) as u64,
wall_secs: extract_num(line, "wall_secs").unwrap_or(0.0),
agent_secs: extract_num(line, "agent_secs").unwrap_or(0.0),
thinking_secs: extract_num(line, "thinking_secs").unwrap_or(0.0),
guards_secs: extract_num(line, "guards_secs").unwrap_or(0.0),
num_turns: extract_num(line, "num_turns").unwrap_or(0.0) as u32,
cost_usd: extract_num(line, "cost_usd").unwrap_or(0.0),
guards_passed: extract_bool(line, "guards_passed").unwrap_or(false),
status_done: extract_bool(line, "status_done").unwrap_or(false),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_is_stable_across_calls() {
let a = project_slug();
let b = project_slug();
assert_eq!(a, b);
}
#[test]
fn run_id_is_sortable_and_unique() {
let a = new_run_id();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = new_run_id();
assert_ne!(a, b);
assert!(b >= a, "run ids should be lexicographically sortable");
}
#[test]
fn tilde_expands() {
if let Some(home) = std::env::var_os("HOME") {
let p = expand_tilde("~/foo");
assert_eq!(p, PathBuf::from(home).join("foo"));
}
}
#[test]
fn unix_to_utc_known_dates() {
// 2021-01-01 00:00:00 UTC = 1609459200
assert_eq!(unix_to_utc(1609459200), (2021, 1, 1, 0, 0, 0));
// 1970-01-01 00:00:00 UTC
assert_eq!(unix_to_utc(0), (1970, 1, 1, 0, 0, 0));
// 2024-02-29 12:34:56 UTC (leap day) = 1709210096
assert_eq!(unix_to_utc(1709210096), (2024, 2, 29, 12, 34, 56));
}
#[test]
fn run_row_round_trips_through_ndjson() {
let original = RunMetrics {
run_id: "20260518T000000-000001".to_string(),
project_slug: "demo-12345678".to_string(),
mode: "brute",
started_at: 1700000000,
ended_at: 1700000050,
iterations: 3,
outcome: "judge_pass",
total_wall_secs: 50.0,
total_agent_secs: 42.5,
total_thinking_secs: 4.1,
total_guards_secs: 3.2,
total_cost_usd: 0.84,
};
let serialized = serialize_run(&original);
let parsed = parse_run_row(&serialized).expect("parse should succeed");
assert_eq!(parsed.run_id, original.run_id);
assert_eq!(parsed.mode, original.mode);
assert_eq!(parsed.outcome, original.outcome);
assert_eq!(parsed.iterations, original.iterations);
assert!((parsed.total_wall_secs - original.total_wall_secs).abs() < 1e-3);
assert!((parsed.total_cost_usd - original.total_cost_usd).abs() < 1e-3);
}
#[test]
fn iter_row_parses_back() {
let m = IterationMetrics {
run_id: "20260518T000000-000001".to_string(),
project_slug: "demo-12345678".to_string(),
mode: "loop",
iteration: 7,
started_at: 1700000000,
wall_secs: 12.5,
restore_ms: 3,
agent_secs: 10.0,
guards_secs: 2.0,
periodics_secs: 0.0,
hooks_secs: 0.5,
judge_secs: None,
agent_reported_secs: Some(9.5),
cost_usd: 0.13,
num_turns: 5,
thinking_secs: 1.2,
tool_counts: BTreeMap::new(),
tool_durations_secs: BTreeMap::new(),
guards: Vec::new(),
guards_passed: true,
status_done: false,
};
let line = serialize_iteration(&m);
let row = parse_iter_row(&line).expect("parse iter row");
assert_eq!(row.iteration, 7);
assert_eq!(row.mode, "loop");
assert_eq!(row.num_turns, 5);
assert!(row.guards_passed);
assert!(!row.status_done);
assert!((row.thinking_secs - 1.2).abs() < 1e-3);
}
#[test]
fn serialize_iteration_is_valid_json_shape() {
let m = IterationMetrics {
run_id: "20260101T000000-abcd".to_string(),
project_slug: "yoke-deadbeef".to_string(),
mode: "loop",
iteration: 3,
started_at: 1700000000,
wall_secs: 42.5,
restore_ms: 17,
agent_secs: 38.0,
guards_secs: 3.2,
periodics_secs: 0.0,
hooks_secs: 0.1,
judge_secs: None,
agent_reported_secs: Some(36.7),
cost_usd: 0.42,
num_turns: 7,
thinking_secs: 4.1,
tool_counts: [("Edit".to_string(), 3u32), ("Bash".to_string(), 1)].into_iter().collect(),
tool_durations_secs: [("Edit".to_string(), 1.8), ("Bash".to_string(), 12.3)].into_iter().collect(),
guards: vec![GuardRow {
name: "cargo test".to_string(),
passed: true,
skipped: false,
elapsed_secs: 2.5,
}],
guards_passed: true,
status_done: false,
};
let s = serialize_iteration(&m);
assert!(s.starts_with('{') && s.ends_with('}'));
assert!(s.contains("\"run_id\":\"20260101T000000-abcd\""));
assert!(s.contains("\"thinking_secs\":4.1000"));
assert!(s.contains("\"judge_secs\":null"));
assert!(s.contains("\"tool_durations_secs\":{"));
}
}

620
src/session_trim.rs Normal file
View file

@ -0,0 +1,620 @@
//! Session-JSONL trimming for Claude Code prompt-cache reuse across rounds.
//!
//! Yoke uses `claude --resume <sid>` to carry a worker's conversation across
//! iterations so Anthropic's prefix cache stays warm. Naive resume grows the
//! session monotonically — bash outputs, thinking blocks, intermediate Reads
//! that are no longer relevant — all of it stays in the prefix and is paid
//! for at cache-read rates every round.
//!
//! This module trims the session file between rounds. The agent declares a
//! `KEEP: <path> <path> ...` line in `.loop/notes.md` listing the file Reads
//! whose results should stay in conversation history. Everything else is
//! dropped, the parent-uuid chain is re-linked across the gaps, and the file
//! is atomically rewritten in place.
//!
//! Safety: if the trim's own validation fails (broken parent chain, orphaned
//! tool_use without tool_result, parse error), the original session is kept
//! untouched and we log a warning. `YOKE_DISABLE_SESSION_TRIM=1` skips the
//! whole pass.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::Value;
#[derive(Debug, Default)]
pub struct TrimStats {
pub records_total: usize,
pub records_kept: usize,
pub records_dropped: usize,
pub keep_paths: Vec<String>,
pub skipped: bool,
}
/// Parse a `KEEP:` line from notes.md (or any text). Returns absolute paths
/// resolved against `cwd`. Multiple `KEEP:` lines are unioned. Missing or
/// `*` token is treated as "keep nothing" — the caller decides what that
/// means, but this fn just returns the explicit paths.
pub fn parse_keep_list(notes_text: &str, cwd: &Path) -> HashSet<PathBuf> {
let mut out = HashSet::new();
for line in notes_text.lines() {
let trimmed = line.trim_start();
let rest = match trimmed.strip_prefix("KEEP:") {
Some(r) => r,
None => continue,
};
for tok in rest.split_whitespace() {
if tok == "*" {
continue;
}
let p = PathBuf::from(tok);
let abs = if p.is_absolute() { p } else { cwd.join(p) };
// Best-effort canonicalize so symlinks / .. don't cause mismatches.
let final_path = fs::canonicalize(&abs).unwrap_or(abs);
out.insert(final_path);
}
}
out
}
/// Locate Claude Code's session file on disk.
/// Format: `<home>/.claude/projects/<cwd-with-/-replaced-by->/<sid>.jsonl`.
pub fn session_file_path(home: &Path, cwd: &Path, session_id: &str) -> PathBuf {
let cwd_str = cwd.to_string_lossy();
// Claude Code's slug: replace `/` with `-`. A leading slash becomes a
// leading dash. `.` characters in path components are preserved.
let slug = cwd_str.replace('/', "-");
home.join(".claude")
.join("projects")
.join(slug)
.join(format!("{}.jsonl", session_id))
}
/// Trim a session file in place. Returns stats. If the trim aborts safely
/// (escape hatch / no kept content / validation failure), the original file
/// is untouched.
pub fn trim_session(session_path: &Path, keep_paths: &HashSet<PathBuf>) -> Result<TrimStats, String> {
let mut stats = TrimStats {
keep_paths: keep_paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect(),
..TrimStats::default()
};
if std::env::var_os("YOKE_DISABLE_SESSION_TRIM").is_some() {
stats.skipped = true;
return Ok(stats);
}
if !session_path.exists() {
return Err(format!("session file not found: {}", session_path.display()));
}
let content = fs::read_to_string(session_path)
.map_err(|e| format!("read {}: {}", session_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
stats.records_total = raw_lines.len();
let mut records: Vec<Value> = Vec::with_capacity(raw_lines.len());
for (i, line) in raw_lines.iter().enumerate() {
let v: Value = serde_json::from_str(line)
.map_err(|e| format!("parse line {}: {}", i + 1, e))?;
records.push(v);
}
let decisions = classify(&records, keep_paths);
let trimmed = relink_and_emit(&records, &raw_lines, &decisions)?;
stats.records_kept = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
stats.records_dropped = stats.records_total.saturating_sub(stats.records_kept);
validate(&trimmed)?;
// Atomic write: tmp → rename. Keep one .bak for recovery / debugging.
let bak_path = session_path.with_extension("jsonl.bak");
let _ = fs::copy(session_path, &bak_path);
let tmp_path = session_path.with_extension("jsonl.tmp");
{
let mut f = fs::File::create(&tmp_path)
.map_err(|e| format!("create tmp: {}", e))?;
f.write_all(trimmed.as_bytes())
.map_err(|e| format!("write tmp: {}", e))?;
f.sync_all().ok();
}
fs::rename(&tmp_path, session_path)
.map_err(|e| format!("rename: {}", e))?;
Ok(stats)
}
/// Per-record decision: keep as-is, drop entirely, or keep with a rewritten
/// parentUuid.
#[derive(Debug, Clone)]
enum Decision {
Keep,
Drop,
}
fn classify(records: &[Value], keep_paths: &HashSet<PathBuf>) -> Vec<Decision> {
// Two-pass: first identify which tool_use ids we keep, then decide each record.
let mut kept_tool_use_ids: HashSet<String> = HashSet::new();
for r in records {
if !is_assistant(r) {
continue;
}
let Some(block) = first_content_block(r) else { continue };
if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
continue;
}
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name != "Read" {
continue;
}
let path_str = block
.get("input")
.and_then(|v| v.get("file_path"))
.and_then(|v| v.as_str());
let Some(path_str) = path_str else { continue };
let p = PathBuf::from(path_str);
let canonical = fs::canonicalize(&p).unwrap_or(p);
if keep_paths.contains(&canonical)
&& let Some(id) = block.get("id").and_then(|v| v.as_str())
{
kept_tool_use_ids.insert(id.to_string());
}
}
records
.iter()
.map(|r| decide(r, &kept_tool_use_ids))
.collect()
}
fn decide(record: &Value, kept_tool_use_ids: &HashSet<String>) -> Decision {
// Metadata records (no top-level uuid OR parentUuid is absent and type is bookkeeping):
// always keep.
let typ = record.get("type").and_then(|v| v.as_str()).unwrap_or("");
match typ {
// Non-conversation bookkeeping — keep unchanged.
"permission-mode"
| "file-history-snapshot"
| "queue-operation"
| "ai-title"
| "last-prompt"
| "attachment" => return Decision::Keep,
_ => {}
}
// user / assistant: examine content.
let Some(msg) = record.get("message") else {
return Decision::Keep;
};
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("");
// Initial user prompt: content is a plain string, not an array. Always keep.
if role == "user" {
match msg.get("content") {
Some(Value::String(_)) => return Decision::Keep,
Some(Value::Array(arr)) => {
// tool_result wrapper. Keep only if its tool_use_id was kept.
if arr.is_empty() {
return Decision::Keep;
}
let block = &arr[0];
let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
if btyp == "tool_result" {
let id = block.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
if kept_tool_use_ids.contains(id) {
return Decision::Keep;
} else {
return Decision::Drop;
}
}
// Other user content (unusual): keep defensively.
return Decision::Keep;
}
_ => return Decision::Keep,
}
}
if role == "assistant" {
let Some(block) = first_content_block(record) else {
return Decision::Drop;
};
let btyp = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
match btyp {
"thinking" => Decision::Drop,
"text" => Decision::Drop,
"tool_use" => {
let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
if kept_tool_use_ids.contains(id) {
Decision::Keep
} else {
Decision::Drop
}
}
_ => Decision::Drop,
}
} else {
// Unknown role — keep, don't make things worse.
Decision::Keep
}
}
fn is_assistant(record: &Value) -> bool {
record.get("type").and_then(|v| v.as_str()) == Some("assistant")
}
fn first_content_block(record: &Value) -> Option<&Value> {
record
.get("message")?
.get("content")?
.as_array()?
.first()
}
/// Build the trimmed JSONL output. For surviving records whose parentUuid
/// points to a dropped record, walks up the parent chain to find the nearest
/// surviving ancestor and rewrites the field.
fn relink_and_emit(records: &[Value], raw: &[&str], decisions: &[Decision]) -> Result<String, String> {
// uuid → parentUuid index for ALL records that have a uuid. Used to walk
// up the chain when re-linking.
let mut parent_of: HashMap<String, Option<String>> = HashMap::new();
let mut kept_uuids: HashSet<String> = HashSet::new();
for (i, r) in records.iter().enumerate() {
let Some(uuid) = r.get("uuid").and_then(|v| v.as_str()) else { continue };
let parent = r
.get("parentUuid")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
parent_of.insert(uuid.to_string(), parent);
if matches!(decisions[i], Decision::Keep) {
kept_uuids.insert(uuid.to_string());
}
}
// For each kept uuid, compute the rewritten parentUuid (nearest kept
// ancestor or null).
let mut rewritten_parent: HashMap<String, Option<String>> = HashMap::new();
for uuid in &kept_uuids {
let mut cur = parent_of.get(uuid).cloned().flatten();
while let Some(p) = cur {
if kept_uuids.contains(&p) {
rewritten_parent.insert(uuid.clone(), Some(p));
break;
}
cur = parent_of.get(&p).cloned().flatten();
}
if !rewritten_parent.contains_key(uuid) {
rewritten_parent.insert(uuid.clone(), None);
}
}
let mut out = String::with_capacity(raw.iter().map(|l| l.len() + 1).sum());
for (i, r) in records.iter().enumerate() {
if matches!(decisions[i], Decision::Drop) {
continue;
}
let uuid_opt = r.get("uuid").and_then(|v| v.as_str()).map(|s| s.to_string());
// If this record has a uuid AND its rewritten parent differs from
// the on-disk parent, re-serialize. Otherwise emit raw.
let needs_rewrite = match &uuid_opt {
Some(uuid) => {
let orig = parent_of.get(uuid).cloned().flatten();
let new = rewritten_parent.get(uuid).cloned().flatten();
orig != new
}
None => false,
};
if needs_rewrite {
let mut v = r.clone();
let uuid = uuid_opt.unwrap();
let new_parent = rewritten_parent.get(&uuid).cloned().flatten();
if let Some(obj) = v.as_object_mut() {
match new_parent {
Some(p) => {
obj.insert("parentUuid".to_string(), Value::String(p));
}
None => {
obj.insert("parentUuid".to_string(), Value::Null);
}
}
}
let s = serde_json::to_string(&v)
.map_err(|e| format!("serialize: {}", e))?;
out.push_str(&s);
out.push('\n');
} else {
out.push_str(raw[i]);
out.push('\n');
}
}
Ok(out)
}
/// Validate that the trimmed JSONL is internally consistent:
/// every tool_use has a matching tool_result downstream.
fn validate(trimmed: &str) -> Result<(), String> {
let mut tool_use_ids: HashSet<String> = HashSet::new();
let mut tool_result_ids: HashSet<String> = HashSet::new();
for (i, line) in trimmed.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let v: Value = serde_json::from_str(line)
.map_err(|e| format!("validate parse line {}: {}", i + 1, e))?;
let Some(arr) = v.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) else {
continue;
};
for block in arr {
match block.get("type").and_then(|v| v.as_str()) {
Some("tool_use") => {
if let Some(id) = block.get("id").and_then(|v| v.as_str()) {
tool_use_ids.insert(id.to_string());
}
}
Some("tool_result") => {
if let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) {
tool_result_ids.insert(id.to_string());
}
}
_ => {}
}
}
}
for id in &tool_use_ids {
if !tool_result_ids.contains(id) {
return Err(format!("orphan tool_use {}: no matching tool_result", id));
}
}
for id in &tool_result_ids {
if !tool_use_ids.contains(id) {
return Err(format!("orphan tool_result {}: no matching tool_use", id));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;
/// Serializes tests that read/mutate the `YOKE_DISABLE_SESSION_TRIM` env
/// var. cargo runs tests in parallel within a binary; without this they
/// race on a process-global.
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Clear the env var before running the closure, then drop the guard.
fn with_clean_env<F: FnOnce()>(f: F) {
let _g = ENV_LOCK.lock().unwrap();
// SAFETY: tests are serialized via ENV_LOCK; no other thread will
// observe a partial write to environ.
unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") };
f();
}
#[test]
fn parse_keep_list_basic() {
let cwd = PathBuf::from("/tmp");
let s = "STATUS: IN_PROGRESS\nKEEP: src/a.rs /abs/b.rs\nfoo\n";
let got = parse_keep_list(s, &cwd);
assert!(got.iter().any(|p| p.ends_with("a.rs")));
assert!(got.iter().any(|p| p == &PathBuf::from("/abs/b.rs")));
}
#[test]
fn parse_keep_list_missing() {
let cwd = PathBuf::from("/tmp");
let s = "STATUS: DONE\n";
assert!(parse_keep_list(s, &cwd).is_empty());
}
#[test]
fn parse_keep_list_star_means_nothing() {
let cwd = PathBuf::from("/tmp");
let s = "KEEP: *\n";
assert!(parse_keep_list(s, &cwd).is_empty());
}
#[test]
fn session_file_path_slug() {
let p = session_file_path(
Path::new("/home/u"),
Path::new("/workspace"),
"abc-123",
);
assert_eq!(p, PathBuf::from("/home/u/.claude/projects/-workspace/abc-123.jsonl"));
}
/// Tiny realistic session: bootstrap + initial prompt + 2 Reads + a Bash
/// + a thinking block. Used by scenario tests below.
fn write_fixture(dir: &Path, paths: &[&str]) -> PathBuf {
let mut lines: Vec<String> = Vec::new();
// queue-operation (metadata) — has no uuid/parentUuid.
lines.push(r#"{"type":"queue-operation","sessionId":"s1"}"#.to_string());
// initial user prompt (root of conversation)
lines.push(r#"{"type":"user","uuid":"u-root","parentUuid":null,"message":{"role":"user","content":"Read .loop/protocol.md and follow its instructions."}}"#.to_string());
// assistant thinking — should always be dropped
lines.push(r#"{"type":"assistant","uuid":"u-think","parentUuid":"u-root","message":{"role":"assistant","content":[{"type":"thinking","thinking":"..."}]}}"#.to_string());
// Read tool_use for paths[0]
let p0 = paths.first().copied().unwrap_or("/tmp/a.rs");
lines.push(format!(
r#"{{"type":"assistant","uuid":"u-read-a","parentUuid":"u-think","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-a","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#,
p0
));
// tool_result for the Read
lines.push(r#"{"type":"user","uuid":"u-res-a","parentUuid":"u-read-a","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-a","content":"file A contents"}]}}"#.to_string());
// Bash tool_use — should always be dropped
lines.push(r#"{"type":"assistant","uuid":"u-bash","parentUuid":"u-res-a","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-bash","name":"Bash","input":{"command":"ls"}}]}}"#.to_string());
lines.push(r#"{"type":"user","uuid":"u-res-bash","parentUuid":"u-bash","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-bash","content":"a\nb\nc"}]}}"#.to_string());
// Read tool_use for paths[1] (a second file)
let p1 = paths.get(1).copied().unwrap_or("/tmp/b.rs");
lines.push(format!(
r#"{{"type":"assistant","uuid":"u-read-b","parentUuid":"u-res-bash","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"tu-b","name":"Read","input":{{"file_path":"{}"}}}}]}}}}"#,
p1
));
lines.push(r#"{"type":"user","uuid":"u-res-b","parentUuid":"u-read-b","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-b","content":"file B contents"}]}}"#.to_string());
let path = dir.join("s1.jsonl");
let mut f = fs::File::create(&path).unwrap();
for l in &lines {
writeln!(f, "{}", l).unwrap();
}
path
}
fn read_records(path: &Path) -> Vec<Value> {
fs::read_to_string(path)
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn trim_with_one_keep_drops_other_reads_and_bash_and_thinking() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.rs");
let b_path = tmp.path().join("b.rs");
fs::write(&a_path, "fn a(){}").unwrap();
fs::write(&b_path, "fn b(){}").unwrap();
let session = write_fixture(
tmp.path(),
&[a_path.to_str().unwrap(), b_path.to_str().unwrap()],
);
let mut keep = HashSet::new();
keep.insert(fs::canonicalize(&a_path).unwrap());
let mut stats_opt = None;
with_clean_env(|| {
stats_opt = Some(trim_session(&session, &keep).expect("trim ok"));
});
let stats = stats_opt.unwrap();
assert!(!stats.skipped);
assert!(stats.records_dropped >= 4, "should drop thinking + bash pair + b read pair, got {:?}", stats);
let recs = read_records(&session);
// Surviving tool_use ids: only tu-a; tu-b and tu-bash are gone.
let tool_use_ids: Vec<String> = recs
.iter()
.filter_map(|r| {
r.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_use"))
.and_then(|b| b.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
})
.collect();
assert_eq!(tool_use_ids, vec!["tu-a".to_string()]);
// Every tool_use has a paired tool_result (validate() enforces this on write;
// re-check here for the behavior we promise).
let tool_result_ids: Vec<String> = recs
.iter()
.filter_map(|r| {
r.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.filter(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
.and_then(|b| b.get("tool_use_id").and_then(|v| v.as_str()).map(|s| s.to_string()))
})
.collect();
assert_eq!(tool_result_ids, vec!["tu-a".to_string()]);
// No thinking blocks survive.
for r in &recs {
let typ_opt = r
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|b| b.get("type"))
.and_then(|v| v.as_str());
assert_ne!(typ_opt, Some("thinking"));
}
}
#[test]
fn trim_relinks_parent_uuids_to_nearest_surviving_ancestor() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.rs");
let b_path = tmp.path().join("b.rs");
fs::write(&a_path, "x").unwrap();
fs::write(&b_path, "y").unwrap();
let session = write_fixture(
tmp.path(),
&[a_path.to_str().unwrap(), b_path.to_str().unwrap()],
);
// Keep only b. Records dropped between root and b-read should result
// in u-read-b's new parent being u-root (the only surviving ancestor).
let mut keep = HashSet::new();
keep.insert(fs::canonicalize(&b_path).unwrap());
with_clean_env(|| {
trim_session(&session, &keep).expect("trim ok");
});
let recs = read_records(&session);
let read_b = recs
.iter()
.find(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-read-b"))
.expect("u-read-b survives");
let new_parent = read_b.get("parentUuid").and_then(|v| v.as_str());
// u-think, u-read-a, u-res-a, u-bash, u-res-bash all dropped → parent
// walks up to u-root.
assert_eq!(new_parent, Some("u-root"));
}
#[test]
fn trim_skipped_when_env_var_set() {
let tmp = tempfile::tempdir().unwrap();
let session = write_fixture(tmp.path(), &["/tmp/a.rs"]);
let original = fs::read_to_string(&session).unwrap();
let _g = ENV_LOCK.lock().unwrap();
// SAFETY: serialized by ENV_LOCK above.
unsafe { std::env::set_var("YOKE_DISABLE_SESSION_TRIM", "1") };
let stats = trim_session(&session, &HashSet::new()).expect("trim ok");
unsafe { std::env::remove_var("YOKE_DISABLE_SESSION_TRIM") };
assert!(stats.skipped);
assert_eq!(original, fs::read_to_string(&session).unwrap());
}
#[test]
fn trim_empty_keep_drops_all_tool_use_pairs() {
let tmp = tempfile::tempdir().unwrap();
let session = write_fixture(tmp.path(), &["/tmp/a.rs", "/tmp/b.rs"]);
with_clean_env(|| {
trim_session(&session, &HashSet::new()).expect("trim ok");
});
let recs = read_records(&session);
for r in &recs {
let block = r
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.and_then(|a| a.first());
if let Some(b) = block {
let btyp = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
assert_ne!(btyp, "tool_use", "no tool_use should survive empty keep");
assert_ne!(btyp, "tool_result", "no tool_result should survive empty keep");
assert_ne!(btyp, "thinking", "no thinking should survive");
}
}
// queue-operation, initial user prompt should survive.
assert!(recs.iter().any(|r| r.get("type").and_then(|v| v.as_str()) == Some("queue-operation")));
assert!(recs.iter().any(|r| r.get("uuid").and_then(|v| v.as_str()) == Some("u-root")));
}
}

View file

@ -1,17 +1,35 @@
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); if INTERRUPTED.swap(true, Ordering::Relaxed) {
// Second Ctrl+C — force exit immediately.
// _exit is async-signal-safe (unlike std::process::exit).
_exit(130);
}
// Kill the child to close the pipe and unblock reader.lines().
// Rust's BufReader retries on EINTR internally, so the only way
// to break out of the blocking read is to close the write end.
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;
safe fn _exit(status: 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 {

348
src/stash.rs Normal file
View file

@ -0,0 +1,348 @@
use std::fs;
use std::path::Path;
use crate::ansi::{BLUE, BOLD, ORANGE, RESET};
use crate::{log, log_error, STASH_DIR};
// ── Stash helpers ──────────────────────────────────────────────────────
/// Format Unix epoch seconds as `YYYY-MM-DDThh:mm:ss` using Hinnant's algorithm.
fn format_unix_timestamp(secs: u64) -> String {
let z = (secs / 86400) as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
let time_of_day = secs % 86400;
let h = time_of_day / 3600;
let min = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", y, m, d, h, min, s)
}
/// SipHash timestamp + file names/contents → lower 28 bits → 7-char hex.
fn generate_stash_hash(timestamp: &str, files: &[(String, Vec<u8>)]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
timestamp.hash(&mut hasher);
for (name, contents) in files {
name.hash(&mut hasher);
contents.hash(&mut hasher);
}
format!("{:07x}", hasher.finish() & 0x0FFF_FFFF)
}
/// Collect all regular files in `.loop/` excluding dotfile/dotdir entries.
/// Returns sorted `(filename, contents)` pairs for deterministic hashing.
pub(crate) fn collect_stashable_files() -> Vec<(String, Vec<u8>)> {
let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(".loop") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_file() {
if let Ok(contents) = fs::read(&path) {
files.push((name, contents));
}
}
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
pub(crate) struct StashEntry {
pub hash: String,
pub timestamp: String,
pub mode: String,
pub files: Vec<String>,
}
/// Parse `.loop/.stash/index` into a list of stash entries (oldest first).
pub(crate) fn parse_stash_index() -> Vec<StashEntry> {
let index_path = format!("{}/index", STASH_DIR);
let content = match fs::read_to_string(&index_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() < 4 {
return None;
}
Some(StashEntry {
hash: parts[0].to_string(),
timestamp: parts[1].to_string(),
mode: parts[2].to_string(),
files: parts[3].split(',').map(|s| s.to_string()).collect(),
})
})
.collect()
}
// ── Stash core ─────────────────────────────────────────────────────────
/// Snapshot all stashable files in `.loop/` to a new log entry.
/// Returns `Ok(hash)` on success.
pub(crate) fn stash_snapshot(mode: &str) -> Result<String, String> {
let files = collect_stashable_files();
if files.is_empty() {
return Err("no files to stash in .loop/".to_string());
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let timestamp = format_unix_timestamp(now);
let mut hash = generate_stash_hash(&timestamp, &files);
// Collision handling: rehash with counter suffix
let stash_base = Path::new(STASH_DIR);
for attempt in 0..100u32 {
if !stash_base.join(&hash).exists() {
break;
}
if attempt == 99 {
return Err("hash collision after 100 attempts".to_string());
}
hash = generate_stash_hash(&format!("{}{}", timestamp, attempt + 1), &files);
}
let entry_dir = stash_base.join(&hash);
fs::create_dir_all(&entry_dir)
.map_err(|e| format!("failed to create {}: {}", entry_dir.display(), e))?;
let file_names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect();
for (name, contents) in &files {
let dest = entry_dir.join(name);
fs::write(&dest, contents)
.map_err(|e| format!("failed to write {}: {}", dest.display(), e))?;
}
// Append to index
let index_path = format!("{}/index", STASH_DIR);
let line = format!(
"{}|{}|{}|{}\n",
hash,
timestamp,
mode,
file_names.join(",")
);
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)
.map_err(|e| format!("failed to open index: {}", e))?;
std::io::Write::write_all(&mut f, line.as_bytes())
.map_err(|e| format!("failed to write index: {}", e))?;
Ok(hash)
}
// ── Stash commands ─────────────────────────────────────────────────────
/// Snapshot `.loop/` to a new stash log entry. Returns file count for `clean()`.
pub(crate) fn stash_working_files(mode: &str) -> usize {
let files = collect_stashable_files();
if files.is_empty() {
return 0;
}
let count = files.len();
match stash_snapshot(mode) {
Ok(_) => count,
Err(_) => 0,
}
}
/// `yoke stash` — create a new stash entry and print the hash.
pub(crate) fn stash_create(mode: &str) -> i32 {
if !Path::new(".loop").exists() {
log_error(".loop/ directory not found");
return 1;
}
match stash_snapshot(mode) {
Ok(hash) => {
for (name, _) in &collect_stashable_files() {
let _ = fs::remove_file(Path::new(".loop").join(name));
}
log(&format!("stashed → {}{}{}", BLUE, hash, RESET));
0
}
Err(msg) => {
log_error(&msg);
1
}
}
}
/// `yoke stash log` — list all stash entries (newest first).
pub(crate) fn stash_log_cmd() -> i32 {
if !Path::new(".loop").exists() {
log_error(".loop/ directory not found");
return 1;
}
let entries = parse_stash_index();
if entries.is_empty() {
log("no stash entries");
return 0;
}
for entry in entries.iter().rev() {
eprintln!(
"{}{}{}{} {} mode={}",
BOLD, BLUE, entry.hash, RESET, entry.timestamp, entry.mode
);
for file in &entry.files {
eprintln!(" {}", file);
}
}
0
}
/// Resolve a hash prefix to a single stash entry. Returns the entry or logs an error.
fn resolve_stash_entry<'a>(entries: &'a [StashEntry], target_hash: &str) -> Option<&'a StashEntry> {
let matches: Vec<&StashEntry> = entries
.iter()
.filter(|e| e.hash == target_hash || e.hash.starts_with(target_hash))
.collect();
match matches.len() {
0 => {
log_error(&format!("no stash entry matching '{}'", target_hash));
None
}
1 => Some(matches[0]),
n => {
log_error(&format!(
"ambiguous hash '{}' — matches {} entries",
target_hash, n
));
None
}
}
}
/// Auto-stash current state, then replace `.loop/` files with those from `entry_dir`.
fn swap_loop_files(entry_dir: &Path, mode: &str) -> Result<(), String> {
let current_files = collect_stashable_files();
if !current_files.is_empty() {
let hash = stash_snapshot(mode)?;
log(&format!(
"auto-stashed current state → {}{}{}",
BLUE, hash, RESET
));
}
for (name, _) in &collect_stashable_files() {
let _ = fs::remove_file(Path::new(".loop").join(name));
}
if let Ok(dir_entries) = fs::read_dir(entry_dir) {
for entry in dir_entries.flatten() {
let name = entry.file_name();
let dest = Path::new(".loop").join(&name);
fs::copy(entry.path(), &dest).map_err(|e| {
format!("failed to restore {}: {}", name.to_string_lossy(), e)
})?;
}
}
Ok(())
}
/// `yoke stash checkout <hash>` — auto-stash current state, clear `.loop/` files,
/// restore target entry. Supports prefix matching.
pub(crate) fn stash_checkout(target_hash: &str, mode: &str) -> i32 {
if !Path::new(".loop").exists() {
log_error(".loop/ directory not found");
return 1;
}
let entries = parse_stash_index();
if entries.is_empty() {
log_error("no stash entries");
return 1;
}
let target = match resolve_stash_entry(&entries, target_hash) {
Some(t) => t,
None => return 1,
};
let entry_dir = Path::new(STASH_DIR).join(&target.hash);
if !entry_dir.exists() {
log_error("stash entry directory missing — index is corrupt");
return 1;
}
if let Err(msg) = swap_loop_files(&entry_dir, mode) {
log_error(&msg);
return 1;
}
log(&format!("checked out {}{}{}", BLUE, target.hash, RESET));
0
}
/// `yoke stash pop` — checkout the most recent stash entry.
pub(crate) fn stash_pop(mode: &str) -> i32 {
let entries = parse_stash_index();
match entries.last() {
Some(entry) => stash_checkout(&entry.hash, mode),
None => {
log_error("no stash found — nothing to restore");
1
}
}
}
pub(crate) fn print_stash_help() {
eprintln!(
"{}{}[yoke stash]{} snapshot and restore .loop/ state",
ORANGE, BOLD, RESET
);
eprintln!();
eprintln!(
"{}USAGE:{} yoke stash [subcommand]",
BOLD, RESET
);
eprintln!();
eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET);
eprintln!(
" {}(none){} Snapshot .loop/ files to stash, then clear the directory",
BOLD, RESET
);
eprintln!(
" {}log{} List all stash entries (hash, timestamp, mode, files)",
BOLD, RESET
);
eprintln!(
" {}checkout <hash>{} Auto-stash current state, then restore target entry",
BOLD, RESET
);
eprintln!(
" {}pop{} Alias for checkout of the most recent entry",
BOLD, RESET
);
eprintln!();
eprintln!("{}EXAMPLES:{}", BOLD, RESET);
eprintln!(" yoke stash # snapshot current .loop/ files");
eprintln!(" yoke stash log # show all stash entries");
eprintln!(" yoke stash checkout a1b2c3d # restore a specific snapshot");
eprintln!(" yoke stash pop # restore the most recent snapshot");
}

View file

@ -1,21 +1,53 @@
use std::io::{BufRead, BufReader, Write}; use std::collections::{BTreeMap, HashMap};
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path; use std::path::Path;
use std::process::ChildStdout; use std::process::ChildStdout;
use std::time::Instant;
use crate::json::{extract_num, extract_str}; use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW};
use crate::json::{extract_num, extract_str, unescape_json};
// ANSI escape codes const BG_RED: &str = "\x1b[48;2;80;30;30m";
const RESET: &str = "\x1b[0m"; const BG_GREEN: &str = "\x1b[48;2;30;60;30m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m"; /// Aggregated, persistable view of one agent invocation's stream.
const CYAN: &str = "\x1b[36m"; ///
const YELLOW: &str = "\x1b[33m"; /// Produced by `filter_stream` after the child's stdout closes. Carries
const GREEN: &str = "\x1b[32m"; /// everything the metrics layer wants: cost, agent-reported wall time,
/// thinking total, and per-tool durations + counts.
#[derive(Debug, Clone, Default)]
pub struct StreamSummary {
pub cost_usd: f64,
/// Wall clock as reported by the agent's `result` event (Claude only).
pub agent_reported_secs: Option<f64>,
pub num_turns: u32,
pub thinking_secs: f64,
pub tool_counts: BTreeMap<String, u32>,
pub tool_durations_secs: BTreeMap<String, f64>,
/// Claude session ID extracted from the init event. Used by the harness
/// to `--resume` the same session on the next iteration, preserving the
/// prompt cache. `None` for OpenCode or if the init event was missed.
pub session_id: Option<String>,
}
struct StreamState { struct StreamState {
turn_num: u32, turn_num: u32,
current_msg_id: Option<String>, current_msg_id: Option<String>,
seen_init: bool, seen_init: bool,
in_thinking: bool,
thinking_start: Option<Instant>,
thinking_total_secs: f64,
iteration_cost: f64,
iteration_duration_secs: f64,
/// Maps tool_use id → (tool name, start instant), so tool_result can
/// look up its origin and elapsed time.
tool_use_starts: HashMap<String, (String, Instant)>,
/// Counts of tool_use events by tool name (for iteration summary strip).
tool_counts: HashMap<String, u32>,
/// Wall-clock duration accumulated per tool name across the iteration.
tool_durations: HashMap<String, f64>,
/// Captured from the `system`/`init` event so the harness can `--resume`.
session_id: Option<String>,
} }
impl StreamState { impl StreamState {
@ -24,8 +56,187 @@ impl StreamState {
turn_num: 0, turn_num: 0,
current_msg_id: None, current_msg_id: None,
seen_init: false, seen_init: false,
in_thinking: false,
thinking_start: None,
thinking_total_secs: 0.0,
iteration_cost: 0.0,
iteration_duration_secs: 0.0,
tool_use_starts: HashMap::new(),
tool_counts: HashMap::new(),
tool_durations: HashMap::new(),
session_id: None,
} }
} }
fn into_summary(self) -> StreamSummary {
let agent_reported_secs = if self.iteration_duration_secs > 0.0 {
Some(self.iteration_duration_secs)
} else {
None
};
StreamSummary {
cost_usd: self.iteration_cost,
agent_reported_secs,
num_turns: self.turn_num,
thinking_secs: self.thinking_total_secs,
tool_counts: self.tool_counts.into_iter().collect(),
tool_durations_secs: self.tool_durations.into_iter().collect(),
session_id: self.session_id,
}
}
}
/// Render a mini-diff from old_string/new_string extracted from an Edit tool_use.
/// Shows red `−` lines for removed and green `+` lines for added, truncated to ~5 lines.
fn format_edit_diff(line: &str) -> String {
let old = extract_str(line, "old_string").map(|s| unescape_json(s));
let new = extract_str(line, "new_string").map(|s| unescape_json(s));
let (old, new) = match (old, new) {
(Some(o), Some(n)) => (o, n),
_ => return String::new(),
};
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut diff_lines: Vec<String> = Vec::new();
for ol in &old_lines {
diff_lines.push(format!(" {}{}{}− {}{}", BG_RED, RED, DIM, ol, RESET));
}
for nl in &new_lines {
diff_lines.push(format!(" {}{}{}+ {}{}", BG_GREEN, GREEN, DIM, nl, RESET));
}
let max_display = 5;
let total = diff_lines.len();
if total <= max_display {
diff_lines.join("\n")
} else {
let mut out: Vec<String> = diff_lines[..max_display].to_vec();
out.push(format!(" {}… +{} more lines{}", DIM, total - max_display, RESET));
out.join("\n")
}
}
/// Extract the last ~3 lines of error content from a Bash tool_result for display.
fn format_bash_error_tail(line: &str) -> String {
let content = match extract_str(line, "content") {
Some(s) => unescape_json(s),
None => return String::new(),
};
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return String::new();
}
let max_tail = 3;
let start = if lines.len() > max_tail { lines.len() - max_tail } else { 0 };
let tail: Vec<String> = lines[start..]
.iter()
.map(|l| format!(" {}{}{}", RED, l, RESET))
.collect();
tail.join("\n")
}
/// Render a preview for Write tool_use: first ~3 lines of content + line count badge.
fn format_write_preview(line: &str) -> String {
let content = match extract_str(line, "content") {
Some(s) => unescape_json(s),
None => return String::new(),
};
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let badge = format!(" {}({} lines){}", DIM, total, RESET);
let max_preview = 3;
let preview_lines: Vec<String> = lines.iter()
.take(max_preview)
.map(|l| format!(" {}{}{}", DIM, l, RESET))
.collect();
let mut out = vec![badge];
out.extend(preview_lines);
if total > max_preview {
out.push(format!(" {}…{}", DIM, RESET));
}
out.join("\n")
}
/// Format a badge for Grep/Glob tool_result content.
/// For Grep: tries to count matches/files from the content.
/// For Glob: counts the number of file paths returned.
fn format_grep_glob_badge(tool_name: &str, line: &str) -> String {
let content = match extract_str(line, "content") {
Some(s) => unescape_json(s),
None => return String::new(),
};
if content.trim().is_empty() {
return format!("{}0 results{}", DIM, RESET);
}
match tool_name {
"Grep" => {
// Grep results are typically one file path per line (files_with_matches mode)
// or content lines. Count non-empty lines as results.
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let count = lines.len();
if count == 1 {
format!("{} match", count)
} else {
format!("{} matches", count)
}
}
"Glob" => {
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let count = lines.len();
if count == 1 {
format!("{} file", count)
} else {
format!("{} files", count)
}
}
_ => String::new(),
}
}
/// Map a file extension to a human-readable language/type label.
fn ext_to_label(ext: &str) -> Option<&'static str> {
match ext {
"rs" => Some("rust"),
"py" => Some("python"),
"js" => Some("javascript"),
"ts" => Some("typescript"),
"tsx" => Some("tsx"),
"jsx" => Some("jsx"),
"json" => Some("json"),
"toml" => Some("toml"),
"yaml" | "yml" => Some("yaml"),
"md" => Some("markdown"),
"sh" | "bash" | "zsh" => Some("shell"),
"html" => Some("html"),
"css" => Some("css"),
"sql" => Some("sql"),
"go" => Some("go"),
"java" => Some("java"),
"c" => Some("c"),
"cpp" | "cc" | "cxx" => Some("c++"),
"h" | "hpp" => Some("header"),
"rb" => Some("ruby"),
"lua" => Some("lua"),
"zig" => Some("zig"),
"lock" => Some("lock"),
"xml" => Some("xml"),
"txt" => Some("text"),
"csv" => Some("csv"),
"dockerfile" => Some("docker"),
"tf" => Some("terraform"),
"ex" | "exs" => Some("elixir"),
_ => None,
}
} }
/// Format a tool_use event into a human-readable string. /// Format a tool_use event into a human-readable string.
@ -35,46 +246,265 @@ fn format_tool_call(line: &str) -> String {
match tool_name { match tool_name {
"Read" => { "Read" => {
let path = extract_str(line, "file_path").unwrap_or("?"); let path = extract_str(line, "file_path").unwrap_or("?");
format!("Read: {}", path) let badge = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.and_then(ext_to_label)
.map(|label| format!(" {}[{}]{}", DIM, label, RESET))
.unwrap_or_default();
format!("{}{}Read:{} {}{}{}{}", BOLD, CYAN, RESET, DIM, path, RESET, badge)
} }
"Edit" => { "Edit" => {
let path = extract_str(line, "file_path").unwrap_or("?"); let path = extract_str(line, "file_path").unwrap_or("?");
format!("Edit: {}", path) let header = format!("{}{}Edit:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET);
let diff = format_edit_diff(line);
if diff.is_empty() {
header
} else {
format!("{}\n{}", header, diff)
}
} }
"Write" => { "Write" => {
let path = extract_str(line, "file_path").unwrap_or("?"); let path = extract_str(line, "file_path").unwrap_or("?");
format!("Write: {}", path) let header = format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET);
let preview = format_write_preview(line);
if preview.is_empty() {
header
} else {
format!("{}\n{}", header, preview)
}
} }
"Bash" => { "Bash" => {
let cmd = extract_str(line, "command").unwrap_or("?"); let cmd = extract_str(line, "command").unwrap_or("?");
if cmd.len() > 80 { if cmd.len() > 80 {
format!("Bash: {}...", &cmd[..77]) let truncated: String = cmd.chars().take(77).collect();
format!("{}{}Bash:{} {}{}...{}", BOLD, MAGENTA, RESET, DIM, truncated, RESET)
} else { } else {
format!("Bash: {}", cmd) format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET)
} }
} }
"Glob" => { "Glob" => {
let pat = extract_str(line, "pattern").unwrap_or("?"); let pat = extract_str(line, "pattern").unwrap_or("?");
format!("Glob: {}", pat) format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
} }
"Grep" => { "Grep" => {
let pat = extract_str(line, "pattern").unwrap_or("?"); let pat = extract_str(line, "pattern").unwrap_or("?");
format!("Grep: {}", pat) format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
} }
other => other.to_string(), other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
} }
} }
/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout. /// Handle "assistant" events: render tool_use summaries and track tool counts.
/// Consumes the stream entirely — raw NDJSON is not written to disk. fn handle_assistant(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) { if !line.contains("\"tool_use\"") {
return Ok(());
}
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) {
state.tool_use_starts.insert(id.to_string(), (name.to_string(), Instant::now()));
*state.tool_counts.entry(name.to_string()).or_insert(0) += 1;
}
let desc = format_tool_call(line);
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)
}
/// Handle "stream_event" events: render thinking timer, text deltas, and block boundaries.
fn handle_stream_event(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
if line.contains("\"content_block_delta\"") {
if line.contains("\"thinking_delta\"") {
if let Some(start) = state.thinking_start {
let elapsed = start.elapsed().as_secs_f64();
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
out.flush()?;
}
} else if line.contains("\"text_delta\"")
&& let Some(text) = extract_str(line, "text")
{
let text = unescape_json(text);
write!(out, "{}{}{}", DIM, text, RESET)?;
out.flush()?;
}
} else if line.contains("\"content_block_start\"") {
if line.contains("\"thinking\"") {
state.thinking_start = Some(Instant::now());
write!(out, "{}{}thinking 0.0s{}", DIM, BLUE, RESET)?;
out.flush()?;
state.in_thinking = true;
} else if !line.contains("\"tool_use\"") {
writeln!(out)?;
}
} else if line.contains("\"content_block_stop\"") {
if state.in_thinking {
if let Some(start) = state.thinking_start {
let elapsed = start.elapsed().as_secs_f64();
state.thinking_total_secs += elapsed;
write!(out, "\r{}{}thinking {:.1}s{}", DIM, BLUE, elapsed, RESET)?;
}
state.in_thinking = false;
state.thinking_start = None;
}
writeln!(out)?;
}
Ok(())
}
/// Handle "user" events: render tool_result success/error badges and
/// accumulate the wall-clock duration of each tool call by name.
fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
if !line.contains("\"tool_result\"") {
return Ok(());
}
// Pair this result with its tool_use; drop the entry and accumulate elapsed.
// Done for both success and error so failed tools still show up in metrics.
let tool_use_id = extract_str(line, "tool_use_id").map(|s| s.to_string());
let tool_name_owned: Option<String> = tool_use_id.and_then(|id| {
state.tool_use_starts.remove(&id).map(|(name, start)| {
let elapsed = start.elapsed().as_secs_f64();
*state.tool_durations.entry(name.clone()).or_insert(0.0) += elapsed;
name
})
});
let is_error = line.contains("\"is_error\":true") || line.contains("\"is_error\": true");
if is_error {
writeln!(out, " {}← {}{}✗{}", GRAY, RESET, RED, RESET)?;
let tail = format_bash_error_tail(line);
if !tail.is_empty() {
writeln!(out, "{}", tail)?;
}
return Ok(());
}
let badge = match tool_name_owned.as_deref() {
Some(name @ ("Grep" | "Glob")) => format_grep_glob_badge(name, line),
_ => String::new(),
};
if badge.is_empty() {
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)
} else {
writeln!(out, " {}← {}✓{} {}", GRAY, GREEN, RESET, badge)
}
}
/// Process a single NDJSON line, writing formatted output to `out`.
/// `prior_total` is the accumulated cost from previous iterations, used to display a running total.
/// Returns `Err` on write failure (e.g. broken pipe) so the caller can stop.
fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState, prior_total: f64) -> io::Result<()> {
// Check for turn boundary (message_id change)
if let Some(msg_id) = extract_str(line, "message_id") {
let is_new = state.current_msg_id.as_deref() != Some(msg_id);
if is_new {
state.current_msg_id = Some(msg_id.to_string());
state.turn_num += 1;
writeln!(out, "{}{}━━━ Turn {} ━━━{}", BOLD, ORANGE, state.turn_num, RESET)?;
}
}
match extract_str(line, "type") {
Some("system") => {
if extract_str(line, "subtype") == Some("init") && !state.seen_init {
state.seen_init = true;
let sid = extract_str(line, "session_id").unwrap_or("?");
if sid != "?" {
state.session_id = Some(sid.to_string());
}
let sid_short: String = sid.chars().take(12).collect();
let model = extract_str(line, "model").unwrap_or("?");
writeln!(out, "{}{}[stream]{} session {}… model={}", ORANGE, BOLD, RESET, sid_short, model)?;
}
}
Some("assistant") => handle_assistant(out, line, state)?,
Some("stream_event") => handle_stream_event(out, line, state)?,
Some("user") => handle_tool_result(out, line, state)?,
Some("result") => {
let cost = extract_num(line, "cost_usd").unwrap_or(0.0);
state.iteration_cost = cost;
let total = prior_total + cost;
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;
state.iteration_duration_secs = dur_secs;
writeln!(
out,
"{}{}[stream]{} done cost=${:.2} (total=${:.2}) turns={} duration={:.1}s",
ORANGE, BOLD, RESET, cost, total, turns, dur_secs
)?;
}
None if !line.trim().is_empty() => {
writeln!(out, "{} {}{}", DIM, line.trim(), RESET)?;
}
_ => {}
}
Ok(())
}
/// Build a compact one-line iteration summary strip from accumulated state.
/// Format: `⟪ 6 turns │ 3 edit 1.8s │ 1 bash 12.3s │ thinking 4.1s │ 42s │ $0.38 ⟫`
///
/// Tool entries show count + elapsed for the top-3 tools by wall time, then
/// count-only for the rest (stops the strip overflowing 80 cols).
fn format_summary_strip(state: &StreamState) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("{} turn{}", state.turn_num, if state.turn_num == 1 { "" } else { "s" }));
// Rank tools by elapsed time; top-3 get the "Ns" suffix, the rest are
// count-only. Tools with no recorded duration (e.g. tool_result never
// arrived) sort to the end of the timed list.
let mut ranked: Vec<(&String, u32, f64)> = state
.tool_counts
.iter()
.map(|(name, &count)| {
let secs = state.tool_durations.get(name).copied().unwrap_or(0.0);
(name, count, secs)
})
.collect();
ranked.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
for (i, (name, count, secs)) in ranked.iter().enumerate() {
let label = name.to_lowercase();
if i < 3 && *secs > 0.0 {
parts.push(format!("{} {} {:.1}s", count, label, secs));
} else {
parts.push(format!("{} {}", count, label));
}
}
if state.thinking_total_secs > 0.0 {
parts.push(format!("thinking {:.1}s", state.thinking_total_secs));
}
// Duration
parts.push(format!("{:.0}s", state.iteration_duration_secs));
// Cost
parts.push(format!("${:.2}", state.iteration_cost));
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
}
/// Shared stream loop: reads lines from stdout, tees to log, calls processor per line,
/// prints a summary strip, and finalizes state into a caller-defined return type.
///
/// Used by both Claude and OpenCode stream filters to avoid duplicating the
/// BufReader/signal-check/log-tee boilerplate. `finalize` consumes the state
/// so the caller can move out of it (e.g. into a `StreamSummary`).
pub fn run_stream_loop<S, R>(
stdout: ChildStdout,
log_path: Option<&Path>,
mut state: S,
mut process: impl FnMut(&mut dyn Write, &str, &mut S) -> io::Result<()>,
summarize: impl FnOnce(&S) -> Option<String>,
finalize: impl FnOnce(S) -> R,
) -> R {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
let mut state = StreamState::new();
let mut log_file = log_path.and_then(|p| { let mut log_file = log_path.and_then(|p| {
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok(); std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
std::fs::File::create(p).ok() std::fs::File::create(p).ok()
}); });
let mut out = io::stdout().lock();
for line_result in reader.lines() { for line_result in reader.lines() {
if crate::signal::interrupted() { if crate::signal::interrupted() {
break; break;
@ -89,80 +519,34 @@ pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>) {
continue; continue;
} }
// Tee raw NDJSON to log file
if let Some(ref mut f) = log_file { if let Some(ref mut f) = log_file {
let _ = writeln!(f, "{}", line); let _ = writeln!(f, "{}", line);
} }
// Check for turn boundary (message_id change) if process(&mut out, &line, &mut state).is_err() {
if let Some(msg_id) = extract_str(&line, "message_id") { break;
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);
}
}
} }
} }
if let Some(strip) = summarize(&state) {
let _ = writeln!(out, "{}", strip);
}
let _ = out.flush();
finalize(state)
}
/// Filter NDJSON stream from Claude and format as rich ANSI output on stdout.
/// `prior_total` is the accumulated cost from previous iterations.
/// Returns a `StreamSummary` carrying cost, thinking time, tool durations, etc.
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> StreamSummary {
let state = StreamState::new();
run_stream_loop(
stdout,
log_path,
state,
|out, line, st| process_line(out, line, st, prior_total),
|st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None },
|st| st.into_summary(),
)
} }

188
src/stream_opencode.rs Normal file
View file

@ -0,0 +1,188 @@
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::Path;
use std::process::ChildStdout;
use crate::ansi::{BLUE, BOLD, CYAN, DIM, GRAY, GREEN, MAGENTA, ORANGE, RED, RESET, YELLOW};
use crate::json::{extract_num, extract_str, unescape_json};
use crate::stream::StreamSummary;
struct StreamState {
turn_num: u32,
iteration_cost: f64,
tool_counts: HashMap<String, u32>,
total_tokens: u64,
}
impl StreamState {
fn new() -> Self {
Self {
turn_num: 0,
iteration_cost: 0.0,
tool_counts: HashMap::new(),
total_tokens: 0,
}
}
}
fn format_tool_call(tool_name: &str, input: &str) -> String {
match tool_name {
"read" => {
let path = extract_str(input, "filePath").unwrap_or("?");
format!("{}{}Read:{} {}{}{}", BOLD, CYAN, RESET, DIM, path, RESET)
}
"write" => {
let path = extract_str(input, "filePath").unwrap_or("?");
format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET)
}
"apply_patch" => {
format!("{}{}ApplyPatch{}{}", BOLD, YELLOW, RESET, RESET)
}
"bash" => {
let cmd = extract_str(input, "command").unwrap_or("?");
if cmd.len() > 80 {
format!(
"{}{}Bash:{} {}{}...{}",
BOLD,
MAGENTA,
RESET,
DIM,
&cmd.chars().take(77).collect::<String>(),
RESET
)
} else {
format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET)
}
}
"glob" => {
let pat = extract_str(input, "pattern").unwrap_or("?");
format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
}
"grep" => {
let pat = extract_str(input, "pattern").unwrap_or("?");
format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
}
other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
}
}
fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamState) -> io::Result<()> {
let ev_type = extract_str(line, "type");
match ev_type {
Some("step_start") => {
state.turn_num += 1;
writeln!(
out,
"{}{}━━━ Turn {} ━━━{}",
BOLD, ORANGE, state.turn_num, RESET
)?;
}
Some("text") => {
if let Some(text) = extract_str(line, "text") {
let text = unescape_json(text);
write!(out, "{}{}{}", DIM, text, RESET)?;
out.flush()?;
}
}
Some("tool_use") => {
let tool_name = extract_str(line, "tool").unwrap_or("?");
let status = if line.contains("\"status\":\"error\"")
|| line.contains("\"status\": \"error\"")
{
"error"
} else if line.contains("\"status\":\"completed\"")
|| line.contains("\"status\": \"completed\"")
{
"completed"
} else {
"pending"
};
*state.tool_counts.entry(tool_name.to_string()).or_insert(0) += 1;
if status == "error" {
if let Some(error) = extract_str(line, "error") {
let error = unescape_json(error);
writeln!(
out,
" {}>> {}{}{} {}{}✗{}",
GRAY, RESET, BOLD, tool_name, RESET, RED, RESET
)?;
writeln!(out, " {}{}{}", RED, error, RESET)?;
} else {
writeln!(
out,
" {}>> {}{}{} {}{}✗{}",
GRAY, RESET, BOLD, tool_name, RESET, RED, RESET
)?;
}
} else if status == "completed" {
let input = extract_str(line, "input").unwrap_or("");
let desc = format_tool_call(tool_name, input);
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
} else {
let input = extract_str(line, "input").unwrap_or("");
let desc = format_tool_call(tool_name, input);
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
}
}
Some("step_finish") => {
let cost = extract_num(line, "cost").unwrap_or(0.0);
state.iteration_cost += cost;
let tokens = extract_num(line, "total").unwrap_or(0.0) as u64;
state.total_tokens = tokens;
}
_ => {}
}
Ok(())
}
fn format_summary_strip(state: &StreamState) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} turn{}",
state.turn_num,
if state.turn_num == 1 { "" } else { "s" }
));
let tool_order = ["bash", "read", "write", "apply_patch", "glob", "grep"];
for tool in &tool_order {
if let Some(&count) = state.tool_counts.get(*tool) {
parts.push(format!("{} {}", count, tool));
}
}
for (name, &count) in &state.tool_counts {
if !tool_order.contains(&name.as_str()) {
parts.push(format!("{} {}", count, name));
}
}
parts.push(format!("${:.2}", state.iteration_cost));
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
}
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, _prior_total: f64) -> StreamSummary {
let state = StreamState::new();
crate::stream::run_stream_loop(
stdout,
log_path,
state,
|out, line, st| process_line(out, line, st),
|st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None },
|st| StreamSummary {
cost_usd: st.iteration_cost,
agent_reported_secs: None,
num_turns: st.turn_num,
thinking_secs: 0.0,
tool_counts: st.tool_counts.into_iter().collect(),
tool_durations_secs: std::collections::BTreeMap::new(),
session_id: None,
},
)
}

View file

@ -0,0 +1,31 @@
# Judge
You are the last line of defense before a human sees this work. You serve two roles: adversary and advocate. You are rough on the implementation so the human who receives it gets something solid and pleasant. A PASS from you means you would stake your reputation on this code.
Read `.loop/plan.md`. For each stage defined in the plan:
## 1. Break it
Try to make the code fail. Do not trust that anything works just because it looks correct. Build it, run it, and feed it inputs designed to expose problems.
- **Boundary inputs** — zeroes, empty strings, max values, negative numbers, Unicode, special characters.
- **Error paths** — missing files, invalid config, network down, permission denied. Does it fail gracefully or crash?
- **Malformed input** — truncated data, wrong types, extra fields, duplicate keys.
- **Concurrency and timing** — if applicable, can you trigger race conditions or ordering bugs?
- **State edges** — what happens on first run vs. repeated runs? Empty state vs. populated state?
You have full shell access. Use it. Build the project, run its tests, then write your own commands to probe beyond what the test suite covers. If you cannot build or run it, that is a FAIL.
## 2. Judge it for the human
Now put on the hat of a senior developer receiving this in a pull request. Would you be pleased or annoyed?
- **Naming** — are functions, variables, and files named so a stranger can read them without a glossary?
- **Error messages** — when something goes wrong, does the user get a message that helps them fix it, or a stack trace and a shrug?
- **API ergonomics** — is the interface (CLI flags, function signatures, config format) intuitive or surprising?
- **Readability** — can you follow the logic without running a debugger in your head?
- **No dead weight** — no leftover TODOs, commented-out code, placeholder text, or debug prints that shipped.
## Verdict
PASS only if both halves hold: nothing you threw at it broke it in a way that matters, AND you would be genuinely happy to receive this code. FAIL with specifics — what broke, what command you ran, what you expected vs. what happened, or what about the code quality fell short.

View file

@ -0,0 +1,90 @@
# Protocol: Brute + Plan Runner (Triple Loop)
You are operating inside an automated triple loop — not a conversation.
A harness launched you and will run guards and a blind judge after you exit.
The outer brute loop retries until a judge says PASS.
Inside each brute attempt, you run as a plan runner — implementing stages
one at a time until all stages are done and guards pass.
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/protocol.md` | read | These instructions. |
| `.loop/plan.md` | read | The feature plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | 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 — check which stage you are on, what you tried, and what you learned.
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
6. **Implement**. Make the code changes for exactly one stage.
7. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
The first line of `.loop/notes.md` must be one of:
- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures).
- `STATUS: DONE` — All stages are implemented and you believe guards will pass.
## KEEP: Carrying File Context Across Iterations
Your conversation history persists across worker iterations via `--resume`.
To keep Claude's prompt cache warm without ballooning, the outer loop trims
your session between rounds: it drops `Bash` output, thinking, and any file
`Read` results that aren't on your KEEP list. Everything else (text turns,
intermediate `Edit`/`Grep`/`Glob` results) is also dropped.
After STATUS, on its own line in `.loop/notes.md`, list the file paths you
want to keep cached for the next iteration:
```
STATUS: IN_PROGRESS
KEEP: src/foo.rs src/bar.rs tests/baz.rs
```
Rules:
- Space-separated repo-relative paths (or absolute).
- List files you read **this iteration** and will still need next iteration.
- Don't list `.loop/*` files — those live on disk and are re-read fresh.
- Keep the list tight. Every kept file is paid for at cache-read rates
every round it stays. Drop a file once it's no longer relevant.
- Omit `KEEP:` (or `KEEP: *`) to keep nothing.
Note: the judge always runs in a fresh session — your KEEP list does not
affect the judge.
## What Happens After You Exit
1. Guards run (diff boundary check + configured guard commands).
2. If guards pass and STATUS is DONE, the plan loop ends.
3. Then the judge (a fresh Claude with zero implementation context) verifies the feature.
4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback.
## Rules
- **No git operations.** Do not commit, push, branch, or modify git config.
- **Do not modify `protocol.md`, `plan.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One stage per iteration.** Implement a single stage, update notes, and exit.
- **Study judge.md.** Knowing the test helps you pass it.
- **The judge's feedback is ground truth.** Fix what they say is broken.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,195 @@
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ Yoke configuration — brute mode ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Brute mode adds a judge on top of the plan loop. The worker iterates
# until STATUS: DONE + guards pass, then a fresh judge agent (with zero
# worker context) independently verifies the result.
#
# Outer loop (brute):
# 1. Run plan loop (worker iterates until DONE + guards pass)
# 2. Invoke judge — reads judge.md, tests the feature, writes verdict.md
# 3. VERDICT: PASS → exit 0
# 4. VERDICT: FAIL → reset STATUS to IN_PROGRESS, retry from step 1
# (verdict.md and guard-results.md are preserved so the worker
# sees what went wrong on its next attempt)
# 5. After max-judge-failures consecutive FAILs → bail out (exit 1)
#
# Inner loop (plan, per iteration):
# 1. Restore protected files (protocol.md, plan.md, yoke.conf)
# 2. Invoke the agent
# 3. Diff boundary check
# 4. Run guards → results to guard-results.md
# 5. Fire periodic agents (if cadence matches)
# 6. Run hooks
# 7. Check: STATUS: DONE + all guards pass → exit inner loop
# ── Backend ────────────────────────────────────────────────────────────
# Which LLM backend to use. Leave commented for Claude CLI (default).
# Setting `model` switches to the OpenCode backend, which supports
# OpenRouter, OpenAI, Anthropic API, and other providers.
#
# model openrouter/anthropic/claude-sonnet-4
# model openai/gpt-4o
# model anthropic/claude-sonnet-4
# ── Claude model (optional) ────────────────────────────────────────────
# Override the model the Claude CLI uses for each iteration. Leave
# commented to use Claude Code's default (Opus). Useful for trading some
# reasoning depth for faster, cheaper iterations.
#
# claude-model claude-sonnet-4-6
# claude-model claude-haiku-4-5
# ── Thinking budget (optional) ─────────────────────────────────────────
# Cap extended-thinking tokens per turn for the Claude CLI backend.
# Useful when running smaller/faster models (sonnet, haiku) and you'd
# rather they spend the iteration acting than reasoning. Sets the
# MAX_THINKING_TOKENS env var on the agent invocation.
#
# thinking off # disable extended thinking entirely (0 tokens)
# thinking low # 2k tokens
# thinking medium # 10k tokens
# thinking high # 32k tokens
#
# Ignored by the OpenCode backend (warns at config load).
#
# thinking low
# ── Sandbox ────────────────────────────────────────────────────────────
# Docker image to run the agent inside. Your working directory is
# bind-mounted into the container at /workspace. Required unless you
# pass --no-sandbox on the command line.
#
# Note: sandbox is not currently supported with the `model` directive.
image claude-code-sandbox:latest
# ── Output ─────────────────────────────────────────────────────────────
# max-tail: max lines of output kept *per guard* in guard-results.md.
# Only affects what the agent reads back — full output still streams to
# your terminal. Default 200 is enough for most test suites; raise it
# if your guards produce essential output beyond 200 lines.
max-tail 200
# log-dir: save raw stream-json output for each iteration. Useful for
# debugging agent behavior or auditing token usage. Files are named
# <log-dir>/iteration-<N>.jsonl.
#
# log-dir .loop/logs
# ── Metrics ────────────────────────────────────────────────────────────
# Per-iteration timing + cost records are written as NDJSON under
# ~/.yoke/metrics/<project-slug>/ by default. Survives `yoke clean`.
#
# metrics-dir ~/.yoke/metrics # default
# metrics off # opt out of disk writes
# ── Scope rules (diff boundary enforcement) ──────────────────────────
# After each iteration yoke diffs the working tree and checks every
# changed file against these rules. If any file is out of scope, ALL
# guards are skipped and the agent gets only boundary feedback.
# Files under .loop/ are always exempt (yoke's own infrastructure).
#
# Three directives, most-specific (longest prefix) match wins:
#
# allow <prefix> — any change permitted (add, modify, delete)
# add-only <prefix> — new files OK; edits to existing files rejected
# no-modify <prefix> — no changes at all (adds or edits rejected)
#
# The special prefix "." matches every path (root catch-all).
#
# Examples:
# allow src/ # full access to source
# allow tests/ # full access to tests
# add-only docs/ # can add new docs, not edit existing
# no-modify .github/ # CI config is off-limits
# no-modify package-lock.json # protect a specific file
# allow . # fallback: everything else allowed
allow .
# ── Guards (post-iteration validation) ────────────────────────────────
# Shell commands that validate the agent's work. All guards run in
# parallel; results are collected in declared order and written to
# .loop/guard-results.md. The agent reads this file on its next turn,
# so failed guards become automatic feedback.
#
# If the boundary check fails, guards are skipped entirely — the agent
# must fix scope violations before guards will run again.
#
# The inner loop only exits when STATUS: DONE *and* all guards pass.
#
# Examples:
# guard cargo test
# guard npm test
# guard python -m pytest tests/ -x
# guard go test ./...
# guard make check
# guard ./scripts/validate.sh
#
# TIP: avoid type-checkers (cargo check, tsc --noEmit) as the sole
# guard — their verbose output can distract the agent from the real
# task. Pair them with a test suite that validates behavior.
# guard cargo test
# ── Judge cadence ─────────────────────────────────────────────────────
# By default the judge runs only after the worker signals DONE + guards
# pass. These settings give you finer control over judge timing.
#
# judge-every <N>: also fire the judge as a mid-loop quality checkpoint
# every N worker iterations (the judge still always fires on DONE too,
# regardless of cadence). Mid-loop verdicts provide early feedback
# without stopping the worker.
#
# judge-every 5
# max-judge-failures <N>: max consecutive FAILs before bailing out.
# Default: 3. The counter is exact — bailout happens on the Nth FAIL,
# and resets to 0 after any PASS. On retry, verdict.md and
# guard-results.md are preserved so the worker sees judge feedback.
#
# max-judge-failures 3
# ── Periodic agents ───────────────────────────────────────────────────
# Supplementary agents invoked at a fixed cadence (every N iterations).
# Useful for cleanup passes, code review, metrics collection, etc.
# Each periodic gets its own fresh agent session.
#
# periodic <protocol-path> <every-N-iterations>
#
# The agent name is derived from the filename stem:
# .loop/cleaner.md → name is "cleaner"
# .loop/reviewer.md → name is "reviewer"
#
# Examples:
# periodic .loop/cleaner.md 10 # cleanup every 10 iterations
# periodic .loop/reviewer.md 5 # review pass every 5 iterations
#
# guard-after: shell commands that run after a specific periodic agent
# completes. Results are written to .loop/periodic-<name>-results.md
# (kept separate from the worker's guard-results.md). Failures produce
# warnings but do not affect the main loop.
#
# guard-after <periodic-name> <command>
#
# Example combo:
# periodic .loop/cleaner.md 10
# guard-after cleaner cargo test
# guard-after cleaner cargo clippy -- -D warnings
# ── Hooks (fire-and-forget post-iteration commands) ───────────────────
# Shell commands that run after each iteration (after guards and
# periodics). Unlike guards, hook failures never block the loop or
# affect its exit code — non-zero exits produce a warning, nothing more.
# Output goes to your terminal only, never to files the agent reads.
#
# The YOKE_ITERATION env var contains the current iteration number.
#
# Examples:
# hook echo "iteration $YOKE_ITERATION done"
# hook git add -A && git commit -m "auto: iteration $YOKE_ITERATION" || true
# hook ./scripts/notify.sh
# hook curl -s -X POST "$WEBHOOK_URL" -d "{\"iteration\": $YOKE_ITERATION}"

View file

@ -0,0 +1,10 @@
## REPL probing
If the code exposes REPL-accessible boundaries (CLI commands, HTTP endpoints, library APIs, shell scripts), open an interactive session and use it to probe the implementation directly.
- **Exercise every boundary** — call each exposed function/endpoint/command with normal inputs first, then adversarial ones.
- **Chain operations** — does state from one call corrupt the next? Try create→read→update→delete sequences and variations.
- **Interrupt mid-flow** — Ctrl-C during an operation, kill a session mid-transaction. Does it recover?
- **Explore discoverability** — can you figure out how to use the interface without reading the source? Are help/usage messages accurate?
Document the REPL session. If something broke, paste the exact input and output.

View file

@ -0,0 +1,20 @@
# Briefing: Planning Agent
You are helping a user write a **plan** for an automated execution harness.
## What is `.loop/`?
The `.loop/` directory contains an automated loop system. After you and the user finish writing `plan.md`, a separate agent (not you) will be launched to execute it — iterating automatically until the plan is complete and all guards pass.
## Your role
Help the user write `plan.md` — a design-level outline broken into stages.
## Guidelines
- **Stages should be goal-oriented.** Describe *what* should be achieved, not *how* at the code level.
- **Stay abstract.** No exact line numbers, function signatures, or copy-paste code snippets. The executing agent will figure out the concrete details.
- **Each stage should be a meaningful unit of work** that can be implemented and verified independently.
- **Only drill into specifics if the user asks.** Default to high-level design intent.
The executing agent has full access to the codebase and will make its own implementation decisions. Your plan is a design reference, not a step-by-step tutorial.

View file

@ -0,0 +1,96 @@
# 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/yoke.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.
## KEEP: Carrying File Context Across Iterations
Your conversation history persists across iterations via `--resume`. To keep
Claude's prompt cache warm without ballooning, the outer loop trims your
session between rounds: it drops `Bash` output, thinking, and any file
`Read` results that aren't on your KEEP list. Everything else (text turns,
intermediate `Edit`/`Grep`/`Glob` results) is also dropped.
After STATUS, on its own line in `.loop/notes.md`, list the file paths you
want to keep cached for the next iteration:
```
STATUS: IN_PROGRESS
KEEP: src/foo.rs src/bar.rs tests/baz.rs
```
Rules:
- Space-separated repo-relative paths (or absolute — both work).
- List files you read **this iteration** and will still need next iteration.
- Don't list `.loop/notes.md`, `.loop/plan.md`, `.loop/protocol.md`,
`.loop/guard-results.md`, or `.loop/verdict.md` — those live on disk and
you re-read them fresh every iteration. Listing them is harmless but wastes
a slot.
- Keep the list tight. Every kept file is paid for (at cache-read rates,
~10% of fresh) every round it stays kept. Drop a file once you're confident
you won't need it again.
- Omit the `KEEP:` line entirely (or `KEEP: *`) to keep nothing — your
conversation prefix shrinks to just the bootstrap. Use this after major
refactors or when you've moved to an unrelated area of the code.
## What the Guards Check
After you exit, the outer loop runs guards defined in `.loop/yoke.conf`.
1. **Diff boundary check** — Always runs first. Verifies every file you changed
or created is within the scope rules defined in `.loop/yoke.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/yoke.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 `yoke.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.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,186 @@
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ Yoke configuration — loop mode ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Loop mode iterates an agent until the job is done. Each iteration:
#
# 1. Restore protected files (protocol.md, plan.md, yoke.conf)
# 2. Invoke the agent (reads protocol.md, does work, updates notes.md)
# 3. Diff boundary check (are changed files within allowed scope?)
# 4. Run guards (test suites, linters — results go to guard-results.md)
# 5. Fire periodic agents (if cadence matches this iteration)
# 6. Run hooks (fire-and-forget side effects)
# 7. Check exit: STATUS: DONE in notes.md AND all guards pass → exit 0
#
# Protected files are backed up at start and restored every iteration,
# so the agent can never permanently corrupt its own instructions.
# ── Backend ────────────────────────────────────────────────────────────
# Which LLM backend to use. Leave commented for Claude CLI (default).
# Setting `model` switches to the OpenCode backend, which supports
# OpenRouter, OpenAI, Anthropic API, and other providers.
#
# model openrouter/anthropic/claude-sonnet-4
# model openai/gpt-4o
# model anthropic/claude-sonnet-4
# ── Claude model (optional) ────────────────────────────────────────────
# Override the model the Claude CLI uses for each iteration. Leave
# commented to use Claude Code's default (Opus). Useful for trading some
# reasoning depth for faster, cheaper iterations.
#
# claude-model claude-sonnet-4-6
# claude-model claude-haiku-4-5
# ── Thinking budget (optional) ─────────────────────────────────────────
# Cap extended-thinking tokens per turn for the Claude CLI backend.
# Useful when running smaller/faster models (sonnet, haiku) and you'd
# rather they spend the iteration acting than reasoning. Sets the
# MAX_THINKING_TOKENS env var on the agent invocation.
#
# thinking off # disable extended thinking entirely (0 tokens)
# thinking low # 2k tokens
# thinking medium # 10k tokens
# thinking high # 32k tokens
#
# Ignored by the OpenCode backend (warns at config load).
#
# thinking low
# ── Sandbox ────────────────────────────────────────────────────────────
# Docker image to run the agent inside. Your working directory is
# bind-mounted into the container at /workspace. Required unless you
# pass --no-sandbox on the command line.
#
# Note: sandbox is not currently supported with the `model` directive.
image claude-code-sandbox:latest
# ── Output ─────────────────────────────────────────────────────────────
# max-tail: max lines of output kept *per guard* in guard-results.md.
# Only affects what the agent reads back — full output still streams to
# your terminal. Default 200 is enough for most test suites; raise it
# if your guards produce essential output beyond 200 lines.
max-tail 200
# log-dir: save raw stream-json output for each iteration. Useful for
# debugging agent behavior or auditing token usage. Files are named
# <log-dir>/iteration-<N>.jsonl.
#
# log-dir .loop/logs
# ── Metrics ────────────────────────────────────────────────────────────
# Per-iteration timing + cost records are written as NDJSON, one row per
# iteration, plus a row-per-run with totals. By default these live under
# ~/.yoke/metrics/<project-slug>/ so they survive `yoke clean`, `yoke
# stash`, and project deletes.
#
# Inspect with: jq . ~/.yoke/metrics/<project-slug>/<run-id>.ndjson
#
# metrics-dir ~/.yoke/metrics # default
# metrics off # opt out of disk writes
# ── Scope rules (diff boundary enforcement) ──────────────────────────
# After each iteration yoke diffs the working tree and checks every
# changed file against these rules. If any file is out of scope, ALL
# guards are skipped and the agent gets only boundary feedback.
# Files under .loop/ are always exempt (yoke's own infrastructure).
#
# Three directives, most-specific (longest prefix) match wins:
#
# allow <prefix> — any change permitted (add, modify, delete)
# add-only <prefix> — new files OK; edits to existing files rejected
# no-modify <prefix> — no changes at all (adds or edits rejected)
#
# The special prefix "." matches every path (root catch-all).
#
# Examples:
# allow src/ # full access to source
# allow tests/ # full access to tests
# add-only docs/ # can add new docs, not edit existing
# no-modify .github/ # CI config is off-limits
# no-modify package-lock.json # protect a specific file
# allow . # fallback: everything else allowed
allow .
# ── Guards (post-iteration validation) ────────────────────────────────
# Shell commands that validate the agent's work. All guards run in
# parallel; results are collected in declared order and written to
# .loop/guard-results.md. The agent reads this file on its next turn,
# so failed guards become automatic feedback.
#
# If the boundary check fails, guards are skipped entirely — the agent
# must fix scope violations before guards will run again.
#
# The loop only exits when STATUS: DONE *and* all guards pass. If the
# agent declares DONE but a guard fails, it keeps iterating.
#
# Examples:
# guard cargo test
# guard npm test
# guard python -m pytest tests/ -x
# guard go test ./...
# guard make check
# guard ./scripts/validate.sh
#
# TIP: avoid type-checkers (cargo check, tsc --noEmit) as the sole
# guard — their verbose output can distract the agent from the real
# task. Pair them with a test suite that validates behavior.
# guard cargo test
# ── Session continuity (KEEP) ─────────────────────────────────────────
# The agent's Claude session is resumed across iterations to preserve the
# prompt cache. Between iterations, yoke trims the session JSONL down to
# the files the agent declares on a `KEEP:` line in .loop/notes.md, e.g.:
#
# STATUS: IN_PROGRESS
# KEEP: src/foo.rs tests/bar.rs
#
# Bash output, thinking, and other tool results are dropped. Only kept
# file Reads survive. .loop/notes.md, .loop/plan.md, .loop/protocol.md,
# .loop/guard-results.md are re-read fresh each iteration and don't need
# to be listed. Set YOKE_DISABLE_SESSION_TRIM=1 to skip trimming.
# ── Periodic agents ───────────────────────────────────────────────────
# Supplementary agents invoked at a fixed cadence (every N iterations).
# Useful for cleanup passes, code review, metrics collection, etc.
# Each periodic gets its own fresh agent session.
#
# periodic <protocol-path> <every-N-iterations>
#
# The agent name is derived from the filename stem:
# .loop/cleaner.md → name is "cleaner"
# .loop/reviewer.md → name is "reviewer"
#
# Examples:
# periodic .loop/cleaner.md 10 # cleanup every 10 iterations
# periodic .loop/reviewer.md 5 # review pass every 5 iterations
#
# guard-after: shell commands that run after a specific periodic agent
# completes. Results are written to .loop/periodic-<name>-results.md
# (kept separate from the worker's guard-results.md). Failures produce
# warnings but do not affect the main loop.
#
# guard-after <periodic-name> <command>
#
# Example combo:
# periodic .loop/cleaner.md 10
# guard-after cleaner cargo test
# guard-after cleaner cargo clippy -- -D warnings
# ── Hooks (fire-and-forget post-iteration commands) ───────────────────
# Shell commands that run after each iteration (after guards and
# periodics). Unlike guards, hook failures never block the loop or
# affect its exit code — non-zero exits produce a warning, nothing more.
# Output goes to your terminal only, never to files the agent reads.
#
# The YOKE_ITERATION env var contains the current iteration number.
#
# Examples:
# hook echo "iteration $YOKE_ITERATION done"
# hook git add -A && git commit -m "auto: iteration $YOKE_ITERATION" || true
# hook ./scripts/notify.sh
# hook curl -s -X POST "$WEBHOOK_URL" -d "{\"iteration\": $YOKE_ITERATION}"

View file

@ -0,0 +1,31 @@
# Judge
You are the last line of defense before a human sees this work. You serve two roles: adversary and advocate. You are rough on the implementation so the human who receives it gets something solid and pleasant. A PASS from you means you would stake your reputation on this code.
Read `.loop/sub-plan.md`. For each stage defined in the plan:
## 1. Break it
Try to make the code fail. Do not trust that anything works just because it looks correct. Build it, run it, and feed it inputs designed to expose problems.
- **Boundary inputs** — zeroes, empty strings, max values, negative numbers, Unicode, special characters.
- **Error paths** — missing files, invalid config, network down, permission denied. Does it fail gracefully or crash?
- **Malformed input** — truncated data, wrong types, extra fields, duplicate keys.
- **Concurrency and timing** — if applicable, can you trigger race conditions or ordering bugs?
- **State edges** — what happens on first run vs. repeated runs? Empty state vs. populated state?
You have full shell access. Use it. Build the project, run its tests, then write your own commands to probe beyond what the test suite covers. If you cannot build or run it, that is a FAIL.
## 2. Judge it for the human
Now put on the hat of a senior developer receiving this in a pull request. Would you be pleased or annoyed?
- **Naming** — are functions, variables, and files named so a stranger can read them without a glossary?
- **Error messages** — when something goes wrong, does the user get a message that helps them fix it, or a stack trace and a shrug?
- **API ergonomics** — is the interface (CLI flags, function signatures, config format) intuitive or surprising?
- **Readability** — can you follow the logic without running a debugger in your head?
- **No dead weight** — no leftover TODOs, commented-out code, placeholder text, or debug prints that shipped.
## Verdict
PASS only if both halves hold: nothing you threw at it broke it in a way that matters, AND you would be genuinely happy to receive this code. FAIL with specifics — what broke, what command you ran, what you expected vs. what happened, or what about the code quality fell short.

View file

@ -0,0 +1,62 @@
# Protocol: Brute + Plan Runner (Triple Loop)
You are operating inside an automated triple loop — not a conversation.
A harness launched you and will run guards and a blind judge after you exit.
The outer brute loop retries until a judge says PASS.
Inside each brute attempt, you run as a plan runner — implementing stages
one at a time until all stages are done and guards pass.
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/protocol.md` | read | These instructions. |
| `.loop/sub-plan.md` | read | The sub-plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. |
All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/sub-plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
6. **Implement**. Make the code changes for exactly one stage.
7. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
The first line of `.loop/notes.md` must be one of:
- `STATUS: IN_PROGRESS` — You have more work to do (stages remain, or you expect guard failures).
- `STATUS: DONE` — All stages are implemented and you believe guards will pass.
## What Happens After You Exit
1. Guards run (diff boundary check + configured guard commands).
2. If guards pass and STATUS is DONE, the plan loop ends.
3. Then the judge (a fresh Claude with zero implementation context) verifies the feature.
4. If the judge says FAIL, you get another brute attempt — your notes are preserved but STATUS is reset to IN_PROGRESS so you re-enter the plan loop with the judge's feedback.
## Rules
- **No git operations.** Do not commit, push, branch, or modify git config.
- **Do not modify `protocol.md`, `sub-plan.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One stage per iteration.** Implement a single stage, update notes, and exit.
- **Study judge.md.** Knowing the test helps you pass it.
- **The judge's feedback is ground truth.** Fix what they say is broken.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,73 @@
# Protocol: Saga Scoper (Agent 1)
You are the planning agent in a saga loop — not a conversation.
A harness launched you. Your job is to decompose a specification into scoped
sub-plans and feed them one at a time to an inner brute loop (Agent 2 + Agent 3).
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/saga-protocol.md` | read | These instructions. |
| `.loop/specification.md` | read | The full feature specification. User-authored, read-only. |
| `.loop/saga-notes.md` | read+write | Your memory across saga cycles. |
| `.loop/decisions.md` | read+write | Implementation decisions not covered by the spec. |
| `.loop/sub-plan.md` | write | The sub-plan for the next brute cycle. Overwritten each cycle. |
| `.loop/notes.md` | read | The implementer's notes from the last brute cycle. |
| `.loop/verdict.md` | read | The judge's last verdict (from the last brute cycle). |
All paths are relative to the repository root.
## Per-Cycle Steps
1. **Read the specification** (`.loop/specification.md`). Understand the full feature.
2. **Read your notes** (`.loop/saga-notes.md`). Check what you have already scoped, what was completed, and what remains.
3. **Read the implementer's notes** (`.loop/notes.md`). Understand what the last brute cycle accomplished or struggled with.
4. **Read the verdict** (`.loop/verdict.md`). If the last sub-plan was judged, check whether it passed or failed. If the brute loop bailed out (3 consecutive judge failures), understand what went wrong.
5. **Determine the next chunk**. Based on the spec, your notes, and the last cycle's outcome:
- If the previous sub-plan passed, scope the next logical chunk.
- If the previous sub-plan bailed out, re-scope — break the work into smaller pieces, try a different approach, or address the root cause of failure.
- If the full spec is covered, signal DONE.
6. **Write `sub-plan.md`**. Use the same `## Stage` format the plan runner expects. Each stage should be a concrete, implementable unit. The sub-plan overwrites the previous one — no archiving.
7. **Update `saga-notes.md`**. Record:
- What you scoped and why
- What has been completed so far
- What remains
- A `STATUS` line at the **top** of the file (see below)
8. **Update `decisions.md`**. If you made implementation decisions not explicitly covered by the specification, record them here. Append — do not overwrite previous decisions.
9. **Exit**. Stop. The harness handles the next step.
## STATUS Signaling
The first line of `.loop/saga-notes.md` must be one of:
- `STATUS: IN_PROGRESS` — More sub-plans remain to cover the full specification.
- `STATUS: DONE` — The full specification has been realized. All sub-plans have passed.
## Sub-Plan Format
Write `.loop/sub-plan.md` using the same format the plan runner expects:
```markdown
# Plan: <short title>
<brief context>
## Stage 1 — <title>
<what to implement>
## Stage 2 — <title>
<what to implement>
```
Keep sub-plans focused. 2–5 stages per sub-plan is ideal. Smaller chunks are easier for the implementer to get right and for the judge to verify.
## Rules
- **No git operations.** Do not commit, push, branch, or modify git config.
- **Do not modify `specification.md`, `saga-protocol.md`, `protocol.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One sub-plan per cycle.** Write a single sub-plan, update your notes, and exit.
- **Re-scope on bailout.** If the brute loop bailed out, do not re-issue the same sub-plan. Break it down further or try a different approach.
- **Be concise in notes.** Future-you needs signal, not noise.

View file

@ -0,0 +1,202 @@
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ Yoke configuration — saga mode ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Saga mode orchestrates large tasks by decomposing them into chunks.
# A scoper agent reads specification.md, writes a sub-plan, and a brute
# loop implements + verifies each chunk. On chunk failure the scoper
# re-scopes rather than aborting.
#
# Saga cycle:
# 1. Invoke scoper — reads spec, writes sub-plan.md, updates saga-notes.md
# 2. If saga-notes.md says STATUS: DONE → exit 0 (all chunks complete)
# 3. Run brute loop on sub-plan.md:
# a. Worker iterates until DONE + guards pass
# b. Judge verifies → PASS: next chunk / FAIL: retry
# c. After max-judge-failures consecutive FAILs → bailout
# 4. On brute PASS → loop back to scoper for next chunk
# 5. On brute bailout → loop back to scoper to re-scope the chunk
#
# Inner plan loop (per worker iteration):
# 1. Restore protected files (protocol.md, plan.md, yoke.conf)
# 2. Invoke the agent
# 3. Diff boundary check
# 4. Run guards → results to guard-results.md
# 5. Fire periodic agents (if cadence matches)
# 6. Run hooks
# 7. Check: STATUS: DONE + all guards pass → exit inner loop
#
# Worker notes are appended to saga-log.md between chunks so the scoper
# has full context of what has been accomplished so far.
# ── Backend ────────────────────────────────────────────────────────────
# Which LLM backend to use. Leave commented for Claude CLI (default).
# Setting `model` switches to the OpenCode backend, which supports
# OpenRouter, OpenAI, Anthropic API, and other providers.
#
# model openrouter/anthropic/claude-sonnet-4
# model openai/gpt-4o
# model anthropic/claude-sonnet-4
# ── Claude model (optional) ────────────────────────────────────────────
# Override the model the Claude CLI uses for each iteration. Leave
# commented to use Claude Code's default (Opus). Useful for trading some
# reasoning depth for faster, cheaper iterations.
#
# claude-model claude-sonnet-4-6
# claude-model claude-haiku-4-5
# ── Thinking budget (optional) ─────────────────────────────────────────
# Cap extended-thinking tokens per turn for the Claude CLI backend.
# Useful when running smaller/faster models (sonnet, haiku) and you'd
# rather they spend the iteration acting than reasoning. Sets the
# MAX_THINKING_TOKENS env var on the agent invocation.
#
# thinking off # disable extended thinking entirely (0 tokens)
# thinking low # 2k tokens
# thinking medium # 10k tokens
# thinking high # 32k tokens
#
# Ignored by the OpenCode backend (warns at config load).
#
# thinking low
# ── Sandbox ────────────────────────────────────────────────────────────
# Docker image to run the agent inside. Your working directory is
# bind-mounted into the container at /workspace. Required unless you
# pass --no-sandbox on the command line.
#
# Note: sandbox is not currently supported with the `model` directive.
image claude-code-sandbox:latest
# ── Output ─────────────────────────────────────────────────────────────
# max-tail: max lines of output kept *per guard* in guard-results.md.
# Only affects what the agent reads back — full output still streams to
# your terminal. Default 200 is enough for most test suites; raise it
# if your guards produce essential output beyond 200 lines.
max-tail 200
# log-dir: save raw stream-json output for each iteration. Useful for
# debugging agent behavior or auditing token usage. Files are named
# <log-dir>/iteration-<N>.jsonl.
#
# log-dir .loop/logs
# ── Metrics ────────────────────────────────────────────────────────────
# Per-iteration timing + cost records are written as NDJSON under
# ~/.yoke/metrics/<project-slug>/ by default. Survives `yoke clean`.
#
# metrics-dir ~/.yoke/metrics # default
# metrics off # opt out of disk writes
# ── Scope rules (diff boundary enforcement) ──────────────────────────
# After each iteration yoke diffs the working tree and checks every
# changed file against these rules. If any file is out of scope, ALL
# guards are skipped and the agent gets only boundary feedback.
# Files under .loop/ are always exempt (yoke's own infrastructure).
#
# Three directives, most-specific (longest prefix) match wins:
#
# allow <prefix> — any change permitted (add, modify, delete)
# add-only <prefix> — new files OK; edits to existing files rejected
# no-modify <prefix> — no changes at all (adds or edits rejected)
#
# The special prefix "." matches every path (root catch-all).
#
# Examples:
# allow src/ # full access to source
# allow tests/ # full access to tests
# add-only docs/ # can add new docs, not edit existing
# no-modify .github/ # CI config is off-limits
# no-modify package-lock.json # protect a specific file
# allow . # fallback: everything else allowed
allow .
# ── Guards (post-iteration validation) ────────────────────────────────
# Shell commands that validate the agent's work. All guards run in
# parallel; results are collected in declared order and written to
# .loop/guard-results.md. The agent reads this file on its next turn,
# so failed guards become automatic feedback.
#
# If the boundary check fails, guards are skipped entirely — the agent
# must fix scope violations before guards will run again.
#
# The inner loop only exits when STATUS: DONE *and* all guards pass.
#
# Examples:
# guard cargo test
# guard npm test
# guard python -m pytest tests/ -x
# guard go test ./...
# guard make check
# guard ./scripts/validate.sh
#
# TIP: avoid type-checkers (cargo check, tsc --noEmit) as the sole
# guard — their verbose output can distract the agent from the real
# task. Pair them with a test suite that validates behavior.
# guard cargo test
# ── Judge cadence ─────────────────────────────────────────────────────
# By default the judge runs only after the worker signals DONE + guards
# pass. These settings give you finer control over judge timing.
#
# judge-every <N>: also fire the judge as a mid-loop quality checkpoint
# every N worker iterations (the judge still always fires on DONE too,
# regardless of cadence). Mid-loop verdicts provide early feedback
# without stopping the worker.
#
# judge-every 5
# max-judge-failures <N>: max consecutive FAILs before bailing out.
# Default: 3. The counter is exact — bailout happens on the Nth FAIL,
# and resets to 0 after any PASS. On retry, verdict.md and
# guard-results.md are preserved so the worker sees judge feedback.
# In saga mode, bailout returns control to the scoper for re-scoping
# rather than aborting the entire saga.
#
# max-judge-failures 3
# ── Periodic agents ───────────────────────────────────────────────────
# Supplementary agents invoked at a fixed cadence (every N iterations).
# Useful for cleanup passes, code review, metrics collection, etc.
# Each periodic gets its own fresh agent session.
#
# periodic <protocol-path> <every-N-iterations>
#
# The agent name is derived from the filename stem:
# .loop/cleaner.md → name is "cleaner"
# .loop/reviewer.md → name is "reviewer"
#
# Examples:
# periodic .loop/cleaner.md 10 # cleanup every 10 iterations
# periodic .loop/reviewer.md 5 # review pass every 5 iterations
#
# guard-after: shell commands that run after a specific periodic agent
# completes. Results are written to .loop/periodic-<name>-results.md
# (kept separate from the worker's guard-results.md). Failures produce
# warnings but do not affect the main loop.
#
# guard-after <periodic-name> <command>
#
# Example combo:
# periodic .loop/cleaner.md 10
# guard-after cleaner cargo test
# guard-after cleaner cargo clippy -- -D warnings
# ── Hooks (fire-and-forget post-iteration commands) ───────────────────
# Shell commands that run after each iteration (after guards and
# periodics). Unlike guards, hook failures never block the loop or
# affect its exit code — non-zero exits produce a warning, nothing more.
# Output goes to your terminal only, never to files the agent reads.
#
# The YOKE_ITERATION env var contains the current iteration number.
#
# Examples:
# hook echo "iteration $YOKE_ITERATION done"
# hook git add -A && git commit -m "auto: iteration $YOKE_ITERATION" || true
# hook ./scripts/notify.sh
# hook curl -s -X POST "$WEBHOOK_URL" -d "{\"iteration\": $YOKE_ITERATION}"

250
tests/brute_verdict.rs Normal file
View file

@ -0,0 +1,250 @@
//! Integration test: brute-mode verdict preservation.
//!
//! Verifies that after a judge writes VERDICT: FAIL, the verdict.md content
//! survives into the next brute iteration so the agent can read the feedback.
//!
//! Uses a mock `claude` bash script to simulate both agent and judge,
//! recording what the agent sees in verdict.md at each invocation.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
/// Build the yoke binary path (relies on `cargo test` putting it in target/).
fn yoke_bin() -> std::path::PathBuf {
let mut path = std::env::current_exe()
.expect("current_exe")
.parent()
.expect("parent of test binary")
.parent()
.expect("parent of deps dir")
.to_path_buf();
path.push("yoke");
path
}
/// Minimal brute protocol — just enough for the plan runner to work.
const PROTOCOL: &str = "\
# Protocol
You are inside an automated loop.
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/protocol.md` | read | These instructions. |
| `.loop/plan.md` | read | The feature plan. |
| `.loop/judge.md` | read | What the judge tests. |
| `.loop/notes.md` | read+write | Your scratchpad. |
| `.loop/verdict.md` | read | Previous judge verdict. |
| `.loop/guard-results.md` | read | Guard results. |
| `.loop/yoke.conf` | read | Configuration. |
## Per-Iteration Steps
1. Read plan.
2. Read notes.
3. Read verdict.
4. Implement one stage.
5. Update notes with STATUS line.
6. Exit.
## STATUS Signaling
First line of notes.md: `STATUS: IN_PROGRESS` or `STATUS: DONE`.
";
const PLAN: &str = "\
## Stage 1 — Minimal
Implement the feature.
";
const JUDGE: &str = "\
# Judge
Verify the feature works.
## Verdict
Write VERDICT: PASS or VERDICT: FAIL to .loop/verdict.md.
";
const CONF: &str = "\
allow .
";
/// Mock claude script that distinguishes agent vs judge by the -p prompt.
///
/// Agent mode (prompt contains "protocol.md"):
/// - Increments .loop/.agent-calls counter
/// - Copies verdict.md to .loop/.witness-N
/// - Writes STATUS: DONE to notes.md
///
/// Judge mode (prompt contains "judge.md"):
/// - Increments .loop/.judge-calls counter
/// - Call 1: writes VERDICT: FAIL + feedback to verdict.md
/// - Call 2+: writes VERDICT: PASS to verdict.md
const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash
set -euo pipefail
# Extract the prompt from -p argument
PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p) PROMPT="$2"; shift 2 ;;
*) shift ;;
esac
done
if echo "$PROMPT" | grep -q "protocol.md"; then
# ── Agent mode ──
COUNTER_FILE=".loop/.agent-calls"
N=0
if [[ -f "$COUNTER_FILE" ]]; then
N=$(cat "$COUNTER_FILE")
fi
N=$((N + 1))
echo "$N" > "$COUNTER_FILE"
# Witness: snapshot of verdict.md at the moment the agent runs
cp .loop/verdict.md ".loop/.witness-${N}"
# Write STATUS: DONE so plan loop exits
printf 'STATUS: DONE\n\n## Stage 1 — Minimal\nDone.\n' > .loop/notes.md
elif echo "$PROMPT" | grep -q "judge.md"; then
# ── Judge mode ──
COUNTER_FILE=".loop/.judge-calls"
N=0
if [[ -f "$COUNTER_FILE" ]]; then
N=$(cat "$COUNTER_FILE")
fi
N=$((N + 1))
echo "$N" > "$COUNTER_FILE"
if [[ "$N" -eq 1 ]]; then
printf 'VERDICT: FAIL\n\nFeature is broken — step counter never increments.' > .loop/verdict.md
else
printf 'VERDICT: PASS\n\nAll checks passed.' > .loop/verdict.md
fi
fi
exit 0
"#;
#[test]
fn brute_verdict_preserved_across_iterations() {
// Build yoke first
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
assert!(yoke.exists(), "yoke binary not found at {:?}", yoke);
// Create a temp directory for the project
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
// Set up .loop/ directory with required files
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).expect("create .loop");
fs::write(loop_dir.join("protocol.md"), PROTOCOL).unwrap();
fs::write(loop_dir.join("plan.md"), PLAN).unwrap();
fs::write(loop_dir.join("judge.md"), JUDGE).unwrap();
fs::write(loop_dir.join("yoke.conf"), CONF).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("verdict.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
// Set up mock claude script on PATH
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).expect("create mock-bin");
let mock_claude_path = mock_bin_dir.join("claude");
fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap();
fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap();
// Set up git repo (boundary checker needs `git diff HEAD` to work)
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(project)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.output()
.unwrap_or_else(|e| panic!("git {:?} failed to spawn: {}", args, e));
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init"]);
fs::write(project.join("dummy.txt"), "seed\n").unwrap();
git(&["add", "dummy.txt"]);
git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]);
// Build PATH: mock-bin first so our mock claude shadows the real one
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
// Run yoke
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(project)
.env("PATH", &test_path)
.output()
.expect("failed to run yoke");
let stderr = String::from_utf8_lossy(&output.stderr);
// ── Assertions ──
// 1. witness-1 should be empty: no verdict exists before first agent run
let witness_1 = fs::read_to_string(loop_dir.join(".witness-1"))
.expect(".witness-1 must exist (agent was called at least once)");
assert!(
witness_1.is_empty(),
"witness-1 should be empty (no prior verdict), got: {:?}",
witness_1
);
// 2. witness-2 must contain VERDICT: FAIL — agent saw judge's feedback
let witness_2_path = loop_dir.join(".witness-2");
assert!(
witness_2_path.exists(),
"witness-2 must exist (agent should have been called a second time).\n\
Agent calls: {:?}\nJudge calls: {:?}\nStderr:\n{}",
fs::read_to_string(loop_dir.join(".agent-calls")).ok(),
fs::read_to_string(loop_dir.join(".judge-calls")).ok(),
stderr,
);
let witness_2 = fs::read_to_string(&witness_2_path).unwrap();
assert!(
witness_2.contains("VERDICT: FAIL"),
"witness-2 must contain 'VERDICT: FAIL' (agent should see judge feedback on retry).\n\
Got: {:?}\nStderr:\n{}",
witness_2,
stderr,
);
// 3. yoke exits 0 — judge eventually said PASS
assert!(
output.status.success(),
"yoke should exit 0 (judge said PASS on second attempt).\n\
Exit code: {:?}\nStderr:\n{}",
output.status.code(),
stderr,
);
}

259
tests/brute_verdict_sandbox.sh Executable file
View file

@ -0,0 +1,259 @@
#!/usr/bin/env bash
# Integration test: brute-mode verdict preservation through Docker sandbox.
#
# Validates that yoke correctly constructs the docker command, volume mounts
# work (container writes to .loop/ are visible on host), and verdict.md
# survives brute retries through the containerized path.
#
# Prerequisites:
# - docker daemon running
# - claude-code-sandbox:latest image available
# - cargo (to build yoke)
#
# Usage:
# bash tests/brute_verdict_sandbox.sh
set -euo pipefail
TEST_IMAGE="yoke-test-verdict:latest"
TMPDIR_ROOT=""
cleanup() {
if [[ -n "$TMPDIR_ROOT" && -d "$TMPDIR_ROOT" ]]; then
rm -rf "$TMPDIR_ROOT"
fi
docker rmi "$TEST_IMAGE" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# ── Prerequisites ──
if ! command -v docker &>/dev/null; then
echo "SKIP: docker not found on PATH"
exit 0
fi
if ! docker image inspect claude-code-sandbox:latest &>/dev/null; then
echo "SKIP: claude-code-sandbox:latest image not found"
exit 0
fi
if ! command -v cargo &>/dev/null; then
echo "SKIP: cargo not found on PATH"
exit 0
fi
# ── Build yoke ──
echo "Building yoke..."
cargo build --quiet
YOKE_BIN="$(pwd)/target/debug/yoke"
if [[ ! -x "$YOKE_BIN" ]]; then
echo "FAIL: yoke binary not found at $YOKE_BIN"
exit 1
fi
# ── Create temp workspace ──
TMPDIR_ROOT="$(mktemp -d)"
PROJECT="$TMPDIR_ROOT/project"
mkdir -p "$PROJECT"
# ── Write mock-claude script ──
cat > "$TMPDIR_ROOT/mock-claude" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
# Extract the prompt from -p argument
PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p) PROMPT="$2"; shift 2 ;;
*) shift ;;
esac
done
if echo "$PROMPT" | grep -q "protocol.md"; then
# ── Agent mode ──
COUNTER_FILE=".loop/.agent-calls"
N=0
if [[ -f "$COUNTER_FILE" ]]; then
N=$(cat "$COUNTER_FILE")
fi
N=$((N + 1))
echo "$N" > "$COUNTER_FILE"
# Witness: snapshot of verdict.md at the moment the agent runs
cp .loop/verdict.md ".loop/.witness-${N}"
# Write STATUS: DONE so plan loop exits
printf 'STATUS: DONE\n\n## Stage 1 — Minimal\nDone.\n' > .loop/notes.md
elif echo "$PROMPT" | grep -q "judge.md"; then
# ── Judge mode ──
COUNTER_FILE=".loop/.judge-calls"
N=0
if [[ -f "$COUNTER_FILE" ]]; then
N=$(cat "$COUNTER_FILE")
fi
N=$((N + 1))
echo "$N" > "$COUNTER_FILE"
if [[ "$N" -eq 1 ]]; then
printf 'VERDICT: FAIL\n\nFeature is broken — step counter never increments.' > .loop/verdict.md
else
printf 'VERDICT: PASS\n\nAll checks passed.' > .loop/verdict.md
fi
fi
exit 0
MOCK
chmod +x "$TMPDIR_ROOT/mock-claude"
# ── Build test Docker image ──
echo "Building test image $TEST_IMAGE..."
docker build -t "$TEST_IMAGE" -f- "$TMPDIR_ROOT" <<'DOCKERFILE'
FROM claude-code-sandbox:latest
COPY --chmod=755 mock-claude /usr/local/bin/claude
DOCKERFILE
# ── Set up project directory ──
LOOP_DIR="$PROJECT/.loop"
mkdir -p "$LOOP_DIR"
cat > "$LOOP_DIR/protocol.md" <<'EOF'
# Protocol
You are inside an automated loop.
## Files
| File | Access | Purpose |
|---|---|---|
| `.loop/protocol.md` | read | These instructions. |
| `.loop/plan.md` | read | The feature plan. |
| `.loop/judge.md` | read | What the judge tests. |
| `.loop/notes.md` | read+write | Your scratchpad. |
| `.loop/verdict.md` | read | Previous judge verdict. |
| `.loop/guard-results.md` | read | Guard results. |
| `.loop/yoke.conf` | read | Configuration. |
## Per-Iteration Steps
1. Read plan.
2. Read notes.
3. Read verdict.
4. Implement one stage.
5. Update notes with STATUS line.
6. Exit.
## STATUS Signaling
First line of notes.md: `STATUS: IN_PROGRESS` or `STATUS: DONE`.
EOF
cat > "$LOOP_DIR/plan.md" <<'EOF'
## Stage 1 — Minimal
Implement the feature.
EOF
cat > "$LOOP_DIR/judge.md" <<'EOF'
# Judge
Verify the feature works.
## Verdict
Write VERDICT: PASS or VERDICT: FAIL to .loop/verdict.md.
EOF
cat > "$LOOP_DIR/yoke.conf" <<EOF
image $TEST_IMAGE
allow .
EOF
touch "$LOOP_DIR/notes.md"
touch "$LOOP_DIR/verdict.md"
touch "$LOOP_DIR/guard-results.md"
# ── Git init ──
git -C "$PROJECT" init --quiet
echo "seed" > "$PROJECT/dummy.txt"
git -C "$PROJECT" add dummy.txt
GIT_CONFIG_NOSYSTEM=1 \
git -C "$PROJECT" \
-c user.name=test \
-c user.email=test@test \
commit --quiet -m "init"
# ── Run yoke (sandboxed — no --no-sandbox flag) ──
echo "Running yoke in sandbox mode..."
set +e
OUTPUT=$(cd "$PROJECT" && "$YOKE_BIN" run 2>&1)
YOKE_EXIT=$?
set -e
# ── Assertions ──
PASS=true
# 1. witness-1 should be empty: no verdict exists before first agent run
if [[ ! -f "$LOOP_DIR/.witness-1" ]]; then
echo "FAIL: .witness-1 does not exist (agent was never called)"
PASS=false
else
WITNESS_1=$(cat "$LOOP_DIR/.witness-1")
if [[ -n "$WITNESS_1" ]]; then
echo "FAIL: witness-1 should be empty (no prior verdict), got: '$WITNESS_1'"
PASS=false
else
echo "OK: witness-1 is empty (no prior verdict)"
fi
fi
# 2. witness-2 must contain VERDICT: FAIL — agent saw judge's feedback
if [[ ! -f "$LOOP_DIR/.witness-2" ]]; then
echo "FAIL: .witness-2 does not exist (agent was not called a second time)"
echo " Agent calls: $(cat "$LOOP_DIR/.agent-calls" 2>/dev/null || echo 'N/A')"
echo " Judge calls: $(cat "$LOOP_DIR/.judge-calls" 2>/dev/null || echo 'N/A')"
echo " Yoke output:"
echo "$OUTPUT" | sed 's/^/ /'
PASS=false
else
WITNESS_2=$(cat "$LOOP_DIR/.witness-2")
if echo "$WITNESS_2" | grep -q "VERDICT: FAIL"; then
echo "OK: witness-2 contains 'VERDICT: FAIL' (agent saw judge feedback)"
else
echo "FAIL: witness-2 should contain 'VERDICT: FAIL', got: '$WITNESS_2'"
PASS=false
fi
fi
# 3. yoke exits 0 — judge eventually said PASS
if [[ "$YOKE_EXIT" -eq 0 ]]; then
echo "OK: yoke exited 0 (judge said PASS)"
else
echo "FAIL: yoke exited $YOKE_EXIT (expected 0)"
echo " Yoke output:"
echo "$OUTPUT" | sed 's/^/ /'
PASS=false
fi
# ── Summary ──
if [[ "$PASS" == true ]]; then
echo ""
echo "PASS: brute verdict preserved through Docker sandbox"
exit 0
else
echo ""
echo "FAIL: one or more assertions failed"
exit 1
fi

773
tests/judge_adversarial.rs Normal file
View file

@ -0,0 +1,773 @@
//! Integration tests: adversarial scenarios targeting the cleaner's refactoring.
//!
//! These tests focus on:
//! 1. Stash extraction: does `yoke stash` / `yoke clean` still correctly
//! snapshot and restore .loop/ files after stash code moved to stash.rs?
//! 2. is_status_done refactor: does the plan loop correctly detect STATUS: DONE
//! when the generalized function is used instead of the old hardcoded one?
//! 3. Saga mode: does the refactored is_status_done(SAGA_NOTES_PATH) correctly
//! detect scoper completion (vs the old hardcoded is_saga_done)?
//! 4. Guard results file: after removing GuardResult.output field, does the
//! guard results markdown file still get written with pass/fail content?
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
/// Build the yoke binary path (relies on `cargo test` putting it in target/).
fn yoke_bin() -> std::path::PathBuf {
let mut path = std::env::current_exe()
.expect("current_exe")
.parent()
.expect("parent of test binary")
.parent()
.expect("parent of deps dir")
.to_path_buf();
path.push("yoke");
path
}
/// Set up a minimal git repo in the given directory.
fn git_init(project: &std::path::Path) {
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(project)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.output()
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
};
git(&["init"]);
fs::write(project.join("dummy.txt"), "seed\n").unwrap();
git(&["add", "dummy.txt"]);
git(&["-c", "user.name=test", "-c", "user.email=test@test", "commit", "-m", "init"]);
}
// ── Test 1: stash + clean round-trip after extraction ──────────────────
/// After extracting stash code to stash.rs, verify that `yoke clean`
/// still auto-stashes working files and that `yoke stash pop` restores them.
/// A broken extraction could lose file data or corrupt the stash index.
#[test]
fn stash_roundtrip_after_extraction() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
// Initialize brute mode (creates .loop/ with judge.md, etc.)
let out = Command::new(&yoke)
.args(["init", "brute"])
.current_dir(project)
.output()
.expect("yoke init brute");
assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr));
// Write distinctive content into plan.md and notes.md
let loop_dir = project.join(".loop");
fs::write(loop_dir.join("plan.md"), "## Stage 1 — Build the widget\n\nDo the thing.\n").unwrap();
fs::write(loop_dir.join("notes.md"), "STATUS: IN_PROGRESS\n\nSome important notes here.\n").unwrap();
// Stash the current state
let out = Command::new(&yoke)
.args(["stash"])
.current_dir(project)
.output()
.expect("yoke stash");
assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr));
// Verify stash log shows an entry
let out = Command::new(&yoke)
.args(["stash", "log"])
.current_dir(project)
.output()
.expect("yoke stash log");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("mode=brute"), "stash log should show mode=brute, got:\n{}", stderr);
// Verify stash cleared .loop/ working files
assert!(!loop_dir.join("plan.md").exists(), "plan.md should be gone after stash");
assert!(!loop_dir.join("notes.md").exists(), "notes.md should be gone after stash");
assert!(loop_dir.join(".stash").exists(), ".stash/ should survive stash clear");
// Pop — should restore the stashed state with our distinctive content
let out = Command::new(&yoke)
.args(["stash", "pop"])
.current_dir(project)
.output()
.expect("yoke stash pop");
assert!(out.status.success(), "yoke stash pop failed: {}", String::from_utf8_lossy(&out.stderr));
// Verify round-trip: files restored with original content
let plan = fs::read_to_string(loop_dir.join("plan.md")).unwrap();
assert!(plan.contains("Build the widget"), "plan.md should be restored after pop, got: {:?}", plan);
}
// ── Test 2: plan loop exits on STATUS: DONE with generalized is_status_done ──
/// The cleaner changed `is_done()` (hardcoded to NOTES_PATH) into
/// `is_status_done(path)`. If any call site mistakenly passes the wrong path,
/// the loop would spin forever or exit prematurely.
///
/// This test verifies: agent writes STATUS: DONE → yoke exits 0.
#[test]
fn plan_loop_exits_on_status_done() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).unwrap();
// Minimal loop-mode setup (no judge.md → loop mode, not brute)
let protocol = "\
# Protocol
Read plan.md, implement it, then set STATUS: DONE in notes.md.
";
let conf = "allow .\n";
let plan = "## Stage 1 — Do something\nJust touch a file.\n";
fs::write(loop_dir.join("protocol.md"), protocol).unwrap();
fs::write(loop_dir.join("yoke.conf"), conf).unwrap();
fs::write(loop_dir.join("plan.md"), plan).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
// Mock claude: immediately writes STATUS: DONE and exits
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).unwrap();
let mock_claude = r#"#!/usr/bin/env bash
set -euo pipefail
# Always signal done immediately
printf 'STATUS: DONE\n' > .loop/notes.md
exit 0
"#;
let mock_path = mock_bin_dir.join("claude");
fs::write(&mock_path, mock_claude).unwrap();
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(project)
.env("PATH", &test_path)
.output()
.expect("yoke run");
let stderr = String::from_utf8_lossy(&output.stderr);
// yoke should exit 0 — the generalized is_status_done(NOTES_PATH) found DONE
assert!(
output.status.success(),
"yoke should exit 0 when agent signals STATUS: DONE.\n\
Exit code: {:?}\nStderr:\n{}",
output.status.code(),
stderr,
);
// Verify the "loop complete" message appears
assert!(
stderr.contains("loop complete"),
"stderr should contain 'loop complete', got:\n{}",
stderr,
);
}
// ── Test 3: brute mode still invokes judge and handles FAIL→PASS correctly ──
/// After the stash extraction and is_done→is_status_done refactor, verify
/// the brute loop still: runs agent → runs judge → on FAIL retries → on PASS exits.
/// This is the same scenario as brute_verdict but run against the refactored code.
#[test]
fn brute_judge_fail_then_pass() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).unwrap();
let protocol = "\
# Protocol
Read plan, implement, set STATUS: DONE in notes.md.
";
let plan = "## Stage 1 — Implement\nDo the feature.\n";
let judge = "# Judge\nVerify the feature.\n\n## Verdict\nWrite verdict.\n";
let conf = "allow .\n";
fs::write(loop_dir.join("protocol.md"), protocol).unwrap();
fs::write(loop_dir.join("plan.md"), plan).unwrap();
fs::write(loop_dir.join("judge.md"), judge).unwrap();
fs::write(loop_dir.join("yoke.conf"), conf).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("verdict.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).unwrap();
// Mock claude: agent writes STATUS: DONE, judge FAILs once then PASSes
let mock_claude = r#"#!/usr/bin/env bash
set -euo pipefail
PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p) PROMPT="$2"; shift 2 ;;
*) shift ;;
esac
done
if echo "$PROMPT" | grep -q "protocol.md"; then
printf 'STATUS: DONE\n' > .loop/notes.md
elif echo "$PROMPT" | grep -q "judge.md"; then
COUNTER=".loop/.judge-count"
N=0
if [[ -f "$COUNTER" ]]; then N=$(cat "$COUNTER"); fi
N=$((N + 1))
echo "$N" > "$COUNTER"
if [[ "$N" -eq 1 ]]; then
printf 'VERDICT: FAIL\n\nNot good enough.' > .loop/verdict.md
else
printf 'VERDICT: PASS\n\nLooks great.' > .loop/verdict.md
fi
fi
exit 0
"#;
let mock_path = mock_bin_dir.join("claude");
fs::write(&mock_path, mock_claude).unwrap();
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(project)
.env("PATH", &test_path)
.output()
.expect("yoke run");
let stderr = String::from_utf8_lossy(&output.stderr);
// Should exit 0 — judge eventually said PASS
assert!(
output.status.success(),
"yoke should exit 0 after judge PASS.\nExit: {:?}\nStderr:\n{}",
output.status.code(),
stderr,
);
// Judge should have been called exactly 2 times
let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap();
assert_eq!(
judge_count.trim(), "2",
"judge should be called exactly twice (FAIL then PASS), got: {:?}",
judge_count.trim(),
);
}
// ── Test 4: saga exits on STATUS: DONE in saga-notes.md (not notes.md) ──
/// The cleaner replaced the hardcoded `is_saga_done()` (which read SAGA_NOTES_PATH)
/// with the generic `is_status_done(SAGA_NOTES_PATH)`. If a refactoring mistake
/// accidentally passes NOTES_PATH instead, the saga loop would either spin forever
/// (scoper keeps writing DONE to saga-notes.md but yoke checks notes.md) or
/// would exit prematurely based on the inner brute worker's notes.md.
///
/// This test sets up saga mode, mocks the scoper to write STATUS: DONE to
/// saga-notes.md on the first cycle, and verifies yoke exits 0 with the
/// "saga complete" message.
#[test]
fn saga_exits_on_saga_notes_done() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).unwrap();
// Saga mode files
let saga_protocol = "# Saga Protocol\nRead specification.md, decompose into sub-plans.\n";
let protocol = "# Worker Protocol\nRead plan, implement, set STATUS: DONE.\n";
let judge = "# Judge\nVerify the sub-plan.\n\n## Verdict\nWrite verdict.\n";
let conf = "allow .\n";
let specification = "# Spec\nBuild a widget that does X.\n";
fs::write(loop_dir.join("saga-protocol.md"), saga_protocol).unwrap();
fs::write(loop_dir.join("protocol.md"), protocol).unwrap();
fs::write(loop_dir.join("judge.md"), judge).unwrap();
fs::write(loop_dir.join("yoke.conf"), conf).unwrap();
fs::write(loop_dir.join("specification.md"), specification).unwrap();
fs::write(loop_dir.join("saga-notes.md"), "").unwrap();
fs::write(loop_dir.join("decisions.md"), "").unwrap();
fs::write(loop_dir.join("sub-plan.md"), "").unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("verdict.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
// Mock claude: scoper writes STATUS: DONE to saga-notes.md immediately.
// Critically: notes.md is left empty — if yoke checks notes.md instead of
// saga-notes.md, it would NOT see DONE and would spin forever.
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).unwrap();
let mock_claude = r#"#!/usr/bin/env bash
set -euo pipefail
PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p) PROMPT="$2"; shift 2 ;;
*) shift ;;
esac
done
if echo "$PROMPT" | grep -q "saga-protocol.md"; then
# Scoper: signal DONE via saga-notes.md
printf 'STATUS: DONE\n\nAll chunks complete.\n' > .loop/saga-notes.md
fi
exit 0
"#;
let mock_path = mock_bin_dir.join("claude");
fs::write(&mock_path, mock_claude).unwrap();
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(project)
.env("PATH", &test_path)
.output()
.expect("yoke run");
let stderr = String::from_utf8_lossy(&output.stderr);
// yoke should exit 0 — is_status_done(SAGA_NOTES_PATH) found DONE
assert!(
output.status.success(),
"yoke should exit 0 when scoper signals DONE in saga-notes.md.\n\
Exit code: {:?}\nStderr:\n{}",
output.status.code(),
stderr,
);
// Verify the "saga complete" message appears
assert!(
stderr.contains("saga complete"),
"stderr should contain 'saga complete', got:\n{}",
stderr,
);
// notes.md should still be empty — confirms yoke checked saga-notes.md, not notes.md
let notes = fs::read_to_string(loop_dir.join("notes.md")).unwrap();
assert!(
notes.is_empty(),
"notes.md should be empty (saga checks saga-notes.md), got: {:?}",
notes,
);
}
// ── Test 5: guard results file written correctly after output field removal ──
/// The cleaner removed the `output` field from `GuardResult`. If the guard
/// results markdown file writing was accidentally broken by this change,
/// the agent would lose visibility into guard pass/fail details on the next
/// iteration — a silent data loss that could cause infinite loops.
///
/// This test uses dry-run mode with a guard that fails, and verifies the
/// guard-results.md file contains the expected FAIL section with output.
#[test]
fn guard_results_written_after_output_field_removal() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).unwrap();
// Loop mode with a guard that deliberately fails with distinctive output
let protocol = "# Protocol\nDo the thing.\n";
let conf = "\
allow .
guard echo SENTINEL_GUARD_OUTPUT && exit 1
";
let plan = "## Stage 1\nDo it.\n";
fs::write(loop_dir.join("protocol.md"), protocol).unwrap();
fs::write(loop_dir.join("yoke.conf"), conf).unwrap();
fs::write(loop_dir.join("plan.md"), plan).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
// Dry-run: no Claude invocation, but guards still execute
let output = Command::new(&yoke)
.args(["run", "--no-sandbox", "--dry-run"])
.current_dir(project)
.output()
.expect("yoke run --dry-run");
// Guard failed so yoke exits non-zero in dry-run
assert!(
!output.status.success(),
"yoke should exit non-zero when guard fails in dry-run"
);
// Verify guard-results.md was written with the guard output
let results = fs::read_to_string(loop_dir.join("guard-results.md")).unwrap();
assert!(
results.contains("FAIL"),
"guard-results.md should contain 'FAIL' for the failing guard.\nGot:\n{}",
results,
);
assert!(
results.contains("SENTINEL_GUARD_OUTPUT"),
"guard-results.md should contain the guard's stdout ('SENTINEL_GUARD_OUTPUT').\n\
If this is missing, the output field removal broke results file writing.\nGot:\n{}",
results,
);
}
// ── Test 6: brute bailout fires at exactly max-judge-failures ───────────
/// The cleaner extracted the bailout threshold check into `is_judge_bailout()`.
/// If the comparison operator was changed (e.g. `>` instead of `>=`), the brute
/// loop would either bail one iteration too late (wasting an API call) or too
/// early (never giving the worker a fair chance).
///
/// This test configures `max-judge-failures 2` and has the judge always FAIL.
/// Expects: yoke exits non-zero after exactly 2 judge failures (2 brute iterations).
#[test]
fn brute_bailout_at_max_judge_failures() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).unwrap();
let protocol = "\
# Protocol
Read plan, implement, set STATUS: DONE in notes.md.
";
let plan = "## Stage 1 — Implement\nDo the feature.\n";
let judge = "# Judge\nVerify the feature.\n\n## Verdict\nWrite verdict.\n";
// max-judge-failures 2 — should bail after exactly 2 consecutive judge FAILs
let conf = "\
allow .
max-judge-failures 2
";
fs::write(loop_dir.join("protocol.md"), protocol).unwrap();
fs::write(loop_dir.join("plan.md"), plan).unwrap();
fs::write(loop_dir.join("judge.md"), judge).unwrap();
fs::write(loop_dir.join("yoke.conf"), conf).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("verdict.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).unwrap();
// Mock claude: agent always writes STATUS: DONE, judge always FAILs.
// Tracks call counts so we can assert the exact number of iterations.
let mock_claude = r#"#!/usr/bin/env bash
set -euo pipefail
PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p) PROMPT="$2"; shift 2 ;;
*) shift ;;
esac
done
if echo "$PROMPT" | grep -q "protocol.md"; then
COUNTER=".loop/.agent-count"
N=0
if [[ -f "$COUNTER" ]]; then N=$(cat "$COUNTER"); fi
N=$((N + 1))
echo "$N" > "$COUNTER"
printf 'STATUS: DONE\n' > .loop/notes.md
elif echo "$PROMPT" | grep -q "judge.md"; then
COUNTER=".loop/.judge-count"
N=0
if [[ -f "$COUNTER" ]]; then N=$(cat "$COUNTER"); fi
N=$((N + 1))
echo "$N" > "$COUNTER"
# Always FAIL
printf 'VERDICT: FAIL\n\nStill broken.\n' > .loop/verdict.md
fi
exit 0
"#;
let mock_path = mock_bin_dir.join("claude");
fs::write(&mock_path, mock_claude).unwrap();
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(project)
.env("PATH", &test_path)
.output()
.expect("yoke run");
let stderr = String::from_utf8_lossy(&output.stderr);
// yoke should exit non-zero (bailout)
assert!(
!output.status.success(),
"yoke should exit non-zero after max judge failures.\nExit: {:?}\nStderr:\n{}",
output.status.code(),
stderr,
);
// Verify stderr mentions bailing out
assert!(
stderr.contains("bailing out"),
"stderr should mention 'bailing out', got:\n{}",
stderr,
);
// Judge should have been called exactly 2 times (matching max-judge-failures)
let judge_count = fs::read_to_string(loop_dir.join(".judge-count")).unwrap();
assert_eq!(
judge_count.trim(), "2",
"judge should be called exactly 2 times (max-judge-failures=2), got: {:?}\nStderr:\n{}",
judge_count.trim(),
stderr,
);
}
// ── Test 7: judge-every fires judge on DONE and exits on PASS ──────────
/// The cleaner extracted the judge-every logic into `evaluate_judge_every()`.
/// If the extraction broke the DONE→judge→PASS→exit path, the plan loop
/// would either: never invoke the judge (spinning forever), invoke it but
/// ignore the PASS (spinning forever), or skip the judge and exit without
/// verification (silent quality regression).
///
/// This test configures `judge-every 5` in loop mode with a judge.md present.
/// The agent signals DONE on iteration 1 (before cadence 5), so the judge
/// should fire because DONE always triggers the judge regardless of cadence.
/// The judge returns PASS, so yoke should exit 0.
#[test]
fn judge_every_fires_on_done_and_exits() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).unwrap();
let protocol = "\
# Protocol
Read plan, implement, set STATUS: DONE in notes.md.
";
let plan = "## Stage 1 — Implement\nDo the feature.\n";
let judge = "# Judge\nVerify the feature.\n\n## Verdict\nWrite verdict.\n";
// judge-every 5: cadence is 5, but DONE should fire judge immediately
let conf = "\
allow .
judge-every 5
";
fs::write(loop_dir.join("protocol.md"), protocol).unwrap();
fs::write(loop_dir.join("yoke.conf"), conf).unwrap();
fs::write(loop_dir.join("plan.md"), plan).unwrap();
fs::write(loop_dir.join("judge.md"), judge).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("verdict.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).unwrap();
// Mock claude: agent immediately signals DONE, judge immediately returns PASS
let mock_claude = r#"#!/usr/bin/env bash
set -euo pipefail
PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p) PROMPT="$2"; shift 2 ;;
*) shift ;;
esac
done
if echo "$PROMPT" | grep -q "protocol.md"; then
printf 'STATUS: DONE\n' > .loop/notes.md
elif echo "$PROMPT" | grep -q "judge.md"; then
printf 'VERDICT: PASS\n\nAll good.\n' > .loop/verdict.md
fi
exit 0
"#;
let mock_path = mock_bin_dir.join("claude");
fs::write(&mock_path, mock_claude).unwrap();
fs::set_permissions(&mock_path, fs::Permissions::from_mode(0o755)).unwrap();
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(project)
.env("PATH", &test_path)
.output()
.expect("yoke run");
let stderr = String::from_utf8_lossy(&output.stderr);
// yoke should exit 0 — judge-every detected DONE and judge said PASS
assert!(
output.status.success(),
"yoke should exit 0 when judge-every fires on DONE and judge PASSes.\n\
Exit code: {:?}\nStderr:\n{}",
output.status.code(),
stderr,
);
// Verify the judge was actually invoked (not skipped)
assert!(
stderr.contains("Judge (DONE)"),
"stderr should show 'Judge (DONE)' banner (judge fired on worker DONE), got:\n{}",
stderr,
);
// Verify verdict.md has PASS
let verdict = fs::read_to_string(loop_dir.join("verdict.md")).unwrap();
assert!(
verdict.starts_with("VERDICT: PASS"),
"verdict.md should contain PASS, got: {:?}",
verdict,
);
}
// ── Test 8: stash mode tag preserved after module extraction ────────────
/// After stash code was extracted to stash.rs, the `mode` parameter is now
/// passed from main.rs rather than calling `detect_mode()` internally.
/// If the caller passes the wrong mode, stash entries would have incorrect
/// mode tags, making `yoke stash log` misleading and potentially breaking
/// mode-aware restoration logic.
///
/// This test initializes brute mode, creates a stash, and verifies the
/// stash index records "brute" (not "unknown" or empty).
#[test]
fn stash_records_correct_mode_after_extraction() {
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
git_init(project);
// Initialize brute mode
let out = Command::new(&yoke)
.args(["init", "brute"])
.current_dir(project)
.output()
.expect("yoke init brute");
assert!(out.status.success(), "yoke init brute failed: {}", String::from_utf8_lossy(&out.stderr));
// Write some content so stash has something to snapshot
fs::write(project.join(".loop/plan.md"), "## Stage 1\nDo it.\n").unwrap();
// Create a stash
let out = Command::new(&yoke)
.args(["stash"])
.current_dir(project)
.output()
.expect("yoke stash");
assert!(out.status.success(), "yoke stash failed: {}", String::from_utf8_lossy(&out.stderr));
// Read the stash index directly and verify mode=brute
let index_path = project.join(".loop/.stash/index");
assert!(index_path.exists(), "stash index should exist after stashing");
let index = fs::read_to_string(&index_path).unwrap();
// Index format: hash|timestamp|mode|file1,file2,...
let first_line = index.lines().next().expect("index should have at least one line");
let parts: Vec<&str> = first_line.splitn(4, '|').collect();
assert!(
parts.len() >= 3,
"index line should have at least 3 pipe-separated fields, got: {:?}",
first_line,
);
assert_eq!(
parts[2], "brute",
"stash mode should be 'brute' (not 'unknown' or empty). \
If this fails, the mode parameter is not being passed correctly \
from main.rs to stash.rs after extraction.\nIndex line: {:?}",
first_line,
);
}

View file

@ -0,0 +1,256 @@
//! Behavioral test: a completed yoke run persists per-iteration and per-run
//! metrics rows under the user's home directory (`~/.yoke/metrics/`) and the
//! rows survive `yoke clean`.
//!
//! Uses a mock `claude` script so no real agent invocation happens. The
//! subprocess gets HOME pointed at the test tempdir so writes don't escape
//! the test sandbox.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
fn yoke_bin() -> std::path::PathBuf {
let mut path = std::env::current_exe()
.expect("current_exe")
.parent()
.expect("parent of test binary")
.parent()
.expect("parent of deps dir")
.to_path_buf();
path.push("yoke");
path
}
const PROTOCOL: &str = "\
# Protocol
Single-iteration test. Write STATUS: DONE and exit.
";
const PLAN: &str = "\
## Stage 1
Be done.
";
const CONF: &str = "\
allow .
";
const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash
set -euo pipefail
# Always: write STATUS: DONE so the loop exits after one iteration.
printf 'STATUS: DONE\n\n## Stage 1\nDone.\n' > .loop/notes.md
exit 0
"#;
#[test]
fn metrics_rows_persist_under_home_and_survive_clean() {
// Build yoke
let status = Command::new("cargo")
.args(["build", "--quiet"])
.status()
.expect("cargo build");
assert!(status.success(), "cargo build failed");
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path().join("proj");
fs::create_dir(&project).expect("create proj");
// Isolated HOME so metrics land under tmp/.yoke/metrics/...
let fake_home = tmp.path().join("home");
fs::create_dir(&fake_home).expect("create home");
// Project files
let loop_dir = project.join(".loop");
fs::create_dir(&loop_dir).expect("create .loop");
fs::write(loop_dir.join("protocol.md"), PROTOCOL).unwrap();
fs::write(loop_dir.join("plan.md"), PLAN).unwrap();
fs::write(loop_dir.join("yoke.conf"), CONF).unwrap();
fs::write(loop_dir.join("notes.md"), "").unwrap();
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
// Mock claude on PATH
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).expect("create mock-bin");
let mock_claude_path = mock_bin_dir.join("claude");
fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap();
fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap();
// Boundary check needs a git repo
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&project)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.output()
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init"]);
fs::write(project.join("seed.txt"), "x\n").unwrap();
git(&["add", "seed.txt"]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"]);
let original_path = std::env::var("PATH").unwrap_or_default();
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
let output = Command::new(&yoke)
.args(["run", "--no-sandbox"])
.current_dir(&project)
.env("PATH", &test_path)
.env("HOME", &fake_home)
.output()
.expect("failed to run yoke");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"yoke should exit 0 on STATUS: DONE.\nstderr:\n{}",
stderr
);
// Find the metrics directory: ~/.yoke/metrics/<project-slug>/
let metrics_dir = fake_home.join(".yoke").join("metrics");
assert!(
metrics_dir.exists(),
"metrics dir should be created at {}",
metrics_dir.display()
);
let project_dirs: Vec<_> = fs::read_dir(&metrics_dir)
.expect("read metrics dir")
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
assert_eq!(
project_dirs.len(),
1,
"expected exactly one project slug dir, got {:?}",
project_dirs
);
let slug_dir = &project_dirs[0];
// The slug dir must contain runs.ndjson + at least one <run-id>.ndjson
let mut runs_path = None;
let mut iter_path = None;
for entry in fs::read_dir(slug_dir).expect("read slug dir").flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name == "runs.ndjson" {
runs_path = Some(entry.path());
} else if name.ends_with(".ndjson") {
iter_path = Some(entry.path());
}
}
let runs_path = runs_path.expect("runs.ndjson should exist");
let iter_path = iter_path.expect("iteration ndjson file should exist");
// Run row: contains outcome=done and iterations >= 1
let runs_content = fs::read_to_string(&runs_path).expect("read runs.ndjson");
assert!(
runs_content.contains("\"outcome\":\"done\""),
"runs.ndjson should record outcome=done.\ncontent: {}",
runs_content
);
assert!(
runs_content.contains("\"iterations\":1"),
"runs.ndjson should report 1 iteration.\ncontent: {}",
runs_content
);
// Iteration row: has mode=loop, status_done=true, restore_ms field
let iter_content = fs::read_to_string(&iter_path).expect("read iter ndjson");
assert!(
iter_content.contains("\"mode\":\"loop\""),
"iter row should carry mode=loop.\ncontent: {}",
iter_content
);
assert!(
iter_content.contains("\"status_done\":true"),
"iter row should record status_done=true.\ncontent: {}",
iter_content
);
assert!(
iter_content.contains("\"restore_ms\":"),
"iter row should record restore_ms.\ncontent: {}",
iter_content
);
// Survive `yoke clean`
let clean_output = Command::new(&yoke)
.args(["clean"])
.current_dir(&project)
.env("HOME", &fake_home)
.output()
.expect("yoke clean");
assert!(clean_output.status.success(), "yoke clean failed");
assert!(
runs_path.exists(),
"runs.ndjson must survive yoke clean (lives outside the project)"
);
assert!(
iter_path.exists(),
"iteration ndjson must survive yoke clean"
);
// `yoke stats` lists the run we just recorded
let stats_output = Command::new(&yoke)
.args(["stats"])
.current_dir(&project)
.env("HOME", &fake_home)
.output()
.expect("yoke stats");
assert!(stats_output.status.success(), "yoke stats failed");
let stats_stdout = String::from_utf8_lossy(&stats_output.stdout);
let stats_stderr = String::from_utf8_lossy(&stats_output.stderr);
let combined = format!("{}{}", stats_stdout, stats_stderr);
// Derive run-id from the file name we found earlier
let run_id = iter_path
.file_stem()
.and_then(|s| s.to_str())
.expect("run id from file stem");
assert!(
combined.contains(run_id),
"yoke stats should list the run-id {} in its output.\ncombined:\n{}",
run_id,
combined
);
assert!(
combined.contains("done"),
"yoke stats should show the run outcome 'done'.\ncombined:\n{}",
combined
);
// `yoke stats --run <id>` shows the iteration table
let run_output = Command::new(&yoke)
.args(["stats", "--run", run_id])
.current_dir(&project)
.env("HOME", &fake_home)
.output()
.expect("yoke stats --run");
assert!(run_output.status.success(), "yoke stats --run failed");
let run_stdout = String::from_utf8_lossy(&run_output.stdout);
let run_stderr = String::from_utf8_lossy(&run_output.stderr);
let run_combined = format!("{}{}", run_stdout, run_stderr);
assert!(
run_combined.contains("ITER"),
"yoke stats --run should print an iteration table header.\noutput:\n{}",
run_combined
);
assert!(
// We produced one iteration; the row should show iteration "1"
// and one of the "pass" / "yes" status badges.
run_combined.contains("pass"),
"yoke stats --run should report guard status.\noutput:\n{}",
run_combined
);
}