main
Caleb Publish 0.5.0 1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924
//! `auto`: run every program-only step in order, and stop with one clear line when the AI's judgement is needed (naming the
//! file or command it wants), when the run is complete, or when a command fails. A script alternates `auto` with the AI's
//! inputs until it says COMPLETE.
//!
//! `plan` decides the one next action from the files as they stand, so it gives the same answer whoever asks: `auto` runs it,
//! and `next` prints it. Nothing here measures or scores: each action is one of the flow's own commands, run as a child of
//! this program, so the ledger shows the same lines a hand-run flow would.
//!
//! The order fixes the known rework loop. `connect attribute` writes nets into copper.json, which makes `check mimic` and the
//! snapshot stale. Attribution therefore runs in the copper step, before anything is built on the copper, and whenever it has
//! to run again (the AI added pin names to nets.json) the plan goes back by itself: build, `check mimic`, snapshot, then on.
use crate::{attrib, checks, dspins, pinnets, flow, items, layerstate, photomat};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};

/// The one next thing to do.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
    /// A program-only command (the arguments after the binary's name), and why it is next.
    Run(Vec<String>, String),
    /// The AI's input: its name, then lines that say exactly what file or command is wanted.
    NeedAi(String, Vec<String>),
    Complete,
}

const RECORD: &str = "auto.json";
const LOG: &str = "auto-log.txt";

fn rd(f: &Path, name: &str) -> Value { items::read_json(&f.join(name)).unwrap_or(Value::Null) }
fn has(f: &Path, name: &str) -> bool { f.join(name).exists() }
fn run(cmd: &[&str], why: &str) -> Action { Action::Run(cmd.iter().map(|s| s.to_string()).collect(), why.to_string()) }
fn need(name: &str, lines: Vec<String>) -> Action { Action::NeedAi(name.to_string(), lines) }
fn fresh(f: &Path, check: &str) -> bool { flow::exists(f, &format!("checks/{check}.ok")) }
fn record(f: &Path) -> Value { let v = rd(f, RECORD); if v.is_object() { v } else { json!({}) } }
fn newer(a: &Path, b: &Path) -> bool { match (std::fs::metadata(a).and_then(|m| m.modified()), std::fs::metadata(b).and_then(|m| m.modified())) { (Ok(x), Ok(y)) => x >= y, (Ok(_), Err(_)) => true, _ => false } }

/// Remember something about what `auto` did, so the plan does not repeat it: a file's hash right after a command wrote it.
fn remember(f: &Path, key: &str, v: Value) {
    let mut r = record(f); r[key] = v;
    let _ = std::fs::write(f.join(RECORD), serde_json::to_string_pretty(&r).unwrap_or_default() + "\n");
}

fn strictly_newer(a: &Path, b: &Path) -> bool { match (std::fs::metadata(a).and_then(|m| m.modified()), std::fs::metadata(b).and_then(|m| m.modified())) { (Ok(x), Ok(y)) => x > y, _ => false } }

/// Where every part sits and which way it is turned: what the joins of pads to copper were read against. A value, a marking or a
/// pin name does not move a pad, so it is not in here.
pub fn placement_key(f: &Path) -> String {
    let doc = rd(f, "parts.json");
    let mut lines: Vec<String> = doc["parts"].as_array().into_iter().flatten().filter(|p| p["onBoard"] != json!(false))
        .map(|p| format!("{}|{}|{}|{}|{}", p["ref"].as_str().unwrap_or(""), p["side"].as_str().unwrap_or(""), p["pos_mm"], p["rot"], p["footprint"].as_str().unwrap_or(""))).collect();
    lines.sort();
    crate::confidence::sha256_hex(lines.join("\n").as_bytes())
}

/// The pads are placed by their own metal once, after the AI has completed parts.json. This holds whichever step the run is at:
/// a `check parts` passed by hand must not let the run go on with parts that were never snapped. Only for a parts.json that
/// `auto` saw start from `parts init`, so an older run is not moved.
fn unsnapped(f: &Path) -> Option<Action> {
    if !has(f, "parts.json") { return None; }
    let r = record(f);
    let sha = crate::sha256(&f.join("parts.json"));
    let untouched = r["partsUntouched"].as_str()?;
    if untouched == sha || r["partsSnapped"].as_str() == Some(sha.as_str()) { return None; }
    let doc = rd(f, "parts.json");
    let list = doc["parts"].as_array()?;
    if list.is_empty() || list.iter().any(|p| p["snap"].is_object()) { return None; }
    Some(run(&["parts", "snap"], "each footprint is placed by its own pads in the photo"))
}

/// The footprint files the parts name that are newer than the board: a footprint written or corrected after the build.
fn newer_footprints(f: &Path, pcb: &Path) -> Vec<String> {
    let cache = crate::kicad_out::cache_dir(f);
    let mut ids: Vec<String> = rd(f, "parts.json")["parts"].as_array().into_iter().flatten().filter_map(|p| p["footprint"].as_str().map(str::to_string)).collect();
    ids.sort(); ids.dedup();
    ids.into_iter().filter(|id| id.split_once(':').is_some_and(|(lib, name)| { let p = cache.join(lib).join(format!("{name}.kicad_mod")); p.is_file() && strictly_newer(&p, pcb) })).collect()
}

/// What the last build could not place (`build-left-out.json`), as the lines the AI needs: the part, the footprint, the file wanted.
fn left_out(f: &Path, pcb: &Path) -> Option<Action> {
    let p = f.join(crate::kicad_out::LEFT_OUT);
    if !p.is_file() || strictly_newer(pcb, &p) { return None; }
    let rows = rd(f, crate::kicad_out::LEFT_OUT)["leftOut"].as_array().cloned().unwrap_or_default();
    if rows.is_empty() { return None; }
    let mut lines: Vec<String> = rows.iter().map(|r| format!("{}: left out of the board: {}", r["ref"].as_str().unwrap_or("?"), r["why"].as_str().unwrap_or(""))).collect();
    lines.push("Give each part a footprint that loads: a KiCad library name in parts.json `footprint` (Library:Name), or your own file at reverse-flow/lib/fp/<Library>/<Name>.kicad_mod in the layout KiCad 8 or later saves (one token per line, as the library files beside it). Then run `auto` again: a new or changed footprint file makes the build run again.".into());
    Some(need("a footprint the build can load", lines))
}

/// Attribution, when it has to run (again): the photos' reading first, then the nets. `None` when it is up to date.
fn attribution(f: &Path) -> Option<Action> {
    if !has(f, "copper.json") || rd(f, "copper.json")["from"] != json!("islands") { return None; }
    // a part moved or turned since the copper was read: its pads are joined again where they are now
    { let (r, pk) = (record(f), placement_key(f));
      if r["placementAtTrace"].as_str().is_some_and(|k| k != pk) { return Some(run(&["copper", "trace"], "a part moved, turned or changed footprint since the copper was traced: the pads are joined to the copper again where they are now")); }
      if r["placementAtMeasure"].as_str().is_some_and(|k| k != pk) { return Some(run(&["connect", "measure"], "a part moved, turned or changed footprint since the photos were read for attribution: each pad's edge is read again")); } }
    let key = attrib::zones_key(&rd(f, "copper.json"));
    let ms = rd(f, "detect/attrib/measure.json");
    if ms.is_null() || ms["copperSha"].as_str().is_some_and(|s| s != key) || ms["stitch"].is_null() {
        return Some(run(&["connect", "measure"], "the photos are read once for attribution, on the copper as traced"));
    }
    // nets from the parts first: the pinout nets are built, or built again, before the copper is held against them
    if pinnets::stale(f) { return Some(run(&["connect", "pinout"], "nets from the parts first: every pin with a known function gets its net by rule, before the copper confirms or contradicts")); }
    // nets.json missing is no obstacle: `connect attribute` starts it empty, and it is the step that does
    if !has(f, "nets.json") { return Some(run(&["connect", "attribute"], "there is no nets.json yet: attribution starts it and names the pins that share copper")); }
    let r = record(f);
    let edited = has(f, "nets.json") && r["netsAfterAttribute"].as_str().is_some_and(|h| h != crate::sha256(&f.join("nets.json")));
    if !has(f, "detect/attribution.json") || !newer(&f.join("detect/attribution.json"), &f.join("detect/attrib/measure.json")) || edited {
        return Some(run(&["connect", "attribute"], if edited { "nets.json changed since attribution: the copper takes its nets again" } else { "every pour, track and via gets a net with graded evidence" }));
    }
    // pin names from a datasheet that came, went or changed since: the copper at those pins takes its names again
    // `check copper` recorded a face as not read (or a new trace cleared that): the nets are made again, without that face's joins or with them
    if r["notReadAtAttribute"].as_str().unwrap_or("") != crate::notread::key(f) { return Some(run(&["connect", "attribute"], "a face's copper was recorded as NOT READ, or read again: the nets are made again, and none is claimed through a face that was not read")); }
    if r["pinNamesAtAttribute"].as_str().unwrap_or("") != dspins::Names::load(f).key() { return Some(run(&["connect", "attribute"], "the datasheet pin names changed since attribution: the copper at those pins is named again")); }
    None
}

