stash
This commit is contained in:
parent
ab135d9442
commit
77f5138151
19 changed files with 3004 additions and 259 deletions
64
CONTEXT_TRIM.md
Normal file
64
CONTEXT_TRIM.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Context trim — design notes
|
||||
|
||||
## What's shipped
|
||||
|
||||
Worker sessions are now resumed across iterations with `claude --resume <sid>`,
|
||||
and the session JSONL is surgically trimmed between rounds so Anthropic's
|
||||
prefix cache stays warm without paying for irrelevant history.
|
||||
|
||||
- Agent declares a `KEEP: <paths>` line in `.loop/notes.md`.
|
||||
- Yoke locates `~/.claude/projects/<cwd-slug>/<sid>.jsonl` and rewrites it
|
||||
to retain only: bootstrap records, the initial user prompt, `Read`
|
||||
tool_use/tool_result pairs for kept paths, and attachments. Drops:
|
||||
`thinking`, intermediate `text`, every non-`Read` tool_use, `Read`s of
|
||||
non-kept files, and the matching `tool_result`s. Re-links the
|
||||
parent-uuid chain across the gaps; validates tool_use ↔ tool_result
|
||||
pairing before commit; keeps a `.bak`.
|
||||
- Judge (brute) and scoper (saga) always run fresh — independence per
|
||||
behavioral-specification §2.1.
|
||||
- `YOKE_DISABLE_SESSION_TRIM=1` is the escape hatch.
|
||||
|
||||
## What still needs adding
|
||||
|
||||
1. **Format-drift guard.** The Claude Code session JSONL is undocumented.
|
||||
A future CLI release could rename a field, change content-block shape,
|
||||
or move the file. The validator catches most damage post-trim, but
|
||||
pre-trim we should fingerprint the format (e.g., known top-level keys
|
||||
on bootstrap records) and bail if it drifts. Today we trust + bail on
|
||||
validate-fail; a positive check would be safer.
|
||||
|
||||
2. **Recovery from `--resume` failure.** If Claude rejects the resumed
|
||||
session (deleted, corrupted, version skew), the iteration aborts. We
|
||||
should detect this from the spawn's exit/early stream error and
|
||||
transparently retry once with no `--resume` (treat last_session_id as
|
||||
stale).
|
||||
|
||||
3. **Sandboxed runs.** When `image` is set, the agent runs inside a
|
||||
Docker container — the session JSONL lives in the container's home,
|
||||
not the host's. Today `trim_worker_session` no-ops in that case
|
||||
(silent). Either mount the session dir into the container, or run the
|
||||
trim inside the container, or document the limitation.
|
||||
|
||||
4. **OpenCode backend.** Trim is Claude-specific. OpenCode users get
|
||||
`--resume` benefits skipped (different session model). If OpenCode
|
||||
becomes a first-class target, we need an analogous trim or a
|
||||
reasoned-down equivalent.
|
||||
|
||||
5. **Fork policy.** v1 has no forking: the trim alone bounds growth.
|
||||
But sessions still grow monotonically in the *kept* portion, and
|
||||
long-running tasks will eventually want a hard reset. A
|
||||
`context-fork-every N` directive (or agent-declared `RESET: TRUE`)
|
||||
would let users break the conversation cleanly at stage boundaries.
|
||||
|
||||
6. **Periodic-agent isolation.** Periodics spawn fresh sessions but
|
||||
share the project's session directory. If a periodic ever needed its
|
||||
own resumable continuity (e.g., a reviewer agent that learns over
|
||||
runs), today there's no separate session-id tracking for it.
|
||||
|
||||
7. **Saga handoff.** Across saga cycles the scoper is fresh and reads
|
||||
`saga-log.md` to reconstruct context. A future variant could let the
|
||||
*brute worker inside saga* keep its session across cycles when the
|
||||
scoper produces a closely-related sub-plan — but only if the scoper
|
||||
signals it (otherwise context bleeds between unrelated chunks).
|
||||
|
||||
Next steps involve creating some test vectors to help make behavior standardized
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -230,6 +230,7 @@ version = "1.0.149"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
|
|
@ -432,6 +433,7 @@ dependencies = [
|
|||
name = "yoke"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -8,5 +8,8 @@ description = "LLM automation loop harness"
|
|||
name = "yoke"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1", default-features = false, features = ["std", "preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -55,13 +55,16 @@ exits successfully. On FAIL, the worker retries.
|
|||
It is a fresh agent invocation with no shared context from the worker. Its only
|
||||
input is `judge.md` and the codebase state.
|
||||
|
||||
**2.2 — Verdict survives retries.**
|
||||
On judge FAIL, `verdict.md` is NOT cleared. The worker sees the judge's
|
||||
feedback on its next iteration. This is how the worker knows what went wrong.
|
||||
**2.2 — Verdict survives retries and restarts.**
|
||||
`verdict.md` is never cleared automatically. On judge FAIL, the worker sees
|
||||
the judge's feedback on its next iteration. On bailout, the verdict remains
|
||||
on disk so the user (or agent on restart) can read why the loop failed.
|
||||
Use `yoke clean` or `yoke stash` to reset.
|
||||
|
||||
**2.3 — Guard results survive retries.**
|
||||
Same as verdict — `guard-results.md` persists across brute retries so the
|
||||
worker sees what the guards reported.
|
||||
**2.3 — Guard results survive retries and restarts.**
|
||||
Same as verdict — `guard-results.md` is never cleared automatically. It
|
||||
persists across brute retries and restarts so the worker sees what the
|
||||
guards reported.
|
||||
|
||||
**2.4 — Notes status reset on retry, nothing else.**
|
||||
On judge FAIL, only the first line of `notes.md` is overwritten to
|
||||
|
|
@ -89,9 +92,11 @@ specific snapshot. `yoke clean` auto-stashes before wiping.
|
|||
|
||||
### Invariants
|
||||
|
||||
**3.1 — Stash is a lossless round-trip.**
|
||||
`stash` then `pop` produces identical `.loop/` contents. No file is lost,
|
||||
truncated, or corrupted.
|
||||
**3.1 — Stash clears the working directory.**
|
||||
`stash` snapshots all non-dotfile files in `.loop/`, then removes them.
|
||||
After stash, `.loop/` contains only dotfile entries (like `.stash/`).
|
||||
`stash` then `pop` (or `checkout`) restores the original contents — no
|
||||
file is lost, truncated, or corrupted.
|
||||
|
||||
**3.2 — Auto-stash before destructive operations.**
|
||||
Both `clean` and `checkout` auto-stash current state before modifying it. You
|
||||
|
|
@ -135,10 +140,11 @@ abort on a single chunk failure.
|
|||
If the scoper produces an empty `sub-plan.md`, the saga aborts. This prevents
|
||||
a brute loop from running with no plan.
|
||||
|
||||
**4.4 — Chunk state is isolated but logged.** `notes.md`, `verdict.md`, and
|
||||
`guard-results.md` are cleared between chunks. Each brute run starts fresh.
|
||||
Previous chunk state does not leak into the next chunk. Before clearing,
|
||||
the contents of `notes.md` are appended to `saga-log.md`.
|
||||
**4.4 — Chunk state persists and is logged.** `notes.md`, `verdict.md`, and
|
||||
`guard-results.md` are NOT cleared between chunks. The scoper can read why
|
||||
the previous chunk failed or succeeded. Before each chunk, the contents of
|
||||
`notes.md` are appended to `saga-log.md`. Use `yoke clean` or `yoke stash`
|
||||
for a full reset.
|
||||
|
||||
**4.5 — Saga log is append-only.** `saga-log.md` accumulates the worker's
|
||||
notes from every completed chunk. It is never cleared or truncated during a
|
||||
|
|
@ -169,6 +175,11 @@ is protected and `src/other.rs` is allowed. Longer prefix wins.
|
|||
A `guard-after` referencing a periodic that does not exist is a config error,
|
||||
not a silent no-op.
|
||||
|
||||
**5.5 — Hooks are fire-and-forget.**
|
||||
A `hook` command that exits non-zero or fails to execute produces a warning on
|
||||
stderr but does not affect the loop's exit code, guard evaluation, or iteration
|
||||
flow. Hook output is never written to any file the agent reads.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mode Switching
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Backend {
|
||||
|
|
@ -39,6 +39,11 @@ pub struct Config {
|
|||
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 {
|
||||
|
|
@ -52,6 +57,9 @@ struct ConfigBuilder {
|
|||
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 {
|
||||
|
|
@ -105,6 +113,9 @@ impl ConfigBuilder {
|
|||
max_judge_failures: 3,
|
||||
periodics: Vec::new(),
|
||||
pending_guard_afters: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
metrics_dir: None,
|
||||
metrics_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +137,19 @@ impl ConfigBuilder {
|
|||
"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(())
|
||||
|
|
@ -139,6 +163,10 @@ impl ConfigBuilder {
|
|||
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,
|
||||
|
|
@ -149,6 +177,9 @@ impl ConfigBuilder {
|
|||
judge_every: self.judge_every,
|
||||
max_judge_failures: self.max_judge_failures,
|
||||
periodics,
|
||||
hooks: self.hooks,
|
||||
metrics_dir,
|
||||
metrics_enabled: self.metrics_enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
src/json.rs
34
src/json.rs
|
|
@ -82,6 +82,32 @@ 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.
|
||||
/// Looks for `"key": 123.45` and returns the number.
|
||||
/// Returns `None` if the key is not found or the value is not a number.
|
||||
|
|
@ -141,4 +167,12 @@ mod tests {
|
|||
assert!((extract_num(line, "num_turns").unwrap() - 5.0).abs() < 1e-10);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
681
src/main.rs
681
src/main.rs
File diff suppressed because it is too large
Load diff
750
src/metrics.rs
Normal file
750
src/metrics.rs
Normal 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
620
src/session_trim.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -180,6 +180,9 @@ pub(crate) fn stash_create(mode: &str) -> i32 {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -321,7 +324,7 @@ pub(crate) fn print_stash_help() {
|
|||
eprintln!();
|
||||
eprintln!("{}SUBCOMMANDS:{}", BOLD, RESET);
|
||||
eprintln!(
|
||||
" {}(none){} Snapshot all .loop/ files to a new stash entry",
|
||||
" {}(none){} Snapshot .loop/ files to stash, then clear the directory",
|
||||
BOLD, RESET
|
||||
);
|
||||
eprintln!(
|
||||
|
|
|
|||
148
src/stream.rs
148
src/stream.rs
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use std::process::ChildStdout;
|
||||
|
|
@ -10,18 +10,44 @@ use crate::json::{extract_num, extract_str, unescape_json};
|
|||
const BG_RED: &str = "\x1b[48;2;80;30;30m";
|
||||
const BG_GREEN: &str = "\x1b[48;2;30;60;30m";
|
||||
|
||||
/// Aggregated, persistable view of one agent invocation's stream.
|
||||
///
|
||||
/// Produced by `filter_stream` after the child's stdout closes. Carries
|
||||
/// 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 {
|
||||
turn_num: u32,
|
||||
current_msg_id: Option<String>,
|
||||
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, so tool_result can look up its origin.
|
||||
tool_use_names: HashMap<String, String>,
|
||||
/// 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 {
|
||||
|
|
@ -32,10 +58,30 @@ impl StreamState {
|
|||
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_names: HashMap::new(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -255,7 +301,7 @@ fn handle_assistant(out: &mut (impl Write + ?Sized), line: &str, state: &mut Str
|
|||
return Ok(());
|
||||
}
|
||||
if let (Some(id), Some(name)) = (extract_str(line, "id"), extract_str(line, "name")) {
|
||||
state.tool_use_names.insert(id.to_string(), name.to_string());
|
||||
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);
|
||||
|
|
@ -291,6 +337,7 @@ fn handle_stream_event(out: &mut (impl Write + ?Sized), line: &str, state: &mut
|
|||
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;
|
||||
|
|
@ -301,11 +348,24 @@ fn handle_stream_event(out: &mut (impl Write + ?Sized), line: &str, state: &mut
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle "user" events: render tool_result success/error badges.
|
||||
fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &StreamState) -> io::Result<()> {
|
||||
/// 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)?;
|
||||
|
|
@ -315,10 +375,7 @@ fn handle_tool_result(out: &mut (impl Write + ?Sized), line: &str, state: &Strea
|
|||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tool_name = extract_str(line, "tool_use_id")
|
||||
.and_then(|id| state.tool_use_names.get(id))
|
||||
.map(|s| s.as_str());
|
||||
let badge = match tool_name {
|
||||
let badge = match tool_name_owned.as_deref() {
|
||||
Some(name @ ("Grep" | "Glob")) => format_grep_glob_badge(name, line),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
|
@ -348,6 +405,9 @@ fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamS
|
|||
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)?;
|
||||
|
|
@ -379,26 +439,39 @@ fn process_line(out: &mut (impl Write + ?Sized), line: &str, state: &mut StreamS
|
|||
}
|
||||
|
||||
/// Build a compact one-line iteration summary strip from accumulated state.
|
||||
/// Format: `⟪ 6 turns │ 3 edits │ 1 bash │ 42s │ $0.38 ⟫`
|
||||
/// 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();
|
||||
|
||||
// Turns
|
||||
parts.push(format!("{} turn{}", state.turn_num, if state.turn_num == 1 { "" } else { "s" }));
|
||||
|
||||
// Tool counts — show the most interesting tools in a stable order
|
||||
let tool_order = ["Edit", "Write", "Read", "Bash", "Grep", "Glob"];
|
||||
for tool in &tool_order {
|
||||
if let Some(&count) = state.tool_counts.get(*tool) {
|
||||
let label = tool.to_lowercase();
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
// Any tools not in the predefined order
|
||||
for (name, &count) in &state.tool_counts {
|
||||
if !tool_order.contains(&name.as_str()) {
|
||||
parts.push(format!("{} {}", count, name.to_lowercase()));
|
||||
}
|
||||
|
||||
if state.thinking_total_secs > 0.0 {
|
||||
parts.push(format!("thinking {:.1}s", state.thinking_total_secs));
|
||||
}
|
||||
|
||||
// Duration
|
||||
|
|
@ -411,18 +484,19 @@ fn format_summary_strip(state: &StreamState) -> String {
|
|||
}
|
||||
|
||||
/// Shared stream loop: reads lines from stdout, tees to log, calls processor per line,
|
||||
/// prints a summary strip, and returns the iteration cost.
|
||||
/// 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.
|
||||
pub fn run_stream_loop<S>(
|
||||
/// 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>,
|
||||
state: &mut S,
|
||||
mut state: S,
|
||||
mut process: impl FnMut(&mut dyn Write, &str, &mut S) -> io::Result<()>,
|
||||
summarize: impl FnOnce(&S) -> Option<String>,
|
||||
get_cost: impl FnOnce(&S) -> f64,
|
||||
) -> f64 {
|
||||
finalize: impl FnOnce(S) -> R,
|
||||
) -> R {
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut log_file = log_path.and_then(|p| {
|
||||
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
|
||||
|
|
@ -449,30 +523,30 @@ pub fn run_stream_loop<S>(
|
|||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
|
||||
if process(&mut out, &line, state).is_err() {
|
||||
if process(&mut out, &line, &mut state).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(strip) = summarize(state) {
|
||||
if let Some(strip) = summarize(&state) {
|
||||
let _ = writeln!(out, "{}", strip);
|
||||
}
|
||||
|
||||
let _ = out.flush();
|
||||
get_cost(state)
|
||||
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 the cost of this iteration (from the `result` event).
|
||||
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, prior_total: f64) -> f64 {
|
||||
let mut state = StreamState::new();
|
||||
/// 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,
|
||||
&mut state,
|
||||
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.iteration_cost,
|
||||
|st| st.into_summary(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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,
|
||||
|
|
@ -166,14 +167,22 @@ fn format_summary_strip(state: &StreamState) -> String {
|
|||
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
|
||||
}
|
||||
|
||||
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, _prior_total: f64) -> f64 {
|
||||
let mut state = StreamState::new();
|
||||
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,
|
||||
&mut state,
|
||||
state,
|
||||
|out, line, st| process_line(out, line, st),
|
||||
|st| if st.turn_num > 0 { Some(format_summary_strip(st)) } else { None },
|
||||
|st| st.iteration_cost,
|
||||
|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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,34 @@ 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).
|
||||
|
|
|
|||
|
|
@ -1,70 +1,172 @@
|
|||
# Yoke configuration (brute mode)
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ 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 ────────────────────────────────────────────────────────────
|
||||
# Model to use for the agent. If unset, defaults to Claude CLI.
|
||||
# Use provider/model format for OpenRouter or other opencode providers.
|
||||
# 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
|
||||
|
||||
# ── Sandbox ──────────────────────────────────────────────────────────
|
||||
# Docker image to run the agent inside. Required unless you pass --no-sandbox.
|
||||
# Note: sandbox is not currently supported with the 'model' directive.
|
||||
# ── 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 lines of tail output kept per guard in guard-results.md.
|
||||
# ── 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
|
||||
|
||||
# Uncomment to save raw stream-json output per iteration.
|
||||
# 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) ──────────────────────────
|
||||
# Controls what files the agent is allowed to change. Most-specific
|
||||
# (longest prefix) match wins.
|
||||
# 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 only; existing files cannot be modified
|
||||
# no-modify <prefix> — no changes at all (adds or modifications rejected)
|
||||
# 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 (run after each plan stage, fail-fast) ────────────────────
|
||||
# Shell commands executed after each agent iteration. If any guard
|
||||
# exits non-zero the iteration fails and results are fed back.
|
||||
# ── 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.
|
||||
#
|
||||
# NOTE: avoid "cargo check" as the sole guard — its type-error output
|
||||
# can confuse the agent into chasing compiler noise instead of finishing
|
||||
# the task. Prefer a test suite or linter that validates behaviour.
|
||||
# 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 check
|
||||
# guard cargo test
|
||||
|
||||
# ── Judge cadence ─────────────────────────────────────────────────────
|
||||
# By default the judge runs only after the worker signals DONE.
|
||||
# Set judge-every to fire the judge as a quality checkpoint every N
|
||||
# worker iterations (it still always fires on DONE too).
|
||||
# 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 consecutive judge failures before bailing out (default: 3).
|
||||
# The counter resets to 0 after any passing verdict.
|
||||
# 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 (cadence-based supplementary agents) ──────────────
|
||||
# Invoke an additional agent protocol at a fixed cadence (every N
|
||||
# worker iterations). Useful for code cleanup, review passes, etc.
|
||||
# The agent name is derived from the filename (cleaner.md → "cleaner").
|
||||
# ── 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>
|
||||
#
|
||||
# periodic .loop/cleaner.md 10
|
||||
|
||||
# Guards that run after a specific periodic agent completes. Results
|
||||
# are written to .loop/periodic-<name>-results.md (separate from the
|
||||
# worker's guard-results.md so the worker isn't confused).
|
||||
# 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}"
|
||||
|
|
|
|||
|
|
@ -37,6 +37,37 @@ The first line of `.loop/notes.md` must be one of:
|
|||
|
||||
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,78 +1,163 @@
|
|||
# Yoke configuration
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ 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 ────────────────────────────────────────────────────────────
|
||||
# Model to use for the agent. If unset, defaults to Claude CLI.
|
||||
# Use provider/model format for OpenRouter or other opencode providers.
|
||||
# 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
|
||||
|
||||
# ── Sandbox ──────────────────────────────────────────────────────────
|
||||
# Docker image to run the agent inside. Required unless you pass --no-sandbox.
|
||||
# Note: sandbox is not currently supported with the 'model' directive.
|
||||
# ── 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 lines of tail output kept per guard in guard-results.md.
|
||||
# Keeps the results file from exploding on verbose commands.
|
||||
# ── 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
|
||||
|
||||
# Uncomment to save raw stream-json output per iteration.
|
||||
# Each iteration writes to <log-dir>/iteration-<N>.jsonl.
|
||||
# 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) ──────────────────────────
|
||||
# Controls what files the agent is allowed to change. After each iteration
|
||||
# yoke diffs the working tree and checks every changed file against
|
||||
# these rules. Most-specific (longest prefix) match wins.
|
||||
# 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:
|
||||
#
|
||||
# Directives:
|
||||
# allow <prefix> — any change permitted (add, modify, delete)
|
||||
# add-only <prefix> — new files only; existing files cannot be modified
|
||||
# no-modify <prefix> — no changes at all (adds or modifications rejected)
|
||||
# add-only <prefix> — new files OK; edits to existing files rejected
|
||||
# no-modify <prefix> — no changes at all (adds or edits rejected)
|
||||
#
|
||||
# The prefix "." matches every path (root catch-all).
|
||||
# The special prefix "." matches every path (root catch-all).
|
||||
#
|
||||
# Examples:
|
||||
# allow src/ # full access under src/
|
||||
# add-only tests/ # can create new test files, not edit existing
|
||||
# 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
|
||||
# allow . # fallback: everything else is allowed
|
||||
# no-modify package-lock.json # protect a specific file
|
||||
# allow . # fallback: everything else allowed
|
||||
|
||||
allow .
|
||||
|
||||
# ── Guards (run in order, fail-fast) ─────────────────────────────────
|
||||
# Shell commands executed after each agent iteration. If any guard
|
||||
# exits non-zero the iteration is marked failed, remaining guards are
|
||||
# skipped, and the results are fed back on the next pass.
|
||||
# ── 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.
|
||||
#
|
||||
# Common examples:
|
||||
# guard cargo check
|
||||
# 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 run lint
|
||||
# guard npm test
|
||||
# guard python -m pytest tests/ -x
|
||||
# guard make test
|
||||
# guard go test ./...
|
||||
# guard make check
|
||||
# guard ./scripts/validate.sh
|
||||
#
|
||||
# NOTE: avoid "cargo check" as the sole guard — its type-error output
|
||||
# can confuse the agent into chasing compiler noise instead of finishing
|
||||
# the task. Prefer a test suite or linter that validates behaviour.
|
||||
# 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 check
|
||||
# guard cargo test
|
||||
|
||||
# ── Periodic agents (cadence-based supplementary agents) ──────────────
|
||||
# Invoke an additional agent protocol at a fixed cadence (every N
|
||||
# worker iterations). Useful for code cleanup, review passes, etc.
|
||||
# The agent name is derived from the filename (cleaner.md → "cleaner").
|
||||
# ── 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>
|
||||
#
|
||||
# periodic .loop/cleaner.md 10
|
||||
|
||||
# Guards that run after a specific periodic agent completes. Results
|
||||
# are written to .loop/periodic-<name>-results.md (separate from the
|
||||
# worker's guard-results.md so the worker isn't confused).
|
||||
# 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}"
|
||||
|
|
|
|||
|
|
@ -1,70 +1,179 @@
|
|||
# Yoke configuration (saga mode)
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ 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 ────────────────────────────────────────────────────────────
|
||||
# Model to use for the agent. If unset, defaults to Claude CLI.
|
||||
# Use provider/model format for OpenRouter or other opencode providers.
|
||||
# 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
|
||||
|
||||
# ── Sandbox ──────────────────────────────────────────────────────────
|
||||
# Docker image to run the agent inside. Required unless you pass --no-sandbox.
|
||||
# Note: sandbox is not currently supported with the 'model' directive.
|
||||
# ── 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 lines of tail output kept per guard in guard-results.md.
|
||||
# ── 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
|
||||
|
||||
# Uncomment to save raw stream-json output per iteration.
|
||||
# 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) ──────────────────────────
|
||||
# Controls what files the agent is allowed to change. Most-specific
|
||||
# (longest prefix) match wins.
|
||||
# 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 only; existing files cannot be modified
|
||||
# no-modify <prefix> — no changes at all (adds or modifications rejected)
|
||||
# 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 (run after each plan stage, fail-fast) ────────────────────
|
||||
# Shell commands executed after each agent iteration. If any guard
|
||||
# exits non-zero the iteration fails and results are fed back.
|
||||
# ── 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.
|
||||
#
|
||||
# NOTE: avoid "cargo check" as the sole guard — its type-error output
|
||||
# can confuse the agent into chasing compiler noise instead of finishing
|
||||
# the task. Prefer a test suite or linter that validates behaviour.
|
||||
# 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 check
|
||||
# guard cargo test
|
||||
|
||||
# ── Judge cadence ─────────────────────────────────────────────────────
|
||||
# By default the judge runs only after the worker signals DONE.
|
||||
# Set judge-every to fire the judge as a quality checkpoint every N
|
||||
# worker iterations (it still always fires on DONE too).
|
||||
# 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 consecutive judge failures before bailing out (default: 3).
|
||||
# The counter resets to 0 after any passing verdict.
|
||||
# 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 (cadence-based supplementary agents) ──────────────
|
||||
# Invoke an additional agent protocol at a fixed cadence (every N
|
||||
# worker iterations). Useful for code cleanup, review passes, etc.
|
||||
# The agent name is derived from the filename (cleaner.md → "cleaner").
|
||||
# ── 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>
|
||||
#
|
||||
# periodic .loop/cleaner.md 10
|
||||
|
||||
# Guards that run after a specific periodic agent completes. Results
|
||||
# are written to .loop/periodic-<name>-results.md (separate from the
|
||||
# worker's guard-results.md so the worker isn't confused).
|
||||
# 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}"
|
||||
|
|
|
|||
|
|
@ -97,26 +97,22 @@ fn stash_roundtrip_after_extraction() {
|
|||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("mode=brute"), "stash log should show mode=brute, got:\n{}", stderr);
|
||||
|
||||
// Clean — this should auto-stash then wipe
|
||||
let out = Command::new(&yoke)
|
||||
.args(["clean"])
|
||||
.current_dir(project)
|
||||
.output()
|
||||
.expect("yoke clean");
|
||||
assert!(out.status.success(), "yoke clean failed: {}", String::from_utf8_lossy(&out.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");
|
||||
|
||||
// Verify plan.md was emptied by clean
|
||||
let plan = fs::read_to_string(loop_dir.join("plan.md")).unwrap();
|
||||
assert!(plan.is_empty(), "plan.md should be empty after clean, got: {:?}", plan);
|
||||
|
||||
// Pop — should restore the auto-stashed state (which is the post-clean state,
|
||||
// but let's verify we can pop without error, meaning the index is intact)
|
||||
// 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 ──
|
||||
|
|
|
|||
256
tests/metrics_persistence.rs
Normal file
256
tests/metrics_persistence.rs
Normal 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
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue