fix: mild behavioral adjustments

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-28 12:32:38 +04:00
parent 5197d677ba
commit c05fb44cbe
12 changed files with 542 additions and 136 deletions

View file

@ -55,6 +55,7 @@ pub struct Config {
pub guards: Vec<String>,
pub judge_every: Option<u32>,
pub max_judge_failures: u32,
pub grind_passes: u32,
pub periodics: Vec<Periodic>,
pub hooks: Vec<String>,
/// Resolved absolute path where NDJSON metrics rows are written.
@ -73,6 +74,7 @@ struct ConfigBuilder {
guards: Vec<String>,
judge_every: Option<u32>,
max_judge_failures: u32,
grind_passes: u32,
periodics: Vec<Periodic>,
pending_guard_afters: Vec<(String, String, usize)>,
hooks: Vec<String>,
@ -166,6 +168,7 @@ impl ConfigBuilder {
guards: Vec::new(),
judge_every: None,
max_judge_failures: 3,
grind_passes: 1,
periodics: Vec::new(),
pending_guard_afters: Vec::new(),
hooks: Vec::new(),
@ -234,6 +237,9 @@ impl ConfigBuilder {
self.max_judge_failures =
parse_positive_u32(value, path, line_num, "max-judge-failures")?
}
"grind-passes" => {
self.grind_passes = parse_positive_u32(value, path, line_num, "grind-passes")?
}
"periodic" => self.periodics.push(parse_periodic(value, path, line_num)?),
"guard-after" => self
.pending_guard_afters
@ -290,6 +296,7 @@ impl ConfigBuilder {
guards: self.guards,
judge_every: self.judge_every,
max_judge_failures: self.max_judge_failures,
grind_passes: self.grind_passes,
periodics,
hooks: self.hooks,
metrics_dir,

View file

@ -19,6 +19,7 @@ use config::{Config, Periodic};
const CONF_PATH: &str = ".loop/yoke.conf";
const NOTES_PATH: &str = ".loop/notes.md";
const ARCHIVE_DIR: &str = ".loop/archive";
const GUARD_RESULTS_PATH: &str = ".loop/guard-results.md";
const PROTOCOL_PATH: &str = ".loop/protocol.md";
const PLAN_PATH: &str = ".loop/plan.md";
@ -38,6 +39,7 @@ const DEFAULT_SAGA_CONF: &str = include_str!("templates/saga/yoke.conf");
const DEFAULT_GRIND_CONF: &str = include_str!("templates/grind/yoke.conf");
const DEFAULT_GRIND_PROTOCOL: &str = include_str!("templates/grind/protocol.md");
const DEFAULT_GRIND_GATE: &str = include_str!("templates/grind/grind-gate.py");
const DEFAULT_GRIND_JUDGE: &str = include_str!("templates/grind/judge.md");
const LAYER_REPL: &str = include_str!("templates/layers/repl.md");
@ -50,7 +52,6 @@ const DECISIONS_PATH: &str = ".loop/decisions.md";
const SUB_PLAN_PATH: &str = ".loop/sub-plan.md";
const GRIND_GATE_PATH: &str = ".loop/grind-gate.py";
const CSTAT_BASELINE_PATH: &str = ".loop/cstat-baseline.json";
pub(crate) const STASH_DIR: &str = ".loop/.stash";
/// Parse the plan file and notes.md to determine stage progress.
/// Returns `Some((completed, total))` if the plan has parseable `## Stage` headers.
@ -284,14 +285,14 @@ fn print_run_help() {
" {}saga{} Scoper + brute loop — detected when {} exists.",
BOLD, RESET, SPECIFICATION_PATH
);
eprintln!(
" {}brute{} Plan stages + judge — detected when {} exists.",
BOLD, RESET, JUDGE_PATH
);
eprintln!(
" {}grind{} cstat cleanup loop — detected when {} and {} exist.",
BOLD, RESET, GRIND_GATE_PATH, CSTAT_BASELINE_PATH
);
eprintln!(
" {}brute{} Plan stages + judge — detected when {} exists without grind files.",
BOLD, RESET, JUDGE_PATH
);
eprintln!(
" {}loop{} (default) Staged plan loop — iterates until STATUS: DONE and guards pass.",
BOLD, RESET
@ -316,6 +317,15 @@ fn print_run_help() {
eprintln!(" d. Run configured guard commands");
eprintln!(" 4. Exit when STATUS: DONE and all guards pass");
eprintln!();
eprintln!("{}WORKFLOW (grind):{}", BOLD, RESET);
eprintln!(" 1. Load config from {}", CONF_PATH);
eprintln!(" 2. Per grind pass:");
eprintln!(" a. Restore protected files");
eprintln!(" b. Invoke OMP with JSON output");
eprintln!(" c. Run diff boundary check and grind gate");
eprintln!(" d. Optionally invoke judge when judge-every cadence matches");
eprintln!(" 3. Exit after grind-passes successful gate passes");
eprintln!();
eprintln!("{}WORKFLOW (brute):{}", BOLD, RESET);
eprintln!(" 1. Load config from {}", CONF_PATH);
eprintln!(" 2. Backup protected files (protocol.md, plan.md, task.md, judge.md, yoke.conf)");
@ -338,14 +348,14 @@ fn print_init_help() {
eprintln!("{}MODES:{}", BOLD, RESET);
eprintln!(" {}(default){} Staged plan loop. Creates:", BOLD, RESET);
eprintln!(" yoke.conf, protocol.md, briefing.md, plan.md, notes.md,");
eprintln!(" guard-results.md");
eprintln!(" archive/, guard-results.md");
eprintln!();
eprintln!(
" {}brute{} Plan stages + judge. Creates:",
BOLD, RESET
);
eprintln!(" yoke.conf, protocol.md, briefing.md, plan.md, judge.md,");
eprintln!(" notes.md, verdict.md, guard-results.md");
eprintln!(" notes.md, archive/, verdict.md, guard-results.md");
eprintln!();
eprintln!(
" {}saga{} Scoper + brute loop. Creates:",
@ -353,11 +363,13 @@ fn print_init_help() {
);
eprintln!(" yoke.conf, saga-protocol.md, protocol.md, judge.md,");
eprintln!(" specification.md, saga-notes.md, decisions.md,");
eprintln!(" sub-plan.md, notes.md, verdict.md, guard-results.md");
eprintln!(" sub-plan.md, notes.md, archive/, verdict.md, guard-results.md");
eprintln!();
eprintln!(" {}grind{} cstat cleanup loop. Creates:", BOLD, RESET);
eprintln!(" yoke.conf, protocol.md, grind-gate.py,");
eprintln!(" cstat-baseline.json, notes.md, guard-results.md");
eprintln!(" yoke.conf, protocol.md, grind-gate.py, judge.md,");
eprintln!(
" cstat-baseline.json, notes.md, archive/, verdict.md, guard-results.md"
);
eprintln!();
eprintln!("Existing files are never overwritten.");
eprintln!();
@ -404,6 +416,13 @@ impl Drop for LoopRunner {
}
}
fn ensure_archive_dir() {
if let Err(e) = fs::create_dir_all(ARCHIVE_DIR) {
log_error(&format!("cannot create {}: {}", ARCHIVE_DIR, e));
process::exit(1);
}
}
/// Check that required loop files exist, create notes if missing.
/// `plan_path` is required for plan-driven modes and absent for grind.
///
@ -430,6 +449,7 @@ fn preflight(plan_path: Option<&str>, clear_guard_results: bool) {
log_error(&format!("cannot create {}: {}", NOTES_PATH, e));
process::exit(1);
}
ensure_archive_dir();
// Clear guard results only on standalone (non-nested) runs.
// In brute mode, run_brute_inner() handles the initial clear and
// retries preserve guard feedback for the worker.
@ -1204,13 +1224,16 @@ fn print_clean_help() {
eprintln!();
eprintln!("Clears working files back to a blank slate:");
eprintln!(" plan.md, notes.md, verdict.md, guard-results.md → emptied");
eprintln!(" archive/*.md → removed");
eprintln!(" saga-notes.md, decisions.md, sub-plan.md → emptied (if present)");
eprintln!(" judge.md → reset to default template");
eprintln!();
eprintln!("Structural files are left untouched:");
eprintln!(" protocol.md, saga-protocol.md, yoke.conf, briefing.md, specification.md");
eprintln!();
eprintln!("Non-empty working files are auto-stashed to .loop/.stash/ before wiping.");
eprintln!(
"Non-empty working files are auto-stashed to ~/.yoke/stash/<project-slug>/ before wiping."
);
eprintln!(
"Browse with: {}yoke stash log{} Recover with: {}yoke stash pop{}",
BOLD, RESET, BOLD, RESET
@ -1250,14 +1273,23 @@ fn clean() -> i32 {
}
}
// Reset judge.md to default template (saga uses a different default)
let archive = Path::new(ARCHIVE_DIR);
if archive.exists() {
if let Err(e) = fs::remove_dir_all(archive) {
log_error(&format!("failed to clear {}: {}", ARCHIVE_DIR, e));
return 1;
}
log(&format!("cleared: {}", ARCHIVE_DIR));
}
ensure_archive_dir();
// Reset judge.md to the active mode's default template.
let judge = Path::new(JUDGE_PATH);
if judge.exists() {
let is_saga = Path::new(SPECIFICATION_PATH).exists();
let template = if is_saga {
DEFAULT_SAGA_JUDGE
} else {
DEFAULT_JUDGE
let template = match mode {
"saga" => DEFAULT_SAGA_JUDGE,
"grind" => DEFAULT_GRIND_JUDGE,
_ => DEFAULT_JUDGE,
};
if let Err(e) = fs::write(judge, template) {
log_error(&format!("failed to reset {}: {}", JUDGE_PATH, e));
@ -1315,8 +1347,10 @@ fn mode_files(mode: &str) -> Option<Vec<(&'static str, &'static str)>> {
(CONF_PATH, DEFAULT_GRIND_CONF),
(PROTOCOL_PATH, DEFAULT_GRIND_PROTOCOL),
(GRIND_GATE_PATH, DEFAULT_GRIND_GATE),
(JUDGE_PATH, DEFAULT_GRIND_JUDGE),
(CSTAT_BASELINE_PATH, ""),
(NOTES_PATH, ""),
(VERDICT_PATH, ""),
(GUARD_RESULTS_PATH, ""),
],
_ => return None,
@ -1330,10 +1364,10 @@ fn detect_mode() -> Option<&'static str> {
}
if Path::new(SPECIFICATION_PATH).exists() {
Some("saga")
} else if Path::new(JUDGE_PATH).exists() {
Some("brute")
} else if Path::new(GRIND_GATE_PATH).exists() && Path::new(CSTAT_BASELINE_PATH).exists() {
Some("grind")
} else if Path::new(JUDGE_PATH).exists() {
Some("brute")
} else if Path::new(PROTOCOL_PATH).exists() {
Some("loop")
} else {
@ -1362,6 +1396,7 @@ fn switch_mode(current: &str, target: &str) -> i32 {
for (name, _) in &current_files {
let _ = fs::remove_file(Path::new(".loop").join(name));
}
ensure_archive_dir();
// 3. Fresh-init target mode
let target_files = mode_files(target).unwrap();
@ -1449,6 +1484,7 @@ fn init(mode: &str) -> i32 {
}
log("Created .loop/");
}
ensure_archive_dir();
log(&format!("Initializing '{}' mode...", mode));
@ -1471,6 +1507,18 @@ fn init(mode: &str) -> i32 {
0
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum LoopKind {
Plan,
Grind,
}
impl LoopKind {
fn uses_status_completion(self) -> bool {
matches!(self, LoopKind::Plan)
}
}
/// Outcome of a plan loop run.
enum PlanLoopOutcome {
/// STATUS: DONE + guards pass (no embedded judge).
@ -1507,8 +1555,8 @@ enum JudgeEveryAction {
Pass,
/// Consecutive failures hit threshold — bail out.
Bailout,
/// Continue to next iteration (judge failed on DONE, or mid-loop check done).
Continue,
/// Continue to next iteration; carries whether a fired judge failed.
Continue { judge_failed: bool },
}
/// Evaluate judge-every logic: fire judge when worker signals DONE or at cadence checkpoints.
@ -1518,12 +1566,13 @@ fn evaluate_judge_every(
config: &Config,
iteration: u32,
guards_passed: bool,
loop_kind: LoopKind,
consecutive_judge_failures: &mut u32,
judge_every: u32,
judge_secs_out: &mut Option<f64>,
) -> JudgeEveryAction {
if guards_passed && is_status_done(NOTES_PATH) {
// Always fire judge when worker signals DONE
if loop_kind.uses_status_completion() && guards_passed && is_status_done(NOTES_PATH) {
// Always fire judge when a status-driven worker signals DONE.
eprintln!();
render_section_banner("Judge (DONE)", "Worker DONE — invoking judge", BLUE);
let judge_start = std::time::Instant::now();
@ -1541,8 +1590,11 @@ fn evaluate_judge_every(
ORANGE, *consecutive_judge_failures, config.max_judge_failures, RESET
));
reset_notes_status();
} else if guards_passed && iteration % judge_every == 0 {
// Mid-loop quality checkpoint
return JudgeEveryAction::Continue { judge_failed: true };
}
if guards_passed && iteration % judge_every == 0 {
// Cadenced quality checkpoint.
eprintln!();
render_section_banner(
"Mid-loop Judge",
@ -1554,15 +1606,21 @@ fn evaluate_judge_every(
*judge_secs_out = Some(judge_start.elapsed().as_secs_f64());
if pass {
*consecutive_judge_failures = 0;
} else {
return JudgeEveryAction::Continue {
judge_failed: false,
};
}
*consecutive_judge_failures += 1;
if is_judge_bailout(*consecutive_judge_failures, config.max_judge_failures) {
return JudgeEveryAction::Bailout;
}
return JudgeEveryAction::Continue { judge_failed: true };
}
// Either way, worker continues — verdict.md has feedback
JudgeEveryAction::Continue {
judge_failed: false,
}
JudgeEveryAction::Continue
}
/// Validate config preconditions for the plan loop. Exits on failure.
@ -1740,6 +1798,7 @@ fn run_plan_loop(
dry_run: bool,
nested: bool,
recorder: &mut metrics::RunRecorder,
kind: LoopKind,
) -> PlanLoopOutcome {
preflight(plan_path, !nested);
validate_loop_config(config);
@ -1749,6 +1808,9 @@ fn run_plan_loop(
if let Some(plan_path) = plan_path {
protected.push(plan_path);
}
if Path::new(JUDGE_PATH).exists() {
protected.push(JUDGE_PATH);
}
if Path::new(GRIND_GATE_PATH).exists() {
protected.push(GRIND_GATE_PATH);
}
@ -1767,6 +1829,7 @@ fn run_plan_loop(
let mut iteration: u32 = 0;
let mut total_cost: f64 = 0.0;
let mut consecutive_judge_failures: u32 = 0;
let mut completed_grind_passes: u32 = 0;
loop {
if signal::interrupted() {
@ -1780,6 +1843,8 @@ fn run_plan_loop(
eprintln!();
let label = if nested {
"Plan Iteration"
} else if kind == LoopKind::Grind {
"Grind Iteration"
} else {
"Iteration"
};
@ -1818,23 +1883,28 @@ fn run_plan_loop(
let mut judge_secs: Option<f64> = None;
let mut early_return: Option<PlanLoopOutcome> = None;
let mut judge_failed_this_iteration = false;
if let Some(judge_every) = config.judge_every {
match evaluate_judge_every(
&mut runner,
config,
iteration,
step_stats.guards_passed,
kind,
&mut consecutive_judge_failures,
judge_every,
&mut judge_secs,
) {
JudgeEveryAction::Pass => early_return = Some(PlanLoopOutcome::JudgePass),
JudgeEveryAction::Bailout => early_return = Some(PlanLoopOutcome::JudgeBailout),
JudgeEveryAction::Continue => {}
JudgeEveryAction::Continue { judge_failed } => {
judge_failed_this_iteration = judge_failed;
}
}
}
let status_done = step_stats.guards_passed && is_status_done(NOTES_PATH);
let status_done =
kind.uses_status_completion() && step_stats.guards_passed && is_status_done(NOTES_PATH);
// Build & flush iteration metrics row before any return path so
// every completed iteration is recorded.
@ -1876,6 +1946,30 @@ fn run_plan_loop(
return outcome;
}
if kind == LoopKind::Grind && step_stats.guards_passed {
if judge_failed_this_iteration {
log(&format!(
"{}Grind gate passed but judge failed \u{2014} worker will see verdict next iteration{}",
ORANGE, RESET
));
} else {
completed_grind_passes += 1;
if completed_grind_passes >= config.grind_passes {
eprintln!();
eprintln!(
"{}{} Grind passes complete ({}/{}) \u{2014} loop complete {}",
GREEN, BOLD, completed_grind_passes, config.grind_passes, RESET
);
eprintln!();
return PlanLoopOutcome::Done;
}
log(&format!(
"{}Grind pass {}/{} complete \u{2014} continuing{}",
GREEN, completed_grind_passes, config.grind_passes, RESET
));
}
}
if status_done {
eprintln!();
eprintln!(
@ -1942,7 +2036,12 @@ fn run_loop_with_label(dry_run: bool, mode_label: &'static str) -> i32 {
} else {
Some(PLAN_PATH)
};
let outcome = run_plan_loop(&config, plan_path, dry_run, false, &mut recorder);
let kind = if mode_label == "grind" {
LoopKind::Grind
} else {
LoopKind::Plan
};
let outcome = run_plan_loop(&config, plan_path, dry_run, false, &mut recorder, kind);
recorder.set_outcome(plan_outcome_label(&outcome));
drop(recorder);
match outcome {
@ -2133,7 +2232,14 @@ fn run_brute_core(
log("(dry-run) Skipping worker invocation");
} else {
log("Using plan runner as worker...");
match run_plan_loop(config, Some(plan_path), false, true, recorder) {
match run_plan_loop(
config,
Some(plan_path),
false,
true,
recorder,
LoopKind::Plan,
) {
PlanLoopOutcome::JudgePass => {
// Embedded judge already passed — skip standalone judge
log("Plan runner completed with embedded judge PASS");

View file

@ -1,8 +1,8 @@
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use crate::ansi::{BLUE, BOLD, ORANGE, RESET};
use crate::{STASH_DIR, log, log_error};
use crate::{log, log_error, metrics};
// ── Stash helpers ──────────────────────────────────────────────────────
@ -41,14 +41,43 @@ fn generate_stash_hash(timestamp: &str, files: &[(String, Vec<u8>)]) -> String {
format!("{:07x}", hasher.finish() & 0x0FFF_FFFF)
}
/// Collect regular files under `dir`, storing paths relative to `.loop/`.
fn collect_regular_files(files: &mut Vec<(String, Vec<u8>)>, dir: &Path, prefix: &str) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let rel = if prefix.is_empty() {
name
} else {
format!("{}/{}", prefix, name)
};
let path = entry.path();
if path.is_file() {
if let Ok(contents) = fs::read(&path) {
files.push((rel, contents));
}
} else if path.is_dir() {
collect_regular_files(files, &path, &rel);
}
}
}
}
/// Collect all regular files in `.loop/` excluding dotfile/dotdir entries.
/// Returns sorted `(filename, contents)` pairs for deterministic hashing.
///
/// Top-level loop files are always included. The secondary note archive lives
/// under `.loop/archive/`, so it is included recursively without pulling in
/// unrelated log/checkpoint directories.
/// Returns sorted `(relative path, contents)` pairs for deterministic hashing.
pub(crate) fn collect_stashable_files() -> Vec<(String, Vec<u8>)> {
let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(".loop") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
if name.starts_with('.') || name.starts_with("backups-") {
continue;
}
let path = entry.path();
@ -56,6 +85,8 @@ pub(crate) fn collect_stashable_files() -> Vec<(String, Vec<u8>)> {
if let Ok(contents) = fs::read(&path) {
files.push((name, contents));
}
} else if name == "archive" && path.is_dir() {
collect_regular_files(&mut files, &path, "archive");
}
}
}
@ -70,9 +101,14 @@ pub(crate) struct StashEntry {
pub files: Vec<String>,
}
/// Parse `.loop/.stash/index` into a list of stash entries (oldest first).
/// Project-scoped stash storage under `~/.yoke/stash/<project-slug>/`.
fn stash_base_dir() -> PathBuf {
metrics::expand_tilde("~/.yoke/stash").join(metrics::project_slug())
}
/// Parse `~/.yoke/stash/<project-slug>/index` into a list of stash entries (oldest first).
pub(crate) fn parse_stash_index() -> Vec<StashEntry> {
let index_path = format!("{}/index", STASH_DIR);
let index_path = stash_base_dir().join("index");
let content = match fs::read_to_string(&index_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
@ -114,7 +150,9 @@ pub(crate) fn stash_snapshot(mode: &str) -> Result<String, String> {
let mut hash = generate_stash_hash(&timestamp, &files);
// Collision handling: rehash with counter suffix
let stash_base = Path::new(STASH_DIR);
let stash_base = stash_base_dir();
fs::create_dir_all(&stash_base)
.map_err(|e| format!("failed to create {}: {}", stash_base.display(), e))?;
for attempt in 0..100u32 {
if !stash_base.join(&hash).exists() {
break;
@ -133,18 +171,22 @@ pub(crate) fn stash_snapshot(mode: &str) -> Result<String, String> {
for (name, contents) in &files {
let dest = entry_dir.join(name);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
}
fs::write(&dest, contents)
.map_err(|e| format!("failed to write {}: {}", dest.display(), e))?;
}
// Append to index
let index_path = format!("{}/index", STASH_DIR);
let index_path = stash_base.join("index");
let line = format!("{}|{}|{}|{}\n", hash, timestamp, mode, file_names.join(","));
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)
.map_err(|e| format!("failed to open index: {}", e))?;
.map_err(|e| format!("failed to open {}: {}", index_path.display(), e))?;
std::io::Write::write_all(&mut f, line.as_bytes())
.map_err(|e| format!("failed to write index: {}", e))?;
@ -248,13 +290,15 @@ fn swap_loop_files(entry_dir: &Path, mode: &str) -> Result<(), String> {
let _ = fs::remove_file(Path::new(".loop").join(name));
}
if let Ok(dir_entries) = fs::read_dir(entry_dir) {
for entry in dir_entries.flatten() {
let name = entry.file_name();
let mut restored_files = Vec::new();
collect_regular_files(&mut restored_files, entry_dir, "");
for (name, contents) in restored_files {
let dest = Path::new(".loop").join(&name);
fs::copy(entry.path(), &dest)
.map_err(|e| format!("failed to restore {}: {}", name.to_string_lossy(), e))?;
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
}
fs::write(&dest, contents).map_err(|e| format!("failed to restore {}: {}", name, e))?;
}
Ok(())
}
@ -277,7 +321,7 @@ pub(crate) fn stash_checkout(target_hash: &str, mode: &str) -> i32 {
None => return 1,
};
let entry_dir = Path::new(STASH_DIR).join(&target.hash);
let entry_dir = stash_base_dir().join(&target.hash);
if !entry_dir.exists() {
log_error("stash entry directory missing — index is corrupt");
return 1;
@ -330,6 +374,8 @@ pub(crate) fn print_stash_help() {
BOLD, RESET
);
eprintln!();
eprintln!("Storage: ~/.yoke/stash/<project-slug>/");
eprintln!();
eprintln!("{}EXAMPLES:{}", BOLD, RESET);
eprintln!(" yoke stash # snapshot current .loop/ files");
eprintln!(" yoke stash log # show all stash entries");

View file

@ -14,7 +14,8 @@ one at a time until all stages are done and guards pass.
| `.loop/protocol.md` | read | These instructions. |
| `.loop/plan.md` | read | The feature plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/notes.md` | read+write | Primary memory across iterations. Keep it concise and stage-indexed. |
| `.loop/archive/<name>.md` | read+write | Detailed secondary memory for a stage, judge failure, or investigation. Use a stable slug such as `stage-2-cache.md`. |
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. |
@ -24,17 +25,18 @@ All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
6. **Implement**. Make the code changes for exactly one stage.
7. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
2. **Read your primary notes** (`.loop/notes.md`). This is the first reference on every stage entry — check current stage, completed stages, repeated failures, judge feedback summaries, and archive pointers.
3. **Read the stage archive** (`.loop/archive/<name>.md`) for the stage or problem you are about to work on, if it exists. Use a stable lowercase slug from the stage number/title or failure (for example `stage-2-parser.md`).
4. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
5. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
6. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
7. **Implement**. Make the code changes for exactly one stage.
8. **Update memory**:
- Put a `STATUS` line at the **top** of `.loop/notes.md` (see below).
- Keep `.loop/notes.md` as the primary stage index: current/completed stage, short outcome, guard/judge failure summary, and links to relevant archive files.
- Append detailed attempts, commands run, dead ends, hypotheses, judge-failure analysis, and next approaches to the stage archive under `.loop/archive/`.
- If you failed on the same issue again, record what was tried so the next iteration does not repeat it.
9. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
@ -46,9 +48,9 @@ The first line of `.loop/notes.md` must be one of:
## Session Continuity
Yoke resumes the same worker OMP session across worker iterations.
`.loop/notes.md`, `.loop/plan.md`, `.loop/protocol.md`, `.loop/verdict.md`,
and `.loop/guard-results.md` also live on disk, so re-read them every
iteration instead of trusting stale context.
`.loop/notes.md`, `.loop/archive/`, `.loop/plan.md`, `.loop/protocol.md`,
`.loop/verdict.md`, and `.loop/guard-results.md` also live on disk, so
re-read them every iteration instead of trusting stale context.
The judge always runs in a fresh OMP session.
@ -66,6 +68,6 @@ The judge always runs in a fresh OMP session.
- **One stage per iteration.** Implement a single stage, update notes, and exit.
- **Study judge.md.** Knowing the test helps you pass it.
- **The judge's feedback is ground truth.** Fix what they say is broken.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check `.loop/notes.md` and the relevant archive file), try a fundamentally different approach.
- **Memory discipline.** Keep `.loop/notes.md` concise and stage-indexed. Put verbose reasoning, failed commands, dead ends, judge-failure analysis, and alternative approaches in `.loop/archive/<name>.md`.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -0,0 +1,13 @@
# Judge: Grind
You are the independent quality check for a grind run. The worker is reducing structural code complexity; your job is to decide whether the cleanup remains behavior-preserving, maintainable, and worth receiving.
Read `.loop/guard-results.md`, `.loop/notes.md`, and the repository diff. Run targeted checks when they cut uncertainty.
PASS only if:
1. the grind gate passed;
2. observable behavior still appears preserved;
3. the cleanup improves maintainability rather than hiding complexity behind worse names, indirection, or brittle special cases.
FAIL with specifics: what you checked, what broke or regressed, and what the worker should repair next.

View file

@ -7,12 +7,15 @@ You are operating inside an automated grind loop — not a conversation. A harne
| File | You can | Purpose |
|------|---------|---------|
| `.loop/protocol.md` | read | This document. Your instructions. |
| `.loop/notes.md` | read + write | Your scratchpad across iterations. |
| `.loop/notes.md` | read + write | Primary memory across iterations. Keep it concise and target-indexed. |
| `.loop/archive/<name>.md` | read + write | Detailed secondary memory for a cleanup target, failure, or investigation. Use a stable slug such as `main-runner.md` or `guard-failure.md`. |
| `.loop/guard-results.md` | read | The previous grind gate output. |
| `.loop/verdict.md` | read | The previous judge verdict, when optional judge cadence is enabled. |
| `.loop/judge.md` | read | The optional judge rubric. Use it to anticipate independent review. |
| `.loop/cstat-baseline.json` | read | Baseline `cstat --path . --json scorecard` captured before edits. |
| `.loop/cstat-current.json` | read | Last gate's current scorecard JSON, when present. |
| `.loop/grind-gate.py` | read | The guard harness. It runs the behavior oracle, optional benchmark, and cstat comparison. |
| `.loop/yoke.conf` | read | Scope rules and guard settings. |
| `.loop/yoke.conf` | read | Scope rules, pass count, and optional judge cadence. |
All paths are relative to the repository root.
@ -60,19 +63,15 @@ Commands from `cstat help`:
## Per-Iteration Steps
1. Read `.loop/notes.md`, `.loop/guard-results.md`, `.loop/cstat-baseline.json`, and `.loop/cstat-current.json` if it exists. If the previous gate failed on behavior, benchmark, boundary, or invalid cstat output, repair that first.
2. Run `cstat --path . scorecard --top 20`. Compare current cost to the baseline and identify the biggest contributors.
3. Use the relevant cstat diagnostics above. Do not sample one random command; pick the commands that explain the top contributors and the scorecard dimensions they affect.
4. Push a coherent cleanup batch. Do not stop after one simplification. Delete dead code, collapse unnecessary abstractions, simplify branching, reduce signatures, shrink spans, and cut coupling while observable behavior remains the same.
5. Treat guards as the safety net. Do not weaken behavior checks, but do not be timid because checks exist. If a cleanup is plausible, behavior-preserving, and aimed at measured complexity, do it.
6. Re-run `cstat --path . scorecard --top 20` when practical. Run targeted behavior checks when they are cheap and directly cover risky edits; the grind gate will run the configured oracle after you exit.
7. Update `.loop/notes.md`. The first line must be `STATUS: IN_PROGRESS` or `STATUS: DONE`. Include starting cost, ending cost if measured, files changed, complexity contributors attacked, guard failures repaired, and concrete next targets.
8. Exit. Do not loop manually; Yoke handles the next iteration.
## STATUS Signaling
Use `STATUS: IN_PROGRESS` when more cleanup or repair is needed.
Use `STATUS: DONE` only when current `code_complexity_cost` is lower than the baseline and you expect the behavior oracle plus optional benchmark to pass.
1. Read `.loop/notes.md`, `.loop/guard-results.md`, `.loop/verdict.md`, `.loop/cstat-baseline.json`, and `.loop/cstat-current.json` if it exists. Treat `.loop/notes.md` as the primary index: current targets, prior guard/judge failures, and archive pointers. If the previous gate or judge failed, repair that feedback first.
2. Read the relevant `.loop/archive/<name>.md` file for the cleanup target or repeated failure, if it exists. Use stable lowercase slugs so future iterations can find the detailed history.
3. Run `cstat --path . scorecard --top 20`. Compare current cost to the baseline and identify the biggest contributors.
4. Use the relevant cstat diagnostics above. Do not sample one random command; pick the commands that explain the top contributors and the scorecard dimensions they affect.
5. Push a coherent cleanup batch. Do not stop after one simplification. Delete dead code, collapse unnecessary abstractions, simplify branching, reduce signatures, shrink spans, and cut coupling while observable behavior remains the same.
6. Treat guards as the safety net. Do not weaken behavior checks, but do not be timid because checks exist. If a cleanup is plausible, behavior-preserving, and aimed at measured complexity, do it.
7. Re-run `cstat --path . scorecard --top 20` when practical. Run targeted behavior checks when they are cheap and directly cover risky edits; the grind gate will run the configured oracle after you exit.
8. Update memory. Keep `.loop/notes.md` as the concise primary index: starting cost, ending cost if measured, files changed, complexity contributors attacked, guard or judge failures repaired, concrete next targets, and links to relevant archive files. Append detailed attempts, failed commands, dead ends, hypotheses, and alternative approaches to `.loop/archive/<name>.md`. Do not rely on `STATUS`; grind mode ignores it for completion.
9. Exit. Do not loop manually; Yoke handles the next iteration and stops after `.loop/yoke.conf` `grind-passes` successful gate passes.
## What the Grind Gate Checks
@ -82,13 +81,16 @@ The guard command runs `python3 .loop/grind-gate.py`. It fails unless:
2. the optional benchmark command succeeds, when configured;
3. current `cstat --path . --json scorecard` has lower `code_complexity_cost` than the baseline.
Optional judge cadence (`judge-every` in `.loop/yoke.conf`) runs only after the gate passes. A judge FAIL writes `.loop/verdict.md`, does not count as a completed grind pass, and the worker gets another iteration unless `max-judge-failures` is reached.
If the gate fails, read `.loop/guard-results.md` on the next iteration and repair exactly that failure before pushing more complexity reduction.
## Rules
- No git operations. Do not commit, push, branch, reset, stash, or modify git config.
- Do not modify `.loop/protocol.md`, `.loop/yoke.conf`, `.loop/grind-gate.py`, or `.loop/cstat-baseline.json`.
- Do not modify `.loop/protocol.md`, `.loop/yoke.conf`, `.loop/judge.md`, `.loop/grind-gate.py`, or `.loop/cstat-baseline.json`.
- Do not weaken, delete, skip, or rewrite behavior checks to make the gate pass.
- Do not edit behavior-defining tests, benches, benchmarks, examples, snapshots, or fixtures unless the user explicitly made behavior change part of the task. If the generated `yoke.conf` has no-modify rules commented out, infer the freeze rule from this protocol.
- Prefer deletion, simplification, and surface-area reduction over new abstractions.
- Push aggressively within the iteration; the default failure mode to avoid is timid under-cleanup.
- Keep `.loop/notes.md` as the primary index and use `.loop/archive/<name>.md` for detailed history. If a cleanup or failure repeats, read the archive and try a different approach instead of replaying the same edit.

View file

@ -8,6 +8,10 @@
max-tail 200
# Number of successful grind gate passes before yoke exits. STATUS in
# notes.md is ignored in grind mode; a pass is boundary PASS + grind gate PASS.
grind-passes 1
# Scope defaults: allow the repository by default because Yoke cannot know
# each project's oracle layout. Uncomment and tune these after identifying
# behavior-defining files for the target repository. Most-specific prefix wins.
@ -25,3 +29,10 @@ allow .
# GRIND_BENCH="cargo bench --no-run" # default: empty / disabled
# GRIND_CSTAT="cstat --path . --json scorecard"
guard python3 .loop/grind-gate.py
# Optional blind judge cadence. Uncomment to fold independent review into
# grind runs. A failed cadenced judge writes .loop/verdict.md and forces
# another worker iteration; repeated failures bail out at max-judge-failures.
#
# judge-every 3
# max-judge-failures 3

View file

@ -8,7 +8,8 @@ You are operating inside an automated loop — not a conversation. A bash script
|------|---------|---------|
| `.loop/protocol.md` | read | This document. Your instructions. |
| `.loop/plan.md` | read | The feature plan. Stages to implement. |
| `.loop/notes.md` | read + write | Your scratchpad. Persists across iterations. |
| `.loop/notes.md` | read + write | Primary memory across iterations. Keep it concise and stage-indexed. |
| `.loop/archive/<name>.md` | read + write | Detailed secondary memory for a stage or investigation. Use a stable slug such as `stage-2-cache.md`. |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Loop configuration. Scope rules, guards, settings. |
@ -17,16 +18,17 @@ All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory across iterations — check which stage you are on, what you tried, and what you learned.
3. **Read guard results** (`.loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage.
4. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan.
5. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree.
6. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
7. **Exit**. Stop. Do not loop — the outer script handles iteration.
2. **Read your primary notes** (`.loop/notes.md`). This is the first reference on every stage entry — check current stage, completed stages, repeated failures, and archive pointers.
3. **Read the stage archive** (`.loop/archive/<name>.md`) for the stage or problem you are about to work on, if it exists. Use a stable lowercase slug from the stage number/title (for example `stage-2-parser.md`).
4. **Read guard results** (`.loop/guard-results.md`). If it exists and is non-empty, the previous iteration's guards ran. Look for failures. If a guard failed, your priority is fixing the failure before advancing to a new stage.
5. **Determine task**. Either fix a guard failure (if any) or implement the next incomplete stage from the plan.
6. **Implement**. Make the code changes for exactly one stage. Work in the repository's working tree.
7. **Update memory**:
- Put a `STATUS` line at the **top** of `.loop/notes.md` (see below).
- Keep `.loop/notes.md` as the primary stage index: current/completed stage, short outcome, guard/judge failure summary, and links to relevant archive files.
- Append detailed attempts, commands run, dead ends, hypotheses, and next approaches to the stage archive under `.loop/archive/`.
- If you failed on the same issue again, record what was tried so the next iteration does not repeat it.
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
@ -40,8 +42,9 @@ The outer loop reads this line. It exits only when `STATUS: DONE` **and** all gu
## Session Continuity
Yoke resumes the same worker OMP session across iterations. `.loop/notes.md`,
`.loop/plan.md`, `.loop/protocol.md`, and `.loop/guard-results.md` also live on
disk, so re-read them every iteration instead of trusting stale context.
`.loop/archive/`, `.loop/plan.md`, `.loop/protocol.md`, and
`.loop/guard-results.md` also live on disk, so re-read them every iteration
instead of trusting stale context.
## What the Guards Check
@ -66,6 +69,6 @@ You may run any commands you find useful during implementation.
- **No git operations.** Do not commit, push, branch, or modify git config. The outer loop owns git.
- **Do not modify `protocol.md`, `plan.md`, or `yoke.conf`.** These are read-only to you.
- **One stage per iteration.** Implement a single stage, update notes, and exit. Do not attempt multiple stages.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check your notes), try a fundamentally different approach. Do not repeat the same fix.
- **Be concise in notes.** Future-you needs signal, not noise. Record what matters: what stage, what changed, what broke, what to try next.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check `.loop/notes.md` and the relevant archive file), try a fundamentally different approach. Do not repeat the same fix.
- **Memory discipline.** Keep `.loop/notes.md` concise and stage-indexed. Put verbose reasoning, failed commands, dead ends, and alternative approaches in `.loop/archive/<name>.md`.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -14,7 +14,8 @@ one at a time until all stages are done and guards pass.
| `.loop/protocol.md` | read | These instructions. |
| `.loop/sub-plan.md` | read | The sub-plan with stages to implement. |
| `.loop/judge.md` | read | What the judge will test. Study this — knowing the test helps you pass it. |
| `.loop/notes.md` | read+write | Your scratchpad across iterations. |
| `.loop/notes.md` | read+write | Primary memory across iterations. Keep it concise and stage-indexed. |
| `.loop/archive/<name>.md` | read+write | Detailed secondary memory for a sub-plan stage, judge failure, or investigation. Use a stable slug such as `stage-2-cache.md`. |
| `.loop/verdict.md` | read | The judge's last verdict (from previous brute attempt). |
| `.loop/guard-results.md` | read | Guard results from the last iteration. |
| `.loop/yoke.conf` | read | Configuration. Scope rules, guards, settings. |
@ -24,17 +25,18 @@ All paths are relative to the repository root.
## Per-Iteration Steps
1. **Read the plan** (`.loop/sub-plan.md`). Understand the full feature and all its stages.
2. **Read your notes** (`.loop/notes.md`). This is your memory — check which stage you are on, what you tried, and what you learned.
3. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
4. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
5. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
6. **Implement**. Make the code changes for exactly one stage.
7. **Update notes**. Write to `.loop/notes.md`:
- Which stage you just worked on
- What you changed and why
- Any issues or observations for your future self
- A `STATUS` line at the **top** of the file (see below)
8. **Exit**. Stop. Do not loop — the outer script handles iteration.
2. **Read your primary notes** (`.loop/notes.md`). This is the first reference on every stage entry — check current stage, completed stages, repeated failures, judge feedback summaries, and archive pointers.
3. **Read the stage archive** (`.loop/archive/<name>.md`) for the stage or problem you are about to work on, if it exists. Use a stable lowercase slug from the stage number/title or failure (for example `stage-2-parser.md`).
4. **Read the verdict** (`.loop/verdict.md`). If the judge previously failed your work, this contains their exact complaints. Fix what they say is broken before advancing.
5. **Read guard results** (`.loop/guard-results.md`). If non-empty, the previous iteration's guards ran. If a guard failed, fix it before advancing.
6. **Determine task**. Either fix a guard/judge failure or implement the next incomplete stage.
7. **Implement**. Make the code changes for exactly one stage.
8. **Update memory**:
- Put a `STATUS` line at the **top** of `.loop/notes.md` (see below).
- Keep `.loop/notes.md` as the primary stage index: current/completed stage, short outcome, guard/judge failure summary, and links to relevant archive files.
- Append detailed attempts, commands run, dead ends, hypotheses, judge-failure analysis, and next approaches to the stage archive under `.loop/archive/`.
- If you failed on the same issue again, record what was tried so the next iteration does not repeat it.
9. **Exit**. Stop. Do not loop — the outer script handles iteration.
## STATUS Signaling
@ -43,6 +45,13 @@ 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.
## Session Continuity
Yoke resumes the same worker OMP session across worker iterations.
`.loop/notes.md`, `.loop/archive/`, `.loop/sub-plan.md`, `.loop/protocol.md`,
`.loop/verdict.md`, and `.loop/guard-results.md` also live on disk, so
re-read them every iteration instead of trusting stale context.
## What Happens After You Exit
1. Guards run (diff boundary check + configured guard commands).
@ -57,6 +66,6 @@ The first line of `.loop/notes.md` must be one of:
- **One stage per iteration.** Implement a single stage, update notes, and exit.
- **Study judge.md.** Knowing the test helps you pass it.
- **The judge's feedback is ground truth.** Fix what they say is broken.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations, try a fundamentally different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Retry discipline.** If you have failed on the same issue for 3 consecutive iterations (check `.loop/notes.md` and the relevant archive file), try a fundamentally different approach.
- **Memory discipline.** Keep `.loop/notes.md` concise and stage-indexed. Put verbose reasoning, failed commands, dead ends, judge-failure analysis, and alternative approaches in `.loop/archive/<name>.md`.
- **Do not waste time.** Set sane timeouts and do not lets tests run indefinitely. Do not run the full test suite before exiting, if the guard check is going to do that anyway.

View file

@ -10,7 +10,8 @@ sub-plans and feed them one at a time to an inner brute loop (Agent 2 + Agent 3)
|---|---|---|
| `.loop/saga-protocol.md` | read | These instructions. |
| `.loop/specification.md` | read | The full feature specification. User-authored, read-only. |
| `.loop/saga-notes.md` | read+write | Your memory across saga cycles. |
| `.loop/saga-notes.md` | read+write | Primary memory across saga cycles. Keep it concise and chunk-indexed. |
| `.loop/archive/<name>.md` | read+write | Detailed secondary memory for scoping, bailouts, or cross-cycle investigations. Use a stable slug such as `scope-auth.md`. |
| `.loop/decisions.md` | read+write | Implementation decisions not covered by the spec. |
| `.loop/sub-plan.md` | write | The sub-plan for the next brute cycle. Overwritten each cycle. |
| `.loop/notes.md` | read | The implementer's notes from the last brute cycle. |
@ -21,21 +22,21 @@ All paths are relative to the repository root.
## Per-Cycle Steps
1. **Read the specification** (`.loop/specification.md`). Understand the full feature.
2. **Read your notes** (`.loop/saga-notes.md`). Check what you have already scoped, what was completed, and what remains.
3. **Read the implementer's notes** (`.loop/notes.md`). Understand what the last brute cycle accomplished or struggled with.
4. **Read the verdict** (`.loop/verdict.md`). If the last sub-plan was judged, check whether it passed or failed. If the brute loop bailed out (3 consecutive judge failures), understand what went wrong.
5. **Determine the next chunk**. Based on the spec, your notes, and the last cycle's outcome:
2. **Read your primary notes** (`.loop/saga-notes.md`). Use this as the first reference for cycle entry: scoped chunks, completed chunks, repeated failures, and archive pointers.
3. **Read the relevant archive** (`.loop/archive/<name>.md`) for the chunk, bailout, or investigation you are about to scope, if it exists. Use stable lowercase slugs so future cycles can find detailed history.
4. **Read the implementer's notes** (`.loop/notes.md`). Understand what the last brute cycle accomplished or struggled with, including any archive pointers it recorded.
5. **Read the verdict** (`.loop/verdict.md`). If the last sub-plan was judged, check whether it passed or failed. If the brute loop bailed out (3 consecutive judge failures), understand what went wrong.
6. **Determine the next chunk**. Based on the spec, your notes, the relevant archive, and the last cycle's outcome:
- If the previous sub-plan passed, scope the next logical chunk.
- If the previous sub-plan bailed out, re-scope — break the work into smaller pieces, try a different approach, or address the root cause of failure.
- If the full spec is covered, signal DONE.
6. **Write `sub-plan.md`**. Use the same `## Stage` format the plan runner expects. Each stage should be a concrete, implementable unit. The sub-plan overwrites the previous one — no archiving.
7. **Update `saga-notes.md`**. Record:
- What you scoped and why
- What has been completed so far
- What remains
- A `STATUS` line at the **top** of the file (see below)
8. **Update `decisions.md`**. If you made implementation decisions not explicitly covered by the specification, record them here. Append — do not overwrite previous decisions.
9. **Exit**. Stop. The harness handles the next step.
7. **Write `sub-plan.md`**. Use the same `## Stage` format the plan runner expects. Each stage should be a concrete, implementable unit. The sub-plan overwrites the previous one — no archiving.
8. **Update memory**:
- Put a `STATUS` line at the **top** of `.loop/saga-notes.md` (see below).
- Keep `.loop/saga-notes.md` as the concise primary chunk index: what you scoped, what passed, what remains, and links to relevant archive files.
- Append detailed scoping rationale, bailout analysis, failed decompositions, and alternative chunking approaches to `.loop/archive/<name>.md`.
9. **Update `decisions.md`**. If you made implementation decisions not explicitly covered by the specification, record them here. Append — do not overwrite previous decisions.
10. **Exit**. Stop. The harness handles the next step.
## STATUS Signaling
@ -70,4 +71,4 @@ Keep sub-plans focused. 2–5 stages per sub-plan is ideal. Smaller chunks are e
- **Do not modify `specification.md`, `saga-protocol.md`, `protocol.md`, `judge.md`, or `yoke.conf`.** These are read-only.
- **One sub-plan per cycle.** Write a single sub-plan, update your notes, and exit.
- **Re-scope on bailout.** If the brute loop bailed out, do not re-issue the same sub-plan. Break it down further or try a different approach.
- **Be concise in notes.** Future-you needs signal, not noise.
- **Memory discipline.** Keep `.loop/saga-notes.md` concise and chunk-indexed. Put verbose scoping rationale, bailout analysis, failed decompositions, and alternative chunking approaches in `.loop/archive/<name>.md`.

View file

@ -123,9 +123,15 @@ fn init_grind_creates_profile_and_captures_baseline() {
"yoke.conf",
"notes.md",
"guard-results.md",
"judge.md",
"verdict.md",
] {
assert!(loop_dir.join(name).exists(), "missing .loop/{name}");
}
assert!(
loop_dir.join("archive").is_dir(),
"missing .loop/archive/ note archive"
);
for name in ["grind.md", "plan.md"] {
assert!(!loop_dir.join(name).exists(), "unexpected .loop/{name}");
}
@ -144,6 +150,16 @@ fn init_grind_creates_profile_and_captures_baseline() {
"grind guard must be configured. yoke.conf:\n{}",
conf
);
assert!(
conf.contains("grind-passes 1"),
"grind pass count must default to one. yoke.conf:\n{}",
conf
);
assert!(
conf.contains("# judge-every 3"),
"grind config should document optional judge cadence. yoke.conf:\n{}",
conf
);
assert!(
conf.contains("# no-modify tests/"),
"tests no-modify example should be commented. yoke.conf:\n{}",
@ -163,6 +179,21 @@ fn init_grind_creates_profile_and_captures_baseline() {
"grind protocol must not reference plan.md. protocol:\n{}",
protocol
);
assert!(
protocol.contains("grind-passes"),
"grind protocol should document pass-count completion. protocol:\n{}",
protocol
);
assert!(
protocol.contains("grind mode ignores it for completion"),
"grind protocol should remove status-based completion. protocol:\n{}",
protocol
);
assert!(
protocol.contains(".loop/archive/<name>.md"),
"grind protocol should document the secondary note archive. protocol:\n{}",
protocol
);
for command in [
"loc",
"symbols",
@ -235,6 +266,120 @@ fn grind_dry_run_rejects_without_cstat_improvement() {
);
}
#[test]
fn grind_counts_gate_passes_without_status_and_runs_optional_judge() {
build_yoke();
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
seed_project(project);
let mock_bin_dir = project.join("mock-bin");
fs::create_dir(&mock_bin_dir).unwrap();
write_executable(
&mock_bin_dir.join("cstat"),
r#"#!/usr/bin/env bash
set -euo pipefail
count_file=".cstat-count"
count=0
if [ -f "$count_file" ]; then
count=$(cat "$count_file")
fi
count=$((count + 1))
printf '%s\n' "$count" > "$count_file"
if [ "$count" -eq 1 ]; then
cost=10.0
else
cost=8.0
fi
printf '{"cstat_version":"fake","score_version":"code_complexity_cost_v0","target":".","code_complexity_cost":%s,"top_contributors":[{"kind":"function","file":"src/lib.rs","function":"hot","cost":%s}]}\n' "$cost" "$cost"
"#,
);
write_executable(
&mock_bin_dir.join("omp"),
r#"#!/usr/bin/env bash
set -euo pipefail
args="$*"
if [[ "$args" == *".loop/judge.md"* ]]; then
count_file=".judge-count"
count=0
if [ -f "$count_file" ]; then
count=$(cat "$count_file")
fi
count=$((count + 1))
printf '%s\n' "$count" > "$count_file"
cat > .loop/verdict.md <<'EOF'
VERDICT: PASS
grind gate passed and cleanup remains acceptable
EOF
exit 0
fi
count_file=".worker-count"
count=0
if [ -f "$count_file" ]; then
count=$(cat "$count_file")
fi
count=$((count + 1))
printf '%s\n' "$count" > "$count_file"
if [ "$count" -gt 2 ]; then
exit 42
fi
printf 'pass %s completed without status\n' "$count" > .loop/notes.md
exit 0
"#,
);
let test_path = mock_path(&mock_bin_dir);
let init = run_yoke_init_grind(&yoke, project, &test_path);
assert!(
init.status.success(),
"init failed: {}",
String::from_utf8_lossy(&init.stderr)
);
let conf_path = project.join(".loop/yoke.conf");
let conf = fs::read_to_string(&conf_path)
.unwrap()
.replace("grind-passes 1", "grind-passes 2\njudge-every 1");
fs::write(&conf_path, conf).unwrap();
let output = Command::new(&yoke)
.args(["run"])
.current_dir(project)
.env("PATH", &test_path)
.env("GRIND_ORACLE", "true")
.output()
.expect("yoke run");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"yoke run should complete two judged grind passes. exit={:?}\nstderr:\n{}",
output.status.code(),
stderr
);
let worker_count = fs::read_to_string(project.join(".worker-count")).unwrap();
assert_eq!(
worker_count.trim(),
"2",
"grind-passes=2 should invoke exactly two workers"
);
let judge_count = fs::read_to_string(project.join(".judge-count")).unwrap();
assert_eq!(
judge_count.trim(),
"2",
"judge-every=1 should judge each successful grind pass"
);
let notes = fs::read_to_string(project.join(".loop/notes.md")).unwrap();
assert!(
!notes.starts_with("STATUS:"),
"grind completion must not depend on STATUS notes. notes:\n{}",
notes
);
}
#[test]
fn grind_restores_gate_and_baseline_before_guard() {
build_yoke();

View file

@ -27,6 +27,23 @@ fn yoke_bin() -> std::path::PathBuf {
path
}
fn project_stash_dir(home: &std::path::Path) -> std::path::PathBuf {
let stash_root = home.join(".yoke/stash");
let mut dirs: Vec<std::path::PathBuf> = fs::read_dir(&stash_root)
.unwrap_or_else(|e| panic!("stash root {} should exist: {}", stash_root.display(), e))
.map(|entry| entry.expect("stash root entry").path())
.filter(|path| path.is_dir())
.collect();
dirs.sort();
assert_eq!(
dirs.len(),
1,
"stash root should contain one project-slug directory, got: {:?}",
dirs
);
dirs.remove(0)
}
/// Set up a minimal git repo in the given directory.
fn git_init(project: &std::path::Path) {
let git = |args: &[&str]| {
@ -77,7 +94,7 @@ fn stash_roundtrip_after_extraction() {
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
let home = project.join("home");
git_init(project);
// Initialize brute mode (creates .loop/ with judge.md, etc.)
@ -92,7 +109,7 @@ fn stash_roundtrip_after_extraction() {
String::from_utf8_lossy(&out.stderr)
);
// Write distinctive content into plan.md and notes.md
// Write distinctive content into plan.md, notes.md, and the detailed archive
let loop_dir = project.join(".loop");
fs::write(
loop_dir.join("plan.md"),
@ -104,11 +121,17 @@ fn stash_roundtrip_after_extraction() {
"STATUS: IN_PROGRESS\n\nSome important notes here.\n",
)
.unwrap();
fs::write(
loop_dir.join("archive/stage-1-widget.md"),
"Tried path A; next try path B.\n",
)
.unwrap();
// Stash the current state
let out = Command::new(&yoke)
.args(["stash"])
.current_dir(project)
.env("HOME", &home)
.output()
.expect("yoke stash");
assert!(
@ -121,6 +144,7 @@ fn stash_roundtrip_after_extraction() {
let out = Command::new(&yoke)
.args(["stash", "log"])
.current_dir(project)
.env("HOME", &home)
.output()
.expect("yoke stash log");
let stderr = String::from_utf8_lossy(&out.stderr);
@ -140,14 +164,44 @@ fn stash_roundtrip_after_extraction() {
"notes.md should be gone after stash"
);
assert!(
loop_dir.join(".stash").exists(),
".stash/ should survive stash clear"
!loop_dir.join("archive/stage-1-widget.md").exists(),
"archive stage note should be gone after stash"
);
let project_stash = project_stash_dir(&home);
assert!(
project_stash.join("index").exists(),
"project-scoped stash index should exist"
);
let has_plan_backup = fs::read_dir(&project_stash)
.expect("project stash dir")
.any(|entry| entry.expect("stash entry").path().join("plan.md").exists());
assert!(
has_plan_backup,
"stashed files should live under ~/.yoke/stash/<project-slug>/<hash>/"
);
let has_archive_backup = fs::read_dir(&project_stash)
.expect("project stash dir")
.any(|entry| {
entry
.expect("stash entry")
.path()
.join("archive/stage-1-widget.md")
.exists()
});
assert!(
has_archive_backup,
"stashed archive files should live under ~/.yoke/stash/<project-slug>/<hash>/archive/"
);
assert!(
!loop_dir.join(".stash").exists(),
".loop/.stash should not be used for stash storage"
);
// Pop — should restore the stashed state with our distinctive content
let out = Command::new(&yoke)
.args(["stash", "pop"])
.current_dir(project)
.env("HOME", &home)
.output()
.expect("yoke stash pop");
assert!(
@ -163,6 +217,12 @@ fn stash_roundtrip_after_extraction() {
"plan.md should be restored after pop, got: {:?}",
plan
);
let archive = fs::read_to_string(loop_dir.join("archive/stage-1-widget.md")).unwrap();
assert!(
archive.contains("next try path B"),
"archive note should be restored after pop, got: {:?}",
archive
);
}
// ── Test 2: plan loop exits on STATUS: DONE with generalized is_status_done ──
@ -780,7 +840,7 @@ fn stash_records_correct_mode_after_extraction() {
let yoke = yoke_bin();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path();
let home = project.join("home");
git_init(project);
// Initialize brute mode
@ -802,6 +862,7 @@ fn stash_records_correct_mode_after_extraction() {
let out = Command::new(&yoke)
.args(["stash"])
.current_dir(project)
.env("HOME", &home)
.output()
.expect("yoke stash");
assert!(
@ -810,8 +871,8 @@ fn stash_records_correct_mode_after_extraction() {
String::from_utf8_lossy(&out.stderr)
);
// Read the stash index directly and verify mode=brute
let index_path = project.join(".loop/.stash/index");
// Read the project-scoped stash index directly and verify mode=brute
let index_path = project_stash_dir(&home).join("index");
assert!(
index_path.exists(),
"stash index should exist after stashing"