/// The one stop for datasheets, after the parts are identified and before anything is named or drawn from them: every
/// identified chip has its pins file or a reasoned "none available", and parts.json carries the pin names. `None` when it does.
fn datasheets(f: &Path) -> Option<Action> {
    let st = dspins::status(f);
    if !st.missing.is_empty() || !st.bad.is_empty() || !st.unread.is_empty() { return Some(need("datasheet pins", dspins::ask_lines(&st))); }
    if !dspins::applied(f, &st) { return Some(run(&["datasheet", "apply"], "the datasheet pin names and types go into parts.json, for the schematic and the net names")); }
    // part, then orientation, then pinout: a chip whose pinout is known and whose orientation is not gets no nets until it is settled
    let asks = pinnets::unsettled(f);
    if !asks.is_empty() { let mut lines = vec!["A pinout on a chip turned the wrong way is worse than none. For each chip below, settle which way round it sits, then run `auto` again: it builds the nets from the pinouts by itself.".to_string()]; lines.extend(asks); return Some(need("orientation of chips with a pinout", lines)); }
    None
}

/// The audits a review pass is waiting for, as exact instructions; or a program step that has to come first.
fn audits(scores: &Value, drawn: &[String]) -> Result<Vec<String>, Action> {
    let mut lines = vec![];
    for (cat, c) in scores["categories"].as_object().into_iter().flatten() {
        // a sample drawn since these scores were computed is judged by the next pass, not drawn again
        if c["heldByAudit"] != json!(true) || drawn.contains(cat) { continue; }
        let a = &c["audit"];
        let status = a["status"].as_str().unwrap_or("");
        match status {
            "void" | "stale" => return Err(run(&["audit", "draw", "--category", cat], &format!("the {cat} sample is {status}: a new one is drawn"))),
            s if s.starts_with("scored") => {
                let n = a["strata"].as_array().into_iter().flatten().filter_map(|r| r["needForTarget"].as_u64()).max().unwrap_or(0);
                let have = a["strata"].as_array().into_iter().flatten().filter_map(|r| r["audited"].as_u64()).min().unwrap_or(0);
                if n > have { return Err(run(&["audit", "draw", "--category", cat, "--n", &n.to_string()], &format!("the {cat} sample is too small to reach the target: it is extended to {n} per stratum"))); }
                return Err(run(&["audit", "draw", "--category", cat], &format!("items of the {cat} sample changed since they were judged: a new sample is drawn")));
            }
            "tampered" => lines.push(format!("{cat}: the sample files were changed by hand ({}): restore reverse-flow/audits/{cat}/ from the ledger, then `audit draw --category {cat}`", a["detail"].as_str().unwrap_or(""))),
            _ => lines.push(format!("{cat}: {} of {} verdicts. An auditor who did not produce this run (another AI thread or a person) judges every crop in reverse-flow/audits/{cat}/sheet.md and sheet-*.jpg, then: audit verdict --category {cat} --sample {} --file <answers.json> --who <auditor>   (answers.json: [{{\"item\": \"a001\", \"verdict\": \"ok|wrong|cannot_tell\", \"note\": \"\"}}, ...])", a["verdicts"], a["items"], a["sample"].as_str().unwrap_or(""))),
        }
    }
    Ok(lines)
}

