← All Pull Requests

adom-aiflow fusion: the whole Fusion project as commands (library, reference, schematic, reset, export) and refine hints #29

Merged Released in 0.1.51 on 6 Oct 2026 opened by John Lauer

The Fusion side of a project as commands, so a human asks for a board in Fusion in one sentence and AI Flow does the rest: adom-aiflow fusion library | reference | schematic | reset | export, plus refine hints and a remote KiCad by default.

adom-aiflow fusion (new, tools/fusion-project.py, embedded in the binary)

  • library: ONE library for the project in Fusion.
    • Each part comes from its wiki component page. The Mouser, DigiKey and LCSC numbers come from the page, then the distributor lookups, each checked against the exact MPN; a part missing one is named.
    • Symbols: the chip outline for ICs, inductors and big capacitors; KiCad's standard symbols for small passives and LEDs.
    • The etched or coloured 3D is bound.
    • Run it again with new parts and they join the same library; parts already bound are kept.
  • reference: a reference KiCad board's placement, fixed parts, outline, spec coordinates and fab rules, moved into the Fusion board's frame.
  • schematic: the design started with the fab's rules and the fixed parts, the schematic drawn live in labelled groups (one per netlist block) and checked pin by pin, the board checked to have followed, the design saved and written for start.
  • reset: for a refine pass, every track, via and pour cleared from the live board; the parts stay.
  • export: Gerbers (JLCPCB CAM job), pick and place, and the BOM with every distributor number.

Elsewhere

  • Refine hints: a failing analyze prints why: lines with the cause of each field-screen neck and the spec change that fixes it. A via whose copper ring is the narrowest copper: viaPower. Another net's track cutting a patch pour off its pin: planeUnder.
  • A remote KiCad by default: with no local kicad-cli, the gate uses the run desktop's KiCad through adom-aiflow-kicad-cli-remote, with KICAD_REMOTE_TARGET set from the run.
  • Fab rules travel with the design: start carries the .kicad_dru beside a .brd.
  • The skill (skills/adom-aiflow/SKILL.md and the root SKILL.md) now teaches the whole Fusion flow and asks the human (AskUserQuestion) before exporting. docs/fusion-lane.md lists the commands.

Proof, 2026-10-06, the buck molecule in Fusion on arav-rog, nothing cached:

  1. fusion library built 17 parts with 16 3D models in one library.
  2. fusion reference and fusion schematic drew 8 groups live, checked pin by pin, and the board followed with 34 parts.
  3. Placement, routing (0 unrouted), the gate, landing and pours all passed.
  4. analyze failed with why: naming the SW patch crossing and the VIN via ring. With both spec changes, fusion reset and a reroute, the next solve found 0 issues (51 A/mm2).
  5. finish and fusion export passed.

Needs Fusion bridge 1.11.39 (fusion_board_seed, fusion_schematic_plan, fusion_remove_pours). Tests: 74 pass.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/SKILL.md+++ b/SKILL.md@@ -1,238 +1,264 @@⋯ 32 unchanged lines ⋯  ## A board in Autodesk Fusion -Start with the board's `.brd` and `--remote-board fusion:<board document name>`: the flow converts it for its offline steps and lands every live step in Fusion through the Fusion bridge (1.11.30 or later), read back each time. `unroute --net <net>` takes a net up to route it again after an analysis. Read `docs/fusion-lane.md` before starting one.+When the human asks for a board in Fusion, the whole flow below is yours: they should never have to+spell out a step. Read `docs/fusion-lane.md` once; the Fusion bridge must be 1.11.33 or later. +1. **One project library.** `adom-aiflow fusion library --parts <netlist.json> --footprints <kicad lib folder> --name <Project>`+   builds ONE library for the project in Fusion: every part from its wiki component page, the Mouser,+   DigiKey and LCSC numbers looked up and written onto the part, the chip outline as the symbol for the+   parts a reader recognises (regulators, inductors, big capacitors) and standard symbols for passives,+   the laser-etched or coloured 3D model bound and rendering. A part added later (the human asks for+   one more LED) goes into the same library: run `fusion library` again with it. Never a second library.+2. **A reference design** (a KiCad board you are reproducing): `adom-aiflow fusion reference --kicad <board.kicad_pcb> --spec <spec.json>`+   moves its placement and every spec coordinate into the Fusion board's frame (`moves.json`,+   `fixed.json`, `spec.json`).+3. **The schematic, live.** `adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>`+   starts the design with the fab's rules and the fixed parts, draws the schematic in labelled groups+   (one per block of the netlist), checks it pin by pin, and saves the design.+4. **The board.** `start --board board.brd --remote-board fusion:<Design>`, then placement, routing,+   pours and `measure` as for any board.+5. **Current and heat, and refine until it passes.** `analyze current` / `analyze thermal` with+   adom-fields on the live board. A failing screen prints `why:` lines naming the cause and the spec+   change that fixes it (a via ring: `viaPower`; another net crossing a patch: `planeUnder`). Change the+   spec, `adom-aiflow fusion reset` (the parts stay, the copper goes), then route, gate, land, pour and+   analyze again. Repeat until both screens pass, then `finish`.+6. **Ask before exporting.** The human may not want manufacturing files yet. Ask with the+   AskUserQuestion tool ("Export the Gerbers, pick and place and the BOM now?", options "Export them" and+   "Not now"); on yes, `adom-aiflow fusion export` writes the Gerbers (the JLCPCB CAM job), the pick and+   place and the BOM with every distributor number to `<run>/manufacturing/`.+ ## Before the board: intake, sourcing, evidence  The clock starts at the prompt even when there is no board yet.⋯ 195 unchanged lines ⋯ `land pours` delegates the complete owned-zone plan to KiCad Bridge in one native transaction. Never replace another tool's zones or infer ownership from names, areas or absence in the intake board. Use Bridge 1.0.24+; no legacy delete/create fallback. An unchanged plan is not a DRC-clean certificate.  Refill acknowledgement is asynchronous. Request it once, then read without refill until two delayed revision/area snapshots agree. This proves observed stability, not a native completion event. Do not use the acknowledgement's old geometry; do not replay a timed-out or post-commit-failed mutation. Inspect the saved response, exact board and current revision. Measurement failure must remain a failed outcome. See docs/native-zone-landing.md.+--- a/crates/adom-aiflow/src/main.rs+++ b/crates/adom-aiflow/src/main.rs@@ -1,3793 +1,3854 @@⋯ 100 unchanged lines ⋯     Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },     /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)     Pour { #[arg(long)] plan: Option<String> },-    /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay-    Unroute { #[arg(long, required = true)] net: Vec<String> },-    /// KiCad DRC offline on the routing plan-    Gate { #[arg(long)] plan: Option<String> },-    /// Replay live through the bridge: route, vias, pours, moves-    Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway-    #[arg(long)] from: Option<usize> },-    /// Filled copper per layer from the live board-    Measure,-    /// current or thermal-    Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf> },-    /// The finish line; refuses until every check passes-    Finish,-    /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.-    Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },-    /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.-    Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)-    #[arg(long)] accept_suspect: bool },-    /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.-    Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)-    #[arg(long)] surface: Option<String> },-    /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.-    Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },-    /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.-    Recut,-    /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.-    Compose { #[arg(long, default_value = "final.mp4")] out: String, /// the longest the final video may be, in seconds-    #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen-    #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings-    #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air-    #[arg(long)] script: Option<PathBuf> },-    /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step.-    Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video-    #[arg(long)] replaces: Option<String>, #[arg(long)] review: Option<PathBuf> },-    /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.-    Usage,-    /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement. Also checks every bound model is right way up (step2glb bbox after the footprint's rotate/offset); warnings must be fixed or acknowledged (--ack/--why) before finish.-    Models { /// only the offline 3D orientation check (no live kicad_model_check through the bridge)-    #[arg(long)] offline: bool, /// acknowledge orientation warnings for these references (comma separated) after looking at them in the native 3D viewer-    #[arg(long)] ack: Option<String>, /// why the acknowledged models are right as they are-    #[arg(long)] why: Option<String> },-    /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.-    Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container-    #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)-    #[arg(long)] on: Option<String> },-    /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.-    Ledger { #[arg(long)] json: bool },-    /// open, start, mark <label>, stop-    Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },-    /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/-    Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },-    /// Where the run stands-    Status,-    /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.-    Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },-    /// The AI's answer to the current prompt is complete ("done, here is ..."): the session's clock stops here. `deliver` is the done of the first task.-    Done { #[arg(long)] message: String },-    /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is unobserved activity, not proven human idle).-    Sessions { #[arg(long)] json: bool },-}--static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();--/// Close the ledger entry for this command (see Run::turn_begin).-fn close_turn(ok_: bool, first_line: &str) {-    if let Some((dir, idx)) = TURN.get() {-        if let Some(mut r) = Run::open(dir) {-            r.turn_end(*idx, ok_, first_line);-            let _ = r.save();-        }-    }-}--fn ok(msg: &str, hints: &[String]) {-    close_turn(true, msg.lines().next().unwrap_or(""));-    println!("OK: {msg}");-    for h in hints {-        if !h.is_empty() {-            println!("Hint: {h}");-        }-    }-}--fn err(msg: &str, hints: &[String]) -> ! {-    close_turn(false, msg.lines().next().unwrap_or(""));-    println!("ERROR: {msg}");-    for h in hints {-        if !h.is_empty() {-            println!("Hint: {h}");-        }-    }-    std::process::exit(2)-}--fn thread(cli: &Cli) -> String {-    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))-}--fn run_dir(cli: &Cli) -> PathBuf {-    cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))-}--fn load_run(cli: &Cli) -> Run {-    let d = run_dir(cli);-    if let Some(r) = Run::open(&d) { if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::fusion::set_run_board(b); } return r; }-    Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))-}--fn read_json(p: &str) -> Value {-    serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[]))-}--fn spec_of(r: &Run) -> Value {-    if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }-    read_json(r.data["spec"].as_str().unwrap_or(""))-}--/// The spec when there is one, Null before the board (an intake run) or when it does not read.-fn spec_soft(r: &Run) -> Value {-    r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)-}--/// requirements.json (aiflow-intake) when the run knows one, else Null.-fn requirements_of(r: &Run) -> Value {-    r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)-}--/// The page the clips go to: `clips --page`, else the run's page (`start --page`, `report --page`).-fn clips_page_of(r: &Run) -> Option<String> {-    r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string)-}--/// Rebuild docs/clips.md in <run>/clips-page and, with `push`, put it and the changed clip media-/// on the page (`adom-wiki repo push --files`, batches of about 20 MB). Refuses to push a page-/// with an em-dash or a match of $AIFLOW_BANNED_RE. Returns (what happened, the files).-fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String, Vec<String>), String> {-    let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);-    let (md, media) = preboard::clips_page(&r.data, dir, &notes, &now());-    let stage = dir.join("clips-page");-    std::fs::create_dir_all(stage.join("docs").join("clips")).map_err(|e| format!("{}: {e}", stage.display()))?;-    // docs/clips.md belongs to the first run that published it (a hidden marker says which). A later-    // run on the same page writes docs/clips-<run>.md and adds one link to it in docs/clips.md,-    // so a second run never overwrites the first run's clips.-    let id = r.data["reportId"].as_str().map(str::to_string).unwrap_or_else(|| report_id(r));-    let marker = format!("<!-- aiflow-clips-run: {id} -->");-    let remote = std::process::Command::new("adom-wiki").args(["repo", "show", page, "docs/clips.md"]).output().ok()-        .filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.trim_start().starts_with('#') || t.contains("aiflow-clips-run"));-    let owner = match &remote { None => true, Some(t) => t.contains(&marker) };-    let doc = if owner { "docs/clips.md".to_string() } else { format!("docs/clips-{id}.md") };-    let md = if owner { format!("{marker}\n{md}") } else { format!("{marker}\n{}", md.replacen("# Build clips", &format!("# Build clips: run {id}"), 1)) };-    std::fs::write(stage.join(&doc), &md).map_err(|e| e.to_string())?;-    let mut index_update: Option<String> = None;-    if let (false, Some(t)) = (owner, &remote) {-        let link = format!("clips-{id}.md");-        if !t.contains(&link) {-            let line = format!("Clips of a later run on this page: [run {id}]({link}).");-            let mut lines: Vec<&str> = t.lines().collect();-            let at = lines.iter().position(|l| l.starts_with("# ")).map(|i| i + 1).unwrap_or(0);-            lines.insert(at, ""); lines.insert(at + 1, &line);-            let updated = lines.join("\n") + "\n";-            std::fs::write(stage.join("docs").join("clips.md"), &updated).map_err(|e| e.to_string())?;-            index_update = Some(updated);-        }-    }-    for (src, rel) in &media {-        let dest = stage.join(rel);-        let same = std::fs::metadata(&dest).ok().zip(std::fs::metadata(src).ok()).map(|(a, b)| a.len() == b.len()).unwrap_or(false);-        if !same { std::fs::copy(src, &dest).map_err(|e| format!("copy {}: {e}", src.display()))?; }-    }-    let clips = md.matches("\n### Clip ").count();-    let mut files: Vec<String> = vec![doc.clone()];-    if index_update.is_some() { files.push("docs/clips.md".into()); }-    files.extend(media.iter().map(|(_, rel)| rel.clone()));-    let banned = std::env::var("AIFLOW_BANNED_RE").ok();-    let scrubbed = preboard::scrub(&md, banned.as_deref());-    let url = format!("https://wiki.adom.inc/{page}/files/{doc}");-    if !push {-        return Ok((format!("{doc} rebuilt with {clips} clip(s) and {} media file(s) in {} (dry run, nothing pushed; {}); {url} after --push", media.len(), stage.display(), match &scrubbed { Ok(()) => "the scrub passes".to_string(), Err(e) => format!("a push would be refused: {e}") }), files));-    }-    scrubbed.map_err(|e| format!("not pushed: {e}"))?;-    // only what changed since the last push goes up (docs/clips.md always), in batches the gateway takes-    let cache_path = stage.join(".pushed.json");-    let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();-    let mut todo: Vec<(String, u64, String)> = Vec::new();-    for f in &files {-        let p = stage.join(f);-        let sum = library_review::hash(&p).unwrap_or_default();-        if f == &doc || f == "docs/clips.md" || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }-    }-    let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];-    let mut acc = 0u64;-    for f in &todo {-        if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }-        batches.last_mut().unwrap().push(f); acc += f.1;-    }-    let (mut pushed, mut failed) = (0usize, Vec::new());-    for batch in batches.iter().filter(|b| !b.is_empty()) {-        let mut cmd = std::process::Command::new("adom-wiki");-        cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("clips: {clips} clip(s), {}", r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);-        for f in batch { cmd.arg(&f.0); }-        match cmd.output() {-            Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }-            Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())),-            Err(e) => failed.push(format!("{} file(s): {e}", batch.len())),-        }-    }-    let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());-    r.log("clips-page", json!({"page": page, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));-    if !failed.is_empty() { return Err(format!("push failed for {}; the next clip stop (or `clips --push`) sends it again", failed.join("; "))); }-    Ok((format!("clips page pushed: {clips} clip(s), {pushed} changed file(s) ({} unchanged skipped): {url}", files.len() - todo.len()), files))-}--/// After a clip stops: when the run has a page, rebuild and push the clips page. Best effort: a-/// failure is said, never a failed step.-fn auto_clips(r: &Run, dir: &Path) -> String {-    let Some(pg) = clips_page_of(r) else { return String::new() };-    match publish_clips(r, dir, &pg, true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") }-}--/// The user's primary machine (`adom-bridge targets` names it).-fn primary_machine() -> Option<String> {-    let o = std::process::Command::new("adom-bridge").arg("targets").output().ok()?;-    preboard::primary_from_targets(&serde_json::from_slice::<Value>(&o.stdout).ok()?)-}--/// The board's own copper as plan-shaped entries (one per net and track width, one per via), so the-/// analyses see a board whose copper is already on it (an adopted, hand-reworked or natively routed-/// board) and not only what a routing plan adds.-fn board_copper_plan(b: &Board) -> Value {-    let net_of = |n: &aiflow_board::sx::Node| -> String {-        let Some(x) = n.find("net") else { return String::new() };-        match x.number(1) { Some(v) => b.net_table.get(&(v as i64)).cloned().unwrap_or_default(), None => x.atom(1).unwrap_or("").to_string() }-    };-    let mut entries: Vec<Value> = Vec::new();-    let mut seen: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();-    for s in b.root.find_all("segment") {-        let (net, w) = (net_of(s), s.find("width").and_then(|w| w.number(1)).unwrap_or(0.0));-        if net.is_empty() || w <= 0.0 { continue; }-        if seen.insert((net.clone(), format!("{w:.4}"))) { entries.push(json!({"net": net, "width": w, "source": "board"})); }-    }-    for v in b.root.find_all("via") {-        let net = net_of(v);-        if net.is_empty() { continue; }-        let at = v.find("at");-        let (x, y) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0));-        let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);-        entries.push(json!({"net": net, "viaDrill": drill, "paths": [[{"layer": "B.Cu", "x": x, "y": y}]], "source": "board"}));-    }-    json!({"nets": entries})-}--/// The plan, plus the board's own copper for every net the plan carries no copper for.-fn plan_with_board_copper(plan: &Value, b: &Board) -> Value {-    let mut entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default();-    let planned: std::collections::BTreeSet<String> = entries.iter().filter_map(|e| e.get("net").and_then(|n| n.as_str()).map(str::to_string)).collect();-    for e in board_copper_plan(b)["nets"].as_array().cloned().unwrap_or_default() {-        if !planned.contains(e["net"].as_str().unwrap_or("")) { entries.push(e); }-    }-    json!({"nets": entries})-}--fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {-    let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();-    for p in &b.pads {-        if p.thru || p.net.is_empty() {-            continue;-        }-        let l = if p.on_f { "F.Cu" } else { "B.Cu" };-        let e = m.entry(p.net.clone()).or_default();-        if !e.iter().any(|x| x == l) {-            e.push(l.to_string());-        }-    }-    m-}--/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;-/// every other copper or keepout zone on the live board is the flow's own.-fn inherited_zone_ids(r: &Run) -> Vec<String> {-    let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };-    let text = std::fs::read_to_string(p).unwrap_or_default();-    let mut ids = Vec::new();-    let mut from = 0;-    while let Some(i) = text[from..].find("(zone") {-        let start = from + i;-        let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());-        if let Some(u) = text[start..end].find("(uuid \"") {-            let s = start + u + 7;-            if let Some(e) = text[s..].find('"') {-                ids.push(text[s..s + e].to_string());-            }-        }-        from = start + 5;-    }-    ids-}--/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.-fn step_table(r: &Run) -> String {-    let rep = r.step_report();-    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];-    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();-    for k in rep.keys() {-        if !names.contains(k) {-            names.push(k.clone());-        }-    }-    let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8}  {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];-    let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);-    for n in names {-        let e = &rep[&n];-        let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));-        tw += w; tt += t; tb += b; tn += k;-        lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8}  {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));-    }-    lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));-    lines.join("\n")-}----/// Free megabytes on the drive holding the run directory.-fn local_free_mb(dir: &Path) -> Option<u64> {-    let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;-    let text = String::from_utf8_lossy(&out.stdout);-    let line = text.lines().last()?;-    line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)-}--/// The recording budget for a step: measured window captures run about 8 MB per minute of-/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the-/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull-/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.-fn clip_budget_mb(step: &str) -> u64 {-    let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };-    minutes * 10 * 2-}--fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {-    let need = clip_budget_mb(step);-    let local = local_free_mb(dir);-    let remote = br.remote_free_mb();-    let floor = need + 1024;-    let mut msgs = Vec::new();-    if let Some(l) = local {-        if l < floor {-            return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));-        }-        msgs.push(format!("run drive {l} MB free"));-    }-    if let Some(rm) = remote {-        if rm < floor {-            return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));-        }-        msgs.push(format!("test box {rm} MB free"));-    }-    Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))-}---/// The Adom faces, embedded so every caption on every machine is set in them.-fn run_fonts(dir: &Path) -> (String, String) {-    let fd = dir.join("fonts");-    let _ = std::fs::create_dir_all(&fd);-    let files: [(&str, &[u8]); 3] = [("FamiljenGrotesk-Bold.ttf", include_bytes!("../../../assets/fonts/FamiljenGrotesk-Bold.ttf")), ("Satoshi-Regular.ttf", include_bytes!("../../../assets/fonts/Satoshi-Regular.ttf")), ("Satoshi-Medium.ttf", include_bytes!("../../../assets/fonts/Satoshi-Medium.ttf"))];-    for (n, b) in files { let p = fd.join(n); if !p.is_file() { let _ = std::fs::write(&p, b); } }-    (fd.join("FamiljenGrotesk-Bold.ttf").display().to_string(), fd.join("Satoshi-Medium.ttf").display().to_string())-}--/// drawtext-safe text: no quotes, colons and commas escaped, percent doubled.-fn dt(t: &str) -> String {-    t.replace('\\', "").replace('\'', "").replace(':', "\\:").replace(',', "\\,").replace('%', "%%")-}---/// The engine's plan usage, as the providers report it: percent used per limit window. Claude-/// from the Claude Code sign-in (the same call Claude Code's /usage panel makes), Codex from the-/// Codex sign-in (the same call its /status shows). Costs no quota. Returns [{provider, label, pct}].-fn usage_snapshot() -> Vec<Value> {-    let mut out = Vec::new();-    let home = std::env::var("HOME").unwrap_or_default();-    let curl = |url: &str, headers: &[String]| -> Option<Value> {-        let mut c = std::process::Command::new("curl");-        c.args(["-s", "-m", "12", url]);-        for h in headers { c.args(["-H", h]); }-        let o = c.output().ok()?;-        serde_json::from_slice::<Value>(&o.stdout).ok()-    };-    if let Ok(t) = std::fs::read_to_string(format!("{home}/.claude/.credentials.json")) {-        if let Ok(c) = serde_json::from_str::<Value>(&t) {-            let oa = c.get("claudeAiOauth").cloned().unwrap_or(c.clone());-            if let Some(tok) = oa.get("accessToken").and_then(|v| v.as_str()) {-                if let Some(u) = curl("https://api.anthropic.com/api/oauth/usage", &[format!("Authorization: Bearer {tok}"), "anthropic-beta: oauth-2025-04-20".into(), "User-Agent: adom-aiflow".into()]) {-                    for lim in u.get("limits").and_then(|l| l.as_array()).cloned().unwrap_or_default() {-                        let Some(pct) = lim.get("percent").and_then(|p| p.as_f64()) else { continue };-                        let label = match lim.get("kind").and_then(|k| k.as_str()) {-                            Some("session") => "5h".to_string(),-                            Some("weekly_all") => "week".to_string(),-                            Some("weekly_scoped") => lim["scope"]["model"]["display_name"].as_str().unwrap_or("model").to_string(),-                            Some(k) => k.to_string(),-                            None => "limit".to_string(),-                        };-                        out.push(json!({"provider": "claude", "label": label, "pct": pct, "resetsAt": lim.get("resets_at").cloned().unwrap_or(Value::Null)}));-                    }-                    if out.is_empty() {-                        for (label, key) in [("5h", "five_hour"), ("week", "seven_day")] {-                            if let Some(v) = u.get(key).and_then(|x| x.get("utilization")).and_then(|x| x.as_f64()) { out.push(json!({"provider": "claude", "label": label, "pct": v})); }-                        }-                    }-                }-            }-        }-    }-    if let Ok(t) = std::fs::read_to_string(format!("{home}/.codex/auth.json")) {-        if let Ok(a) = serde_json::from_str::<Value>(&t) {-            let tk = a.get("tokens").cloned().unwrap_or(Value::Null);-            if let Some(tok) = tk.get("access_token").and_then(|v| v.as_str()) {-                let acct = tk.get("account_id").and_then(|v| v.as_str()).unwrap_or("");-                if let Some(u) = curl("https://chatgpt.com/backend-api/wham/usage", &[format!("Authorization: Bearer {tok}"), format!("ChatGPT-Account-Id: {acct}"), "User-Agent: adom-aiflow".into(), "Accept: application/json".into()]) {-                    let rl = u.get("rate_limit").cloned().unwrap_or(Value::Null);-                    let label = |w: &Value| -> String { let secs = w.get("limit_window_seconds").and_then(|v| v.as_f64()).unwrap_or(0.0); if secs >= 6.0 * 86400.0 { "week".into() } else if secs >= 4.0 * 3600.0 { "5h".into() } else { format!("{}h", ((secs / 3600.0).floor() as i64).max(1)) } };-                    for key in ["primary_window", "secondary_window"] {-                        if let Some(w) = rl.get(key).filter(|w| w.is_object()) {-                            out.push(json!({"provider": "codex", "label": label(w), "pct": w.get("used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0), "plan": u.get("plan_type").cloned().unwrap_or(Value::Null)}));-                        }-                    }-                }-            }-        }-    }-    out-}--/// Snapshot the plan usage into the ledger and run.json (best effort, never fatal).-fn record_usage(r: &mut Run, when: &str) -> Vec<Value> {-    let snap = usage_snapshot();-    if !snap.is_empty() {-        r.log("usage", json!({"when": when, "limits": snap}));-        if !r.data["usage"].is_array() { r.data["usage"] = json!([]); }-        r.data["usage"].as_array_mut().unwrap().push(json!({"t": now(), "when": when, "limits": snap}));-    }-    snap-}--/// First and last usage per provider and label: what the run cost in plan percent.-fn usage_delta(r: &Run) -> Vec<Value> {-    let snaps = r.data["usage"].as_array().cloned().unwrap_or_default();-    let mut first: std::collections::BTreeMap<String, (String, String, f64)> = Default::default();-    let mut last: std::collections::BTreeMap<String, f64> = Default::default();-    for s in &snaps {-        for l in s["limits"].as_array().cloned().unwrap_or_default() {-            let key = format!("{}:{}", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""));-            let pct = l["pct"].as_f64().unwrap_or(0.0);-            first.entry(key.clone()).or_insert((l["provider"].as_str().unwrap_or("").to_string(), l["label"].as_str().unwrap_or("").to_string(), pct));-            last.insert(key, pct);-        }-    }-    first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": ((last[k] - f) * 10.0).round() / 10.0})).collect()-}--fn usage_line(r: &Run) -> String {-    let d = usage_delta(r);-    if d.is_empty() { return "plan usage: no sign-in found to read".into(); }-    format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.0}% -> {:.0}%", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "))-}---/// The overlay chain the final video draws on a linear-time (10x) cut: the step chip top left,-/// the engine and board top right, and in the lower right two small tables, "RUN, SINCE YOUR-/// PROMPT" at the far right and "THIS STEP" beside it, headers HR / MIN / SEC over big numbers,-/// the HR column only when the run passed an hour. Same type sizes and row lines, 16 px margins.-/// Drawn after the decimation with t = real seconds inside the clip; `offset` is the clip's start-/// since the prompt. Returns the full -vf string from the 10x cut to the captioned action cut.-/// The overlay filters of the approved layout (step chip top-left, engine and board top-right, the-/// run clock and the step clock lower-right); `offset` is the segment's start on the run clock,-/// `step_offset` its start on the step clock, both in seconds, both advancing with the frame time.-fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String {-    let (font_display, font_text) = run_fonts(dir);-    let off = format!("{offset:.0}");-    let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;-    let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;-    let run_secs = format!("(t+{off})");-    let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours {-        (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap)-    } else {-        (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap)-    };-    let run_inner = run_w.max(162.0);-    let step_secs = format!("(t+{step_offset:.0})");-    let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];-    let step_inner = (2.0 * colw + gap).max(62.0);-    let mut ov: Vec<String> = Vec::new();-    let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad;-    ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box));-    ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));-    let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);-    let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner;-    ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));-    ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));-    for (k, (hdr, expr)) in run_cols.iter().enumerate() {-        let cx = format!("w-{}+{}", run_left, k as f64 * (colw + gap) + colw / 2.0);-        ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));-        ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));-    }-    for (k, (hdr, expr)) in step_cols.iter().enumerate() {-        let cx = format!("w-{}+{}", step_left, k as f64 * (colw + gap) + colw / 2.0);-        ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));-        ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));-    }-    ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));-    ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));-    ov.join(",")-}--/// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are-/// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at-/// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).-fn overlay_chain(dir: &Path, title: &str, engine_board: &str, offset: f64, run_has_hours: bool, speed: u32, rate: f64) -> String {-    format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,{},setpts=N/{rate:.3}/TB", overlay_filters(dir, title, engine_board, offset, 0.0, run_has_hours))-}--/// Drops frames that are almost entirely black: the window recorder's first frame is one, and the-/// scripted pace can stretch a single frame across seconds of narration. Every frame is tagged with-/// its black fraction (amount=0), and only the ones under 95 percent black pass.-const DROP_BLACK: &str = "blackframe=amount=0:threshold=24,metadata=mode=select:key=lavfi.blackframe.pblack:value=95:function=less";--/// A step's display name for chips and cards.-fn step_shown(step: &str) -> String {-    match step { "3d" => "3D WALKTHROUGH".into(), "nets" => "NET WALKTHROUGH".into(), "models" => "3D MODELS".into(), "current" => "CURRENT ANALYSIS".into(), "thermal" => "THERMAL ANALYSIS".into(), other => other.to_uppercase() }-}---/// A clip stopped twice loses its file on the second stop (the recorder answers "nothing running"),-/// though the ledger kept the first stop's file under the same recording id. Put it back.-fn recover_capture_files(r: &mut Run, dir: &Path) -> usize {-    let mut by_rid: std::collections::HashMap<String, String> = Default::default();-    for e in &r.ledger() {-        if e["event"] == "clip-stop" {-            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {-                let local = dir.join(Path::new(f).file_name().unwrap_or_default());-                if local.is_file() { by_rid.insert(rid.to_string(), local.display().to_string()); }-            }-        }-    }-    let mut n = 0;-    if let Some(caps) = r.data["captures"].as_array_mut() {-        for c in caps.iter_mut() {-            if c["file"].as_str().is_some() { continue; }-            if let Some(f) = c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned() { c["file"] = json!(f); n += 1; }-        }-    }-    if n > 0 { r.mark(&format!("recovered {n} clip file(s) from the ledger")); }-    n-}--/// Put one clip stop on its capture. The remote path and the local file are only ever filled in,-/// never blanked: a second stop of the same recording (the recorder answers "nothing running")-/// keeps what the first stop recorded. A clip whose pull did not land after the retries is-/// flagged `pullFailed` with its desktop path, its `file` stays null, and a WARNING names the-/// remote path; `recut`, `finish` and `deliver` pull it again. Returns the local file.-fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Option<String> {-    let cap = &mut r.data["captures"][pos];-    let had_file = cap["file"].as_str().map(str::to_string);-    if stop.remote.is_some() || cap.get("stop").is_none() { cap["stop"] = json!(now()); }-    match &stop.remote {-        Some(remote) => {-            cap["remoteFile"] = json!(remote);-            let kept = had_file.as_deref().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false);-            if stop.pulled {-                cap["file"] = json!(dir.join(Path::new(remote).file_name().unwrap_or_default()).display().to_string());-                if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }-            } else if !kept {-                cap["file"] = Value::Null;-                cap["pullFailed"] = json!(true);-                cap["pullAttempts"] = json!(stop.attempts);-                eprintln!("WARNING: clip {}-{} did NOT reach the run after {} pull(s): it is still on the desktop at {remote}. Run `adom-aiflow recut` to pull it again before the desktop cleans its recordings folder.", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1), stop.attempts);-            }-        }-        None => {-            if had_file.is_none() { cap["file"] = Value::Null; }-            if cap.get("remoteFile").is_none() { cap["remoteFile"] = Value::Null; }-        }-    }-    cap["file"].as_str().map(str::to_string)-}--/// The ledger line of a clip stop: `file` is the desktop path (what recover_capture_files reads),-/// `pullFailed` when the pull did not land.-fn clip_stop_event(mut ev: Value, stop: &ClipStop) -> Value {-    ev["file"] = json!(stop.remote);-    if stop.remote.is_some() && !stop.pulled { ev["pullFailed"] = json!(true); }-    ev-}--/// Pull again every stopped clip that is not in the run: flagged `pullFailed`, or recorded with a-/// local file that never arrived. The desktop path comes from the capture or, for older runs,-/// from the ledger's clip-stop lines. Returns (pulled now, still missing).-fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) {-    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();-    let mut by_rid: std::collections::HashMap<String, String> = Default::default();-    for e in &r.ledger() {-        if e["event"] == "clip-stop" {-            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) { by_rid.insert(rid.to_string(), f.to_string()); }-        }-    }-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    let (mut pulled, mut missing) = (0, 0);-    for (i, c) in caps.iter().enumerate() {-        // running clips have no file key yet; discarded ones are never shown-        if c.get("file").is_none() || c.get("discarded").is_some() { continue; }-        if c["file"].as_str().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false) { continue; }-        let Some(remote) = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned()) else { continue };-        let local = dir.join(Path::new(&remote).file_name().unwrap_or_default()).display().to_string();-        let tag = format!("{}-{}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1));-        let landed = aiflow_bridge::nonempty(Path::new(&local)) || br.pull_verified(&remote, &save_to, aiflow_bridge::PULL_ATTEMPTS).0;-        let cap = &mut r.data["captures"][i];-        cap["remoteFile"] = json!(remote);-        if landed {-            cap["file"] = json!(local);-            cap["pulledLater"] = json!(now());-            if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }-            r.log("clip-pulled", json!({"step": tag, "recordingId": c["recordingId"], "file": remote}));-            clip_artifacts(r, &json!(tag), &local, 10);-            pulled += 1;-        } else {-            cap["pullFailed"] = json!(true);-            eprintln!("WARNING: clip {tag} is still not in the run: pulling {remote} from the desktop failed again (the desktop may have cleaned it). Re-record the step if it matters.");-            missing += 1;-        }-    }-    if pulled > 0 { r.mark(&format!("pulled {pulled} clip(s) that had not reached the run")); }-    (pulled, missing)-}--/// True when a STEP carries a laser-etched MPN part: `Adom.LaserEtch` (adom-aiflow-etch 0.1.48+, the-/// shared service) or the legacy `laser_etch` part of earlier releases.-fn has_mpn_mark(bytes: &[u8]) -> bool {-    bytes.windows(14).any(|w| w == b"Adom.LaserEtch") || bytes.windows(10).any(|w| w == b"laser_etch")-}--/// IC footprints (U*, IC*) whose bound 3D model is a local STEP without a laser-etched MPN part.-/// Models are found beside the source board (${KIPRJMOD}); an unresolvable path is left to `models`.-fn unmarked_ics(r: &Run) -> Vec<String> {-    let Ok(b) = Board::load(&r.current_board()) else { return vec![] };-    let proj = r.data["sourceBoard"].as_str().map(|p| Path::new(p).parent().map(|d| d.to_path_buf()).unwrap_or_default()).unwrap_or_default();-    let mut out = Vec::new();-    for fp in b.root.find_all("footprint") {-        let Some(reference) = fp.find_all("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).map(str::to_string) else { continue };-        let is_ic = reference.starts_with("IC") || (reference.starts_with('U') && reference[1..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false));-        if !is_ic { continue; }-        for m in fp.find_all("model").filter_map(|n| n.value()) {-            let path = m.replace("${KIPRJMOD}", &proj.display().to_string());-            if !(path.to_ascii_lowercase().ends_with(".step") || path.to_ascii_lowercase().ends_with(".stp")) { continue; }-            match std::fs::read(&path) {-                Ok(bytes) => { if !has_mpn_mark(&bytes) { out.push(reference.clone()); } }-                Err(_) => {}-            }-        }-    }-    out-}--/// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so-/// no recorder outlives the run (a clip left running once recorded nine hours of a static editor).-fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;-    let rid = caps[pos]["recordingId"].as_str()?.to_string();-    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();-    let stop = br.record_stop(&rid, &save_to);-    let local = settle_clip_stop(r, pos, &stop, dir);-    let cap = &mut r.data["captures"][pos];-    cap["stoppedBy"] = json!(why);-    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-    r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": why}), &stop));-    if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }-    let note = auto_clips(r, dir);-    if !note.is_empty() { eprintln!("{note}"); }-    local-}---/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a-/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey-/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than-/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.-fn blank_check(file: &str) -> Option<String> {-    let secs = media_seconds(file);-    if secs <= 0.0 { return Some("no frames".into()); }-    let n = 8usize;-    let mut flat = 0usize; let mut total = 0usize;-    for k in 0..n {-        let t = secs * (k as f64 + 0.5) / n as f64;-        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;-        let px = out.stdout;-        if px.len() < 1000 { continue; }-        let mut bins = [0usize; 16];-        for b in &px { bins[(*b as usize) >> 4] += 1; }-        total += 1;-        if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }-    }-    if total == 0 { return Some("could not read frames".into()); }-    if flat * 4 > total * 3 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }-}--/// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it-/// filmed before it moves on, and the run page shows it under the clip.-fn contact_sheet(file: &str) -> Option<String> {-    let secs = media_seconds(file);-    if secs <= 0.0 { return None; }-    let out = format!("{}-sheet.png", file.trim_end_matches(".mp4"));-    let fps = 9.0 / secs;-    let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", file, "-vf", &format!("fps={fps:.6},scale=426:-2,tile=3x3:padding=4:margin=4:color=0x0B1420"), "-frames:v", "1", &out]).status().map(|s| s.success()).unwrap_or(false);-    if ok && Path::new(&out).is_file() { Some(out) } else { None }-}--/// Seconds of pure black at the head of a clip (the recorder started before the window painted), up to five.-fn leading_black(file: &str) -> f64 {-    let out = std::process::Command::new("ffmpeg").args(["-v", "info", "-t", "6", "-i", file, "-an", "-vf", "blackdetect=d=0.05:pic_th=0.98:pix_th=0.10", "-f", "null", "-"]).output();-    let Ok(o) = out else { return 0.0 };-    let text = String::from_utf8_lossy(&o.stderr);-    for line in text.lines() {-        if let Some(i) = line.find("black_start:") {-            let rest = &line[i + 12..];-            let start: f64 = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(1.0);-            let end: f64 = line.find("black_end:").and_then(|j| line[j + 10..].split_whitespace().next()).and_then(|v| v.parse().ok()).unwrap_or(0.0);-            if start <= 0.05 && end > 0.0 && end <= 5.0 { return end; }-            break;-        }-    }-    0.0-}--/// The clip's seconds, from ffprobe (0.0 when unknown).-fn media_seconds(path: &str) -> f64 {-    std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)-}--/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts-/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.-fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {-    let dir = r.dir.clone();-    let tag = step.as_str().unwrap_or("step").to_string();-    // the clip's offset from the prompt, so the overlay can show the run's own clock-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    let cap = caps.iter().find(|c| c["file"].as_str() == Some(file) || format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag).cloned().unwrap_or(Value::Null);-    let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-    let offset = cap["start"].as_str().and_then(aiflow_run::parse).map(|t| t.saturating_sub(t0)).unwrap_or(0) as f64;-    let (step_name, visit, back) = (cap["step"].as_str().unwrap_or(&tag).to_string(), cap["visit"].as_u64().unwrap_or(1), cap["back"].as_bool() == Some(true));-    let _ = (&step_name, visit, back, offset, &dir);-    let secs = media_seconds(file);-    // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an-    // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud-    let verdict = blank_check(file);-    if let Some(why) = &verdict {-        eprintln!("WARNING clip {} looks blank: {why}", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));-        if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["suspect"] = json!(why); }-        r.log("clip-suspect", json!({"step": step, "file": file, "why": why}));-    }-    r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0, "suspect": verdict}));-    if let Some(sheet) = contact_sheet(file) {-        r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));-        eprintln!("contact sheet (look at it before you go on): {sheet}");-    }-    let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));-    // the recorder's first frames are black before the window paints: they are cut off the head-    // of both cuts (never more than five seconds; longer black is a real clip problem, kept)-    let lead = leading_black(file);-    let ss = format!("{lead:.3}");-    let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);-    if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }-    if cut {-        let fast = media_seconds(&out);-        r.log("artifact", json!({"step": step, "kind": format!("clip{speed}x"), "file": out, "seconds": (fast * 10.0).round() / 10.0, "speed": speed}));-        eprintln!("clip {:.1} s, {speed}x cut {:.1} s: {out}", secs, fast);-        // the action cut: only the frames that changed, with the real elapsed time burned in.-        // Made from the 10x cut (cheap): stretch its timestamps back to real time so the clock is-        // honest, drop near-duplicate frames, then pack what is left at 30 fps.-        let action = format!("{}-action.mp4", file.trim_end_matches(".mp4"));-        // the action cut stays RAW (no captions): the final video draws its own overlays from the-        // ledger, and a side-by-side of two runs must not paint overlays over overlays-        let vf = format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,setpts=N/4/TB");-        let ok_action = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &out, "-an", "-vf", &vf, "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &action]).status().map(|s| s.success()).unwrap_or(false);-        if ok_action {-            let a = media_seconds(&action);-            r.log("artifact", json!({"step": step, "kind": "clipAction", "file": action, "seconds": (a * 10.0).round() / 10.0, "realSeconds": (secs * 10.0).round() / 10.0}));-            eprintln!("action cut {:.1} s of motion out of {:.1} s: {action}", a, secs);-        }-    }-}---/// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made.-enum Piece { Library { file:String, caption:String, at:u64, seconds:f64 }, Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } }--/// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is-/// said faster when even it runs long. The picture sets the length; the words are cut to it.-fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {-    if !has_tts || vlen < 1.5 { return None; }-    let mut best: Option<(PathBuf, f64, String)> = None;-    for (k, words) in tiers.iter().enumerate() {-        let n = words.split_whitespace().count() as f64;-        let last = k + 1 == tiers.len();-        if n > vlen * 2.3 && !last { continue; }-        let rate = if last && n > vlen * 2.3 { "+30%" } else { "+0%" };-        let audio = workdir.join(format!("say-{idx}-{k}.mp3"));-        let ok = std::process::Command::new("adom-tts").args(["say", "--rate", rate, "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);-        if !ok { continue; }-        let alen = media_seconds(audio.to_str().unwrap());-        best = Some((audio, alen, words.clone()));-        if alen <= vlen + 0.05 { break; }-    }-    best-}--/// The first clause of a sentence: up to the first semicolon, colon or comma.-fn first_clause(s: &str) -> String {-    s.split([';', ':', ',']).next().unwrap_or(s).trim().to_string()-}--/// A step's name in words for the narration.-fn step_said(step: &str) -> String {-    step_shown(step).to_lowercase().replace("3d", "3D")-}--/// Compose the final video. Returns (seconds, clip segments, stills, frame rate of motion, narrated).-fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64) -> (f64, usize, usize, f64, bool) {-    let led = r.ledger();-    let mut act: std::collections::BTreeMap<String, (f64, f64)> = Default::default();-    for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), (e["realSeconds"].as_f64().unwrap_or(0.0), e["seconds"].as_f64().unwrap_or(0.0))); } }-    let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    // a clip stopped twice loses its file on the second stop (the recorder answers "nothing running");-    // the ledger kept the first stop's file under the same recording id, so recover it from there-    let mut ledger_file: std::collections::HashMap<String, String> = Default::default();-    for e in &led {-        if e["event"] == "clip-stop" {-            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {-                let local = dir.join(Path::new(f).file_name().unwrap_or_default());-                if local.is_file() { ledger_file.insert(rid.to_string(), local.display().to_string()); }-            }-        }-    }-    // the pieces, in the order they happened: clips by their start, drawings by the moment the AI registered them-    let mut pieces: Vec<(u64, Piece)> = Vec::new();-    for (i, c) in caps.iter().enumerate() {-        if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }-        let recovered = c["recordingId"].as_str().and_then(|rid| ledger_file.get(rid)).cloned();-        let mut c = c.clone();-        if c["file"].as_str().is_none() { if let Some(f) = &recovered { c["file"] = json!(f); } }-        let c = &c;-        let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };-        if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }-        let visit = c["visit"].as_u64().unwrap_or(1);-        let tag = format!("{step}-{visit}");-        let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));-        if !Path::new(&fast).is_file() { continue; }-        let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));-        let motion = act.get(&tag).map(|x| x.1).filter(|m| *m > 0.0).unwrap_or_else(|| media_seconds(&action));-        // a visit with under a second of motion is nothing to watch: it stays on the page, not in the video-        if motion < 1.0 { eprintln!("segment {tag}: {motion:.1} s of motion, left out"); continue; }-        let at = c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-        // a clip that ran while the human was away (a recorder left on) is nothing to watch-        let stop = c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(at);-        if r.active_between(at, stop) == 0 && stop.saturating_sub(at) > r.idle_minutes() * 60 { eprintln!("segment {tag}: recorded outside the AI's time, left out"); continue; }-        pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion }));-    }-    for e in &standing_artifacts(&led) {-        if e["kind"]=="library-overview" {-            let Some(file)=e["file"].as_str() else {continue};-            let Some(review)=e["review"].as_str() else {err("library overview has no review contract", &["Re-register it with artifact --kind library-overview --review <review.json>.".into()])};-            library_review::verify(Path::new(review),Path::new(&r.current_board()),Path::new(file)).unwrap_or_else(|e|err(&e,&["Re-review the current native board and selected component variants before composing.".into()]));-            let at=e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-            pieces.push((at,Piece::Library{file:file.into(),caption:e["caption"].as_str().unwrap_or("Component library").into(),at,seconds:media_seconds(file).min(5.0)}));-            continue;-        }-        if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }-        let Some(f) = e["file"].as_str() else { continue };-        if !Path::new(f).is_file() { continue; }-        let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-        pieces.push((at, Piece::Still { file: f.to_string(), caption: e["caption"].as_str().unwrap_or("").to_string(), step: e["step"].as_str().unwrap_or("").to_string(), visit: e["visit"].as_u64().unwrap_or(1), at }));-    }-    pieces.sort_by_key(|p| p.0);-    if pieces.is_empty() { err("nothing to compose: no clips with motion and no drawings in this run (run `recut` first)", &[]); }-    // the pace: every kept frame of motion plays at 4 per second when the run fits; faster, evenly, when it does not-    let n_stills = pieces.iter().filter(|p| matches!(p.1, Piece::Still { .. })).count();-    let motion_total: f64 = pieces.iter().map(|p| if let Piece::Clip { motion, .. } = &p.1 { *motion } else { 0.0 }).sum();-    let library_seconds: f64=pieces.iter().map(|p|if let Piece::Library{seconds,..}=p.1{seconds}else{0.0}).sum();-    let still_seconds=still_seconds.min((max_seconds-library_seconds).max(0.0)*0.5/(n_stills.max(1) as f64));-    if library_seconds>=max_seconds {err("component overviews exceed the video budget", &["Replace superseded overviews with artifact --replaces, or raise compose --max-seconds.".into()]);}-    let clip_budget = (max_seconds - library_seconds - n_stills as f64 * still_seconds).max(0.1);-    let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 };-    let engine = r.data["engine"].as_str().unwrap_or("").to_string();-    let minutes = r.active_minutes_total();-    let run_has_hours = minutes >= 60.0;-    let board = Path::new(r.data["sourceBoard"].as_str().or(r.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();-    let engine_board = format!("{engine}   ·   {board}");-    let workdir = dir.join("compose");-    let _ = std::fs::remove_dir_all(&workdir);-    let _ = std::fs::create_dir_all(&workdir);-    let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);-    let step_does = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };-    let visit_start = |step: &str, visit: u64| -> u64 { caps.iter().find(|c| c["step"].as_str() == Some(step) && c["visit"].as_u64() == Some(visit)).and_then(|c| c["start"].as_str()).and_then(aiflow_run::parse).unwrap_or(0) };-    let fit = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0B1420";-    let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();-    let (mut n_clips, mut n_stills_done) = (0usize, 0usize);-    for (k, (_, piece)) in pieces.iter().enumerate() {-        let seg = workdir.join(format!("seg-{k}.mp4"));-        let aseg = workdir.join(format!("aud-{k}.m4a"));-        let (vlen, tiers): (f64, Vec<String>) = match piece {-            Piece::Library{file,caption,at,seconds}=>{-                let offset=r.active_offset(*at) as f64;-                let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));-                let good=std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]).status().map(|s|s.success()).unwrap_or(false);-                if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);}-                n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])-            }-            Piece::Clip { cap: c, idx, motion } => {-                let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));-                let tag = format!("{step}-{visit}");-                let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));-                // the run clock is the AI's time: the active seconds from the first prompt to this clip-                let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));-                let offset = r.active_offset(cs) as f64;-                let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });-                let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));-                let okc = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-                if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }-                let vlen = media_seconds(seg.to_str().unwrap());-                if vlen < 0.5 { continue; }-                let _ = (idx, motion);-                let real = if ce > cs { r.active_between(cs, ce) as f64 } else { act.get(&tag).map(|x| x.0).unwrap_or_else(|| media_seconds(f)) };-                let said = step_said(step);-                let visit_words = if visit > 1 { format!(", visit {visit}") } else { String::new() };-                let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));-                let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });-                n_clips += 1;-                (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])-            }-            Piece::Still { file, caption, step, visit, at } => {-                let offset = r.active_offset(*at) as f64;-                let step_offset = r.active_between(visit_start(step, *visit), *at) as f64;-                let title = format!("{}   ·   the AI's drawing", step_shown(step));-                // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p-                let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));-                let oks = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-                if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }-                let vlen = media_seconds(seg.to_str().unwrap());-                n_stills_done += 1;-                (vlen, vec![caption.clone(), first_clause(caption), format!("{}, the AI's own drawing.", step_said(step))])-            }-        };-        // the words are cut to the picture: the segment is exactly as long as its motion-        let said = narrate(&workdir, k, &tiers, vlen, has_tts);-        let oka = match &said {-            Some((audio, _, _)) => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),-            None => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),-        };-        if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }-        if oka { parts.push((seg, aseg)); }-    }-    if parts.is_empty() { err("nothing to compose: every segment failed to render", &[]); }-    let vlist = workdir.join("video.txt");-    let alist = workdir.join("audio.txt");-    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();-    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();-    let vcat = workdir.join("video.mp4");-    let acat = workdir.join("audio.m4a");-    let okv = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    let oka = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    let okf = okv && oka && std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }-    // the page takes files up to 10 MB: when the cut is bigger, the video is re-encoded once at the-    // bitrate that fits (screen captures are mostly still, so the picture survives it)-    let cap_bytes: u64 = 9_800_000;-    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {-        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);-        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();-        let fitted = workdir.join("fitted.mp4");-        let okr = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-        if okr { let _ = std::fs::copy(&fitted, outp); eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0)); }-    }-    let secs = media_seconds(outp.to_str().unwrap());-    if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }-    (secs, n_clips, n_stills_done, rate, has_tts)-}--/// The AI's artifacts that still stand: an artifact whose file a later `artifact --replaces` names is-/// superseded, as is an earlier event of the same file re-registered later. Returns the ledger events.-fn standing_artifacts(led: &[Value]) -> Vec<Value> {-    let mut out: Vec<Value> = Vec::new();-    for (i, e) in led.iter().enumerate() {-        if e["event"] != "artifact" || e["byAi"].as_bool() != Some(true) { continue; }-        let Some(f) = e["file"].as_str() else { continue };-        let superseded = led.iter().skip(i + 1).any(|l| l["event"] == "artifact" && l["byAi"].as_bool() == Some(true) && (l["replaces"].as_str() == Some(f) || l["file"].as_str() == Some(f)));-        if !superseded { out.push(e.clone()); }-    }-    out-}--/// The sessions table for the terminal and the run page.-fn sessions_table(r: &Run) -> String {-    let ss = r.sessions();-    let mut out = String::from("| Session | Prompt at | Done at | AI time min | Unobserved gap min | What |\n|---|---|---|---|---|---|\n");-    for x in &ss {-        out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/")));-    }-    out.push_str(&format!("| **all** | | | **{:.1}** | | |\n", r.active_minutes_total()));-    out-}--/// One engine's clip of one step visit, ready for the side-by-side.-struct VisitClip { tenx: String, motion: f64, offset: f64, title: String }--/// The 10x cut of a capture, in its own run or, for a run pulled from another machine, by file name in the run directory.-fn tenx_of(run_dir: &Path, file: &str) -> Option<String> {-    let fast = format!("{}-10x.mp4", file.trim_end_matches(".mp4"));-    if Path::new(&fast).is_file() { return Some(fast); }-    let local = run_dir.join(Path::new(&fast).file_name()?);-    if local.is_file() { Some(local.display().to_string()) } else { None }-}--/// A run's visits per step (flow order kept by the caller), with their motion seconds and active-time offsets.-fn visits_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<VisitClip>> {-    let led = r.ledger();-    let mut act: std::collections::BTreeMap<String, f64> = Default::default();-    for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), e["seconds"].as_f64().unwrap_or(0.0)); } }-    let mut out: std::collections::BTreeMap<String, Vec<VisitClip>> = Default::default();-    let mut caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));-    for c in &caps {-        if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }-        let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };-        // the 3D step's camera is the viewer; an editor clip under that step is the wait before it opened-        if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }-        let visit = c["visit"].as_u64().unwrap_or(1);-        let Some(tenx) = tenx_of(&r.dir, f) else { continue };-        let motion = act.get(&format!("{step}-{visit}")).copied().unwrap_or(0.0);-        if motion < 1.0 { continue; }-        let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));-        if r.active_between(cs, ce) == 0 && ce.saturating_sub(cs) > r.idle_minutes() * 60 { continue; }-        let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });-        out.entry(step.to_string()).or_default().push(VisitClip { tenx, motion, offset: r.active_offset(cs) as f64, title });-    }-    out-}--/// A run's standing drawings per step, as (file, caption, active offset).-fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, String, f64)>> {-    let mut out: std::collections::BTreeMap<String, Vec<(String, String, f64)>> = Default::default();-    for e in standing_artifacts(&r.ledger()) {-        if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }-        let Some(f) = e["file"].as_str() else { continue };-        let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name().unwrap_or_default()); if l.is_file() { l.display().to_string() } else { continue } };-        let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-        out.entry(e["step"].as_str().unwrap_or("").to_string()).or_default().push((local, e["caption"].as_str().unwrap_or("").to_string(), r.active_offset(at) as f64));-    }-    out-}--fn run_ffmpeg(args: &[&str]) -> bool {-    std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args).status().map(|s| s.success()).unwrap_or(false)-}--/// Two runs side by side, step by step in flow order: the left engine's visits of the step play-/// in its column, the right engine's in its column, both paced to the same seconds (the run with-/// more motion sets the pace; the other plays slower, never faster than its own clips); their own-/// clocks in their own corners; the drawings of both side by side; the words cut to the picture.-fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64, script: Option<&serde_json::Map<String, Value>>) -> (f64, usize, usize, bool) {-    let (fd, ft) = run_fonts(dir);-    let workdir = dir.join("compose-pair");-    let _ = std::fs::remove_dir_all(&workdir);-    let _ = std::fs::create_dir_all(&workdir);-    let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);-    let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());-    let board = Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();-    let (va, vb) = (visits_by_step(a), visits_by_step(b));-    let (da, db) = (drawings_by_step(a), drawings_by_step(b));-    let (ra, rb) = (a.step_report(), b.step_report());-    let hours = a.active_minutes_total() >= 60.0 || b.active_minutes_total() >= 60.0;-    // the steps in flow order, then any the runs added-    // the story's order: the build steps, the analyses, the walkthroughs, the finish line last-    let mut steps: Vec<String> = ["intake", "models", "placement", "routing", "pours", "current", "thermal", "fields", "nets", "3d", "finish"].iter().map(|s| s.to_string()).collect();-    for k in va.keys().chain(vb.keys()).chain(da.keys()).chain(db.keys()) { if !steps.contains(k) { steps.insert(steps.len() - 1, k.clone()); } }-    let has = |s: &str| va.contains_key(s) || vb.contains_key(s) || da.contains_key(s) || db.contains_key(s);-    let steps: Vec<String> = steps.into_iter().filter(|s| has(s) && s != "capture").collect();-    // the pace: each step's seconds is the larger of the two runs' motion; scaled evenly to fit the budget-    let frames = |v: Option<&Vec<VisitClip>>| -> f64 { v.map(|x| x.iter().map(|c| c.motion * 4.0).sum()).unwrap_or(0.0) };-    let mut raw: Vec<f64> = Vec::new(); let mut pairs_total = 0usize;-    for s in &steps {-        let (fa, fb) = (frames(va.get(s)), frames(vb.get(s)));-        raw.push(fa.max(fb) / 4.0);-        pairs_total += da.get(s).map(|x| x.len()).unwrap_or(0).max(db.get(s).map(|x| x.len()).unwrap_or(0));-    }-    let raw_sum: f64 = raw.iter().sum();-    let clip_budget = (max_seconds - pairs_total as f64 * still_seconds).max(max_seconds * 0.5);-    let scale = if raw_sum > clip_budget { clip_budget / raw_sum } else { 1.0 };-    let mins = |rep: &serde_json::Map<String, Value>, s: &str| -> (f64, u64, u64) { rep.get(s).map(|e| (e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0))).unwrap_or((0.0, 0, 0)) };-    let (cw, ch, band) = (960, 540, 110);-    // the bars below the columns: this step's AI minutes per engine against the longest step of either run-    let max_min = steps.iter().map(|s| mins(&ra, s).0.max(mins(&rb, s).0)).fold(1.0f64, f64::max);-    let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));-    let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());-    let footer = dt(&format!("Delivered: {na} in {d_a:.0} min of AI time with {ret_a} return{} to earlier steps   ·   {nb} in {d_b:.0} min with {ret_b} return{}", if ret_a == 1 { "" } else { "s" }, if ret_b == 1 { "" } else { "s" }));-    let frame = |title: &str, sub: &str, bars: Option<((f64, u64), (f64, u64))>| -> String {-        let mut f = format!("[0:v][1:v]hstack=inputs=2,pad=1920:1080:0:{band}:color=0x0B1420,drawtext=fontfile={fd}:text='{}':x=(w-tw)/2:y=22:fontsize=40:fontcolor=0xF2E9E1,drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=74:fontsize=20:fontcolor=0xD9E1E8", dt(title), dt(sub));-        if let Some(((ma, va), (mb, vb))) = bars {-            let (x0, wmax, y1, y2, h) = (420.0, 1180.0, 720.0, 800.0, 40.0);-            for (k, (name, m, v, color, y)) in [(&na, ma, va, "0xDB8B58", y1), (&nb, mb, vb, "0x5FB6D6", y2)].into_iter().enumerate() {-                let w = ((m / max_min) * wmax).max(4.0);-                let _ = k;-                f.push_str(&format!(",drawtext=fontfile={ft}:text='{}':x={x0}-tw-24:y={y}+9:fontsize=20:fontcolor=0xF2E9E1", dt(name)));-                f.push_str(&format!(",drawbox=x={x0}:y={y}:w={w:.0}:h={h}:color={color}@0.9:t=fill"));-                f.push_str(&format!(",drawtext=fontfile={fd}:text='{}':x={x0}+{w:.0}+16:y={y}+6:fontsize=26:fontcolor=0xF2E9E1", dt(&format!("{m:.0} min   ·   {v} visit{}", if v == 1 { "" } else { "s" }))));-            }-            f.push_str(&format!(",drawtext=fontfile={ft}:text='THIS STEP, AI TIME':x={x0}:y=684:fontsize=13:fontcolor=0xA0AFBE"));-        }-        f.push_str(&format!(",drawtext=fontfile={ft}:text='{footer}':x=(w-tw)/2:y=1010:fontsize=19:fontcolor=0xD9E1E8"));-        f-    };-    // a clip of any window shape fits the column both ways (a pup window is taller than the editor's)-    let fit_col = format!("scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=0x0B1420");-    let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();-    let (mut n_steps, mut n_pairs) = (0usize, 0usize);-    // with a script the words lead: each step's picture is paced to its narration (slower or faster,-    // never held, never cut short), so the voice never stops and never runs past the picture-    let scripted = script.is_some();-    let say_len = |k: usize, words: &str| -> Option<(PathBuf, f64)> {-        if !has_tts || words.trim().is_empty() { return None; }-        let audio = workdir.join(format!("script-{k}.mp3"));-        let okv = std::process::Command::new("adom-tts").args(["say", "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);-        if okv { Some((audio.clone(), media_seconds(audio.to_str().unwrap()))) } else { None }-    };-    for (si, step) in steps.iter().enumerate() {-        let script_words = script.and_then(|m| m.get(step.as_str())).and_then(|v| v.as_str()).map(str::to_string);-        let scripted_audio = script_words.as_ref().and_then(|w| say_len(1000 + si, w));-        let t_step = match &scripted_audio { Some((_, alen)) => alen + 0.3, None => raw[si] * scale };-        if scripted && script_words.is_none() { eprintln!("side by side, step {step}: no words in the script; the picture keeps its own pace"); }-        let mut seg_index = parts.len();-        // the two columns of clips-        if t_step >= 0.5 && (raw[si] > 0.0 || scripted_audio.is_some()) {-            let mut cols: Vec<PathBuf> = Vec::new();-            for (side, (run, name, vis)) in [(a as &Run, &na, va.get(step)), (b, &nb, vb.get(step))].into_iter().enumerate() {-                let col = workdir.join(format!("col-{si}-{side}.mp4"));-                let f = frames(vis);-                if f > 0.0 {-                    // scripted: the frames spread over the words (down to one frame every five seconds-                    // for a step with little motion); unscripted: never slower than one a second-                    let rate = if scripted_audio.is_some() { (f / t_step).max(0.2) } else { (f / t_step).max(1.0) };-                    let mut pieces: Vec<PathBuf> = Vec::new();-                    for (k, v) in vis.unwrap().iter().enumerate() {-                        let piece = workdir.join(format!("piece-{si}-{side}-{k}.mp4"));-                        let chain = format!("{},{fit_col}", overlay_chain(dir, &v.title, &format!("{name}   ·   {board}"), v.offset, hours, 10, rate));-                        if run_ffmpeg(&["-i", &v.tenx, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", piece.to_str().unwrap()]) { pieces.push(piece); }-                    }-                    let list = workdir.join(format!("col-{si}-{side}.txt"));-                    std::fs::write(&list, pieces.iter().map(|p| format!("file '{}'\n", std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display())).collect::<String>()).unwrap();-                    let cat = workdir.join(format!("cat-{si}-{side}.mp4"));-                    let _ = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", list.to_str().unwrap(), "-c", "copy", cat.to_str().unwrap()]);-                    // exactly t_step: the slower column holds its last frame for the rest (a side-by-side has to wait)-                    let _ = run_ffmpeg(&["-i", cat.to_str().unwrap(), "-vf", &format!("tpad=stop_mode=clone:stop_duration={t_step:.2},trim=0:{t_step:.3}"), "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]);-                    let _ = run;-                } else {-                    // no clip on this side: the run's own screenshot of the step, or its latest board shot,-                    // stands in with a caption, so the column is never a dark hole-                    let shot = latest_shot(&run.dir, step);-                    let msg = dt(&format!("{name}: no clip for this step{}", if shot.is_some() { ", its board as filmed last" } else { "" }));-                    let caption = format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=h-th-14:fontsize=20:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=8");-                    let okp = match &shot {-                        Some(img) => run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{t_step:.3}"), "-i", img, "-vf", &format!("{fit_col},{caption}"), "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),-                        None => false,-                    };-                    if !okp { let _ = run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={t_step:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE"), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]); }-                }-                if col.is_file() { cols.push(col); } else { eprintln!("side by side, step {step}, {name}: its column did not render; the step is left out"); }-            }-            if cols.len() == 2 {-                let (ma, mb) = (mins(&ra, step), mins(&rb, step));-                let seg = workdir.join(format!("seg-{}.mp4", parts.len()));-                let fc = frame(&step_shown(step), &format!("{na} on the left, {nb} on the right; each run's own clocks in its own corner"), Some(((ma.0, ma.1), (mb.0, mb.1))));-                if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {-                    let vlen = media_seconds(seg.to_str().unwrap());-                    let said = step_said(step);-                    let tiers = if step == "finish" {-                        let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));-                        let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());-                        vec![format!("Delivered. {na}: {d_a:.0} minutes of AI time from the prompt to done, {ret_a} returns to earlier steps. {nb}: {d_b:.0} minutes, {ret_b} returns."), format!("Delivered. {na} {d_a:.0} minutes, {nb} {d_b:.0}."), "Delivered.".to_string()]-                    } else {-                        vec![format!("{said}. {na}: {:.0} minutes of AI time in {} visit{}. {nb}: {:.0} minutes in {} visit{}.", ma.0, ma.1, if ma.1 == 1 { "" } else { "s" }, mb.0, mb.1, if mb.1 == 1 { "" } else { "s" }), format!("{said}. {na} {:.0} minutes, {nb} {:.0}.", ma.0, mb.0), format!("{said}.")]-                    };-                    let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));-                    let oka = match &scripted_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };-                    if oka { parts.push((seg, aseg)); n_steps += 1; }-                }-            }-        }-        // the drawings of both, side by side-        let (dra, drb) = (da.get(step).cloned().unwrap_or_default(), db.get(step).cloned().unwrap_or_default());-        for k in 0..dra.len().max(drb.len()) {-            let pair_words = script.and_then(|m| m.get(&format!("drawings:{step}:{k}")).or_else(|| m.get(&format!("drawings:{step}"))).filter(|_| k == 0 || m.contains_key(&format!("drawings:{step}:{k}")))).and_then(|v| v.as_str()).map(str::to_string);-            let pair_audio = pair_words.as_ref().and_then(|w| say_len(2000 + si * 10 + k, w));-            let still_len = match &pair_audio { Some((_, alen)) => alen + 0.3, None => still_seconds };-            let mut cols: Vec<PathBuf> = Vec::new();-            for (side, (name, d)) in [(&na, dra.get(k)), (&nb, drb.get(k))].into_iter().enumerate() {-                let col = workdir.join(format!("still-{si}-{k}-{side}.mp4"));-                let okc = match d {-                    Some((file, _, off)) => {-                        let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{}   ·   the AI's drawing", step_shown(step)), &format!("{name}   ·   {board}"), *off, 0.0, hours));-                        run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()])-                    }-                    None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),-                };-                if okc { cols.push(col); }-            }-            if cols.len() != 2 { continue; }-            let seg = workdir.join(format!("seg-{}.mp4", parts.len()));-            let (ma, mb) = (mins(&ra, step), mins(&rb, step));-            let fc = frame(&step_shown(step), &format!("the AIs' own drawings: {na} on the left, {nb} on the right"), Some(((ma.0, ma.1), (mb.0, mb.1))));-            if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {-                let vlen = media_seconds(seg.to_str().unwrap());-                let cap = dra.get(k).map(|x| x.1.clone()).or_else(|| drb.get(k).map(|x| x.1.clone())).unwrap_or_default();-                let tiers = vec![format!("The {} drawings, {na} left, {nb} right. {}", step_said(step), first_clause(&cap)), format!("The {} drawings, {na} left, {nb} right.", step_said(step)), format!("{} drawings.", step_said(step))];-                let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));-                let oka = match &pair_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };-                if oka { parts.push((seg, aseg)); n_pairs += 1; }-            }-        }-        let _ = &mut seg_index;-    }-    if parts.is_empty() { err("nothing to compose side by side: no step with motion in either run", &[]); }-    assemble(&workdir, &parts, outp);-    (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts)-}--/// The narration for one segment, exactly `vlen` seconds long (silence when there is nothing to say).-fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: bool, aseg: &Path) -> bool {-    match narrate(workdir, k, tiers, vlen, has_tts) {-        Some((audio, _, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]),-        None => run_ffmpeg(&["-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]),-    }-}--/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.-fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {-    let vlist = workdir.join("video.txt");-    let alist = workdir.join("audio.txt");-    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();-    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();-    let vcat = workdir.join("video.mp4");-    let acat = workdir.join("audio.m4a");-    let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);-    let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);-    let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);-    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }-    let cap_bytes: u64 = 9_800_000;-    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {-        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);-        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();-        let fitted = workdir.join("fitted.mp4");-        if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) {-            let _ = std::fs::copy(&fitted, outp);-            eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));-        }-    }-}--/// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push).-fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {-    let (ida, idb) = (report_id(a), report_id(b));-    let rel = format!("docs/comparisons/{ida}-vs-{idb}");-    let stage = dir.join("report");-    let out = stage.join(&rel);-    let _ = std::fs::remove_dir_all(&out);-    let _ = std::fs::create_dir_all(&out);-    let slug = page.split('/').nth(1).unwrap_or(page);-    let blob = format!("/blob/app/{slug}/{rel}");-    let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());-    // the board by the spec's name when it has one (ESC G431), else by its file-    let board = spec_of(a).get("board").and_then(|v| v.as_str()).map(|v| v.split('(').next().unwrap_or(v).trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string());-    let mut files: Vec<String> = Vec::new();-    let mut md = String::new();-    md.push_str(&format!("# {na} versus {nb} on the {board}\n\n"));-    md.push_str("Two AIs, the same board, the same spec, the same prompt, the same flow (Adom's AI Flow), measured the same way: the AI's own time from the prompt to \"done, here is your video\", every return to an earlier step, every clip, and the analysis each AI did itself. Nobody had put two engines through one measured board flow before; this page is the first pair.\n\n");-    // the video first-    if let Some(v) = a.data["comparison"]["video"].as_str().filter(|v| Path::new(v).is_file()) {-        let stamp: String = a.ledger().iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison").and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());-        let name = format!("comparison-{stamp}.mp4");-        let _ = std::fs::copy(v, out.join(&name)); files.push(format!("{rel}/{name}"));-        md.push_str(&format!("**The side by side, {:.0} s: {na} on the left, {nb} on the right, step by step in the flow's order; each run's own clocks in its own corner (AI time, the idle cut out); both AIs' analysis drawings side by side; each step's AI-minute bars below; the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", a.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));-    }-    // the number-    let (da, db) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));-    let (ra, rb) = (a.step_report(), b.step_report());-    let turns = |r: &Run| r.data["turns"].as_array().map(|x| x.len()).unwrap_or(0);-    let think = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["thinkingSeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;-    let tool = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["binarySeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;-    md.push_str("## The number: prompt to \"done, here is your video\"\n\n");-    md.push_str("| | AI time to delivery | Returns to earlier steps | Turns | AI thinking min | Binary min |\n|---|---|---|---|---|---|\n");-    md.push_str(&format!("| **{na}** | **{da:.1} min** | {} | {} | {:.1} | {:.1} |\n", a.returns_before_delivery(), turns(a), think(&ra), tool(&ra)));-    md.push_str(&format!("| **{nb}** | **{db:.1} min** | {} | {} | {:.1} | {:.1} |\n\n", b.returns_before_delivery(), turns(b), think(&rb), tool(&rb)));-    md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (unobserved, not proven idle) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n");-    // per step-    let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();-    for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }-    md.push_str(&format!("## Per step, AI time\n\n| Step | {na} | {nb} |\n|---|---|---|\n"));-    for st in &steps {-        let cell = |rep: &serde_json::Map<String, Value>| rep.get(st).map(|e| format!("{:.1} min, {} visit{}{}", e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"], if e["visits"].as_u64() == Some(1) { "" } else { "s" }, if e["returns"].as_u64().unwrap_or(0) > 0 { format!(", {} return{}", e["returns"], if e["returns"].as_u64() == Some(1) { "" } else { "s" }) } else { String::new() })).unwrap_or_else(|| "-".into());-        if ra.contains_key(st) || rb.contains_key(st) { md.push_str(&format!("| {st} | {} | {} |\n", cell(&ra), cell(&rb))); }-    }-    md.push_str("\n");-    // the drawings side by side-    let draw = |r: &Run| -> Vec<(String, String, String)> { standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| { let f = e["file"].as_str()?; let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } }; Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string())) }).collect() };-    let (dra, drb) = (draw(a), draw(b));-    if !dra.is_empty() || !drb.is_empty() {-        md.push_str("## The analysis each AI did itself\n\nEach AI solved the current and the heat on its own board and drew it; the pictures are theirs, not the binary's. Left, then right, per step.\n\n");-        for st in &steps {-            let la: Vec<&(String, String, String)> = dra.iter().filter(|d| d.0 == *st).collect();-            let lb: Vec<&(String, String, String)> = drb.iter().filter(|d| d.0 == *st).collect();-            for k in 0..la.len().max(lb.len()) {-                md.push_str("<table><tr>");-                for (side, name, it) in [("a", &na, la.get(k)), ("b", &nb, lb.get(k))] {-                    match it {-                        Some((_, file, cap)) => { let fname = format!("{side}-{st}-{k}.{}", Path::new(file).extension().and_then(|x| x.to_str()).unwrap_or("png")); let _ = std::fs::copy(file, out.join(&fname)); files.push(format!("{rel}/{fname}")); md.push_str(&format!("<td width=\"50%\"><b>{name}: {}</b><br><img src=\"{blob}/{fname}\" width=\"100%\"></td>", cap.replace('|', "/"))); }-                        None => md.push_str(&format!("<td width=\"50%\"><b>{name}: no drawing for this step</b></td>")),-                    }-                }-                md.push_str("</tr></table>\n\n");-            }-        }-    }-    // the links-    md.push_str("## The two runs, in full\n\n");-    md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));-    md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));-    md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));-    md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");-    let readme = out.join("README.md");-    std::fs::write(&readme, md).unwrap();-    files.push(format!("{rel}/README.md"));-    (rel.clone(), format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"), files)-}--/// The newest screenshot of a step in a run directory (`shot-<step>-...png`), else the newest screenshot of any step.-fn latest_shot(dir: &Path, step: &str) -> Option<String> {-    let mut shots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with("shot-") && e.file_name().to_string_lossy().ends_with(".png")).filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();-    shots.sort_by(|a, b| b.0.cmp(&a.0));-    let of_step = shots.iter().find(|(_, p)| p.file_name().map(|n| n.to_string_lossy().starts_with(&format!("shot-{step}-"))).unwrap_or(false));-    of_step.or(shots.first()).map(|(_, p)| p.display().to_string())-}--/// The give-back hint: what the AI learned belongs to every Adom user.-fn give_back_hint() -> String {-    "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()-}---/// The run id used for its page folder: board stem, engine slug, prompt date.-fn report_id(r: &Run) -> String {-    if let Some(id) = r.data["reportId"].as_str() {-        return id.to_string();-    }-    let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");-    let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();-    let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");-    // prompt date and time, so two runs by one engine on one day get their own folders-    let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");-    let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();-    let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();-    format!("{board}-{eng}-{date}-{hhmm}")-}--/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).-fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {-    let id = report_id(r);-    let rel = format!("docs/runs/{id}");-    let stage = dir.join("report");-    let out = stage.join(&rel);-    // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along-    let _ = std::fs::remove_dir_all(&out);-    let _ = std::fs::create_dir_all(&out);-    let slug = page.split('/').nth(1).unwrap_or(page);-    let blob = format!("/blob/app/{slug}/{rel}");-    let led = r.ledger();-    let engine = r.data["engine"].as_str().unwrap_or("");-    let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");-    let finished = r.data["clock"]["finishTime"].as_str();-    let delivered = r.data["clock"]["deliveredTime"].as_str();-    let cur = r.data["currentStep"].as_str().unwrap_or("intake");-    let now_min = r.elapsed_minutes();-    let mut md = String::new();-    md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));-    let ss = r.sessions();-    let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);-    let status = match (delivered, finished) {-        (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),-        (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),-        _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),-    };-    md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));-    if let Some(d) = r.data["delivery"].as_object() {-        if let Some(v) = d.get("video").and_then(|v| v.as_str()) {-            if Path::new(v).is_file() {-                // a new name for every cut, so a browser that cached the last one shows this one-                let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).filter(|s: &String| !s.is_empty()).unwrap_or_else(|| std::fs::metadata(v).and_then(|m| m.modified()).ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()).unwrap_or_else(|| "0".into()));-                let name = format!("final-{stamp}.mp4");-                let _ = std::fs::copy(v, out.join(&name));-                let vs = media_seconds(v);-                md.push_str(&format!("## The video\n\n**The final cut of this run, {vs:.0} s: only the moments something moved, every step in order, the AI's analysis drawings fullscreen, the run and step clocks in the corner, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n"));-            }-        }-    }-    if let Some(v) = r.data["comparison"]["video"].as_str() {-        if Path::new(v).is_file() {-            let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());-            let name = format!("comparison-{stamp}.mp4");-            let _ = std::fs::copy(v, out.join(&name));-            md.push_str(&format!("## Side by side with {}\n\n**{} on the left, {} on the right, step by step in flow order, {:.0} s: each run's own clocks in its own corner, both AIs' drawings side by side, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", r.data["comparison"]["withEngine"].as_str().unwrap_or("another run"), engine, r.data["comparison"]["withEngine"].as_str().unwrap_or(""), r.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));-        }-    }-    // sessions-    md.push_str("## Sessions\n\nOne row per human prompt: the AI's time on it, unobserved gaps excluded by the heuristic, which may include external AI work.\n\n");-    md.push_str(&sessions_table(r));-    md.push_str("\n");-    // per-step table-    let rep = r.step_report();-    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];-    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();-    for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }-    md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");-    let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);-    for n in &names {-        let e = &rep[n];-        let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));-        tw += w; tt += t; tb += b;-        md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));-    }-    md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));-    // returns-    let mut returns: Vec<String> = Vec::new();-    for e in &led {-        if e["event"] == "step" && e["back"].as_bool() == Some(true) {-            returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));-        }-    }-    if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }-    // decisions and markers-    let mut marks: Vec<String> = Vec::new();-    for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }-    if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }-    // outcomes-    let o = r.outcomes();-    md.push_str("## Where the board stands\n\n");-    if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }-    if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }-    if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }-    if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours: {} groups, failed {}\n", x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }-    if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }-    for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }-    md.push_str("\n");-    // clips and screenshots per visit-    md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    // clip seconds from the ledger's artifacts-    let mut secs: std::collections::BTreeMap<String, (f64, f64, f64)> = std::collections::BTreeMap::new(); // real, 10x, action-    for e in &led {-        if e["event"] == "artifact" {-            let key = e["step"].as_str().unwrap_or("").to_string();-            let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));-            match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }-        }-    }-    let standing = standing_artifacts(&led);-    let what_of = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };-    for c in &caps {-        if c.get("discarded").is_some() { continue; }-        let Some(step) = c["step"].as_str() else { continue };-        let visit = c["visit"].as_u64().unwrap_or(1);-        let tag = format!("{step}-{visit}");-        let rework = c["back"].as_bool() == Some(true);-        let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };-        md.push_str(&format!("### {title}\n\n"));-        md.push_str(&format!("What this step does: {}\n\n", what_of(step)));-        if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }-        if let Some(sus) = c["suspect"].as_str() { md.push_str(&format!("**This clip filmed nothing usable: {sus}. It is kept as evidence and left out of the video.**\n\n")); }-        let (start, stop) = (c["start"].as_str().unwrap_or(""), c["stop"].as_str().unwrap_or("still running"));-        // the 10x cut next to the raw clip-        let mut have_clip = false;-        if let Some(f) = c["file"].as_str() {-            let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));-            let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));-            let (real, quick, act) = secs.get(&tag).copied().unwrap_or((0.0, 0.0, 0.0));-            if Path::new(&action).is_file() {-                let _ = std::fs::copy(&action, out.join(format!("{tag}.mp4")));-                md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}. Motion only: {:.1} min of real time, {:.0} s of it had something moving; the clock in the corner is the real elapsed time.**\n\n", real / 60.0, act));-                md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));-                have_clip = true;-            } else if Path::new(&fast).is_file() {-                let _ = std::fs::copy(&fast, out.join(format!("{tag}.mp4")));-                md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}, {:.1} min of real time shown at 10x ({:.0} s).**\n\n", real / 60.0, quick));-                md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));-                have_clip = true;-            }-        }-        if !have_clip { md.push_str(&format!("**Clip: {title}, still recording (started {start}); it appears here when the step ends.**\n\n")); }-        if let Some(f) = c["file"].as_str() {-            let sheet = format!("{}-sheet.png", f.trim_end_matches(".mp4"));-            if Path::new(&sheet).is_file() {-                let _ = std::fs::copy(&sheet, out.join(format!("{tag}-sheet.png")));-                md.push_str(&format!("**Contact sheet: nine frames spread through the {title} clip, what the camera saw.**\n\n![nine frames of {title}]({blob}/{tag}-sheet.png)\n\n"));-            }-        }-        for when in ["start", "end"] {-            let src = dir.join(format!("shot-{tag}-{when}.png"));-            if src.is_file() {-                let _ = std::fs::copy(&src, out.join(format!("shot-{tag}-{when}.png")));-                md.push_str(&format!("**Screenshot: the board when {title} {}.**\n\n", if when == "start" { "began" } else { "ended" }));-                md.push_str(&format!("![the board when {title} {}]({blob}/shot-{tag}-{when}.png)\n\n", if when == "start" { "began" } else { "ended" }));-            }-        }-        // what the AI made during this visit: its own analysis drawings, with its caption above each-        for (n, e) in standing.iter().filter(|e| e["step"].as_str() == Some(step) && e["visit"].as_u64() == Some(visit)).enumerate() {-            let Some(f) = e["file"].as_str() else { continue };-            if !Path::new(f).is_file() { continue; }-            let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("png");-            let name = format!("{tag}-{}-{n}.{ext}", e["kind"].as_str().unwrap_or("artifact"));-            let _ = std::fs::copy(f, out.join(&name));-            let cap = e["caption"].as_str().unwrap_or("");-            if matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp") {-                md.push_str(&format!("**The AI's drawing: {cap}**\n\n![{cap}]({blob}/{name})\n\n"));-            } else {-                md.push_str(&format!("**The AI's file: {cap}** [{name}]({blob}/{name})\n\n"));-            }-        }-    }-    if let Some(d) = r.data["delivery"].as_object() {-        md.push_str("## Delivered\n\n");-        md.push_str(&format!("What the AI said when it delivered (the video is at the top of this page):\n\n> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));-    }-    let ud = usage_delta(r);-    if !ud.is_empty() {-        md.push_str("## Plan usage, the second cost line\n\nPercent of each plan limit used, as the provider reports it, at the start of the run and at its end.\n\n| Provider | Limit | At start | At end | Used by this run |\n|---|---|---|---|---|\n");-        for x in &ud { md.push_str(&format!("| {} | {} | {:.0} % | {:.0} % | {:.1} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }-        md.push_str("\n*The limits are per account, not per run: every AI thread signed in as this user draws on the same numbers, so a run made while other threads were working shows their usage too. Read it as an upper bound unless this run was the only thread active.*\n\n");-    }-    let gb = r.data["giveback"].as_array().cloned().unwrap_or_default();-    md.push_str(&format!("## Given back\n\n{} issue(s) or PR(s) filed by this run's AI to make the tool better for every Adom user{}\n", gb.len(), if gb.is_empty() { ".".to_string() } else { ":\n\n".to_string() + &gb.iter().map(|g| format!("- {} {}{}", g["kind"].as_str().unwrap_or("issue"), g["url"].as_str().unwrap_or(""), g["note"].as_str().map(|n| format!(": {n}")).unwrap_or_default())).collect::<Vec<_>>().join("\n") }));-    md.push_str("\nAdom processes PRs within minutes during business hours: a non-stop PR intake runs all day.\n");-    let readme = out.join("README.md");-    let _ = std::fs::write(&readme, md);-    (stage, rel, readme)-}--/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.-fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {-    let (stage, rel, _readme) = build_report(r, dir, page);-    // the files viewer renders a sub-README as a page; the bare path serves raw markdown-    let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");-    let mut notes = Vec::new();-    if push {-        // only the files that changed since the last push go up, in batches the gateway takes-        // (about 20 MB each; a 75 MB report in one request came back 502); a failed batch is said-        // out loud and the caller turns it into an ERROR-        let cache_path = dir.join("report").join(".pushed.json");-        let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();-        let mut files: Vec<(String, u64, String)> = Vec::new();-        if let Ok(d) = std::fs::read_dir(stage.join(&rel)) {-            for e in d.filter_map(|e| e.ok()) {-                let name = e.file_name().to_string_lossy().to_string();-                if name.starts_with('.') { continue; }-                let size = e.metadata().map(|m| m.len()).unwrap_or(0);-                let sum = std::process::Command::new("sha256sum").arg(e.path()).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();-                files.push((format!("{rel}/{name}"), size, sum));-            }-        }-        files.sort();-        let changed: Vec<&(String, u64, String)> = files.iter().filter(|(p, _, sum)| cache.get(p).and_then(|v| v.as_str()) != Some(sum.as_str()) || sum.is_empty()).collect();-        let (mut pushed, mut failed) = (0usize, 0usize);-        let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];-        let mut acc = 0u64;-        for f in &changed {-            if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }-            batches.last_mut().unwrap().push(f); acc += f.1;-        }-        for batch in batches.iter().filter(|b| !b.is_empty()) {-            let mut cmd = std::process::Command::new("adom-wiki");-            cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("aiflow run {}: {}", report_id(r), r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);-            for f in batch { cmd.arg(&f.0); }-            match cmd.output() {-                Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }-                Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())); }-                Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }-            }-        }-        let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());-        notes.insert(0, format!("pushed {pushed} changed file(s) to {page}/{rel} ({} unchanged skipped{})", files.len() - changed.len(), if failed > 0 { format!("; {failed} FAILED") } else { String::new() }));-    }-    if refresh && r.data["reportSurface"].as_str() == Some("webview") {-        // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle-        let bust = format!("{url}?v={}", now().replace([':', '-'], ""));-        let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());-        fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }-        let mut ids = Vec::new();-        if let Some(w) = &ws { leaves(&w["root"], &mut ids); }-        let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();-        let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();-        match panel {-            Some(pid) => {-                let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();-                notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });-            }-            None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),-        }-    } else if refresh {-        if let Some(m) = r.data["userMachine"].as_str() {-            let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };-            let bust = format!("{url}?v={}", now().replace([':', '-'], ""));-            let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));-            if !Bridge::ok(&nav) {-                let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));-                notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });-            } else {-                notes.push(format!("refreshed in the browser on {m}"));-            }-            let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));-        }-    }-    (url, notes)-}--fn board_of(r: &Run) -> Board {-    if r.board_pending() { err("this run has no board yet (an intake run)", &["Attach it when it exists: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (same run, same clock). Pre-board steps (sourcing, design, simulation, schematic) need no board.".into()]); }-    Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))-}--/// A Fusion run's live board, pulled fresh: `board-live.brd` (Fusion's own XML, fills included,-/// what adom-fields reads) and `board-live.kicad_pcb` (the same board for the offline checks).-/// None for a KiCad run, whose current board file is already the one to analyze.-fn fusion_live(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, String)> {-    let doc = aiflow_bridge::fusion::active()?;-    let br = bridge_of(r, cli);-    let mut reply = br.raw_call("fusion_board_source", &json!({"expectDocument": doc}));-    for _ in 0..3 {-        if reply["errorCode"] != "main_thread_busy" && !reply["error"].as_str().unwrap_or("").contains("main thread busy") { break; }-        std::thread::sleep(std::time::Duration::from_secs(5));-        reply = br.raw_call("fusion_board_source", &json!({"expectDocument": doc}));-    }-    let text = reply["source"].as_str().filter(|_| reply["success"].as_bool() == Some(true)).unwrap_or_else(|| err(&format!("could not read the live Fusion board {doc}: {}", reply["error"]), &["Activate the board in Fusion (fusion_show_2d_board), then retry. fusion_board_source needs bridge 1.11.17 or later.".into()]));-    let brd = dir.join("board-live.brd");-    let kicad = dir.join("board-live.kicad_pcb");-    std::fs::write(&brd, text).unwrap_or_else(|e| err(&e.to_string(), &[]));-    let converted = aiflow_board::eagle::to_kicad(text).unwrap_or_else(|e| err(&format!("live Fusion board: {e}"), &[]));-    std::fs::write(&kicad, converted).unwrap_or_else(|e| err(&e.to_string(), &[]));-    Some((brd.display().to_string(), kicad.display().to_string()))-}--fn bridge_of(r: &Run, cli: &Cli) -> Bridge {-    let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();-    Bridge { ai_thread: thread(cli), target }-}--fn write_json(p: &Path, v: &Value) {-    std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));-}--/// The command as typed, without the thread and run plumbing.-fn command_line() -> String {-    let args: Vec<String> = std::env::args().skip(1).collect();-    let mut out = Vec::new();-    let mut skip = false;-    for a in args {-        if skip { skip = false; continue; }-        if a == "--ai-thread" || a == "--run" { skip = true; continue; }-        if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }-        out.push(a);-    }-    out.join(" ")-}--// ----------------------------------------------------------------------------- design gates (checks.rs)--fn sha256_file(p: &str) -> String {-    use sha2::{Digest, Sha256};-    std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()-}--/// Export a schematic's netlist (kicadsexpr) with the native kicad-cli: ADOM_AIFLOW_KICAD_CLI, else-/// `kicad-cli`. In a container with no native KiCad, adom-aiflow-kicad-cli-remote runs a desktop's.-fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> {-    let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());-    let o = std::process::Command::new(&cli).args(["sch", "export", "netlist", "--format", "kicadsexpr", "--output"]).arg(out).arg(sch).output()-        .map_err(|e| format!("{cli} is not runnable: {e}"))?;-    if !o.status.success() || !out.is_file() {-        let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(500).collect::<String>().chars().rev().collect::<String>() };-        return Err(format!("{cli} sch export netlist failed: {} {}", tail(&o.stdout), tail(&o.stderr)));-    }-    Ok(cli)-}--/// The run's first board (what `start` copied): the reference for fixed parts.-fn first_board(r: &Run) -> Option<Board> {-    r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()).and_then(|p| Board::load(p).ok())-}--/// Molecule conformance of a board when the spec declares a molecule; None otherwise.-fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<checks::Findings> {-    if spec.get("molecule").is_none() { return None; }-    let before = r.and_then(first_board);-    Some(checks::molecule_check(board, spec, before.as_ref()))-}--/// Why the run's schematic and current board are not proven equal, or Ok when they are: the-/// recorded `netlist check` passed on this schematic (same sha256), and its exported netlist still-/// matches the current board (placement, routing or an adopted board cannot change a pin's net-/// unnoticed).-fn netlist_gate(r: &Run) -> Result<String, String> {-    let Some(sch) = r.data["schematic"].as_str() else { return Ok("no schematic recorded".into()) };-    let o = r.outcomes().get("netlist").cloned().ok_or("netlist check (the run has a schematic; run `adom-aiflow netlist check`)")?;-    if o["sha256"].as_str() != Some(sha256_file(sch).as_str()) { return Err("netlist check (the schematic changed since the last check; run it again)".into()); }-    let net = o["netlist"].as_str().unwrap_or("");-    let sn = checks::parse_sch_netlist(&std::fs::read_to_string(net).map_err(|e| format!("netlist check ({net}: {e}; run it again)"))?).map_err(|e| format!("netlist check ({e})"))?;-    let rep = checks::compare_netlists(&sn, &Board::load(&r.current_board()).map_err(|e| format!("netlist check ({e})"))?);-    if rep["ok"] != true { return Err(format!("netlist check (the current board differs from the schematic: {} mismatched pin(s), missing {}, extra {})", rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), rep["missingRefs"], rep["extraRefs"])); }-    Ok(format!("{}/{} pins", rep["matchedPins"], rep["connectedPins"]))-}--/// The 3D orientation pass of the models step: every model bound on the board, its bbox from-/// step2glb (or its STEP points), transformed as KiCad places it, judged. Returns-/// (warnings as [{ref, model, finding}], notes on what could not be checked). Bboxes are cached per-/// file (path, size, mtime) in the run directory so reruns do not call the shared service again.-fn model_orientation(board: &Board, project_dir: &Path, cache_path: Option<&Path>) -> (Vec<Value>, Vec<String>) {-    let mut cache: serde_json::Map<String, Value> = cache_path.and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();-    let (mut warnings, mut notes, mut calls) = (Vec::new(), Vec::new(), 0usize);-    let mut unresolved: BTreeMap<String, Vec<String>> = BTreeMap::new();-    for m in checks::model_refs(board) {-        let Some(file) = checks::resolve_model(&m.path, project_dir) else { unresolved.entry(m.path.clone()).or_default().push(m.reference.clone()); continue };-        let meta = std::fs::metadata(&file).ok();-        let key = format!("{}|{}|{}", file.display(), meta.as_ref().map(|x| x.len()).unwrap_or(0), meta.and_then(|x| x.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0));-        let got = match cache.get(&key) {-            Some(v) => serde_json::from_value::<checks::BBox>(v["bbox"].clone()).ok().map(|b| (b, v["source"].as_str().unwrap_or("").to_string())),-            None => {-                let g = checks::model_bbox(&file, &mut calls);-                if let Some((b, src)) = &g { cache.insert(key, json!({"bbox": b, "source": src})); }-                g-            }-        };-        let Some((bb, src)) = got else { notes.push(format!("{} ({}): no bbox from step2glb or the STEP points", m.reference, file.display())); continue };-        let placed = checks::transform_bbox(&bb, m.offset, m.scale, m.rotate);-        for finding in checks::orientation_findings(&m, &placed) {-            warnings.push(json!({"ref": m.reference, "model": file.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), "rotate": m.rotate, "offset": m.offset, "bboxSource": src, "finding": finding}));-        }-    }-    for (p, refs) in unresolved { notes.push(format!("not checked here (path does not resolve in this container): {p} ({})", refs.join(","))); }-    if let Some(p) = cache_path { let _ = std::fs::write(p, serde_json::to_string_pretty(&Value::Object(cache)).unwrap_or_default()); }-    (warnings, notes)-}--/// Orientation warnings the AI has not acknowledged (run.data.modelAcks: {ref: why}).-fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> {-    let mut refs: Vec<String> = warnings.iter().filter_map(|w| w["ref"].as_str()).filter(|x| r.data["modelAcks"].get(*x).is_none()).map(str::to_string).collect();-    refs.dedup();-    refs-}--fn project_dir_of(r: &Run) -> PathBuf {-    let src = r.data["sourceBoard"].as_str().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r.current_board()));-    src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))-}--fn main() {-    let cli = Cli::parse();-    let dir = run_dir(&cli);-    // every command is a turn in the run's ledger; the gap before it is the AI's thinking time-    if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. }) {-        if let Some(mut r) = Run::open(&dir) {-            let idx = r.turn_begin(&command_line());-            let _ = r.save();-            let _ = TURN.set((dir.clone(), idx));-            // the clip guard: a recording that has outrun its step's budget is a mistake, not evidence.-            // Stop it here, on whatever command comes next, before it eats the disk (one once ran-            // nine hours on a static editor because deliver did not stop it).-            if let (Some(target), Some(t)) = (r.data["target"].as_str().map(str::to_string), cli.ai_thread.clone()) {-                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                    let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);-                    let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);-                    let step = caps[pos]["step"].as_str().unwrap_or("").to_string();-                    let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, "sourcing" => 20, "design" | "simulation" | "schematic" => 30, _ => 10 } as u64;-                    if age > budget_min * 60 * 2 || age > 3600 {-                        let br = Bridge { ai_thread: t, target };-                        if let Some(mut r2) = Run::open(&dir) {-                            r2.mark(&format!("clip guard: the {step} clip ran {} min, past its {budget_min} min budget twice over; stopped by the guard", age / 60));-                            stop_running_clip(&mut r2, &br, &dir, "guard");-                            let _ = r2.save();-                            eprintln!("clip guard: stopped the {step} clip after {} min (budget {budget_min} min)", age / 60);-                        }-                    }-                }-            }-        }-    }-    match &cli.cmd {-        Cmd::Exec { command } => {-            thread(&cli); let mut r=load_run(&cli);-            r.log("external-start",json!({"program":command[0],"args":&command[1..]}));-            r.save().unwrap();-            let status=std::process::Command::new(&command[0]).args(&command[1..]).status();-            let mut r=load_run(&cli);-            match status {-                Ok(status)=>{-                    r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success()}));r.save().unwrap();-                    if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}-                    ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);-                }-                Err(e)=>err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()])-            }-        }-        Cmd::SilkscreenSections { input, out } => {-            thread(&cli); let _r=load_run(&cli);-            let data:serde_json::Value=serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e|err(&e.to_string(), &[]))).unwrap_or_else(|e|err(&e.to_string(), &[]));-            let result=silkscreen_sections::solve(&data).unwrap_or_else(|e|err(&e,&[]));-            std::fs::write(out,serde_json::to_vec_pretty(&result).unwrap()).unwrap_or_else(|e|err(&e.to_string(), &[]));-            if !result["unresolved"].as_array().unwrap().is_empty(){err("Documentation sections remain unresolved", &["Keep rows together; try a shorter line layout, another interior region or a readable font. Do not scatter sentences.".into()]);}-            let fallbacks=result["sections"].as_array().unwrap().iter().filter(|s|s["edgeFallback"]==true).count();-            ok(&format!("Native Rust placed {} documentation labels; {fallbacks} edge fallbacks",result["placements"].as_array().unwrap().len()), &["Final documentation pass: prefer open board interior, preserve left-aligned sections and include a source-backed board description. Physical pin/contact labels remain local. Review edge fallbacks, font sizes, native DRC and BOTH EDA views.".into()]);-        }-        Cmd::SilkscreenPreflight { input, out } => {-            thread(&cli); let _r=load_run(&cli);-            let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));-            if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}-            ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);-        }-        Cmd::BoardGlb { action, glb, board, out, finish, paste, report } => {-            let mut run = Run::open(&dir);-            if run.is_some() { thread(&cli); }-            let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));-            let spec_finish = run.as_ref().map(spec_soft).and_then(|v| v["fab"]["finish"].as_str().map(String::from));-            let finish = finish.clone().or(spec_finish).unwrap_or_else(|| "copper".into());-            let mut command = std::process::Command::new(aiflow_python());-            command.arg("-c").arg(include_str!("../../../tools/board-glb-copper.py")).arg(action).arg("--glb").arg(glb).arg("--board").arg(&board_path).arg("--finish").arg(&finish).arg("--paste").arg(paste);-            if let Some(v) = out { command.arg("--out").arg(v); }-            if let Some(v) = report { command.arg("--report").arg(v); }-            let result = command.output().unwrap_or_else(|e| err(&format!("board-glb needs python3 with shapely and numpy: {e}"), &["Reinstall adom-aiflow so its isolated Python environment exists.".into()]));-            let text = format!("{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));-            if let Some(r) = run.as_mut() {-                r.outcome("boardGlb", json!({"ok": result.status.success(), "action": action, "glb": out.as_ref().unwrap_or(glb), "board": board_path, "finish": finish, "paste": paste}));-                r.save().unwrap_or_else(|e| err(&e, &[]));-            }-            if !result.status.success() {-                err(&text, &["Pads and test points are always real copper (or the board's stated finish), and the mask opens exactly where the footprint mask layer says. Fix it by construction with `adom-aiflow board-glb copper`; never hand-recolour a material index or lift the pads.".into(), "Use the .kicad_pcb the STEP was exported from, and export the STEP with --include-pads --include-soldermask (skill aiflow-molecule).".into()]);-            }-            ok(&text, &["Look at it before shipping: render a close-up of the test points and an under-part pad (skill aiflow-molecule). Grey or pale pads are not done.".into()]);-        }-        Cmd::SilkscreenMask { board, out } => {-            thread(&cli); let _r=load_run(&cli);-            let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));-            if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}-            ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);-        }-        Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {-            thread(&cli); let _r=load_run(&cli);-            let mut command=std::process::Command::new(aiflow_python());command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);-            if *no_contact_borders{command.arg("--no-contact-borders");}-            let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));-            if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}-            ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);-        }-        Cmd::SilkscreenAudit { input, out } => {-            thread(&cli); let _r = load_run(&cli);-            let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));-            if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }-            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);-        }-        Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {-            let t = thread(&cli); let _r = load_run(&cli);-            let mut command = std::process::Command::new("adom-aiflow-silkscreen");-            command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);-            if let Some(v)=url { command.arg("--url").arg(v); }-            if let Some(v)=target { command.arg("--target").arg(v); }-            if let Some(v)=json { command.arg("--json").arg(v); }-            if let Some(v)=reason { command.arg("--reason").arg(v); }-            let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));-            if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}-            ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);-        }-        Cmd::SilkscreenLayout { input, out, events } => {-            thread(&cli);-            let _r = load_run(&cli);-            let source = include_str!("../../../tools/silkscreen-layout.py");-            let mut command = std::process::Command::new(aiflow_python());-            command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);-            if let Some(v)=events {command.arg("--events").arg(v);}-            let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));-            if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }-            ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);-        }-        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page } => {-            let t = thread(&cli);-            if !Path::new(board).is_file() {-                err(&format!("board not found: {board}"), &[]);-            }-            if !Path::new(spec).is_file() {-                err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);-            }-            if let Some(s) = sch { if !Path::new(s).is_file() { err(&format!("schematic not found: {s}"), &["--sch names the .kicad_sch the board was made from.".into()]); } }-            // a run made by `intake` has no board yet: start attaches it and keeps the clock and the ledger-            let intake_run = Run::open(&dir).filter(|r| r.board_pending() && !*force);-            if let (Some(r), false, true) = (Run::open(&dir), *force, intake_run.is_none()) {-                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);-            }-            std::fs::create_dir_all(&dir).unwrap();-            let copy = dir.join("board-0.kicad_pcb");-            let src_text = std::fs::read_to_string(board).unwrap_or_default();-            if aiflow_board::eagle::is_eagle(&src_text) {-                // the Fusion lane: the plan board is the Fusion export rendered as .kicad_pcb; the live-                // board is the Fusion document named by --remote-board fusion:<doc>-                if !remote_board.as_deref().map(|b| b.starts_with(aiflow_bridge::fusion::PREFIX)).unwrap_or(false) {-                    err("a Fusion board needs --remote-board fusion:<exact board document name> (and --target <box>)", &["Export it with fusion_export_eagle_source {outputPath:\"...brd\"} on the Fusion bridge, pull it here, and name the open board document: --remote-board fusion:Buck-brd.".into()]);-                }-                let text = aiflow_board::eagle::to_kicad(&src_text).unwrap_or_else(|e| err(&e, &[]));-                std::fs::write(&copy, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));-                aiflow_bridge::fusion::set_run_board(remote_board.as_deref().unwrap_or(""));-            } else {-                std::fs::copy(board, &copy).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));-                aiflow_copper::copy_project_rules(board, &copy.display().to_string()).unwrap_or_else(|e|err(&e,&[]));-            }-            let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());-            if let Some(mut r) = intake_run {-                let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("").to_string();-                let differs = prompt_time.as_ref().filter(|p| **p != pt).map(|p| format!("--prompt-time {p} ignored: the intake run's clock started at {pt} and stays there. "));-                r.attach_board(&copy.display().to_string(), &specp, engine, target.as_deref(), remote_board.as_deref());-                r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));-                if let Some(p) = page { r.data["page"] = json!(p); }-                if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }-                let idx = r.turn_begin(&command_line());-                record_usage(&mut r, "start");-                r.save().unwrap();-                let _ = TURN.set((dir.clone(), idx));-                let steps: Vec<String> = r.data["steps"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();-                ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &[differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()]);-                return;-            }-            let pt = prompt_time.clone().unwrap_or_else(now);-            let mut r = Run::create(&dir, &copy.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));-            r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));-            if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }-            if let Some(p) = page { r.data["page"] = json!(p); }-            let idx = r.turn_begin(&command_line());-            record_usage(&mut r, "start");-            r.save().unwrap();-            let _ = TURN.set((dir.clone(), idx));-            let mut start_hints = vec![format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display())];-            if sch.is_some() { start_hints.push("The run has a schematic: `adom-aiflow netlist check` proves the board equals it pin by pin; finish requires it.".into()); }-            else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }-            let spec_v = read_json(spec);-            if let Some(p) = checks::rules_profile(&spec_v) { if let Err(e) = checks::rules_check(&p, board) { start_hints.push(format!("Fab rules: {e}. {}", checks::rules_hint(&p))); } }-            if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }-            start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);-            ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);-        }-        Cmd::Intake { prompt_time, brief, requirements, engine, target, page } => {-            let t = thread(&cli);-            if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }-            if let Some(r) = Run::open(&dir) {-                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);-            }-            for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }-            let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());-            // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both-            let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));-            let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[]));-            if let Some(p) = page { r.data["page"] = json!(p); }-            let idx = r.turn_begin(&command_line());-            record_usage(&mut r, "start");-            r.save().unwrap();-            let _ = TURN.set((dir.clone(), idx));-            let profile = preboard::named_profile(&requirements_of(&r));-            ok(&format!("intake run started at {}; the clock started at the prompt, {prompt_time}; step intake is open and there is no board yet", dir.display()), &[-                "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step records a clip of the desktop until a PCB editor is open.".into(),-                match profile { Some(p) => format!("Sourcing profile {p} (from the requirements): `sourcing check --bom design/bom.csv` gates the BOM; finish requires it."), None => "No sourcing profile named yet: write requirements.json with fab.target `fab` (default) or `jlcpcb` (aiflow-intake), then `sourcing check --bom <csv> --requirements requirements.json`.".into() },-                "Record the design record as it lands: `evidence add --kind calcs --file design/calcs.json`, `evidence add --kind simulation --file sim/<results>.json` (a failing simulation is refused), `evidence add --kind vendor-model --file <result>` for a vendor PSpice/LTspice run (adom/adom-spice-skillpack).".into(),-                "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),-                if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },-            ]);-        }-        Cmd::Sourcing { what, bom, profile, qty, board, requirements } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>`"), &[]); }-            if let Some(q) = requirements {-                if !Path::new(q).is_file() { err(&format!("not found: {q}"), &[]); }-                r.data["requirements"] = json!(std::fs::canonicalize(q).map(|p| p.display().to_string()).unwrap_or(q.clone()));-            }-            let (spec, req) = (spec_soft(&r), requirements_of(&r));-            let named = preboard::named_profile(&spec).map(|p| (p, "the spec")).or_else(|| preboard::named_profile(&req).map(|p| (p, "the requirements")));-            let prof = profile.clone().or_else(|| named.as_ref().map(|n| n.0.clone())).unwrap_or_else(|| "fab".into());-            let override_note = match (profile, &named) { (Some(p), Some((n, by))) if p != n => format!("--profile {p} overrides {by}, which name {n}; finish checks against the recorded outcome, so re-run without --profile if that was not meant."), _ => String::new() };-            let (qty, qty_src) = match qty { Some(q) => (*q, "--qty"), None => match req["build"]["quantity"].as_u64().or(req["buildQuantity"].as_u64()).map(|q| (q, "the requirements")).or_else(|| spec["buildQuantity"].as_u64().map(|q| (q, "the spec"))) { Some(x) => x, None => (10, "the default; say --qty or requirements build.quantity") } };-            let text = std::fs::read_to_string(bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));-            let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));-            let refs = board_path.as_ref().map(|p| Board::load(p).map(|b| preboard::board_refs(&b)).unwrap_or_else(|e| err(&format!("board {p}: {e}"), &[])));-            let mut out = preboard::sourcing_check(&text, refs.as_ref(), &prof, qty);-            let bomp = std::fs::canonicalize(bom).map(|p| p.display().to_string()).unwrap_or(bom.clone());-            out["bom"] = json!(bomp);-            out["bomSha256"] = json!(library_review::hash(Path::new(bom)).unwrap_or_default());-            out["board"] = json!(board_path);-            out["checkedAt"] = json!(now());-            out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") });-            out["qtySource"] = json!(qty_src);-            r.outcome("sourcing", out.clone());-            r.save().unwrap();-            let warnings: Vec<String> = out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("warning: {s}"))).collect();-            let rule = if prof == "jlcpcb" { "jlcpcb: every row carries its LCSC number (JLCPCB/LCSC basic parts first)" } else { "fab: every row from Mouser or Adom stock, no LCSC/JLCPCB parts (the 3rd party fab)" };-            if out["pass"] != true {-                let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("  {s}"))).collect();-                let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note];-                hints.extend(warnings);-                err(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints);-            }-            let diff = if out["boardChecked"] == true { format!("every one of the board's {} BOM references is in the BOM ({} excluded by their footprints)", out["boardRefs"], out["excludedByBoard"].as_array().map(|a| a.len()).unwrap_or(0)) } else { "no board yet, so no reference diff: check again after `start --board` (finish requires the diff)".into() };-            let mut hints = vec![format!("Profile {rule}; build quantity {qty} ({qty_src})."), override_note];-            hints.extend(warnings);-            hints.push("Next: `evidence add --kind calcs --file design/calcs.json` once the values are derived (aiflow-circuit-design).".into());-            ok(&format!("sourcing check passed on the {prof} profile: {} rows, {} references ({} Adom stock, {} distributor, {} no part), stock checked {}; {diff}", out["rows"], out["refs"], out["adomStockRows"], out["distributorRows"], out["noPartRows"], out["stockCheckDates"].as_array().map(|a| a.iter().filter_map(|d| d.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()), &hints);-        }-        Cmd::Evidence { what, kind, file, note } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if what != "add" { err(&format!("evidence {what}: use `evidence add --kind calcs|simulation|vendor-model --file <json|md>`"), &[]); }-            let src = PathBuf::from(file);-            if !src.is_file() { err(&format!("no such file: {file}"), &[]); }-            let text = std::fs::read_to_string(&src).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["Evidence is a JSON or Markdown file.".into()]));-            let is_json = src.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("json")).unwrap_or(false);-            if is_json && serde_json::from_str::<Value>(&text).is_err() { err(&format!("{file}: not JSON"), &[]); }-            let sha = library_review::hash(&src).unwrap_or_else(|e| err(&e, &[]));-            let (top, checks, failing) = preboard::evidence_checks(&text, is_json);-            let pass = if top == Some(false) || !failing.is_empty() { Some(false) } else if top == Some(true) || !checks.is_empty() { Some(true) } else { None };-            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());-            let step = r.data["currentStep"].clone();-            if kind == "simulation" && pass == Some(false) {-                r.log("evidence-refused", json!({"kind": kind, "file": canon.display().to_string(), "sha256": sha, "failing": failing, "pass": top}));-                r.save().unwrap();-                err(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]);-            }-            // a copy in the run keeps the record when the design folder moves on-            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());-            let copy = if canon.starts_with(&rdir) { canon.clone() } else {-                let d = dir.join("evidence");-                let _ = std::fs::create_dir_all(&d);-                let dest = d.join(format!("{kind}-{}", src.file_name().and_then(|n| n.to_str()).unwrap_or("file")));-                std::fs::copy(&src, &dest).unwrap_or_else(|e| err(&format!("copy into the run: {e}"), &[]));-                std::fs::canonicalize(&dest).unwrap_or(dest)-            };-            let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks});-            if !r.data["evidence"].is_array() { r.data["evidence"] = json!([]); }-            r.data["evidence"].as_array_mut().unwrap().push(entry.clone());-            r.log("evidence", entry);-            r.save().unwrap();-            let n = r.data["evidence"].as_array().map(|a| a.iter().filter(|e| e["kind"] == json!(kind)).count()).unwrap_or(0);-            let state = match pass { Some(true) => format!("pass ({} check(s))", checks.len()), Some(false) => format!("FAIL ({} failing check(s))", failing.len()), None => "no pass/fail fields".into() };-            let hint = match kind.as_str() {-                "calcs" => "Next: simulate the values the equations cannot settle (loop, startup, load step; aiflow-simulate), then `evidence add --kind simulation --file sim/<results>.json` with a top-level \"pass\" or \"checks\": [{\"name\",\"value\",\"limit\",\"pass\"}] so the gate can read it.".to_string(),-                "simulation" => if pass.is_none() { "This result states no pass or fail: add a top-level \"pass\" or a \"checks\" list against the requirements so the gate can read it (the file is recorded either way).".to_string() } else { "Next: the vendor model if the brief asks for one (`evidence add --kind vendor-model`), then `step schematic`.".to_string() },-                _ => format!("Vendor PSpice/LTspice runs belong to the adom/adom-spice-skillpack (`adom-wiki pkg install adom/adom-spice-skillpack`: PSpice for TI and LTspice on a Windows desktop through Adom Bridge); this records its result{}. Publish results, never solver speed numbers.", if pass == Some(false) { ", which says it fails: write the design conclusion in sim/<vendor>-validation.md" } else { "" }),-            };-            ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &[hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()]);-        }-        Cmd::Clips { page, push } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if let Some(p) = page { r.data["clipsPage"] = json!(p); r.save().unwrap(); }-            let pg = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));-            match publish_clips(&r, &dir, &pg, *push) {-                Ok((said, files)) => ok(&said, &[if *push { "Look at the rendered page once (a pup tab or a Hydrogen webview on the human's machine): a successful push is not a working page. Link docs/clips.md from the README once (wiki-sub-readme).".to_string() } else { format!("Would push {} file(s): {}", files.len(), files.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) + if files.len() > 8 { ", ..." } else { "" } }, "From now on every clip stop rebuilds and pushes this page by itself; write each clip's note in <run>/clip-notes.json (step, shows, state) after you look at its contact sheet (aiflow-live-clips).".into()]),-                Err(e) => err(&e, &["Fix the page text (clip-notes.json is where notes come from), then `clips --push` again.".into()]),-            }-        }-        Cmd::Widget { action, file, label, step, progress } => {-            let t = thread(&cli);-            let _r = load_run(&cli);-            let mut cmd = std::process::Command::new("adom-aiflow-widget");-            cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);-            if let Some(v) = file { cmd.arg("--file").arg(v); }-            if let Some(v) = label { cmd.arg("--label").arg(v); }-            if let Some(v) = step { cmd.arg("--step").arg(v); }-            if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }-            let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));-            if !status.success() { err("widget helper failed", &[]); }-            ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);-        }-        Cmd::AdoptBoard { board, evidence } => {-            thread(&cli); let mut r=load_run(&cli);-            if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);}-            Board::load(board).unwrap_or_else(|e|err(&e,&[]));-            let base=aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e|err(&e,&[]));-            let candidate=aiflow_copper::run_drc(board).unwrap_or_else(|e|err(&e,&[]));-            let summary=aiflow_copper::summarize(&candidate,Some(&base));-            if summary["newErrors"].as_u64()!=Some(0){err(&format!("adopt-board adds native DRC violations: {}",summary["newErrorList"]), &["Correct the new violations and rerun adopt-board. Existing connectivity may remain for later routing.".into()]);}-            let n=r.data["boards"].as_array().map(|a|a.len()).unwrap_or(1);-            let dest=dir.join(format!("board-{n}.kicad_pcb"));-            std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));-            aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));-            r.add_board(&dest.display().to_string());-            r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary}));-            r.save().unwrap();-            ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]);-        }-        Cmd::Components { etch } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let b = board_of(&r);-            let path = dir.join("components.json");-            let old: Value = if path.exists() {-                let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));-                serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))-            } else { json!({}) };-            let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("on");-            let mut entries = Vec::new();-            for fp in b.root.find_all("footprint") {-                let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();-                let Some(reference) = props.get("Reference") else { continue; };-                let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));-                let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());-                if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {-                    entry["review"]["status"] = json!("stale");-                    entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");-                }-                entry["properties"] = json!(props);-                entry["footprint"] = json!(fp.value());-                entry["modelReferences"] = models;-                entries.push(entry);-            }-            let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,-                "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});-            std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));-            r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});-            r.save().unwrap_or_else(|e| err(&e, &[]));-            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();-            let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();-            hints.push(format!("MPN marking preference: {preference} (default on: every IC's model carries its laser-etched MPN, and finish checks it). Mark a model with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>`: the mark planner places it (along the longest usable dimension, between the terminations, as large as fits, upright with pin 1 lower left), the shared service writes it with the model's colours, and the legibility gate must say PASS; look at <etched>.check.png before you report (Marking checklist in the adom-aiflow skill). 'off' keeps plain models; 'ask' offers the option. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));-            ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);-        }-        Cmd::Plan => {-            let mut r = load_run(&cli);-            // the flow is a file: the steps in order, who does each, what the binary offers, what comes later-            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            r.data["flow"] = flow.clone();-            let spec = spec_of(&r);-            let b = board_of(&r);-            let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();-            let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);-            let stages = json!({-                "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},-                "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},-                "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},-                "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},-                "land": {"engines": ["binary"], "default": "binary", "why": "native bridge undo transactions with candidate DRC; pours use the complete explicitly owned zone plan without a delete/create fallback"},-                "measure": {"engines": ["binary"], "default": "binary", "why": "request refill once, then read two delayed matching native revision/copper snapshots; acknowledgement is not measurement and post-commit errors forbid blind replay"},-                "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},-                "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},-                "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},-            });-            r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});-            r.save().unwrap_or_else(|e| err(&e, &[]));-            let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];-            lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));-            for st in flow["steps"].as_array().cloned().unwrap_or_default() {-                lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));-                if let Some(workflow) = st["workflow"].as_array() {-                    for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {-                        lines.push(format!("    {}. {}", i + 1, action));-                    }-                }-            }-            lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));-            lines.push("stages the binary offers:".into());-            for n in STAGES {-                let s = &r.data["plan"]["stages"][n];-                lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));-            }-            ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);-        }-        Cmd::Take { decisions } => {-            thread(&cli);-            let mut r = load_run(&cli);-            for kv in decisions {-                let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };-                if stage.is_empty() || !stage.chars().all(|c| c.is_ascii_alphanumeric() || c=='-' || c=='_') || !["ai","binary"].contains(&engine) {-                    err("invalid stage decision", &["Use adom-aiflow take <stage>=ai or <stage>=binary; custom stage names may contain letters, digits, hyphens and underscores.".into()]);-                }-                r.data["decisions"][stage] = json!(engine);-            }-            r.save().unwrap();-            ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);-        }-        Cmd::Stage { action, name, note, result } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if action == "start" {-                let e = r.data["decisions"][name].as_str().unwrap_or("ai").to_string();-                r.stage_start(name, Some(&e), note.as_deref());-                r.save().unwrap();-                ok(&format!("stage {name} started at {} by {e}", now()), &[]);-            } else {-                let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));-                r.stage_end(name, res, note.as_deref());-                r.save().unwrap();-                ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);-            }-        }-        Cmd::Place { what, wish, moves, out, pause_ms } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let b = board_of(&r);-            match what.as_str() {-                "pack" => {-                    let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));-                    let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));-                    let spec = spec_of(&r);-                    let courtyard_edge=spec["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);-                    let chains = aiflow_place::chains_from_spec(&spec).unwrap_or_else(|e| err(&format!("spec.chains: {e}"), &[]));-                    let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);-                    let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);-                    write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());-                    let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();-                    let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);-                    let mut msg = format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n"));-                    if !chains.is_empty() {-                        let honoured = verdicts.iter().filter(|v| v.1).count();-                        msg.push_str(&format!("\nchains: {honoured} of {} honoured", chains.len()));-                        for l in &chain_lines { msg.push_str(&format!("\n  {l}")); }-                        for (name, _, v) in &verdicts { msg.push_str(&format!("\n  chain {name}: {v}")); }-                    }-                    let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() };-                    ok(&msg, &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }, chain_hint]);-                }-                "check" => {-                    let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));-                    let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));-                    let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));-                    let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);-                    let outp = dir.join(format!("board-{n}.kicad_pcb"));-                    std::fs::write(&outp, &text).unwrap();-                    aiflow_copper::copy_project_rules(&r.current_board(), &outp.display().to_string()).unwrap_or_else(|e|err(&e,&[]));-                    let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));-                    let moved: Vec<String> = mv.keys().cloned().collect();-                    let problems = aiflow_place::check(&nb, &moved, 0.05);-                    if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {-                        if !f.errors.is_empty() {-                            let mut h = f.hints.clone();-                            h.push(format!("The moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));-                            err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);-                        }-                    }-                    let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));-                    let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));-                    let s = aiflow_copper::summarize(&drc, Some(&baseline));-                    let errors = s["errors"].as_u64().unwrap_or(0);-                    // Bounding boxes are conservative packing hints, not exact courtyard geometry.-                    // Native KiCad DRC decides whether the actual courtyards overlap.-                    if !problems.is_empty() { eprintln!("Hint: conservative bounding-box advisory: {}. Native courtyard DRC is authoritative.", problems.join("; ")); }-                    if s["newErrors"].as_u64() != Some(0) {-                        err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]);-                    }-                    r.add_board(&outp.display().to_string());-                    r.save().unwrap();-                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited) and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);-                }-                "land" => {-                    let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));-                    let br = bridge_of(&r, &cli);-                    let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-                    r.stage_start("place-land", Some("binary"), None);-                    let mv = read_json(m);-                    let t0 = std::time::Instant::now();-                    // one part at a time: the camera sees each part land, not a board that changes at once-                    let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {-                        eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });-                    });-                    let total = mv.as_object().map(|o| o.len()).unwrap_or(0);-                    write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped}));-                    r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));-                    r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        if rep["errorCode"] == "board_mismatch" {-                            err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);-                        }-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);-                    }-                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);-                }-                other => err(&format!("place {other}: use pack, check or land"), &[]),-            }-        }-        Cmd::Route { passes, rip_per_net, rip_global } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if r.engine_for("route") == "ai" {-                err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);-            }-            // a Fusion run routes around what is in Fusion now (copper already landed, a net taken-            // up for a refine), not the placement snapshot: plan on a fresh copy of the live board-            if let Some((_, k)) = fusion_live(&r, &cli, &dir) {-                let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);-                let dest = dir.join(format!("board-{n}.kicad_pcb"));-                std::fs::copy(&k, &dest).unwrap_or_else(|e| err(&e.to_string(), &[]));-                aiflow_copper::copy_project_rules(&r.current_board(), &dest.display().to_string()).unwrap_or_else(|e| err(&e, &[]));-                r.add_board(&dest.display().to_string());-                r.save().unwrap();-                eprintln!("routing on the live Fusion board ({})", dest.display());-            }-            let spec = spec_of(&r);-            let b = board_of(&r);-            let missing=aiflow_pours::missing_planes(&b,&spec);-            if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);}-            let rules = aiflow_router::Rules::from_spec(&spec);-            let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()]));-            if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) {-                err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);-            }-            let preexisting = aiflow_router::plane_under::issues(&b, &regions);-            if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}-            r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));-            let mut router = aiflow_router::Router::new(b, rules, true);-            let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);-            std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();-            let planp = dir.join("plan-route.json");-            let bc = report["boardCopper"].clone();-            let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) > 0;-            let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});-            if has_copper {-                let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);-                plan_doc["boardCopper"] = bc.clone();-                plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {-                    format!("nothing to route: the board's own copper ({} track pieces, {} vias, filled zones) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"])-                } else {-                    format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias); {complete} nets are complete on it and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["openConnections"])-                });-            }-            if !regions.is_empty() {-                plan_doc["planeUnder"] = json!(regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>());-            }-            write_json(&planp, &plan_doc);-            write_json(&dir.join("plan-route-report.json"), &report);-            let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();-            r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);-            r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));-            r.save().unwrap();-            let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {-                format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))-            } else {-                format!("planeUnder: the board's own copper already sits inside a region ({}); the gate reports it until that copper is moved (rework it in KiCad, then adopt-board).", preexisting.iter().take(4).cloned().collect::<Vec<_>>().join("; "))-            };-            if failed > 0 {-                let mut blockers: Vec<String> = Vec::new();-                for u in &unrouted {-                    if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {-                        if let Some(rest) = why.strip_prefix("blocked by ") {-                            for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {-                                let b = b.trim_matches('"').trim_matches('\'');-                                if !b.is_empty() && !blockers.contains(&b.to_string()) {-                                    blockers.push(b.to_string());-                                }-                            }-                        }-                    }-                }-                let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();-                let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };-                err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);-            }-            if has_copper && plan.is_empty() {-                ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);-            } else {-                let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };-                ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);-            }-        }-        Cmd::Pour { plan } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let spec = spec_of(&r);-            let b = board_of(&r);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));-            let plan_v = planp.as_deref().map(read_json);-            r.stage_start("pour", Some("binary"), None);-            let (zones, vias, keepouts) = aiflow_pours::build(&b, &spec, plan_v.as_ref()).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });-            let zp = dir.join("plan-pours.json");-            let vp = dir.join("plan-vias.json");-            write_json(&zp, &json!({"zones": zones}));-            write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));-            r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);-            r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));-            r.save().unwrap();-            let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();-            ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);-        }-        Cmd::Unroute { net } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let live = br.live_copper(&rb).unwrap_or_else(|| err("could not read the live board", &["Open the board in its EDA (or fusion_show_2d_board) and retry.".into()]));-            let ids: Vec<String> = live.segments.iter().filter(|s| net.contains(&s.net)).map(|s| s.uuid.clone())-                .chain(live.vias.iter().filter(|v| net.contains(&v.net)).map(|v| v.uuid.clone())).filter(|u| !u.is_empty()).collect();-            if ids.is_empty() { ok(&format!("nothing to unroute: no tracks or vias on {}", net.join(", ")), &["Next: route, gate, land route.".into()]); return; }-            let rep = br.remove_items(&rb, &ids);-            r.log("unroute", json!({"nets": net, "items": ids.len(), "reply": rep}));-            r.save().unwrap();-            if rep["success"].as_bool() != Some(true) { err(&format!("unroute refused: {} {}", rep["errorCode"], rep["error"].as_str().or(rep["message"].as_str()).unwrap_or("")), &["Nothing else was changed. Read the live board state before retrying.".into()]); }-            ok(&format!("unrouted {}: {} track(s) and via(s) taken up, pours kept", net.join(", "), ids.len()), &["Change what needs changing (spec wideNets for a wider track), then route, gate and land route again.".into()]);-        }-        Cmd::Gate { plan } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let b = board_of(&r);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| {-                // an adopted board that already carries its copper: gate the board as it is-                if b.tracks().is_empty() && b.vias().is_empty() {-                    err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]);-                }-                let p = dir.join("plan-route.json");-                write_json(&p, &json!({"engine": "none (the board's own copper)", "nets": [], "note": "gate with no routing plan on a board that carries copper: the board is gated as it is"}));-                eprintln!("Hint: no routing plan; the board carries copper, so it is gated as it is with an empty plan ({})", p.display());-                p.display().to_string()-            });-            let plan_v = read_json(&planp);-            let spec_g = spec_of(&r);-            if let Some(p) = checks::rules_profile(&spec_g) {-                if let Err(e) = checks::rules_check(&p, &r.current_board()) { err(&format!("gate refused: {e}"), &[checks::rules_hint(&p), "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }-            }-            if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {-                if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }-            }-            r.stage_start("gate", Some("binary"), None);-            // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors-            let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);-            let (text, stats) = aiflow_copper::apply(&b, &plan_v, adds_copper && !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));-            let routed = dir.join("board-routed.kicad_pcb");-            std::fs::write(&routed, &text).unwrap();-            aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));-            let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));-            let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));-            let mut s = aiflow_copper::summarize(&data, Some(&base));-            s["copper"] = stats;-            s["file"] = json!(routed.display().to_string());-            // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error-            let spec = spec_of(&r);-            let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec);-            let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {-                let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[]));-                match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, &regs), Err(e) => vec![format!("spec.planeUnder: {e}")] }-            };-            s["planeUnderIssues"] = json!(pu_issues);-            if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }-            write_json(&dir.join("gate.json"), &s);-            let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));-            let npu = pu_issues.len() as u64;-            r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);-            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "routedBoard": routed.display().to_string(), "plan": planp}));-            r.save().unwrap();-            if npu > 0 && newe == 0 && unc == 0 {-                err(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]);-            }-            if newe > 0 || unc > 0 {-                // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever-                let mut hair: Vec<String> = Vec::new();-                for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {-                    if v["type"].as_str() == Some("clearance") {-                        let d = v["description"].as_str().unwrap_or("");-                        let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());-                        let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());-                        if let (Some(a), Some(r)) = (actual, rule) {-                            if r - a < 0.02 {-                                for it in v["items"].as_array().cloned().unwrap_or_default() {-                                    if let Some(t) = it.as_str() {-                                        if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }-                                    }-                                }-                            }-                        }-                    }-                }-                hair.sort(); hair.dedup();-                let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };-                let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };-                err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);-            }-            let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);-            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}; copper {}", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);-        }-        Cmd::Land { what, plan, moves, pause_ms, from } => {-            let mut r = load_run(&cli);-            if what == "route" {-                // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call-                let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);-                let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0;-                let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));-                if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && read_json(p)["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false)) {-                    let note = read_json(&planp).get("note").cloned().unwrap_or(json!("empty plan"));-                    r.stage_start("land-route", Some("binary"), None);-                    r.stage_end("land-route", Some(json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0})), Some("empty plan: nothing to land"));-                    r.outcome("land-route", json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0, "stoppedAt": Value::Null, "emptyPlan": true, "note": note}));-                    r.save().unwrap();-                    ok(&format!("nothing to land: the gated plan {planp} is empty (the board already carries its copper); recorded as landed"), &["Next: adom-aiflow pour, land vias, land pours.".into()]);-                    return;-                }-            }-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            match what.as_str() {-                "moves" => {-                    let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));-                    r.stage_start("place-land", Some("binary"), None);-                    let mv = read_json(m);-                    let t0 = std::time::Instant::now();-                    // one part at a time: the camera sees each part land, not a board that changes at once-                    let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {-                        eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });-                    });-                    let total = mv.as_object().map(|o| o.len()).unwrap_or(0);-                    r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));-                    r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);-                    }-                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);-                }-                "route" | "vias" => {-                    let planp = if what == "route" {-                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0;-                        if !gate_ok && plan.is_none() {-                            err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);-                        }-                        plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))-                    } else {-                        r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))-                    };-                    let entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();-                    // a via must not touch another net's copper, planned or already on the board: KiCad-                    // hands a touching via the other net and the bridge's DRC then sees nothing wrong-                    let b = board_of(&r);-                    let pad_copper: Vec<Copper> = b.pads.iter().flat_map(|p| p.polys.iter().map(move |poly| Copper::Poly { poly: poly.clone(), net: p.net.clone(), what: format!("pad {} ({})", p.key, if p.net.is_empty() { "no net" } else { p.net.as_str() }) })).collect();-                    let mut plans: Vec<Value> = vec![read_json(&planp)];-                    if what == "vias" {-                        if let Some(gp) = r.outcomes().get("gate").and_then(|g| g.get("plan")).or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan"))).and_then(|p| p.as_str()) {-                            if Path::new(gp).is_file() { plans.push(read_json(gp)); }-                        }-                    }-                    let mut copper_cache: Option<Vec<Copper>> = None;-                    let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> {-                        let t = landing::resolve(e, &live.pads)?;-                        if t.vias.is_empty() { return None; }-                        let copper = copper_cache.get_or_insert_with(|| {-                            let mut c = pad_copper.clone();-                            c.extend(landing::live_copper(live));-                            for p in &plans { c.extend(landing::plan_copper(p, &live.pads)); }-                            c-                        });-                        t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)})))-                    };-                    let stage = format!("land-{what}");-                    r.stage_start(&stage, Some("binary"), None);-                    let t0 = std::time::Instant::now();-                    // one trace at a time: a net with several paths lands as several undo steps-                    let res = br.land_plan(&rb, &entries, from.unwrap_or(1), *pause_ms, precheck, |i, n, entry, rep| {-                        if rep["alreadyPresent"] == true { return; }-                        eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);-                    });-                    let (accepted, stopped, traces) = (res.accepted, res.stopped, res.traces);-                    if res.skipped > 0 {-                        eprintln!("{} {} already on the live board (same net, same points): not landed again", res.skipped, if what == "vias" { "vias" } else { "traces" });-                    }-                    if res.recovered + res.retried > 0 {-                        eprintln!("transport: {} lost repl(ies) whose trace had landed, {} trace(s) landed on the one retry", res.recovered, res.retried);-                    }-                    // read back every landed via: the planned net must be the net KiCad gave it-                    let mut mismatches: Vec<Value> = Vec::new();-                    let with_vias: Vec<&(usize, Value, Value)> = res.landed.iter().filter(|(_, e, _)| e["paths"].to_string().contains("\"layer\"") || e["points"].to_string().contains("\"layer\"")).collect();-                    if !with_vias.is_empty() {-                        let Some(live) = br.live_copper(&rb) else {-                            r.stage_end(&stage, Some(json!({"accepted": accepted, "viaReadback": "unavailable"})), None);-                            r.save().unwrap();-                            err(&format!("{accepted} trace(s) landed, but the live board could not be read back to check the vias' nets"), &[format!("Read kicad_routing_state for {rb} and compare every via's net with {planp} before going on.")]);-                        };-                        let landed: Vec<(usize, landing::Trace)> = with_vias.iter().filter_map(|(n, e, _)| landing::resolve(e, &live.pads).map(|t| (*n, t))).collect();-                        for m in landing::via_net_mismatches(&landed, &live) {-                            let reply = with_vias.iter().find(|(n, _, _)| *n == m.trace).map(|(_, _, rep)| rep.clone()).unwrap_or(Value::Null);-                            let mut ids: Vec<String> = reply["itemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();-                            if ids.is_empty() && !m.uuid.is_empty() { ids.push(m.uuid.clone()); }-                            let undo = if ids.is_empty() { json!({"success": false, "errorCode": "no_item_ids"}) } else { br.remove_items(&rb, &ids) };-                            eprintln!("trace {}: the {} via at ({:.2}, {:.2}) landed as {}; {}", m.trace, m.planned, m.at.0, m.at.1, m.actual, if Bridge::ok(&undo) { "taken back" } else { "NOT taken back" });-                            mismatches.push(json!({"trace": m.trace, "planned": m.planned, "actual": m.actual, "at": [m.at.0, m.at.1], "viaId": m.uuid, "removedIds": ids, "undone": Bridge::ok(&undo), "undo": undo}));-                        }-                    }-                    let undone = mismatches.iter().filter(|m| m["undone"] == true).count();-                    let accepted = accepted - undone.min(accepted);-                    let wall = (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0;-                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "from": from, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "accepted": accepted, "viaNetMismatches": mismatches, "replies": res.replies, "wallSeconds": t0.elapsed().as_secs_f64()}));-                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "wallSeconds": wall})), None);-                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "viaNetMismatches": mismatches.len(), "wallSeconds": wall, "stoppedAt": stopped}));-                    r.save().unwrap();-                    if !mismatches.is_empty() {-                        let lines: Vec<String> = mismatches.iter().map(|m| format!("trace {}: planned {} at {}, landed as {}{}", m["trace"], m["planned"], m["at"], m["actual"], if m["undone"] == true { " (taken back)" } else { " (NOT taken back: remove it by hand)" })).collect();-                        err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]);-                    }-                    if let Some(i) = stopped {-                        let rep = res.replies.last().cloned().unwrap_or(Value::Null);-                        if rep["errorCode"] == "via_touches_other_net" {-                            err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);-                        }-                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped (or start at `--from {}`). The log has every reply.", i + 1)]);-                    }-                    let skipped_note = if res.skipped > 0 { format!(", {} already on the board skipped", res.skipped) } else { String::new() };-                    ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries{skipped_note}, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);-                }-                "pours" => {-                    let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));-                    let b = board_of(&r);-                    let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));-                    r.stage_start("land-pours", Some("binary"), None);-                    let before = br.zone_state(&rb, true);-                    if !Bridge::ok(&before) { err("cannot read settled zones before landing", &[before.to_string()]); }-                    let mut owned: Vec<String> = r.data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(|v|v.as_str().map(str::to_string)).collect();-                    // Legacy successful operation replies are evidence of creation, not an inference.-                    if let Some(groups)=r.outcomes().get("land-pours").and_then(|o|o["groups"].as_array()).filter(|_| r.data.get("ownedZoneIds").is_none()) {-                        for g in groups.iter().filter(|g|g["ok"]==true) {-                            for id in g["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {-                                if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }-                            }-                        }-                    }-                    let results = br.land_zones(&rb, &zones, *pause_ms, &owned);-                    for (_,reply) in &results {-                        if Bridge::ok(reply) || reply["deletionVerified"] == true {-                            for id in reply["removedIds"].as_array().into_iter().flatten().filter_map(Value::as_str) { owned.retain(|x|x!=id); }-                        }-                        if Bridge::ok(reply) || reply["mutated"]==true {-                            for id in reply["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {-                                if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }-                            }-                        }-                    }-                    r.data["ownedZoneIds"]=json!(owned);-                    if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {-                        eprintln!("{}", rep["message"].as_str().unwrap_or(""));-                    }-                    let after = br.zone_state(&rb, results.iter().all(|(_, reply)| Bridge::ok(reply)));-                    let mut results = results;-                    if !Bridge::ok(&after) { results.push(("post-landing-readback".into(), after.clone())); }-                    let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();-                    r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);-                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));-                    write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));-                    r.save().unwrap();-                    if let Some((net, rep)) = failed.first() {-                        let mut types: BTreeMap<String, usize> = BTreeMap::new();-                        for v in rep["violations"].as_array().cloned().unwrap_or_default() {-                            *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;-                        }-                        err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);-                    }-                    ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);-                }-                other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),-            }-        }-        Cmd::Measure => {-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            r.stage_start("measure", Some("binary"), None);-            let zs = br.zone_state(&rb, true);-            r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);-            write_json(&dir.join("zone-state.json"), &zs);-            r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));-            if !Bridge::ok(&zs) {-                r.outcome("measure", json!({"failed":true,"errorCode":zs["errorCode"],"reply":zs}));-                r.save().unwrap();-                err("native copper measurement did not settle", &["The refill request was not replayed. Inspect zone-state.json and the native board before retrying a read.".into()]);-            }-            r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));-            r.save().unwrap();-            ok(&format!("copper kept per layer (KiCad's fills): {}; coverage {}", zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);-        }-        Cmd::Analyze { what, plan, fields } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));-            // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot-            let live = fusion_live(&r, &cli, &dir);-            let board_now = match &live { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };-            let solved_board = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());-            let plan_v = plan_with_board_copper(&read_json(&planp), &board_now);-            let spec = spec_of(&r);-            let zsp = dir.join("zone-state.json");-            let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };-            let name = format!("analyze-{what}");-            r.stage_start(&name, Some("binary"), None);-            let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };-            let fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));-            let screen=if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {-                let f=read_json(&fields_file.display().to_string());-                let board_bytes=std::fs::read(&solved_board).unwrap_or_else(|e|err(&e.to_string(),&[]));-                let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));-                aiflow_analyze::field_screen(&f,&spec,&board_bytes,&spec_bytes,what).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e })-            } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };-            let passed=heuristic_passed && screen.is_ok();-            let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());-            r.stage_end(&name, Some(json!({"pass": passed})), None);-            r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "results": res}));-            r.save().unwrap();-            let lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();-            // the copper this analysis judged: the nets with pours, for the drawing and the walkthrough-            let mut pour_nets: Vec<String> = Vec::new();-            for x in &res {-                let net = x.get("net").or(x.get("tabNet")).and_then(|v| v.as_str()).unwrap_or("");-                if !net.is_empty() && x["pourLayers"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !pour_nets.contains(&net.to_string()) { pour_nets.push(net.to_string()); }-            }-            let draw = match what.as_str() {-                "current" => "Now do the analysis yourself, not only this table: draw the current density on the copper (amps per square millimetre on every loaded net's pours and tracks; a Laplace solve on the filled copper between the source pads and the sink pads is enough, the board file has the filled polygons) as a 1920x1080 PNG with a colour scale, then `artifact --kind analysis-image --file <png> --caption \"...\"`. It becomes a fullscreen shot in the final video and a figure on the run page.".to_string(),-                _ => "Now do the analysis yourself, not only this table, as two drawings: first the heat flow in the copper, layer by layer like the current picture (a multi-layer solve: each hot tab's watts spreading on its layer and going down through its vias to the other layers, watts per millimetre of width, with the vias drawn and each chip's split between its layer, its vias and the bare dielectric); then the temperature rise over the whole board (the same solve read as temperature, the FR4 spreading). Each a 1920x1080 PNG with a colour scale, registered in that order with `artifact --kind analysis-image --file <png> --caption \"...\"` (`--replaces <old png>` when you redo one). They become fullscreen shots in the final video and figures on the run page.".to_string(),-            };-            let fields_hint = "Adom Fields 0.4.0 also supports specified conducting heatsinks, per-layer copper thickness, and an optional Signals view for overlap capacitance and differential-pair screening. Read its heatsinks-and-signals guide; use real assembly/material data, never invent a heatsink to pass this gate. Unsupported signal geometry is not a passing impedance result. adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string();-            let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) };-            if !passed {-                err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);-            }-            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()]);-        }-        Cmd::Finish => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t.clone(), target }, &dir);-                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }-            }-            let o = r.outcomes().clone();-            let mut missing = Vec::new();-            let gate = o.get("gate").cloned().unwrap_or(Value::Null);-            if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {-                missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());-            }-            if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {-                missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));-            }-            if !o.contains_key("land-route") {-                missing.push("land route".into());-            }-            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true) {-                missing.push("land pours (every planned pour accepted)".into());-            }-            if !measurement_usable(o.get("measure")) {-                missing.push("measure (successful native copper readback)".into());-            }-            // a Fusion run is judged on the board as it is in Fusion now-            let live_board = fusion_live(&r, &cli, &dir);-            let solved_board = live_board.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());-            let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };-            for k in ["analyze-current", "analyze-thermal"] {-                if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {-                    missing.push(format!("{k} (pass)"));-                } else {-                    if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}-                    let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());-                    let board_bytes=std::fs::read(&solved_board).unwrap_or_default();-                    let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();-                    if let Some(report)=report {-                        if let Err(reason)=aiflow_analyze::field_screen(&report,&spec_of(&r),&board_bytes,&spec_bytes,k.trim_start_matches("analyze-")) { missing.push(format!("{k}: {reason}")); }-                    } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }-                }-            }-            if let Err(m) = netlist_gate(&r) { missing.push(m); }-            let spec_f = spec_of(&r);-            if let Some(p) = checks::rules_profile(&spec_f) {-                if let Err(e) = checks::rules_check(&p, &r.current_board()) { missing.push(format!("fab rules ({e})")); }-            }-            if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {-                if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }-            }-            if r.outcomes().contains_key("models-orientation") {-                let (w, _) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));-                let open = unacked_orientation(&r, &w);-                if !open.is_empty() { missing.push(format!("3D models right way up (fix or `models --ack {} --why ...`)", open.join(","))); }-            }-            let mut live = Value::Null;-            if let Some(target) = r.data["target"].as_str() {-                let br = Bridge { ai_thread: t.clone(), target: target.to_string() };-                live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));-                let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);-                if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {-                    missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));-                }-            }-            // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for-            let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));-            if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)) {-                let sc = o.get("sourcing").cloned().unwrap_or(Value::Null);-                if sc["pass"] != true { missing.push(format!("sourcing check on the {p} profile (sourcing check --bom <csv>)")); }-                else if sc["boardChecked"] != true { missing.push("sourcing check against the board's references (run it again now the board exists)".into()); }-                else if sc["profile"].as_str() != Some(p.as_str()) { missing.push(format!("sourcing check on the {p} profile the spec or requirements name (the last check used {})", sc["profile"].as_str().unwrap_or("?"))); }-            }-            let mut required: Vec<String> = Vec::new();-            for v in [&spec_v, &req_v] { for k in v["requireEvidence"].as_array().cloned().unwrap_or_default() { if let Some(k) = k.as_str() { if !required.iter().any(|x| x == k) { required.push(k.to_string()); } } } }-            for k in &required {-                let have = r.data["evidence"].as_array().map(|a| a.iter().any(|e| e["kind"].as_str() == Some(k.as_str()) && e["pass"] != false)).unwrap_or(false);-                if !have { missing.push(format!("{k} evidence (evidence add --kind {k} --file <json|md>)")); }-            }-            // MPN marking: unless it was turned off, every IC (U*, IC*) carries its laser-etched MPN on the-            // marking face of its model (an 'Adom.LaserEtch' or legacy 'laser_etch' part in the STEP), so the board and the component-            // pages show which chip is which-            let marking = r.data["components"]["mpnMarking"].as_str().unwrap_or("on").to_string();-            if marking != "off" {-                let unmarked = unmarked_ics(&r);-                if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>` (gate must PASS), or `components --etch off` to decline", unmarked.join(", "))); }-            }-            if !missing.is_empty() {-                err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);-            }-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let brs = Bridge { ai_thread: t.clone(), target };-                stop_running_clip(&mut r, &brs, &dir, "finish");-            }-            r.data["clock"]["finishTime"] = json!(now());-            record_usage(&mut r, "finish");-            let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true});-            r.data["summary"] = summary;-            r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));-            r.save().unwrap();-            ok(&format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);-        }-        Cmd::Capture { action, label, reason } => {-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let engine = r.data["engine"].as_str().unwrap_or("").to_string();-            match action.as_str() {-                "open" => {-                    let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));-                    r.save().unwrap();-                    let (revision, reply) = br.revision_with_reply(&rb);-                    write_json(&dir.join("capture-open-ipc.json"), &reply);-                    if revision.is_none() {-                        err(&format!("board window opened (hwnd {hwnd}) but IPC is not ready for {rb}: {} {}; openBoards {}",reply["errorCode"],reply["error"],reply["openBoards"]), &["Read capture-open-ipc.json. For board_mismatch, save and gracefully close only your task-owned conflicting editors, then `capture open` so this board owns the IPC socket. Preserve other users' windows and shared bridges.".into()]);-                    }-                    let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();-                    let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };-                    let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };-                    ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);-                }-                "start" => {-                    let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));-                    // the PCB editor window itself, background-capturable; the monitor only when no editor is open-                    // the editor open NOW first: a stored handle dies when the editor is closed and reopened (an offline edit)-                    let hwnd = br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))));-                    let (rid, kind) = match hwnd {-                        Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),-                        None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),-                    };-                    let mut entry = json!({"recordingId": rid, "start": now()});-                    for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }-                    r.data["captures"].as_array_mut().unwrap().push(entry);-                    r.mark("capture-start");-                    r.save().unwrap();-                    ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);-                }-                "mark" => {-                    let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));-                    r.mark(&l);-                    r.save().unwrap();-                    ok(&format!("marker {l} at {}", now()), &[]);-                }-                "stop" => {-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };-                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let stop = br.record_stop(&rid, &save_to);-                    let local = settle_clip_stop(&mut r, pos, &stop, &dir);-                    let cap = &r.data["captures"][pos];-                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-                    let p = stop.remote.clone();-                    r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": "capture stop"}), &stop));-                    r.mark("capture-stop");-                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {-                        clip_artifacts(&mut r, &st, &f, 10);-                    }-                    r.save().unwrap();-                    if p.is_some() && !stop.pulled {-                        err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]);-                    }-                    let clips_note = auto_clips(&r, &dir);-                    ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into(), clips_note]);-                }-                "reject" => {-                    // the AI looked at a contact sheet and saw a clip the blank check accepted but a-                    // human would not (stale pixels after a window resize, a covered canvas)-                    let tag = label.clone().unwrap_or_else(|| err("capture reject <step>-<visit> --reason \"...\"", &[]));-                    let why = reason.clone().unwrap_or_else(|| err("say why in --reason", &[]));-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    let pos = caps.iter().rposition(|c| format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag || c["file"].as_str().map(|f| f.ends_with(&tag)).unwrap_or(false)).unwrap_or_else(|| err(&format!("no clip {tag} in this run"), &["Tags are <step>-<visit>, e.g. nets-2; or the clip's file name.".into()]));-                    r.data["captures"][pos]["suspect"] = json!(format!("rejected by the AI: {why}"));-                    let st = json!(format!("{}-{}", caps[pos]["step"].as_str().unwrap_or("?"), caps[pos]["visit"].as_u64().unwrap_or(1)));-                    r.log("clip-suspect", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai"}));-                    r.save().unwrap();-                    ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]);-                }-                other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),-            }-        }-        Cmd::Status => {-            let r = load_run(&cli);-            let o = r.outcomes();-            ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",-                dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],-                o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),-                if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { "landed" } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),-                o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);-        }-        Cmd::Report { page, push, refresh, user_machine, surface } => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            if let Some(p) = page { r.data["page"] = json!(p); }-            if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }-            if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }-            r.save().unwrap();-            let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));-            if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }-            let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);-            if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }-            ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);-        }-        Cmd::Giveback { url, kind, note } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }-            r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));-            r.log("giveback", json!({"url": url, "kind": kind, "note": note}));-            r.save().unwrap();-            ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);-        }--        Cmd::Recut => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t, target }, &dir);-                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }-            }-            let recovered = recover_capture_files(&mut r, &dir);-            if recovered > 0 { eprintln!("recovered {recovered} clip file(s) the manifest had lost"); }-            let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-            let mut n = 0;-            for c in &caps {-                let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };-                if !Path::new(f).is_file() { continue; }-                let tag = json!(format!("{step}-{}", c["visit"].as_u64().unwrap_or(1)));-                clip_artifacts(&mut r, &tag, f, 10);-                n += 1;-            }-            r.mark(&format!("recut: {n} clips re-cut (10x and action) with this binary's rules"));-            r.save().unwrap();-            ok(&format!("{n} clips re-cut; the run page uses the action cuts"), &["Next: adom-aiflow compose --out <mp4>, then report --push --refresh.".into()]);-        }-        Cmd::Compose { out, max_seconds, still_seconds, with, script } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }-            let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };-            if let Some(other) = with {-                let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));-                let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));-                let (secs, steps, pairs, has_tts) = compose_pair(&mut r, &b, &dir, &outp, *max_seconds, *still_seconds, script.as_ref());-                r.log("artifact", json!({"step": "finish", "kind": "video-comparison", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "steps": steps, "drawingPairs": pairs, "with": other.display().to_string(), "withEngine": b.data["engine"], "maxSeconds": max_seconds, "narrated": has_tts}));-                r.data["comparison"] = json!({"video": outp.display().to_string(), "with": other.display().to_string(), "withEngine": b.data["engine"], "seconds": (secs * 10.0).round() / 10.0});-                r.save().unwrap();-                ok(&format!("composed {secs:.0} s side by side: {} left, {} right, {steps} steps and {pairs} drawing pair(s){}: {}", r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or(""), if has_tts { ", narrated" } else { ", no narration" }, outp.display()), &["Look at it before you post it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png.".into(), "report --push --refresh puts it on this run's page under the single-run video.".into()]);-                return;-            }-            let (secs, parts, stills, rate, has_tts) = compose_run(&mut r, &dir, &outp, *max_seconds, *still_seconds);-            r.log("artifact", json!({"step": "finish", "kind": "video", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "segments": parts, "stills": stills, "rate": rate, "maxSeconds": max_seconds, "narrated": has_tts}));-            if r.data["delivery"].is_object() {-                r.data["delivery"]["video"] = json!(outp.display().to_string());-                r.mark("compose: the delivered video replaced by the composed cut (motion only, the ledger's overlays, the AI's drawings, narration cut to the picture); the clock of record unchanged");-            }-            r.save().unwrap();-            let minutes = r.active_minutes_total();-            ok(&format!("composed {secs:.0} s from {parts} step visits and {stills} drawing(s){} for {}, {minutes:.1} min of AI time, motion at {rate:.1} frames per second: {}", if has_tts { ", narrated" } else { ", no narration (adom-tts not reachable)" }, r.data["engine"].as_str().unwrap_or(""), outp.display()), &["Look at it before you deliver it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png gives one page of it.".into(), "Next: report --push --refresh so the run page carries it; deliver --video <this file> if the run is not delivered yet.".into()]);-        }-        Cmd::Artifact { file, kind, caption, step, replaces, review } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let src = PathBuf::from(file);-            if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }-            let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));-            let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-            // the file lives in the run directory so the page and the video can find it later-            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());-            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());-            let dest = if canon.starts_with(&rdir) { canon } else { let d = dir.join(src.file_name().unwrap_or_default()); let _ = std::fs::copy(&src, &d); std::fs::canonicalize(&d).unwrap_or(d) };-            let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));-            let review_path=if kind=="library-overview" {-                let rp=review.as_ref().unwrap_or_else(||err("library-overview needs --review <review.json>", &["Review exact selected STEP/GLB variants in the native EDA and the overview. See docs/library-overview-review.md.".into()]));-                library_review::verify(rp,Path::new(&r.current_board()),&dest).unwrap_or_else(|e|err(&e,&["Update the actual selection/native evidence, then re-register; do not mark pending evidence passed.".into()]));-                let seconds=media_seconds(dest.to_str().unwrap());-                if !(1.0..=6.0).contains(&seconds){err("library overview must be a moving clip between one and six seconds", &["Keep the full component tour separate; cut a roughly five-second overview.".into()]);}-                Some(std::fs::canonicalize(rp).unwrap_or(rp.clone()).display().to_string())-            }else{None};-            let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());-            r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path}));-            r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));-            r.save().unwrap();-            ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else if kind=="library-overview" { "The reviewed moving overview goes into compose; the full tour remains separate.".into() } else { "It goes on the run page under this step with your caption; this artifact kind does not automatically enter the final video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);-        }-        Cmd::Compare { with, page, push, refresh } => {-            let t = thread(&cli);-            let r = load_run(&cli);-            let b = Run::open(with).unwrap_or_else(|| err(&format!("no run at {}", with.display()), &[]));-            let page = page.clone().or_else(|| r.data["page"].as_str().map(str::to_string)).unwrap_or_else(|| err("no page: --page <owner/slug>", &[]));-            let (rel, url, files) = build_comparison(&r, &b, &dir, &page);-            let mut notes = Vec::new();-            if *push {-                let stage = dir.join("report");-                let mut cmd = std::process::Command::new("adom-wiki");-                cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);-                for f in &files { cmd.arg(f); }-                match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>()), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }-            }-            if *refresh {-                if let Some(m) = r.data["userMachine"].as_str() {-                    let br = Bridge { ai_thread: t.clone(), target: m.to_string() };-                    let bust = format!("{url}?v={}", now().replace([':', '-'], ""));-                    let op = br.call("pup_open_window", &json!({"sessionId": format!("aiflow-compare-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>()), "url": bust, "reason": "show the human the comparison page"}));-                    notes.push(if Bridge::ok(&op) { format!("opened in pup on {m}") } else { format!("pup on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });-                }-            }-            ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]);-        }-        Cmd::Prompt { text, at } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let at = at.clone().unwrap_or_else(now);-            if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); }-            let n = r.sessions().len() + 1;-            r.log("prompt", json!({"n": n, "text": text, "at": at}));-            r.data["clock"]["lastPromptTime"] = json!(at);-            r.save().unwrap();-            ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Say `done --message \"...\"` the moment your answer to this prompt is complete; the clock of this session stops there, and what sits idle after it is not charged to you.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]);-        }-        Cmd::Done { message } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let ss = r.sessions();-            let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };-            if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }-            let t = now();-            r.log("done", json!({"n": cur.n, "message": message}));-            r.save().unwrap();-            let ss = r.sessions();-            let cur = ss.last().unwrap();-            ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min unobserved gaps excluded); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]);-        }-        Cmd::Sessions { json: as_json } => {-            thread(&cli);-            let r = load_run(&cli);-            let ss = r.sessions();-            if *as_json {-                println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "unobservedMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "measurement": "ledger activity with heuristic gap exclusion; not harness-complete", "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());-                return;-            }-            println!("{}", sessions_table(&r));-            ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["A session without a `done` ends at its last event; say `done` when an answer is complete so nothing idle is charged.".into()]);-        }-        Cmd::Usage => {-            thread(&cli);-            let mut r = load_run(&cli);-            let snap = record_usage(&mut r, "manual");-            r.save().unwrap();-            if snap.is_empty() { err("no plan usage readable: no Claude Code (~/.claude/.credentials.json) or Codex (~/.codex/auth.json) sign-in in this container, or the provider did not answer", &["The run still counts time; usage is the second cost line and it needs a sign-in the binary can read.".into()]); }-            ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);-        }--        Cmd::Netlist { action: _, sch, board, net } => {-            let mut run = Run::open(&dir);-            if run.is_some() { thread(&cli); }-            let sch = sch.clone().or_else(|| run.as_ref().and_then(|r| r.data["schematic"].as_str().map(str::to_string)))-                .unwrap_or_else(|| err("netlist check needs --sch <file.kicad_sch> (or a run started with --sch)", &["The schematic the board was made from; its netlist is exported with kicad-cli and compared pin by pin.".into()]));-            if !Path::new(&sch).is_file() { err(&format!("schematic not found: {sch}"), &[]); }-            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("netlist check needs --board <file.kicad_pcb> outside a run", &[]));-            let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));-            let out_dir = if run.is_some() { dir.clone() } else { std::env::temp_dir() };-            let (netfile, via) = match net {-                Some(n) => (PathBuf::from(n), "given netlist".to_string()),-                None => {-                    let out = out_dir.join(if run.is_some() { "netlist-sch.net".to_string() } else { format!("aiflow-netlist-{}.net", std::process::id()) });-                    let _ = std::fs::remove_file(&out);-                    let cli_used = export_sch_netlist(&sch, &out).unwrap_or_else(|e| err(&e, &["Export needs KiCad's own kicad-cli for the schematic's version (a KiCad 7 kicad-cli cannot read a KiCad 10 schematic). In a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> KICAD_REMOTE_THREAD=<your thread>. Or export it yourself (`kicad-cli sch export netlist --format kicadsexpr`) and pass --net <file>.".into()]));-                    (out, format!("exported by {cli_used}"))-                }-            };-            let text = std::fs::read_to_string(&netfile).unwrap_or_else(|e| err(&format!("{}: {e}", netfile.display()), &[]));-            let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));-            let rep = checks::compare_netlists(&sn, &b);-            let passed = rep["ok"] == true;-            let mut lines = vec![format!("schematic {} ({} refs) vs board {} ({} refs): {}/{} connected pins on the same net ({via})", sch, rep["schematicRefs"], board_path, rep["boardRefs"], rep["matchedPins"], rep["connectedPins"])];-            for (k, label) in [("missingRefs", "missing on the board"), ("extraRefs", "on the board, not in the schematic")] {-                if let Some(a) = rep[k].as_array().filter(|a| !a.is_empty()) { lines.push(format!("  {label}: {}", a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }-            }-            for m in rep["mismatches"].as_array().cloned().unwrap_or_default() {-                lines.push(format!("  {:<10} schematic {:<12} board {:<12} {}", m["pin"].as_str().unwrap_or(""), m["schematic"].as_str().unwrap_or("-"), m["board"].as_str().unwrap_or("-"), m["why"].as_str().unwrap_or("")));-            }-            let fpd: Vec<String> = rep["footprintDifferences"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();-            if let Some(r) = run.as_mut() {-                let report = dir.join("netlist.json");-                write_json(&report, &rep);-                if r.data["schematic"].is_null() { r.data["schematic"] = json!(std::fs::canonicalize(&sch).map(|p| p.display().to_string()).unwrap_or(sch.clone())); }-                r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));-                r.save().unwrap_or_else(|e| err(&e, &[]));-            }-            let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };-            if !passed {-                err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);-            }-            ok(&lines.join("\n"), &[if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);-        }-        Cmd::Molecule { action: _, board, spec } => {-            let mut run = Run::open(&dir);-            if run.is_some() && board.is_none() { thread(&cli); }-            let spec_v = match spec { Some(p) => read_json(p), None => run.as_ref().map(spec_of).unwrap_or_else(|| err("molecule check needs --spec <spec.json> outside a run", &[])) };-            if spec_v.get("molecule").is_none() { eprintln!("Hint: the spec has no \"molecule\" object; checking with the Adom defaults (2 mm grid, 2 mm edge margin, medium pins). Add \"molecule\": {{...}} so place check, gate and finish run it too."); }-            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("molecule check needs --board <file.kicad_pcb> outside a run", &[]));-            let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));-            let before = if board.is_none() { run.as_ref().and_then(first_board) } else { None };-            let f = checks::molecule_check(&b, &spec_v, before.as_ref());-            if let (Some(r), None) = (run.as_mut(), board) {-                r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings}));-                r.save().unwrap_or_else(|e| err(&e, &[]));-            }-            let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));-            if !f.errors.is_empty() { err(&text, &f.hints); }-            let mut hints = f.hints.clone();-            hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into());-            ok(&text, &hints);-        }-        Cmd::Rules { action, profile, board, force } => {-            let run = Run::open(&dir);-            if run.is_some() { thread(&cli); }-            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("rules needs --board <file.kicad_pcb> outside a run", &[]));-            let profile = profile.clone().or_else(|| run.as_ref().and_then(|r| checks::rules_profile(&spec_of(r)))).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));-            match action.as_str() {-                "install" => {-                    if profile != "jlcpcb" {-                        err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb.".into()]);-                    }-                    let dru = Path::new(&board_path).with_extension("kicad_dru");-                    if dru.is_file() && !*force {-                        let cur = std::fs::read_to_string(&dru).unwrap_or_default();-                        if cur.contains(checks::JLCPCB_MARKER) { ok(&format!("{} already carries the jlcpcb-2L profile", dru.display()), &[]); return; }-                        err(&format!("{} already exists and is another rules file", dru.display()), &["Keep it (it may be the 3rd party fab's rules) or pass --force to replace it with the jlcpcb-2L profile.".into()]);-                    }-                    std::fs::write(&dru, checks::JLCPCB_2L).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));-                    let mut hints = vec!["DRC loads it with the board: `gate` and `place check` need a native kicad-cli for project rules (ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote in a container).".into(), "Set \"fab\": {\"rules\": \"jlcpcb\"} in the spec so the gate refuses a board without it, and source parts on the jlcpcb profile (skill aiflow-sourcing).".into()];-                    if run.is_some() && board.is_none() { hints.push("Installed beside the run's current board; later snapshots carry it. Put the same file beside the source board too so the project keeps it.".into()); }-                    ok(&format!("installed the jlcpcb-2L rules (JLCPCB published 2-layer capabilities, cited in the file) at {}", dru.display()), &hints);-                }-                _ => {-                    match checks::rules_check(&profile, &board_path) {-                        Ok(m) => ok(&m, &[]),-                        Err(e) => err(&e, &[checks::rules_hint(&profile)]),-                    }-                }-            }-        }-        Cmd::Models { offline, ack, why } => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            if let Some(refs) = ack {-                let why = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<what you checked in the native 3D viewer>\"", &[]));-                for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["modelAcks"][x] = json!(why.clone()); }-                r.log("models-ack", json!({"refs": refs, "why": why}));-            }-            // the orientation pass: offline, every model bound on the board, right way up-            let (orient, orient_notes) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));-            let unacked = unacked_orientation(&r, &orient);-            r.outcome("models-orientation", json!({"warnings": orient, "notChecked": orient_notes, "unacknowledged": unacked}));-            let orient_lines: Vec<String> = orient.iter().map(|w| format!("  {} {}: {}{}", w["ref"].as_str().unwrap_or(""), w["model"].as_str().unwrap_or(""), w["finding"].as_str().unwrap_or(""), if r.data["modelAcks"].get(w["ref"].as_str().unwrap_or("")).is_some() { " (acknowledged)" } else { "" })).collect();-            let orient_hint = if unacked.is_empty() { String::new() } else { format!("3D orientation warnings to fix or acknowledge ({}): correct the model's rotate/offset in the footprint and look at it in the native 3D viewer, then `models` again; when the model is right as it is, `models --ack {} --why \"<what you saw>\"`. finish refuses while any stays open.", unacked.join(","), unacked.join(",")) };-            if *offline {-                r.save().unwrap();-                let text = format!("3D orientation (offline): {} warning(s), {} not checked here{}{}", orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }, if orient_notes.is_empty() { String::new() } else { format!("\n  {}", orient_notes.join("\n  ")) });-                if !unacked.is_empty() { err(&text, &[orient_hint]); }-                ok(&text, &["The live resolution check is `models` without --offline (kicad_model_check on the test box).".into()]);-                return;-            }-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let _ = t;-            r.stage_start("models", Some("binary"), None);-            let v = br.call("kicad_model_check", &json!({"filePath": rb}));-            let d = v.get("data").cloned().unwrap_or(v.clone());-            let counts = d["counts"].clone();-            let unresolved = counts["unresolvedModel"].as_u64().unwrap_or(0) + counts["missingModel"].as_u64().unwrap_or(0);-            let mut missing: Vec<String> = Vec::new();-            for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {-                let fp = f["footprint"].as_str().unwrap_or("").to_string();-                let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();-                let models: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(|s| s.to_string())).collect();-                if !models.is_empty() { missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, models.join(", "))); }-            }-            r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);-            r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing}));-            r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));-            r.save().unwrap();-            if !Bridge::ok(&v) && d["counts"].is_null() {-                err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);-            }-            if unresolved > 0 {-                err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);-            }-            if !unacked.is_empty() {-                err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);-            }-            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);-        }-        Cmd::Tour { what, nets, port, on } => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            // a localhost Fields URL ({{port}}.localhost, 127.0.0.1) is reachable only on the machine-            // hosting this container: film it there (the user's primary machine), not on the test box-            let local_proxy = what == "fields" && std::env::var("VSCODE_PROXY_URI").map(|u| preboard::proxy_is_local(&u)).unwrap_or(false);-            let mut on_note = String::new();-            let br = match on {-                Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },-                _ if local_proxy => match primary_machine() {-                    Some(m) => { on_note = format!("filmed on {m}, the user's primary machine: the Fields URL is a localhost proxy only the container's host can reach (--on <box> overrides)"); Bridge { ai_thread: t.clone(), target: m } }-                    None => { on_note = "the Fields URL is a localhost proxy only the container's host can reach, and `adom-bridge targets` named no primary machine: filmed on the test box; say --on <the machine hosting this container> if the window does not appear".into(); bridge_of(&r, &cli) }-                },-                _ => bridge_of(&r, &cli),-            };-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let editor = if what == "fields" { 0 } else { br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())))).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };-            match what.as_str() {-                "3d" => {-                    r.stage_start("tour-3d", Some("binary"), None);-                    // the viewer, its own window, is this step's camera: swap the running clip onto it-                    let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));-                    std::thread::sleep(std::time::Duration::from_secs(6));-                    let find_viewer = || -> Option<i64> {-                        let w = br.call("kicad_window_info", &json!({}));-                        let d = w.get("data").cloned().unwrap_or(w.clone());-                        d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())-                    };-                    let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));-                    // a viewer opened in the background never paints its GL canvas (a white window with a-                    // black bar, "last render time 1 ms"): show it once, maximised, and wait until the-                    // bridge's screenshot says the canvas rendered, or refuse rather than film a blank-                    let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));-                    let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));-                    let mut painted = false;-                    for _ in 0..12 {-                        std::thread::sleep(std::time::Duration::from_secs(3));-                        let sh = br.call("kicad_screenshot_all", &json!({}));-                        let d = sh.get("data").cloned().unwrap_or(sh.clone());-                        if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {-                            painted = true;-                            break;-                        }-                    }-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    // the editor clip of this step visit is not the walkthrough: discard it, the viewer is the camera-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));-                    if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                        let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                        let stop = br.record_stop(&rid, &save_to);-                        settle_clip_stop(&mut r, pos, &stop, &dir);-                        r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");-                    }-                    if !painted {-                        let _ = br.call("kicad_close_3d_viewer", &json!({}));-                        r.log("clip-refused", json!({"step": step, "visit": visit, "why": "the 3D viewer's canvas never painted"}));-                        r.save().unwrap();-                        err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);-                    }-                    let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));-                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));-                    r.save().unwrap();-                    let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));-                    if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }-                    let tour = br.call("kicad_3d_view", &json!({"action": "tour", "pauseMs": 700}));-                    let cmds = tour["commands"].as_u64().unwrap_or(0);-                    std::thread::sleep(std::time::Duration::from_secs(2));-                    let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));-                    if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let stop = br.record_stop(&rid, &save_to);-                        let local = settle_clip_stop(&mut r, pos, &stop, &dir);-                        r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));-                        if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }-                    }-                    let _ = br.call("kicad_close_3d_viewer", &json!({}));-                    let clips_note = auto_clips(&r, &dir);-                    if !clips_note.is_empty() { eprintln!("{clips_note}"); }-                    r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);-                    r.outcome("tour-3d", json!({"commands": cmds}));-                    r.save().unwrap();-                    if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }-                    ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);-                }-                "nets" => {-                    let spec = spec_of(&r);-                    let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();-                    if list.is_empty() {-                        for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }-                        for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }-                        for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {-                            let b = board_of(&r);-                            for key in [src.clone(), dst.as_str().unwrap_or("").to_string()] { if let Some(p) = b.pad_by_key(&key) { if !list.contains(&p.net) { list.push(p.net.clone()); } } }-                        }-                    }-                    if list.is_empty() { err("no nets to tour: the spec has no loads, wideNets or kelvin, and --nets was not given", &[]); }-                    r.stage_start("tour-nets", Some("binary"), None);-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));-                    let mut toured: Vec<Value> = Vec::new();-                    let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));-                    std::thread::sleep(std::time::Duration::from_millis(1500));-                    for net in &list {-                        let sel = br.call("kicad_select_net", &json!({"filePath": rb, "net": net}));-                        if !Bridge::ok(&sel) || sel["selected"].as_u64().unwrap_or(0) == 0 {-                            let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));-                            r.stage_end("tour-nets", Some(json!({"failed": net, "error": sel["errorCode"]})), None);-                            r.save().unwrap();-                            err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);-                        }-                        r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));-                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));-                        std::thread::sleep(std::time::Duration::from_millis(2600));-                        let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();-                        if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }-                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));-                        std::thread::sleep(std::time::Duration::from_millis(1200));-                        toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"]}));-                    }-                    let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));-                    r.stage_end("tour-nets", Some(json!({"nets": toured.len()})), None);-                    r.outcome("tour-nets", json!({"nets": toured}));-                    r.save().unwrap();-                    ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);-                }-                "fields" => {-                    // the fields step: adom-fields solved the board and serves it; a pup window on the test-                    // box shows it, the recorder films that window while `adom-fields tour` drives it-                    let health = std::process::Command::new("adom-fields").args(["health", "--port", &port.to_string()]).output().map(|o| o.status.success()).unwrap_or(false);-                    if !health { err(&format!("adom-fields is not serving on port {port}"), &["Solve and serve first: `adom-fields analyze --board <the current board> --spec <spec> --out fields` then `adom-fields serve --fields fields --port ".to_string() + &port.to_string() + " &` (adom-wiki pkg install adom/adom-fields if it is missing).".into()]); }-                    let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));-                    r.stage_start("tour-fields", Some("binary"), None);-                    // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready-                    let _ = br.call("pup_prewarm", &json!({"reason": "aiflow fields walkthrough: warming pup on the test box"}));-                    for _ in 0..30 {-                        let rd = br.call("pup_readiness", &json!({}));-                        if rd["ready"].as_bool() == Some(true) { break; }-                        std::thread::sleep(std::time::Duration::from_secs(3));-                    }-                    // a fresh pup session every time: a session whose browser died answers browser_gone forever-                    let sid = format!("aiflow-fields-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>());-                    let op = br.call("pup_open_window", &json!({"sessionId": sid, "url": proxy, "width": 1600, "height": 1000, "reason": "aiflow fields walkthrough: the AI's current and heat analysis on camera"}));-                    let open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().unwrap_or("?").to_string()) };-                    // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed-                    let mut hwnd: Option<i64> = None;-                    for _ in 0..40 {-                        std::thread::sleep(std::time::Duration::from_secs(2));-                        let lw = br.call("desktop_list_windows", &json!({}));-                        // the relay wraps some answers as a JSON string under `output`-                        let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);-                        let d = lw.get("data").cloned().or_else(|| inner.get("data").cloned()).unwrap_or(lw.clone());-                        let wins = d["windows"].as_array().cloned().or_else(|| lw["windows"].as_array().cloned()).or_else(|| inner["windows"].as_array().cloned()).unwrap_or_default();-                        // the app's window, not a wiki page about it: the app titles itself exactly "Adom Fields"-                        hwnd = wins.iter().find(|w| w["title"].as_str().map(|t| (t == "Adom Fields" || t.starts_with("Adom Fields - ")) && !t.contains("Wiki") && !t.contains("wiki")).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())));-                        if hwnd.is_some() { break; }-                    }-                    let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on {} (no window titled Adom Fields{})", br.target, open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into(), if preboard::proxy_is_local(&proxy) { format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`.") } else { String::new() }, on_note.clone()]); };-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));-                    // the editor clip of this visit is not the walkthrough: the app window is the camera-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                        let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                        let stop = br.record_stop(&rid, &save_to);-                        settle_clip_stop(&mut r, pos, &stop, &dir);-                        r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");-                    }-                    std::thread::sleep(std::time::Duration::from_secs(4));-                    let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));-                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));-                    r.save().unwrap();-                    if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }-                    // no pace of our own: the app holds each beat and drifts the board gently, because-                    // this clip is played back at ten times speed-                    let tour = std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]).output();-                    let tour_ok = tour.as_ref().map(|o| o.status.success()).unwrap_or(false);-                    std::thread::sleep(std::time::Duration::from_secs(2));-                    if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let stop = br.record_stop(&rid, &save_to);-                        let local = settle_clip_stop(&mut r, pos, &stop, &dir);-                        r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));-                        if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }-                    }-                    let _ = br.call("pup_close_window", &json!({"sessionId": sid}));-                    let clips_note = auto_clips(&r, &dir);-                    if !clips_note.is_empty() { eprintln!("{clips_note}"); }-                    // the solved numbers ride the run: the issues are what the AI answers for-                    let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);-                    let issues = fields["issues"].as_array().cloned().unwrap_or_default();-                    r.stage_end("tour-fields", Some(json!({"issues": issues.len(), "tour": tour_ok})), None);-                    r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"]}));-                    r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));-                    r.save().unwrap();-                    if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }-                    ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);-                }-                other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),-            }-        }-        Cmd::Ledger { json: as_json } => {-            let r = load_run(&cli);-            let led = r.ledger();-            if led.is_empty() {-                err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);-            }-            if *as_json {-                for e in &led {-                    println!("{e}");-                }-            } else {-                for e in &led {-                    let t = e["t"].as_str().unwrap_or("");-                    match e["event"].as_str().unwrap_or("") {-                        "start" => println!("{t}  start      engine {} prompt {}", e["engine"], e["promptTime"]),-                        "turn" => println!("{t}  turn {:>3}   {:<10} think {:>6.1} min  {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),-                        "turn-end" => println!("{t}  end  {:>3}   {:<10} tool  {:>6.1} min  {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),-                        "step" => println!("{t}  step       {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),-                        "stage-end" => println!("{t}  stage      {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),-                        "marker" => println!("{t}  marker     {}", e["label"].as_str().unwrap_or("")),-                        "finish" | "deliver" => println!("{t}  {}    {}", e["event"].as_str().unwrap_or(""), e["minutes"]),-                        "prompt" => println!("{t}  prompt {:>2}  {}", e["n"], e["text"].as_str().unwrap_or("")),-                        "board-attached" => println!("{t}  board      {} attached to the intake run (clock from {})", e["board"].as_str().unwrap_or(""), e["promptTime"].as_str().unwrap_or("")),-                        "evidence" => println!("{t}  evidence   {} {} pass {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or(""), e["pass"]),-                        "evidence-refused" => println!("{t}  refused    {} {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or("")),-                        "done" => println!("{t}  done   {:>2}  {}", e["n"], e["message"].as_str().unwrap_or("")),-                        _ => {}-                    }-                }-            }-            println!("{}", step_table(&r));-        }-        Cmd::Step { name, back, why } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());-            for s in preboard::PREBOARD_STEPS { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }-            let known: Vec<&str> = known.iter().map(String::as_str).collect();-            let mut clip_stopped = false;-            let prev = r.data["currentStep"].as_str().map(str::to_string);-            record_usage(&mut r, &format!("step:{name}"));-            r.step_set(name, *back, why.as_deref());-            // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)-            let mut clip_note = String::new();-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let br = Bridge { ai_thread: thread(&cli), target };-                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    // the step's closing screenshot: what the board looked like when the step ended-                    if let Some(h) = caps[pos]["hwnd"].as_i64() {-                        let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));-                        if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {-                            r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));-                        }-                    }-                    let stop = br.record_stop(&rid, &save_to);-                    let local = settle_clip_stop(&mut r, pos, &stop, &dir);-                    let cap = &r.data["captures"][pos];-                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-                    let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());-                    r.log("clip-stop", clip_stop_event(json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid}), &stop));-                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {-                        clip_artifacts(&mut r, &st, &f, 10);-                    }-                    clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));-                    clip_stopped = true;-                }-                // the fields step films the app's window through `tour fields`: no editor clip for it-                let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };-                let disk = disk_check(&dir, &br, name);-                if let Err(e) = &disk {-                    r.log("clip-refused", json!({"step": name, "why": e}));-                    r.save().unwrap();-                    err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);-                }-                match hwnd {-                    Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {-                        Ok(rid) => {-                            // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back-                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));-                            r.mark(&format!("clip-start:{name}-{visit}"));-                            let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                            if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {-                                r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));-                            }-                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));-                            clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));-                        }-                        Err(rep) if rep["errorCode"].as_str() == Some("window_not_found") => {-                            // the remembered editor window is gone (KiCad closed, the box restarted): look once more, live-                            match br.pcb_editor_hwnd() {-                                Some(h2) if h2 != h => match br.record_window_start(h2, &format!("aiflow step {name}")) {-                                    Ok(rid) => { let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h2, "step": name, "visit": visit, "back": *back, "why": why})); r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid})); clip_note.push_str(&format!("clip {name}-{visit} recording (the editor moved to window {h2})")); }-                                    Err(rep2) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep2["errorCode"].as_str().unwrap_or("?"))),-                                },-                                _ => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box any more (capture open to bring the board back)")),-                            }-                        }-                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),-                    },-                    None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),-                    // before the board there is no editor to film: the desktop is the camera-                    None if preboard::PREBOARD_STEPS.contains(&name.as_str()) => match br.record_start(&format!("aiflow step {name} (before the board)")) {-                        Ok(rid) => {-                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "monitor", "step": name, "visit": visit, "back": *back, "why": why}));-                            r.mark(&format!("clip-start:{name}-{visit}"));-                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid, "window": "monitor"}));-                            clip_note.push_str(&format!("clip {name}-{visit} recording the desktop (no PCB editor before the board)"));-                        }-                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),-                    },-                    None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),-                }-            }-            r.save().unwrap();-            let clips_note = if clip_stopped { auto_clips(&r, &dir) } else { String::new() };-            let mut report_note = String::new();-            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {-                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));-                report_note = format!("Progress page: {url} ({})", notes.join("; "));-            }-            let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();-            let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };-            let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };-            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note, clips_note]);-        }-        Cmd::Deliver { video, message, accept_suspect } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: thread(&cli), target }, &dir);-                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }-            }-            let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();-            if !suspects.is_empty() && !*accept_suspect {-                err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!("  {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);-            }-            if r.data["clock"]["finishTime"].is_null() {-                err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the qualified board first, then the video and your message.".into()]);-            }-            let vp = Path::new(video);-            if !vp.is_file() {-                err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);-            }-            let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let brs = Bridge { ai_thread: thread(&cli), target };-                stop_running_clip(&mut r, &brs, &dir, "deliver");-            }-            let t = now();-            r.data["clock"]["deliveredTime"] = json!(t);-            record_usage(&mut r, "deliver");-            r.data["summary"]["planUsage"] = json!(usage_delta(&r));-            r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message});-            r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());-            r.data["summary"]["steps"] = Value::Object(r.step_report());-            r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0));-            r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"]})).collect::<Vec<_>>()).unwrap_or_default());-            r.log("deliver", json!({"minutes": r.delivered_minutes(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));-            r.save().unwrap();-            let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);-            let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));-            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {-                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));-                report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));-            }-            ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);-        }-    }-}--fn measurement_usable(value: Option<&Value>) -> bool {-    value.map(|v| v["failed"] != true && v["filledAreaByLayerMm2"].as_object().map(|a| !a.is_empty() && a.values().all(|n| n.as_f64().map(|x| x.is_finite() && x >= 0.0).unwrap_or(false))).unwrap_or(false)).unwrap_or(false)-}--#[cfg(test)]-mod mpn_mark_tests {-    use super::*;-    #[test]-    fn recognises_service_and_legacy_marks() {-        assert!(has_mpn_mark(b"#405 = PRODUCT('Adom.LaserEtch','Adom.LaserEtch','',(#406));"));-        assert!(has_mpn_mark(b"#3258 = PRODUCT('laser_etch','laser_etch','',(#1));"));-        assert!(!has_mpn_mark(b"#7 = PRODUCT('SOT-23-6','SOT-23-6','',(#8));"));-    }-}--#[cfg(test)]-mod measurement_gate_tests {-    use super::*;-    #[test]-    fn refuses_failed_or_missing_copper_measurement() {-        for v in [Value::Null, json!({"failed":true,"filledAreaByLayerMm2":{"F.Cu":12}}), json!({"filledAreaByLayerMm2":null}), json!({"filledAreaByLayerMm2":{}}), json!({"filledAreaByLayerMm2":{"F.Cu":-1}})] {-            assert!(!measurement_usable(Some(&v)));-        }-        assert!(!measurement_usable(None));-        assert!(measurement_usable(Some(&json!({"filledAreaByLayerMm2":{"F.Cu":12,"B.Cu":0}}))));-    }-}--#[cfg(test)]-mod clip_stop_tests {-    use super::*;--    fn run_with_capture(dir: &Path, cap: Value) -> Run {-        let _ = std::fs::remove_dir_all(dir);-        std::fs::create_dir_all(dir).unwrap();-        Run { dir: dir.to_path_buf(), data: json!({"captures": [cap]}) }-    }-    fn stop(remote: Option<&str>, pulled: bool) -> ClipStop {-        ClipStop { remote: remote.map(str::to_string), local: None, pulled, attempts: if remote.is_some() { 3 } else { 0 }, reply: Value::Null }-    }--    #[test]-    fn a_failed_pull_is_flagged_with_its_remote_path_not_a_local_file() {-        let dir = std::env::temp_dir().join(format!("aiflow-clipstop-a-{}", std::process::id()));-        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-1", "step": "components", "visit": 1}));-        let remote = "C:/Users/j/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4";-        assert_eq!(settle_clip_stop(&mut r, 0, &stop(Some(remote), false), &dir), None);-        let c = &r.data["captures"][0];-        assert!(c["file"].is_null() && c.get("file").is_some(), "stopped, with no local file");-        assert_eq!((c["pullFailed"].clone(), c["remoteFile"].clone()), (json!(true), json!(remote)));-        assert_eq!(clip_stop_event(json!({}), &stop(Some(remote), false))["pullFailed"], true);-        // the pull that lands clears the flag and names the local file-        std::fs::write(dir.join("window-1.mp4"), b"mp4").unwrap();-        let f = settle_clip_stop(&mut r, 0, &stop(Some(remote), true), &dir).unwrap();-        assert!(f.ends_with("window-1.mp4"));-        assert!(r.data["captures"][0].get("pullFailed").is_none());-        let _ = std::fs::remove_dir_all(&dir);-    }--    #[test]-    fn a_second_stop_never_blanks_the_first_stops_file() {-        let dir = std::env::temp_dir().join(format!("aiflow-clipstop-b-{}", std::process::id()));-        let local = dir.join("window-2.mp4").display().to_string();-        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-2", "step": "placement", "visit": 1, "stop": "2026-09-29T02:43:48Z", "file": local, "remoteFile": "C:/rec/window-2.mp4"}));-        std::fs::write(&local, b"mp4").unwrap();-        // the recorder answers "nothing running": no path-        assert_eq!(settle_clip_stop(&mut r, 0, &stop(None, false), &dir), Some(local.clone()));-        let c = &r.data["captures"][0];-        assert_eq!((c["file"].as_str(), c["remoteFile"].as_str(), c["stop"].as_str()), (Some(local.as_str()), Some("C:/rec/window-2.mp4"), Some("2026-09-29T02:43:48Z")));-        // a failed re-pull of the same file keeps the good local copy-        settle_clip_stop(&mut r, 0, &stop(Some("C:/rec/window-2.mp4"), false), &dir);-        assert_eq!(r.data["captures"][0]["file"].as_str(), Some(local.as_str()));-        assert!(r.data["captures"][0].get("pullFailed").is_none());-        // a first stop with no path still marks the capture stopped-        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "nets", "visit": 1}));-        settle_clip_stop(&mut r, 0, &stop(None, false), &dir);-        assert!(r.data["captures"][0].get("file").is_some());-        let _ = std::fs::remove_dir_all(&dir);-    }-}+    /// The Fusion side of a project: `library` (one project library with real 3D and part numbers), `reference` (placement and spec from a reference KiCad board, in the Fusion board's frame), `schematic` (the design started with the fab's rules, the schematic drawn live in groups), `export` (Gerbers, pick and place, BOM). `adom-aiflow fusion <step> --help` for each.+    Fusion { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },+    /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay+    Unroute { #[arg(long, required = true)] net: Vec<String> },+    /// KiCad DRC offline on the routing plan+    Gate { #[arg(long)] plan: Option<String> },+    /// Replay live through the bridge: route, vias, pours, moves+    Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway+    #[arg(long)] from: Option<usize> },+    /// Filled copper per layer from the live board+    Measure,+    /// current or thermal+    Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf> },+    /// The finish line; refuses until every check passes+    Finish,+    /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.+    Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+    /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.+    Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)+    #[arg(long)] accept_suspect: bool },+    /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.+    Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)+    #[arg(long)] surface: Option<String> },+    /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.+    Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },+    /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.+    Recut,+    /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.+    Compose { #[arg(long, default_value = "final.mp4")] out: String, /// the longest the final video may be, in seconds+    #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen+    #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings+    #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air+    #[arg(long)] script: Option<PathBuf> },+    /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step.+    Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video+    #[arg(long)] replaces: Option<String>, #[arg(long)] review: Option<PathBuf> },+    /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.+    Usage,+    /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement. Also checks every bound model is right way up (step2glb bbox after the footprint's rotate/offset); warnings must be fixed or acknowledged (--ack/--why) before finish.+    Models { /// only the offline 3D orientation check (no live kicad_model_check through the bridge)+    #[arg(long)] offline: bool, /// acknowledge orientation warnings for these references (comma separated) after looking at them in the native 3D viewer+    #[arg(long)] ack: Option<String>, /// why the acknowledged models are right as they are+    #[arg(long)] why: Option<String> },+    /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.+    Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container+    #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)+    #[arg(long)] on: Option<String> },+    /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.+    Ledger { #[arg(long)] json: bool },+    /// open, start, mark <label>, stop+    Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },+    /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/+    Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },+    /// Where the run stands+    Status,+    /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.+    Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },+    /// The AI's answer to the current prompt is complete ("done, here is ..."): the session's clock stops here. `deliver` is the done of the first task.+    Done { #[arg(long)] message: String },+    /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is unobserved activity, not proven human idle).+    Sessions { #[arg(long)] json: bool },+}++static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();++/// Close the ledger entry for this command (see Run::turn_begin).+fn close_turn(ok_: bool, first_line: &str) {+    if let Some((dir, idx)) = TURN.get() {+        if let Some(mut r) = Run::open(dir) {+            r.turn_end(*idx, ok_, first_line);+            let _ = r.save();+        }+    }+}++fn ok(msg: &str, hints: &[String]) {+    close_turn(true, msg.lines().next().unwrap_or(""));+    println!("OK: {msg}");+    for h in hints {+        if !h.is_empty() {+            println!("Hint: {h}");+        }+    }+}++fn err(msg: &str, hints: &[String]) -> ! {+    close_turn(false, msg.lines().next().unwrap_or(""));+    println!("ERROR: {msg}");+    for h in hints {+        if !h.is_empty() {+            println!("Hint: {h}");+        }+    }+    std::process::exit(2)+}++fn thread(cli: &Cli) -> String {+    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))+}++fn run_dir(cli: &Cli) -> PathBuf {+    cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))+}++fn load_run(cli: &Cli) -> Run {+    let d = run_dir(cli);+    if let Some(r) = Run::open(&d) {+        if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::fusion::set_run_board(b); }+        // the run's desktop is where a remote KiCad runs, unless the human named another+        if let (Some(t), Err(_)) = (r.data["target"].as_str(), std::env::var("KICAD_REMOTE_TARGET")) { std::env::set_var("KICAD_REMOTE_TARGET", t); }+        return r;+    }+    Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))+}++fn read_json(p: &str) -> Value {+    serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[]))+}++fn spec_of(r: &Run) -> Value {+    if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }+    read_json(r.data["spec"].as_str().unwrap_or(""))+}++/// The spec when there is one, Null before the board (an intake run) or when it does not read.+fn spec_soft(r: &Run) -> Value {+    r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++/// requirements.json (aiflow-intake) when the run knows one, else Null.+fn requirements_of(r: &Run) -> Value {+    r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++/// The page the clips go to: `clips --page`, else the run's page (`start --page`, `report --page`).+fn clips_page_of(r: &Run) -> Option<String> {+    r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string)+}++/// Rebuild docs/clips.md in <run>/clips-page and, with `push`, put it and the changed clip media+/// on the page (`adom-wiki repo push --files`, batches of about 20 MB). Refuses to push a page+/// with an em-dash or a match of $AIFLOW_BANNED_RE. Returns (what happened, the files).+fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String, Vec<String>), String> {+    let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    let (md, media) = preboard::clips_page(&r.data, dir, &notes, &now());+    let stage = dir.join("clips-page");+    std::fs::create_dir_all(stage.join("docs").join("clips")).map_err(|e| format!("{}: {e}", stage.display()))?;+    // docs/clips.md belongs to the first run that published it (a hidden marker says which). A later+    // run on the same page writes docs/clips-<run>.md and adds one link to it in docs/clips.md,+    // so a second run never overwrites the first run's clips.+    let id = r.data["reportId"].as_str().map(str::to_string).unwrap_or_else(|| report_id(r));+    let marker = format!("<!-- aiflow-clips-run: {id} -->");+    let remote = std::process::Command::new("adom-wiki").args(["repo", "show", page, "docs/clips.md"]).output().ok()+        .filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.trim_start().starts_with('#') || t.contains("aiflow-clips-run"));+    let owner = match &remote { None => true, Some(t) => t.contains(&marker) };+    let doc = if owner { "docs/clips.md".to_string() } else { format!("docs/clips-{id}.md") };+    let md = if owner { format!("{marker}\n{md}") } else { format!("{marker}\n{}", md.replacen("# Build clips", &format!("# Build clips: run {id}"), 1)) };+    std::fs::write(stage.join(&doc), &md).map_err(|e| e.to_string())?;+    let mut index_update: Option<String> = None;+    if let (false, Some(t)) = (owner, &remote) {+        let link = format!("clips-{id}.md");+        if !t.contains(&link) {+            let line = format!("Clips of a later run on this page: [run {id}]({link}).");+            let mut lines: Vec<&str> = t.lines().collect();+            let at = lines.iter().position(|l| l.starts_with("# ")).map(|i| i + 1).unwrap_or(0);+            lines.insert(at, ""); lines.insert(at + 1, &line);+            let updated = lines.join("\n") + "\n";+            std::fs::write(stage.join("docs").join("clips.md"), &updated).map_err(|e| e.to_string())?;+            index_update = Some(updated);+        }+    }+    for (src, rel) in &media {+        let dest = stage.join(rel);+        let same = std::fs::metadata(&dest).ok().zip(std::fs::metadata(src).ok()).map(|(a, b)| a.len() == b.len()).unwrap_or(false);+        if !same { std::fs::copy(src, &dest).map_err(|e| format!("copy {}: {e}", src.display()))?; }+    }+    let clips = md.matches("\n### Clip ").count();+    let mut files: Vec<String> = vec![doc.clone()];+    if index_update.is_some() { files.push("docs/clips.md".into()); }+    files.extend(media.iter().map(|(_, rel)| rel.clone()));+    let banned = std::env::var("AIFLOW_BANNED_RE").ok();+    let scrubbed = preboard::scrub(&md, banned.as_deref());+    let url = format!("https://wiki.adom.inc/{page}/files/{doc}");+    if !push {+        return Ok((format!("{doc} rebuilt with {clips} clip(s) and {} media file(s) in {} (dry run, nothing pushed; {}); {url} after --push", media.len(), stage.display(), match &scrubbed { Ok(()) => "the scrub passes".to_string(), Err(e) => format!("a push would be refused: {e}") }), files));+    }+    scrubbed.map_err(|e| format!("not pushed: {e}"))?;+    // only what changed since the last push goes up (docs/clips.md always), in batches the gateway takes+    let cache_path = stage.join(".pushed.json");+    let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+    let mut todo: Vec<(String, u64, String)> = Vec::new();+    for f in &files {+        let p = stage.join(f);+        let sum = library_review::hash(&p).unwrap_or_default();+        if f == &doc || f == "docs/clips.md" || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }+    }+    let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];+    let mut acc = 0u64;+    for f in &todo {+        if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }+        batches.last_mut().unwrap().push(f); acc += f.1;+    }+    let (mut pushed, mut failed) = (0usize, Vec::new());+    for batch in batches.iter().filter(|b| !b.is_empty()) {+        let mut cmd = std::process::Command::new("adom-wiki");+        cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("clips: {clips} clip(s), {}", r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);+        for f in batch { cmd.arg(&f.0); }+        match cmd.output() {+            Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }+            Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())),+            Err(e) => failed.push(format!("{} file(s): {e}", batch.len())),+        }+    }+    let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());+    r.log("clips-page", json!({"page": page, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));+    if !failed.is_empty() { return Err(format!("push failed for {}; the next clip stop (or `clips --push`) sends it again", failed.join("; "))); }+    Ok((format!("clips page pushed: {clips} clip(s), {pushed} changed file(s) ({} unchanged skipped): {url}", files.len() - todo.len()), files))+}++/// After a clip stops: when the run has a page, rebuild and push the clips page. Best effort: a+/// failure is said, never a failed step.+fn auto_clips(r: &Run, dir: &Path) -> String {+    let Some(pg) = clips_page_of(r) else { return String::new() };+    match publish_clips(r, dir, &pg, true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") }+}++/// The user's primary machine (`adom-bridge targets` names it).+fn primary_machine() -> Option<String> {+    let o = std::process::Command::new("adom-bridge").arg("targets").output().ok()?;+    preboard::primary_from_targets(&serde_json::from_slice::<Value>(&o.stdout).ok()?)+}++/// The board's own copper as plan-shaped entries (one per net and track width, one per via), so the+/// analyses see a board whose copper is already on it (an adopted, hand-reworked or natively routed+/// board) and not only what a routing plan adds.+fn board_copper_plan(b: &Board) -> Value {+    let net_of = |n: &aiflow_board::sx::Node| -> String {+        let Some(x) = n.find("net") else { return String::new() };+        match x.number(1) { Some(v) => b.net_table.get(&(v as i64)).cloned().unwrap_or_default(), None => x.atom(1).unwrap_or("").to_string() }+    };+    let mut entries: Vec<Value> = Vec::new();+    let mut seen: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();+    for s in b.root.find_all("segment") {+        let (net, w) = (net_of(s), s.find("width").and_then(|w| w.number(1)).unwrap_or(0.0));+        if net.is_empty() || w <= 0.0 { continue; }+        if seen.insert((net.clone(), format!("{w:.4}"))) { entries.push(json!({"net": net, "width": w, "source": "board"})); }+    }+    for v in b.root.find_all("via") {+        let net = net_of(v);+        if net.is_empty() { continue; }+        let at = v.find("at");+        let (x, y) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0));+        let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);+        entries.push(json!({"net": net, "viaDrill": drill, "paths": [[{"layer": "B.Cu", "x": x, "y": y}]], "source": "board"}));+    }+    json!({"nets": entries})+}++/// The plan, plus the board's own copper for every net the plan carries no copper for.+fn plan_with_board_copper(plan: &Value, b: &Board) -> Value {+    let mut entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default();+    let planned: std::collections::BTreeSet<String> = entries.iter().filter_map(|e| e.get("net").and_then(|n| n.as_str()).map(str::to_string)).collect();+    for e in board_copper_plan(b)["nets"].as_array().cloned().unwrap_or_default() {+        if !planned.contains(e["net"].as_str().unwrap_or("")) { entries.push(e); }+    }+    json!({"nets": entries})+}++fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {+    let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for p in &b.pads {+        if p.thru || p.net.is_empty() {+            continue;+        }+        let l = if p.on_f { "F.Cu" } else { "B.Cu" };+        let e = m.entry(p.net.clone()).or_default();+        if !e.iter().any(|x| x == l) {+            e.push(l.to_string());+        }+    }+    m+}++/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;+/// every other copper or keepout zone on the live board is the flow's own.+fn inherited_zone_ids(r: &Run) -> Vec<String> {+    let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };+    let text = std::fs::read_to_string(p).unwrap_or_default();+    let mut ids = Vec::new();+    let mut from = 0;+    while let Some(i) = text[from..].find("(zone") {+        let start = from + i;+        let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());+        if let Some(u) = text[start..end].find("(uuid \"") {+            let s = start + u + 7;+            if let Some(e) = text[s..].find('"') {+                ids.push(text[s..s + e].to_string());+            }+        }+        from = start + 5;+    }+    ids+}++/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.+fn step_table(r: &Run) -> String {+    let rep = r.step_report();+    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+    for k in rep.keys() {+        if !names.contains(k) {+            names.push(k.clone());+        }+    }+    let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8}  {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];+    let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);+    for n in names {+        let e = &rep[&n];+        let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));+        tw += w; tt += t; tb += b; tn += k;+        lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8}  {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+    }+    lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));+    lines.join("\n")+}++++/// Free megabytes on the drive holding the run directory.+fn local_free_mb(dir: &Path) -> Option<u64> {+    let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;+    let text = String::from_utf8_lossy(&out.stdout);+    let line = text.lines().last()?;+    line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)+}++/// The recording budget for a step: measured window captures run about 8 MB per minute of+/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the+/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull+/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.+fn clip_budget_mb(step: &str) -> u64 {+    let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };+    minutes * 10 * 2+}++fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {+    let need = clip_budget_mb(step);+    let local = local_free_mb(dir);+    let remote = br.remote_free_mb();+    let floor = need + 1024;+    let mut msgs = Vec::new();+    if let Some(l) = local {+        if l < floor {+            return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));+        }+        msgs.push(format!("run drive {l} MB free"));+    }+    if let Some(rm) = remote {+        if rm < floor {+            return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));+        }+        msgs.push(format!("test box {rm} MB free"));+    }+    Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))+}+++/// The Adom faces, embedded so every caption on every machine is set in them.+fn run_fonts(dir: &Path) -> (String, String) {+    let fd = dir.join("fonts");+    let _ = std::fs::create_dir_all(&fd);+    let files: [(&str, &[u8]); 3] = [("FamiljenGrotesk-Bold.ttf", include_bytes!("../../../assets/fonts/FamiljenGrotesk-Bold.ttf")), ("Satoshi-Regular.ttf", include_bytes!("../../../assets/fonts/Satoshi-Regular.ttf")), ("Satoshi-Medium.ttf", include_bytes!("../../../assets/fonts/Satoshi-Medium.ttf"))];+    for (n, b) in files { let p = fd.join(n); if !p.is_file() { let _ = std::fs::write(&p, b); } }+    (fd.join("FamiljenGrotesk-Bold.ttf").display().to_string(), fd.join("Satoshi-Medium.ttf").display().to_string())+}++/// drawtext-safe text: no quotes, colons and commas escaped, percent doubled.+fn dt(t: &str) -> String {+    t.replace('\\', "").replace('\'', "").replace(':', "\\:").replace(',', "\\,").replace('%', "%%")+}+++/// The engine's plan usage, as the providers report it: percent used per limit window. Claude+/// from the Claude Code sign-in (the same call Claude Code's /usage panel makes), Codex from the+/// Codex sign-in (the same call its /status shows). Costs no quota. Returns [{provider, label, pct}].+fn usage_snapshot() -> Vec<Value> {+    let mut out = Vec::new();+    let home = std::env::var("HOME").unwrap_or_default();+    let curl = |url: &str, headers: &[String]| -> Option<Value> {+        let mut c = std::process::Command::new("curl");+        c.args(["-s", "-m", "12", url]);+        for h in headers { c.args(["-H", h]); }+        let o = c.output().ok()?;+        serde_json::from_slice::<Value>(&o.stdout).ok()+    };+    if let Ok(t) = std::fs::read_to_string(format!("{home}/.claude/.credentials.json")) {+        if let Ok(c) = serde_json::from_str::<Value>(&t) {+            let oa = c.get("claudeAiOauth").cloned().unwrap_or(c.clone());+            if let Some(tok) = oa.get("accessToken").and_then(|v| v.as_str()) {+                if let Some(u) = curl("https://api.anthropic.com/api/oauth/usage", &[format!("Authorization: Bearer {tok}"), "anthropic-beta: oauth-2025-04-20".into(), "User-Agent: adom-aiflow".into()]) {+                    for lim in u.get("limits").and_then(|l| l.as_array()).cloned().unwrap_or_default() {+                        let Some(pct) = lim.get("percent").and_then(|p| p.as_f64()) else { continue };+                        let label = match lim.get("kind").and_then(|k| k.as_str()) {+                            Some("session") => "5h".to_string(),+                            Some("weekly_all") => "week".to_string(),+                            Some("weekly_scoped") => lim["scope"]["model"]["display_name"].as_str().unwrap_or("model").to_string(),+                            Some(k) => k.to_string(),+                            None => "limit".to_string(),+                        };+                        out.push(json!({"provider": "claude", "label": label, "pct": pct, "resetsAt": lim.get("resets_at").cloned().unwrap_or(Value::Null)}));+                    }+                    if out.is_empty() {+                        for (label, key) in [("5h", "five_hour"), ("week", "seven_day")] {+                            if let Some(v) = u.get(key).and_then(|x| x.get("utilization")).and_then(|x| x.as_f64()) { out.push(json!({"provider": "claude", "label": label, "pct": v})); }+                        }+                    }+                }+            }+        }+    }+    if let Ok(t) = std::fs::read_to_string(format!("{home}/.codex/auth.json")) {+        if let Ok(a) = serde_json::from_str::<Value>(&t) {+            let tk = a.get("tokens").cloned().unwrap_or(Value::Null);+            if let Some(tok) = tk.get("access_token").and_then(|v| v.as_str()) {+                let acct = tk.get("account_id").and_then(|v| v.as_str()).unwrap_or("");+                if let Some(u) = curl("https://chatgpt.com/backend-api/wham/usage", &[format!("Authorization: Bearer {tok}"), format!("ChatGPT-Account-Id: {acct}"), "User-Agent: adom-aiflow".into(), "Accept: application/json".into()]) {+                    let rl = u.get("rate_limit").cloned().unwrap_or(Value::Null);+                    let label = |w: &Value| -> String { let secs = w.get("limit_window_seconds").and_then(|v| v.as_f64()).unwrap_or(0.0); if secs >= 6.0 * 86400.0 { "week".into() } else if secs >= 4.0 * 3600.0 { "5h".into() } else { format!("{}h", ((secs / 3600.0).floor() as i64).max(1)) } };+                    for key in ["primary_window", "secondary_window"] {+                        if let Some(w) = rl.get(key).filter(|w| w.is_object()) {+                            out.push(json!({"provider": "codex", "label": label(w), "pct": w.get("used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0), "plan": u.get("plan_type").cloned().unwrap_or(Value::Null)}));+                        }+                    }+                }+            }+        }+    }+    out+}++/// Snapshot the plan usage into the ledger and run.json (best effort, never fatal).+fn record_usage(r: &mut Run, when: &str) -> Vec<Value> {+    let snap = usage_snapshot();+    if !snap.is_empty() {+        r.log("usage", json!({"when": when, "limits": snap}));+        if !r.data["usage"].is_array() { r.data["usage"] = json!([]); }+        r.data["usage"].as_array_mut().unwrap().push(json!({"t": now(), "when": when, "limits": snap}));+    }+    snap+}++/// First and last usage per provider and label: what the run cost in plan percent.+fn usage_delta(r: &Run) -> Vec<Value> {+    let snaps = r.data["usage"].as_array().cloned().unwrap_or_default();+    let mut first: std::collections::BTreeMap<String, (String, String, f64)> = Default::default();+    let mut last: std::collections::BTreeMap<String, f64> = Default::default();+    for s in &snaps {+        for l in s["limits"].as_array().cloned().unwrap_or_default() {+            let key = format!("{}:{}", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""));+            let pct = l["pct"].as_f64().unwrap_or(0.0);+            first.entry(key.clone()).or_insert((l["provider"].as_str().unwrap_or("").to_string(), l["label"].as_str().unwrap_or("").to_string(), pct));+            last.insert(key, pct);+        }+    }+    first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": ((last[k] - f) * 10.0).round() / 10.0})).collect()+}++fn usage_line(r: &Run) -> String {+    let d = usage_delta(r);+    if d.is_empty() { return "plan usage: no sign-in found to read".into(); }+    format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.0}% -> {:.0}%", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "))+}+++/// The overlay chain the final video draws on a linear-time (10x) cut: the step chip top left,+/// the engine and board top right, and in the lower right two small tables, "RUN, SINCE YOUR+/// PROMPT" at the far right and "THIS STEP" beside it, headers HR / MIN / SEC over big numbers,+/// the HR column only when the run passed an hour. Same type sizes and row lines, 16 px margins.+/// Drawn after the decimation with t = real seconds inside the clip; `offset` is the clip's start+/// since the prompt. Returns the full -vf string from the 10x cut to the captioned action cut.+/// The overlay filters of the approved layout (step chip top-left, engine and board top-right, the+/// run clock and the step clock lower-right); `offset` is the segment's start on the run clock,+/// `step_offset` its start on the step clock, both in seconds, both advancing with the frame time.+fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String {+    let (font_display, font_text) = run_fonts(dir);+    let off = format!("{offset:.0}");+    let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;+    let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;+    let run_secs = format!("(t+{off})");+    let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours {+        (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap)+    } else {+        (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap)+    };+    let run_inner = run_w.max(162.0);+    let step_secs = format!("(t+{step_offset:.0})");+    let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];+    let step_inner = (2.0 * colw + gap).max(62.0);+    let mut ov: Vec<String> = Vec::new();+    let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad;+    ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box));+    ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));+    let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);+    let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner;+    ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));+    ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));+    for (k, (hdr, expr)) in run_cols.iter().enumerate() {+        let cx = format!("w-{}+{}", run_left, k as f64 * (colw + gap) + colw / 2.0);+        ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));+        ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));+    }+    for (k, (hdr, expr)) in step_cols.iter().enumerate() {+        let cx = format!("w-{}+{}", step_left, k as f64 * (colw + gap) + colw / 2.0);+        ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));+        ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));+    }+    ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));+    ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));+    ov.join(",")+}++/// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are+/// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at+/// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).+fn overlay_chain(dir: &Path, title: &str, engine_board: &str, offset: f64, run_has_hours: bool, speed: u32, rate: f64) -> String {+    format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,{},setpts=N/{rate:.3}/TB", overlay_filters(dir, title, engine_board, offset, 0.0, run_has_hours))+}++/// Drops frames that are almost entirely black: the window recorder's first frame is one, and the+/// scripted pace can stretch a single frame across seconds of narration. Every frame is tagged with+/// its black fraction (amount=0), and only the ones under 95 percent black pass.+const DROP_BLACK: &str = "blackframe=amount=0:threshold=24,metadata=mode=select:key=lavfi.blackframe.pblack:value=95:function=less";++/// A step's display name for chips and cards.+fn step_shown(step: &str) -> String {+    match step { "3d" => "3D WALKTHROUGH".into(), "nets" => "NET WALKTHROUGH".into(), "models" => "3D MODELS".into(), "current" => "CURRENT ANALYSIS".into(), "thermal" => "THERMAL ANALYSIS".into(), other => other.to_uppercase() }+}+++/// A clip stopped twice loses its file on the second stop (the recorder answers "nothing running"),+/// though the ledger kept the first stop's file under the same recording id. Put it back.+fn recover_capture_files(r: &mut Run, dir: &Path) -> usize {+    let mut by_rid: std::collections::HashMap<String, String> = Default::default();+    for e in &r.ledger() {+        if e["event"] == "clip-stop" {+            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {+                let local = dir.join(Path::new(f).file_name().unwrap_or_default());+                if local.is_file() { by_rid.insert(rid.to_string(), local.display().to_string()); }+            }+        }+    }+    let mut n = 0;+    if let Some(caps) = r.data["captures"].as_array_mut() {+        for c in caps.iter_mut() {+            if c["file"].as_str().is_some() { continue; }+            if let Some(f) = c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned() { c["file"] = json!(f); n += 1; }+        }+    }+    if n > 0 { r.mark(&format!("recovered {n} clip file(s) from the ledger")); }+    n+}++/// Put one clip stop on its capture. The remote path and the local file are only ever filled in,+/// never blanked: a second stop of the same recording (the recorder answers "nothing running")+/// keeps what the first stop recorded. A clip whose pull did not land after the retries is+/// flagged `pullFailed` with its desktop path, its `file` stays null, and a WARNING names the+/// remote path; `recut`, `finish` and `deliver` pull it again. Returns the local file.+fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Option<String> {+    let cap = &mut r.data["captures"][pos];+    let had_file = cap["file"].as_str().map(str::to_string);+    if stop.remote.is_some() || cap.get("stop").is_none() { cap["stop"] = json!(now()); }+    match &stop.remote {+        Some(remote) => {+            cap["remoteFile"] = json!(remote);+            let kept = had_file.as_deref().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false);+            if stop.pulled {+                cap["file"] = json!(dir.join(Path::new(remote).file_name().unwrap_or_default()).display().to_string());+                if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }+            } else if !kept {+                cap["file"] = Value::Null;+                cap["pullFailed"] = json!(true);+                cap["pullAttempts"] = json!(stop.attempts);+                eprintln!("WARNING: clip {}-{} did NOT reach the run after {} pull(s): it is still on the desktop at {remote}. Run `adom-aiflow recut` to pull it again before the desktop cleans its recordings folder.", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1), stop.attempts);+            }+        }+        None => {+            if had_file.is_none() { cap["file"] = Value::Null; }+            if cap.get("remoteFile").is_none() { cap["remoteFile"] = Value::Null; }+        }+    }+    cap["file"].as_str().map(str::to_string)+}++/// The ledger line of a clip stop: `file` is the desktop path (what recover_capture_files reads),+/// `pullFailed` when the pull did not land.+fn clip_stop_event(mut ev: Value, stop: &ClipStop) -> Value {+    ev["file"] = json!(stop.remote);+    if stop.remote.is_some() && !stop.pulled { ev["pullFailed"] = json!(true); }+    ev+}++/// Pull again every stopped clip that is not in the run: flagged `pullFailed`, or recorded with a+/// local file that never arrived. The desktop path comes from the capture or, for older runs,+/// from the ledger's clip-stop lines. Returns (pulled now, still missing).+fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) {+    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+    let mut by_rid: std::collections::HashMap<String, String> = Default::default();+    for e in &r.ledger() {+        if e["event"] == "clip-stop" {+            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) { by_rid.insert(rid.to_string(), f.to_string()); }+        }+    }+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    let (mut pulled, mut missing) = (0, 0);+    for (i, c) in caps.iter().enumerate() {+        // running clips have no file key yet; discarded ones are never shown+        if c.get("file").is_none() || c.get("discarded").is_some() { continue; }+        if c["file"].as_str().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false) { continue; }+        let Some(remote) = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned()) else { continue };+        let local = dir.join(Path::new(&remote).file_name().unwrap_or_default()).display().to_string();+        let tag = format!("{}-{}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1));+        let landed = aiflow_bridge::nonempty(Path::new(&local)) || br.pull_verified(&remote, &save_to, aiflow_bridge::PULL_ATTEMPTS).0;+        let cap = &mut r.data["captures"][i];+        cap["remoteFile"] = json!(remote);+        if landed {+            cap["file"] = json!(local);+            cap["pulledLater"] = json!(now());+            if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }+            r.log("clip-pulled", json!({"step": tag, "recordingId": c["recordingId"], "file": remote}));+            clip_artifacts(r, &json!(tag), &local, 10);+            pulled += 1;+        } else {+            cap["pullFailed"] = json!(true);+            eprintln!("WARNING: clip {tag} is still not in the run: pulling {remote} from the desktop failed again (the desktop may have cleaned it). Re-record the step if it matters.");+            missing += 1;+        }+    }+    if pulled > 0 { r.mark(&format!("pulled {pulled} clip(s) that had not reached the run")); }+    (pulled, missing)+}++/// True when a STEP carries a laser-etched MPN part: `Adom.LaserEtch` (adom-aiflow-etch 0.1.48+, the+/// shared service) or the legacy `laser_etch` part of earlier releases.+fn has_mpn_mark(bytes: &[u8]) -> bool {+    bytes.windows(14).any(|w| w == b"Adom.LaserEtch") || bytes.windows(10).any(|w| w == b"laser_etch")+}++/// IC footprints (U*, IC*) whose bound 3D model is a local STEP without a laser-etched MPN part.+/// Models are found beside the source board (${KIPRJMOD}); an unresolvable path is left to `models`.+fn unmarked_ics(r: &Run) -> Vec<String> {+    let Ok(b) = Board::load(&r.current_board()) else { return vec![] };+    let proj = r.data["sourceBoard"].as_str().map(|p| Path::new(p).parent().map(|d| d.to_path_buf()).unwrap_or_default()).unwrap_or_default();+    let mut out = Vec::new();+    for fp in b.root.find_all("footprint") {+        let Some(reference) = fp.find_all("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).map(str::to_string) else { continue };+        let is_ic = reference.starts_with("IC") || (reference.starts_with('U') && reference[1..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false));+        if !is_ic { continue; }+        for m in fp.find_all("model").filter_map(|n| n.value()) {+            let path = m.replace("${KIPRJMOD}", &proj.display().to_string());+            if !(path.to_ascii_lowercase().ends_with(".step") || path.to_ascii_lowercase().ends_with(".stp")) { continue; }+            match std::fs::read(&path) {+                Ok(bytes) => { if !has_mpn_mark(&bytes) { out.push(reference.clone()); } }+                Err(_) => {}+            }+        }+    }+    out+}++/// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so+/// no recorder outlives the run (a clip left running once recorded nine hours of a static editor).+fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;+    let rid = caps[pos]["recordingId"].as_str()?.to_string();+    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+    let stop = br.record_stop(&rid, &save_to);+    let local = settle_clip_stop(r, pos, &stop, dir);+    let cap = &mut r.data["captures"][pos];+    cap["stoppedBy"] = json!(why);+    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+    r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": why}), &stop));+    if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }+    let note = auto_clips(r, dir);+    if !note.is_empty() { eprintln!("{note}"); }+    local+}+++/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a+/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey+/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than+/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.+fn blank_check(file: &str) -> Option<String> {+    let secs = media_seconds(file);+    if secs <= 0.0 { return Some("no frames".into()); }+    let n = 8usize;+    let mut flat = 0usize; let mut total = 0usize;+    for k in 0..n {+        let t = secs * (k as f64 + 0.5) / n as f64;+        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;+        let px = out.stdout;+        if px.len() < 1000 { continue; }+        let mut bins = [0usize; 16];+        for b in &px { bins[(*b as usize) >> 4] += 1; }+        total += 1;+        if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }+    }+    if total == 0 { return Some("could not read frames".into()); }+    if flat * 4 > total * 3 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }+}++/// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it+/// filmed before it moves on, and the run page shows it under the clip.+fn contact_sheet(file: &str) -> Option<String> {+    let secs = media_seconds(file);+    if secs <= 0.0 { return None; }+    let out = format!("{}-sheet.png", file.trim_end_matches(".mp4"));+    let fps = 9.0 / secs;+    let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", file, "-vf", &format!("fps={fps:.6},scale=426:-2,tile=3x3:padding=4:margin=4:color=0x0B1420"), "-frames:v", "1", &out]).status().map(|s| s.success()).unwrap_or(false);+    if ok && Path::new(&out).is_file() { Some(out) } else { None }+}++/// Seconds of pure black at the head of a clip (the recorder started before the window painted), up to five.+fn leading_black(file: &str) -> f64 {+    let out = std::process::Command::new("ffmpeg").args(["-v", "info", "-t", "6", "-i", file, "-an", "-vf", "blackdetect=d=0.05:pic_th=0.98:pix_th=0.10", "-f", "null", "-"]).output();+    let Ok(o) = out else { return 0.0 };+    let text = String::from_utf8_lossy(&o.stderr);+    for line in text.lines() {+        if let Some(i) = line.find("black_start:") {+            let rest = &line[i + 12..];+            let start: f64 = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(1.0);+            let end: f64 = line.find("black_end:").and_then(|j| line[j + 10..].split_whitespace().next()).and_then(|v| v.parse().ok()).unwrap_or(0.0);+            if start <= 0.05 && end > 0.0 && end <= 5.0 { return end; }+            break;+        }+    }+    0.0+}++/// The clip's seconds, from ffprobe (0.0 when unknown).+fn media_seconds(path: &str) -> f64 {+    std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)+}++/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts+/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.+fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {+    let dir = r.dir.clone();+    let tag = step.as_str().unwrap_or("step").to_string();+    // the clip's offset from the prompt, so the overlay can show the run's own clock+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    let cap = caps.iter().find(|c| c["file"].as_str() == Some(file) || format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag).cloned().unwrap_or(Value::Null);+    let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+    let offset = cap["start"].as_str().and_then(aiflow_run::parse).map(|t| t.saturating_sub(t0)).unwrap_or(0) as f64;+    let (step_name, visit, back) = (cap["step"].as_str().unwrap_or(&tag).to_string(), cap["visit"].as_u64().unwrap_or(1), cap["back"].as_bool() == Some(true));+    let _ = (&step_name, visit, back, offset, &dir);+    let secs = media_seconds(file);+    // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an+    // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud+    let verdict = blank_check(file);+    if let Some(why) = &verdict {+        eprintln!("WARNING clip {} looks blank: {why}", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));+        if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["suspect"] = json!(why); }+        r.log("clip-suspect", json!({"step": step, "file": file, "why": why}));+    }+    r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0, "suspect": verdict}));+    if let Some(sheet) = contact_sheet(file) {+        r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));+        eprintln!("contact sheet (look at it before you go on): {sheet}");+    }+    let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));+    // the recorder's first frames are black before the window paints: they are cut off the head+    // of both cuts (never more than five seconds; longer black is a real clip problem, kept)+    let lead = leading_black(file);+    let ss = format!("{lead:.3}");+    let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);+    if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }+    if cut {+        let fast = media_seconds(&out);+        r.log("artifact", json!({"step": step, "kind": format!("clip{speed}x"), "file": out, "seconds": (fast * 10.0).round() / 10.0, "speed": speed}));+        eprintln!("clip {:.1} s, {speed}x cut {:.1} s: {out}", secs, fast);+        // the action cut: only the frames that changed, with the real elapsed time burned in.+        // Made from the 10x cut (cheap): stretch its timestamps back to real time so the clock is+        // honest, drop near-duplicate frames, then pack what is left at 30 fps.+        let action = format!("{}-action.mp4", file.trim_end_matches(".mp4"));+        // the action cut stays RAW (no captions): the final video draws its own overlays from the+        // ledger, and a side-by-side of two runs must not paint overlays over overlays+        let vf = format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,setpts=N/4/TB");+        let ok_action = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &out, "-an", "-vf", &vf, "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &action]).status().map(|s| s.success()).unwrap_or(false);+        if ok_action {+            let a = media_seconds(&action);+            r.log("artifact", json!({"step": step, "kind": "clipAction", "file": action, "seconds": (a * 10.0).round() / 10.0, "realSeconds": (secs * 10.0).round() / 10.0}));+            eprintln!("action cut {:.1} s of motion out of {:.1} s: {action}", a, secs);+        }+    }+}+++/// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made.+enum Piece { Library { file:String, caption:String, at:u64, seconds:f64 }, Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } }++/// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is+/// said faster when even it runs long. The picture sets the length; the words are cut to it.+fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {+    if !has_tts || vlen < 1.5 { return None; }+    let mut best: Option<(PathBuf, f64, String)> = None;+    for (k, words) in tiers.iter().enumerate() {+        let n = words.split_whitespace().count() as f64;+        let last = k + 1 == tiers.len();+        if n > vlen * 2.3 && !last { continue; }+        let rate = if last && n > vlen * 2.3 { "+30%" } else { "+0%" };+        let audio = workdir.join(format!("say-{idx}-{k}.mp3"));+        let ok = std::process::Command::new("adom-tts").args(["say", "--rate", rate, "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);+        if !ok { continue; }+        let alen = media_seconds(audio.to_str().unwrap());+        best = Some((audio, alen, words.clone()));+        if alen <= vlen + 0.05 { break; }+    }+    best+}++/// The first clause of a sentence: up to the first semicolon, colon or comma.+fn first_clause(s: &str) -> String {+    s.split([';', ':', ',']).next().unwrap_or(s).trim().to_string()+}++/// A step's name in words for the narration.+fn step_said(step: &str) -> String {+    step_shown(step).to_lowercase().replace("3d", "3D")+}++/// Compose the final video. Returns (seconds, clip segments, stills, frame rate of motion, narrated).+fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64) -> (f64, usize, usize, f64, bool) {+    let led = r.ledger();+    let mut act: std::collections::BTreeMap<String, (f64, f64)> = Default::default();+    for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), (e["realSeconds"].as_f64().unwrap_or(0.0), e["seconds"].as_f64().unwrap_or(0.0))); } }+    let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    // a clip stopped twice loses its file on the second stop (the recorder answers "nothing running");+    // the ledger kept the first stop's file under the same recording id, so recover it from there+    let mut ledger_file: std::collections::HashMap<String, String> = Default::default();+    for e in &led {+        if e["event"] == "clip-stop" {+            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) {+                let local = dir.join(Path::new(f).file_name().unwrap_or_default());+                if local.is_file() { ledger_file.insert(rid.to_string(), local.display().to_string()); }+            }+        }+    }+    // the pieces, in the order they happened: clips by their start, drawings by the moment the AI registered them+    let mut pieces: Vec<(u64, Piece)> = Vec::new();+    for (i, c) in caps.iter().enumerate() {+        if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }+        let recovered = c["recordingId"].as_str().and_then(|rid| ledger_file.get(rid)).cloned();+        let mut c = c.clone();+        if c["file"].as_str().is_none() { if let Some(f) = &recovered { c["file"] = json!(f); } }+        let c = &c;+        let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+        if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }+        let visit = c["visit"].as_u64().unwrap_or(1);+        let tag = format!("{step}-{visit}");+        let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+        if !Path::new(&fast).is_file() { continue; }+        let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));+        let motion = act.get(&tag).map(|x| x.1).filter(|m| *m > 0.0).unwrap_or_else(|| media_seconds(&action));+        // a visit with under a second of motion is nothing to watch: it stays on the page, not in the video+        if motion < 1.0 { eprintln!("segment {tag}: {motion:.1} s of motion, left out"); continue; }+        let at = c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+        // a clip that ran while the human was away (a recorder left on) is nothing to watch+        let stop = c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(at);+        if r.active_between(at, stop) == 0 && stop.saturating_sub(at) > r.idle_minutes() * 60 { eprintln!("segment {tag}: recorded outside the AI's time, left out"); continue; }+        pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion }));+    }+    for e in &standing_artifacts(&led) {+        if e["kind"]=="library-overview" {+            let Some(file)=e["file"].as_str() else {continue};+            let Some(review)=e["review"].as_str() else {err("library overview has no review contract", &["Re-register it with artifact --kind library-overview --review <review.json>.".into()])};+            library_review::verify(Path::new(review),Path::new(&r.current_board()),Path::new(file)).unwrap_or_else(|e|err(&e,&["Re-review the current native board and selected component variants before composing.".into()]));+            let at=e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+            pieces.push((at,Piece::Library{file:file.into(),caption:e["caption"].as_str().unwrap_or("Component library").into(),at,seconds:media_seconds(file).min(5.0)}));+            continue;+        }+        if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }+        let Some(f) = e["file"].as_str() else { continue };+        if !Path::new(f).is_file() { continue; }+        let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+        pieces.push((at, Piece::Still { file: f.to_string(), caption: e["caption"].as_str().unwrap_or("").to_string(), step: e["step"].as_str().unwrap_or("").to_string(), visit: e["visit"].as_u64().unwrap_or(1), at }));+    }+    pieces.sort_by_key(|p| p.0);+    if pieces.is_empty() { err("nothing to compose: no clips with motion and no drawings in this run (run `recut` first)", &[]); }+    // the pace: every kept frame of motion plays at 4 per second when the run fits; faster, evenly, when it does not+    let n_stills = pieces.iter().filter(|p| matches!(p.1, Piece::Still { .. })).count();+    let motion_total: f64 = pieces.iter().map(|p| if let Piece::Clip { motion, .. } = &p.1 { *motion } else { 0.0 }).sum();+    let library_seconds: f64=pieces.iter().map(|p|if let Piece::Library{seconds,..}=p.1{seconds}else{0.0}).sum();+    let still_seconds=still_seconds.min((max_seconds-library_seconds).max(0.0)*0.5/(n_stills.max(1) as f64));+    if library_seconds>=max_seconds {err("component overviews exceed the video budget", &["Replace superseded overviews with artifact --replaces, or raise compose --max-seconds.".into()]);}+    let clip_budget = (max_seconds - library_seconds - n_stills as f64 * still_seconds).max(0.1);+    let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 };+    let engine = r.data["engine"].as_str().unwrap_or("").to_string();+    let minutes = r.active_minutes_total();+    let run_has_hours = minutes >= 60.0;+    let board = Path::new(r.data["sourceBoard"].as_str().or(r.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+    let engine_board = format!("{engine}   ·   {board}");+    let workdir = dir.join("compose");+    let _ = std::fs::remove_dir_all(&workdir);+    let _ = std::fs::create_dir_all(&workdir);+    let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);+    let step_does = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };+    let visit_start = |step: &str, visit: u64| -> u64 { caps.iter().find(|c| c["step"].as_str() == Some(step) && c["visit"].as_u64() == Some(visit)).and_then(|c| c["start"].as_str()).and_then(aiflow_run::parse).unwrap_or(0) };+    let fit = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0B1420";+    let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();+    let (mut n_clips, mut n_stills_done) = (0usize, 0usize);+    for (k, (_, piece)) in pieces.iter().enumerate() {+        let seg = workdir.join(format!("seg-{k}.mp4"));+        let aseg = workdir.join(format!("aud-{k}.m4a"));+        let (vlen, tiers): (f64, Vec<String>) = match piece {+            Piece::Library{file,caption,at,seconds}=>{+                let offset=r.active_offset(*at) as f64;+                let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));+                let good=std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]).status().map(|s|s.success()).unwrap_or(false);+                if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);}+                n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])+            }+            Piece::Clip { cap: c, idx, motion } => {+                let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));+                let tag = format!("{step}-{visit}");+                let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+                // the run clock is the AI's time: the active seconds from the first prompt to this clip+                let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));+                let offset = r.active_offset(cs) as f64;+                let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });+                let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));+                let okc = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+                if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }+                let vlen = media_seconds(seg.to_str().unwrap());+                if vlen < 0.5 { continue; }+                let _ = (idx, motion);+                let real = if ce > cs { r.active_between(cs, ce) as f64 } else { act.get(&tag).map(|x| x.0).unwrap_or_else(|| media_seconds(f)) };+                let said = step_said(step);+                let visit_words = if visit > 1 { format!(", visit {visit}") } else { String::new() };+                let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));+                let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });+                n_clips += 1;+                (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])+            }+            Piece::Still { file, caption, step, visit, at } => {+                let offset = r.active_offset(*at) as f64;+                let step_offset = r.active_between(visit_start(step, *visit), *at) as f64;+                let title = format!("{}   ·   the AI's drawing", step_shown(step));+                // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p+                let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));+                let oks = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+                if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }+                let vlen = media_seconds(seg.to_str().unwrap());+                n_stills_done += 1;+                (vlen, vec![caption.clone(), first_clause(caption), format!("{}, the AI's own drawing.", step_said(step))])+            }+        };+        // the words are cut to the picture: the segment is exactly as long as its motion+        let said = narrate(&workdir, k, &tiers, vlen, has_tts);+        let oka = match &said {+            Some((audio, _, _)) => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),+            None => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),+        };+        if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }+        if oka { parts.push((seg, aseg)); }+    }+    if parts.is_empty() { err("nothing to compose: every segment failed to render", &[]); }+    let vlist = workdir.join("video.txt");+    let alist = workdir.join("audio.txt");+    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();+    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();+    let vcat = workdir.join("video.mp4");+    let acat = workdir.join("audio.m4a");+    let okv = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+    let oka = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+    let okf = okv && oka && std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }+    // the page takes files up to 10 MB: when the cut is bigger, the video is re-encoded once at the+    // bitrate that fits (screen captures are mostly still, so the picture survives it)+    let cap_bytes: u64 = 9_800_000;+    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {+        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);+        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();+        let fitted = workdir.join("fitted.mp4");+        let okr = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+        if okr { let _ = std::fs::copy(&fitted, outp); eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0)); }+    }+    let secs = media_seconds(outp.to_str().unwrap());+    if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }+    (secs, n_clips, n_stills_done, rate, has_tts)+}++/// The AI's artifacts that still stand: an artifact whose file a later `artifact --replaces` names is+/// superseded, as is an earlier event of the same file re-registered later. Returns the ledger events.+fn standing_artifacts(led: &[Value]) -> Vec<Value> {+    let mut out: Vec<Value> = Vec::new();+    for (i, e) in led.iter().enumerate() {+        if e["event"] != "artifact" || e["byAi"].as_bool() != Some(true) { continue; }+        let Some(f) = e["file"].as_str() else { continue };+        let superseded = led.iter().skip(i + 1).any(|l| l["event"] == "artifact" && l["byAi"].as_bool() == Some(true) && (l["replaces"].as_str() == Some(f) || l["file"].as_str() == Some(f)));+        if !superseded { out.push(e.clone()); }+    }+    out+}++/// The sessions table for the terminal and the run page.+fn sessions_table(r: &Run) -> String {+    let ss = r.sessions();+    let mut out = String::from("| Session | Prompt at | Done at | AI time min | Unobserved gap min | What |\n|---|---|---|---|---|---|\n");+    for x in &ss {+        out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/")));+    }+    out.push_str(&format!("| **all** | | | **{:.1}** | | |\n", r.active_minutes_total()));+    out+}++/// One engine's clip of one step visit, ready for the side-by-side.+struct VisitClip { tenx: String, motion: f64, offset: f64, title: String }++/// The 10x cut of a capture, in its own run or, for a run pulled from another machine, by file name in the run directory.+fn tenx_of(run_dir: &Path, file: &str) -> Option<String> {+    let fast = format!("{}-10x.mp4", file.trim_end_matches(".mp4"));+    if Path::new(&fast).is_file() { return Some(fast); }+    let local = run_dir.join(Path::new(&fast).file_name()?);+    if local.is_file() { Some(local.display().to_string()) } else { None }+}++/// A run's visits per step (flow order kept by the caller), with their motion seconds and active-time offsets.+fn visits_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<VisitClip>> {+    let led = r.ledger();+    let mut act: std::collections::BTreeMap<String, f64> = Default::default();+    for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), e["seconds"].as_f64().unwrap_or(0.0)); } }+    let mut out: std::collections::BTreeMap<String, Vec<VisitClip>> = Default::default();+    let mut caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));+    for c in &caps {+        if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }+        let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+        // the 3D step's camera is the viewer; an editor clip under that step is the wait before it opened+        if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }+        let visit = c["visit"].as_u64().unwrap_or(1);+        let Some(tenx) = tenx_of(&r.dir, f) else { continue };+        let motion = act.get(&format!("{step}-{visit}")).copied().unwrap_or(0.0);+        if motion < 1.0 { continue; }+        let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));+        if r.active_between(cs, ce) == 0 && ce.saturating_sub(cs) > r.idle_minutes() * 60 { continue; }+        let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });+        out.entry(step.to_string()).or_default().push(VisitClip { tenx, motion, offset: r.active_offset(cs) as f64, title });+    }+    out+}++/// A run's standing drawings per step, as (file, caption, active offset).+fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, String, f64)>> {+    let mut out: std::collections::BTreeMap<String, Vec<(String, String, f64)>> = Default::default();+    for e in standing_artifacts(&r.ledger()) {+        if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }+        let Some(f) = e["file"].as_str() else { continue };+        let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name().unwrap_or_default()); if l.is_file() { l.display().to_string() } else { continue } };+        let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+        out.entry(e["step"].as_str().unwrap_or("").to_string()).or_default().push((local, e["caption"].as_str().unwrap_or("").to_string(), r.active_offset(at) as f64));+    }+    out+}++fn run_ffmpeg(args: &[&str]) -> bool {+    std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args).status().map(|s| s.success()).unwrap_or(false)+}++/// Two runs side by side, step by step in flow order: the left engine's visits of the step play+/// in its column, the right engine's in its column, both paced to the same seconds (the run with+/// more motion sets the pace; the other plays slower, never faster than its own clips); their own+/// clocks in their own corners; the drawings of both side by side; the words cut to the picture.+fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64, script: Option<&serde_json::Map<String, Value>>) -> (f64, usize, usize, bool) {+    let (fd, ft) = run_fonts(dir);+    let workdir = dir.join("compose-pair");+    let _ = std::fs::remove_dir_all(&workdir);+    let _ = std::fs::create_dir_all(&workdir);+    let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);+    let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());+    let board = Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+    let (va, vb) = (visits_by_step(a), visits_by_step(b));+    let (da, db) = (drawings_by_step(a), drawings_by_step(b));+    let (ra, rb) = (a.step_report(), b.step_report());+    let hours = a.active_minutes_total() >= 60.0 || b.active_minutes_total() >= 60.0;+    // the steps in flow order, then any the runs added+    // the story's order: the build steps, the analyses, the walkthroughs, the finish line last+    let mut steps: Vec<String> = ["intake", "models", "placement", "routing", "pours", "current", "thermal", "fields", "nets", "3d", "finish"].iter().map(|s| s.to_string()).collect();+    for k in va.keys().chain(vb.keys()).chain(da.keys()).chain(db.keys()) { if !steps.contains(k) { steps.insert(steps.len() - 1, k.clone()); } }+    let has = |s: &str| va.contains_key(s) || vb.contains_key(s) || da.contains_key(s) || db.contains_key(s);+    let steps: Vec<String> = steps.into_iter().filter(|s| has(s) && s != "capture").collect();+    // the pace: each step's seconds is the larger of the two runs' motion; scaled evenly to fit the budget+    let frames = |v: Option<&Vec<VisitClip>>| -> f64 { v.map(|x| x.iter().map(|c| c.motion * 4.0).sum()).unwrap_or(0.0) };+    let mut raw: Vec<f64> = Vec::new(); let mut pairs_total = 0usize;+    for s in &steps {+        let (fa, fb) = (frames(va.get(s)), frames(vb.get(s)));+        raw.push(fa.max(fb) / 4.0);+        pairs_total += da.get(s).map(|x| x.len()).unwrap_or(0).max(db.get(s).map(|x| x.len()).unwrap_or(0));+    }+    let raw_sum: f64 = raw.iter().sum();+    let clip_budget = (max_seconds - pairs_total as f64 * still_seconds).max(max_seconds * 0.5);+    let scale = if raw_sum > clip_budget { clip_budget / raw_sum } else { 1.0 };+    let mins = |rep: &serde_json::Map<String, Value>, s: &str| -> (f64, u64, u64) { rep.get(s).map(|e| (e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0))).unwrap_or((0.0, 0, 0)) };+    let (cw, ch, band) = (960, 540, 110);+    // the bars below the columns: this step's AI minutes per engine against the longest step of either run+    let max_min = steps.iter().map(|s| mins(&ra, s).0.max(mins(&rb, s).0)).fold(1.0f64, f64::max);+    let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+    let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());+    let footer = dt(&format!("Delivered: {na} in {d_a:.0} min of AI time with {ret_a} return{} to earlier steps   ·   {nb} in {d_b:.0} min with {ret_b} return{}", if ret_a == 1 { "" } else { "s" }, if ret_b == 1 { "" } else { "s" }));+    let frame = |title: &str, sub: &str, bars: Option<((f64, u64), (f64, u64))>| -> String {+        let mut f = format!("[0:v][1:v]hstack=inputs=2,pad=1920:1080:0:{band}:color=0x0B1420,drawtext=fontfile={fd}:text='{}':x=(w-tw)/2:y=22:fontsize=40:fontcolor=0xF2E9E1,drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=74:fontsize=20:fontcolor=0xD9E1E8", dt(title), dt(sub));+        if let Some(((ma, va), (mb, vb))) = bars {+            let (x0, wmax, y1, y2, h) = (420.0, 1180.0, 720.0, 800.0, 40.0);+            for (k, (name, m, v, color, y)) in [(&na, ma, va, "0xDB8B58", y1), (&nb, mb, vb, "0x5FB6D6", y2)].into_iter().enumerate() {+                let w = ((m / max_min) * wmax).max(4.0);+                let _ = k;+                f.push_str(&format!(",drawtext=fontfile={ft}:text='{}':x={x0}-tw-24:y={y}+9:fontsize=20:fontcolor=0xF2E9E1", dt(name)));+                f.push_str(&format!(",drawbox=x={x0}:y={y}:w={w:.0}:h={h}:color={color}@0.9:t=fill"));+                f.push_str(&format!(",drawtext=fontfile={fd}:text='{}':x={x0}+{w:.0}+16:y={y}+6:fontsize=26:fontcolor=0xF2E9E1", dt(&format!("{m:.0} min   ·   {v} visit{}", if v == 1 { "" } else { "s" }))));+            }+            f.push_str(&format!(",drawtext=fontfile={ft}:text='THIS STEP, AI TIME':x={x0}:y=684:fontsize=13:fontcolor=0xA0AFBE"));+        }+        f.push_str(&format!(",drawtext=fontfile={ft}:text='{footer}':x=(w-tw)/2:y=1010:fontsize=19:fontcolor=0xD9E1E8"));+        f+    };+    // a clip of any window shape fits the column both ways (a pup window is taller than the editor's)+    let fit_col = format!("scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=0x0B1420");+    let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();+    let (mut n_steps, mut n_pairs) = (0usize, 0usize);+    // with a script the words lead: each step's picture is paced to its narration (slower or faster,+    // never held, never cut short), so the voice never stops and never runs past the picture+    let scripted = script.is_some();+    let say_len = |k: usize, words: &str| -> Option<(PathBuf, f64)> {+        if !has_tts || words.trim().is_empty() { return None; }+        let audio = workdir.join(format!("script-{k}.mp3"));+        let okv = std::process::Command::new("adom-tts").args(["say", "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);+        if okv { Some((audio.clone(), media_seconds(audio.to_str().unwrap()))) } else { None }+    };+    for (si, step) in steps.iter().enumerate() {+        let script_words = script.and_then(|m| m.get(step.as_str())).and_then(|v| v.as_str()).map(str::to_string);+        let scripted_audio = script_words.as_ref().and_then(|w| say_len(1000 + si, w));+        let t_step = match &scripted_audio { Some((_, alen)) => alen + 0.3, None => raw[si] * scale };+        if scripted && script_words.is_none() { eprintln!("side by side, step {step}: no words in the script; the picture keeps its own pace"); }+        let mut seg_index = parts.len();+        // the two columns of clips+        if t_step >= 0.5 && (raw[si] > 0.0 || scripted_audio.is_some()) {+            let mut cols: Vec<PathBuf> = Vec::new();+            for (side, (run, name, vis)) in [(a as &Run, &na, va.get(step)), (b, &nb, vb.get(step))].into_iter().enumerate() {+                let col = workdir.join(format!("col-{si}-{side}.mp4"));+                let f = frames(vis);+                if f > 0.0 {+                    // scripted: the frames spread over the words (down to one frame every five seconds+                    // for a step with little motion); unscripted: never slower than one a second+                    let rate = if scripted_audio.is_some() { (f / t_step).max(0.2) } else { (f / t_step).max(1.0) };+                    let mut pieces: Vec<PathBuf> = Vec::new();+                    for (k, v) in vis.unwrap().iter().enumerate() {+                        let piece = workdir.join(format!("piece-{si}-{side}-{k}.mp4"));+                        let chain = format!("{},{fit_col}", overlay_chain(dir, &v.title, &format!("{name}   ·   {board}"), v.offset, hours, 10, rate));+                        if run_ffmpeg(&["-i", &v.tenx, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", piece.to_str().unwrap()]) { pieces.push(piece); }+                    }+                    let list = workdir.join(format!("col-{si}-{side}.txt"));+                    std::fs::write(&list, pieces.iter().map(|p| format!("file '{}'\n", std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display())).collect::<String>()).unwrap();+                    let cat = workdir.join(format!("cat-{si}-{side}.mp4"));+                    let _ = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", list.to_str().unwrap(), "-c", "copy", cat.to_str().unwrap()]);+                    // exactly t_step: the slower column holds its last frame for the rest (a side-by-side has to wait)+                    let _ = run_ffmpeg(&["-i", cat.to_str().unwrap(), "-vf", &format!("tpad=stop_mode=clone:stop_duration={t_step:.2},trim=0:{t_step:.3}"), "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]);+                    let _ = run;+                } else {+                    // no clip on this side: the run's own screenshot of the step, or its latest board shot,+                    // stands in with a caption, so the column is never a dark hole+                    let shot = latest_shot(&run.dir, step);+                    let msg = dt(&format!("{name}: no clip for this step{}", if shot.is_some() { ", its board as filmed last" } else { "" }));+                    let caption = format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=h-th-14:fontsize=20:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=8");+                    let okp = match &shot {+                        Some(img) => run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{t_step:.3}"), "-i", img, "-vf", &format!("{fit_col},{caption}"), "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),+                        None => false,+                    };+                    if !okp { let _ = run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={t_step:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE"), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]); }+                }+                if col.is_file() { cols.push(col); } else { eprintln!("side by side, step {step}, {name}: its column did not render; the step is left out"); }+            }+            if cols.len() == 2 {+                let (ma, mb) = (mins(&ra, step), mins(&rb, step));+                let seg = workdir.join(format!("seg-{}.mp4", parts.len()));+                let fc = frame(&step_shown(step), &format!("{na} on the left, {nb} on the right; each run's own clocks in its own corner"), Some(((ma.0, ma.1), (mb.0, mb.1))));+                if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {+                    let vlen = media_seconds(seg.to_str().unwrap());+                    let said = step_said(step);+                    let tiers = if step == "finish" {+                        let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+                        let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());+                        vec![format!("Delivered. {na}: {d_a:.0} minutes of AI time from the prompt to done, {ret_a} returns to earlier steps. {nb}: {d_b:.0} minutes, {ret_b} returns."), format!("Delivered. {na} {d_a:.0} minutes, {nb} {d_b:.0}."), "Delivered.".to_string()]+                    } else {+                        vec![format!("{said}. {na}: {:.0} minutes of AI time in {} visit{}. {nb}: {:.0} minutes in {} visit{}.", ma.0, ma.1, if ma.1 == 1 { "" } else { "s" }, mb.0, mb.1, if mb.1 == 1 { "" } else { "s" }), format!("{said}. {na} {:.0} minutes, {nb} {:.0}.", ma.0, mb.0), format!("{said}.")]+                    };+                    let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));+                    let oka = match &scripted_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };+                    if oka { parts.push((seg, aseg)); n_steps += 1; }+                }+            }+        }+        // the drawings of both, side by side+        let (dra, drb) = (da.get(step).cloned().unwrap_or_default(), db.get(step).cloned().unwrap_or_default());+        for k in 0..dra.len().max(drb.len()) {+            let pair_words = script.and_then(|m| m.get(&format!("drawings:{step}:{k}")).or_else(|| m.get(&format!("drawings:{step}"))).filter(|_| k == 0 || m.contains_key(&format!("drawings:{step}:{k}")))).and_then(|v| v.as_str()).map(str::to_string);+            let pair_audio = pair_words.as_ref().and_then(|w| say_len(2000 + si * 10 + k, w));+            let still_len = match &pair_audio { Some((_, alen)) => alen + 0.3, None => still_seconds };+            let mut cols: Vec<PathBuf> = Vec::new();+            for (side, (name, d)) in [(&na, dra.get(k)), (&nb, drb.get(k))].into_iter().enumerate() {+                let col = workdir.join(format!("still-{si}-{k}-{side}.mp4"));+                let okc = match d {+                    Some((file, _, off)) => {+                        let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{}   ·   the AI's drawing", step_shown(step)), &format!("{name}   ·   {board}"), *off, 0.0, hours));+                        run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()])+                    }+                    None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),+                };+                if okc { cols.push(col); }+            }+            if cols.len() != 2 { continue; }+            let seg = workdir.join(format!("seg-{}.mp4", parts.len()));+            let (ma, mb) = (mins(&ra, step), mins(&rb, step));+            let fc = frame(&step_shown(step), &format!("the AIs' own drawings: {na} on the left, {nb} on the right"), Some(((ma.0, ma.1), (mb.0, mb.1))));+            if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {+                let vlen = media_seconds(seg.to_str().unwrap());+                let cap = dra.get(k).map(|x| x.1.clone()).or_else(|| drb.get(k).map(|x| x.1.clone())).unwrap_or_default();+                let tiers = vec![format!("The {} drawings, {na} left, {nb} right. {}", step_said(step), first_clause(&cap)), format!("The {} drawings, {na} left, {nb} right.", step_said(step)), format!("{} drawings.", step_said(step))];+                let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));+                let oka = match &pair_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };+                if oka { parts.push((seg, aseg)); n_pairs += 1; }+            }+        }+        let _ = &mut seg_index;+    }+    if parts.is_empty() { err("nothing to compose side by side: no step with motion in either run", &[]); }+    assemble(&workdir, &parts, outp);+    (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts)+}++/// The narration for one segment, exactly `vlen` seconds long (silence when there is nothing to say).+fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: bool, aseg: &Path) -> bool {+    match narrate(workdir, k, tiers, vlen, has_tts) {+        Some((audio, _, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]),+        None => run_ffmpeg(&["-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]),+    }+}++/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.+fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {+    let vlist = workdir.join("video.txt");+    let alist = workdir.join("audio.txt");+    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();+    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();+    let vcat = workdir.join("video.mp4");+    let acat = workdir.join("audio.m4a");+    let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);+    let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);+    let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);+    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }+    let cap_bytes: u64 = 9_800_000;+    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {+        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);+        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();+        let fitted = workdir.join("fitted.mp4");+        if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) {+            let _ = std::fs::copy(&fitted, outp);+            eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));+        }+    }+}++/// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push).+fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {+    let (ida, idb) = (report_id(a), report_id(b));+    let rel = format!("docs/comparisons/{ida}-vs-{idb}");+    let stage = dir.join("report");+    let out = stage.join(&rel);+    let _ = std::fs::remove_dir_all(&out);+    let _ = std::fs::create_dir_all(&out);+    let slug = page.split('/').nth(1).unwrap_or(page);+    let blob = format!("/blob/app/{slug}/{rel}");+    let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());+    // the board by the spec's name when it has one (ESC G431), else by its file+    let board = spec_of(a).get("board").and_then(|v| v.as_str()).map(|v| v.split('(').next().unwrap_or(v).trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string());+    let mut files: Vec<String> = Vec::new();+    let mut md = String::new();+    md.push_str(&format!("# {na} versus {nb} on the {board}\n\n"));+    md.push_str("Two AIs, the same board, the same spec, the same prompt, the same flow (Adom's AI Flow), measured the same way: the AI's own time from the prompt to \"done, here is your video\", every return to an earlier step, every clip, and the analysis each AI did itself. Nobody had put two engines through one measured board flow before; this page is the first pair.\n\n");+    // the video first+    if let Some(v) = a.data["comparison"]["video"].as_str().filter(|v| Path::new(v).is_file()) {+        let stamp: String = a.ledger().iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison").and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());+        let name = format!("comparison-{stamp}.mp4");+        let _ = std::fs::copy(v, out.join(&name)); files.push(format!("{rel}/{name}"));+        md.push_str(&format!("**The side by side, {:.0} s: {na} on the left, {nb} on the right, step by step in the flow's order; each run's own clocks in its own corner (AI time, the idle cut out); both AIs' analysis drawings side by side; each step's AI-minute bars below; the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", a.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));+    }+    // the number+    let (da, db) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+    let (ra, rb) = (a.step_report(), b.step_report());+    let turns = |r: &Run| r.data["turns"].as_array().map(|x| x.len()).unwrap_or(0);+    let think = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["thinkingSeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;+    let tool = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["binarySeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;+    md.push_str("## The number: prompt to \"done, here is your video\"\n\n");+    md.push_str("| | AI time to delivery | Returns to earlier steps | Turns | AI thinking min | Binary min |\n|---|---|---|---|---|---|\n");+    md.push_str(&format!("| **{na}** | **{da:.1} min** | {} | {} | {:.1} | {:.1} |\n", a.returns_before_delivery(), turns(a), think(&ra), tool(&ra)));+    md.push_str(&format!("| **{nb}** | **{db:.1} min** | {} | {} | {:.1} | {:.1} |\n\n", b.returns_before_delivery(), turns(b), think(&rb), tool(&rb)));+    md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (unobserved, not proven idle) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n");+    // per step+    let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();+    for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }+    md.push_str(&format!("## Per step, AI time\n\n| Step | {na} | {nb} |\n|---|---|---|\n"));+    for st in &steps {+        let cell = |rep: &serde_json::Map<String, Value>| rep.get(st).map(|e| format!("{:.1} min, {} visit{}{}", e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"], if e["visits"].as_u64() == Some(1) { "" } else { "s" }, if e["returns"].as_u64().unwrap_or(0) > 0 { format!(", {} return{}", e["returns"], if e["returns"].as_u64() == Some(1) { "" } else { "s" }) } else { String::new() })).unwrap_or_else(|| "-".into());+        if ra.contains_key(st) || rb.contains_key(st) { md.push_str(&format!("| {st} | {} | {} |\n", cell(&ra), cell(&rb))); }+    }+    md.push_str("\n");+    // the drawings side by side+    let draw = |r: &Run| -> Vec<(String, String, String)> { standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| { let f = e["file"].as_str()?; let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } }; Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string())) }).collect() };+    let (dra, drb) = (draw(a), draw(b));+    if !dra.is_empty() || !drb.is_empty() {+        md.push_str("## The analysis each AI did itself\n\nEach AI solved the current and the heat on its own board and drew it; the pictures are theirs, not the binary's. Left, then right, per step.\n\n");+        for st in &steps {+            let la: Vec<&(String, String, String)> = dra.iter().filter(|d| d.0 == *st).collect();+            let lb: Vec<&(String, String, String)> = drb.iter().filter(|d| d.0 == *st).collect();+            for k in 0..la.len().max(lb.len()) {+                md.push_str("<table><tr>");+                for (side, name, it) in [("a", &na, la.get(k)), ("b", &nb, lb.get(k))] {+                    match it {+                        Some((_, file, cap)) => { let fname = format!("{side}-{st}-{k}.{}", Path::new(file).extension().and_then(|x| x.to_str()).unwrap_or("png")); let _ = std::fs::copy(file, out.join(&fname)); files.push(format!("{rel}/{fname}")); md.push_str(&format!("<td width=\"50%\"><b>{name}: {}</b><br><img src=\"{blob}/{fname}\" width=\"100%\"></td>", cap.replace('|', "/"))); }+                        None => md.push_str(&format!("<td width=\"50%\"><b>{name}: no drawing for this step</b></td>")),+                    }+                }+                md.push_str("</tr></table>\n\n");+            }+        }+    }+    // the links+    md.push_str("## The two runs, in full\n\n");+    md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));+    md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));+    md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));+    md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");+    let readme = out.join("README.md");+    std::fs::write(&readme, md).unwrap();+    files.push(format!("{rel}/README.md"));+    (rel.clone(), format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"), files)+}++/// The newest screenshot of a step in a run directory (`shot-<step>-...png`), else the newest screenshot of any step.+fn latest_shot(dir: &Path, step: &str) -> Option<String> {+    let mut shots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with("shot-") && e.file_name().to_string_lossy().ends_with(".png")).filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();+    shots.sort_by(|a, b| b.0.cmp(&a.0));+    let of_step = shots.iter().find(|(_, p)| p.file_name().map(|n| n.to_string_lossy().starts_with(&format!("shot-{step}-"))).unwrap_or(false));+    of_step.or(shots.first()).map(|(_, p)| p.display().to_string())+}++/// The give-back hint: what the AI learned belongs to every Adom user.+fn give_back_hint() -> String {+    "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()+}+++/// The run id used for its page folder: board stem, engine slug, prompt date.+fn report_id(r: &Run) -> String {+    if let Some(id) = r.data["reportId"].as_str() {+        return id.to_string();+    }+    let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");+    let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+    let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");+    // prompt date and time, so two runs by one engine on one day get their own folders+    let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+    let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();+    let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();+    format!("{board}-{eng}-{date}-{hhmm}")+}++/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).+fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {+    let id = report_id(r);+    let rel = format!("docs/runs/{id}");+    let stage = dir.join("report");+    let out = stage.join(&rel);+    // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along+    let _ = std::fs::remove_dir_all(&out);+    let _ = std::fs::create_dir_all(&out);+    let slug = page.split('/').nth(1).unwrap_or(page);+    let blob = format!("/blob/app/{slug}/{rel}");+    let led = r.ledger();+    let engine = r.data["engine"].as_str().unwrap_or("");+    let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+    let finished = r.data["clock"]["finishTime"].as_str();+    let delivered = r.data["clock"]["deliveredTime"].as_str();+    let cur = r.data["currentStep"].as_str().unwrap_or("intake");+    let now_min = r.elapsed_minutes();+    let mut md = String::new();+    md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));+    let ss = r.sessions();+    let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);+    let status = match (delivered, finished) {+        (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),+        (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),+        _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),+    };+    md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));+    if let Some(d) = r.data["delivery"].as_object() {+        if let Some(v) = d.get("video").and_then(|v| v.as_str()) {+            if Path::new(v).is_file() {+                // a new name for every cut, so a browser that cached the last one shows this one+                let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).filter(|s: &String| !s.is_empty()).unwrap_or_else(|| std::fs::metadata(v).and_then(|m| m.modified()).ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()).unwrap_or_else(|| "0".into()));+                let name = format!("final-{stamp}.mp4");+                let _ = std::fs::copy(v, out.join(&name));+                let vs = media_seconds(v);+                md.push_str(&format!("## The video\n\n**The final cut of this run, {vs:.0} s: only the moments something moved, every step in order, the AI's analysis drawings fullscreen, the run and step clocks in the corner, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n"));+            }+        }+    }+    if let Some(v) = r.data["comparison"]["video"].as_str() {+        if Path::new(v).is_file() {+            let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());+            let name = format!("comparison-{stamp}.mp4");+            let _ = std::fs::copy(v, out.join(&name));+            md.push_str(&format!("## Side by side with {}\n\n**{} on the left, {} on the right, step by step in flow order, {:.0} s: each run's own clocks in its own corner, both AIs' drawings side by side, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", r.data["comparison"]["withEngine"].as_str().unwrap_or("another run"), engine, r.data["comparison"]["withEngine"].as_str().unwrap_or(""), r.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));+        }+    }+    // sessions+    md.push_str("## Sessions\n\nOne row per human prompt: the AI's time on it, unobserved gaps excluded by the heuristic, which may include external AI work.\n\n");+    md.push_str(&sessions_table(r));+    md.push_str("\n");+    // per-step table+    let rep = r.step_report();+    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+    for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }+    md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");+    let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);+    for n in &names {+        let e = &rep[n];+        let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));+        tw += w; tt += t; tb += b;+        md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+    }+    md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));+    // returns+    let mut returns: Vec<String> = Vec::new();+    for e in &led {+        if e["event"] == "step" && e["back"].as_bool() == Some(true) {+            returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));+        }+    }+    if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }+    // decisions and markers+    let mut marks: Vec<String> = Vec::new();+    for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }+    if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }+    // outcomes+    let o = r.outcomes();+    md.push_str("## Where the board stands\n\n");+    if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }+    if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }+    if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }+    if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours: {} groups, failed {}\n", x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }+    if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }+    for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }+    md.push_str("\n");+    // clips and screenshots per visit+    md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    // clip seconds from the ledger's artifacts+    let mut secs: std::collections::BTreeMap<String, (f64, f64, f64)> = std::collections::BTreeMap::new(); // real, 10x, action+    for e in &led {+        if e["event"] == "artifact" {+            let key = e["step"].as_str().unwrap_or("").to_string();+            let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));+            match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }+        }+    }+    let standing = standing_artifacts(&led);+    let what_of = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };+    for c in &caps {+        if c.get("discarded").is_some() { continue; }+        let Some(step) = c["step"].as_str() else { continue };+        let visit = c["visit"].as_u64().unwrap_or(1);+        let tag = format!("{step}-{visit}");+        let rework = c["back"].as_bool() == Some(true);+        let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };+        md.push_str(&format!("### {title}\n\n"));+        md.push_str(&format!("What this step does: {}\n\n", what_of(step)));+        if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }+        if let Some(sus) = c["suspect"].as_str() { md.push_str(&format!("**This clip filmed nothing usable: {sus}. It is kept as evidence and left out of the video.**\n\n")); }+        let (start, stop) = (c["start"].as_str().unwrap_or(""), c["stop"].as_str().unwrap_or("still running"));+        // the 10x cut next to the raw clip+        let mut have_clip = false;+        if let Some(f) = c["file"].as_str() {+            let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));+            let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+            let (real, quick, act) = secs.get(&tag).copied().unwrap_or((0.0, 0.0, 0.0));+            if Path::new(&action).is_file() {+                let _ = std::fs::copy(&action, out.join(format!("{tag}.mp4")));+                md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}. Motion only: {:.1} min of real time, {:.0} s of it had something moving; the clock in the corner is the real elapsed time.**\n\n", real / 60.0, act));+                md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));+                have_clip = true;+            } else if Path::new(&fast).is_file() {+                let _ = std::fs::copy(&fast, out.join(format!("{tag}.mp4")));+                md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}, {:.1} min of real time shown at 10x ({:.0} s).**\n\n", real / 60.0, quick));+                md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));+                have_clip = true;+            }+        }+        if !have_clip { md.push_str(&format!("**Clip: {title}, still recording (started {start}); it appears here when the step ends.**\n\n")); }+        if let Some(f) = c["file"].as_str() {+            let sheet = format!("{}-sheet.png", f.trim_end_matches(".mp4"));+            if Path::new(&sheet).is_file() {+                let _ = std::fs::copy(&sheet, out.join(format!("{tag}-sheet.png")));+                md.push_str(&format!("**Contact sheet: nine frames spread through the {title} clip, what the camera saw.**\n\n![nine frames of {title}]({blob}/{tag}-sheet.png)\n\n"));+            }+        }+        for when in ["start", "end"] {+            let src = dir.join(format!("shot-{tag}-{when}.png"));+            if src.is_file() {+                let _ = std::fs::copy(&src, out.join(format!("shot-{tag}-{when}.png")));+                md.push_str(&format!("**Screenshot: the board when {title} {}.**\n\n", if when == "start" { "began" } else { "ended" }));+                md.push_str(&format!("![the board when {title} {}]({blob}/shot-{tag}-{when}.png)\n\n", if when == "start" { "began" } else { "ended" }));+            }+        }+        // what the AI made during this visit: its own analysis drawings, with its caption above each+        for (n, e) in standing.iter().filter(|e| e["step"].as_str() == Some(step) && e["visit"].as_u64() == Some(visit)).enumerate() {+            let Some(f) = e["file"].as_str() else { continue };+            if !Path::new(f).is_file() { continue; }+            let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("png");+            let name = format!("{tag}-{}-{n}.{ext}", e["kind"].as_str().unwrap_or("artifact"));+            let _ = std::fs::copy(f, out.join(&name));+            let cap = e["caption"].as_str().unwrap_or("");+            if matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp") {+                md.push_str(&format!("**The AI's drawing: {cap}**\n\n![{cap}]({blob}/{name})\n\n"));+            } else {+                md.push_str(&format!("**The AI's file: {cap}** [{name}]({blob}/{name})\n\n"));+            }+        }+    }+    if let Some(d) = r.data["delivery"].as_object() {+        md.push_str("## Delivered\n\n");+        md.push_str(&format!("What the AI said when it delivered (the video is at the top of this page):\n\n> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));+    }+    let ud = usage_delta(r);+    if !ud.is_empty() {+        md.push_str("## Plan usage, the second cost line\n\nPercent of each plan limit used, as the provider reports it, at the start of the run and at its end.\n\n| Provider | Limit | At start | At end | Used by this run |\n|---|---|---|---|---|\n");+        for x in &ud { md.push_str(&format!("| {} | {} | {:.0} % | {:.0} % | {:.1} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }+        md.push_str("\n*The limits are per account, not per run: every AI thread signed in as this user draws on the same numbers, so a run made while other threads were working shows their usage too. Read it as an upper bound unless this run was the only thread active.*\n\n");+    }+    let gb = r.data["giveback"].as_array().cloned().unwrap_or_default();+    md.push_str(&format!("## Given back\n\n{} issue(s) or PR(s) filed by this run's AI to make the tool better for every Adom user{}\n", gb.len(), if gb.is_empty() { ".".to_string() } else { ":\n\n".to_string() + &gb.iter().map(|g| format!("- {} {}{}", g["kind"].as_str().unwrap_or("issue"), g["url"].as_str().unwrap_or(""), g["note"].as_str().map(|n| format!(": {n}")).unwrap_or_default())).collect::<Vec<_>>().join("\n") }));+    md.push_str("\nAdom processes PRs within minutes during business hours: a non-stop PR intake runs all day.\n");+    let readme = out.join("README.md");+    let _ = std::fs::write(&readme, md);+    (stage, rel, readme)+}++/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.+fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {+    let (stage, rel, _readme) = build_report(r, dir, page);+    // the files viewer renders a sub-README as a page; the bare path serves raw markdown+    let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");+    let mut notes = Vec::new();+    if push {+        // only the files that changed since the last push go up, in batches the gateway takes+        // (about 20 MB each; a 75 MB report in one request came back 502); a failed batch is said+        // out loud and the caller turns it into an ERROR+        let cache_path = dir.join("report").join(".pushed.json");+        let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+        let mut files: Vec<(String, u64, String)> = Vec::new();+        if let Ok(d) = std::fs::read_dir(stage.join(&rel)) {+            for e in d.filter_map(|e| e.ok()) {+                let name = e.file_name().to_string_lossy().to_string();+                if name.starts_with('.') { continue; }+                let size = e.metadata().map(|m| m.len()).unwrap_or(0);+                let sum = std::process::Command::new("sha256sum").arg(e.path()).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();+                files.push((format!("{rel}/{name}"), size, sum));+            }+        }+        files.sort();+        let changed: Vec<&(String, u64, String)> = files.iter().filter(|(p, _, sum)| cache.get(p).and_then(|v| v.as_str()) != Some(sum.as_str()) || sum.is_empty()).collect();+        let (mut pushed, mut failed) = (0usize, 0usize);+        let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];+        let mut acc = 0u64;+        for f in &changed {+            if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }+            batches.last_mut().unwrap().push(f); acc += f.1;+        }+        for batch in batches.iter().filter(|b| !b.is_empty()) {+            let mut cmd = std::process::Command::new("adom-wiki");+            cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("aiflow run {}: {}", report_id(r), r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);+            for f in batch { cmd.arg(&f.0); }+            match cmd.output() {+                Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }+                Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())); }+                Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }+            }+        }+        let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());+        notes.insert(0, format!("pushed {pushed} changed file(s) to {page}/{rel} ({} unchanged skipped{})", files.len() - changed.len(), if failed > 0 { format!("; {failed} FAILED") } else { String::new() }));+    }+    if refresh && r.data["reportSurface"].as_str() == Some("webview") {+        // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle+        let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+        let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());+        fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }+        let mut ids = Vec::new();+        if let Some(w) = &ws { leaves(&w["root"], &mut ids); }+        let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();+        let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();+        match panel {+            Some(pid) => {+                let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();+                notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });+            }+            None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),+        }+    } else if refresh {+        if let Some(m) = r.data["userMachine"].as_str() {+            let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };+            let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+            let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));+            if !Bridge::ok(&nav) {+                let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));+                notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+            } else {+                notes.push(format!("refreshed in the browser on {m}"));+            }+            let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));+        }+    }+    (url, notes)+}++fn board_of(r: &Run) -> Board {+    if r.board_pending() { err("this run has no board yet (an intake run)", &["Attach it when it exists: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (same run, same clock). Pre-board steps (sourcing, design, simulation, schematic) need no board.".into()]); }+    Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))+}++/// A Fusion run's live board, pulled fresh: `board-live.brd` (Fusion's own XML, fills included,+/// what adom-fields reads) and `board-live.kicad_pcb` (the same board for the offline checks).+/// None for a KiCad run, whose current board file is already the one to analyze.+fn fusion_live(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, String)> {+    let doc = aiflow_bridge::fusion::active()?;+    let br = bridge_of(r, cli);+    let mut reply = br.raw_call("fusion_board_source", &json!({"expectDocument": doc}));+    for _ in 0..3 {+        if reply["errorCode"] != "main_thread_busy" && !reply["error"].as_str().unwrap_or("").contains("main thread busy") { break; }+        std::thread::sleep(std::time::Duration::from_secs(5));+        reply = br.raw_call("fusion_board_source", &json!({"expectDocument": doc}));+    }+    let text = reply["source"].as_str().filter(|_| reply["success"].as_bool() == Some(true)).unwrap_or_else(|| err(&format!("could not read the live Fusion board {doc}: {}", reply["error"]), &["Activate the board in Fusion (fusion_show_2d_board), then retry. fusion_board_source needs bridge 1.11.17 or later.".into()]));+    let brd = dir.join("board-live.brd");+    let kicad = dir.join("board-live.kicad_pcb");+    std::fs::write(&brd, text).unwrap_or_else(|e| err(&e.to_string(), &[]));+    let converted = aiflow_board::eagle::to_kicad(text).unwrap_or_else(|e| err(&format!("live Fusion board: {e}"), &[]));+    std::fs::write(&kicad, converted).unwrap_or_else(|e| err(&e.to_string(), &[]));+    Some((brd.display().to_string(), kicad.display().to_string()))+}++/// Why a field-screen neck happens, from the board the solve ran on: a via whose copper ring is the+/// narrowest copper of its net, or another net's track cutting a net's patch pour off from its pin.+/// Each hint names the spec change that fixes it, so a refine pass is a decision, not a search.+fn refine_hints(fields: &Value, board_text: &str, spec: &Value) -> Vec<String> {+    let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);+    let via_re = regex_lite::Regex::new(r#"\(via \(at ([-\d.]+) ([-\d.]+)\) \(size ([\d.]+)\) \(drill ([\d.]+)\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();+    let seg_re = regex_lite::Regex::new(r#"\(segment \(start ([-\d.]+) ([-\d.]+)\) \(end ([-\d.]+) ([-\d.]+)\) \(width ([\d.]+)\) \(layer "?([^")]+)"?\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();+    let vias: Vec<(f64, f64, f64, f64, String)> = via_re.captures_iter(board_text).map(|c| (num(&c[1]), num(&c[2]), num(&c[3]), num(&c[4]), c[5].to_string())).collect();+    let segs: Vec<((f64, f64), (f64, f64), String, String)> = seg_re.captures_iter(board_text).map(|c| ((num(&c[1]), num(&c[2])), (num(&c[3]), num(&c[4])), c[6].to_string(), c[7].to_string())).collect();+    let inside = |p: (f64, f64), poly: &[(f64, f64)]| { let mut c = false; let n = poly.len(); for i in 0..n { let (a, b) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } } c };+    let cross = |a: (f64, f64), b: (f64, f64), poly: &[(f64, f64)]| { if inside(a, poly) || inside(b, poly) { return true; } let n = poly.len(); let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); (0..n).any(|i| { let (c, d) = (poly[i], poly[(i + 1) % n]); o(a, b, c) * o(a, b, d) < 0.0 && o(c, d, a) * o(c, d, b) < 0.0 }) };+    let mut out = Vec::new();+    for is in fields["issues"].as_array().cloned().unwrap_or_default() {+        if is["kind"].as_str() != Some("current-neck") { continue; }+        let net = is["net"].as_str().unwrap_or("").to_string();+        let at = (is["at"][0].as_f64().unwrap_or(0.0), is["at"][1].as_f64().unwrap_or(0.0));+        if let Some(v) = vias.iter().filter(|v| v.4 == net).find(|v| ((v.0 - at.0).powi(2) + (v.1 - at.1).powi(2)).sqrt() <= v.2 / 2.0 + 0.4) {+            out.push(format!("{net}: the neck at ({:.1}, {:.1}) is the copper ring around the via at ({:.2}, {:.2}) ({:.2} mm, {:.2} mm drill). Raise `viaPower` in the spec (1.0 mm with a 0.4 mm drill gives the ring 0.3 mm a side), then route, gate and land again.", at.0, at.1, v.0, v.1, v.2, v.3));+            continue;+        }+        let mut said = false;+        for z in spec["pours"].as_array().cloned().unwrap_or_default() {+            if z["net"].as_str() != Some(net.as_str()) { continue; }+            let poly: Vec<(f64, f64)> = z["polygon"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+            if poly.len() < 3 { continue; }+            let layers: Vec<String> = z["layers"].as_array().cloned().unwrap_or_default().iter().filter_map(|l| l.as_str().map(str::to_string)).collect();+            if let Some(sg) = segs.iter().find(|sg| sg.3 != net && layers.contains(&sg.2) && cross(sg.0, sg.1, &poly)) {+                let covered = spec["planeUnder"].as_array().map(|a| a.iter().any(|p| p["net"].as_str() == Some(net.as_str()))).unwrap_or(false);+                out.push(format!("{net}: a {} track on {} crosses the pour \"{}\", cutting it off from its pin, so the current takes a narrow track instead. {}", sg.3, sg.2, z["name"].as_str().unwrap_or(""), if covered { "A planeUnder entry for it exists already: check that its polygon covers the whole pour." } else { "Add a `planeUnder` entry {layer, net, polygon: that pour's polygon} so the router keeps other nets out of it, then route, gate and land again." }));+                said = true; break;+            }+        }+        if !said { out.push(format!("{net}: neck at ({:.1}, {:.1}) on {}: widen {net} in `wideNets`, or give it a patch pour there.", at.0, at.1, is["layer"].as_str().unwrap_or("?"))); }+    }+    out+}++fn bridge_of(r: &Run, cli: &Cli) -> Bridge {+    let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();+    Bridge { ai_thread: thread(cli), target }+}++fn write_json(p: &Path, v: &Value) {+    std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));+}++/// The command as typed, without the thread and run plumbing.+fn command_line() -> String {+    let args: Vec<String> = std::env::args().skip(1).collect();+    let mut out = Vec::new();+    let mut skip = false;+    for a in args {+        if skip { skip = false; continue; }+        if a == "--ai-thread" || a == "--run" { skip = true; continue; }+        if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }+        out.push(a);+    }+    out.join(" ")+}++// ----------------------------------------------------------------------------- design gates (checks.rs)++fn sha256_file(p: &str) -> String {+    use sha2::{Digest, Sha256};+    std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()+}++/// Export a schematic's netlist (kicadsexpr) with the native kicad-cli: ADOM_AIFLOW_KICAD_CLI, else+/// `kicad-cli`. In a container with no native KiCad, adom-aiflow-kicad-cli-remote runs a desktop's.+fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> {+    let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+    let o = std::process::Command::new(&cli).args(["sch", "export", "netlist", "--format", "kicadsexpr", "--output"]).arg(out).arg(sch).output()+        .map_err(|e| format!("{cli} is not runnable: {e}"))?;+    if !o.status.success() || !out.is_file() {+        let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(500).collect::<String>().chars().rev().collect::<String>() };+        return Err(format!("{cli} sch export netlist failed: {} {}", tail(&o.stdout), tail(&o.stderr)));+    }+    Ok(cli)+}++/// The run's first board (what `start` copied): the reference for fixed parts.+fn first_board(r: &Run) -> Option<Board> {+    r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()).and_then(|p| Board::load(p).ok())+}++/// Molecule conformance of a board when the spec declares a molecule; None otherwise.+fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<checks::Findings> {+    if spec.get("molecule").is_none() { return None; }+    let before = r.and_then(first_board);+    Some(checks::molecule_check(board, spec, before.as_ref()))+}++/// Why the run's schematic and current board are not proven equal, or Ok when they are: the+/// recorded `netlist check` passed on this schematic (same sha256), and its exported netlist still+/// matches the current board (placement, routing or an adopted board cannot change a pin's net+/// unnoticed).+fn netlist_gate(r: &Run) -> Result<String, String> {+    let Some(sch) = r.data["schematic"].as_str() else { return Ok("no schematic recorded".into()) };+    let o = r.outcomes().get("netlist").cloned().ok_or("netlist check (the run has a schematic; run `adom-aiflow netlist check`)")?;+    if o["sha256"].as_str() != Some(sha256_file(sch).as_str()) { return Err("netlist check (the schematic changed since the last check; run it again)".into()); }+    let net = o["netlist"].as_str().unwrap_or("");+    let sn = checks::parse_sch_netlist(&std::fs::read_to_string(net).map_err(|e| format!("netlist check ({net}: {e}; run it again)"))?).map_err(|e| format!("netlist check ({e})"))?;+    let rep = checks::compare_netlists(&sn, &Board::load(&r.current_board()).map_err(|e| format!("netlist check ({e})"))?);+    if rep["ok"] != true { return Err(format!("netlist check (the current board differs from the schematic: {} mismatched pin(s), missing {}, extra {})", rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), rep["missingRefs"], rep["extraRefs"])); }+    Ok(format!("{}/{} pins", rep["matchedPins"], rep["connectedPins"]))+}++/// The 3D orientation pass of the models step: every model bound on the board, its bbox from+/// step2glb (or its STEP points), transformed as KiCad places it, judged. Returns+/// (warnings as [{ref, model, finding}], notes on what could not be checked). Bboxes are cached per+/// file (path, size, mtime) in the run directory so reruns do not call the shared service again.+fn model_orientation(board: &Board, project_dir: &Path, cache_path: Option<&Path>) -> (Vec<Value>, Vec<String>) {+    let mut cache: serde_json::Map<String, Value> = cache_path.and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();+    let (mut warnings, mut notes, mut calls) = (Vec::new(), Vec::new(), 0usize);+    let mut unresolved: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for m in checks::model_refs(board) {+        let Some(file) = checks::resolve_model(&m.path, project_dir) else { unresolved.entry(m.path.clone()).or_default().push(m.reference.clone()); continue };+        let meta = std::fs::metadata(&file).ok();+        let key = format!("{}|{}|{}", file.display(), meta.as_ref().map(|x| x.len()).unwrap_or(0), meta.and_then(|x| x.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0));+        let got = match cache.get(&key) {+            Some(v) => serde_json::from_value::<checks::BBox>(v["bbox"].clone()).ok().map(|b| (b, v["source"].as_str().unwrap_or("").to_string())),+            None => {+                let g = checks::model_bbox(&file, &mut calls);+                if let Some((b, src)) = &g { cache.insert(key, json!({"bbox": b, "source": src})); }+                g+            }+        };+        let Some((bb, src)) = got else { notes.push(format!("{} ({}): no bbox from step2glb or the STEP points", m.reference, file.display())); continue };+        let placed = checks::transform_bbox(&bb, m.offset, m.scale, m.rotate);+        for finding in checks::orientation_findings(&m, &placed) {+            warnings.push(json!({"ref": m.reference, "model": file.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), "rotate": m.rotate, "offset": m.offset, "bboxSource": src, "finding": finding}));+        }+    }+    for (p, refs) in unresolved { notes.push(format!("not checked here (path does not resolve in this container): {p} ({})", refs.join(","))); }+    if let Some(p) = cache_path { let _ = std::fs::write(p, serde_json::to_string_pretty(&Value::Object(cache)).unwrap_or_default()); }+    (warnings, notes)+}++/// Orientation warnings the AI has not acknowledged (run.data.modelAcks: {ref: why}).+fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> {+    let mut refs: Vec<String> = warnings.iter().filter_map(|w| w["ref"].as_str()).filter(|x| r.data["modelAcks"].get(*x).is_none()).map(str::to_string).collect();+    refs.dedup();+    refs+}++fn project_dir_of(r: &Run) -> PathBuf {+    let src = r.data["sourceBoard"].as_str().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r.current_board()));+    src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))+}++fn main() {+    let cli = Cli::parse();+    let dir = run_dir(&cli);+    // every command is a turn in the run's ledger; the gap before it is the AI's thinking time+    if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. }) {+        if let Some(mut r) = Run::open(&dir) {+            let idx = r.turn_begin(&command_line());+            let _ = r.save();+            let _ = TURN.set((dir.clone(), idx));+            // the clip guard: a recording that has outrun its step's budget is a mistake, not evidence.+            // Stop it here, on whatever command comes next, before it eats the disk (one once ran+            // nine hours on a static editor because deliver did not stop it).+            if let (Some(target), Some(t)) = (r.data["target"].as_str().map(str::to_string), cli.ai_thread.clone()) {+                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+                    let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);+                    let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);+                    let step = caps[pos]["step"].as_str().unwrap_or("").to_string();+                    let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, "sourcing" => 20, "design" | "simulation" | "schematic" => 30, _ => 10 } as u64;+                    if age > budget_min * 60 * 2 || age > 3600 {+                        let br = Bridge { ai_thread: t, target };+                        if let Some(mut r2) = Run::open(&dir) {+                            r2.mark(&format!("clip guard: the {step} clip ran {} min, past its {budget_min} min budget twice over; stopped by the guard", age / 60));+                            stop_running_clip(&mut r2, &br, &dir, "guard");+                            let _ = r2.save();+                            eprintln!("clip guard: stopped the {step} clip after {} min (budget {budget_min} min)", age / 60);+                        }+                    }+                }+            }+        }+    }+    match &cli.cmd {+        Cmd::Exec { command } => {+            thread(&cli); let mut r=load_run(&cli);+            r.log("external-start",json!({"program":command[0],"args":&command[1..]}));+            r.save().unwrap();+            let status=std::process::Command::new(&command[0]).args(&command[1..]).status();+            let mut r=load_run(&cli);+            match status {+                Ok(status)=>{+                    r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success()}));r.save().unwrap();+                    if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}+                    ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);+                }+                Err(e)=>err(&format!("could not start external command {}: {e}",command[0]), &["Check the executable path, then retry adom-aiflow exec -- <program> <args>.".into()])+            }+        }+        Cmd::SilkscreenSections { input, out } => {+            thread(&cli); let _r=load_run(&cli);+            let data:serde_json::Value=serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e|err(&e.to_string(), &[]))).unwrap_or_else(|e|err(&e.to_string(), &[]));+            let result=silkscreen_sections::solve(&data).unwrap_or_else(|e|err(&e,&[]));+            std::fs::write(out,serde_json::to_vec_pretty(&result).unwrap()).unwrap_or_else(|e|err(&e.to_string(), &[]));+            if !result["unresolved"].as_array().unwrap().is_empty(){err("Documentation sections remain unresolved", &["Keep rows together; try a shorter line layout, another interior region or a readable font. Do not scatter sentences.".into()]);}+            let fallbacks=result["sections"].as_array().unwrap().iter().filter(|s|s["edgeFallback"]==true).count();+            ok(&format!("Native Rust placed {} documentation labels; {fallbacks} edge fallbacks",result["placements"].as_array().unwrap().len()), &["Final documentation pass: prefer open board interior, preserve left-aligned sections and include a source-backed board description. Physical pin/contact labels remain local. Review edge fallbacks, font sizes, native DRC and BOTH EDA views.".into()]);+        }+        Cmd::SilkscreenPreflight { input, out } => {+            thread(&cli); let _r=load_run(&cli);+            let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));+            if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);+        }+        Cmd::BoardGlb { action, glb, board, out, finish, paste, report } => {+            let mut run = Run::open(&dir);+            if run.is_some() { thread(&cli); }+            let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));+            let spec_finish = run.as_ref().map(spec_soft).and_then(|v| v["fab"]["finish"].as_str().map(String::from));+            let finish = finish.clone().or(spec_finish).unwrap_or_else(|| "copper".into());+            let mut command = std::process::Command::new(aiflow_python());+            command.arg("-c").arg(include_str!("../../../tools/board-glb-copper.py")).arg(action).arg("--glb").arg(glb).arg("--board").arg(&board_path).arg("--finish").arg(&finish).arg("--paste").arg(paste);+            if let Some(v) = out { command.arg("--out").arg(v); }+            if let Some(v) = report { command.arg("--report").arg(v); }+            let result = command.output().unwrap_or_else(|e| err(&format!("board-glb needs python3 with shapely and numpy: {e}"), &["Reinstall adom-aiflow so its isolated Python environment exists.".into()]));+            let text = format!("{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));+            if let Some(r) = run.as_mut() {+                r.outcome("boardGlb", json!({"ok": result.status.success(), "action": action, "glb": out.as_ref().unwrap_or(glb), "board": board_path, "finish": finish, "paste": paste}));+                r.save().unwrap_or_else(|e| err(&e, &[]));+            }+            if !result.status.success() {+                err(&text, &["Pads and test points are always real copper (or the board's stated finish), and the mask opens exactly where the footprint mask layer says. Fix it by construction with `adom-aiflow board-glb copper`; never hand-recolour a material index or lift the pads.".into(), "Use the .kicad_pcb the STEP was exported from, and export the STEP with --include-pads --include-soldermask (skill aiflow-molecule).".into()]);+            }+            ok(&text, &["Look at it before shipping: render a close-up of the test points and an under-part pad (skill aiflow-molecule). Grey or pale pads are not done.".into()]);+        }+        Cmd::SilkscreenMask { board, out } => {+            thread(&cli); let _r=load_run(&cli);+            let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));+            if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);+        }+        Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {+            thread(&cli); let _r=load_run(&cli);+            let mut command=std::process::Command::new(aiflow_python());command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);+            if *no_contact_borders{command.arg("--no-contact-borders");}+            let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));+            if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);+        }+        Cmd::SilkscreenAudit { input, out } => {+            thread(&cli); let _r = load_run(&cli);+            let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));+            if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }+            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);+        }+        Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {+            let t = thread(&cli); let _r = load_run(&cli);+            let mut command = std::process::Command::new("adom-aiflow-silkscreen");+            command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);+            if let Some(v)=url { command.arg("--url").arg(v); }+            if let Some(v)=target { command.arg("--target").arg(v); }+            if let Some(v)=json { command.arg("--json").arg(v); }+            if let Some(v)=reason { command.arg("--reason").arg(v); }+            let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));+            if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);+        }+        Cmd::SilkscreenLayout { input, out, events } => {+            thread(&cli);+            let _r = load_run(&cli);+            let source = include_str!("../../../tools/silkscreen-layout.py");+            let mut command = std::process::Command::new(aiflow_python());+            command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);+            if let Some(v)=events {command.arg("--events").arg(v);}+            let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));+            if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }+            ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);+        }+        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page } => {+            let t = thread(&cli);+            if !Path::new(board).is_file() {+                err(&format!("board not found: {board}"), &[]);+            }+            if !Path::new(spec).is_file() {+                err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);+            }+            if let Some(s) = sch { if !Path::new(s).is_file() { err(&format!("schematic not found: {s}"), &["--sch names the .kicad_sch the board was made from.".into()]); } }+            // a run made by `intake` has no board yet: start attaches it and keeps the clock and the ledger+            let intake_run = Run::open(&dir).filter(|r| r.board_pending() && !*force);+            if let (Some(r), false, true) = (Run::open(&dir), *force, intake_run.is_none()) {+                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);+            }+            std::fs::create_dir_all(&dir).unwrap();+            let copy = dir.join("board-0.kicad_pcb");+            let src_text = std::fs::read_to_string(board).unwrap_or_default();+            if aiflow_board::eagle::is_eagle(&src_text) {+                // the Fusion lane: the plan board is the Fusion export rendered as .kicad_pcb; the live+                // board is the Fusion document named by --remote-board fusion:<doc>+                if !remote_board.as_deref().map(|b| b.starts_with(aiflow_bridge::fusion::PREFIX)).unwrap_or(false) {+                    err("a Fusion board needs --remote-board fusion:<exact board document name> (and --target <box>)", &["Export it with fusion_export_eagle_source {outputPath:\"...brd\"} on the Fusion bridge, pull it here, and name the open board document: --remote-board fusion:Buck-brd.".into()]);+                }+                let text = aiflow_board::eagle::to_kicad(&src_text).unwrap_or_else(|e| err(&e, &[]));+                std::fs::write(&copy, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));+                // the fab's KiCad rules beside the .brd (`fusion schematic` puts them there) go with the+                // plan board, so the gate checks the fab, not KiCad's defaults+                let dru = std::path::Path::new(board).with_extension("kicad_dru");+                if dru.is_file() { let _ = std::fs::copy(&dru, copy.with_extension("kicad_dru")); }+                aiflow_bridge::fusion::set_run_board(remote_board.as_deref().unwrap_or(""));+            } else {+                std::fs::copy(board, &copy).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));+                aiflow_copper::copy_project_rules(board, &copy.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+            }+            let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());+            if let Some(mut r) = intake_run {+                let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("").to_string();+                let differs = prompt_time.as_ref().filter(|p| **p != pt).map(|p| format!("--prompt-time {p} ignored: the intake run's clock started at {pt} and stays there. "));+                r.attach_board(&copy.display().to_string(), &specp, engine, target.as_deref(), remote_board.as_deref());+                r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+                if let Some(p) = page { r.data["page"] = json!(p); }+                if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+                let idx = r.turn_begin(&command_line());+                record_usage(&mut r, "start");+                r.save().unwrap();+                let _ = TURN.set((dir.clone(), idx));+                let steps: Vec<String> = r.data["steps"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+                ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &[differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()]);+                return;+            }+            let pt = prompt_time.clone().unwrap_or_else(now);+            let mut r = Run::create(&dir, &copy.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));+            r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+            if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+            if let Some(p) = page { r.data["page"] = json!(p); }+            let idx = r.turn_begin(&command_line());+            record_usage(&mut r, "start");+            r.save().unwrap();+            let _ = TURN.set((dir.clone(), idx));+            let mut start_hints = vec![format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display())];+            if sch.is_some() { start_hints.push("The run has a schematic: `adom-aiflow netlist check` proves the board equals it pin by pin; finish requires it.".into()); }+            else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }+            let spec_v = read_json(spec);+            if let Some(p) = checks::rules_profile(&spec_v) { if let Err(e) = checks::rules_check(&p, board) { start_hints.push(format!("Fab rules: {e}. {}", checks::rules_hint(&p))); } }+            if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }+            start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+            ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);+        }+        Cmd::Intake { prompt_time, brief, requirements, engine, target, page } => {+            let t = thread(&cli);+            if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }+            if let Some(r) = Run::open(&dir) {+                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);+            }+            for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }+            let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());+            // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both+            let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));+            let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[]));+            if let Some(p) = page { r.data["page"] = json!(p); }+            let idx = r.turn_begin(&command_line());+            record_usage(&mut r, "start");+            r.save().unwrap();+            let _ = TURN.set((dir.clone(), idx));+            let profile = preboard::named_profile(&requirements_of(&r));+            ok(&format!("intake run started at {}; the clock started at the prompt, {prompt_time}; step intake is open and there is no board yet", dir.display()), &[+                "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step records a clip of the desktop until a PCB editor is open.".into(),+                match profile { Some(p) => format!("Sourcing profile {p} (from the requirements): `sourcing check --bom design/bom.csv` gates the BOM; finish requires it."), None => "No sourcing profile named yet: write requirements.json with fab.target `fab` (default) or `jlcpcb` (aiflow-intake), then `sourcing check --bom <csv> --requirements requirements.json`.".into() },+                "Record the design record as it lands: `evidence add --kind calcs --file design/calcs.json`, `evidence add --kind simulation --file sim/<results>.json` (a failing simulation is refused), `evidence add --kind vendor-model --file <result>` for a vendor PSpice/LTspice run (adom/adom-spice-skillpack).".into(),+                "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),+                if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },+            ]);+        }+        Cmd::Sourcing { what, bom, profile, qty, board, requirements } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>`"), &[]); }+            if let Some(q) = requirements {+                if !Path::new(q).is_file() { err(&format!("not found: {q}"), &[]); }+                r.data["requirements"] = json!(std::fs::canonicalize(q).map(|p| p.display().to_string()).unwrap_or(q.clone()));+            }+            let (spec, req) = (spec_soft(&r), requirements_of(&r));+            let named = preboard::named_profile(&spec).map(|p| (p, "the spec")).or_else(|| preboard::named_profile(&req).map(|p| (p, "the requirements")));+            let prof = profile.clone().or_else(|| named.as_ref().map(|n| n.0.clone())).unwrap_or_else(|| "fab".into());+            let override_note = match (profile, &named) { (Some(p), Some((n, by))) if p != n => format!("--profile {p} overrides {by}, which name {n}; finish checks against the recorded outcome, so re-run without --profile if that was not meant."), _ => String::new() };+            let (qty, qty_src) = match qty { Some(q) => (*q, "--qty"), None => match req["build"]["quantity"].as_u64().or(req["buildQuantity"].as_u64()).map(|q| (q, "the requirements")).or_else(|| spec["buildQuantity"].as_u64().map(|q| (q, "the spec"))) { Some(x) => x, None => (10, "the default; say --qty or requirements build.quantity") } };+            let text = std::fs::read_to_string(bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));+            let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));+            let refs = board_path.as_ref().map(|p| Board::load(p).map(|b| preboard::board_refs(&b)).unwrap_or_else(|e| err(&format!("board {p}: {e}"), &[])));+            let mut out = preboard::sourcing_check(&text, refs.as_ref(), &prof, qty);+            let bomp = std::fs::canonicalize(bom).map(|p| p.display().to_string()).unwrap_or(bom.clone());+            out["bom"] = json!(bomp);+            out["bomSha256"] = json!(library_review::hash(Path::new(bom)).unwrap_or_default());+            out["board"] = json!(board_path);+            out["checkedAt"] = json!(now());+            out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") });+            out["qtySource"] = json!(qty_src);+            r.outcome("sourcing", out.clone());+            r.save().unwrap();+            let warnings: Vec<String> = out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("warning: {s}"))).collect();+            let rule = if prof == "jlcpcb" { "jlcpcb: every row carries its LCSC number (JLCPCB/LCSC basic parts first)" } else { "fab: every row from Mouser or Adom stock, no LCSC/JLCPCB parts (the 3rd party fab)" };+            if out["pass"] != true {+                let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("  {s}"))).collect();+                let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note];+                hints.extend(warnings);+                err(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints);+            }+            let diff = if out["boardChecked"] == true { format!("every one of the board's {} BOM references is in the BOM ({} excluded by their footprints)", out["boardRefs"], out["excludedByBoard"].as_array().map(|a| a.len()).unwrap_or(0)) } else { "no board yet, so no reference diff: check again after `start --board` (finish requires the diff)".into() };+            let mut hints = vec![format!("Profile {rule}; build quantity {qty} ({qty_src})."), override_note];+            hints.extend(warnings);+            hints.push("Next: `evidence add --kind calcs --file design/calcs.json` once the values are derived (aiflow-circuit-design).".into());+            ok(&format!("sourcing check passed on the {prof} profile: {} rows, {} references ({} Adom stock, {} distributor, {} no part), stock checked {}; {diff}", out["rows"], out["refs"], out["adomStockRows"], out["distributorRows"], out["noPartRows"], out["stockCheckDates"].as_array().map(|a| a.iter().filter_map(|d| d.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()), &hints);+        }+        Cmd::Evidence { what, kind, file, note } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if what != "add" { err(&format!("evidence {what}: use `evidence add --kind calcs|simulation|vendor-model --file <json|md>`"), &[]); }+            let src = PathBuf::from(file);+            if !src.is_file() { err(&format!("no such file: {file}"), &[]); }+            let text = std::fs::read_to_string(&src).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["Evidence is a JSON or Markdown file.".into()]));+            let is_json = src.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("json")).unwrap_or(false);+            if is_json && serde_json::from_str::<Value>(&text).is_err() { err(&format!("{file}: not JSON"), &[]); }+            let sha = library_review::hash(&src).unwrap_or_else(|e| err(&e, &[]));+            let (top, checks, failing) = preboard::evidence_checks(&text, is_json);+            let pass = if top == Some(false) || !failing.is_empty() { Some(false) } else if top == Some(true) || !checks.is_empty() { Some(true) } else { None };+            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());+            let step = r.data["currentStep"].clone();+            if kind == "simulation" && pass == Some(false) {+                r.log("evidence-refused", json!({"kind": kind, "file": canon.display().to_string(), "sha256": sha, "failing": failing, "pass": top}));+                r.save().unwrap();+                err(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]);+            }+            // a copy in the run keeps the record when the design folder moves on+            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());+            let copy = if canon.starts_with(&rdir) { canon.clone() } else {+                let d = dir.join("evidence");+                let _ = std::fs::create_dir_all(&d);+                let dest = d.join(format!("{kind}-{}", src.file_name().and_then(|n| n.to_str()).unwrap_or("file")));+                std::fs::copy(&src, &dest).unwrap_or_else(|e| err(&format!("copy into the run: {e}"), &[]));+                std::fs::canonicalize(&dest).unwrap_or(dest)+            };+            let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks});+            if !r.data["evidence"].is_array() { r.data["evidence"] = json!([]); }+            r.data["evidence"].as_array_mut().unwrap().push(entry.clone());+            r.log("evidence", entry);+            r.save().unwrap();+            let n = r.data["evidence"].as_array().map(|a| a.iter().filter(|e| e["kind"] == json!(kind)).count()).unwrap_or(0);+            let state = match pass { Some(true) => format!("pass ({} check(s))", checks.len()), Some(false) => format!("FAIL ({} failing check(s))", failing.len()), None => "no pass/fail fields".into() };+            let hint = match kind.as_str() {+                "calcs" => "Next: simulate the values the equations cannot settle (loop, startup, load step; aiflow-simulate), then `evidence add --kind simulation --file sim/<results>.json` with a top-level \"pass\" or \"checks\": [{\"name\",\"value\",\"limit\",\"pass\"}] so the gate can read it.".to_string(),+                "simulation" => if pass.is_none() { "This result states no pass or fail: add a top-level \"pass\" or a \"checks\" list against the requirements so the gate can read it (the file is recorded either way).".to_string() } else { "Next: the vendor model if the brief asks for one (`evidence add --kind vendor-model`), then `step schematic`.".to_string() },+                _ => format!("Vendor PSpice/LTspice runs belong to the adom/adom-spice-skillpack (`adom-wiki pkg install adom/adom-spice-skillpack`: PSpice for TI and LTspice on a Windows desktop through Adom Bridge); this records its result{}. Publish results, never solver speed numbers.", if pass == Some(false) { ", which says it fails: write the design conclusion in sim/<vendor>-validation.md" } else { "" }),+            };+            ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &[hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()]);+        }+        Cmd::Clips { page, push } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if let Some(p) = page { r.data["clipsPage"] = json!(p); r.save().unwrap(); }+            let pg = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));+            match publish_clips(&r, &dir, &pg, *push) {+                Ok((said, files)) => ok(&said, &[if *push { "Look at the rendered page once (a pup tab or a Hydrogen webview on the human's machine): a successful push is not a working page. Link docs/clips.md from the README once (wiki-sub-readme).".to_string() } else { format!("Would push {} file(s): {}", files.len(), files.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) + if files.len() > 8 { ", ..." } else { "" } }, "From now on every clip stop rebuilds and pushes this page by itself; write each clip's note in <run>/clip-notes.json (step, shows, state) after you look at its contact sheet (aiflow-live-clips).".into()]),+                Err(e) => err(&e, &["Fix the page text (clip-notes.json is where notes come from), then `clips --push` again.".into()]),+            }+        }+        Cmd::Widget { action, file, label, step, progress } => {+            let t = thread(&cli);+            let _r = load_run(&cli);+            let mut cmd = std::process::Command::new("adom-aiflow-widget");+            cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);+            if let Some(v) = file { cmd.arg("--file").arg(v); }+            if let Some(v) = label { cmd.arg("--label").arg(v); }+            if let Some(v) = step { cmd.arg("--step").arg(v); }+            if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }+            let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));+            if !status.success() { err("widget helper failed", &[]); }+            ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);+        }+        Cmd::AdoptBoard { board, evidence } => {+            thread(&cli); let mut r=load_run(&cli);+            if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);}+            Board::load(board).unwrap_or_else(|e|err(&e,&[]));+            let base=aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e|err(&e,&[]));+            let candidate=aiflow_copper::run_drc(board).unwrap_or_else(|e|err(&e,&[]));+            let summary=aiflow_copper::summarize(&candidate,Some(&base));+            if summary["newErrors"].as_u64()!=Some(0){err(&format!("adopt-board adds native DRC violations: {}",summary["newErrorList"]), &["Correct the new violations and rerun adopt-board. Existing connectivity may remain for later routing.".into()]);}+            let n=r.data["boards"].as_array().map(|a|a.len()).unwrap_or(1);+            let dest=dir.join(format!("board-{n}.kicad_pcb"));+            std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));+            aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+            r.add_board(&dest.display().to_string());+            r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary}));+            r.save().unwrap();+            ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]);+        }+        Cmd::Components { etch } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let b = board_of(&r);+            let path = dir.join("components.json");+            let old: Value = if path.exists() {+                let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));+                serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))+            } else { json!({}) };+            let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("on");+            let mut entries = Vec::new();+            for fp in b.root.find_all("footprint") {+                let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();+                let Some(reference) = props.get("Reference") else { continue; };+                let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));+                let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());+                if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {+                    entry["review"]["status"] = json!("stale");+                    entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");+                }+                entry["properties"] = json!(props);+                entry["footprint"] = json!(fp.value());+                entry["modelReferences"] = models;+                entries.push(entry);+            }+            let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,+                "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});+            std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));+            r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});+            r.save().unwrap_or_else(|e| err(&e, &[]));+            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();+            let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();+            hints.push(format!("MPN marking preference: {preference} (default on: every IC's model carries its laser-etched MPN, and finish checks it). Mark a model with `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>`: the mark planner places it (along the longest usable dimension, between the terminations, as large as fits, upright with pin 1 lower left), the shared service writes it with the model's colours, and the legibility gate must say PASS; look at <etched>.check.png before you report (Marking checklist in the adom-aiflow skill). 'off' keeps plain models; 'ask' offers the option. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));+            ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);+        }+        Cmd::Plan => {+            let mut r = load_run(&cli);+            // the flow is a file: the steps in order, who does each, what the binary offers, what comes later+            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            r.data["flow"] = flow.clone();+            let spec = spec_of(&r);+            let b = board_of(&r);+            let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();+            let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);+            let stages = json!({+                "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},+                "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},+                "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},+                "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},+                "land": {"engines": ["binary"], "default": "binary", "why": "native bridge undo transactions with candidate DRC; pours use the complete explicitly owned zone plan without a delete/create fallback"},+                "measure": {"engines": ["binary"], "default": "binary", "why": "request refill once, then read two delayed matching native revision/copper snapshots; acknowledgement is not measurement and post-commit errors forbid blind replay"},+                "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},+                "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},+                "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},+            });+            r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});+            r.save().unwrap_or_else(|e| err(&e, &[]));+            let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];+            lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));+            for st in flow["steps"].as_array().cloned().unwrap_or_default() {+                lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));+                if let Some(workflow) = st["workflow"].as_array() {+                    for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {+                        lines.push(format!("    {}. {}", i + 1, action));+                    }+                }+            }+            lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));+            lines.push("stages the binary offers:".into());+            for n in STAGES {+                let s = &r.data["plan"]["stages"][n];+                lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));+            }+            ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);+        }+        Cmd::Take { decisions } => {+            thread(&cli);+            let mut r = load_run(&cli);+            for kv in decisions {+                let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };+                if stage.is_empty() || !stage.chars().all(|c| c.is_ascii_alphanumeric() || c=='-' || c=='_') || !["ai","binary"].contains(&engine) {+                    err("invalid stage decision", &["Use adom-aiflow take <stage>=ai or <stage>=binary; custom stage names may contain letters, digits, hyphens and underscores.".into()]);+                }+                r.data["decisions"][stage] = json!(engine);+            }+            r.save().unwrap();+            ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);+        }+        Cmd::Stage { action, name, note, result } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if action == "start" {+                let e = r.data["decisions"][name].as_str().unwrap_or("ai").to_string();+                r.stage_start(name, Some(&e), note.as_deref());+                r.save().unwrap();+                ok(&format!("stage {name} started at {} by {e}", now()), &[]);+            } else {+                let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));+                r.stage_end(name, res, note.as_deref());+                r.save().unwrap();+                ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);+            }+        }+        Cmd::Place { what, wish, moves, out, pause_ms } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let b = board_of(&r);+            match what.as_str() {+                "pack" => {+                    let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));+                    let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));+                    let spec = spec_of(&r);+                    let courtyard_edge=spec["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);+                    let chains = aiflow_place::chains_from_spec(&spec).unwrap_or_else(|e| err(&format!("spec.chains: {e}"), &[]));+                    let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);+                    let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);+                    write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());+                    let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();+                    let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);+                    let mut msg = format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n"));+                    if !chains.is_empty() {+                        let honoured = verdicts.iter().filter(|v| v.1).count();+                        msg.push_str(&format!("\nchains: {honoured} of {} honoured", chains.len()));+                        for l in &chain_lines { msg.push_str(&format!("\n  {l}")); }+                        for (name, _, v) in &verdicts { msg.push_str(&format!("\n  chain {name}: {v}")); }+                    }+                    let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() };+                    ok(&msg, &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }, chain_hint]);+                }+                "check" => {+                    let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));+                    let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));+                    let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));+                    let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+                    let outp = dir.join(format!("board-{n}.kicad_pcb"));+                    std::fs::write(&outp, &text).unwrap();+                    aiflow_copper::copy_project_rules(&r.current_board(), &outp.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+                    let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+                    let moved: Vec<String> = mv.keys().cloned().collect();+                    let problems = aiflow_place::check(&nb, &moved, 0.05);+                    if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {+                        if !f.errors.is_empty() {+                            let mut h = f.hints.clone();+                            h.push(format!("The moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));+                            err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);+                        }+                    }+                    let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));+                    let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));+                    let s = aiflow_copper::summarize(&drc, Some(&baseline));+                    let errors = s["errors"].as_u64().unwrap_or(0);+                    // Bounding boxes are conservative packing hints, not exact courtyard geometry.+                    // Native KiCad DRC decides whether the actual courtyards overlap.+                    if !problems.is_empty() { eprintln!("Hint: conservative bounding-box advisory: {}. Native courtyard DRC is authoritative.", problems.join("; ")); }+                    if s["newErrors"].as_u64() != Some(0) {+                        err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]);+                    }+                    r.add_board(&outp.display().to_string());+                    r.save().unwrap();+                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited) and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);+                }+                "land" => {+                    let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));+                    let br = bridge_of(&r, &cli);+                    let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+                    r.stage_start("place-land", Some("binary"), None);+                    let mv = read_json(m);+                    let t0 = std::time::Instant::now();+                    // one part at a time: the camera sees each part land, not a board that changes at once+                    let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+                        eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });+                    });+                    let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+                    write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped}));+                    r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+                    r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+                    r.save().unwrap();+                    if let Some(i) = stopped {+                        let rep = replies.last().cloned().unwrap_or(Value::Null);+                        if rep["errorCode"] == "board_mismatch" {+                            err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);+                        }+                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+                    }+                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);+                }+                other => err(&format!("place {other}: use pack, check or land"), &[]),+            }+        }+        Cmd::Route { passes, rip_per_net, rip_global } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if r.engine_for("route") == "ai" {+                err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);+            }+            // a Fusion run routes around what is in Fusion now (copper already landed, a net taken+            // up for a refine), not the placement snapshot: plan on a fresh copy of the live board+            if let Some((_, k)) = fusion_live(&r, &cli, &dir) {+                let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+                let dest = dir.join(format!("board-{n}.kicad_pcb"));+                std::fs::copy(&k, &dest).unwrap_or_else(|e| err(&e.to_string(), &[]));+                aiflow_copper::copy_project_rules(&r.current_board(), &dest.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+                r.add_board(&dest.display().to_string());+                r.save().unwrap();+                eprintln!("routing on the live Fusion board ({})", dest.display());+            }+            let spec = spec_of(&r);+            let b = board_of(&r);+            let missing=aiflow_pours::missing_planes(&b,&spec);+            if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);}+            let rules = aiflow_router::Rules::from_spec(&spec);+            let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()]));+            if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) {+                err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);+            }+            let preexisting = aiflow_router::plane_under::issues(&b, &regions);+            if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}+            r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));+            let mut router = aiflow_router::Router::new(b, rules, true);+            let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);+            std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();+            let planp = dir.join("plan-route.json");+            let bc = report["boardCopper"].clone();+            let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) > 0;+            let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});+            if has_copper {+                let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);+                plan_doc["boardCopper"] = bc.clone();+                plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {+                    format!("nothing to route: the board's own copper ({} track pieces, {} vias, filled zones) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"])+                } else {+                    format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias); {complete} nets are complete on it and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["openConnections"])+                });+            }+            if !regions.is_empty() {+                plan_doc["planeUnder"] = json!(regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>());+            }+            write_json(&planp, &plan_doc);+            write_json(&dir.join("plan-route-report.json"), &report);+            let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();+            r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);+            r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));+            r.save().unwrap();+            let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {+                format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))+            } else {+                format!("planeUnder: the board's own copper already sits inside a region ({}); the gate reports it until that copper is moved (rework it in KiCad, then adopt-board).", preexisting.iter().take(4).cloned().collect::<Vec<_>>().join("; "))+            };+            if failed > 0 {+                let mut blockers: Vec<String> = Vec::new();+                for u in &unrouted {+                    if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {+                        if let Some(rest) = why.strip_prefix("blocked by ") {+                            for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {+                                let b = b.trim_matches('"').trim_matches('\'');+                                if !b.is_empty() && !blockers.contains(&b.to_string()) {+                                    blockers.push(b.to_string());+                                }+                            }+                        }+                    }+                }+                let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();+                let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };+                err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+            }+            if has_copper && plan.is_empty() {+                ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);+            } else {+                let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };+                ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);+            }+        }+        Cmd::Pour { plan } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let spec = spec_of(&r);+            let b = board_of(&r);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));+            let plan_v = planp.as_deref().map(read_json);+            r.stage_start("pour", Some("binary"), None);+            let (zones, vias, keepouts) = aiflow_pours::build(&b, &spec, plan_v.as_ref()).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });+            let zp = dir.join("plan-pours.json");+            let vp = dir.join("plan-vias.json");+            write_json(&zp, &json!({"zones": zones}));+            write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));+            r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);+            r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));+            r.save().unwrap();+            let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();+            ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);+        }+        Cmd::Fusion { args, target } => {+            // the Fusion steps run in Python beside the bridge calls; the run gives them their folder,+            // the desktop and the thread+            let run = Run::open(&dir);+            let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();+            let status = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/fusion-project.py")).args(args)+                .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli))+                .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));+            std::process::exit(status.code().unwrap_or(1));+        }+        Cmd::Unroute { net } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            let live = br.live_copper(&rb).unwrap_or_else(|| err("could not read the live board", &["Open the board in its EDA (or fusion_show_2d_board) and retry.".into()]));+            let ids: Vec<String> = live.segments.iter().filter(|s| net.contains(&s.net)).map(|s| s.uuid.clone())+                .chain(live.vias.iter().filter(|v| net.contains(&v.net)).map(|v| v.uuid.clone())).filter(|u| !u.is_empty()).collect();+            if ids.is_empty() { ok(&format!("nothing to unroute: no tracks or vias on {}", net.join(", ")), &["Next: route, gate, land route.".into()]); return; }+            let rep = br.remove_items(&rb, &ids);+            r.log("unroute", json!({"nets": net, "items": ids.len(), "reply": rep}));+            r.save().unwrap();+            if rep["success"].as_bool() != Some(true) { err(&format!("unroute refused: {} {}", rep["errorCode"], rep["error"].as_str().or(rep["message"].as_str()).unwrap_or("")), &["Nothing else was changed. Read the live board state before retrying.".into()]); }+            ok(&format!("unrouted {}: {} track(s) and via(s) taken up, pours kept", net.join(", "), ids.len()), &["Change what needs changing (spec wideNets for a wider track), then route, gate and land route again.".into()]);+        }+        Cmd::Gate { plan } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let b = board_of(&r);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| {+                // an adopted board that already carries its copper: gate the board as it is+                if b.tracks().is_empty() && b.vias().is_empty() {+                    err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]);+                }+                let p = dir.join("plan-route.json");+                write_json(&p, &json!({"engine": "none (the board's own copper)", "nets": [], "note": "gate with no routing plan on a board that carries copper: the board is gated as it is"}));+                eprintln!("Hint: no routing plan; the board carries copper, so it is gated as it is with an empty plan ({})", p.display());+                p.display().to_string()+            });+            let plan_v = read_json(&planp);+            let spec_g = spec_of(&r);+            if let Some(p) = checks::rules_profile(&spec_g) {+                if let Err(e) = checks::rules_check(&p, &r.current_board()) { err(&format!("gate refused: {e}"), &[checks::rules_hint(&p), "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }+            }+            if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {+                if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }+            }+            r.stage_start("gate", Some("binary"), None);+            // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors+            let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);+            let (text, stats) = aiflow_copper::apply(&b, &plan_v, adds_copper && !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));+            let routed = dir.join("board-routed.kicad_pcb");+            std::fs::write(&routed, &text).unwrap();+            aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+            let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));+            let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+            let mut s = aiflow_copper::summarize(&data, Some(&base));+            s["copper"] = stats;+            s["file"] = json!(routed.display().to_string());+            // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error+            let spec = spec_of(&r);+            let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec);+            let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {+                let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[]));+                match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, &regs), Err(e) => vec![format!("spec.planeUnder: {e}")] }+            };+            s["planeUnderIssues"] = json!(pu_issues);+            if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }+            write_json(&dir.join("gate.json"), &s);+            let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));+            let npu = pu_issues.len() as u64;+            r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);+            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "routedBoard": routed.display().to_string(), "plan": planp}));+            r.save().unwrap();+            if npu > 0 && newe == 0 && unc == 0 {+                err(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]);+            }+            if newe > 0 || unc > 0 {+                // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever+                let mut hair: Vec<String> = Vec::new();+                for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {+                    if v["type"].as_str() == Some("clearance") {+                        let d = v["description"].as_str().unwrap_or("");+                        let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+                        let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+                        if let (Some(a), Some(r)) = (actual, rule) {+                            if r - a < 0.02 {+                                for it in v["items"].as_array().cloned().unwrap_or_default() {+                                    if let Some(t) = it.as_str() {+                                        if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }+                                    }+                                }+                            }+                        }+                    }+                }+                hair.sort(); hair.dedup();+                let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };+                let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };+                err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);+            }+            let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);+            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}; copper {}", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);+        }+        Cmd::Land { what, plan, moves, pause_ms, from } => {+            let mut r = load_run(&cli);+            if what == "route" {+                // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call+                let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);+                let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0;+                let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));+                if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && read_json(p)["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false)) {+                    let note = read_json(&planp).get("note").cloned().unwrap_or(json!("empty plan"));+                    r.stage_start("land-route", Some("binary"), None);+                    r.stage_end("land-route", Some(json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0})), Some("empty plan: nothing to land"));+                    r.outcome("land-route", json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0, "stoppedAt": Value::Null, "emptyPlan": true, "note": note}));+                    r.save().unwrap();+                    ok(&format!("nothing to land: the gated plan {planp} is empty (the board already carries its copper); recorded as landed"), &["Next: adom-aiflow pour, land vias, land pours.".into()]);+                    return;+                }+            }+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            match what.as_str() {+                "moves" => {+                    let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));+                    r.stage_start("place-land", Some("binary"), None);+                    let mv = read_json(m);+                    let t0 = std::time::Instant::now();+                    // one part at a time: the camera sees each part land, not a board that changes at once+                    let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+                        eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });+                    });+                    let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+                    r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+                    r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+                    r.save().unwrap();+                    if let Some(i) = stopped {+                        let rep = replies.last().cloned().unwrap_or(Value::Null);+                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+                    }+                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);+                }+                "route" | "vias" => {+                    let planp = if what == "route" {+                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0;+                        if !gate_ok && plan.is_none() {+                            err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);+                        }+                        plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))+                    } else {+                        r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))+                    };+                    let entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();+                    // a via must not touch another net's copper, planned or already on the board: KiCad+                    // hands a touching via the other net and the bridge's DRC then sees nothing wrong+                    let b = board_of(&r);+                    let pad_copper: Vec<Copper> = b.pads.iter().flat_map(|p| p.polys.iter().map(move |poly| Copper::Poly { poly: poly.clone(), net: p.net.clone(), what: format!("pad {} ({})", p.key, if p.net.is_empty() { "no net" } else { p.net.as_str() }) })).collect();+                    let mut plans: Vec<Value> = vec![read_json(&planp)];+                    if what == "vias" {+                        if let Some(gp) = r.outcomes().get("gate").and_then(|g| g.get("plan")).or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan"))).and_then(|p| p.as_str()) {+                            if Path::new(gp).is_file() { plans.push(read_json(gp)); }+                        }+                    }+                    let mut copper_cache: Option<Vec<Copper>> = None;+                    let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> {+                        let t = landing::resolve(e, &live.pads)?;+                        if t.vias.is_empty() { return None; }+                        let copper = copper_cache.get_or_insert_with(|| {+                            let mut c = pad_copper.clone();+                            c.extend(landing::live_copper(live));+                            for p in &plans { c.extend(landing::plan_copper(p, &live.pads)); }+                            c+                        });+                        t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)})))+                    };+                    let stage = format!("land-{what}");+                    r.stage_start(&stage, Some("binary"), None);+                    let t0 = std::time::Instant::now();+                    // one trace at a time: a net with several paths lands as several undo steps+                    let res = br.land_plan(&rb, &entries, from.unwrap_or(1), *pause_ms, precheck, |i, n, entry, rep| {+                        if rep["alreadyPresent"] == true { return; }+                        eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);+                    });+                    let (accepted, stopped, traces) = (res.accepted, res.stopped, res.traces);+                    if res.skipped > 0 {+                        eprintln!("{} {} already on the live board (same net, same points): not landed again", res.skipped, if what == "vias" { "vias" } else { "traces" });+                    }+                    if res.recovered + res.retried > 0 {+                        eprintln!("transport: {} lost repl(ies) whose trace had landed, {} trace(s) landed on the one retry", res.recovered, res.retried);+                    }+                    // read back every landed via: the planned net must be the net KiCad gave it+                    let mut mismatches: Vec<Value> = Vec::new();+                    let with_vias: Vec<&(usize, Value, Value)> = res.landed.iter().filter(|(_, e, _)| e["paths"].to_string().contains("\"layer\"") || e["points"].to_string().contains("\"layer\"")).collect();+                    if !with_vias.is_empty() {+                        let Some(live) = br.live_copper(&rb) else {+                            r.stage_end(&stage, Some(json!({"accepted": accepted, "viaReadback": "unavailable"})), None);+                            r.save().unwrap();+                            err(&format!("{accepted} trace(s) landed, but the live board could not be read back to check the vias' nets"), &[format!("Read kicad_routing_state for {rb} and compare every via's net with {planp} before going on.")]);+                        };+                        let landed: Vec<(usize, landing::Trace)> = with_vias.iter().filter_map(|(n, e, _)| landing::resolve(e, &live.pads).map(|t| (*n, t))).collect();+                        for m in landing::via_net_mismatches(&landed, &live) {+                            let reply = with_vias.iter().find(|(n, _, _)| *n == m.trace).map(|(_, _, rep)| rep.clone()).unwrap_or(Value::Null);+                            let mut ids: Vec<String> = reply["itemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();+                            if ids.is_empty() && !m.uuid.is_empty() { ids.push(m.uuid.clone()); }+                            let undo = if ids.is_empty() { json!({"success": false, "errorCode": "no_item_ids"}) } else { br.remove_items(&rb, &ids) };+                            eprintln!("trace {}: the {} via at ({:.2}, {:.2}) landed as {}; {}", m.trace, m.planned, m.at.0, m.at.1, m.actual, if Bridge::ok(&undo) { "taken back" } else { "NOT taken back" });+                            mismatches.push(json!({"trace": m.trace, "planned": m.planned, "actual": m.actual, "at": [m.at.0, m.at.1], "viaId": m.uuid, "removedIds": ids, "undone": Bridge::ok(&undo), "undo": undo}));+                        }+                    }+                    let undone = mismatches.iter().filter(|m| m["undone"] == true).count();+                    let accepted = accepted - undone.min(accepted);+                    let wall = (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0;+                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "from": from, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "accepted": accepted, "viaNetMismatches": mismatches, "replies": res.replies, "wallSeconds": t0.elapsed().as_secs_f64()}));+                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "wallSeconds": wall})), None);+                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "viaNetMismatches": mismatches.len(), "wallSeconds": wall, "stoppedAt": stopped}));+                    r.save().unwrap();+                    if !mismatches.is_empty() {+                        let lines: Vec<String> = mismatches.iter().map(|m| format!("trace {}: planned {} at {}, landed as {}{}", m["trace"], m["planned"], m["at"], m["actual"], if m["undone"] == true { " (taken back)" } else { " (NOT taken back: remove it by hand)" })).collect();+                        err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]);+                    }+                    if let Some(i) = stopped {+                        let rep = res.replies.last().cloned().unwrap_or(Value::Null);+                        if rep["errorCode"] == "via_touches_other_net" {+                            err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);+                        }+                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped (or start at `--from {}`). The log has every reply.", i + 1)]);+                    }+                    let skipped_note = if res.skipped > 0 { format!(", {} already on the board skipped", res.skipped) } else { String::new() };+                    ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries{skipped_note}, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);+                }+                "pours" => {+                    let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));+                    let b = board_of(&r);+                    let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));+                    r.stage_start("land-pours", Some("binary"), None);+                    let before = br.zone_state(&rb, true);+                    if !Bridge::ok(&before) { err("cannot read settled zones before landing", &[before.to_string()]); }+                    let mut owned: Vec<String> = r.data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(|v|v.as_str().map(str::to_string)).collect();+                    // Legacy successful operation replies are evidence of creation, not an inference.+                    if let Some(groups)=r.outcomes().get("land-pours").and_then(|o|o["groups"].as_array()).filter(|_| r.data.get("ownedZoneIds").is_none()) {+                        for g in groups.iter().filter(|g|g["ok"]==true) {+                            for id in g["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {+                                if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }+                            }+                        }+                    }+                    let results = br.land_zones(&rb, &zones, *pause_ms, &owned);+                    for (_,reply) in &results {+                        if Bridge::ok(reply) || reply["deletionVerified"] == true {+                            for id in reply["removedIds"].as_array().into_iter().flatten().filter_map(Value::as_str) { owned.retain(|x|x!=id); }+                        }+                        if Bridge::ok(reply) || reply["mutated"]==true {+                            for id in reply["itemIds"].as_array().into_iter().flatten().filter_map(Value::as_str) {+                                if !owned.iter().any(|x|x==id) { owned.push(id.to_string()); }+                            }+                        }+                    }+                    r.data["ownedZoneIds"]=json!(owned);+                    if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {+                        eprintln!("{}", rep["message"].as_str().unwrap_or(""));+                    }+                    let after = br.zone_state(&rb, results.iter().all(|(_, reply)| Bridge::ok(reply)));+                    let mut results = results;+                    if !Bridge::ok(&after) { results.push(("post-landing-readback".into(), after.clone())); }+                    let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();+                    r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);+                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));+                    write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));+                    r.save().unwrap();+                    if let Some((net, rep)) = failed.first() {+                        let mut types: BTreeMap<String, usize> = BTreeMap::new();+                        for v in rep["violations"].as_array().cloned().unwrap_or_default() {+                            *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;+                        }+                        err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);+                    }+                    ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);+                }+                other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),+            }+        }+        Cmd::Measure => {+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            r.stage_start("measure", Some("binary"), None);+            let zs = br.zone_state(&rb, true);+            r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);+            write_json(&dir.join("zone-state.json"), &zs);+            r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));+            if !Bridge::ok(&zs) {+                r.outcome("measure", json!({"failed":true,"errorCode":zs["errorCode"],"reply":zs}));+                r.save().unwrap();+                err("native copper measurement did not settle", &["The refill request was not replayed. Inspect zone-state.json and the native board before retrying a read.".into()]);+            }+            r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));+            r.save().unwrap();+            ok(&format!("copper kept per layer (KiCad's fills): {}; coverage {}", zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);+        }+        Cmd::Analyze { what, plan, fields } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));+            // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot+            let live = fusion_live(&r, &cli, &dir);+            let board_now = match &live { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };+            let solved_board = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());+            let plan_v = plan_with_board_copper(&read_json(&planp), &board_now);+            let spec = spec_of(&r);+            let zsp = dir.join("zone-state.json");+            let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };+            let name = format!("analyze-{what}");+            r.stage_start(&name, Some("binary"), None);+            let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+            let fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));+            let screen=if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {+                let f=read_json(&fields_file.display().to_string());+                let board_bytes=std::fs::read(&solved_board).unwrap_or_else(|e|err(&e.to_string(),&[]));+                let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));+                aiflow_analyze::field_screen(&f,&spec,&board_bytes,&spec_bytes,what).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e })+            } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };+            let passed=heuristic_passed && screen.is_ok();+            let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());+            r.stage_end(&name, Some(json!({"pass": passed})), None);+            r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "results": res}));+            r.save().unwrap();+            let lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();+            // the copper this analysis judged: the nets with pours, for the drawing and the walkthrough+            let mut pour_nets: Vec<String> = Vec::new();+            for x in &res {+                let net = x.get("net").or(x.get("tabNet")).and_then(|v| v.as_str()).unwrap_or("");+                if !net.is_empty() && x["pourLayers"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !pour_nets.contains(&net.to_string()) { pour_nets.push(net.to_string()); }+            }+            let draw = match what.as_str() {+                "current" => "Now do the analysis yourself, not only this table: draw the current density on the copper (amps per square millimetre on every loaded net's pours and tracks; a Laplace solve on the filled copper between the source pads and the sink pads is enough, the board file has the filled polygons) as a 1920x1080 PNG with a colour scale, then `artifact --kind analysis-image --file <png> --caption \"...\"`. It becomes a fullscreen shot in the final video and a figure on the run page.".to_string(),+                _ => "Now do the analysis yourself, not only this table, as two drawings: first the heat flow in the copper, layer by layer like the current picture (a multi-layer solve: each hot tab's watts spreading on its layer and going down through its vias to the other layers, watts per millimetre of width, with the vias drawn and each chip's split between its layer, its vias and the bare dielectric); then the temperature rise over the whole board (the same solve read as temperature, the FR4 spreading). Each a 1920x1080 PNG with a colour scale, registered in that order with `artifact --kind analysis-image --file <png> --caption \"...\"` (`--replaces <old png>` when you redo one). They become fullscreen shots in the final video and figures on the run page.".to_string(),+            };+            let fields_hint = "Adom Fields 0.4.0 also supports specified conducting heatsinks, per-layer copper thickness, and an optional Signals view for overlap capacitance and differential-pair screening. Read its heatsinks-and-signals guide; use real assembly/material data, never invent a heatsink to pass this gate. Unsupported signal geometry is not a passing impedance result. adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string();+            let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) };+            if !passed {+                let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&read_json(&fields_file.display().to_string()), &bt, &spec) } else { Vec::new() };+                if !why.is_empty() { eprintln!("why:\n  {}", why.join("\n  ")); }+                err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);+            }+            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()]);+        }+        Cmd::Finish => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t.clone(), target }, &dir);+                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+            }+            let o = r.outcomes().clone();+            let mut missing = Vec::new();+            let gate = o.get("gate").cloned().unwrap_or(Value::Null);+            if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {+                missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());+            }+            if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {+                missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));+            }+            if !o.contains_key("land-route") {+                missing.push("land route".into());+            }+            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true) {+                missing.push("land pours (every planned pour accepted)".into());+            }+            if !measurement_usable(o.get("measure")) {+                missing.push("measure (successful native copper readback)".into());+            }+            // a Fusion run is judged on the board as it is in Fusion now+            let live_board = fusion_live(&r, &cli, &dir);+            let solved_board = live_board.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());+            let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };+            for k in ["analyze-current", "analyze-thermal"] {+                if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {+                    missing.push(format!("{k} (pass)"));+                } else {+                    if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}+                    let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());+                    let board_bytes=std::fs::read(&solved_board).unwrap_or_default();+                    let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();+                    if let Some(report)=report {+                        if let Err(reason)=aiflow_analyze::field_screen(&report,&spec_of(&r),&board_bytes,&spec_bytes,k.trim_start_matches("analyze-")) { missing.push(format!("{k}: {reason}")); }+                    } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }+                }+            }+            if let Err(m) = netlist_gate(&r) { missing.push(m); }+            let spec_f = spec_of(&r);+            if let Some(p) = checks::rules_profile(&spec_f) {+                if let Err(e) = checks::rules_check(&p, &r.current_board()) { missing.push(format!("fab rules ({e})")); }+            }+            if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {+                if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }+            }+            if r.outcomes().contains_key("models-orientation") {+                let (w, _) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));+                let open = unacked_orientation(&r, &w);+                if !open.is_empty() { missing.push(format!("3D models right way up (fix or `models --ack {} --why ...`)", open.join(","))); }+            }+            let mut live = Value::Null;+            if let Some(target) = r.data["target"].as_str() {+                let br = Bridge { ai_thread: t.clone(), target: target.to_string() };+                live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));+                let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+                if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {+                    missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));+                }+            }+            // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for+            let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));+            if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)) {+                let sc = o.get("sourcing").cloned().unwrap_or(Value::Null);+                if sc["pass"] != true { missing.push(format!("sourcing check on the {p} profile (sourcing check --bom <csv>)")); }+                else if sc["boardChecked"] != true { missing.push("sourcing check against the board's references (run it again now the board exists)".into()); }+                else if sc["profile"].as_str() != Some(p.as_str()) { missing.push(format!("sourcing check on the {p} profile the spec or requirements name (the last check used {})", sc["profile"].as_str().unwrap_or("?"))); }+            }+            let mut required: Vec<String> = Vec::new();+            for v in [&spec_v, &req_v] { for k in v["requireEvidence"].as_array().cloned().unwrap_or_default() { if let Some(k) = k.as_str() { if !required.iter().any(|x| x == k) { required.push(k.to_string()); } } } }+            for k in &required {+                let have = r.data["evidence"].as_array().map(|a| a.iter().any(|e| e["kind"].as_str() == Some(k.as_str()) && e["pass"] != false)).unwrap_or(false);+                if !have { missing.push(format!("{k} evidence (evidence add --kind {k} --file <json|md>)")); }+            }+            // MPN marking: unless it was turned off, every IC (U*, IC*) carries its laser-etched MPN on the+            // marking face of its model (an 'Adom.LaserEtch' or legacy 'laser_etch' part in the STEP), so the board and the component+            // pages show which chip is which+            let marking = r.data["components"]["mpnMarking"].as_str().unwrap_or("on").to_string();+            if marking != "off" {+                let unmarked = unmarked_ics(&r);+                if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>` (gate must PASS), or `components --etch off` to decline", unmarked.join(", "))); }+            }+            if !missing.is_empty() {+                err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);+            }+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let brs = Bridge { ai_thread: t.clone(), target };+                stop_running_clip(&mut r, &brs, &dir, "finish");+            }+            r.data["clock"]["finishTime"] = json!(now());+            record_usage(&mut r, "finish");+            let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true});+            r.data["summary"] = summary;+            r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));+            r.save().unwrap();+            ok(&format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+        }+        Cmd::Capture { action, label, reason } => {+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            let engine = r.data["engine"].as_str().unwrap_or("").to_string();+            match action.as_str() {+                "open" => {+                    let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));+                    r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));+                    r.save().unwrap();+                    let (revision, reply) = br.revision_with_reply(&rb);+                    write_json(&dir.join("capture-open-ipc.json"), &reply);+                    if revision.is_none() {+                        err(&format!("board window opened (hwnd {hwnd}) but IPC is not ready for {rb}: {} {}; openBoards {}",reply["errorCode"],reply["error"],reply["openBoards"]), &["Read capture-open-ipc.json. For board_mismatch, save and gracefully close only your task-owned conflicting editors, then `capture open` so this board owns the IPC socket. Preserve other users' windows and shared bridges.".into()]);+                    }+                    let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();+                    let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };+                    let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };+                    ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);+                }+                "start" => {+                    let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));+                    // the PCB editor window itself, background-capturable; the monitor only when no editor is open+                    // the editor open NOW first: a stored handle dies when the editor is closed and reopened (an offline edit)+                    let hwnd = br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))));+                    let (rid, kind) = match hwnd {+                        Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),+                        None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),+                    };+                    let mut entry = json!({"recordingId": rid, "start": now()});+                    for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }+                    r.data["captures"].as_array_mut().unwrap().push(entry);+                    r.mark("capture-start");+                    r.save().unwrap();+                    ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);+                }+                "mark" => {+                    let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));+                    r.mark(&l);+                    r.save().unwrap();+                    ok(&format!("marker {l} at {}", now()), &[]);+                }+                "stop" => {+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };+                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    let stop = br.record_stop(&rid, &save_to);+                    let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                    let cap = &r.data["captures"][pos];+                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+                    let p = stop.remote.clone();+                    r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": "capture stop"}), &stop));+                    r.mark("capture-stop");+                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+                        clip_artifacts(&mut r, &st, &f, 10);+                    }+                    r.save().unwrap();+                    if p.is_some() && !stop.pulled {+                        err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]);+                    }+                    let clips_note = auto_clips(&r, &dir);+                    ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into(), clips_note]);+                }+                "reject" => {+                    // the AI looked at a contact sheet and saw a clip the blank check accepted but a+                    // human would not (stale pixels after a window resize, a covered canvas)+                    let tag = label.clone().unwrap_or_else(|| err("capture reject <step>-<visit> --reason \"...\"", &[]));+                    let why = reason.clone().unwrap_or_else(|| err("say why in --reason", &[]));+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    let pos = caps.iter().rposition(|c| format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag || c["file"].as_str().map(|f| f.ends_with(&tag)).unwrap_or(false)).unwrap_or_else(|| err(&format!("no clip {tag} in this run"), &["Tags are <step>-<visit>, e.g. nets-2; or the clip's file name.".into()]));+                    r.data["captures"][pos]["suspect"] = json!(format!("rejected by the AI: {why}"));+                    let st = json!(format!("{}-{}", caps[pos]["step"].as_str().unwrap_or("?"), caps[pos]["visit"].as_u64().unwrap_or(1)));+                    r.log("clip-suspect", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai"}));+                    r.save().unwrap();+                    ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]);+                }+                other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),+            }+        }+        Cmd::Status => {+            let r = load_run(&cli);+            let o = r.outcomes();+            ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",+                dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],+                o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),+                if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { "landed" } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),+                o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);+        }+        Cmd::Report { page, push, refresh, user_machine, surface } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            if let Some(p) = page { r.data["page"] = json!(p); }+            if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }+            if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }+            r.save().unwrap();+            let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));+            if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }+            let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);+            if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }+            ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);+        }+        Cmd::Giveback { url, kind, note } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }+            r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));+            r.log("giveback", json!({"url": url, "kind": kind, "note": note}));+            r.save().unwrap();+            ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);+        }++        Cmd::Recut => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t, target }, &dir);+                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+            }+            let recovered = recover_capture_files(&mut r, &dir);+            if recovered > 0 { eprintln!("recovered {recovered} clip file(s) the manifest had lost"); }+            let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+            let mut n = 0;+            for c in &caps {+                let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+                if !Path::new(f).is_file() { continue; }+                let tag = json!(format!("{step}-{}", c["visit"].as_u64().unwrap_or(1)));+                clip_artifacts(&mut r, &tag, f, 10);+                n += 1;+            }+            r.mark(&format!("recut: {n} clips re-cut (10x and action) with this binary's rules"));+            r.save().unwrap();+            ok(&format!("{n} clips re-cut; the run page uses the action cuts"), &["Next: adom-aiflow compose --out <mp4>, then report --push --refresh.".into()]);+        }+        Cmd::Compose { out, max_seconds, still_seconds, with, script } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }+            let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };+            if let Some(other) = with {+                let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));+                let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));+                let (secs, steps, pairs, has_tts) = compose_pair(&mut r, &b, &dir, &outp, *max_seconds, *still_seconds, script.as_ref());+                r.log("artifact", json!({"step": "finish", "kind": "video-comparison", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "steps": steps, "drawingPairs": pairs, "with": other.display().to_string(), "withEngine": b.data["engine"], "maxSeconds": max_seconds, "narrated": has_tts}));+                r.data["comparison"] = json!({"video": outp.display().to_string(), "with": other.display().to_string(), "withEngine": b.data["engine"], "seconds": (secs * 10.0).round() / 10.0});+                r.save().unwrap();+                ok(&format!("composed {secs:.0} s side by side: {} left, {} right, {steps} steps and {pairs} drawing pair(s){}: {}", r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or(""), if has_tts { ", narrated" } else { ", no narration" }, outp.display()), &["Look at it before you post it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png.".into(), "report --push --refresh puts it on this run's page under the single-run video.".into()]);+                return;+            }+            let (secs, parts, stills, rate, has_tts) = compose_run(&mut r, &dir, &outp, *max_seconds, *still_seconds);+            r.log("artifact", json!({"step": "finish", "kind": "video", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "segments": parts, "stills": stills, "rate": rate, "maxSeconds": max_seconds, "narrated": has_tts}));+            if r.data["delivery"].is_object() {+                r.data["delivery"]["video"] = json!(outp.display().to_string());+                r.mark("compose: the delivered video replaced by the composed cut (motion only, the ledger's overlays, the AI's drawings, narration cut to the picture); the clock of record unchanged");+            }+            r.save().unwrap();+            let minutes = r.active_minutes_total();+            ok(&format!("composed {secs:.0} s from {parts} step visits and {stills} drawing(s){} for {}, {minutes:.1} min of AI time, motion at {rate:.1} frames per second: {}", if has_tts { ", narrated" } else { ", no narration (adom-tts not reachable)" }, r.data["engine"].as_str().unwrap_or(""), outp.display()), &["Look at it before you deliver it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png gives one page of it.".into(), "Next: report --push --refresh so the run page carries it; deliver --video <this file> if the run is not delivered yet.".into()]);+        }+        Cmd::Artifact { file, kind, caption, step, replaces, review } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let src = PathBuf::from(file);+            if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }+            let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));+            let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+            // the file lives in the run directory so the page and the video can find it later+            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());+            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());+            let dest = if canon.starts_with(&rdir) { canon } else { let d = dir.join(src.file_name().unwrap_or_default()); let _ = std::fs::copy(&src, &d); std::fs::canonicalize(&d).unwrap_or(d) };+            let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));+            let review_path=if kind=="library-overview" {+                let rp=review.as_ref().unwrap_or_else(||err("library-overview needs --review <review.json>", &["Review exact selected STEP/GLB variants in the native EDA and the overview. See docs/library-overview-review.md.".into()]));+                library_review::verify(rp,Path::new(&r.current_board()),&dest).unwrap_or_else(|e|err(&e,&["Update the actual selection/native evidence, then re-register; do not mark pending evidence passed.".into()]));+                let seconds=media_seconds(dest.to_str().unwrap());+                if !(1.0..=6.0).contains(&seconds){err("library overview must be a moving clip between one and six seconds", &["Keep the full component tour separate; cut a roughly five-second overview.".into()]);}+                Some(std::fs::canonicalize(rp).unwrap_or(rp.clone()).display().to_string())+            }else{None};+            let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());+            r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path}));+            r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));+            r.save().unwrap();+            ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else if kind=="library-overview" { "The reviewed moving overview goes into compose; the full tour remains separate.".into() } else { "It goes on the run page under this step with your caption; this artifact kind does not automatically enter the final video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);+        }+        Cmd::Compare { with, page, push, refresh } => {+            let t = thread(&cli);+            let r = load_run(&cli);+            let b = Run::open(with).unwrap_or_else(|| err(&format!("no run at {}", with.display()), &[]));+            let page = page.clone().or_else(|| r.data["page"].as_str().map(str::to_string)).unwrap_or_else(|| err("no page: --page <owner/slug>", &[]));+            let (rel, url, files) = build_comparison(&r, &b, &dir, &page);+            let mut notes = Vec::new();+            if *push {+                let stage = dir.join("report");+                let mut cmd = std::process::Command::new("adom-wiki");+                cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);+                for f in &files { cmd.arg(f); }+                match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>()), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }+            }+            if *refresh {+                if let Some(m) = r.data["userMachine"].as_str() {+                    let br = Bridge { ai_thread: t.clone(), target: m.to_string() };+                    let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+                    let op = br.call("pup_open_window", &json!({"sessionId": format!("aiflow-compare-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>()), "url": bust, "reason": "show the human the comparison page"}));+                    notes.push(if Bridge::ok(&op) { format!("opened in pup on {m}") } else { format!("pup on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+                }+            }+            ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]);+        }+        Cmd::Prompt { text, at } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let at = at.clone().unwrap_or_else(now);+            if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); }+            let n = r.sessions().len() + 1;+            r.log("prompt", json!({"n": n, "text": text, "at": at}));+            r.data["clock"]["lastPromptTime"] = json!(at);+            r.save().unwrap();+            ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Say `done --message \"...\"` the moment your answer to this prompt is complete; the clock of this session stops there, and what sits idle after it is not charged to you.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]);+        }+        Cmd::Done { message } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let ss = r.sessions();+            let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };+            if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }+            let t = now();+            r.log("done", json!({"n": cur.n, "message": message}));+            r.save().unwrap();+            let ss = r.sessions();+            let cur = ss.last().unwrap();+            ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min unobserved gaps excluded); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]);+        }+        Cmd::Sessions { json: as_json } => {+            thread(&cli);+            let r = load_run(&cli);+            let ss = r.sessions();+            if *as_json {+                println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "unobservedMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "measurement": "ledger activity with heuristic gap exclusion; not harness-complete", "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());+                return;+            }+            println!("{}", sessions_table(&r));+            ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["A session without a `done` ends at its last event; say `done` when an answer is complete so nothing idle is charged.".into()]);+        }+        Cmd::Usage => {+            thread(&cli);+            let mut r = load_run(&cli);+            let snap = record_usage(&mut r, "manual");+            r.save().unwrap();+            if snap.is_empty() { err("no plan usage readable: no Claude Code (~/.claude/.credentials.json) or Codex (~/.codex/auth.json) sign-in in this container, or the provider did not answer", &["The run still counts time; usage is the second cost line and it needs a sign-in the binary can read.".into()]); }+            ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);+        }++        Cmd::Netlist { action: _, sch, board, net } => {+            let mut run = Run::open(&dir);+            if run.is_some() { thread(&cli); }+            let sch = sch.clone().or_else(|| run.as_ref().and_then(|r| r.data["schematic"].as_str().map(str::to_string)))+                .unwrap_or_else(|| err("netlist check needs --sch <file.kicad_sch> (or a run started with --sch)", &["The schematic the board was made from; its netlist is exported with kicad-cli and compared pin by pin.".into()]));+            if !Path::new(&sch).is_file() { err(&format!("schematic not found: {sch}"), &[]); }+            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("netlist check needs --board <file.kicad_pcb> outside a run", &[]));+            let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+            let out_dir = if run.is_some() { dir.clone() } else { std::env::temp_dir() };+            let (netfile, via) = match net {+                Some(n) => (PathBuf::from(n), "given netlist".to_string()),+                None => {+                    let out = out_dir.join(if run.is_some() { "netlist-sch.net".to_string() } else { format!("aiflow-netlist-{}.net", std::process::id()) });+                    let _ = std::fs::remove_file(&out);+                    let cli_used = export_sch_netlist(&sch, &out).unwrap_or_else(|e| err(&e, &["Export needs KiCad's own kicad-cli for the schematic's version (a KiCad 7 kicad-cli cannot read a KiCad 10 schematic). In a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> KICAD_REMOTE_THREAD=<your thread>. Or export it yourself (`kicad-cli sch export netlist --format kicadsexpr`) and pass --net <file>.".into()]));+                    (out, format!("exported by {cli_used}"))+                }+            };+            let text = std::fs::read_to_string(&netfile).unwrap_or_else(|e| err(&format!("{}: {e}", netfile.display()), &[]));+            let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));+            let rep = checks::compare_netlists(&sn, &b);+            let passed = rep["ok"] == true;+            let mut lines = vec![format!("schematic {} ({} refs) vs board {} ({} refs): {}/{} connected pins on the same net ({via})", sch, rep["schematicRefs"], board_path, rep["boardRefs"], rep["matchedPins"], rep["connectedPins"])];+            for (k, label) in [("missingRefs", "missing on the board"), ("extraRefs", "on the board, not in the schematic")] {+                if let Some(a) = rep[k].as_array().filter(|a| !a.is_empty()) { lines.push(format!("  {label}: {}", a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+            }+            for m in rep["mismatches"].as_array().cloned().unwrap_or_default() {+                lines.push(format!("  {:<10} schematic {:<12} board {:<12} {}", m["pin"].as_str().unwrap_or(""), m["schematic"].as_str().unwrap_or("-"), m["board"].as_str().unwrap_or("-"), m["why"].as_str().unwrap_or("")));+            }+            let fpd: Vec<String> = rep["footprintDifferences"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+            if let Some(r) = run.as_mut() {+                let report = dir.join("netlist.json");+                write_json(&report, &rep);+                if r.data["schematic"].is_null() { r.data["schematic"] = json!(std::fs::canonicalize(&sch).map(|p| p.display().to_string()).unwrap_or(sch.clone())); }+                r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));+                r.save().unwrap_or_else(|e| err(&e, &[]));+            }+            let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };+            if !passed {+                err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);+            }+            ok(&lines.join("\n"), &[if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);+        }+        Cmd::Molecule { action: _, board, spec } => {+            let mut run = Run::open(&dir);+            if run.is_some() && board.is_none() { thread(&cli); }+            let spec_v = match spec { Some(p) => read_json(p), None => run.as_ref().map(spec_of).unwrap_or_else(|| err("molecule check needs --spec <spec.json> outside a run", &[])) };+            if spec_v.get("molecule").is_none() { eprintln!("Hint: the spec has no \"molecule\" object; checking with the Adom defaults (2 mm grid, 2 mm edge margin, medium pins). Add \"molecule\": {{...}} so place check, gate and finish run it too."); }+            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("molecule check needs --board <file.kicad_pcb> outside a run", &[]));+            let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+            let before = if board.is_none() { run.as_ref().and_then(first_board) } else { None };+            let f = checks::molecule_check(&b, &spec_v, before.as_ref());+            if let (Some(r), None) = (run.as_mut(), board) {+                r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings}));+                r.save().unwrap_or_else(|e| err(&e, &[]));+            }+            let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));+            if !f.errors.is_empty() { err(&text, &f.hints); }+            let mut hints = f.hints.clone();+            hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into());+            ok(&text, &hints);+        }+        Cmd::Rules { action, profile, board, force } => {+            let run = Run::open(&dir);+            if run.is_some() { thread(&cli); }+            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("rules needs --board <file.kicad_pcb> outside a run", &[]));+            let profile = profile.clone().or_else(|| run.as_ref().and_then(|r| checks::rules_profile(&spec_of(r)))).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));+            match action.as_str() {+                "install" => {+                    if profile != "jlcpcb" {+                        err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb.".into()]);+                    }+                    let dru = Path::new(&board_path).with_extension("kicad_dru");+                    if dru.is_file() && !*force {+                        let cur = std::fs::read_to_string(&dru).unwrap_or_default();+                        if cur.contains(checks::JLCPCB_MARKER) { ok(&format!("{} already carries the jlcpcb-2L profile", dru.display()), &[]); return; }+                        err(&format!("{} already exists and is another rules file", dru.display()), &["Keep it (it may be the 3rd party fab's rules) or pass --force to replace it with the jlcpcb-2L profile.".into()]);+                    }+                    std::fs::write(&dru, checks::JLCPCB_2L).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));+                    let mut hints = vec!["DRC loads it with the board: `gate` and `place check` need a native kicad-cli for project rules (ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote in a container).".into(), "Set \"fab\": {\"rules\": \"jlcpcb\"} in the spec so the gate refuses a board without it, and source parts on the jlcpcb profile (skill aiflow-sourcing).".into()];+                    if run.is_some() && board.is_none() { hints.push("Installed beside the run's current board; later snapshots carry it. Put the same file beside the source board too so the project keeps it.".into()); }+                    ok(&format!("installed the jlcpcb-2L rules (JLCPCB published 2-layer capabilities, cited in the file) at {}", dru.display()), &hints);+                }+                _ => {+                    match checks::rules_check(&profile, &board_path) {+                        Ok(m) => ok(&m, &[]),+                        Err(e) => err(&e, &[checks::rules_hint(&profile)]),+                    }+                }+            }+        }+        Cmd::Models { offline, ack, why } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            if let Some(refs) = ack {+                let why = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<what you checked in the native 3D viewer>\"", &[]));+                for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["modelAcks"][x] = json!(why.clone()); }+                r.log("models-ack", json!({"refs": refs, "why": why}));+            }+            // the orientation pass: offline, every model bound on the board, right way up+            let (orient, orient_notes) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));+            let unacked = unacked_orientation(&r, &orient);+            r.outcome("models-orientation", json!({"warnings": orient, "notChecked": orient_notes, "unacknowledged": unacked}));+            let orient_lines: Vec<String> = orient.iter().map(|w| format!("  {} {}: {}{}", w["ref"].as_str().unwrap_or(""), w["model"].as_str().unwrap_or(""), w["finding"].as_str().unwrap_or(""), if r.data["modelAcks"].get(w["ref"].as_str().unwrap_or("")).is_some() { " (acknowledged)" } else { "" })).collect();+            let orient_hint = if unacked.is_empty() { String::new() } else { format!("3D orientation warnings to fix or acknowledge ({}): correct the model's rotate/offset in the footprint and look at it in the native 3D viewer, then `models` again; when the model is right as it is, `models --ack {} --why \"<what you saw>\"`. finish refuses while any stays open.", unacked.join(","), unacked.join(",")) };+            if *offline {+                r.save().unwrap();+                let text = format!("3D orientation (offline): {} warning(s), {} not checked here{}{}", orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }, if orient_notes.is_empty() { String::new() } else { format!("\n  {}", orient_notes.join("\n  ")) });+                if !unacked.is_empty() { err(&text, &[orient_hint]); }+                ok(&text, &["The live resolution check is `models` without --offline (kicad_model_check on the test box).".into()]);+                return;+            }+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            let _ = t;+            r.stage_start("models", Some("binary"), None);+            let v = br.call("kicad_model_check", &json!({"filePath": rb}));+            let d = v.get("data").cloned().unwrap_or(v.clone());+            let counts = d["counts"].clone();+            let unresolved = counts["unresolvedModel"].as_u64().unwrap_or(0) + counts["missingModel"].as_u64().unwrap_or(0);+            let mut missing: Vec<String> = Vec::new();+            for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {+                let fp = f["footprint"].as_str().unwrap_or("").to_string();+                let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();+                let models: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(|s| s.to_string())).collect();+                if !models.is_empty() { missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, models.join(", "))); }+            }+            r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);+            r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing}));+            r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));+            r.save().unwrap();+            if !Bridge::ok(&v) && d["counts"].is_null() {+                err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);+            }+            if unresolved > 0 {+                err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);+            }+            if !unacked.is_empty() {+                err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);+            }+            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);+        }+        Cmd::Tour { what, nets, port, on } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            // a localhost Fields URL ({{port}}.localhost, 127.0.0.1) is reachable only on the machine+            // hosting this container: film it there (the user's primary machine), not on the test box+            let local_proxy = what == "fields" && std::env::var("VSCODE_PROXY_URI").map(|u| preboard::proxy_is_local(&u)).unwrap_or(false);+            let mut on_note = String::new();+            let br = match on {+                Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },+                _ if local_proxy => match primary_machine() {+                    Some(m) => { on_note = format!("filmed on {m}, the user's primary machine: the Fields URL is a localhost proxy only the container's host can reach (--on <box> overrides)"); Bridge { ai_thread: t.clone(), target: m } }+                    None => { on_note = "the Fields URL is a localhost proxy only the container's host can reach, and `adom-bridge targets` named no primary machine: filmed on the test box; say --on <the machine hosting this container> if the window does not appear".into(); bridge_of(&r, &cli) }+                },+                _ => bridge_of(&r, &cli),+            };+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            let editor = if what == "fields" { 0 } else { br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())))).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };+            match what.as_str() {+                "3d" => {+                    r.stage_start("tour-3d", Some("binary"), None);+                    // the viewer, its own window, is this step's camera: swap the running clip onto it+                    let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));+                    std::thread::sleep(std::time::Duration::from_secs(6));+                    let find_viewer = || -> Option<i64> {+                        let w = br.call("kicad_window_info", &json!({}));+                        let d = w.get("data").cloned().unwrap_or(w.clone());+                        d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())+                    };+                    let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));+                    // a viewer opened in the background never paints its GL canvas (a white window with a+                    // black bar, "last render time 1 ms"): show it once, maximised, and wait until the+                    // bridge's screenshot says the canvas rendered, or refuse rather than film a blank+                    let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));+                    let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));+                    let mut painted = false;+                    for _ in 0..12 {+                        std::thread::sleep(std::time::Duration::from_secs(3));+                        let sh = br.call("kicad_screenshot_all", &json!({}));+                        let d = sh.get("data").cloned().unwrap_or(sh.clone());+                        if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {+                            painted = true;+                            break;+                        }+                    }+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    // the editor clip of this step visit is not the walkthrough: discard it, the viewer is the camera+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+                    if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+                        let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+                        let stop = br.record_stop(&rid, &save_to);+                        settle_clip_stop(&mut r, pos, &stop, &dir);+                        r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");+                    }+                    if !painted {+                        let _ = br.call("kicad_close_3d_viewer", &json!({}));+                        r.log("clip-refused", json!({"step": step, "visit": visit, "why": "the 3D viewer's canvas never painted"}));+                        r.save().unwrap();+                        err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);+                    }+                    let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));+                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));+                    r.save().unwrap();+                    let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));+                    if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }+                    let tour = br.call("kicad_3d_view", &json!({"action": "tour", "pauseMs": 700}));+                    let cmds = tour["commands"].as_u64().unwrap_or(0);+                    std::thread::sleep(std::time::Duration::from_secs(2));+                    let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));+                    if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {+                        let stop = br.record_stop(&rid, &save_to);+                        let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                        r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));+                        if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }+                    }+                    let _ = br.call("kicad_close_3d_viewer", &json!({}));+                    let clips_note = auto_clips(&r, &dir);+                    if !clips_note.is_empty() { eprintln!("{clips_note}"); }+                    r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);+                    r.outcome("tour-3d", json!({"commands": cmds}));+                    r.save().unwrap();+                    if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }+                    ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);+                }+                "nets" => {+                    let spec = spec_of(&r);+                    let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();+                    if list.is_empty() {+                        for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }+                        for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }+                        for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {+                            let b = board_of(&r);+                            for key in [src.clone(), dst.as_str().unwrap_or("").to_string()] { if let Some(p) = b.pad_by_key(&key) { if !list.contains(&p.net) { list.push(p.net.clone()); } } }+                        }+                    }+                    if list.is_empty() { err("no nets to tour: the spec has no loads, wideNets or kelvin, and --nets was not given", &[]); }+                    r.stage_start("tour-nets", Some("binary"), None);+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+                    let mut toured: Vec<Value> = Vec::new();+                    let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+                    std::thread::sleep(std::time::Duration::from_millis(1500));+                    for net in &list {+                        let sel = br.call("kicad_select_net", &json!({"filePath": rb, "net": net}));+                        if !Bridge::ok(&sel) || sel["selected"].as_u64().unwrap_or(0) == 0 {+                            let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));+                            r.stage_end("tour-nets", Some(json!({"failed": net, "error": sel["errorCode"]})), None);+                            r.save().unwrap();+                            err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);+                        }+                        r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));+                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));+                        std::thread::sleep(std::time::Duration::from_millis(2600));+                        let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();+                        if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }+                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+                        std::thread::sleep(std::time::Duration::from_millis(1200));+                        toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"]}));+                    }+                    let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));+                    r.stage_end("tour-nets", Some(json!({"nets": toured.len()})), None);+                    r.outcome("tour-nets", json!({"nets": toured}));+                    r.save().unwrap();+                    ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);+                }+                "fields" => {+                    // the fields step: adom-fields solved the board and serves it; a pup window on the test+                    // box shows it, the recorder films that window while `adom-fields tour` drives it+                    let health = std::process::Command::new("adom-fields").args(["health", "--port", &port.to_string()]).output().map(|o| o.status.success()).unwrap_or(false);+                    if !health { err(&format!("adom-fields is not serving on port {port}"), &["Solve and serve first: `adom-fields analyze --board <the current board> --spec <spec> --out fields` then `adom-fields serve --fields fields --port ".to_string() + &port.to_string() + " &` (adom-wiki pkg install adom/adom-fields if it is missing).".into()]); }+                    let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));+                    r.stage_start("tour-fields", Some("binary"), None);+                    // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready+                    let _ = br.call("pup_prewarm", &json!({"reason": "aiflow fields walkthrough: warming pup on the test box"}));+                    for _ in 0..30 {+                        let rd = br.call("pup_readiness", &json!({}));+                        if rd["ready"].as_bool() == Some(true) { break; }+                        std::thread::sleep(std::time::Duration::from_secs(3));+                    }+                    // a fresh pup session every time: a session whose browser died answers browser_gone forever+                    let sid = format!("aiflow-fields-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>());+                    let op = br.call("pup_open_window", &json!({"sessionId": sid, "url": proxy, "width": 1600, "height": 1000, "reason": "aiflow fields walkthrough: the AI's current and heat analysis on camera"}));+                    let open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().unwrap_or("?").to_string()) };+                    // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed+                    let mut hwnd: Option<i64> = None;+                    for _ in 0..40 {+                        std::thread::sleep(std::time::Duration::from_secs(2));+                        let lw = br.call("desktop_list_windows", &json!({}));+                        // the relay wraps some answers as a JSON string under `output`+                        let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+                        let d = lw.get("data").cloned().or_else(|| inner.get("data").cloned()).unwrap_or(lw.clone());+                        let wins = d["windows"].as_array().cloned().or_else(|| lw["windows"].as_array().cloned()).or_else(|| inner["windows"].as_array().cloned()).unwrap_or_default();+                        // the app's window, not a wiki page about it: the app titles itself exactly "Adom Fields"+                        hwnd = wins.iter().find(|w| w["title"].as_str().map(|t| (t == "Adom Fields" || t.starts_with("Adom Fields - ")) && !t.contains("Wiki") && !t.contains("wiki")).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())));+                        if hwnd.is_some() { break; }+                    }+                    let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on {} (no window titled Adom Fields{})", br.target, open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into(), if preboard::proxy_is_local(&proxy) { format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`.") } else { String::new() }, on_note.clone()]); };+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+                    // the editor clip of this visit is not the walkthrough: the app window is the camera+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+                        let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+                        let stop = br.record_stop(&rid, &save_to);+                        settle_clip_stop(&mut r, pos, &stop, &dir);+                        r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");+                    }+                    std::thread::sleep(std::time::Duration::from_secs(4));+                    let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));+                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));+                    r.save().unwrap();+                    if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }+                    // no pace of our own: the app holds each beat and drifts the board gently, because+                    // this clip is played back at ten times speed+                    let tour = std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]).output();+                    let tour_ok = tour.as_ref().map(|o| o.status.success()).unwrap_or(false);+                    std::thread::sleep(std::time::Duration::from_secs(2));+                    if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {+                        let stop = br.record_stop(&rid, &save_to);+                        let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                        r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));+                        if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }+                    }+                    let _ = br.call("pup_close_window", &json!({"sessionId": sid}));+                    let clips_note = auto_clips(&r, &dir);+                    if !clips_note.is_empty() { eprintln!("{clips_note}"); }+                    // the solved numbers ride the run: the issues are what the AI answers for+                    let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+                    let issues = fields["issues"].as_array().cloned().unwrap_or_default();+                    r.stage_end("tour-fields", Some(json!({"issues": issues.len(), "tour": tour_ok})), None);+                    r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"]}));+                    r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));+                    r.save().unwrap();+                    if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }+                    ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);+                }+                other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),+            }+        }+        Cmd::Ledger { json: as_json } => {+            let r = load_run(&cli);+            let led = r.ledger();+            if led.is_empty() {+                err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);+            }+            if *as_json {+                for e in &led {+                    println!("{e}");+                }+            } else {+                for e in &led {+                    let t = e["t"].as_str().unwrap_or("");+                    match e["event"].as_str().unwrap_or("") {+                        "start" => println!("{t}  start      engine {} prompt {}", e["engine"], e["promptTime"]),+                        "turn" => println!("{t}  turn {:>3}   {:<10} think {:>6.1} min  {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),+                        "turn-end" => println!("{t}  end  {:>3}   {:<10} tool  {:>6.1} min  {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),+                        "step" => println!("{t}  step       {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),+                        "stage-end" => println!("{t}  stage      {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),+                        "marker" => println!("{t}  marker     {}", e["label"].as_str().unwrap_or("")),+                        "finish" | "deliver" => println!("{t}  {}    {}", e["event"].as_str().unwrap_or(""), e["minutes"]),+                        "prompt" => println!("{t}  prompt {:>2}  {}", e["n"], e["text"].as_str().unwrap_or("")),+                        "board-attached" => println!("{t}  board      {} attached to the intake run (clock from {})", e["board"].as_str().unwrap_or(""), e["promptTime"].as_str().unwrap_or("")),+                        "evidence" => println!("{t}  evidence   {} {} pass {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or(""), e["pass"]),+                        "evidence-refused" => println!("{t}  refused    {} {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or("")),+                        "done" => println!("{t}  done   {:>2}  {}", e["n"], e["message"].as_str().unwrap_or("")),+                        _ => {}+                    }+                }+            }+            println!("{}", step_table(&r));+        }+        Cmd::Step { name, back, why } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());+            for s in preboard::PREBOARD_STEPS { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }+            let known: Vec<&str> = known.iter().map(String::as_str).collect();+            let mut clip_stopped = false;+            let prev = r.data["currentStep"].as_str().map(str::to_string);+            record_usage(&mut r, &format!("step:{name}"));+            r.step_set(name, *back, why.as_deref());+            // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)+            let mut clip_note = String::new();+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let br = Bridge { ai_thread: thread(&cli), target };+                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    // the step's closing screenshot: what the board looked like when the step ended+                    if let Some(h) = caps[pos]["hwnd"].as_i64() {+                        let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));+                        if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {+                            r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));+                        }+                    }+                    let stop = br.record_stop(&rid, &save_to);+                    let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                    let cap = &r.data["captures"][pos];+                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+                    let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());+                    r.log("clip-stop", clip_stop_event(json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid}), &stop));+                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+                        clip_artifacts(&mut r, &st, &f, 10);+                    }+                    clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));+                    clip_stopped = true;+                }+                // the fields step films the app's window through `tour fields`: no editor clip for it+                let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };+                let disk = disk_check(&dir, &br, name);+                if let Err(e) = &disk {+                    r.log("clip-refused", json!({"step": name, "why": e}));+                    r.save().unwrap();+                    err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);+                }+                match hwnd {+                    Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {+                        Ok(rid) => {+                            // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back+                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));+                            r.mark(&format!("clip-start:{name}-{visit}"));+                            let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                            if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {+                                r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));+                            }+                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));+                            clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));+                        }+                        Err(rep) if rep["errorCode"].as_str() == Some("window_not_found") => {+                            // the remembered editor window is gone (KiCad closed, the box restarted): look once more, live+                            match br.pcb_editor_hwnd() {+                                Some(h2) if h2 != h => match br.record_window_start(h2, &format!("aiflow step {name}")) {+                                    Ok(rid) => { let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h2, "step": name, "visit": visit, "back": *back, "why": why})); r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid})); clip_note.push_str(&format!("clip {name}-{visit} recording (the editor moved to window {h2})")); }+                                    Err(rep2) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep2["errorCode"].as_str().unwrap_or("?"))),+                                },+                                _ => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box any more (capture open to bring the board back)")),+                            }+                        }+                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),+                    },+                    None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),+                    // before the board there is no editor to film: the desktop is the camera+                    None if preboard::PREBOARD_STEPS.contains(&name.as_str()) => match br.record_start(&format!("aiflow step {name} (before the board)")) {+                        Ok(rid) => {+                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "monitor", "step": name, "visit": visit, "back": *back, "why": why}));+                            r.mark(&format!("clip-start:{name}-{visit}"));+                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid, "window": "monitor"}));+                            clip_note.push_str(&format!("clip {name}-{visit} recording the desktop (no PCB editor before the board)"));+                        }+                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),+                    },+                    None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),+                }+            }+            r.save().unwrap();+            let clips_note = if clip_stopped { auto_clips(&r, &dir) } else { String::new() };+            let mut report_note = String::new();+            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+                report_note = format!("Progress page: {url} ({})", notes.join("; "));+            }+            let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();+            let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };+            let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };+            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note, clips_note]);+        }+        Cmd::Deliver { video, message, accept_suspect } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: thread(&cli), target }, &dir);+                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+            }+            let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();+            if !suspects.is_empty() && !*accept_suspect {+                err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!("  {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);+            }+            if r.data["clock"]["finishTime"].is_null() {+                err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the qualified board first, then the video and your message.".into()]);+            }+            let vp = Path::new(video);+            if !vp.is_file() {+                err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);+            }+            let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let brs = Bridge { ai_thread: thread(&cli), target };+                stop_running_clip(&mut r, &brs, &dir, "deliver");+            }+            let t = now();+            r.data["clock"]["deliveredTime"] = json!(t);+            record_usage(&mut r, "deliver");+            r.data["summary"]["planUsage"] = json!(usage_delta(&r));+            r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message});+            r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());+            r.data["summary"]["steps"] = Value::Object(r.step_report());+            r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0));+            r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"]})).collect::<Vec<_>>()).unwrap_or_default());+            r.log("deliver", json!({"minutes": r.delivered_minutes(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));+            r.save().unwrap();+            let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);+            let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));+            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+                report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));+            }+            ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);+        }+    }+}++fn measurement_usable(value: Option<&Value>) -> bool {+    value.map(|v| v["failed"] != true && v["filledAreaByLayerMm2"].as_object().map(|a| !a.is_empty() && a.values().all(|n| n.as_f64().map(|x| x.is_finite() && x >= 0.0).unwrap_or(false))).unwrap_or(false)).unwrap_or(false)+}++#[cfg(test)]+mod mpn_mark_tests {+    use super::*;+    #[test]+    fn recognises_service_and_legacy_marks() {+        assert!(has_mpn_mark(b"#405 = PRODUCT('Adom.LaserEtch','Adom.LaserEtch','',(#406));"));+        assert!(has_mpn_mark(b"#3258 = PRODUCT('laser_etch','laser_etch','',(#1));"));+        assert!(!has_mpn_mark(b"#7 = PRODUCT('SOT-23-6','SOT-23-6','',(#8));"));+    }+}++#[cfg(test)]+mod measurement_gate_tests {+    use super::*;+    #[test]+    fn refuses_failed_or_missing_copper_measurement() {+        for v in [Value::Null, json!({"failed":true,"filledAreaByLayerMm2":{"F.Cu":12}}), json!({"filledAreaByLayerMm2":null}), json!({"filledAreaByLayerMm2":{}}), json!({"filledAreaByLayerMm2":{"F.Cu":-1}})] {+            assert!(!measurement_usable(Some(&v)));+        }+        assert!(!measurement_usable(None));+        assert!(measurement_usable(Some(&json!({"filledAreaByLayerMm2":{"F.Cu":12,"B.Cu":0}}))));+    }+}++#[cfg(test)]+mod clip_stop_tests {+    use super::*;++    fn run_with_capture(dir: &Path, cap: Value) -> Run {+        let _ = std::fs::remove_dir_all(dir);+        std::fs::create_dir_all(dir).unwrap();+        Run { dir: dir.to_path_buf(), data: json!({"captures": [cap]}) }+    }+    fn stop(remote: Option<&str>, pulled: bool) -> ClipStop {+        ClipStop { remote: remote.map(str::to_string), local: None, pulled, attempts: if remote.is_some() { 3 } else { 0 }, reply: Value::Null }+    }++    #[test]+    fn a_failed_pull_is_flagged_with_its_remote_path_not_a_local_file() {+        let dir = std::env::temp_dir().join(format!("aiflow-clipstop-a-{}", std::process::id()));+        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-1", "step": "components", "visit": 1}));+        let remote = "C:/Users/j/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4";+        assert_eq!(settle_clip_stop(&mut r, 0, &stop(Some(remote), false), &dir), None);+        let c = &r.data["captures"][0];+        assert!(c["file"].is_null() && c.get("file").is_some(), "stopped, with no local file");+        assert_eq!((c["pullFailed"].clone(), c["remoteFile"].clone()), (json!(true), json!(remote)));+        assert_eq!(clip_stop_event(json!({}), &stop(Some(remote), false))["pullFailed"], true);+        // the pull that lands clears the flag and names the local file+        std::fs::write(dir.join("window-1.mp4"), b"mp4").unwrap();+        let f = settle_clip_stop(&mut r, 0, &stop(Some(remote), true), &dir).unwrap();+        assert!(f.ends_with("window-1.mp4"));+        assert!(r.data["captures"][0].get("pullFailed").is_none());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn a_second_stop_never_blanks_the_first_stops_file() {+        let dir = std::env::temp_dir().join(format!("aiflow-clipstop-b-{}", std::process::id()));+        let local = dir.join("window-2.mp4").display().to_string();+        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-2", "step": "placement", "visit": 1, "stop": "2026-09-29T02:43:48Z", "file": local, "remoteFile": "C:/rec/window-2.mp4"}));+        std::fs::write(&local, b"mp4").unwrap();+        // the recorder answers "nothing running": no path+        assert_eq!(settle_clip_stop(&mut r, 0, &stop(None, false), &dir), Some(local.clone()));+        let c = &r.data["captures"][0];+        assert_eq!((c["file"].as_str(), c["remoteFile"].as_str(), c["stop"].as_str()), (Some(local.as_str()), Some("C:/rec/window-2.mp4"), Some("2026-09-29T02:43:48Z")));+        // a failed re-pull of the same file keeps the good local copy+        settle_clip_stop(&mut r, 0, &stop(Some("C:/rec/window-2.mp4"), false), &dir);+        assert_eq!(r.data["captures"][0]["file"].as_str(), Some(local.as_str()));+        assert!(r.data["captures"][0].get("pullFailed").is_none());+        // a first stop with no path still marks the capture stopped+        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "nets", "visit": 1}));+        settle_clip_stop(&mut r, 0, &stop(None, false), &dir);+        assert!(r.data["captures"][0].get("file").is_some());+        let _ = std::fs::remove_dir_all(&dir);+    }+}+--- a/crates/aiflow-copper/src/lib.rs+++ b/crates/aiflow-copper/src/lib.rs@@ -1,568 +1,573 @@⋯ 457 unchanged lines ⋯ pub fn native_kicad_cli() -> Option<String> {     static NATIVE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();     NATIVE.get_or_init(|| {-        let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());-        let help = Command::new(&cli).args(["pcb", "drc", "--help"]).output().ok()?;-        let text = format!("{}{}", String::from_utf8_lossy(&help.stdout), String::from_utf8_lossy(&help.stderr));-        text.contains("--refill-zones").then_some(cli)+        let probe = |cli: &str| -> bool {+            Command::new(cli).args(["pcb", "drc", "--help"]).output().ok()+                .map(|h| format!("{}{}", String::from_utf8_lossy(&h.stdout), String::from_utf8_lossy(&h.stderr)).contains("--refill-zones")).unwrap_or(false)+        };+        if let Ok(cli) = std::env::var("ADOM_AIFLOW_KICAD_CLI") { return probe(&cli).then_some(cli); }+        if probe("kicad-cli") { return Some("kicad-cli".into()); }+        // no KiCad here (a container): the run's own desktop runs it through Adom Bridge+        (std::env::var("KICAD_REMOTE_TARGET").is_ok() && probe("adom-aiflow-kicad-cli-remote")).then(|| "adom-aiflow-kicad-cli-remote".to_string())     }).clone() } ⋯ 98 unchanged lines ⋯         assert_eq!(summarize(&candidate,Some(&moved))["newErrors"],1);     } }+--- a/docs/fusion-lane.md+++ b/docs/fusion-lane.md@@ -1,121 +1,138 @@⋯ 38 unchanged lines ⋯ Every mutating call carries the board id and the revision the flow last read. A board changed behind the flow's back is refused (`stale_board`), never edited blind. +## The whole project, by command++`adom-aiflow fusion` does the Fusion side of a project the same way every time (`--help` on each step):++| step | what it does |+|---|---|+| `fusion library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE library for the project: each part from its wiki component page, Mouser, DigiKey and LCSC numbers (the page's own, then the distributor lookups, each checked against the exact MPN), the chip outline as symbol for ICs, inductors and big capacitors, standard symbols for passives, the etched or coloured 3D bound. Run again with new parts and they join the same library; parts already bound are kept. |+| `fusion reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Fusion board's frame: `moves.json`, `fixed.json`, `spec.json`. |+| `fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>` | The design started with the fab's rules and the fixed parts (`fusion_board_seed`), the schematic drawn live in labelled groups (`fusion_schematic_plan`, `fusion_schematic_run`), checked pin by pin, saved, and the board written for `start`. |+| `fusion reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. |+| `fusion export` | Gerbers (the JLCPCB CAM job), pick and place, and the BOM with every distributor number, in `<run>/manufacturing/`. Ask the human first. |++A failing `analyze` prints `why:` lines with the cause of each field-screen neck and the spec change that+fixes it: a via whose copper ring is the narrowest copper (`viaPower`), or another net's track cutting a+patch pour off from its pin (`planeUnder`).+ ## The board's design rules come from the fab, not from Fusion's defaults  A new Fusion board carries Fusion's default rules: 0.35 mm minimum drill, 1 mm (40 mil) from copper to the⋯ 74 unchanged lines ⋯ - **First analysis:** adom-fields on the live board found VIN at 97 A/mm² through a 0.25 mm track, against a 60 A/mm² limit. - **Refine:** `unroute --net VIN`, then re-routing VIN at 0.4 mm, re-gating and re-landing it. - **Second analysis:** the new solve reported 0 issues, and both current and thermal passed.+--- a/skills/adom-aiflow/SKILL.md+++ b/skills/adom-aiflow/SKILL.md@@ -1,234 +1,264 @@ --- name: adom-aiflow description: >--  The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+  The board loop as one command for KiCad boards on the Adom KiCad Bridge and for Autodesk Fusion boards on the Fusion bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. ---  # adom-aiflow: the loop, measured⋯ 23 unchanged lines ⋯ 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. +## A board in Autodesk Fusion++When the human asks for a board in Fusion, the whole flow below is yours: they should never have to+spell out a step. Read `docs/fusion-lane.md` once; the Fusion bridge must be 1.11.33 or later.++1. **One project library.** `adom-aiflow fusion library --parts <netlist.json> --footprints <kicad lib folder> --name <Project>`+   builds ONE library for the project in Fusion: every part from its wiki component page, the Mouser,+   DigiKey and LCSC numbers looked up and written onto the part, the chip outline as the symbol for the+   parts a reader recognises (regulators, inductors, big capacitors) and standard symbols for passives,+   the laser-etched or coloured 3D model bound and rendering. A part added later (the human asks for+   one more LED) goes into the same library: run `fusion library` again with it. Never a second library.+2. **A reference design** (a KiCad board you are reproducing): `adom-aiflow fusion reference --kicad <board.kicad_pcb> --spec <spec.json>`+   moves its placement and every spec coordinate into the Fusion board's frame (`moves.json`,+   `fixed.json`, `spec.json`).+3. **The schematic, live.** `adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>`+   starts the design with the fab's rules and the fixed parts, draws the schematic in labelled groups+   (one per block of the netlist), checks it pin by pin, and saves the design.+4. **The board.** `start --board board.brd --remote-board fusion:<Design>`, then placement, routing,+   pours and `measure` as for any board.+5. **Current and heat, and refine until it passes.** `analyze current` / `analyze thermal` with+   adom-fields on the live board. A failing screen prints `why:` lines naming the cause and the spec+   change that fixes it (a via ring: `viaPower`; another net crossing a patch: `planeUnder`). Change the+   spec, `adom-aiflow fusion reset` (the parts stay, the copper goes), then route, gate, land, pour and+   analyze again. Repeat until both screens pass, then `finish`.+6. **Ask before exporting.** The human may not want manufacturing files yet. Ask with the+   AskUserQuestion tool ("Export the Gerbers, pick and place and the BOM now?", options "Export them" and+   "Not now"); on yes, `adom-aiflow fusion export` writes the Gerbers (the JLCPCB CAM job), the pick and+   place and the BOM with every distributor number to `<run>/manufacturing/`.+ ## Before the board: intake, sourcing, evidence  The clock starts at the prompt even when there is no board yet.⋯ 195 unchanged lines ⋯ `land pours` delegates the complete owned-zone plan to KiCad Bridge in one native transaction. Never replace another tool's zones or infer ownership from names, areas or absence in the intake board. Use Bridge 1.0.24+; no legacy delete/create fallback. An unchanged plan is not a DRC-clean certificate.  Refill acknowledgement is asynchronous. Request it once, then read without refill until two delayed revision/area snapshots agree. This proves observed stability, not a native completion event. Do not use the acknowledgement's old geometry; do not replay a timed-out or post-commit-failed mutation. Inspect the saved response, exact board and current revision. Measurement failure must remain a failed outcome. See docs/native-zone-landing.md.+--- a/tools/fusion-project.py+++ b/tools/fusion-project.py@@ -0,0 +1,513 @@+#!/usr/bin/env python3+"""adom-aiflow fusion: the Fusion side of a project, done the same way every time.++  library    one project library in Fusion: parts from their wiki component pages, the Mouser, DigiKey+             and LCSC numbers looked up and checked, a symbol per part (the chip outline for the parts a+             reader should recognise, standard symbols for passives), real 3D bound, and later parts+             added to the SAME library, never a new one+  reference  placement and spec coordinates from a reference KiCad board, moved into the Fusion board's+             frame (the board's lower-left corner at 0,0)+  schematic  the design started with the fab's rules and the fixed parts, then the schematic drawn live+             in labelled groups from the netlist, checked pin by pin+  reset      clear the live board's copper (tracks, vias, pours) for a refine pass; the parts stay+  export     Gerbers (the JLCPCB CAM job), pick and place and the BOM with every distributor number++Every step prints OK or ERROR with Hint lines and keeps its state in <run>/fusion/.+Called by `adom-aiflow fusion <step> ...`; AIFLOW_RUN, AIFLOW_TARGET and AIFLOW_THREAD come from it.+"""+import argparse, base64, csv, json, math, os, re, shutil, subprocess, sys, time+import xml.etree.ElementTree as ET+from pathlib import Path++RUN = Path(os.environ.get('AIFLOW_RUN', 'aiflow-run'))+TARGET = os.environ.get('AIFLOW_TARGET', '')+THREAD = os.environ.get('AIFLOW_THREAD', 'adom-aiflow')+HOME_WIN = os.environ.get('AIFLOW_BOX_DIR', '')  # folder on the box; default under the user's Documents+STATE = RUN / 'fusion'++# KiCad's standard schematic symbols (KiCad library, CC-BY-SA 4.0 with the design exception), used for+# passives so a schematic reads the EE way. Pin 1 at the top for R/C/L, anode at the top for an LED.+DEVICE_SYMBOLS = {+ 'R': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+  (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)+    (property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))+    (property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))+    (symbol "R_0_1" (rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none))))+    (symbol "R_1_1"+      (pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+      (pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+ 'C': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+  (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)+    (property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))+    (property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))+    (symbol "C_0_1"+      (polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))+      (polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none))))+    (symbol "C_1_1"+      (pin passive line (at 0 3.81 270) (length 2.794) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+      (pin passive line (at 0 -3.81 90) (length 2.794) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+ 'L': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+  (symbol "L" (pin_numbers hide) (pin_names (offset 1.016) hide) (exclude_from_sim no) (in_bom yes) (on_board yes)+    (property "Reference" "L" (at -1.27 0 90) (effects (font (size 1.27 1.27))))+    (property "Value" "L" (at 1.905 0 90) (effects (font (size 1.27 1.27))))+    (symbol "L_0_1"+      (arc (start 0 -2.54) (mid 0.6323 -1.905) (end 0 -1.27) (stroke (width 0) (type default)) (fill (type none)))+      (arc (start 0 -1.27) (mid 0.6323 -0.635) (end 0 0) (stroke (width 0) (type default)) (fill (type none)))+      (arc (start 0 0) (mid 0.6323 0.635) (end 0 1.27) (stroke (width 0) (type default)) (fill (type none)))+      (arc (start 0 1.27) (mid 0.6323 1.905) (end 0 2.54) (stroke (width 0) (type default)) (fill (type none))))+    (symbol "L_1_1"+      (pin passive line (at 0 3.81 270) (length 1.27) (name "1" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+      (pin passive line (at 0 -3.81 90) (length 1.27) (name "2" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+ 'LED': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")+  (symbol "LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (exclude_from_sim no) (in_bom yes) (on_board yes)+    (property "Reference" "D" (at 0 2.54 0) (effects (font (size 1.27 1.27))))+    (property "Value" "LED" (at 0 -2.54 0) (effects (font (size 1.27 1.27))))+    (symbol "LED_0_1"+      (polyline (pts (xy -1.27 -1.27) (xy -1.27 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))+      (polyline (pts (xy -1.27 0) (xy 1.27 0)) (stroke (width 0) (type default)) (fill (type none)))+      (polyline (pts (xy 1.27 -1.27) (xy 1.27 1.27) (xy -1.27 0) (xy 1.27 -1.27)) (stroke (width 0.254) (type default)) (fill (type none))))+    (symbol "LED_1_1"+      (pin passive line (at -3.81 0 0) (length 2.54) (name "K" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+      (pin passive line (at 3.81 0 180) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',+}+++def ok(msg, hints=()):+    print('OK: ' + msg)+    for h in hints:+        if h: print('Hint: ' + h)+    sys.exit(0)+++def err(msg, hints=()):+    print('ERROR: ' + msg)+    for h in hints:+        if h: print('Hint: ' + h)+    sys.exit(1)+++def say(msg):+    print(msg, flush=True)+++def run(cmd, timeout=600, check=False):+    p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+    if check and p.returncode != 0:+        err('%s failed: %s' % (cmd[0], (p.stdout + p.stderr).strip()[-400:]))+    return p+++def ab(verb, args, timeout=600):+    """One adom-bridge verb on the run's desktop; the reply with a JSON-string `output` unwrapped."""+    if not TARGET: err('the run has no desktop target', ['Start the run with --target <box> (adom-aiflow start ... --target), or set AIFLOW_TARGET.'])+    a = dict(args); a.setdefault('reason', 'adom-aiflow fusion')+    p = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, verb, json.dumps(a)], timeout=timeout)+    try: d = json.loads(p.stdout)+    except Exception: return {'success': False, 'error': (p.stdout + p.stderr)[-400:]}+    o = d.get('output')+    if isinstance(o, str) and o.startswith('{'):+        try: return json.loads(o)+        except Exception: pass+    return d+++def box_dir():+    """The project's folder on the desktop, under the user's Documents (one per run)."""+    if HOME_WIN: return HOME_WIN.rstrip('/')+    st = load_state()+    if st.get('boxDir'): return st['boxDir']+    r = ab('shell_execute', {'command': 'echo %USERPROFILE%'})+    out = [l.strip() for l in str(r.get('stdout') or r.get('output') or '').splitlines() if l.strip() and '%' not in l]+    home = out[-1] if out else 'C:/Users/Public'+    d = home.replace('\\', '/') + '/Documents/AdomAiFlow/' + RUN.resolve().name+    st['boxDir'] = d; save_state(st)+    return d+++def send(paths, dest):+    r = ab('send_files', {'filePaths': [str(Path(p).resolve()) for p in paths], 'dest': dest, 'destIsFolder': True}, timeout=1800)+    if r.get('success') is False: err('could not copy files to the desktop: %s' % str(r.get('error'))[:300], ['Check `adom-bridge --target %s ping` and retry.' % TARGET])+++def load_state():+    f = STATE / 'project.json'+    return json.loads(f.read_text()) if f.is_file() else {'parts': {}}+++def save_state(st):+    STATE.mkdir(parents=True, exist_ok=True)+    (STATE / 'project.json').write_text(json.dumps(st, indent=1))+++# ---------------------------------------------------------------- library++def lookup_numbers(mpn, page_links, cache):+    """Mouser, DigiKey and LCSC numbers for one MPN, each checked to belong to that exact MPN.+    The page's own links count; the distributor CLIs fill the gaps; a dead backend is said, never guessed."""+    got = {k: v for k, v in (page_links or {}).items() if k in ('mouser', 'digikey', 'lcsc') and v}+    notes = []+    if mpn in cache:+        for k, v in cache[mpn].items():+            if k in ('mouser', 'digikey', 'lcsc') and v and not got.get(k): got[k] = v+    def want(k): return not got.get(k)+    norm = lambda s: re.sub(r'[^A-Z0-9]', '', str(s).upper())+    for k, tool in (('mouser', 'adom-mouser'), ('digikey', 'adom-digikey')):+        if not want(k) or not shutil.which(tool): continue+        p = run([tool, 'part', mpn], timeout=90)+        if p.returncode != 0 or not p.stdout.strip().startswith(('{', '[')):+            txt = (p.stdout + p.stderr)+            notes.append('%s service %s' % (k, 'down (HTTP %s)' % m.group(1) if (m := re.search(r'HTTP (\d{3})', txt)) else 'unreachable')); continue+        try: d = json.loads(p.stdout)+        except Exception: continue+        items = d if isinstance(d, list) else (d.get('parts') or d.get('results') or [d])+        for it in items:+            if not isinstance(it, dict): continue+            m = it.get('mpn') or it.get('ManufacturerPartNumber') or it.get('manufacturerPartNumber')+            pn = it.get('mouserPartNumber') or it.get('MouserPartNumber') or it.get('digikeyPartNumber') or it.get('DigiKeyPartNumber') or it.get('partNumber')+            if m and pn and norm(m) == norm(mpn): got[k] = pn; break+    if want('lcsc') and shutil.which('adom-jlcpcb'):+        p = run(['adom-jlcpcb', 'search', mpn], timeout=90)+        try:+            d = json.loads(p.stdout); items = d if isinstance(d, list) else (d.get('results') or d.get('parts') or [])+            for it in items:+                m = it.get('mpn') or it.get('mfrPart') or it.get('manufacturerPartNumber')+                c = it.get('lcsc') or it.get('lcscPart') or it.get('code')+                if m and c and norm(m) == norm(mpn): got['lcsc'] = c; break+        except Exception:+            notes.append('lcsc service unreachable')+    return got, notes+++def pick_files(d, comp):+    parts = comp.get('parts', {})+    def first(*names):+        for n in names:+            if n and (d / n).is_file(): return d / n+        return None+    sym = first(parts.get('symbol')) or next(iter(sorted(x for x in d.glob('*.kicad_sym') if 'outline' not in x.name)), None)+    outline = next(iter(sorted(d.glob('*-outline.kicad_sym'))), None)+    fp = first(parts.get('footprint')) or next(iter(sorted(d.glob('*.kicad_mod'))), None)+    step = first(parts.get('model_3d_step'), parts.get('step')) or next(iter(sorted(x for x in d.glob('*.step') if 'unetched' not in x.name and 'uncoloured' not in x.name)), None)+    return sym, outline, fp, step+++def symbol_choice(comp, fp_text, sym_path):+    """The chip outline for the parts a reader should recognise at a glance (ICs, inductors, big and+    polarised capacitors); KiCad's standard symbol for small passives and LEDs."""+    cat = (comp.get('category') or '').lower(); sub = (comp.get('subcategory') or '').lower()+    pads = len(set(re.findall(r'\(pad "([^"]+)"', fp_text or '')))+    if pads > 2: return 'outline'+    if 'inductor' in cat: return 'outline'+    if 'capacitor' in cat and any(w in (cat + sub + (comp.get('package') or '').lower()) for w in ('alumin', 'electrolytic', 'polar', 'tantal', 'smd alu')): return 'outline'+    if 'resistor' in cat: return 'R'+    if 'capacitor' in cat: return 'C'+    if 'inductor' in cat or 'ferrite' in cat: return 'L'+    if 'led' in cat or 'diode' in cat and 'light' in sub: return 'LED'+    return 'own'+++def cmd_library(a):+    st = load_state(); name = st.get('library') or a.name or 'Project'+    if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', name): err('library name: letters, digits, _ and - only')+    st['library'] = name+    want = []+    src = json.loads(Path(a.parts).read_text()) if a.parts.endswith('.json') else []+    if isinstance(src, dict) and 'components' in src:+        # a netlist: each component's MPN; a component with none is a board-only pad, its footprint found+        # by name under --footprints (a KiCad library folder)+        refs = st.setdefault('refs', {})+        for c in src['components']:+            if c.get('mpn'):+                want.append({'mpn': c['mpn'], 'manufacturer': c.get('mfr')}); refs[c['ref']] = c['mpn']+            else:+                fpname = (c.get('fp') or '').split(':')[-1]+                hit = next(iter(sorted(Path(a.footprints).rglob(fpname + '.kicad_mod'))), None) if a.footprints and fpname else None+                if not hit: err('%s has no MPN and its footprint %s was not found' % (c['ref'], fpname or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpname])+                pad = re.sub(r'[^A-Za-z0-9_-]', '-', fpname).upper()+                want.append({'mpn': pad, 'footprint': str(hit)}); refs[c['ref']] = pad+        save_state(st)+    else:+        want.extend(src)+    if a.parts.endswith('.csv'):+        for row in csv.DictReader(open(a.parts, newline='')):+            mpn = row.get('mpn') or row.get('MPN')+            if mpn and mpn.strip().lower() not in ('', 'no part'): want.append({'mpn': mpn.strip(), 'ref': row.get('ref') or row.get('Designator')})+    if not want: err('no parts: give --parts parts.json ([{mpn, page?, footprint?}]) or a BOM .csv with an mpn column')+    cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {}+    lib_dir = STATE / 'library'; (lib_dir / 'parts').mkdir(parents=True, exist_ok=True)+    added, kept, unverified = [], [], {}+    seen = set()+    for w in want:+        mpn = w['mpn']+        if mpn in seen: continue+        seen.add(mpn)+        if mpn in st['parts'] and (st['parts'][mpn].get('urn') or st['parts'][mpn].get('boardOnly')):+            kept.append(mpn); continue+        pdir = STATE / 'pages' / re.sub(r'[^A-Za-z0-9._-]', '_', mpn)+        out_lbr = lib_dir / 'parts' / (mpn + '.lbr')+        if w.get('footprint'):  # a board-only part (test pad) from a footprint file+            r = run(['adom-lbr', 'generate', '--fp', w['footprint'], '--name', mpn, '--package-name', mpn, '-o', str(out_lbr)])+            if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, (r.stdout + r.stderr)[-300:]))+            st['parts'][mpn] = {'lbr': str(out_lbr), 'step': None, 'boardOnly': True, 'numbers': {}}+            added.append(mpn); say('part %s: board only (footprint %s)' % (mpn, Path(w['footprint']).name)); continue+        slug = w.get('page') or mpn.lower()+        shutil.rmtree(pdir, ignore_errors=True)  # always the page as it is now (its numbers may have been added since)+        if True:+            r = run(['adom-wiki', 'repo', 'clone', slug, '--dir', str(pdir)], timeout=900)+            if r.returncode != 0 or not (pdir / 'page.json').is_file():+                err('%s: no wiki component page at %s' % (mpn, slug), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in parts.json: {"mpn": "%s", "page": "<owner>/<slug>"}.' % mpn])+        page = json.loads((pdir / 'page.json').read_text()); comp = page.get('component', {})+        sym, outline, fp, step = pick_files(pdir, comp)+        if not fp: err('%s: the page has no .kicad_mod footprint' % mpn)+        numbers, notes = lookup_numbers(mpn, comp.get('distributor_links'), cache)+        meta = dict(page); meta['component'] = dict(comp); meta['component']['distributor_links'] = numbers+        if not meta['component'].get('manufacturer') and w.get('manufacturer'): meta['component']['manufacturer'] = w['manufacturer']+        (pdir / 'page.meta.json').write_text(json.dumps(meta))+        choice = w.get('symbol') or symbol_choice(comp, fp.read_text(errors='ignore'), sym)+        if choice == 'outline' and outline: use = outline+        elif choice in DEVICE_SYMBOLS:+            use = STATE / 'symbols' / (choice + '.kicad_sym'); use.parent.mkdir(parents=True, exist_ok=True); use.write_text(DEVICE_SYMBOLS[choice])+        else: use = sym+        args = ['adom-lbr', 'generate', '--fp', str(fp), '--part-metadata', str(pdir / 'page.meta.json'), '--name', mpn, '--package-name', mpn, '-o', str(out_lbr)]+        if use: args[2:2] = ['--sym', str(use)]+        if choice in DEVICE_SYMBOLS: args += ['--symbol-layout', 'keep']+        r = run(args)+        if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, (r.stdout + r.stderr)[-300:]))+        missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)]+        stock = (meta['component'].get('manufacturer') or '').strip().lower() == 'adom'+        if missing and not stock: unverified[mpn] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])+        st['parts'][mpn] = {**st['parts'].get(mpn, {}), 'lbr': str(out_lbr), 'step': str(step) if step else None, 'numbers': numbers, 'symbol': choice}+        added.append(mpn)+        say('part %s: %s symbol, model %s, %s' % (mpn, choice, step.name if step else 'none', 'Adom stock' if stock else 'Mouser %s, DigiKey %s, LCSC %s' % (numbers.get('mouser', '-'), numbers.get('digikey', '-'), numbers.get('lcsc', '-'))))+    save_state(st)+    # the one library: every part, user values allowed (VALUE never asks), assembled into <name>.lbr+    combined = lib_dir / (name + '.lbr')+    lbrs = sorted(str(Path(p['lbr'])) for p in st['parts'].values())+    r = run(['adom-lbr', 'assemble', *lbrs, '-o', str(combined), '--name', name], check=True)+    t = combined.read_text()+    t = re.sub(r'<deviceset ((?:(?!uservalue)[^>])*)>', lambda m: '<deviceset %s uservalue="yes">' % m.group(1), t)+    combined.write_text(t)+    # 3D: build only the parts that have no binding yet, then carry every binding into the library+    todo = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]+    bdir = box_dir() + '/library'+    if todo:+        stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); (stage / 'lib').mkdir(parents=True); (stage / 'models').mkdir()+        for m in todo:+            shutil.copy(st['parts'][m]['lbr'], stage / 'lib' / (m + '.lbr')); shutil.copy(st['parts'][m]['step'], stage / 'models' / (m + '.step'))+        send(sorted(stage.glob('lib/*')), bdir + '/lib'); send(sorted(stage.glob('models/*')), bdir + '/models'); send([combined], bdir)+        say('binding real 3D to %d part(s) in Fusion (about a minute each)' % len(todo))+        r = ab('fusion_build_library_3d', {'lbrPath': bdir + '/' + combined.name, 'outLbrPath': bdir + '/' + name + '_3d.lbr', 'capture': True,+                                           'parts': [{'package': m, 'lbrPath': bdir + '/lib/' + m + '.lbr', 'modelPath': bdir + '/models/' + m + '.step'} for m in todo]}, timeout=300)+        t0 = time.time(); res = {}+        while time.time() - t0 < 60 * (5 + 3 * len(todo)):+            time.sleep(20)+            pr = ab('fusion_prove_result', {}).get('result') or {}+            if pr.get('boundLbr', '').endswith(name + '_3d.lbr') and pr.get('partsDone') == len(todo) and all(x.get('package') in todo for x in pr.get('parts', [])):+                res = pr; break+            if pr.get('stage'): say('  3D: %s' % pr.get('stage'))+        if not res: err('the 3D build did not finish in time', ['Read fusion_prove_result; run `adom-aiflow fusion library` again: parts already bound are kept and only the rest are built.'])+        for x in res.get('parts', []):+            if x.get('success') and x.get('wip_urn'): st['parts'][x['package']]['urn'] = x['wip_urn']+        save_state(st)+    # inject every binding (this build's and earlier ones) into the library, then put it on the box+    urns = {m: p['urn'] for m, p in st['parts'].items() if p.get('urn')}+    t = combined.read_text()+    t = re.sub(r'(?s)<packages3d>.*?</packages3d>', '', t); t = re.sub(r'(?s)<package3dinstances>.*?</package3dinstances>', '', t)+    p3 = ''.join('<package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model"><description>%s</description><packageinstances><packageinstance name="%s"/></packageinstances></package3d>' % (m, u, m, m) for m, u in urns.items())+    if p3: t = t.replace('</packages>', '</packages><packages3d>' + p3 + '</packages3d>', 1)+    def dev(mt):+        d = mt.group(0); pk = re.search(r'package="([^"]+)"', d.split('>', 1)[0])+        u = urns.get(pk.group(1)) if pk else None+        return d.replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % u, 1) if u else d+    t = re.sub(r'(?s)<device [^>]*package="[^"]+"[^>]*>.*?</device>', dev, t)+    combined.write_text(t); send([combined], bdir)+    st['libraryPath'] = bdir + '/' + combined.name; save_state(st)+    unbound = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]+    ok('library %s: %d part(s) (%d added, %d kept), %d with real 3D, one library at %s' % (name, len(st['parts']), len(added), len(kept), len(urns), st['libraryPath']),+       ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `fusion library` again when the services are back, or add the numbers to the part\'s wiki page (component.distributor_links).' if unverified else '',+        '3D not bound yet: %s; run this again to retry them' % ', '.join(unbound) if unbound else '',+        'Next: adom-aiflow fusion schematic --netlist <netlist.json> (the design starts from this library). New parts later: run `fusion library` again with them; they join this same library.'])+++# ---------------------------------------------------------------- reference++def kicad_footprints(text):+    out = {}+    for m in re.finditer(r'\(footprint "([^"]+)".*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\).*?\(property "Reference" "([^"]+)"', text, re.S):+        out[m.group(5)] = (float(m.group(2)), float(m.group(3)), float(m.group(4) or 0) % 360)+    return out+++def kicad_outline(text):+    xs, ys = [], []+    for m in re.finditer(r'\(gr_(?:line|rect)[^()]*\(start ([-\d.]+) ([-\d.]+)\)\s*\(end ([-\d.]+) ([-\d.]+)\).*?\(layer "Edge.Cuts"\)', text, re.S):+        xs += [float(m.group(1)), float(m.group(3))]; ys += [float(m.group(2)), float(m.group(4))]+    if not xs: err('the reference board has no Edge.Cuts outline')+    return min(xs), min(ys), max(xs), max(ys)+++def cmd_reference(a):+    text = Path(a.kicad).read_text(); x0, y0, x1, y1 = kicad_outline(text)+    fps = kicad_footprints(text); spec = json.loads(Path(a.spec).read_text())+    # the Fusion board's frame: lower-left corner at 0,0, y up; the run's KiCad frame is (x, -y) of it+    def to_run(x, y): return round(x - x0, 3), round(y - y1, 3)+    fixed = set(spec.get('fixedRefs') or [])+    moves = {r: {'x': to_run(x, y)[0], 'y': to_run(x, y)[1], 'rotation': rot} for r, (x, y, rot) in fps.items() if r not in fixed}+    out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)+    (out / 'moves.json').write_text(json.dumps(moves, indent=1))+    fixed_pos = {r: {'x': round(x - x0, 3), 'y': round(y1 - y, 3), 'rot': rot} for r, (x, y, rot) in fps.items() if r in fixed}+    (out / 'fixed.json').write_text(json.dumps({'outlineMm': [round(x1 - x0, 3), round(y1 - y0, 3)], 'fixed': fixed_pos}, indent=1))+    moved = 0+    def tr(p): return [round(p[0] - x0, 3), round(p[1] - y1, 3)]+    for key in ('pours', 'planeUnder'):+        for z in spec.get(key, []) or []:+            if 'polygon' in z and z['polygon'] and any(abs(v) > 40 for pt in z['polygon'] for v in pt): z['polygon'] = [tr(p) for p in z['polygon']]; moved += 1+    for v in spec.get('stitchVias', []) or []:+        if abs(v.get('x', 0)) > 40 or abs(v.get('y', 0)) > 40: v['x'], v['y'] = tr([v['x'], v['y']]); moved += 1+    spec['frameNote'] = "Coordinates are in the Fusion board's frame (reference board shifted by (%g, %g))." % (-x0, -y1)+    (out / 'spec.json').write_text(json.dumps(spec, indent=1))+    dru = Path(a.kicad).with_suffix('.kicad_dru')+    if dru.is_file(): shutil.copy(dru, out / 'board.kicad_dru')  # the fab's rules travel with the design+    ok('reference %s: %d part placements, %d fixed parts, outline %.2f x %.2f mm, %d spec coordinate(s) moved into the board frame' % (Path(a.kicad).name, len(moves), len(fixed_pos), x1 - x0, y1 - y0, moved),+       ['Wrote %s/moves.json (for place check/land), %s/fixed.json (for fusion schematic) and %s/spec.json (use it for start).' % (out, out, out)])+++# ---------------------------------------------------------------- schematic++def cmd_schematic(a):+    st = load_state()+    if not st.get('libraryPath'): err('no project library yet', ['Run `adom-aiflow fusion library --parts <parts>` first.'])+    nl = json.loads(Path(a.netlist).read_text()); spec = json.loads(Path(a.spec).read_text()) if a.spec else {}+    fx = json.loads(Path(a.fixed).read_text()) if a.fixed else {'outlineMm': a.outline, 'fixed': {}}+    by_ref = {}+    mpn_of = {c['ref']: c.get('mpn') for c in nl.get('components', [])}+    mpn_of.update(st.get('refs') or {})+    for r, m in (json.loads(Path(a.mpns).read_text()) if a.mpns else {}).items(): mpn_of[r] = m+    lib = ET.parse(STATE / 'library' / (st['library'] + '.lbr')).getroot()+    dsets = {d.get('name').upper(): d.get('name') for d in lib.iter('deviceset')}+    for c in nl['components']:+        m = mpn_of.get(c['ref'])+        if not m or m.upper() not in dsets: err('%s: no part %s in the project library' % (c['ref'], m), ['Add it with `adom-aiflow fusion library` (a board-only pad takes {"mpn": "<name>", "footprint": "<file.kicad_mod>"}), or map the reference with --mpns refs.json.'])+        by_ref[c['ref']] = dsets[m.upper()]+    name = a.name+    rules = {'clearance': spec.get('clearance', 0.15), 'edge': spec.get('edgeClearance', 0.3), 'minWidth': 0.1, 'minDrill': 0.15, 'holeToHole': 0.2,+             'padRestringRatio': 0.15, 'padRestring': 0.2, 'viaRestring': 0.1, 'copperUm': spec.get('copperUm', 35)}+    rules.update(spec.get('fusionRules') or {})+    nets = nl.get('nets', {})+    pin_net = {}+    for n, ms in nets.items():+        for m in ms: pin_net[m] = n+    fixed = []+    for r, p in fx['fixed'].items():+        fixed.append({'ref': r, 'deviceset': by_ref[r], 'value': next((n for k, n in pin_net.items() if k.split('.')[0] == r), ''),+                      'x': p['x'], 'y': p['y'], 'rot': ('R%d' % int(p.get('rot', 0))) if p.get('rot') else None,+                      'nets': {k.split('.', 1)[1]: n for k, n in pin_net.items() if k.split('.')[0] == r}})+    bdir = box_dir() + '/design'+    say('starting the design with the fab rules (clearance %.2f mm, edge %.2f mm, 0.15 mm drill) and %d fixed part(s)' % (rules['clearance'], rules['edge'], len(fixed)))+    r = ab('fusion_board_seed', {'libraryPath': st['libraryPath'], 'outDir': bdir, 'name': 'Seed', 'title': nl.get('title', name), 'outlineMm': fx['outlineMm'], 'fixed': fixed, 'rules': rules})+    if not r.get('success'): err('seed: %s' % r.get('error'), [r.get('_hint')])+    ab('fusion_execute_text_command', {'command': 'Commands.Start NewElectronDesignDocumentCommand'})+    for _ in range(20):  # the import starts from an electronics design that has settled+        time.sleep(2)+        st_ = ab('fusion_get_app_state', {}); d_ = st_.get('data') or st_+        if d_.get('productType') == 'EcadDesignProductType' and not d_.get('busyCommand'): break+    time.sleep(3)+    r = ab('fusion_new_electronics_from_eagle', {'schPath': r['schPath'], 'brdPath': r['brdPath']})+    rid = r.get('runId')+    for _ in range(60):+        time.sleep(6)+        s = ab('fusion_eagle_import_status', {'runId': rid})+        if s.get('stage') in ('done', 'failed'): break+    if s.get('stage') != 'done': err('the design import did not finish: %s' % str((s.get('result') or {}).get('error'))[:300], ['Start from a clean Fusion session (save and close half-finished designs) and run this again.'])+    ab('fusion_schematic', {'action': 'show'}); time.sleep(3)+    fixed_refs = set(fx['fixed'])+    parts = [{'ref': c['ref'], 'deviceset': by_ref[c['ref']], 'value': c.get('value'), 'block': c.get('block')} for c in nl['components'] if c['ref'] not in fixed_refs]+    plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}})+    if not plan.get('groups'): err('schematic plan: %s' % plan.get('error'))+    sheet = {}+    for g in plan['groups']:+        say('drawing %s: %s' % (g['group'], ', '.join(g['parts'])))+        r = ab('fusion_schematic_run', {'libraryPath': st['libraryPath'], 'commands': g['commands'], 'pauseMs': a.pause_ms}, timeout=180)+        if not r.get('success'): err('schematic group %s: %s' % (g['group'], r.get('error')), ['Read fusion_check_dialogs; nothing after this group was drawn.'])+        sheet = r.get('nets') or sheet+    # pin by pin against the netlist (pads and pin names both count)+    pad2pin = {}+    for d in lib.iter('deviceset'):+        for dv in d.iter('device'):+            for c in dv.iter('connect'):+                for pad in c.get('pad', '').split(): pad2pin[(d.get('name').upper(), pad)] = c.get('pin')+    want = {n: sorted('%s.%s' % (m.split('.')[0], pad2pin.get((by_ref[m.split('.')[0]].upper(), m.split('.', 1)[1]), m.split('.', 1)[1])) for m in ms) for n, ms in nets.items()}+    got = {n: sorted(v) for n, v in sheet.items()}+    bad = [n for n in want if want[n] != got.get(n)]+    if bad: err('the sheet differs from the netlist on %s' % ', '.join(bad), ['Compare with fusion_schematic_run {"commands":["WINDOW FIT;"]} (its nets) and fix the netlist or the library pins.'])+    ab('fusion_show_2d_board', {}); time.sleep(3)+    bi = ab('fusion_board_info', {}); n = ((bi.get('data') or bi).get('summary') or (bi.get('data') or bi)).get('componentCount')+    if n != len(nl['components']): err('the board has %s of %d parts: schematic/board sync is off' % (n, len(nl['components'])), ['Start from a clean Fusion session (close half-finished imported designs) and run this again.'])+    s = ab('fusion_save_to_cloud', {'name': name, 'projectName': a.project, 'folderPath': a.folder})+    if not s.get('success'): ab('fusion_create_cloud_folder', {'folderName': a.folder, 'projectName': a.project}); s = ab('fusion_save_to_cloud', {'name': name, 'projectName': a.project, 'folderPath': a.folder})+    src = ab('fusion_board_source', {'expectDocument': name})+    if not src.get('source'): err('could not read the board back: %s' % src.get('error'))+    out = Path(a.board_out); out.write_text(src['source'])+    dru = Path(a.fixed).parent / 'board.kicad_dru' if a.fixed else None+    if dru and dru.is_file(): shutil.copy(dru, out.with_suffix('.kicad_dru'))+    st['design'] = name; save_state(st)+    ok('schematic drawn live in %d group(s), every pin on its net; the board followed with %d parts; saved as %s; board written to %s' % (len(plan['groups']), n, name, out),+       ['Next: adom-aiflow start --board %s --spec <spec.json> --target %s --remote-board fusion:%s --engine <you> --prompt-time <paste time>' % (out, TARGET or '<box>', name)])+++# ---------------------------------------------------------------- export++def cmd_export(a):+    st = load_state(); name = a.design or st.get('design')+    if not name: err('which design? pass --design <name>')+    out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)+    bdir = box_dir() + '/manufacturing'+    g = ab('fusion_export_gerbers', {'outputPath': bdir + '/' + name + '-gerbers.zip'}, timeout=600)+    gd = g.get('data') or g+    if not gd.get('zipPath'): err('Gerbers: %s' % g.get('error'))+    ab('fusion_export_cpl', {'outputPath': bdir + '/' + name + '-cpl.csv'})+    b = ab('fusion_board_bom', {'expectDocument': name, 'outputDir': bdir})+    if not b.get('success'): err('BOM: %s' % b.get('error'))+    files = [gd['zipPath'], bdir + '/' + name + '-cpl.csv', bdir + '/bom.csv', bdir + '/bom-jlcpcb.csv']+    r = ab('pull_file', {'filePaths': files, 'saveTo': str(out.resolve())}, timeout=600)+    missing = {k: v for k, v in (b.get('missingPartNumbers') or {}).items() if v}+    ok('manufacturing files in %s: Gerbers (%d files, %d layers), pick and place, BOM (%d lines) and the JLCPCB BOM' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(b.get('lines') or [])),+       ['Parts without a distributor number: ' + '; '.join('%s: %s' % (k, ', '.join(v)) for k, v in missing.items()) if missing else '',+        'Board-only parts (nothing to buy): ' + ', '.join(b.get('boardOnly') or []) if b.get('boardOnly') else ''])+++def cmd_reset(a):+    """Clear the live board's copper for a refine pass: every track and via (ripped up net by net, pours+    kept filled), then every pour. The parts stay where they are; route, gate, land and pour again after."""+    st = load_state(); name = a.design or st.get('design')+    if not name: err('which design? pass --design <name>')+    s0 = ab('fusion_routing_state', {'expectDocument': name})+    if not s0.get('success'): err('could not read the board: %s' % s0.get('error'))+    nets = sorted({w['net'] for w in s0.get('segments', []) if w.get('net')} | {v['net'] for v in s0.get('vias', []) if v.get('net')})+    if nets:+        r = ab('fusion_ripup_nets', {'expectDocument': name, 'boardId': s0['boardId'], 'expectedRevision': s0['revision'], 'nets': nets})+        if not r.get('success'): err('rip-up: %s' % r.get('error'), [r.get('_hint')])+    s1 = ab('fusion_routing_state', {'expectDocument': name})+    r = ab('fusion_remove_pours', {'expectDocument': name, 'boardId': s1['boardId'], 'expectedRevision': s1['revision'], 'pours': 'all'})+    if not r.get('success'): err('pours: %s' % r.get('error'), [r.get('_hint')])+    ok('copper cleared on %s: %d net(s) ripped up, %d pour(s) removed; the parts did not move' % (name, len(nets), r.get('removed') or 0),+       ['Next: adom-aiflow route (it plans on the live board), gate, land route, pour, land vias, land pours, measure, then analyze again.'])+++def main():+    ap = argparse.ArgumentParser(prog='adom-aiflow fusion')+    sub = ap.add_subparsers(dest='cmd', required=True)+    p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json, parts.json [{mpn, page?, footprint?}] or a BOM .csv'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} of numbers already checked')+    p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'fusion'))+    p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--spec'); p.add_argument('--fixed'); p.add_argument('--outline', nargs=2, type=float)+    p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', default='Main'); p.add_argument('--folder', default='Adom AI Flow')+    p.add_argument('--board-out', default='board.brd'); p.add_argument('--pause-ms', type=int, default=60)+    p = sub.add_parser('reset'); p.add_argument('--design')+    p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing'))+    a = ap.parse_args()+    {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'reset': cmd_reset, 'export': cmd_export}[a.cmd](a)+++if __name__ == '__main__':+    main()+

Comments

No comments yet.

Log in to comment.