/// After the last step: the run video. Built once per report; a build that failed on these files is not tried again,
/// because a missing video never holds a run up.
fn video(f: &Path) -> Action {
    // the bench instruction set is a deliverable of every run (the optional passback): written once, before the video.
    // A failure is remembered and never holds the run up.
    if !f.join("bench/plan.md").exists() && record(f)["benchPlanFailed"].is_null() { return run(&["bench", "plan"], "the instruction set for a person with the real board (the optional passback)"); }
    if crate::video::fresh(f) || record(f)["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str()) { return Action::Complete; }
    run(&["video", "build"], "the two minute video of the run, made from its files")
}

/// What `next` and `steps` say about the video once every step is done: nothing while it is still to build or built, the
/// reason when the last build failed.
pub fn video_note(f: &Path) -> Vec<String> {
    let r = record(f);
    if crate::video::fresh(f) { return vec![format!("the run video: {}", crate::video::record(f)["file"].as_str().unwrap_or("reverse-flow/video/"))]; }
    if r["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str()) { return vec![format!("the run video was not built: {}. The run is complete without it; `video build` tries again", r["videoError"].as_str().unwrap_or("see reverse-flow/video/build-log.txt"))]; }
    vec![]
}

/// What comes next, from the files alone.
pub fn plan(f: &Path, fl: &flow::Flow) -> Action {
    let Some(step) = flow::current(fl, f) else { return video(f) };
    // nets from the parts first, wherever the run stands: a run past attribution (waiting for audits, at the tests or the report) whose pin
    // tables, orientations or copper are newer than its pinout nets, or that never had any, gets them built before anything else is done
    if !matches!(step.name.as_str(), "intake" | "scale" | "parts" | "blocks" | "copper" | "mimic" | "snapshot" | "connect") && pinnets::stale(f) {
        return run(&["connect", "pinout"], "the pinout nets are older than the pin tables, the orientations or the copper they are built from (or were never built): they are built first, and the flow goes back to hold the copper against them");
    }
    let state = rd(f, "state.json");
    let kdir = f.parent().map(|p| p.join("kicad")).unwrap_or_else(|| PathBuf::from("kicad"));
    let pcb = state["kicad"]["pcb"].as_str().map(PathBuf::from);
    // the faces were stitched again (`stitch --top <photo>`) after the photos were measured: what was measured is of the old faces
    // Told by what the files hold, not by their times (a copied run folder has new times): the stitch record `prep` last ran on, or the one `check prep` passed on
    if !crate::intake::pending(&state) && has(f, "prep.json") && has(f, "stitch.json") {
        let moved = record(f)["stitchAtPrep"].as_str().is_some_and(|h| h != crate::sha256(&f.join("stitch.json"))) || flow::stale(f, "checks/prep.ok").iter().any(|x| x == "stitch.json");
        if moved { return run(&["prep"], "the faces were stitched again after the photos were measured: every photo is measured again"); }
    }
    if !matches!(step.name.as_str(), "intake" | "scale" | "parts") { if let Some(a) = unsnapped(f) { return a; } }
    match step.name.as_str() {
        "intake" => {
            // an unsorted set of photos (`init --photos`) is sorted and stitched before anything is measured
            if crate::intake::pending(&state) { return run(&["stitch"], "the photos are sorted into faces, tiles and overviews and stitched into one image per face"); }
            if !has(f, "prep.json") { return run(&["prep"], "every photo is measured"); }
            run(&["check", "prep"], "the photos' measurements are checked")
        }
        "scale" => need("scale", vec![
            "Pick one part body whose size a datasheet gives (body ids are drawn on reverse-flow/evidence/prep-<top photo>.jpg) and run: scale --body <id>=<length>x<width> --source \"<datasheet, package drawing>\"".into(),
            "A board with no rows of holes and four mounting holes at a spacing a document gives: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\" [--flip x|y]".into(),
        ]),
        "parts" => {
            if !has(f, "detect/parts.json") {
                let bottom = image::open(f.join("board/bottom.jpg")).ok().map(|i| photomat::carries_parts(&i.to_rgb8())).unwrap_or(false);
                return if bottom { run(&["detect", "parts", "--bottom"], "part candidates on both faces: the bottom photo shows parts") } else { run(&["detect", "parts"], "part candidates on the top face: the bottom photo shows no parts") };
            }
            if !has(f, "parts.json") { return run(&["parts", "init"], "parts.json from the candidates"); }
            let r = record(f);
            let sha = crate::sha256(&f.join("parts.json"));
            if r["partsUntouched"].as_str() == Some(sha.as_str()) {
                return need("parts.json", vec![
                    "Read every crop in reverse-flow/evidence/parts/ (contact sheets: evidence/parts-sheet-*.jpg, map: evidence/parts-map.jpg) and complete reverse-flow/parts.json: for each part its ref, value, marking, mpn, function, the footprint where the size guess is wrong, rot, and pin1.mark for every part that fits one way; tag each field by how it is known (fieldTags).".into(),
                    "Add every candidate in `unassigned` as a part, or list it in `dismissed` with why. `decode <code>` reads resistor markings. Then run `auto` again: it snaps every footprint to its pads once, also when you ran `check parts` yourself first. A part you add after that is snapped with `parts snap` by you.".into(),
                ]);
            }
            let doc = rd(f, "parts.json");
            let used: std::collections::HashSet<&str> = doc["parts"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str()).chain(doc["dismissed"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str())).collect();
            let loose = rd(f, "detect/parts.json")["candidates"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).any(|c| !used.contains(c));
            if loose && r["partsReconciled"].as_str() != Some(sha.as_str()) { return run(&["parts", "reconcile"], "candidates that are fragments of placed parts are dismissed"); }
            // pads are snapped once, when no part has been yet. A later edit (a value from the bench, a marking read) must not move every part again:
            // that would make the copper, the placement audit and everything built on them stale for nothing. Parts added later are snapped by hand
            let never = !doc["parts"].as_array().into_iter().flatten().any(|p| p["snap"].is_object());
            if never && r["partsSnapped"].as_str() != Some(sha.as_str()) { return run(&["parts", "snap"], "each footprint is placed by its own pads in the photo"); }
            if let Some(a) = datasheets(f) { return a; }
            run(&["check", "parts"], "parts.json is checked")
        }
        "blocks" => {
            if !has(f, "blocks.json") {
                if !has(f, "detect/blocks-proposal.json") { return run(&["blocks", "propose"], "groups of parts that sit together"); }
                return need("blocks.json", vec!["Write reverse-flow/blocks.json from reverse-flow/detect/blocks-proposal.json: {\"blocks\": [{\"id\", \"name\", \"function\", \"parts\": [refs], \"tag\", \"evidence\" or \"tests\"}]}. Name each block, say what it does, move parts between groups where silkscreen or the circuit says so; every part is in exactly one block (holes and fiducials in a `mechanical` block). Then run `auto` again.".into()]);
            }
            run(&["check", "blocks"], "blocks.json is checked")
        }
        "copper" => {
            if !has(f, "detect/domes.json") { return run(&["copper", "prep"], "gap maps and the vias read from the photos"); }
            if !has(f, "copper.json") || rd(f, "copper.json")["from"] != json!("islands") { return run(&["copper", "trace"], "every island of copper on both faces"); }
            if let Some(a) = attribution(f) { return a; }
            run(&["check", "copper"], "copper.json is checked")
        }
        "mimic" => {
            if let Some(a) = attribution(f) { return a; }
            let r = record(f);
            if !has(f, crate::photolayers::FILE) && r["layersTraceFailed"].is_null() { return run(&["layers", "trace"], "silk, pad shapes, holes and outline read from the photos, for the build to draw"); }
            let built = pcb.as_ref().is_some_and(|p| ["copper.json", "parts.json", "nets.json", "layers.json", crate::photolayers::FILE].iter().all(|x| !has(f, x) || newer(p, &f.join(x))));
            if !built { return run(&["build"], "the KiCad copy of everything visible"); }
            // a board from a build that did not yet record what it left out or could not fill: built again once, so the mimic check has the reasons
            if !has(f, crate::kicad_out::LEFT_OUT) { return run(&["build"], "the board was built before `build` recorded what it left out and which pours it could not fill: it is built again"); }
            if let Some(p) = &pcb {
                let fresh_fp = newer_footprints(f, p);
                if !fresh_fp.is_empty() { return run(&["build"], &format!("a footprint file is newer than the board ({}): the copy is built again with it", fresh_fp.join(", "))); }
                // a part the build left out is said here, with the footprint it wants, not later as "not on the board"
                if let Some(a) = left_out(f, p) { return a; }
            }
            run(&["check", "mimic"], "the copy is checked against what was measured")
        }
        "snapshot" => {
            if let Some(a) = attribution(f) { return a; }
            Action::Run(vec!["snapshot".into(), "--name".into(), "mimic".into(), "--dir".into(), kdir.display().to_string()], "the copy as it stands is saved".into())
        }
        "connect" => {
            if let Some(a) = attribution(f) { return a; }
            let l = rd(f, "layers.json");
            let routed = l["candidates"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && l["method"] != json!("stated");
            if !routed && !layerstate::current(f) { return run(&["layers", "state"], "the layer count is stated from what is known, without routing"); }
            if !fresh(f, "nets") { return run(&["check", "nets"], "nets.json is checked"); }
            // the copy carries the nets and the stated stackup: built before either changed, it is built again, so its ratsnest and
            // evidence classes are the ones the files give now and nothing an earlier route drew stays in it
            if let (Some(p), false) = (&pcb, routed) {
                let have = std::fs::read_to_string(p).map(|t| crate::kicad_in::copper_layers(&t)).unwrap_or(0) as u64;
                if l["proposed"].as_u64().is_some_and(|n| n != have) { return run(&["build"], &format!("the copy takes the stated {} layers", l["proposed"])); }
                if ["nets.json", "layers.json"].iter().any(|x| has(f, x) && !newer(p, &f.join(x))) { return run(&["build"], "the copy is older than the nets or the layer statement: it is built again with the hidden connections left unrouted"); }
            }
            if !fresh(f, "layers") { return run(&["check", "layers"], "the layer statement is checked"); }
            // the schematic follows the nets: its sheets are written again whenever parts, blocks, nets or their evidence are newer,
            // and KiCad's ERC, KiCad's netlist against nets.json and the sheet rules must pass before the review. A failure stops here
            // a run that passed the parts step before it asked for datasheets is asked here, before its chips are drawn as bare boxes
            if let Some(a) = datasheets(f) { return a; }
            let sch = state["kicad"]["sch"].as_str().map(PathBuf::from);
            let drawn = sch.as_ref().is_some_and(|s| s.is_file() && ["parts.json", "blocks.json", "nets.json", "net-evidence.json"].iter().all(|x| !has(f, x) || newer(s, &f.join(x))));
            if !drawn { return run(&["schematic"], "the schematic sheets are drawn from the parts, blocks and nets as they stand"); }
            run(&["check", "schematic"], "the schematic is checked: KiCad's ERC, its netlist against nets.json, the sheet rules")
        }
        "review" => {
            let r = record(f);
            let fid = rd(f, "fidelity.json");
            let fid_stale = fid.is_null() || fid["inputs"].as_object().into_iter().flatten().any(|(k, h)| h.as_str().is_some_and(|w| !flow::matches_hash(&f.join(k), w)));
            let at = format!("{}|{}", attrib::geometry_hash(&f.join("copper.json")), crate::sha256(&f.join("parts.json")));
            if fid_stale && pcb.is_some() && r["fidelityTriedAt"].as_str() != Some(at.as_str()) { return run(&["check", "fidelity"], "what the copy draws is scored against the photos, for the review"); }
            let review = rd(f, "review.json");
            let last = review["passes"].as_array().and_then(|p| p.iter().rev().find(|p| p["scores"].is_object()).cloned());
            let now: std::collections::BTreeMap<String, String> = checks::current_hashes(f);
            let Some(last) = last else { return run(&["review", "pass"], "the first measured pass of the review loop") };
            if r["reviewRedo"] == json!(true) {
                let note = last["note"].as_str().unwrap_or("").trim().to_string();
                if last["verdict"] == json!("STOPPED") && !note.is_empty() {
                    let max = last["maxPasses"].as_u64().unwrap_or(1).to_string();
                    return run(&["review", "pass", "--max-passes", &max, "--note", &note], "the recorded pass no longer matches the files: the review measures again and keeps its declared stop");
                }
                return run(&["review", "pass"], "the recorded pass no longer matches the files: the review measures again");
            }
            // every sample the last pass found void, stale or too small is drawn before the next pass, so one pass sees them all
            let drawn: Vec<String> = if r["drawn"]["pass"] == last["n"] { r["drawn"]["cats"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect() } else { vec![] };
            if last["verdict"] == json!("CONTINUE") { if let Err(a) = audits(&last["scores"], &drawn) { return a; } }
            let then: std::collections::BTreeMap<String, String> = serde_json::from_value(last["hashes"].clone()).unwrap_or_default();
            if then != now {
                // a declared stop (a STOPPED pass that carries its reason) holds when later steps change files: the review
                // measures again under the same limit and with the same reason, and does not start asking for audits again
                let note = last["note"].as_str().unwrap_or("").trim().to_string();
                if last["verdict"] == json!("STOPPED") && !note.is_empty() {
                    let max = last["maxPasses"].as_u64().unwrap_or(1).to_string();
                    return run(&["review", "pass", "--max-passes", &max, "--note", &note], "files changed since the declared stop: the review measures again and stops again for the same reason");
                }
                return run(&["review", "pass"], "files changed since the last pass: the review measures again");
            }
            match last["verdict"].as_str().unwrap_or("") {
                "TARGET MET" | "STALLED" | "STOPPED" => run(&["check", "review"], "the review's verdict is checked against the files"),
                _ => match audits(&last["scores"], &drawn) {
                    Err(a) => a,
                    Ok(lines) if !lines.is_empty() => need("independent audit verdicts", lines),
                    Ok(_) => run(&["review", "pass"], "no audit is outstanding: another pass shows whether anything still rises"),
                },
            }
        }
        "tests" => {
            let t = f.join("tests.json");
            let generated = t.is_file() && items::ARTIFACTS.iter().all(|a| !has(f, a) || newer(&t, &f.join(a)));
            if !generated { return run(&["tests", "generate"], "a test for every INFERRED or UNKNOWN item"); }
            run(&["check", "tests"], "tests.json is checked")
        }
        "report" => {
            // the net pictures are drawn before the report is written, so whoever writes it has them; `report` draws them again when it passes
            let drawn = f.join("nets/index.md");
            let stale = !drawn.is_file() || ["nets.json", "detect/attribution.json", "copper.json"].iter().any(|x| has(f, x) && !newer(&drawn, &f.join(x)));
            if stale && has(f, "detect/attribution.json") && record(f)["netsDrawFailedAt"].as_str() != Some(crate::sha256(&f.join("nets.json")).as_str()) { return run(&["nets", "draw"], "every net graded by its weakest link and drawn under reverse-flow/nets/, for the report"); }
            if !has(f, "report.md") {
                if !has(f, "report-tables.md") || !newer(&f.join("report-tables.md"), &f.join("checks/tests.ok")) { return run(&["report"], "the generated tables"); }
                let (s, verdict) = crate::report::current(f);
                return need("report.md", vec![
                    "Write reverse-flow/report.md around reverse-flow/report-tables.md: what the board is, the blocks, the layer statement and its evidence, both scores, copied versus inferred, the nets by level (reverse-flow/nets/index.md and confidence.jpg), the tests.".into(),
                    format!("It must carry this line word for word: {}", crate::confidence::overall_line(&s, &verdict)),
                    "`report --draft` writes a starting report.md from the tables with that line in it. Then run `auto` again.".into(),
                ].into_iter().chain(crate::notread::faces(f).iter().map(|x| format!("It must also carry this line word for word: {}", crate::notread::required(x))))
                 .chain(crate::window::size_sentence(f).filter(|_| !crate::window::cut(f).is_empty()).map(|s| format!("The photos cut the board off: where you state a size, say it is the photographed window. The tables say: {s}"))).collect());
            }
            run(&["report"], "the report is checked against the computed scores")
        }
        other => need(other, vec![step.todo.clone()]),
    }
}

/// `next` and every step hint: the exact next action.
pub fn now_lines(f: &Path, fl: &flow::Flow) -> Vec<String> {
    match plan(f, fl) {
        Action::Run(cmd, why) => vec![format!("now: `{}` ({why}). `auto` runs this and every program-only step after it", cmd.join(" "))],
        Action::NeedAi(name, lines) => { let mut v = vec![format!("now: your input is needed: {name}")]; v.extend(lines); v }
        Action::Complete => vec![],
    }
}

/// What a finished command changed that the plan should remember.
fn after(f: &Path, cmd: &[String], ok: bool) {
    let c: Vec<&str> = cmd.iter().map(String::as_str).collect();
    let parts = || json!(crate::sha256(&f.join("parts.json")));
    match (c.as_slice(), ok) {
        (["parts", "init"], true) => remember(f, "partsUntouched", parts()),
        (["parts", "reconcile"], _) => remember(f, "partsReconciled", parts()),
        (["parts", "snap"], _) => remember(f, "partsSnapped", parts()),
        (["prep"], true) => { if has(f, "stitch.json") { remember(f, "stitchAtPrep", json!(crate::sha256(&f.join("stitch.json")))); } }
        (["copper", "trace"], true) => remember(f, "placementAtTrace", json!(placement_key(f))),
        (["connect", "measure"], true) => remember(f, "placementAtMeasure", json!(placement_key(f))),
        (["connect", "attribute"], true) => { remember(f, "netsAfterAttribute", json!(crate::sha256(&f.join("nets.json")))); remember(f, "pinNamesAtAttribute", json!(dspins::Names::load(f).key())); remember(f, "notReadAtAttribute", json!(crate::notread::key(f))); }
        (["layers", "trace"], false) => remember(f, "layersTraceFailed", json!(true)),
        (["nets", "draw"], false) => remember(f, "netsDrawFailedAt", json!(crate::sha256(&f.join("nets.json")))),
        (["review", "pass"], _) | (["review"], _) => remember(f, "reviewRedo", json!(false)),
        (["audit", "draw", "--category", cat, ..], _) => {
            let n = rd(f, "review.json")["passes"].as_array().and_then(|p| p.iter().rev().find(|p| p["scores"].is_object()).map(|p| p["n"].clone())).unwrap_or(Value::Null);
            let r = record(f);
            let mut cats: Vec<Value> = if r["drawn"]["pass"] == n { r["drawn"]["cats"].as_array().cloned().unwrap_or_default() } else { vec![] };
            cats.push(json!(cat));
            remember(f, "drawn", json!({"pass": n, "cats": cats}));
        }
        (["check", "fidelity"], _) => remember(f, "fidelityTriedAt", json!(format!("{}|{}", attrib::geometry_hash(&f.join("copper.json")), crate::sha256(&f.join("parts.json"))))),
        _ => {}
    }
}

/// What the plan reads, as sizes and times: when a command passed and none of this moved, running it again cannot help.
fn fingerprint(f: &Path, pcb: Option<&Path>) -> String {
    let mut files: Vec<PathBuf> = pcb.map(|p| vec![p.to_path_buf()]).unwrap_or_default();
    let mut walk = |dir: PathBuf, depth: usize| {
        let mut stack = vec![(dir, depth)];
        while let Some((d, k)) = stack.pop() {
            for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
                let (p, name) = (e.path(), e.file_name().to_string_lossy().to_string());
                if p.is_dir() { if k > 0 { stack.push((p, k - 1)); } continue; }
                if matches!(name.as_str(), "run.jsonl" | "views.json" | "auto-log.txt" | "serve.log") || !(name.ends_with(".json") || name.ends_with(".jsonl") || name.ends_with(".ok") || name.ends_with(".md")) { continue; }
                files.push(p);
            }
        }
    };
    walk(f.to_path_buf(), 0);
    for (d, k) in [("checks", 0), ("snapshots", 1), ("detect", 1), ("audits", 1), ("bench", 0)] { walk(f.join(d), k); }
    files.sort();
    let lines: Vec<String> = files.iter().filter_map(|p| { let m = std::fs::metadata(p).ok()?; Some(format!("{}|{}|{:?}", p.display(), m.len(), m.modified().ok()?)) }).collect();
    crate::confidence::sha256_hex(lines.join("\n").as_bytes())
}

/// A step that ended without its own `ERROR:` line (killed, or dead on an allocation) still gets a reason: how it ended and
/// the last thing it printed. `None` when the step passed or said what was wrong itself.
fn died(status: &std::process::ExitStatus, text: &str) -> Option<String> {
    if status.success() || text.lines().any(|l| l.starts_with("ERROR:")) { return None; }
    #[cfg(unix)]
    let signal = { use std::os::unix::process::ExitStatusExt; status.signal() };
    #[cfg(not(unix))]
    let signal: Option<i32> = None;
    Some(died_text(status.code(), signal, text))
}

fn died_text(code: Option<i32>, signal: Option<i32>, text: &str) -> String {
    let last: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev().take(2).collect();
    let said = if last.is_empty() { "it printed nothing".to_string() } else { format!("the last it printed: {}", last.into_iter().rev().map(|l| l.chars().take(240).collect::<String>()).collect::<Vec<_>>().join(" | ")) };
    let memory = text.contains("memory allocation of") || text.to_lowercase().contains("out of memory") || text.contains("capacity overflow");
    let how = match (signal, code) {
        (Some(9), _) => "the step was killed (signal 9) before it could answer: the system or the memory guard stopped it, most often for running out of memory".to_string(),
        (Some(6), _) | (_, Some(134)) if memory => "the step ran out of memory and stopped (an allocation failed)".to_string(),
        (Some(n), _) => format!("the step was stopped by signal {n} before it could answer"),
        (_, Some(c)) if memory => format!("the step ran out of memory and stopped (an allocation failed, exit code {c})"),
        (_, Some(c)) => format!("the step ended with exit code {c} and no answer of its own (a crash in the program)"),
        _ => "the step ended with no answer of its own".to_string(),
    };
    format!("{how}; {said}")
}

/// How one `auto` ended.
pub struct End { pub state: &'static str, pub line: String, pub hints: Vec<String>, pub ran: Vec<String> }

/// Run program-only steps until the AI is needed, the run is complete, a command fails or `max` commands have run.
pub fn run_all(f: &Path, project_dir: &Path, fl: &flow::Flow, thread: &str, max: usize, say: &mut dyn FnMut(String)) -> End {
    let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("adom-aiflow-reverse"));
    let mut ran: Vec<String> = vec![];
    let mut last: Option<(Vec<String>, bool, bool)> = None;
    let mut review_redos = 0;
    let pcb_of = || rd(f, "state.json")["kicad"]["pcb"].as_str().map(PathBuf::from);
    let end = |state: &'static str, line: String, mut hints: Vec<String>, ran: &[String]| {
        // what the run carries as failed and no input of yours changes: said at every stop, so nobody waits on it or takes it as passed
        for s in crate::notread::sentences(f) { hints.push(format!("Carried as failed, not waiting for you: {s}")); }
        remember(f, "last", json!({"state": state, "line": line, "hints": hints, "ran": ran, "at": crate::ledger::now()}));
        End { state, line, hints, ran: ran.to_vec() }
    };
    loop {
        let action = plan(f, fl);
        let (cmd, why) = match action {
            Action::Complete => {
                // the video is named in the last line either way: where it is, or plainly that it was not built and why
                let failed = !crate::video::fresh(f) && record(f)["videoFailedAt"].as_str() == Some(crate::video::input_key(f).as_str());
                let line = if failed { format!("AUTO COMPLETE: every step is done; the video was NOT built: {}", record(f)["videoError"].as_str().unwrap_or("see reverse-flow/video/build-log.txt")) }
                    else { format!("AUTO COMPLETE: every step is done; video: {}", crate::video::record(f)["file"].as_str().unwrap_or("none")) };
                let mut hints = vec!["`confidence` prints both scores; `bench plan` writes the optional instructions for a person with the real board".to_string()];
                if failed { hints.push("the run is complete without the video: fix what the line names and run `video build` (reverse-flow/video/build-log.txt has the tool's output)".into()); }
                return end("complete", line, hints, &ran);
            }
            Action::NeedAi(name, lines) => return end("needs-ai", format!("AUTO NEEDS AI: {name}"), lines, &ran),
            Action::Run(cmd, why) => (cmd, why),
        };
        // the same command again after it passed and changed nothing the plan reads: stop, never loop
        if last.as_ref().is_some_and(|(c, ok, moved)| *ok && !*moved && *c == cmd) { return end("failed", format!("AUTO FAILED: `{}` passed and is still next: the flow is not moving", cmd.join(" ")), vec!["This is a defect in the flow, not in the board: report it with reverse-flow/auto-log.txt".into()], &ran); }
        if ran.len() >= max { return end("failed", format!("AUTO FAILED: {max} commands run without reaching an end"), vec![format!("next would be `{}`; run `auto` again to go on, or raise --max-steps", cmd.join(" "))], &ran); }
        // a sample is drawn once per run of `auto`: asked for again, the answer is the auditor's to give
        if cmd.first().map(String::as_str) == Some("audit") && ran.contains(&cmd.join(" ")) { return end("needs-ai", "AUTO NEEDS AI: independent audit verdicts".into(), vec![format!("`{}` was drawn and is still what the review waits for: `audit status` shows every sample, `review pass` says what each needs. Finish the current sample with an auditor who did not produce this run, then run `auto` again.", cmd.join(" "))], &ran); }
        let before = fingerprint(f, pcb_of().as_deref());
        say(format!("> {}   ({why})", cmd.join(" ")));
        let out = std::process::Command::new(&exe).arg("--ai-thread").arg(thread).arg("--project").arg(project_dir).args(&cmd).output();
        let (ok, text) = match out { Ok(o) => { let t = String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr); let died = died(&o.status, &t); (o.status.success(), match died { Some(d) => format!("{t}\nERROR: {d}\n"), None => t }) } Err(e) => (false, format!("ERROR: could not start {}: {e}", exe.display())) };
        if let Ok(mut lf) = std::fs::OpenOptions::new().create(true).append(true).open(f.join(LOG)) { use std::io::Write; let _ = writeln!(lf, "## {} > {}\n{text}", crate::ledger::now(), cmd.join(" ")); }
        // a step that passed answers OK first; one that failed is known by its ERROR line, its own or the one `died` wrote for it
        let head = text.lines().find(|l| if ok { l.starts_with("OK:") } else { l.starts_with("ERROR:") }).or_else(|| text.lines().find(|l| l.starts_with("OK:") || l.starts_with("ERROR:"))).unwrap_or("").to_string();
        let died_line = !ok && head.starts_with("ERROR: the step ");
        let hints: Vec<String> = text.lines().filter(|l| l.starts_with("Hint: ")).map(|l| l[6..].to_string()).collect();
        say(format!("  {head}"));
        ran.push(cmd.join(" "));
        after(f, &cmd, ok);
        let moved = fingerprint(f, pcb_of().as_deref()) != before;
        if !ok {
            let c: Vec<&str> = cmd.iter().map(String::as_str).collect();
            match c.as_slice() {
                // the photos' own layers and their score feed the review; the flow goes on without them and the review says so
                // the video is a deliverable, not a gate: the reason is kept for the last line and the run completes
                ["bench", "plan"] => { remember(f, "benchPlanFailed", json!(true)); say("  not a gate: the run completes without the bench plan; `bench plan` tries again".into()); last = Some((cmd, false, moved)); continue; }
                ["video", "build"] => {
                    remember(f, "videoFailedAt", json!(crate::video::input_key(f)));
                    remember(f, "videoError", json!(head.trim_start_matches("ERROR: ").trim_start_matches("the video was not built: ")));
                    say("  not a gate: the run completes without the video, and the last line says so".into());
                    last = Some((cmd, false, moved)); continue;
                }
                ["layers", "trace"] | ["check", "fidelity"] | ["nets", "draw"] => { say("  not a gate: the flow goes on, and the review counts the missing figure against the score".into()); last = Some((cmd, false, moved)); continue; }
                // the review's record no longer matches the files (a bench result came in, a score moved): one more pass measures again
                ["check", "review"] if review_redos < 2 => { review_redos += 1; remember(f, "reviewRedo", json!(true)); say("  the recorded pass no longer matches the files: the review measures again".into()); last = Some((cmd, false, moved)); continue; }
                // a holes board whose flip the holes cannot settle is settled by `scale --holes`
                ["check", "prep"] if !has(f, "scale.json") => return end("needs-ai", "AUTO NEEDS AI: scale".into(), { let mut v = hints.clone(); v.push("The photos' check waits for the scale to settle which way the board was turned over: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\" [--flip x|y], or scale --body <id>=<L>x<W> --source \"<datasheet>\". Then run `auto` again.".into()); v }, &ran),
                // the build could not be written at all: what it says is the AI's to fix
                ["build"] => return end("needs-ai", "AUTO NEEDS AI: fix what `build` lists".into(), { let mut v = vec![head.clone()]; v.extend(hints.iter().take(40).cloned()); v.push("Then run `auto` again.".into()); v }, &ran),
                // a failed check lists what to fix: that is the AI's input
                ["check", what, ..] => return end("needs-ai", format!("AUTO NEEDS AI: fix what `check {what}` lists"), { let mut v: Vec<String> = hints.iter().take(40).cloned().collect(); if hints.len() > 40 { v.push(format!("and {} more: run `check {what}`", hints.len() - 40)); } v.push("Then run `auto` again.".into()); v }, &ran),
                ["report"] => return end("needs-ai", "AUTO NEEDS AI: report.md".into(), { let mut v = hints.clone(); v.push("Correct reverse-flow/report.md, then run `auto` again.".into()); v }, &ran),
                // a sample that cannot be drawn on this board is recorded as such: the category stays at its cap and the next pass no longer waits for it
                ["audit", "draw", ..] if head.contains("planted claims") => { say("  no sample can be drawn on this board: the category stays at its cap, and the review goes on without this audit".into()); last = Some((cmd, false, moved)); continue; }
                ["audit", "draw", ..] => return end("needs-ai", "AUTO NEEDS AI: independent audit verdicts".into(), { let mut v = vec![head.clone()]; v.extend(hints.clone()); v.push("`audit status` shows every sample; finish the current one, then run `auto` again.".into()); v }, &ran),
                _ => return end("failed", format!("AUTO FAILED: `{}`: {}", cmd.join(" "), head.trim_start_matches("ERROR: ")), { let mut v = hints; if head.contains("memory") { v.push("This step needs more memory than it was allowed: run it with nothing else running, or raise the limit it runs under. If it still fails, this is a defect in the flow, not in the board: report it with reverse-flow/auto-log.txt".into()); } else if died_line { v.push("The step did not end by itself: this is a defect in the flow, not in the board. Report it with reverse-flow/auto-log.txt".into()); } v }, &ran),
            }
        }
        last = Some((cmd, ok, moved));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dir(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("auto-test-{name}-{}", std::process::id())).join("reverse-flow");
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("checks")).unwrap();
        std::fs::create_dir_all(d.join("detect")).unwrap();
        d
    }
    fn put(d: &Path, f: &str, v: Value) { if let Some(p) = d.join(f).parent() { std::fs::create_dir_all(p).unwrap(); } std::fs::write(d.join(f), v.to_string()).unwrap(); }
    fn pass(d: &Path, check: &str, files: &[&str]) {
        let m: serde_json::Map<String, Value> = files.iter().map(|f| (f.to_string(), json!(if check == "copper" && *f == "copper.json" { attrib::geometry_hash(&d.join(f)) } else { crate::sha256(&d.join(f)) }))).collect();
        put(d, &format!("checks/{check}.ok"), json!({"check": check, "files": m}));
    }
    fn cmd(a: &Action) -> String { match a { Action::Run(c, _) => c.join(" "), Action::NeedAi(n, _) => format!("AI: {n}"), Action::Complete => "complete".into() } }

    #[test]
    fn the_plan_walks_the_early_steps_and_names_each_input_the_ai_owes() {
        let d = dir("early"); let fl = flow::load();
        put(&d, "state.json", json!({"photos": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "prep");
        put(&d, "prep.json", json!({"photos": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "check prep");
        pass(&d, "prep", &["prep.json"]);
        // the scale takes a judgement: which body, from which datasheet
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: scale");
        if let Action::NeedAi(_, lines) = &a { assert!(lines.iter().any(|l| l.contains("scale --body")) && lines.iter().any(|l| l.contains("scale --holes"))); }
        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
        assert_eq!(cmd(&plan(&d, &fl)), "detect parts");
        put(&d, "detect/parts.json", json!({"candidates": [{"id": "P001"}, {"id": "P002"}]}));
        assert_eq!(cmd(&plan(&d, &fl)), "parts init");
        // parts.json exactly as `parts init` left it: the crops have not been read yet
        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402"}], "unassigned": [{"candidate": "P002"}], "dismissed": []}));
        after(&d, &["parts".into(), "init".into()], true);
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: parts.json");
        if let Action::NeedAi(_, lines) = &a { assert!(lines[0].contains("reverse-flow/parts.json")); }
        // once edited, the program goes on: fragments, pads, the check
        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up"}], "unassigned": [{"candidate": "P002"}], "dismissed": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "parts reconcile");
        after(&d, &["parts".into(), "reconcile".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "parts snap");
        after(&d, &["parts".into(), "snap".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "check parts");
        // a later edit to a snapped part (a value from the bench) does not move the parts again
        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up", "snap": {"method": "pads"}, "value": "10k"}, {"ref": "R2", "footprint": "R_0402"}], "unassigned": [], "dismissed": [{"candidate": "P002", "why": "silk"}]}));
        assert_eq!(cmd(&plan(&d, &fl)), "check parts");
        pass(&d, "parts", &["parts.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "blocks propose");
        put(&d, "detect/blocks-proposal.json", json!({"groups": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "AI: blocks.json");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn the_plan_stops_once_for_datasheet_pins_and_goes_on_with_a_sheet_or_a_reasoned_no() {
        let d = dir("datasheets"); let fl = flow::load();
        put(&d, "state.json", json!({"photos": []}));
        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
        put(&d, "detect/parts.json", json!({"candidates": []}));
        // two chips whose markings were read, one that was not, a resistor: snapped already, so the check would be next
        put(&d, "parts.json", json!({"parts": [
            {"ref": "U1", "value": "XY1234", "marking": "XY1234", "fieldTags": {"value": "SEEN"}, "snap": {"method": "pads"}, "pin1": {"mark": "dot at the top left corner", "tag": "SEEN"}},
            {"ref": "U2", "value": "LM000", "marking": "LM000", "fieldTags": {"value": "SEEN"}},
            {"ref": "U3", "value": "regulator", "marking": "unreadable", "fieldTags": {"value": "UNKNOWN"}},
            {"ref": "R1", "value": "10k", "marking": "103", "fieldTags": {"value": "SEEN"}}], "unassigned": [], "dismissed": []}));
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: datasheet pins");
        if let Action::NeedAi(_, lines) = &a {
            assert!(lines[0].contains("reverse-flow/datasheets/<ref>.pins.json") && lines[1].contains("\"available\": false"));
            assert!(lines.iter().any(|l| l.starts_with("U1: XY1234")) && lines.iter().any(|l| l.starts_with("U2: LM000")));
            assert!(!lines.iter().any(|l| l.starts_with("U3") || l.starts_with("R1")), "a chip nobody read and a resistor are not asked for");
        }
        // one answered "none available" with a reason: the other still waits
        put(&d, "datasheets/U2.pins.json", json!({"part": "U2", "available": false, "reason": "the top mark fits three parts"}));
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: datasheet pins");
        if let Action::NeedAi(_, lines) = &a { assert!(!lines.iter().any(|l| l.starts_with("U2"))); }
        // a file that does not pass is the AI's to fix, with the reason
        put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234"}, "pins": [{"pad": "1", "name": "VIN", "type": "power_in"}]}));
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: datasheet pins");
        if let Action::NeedAi(_, lines) = &a { assert!(lines.iter().any(|l| l.contains("document.revision"))); }
        // a sheet that passes: the program writes the pins into parts.json, then goes on to the check, and does not stop again
        put(&d, "datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234", "revision": "B", "page": 3}, "pins": [{"pad": "1", "name": "VIN", "type": "power_in"}, {"pad": "2", "name": "GND", "type": "ground"}]}));
        assert_eq!(cmd(&plan(&d, &fl)), "datasheet apply");
        dspins::apply(&d).unwrap();
        assert_eq!(items::read_json(&d.join("parts.json")).unwrap()["parts"][0]["pins"]["1"]["name"], "VIN");
        assert_eq!(cmd(&plan(&d, &fl)), "check parts");
        // part, then orientation, then pinout: a chip with a pinout and an inferred orientation is asked about, by name, before anything is built on it
        let mut pj = items::read_json(&d.join("parts.json")).unwrap();
        pj["parts"][0]["pin1"] = json!({"mark": "taken from the reading direction of the marking", "tag": "INFERRED"});
        put(&d, "parts.json", pj.clone());
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: orientation of chips with a pinout");
        if let Action::NeedAi(_, lines) = &a { assert!(lines.len() == 2 && lines[1].starts_with("U1 (package") && lines[1].contains("XY1234") && lines[1].contains("pin1: tagged INFERRED") && lines[1].contains("Look for a dot")); }
        // settled, or said to be not determinable: the plan goes on either way
        pj["parts"][0]["pin1"] = json!({"mark": "looked for a dot, a notch and a bevel: none", "tag": "UNKNOWN"});
        put(&d, "parts.json", pj.clone());
        assert_eq!(cmd(&plan(&d, &fl)), "check parts");
        pj["parts"][0]["pin1"] = json!({"mark": "dot at the top left corner", "tag": "SEEN"});
        put(&d, "parts.json", pj);
        assert_eq!(cmd(&plan(&d, &fl)), "check parts");
        // names the sheets give that attribution has not seen send the plan back to it
        put(&d, "copper.json", json!({"from": "islands", "zones": [], "traces": [], "vias": []}));
        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&rd(&d, "copper.json")), "stitch": []}));
        put(&d, "nets.json", json!({"nets": []}));
        put(&d, "detect/attribution.json", json!({}));
        remember(&d, "netsAfterAttribute", json!(crate::sha256(&d.join("nets.json"))));
        // the nets are built from the pinout first, then the copper is held against them
        assert_eq!(attribution(&d).map(|a| cmd(&a)).as_deref(), Some("connect pinout"));
        let s = pinnets::build(&d).unwrap();
        assert_eq!((s["pinoutNets"].as_u64(), s["pinoutNetsDatasheet"].as_u64()), (Some(2), Some(2)));
        assert_eq!(attribution(&d).map(|a| cmd(&a)).as_deref(), Some("connect attribute"));
        after(&d, &["connect".into(), "attribute".into()], true);
        assert!(attribution(&d).is_none());
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn attribution_comes_before_the_copper_check_and_an_edit_to_the_nets_sends_the_plan_back_by_itself() {
        let d = dir("order"); let fl = flow::load();
        put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string()}}));
        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
        put(&d, "parts.json", json!({"parts": []})); pass(&d, "parts", &["parts.json"]);
        put(&d, "blocks.json", json!({"blocks": []})); pass(&d, "blocks", &["blocks.json", "parts.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "copper prep");
        put(&d, "detect/domes.json", json!({"domes": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "copper trace");
        let copper = json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "F.Cu", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}], "traces": [], "vias": []});
        put(&d, "copper.json", copper.clone());
        // no net named by anyone and no nets.json: the photos are read for attribution, then attribution starts the nets itself
        assert_eq!(cmd(&plan(&d, &fl)), "connect measure");
        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&copper), "stitch": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
        put(&d, "nets.json", json!({"nets": []}));
        put(&d, "detect/attribution.json", json!({}));
        after(&d, &["connect".into(), "attribute".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "check copper");
        pass(&d, "copper", &["copper.json"]);
        // built, checked and saved
        put(&d, crate::photolayers::FILE, json!({}));
        assert_eq!(cmd(&plan(&d, &fl)), "build");
        std::fs::write(d.join("x.kicad_pcb"), "(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();
        // a board with no record of what its build left out is from an older build: built again once
        assert_eq!(cmd(&plan(&d, &fl)), "build");
        put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": [], "noFill": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "check mimic");
        pass(&d, "mimic", &["parts.json", "copper.json"]);
        assert!(cmd(&plan(&d, &fl)).starts_with("snapshot --name mimic --dir "));
        put(&d, "snapshots/mimic/manifest.json", json!({"inputs": {"copper.json": crate::sha256(&d.join("copper.json")), "parts.json": crate::sha256(&d.join("parts.json"))}}));
        // the connect step never asks for the router
        assert_eq!(cmd(&plan(&d, &fl)), "layers state");
        assert!(!fl.steps.iter().find(|s| s.name == "connect").unwrap().done.iter().any(|x| x.contains("route")));
        // nets.json gone missing: attribution is the step that writes it, not a check that would only fail on it
        std::fs::remove_file(d.join("nets.json")).unwrap();
        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
        put(&d, "nets.json", json!({"nets": []}));
        after(&d, &["connect".into(), "attribute".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "layers state");
        // the AI names pins from a datasheet: attribution runs again, which rewrites the nets in copper.json
        put(&d, "nets.json", json!({"nets": [{"name": "VCC", "pins": ["U1.1"], "tag": "DATASHEET", "source": "datasheet p.3"}]}));
        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
        let mut named = copper.clone(); named["zones"][0]["net"] = json!("VCC");
        put(&d, "copper.json", named);
        put(&d, "detect/attribution.json", json!({}));
        after(&d, &["connect".into(), "attribute".into()], true);
        // the copper check reads shapes and stays; what was built on the nets is redone without anyone asking
        assert!(flow::exists(&d, "checks/copper.ok"));
        assert_eq!(cmd(&plan(&d, &fl)), "build");
        std::fs::write(d.join("x.kicad_pcb"), "(kicad_pcb (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();
        assert_eq!(cmd(&plan(&d, &fl)), "check mimic");
        pass(&d, "mimic", &["parts.json", "copper.json"]);
        assert!(cmd(&plan(&d, &fl)).starts_with("snapshot"));
        // every step up to the report done: the net pictures are drawn before the report is asked for, and again when the nets change
        put(&d, "layers.json", json!({"candidates": [{"layers": 2}], "method": "routed"}));
        for (c, files) in [("nets", vec!["nets.json", "parts.json"]), ("layers", vec!["layers.json", "nets.json", "copper.json"]), ("review", vec!["review.json"]), ("tests", vec!["tests.json"])] {
            for x in &files { if !d.join(x).is_file() { put(&d, x, json!({})); } }
            pass(&d, c, &files);
        }
        put(&d, "snapshots/mimic/manifest.json", json!({"inputs": {"copper.json": crate::sha256(&d.join("copper.json")), "parts.json": crate::sha256(&d.join("parts.json"))}}));
        // the schematic belongs to the connect step: drawn once the nets and the layer statement are checked, then checked by itself
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("connect"));
        assert_eq!(cmd(&plan(&d, &fl)), "schematic");
        let sch = d.join("x.kicad_sch");
        std::fs::write(&sch, "(kicad_sch)").unwrap();
        put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string(), "sch": sch.display().to_string()}}));
        assert_eq!(cmd(&plan(&d, &fl)), "check schematic");
        pass(&d, "schematic", &["nets.json", "parts.json", "blocks.json"]);
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("report"));
        // parts, blocks or nets newer than the sheets: the check is stale, the flow comes back, and the sheets are drawn again first
        std::thread::sleep(std::time::Duration::from_millis(30));
        put(&d, "blocks.json", json!({"blocks": [{"id": "a"}]}));
        pass(&d, "blocks", &["blocks.json", "parts.json"]);
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("connect"));
        assert_eq!(cmd(&plan(&d, &fl)), "schematic");
        std::fs::write(&sch, "(kicad_sch )").unwrap();
        assert_eq!(cmd(&plan(&d, &fl)), "check schematic");
        pass(&d, "schematic", &["nets.json", "parts.json", "blocks.json"]);
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("report"));
        assert_eq!(cmd(&plan(&d, &fl)), "nets draw");
        std::fs::create_dir_all(d.join("nets")).unwrap(); std::fs::write(d.join("nets/index.md"), "x").unwrap();
        assert_eq!(cmd(&plan(&d, &fl)), "report");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    /// A run that has passed its early steps and has traced, attributed copper: the copper check is next.
    fn at_copper_check(name: &str) -> PathBuf {
        let d = dir(name);
        put(&d, "state.json", json!({"photos": [], "kicad": {"pcb": d.join("x.kicad_pcb").display().to_string()}}));
        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); for s in ["top", "bottom"] { std::fs::write(d.join(format!("board/{s}.jpg")), "x").unwrap(); }
        put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 0.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}}]})); pass(&d, "parts", &["parts.json"]);
        put(&d, "blocks.json", json!({"blocks": []})); pass(&d, "blocks", &["blocks.json", "parts.json"]);
        put(&d, "detect/domes.json", json!({"domes": []}));
        let copper = json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "B.Cu", "polygon_mm": [[0, 0], [1, 0], [1, 1]]}], "traces": [], "vias": []});
        put(&d, "copper.json", copper.clone());
        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&copper), "stitch": []}));
        put(&d, "nets.json", json!({"nets": []}));
        put(&d, "detect/attribution.json", json!({}));
        after(&d, &["connect".into(), "attribute".into()], true);
        d
    }

    #[test]
    fn a_face_recorded_as_not_read_sends_attribution_round_again_and_the_run_goes_on() {
        let d = at_copper_check("notread"); let fl = flow::load();
        assert_eq!(cmd(&plan(&d, &fl)), "check copper");
        // `check copper` found the bottom swallowed: it records the face and the step is done
        let (faces, _) = crate::notread::split(vec!["bottom face: island 2 holds 117 of the 148 pins joined on this face (79 percent).".into()]);
        put(&d, "checks/copper.ok", json!({"check": "copper", "files": {"copper.json": attrib::geometry_hash(&d.join("copper.json"))}, "summary": {"notRead": crate::notread::to_json(&faces)}}));
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("mimic"));
        // the nets were made with that face's joins: they are made again without them, once
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "connect attribute");
        if let Action::Run(_, why) = &a { assert!(why.contains("NOT READ")); }
        after(&d, &["connect".into(), "attribute".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "layers trace");
        // the copper traced again and passing: the record is gone with the stale check, and the nets are made with every face again
        put(&d, "copper.json", json!({"from": "islands", "zones": [{"id": "z", "net": "UNK_1", "layer": "B.Cu", "polygon_mm": [[0, 0], [2, 0], [1, 1]]}], "traces": [], "vias": []}));
        put(&d, "detect/attrib/measure.json", json!({"copperSha": attrib::zones_key(&rd(&d, "copper.json")), "stitch": []}));
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("copper"));
        assert_eq!(cmd(&plan(&d, &fl)), "connect attribute");
        after(&d, &["connect".into(), "attribute".into()], true);
        put(&d, "detect/attribution.json", json!({"again": 1}));
        assert_eq!(cmd(&plan(&d, &fl)), "check copper");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn a_part_turned_after_the_copper_was_read_has_its_joins_read_again() {
        let d = at_copper_check("turned"); let fl = flow::load();
        // `auto` traced and measured with the parts where they were
        after(&d, &["copper".into(), "trace".into()], true);
        after(&d, &["connect".into(), "measure".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "check copper");
        // a value read later moves no pad: nothing is read again
        put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 0.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}, "value": "edge connector"}]}));
        pass(&d, "parts", &["parts.json"]); pass(&d, "blocks", &["blocks.json", "parts.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "check copper");
        // the connector turned by 180 degrees (board 1's J2): its 55 pads are joined to the copper again, then read again for attribution
        put(&d, "parts.json", json!({"parts": [{"ref": "J2", "side": "top", "pos_mm": [110.0, 120.0], "rot": 180.0, "footprint": "Lib:Edge", "snap": {"method": "pads"}, "value": "edge connector"}]}));
        pass(&d, "parts", &["parts.json"]); pass(&d, "blocks", &["blocks.json", "parts.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "copper trace");
        after(&d, &["copper".into(), "trace".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "connect measure");
        after(&d, &["connect".into(), "measure".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "check copper");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn parts_are_snapped_even_when_the_parts_check_was_passed_by_hand() {
        let d = dir("handcheck"); let fl = flow::load();
        put(&d, "state.json", json!({"photos": []}));
        put(&d, "prep.json", json!({})); pass(&d, "prep", &["prep.json"]);
        put(&d, "scale.json", json!({})); std::fs::create_dir_all(d.join("board")).unwrap(); std::fs::write(d.join("board/top.jpg"), "x").unwrap();
        put(&d, "detect/parts.json", json!({"candidates": [{"id": "P001"}]}));
        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402"}], "unassigned": [], "dismissed": []}));
        after(&d, &["parts".into(), "init".into()], true);
        // the AI completes parts.json and runs `check parts` itself: the step reads done
        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up"}], "unassigned": [], "dismissed": []}));
        pass(&d, "parts", &["parts.json"]);
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("blocks"));
        // the pads are still placed first
        assert_eq!(cmd(&plan(&d, &fl)), "parts snap");
        put(&d, "parts.json", json!({"parts": [{"ref": "R1", "candidate": "P001", "footprint": "R_0402", "function": "pull-up", "snap": {"method": "pads"}}], "unassigned": [], "dismissed": []}));
        after(&d, &["parts".into(), "snap".into()], true);
        // which made the hand-run check stale: the plan is back in the parts step, and goes on from there
        assert_eq!(cmd(&plan(&d, &fl)), "check parts");
        pass(&d, "parts", &["parts.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "blocks propose");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn a_restitch_sends_the_plan_back_to_prep_before_the_scale_is_asked_for() {
        let d = dir("restitch"); let fl = flow::load();
        put(&d, "state.json", json!({"photos": [], "intake": {"stitched": true}}));
        put(&d, "stitch.json", json!({"faces": {"top": {"file": "top.jpg"}}, "top": "Board2.3.JPG"}));
        put(&d, "prep.json", json!({"photos": []}));
        after(&d, &["prep".into()], true);
        pass(&d, "prep", &["prep.json", "stitch.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "AI: scale");
        // `stitch --top <photo>`: the other face is the top now, and what was measured is of the old faces
        put(&d, "stitch.json", json!({"faces": {"top": {"file": "top.jpg"}}, "top": "Board2.7.JPG"}));
        assert_eq!(flow::current(&fl, &d).map(|s| s.name.as_str()), Some("intake"), "the prep check is stale with the stitch");
        assert_eq!(cmd(&plan(&d, &fl)), "prep");
        after(&d, &["prep".into()], true);
        assert_eq!(cmd(&plan(&d, &fl)), "prep", "a check that passed on the old stitch still says so until it is run again");
        pass(&d, "prep", &["prep.json", "stitch.json"]);
        assert_eq!(cmd(&plan(&d, &fl)), "AI: scale");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn a_part_the_build_left_out_is_asked_for_by_name_and_a_new_footprint_file_builds_again() {
        let d = at_copper_check("leftout"); let fl = flow::load();
        pass(&d, "copper", &["copper.json"]);
        put(&d, crate::photolayers::FILE, json!({}));
        assert_eq!(cmd(&plan(&d, &fl)), "build");
        let pcb = d.join("x.kicad_pcb");
        std::thread::sleep(std::time::Duration::from_millis(30));
        std::fs::write(&pcb, "(kicad_pcb)").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(30));
        // the build could not load J2's footprint and said so in its record
        let rows = crate::kicad_out::left_out_rows(&[format!("{}J2: footprint Lib:Edge not in KiCad's library ()", crate::kicad_out::LEFT_OUT_MARK)], rd(&d, "parts.json")["parts"].as_array().unwrap());
        put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": rows}));
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "AI: a footprint the build can load");
        if let Action::NeedAi(_, lines) = &a { assert!(lines[0].starts_with("J2: left out of the board") && lines[0].contains("Lib:Edge") && lines[0].contains("reverse-flow/lib/fp/Lib/Edge.kicad_mod"), "{lines:?}"); }
        // the AI writes the file: newer than the board, so the board is built again without anyone asking
        std::thread::sleep(std::time::Duration::from_millis(30));
        put(&d, "lib/fp/Lib/Edge.kicad_mod", json!("(footprint)"));
        let a = plan(&d, &fl);
        assert_eq!(cmd(&a), "build");
        if let Action::Run(_, why) = &a { assert!(why.contains("Lib:Edge")); }
        std::thread::sleep(std::time::Duration::from_millis(30));
        std::fs::write(&pcb, "(kicad_pcb )").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(30));
        put(&d, crate::kicad_out::LEFT_OUT, json!({"leftOut": []}));
        assert_eq!(cmd(&plan(&d, &fl)), "check mimic");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn a_step_that_dies_without_a_word_still_gets_a_reason() {
        // board 11 under the memory guard: `connect measure` printed the allocator's line and was gone; `auto` said "AUTO FAILED: `connect measure`: "
        let t = died_text(None, Some(6), "tracing the top face\nmemory allocation of 81460080 bytes failed\n");
        assert!(t.starts_with("the step ran out of memory and stopped") && t.contains("memory allocation of 81460080 bytes failed"), "{t}");
        let t = died_text(None, Some(9), "");
        assert!(t.contains("killed (signal 9)") && t.contains("it printed nothing"), "{t}");
        let t = died_text(Some(101), None, "thread 'main' panicked at src/x.rs:1:1:\nindex out of bounds");
        assert!(t.contains("exit code 101") && t.contains("index out of bounds"), "{t}");
    }

    #[test]
    fn the_video_follows_the_report_and_a_failed_build_does_not_hold_the_run() {
        let d = dir("video");
        // the plan after the last step, as `plan` calls it once every step is done: the bench plan first, then the video
        assert_eq!(cmd(&video(&d)), "bench plan");
        std::fs::create_dir_all(d.join("bench")).unwrap(); std::fs::write(d.join("bench/plan.md"), "x").unwrap();
        assert_eq!(cmd(&video(&d)), "video build");
        assert!(video_note(&d).is_empty());
        // it failed on these files: recorded, not tried again, and said plainly
        std::fs::write(d.join("checks/report.ok"), "{}").unwrap();
        remember(&d, "videoFailedAt", json!(crate::video::input_key(&d)));
        remember(&d, "videoError", json!("ffmpeg is not installed on this machine"));
        assert_eq!(cmd(&video(&d)), "complete");
        assert!(video_note(&d)[0].contains("was not built: ffmpeg is not installed"));
        // the report passes again on changed files: the video is tried again
        std::fs::write(d.join("checks/report.ok"), "{\"again\": 1}").unwrap();
        assert_eq!(cmd(&video(&d)), "video build");
        // built: complete, and `next` names the file
        std::fs::create_dir_all(d.join("video")).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(30));
        std::fs::write(d.join("video/x-run.mp4"), "x").unwrap();
        std::fs::write(d.join("video/video.json"), json!({"ok": true, "file": "reverse-flow/video/x-run.mp4"}).to_string()).unwrap();
        assert_eq!(cmd(&video(&d)), "complete");
        assert!(video_note(&d)[0].contains("reverse-flow/video/x-run.mp4"));
        // every step of the flow file comes before it: the video is not a step, so `steps` and the flow window keep their eleven
        assert!(!flow::load().steps.iter().any(|s| s.name == "video"));
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }

    #[test]
    fn a_review_waiting_for_audits_names_each_sheet_and_the_exact_command() {
        let d = dir("review");
        let scores = json!({"categories": {
            "copper": {"heldByAudit": true, "audit": {"status": "incomplete", "sample": "abc123", "verdicts": 3, "items": 110}},
            "vias": {"heldByAudit": true, "audit": {"status": "none", "sample": "def456", "verdicts": 0, "items": 57}},
            "layers": {"heldByAudit": false, "audit": {"status": "none"}}}});
        let lines = audits(&scores, &[]).unwrap();
        assert_eq!(lines.len(), 2);
        assert!(lines[0].contains("reverse-flow/audits/copper/sheet.md") && lines[0].contains("audit verdict --category copper --sample abc123 --file"));
        assert!(lines[1].contains("--category vias --sample def456"));
        // a voided sample is drawn again by the program before anyone is asked
        let void = json!({"categories": {"copper": {"heldByAudit": true, "audit": {"status": "void"}}}});
        assert_eq!(cmd(&audits(&void, &[]).err().unwrap()), "audit draw --category copper");
        // and one too small to reach the target is extended
        let small = json!({"categories": {"vias": {"heldByAudit": true, "audit": {"status": "scored", "strata": [{"audited": 6, "needForTarget": 35}]}}}});
        assert_eq!(cmd(&audits(&small, &[]).err().unwrap()), "audit draw --category vias --n 35");
        // once drawn, it is not drawn again before the next pass has looked
        assert!(audits(&small, &["vias".to_string()]).unwrap().is_empty());
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }
}