app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← All Pull Requests
Off-board BOM parts: sourced in the BOM, do-not-place lines in the fab BOM (spec offBoard) #74
Closed opened by John Lauer
Why
A board that ships with a part that is not on it (a LiPo on a lead plugged into a JST header, a case part, a cable) had no correct place in AI Flow:
sourcing check --bomrefused the row: "in the BOM but not an electrical part on the board: BT1". The only ways through were to leave the battery out of the BOM (no vendor, price or stock on record) or a deviation.exportwrites the fab BOM from the board's footprints, andcheck_bomrefuses any extra line. So the order could not list the battery, even as do-not-place.
Change
- Sourcing: a BOM
placementcolumn (also read asfitted,mount,mounting,location) withoff-board(oroffboard,loose,cable,enclosure). The row is still sourced like any other (MPN, source, dated stock check), but it is left out of the board diff. A row marked off-board that is also a footprint on the board is an error. The outcome listsoffBoard. - Export: spec
offBoard: [{"ref", "qty", "value", "mpn", "manufacturer", "connectsTo"}]. Each part is appended to the fab BOM as a do-not-place line (DNP =off-board (not assembled), footprintoff-board, plugs into J2).check_bom_withaccepts these refs only as DNP lines and needs each one exactly once.write_bom,check_bomandexportkeep their signatures (they call the_withvariants with no off-board parts), so nothing else changes.
Tested
- Base: adom-aiflow 0.2.57 as published.
cargo test --release -p adom-aiflow: 400 passed. Two new tests cover the sourcing diff (off-board row passes, an unmarked one is extra, an off-board row that is on the board errors, and a missing stock check still errors) and the BOM round trip (written and accepted, fitted refused, missing refused, and refused without the spec).- The Ghost Hand board (Adafruit 1578 LiPo as BT1 on J2) passes
sourcing checkwithoffBoard: ["BT1"].exportwrites the BT1 do-not-place line and accepts it.
🤖 Generated with Claude Code
Diff Skip to comments (1)
@@ -1,622 +1,685 @@⋯ 132 unchanged lines ⋯ /// The BOM from the board's own footprints: one line per value, footprint, MPN and DNP state; /// `exclude_from_bom` footprints are left out, DNP parts stay as DNP lines.-pub fn write_bom(inv: &Inventory) -> String {+pub fn write_bom(inv: &Inventory) -> String { write_bom_with(inv, &[]) }++/// A part that ships with the board but is not on it (a battery on a cable, a case part): spec+/// `offBoard`, `[{"ref": "BT1", "value": "...", "mpn": "...", "manufacturer": "...", "connectsTo": "J2"}]`.+/// It goes in the fab BOM as a do-not-place line, so the order lists it and the fab never fits it.+#[derive(Debug, Clone, Default, PartialEq)]+pub struct OffBoard { pub reference: String, pub qty: usize, pub value: String, pub mpn: String, pub manufacturer: String, pub connects_to: String }++/// The spec's `offBoard` list (missing or malformed entries are left out; a reference is required).+pub fn off_board(spec: &Value) -> Vec<OffBoard> {+ spec["offBoard"].as_array().into_iter().flatten().filter_map(|x| {+ let reference = x["ref"].as_str().or(x["reference"].as_str())?.trim().to_string();+ if reference.is_empty() { return None; }+ let s = |k: &str| x[k].as_str().unwrap_or("").trim().to_string();+ Some(OffBoard { reference, qty: x["qty"].as_u64().unwrap_or(1).max(1) as usize, value: s("value"), mpn: s("mpn"), manufacturer: s("manufacturer"), connects_to: s("connectsTo") })+ }).collect()+}++/// The DNP column of an off-board line: never empty, so a fab reads it as do not place.+pub const OFF_BOARD_DNP: &str = "off-board (not assembled)";++/// write_bom, plus one do-not-place line per off-board part after the board's own lines.+pub fn write_bom_with(inv: &Inventory, off: &[OffBoard]) -> String { let mut groups: BTreeMap<(String, String, String, String, bool), Vec<String>> = BTreeMap::new(); for (r, p) in inv.parts.iter().filter(|(_, p)| p.in_bom) { groups.entry((p.value.clone(), p.footprint.clone(), p.manufacturer.clone(), p.mpn.clone(), p.dnp)).or_default().push(r.clone());⋯ 6 unchanged lines ⋯ lines.sort_by_key(|(refs, _)| natural(&refs[0])); let mut out = String::from("Reference,Qty,Value,Footprint,Manufacturer,MPN,DNP\n"); for (_, l) in lines { out.push_str(&l); out.push('\n'); }+ for o in off.iter().filter(|o| !inv.parts.contains_key(&o.reference)) {+ let fp = if o.connects_to.is_empty() { "off-board".to_string() } else { format!("off-board, plugs into {}", o.connects_to) };+ let line = [o.reference.clone(), o.qty.to_string(), o.value.clone(), fp, o.manufacturer.clone(), o.mpn.clone(), OFF_BOARD_DNP.to_string()].iter().map(|s| csv_field(s)).collect::<Vec<_>>().join(",");+ out.push_str(&line); out.push('\n');+ } out } ⋯ 13 unchanged lines ⋯ } /// Re-read the BOM against the board: every BOM footprint exactly once, its value and DNP state as placed.-pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) {+pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) { check_bom_with(text, inv, &[]) }++/// check_bom, where the spec's off-board parts may appear as do-not-place lines (and must, once each).+pub fn check_bom_with(text: &str, inv: &Inventory, off: &[OffBoard]) -> (Value, Vec<String>) {+ let off_refs: BTreeSet<String> = off.iter().filter(|o| !inv.parts.contains_key(&o.reference)).map(|o| o.reference.clone()).collect();+ let mut off_seen = BTreeSet::new(); let rows = read_csv(text); let mut problems = Vec::new(); let Some(h) = rows.first() else { return (json!({"lines": 0}), vec!["BOM is empty".into()]) };⋯ 10 unchanged lines ⋯ } let is_dnp = dc.and_then(|i| row.get(i)).map(|s| !s.trim().is_empty()).unwrap_or(false); for r in refs {+ if off_refs.contains(&r) {+ if !is_dnp { problems.push(format!("BOM fits {r}, which the spec lists as off-board: mark it do not place ({OFF_BOARD_DNP})")); }+ if !off_seen.insert(r.clone()) { dup.push(r); }+ continue;+ } if let Some(p) = inv.parts.get(&r) { if let Some(v) = vc.and_then(|i| row.get(i)) { if *v != p.value { problems.push(format!("BOM gives {r} the value {v}, the board has {}", p.value)); } } if is_dnp != p.dnp { problems.push(format!("BOM marks {r} {}, the board {}", if is_dnp { "DNP" } else { "fitted" }, if p.dnp { "does not fit it (DNP)" } else { "fits it" })); }⋯ 7 unchanged lines ⋯ if !missing.is_empty() { problems.push(format!("BOM is missing {}", list(&missing))); } if !extra.is_empty() { problems.push(format!("BOM lists parts the board does not put in a BOM: {}", list(&extra))); } if !dup.is_empty() { problems.push(format!("BOM lists more than once: {}", list(&dup))); }- (json!({"lines": rows.len() - 1, "refs": seen.len(), "fitted": placed, "dnp": dnp}), problems)+ let off_missing: Vec<String> = off_refs.difference(&off_seen).cloned().collect();+ if !off_missing.is_empty() { problems.push(format!("BOM is missing the off-board part(s) the spec lists: {}", list(&off_missing))); }+ (json!({"lines": rows.len() - 1, "refs": seen.len(), "fitted": placed, "dnp": dnp, "offBoard": off_seen.iter().collect::<Vec<_>>()}), problems) } /// Re-read the pick and place: exactly the fitted, placeable parts, each on its own side, the top-side⋯ 167 unchanged lines ⋯ } /// Write the set into `out` and check it. Ok: the proof (what was checked); Err: the report and the problems.-pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value, Vec<String>)> {+pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value, Vec<String>)> { export_with(board_path, out, cli, &[]) }++/// export, with the spec's off-board parts in the BOM as do-not-place lines.+pub fn export_with(board_path: &str, out: &Path, cli: &str, off: &[OffBoard]) -> Result<Value, (Value, Vec<String>)> { let fail = |m: String| (json!({"board": board_path}), vec![m]); // kicad-cli gets absolute outputs (#72): a relative run folder means nothing on a desktop's KiCad let out_abs = std::path::absolute(out).unwrap_or_else(|_| out.to_path_buf());⋯ 17 unchanged lines ⋯ let _ = std::fs::remove_file(&cpl); kicad(cli, &[s(&["pcb", "export", "pos", "--output", &cpl.display().to_string(), "--format", "csv", "--units", "mm", "--side", "both", "--exclude-dnp"]), vec![board_path.to_string()]].concat()).map_err(fail)?; let bom = out.join(format!("{stem}-bom.csv"));- std::fs::write(&bom, write_bom(&inv)).map_err(|e| fail(format!("{}: {e}", bom.display())))?;+ std::fs::write(&bom, write_bom_with(&inv, off)).map_err(|e| fail(format!("{}: {e}", bom.display())))?; // read every file back, as the fab will let gerbers = read_dir(&gdir, &["gbr"]);⋯ 16 unchanged lines ⋯ problems.extend(p2); notes.extend(n2); let (c, p3) = match std::fs::read_to_string(&cpl) { Ok(t) => check_cpl(&t, &inv), Err(e) => (Value::Null, vec![format!("pick and place not written ({}: {e})", cpl.display())]) }; problems.extend(p3);- let (b, p4) = check_bom(&std::fs::read_to_string(&bom).unwrap_or_default(), &inv);+ let (b, p4) = check_bom_with(&std::fs::read_to_string(&bom).unwrap_or_default(), &inv, off); problems.extend(p4); notes.extend(fab_role_notes(&inv)); let no_mpn: Vec<String> = inv.parts.iter().filter(|(_, p)| p.in_bom && !p.dnp && p.mpn.is_empty()).map(|(r, _)| r.clone()).collect();⋯ 3 unchanged lines ⋯ let report = json!({ "pass": problems.is_empty(), "dir": out.display().to_string(), "board": board_path, "boardSha256": crate::sha256_file(board_path), "kicadCli": cli,- "gerbers": g, "drill": d, "cpl": c, "bom": b, "fillsSaved": saved_fill, "fillsGerbers": gerber_fill, "fillsCurrent": fills_current, "bomSource": "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)",+ "gerbers": g, "drill": d, "cpl": c, "bom": b, "fillsSaved": saved_fill, "fillsGerbers": gerber_fill, "fillsCurrent": fills_current, "bomSource": if off.is_empty() { "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)".to_string() } else { format!("the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes), plus {} off-board part(s) from the spec as do-not-place lines", off.len()) }, "files": files, "notes": notes, "problems": problems, }); if problems.is_empty() { Ok(report) } else { Err((report, problems)) }⋯ 103 unchanged lines ⋯ } #[test]+ fn off_board_parts_go_in_the_bom_as_do_not_place_lines() {+ let i = inv();+ let spec = json!({"offBoard": [{"ref": "BT1", "value": "500mAh 1S LiPo", "mpn": "1578", "manufacturer": "Adafruit", "connectsTo": "J2"}, {"value": "no reference"}]});+ let off = off_board(&spec);+ assert_eq!(off.len(), 1);+ let text = write_bom_with(&i, &off);+ assert!(text.contains("BT1,1,500mAh 1S LiPo,\"off-board, plugs into J2\",Adafruit,1578,off-board (not assembled)"), "{text}");+ let (b, p) = check_bom_with(&text, &i, &off);+ assert!(p.is_empty(), "{p:?}\n{text}");+ assert_eq!(b["offBoard"][0], "BT1");+ // fitted, missing, or not in the spec+ let p = check_bom_with(&text.replace(",off-board (not assembled)", ","), &i, &off).1.join("; ");+ assert!(p.contains("BOM fits BT1, which the spec lists as off-board"), "{p}");+ let p = check_bom_with(&write_bom(&i), &i, &off).1.join("; ");+ assert!(p.contains("missing the off-board part(s) the spec lists: BT1"), "{p}");+ let p = check_bom(&text, &i).1.join("; ");+ assert!(p.contains("does not put in a BOM: BT1"), "{p}");+ }++ #[test] fn stale_saved_fills_are_found_in_a_drc_without_refill() { // issue #160: the saved GND fill overlaps a track added after it; DRC with refill was clean let drc = json!({"violations": [⋯ 78 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&dir); } }+@@ -1,978 +1,1009 @@⋯ 109 unchanged lines ⋯ let c_stock_dated = header.iter().position(|h| h.to_ascii_lowercase().contains("stock") && date_in(h).is_some()); let c_stock = c_stock_dated.or_else(|| col(header, &["stock", "stock_qty", "in_stock", "stock_checked"])); let c_stock_date = col(header, &["stock_date", "stock_checked_on", "checked", "checked_on", "stock_checked_date"]);+ // a part that ships with the board but is not on it (a battery on a cable, a case part): placement `off-board`.+ // It is sourced like any row (MPN, source, dated stock) and is left out of the board diff.+ let c_place = col(header, &["placement", "fitted", "mount", "mounting", "location"]); let header_date = c_stock_dated.and_then(|i| date_in(&header[i])); for (name, c) in [("ref", c_ref), ("mpn", c_mpn), ("source", c_src)] { if c.is_none() { errors.push(format!("the BOM has no {name} column (header: {})", header.join(","))); }⋯ 11 unchanged lines ⋯ // that fab prefers. Anything else is a consigned (customer-supplied) part the user sends the fab, // which every fab accepts. Never an error. let mut consigned: Vec<Value> = Vec::new();+ let mut off_board: BTreeSet<String> = BTreeSet::new(); for (i, row) in rows.iter().enumerate().skip(1) { let line = i + 1; let refs: Vec<String> = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();⋯ 2 unchanged lines ⋯ for rf in &refs { if bom_refs.insert(rf.clone(), line).is_some() { errors.push(format!("{rf}: listed twice in the BOM")); } }+ if is_off_board(&get(row, c_place)) { off_board.extend(refs.iter().cloned()); } let mpn = get(row, c_mpn); let src = get(row, c_src); let src_l = src.to_ascii_lowercase();⋯ 48 unchanged lines ⋯ let (mut missing, mut extra) = (Vec::new(), Vec::new()); if let Some((need, excluded)) = board { for rf in need { if !bom_refs.contains_key(rf) { missing.push(rf.clone()); } }- for rf in bom_refs.keys() { if !need.contains(rf) && !excluded.contains(rf) { extra.push(rf.clone()); } }+ for rf in bom_refs.keys() { if !need.contains(rf) && !excluded.contains(rf) && !off_board.contains(rf) { extra.push(rf.clone()); } }+ let on_board: Vec<String> = off_board.iter().filter(|rf| need.contains(*rf) || excluded.contains(*rf)).cloned().collect();+ if !on_board.is_empty() { errors.push(format!("marked off-board in the BOM but a footprint on the board: {}", on_board.join(", "))); } if !missing.is_empty() { errors.push(format!("on the board but not in the BOM: {}", missing.join(", "))); } if !extra.is_empty() { errors.push(format!("in the BOM but not an electrical part on the board: {}", extra.join(", "))); } }⋯ 2 unchanged lines ⋯ "noPartRows": no_part_rows, "adomStockRows": adom_rows, "distributorRows": distributor_rows, "lcscRows": lcsc_rows, "stockCheckDates": dates, "boardChecked": board.is_some(), "boardRefs": board.map(|(n, _)| n.len()), "excludedByBoard": board.map(|(_, x)| x.iter().cloned().collect::<Vec<_>>()),- "missing": missing, "extra": extra, "errors": errors, "warnings": warnings,+ "missing": missing, "extra": extra, "offBoard": off_board, "errors": errors, "warnings": warnings, "consignedRows": {"count": consigned.len(), "list": consigned, "warning": consigned_warning}, }) } +/// A BOM placement value that says the part ships with the board but is not on it.+fn is_off_board(v: &str) -> bool {+ let v = v.trim().to_ascii_lowercase().replace(['_', ' '], "-");+ matches!(v.as_str(), "off-board" | "offboard" | "off-pcb" | "loose" | "cable" | "enclosure")+}+ /// The source a profile prefers, by name: where the part suggestions look. pub fn preferred_source(profile: &str) -> &'static str { if profile == "jlcpcb" { "LCSC" } else { "Mouser" }⋯ 387 unchanged lines ⋯ } #[test]+ fn off_board_rows_are_sourced_but_left_out_of_the_board_diff() {+ let bom = "ref,mpn,source,vendor_pn,stock_checked_2026-11-07,placement\nU1,TPS,Mouser,595-TPS,5000,\nBT1,1578,Mouser,485-1578,435,off-board\n";+ let out = sourcing_check(bom, Some(&refs(&["U1"], &[])), "fab", 10);+ assert_eq!(out["pass"], true, "{out}");+ assert_eq!(out["offBoard"][0], "BT1");+ // without the placement it is an extra reference+ let out = sourcing_check(&bom.replace(",off-board", ","), Some(&refs(&["U1"], &[])), "fab", 10);+ assert!(out["errors"].to_string().contains("not an electrical part on the board: BT1"), "{out}");+ // marked off-board but on the board+ let out = sourcing_check(bom, Some(&refs(&["U1", "BT1"], &[])), "fab", 10);+ assert!(out["errors"].to_string().contains("marked off-board in the BOM but a footprint on the board: BT1"), "{out}");+ // an off-board row still needs its stock check+ let out = sourcing_check(&bom.replace("485-1578,435", "485-1578,"), Some(&refs(&["U1"], &[])), "fab", 10);+ assert!(out["errors"].to_string().contains("BT1 (1578): no stock check recorded"), "{out}");+ }++ #[test] fn fab_profile_passes_mouser_and_adom_and_diffs_the_board() { let bom = format!("{HEAD}U1,x,TPS54202DDCR,TI,Mouser,595-TPS54202DDCR,160 (lead 140 d),\n\"R1 R2\",10k,CR0402,Bourns,Adom stock,,,\n"); let out = sourcing_check(&bom, Some(&refs(&["U1", "R1", "R2"], &["J1"])), "fab", 10);⋯ 374 unchanged lines ⋯ let _ = std::fs::remove_file(&f); } }+@@ -1,7074 +1,7076 @@⋯ 3312 unchanged lines ⋯ let board_path = board.clone().unwrap_or_else(|| r.current_board()); let out_dir = out.clone().unwrap_or_else(|| dir.join("manufacturing")); let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- match export::export(&board_path, &out_dir, &kicad) {- Ok(report) => {- write_json(&out_dir.join("export-check.json"), &report);- r.outcome("export", report.clone());- r.save().unwrap();- let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect();- hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string));- hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());- if let Some(h) = molecule_publish_hint(&r) { hints.push(h); }- // issue #160: the saved fills are older than the copper: a finding, the files are written- if report["fillsCurrent"]["pass"] == false {- let fc = &report["fillsCurrent"];- let list: Vec<String> = fc["list"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect();- hints.insert(0, export::STALE_FILLS_HINT.to_string());- findings(&format!("manufacturing files written to {}, but the saved zone fills are stale: DRC of the saved board without refilling finds {} violation(s) involving zones ({}), and the Gerbers plot those fills\n {}", out_dir.display(), fc["zoneViolations"], fc["zoneViolationTypes"].as_object().map(|o| o.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ")).unwrap_or_default(), list.join("\n ")), &hints);- }- let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));- let checked = verified::run_items(&r, facts.as_ref());- ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}, pours present as regions on {} poured layer(s)), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)\n{}", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["fillsGerbers"]["layersWithPours"].as_object().map(|o| o.len()).unwrap_or(0), report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"], verified::message(&checked)), &hints);- }- Err((report, problems)) => {- let _ = std::fs::create_dir_all(&out_dir);- write_json(&out_dir.join("export-check.json"), &report);- r.log("export-refused", json!({"board": board_path, "dir": out_dir, "problems": problems}));- r.save().unwrap();- let fill_hint = if problems.iter().any(|p| p.contains("filled polygon") || p.contains("G36")) { fills::HINT.to_string() } else { String::new() };- err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &[fill_hint, "Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".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() { findings(&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, page_dir } => {- if let Some(d) = page_dir { if let Err(e) = pagedir::validate(d) { err(&e, &[]); } }- 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();- // the plan board: rendered by the backend --remote-board names (a Fusion export, an Altium- // read), or the KiCad board copied as it is (eda.rs import_board)- if let Some(text) = aiflow_bridge::eda::import_board(remote_board.as_deref(), &src_text).unwrap_or_else(|e| err(&e, &[])) {- std::fs::write(©, 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::eda::set_run_board(remote_board.as_deref().unwrap_or(""));- } else {- std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));- aiflow_copper::copy_project_rules(board, ©.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(©.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 Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }- if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }- if r.data["target"].is_string() { screen_mode(&mut r); }- 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();- let mut attach_hints = vec![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()];- // `rules use` in the intake run recorded fabRules before there was a spec: the gate- // falls back to it when the spec names no fab.rules, and says when they disagree- if let Some((p, layers, warning)) = checks::rules_target(&read_json(spec), &r.data["fabRules"]) {- if let Some(w) = warning { attach_hints.push(format!("Fab rules: {w}.")); }- if let Err((e, hint)) = checks::rules_gate(&p, ©.display().to_string(), layers) { attach_hints.push(format!("Fab rules: {e}. {hint}")); }- }- let rc_flags = rules_card::at_start(&dir, &read_json(spec), ©, &r.data["fabRules"]);- for f in &rc_flags { eprintln!("WARNING: {f}"); }- attach_hints.push(rules_card_hint(&rc_flags));- attach_hints.extend(converter_warning(&r.data, &read_json(spec), None, Board::load(©.display().to_string()).ok().as_ref()));- 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(", ") }), &attach_hints);- print_next(&cli);- return;- }- let pt = prompt_time.clone().unwrap_or_else(now);- let mut r = Run::create(&dir, ©.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 Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }- let hands_line = handson::apply_default(&mut r.data, &now());- if target.is_some() { screen_mode(&mut r); }- 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, layers, _)) = checks::rules_target(&spec_v, &Value::Null) { if let Err((e, hint)) = checks::rules_gate(&p, board, layers) { start_hints.push(format!("Fab rules: {e}. {hint}")); } }- let rc_flags = rules_card::at_start(&dir, &spec_v, ©, &r.data["fabRules"]);- for f in &rc_flags { eprintln!("WARNING: {f}"); }- start_hints.push(rules_card_hint(&rc_flags));- start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(©.display().to_string()).ok().as_ref()));- 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()]);- start_hints.extend(hands_line);- ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);- print_next(&cli);- }- Cmd::Intake { prompt_time, brief, requirements, engine, target, page, page_dir, eda, kind } => {- 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 mut probe = json!({}); if let Err(e) = pagedir::set(&mut probe, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); } }- if kind.is_some() && eda.as_deref().is_some_and(|e| e != "kicad") { err("--kind pattern is a KiCad lane", &["Build the pattern board in KiCad (--eda kicad, or leave --eda out).".into()]); }- 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 Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }- // the EDA tool, named: the step sequence is fixed at the first step after intake, before any- // backend project exists, so a brief that never says "Altium" must not fall into kicad-new- if let Some(e) = eda { r.data["eda"] = json!(e); }- // a passive copper-pattern board: the pattern sequence (boardkind.rs)- if let Some(k) = kind { r.data["kind"] = json!(k); }- let hands_line = handson::apply_default(&mut r.data, &now()).unwrap_or_else(|| "Hands-on AI Flow: when the human asks for it (\"hands-on\", \"check in with me after placement\", \"I'll route it myself\"), run `handson plan` now, before any design work: they choose per step on the timeline, and the choice is saved as their flow profile for the next board (skill aiflow-hands-on).".into());- if target.is_some() { screen_mode(&mut r); }- 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 before the board films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else your own terminal window (nothing meaningful on screen is a recorded skip): work in a visible window.".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() },- "Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it.".into(),- hands_line,- ]);- print_next(&cli);- }- 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);- design_evidence::exact_mpn_evidence(&text, design_evidence::require_exact(&spec, &req), &mut out);- 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);- let (suggestions, suggestion_note, lookup_counts) = part_suggestions(&r.dir, &prof, &out["consignedRows"]["list"]);- out["suggestions"] = json!(suggestions);- if !lookup_counts.is_null() { out["suggestionLookups"] = lookup_counts; }- if let Some(n) = &suggestion_note { out["suggestionsNote"] = json!(n); }- r.outcome("sourcing", out.clone());- r.save().unwrap();- let consigned_warning = out["consignedRows"]["warning"].as_str().unwrap_or("").to_string();- let mut warnings: Vec<String> = Vec::new();- for w in out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str()) {- warnings.push(format!("warning: {w}"));- if !consigned_warning.is_empty() && w == consigned_warning {- for sg in &suggestions { if let Some(t) = sg["suggestion"].as_str() { warnings.push(format!(" suggestion: {t}")); } }- if let Some(n) = &suggestion_note { warnings.push(format!(" {n}")); }- }- }- let rule = if prof == "jlcpcb" { "jlcpcb prefers JLCPCB/LCSC parts with their LCSC number (basic parts first); any other source is fine as consigned parts you send JLCPCB" } else { "fab (the 3rd party fab) prefers Mouser or Adom stock; any other distributor (DigiKey, Arrow, ...) is fine as consigned parts you send the 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);- findings(&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();- findings(&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 review: Vec<String> = match (kind.as_str(), serde_json::from_str::<Value>(&text)) { ("calcs", Ok(v)) => design_evidence::calcs_warnings(&v), ("simulation", Ok(v)) => design_evidence::simulation_warnings(&requirements_of(&r), &v), _ => Vec::new() };- 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, "warnings": review});- 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 { "" }),- };- let mut hints = vec![hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()];- hints.extend(review.iter().map(|w| format!("warning: {w}")));- // issue #75: the calcs say what the converter is; a spec that exists already must carry Fields' block- if kind == "calcs" {- let spec = spec_soft(&r);- if !spec.is_null() { hints.extend(converter_warning(&r.data, &spec, None, Board::load(&r.current_board()).ok().as_ref())); }- }- ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);- }- Cmd::Clips { page, page_dir, push } => {- thread(&cli);- let mut r = load_run(&cli);- if page.is_some() || page_dir.is_some() {- // --page-dir alone sets the folder of the page the clips already go to- if page.is_none() && r.data["clipsPage"].is_null() { if let Some(p) = r.data["page"].as_str().map(str::to_string) { r.data["clipsPage"] = json!(p); r.data["clipsPageDir"] = r.data["pageDir"].clone(); } }- if let Err(e) = pagedir::set(&mut r.data, "clipsPage", "clipsPageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }- r.save().unwrap();- }- let (pg, pd) = 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, pd.as_deref(), *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::BindRemoteBoard { remote_board } => {- thread(&cli);let mut r=load_run(&cli);let br=bridge_of(&r,&cli);- let proof=checked_remote_binding(&r,&br,remote_board,&dir).unwrap_or_else(|e|err(&e,&["Transfer the current adopted board and unchanged project/rule sidecars to the intended desktop path, then bind again. This never edits the desktop file or accepts a gate.".into()]));- let old=r.data["remoteBoard"].clone();r.data["remoteBoard"]=proof["remoteBoard"].clone();r.data["remoteBinding"]=proof.clone();r.log("bind-remote-board",json!({"previousRemoteBoard":old,"proof":proof}));r.save().unwrap();- ok("native board and project rules match the adopted snapshot; desktop binding updated",&["Run models and verify the native 2D/3D views. Binding is not engineering acceptance.".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=run_drc_for(&r, &r.current_board()).unwrap_or_else(|e|err(&e,&[]));- let candidate=run_drc_for(&r, board).unwrap_or_else(|e|err(&e,&[]));- let summary=aiflow_copper::summarize_with(&candidate,Some(&base),silk_policy::blocking(&spec_soft(&r),&r.current_board()));- 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,&[]));- let (carried, carry_note) = copper_carry::adopt(&mut r, &dest.display().to_string());- r.data["remoteBinding"]=Value::Null;- r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary,"copperCarried":carried}));- r.save().unwrap();- if carried { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["Silkscreen changed: record the silkscreen proof again (`silkscreen-audit --record`) and export again; finish reruns the full native DRC. Bind the desktop board again (bind-remote-board) before live commands.".into()]); }- else { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["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::Parts { bom, show, dwell, project_lib } => {- let t = thread(&cli);- let mut r = load_run(&cli);- let bom = bom.clone().or_else(|| r.data["outcomes"]["sourcing"]["bom"].as_str().map(str::to_string))- .unwrap_or_else(|| err("no BOM: pass --bom <csv> or pass `sourcing check --bom <csv>` first", &[]));- let text = std::fs::read_to_string(&bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));- let mut rows = preboard::bom_parts(&text);- if rows.is_empty() { err(&format!("{bom}: no rows with an MPN"), &[]); }- let mut missing = Vec::new();- for row in rows.iter_mut() {- let mpn = row["mpn"].as_str().unwrap_or("").to_string();- // the MPN, then the wiki's slug form of it (AP2112K-3.3TRG1 lives at ap2112k-3-3trg1, issue #76)- let mut page = None;- let mut queries = vec![mpn.clone()];- if let Some(w) = preboard::part_slug_candidates(&mpn).into_iter().skip(1).next() { queries.push(w); }- // MCP73831T-2ACI/OT's factory page is adom/mcp73831t-2aci (issue #161): the MPN without its "/" suffix last- if let Some(b) = preboard::mpn_base(&mpn) { queries.push(b); }- for q in &queries {- let o = std::process::Command::new("adom-wiki").args(["search", q, "--json"]).stdin(std::process::Stdio::null()).output().ok();- let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);- let hits = v["data"]["results"].as_array().or(v["data"].as_array()).or(v["results"].as_array()).cloned().unwrap_or_default();- page = preboard::part_page(&hits, &mpn);- if page.is_some() { break; }- }- if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }- if let Some(how) = page.as_deref().and_then(|p| preboard::matched_by_base(p, &mpn)) { row["wikiMatch"] = json!(how); }- row["wiki"] = json!(page);- }- // parts with no public page may come from the board's own project library (issue #66)- let plib = project_lib.clone().or_else(|| spec_soft(&r)["projectLib"].as_str().or(requirements_of(&r)["projectLib"].as_str()).map(str::to_string));- let mut why_missing: Vec<(String, String)> = Vec::new();- let mut source_notes: Vec<String> = rows.iter().filter_map(|x| Some(format!("{}: {}", x["mpn"].as_str()?, x["wikiMatch"].as_str()?))).collect();- if let Some(d) = &plib {- let lib = project_lib::ProjectLib::load(Path::new(d)).unwrap_or_else(|e| err(&e, &["A project library is a folder with *.kicad_sym, *.pretty/*.kicad_mod, STEP models and provenance.json (see the aiflow-sourcing skill).".into()]));- // a page and a library entry for one MPN: "prefer": true lets the library's verified entry win (issue #157)- source_notes.extend(project_lib::prefer(&mut rows, &lib));- why_missing = project_lib::resolve(&mut rows, &lib);- missing.retain(|m| why_missing.iter().any(|(w, _)| w == m));- }- let from_lib: Vec<String> = rows.iter().filter(|x| x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect();- let pass = missing.is_empty();- r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing, "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>(), "sourceNotes": source_notes}));- r.save().unwrap();- if *show {- // one named tab walks through the pages (never a tab per part)- // ASCII only: Hydrogen's open-or-refresh never matches a name with a non-ASCII character- // and opens a new tab per call (adom/hydrogen#137)- let name = format!("Parts - {}", &r.data["id"].as_str().unwrap_or("run")[..8.min(r.data["id"].as_str().unwrap_or("run").len())]);- for row in rows.iter().filter(|x| x["wiki"].is_string()) {- let url = format!("https://wiki.adom.inc/{}", row["wiki"].as_str().unwrap());- let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url, "--ai-thread", &t, "--reason", "adom-aiflow: show the chosen parts"]).stdin(std::process::Stdio::null()).output();- std::thread::sleep(std::time::Duration::from_secs(*dwell));- }- }- let list: Vec<String> = rows.iter().map(|x| format!(" {:<22} {:<10} {}", x["mpn"].as_str().unwrap_or(""), x["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default(), x["wiki"].as_str().map(str::to_string).or_else(|| x["projectLib"].is_object().then(|| x["wikiOverridden"].as_str().map(|w| format!("project library (preferred over {w})")).unwrap_or_else(|| "project library".to_string()))).unwrap_or_else(|| "MISSING".into()))).collect();- let list: Vec<String> = if source_notes.is_empty() { list } else { list.into_iter().chain(std::iter::once(" which source each part comes from:".to_string())).chain(source_notes.iter().map(|n| format!(" {n}"))).collect() };- if !pass {- let gaps: Vec<String> = why_missing.iter().map(|(m, w)| format!(" {m}: project library: {w}")).collect();- // AI Flow never refuses (0.2.48): the parts record is saved; the missing parts are findings with the ways on- findings(&format!("{} of {} parts have no Adom wiki component page{} yet:\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.join("\n")) }), &["Make each missing part's page (the wiki-component skill: symbol, footprint and STEP from the datasheet), then run `parts` again. The project page and the KiCad library are built from these pages.".into(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);- }- ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);- }- Cmd::ProjectPage { page, results, out, sync, create, existing, private, title, brief, replace_readme, no_push, local_only, page_dir } => {- let t = thread(&cli);- let no_push = &(*no_push || *local_only);- let mut r = load_run(&cli);- if *results { page_results_cmd(&mut r, &dir, page, out.as_deref(), page_dir.as_deref()); return; }- if page.is_empty() { err("project-page needs --page <owner>/<slug> (only --results finds the run's page by itself)", &[]); }- // a folder of an existing page (issue #85): implies --existing, never creates a page- let page_dir_arg = page_dir.clone();- let page_dir: Option<String> = page_dir.as_deref().map(pagedir::validate).transpose().unwrap_or_else(|e| err(&e, &[])).flatten();- if page_dir.is_some() && *create { err("--page-dir writes into a folder of an existing page and never creates one: drop --create (or drop --page-dir for a new page)", &[]); }- let existing = &(*existing || page_dir.is_some());- if *sync {- if let Err(e) = pagedir::set(&mut r.data, "projectPage", "projectPageDir", Some(page.as_str()), page_dir_arg.as_deref()) { err(&e, &[]); }- r.save().unwrap();- let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-steps.py")).arg(&dir).arg(page).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));- let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).to_string()}));- if v["ok"] != true { err(&format!("step pages: {}", v["error"].as_str().unwrap_or("failed")), &[]); }- r.data["projectPage"] = json!(page); r.save().unwrap();- ok(&format!("{} step page(s) and the README's build section pushed to {page} (commit {})", v["steps"].as_array().map(|a| a.len()).unwrap_or(0), v["commit"].as_str().unwrap_or("?")), &[]);- return;- }- let parts = r.data["outcomes"]["parts"].clone();- if parts["pass"] != true { note("the parts are not all on the wiki yet: the page lists what is there; run `parts` until it passes to add the rest", &[]); }- if !page.contains('/') { err("--page is <owner>/<slug>", &[]); }- let bom_path = parts["bom"].as_str().unwrap_or("").to_string();- let stale = project_lib::stale(&parts, &bom_path, std::fs::read_to_string(&bom_path).ok().as_deref());- if !stale.is_empty() { note(&format!("the parts record is stale: {}", stale.join("; ")), &[format!("Run `parts` again (`parts --bom <current BOM>{}`), then `project-page` again.", parts["projectLib"].as_str().map(|d| format!(" --project-lib {d}")).unwrap_or_default())]); }- let (spec, req) = (spec_soft(&r), requirements_of(&r));- let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::label(&dir, &r.data));- let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());- let lane = sequence::lane(&dir, &r.data);- let cfg = json!({"run": dir.display().to_string(), "eda": lane, "page": page, "pageDir": page_dir, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "localOnly": local_only, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",- // issue #137: a molecule's page is kind molecule; molecule-publish puts its sources there- "molecule": molecule_pins::wants_molecule(&spec, &req), // issue #158: or requirements formFactor.kind- "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())});- let cfgp = dir.join("project-page").join("config.json");- let _ = std::fs::create_dir_all(cfgp.parent().unwrap());- std::fs::write(&cfgp, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();- let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-page.py")).arg(&cfgp).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));- let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| json!({"ok": false, "error": aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 600)}));- if v["ok"] != true {- if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); r.save().unwrap(); }- err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &["Nothing was published. Rate-limited adds are retried with backoff; rerun `project-page` after the wait and it reuses the same Cloud Library (never a second one).".into()]);- }- r.outcome("project-page", v.clone());- r.data["projectPage"] = json!(page);- r.data["projectPageDir"] = json!(page_dir);- if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); }- r.save().unwrap();- if !no_push {- // a webview tab has no wiki session: a private page would show "Page not found", so open the page- // through a single-use browser-session link (adom-wiki auth login; never printed)- // (every time, not only with --private: an --existing page may be private too)- let url = signed_in_wiki_url(page).unwrap_or_else(|| v["url"].as_str().unwrap_or("").to_string());- let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "Project page", "--url", &url, "--ai-thread", &t, "--reason", "adom-aiflow: the board's project page, revision 1"]).stdin(std::process::Stdio::null()).output();- }- // --no-push builds everything locally: never claim the page was created or updated (issue #82)- let page_said = if *local_only { format!("project page built locally in {} (--local-only: no wiki call; {} was not created or updated)", dir.join("project-page").display(), v["url"].as_str().unwrap_or(page)) }- else if *no_push { format!("project page built locally in {} (--no-push: nothing pushed to {}; it was not created or updated{})", dir.join("project-page").display(), v["url"].as_str().unwrap_or(page), v["cloudLibrary"].as_str().map(|l| format!("; the wiki parts were added to your private Cloud Library {l} to export their files, `--local-only` skips that")).unwrap_or_default()) }- else { format!("project page {} ({})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }) };- if lane == "altium" {- let from_lib: Vec<&str> = v["projectLibParts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();- ok(&format!("{}: README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages{})", page_said, v["parts"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }),- &[if from_lib.is_empty() { String::new() } else { format!("from the board's project library (no public page yet): {}", from_lib.join(", ")) },- if v["kind"] == "molecule" { MOLECULE_PAGE_HINT.into() } else { String::new() },- "Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages (project-library parts from their own KiCad files, through the same converters). Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]);- return;- }- let c = &v["counts"];- let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();- if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }- if v["kind"] == "molecule" { hints.push(MOLECULE_PAGE_HINT.into()); }- if let Some(lo) = v["localOnlyLeftOut"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("--local-only left out the wiki parts (their files come from a Cloud Library export, a wiki call): {}; run without --local-only to package them", lo.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }- if v["zip"].is_null() && *local_only { ok(&format!("{page_said}: README with {} parts; no KiCad package (no project-library parts to package without a wiki call)", v["parts"]), &hints); return; }- // a wiki part whose footprint named no 3D model took its own page's STEP (project-page.py bind_page_models)- if let Some(b) = v["pageModels"]["bound"].as_array().filter(|a| !a.is_empty()) {- hints.push(format!("3D models bound from the parts' own wiki pages (their footprints named none): {}; `models` checks them on the board like any other",- b.iter().map(|x| format!("{} -> {}", x["mpn"].as_str().unwrap_or("?"), x["model"].as_str().unwrap_or("?"))).collect::<Vec<_>>().join(", ")));- }- ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),- &[hints, if *no_push { vec![format!("Local build only: the README, PCM zip and repository files are in {}. The repository URL above works only after `project-page` runs without --no-push.", dir.join("project-page").display())] } else { vec![] }, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());- }- Cmd::Pcm { what: _ } => {- thread(&cli);- let mut r = load_run(&cli);- if sequence::lane(&dir, &r.data) != "kicad" { err("pcm check is KiCad's library check (the project page's PCM package installed in KiCad); this run builds in another tool", &["Altium: `adom-aiflow altium library` builds and records the library.".into()]); }- let pp = r.data["outcomes"]["project-page"].clone();- let Some(id) = pp["identifier"].as_str().map(str::to_string) else { err("no project page yet: `project-page --page <owner/slug> --create` first", &[]) };- let br = bridge_of(&r, &cli);- let list = br.raw_call("kicad_pcm_list", &json!({}));- let text = list.to_string();- let installed = text.contains(&id);- r.outcome("library", json!({"pass": installed, "identifier": id, "libName": pp["libName"], "repositoryUrl": pp["repositoryUrl"], "pcmList": list}));- r.save().unwrap();- if !installed { err(&format!("{id} is not installed in the test box's KiCad"), &[format!("kicad_pcm_add_repository {{\"url\":\"{}\"}}, then kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{id}\"}}, restart KiCad, `pcm check` again.", pp["repositoryUrl"].as_str().unwrap_or(""), pp["repositoryUrl"].as_str().unwrap_or(""))]); }- ok(&format!("{id} is installed in KiCad: the schematic uses PCM_{}:<part>", pp["libName"].as_str().unwrap_or("")), &["Next: `step schematic`: draw it in KiCad's schematic editor (kicad_sch_place_symbol, kicad_sch_wire, kicad_sch_label), ERC clean, every BOM reference on the sheet.".into()]);- }- Cmd::Schematic { what: _, sch } => {- thread(&cli);- let mut r = load_run(&cli);- if sequence::lane(&dir, &r.data) != "kicad" { err("schematic check reads a KiCad schematic (kicad-cli ERC); this run builds in another tool", &["Altium: `adom-aiflow altium schematic` draws, compiles and checks the schematic pin for pin.".into()]); }- if !Path::new(sch).is_file() { err(&format!("no such schematic: {sch}"), &[]); }- let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- let sdir = dir.join("schematic"); let _ = std::fs::create_dir_all(&sdir);- let erc = sdir.join("erc.json");- let (_, erc_errors) = schematic_check::run_erc(&kc, sch, &erc).unwrap_or_else(|e| err(&format!("schematic check: {e}"), &["ERC must run and its report come back before the schematic can pass. In a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> (it runs `sch erc` on the desktop's KiCad).".into()]));- let net = sdir.join("netlist.net");- export_sch_netlist(sch, &net).unwrap_or_else(|e| err(&e, &[]));- let on_sheet = schematic_check::sheet_symbols(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));- let lib = r.data["outcomes"]["project-page"]["libName"].as_str().map(|l| format!("PCM_{l}"));- let bom_refs: Vec<String> = r.data["outcomes"]["parts"]["parts"].as_array().cloned().unwrap_or_default().iter().flat_map(|p| p["refs"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect();- let missing: Vec<String> = bom_refs.iter().filter(|rf| !on_sheet.contains_key(*rf)).cloned().collect();- let foreign: Vec<String> = match &lib { Some(l) => on_sheet.iter().filter(|(rf, (sl, fp))| bom_refs.contains(rf) && (sl != l || !fp.starts_with(&format!("{l}:")))).map(|(rf, (sl, fp))| format!("{rf} ({sl} / {fp})")).collect(), None => Vec::new() };- let extra: Vec<String> = on_sheet.keys().filter(|rf| !bom_refs.contains(rf)).cloned().collect();- // issue #39: symbol polarity drawings, active-device pin types and readability (schematic_symbols.rs)- let symbols = schematic_symbols::check(Path::new(sch), &spec_soft(&r)).unwrap_or_else(|e| json!({"ok": false, "errors": [format!("could not read the hierarchy: {e}")], "warnings": []}));- schematic_symbols::record(&mut r, sch, &symbols);- let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty() && symbols["ok"] == true;- // issue #138: name machine pins and contacts the way the wiki's model registry counts them- // while it is one edit in the schematic; a hint, never a refusal- let pin_names = molecule_pins::names_from_footprints(&on_sheet.iter().map(|(rf, (_, fp))| (rf.clone(), fp.clone())).collect::<Vec<_>>());- let pin_hints: Vec<String> = pin_names.iter().map(|(finding, suggestion)| format!("Molecule names: {finding}. {suggestion}")).collect();- let canon = std::fs::canonicalize(sch).map(|p| p.display().to_string()).unwrap_or(sch.clone());- r.outcome("erc", json!({"pass": erc_errors.is_empty(), "errors": erc_errors, "schematic": canon, "sha256": sha256_file(sch), "report": erc.display().to_string(), "source": "schematic check (kicad-cli sch erc)"}));- r.outcome("schematic", json!({"pass": pass, "schematic": canon, "sha256": sha256_file(sch), "ercErrors": erc_errors, "symbols": on_sheet.len(), "missingRefs": missing, "notFromProjectLibrary": foreign, "notInBom": extra, "library": lib, "moleculeNames": pin_names.iter().map(|(a, b)| json!({"finding": a, "suggestion": b})).collect::<Vec<_>>()}));- if pass { r.data["schematic"] = json!(canon); }- r.save().unwrap();- if !pass {- let mut why = Vec::new();- if !erc_errors.is_empty() { why.push(format!("ERC: {} error(s): {}", erc_errors.len(), erc_errors.iter().take(6).cloned().collect::<Vec<_>>().join("; "))); }- if !missing.is_empty() { why.push(format!("not on the sheet: {}", missing.join(", "))); }- if !foreign.is_empty() { why.push(format!("not from the project library {}: {}", lib.clone().unwrap_or_default(), foreign.join(", "))); }- why.extend(symbols["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)));- let mut hints = vec!["Fix it in KiCad's schematic editor on camera (kicad_sch_place_symbol / kicad_sch_wire / kicad_sch_label / kicad_sch_set_property), save, and check again.".to_string()];- hints.extend(schematic_symbols::render(&symbols, true).1);- hints.push(schematic_symbols::warning_hint(&symbols));- hints.extend(pin_hints.iter().cloned());- findings(&format!("schematic check failed:\n {}", why.join("\n ")), &hints);- }- ok(&format!("schematic {canon}: {} symbols, ERC clean, every BOM reference placed from {}{}", on_sheet.len(), lib.unwrap_or_else(|| "the project library".into()), if extra.is_empty() { String::new() } else { format!(" ({} not in the BOM: {})", extra.len(), extra.join(", ")) }),- &[pin_hints, vec![schematic_symbols::warning_hint(&symbols), "Next: make the board from it (KiCad: Update PCB from Schematic), draw the outline, save, bind it (`bind-remote-board` / `start --board`), then `step components`.".into()]].concat());- }- 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": "the report card: what passed, what is still open, and suggestions; it never refuses"},- });- 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())]);- print_next(&cli);- }- }- Cmd::Place { what, wish, moves, out, pause_ms, allow_outside } => {- thread(&cli);- let mut r = load_run(&cli);- let b = board_of(&r);- match what.as_str() {- "verify" => {- let board_path=r.current_board();- let spec_path=r.data["spec"].as_str().unwrap_or("").to_string();- let board_hash=placement_verify::digest(Path::new(&board_path)).unwrap_or_else(|e|err(&e,&[]));- let spec_hash=placement_verify::digest(Path::new(&spec_path)).unwrap_or_else(|e|err(&e,&[]));- if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {- if !f.errors.is_empty() { note(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }- }- let spec_i=spec_of(&r);- let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});- for l in &intent_lines { eprintln!("{l}"); }- let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();- let br=bridge_of(&r,&cli);- let before=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));- let validation=br.call("kicad_placement_validate",&json!({"filePath":rb}));- let after=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));- let report=dir.join("placement-verify.json");- write_json(&report,&json!({"before":before,"validation":validation,"after":after}));- let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r));- let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str());- let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };- let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()||!v.approved_contacts.is_empty()||!v.net_ties.is_empty()).map(|v|v.to_json());- r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));- r.save().unwrap_or_else(|e|err(&e,&[]));- if let Some(e)=error { findings(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }- let excused_line=excused.as_ref().map(|e|format!("; reviewed assembly contacts: {}; fixedRefs box exceptions: {}; padless courtyard exceptions: {}; net ties (KiCad's own rules): {} (original native findings retained)",e["approvedCourtyardContacts"].as_array().map_or(0,|a|a.len()),e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()),e["netTies"].as_array().map_or(0,|a|a.len()))).unwrap_or_default();- ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);- }- "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 intent = placement_intent::of_spec(&spec);- let (wishes, anchor_lines) = aiflow_place::intent::apply_anchors(&b, &wishes, &intent);- let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);- let (mv, mut lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);- lines.extend(anchor_lines);- lines.extend(placement_intent::pack_lines(&b, &mv, &intent));- 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);- // issue #140: name the parts left without a spot (they stay where they are, maybe off the board)- let left: Vec<String> = wishes.iter().map(|(r, _)| r.clone()).filter(|r| !mv.contains_key(r)).collect();- let mut msg = format!("legal spots for {} of {} parts{}:\n{}", mv.len(), wishes.len(), if left.is_empty() { String::new() } else { format!(" (no spot for {}; left where {} now)", left.join(", "), if left.len() == 1 { "it is" } else { "they are" }) }, lines.join("\n"));- // and every part the packed board would still have outside the outline- let packed = aiflow_place::move_in_text(&b, &mv).ok().and_then(|(t, _)| Board::from_text(&b.path, t).ok());- let overhang_refs = edge_overhang::allowed(&spec, allow_outside).map(|o| edge_overhang::refs(&o)).unwrap_or_default();- let outside = aiflow_place::parts_outside(packed.as_ref().unwrap_or(&b), &overhang_refs);- if !outside.is_empty() {- msg.push_str(&format!("\nFINDING: {} part(s) outside the board outline after this pack:", outside.len()));- for (_, w) in &outside { msg.push_str(&format!("\n {w}")); }- }- 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() }, if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() }, 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();- // issue #95: an edge connector may overhang the outline by design, never with copper- let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));- let overhang_lines = edge_overhang::check_board(&nb, &overhang).unwrap_or_else(|e| err(&e, &[format!("Move the part inward until every pad is inside; the run's current board is unchanged ({}).", outp.display())]));- let problems = aiflow_place::check_allowing(&nb, &moved, 0.05, &edge_overhang::refs(&overhang));- // issue #140: every part outside the outline, moved or not (a part pack found no spot for is not in the moves)- let outside = aiflow_place::parts_outside(&nb, &edge_overhang::refs(&overhang));- if !outside.is_empty() {- note(&format!("{} part(s) outside the board outline on the moved board:\n {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n ")), &[aiflow_place::OUTSIDE_HINT.into()]);- }- 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 spec_i = spec_of(&r);- if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }- let drc = run_drc_for(&r, &outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));- let baseline = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));- let s = aiflow_copper::summarize_with(&drc, Some(&baseline), silk_policy::blocking(&spec_i, &r.current_board()));- 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) {- note(&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())]);- }- // issue #39: courtyards present and clear, part bodies clear, whatever the project severities say- let mech_line = if mechanical::applies(&outp.display().to_string()) {- let mech = mechanical::check_board(&nb, &outp.display().to_string(), &project_dir_of(&r), &spec_soft(&r));- for n in &mech.severity_notes { eprintln!("Warning: {n}"); }- if !mech.errors().is_empty() { note(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }- if mech.errors().is_empty() { format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked) } else { format!("; {} courtyard/body finding(s) to look at (above)", mech.errors().len()) }- } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };- let old_board = r.current_board();- if let Some(n) = reviews::void_on_service_change(&mut r, Path::new(&old_board), &outp) { eprintln!("{n}"); }- r.add_board(&outp.display().to_string());- r.save().unwrap();- let lines: Vec<String> = lines.into_iter().chain(overhang_lines).chain(outside.iter().map(|(_, w)| format!("FINDING: {w}"))).collect();- let outside_hint = if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() };- ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line}{} and is now the run's current board", lines.join("\n"), outp.display(), if outside.is_empty() { String::new() } else { format!("; {} part(s) outside the outline (FINDING lines above)", outside.len()) }), &[outside_hint, if overhang.is_empty() { "Land the same moves live with `place land --moves ...`.".into() } else { "Land the same moves live with `place land --moves ...` (the edge overhang parts are sent with allowOutside, the others are not).".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();- let mut mv = read_json(m);- let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));- for l in edge_overhang::check_moves(&b, &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }- // issue #140: parts that will still be outside the outline once these moves land (named, then landed anyway)- let landed_board = aiflow_place::moves_from_json(&mv).ok().and_then(|m| aiflow_place::move_in_text(&b, &m).ok()).and_then(|(t, _)| Board::from_text(&b.path, t).ok());- if let Some(after) = landed_board.as_ref() {- land_moves::fill_rotations(&mut mv, after);- // issue #142: a Fusion move must keep each part on its side; read from the live board, not the run's copy- if aiflow_bridge::fusion::doc_of(&rb).is_some() && mv.as_object().is_some_and(|o| !o.is_empty()) {- if let Some((brd, kicad)) = live_board(&r, &cli, &dir) {- let rots = std::fs::read_to_string(&brd).map_err(|e| e.to_string()).and_then(|t| aiflow_board::eagle::element_rotations(&t)).unwrap_or_else(|e| err(&e, &[]));- let live = Board::load(&kicad).unwrap_or_else(|e| err(&e, &[]));- if let Err(clash) = land_moves::fusion_sides(&mut mv, after, &live, &rots) {- err(&format!("nothing landed: {} part(s) are on a different side in the run's board and on the live board:\n {}", clash.len(), clash.join("\n ")), &["To put a part back on the bottom, call fusion_move_parts through Adom Bridge with its x, y (Fusion's frame, y up) and a rotation such as \"MR0\". To keep the live side, make the run's board match it, then land again.".into()]);- }- }- }- }- let outside = aiflow_place::parts_outside(landed_board.as_ref().unwrap_or(&b), &edge_overhang::refs(&overhang));- if !outside.is_empty() {- note(&format!("{} part(s) will be outside the board outline after this landing:\n {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n ")), &[aiflow_place::OUTSIDE_HINT.into()]);- }- r.stage_start("place-land", Some("binary"), None);- 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_allowing(&rb, &mv, *pause_ms, &edge_overhang::refs(&overhang), |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,"edgeOverhang":edge_overhang::record(&overhang)}));- 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, "edgeOverhang": edge_overhang::record(&overhang)}));- 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(), KICAD_SOCKET_HINT.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("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "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.".to_string() }]);- }- let outside_line = if outside.is_empty() { String::new() } else { format!("\nFINDING: {} part(s) outside the board outline: {}", outside.len(), outside.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().join(", ")) };- ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s{outside_line}", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into(), if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.into() }]);- }- other => err(&format!("place {other}: use pack, check, land or verify"), &[]),- }- }- Cmd::Route { passes, rip_per_net, rip_global, variants, pick, why } => {- let ai_thread = 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)) = live_board(&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);- // issue #140: a part outside the outline cannot be routed; name it before routing (the router reports its nets as unrouted)- let outside = aiflow_place::parts_outside(&b, &edge_overhang::allowed(&spec, &[]).map(|o| edge_overhang::refs(&o)).unwrap_or_default());- if !outside.is_empty() {- note(&format!("{} part(s) outside the board outline; their connections are left unrouted:\n {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n ")), &[aiflow_place::OUTSIDE_HINT.into()]);- }- 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 mut rules = aiflow_router::Rules::from_spec(&spec);- // issue #52: impedance targets set their nets' width (and pair pitch) and hold them to one layer- let imp_choice = impedance_cmd::for_route(&b, &spec, &r.current_board(), &mut rules).unwrap_or_else(|(m, h)| err(&m, &h));- // issue #165: say the sizes the router actually uses, so a spec key it did not read shows at once- eprintln!("route rules used: track {} mm, clearance {} mm, via {} mm; per-net widths: wide {}, mid {}{}", rules.track, rules.clearance, rules.via,- if rules.wide.is_empty() { "none".to_string() } else { rules.wide.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") },- if rules.mid.is_empty() { "none".to_string() } else { rules.mid.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") },- if spec.get("track").is_none() && spec.get("trackWidth").is_some() { " (track from the spec's trackWidth)" } else if spec.get("track").is_none() { " (track is the router default: the spec names no track)" } else { "" });- 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, ®ions);- decoupling_check::refuse_bad_spec(&spec, &b);- 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(&match (variants, pick) { (Some(n), _) => format!("variants={n} passes={passes}"), (_, Some(k)) => format!("pick=v{k}"), _ => format!("passes={passes}") }));- let labels: Vec<String> = regions.iter().map(|k| k.label.clone()).collect();- // issue #55: --variants routes several and the gates pick; --pick takes one of them; plain route is the baseline levers- let (rt, variants_rec) = match (variants, pick) {- (_, Some(k)) => route_variants::pick(&r, *k, why.as_deref(), &ai_thread),- (Some(n), None) => route_variants::run(&r, &dir, &rules, &labels, *n, *passes, *rip_per_net, *rip_global),- _ => (route_variants::route_once(b, &rules, &aiflow_router::variants::Levers::baseline(*passes), *rip_per_net, *rip_global, &labels, true), Value::Null),- };- let route_variants::Routed { plan_doc, mut report, failed, simp_line, log, board: routed_on, has_copper, bc } = rt;- let plan: Vec<Value> = plan_doc["nets"].as_array().cloned().unwrap_or_default();- std::fs::write(dir.join("router.log"), log.join("\n")).ok();- let planp = dir.join("plan-route.json");- for w in bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str()) { eprintln!("WARNING {w}"); }- // issue #52: measure the chosen plan's impedance targets (coupling, skew) before it is written- let imp_lines = impedance_cmd::after_route(&imp_choice, &routed_on, &spec, &r.current_board(), &plan_doc, &mut report);- for l in imp_lines.iter().filter(|l| l.starts_with("WARNING")) { eprintln!("{l}"); }- write_json(&planp, &plan_doc);- write_json(&dir.join("plan-route-report.json"), &report);- let dec = decoupling_check::review_route(&spec, &routed_on, &plan_doc, &report, r.outcomes().get("pour"));- decoupling_check::record(&mut r, &dir, &dec);- let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();- r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);- let mut route_o = 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()});- if !variants_rec.is_null() { route_o["variants"] = variants_rec; }- if !report["impedance"].is_null() { route_o["impedance"] = report["impedance"].clone(); }- // issues #127/#128: route geometry and signal quality of the plan on this board, suggestions only- let quality_lines = match route_quality::of_plan(&board_of(&r), &plan_doc, &spec) {- Some((qv, lines)) => { let qp = dir.join("plan-route-quality.json"); write_json(&qp, &qv); route_o["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string()); lines }- None => Vec::new(),- };- for l in &quality_lines { eprintln!("{l}"); }- // issue #141: the route's numbers, kept across board changes until the next route- let mut route_warnings: Vec<String> = bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str().map(str::to_string)).collect();- route_warnings.extend(imp_lines.iter().filter(|l| l.starts_with("WARNING")).map(|l| l.trim_start_matches("WARNING").trim_start_matches(':').trim().to_string()));- if let Some(d) = dec.as_ref().filter(|d| d.errors + d.warnings > 0) { route_warnings.push(format!("decoupling: {} error(s), {} warning(s) (decoupling-route.json)", d.errors, d.warnings)); }- let plan_vias = aiflow_copper::apply(&board_of(&r), &plan_doc, false).ok().and_then(|(_, st)| st["vias"].as_u64());- let revision = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);- r.data["lastRoute"] = attention::route_record(&report, plan_vias, route_warnings, attention::quality_headline(&quality_lines), &r.current_board(), revision, &now(), &planp.display().to_string(), *passes);- r.outcome("route", route_o);- // the same timestamp as the outcome: a carried outcome (copper_carry) is recognised by it- r.data["lastRoute"]["at"] = r.data["outcomeAt"]["route"].clone();- 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() };- findings(&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()]);- }- decoupling_check::refuse_errors("route", &dec);- if has_copper && plan.is_empty() {- ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias, {} filled zone islands); empty plan {}", bc["trackPieces"], bc["vias"], bc["zoneIslands"], 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 (nets already complete on it, skipped: {}) and {} connection(s) it left open were planned", bc["netsComplete"].as_array().filter(|a| !a.is_empty()).map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_else(|| "none".into()), bc["openConnections"]) } else { String::new() };- let imp_text = if imp_lines.is_empty() { String::new() } else { format!("\n{}", imp_lines.join("\n")) };- ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}{imp_text}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint, if imp_lines.is_empty() { String::new() } else { "After landing: `adom-aiflow analyze impedance` judges the landed copper (and a same-input Fields Signals solve); finish requires it.".into() }]);- }- }- Cmd::Pour { plan } => {- thread(&cli);- let mut r = load_run(&cli);- let spec = spec_of(&r);- // a Fusion run pours around what is in Fusion now (the landed copper and heatsink vias)- let b = live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| 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);- // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of- let tv_holes = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).filter(|p| std::path::Path::new(p).is_file()).map(|p| aiflow_pours::plan_holes(&read_json(p))).unwrap_or_default();- r.stage_start("pour", Some("binary"), None);- let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &aiflow_pours::with_project_rules(&spec, &r.current_board()), plan_v.as_ref(), &tv_holes).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}));- pourverify::record_plan(&mut r.data, &zp, "pour").unwrap_or_else(|e| err(&e, &[]));- 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();- // issue #143: where `fusion library` finds the test point when its private wiki page cannot be read- if args.first().is_some_and(|a| a == "library") { if let Err(e) = testplan::write_test_point_files(&dir) { eprintln!("WARNING the test point files could not be written: {e}"); } }- let status = std::process::Command::new("python3").arg(lane_script("fusion", &format!("{}\n{}", include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/fusion-project.py")))).args(args)- .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())- .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));- std::process::exit(status.code().unwrap_or(1));- }- Cmd::Altium { args, target } => {- // the Altium steps: the shared project half (tools/project-common.py), the shared silkscreen planner,- // the lane's pure helpers (tools/altium-lane.py: the symbol check, project-library parts), then the Altium half- 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 script = format!("{}\n{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-lane.py"), include_str!("../../../tools/altium-project.py"));- let status = std::process::Command::new("python3").arg(lane_script("altium", &script)).args(args)- .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())- .status().unwrap_or_else(|e| err(&format!("altium 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()- });- // the same normalisation `land route` applies, so the gate passes only what lands (issue #33)- let (plan_v, plan_notes) = read_plan_normalised(&planp, &b);- print_plan_notes(&plan_notes);- let spec_g = spec_of(&r);- if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) {- if let Some(w) = warning { eprintln!("Warning: {w}"); }- if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { findings(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }- }- // issue #148: molecule findings are reported and the gate still runs DRC (a suggestion, never a stop)- let mut mol_errors: Vec<String> = Vec::new();- if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {- if !f.errors.is_empty() { note(&format!("the board is not yet a conforming molecule (DRC runs anyway):\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); mol_errors = f.errors.clone(); }- }- r.stage_start("gate", Some("binary"), None);- // the checks themselves are gate_eval::evaluate, shared with `route --variants` (issue #55)- let base = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&e, &[]));- let routed = dir.join("board-routed.kicad_pcb");- let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, quality, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[]));- // issues #127/#128: route geometry and signal quality, reported with suggestions, never a refusal- let qp = dir.join("route-quality.json");- write_json(&qp, &quality.0);- s["routeQuality"] = route_quality::outcome(&quality.0, &qp.display().to_string());- for l in &quality.1 { eprintln!("{l}"); }- decoupling_check::record(&mut r, &dir, &dec);- 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")); }- if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }- 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, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routeQuality": s["routeQuality"], "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));- if !mol_errors.is_empty() { r.data["outcomes"]["gate"]["moleculeErrors"] = json!(mol_errors); }- r.data["lastGate"] = json!({"at": r.data["outcomeAt"]["gate"], "board": r.current_board(), "boardRevision": r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1), "newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu, "plan": planp});- r.save().unwrap();- if !sw_issues.is_empty() {- r.outcome("switch-node", json!({"issues": sw_issues}));- r.save().unwrap();- findings(&format!("gate failed: the switch node:\n {}", sw_issues.join("\n ")), &[switchnode::HINT.into()]);- }- if npu > 0 && newe == 0 && unc == 0 {- findings(&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() };- findings(&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, "Suggested: fix the plan (route again, or move parts) and gate again; landing it as it is lands these errors too.".into()]);- }- decoupling_check::refuse_errors("gate", &dec);- if let Some(m) = &mech {- for n in &m.severity_notes { eprintln!("Warning: {n}"); }- if !m.errors().is_empty() { findings(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); }- } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }- let silk_n = s["silkAdvisories"].as_u64().unwrap_or(0) as usize;- if silk_n > 0 { eprintln!("Warning: {}", silk_policy::advisory_line(silk_n, &s["silkAdvisoryList"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())); }- if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); }- let checked = match &mech { Some(m) => format!("; courtyards checked on {} footprint(s), {} explicitly reviewed contact(s), {} part bodies clear", m.with_courtyard, m.approved_contacts.len(), m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() };- let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);- if !mol_errors.is_empty() { findings(&format!("gate: 0 new DRC errors ({inh} inherited), 0 unconnected, but {} molecule finding(s) are open:\n {}", mol_errors.len(), mol_errors.join("\n ")), &["DRC is recorded (gate.json). Fix the molecule findings above (`adom-aiflow molecule check` lists them with suggestions), then gate again; finish lists whatever is still open.".into()]); }- ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", 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, allow_outside } => {- 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 && mechanical::gate_clear(&gate);- let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));- let empty_after_normalising = |p: &String| { let (v, _) = read_plan_normalised(p, &board_of(&r)); v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) };- if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && empty_after_normalising(p)) {- 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;- }- }- if what == "pours" {- // zones adopted with this exact plan that still match it: nothing to land, no bridge call (issue #83)- if let Some(zp) = r.outcomes().get("pour").and_then(|p| p["pours"].as_str()).map(str::to_string) {- let b = board_of(&r);- if let Ok(n) = pourverify::adopted_unchanged(&r.data, Path::new(&zp), &b) {- r.log("land-pours-skipped", json!({"plan": zp, "zones": n, "why": "adopted zones already match the plan"}));- r.save().unwrap();- ok(&format!("adopted zones already match the plan; nothing to land ({n} zone(s) adopted by `verify-pours --adopt` with this plan, unchanged on the saved board)"), &[if pourverify::is_existing(&r.data["outcomes"]["land-pours"]) { "The adopted-existing pours proof stands. Next: adom-aiflow measure, analyze current, analyze thermal, finish.".to_string() } else { format!("The pours proof was replaced by a later landing attempt: run `verify-pours --plan {zp}` again to record it (no copper changes), then measure, analyze current, analyze thermal, finish.") }]);- 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", &[]));- let mv = read_json(m);- let overhang = edge_overhang::allowed(&spec_soft(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));- for l in edge_overhang::check_moves(&board_of(&r), &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }- r.stage_start("place-land", Some("binary"), None);- 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_allowing(&rb, &mv, *pause_ms, &edge_overhang::refs(&overhang), |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, "edgeOverhang": edge_overhang::record(&overhang)}));- 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("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "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.".to_string() }]);- }- 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 && r.outcomes().get("gate").map(mechanical::gate_clear).unwrap_or(false);- if !gate_ok && plan.is_none() {- note("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 {- plan.clone().or_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", &[]))- };- // the gate's normalisation (issue #33): no zero-length trace reaches the bridge, and- // trace numbers (`--from`) count the same traces the gate passed- let b = board_of(&r);- let (plan_main, plan_notes) = read_plan_normalised(&planp, &b);- print_plan_notes(&plan_notes);- let entries: Vec<Value> = aiflow_copper::plan_entries(&plan_main).unwrap_or_else(|e| err(&format!("{planp}: {e}"), &[]));- // 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 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![plan_main.clone()];- 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_plan_normalised(gp, &b).0); }- }- }- let mut copper_cache: Option<Vec<Copper>> = None;- // and keep the spec's hole-to-hole minimum and via-to-via clearance from every via and- // hole, live or planned, of any net (issue #83): checked for every trace before any lands- // the spec's numbers, else the project's .kicad_dru, else 0.2 mm (issue #90)- let spec_v = aiflow_pours::with_project_rules(&r.data["spec"].as_str().filter(|p| Path::new(p).is_file()).map(read_json).unwrap_or(Value::Null), &r.current_board());- let (h2h, via_clearance) = (aiflow_pours::hole_to_hole(&spec_v), aiflow_pours::via_clearance(&spec_v));- let mut holes_cache: Option<Vec<landing::Hole>> = 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- });- if let Some(refusal) = 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)}))) { return Some(refusal); }- let holes = holes_cache.get_or_insert_with(|| {- let mut h = landing::live_holes(live);- h.extend(landing::board_holes(&b));- for p in &plans { h.extend(landing::plan_holes(p, &live.pads)); }- h- });- let drill = e["viaDrill"].as_f64().unwrap_or(t.via_size / 2.0);- t.vias.iter().find_map(|v| landing::via_hole_clash(&t.net, *v, t.via_size, drill, holes, h2h, via_clearance).map(|what| json!({"success": false, "errorCode": "via_hole_clash", "net": t.net, "at": [v.0, v.1], "clashes": what, "message": format!("the planned {} via at ({:.2}, {:.2}) is too close to {what}", 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 entries take \"size\" and \"drill\" in mm, 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" || rep["errorCode"] == "via_hole_clash" {- 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; an entry's \"size\" and \"drill\" set its via, and spec viaClearance or the project's .kicad_dru its clearance), 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. To resume at this trace: `adom-aiflow land {what}{} --from {}`. The log has every reply.", plan.as_ref().map(|p| format!(" --plan {p}")).unwrap_or_default(), 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()));- pourverify::landable(&zones).unwrap_or_else(|e| err(&e, &["Write that zone natively in KiCad, save and fill it, then accept it with `verify-pours --plan <plan>` (add --adopt --why to own it). Nothing was landed.".into()]));- 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()]); }- // issue #102: owned IDs gone from the live board and both saved boards (KiCad died after the commit)- let run_board = r.current_board();- for l in zone_ownership::reconcile_before_landing(&mut r.data, &before, &run_board, &br, &rb, &dir) { eprintln!("{l}"); }- 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()); }- }- }- }- // issue #69 follow-up: a zone already on the board that is identical to a plan zone is adopted- // (or, owned, kept) instead of refused; one that differs is refused before anything is landed- let adoption = match pour_adopt::plan(&b, &zones, &before, &owned) {- Ok(a) => a,- Err(refusal) => {- r.stage_end("land-pours", Some(json!({"groups": 0, "failed": 1})), None);- r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": Value::Null, "failed": ["zone-adoption"],- "groups": [json!({"net": "zone-adoption", "ok": false, "errorCode": refusal["errorCode"], "message": refusal["message"]})], "adoptionRefused": refusal}));- write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "adoptionRefused": refusal}));- r.save().unwrap();- // AI Flow never refuses (0.2.48): the bridge would not land over a zone this run does not own, so- // nothing was sent; this is reported as findings with the ways on- findings(&format!("land pours: nothing landed yet, a zone already on the board stands where the plan puts one: {}. Nothing was landed or changed.", refusal["message"].as_str().unwrap_or("")),- &[format!("Suggested: a zone already on the board is adopted when it is identical to its plan zone: the same name, net, layers, outline (every vertex within {} mm), priority, clearance, min thickness, pad connection and fill settings, not locked. Make the board zone match the plan (or the spec and `pour` match the board), or delete it in KiCad, then land again. Full readback: {}.", pourverify::ADOPT_TOLERANCE_MM, dir.join("land-pours-log.json").display())]);- }- };- if let Some(n) = &adoption.note { eprintln!("{n}"); }- let results = if adoption.send_zones.is_empty() && adoption.send_owned.is_empty() { Vec::new() } else { br.land_zones(&rb, &adoption.send_zones, *pause_ms, &adoption.send_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();- if failed.is_empty() && !adoption.adopted.is_empty() {- // ownership of an adopted zone is granted only with a landing that succeeded- for a in &adoption.adopted { if let Some(id) = a["id"].as_str() { if !owned.iter().any(|x| x == id) { owned.push(id.to_string()); } } }- r.data["ownedZoneIds"] = json!(owned);- if !r.data["zoneAdoptionsByLanding"].is_array() { r.data["zoneAdoptionsByLanding"] = json!([]); }- let entry = json!({"at": aiflow_run::now(), "pourPlan": zp, "planSha256": pourverify::hash(Path::new(&zp)).ok(), "liveRevision": before["revision"], "zones": adoption.adopted});- r.data["zoneAdoptionsByLanding"].as_array_mut().unwrap().push(entry.clone());- r.log("zone-adopted", entry);- }- r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len(), "adopted": adoption.adopted.len(), "kept": adoption.kept.len()})), None);- r.outcome("land-pours", json!({"adopted": if failed.is_empty() { json!(adoption.adopted) } else { json!([]) }, "kept": adoption.kept, "leftAlone": adoption.left_alone, "adoptionNote": adoption.note, "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"], "message": rep["message"], "reply": if Bridge::ok(rep) { Value::Null } else { json!(aiflow_run::clip_text(&rep.to_string(), 1200)) }})).collect::<Vec<_>>()}));- write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "adopted": adoption.adopted, "kept": adoption.kept, "leftAlone": adoption.left_alone, "sent": {"zones": adoption.send_zones.iter().map(|z| z["name"].clone()).collect::<Vec<_>>(), "ownedItemIds": adoption.send_owned}, "results": results, "after": after}));- r.save().unwrap();- if let Some((net, rep)) = failed.first() {- err(&pourverify::refusal(net, rep, &zones), &[format!("Full replies: {}. A zone adopted with `verify-pours --adopt` and unchanged since is not landed again; this plan was sent because {}.", dir.join("land-pours-log.json").display(), pourverify::adopted_unchanged(&r.data, Path::new(&zp), &b).err().unwrap_or_default()),"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()]);- }- let names = |v: &Vec<Value>| v.iter().filter_map(|a| a["name"].as_str()).collect::<Vec<_>>().join(", ");- let adopted_note = if adoption.adopted.is_empty() { String::new() } else { format!("; {} zone(s) already on the board adopted as identical to the plan, not landed again: {}", adoption.adopted.len(), names(&adoption.adopted)) };- let kept_note = if adoption.kept.is_empty() { String::new() } else { format!("; {} owned zone(s) identical to the plan kept as they are: {}", adoption.kept.len(), names(&adoption.kept)) };- let sent_note = format!("; {} zone(s) landed through the bridge", adoption.send_zones.len());- let mut hints: Vec<String> = Vec::new();- if !adoption.left_alone.is_empty() { hints.push(format!("{} board zone(s) this run does not own and the plan does not name were left alone: {}", adoption.left_alone.len(), names(&adoption.left_alone))); }- if !adoption.adopted.is_empty() { hints.push(format!("The adoption evidence (each zone's compared fields, outline deviation and definition sha256) is in run.json zoneAdoptionsByLanding and {}.", dir.join("land-pours-log.json").display())); }- hints.push("Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into());- ok(&format!("pours landed: copper per layer before {} after {}{adopted_note}{kept_note}{sent_note}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &hints);- }- other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),- }- }- Cmd::VerifyPours { plan, from_board, adopt, why, record_plan } => {- if *adopt || *record_plan { thread(&cli); }- let mut r = load_run(&cli);- match pourverify::command(&mut r, &dir, plan.as_deref(), *from_board, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }- }- Cmd::Zones { action: _, id, missing, why } => {- thread(&cli);- let mut r = load_run(&cli);- let br = bridge_of(&r, &cli);- match zone_ownership::forget_command(&mut r, &br, &dir, id, *missing, why.as_deref()) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }- }- 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 ({}'s fills): {}; coverage {}", aiflow_bridge::eda::active().name(), zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);- }- Cmd::Analyze { what, plan, fields, ack, why } => {- let t = thread(&cli);- if !ack.is_empty() && what != "current" { err("--ack acknowledges a current-density peak: `analyze current --ack [email protected] --why ...`", &[]); }- let mut r = load_run(&cli);- if what == "impedance" {- // issue #52: closed-form impedance of the landed copper, Fields Signals beside it- let live = live_board(&r, &cli, &dir);- let copper = live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board());- let solved = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());- let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o["plan"].as_str()).map(str::to_string));- let spec = spec_of(&r);- match impedance_cmd::analyze_run(&mut r, &dir, &spec, &copper, &solved, planp.as_deref(), fields.as_deref()) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }- return;- }- 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 = live_board(&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 (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current, thermal or impedance"), &[]) };- let mut fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));- // provenance: a fields.json is evidence only when it is the output of a solve aiflow ran or watched;- // any other file (copied, edited, or written to look like one) is solved again here, same inputs- let mut unobserved: Option<String> = None;- if aiflow_analyze::needs_field_screen(&spec,what) && fields_file.is_file() && !fields_observed(&r,&fields_file) {- let out_dir = dir.join("fields");- let args: Vec<String> = vec!["analyze".into(),"--board".into(),solved_board.clone(),"--spec".into(),r.data["spec"].as_str().unwrap_or("").to_string(),"--out".into(),out_dir.display().to_string()];- eprintln!("{} was not produced by an Adom Fields solve this run watched; solving it now: adom-fields {}", fields_file.display(), args.join(" "));- match std::process::Command::new("adom-fields").args(&args).status() {- Ok(st) if st.success() => { fields_file = out_dir.join("fields.json"); let mut full=vec!["adom-fields".to_string()]; full.extend(args.clone()); record_fields_solve(&mut r, &fields_file, "analyze", &full); r.save().unwrap(); }- Ok(st) => { unobserved = Some(format!("the field solve aiflow started failed ({st}); fix what adom-fields printed, then `adom-aiflow exec -- adom-fields {}` and analyze {what} again", args.join(" "))); }- Err(e) => { unobserved = Some(format!("{} was not produced by an Adom Fields solve this run watched, and adom-fields could not start ({e}): install adom-fields, then `adom-aiflow exec -- adom-fields {}`", fields_file.display(), args.join(" "))); }- }- }- if !ack.is_empty() && (unobserved.is_some() || !fields_file.is_file()) { err("--ack needs a same-input Fields solve this run watched to read the peak from; analyze current without --ack first", &[]); }- let mut expired_acks: Vec<Value> = Vec::new();- let mut sink_notes: Vec<String> = Vec::new();- let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(Vec::new()) } else if fields_file.is_file() {- let f=aiflow_analyze::fields_report::load(&fields_file).unwrap_or_else(|e| err(&e,&[]));- if let Err(e)=std::fs::metadata(&solved_board) { 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(),&[]));- // issue #70: record reviewed part-limited peaks, then screen with the live ones- let live_acks = if what == "current" {- let copper = ack_copper(live.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board));- if !ack.is_empty() {- let spec_sha = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(&spec_bytes)) };- if f["inputIdentity"]["spec"].as_str() != Some(spec_sha.as_str()) || copper_carry::carried_from(&r.data, f["inputIdentity"]["board"].as_str().unwrap_or(""), Path::new(&solved_board)).is_err() { err("--ack: the Fields report is not of this saved board (or its carried-over copper) and spec; solve again, then acknowledge", &[]); }- let copper = copper.clone().unwrap_or_else(|e| err(&format!("--ack: this board has no copper fingerprint to bind the acknowledgement to ({e})"), &[]));- let w = why.clone().unwrap_or_else(|| err("--ack needs --why \"<what limits the peak and why the board cannot change it>\"", &[]));- let limit = spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);- for a in ack.iter() {- let rec = aiflow_analyze::current_acks::record(a, &w, &t, &f, &board_now, &copper, limit, &now()).unwrap_or_else(|e| err(&e, &["The place is a pad of the part that limits the peak (REF.PAD on that net) or the Fields peak location x,y within 0.5 mm.".into()]));- let mut list: Vec<Value> = r.data["currentAcks"].as_array().cloned().unwrap_or_default();- list.retain(|x| x["target"] != rec["target"]);- list.push(rec);- r.data["currentAcks"] = json!(list);- }- r.save().unwrap();- }- let (live_a, expired) = aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &copper.unwrap_or_else(|_| "unavailable".into()), f["inputIdentity"]["spec"].as_str().unwrap_or(""));- expired_acks = expired;- live_a- } else { Vec::new() };- let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).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 });- // issue #67: the same-input solve with a declared board-sink block stands in for the estimate- if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); sink_notes.extend(aiflow_analyze::plane_backed::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }- // issue #107: the passed solve's rise at each net's narrowest neck judges the whole-net width screen- if what == "current" && s.is_ok() { aiflow_analyze::solved_ampacity::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }- s- } 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();- // issue #75: the fields step refuses a converter Fields did not model; say so here already- let converter_hints = converter_warning(&r.data, &spec, aiflow_analyze::fields_report::load(&fields_file).ok().as_ref(), Some(&board_now));- let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default();- let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res);- 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, "acknowledgedPeaks": covered_acks, "expiredAcks": expired_acks, "boardSinkAssumptions": sink_assumptions, "boardSinkNotes": sink_notes}));- r.save().unwrap();- let mut 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();- for c in &covered_acks { lines.push(format!("acknowledged peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} by --ack {}: \"{}\" ({}); Fields reports one maximum per net, so check its density map for a second spot", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }- for a in &expired_acks { lines.push(format!("expired acknowledgement (copper or spec changed since the review; acknowledge again if it still holds): {}", aiflow_analyze::current_acks::describe(a, "expired"))); }- for a in &sink_assumptions { lines.push(format!("board-sink assumption: {a}")); }- for n in &sink_notes { lines.push(format!("board sink: {n}")); }- // 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(&aiflow_analyze::fields_report::load(&fields_file).unwrap_or_default(), &bt, &spec) } else { Vec::new() };- if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); }- findings(&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, results_hint(&r)]);- }- let mut hints = vec![draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()];- hints.extend(converter_hints);- hints.push(results_hint(&r)); // issue #147- ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &hints);- }- Cmd::Impedance { action, board, spec, plan, json: as_json } => {- let (board_file, spec_v, plan_p) = match (board, spec) {- (Some(b), Some(s)) => (b.clone(), read_json(s), plan.clone()),- (None, None) => { let r = load_run(&cli); let p = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o["plan"].as_str()).map(str::to_string)); (r.current_board(), spec_of(&r), p) }- _ => err("give both --board and --spec, or neither (the run's board and spec)", &[]),- };- match impedance_cmd::command(action, &board_file, &spec_v, plan_p.as_deref(), *as_json) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }- }- Cmd::Deviate { item, why, list, clear } => {- let mut r = load_run(&cli);- let (bs, ss) = run_hashes(&r);- if *list {- let (live, old) = (deviations::live(&r.data, &bs, &ss), deviations::expired(&r.data, &bs, &ss));- let fmt = |d: &Value| format!("{}: \"{}\" ({}, {})", d["item"].as_str().unwrap_or(""), d["why"].as_str().unwrap_or(""), d["aiThread"].as_str().unwrap_or(""), d["at"].as_str().unwrap_or(""));- ok(&format!("{} deviation(s) on this board and spec{}{}", live.len(), live.iter().map(|d| format!("\n {}", fmt(d))).collect::<String>(), if old.is_empty() { String::new() } else { format!("\n{} expired (the board or spec changed since):{}", old.len(), old.iter().map(|d| format!("\n {}", fmt(d))).collect::<String>()) }), &[]);- return;- }- let t = thread(&cli);- let item = item.clone().unwrap_or_else(|| err("deviate needs the item, as the refusal names it: `deviate \"gate\" --why \"...\"`", &["`deviate --list` shows the recorded ones.".into()]));- if *clear {- let had = deviations::clear(&mut r.data, &item);- r.save().unwrap();- ok(&format!("{}", if had { format!("removed the deviation for {}", deviations::key(&item)) } else { format!("no deviation recorded for {}", deviations::key(&item)) }), &[]);- return;- }- let known: Vec<String> = r.data["lastRefusalItems"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();- let w = why.clone().unwrap_or_default();- let (rec, dnotes) = deviations::record(&mut r.data, &item, &w, &t, &bs, &ss, &now(), &known);- for n in &dnotes { note(n, &[]); }- r.log("deviate", rec.clone());- r.save().unwrap();- ok(&format!("deviation recorded for {}: \"{}\"", rec["item"].as_str().unwrap_or(""), rec["why"].as_str().unwrap_or("")), &["It holds for this saved board and spec only; any change to either expires it. Run the refused command again (finish, or the step). Tell the human about every deviation: finish lists them.".into()]);- }- Cmd::SpecAmend { why } => {- let t = thread(&cli);- let r = load_run(&cli);- if why.trim().chars().count() < spec_freeze::MIN_WHY { note("the reason in --why is short: the datasheet page and what was misread make it useful later; recorded as given", &[]); }- if r.data["spec"].is_null() { err("this run has no spec yet", &[]); }- let spec = read_json(r.data["spec"].as_str().unwrap_or(""));- let Some(mut rec) = spec_freeze::read(&r.dir) else { err("the budgets are not frozen yet (they freeze once placement begins): edit the spec freely until then", &[]) };- let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(&spec));- // a change already recorded without a reason (the spec was read after an edit) takes this reason- if changed.is_empty() {- let mut filled = 0;- for a in rec["amendments"].as_array_mut().into_iter().flatten() { if a["why"].as_str().unwrap_or("").is_empty() { a["why"] = json!(why.trim()); a["aiThread"] = json!(t); filled += 1; } }- if filled == 0 { ok("the spec's budgets match the recorded ones: nothing to amend", &[]); return; }- spec_freeze::write(&r.dir, &rec);- r.log("spec-amend-why", json!({"why": why.trim(), "amendments": filled}));- ok(&format!("reason recorded for {filled} budget change(s) made earlier in the spec"), &["Listed in the finish summary.".into()]);- return;- }- let a = json!({"at": now(), "step": r.data["currentStep"], "why": why.trim(), "changes": changed, "aiThread": t});- rec["budgets"] = spec_freeze::budgets(&spec);- rec["amendments"].as_array_mut().unwrap().push(a.clone());- spec_freeze::write(&r.dir, &rec);- r.log("spec-amend", a);- ok(&format!("spec budgets amended on the record ({} change(s)): {}", changed.len(), changed.join("; ")), &["The amendment is listed in the finish summary; the gates now measure against the new values.".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();- // a pattern board's run (boardkind.rs): the electrical steps are not applicable, the native DRC is the live one- let pattern = verified::pattern_run(&r.data);- let not_applicable = boardkind::finish_lines(&r.data);- if pattern {- if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref())) { missing.push(format!("pattern lane: {why} (start a run on the electrical lane)")); }- if r.data["target"].is_null() { missing.push("native DRC: a pattern board's DRC is finish's live full DRC with the fab rules; bind the board on a test box (start --target <box> --remote-board <path>, bind-remote-board) and finish again".into()); }- }- if o.get("tour-fields").is_some_and(|f| f["notApplicable"] == true) {- let why = boardkind::fields_applies(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref());- if !why.is_empty() { missing.push(format!("fields: recorded as not applicable, but now {} (solve with adom-fields, then `tour fields`)", why.join("; "))); }- }- let placement=o.get("place-land").cloned().unwrap_or(Value::Null);- if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {- missing.push("place verify (saved board or spec changed; verify again)".into());- }- {- let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&r.dir, &r.data)));- missing.extend(handson::finish_missing(&r.data, &seq));- }- let gate = o.get("gate").cloned().unwrap_or(Value::Null);- if !pattern && (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 let Some(m) = decoupling_check::blocks_finish(o.get("decoupling")) { missing.push(m); }- if let Some(m) = placement_intent::blocks_finish(o.get("placement-intent")) { missing.push(m); }- if !mechanical::gate_clear(&gate) {- missing.push("gate: courtyards and part bodies (missing or overlapping; see gate.json \"mechanical\")".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 !pattern && !o.contains_key("land-route") {- missing.push("land route".into());- }- if !pattern && o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {- missing.push("land pours or verify-pours (every planned zone accepted; current inputs required)".into());- }- if !pattern && !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 = live_board(&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) };- let mut acked_peaks: Vec<Value> = Vec::new();- for k in ["analyze-current", "analyze-thermal"] {- if pattern { continue; }- 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().filter(|p| Path::new(p).is_file()).map(|p| aiflow_analyze::fields_report::load(Path::new(p)));- let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();- if let Some(Err(e))=&report { missing.push(format!("{k}: {e}")); }- else if let Some(Ok(report))=report {- let report_path=o[k]["fieldReport"].as_str().unwrap_or("").to_string();- if !fields_observed(&r, Path::new(&report_path)) { missing.push(format!("{k}: the field report was not produced by an Adom Fields solve this run watched (analyze {} again; aiflow solves it)", k.trim_start_matches("analyze-"))); }- // issue #70: only acknowledgements of this copper fingerprint and this Fields spec identity cover a peak- let live_acks = if k == "analyze-current" { aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &ack_copper(live_board.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board)).unwrap_or_else(|_| "unavailable".into()), report["inputIdentity"]["spec"].as_str().unwrap_or("")).0 } else { Vec::new() };- match copper_carry::field_screen_with_acks(&r.data,&report,&spec_of(&r),Path::new(&solved_board),&spec_bytes,k.trim_start_matches("analyze-"),&live_acks) { Err(reason) => missing.push(format!("{k}: {reason}")), Ok((n, c)) => { if let Some(n) = n { eprintln!("{n}"); } acked_peaks.extend(c); } }- } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }- }- }- // issue #75: a switching converter needs Fields' voltages/sources/switching and a solve that modelled it- {- let report = o.get("analyze-current").and_then(|a| a["fieldReport"].as_str()).map(PathBuf::from).filter(|p| p.is_file()).or_else(|| Some(dir.join("fields/fields.json")).filter(|p| p.is_file()))- .and_then(|p| aiflow_analyze::fields_report::load(&p).ok());- if let Some((msg, hints)) = converter::check(&spec_of(&r), converter::calcs_of_run(&r.data).as_ref(), report.as_ref(), Some(&board_now())) {- missing.push(format!("fields: {msg}. {}", hints.join(" ")));- }- }- // issue #52: impedance targets hold on the live board's copper- if !pattern { if let Some(m) = impedance_cmd::finish_check(&board_now(), &spec_of(&r), &solved_board, o.get("analyze-impedance")) { missing.push(m); } }- if let Err(m) = netlist_gate(&r) { missing.push(m); }- if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); }- let silk_warnings = silk_polarity::warnings(&board_now());- for w in &silk_warnings { eprintln!("Warning: {w}"); }- // issue #163: the Fab layer standard (a warning with the worst offenders, never an open item)- { let b = board_now(); if mechanical::applies(&b.path) { let fab = fab_layer::check(&b); for f in fab["findings"].as_array().into_iter().flatten().filter_map(Value::as_str) { eprintln!("Warning (Fab layer, not blocking): {f}. {}", fab_layer::TIDY_HINT); } } }- // issue #97: every label, LED and programming check says what it found, on a pass too- let label_checks = silk_polarity::label_checks(&board_now());- let mut check_lines = silk_polarity::label_check_lines(&label_checks);- // 0.2.18: every LED's on/off state, and a programmable part's interface where the workcell reaches it- let led_check = led_states::check(&solved_board, &board_now());- check_lines.push(led_states::summary(&solved_board, &board_now(), &led_check));- // the human's own preferences: listed, never blocking (they are taste, not the fab's limits)- let pref = spec_soft(&r)["fab"]["preferences"].clone();- if pref.is_object() {- let found = prefs::findings(&board_now(), &pref);- check_lines.push(if found.is_empty() { "preferences: all of the human's own preferences met".to_string() } else { format!("preferences (warnings, not blocking): {}", found.join("; ")) });- }- let led_file = match led_check { Ok(p) => p, Err(p) => { missing.push(led_states::gate_message(&p)); None } };- let prog_check = programming::check(&solved_board, &board_now(), &spec_soft(&r));- check_lines.push(programming::summary(&solved_board, &board_now(), &spec_soft(&r), &prog_check));- if let Err(p) = prog_check { missing.push(programming::gate_message(&p)); }- for l in &check_lines { eprintln!("{l}"); }-- let spec_f = spec_of(&r);- if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) {- if let Some(w) = warning { eprintln!("Warning: {w}"); }- if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { missing.push(format!("fab rules ({e}; {hint})")); }- }- 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("; "))); }- }- // issue #39: courtyards, part bodies and the pours in the saved board (the board that ships)- // Altium: the live board is a fresh render of the board of record (its courtyards and fab- // outlines from the parts' source footprints, Altium's own fills), so the facts hold for it- let facts = ((live_board.is_none() || aiflow_bridge::eda::active().id() == "altium") && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r)));- match &facts {- Some(f) => {- for n in &f.mech.severity_notes { eprintln!("Warning: {n}"); }- for e in f.mech.errors() { missing.push(format!("mechanical: {e} ({})", f.mech.hint())); }- for p in &f.fill_problems { missing.push(format!("pours in the saved board: {p} ({})", fills::HINT)); }- }- None => eprintln!("Warning: courtyards, bodies and saved pours not checked here (Fusion board); the summary says so"),- }- {- let (labels, fps) = provenance::silk_labels(&board_now());- // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,- // which now carries its silkscreen text (issue #74); `fusion silkscreen`'s proof is the second source.- if aiflow_bridge::eda::active().id() == "fusion" {- let proof = std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());- let (m, w) = provenance::fusion_silk(labels, fps, proof.as_ref());- missing.extend(m);- if let Some(w) = w { eprintln!("Warning: {w}"); }- }- if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {- missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT));- }- let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));- if !proxies.is_empty() {- missing.push(format!("3D models: {} footprint(s) use the laser-etch proxy box as their model ({}): fetch or build the real part's model (wiki component page, vendor STEP, adom-step)", proxies.len(), proxies.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().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;- let mut live_rec: Option<Value> = None;- let mut silk_advisories: Vec<String> = Vec::new();- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let br = Bridge { ai_thread: t.clone(), target: target.clone() };- live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));- let eda = aiflow_bridge::eda::active();- if eda.caps().file_backed_board {- let st = br.status();- let kv = st["kicadVersion"].as_str().or(st["data"]["kicadVersion"].as_str()).unwrap_or("").to_string();- let text = std::fs::read_to_string(r.current_board()).unwrap_or_default();- if !text.contains("(generator \"adom-aiflow-fusion\")") {- if let Err(e) = provenance::saved_by_live_kicad(&text, &kv) { missing.push(format!("board file: {e}")); }- }- }- let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);- // Owner-reviewed contacts are revalidated, by exact identity, at finish.- // Never turn their number into a generic inherited-error allowance.- let mut contacts_at_finish = Value::Null;- let contact_declared = spec_f["placement"]["approvedCourtyardContacts"].as_array().is_some_and(|a| !a.is_empty());- if contact_declared && !pattern {- let rb = r.data["remoteBoard"].as_str().unwrap_or("");- let before = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true}));- let validation = br.call("kicad_placement_validate", &json!({"filePath": rb}));- let after = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true}));- let checked = placement_verify::check_with_spec(&board_of(&r), &before, &validation, &after, rb, &spec_f);- write_json(&dir.join("finish-assembly-contacts.json"), &json!({"before":before,"validation":validation,"after":after}));- match checked {- Ok(v) if !v.approved_contacts.is_empty()- && validation["drc"]["unconnected"].as_u64() == Some(0)- && validation["drc"]["unconnectedReportMayBeTruncated"] == false- && live["errors"] == validation["drc"]["errors"] => contacts_at_finish = v.to_json(),- Ok(_) => missing.push("finish: native contact/connectivity reports do not reconcile".into()),- Err(e) => missing.push(format!("finish: reviewed assembly contacts: {e}")),- }- }- if live["unconnected"].as_u64() != Some(0) || (live["errors"].as_u64().unwrap_or(u64::MAX) > inherited && contacts_at_finish.is_null()) {- missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));- }- // KiCad's full DRC on the live board, warnings included: the manufacturing findings, and the silkscreen- // findings (ink over a mask opening, ink on ink, ink past the edge, text under the fab minimum), which are- // advisories unless fab.silkBlocking opts in (issue #92); the routing validate above counts errors only- if !eda.caps().full_drc {- eprintln!("finish: {} has no full DRC through its bridge; the silkscreen findings below are not checked (recorded as a gap)", eda.name());- r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));- }- let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null };- // the live result is the run's DRC evidence (issue #86): the pattern lane's DRC and connectivity- // items read it, bound to the board it ran on; recorded even when finish refuses- let silk_blocking = silk_policy::blocking(&spec_f, &r.current_board());- let mut rec = verified::live_drc_record_with(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited, silk_blocking);- rec["reviewedAssemblyContacts"] = contacts_at_finish; // raw counts and clean=false stay honest- if pattern && rec["clean"] != true {- let f = &rec["fullDrc"];- if f["ran"] != true { missing.push(format!("native DRC: the live full DRC returned no result ({}); finish again with the board open on {target}", f["error"].as_str().unwrap_or("no reply"))); }- else if f["errors"] != 0 || f["unconnected"] != 0 || f["manufacturingWarnings"] != 0 { missing.push(format!("native DRC: the live full DRC has {} error(s), {} unconnected, {} manufacturing warning(s) {}", f["errors"], f["unconnected"], f["manufacturingWarnings"], f["manufacturingWarningTypes"])); }- }- live_rec = Some(rec);- let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).or(drc["data"]["violations"].as_array()).cloned().unwrap_or_default();- // silkscreen findings are advisories unless the spec or the rules profile opts in (issue #92)- let (silk, refusal) = silk_policy::finish_findings(&found, silk_blocking);- if let Some(m) = refusal { missing.push(m); } else if !silk.is_empty() { eprintln!("Warning: {}", silk_policy::advisory_line(silk.len(), &silk)); }- silk_advisories = if silk_blocking { Vec::new() } else { silk };- }- // 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));- // a pattern board with nothing in its BOM has nothing to source; any BOM footprint keeps the check- let nothing_to_source = pattern && preboard::board_refs(&board_now()).0.is_empty();- if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)).filter(|_| !nothing_to_source) {- 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(", "))); }- }- {- let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();- for (a, h, t) in thermalvias::short(&spec_f, &board_now(), &acked) { missing.push(format!("heatsink vias: {a} has {h} of {t} (thermal-vias plan, land)")); }- }- {- // the outline as the board is now: the live snapshot when the tool has one, else the plan board- let edges = outline::kicad_edges(&std::fs::read_to_string(live_board.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default());- let sharp = outline::sharp_corners(&edges);- if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }- }- if let Some(n) = switchnode::note(&spec_f, &board_now()) { eprintln!("{n}"); }- for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); }- // a recorded silkscreen proof is re-checked against the board's own pads and vias (the audit's geometry is supplied)- if let Some(inp) = o.get("silkscreen").and_then(|p| p["input"].as_str()) {- if let Ok(t) = std::fs::read_to_string(inp) {- if let Ok(v) = serde_json::from_str::<Value>(&t) {- for g in silkproof::geometry_gaps(&v, &board_now()) { missing.push(format!("silkscreen audit: {g} (silkscreen-audit --record again on real geometry)")); }- }- }- }- if copper_carry::silk_dropped(&r.data) { missing.push("silkscreen proof (adopt-board changed the board file; `silkscreen-audit --record` again)".into()); }- // the test plan again, on the finished board: every measurement still has a landing- if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {- let list = testplan::tests_of(&read_json(&tp));- let (_, gaps, _) = testplan::check_board(&list, &board_now());- if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); }- }- let (qv, quality_lines) = route_quality::of_board(&board_now(), &spec_soft(&r));- // every required step before the finish line, done in order and proven- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- // issue #60: declared mechanical and wiring reviews, bound to this board, spec and review file- reviews::sync_sequence(&mut r.data);- for g in reviews::finish_gaps(&r.data, Path::new(&r.current_board())) { missing.push(g); }- let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();- if !seq.is_empty() {- let st = sequence::states(&dir, &r.data, &flow_full, &seq);- for b in sequence::blockers(&st, "finish") { missing.push(format!("step {b}")); }- sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE);- }- // parts from the board's own project library stay visible as evidence (issue #66)- let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);- let lib_parts = project_lib::report(&parts_v);- for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }- if let Some(rec) = live_rec.take() { r.outcome(verified::LIVE_DRC, rec); r.save().ok(); }- r.outcome("finish-checks", json!({"powerInputs": label_checks["powerInputs"], "testPoints": label_checks["testPoints"], "lines": check_lines})); r.save().ok();- let (bs, ss) = run_hashes(&r);- let mut items: Vec<String> = missing.iter().map(|m| deviations::key(m)).collect();- items.dedup();- let (missing, deviated) = deviations::split(missing, &deviations::live(&r.data, &bs, &ss));- r.data["lastRefusalItems"] = json!(items);- r.save().ok();- // a report card, never a refusal (John, 2026-11-04): what is still open is listed with suggestions- let open_items = missing.clone();- if !open_items.is_empty() {- eprintln!("Open items (suggestions, the board is not blocked):\n {}", open_items.join("\n "));- }- if !deviated.is_empty() {- // finish-guard honours the steps these deviations left unproven, on this board and spec only- let before_finish: Vec<String> = pending_steps(&r).into_iter().take_while(|s| s != "finish").collect();- r.data["deviatedSteps"] = json!({"steps": before_finish, "boardSha256": bs, "specSha256": ss});- }- 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 sink_assumptions = o.get("analyze-thermal").map(|x| x["boardSinkAssumptions"].clone()).filter(|v| v.is_array()).unwrap_or(json!([]));- let mut reviewed: Vec<String> = acked_peaks.iter().map(|c| format!("acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} ({}): \"{}\" by {}", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))).collect();- reviewed.extend(sink_assumptions.as_array().into_iter().flatten().filter_map(|a| a.as_str()).map(|a| format!("board-sink assumption: {a}")));- let checked = verified::run_items(&r, facts.as_ref());- let summary = json!({"verified": verified::to_json(&checked), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "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, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions});- r.data["summary"] = summary;- if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }- r.data["summary"]["projectLibraryParts"] = json!(lib_parts);- r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);- r.data["summary"]["silkAdvisories"] = json!({"count": silk_advisories.len(), "list": silk_advisories.iter().take(50).collect::<Vec<_>>()});- if let Some(lf) = &led_file {- let sha = sha256_file(&lf.display().to_string());- r.data["summary"]["ledStates"] = json!({"path": lf.display().to_string(), "sha256": sha});- if let Some(rel) = led_states::record_in_release(&solved_board, lf, &sha) { eprintln!("ledStates recorded in {}", rel.display()); }- }- r.data["summary"]["notApplicable"] = json!(not_applicable);- r.data["summary"]["deviations"] = json!(deviated);- r.data["summary"]["open"] = json!(open_items);- // issues #127/#128: route geometry and signal quality of the saved board, report-card lines that never block- let qp = dir.join("finish-route-quality.json");- write_json(&qp, &qv);- r.data["summary"]["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string());- if nothing_to_source { r.data["summary"]["notApplicable"].as_array_mut().unwrap().push(json!("sourcing check: not applicable (no footprint in the BOM)")); }- 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 {}, {:.1} min\npours: {}\n{}{}", r.data["engine"], match (open_items.len(), deviated.len()) { (0, 0) => "passing the configured screens".to_string(), (0, d) => format!("passing the configured screens except {d} recorded deviation(s), listed below"), (o, d) => format!("with {o} open item(s) still worth a look{} (listed above; finish again after any change)", if d > 0 { format!(" and {d} recorded deviation(s)") } else { String::new() }) }, r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &na_text(&r.data["summary"]["notApplicable"]) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n {}", reviewed.join("\n ")) } + &if deviated.is_empty() { String::new() } else { format!("\nfinished with {} recorded deviation(s) (checks this board did not meet, accepted on purpose):\n {}", deviated.len(), deviated.iter().map(|d| format!("{}: {} (why: {})", d["item"].as_str().unwrap_or(""), d["line"].as_str().unwrap_or(""), d["why"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("\n ")) } + &if quality_lines.is_empty() { String::new() } else { format!("\n{}", quality_lines.join("\n")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "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(), results_hint(&r), molecule_publish_hint(&r).unwrap_or_default(), give_back_hint()]);- }- Cmd::Capture { action, label, reason, off_page } => {- let mut r = load_run(&cli);- if action == "screen" {- // the human's word, mid-run: "you can take my screen now" -> allowed, until they say otherwise- let m = match label.as_deref() {- None => None,- Some(l) => match screen::Mode::parse(l) {- Some(m) => Some(m.as_str()),- None => { note(&format!("capture screen {l}: not a screen mode; the modes are hands-off, ask, allowed. The mode is unchanged."), &[]); None }- },- };- if let Some(m) = m { r.data["screen"] = json!(m); }- let mode = screen_mode(&mut r);- r.save().unwrap();- let src = r.data["screenMode"]["source"].as_str().unwrap_or("").to_string();- ok(&screen::line(mode, &src), &[if m.is_some() && std::env::var("AIFLOW_SCREEN").is_ok() { "AIFLOW_SCREEN is set in this environment and wins over run.json.".into() } else { String::new() }]);- return;- }- 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 raise = screen_mode(&mut r).may_raise();- let (hwnd, shown) = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box")), raise).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, "window": shown}));- 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(), KICAD_SOCKET_HINT.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, {shown} (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()))));- if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&clipwatch::no_recorder_note(&r.data).unwrap_or_default(), &["Steps still keep their start and end screenshots; `deliver` works without clips.".into()]); }- 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 let Some(why) = stop.failure().filter(|_| p.is_none()) {- err(&format!("recording not collected: {why}"), &["The capture is flagged pullFailed. When the take was still finalising, `adom-aiflow recut` stops and pulls it later; a take Adom Bridge no longer knows cannot be pulled: re-record the step if the clip matters.".into()]);- }- 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);- if r.data["captures"][pos]["truncated"] == true { err(&format!("recording stopped, but the clip {} is truncated (no moov atom: the take died before it was finalised)", p.unwrap_or_default()), &["Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.".into(), clips_note]); }- 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)));- // issue #155: a clip of another project (privacy) leaves the page and the run, not kept as evidence- if *off_page {- let gone = discard_clip_files(&mut r, pos, &format!("off the page by the AI: {why}"));- r.log("clip-discarded", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai", "removed": gone}));- r.save().unwrap();- ok(&format!("clip {tag} taken off the page: {} local file(s) deleted, the capture kept only as a ledger line with your reason", gone.len()), &["The next page publish leaves it out (the page is rebuilt from the run). A page published before keeps it until then: publish again now if it was pushed. Re-take the step if its work should be on camera: `step <name> --back --why ...`.".into()]);- }- 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, reject or screen"), &[]),- }- }- 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()).or_else(|| r.data["lastRoute"]["unrouted"].as_u64().map(|u| format!("{u} (previous board)"))).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) { pourverify::mode_label(&o["land-pours"]) } 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"]) + "\n" + &attention::status_lines(&r.data).join("\n")), &[step_table(&r)]);- }- Cmd::Report { page, page_dir, push, refresh, user_machine, surface } => {- let t = thread(&cli);- let mut r = load_run(&cli);- if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }- 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();- if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }- // no page (issue #85): the run README is written locally and nothing is pushed- let Some(pg) = r.data["page"].as_str().map(str::to_string) else {- let (_, _, readme) = build_report(&r, &dir, None);- ok(&format!("report written locally: {} (no page for this run: nothing pushed{})", readme.display(), if *push || *refresh { "; --push and --refresh need a page" } else { "" }), &["Its media sit beside it and are referenced relatively. To publish it, name the page once: `report --page <owner/slug> [--page-dir <folder>] --push`.".into()]);- return;- };- 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);- if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then recut again. No clip was judged.".into()]); }- 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);- if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }- let mut r = load_run(&cli);- if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }- let cwd = std::env::current_dir().unwrap_or_else(|e| err(&format!("cannot read the working directory: {e}"), &[]));- let outp = compose_out_path(out.as_deref(), &dir, &cwd);- if let Some(parent) = outp.parent() {- if let Err(e) = std::fs::create_dir_all(parent) { err(&format!("compose cannot create the output folder {} for {}: {e}", parent.display(), outp.display()), &["Pass --out <file.mp4> in a folder you can write; a relative --out is resolved against the current working directory, and without --out the video is final.mp4 in the run directory.".into()]); }- }- 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);- // Snapshot even in-run sources: later edits must not rewrite earlier evidence.- let (dest, artifact_hash) = artifact_store::snapshot(&src, &dir)- .unwrap_or_else(|e| err(&e, &["No artifact was registered. Resolve the storage error and retry; existing evidence is preserved.".into()]));- 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, "sha256": artifact_hash, "sourceFile": src.display().to_string()}));- 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: {}", aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 200)), &["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"), &["Continue the original requested scope after supporting tasks and status updates. Use `done --message \"...\"` only at completion, an explicit pause, or a genuine blocking handoff; it stops session accounting, not the engineering run.".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()]);- print_next(&cli);- }- Cmd::FinishGuard { .. } => unreachable!("handled before side effects"),- Cmd::Done { message, stop_evidence } => {- thread(&cli);- let mut r = load_run(&cli);- let verdict = continuation_report(&r, stop_evidence.as_deref());- if verdict["mayStop"] != true {- note("closing this session before the finish line (the run stays open for later prompts)", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]);- }- 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, "stopDecision": verdict}));- 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()), &["Run finish-guard before a final answer. Intermediate progress stays in commentary; done requires completed delivery or explicit stop evidence. Unobserved time is not automatically 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 schematic_files = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy: {e}"), &[]));- 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 netlist_hash = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(text.as_bytes())) };- let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));- let after_export = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy after export: {e}"), &[]));- if schematic_files != after_export { err("schematic hierarchy changed during netlist export; rerun with stable inputs", &[]); }- 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 sch_check = schematic_symbols::check(Path::new(&sch), &run.as_ref().map(spec_soft).unwrap_or(Value::Null));- lines.push(schematic_symbols::summary_line(&sch_check));- if let (Some(r), Ok(sc)) = (run.as_mut(), &sch_check) { schematic_symbols::record(r, &sch, sc); }- // issue #150: KiCad's ERC on the same schematic, recorded so finish knows it (an adopted board- // has no `schematic check`); a kicad-cli that cannot run ERC is a note, never a refusal- if let Some(r) = run.as_mut() {- let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());- let erc_report = dir.join("erc.json");- match schematic_check::run_erc(&kc, &sch, &erc_report) {- Ok((_, errs)) => {- lines.push(if errs.is_empty() { "ERC (kicad-cli sch erc --severity-all): 0 errors, recorded for finish".to_string() } else { format!("ERC (kicad-cli sch erc --severity-all): {} error(s): {}", errs.len(), errs.iter().take(6).cloned().collect::<Vec<_>>().join("; ")) });- r.outcome("erc", json!({"pass": errs.is_empty(), "errors": errs, "schematic": sch, "sha256": sha256_file(&sch), "report": erc_report.display().to_string(), "source": "netlist check (kicad-cli sch erc)"}));- }- Err(e) => note(&format!("ERC did not run, so it is not recorded: {e}"), &["Run `netlist check` again with a kicad-cli that can run `sch erc` (in a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>); finish shows ERC as not known until then.".into()]),- }- }- 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), "schematicFiles": schematic_files, "netlistSha256": netlist_hash, "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("; ")) };- // issue #138: the machine pin and contact names the wiki's model registry counts, from the schematic's references- let names_hint = molecule_pins::names_from_footprints(&sn.refs.iter().map(|(a, b)| (a.clone(), b.clone())).collect::<Vec<_>>()).into_iter().map(|(finding, suggestion)| format!("Molecule names: {finding}. {suggestion}")).collect::<Vec<_>>().join(" ");- if !passed {- // AI Flow never refuses (0.2.48): a mismatch is findings with the ways on- findings(&format!("the board does not match the schematic yet\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, names_hint]);- }- // issue #162: the edge rule as early as the board exists, while moving a fixed contact is one edit- let edge_hint = run.as_ref().map(spec_soft).filter(|sp| sp.get("molecule").is_some()).map(|sp| checks::contact_edge(&b, &sp).1.into_iter().map(|(f, g)| format!("Molecule contacts: {f}. {g}")).collect::<Vec<_>>().join(" ")).unwrap_or_default();- ok(&lines.join("\n"), &[names_hint, edge_hint, "This is electrical parity between schematic and board (plus the ERC line above, when it ran) only: not DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), 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, bom } => {- 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 bom_path = bom.clone().or_else(|| run.as_ref().and_then(run_bom));- let bom_text = bom_path.as_ref().map(|p| std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[])));- let f = checks::molecule_check_bom(&b, &spec_v, before.as_ref(), bom_text.as_deref());- 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, "pinClass": f.pin_class, "bom": bom_path}));- 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() { findings(&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::ThermalVias { action, ack, why, pause_ms } => {- thread(&cli);- let mut r = load_run(&cli);- let spec = spec_of(&r);- if let Some(refs) = ack {- let w = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<why this part needs no more vias>\"", &[]));- for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }- }- let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();- // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it- let b = if action == "plan" { live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));- let sw = switchnode::nets(&spec, &b);- if action == "plan" {- let (parts, plan) = thermalvias::plan_with_acknowledgements(&aiflow_pours::with_project_rules(&spec, &r.current_board()), &b, &sw, &acked);- let pp = dir.join("plan-thermal-vias.json");- write_json(&pp, &plan);- // issue #40: spec.pours against the board's zones; a stale outline makes the counts wrong- let pours_landed = r.outcomes().get("land-pours").map(|p| p["pass"] == true || p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false);- let freshness = thermalvias::pour_freshness(&spec, &b, pours_landed);- for f in freshness.iter().filter(|f| f.starts_with("WARNING")) { eprintln!("{f}"); }- let mut lines: Vec<String> = parts.iter().map(|p| format!(" {:<5} {:<5} target {:>2} existing {:>2} planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!(" ({n})")).unwrap_or_default())).collect();- // issue #136: say which copper each answer rests on, the board's own zones by name- for p in parts.iter().filter(|p| !p.copper.is_empty()) { lines.push(format!(" {} copper read: {}", p.reference, p.copper.join("; "))); }- let board_zones: Vec<String> = thermalvias::board_regions(&spec, &b).iter().filter_map(|z| z["label"].as_str().map(str::to_string)).fold(Vec::new(), |mut v, l| { if !v.contains(&l) { v.push(l); } v });- if board_zones.is_empty() { lines.push(" board zones: none filled on the saved board; only spec.pours was read as copper".into()); }- else { lines.push(format!(" board zones read as copper ({} filled on the saved board): {}", board_zones.len(), board_zones.join("; "))); }- lines.extend(freshness.iter().map(|f| format!(" {f}")));- r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note, "copper": p.copper})).collect::<Vec<_>>(), "pourFreshness": freshness, "boardZones": board_zones}));- r.save().unwrap();- if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }- ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into(), "Copper here is spec.pours plus the saved board's filled zones (the human's zones count before the pours step adopts them); after the human redraws or refills a zone, save the board (adopt-board) and plan again.".into()]);- } else {- let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[]));- // issue #90: an acknowledged part's planned vias stay off the board- let (to_land, left_out) = thermalvias::without_acked(&read_json(&pp), &acked);- let pp = if left_out.is_empty() { pp } else { let lp = dir.join("plan-thermal-vias-landing.json"); write_json(&lp, &to_land); for (a, k) in &left_out { eprintln!("{a}: {k} planned heatsink via(s) left out (acknowledged)"); } lp.display().to_string() };- let n = to_land["nets"].as_array().map(|a| a.len()).unwrap_or(0);- if n > 0 {- let exe = std::env::current_exe().unwrap();- let st = std::process::Command::new(exe).args(["--run", &dir.display().to_string(), "--ai-thread", &thread(&cli), "land", "vias", "--plan", &pp, "--pause-ms", &pause_ms.to_string()]).status().unwrap_or_else(|e| err(&e.to_string(), &[]));- if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); }- }- let r2 = load_run(&cli);- // issue #101: count on the live board (Fusion: its live snapshot; KiCad: kicad_routing_state)- let (short, counted_on) = match live_board(&r2, &cli, &dir) {- Some(l) => (thermalvias::short(&spec, &Board::load(&l.1).unwrap_or_else(|e| err(&e, &[])), &acked), "live board"),- None => match r2.data["remoteBoard"].as_str().filter(|s| !s.is_empty()).and_then(|rb| bridge_of(&r2, &cli).live_copper(rb)) {- Some(live) => (thermalvias::short_live(&spec, &board_of(&r2), &live, &acked), "live board (kicad_routing_state)"),- None => { eprintln!("NOTE: the live board could not be read (kicad_routing_state); the heatsink vias are counted on the saved snapshot, which lacks vias landed since the last adopt-board"); (thermalvias::short(&spec, &board_of(&r2), &acked), "saved snapshot (live read unavailable)") }- },- };- let mut r = r2;- r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked, "leftOutAcked": left_out, "countedOn": counted_on}));- r.save().unwrap();- if !short.is_empty() { findings(&format!("{n} heatsink vias landed, but parts are still short (counted on the {counted_on}): {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }- ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]);- }- }- Cmd::Outline { action, radius } => {- thread(&cli);- let mut r = load_run(&cli);- let rad = radius.or_else(|| spec_soft(&r)["outline"]["cornerRadius"].as_f64()).unwrap_or(1.0);- let eda = aiflow_bridge::eda::active();- let read = |r: &Run| -> Vec<outline::Edge> {- let board = live_board(r, &cli, &dir).map(|l| l.1).unwrap_or_else(|| r.current_board());- outline::kicad_edges(&std::fs::read_to_string(board).unwrap_or_default())- };- let before = outline::sharp_corners(&read(&r));- if action == "check" || before.is_empty() {- // issue #152: `check` records the proof too when nothing is sharp (round would change nothing)- if before.is_empty() { r.outcome("outline", json!({"radius": rad, "rounded": 0, "sharp": 0, "by": action})); r.save().unwrap(); }- if before.is_empty() { ok("the board outline has no sharp corners (recorded: the outline step is proven)", &[]); return; }- err(&format!("{} sharp outline corner(s): {}", before.len(), before.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["`outline round` turns each into an arc (spec outline.cornerRadius, default 1.0 mm).".into()]);- }- let br = bridge_of(&r, &cli);- let rep = eda.round_corners(&|v: &str, a: &Value| br.raw_call(v, a), &before, rad);- if rep["errorCode"].as_str().map(|c| c.starts_with("unsupported_in_")).unwrap_or(false) {- err(&format!("outline round does not land live in {} yet", eda.name()), &["Round the board-outline corners in the tool (the corner radius), save, then `outline check` and `outline round` again to record it.".into()]);- }- let done = rep["rounded"].as_u64().unwrap_or(0);- let after = outline::sharp_corners(&read(&r));- r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()}));- r.save().unwrap();- if !after.is_empty() { findings(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }- ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);- }- Cmd::Timeline { action, port, json: presentation, reuse_tab, new_tab, why, name, finished, stale } => {- if action == "list" {- let (head, lines) = dashboard::list().unwrap_or_else(|e| err(&e, &["`timeline list` reads Hydrogen's tabs; retry when Hydrogen is connected.".into()]));- println!("OK: {head}");- for l in &lines { println!("- {l}"); }- println!("Hint: one tab per run keeps the human's Hydrogen tidy. Close finished runs' tabs with `adom-aiflow --ai-thread <you> timeline close --finished`; a thread reuses its own finished tab automatically.");- return;- }- if action == "close" {- thread(&cli);- let picked = [cli.run.is_some(), name.is_some(), *finished, *stale].iter().filter(|x| **x).count();- if picked != 1 { err("timeline close needs exactly one of --run <dir>, --name \"AI Flow · ...\", --finished, --stale", &["`timeline list` shows every AI Flow tab with its run, thread and whether it finished.".into()]); }- let sel = if let Some(n) = name { dashboard::Close::Name(n.clone()) } else if *finished { dashboard::Close::Finished } else if *stale { dashboard::Close::Stale } else { dashboard::Close::Run(dir.clone()) };- let (closed, stopped) = dashboard::close(&sel).unwrap_or_else(|e| err(&e, &["Only tabs named \"AI Flow · ...\" are ever closed; other tabs are the human's.".into()]));- ok(&format!("closed {} AI Flow tab(s){}{}", closed.len(), if closed.is_empty() { String::new() } else { format!(": {}", closed.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ")) }, if stopped.is_empty() { String::new() } else { format!("; stopped timeline server(s) of {}", stopped.join(", ")) }),- &["Board runs and their files are unchanged; `timeline open --run <dir>` shows a run again.".into()]);- return;- }- let mut r = load_run(&cli);- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- {- let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data));- if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); r.save().unwrap(); }- }- let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));- let tl = sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report());- sequence::write_timeline(&dir, &tl, TIMELINE_PAGE);- let tdir = dir.join("timeline");- match action.as_str() {- "write" => ok(&format!("timeline written: step {} of {}, {} done", tl["position"], tl["total"], tl["done"]), &[]),- "serve" => {- timeline_server::serve(&tdir,*port).unwrap_or_else(|e|err(&e,&["Use the existing run server; do not create another dashboard.".into()]));- }- "present" => {- let caller=thread(&cli);- let patch:Value=serde_json::from_str(presentation.as_deref().unwrap_or("{}")).unwrap_or_else(|e|err(&e.to_string(),&[]));- let shown=dashboard::ensure(&dir,&caller,None,false,&dashboard::OpenOpts::default()).unwrap_or_else(|e|err(&e,&[]));- if let Some(w)=&shown.warning { err(w,&["The presentation goes to this run's own dashboard tab; settle which tab shows this run first.".into()]); }- let config=read_json(&dir.join("dashboard.json").to_string_lossy());- let reply=timeline_server::update(config["port"].as_u64().unwrap() as u16,&caller,&patch).unwrap_or_else(|e|err(&e,&["The existing server must support the current live channel. Restart only this run server after an upgrade.".into()]));- ok(&format!("presentation updated in the existing dashboard: {}",reply["presentation"]), &["SSE updates the open page in place; engineering gates are unchanged.".into()]);- }- _ => {- let ai_thread=cli.ai_thread.clone().or_else(||r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(||"adom-aiflow".into());- let opts=dashboard::OpenOpts{explicit:true,reuse_tab:reuse_tab.clone(),new_tab:*new_tab,why:why.clone()};- let shown=dashboard::ensure(&dir,&ai_thread,Some(*port),true,&opts).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()]));- if let Some(w)=&shown.warning { err(w,&["`adom-aiflow timeline list` shows every AI Flow tab, its run, thread and whether it finished.".into()]); }- let mut hints: Vec<String> = shown.notes.clone();- hints.push("Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into());- ok(&format!("timeline open in Hydrogen as \"{}\": {} (step {} of {})", shown.name.unwrap_or_default(), shown.url.unwrap_or_default(), tl["position"], tl["total"]), &hints);- }- }- }- Cmd::Demo { action, at, port, title } => {- let dir = run_dir(&cli);- if action == "serve" {- let d = dir.join("demo").join("timeline");- if !d.join("timeline.json").exists() { err("nothing to serve yet", &["Run `demo at <step>` first; it writes <run>/demo/timeline/.".into()]); }- timeline_server::serve(&d, *port).unwrap_or_else(|e| err(&e, &["Pick another --port; the run's live dashboard keeps its own.".into()]));- return;- }- let r = load_run(&cli);- let at = at.clone().unwrap_or_else(|| err("demo at needs a moment", &["`demo at placement`, `demo at routing@2+60`, or an ISO time like 2026-10-18T19:50:00Z.".into()]));- let t = demo::resolve(&r.data, &at).unwrap_or_else(|e| err(&e, &["Step names are the run's (`status` lists them); @n picks the nth visit.".into()]));- let state = demo::rewind(&dir, &t).unwrap_or_else(|e| err(&e, &[]));- let rs = Run::open(&state).unwrap_or_else(|| err("the rewound state did not open", &[]));- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));- let mut tl = sequence::timeline(&state, &rs.data, &flow_full, &seq, &rs.step_report());- let d = demo::write(&dir, &t, &mut tl, TIMELINE_PAGE, title.as_deref()).unwrap_or_else(|e| err(&e, &[]));- ok(&format!("demo at {t}: step {} of {}, {} done, {} loop back(s), current {}; written to {}", tl["position"], tl["total"], tl["done"], tl["loops"].as_array().map(|a| a.len()).unwrap_or(0), tl["current"], d.display()),- &["`demo serve --port 8894` serves it (the run's live dashboard is untouched); an open page updates in place on the next `demo at`.".into()]);- }- Cmd::Review { kind, input, glb } => {- thread(&cli);- let mut r = load_run(&cli);- let res = reviews::run(&mut r, kind, input.as_deref(), glb.as_deref());- r.save().unwrap();- // a review that ran and found problems is findings; a review that could not run (no tool, bad input) is an error- match res { Ok((m, h)) => ok(&m, &h), Err((m, h)) if m.contains("did not pass") => findings(&m, &h), Err((m, h)) => err(&m, &h) }- }- Cmd::Testplan { action: _, tests, netlist, add } => {- thread(&cli);- let mut r = load_run(&cli);- let tp = tests.clone().or_else(|| r.data["testPlan"].as_str().map(str::to_string)).unwrap_or_else(|| err("testplan check needs --tests tests.json the first time", &["Write tests.json: [{id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}], one per measurement you will take on the probing workcell (skill aiflow-test-plan).".into()]));- let tv = read_json(&tp);- let list = testplan::tests_of(&tv);- if list.is_empty() { err(&format!("{tp} has no tests"), &["A board with nothing to measure has not been thought through: list the rails, the switch node, feedback, ripple, enable and every LED.".into()]); }- let tp_abs = std::fs::canonicalize(&tp).map(|p| p.display().to_string()).unwrap_or(tp.clone());- r.data["testPlan"] = json!(tp_abs);- let has_board = r.data["boards"].as_array().map(|a| !a.is_empty()).unwrap_or(false);- let (rows, gaps, notes, added, mode) = if has_board {- let (rows, gaps, warn) = testplan::check_board(&list, &board_of(&r));- (rows, gaps, warn, Vec::new(), "board")- } else {- let np = netlist.clone().unwrap_or_else(|| err("before the board, testplan check needs --netlist <netlist.json>", &[]));- let mut nl = read_json(&np);- let (mut rows, mut gaps, unknown) = testplan::check_netlist(&list, &nl);- if !unknown.is_empty() { err(&format!("tests name nets the netlist does not have: {}", unknown.join(", ")), &["Use the netlist's own net names (or rename the net in the design so the measurement has a name).".into()]); }- let fixable = testplan::addable(&rows);- let added = if *add && !fixable.is_empty() {- let a = testplan::add_test_points(&mut nl, &fixable); write_json(Path::new(&np), &nl);- // issue #143: the footprint, symbol and 3D model come with the binary; the wiki page is private- match testplan::write_test_point_files(Path::new(&np).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."))) {- Ok(to) => eprintln!("test point footprint, symbol, Fusion library and 3D model: {}", to.display()),- Err(e) => eprintln!("WARNING the test point files could not be written: {e}"),- }- // the table and the gaps as the netlist now stands: only a ground test with no contact is left- let again = testplan::check_netlist(&list, &nl); rows = again.0; gaps = again.1;- a- } else { vec![] };- (rows, gaps, vec![], added, "netlist")- };- let table: Vec<String> = rows.iter().map(|x| format!(" {:<10} {:<12} {:<9} {}", x["id"].as_str().unwrap_or(""), x["net"].as_str().unwrap_or(""), x["kind"].as_str().unwrap_or(""), match x["status"].as_str().unwrap_or("") { "gap" => format!("GAP: {}", x["why"].as_str().unwrap_or("no test point")), s => format!("{s} {}", x["spot"].as_object().map(|o| format!("{} at ({}, {})", o["pad"].as_str().unwrap_or(""), o["x"], o["y"])).or_else(|| x["spot"].as_str().map(str::to_string)).unwrap_or_default()) })).collect();- r.outcome("testplan", json!({"mode": mode, "tests": rows, "gaps": gaps, "added": added, "notes": notes, "file": tp_abs}));- r.save().unwrap();- let head = format!("test plan ({mode}): {} measurements, {} probe-able, {} gap(s){}", rows.len(), rows.iter().filter(|x| x["status"] != "gap").count(), gaps.len(), if added.is_empty() { String::new() } else { format!("; added {}", added.join(", ")) });- let body = format!("{head}\n{}", table.join("\n"));- if !gaps.is_empty() {- let tether_hint = "A ground test needs a free MachineContactMedium on that net for the tether's medium pin: add one to the design (a test point or a large contact cannot take it).".to_string();- if testplan::ground_gap(&rows) && testplan::addable(&rows).is_empty() { findings(&body, &[tether_hint]); }- err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]);- }- let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];- hints.extend(notes.iter().map(|n| format!("Note: {n}")));- if mode == "netlist" { hints.push("Run it again once the board is placed (`testplan check`): a test point under a part or out of tether reach is a gap again. finish runs it too.".into()); }- ok(&body, &hints);- }- Cmd::Rules { action, profile, board, force, layers, variant, prefs } if action == "use" || action == "list" => {- let page = "adom/pcb-design-rules";- let list = || -> Vec<String> {- let o = std::process::Command::new("adom-wiki").args(["api", "/api/v1/pages/adom/pcb-design-rules/files?path=kicad"]).output().ok();- let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);- let mut out: Vec<String> = v["files"].as_array().cloned().unwrap_or_default().iter().filter_map(|f| f["name"].as_str()).filter(|n| n.ends_with(".kicad_dru")).map(|n| n.trim_end_matches(".kicad_dru").to_string()).collect();- out.sort(); out- };- if action == "list" {- let l = list();- if l.is_empty() { err(&format!("could not read the rule packs on {page}"), &["Check `adom-wiki whoami` and the wiki's health, then retry.".into()]); }- // published packs end in -<n>L; anything after that is a variant someone published on top of one- let is_variant = |n: &str| n.rsplit_once("L-").is_some_and(|(a, _)| a.rsplit('-').next().is_some_and(|d| d.chars().all(|c| c.is_ascii_digit()) && !d.is_empty()));- let (vars, pubs): (Vec<String>, Vec<String>) = l.into_iter().partition(|n| is_variant(n));- ok(&format!("{} published rule packs on {page} (<fab>[-<process>]-<n>L), the fab's own limits:\n {}{}", pubs.len(), pubs.join("\n "), if vars.is_empty() { String::new() } else { format!("\n{} variant(s) people published on top of a pack (<pack>-<variant>, opt-in):\n {}", vars.len(), vars.join("\n ")) }), &["Ask the human which fab and which set before choosing: the published pack (the default, the fab's own limits), a variant, or their own preferences (`rules use --prefs <file.json>`). `rules use --profile <fab> --layers <n> [--variant <name>]`.".into()]);- return;- }- let mut r = load_run(&cli);- let asked = profile.clone().unwrap_or_else(|| "3rdPartyFab".into());- // `--profile jlcpcb-6L` names the layers too; --layers must agree with it- let (asked_base, asked_layers) = checks::split_profile(&asked);- if let (Some(a), Some(b)) = (asked_layers, *layers) { if a != b { err(&format!("--profile {asked} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }- let cb = board.clone().unwrap_or_else(|| r.current_board());- let layers: u32 = layers.or(asked_layers).or_else(|| checks::board_copper_layers(&cb)).unwrap_or(2);- let slug = match asked_base.as_str() { "fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };- let shown = if slug == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { slug.clone() };- let variant = variant.clone().map(|v| v.trim().to_ascii_lowercase()).filter(|v| !v.is_empty());- let file = match &variant { Some(v) => format!("{slug}-{layers}L-{v}.kicad_dru"), None => format!("{slug}-{layers}L.kicad_dru") };- let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, &format!("kicad/{file}")]).output();- let text = o.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.contains("(rule")).unwrap_or_else(|| {- let l = list();- let near: Vec<&String> = l.iter().filter(|n| n.starts_with(&format!("{slug}-{layers}L"))).collect();- err(&format!("no {} for {shown} on {page} ({file})", match &variant { Some(v) => format!("{layers}-layer variant {v:?}"), None => format!("{layers}-layer rule pack") }), &[format!("For {shown} {layers}L there: {}. All packs: `rules list`.", if near.is_empty() { "nothing".into() } else { near.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ") })])- });- // the human's own preferences: kept in the spec and checked by AI Flow as warnings, never written into the- // KiCad rules (KiCad applies one rule per constraint, so a preference rule would replace the fab's limit)- let mut prefs_rec = Value::Null;- if let Some(pf) = prefs {- let pv: Value = serde_json::from_str(&std::fs::read_to_string(pf).unwrap_or_else(|e| err(&format!("{pf}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{pf} is not JSON: {e}"), &[]));- prefs::validate(&pv).unwrap_or_else(|e| err(&format!("{pf}: {e}"), &["A stricter set others should share belongs on adom/pcb-design-rules as a profile (README, Published limits and profiles).".into()]));- prefs_rec = pv;- }- let body = text.clone();- let marked = format!("# adom-aiflow rules: {slug} {layers}L{} (from {page})\n{body}", variant.as_ref().map(|v| format!(" variant {v}")).unwrap_or_default());- let rd = dir.join("rules"); let _ = std::fs::create_dir_all(&rd);- std::fs::write(rd.join(&file), &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));- let mut placed = vec![rd.join(&file).display().to_string()];- let mut replaced_note = String::new();- if cb.ends_with(".kicad_pcb") {- let dru = Path::new(&cb).with_extension("kicad_dru");- let cur = std::fs::read_to_string(&dru).ok();- let cur_pack = cur.as_deref().and_then(checks::pack_of);- let same_pack = cur_pack.as_ref().is_some_and(|(s, l)| *s == slug && l.is_none_or(|l| l == layers));- if cur.is_some() && !*force && !same_pack {- let what = cur_pack.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "other rules".into());- note(&format!("{} carried {what}: replaced with {slug}-{layers}L, the old file kept as .kicad_dru.bak", dru.display()), &[]);- }- if cur.is_some() {- let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak"));- if !same_pack { replaced_note = format!(" (replaced {}; the old file is {})", cur_pack.map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into()), dru.with_extension("kicad_dru.bak").display()); }- }- std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));- placed.push(dru.display().to_string());- // a plan board rendered from another tool has no KiCad project: give it one carrying the same- // fab floors, so the gate's KiCad DRC judges by the fab's rules, not KiCad's defaults- let pro = Path::new(&cb).with_extension("kicad_pro");- if !aiflow_bridge::eda::active().caps().file_backed_board && !pro.is_file() {- let name = pro.file_name().and_then(|n| n.to_str()).unwrap_or("board.kicad_pro").to_string();- std::fs::write(&pro, aiflow_bridge::eda::plan_project_json(&marked, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));- placed.push(pro.display().to_string());- }- }- // Fusion: the bridge loads the same pack's Fusion rules into the open design- let mut fusion_note = String::new();- let mut fusion_loaded = Value::Null;- let eda = aiflow_bridge::eda::active();- if let Some(target) = r.data["target"].as_str().map(str::to_string) {- let br = Bridge { ai_thread: thread(&cli), target };- if let Some(rep) = eda.load_design_rules(&|v: &str, a: &Value| br.raw_call(v, a), &slug, layers, &text) {- let okk = Bridge::ok(&rep);- fusion_loaded = json!(okk);- fusion_note = if okk { format!("; {}: {shown} {layers}-layer rules loaded into the open design", eda.name()) } else { format!("; {} did not load them ({}): open the design and run `rules use` again", eda.name(), rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };- }- }- // the choice: in the spec when there is one, and in the run (an intake run has no spec yet)- r.data["fabRules"] = json!({"profile": shown, "slug": slug, "layers": layers, "file": file, "variant": variant});- if let Some(sp) = r.data["spec"].as_str().map(str::to_string) {- if let Ok(mut v) = std::fs::read_to_string(&sp).map_err(|_| ()).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|_| ())) {- if !v["fab"].is_object() { v["fab"] = json!({}); }- v["fab"]["rules"] = json!(slug);- match &variant { Some(x) => v["fab"]["rulesVariant"] = json!(x), None => { if let Some(o) = v["fab"].as_object_mut() { o.remove("rulesVariant"); } } }- if !prefs_rec.is_null() { v["fab"]["preferences"] = prefs_rec.clone(); }- write_json(Path::new(&sp), &v);- }- }- let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();- r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "variant": variant, "prefs": prefs_rec, "kicad": placed, "native": {"eda": eda.id(), "loaded": fusion_loaded}, "fusion": if eda.id() == "fusion" { fusion_loaded.clone() } else { Value::Null }}));- r.save().unwrap();- let set = match &variant { Some(v) => format!(" (variant {v}, opt-in, stricter or different from the fab's published limits)"), None => " (the fab's published limits)".into() };- let pref_now = if prefs_rec.is_null() { spec_soft(&r)["fab"]["preferences"].clone() } else { prefs_rec.clone() };- let pref_lines = if pref_now.is_object() && cb.ends_with(".kicad_pcb") { Board::load(&cb).map(|b| prefs::findings(&b, &pref_now)).unwrap_or_default() } else { Vec::new() };- let pref_note = if pref_now.is_object() { format!(", the human's own preferences kept (checked as warnings: {})", if pref_lines.is_empty() { "all met now".to_string() } else { pref_lines.join("; ") }) } else { String::new() };- ok(&format!("design rules: {shown}, {layers} layers{set}{pref_note}{fusion_note}. KiCad rules at {}{replaced_note}", placed.join(", ")), &[- format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),- "Every later step designs to these: placement checks, routing widths and clearances, the gate's DRC and finish all read them. Design with margin above the minimums.".into(),- "The human can switch at any time: another variant (`rules use --variant <name> --force`), back to the published pack (`rules use --force`), or their own preferences (`--prefs <file.json>`); `rules list` shows what exists.".into()]);- }- Cmd::Rules { action, profile, board, force, layers, .. } => {- 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", &[]));- // the profile: --profile, else the spec's fab.rules, else the run's fabRules (`rules use`)- let target = run.as_ref().and_then(|r| checks::rules_target(&spec_of(r), &r.data["fabRules"]));- if profile.is_none() { if let Some(w) = target.as_ref().and_then(|t| t.2.clone()) { eprintln!("Warning: {w}"); } }- if let (Some(r), Ok(b)) = (run.as_ref(), Board::load(&board_path)) {- let pref = spec_soft(r)["fab"]["preferences"].clone();- if pref.is_object() { for w in prefs::findings(&b, &pref) { eprintln!("Warning (the human's own preference, not blocking): {w}"); } }- }- let recorded_layers = if profile.is_none() { target.as_ref().and_then(|t| t.1) } else { None };- let profile = profile.clone().or_else(|| target.as_ref().map(|t| t.0.clone())).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|jlcpcb-<n>L|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));- match action.as_str() {- "install" => {- let (base, suffix) = checks::split_profile(&profile);- if base != "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 (2 layers); `rules use --profile <fab> --layers <n>` loads any pack on adom/pcb-design-rules.".into()]);- }- if let (Some(a), Some(b)) = (suffix, *layers) { if a != b { err(&format!("--profile {profile} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }- let board_layers = checks::board_copper_layers(&board_path);- let n = layers.or(suffix).or(board_layers).or(recorded_layers).unwrap_or(2);- if let Some(bl) = board_layers.filter(|bl| *bl != n) {- err(&format!("--layers {n} but {board_path} has {bl} copper layers"), &[format!("Install the pack for the board's stackup: `adom-aiflow rules use --profile jlcpcb --layers {bl}`{}.", if bl == 2 { " or `rules install --profile jlcpcb --layers 2`" } else { "" })]);- }- let shipped = checks::shipped_pack(&base, n).unwrap_or_else(|| err(&format!("no shipped jlcpcb {n}-layer rules: adom-aiflow ships only jlcpcb-2L"), &[format!("Load the {n}-layer pack from adom/pcb-design-rules instead: `adom-aiflow rules use --profile jlcpcb --layers {n}` (`rules list` shows the packs).")]));- let dru = Path::new(&board_path).with_extension("kicad_dru");- let cur = std::fs::read_to_string(&dru).ok();- let cur_pack = cur.as_deref().and_then(checks::pack_of);- let describe = |p: &Option<(String, Option<u32>)>| p.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into());- let mut replaced = String::new();- if let Some(c) = &cur {- if cur_pack == Some(("jlcpcb".into(), Some(n))) { let _ = c; ok(&format!("{} already carries the jlcpcb-{n}L profile", dru.display()), &[]); return; }- if !*force {- note(&format!("{} already carried {}: replaced, the old file kept as .kicad_dru.bak beside it", dru.display(), describe(&cur_pack)), &[format!("If the old rules were the ones to keep (the 3rd party fab's, or a pack `rules use` loaded), copy the .kicad_dru.bak back over the jlcpcb-{n}L file.")]);- }- let bak = dru.with_extension("kicad_dru.bak");- std::fs::copy(&dru, &bak).unwrap_or_else(|e| err(&format!("{}: {e}", bak.display()), &[]));- replaced = format!("; replaced {} (kept as {})", describe(&cur_pack), bak.display());- }- std::fs::write(&dru, shipped).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()); }- if let Some(fr) = run.as_ref().map(|r| r.data["fabRules"].clone()).filter(|f| f.is_object()) {- let (fs, fl) = (fr["slug"].as_str().unwrap_or(""), fr["layers"].as_u64());- if fs != "jlcpcb" || fl.is_some_and(|l| l as u32 != n) { hints.push(format!("Note: run.json fabRules still records {fs} {}L from `rules use`; the board now carries jlcpcb-{n}L.", fl.unwrap_or(0))); }- }- ok(&format!("installed the jlcpcb-{n}L rules (JLCPCB published {n}-layer capabilities, cited in the file) at {}{replaced}", dru.display()), &hints);- }- _ => {- let fallback_layers = layers.or(recorded_layers);- let result = checks::rules_check_layers(&profile, &board_path, fallback_layers);- // A read-only check of this run's selected project rules is proof too.- // Do not rewrite custom rules, or qualify a different board/profile.- if let Some(mut r) = run {- let same_board = std::fs::canonicalize(&board_path).ok()- .zip(std::fs::canonicalize(r.current_board()).ok())- .is_some_and(|(a, b)| a == b);- let same_profile = checks::rules_target(&spec_of(&r), &r.data["fabRules"])- .is_some_and(|t| t.0 == profile);- let dru = Path::new(&board_path).with_extension("kicad_dru");- let selected_dru = Path::new(&r.current_board()).with_extension("kicad_dru");- let same_rules = dru == selected_dru || std::fs::canonicalize(&dru).ok()- .zip(std::fs::canonicalize(&selected_dru).ok())- .is_some_and(|(a, b)| a == b);- if same_board && same_profile && same_rules {- r.outcome("rules", json!({- "pass": result.is_ok(), "profile": profile,- "method": "existing-project-check", "board": board_path,- "boardSha256": sha256_file(&board_path),- "file": dru.display().to_string(),- "rulesSha256": sha256_file(&dru.display().to_string()),- "note": result.as_ref().map(|m| m.as_str())- .unwrap_or_else(|e| e.as_str())- }));- r.save().unwrap_or_else(|e| err(&e.to_string(), &[]));- }- }- match result {- Ok(m) => ok(&m, &[]),- Err(e) => findings(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]),- }- }- }- }- 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() { findings(&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;- if aiflow_bridge::eda::active().caps().file_backed_board {- let proof=checked_remote_binding(&r,&br,&rb,&dir).unwrap_or_else(|e|err(&e,&["Use bind-remote-board --remote-board <exact native path> after adopting a different board. Old desktop model results do not qualify the current snapshot.".into()]));- r.data["remoteBinding"]=proof;- }- 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);- // complete modelBindings + viewer classes (KiCad Bridge 1.0.22+); file resolution is not render proof- let mc = aiflow_bridge::model_check_summary(&d);- let missing = mc.missing.clone();- let vis = if mc.hidden.is_empty() { String::new() } else { format!("; NOT visible proof, {}: read/set with kicad_3d_viewer_settings (KiCad closed) and confirm in the 3D frame, never by changing BOM/POS/DNP flags", mc.hidden.join("; ")) };- r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);- r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing, "viewerHidden": mc.hidden, "bindingsComplete": mc.bindings_complete, "evidenceSource": mc.evidence}));- 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 {- findings(&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()]);- }- let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));- if !proxies.is_empty() {- findings(&format!("{} footprint model(s) are the laser-etch proxy box, not the part:\n{}", proxies.len(), proxies.iter().map(|(r, m)| format!(" {r}: {m}")).collect::<Vec<_>>().join("\n")), &["A renamed or resized proxy box is a placeholder, not a 3D model. Use the part's real model: its wiki component page, the vendor STEP (adom-parts-search, chip-fetcher), or build one from the datasheet (adom-step); then `models` again.".into()]);- }- if !unacked.is_empty() {- findings(&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{vis}); 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);- // nothing for Adom Fields to solve (issue #84): the fields step is recorded as not applicable, never faked- if what == "fields" && !r.board_pending() {- let spec_path = r.data["spec"].as_str().unwrap_or("").to_string();- let calcs = converter::calcs_of_run(&r.data);- if Path::new(&spec_path).is_file() && boardkind::fields_applies(&spec_soft(&r), calcs.as_ref()).is_empty() {- r.outcome("tour-fields", json!({"notApplicable": true, "reason": boardkind::FIELDS_NA, "spec": spec_path, "specSha256": sha256_file(&spec_path), "board": r.current_board()}));- r.log("step-not-applicable", json!({"step": "fields", "reason": boardkind::FIELDS_NA}));- r.save().unwrap();- ok(&format!("fields: not applicable ({}); recorded as the fields step's proof (tour-fields), nothing solved or filmed", boardkind::FIELDS_NA), &["finish lists the fields step as not applicable, and refuses this record if the spec later names a load, hot part or signal check (then solve with adom-fields and `tour fields` again). Never add a load only to get a solve.".into()]);- return;- }- }- // 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 host = if local_proxy { container_host() } else { None };- let br = match (on, &host) {- // a box other than the host cannot open the localhost URL: it would film an error page- // (2026-10-20: a run sent the walkthrough to the user's own machine this way)- (Some(box_name), Some(h)) if what == "fields" && !box_name.eq_ignore_ascii_case(h) => err(- &format!("`--on {box_name}` cannot film the Fields app: its URL ({}) is a localhost proxy, and only {h}, the machine running this container, can open it", std::env::var("VSCODE_PROXY_URI").unwrap_or_default().replace("{{port}}", &port.to_string())),- &[format!("Run `tour fields --port {port}` with no --on: it films on {h}. Never move the walkthrough to another machine to get past a window that did not appear; retry it on {h}.")]),- (Some(box_name), _) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },- (_, Some(h)) => { on_note = format!("filmed on {h}, the machine running this container: the Fields URL is a localhost proxy only it can open"); Bridge { ai_thread: t.clone(), target: h.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" => {- // the viewer paints its GL canvas only in front (below): on the human's own machine- // (screen hands-off or ask) it is their call, so nothing is opened, raised or filmed- let mode = screen_mode(&mut r);- if !mode.may_raise() {- r.log("tour-3d-skipped", json!({"step": r.data["currentStep"], "screen": mode.as_str(), "why": "the 3D viewer paints only in front"}));- r.save().unwrap();- ok(&format!("3D walkthrough not run: screen is {} on {}, and the 3D viewer only paints in front, so filming it would take the human's screen for about a minute", mode.as_str(), br.target), &[- "Suggest to the human: run it themselves (KiCad: View > 3D Viewer, then watch it here), or let AI Flow take the screen for it: `capture screen allowed` (or AIFLOW_SCREEN=allowed for one command), then `tour 3d` again, then `capture screen hands-off`.".into(),- "Nothing is missing: finish does not need the 3D walkthrough. Next: step nets, or step capture / finish.".into()]);- return;- }- 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") || (e["kind"].is_null() && e["title"].as_str().is_some_and(|t| t.starts_with("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)) && (if s["canvas"].is_object() { s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8 } else { screenshot_distinct_colors(s["localPath"].as_str().or(s["savedTo"].as_str()).unwrap_or("")).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()]);- }- if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); }- 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() {- // the pour walkthrough: every poured net first, then the loaded, wide and Kelvin ones- for z in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() { if let Some(n) = z["net"].as_str() { if !list.iter().any(|x| x == n) { list.push(n.to_string()); } } }- 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();- // PR #65 (Drew): frame with kicad_zoom_to where the bridge has it, the menu key otherwise, and keep the replies- let callf = |v: &str, a: &Value| br.call(v, a);- let mut zoom_to = true;- let fit0 = clipwatch::frame_view(&callf, &mut zoom_to, editor, &rb, None);- 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 framed = clipwatch::frame_view(&callf, &mut zoom_to, editor, &rb, Some(net));- std::thread::sleep(std::time::Duration::from_millis(2600));- let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();- let shot = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}"));- if let Some(pth) = &shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }- let back = clipwatch::frame_view(&callf, &mut zoom_to, editor, &rb, None);- std::thread::sleep(std::time::Duration::from_millis(1200));- toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"], "framedBy": framed.how, "frameFailed": framed.failed, "screenshot": shot.is_some(), "fitBackFailed": if back.how.is_none() { json!(back.failed) } else { Value::Null }}));- }- 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, "fitFirst": {"framedBy": fit0.how, "failed": fit0.failed}}));- r.save().unwrap();- let (line, hints) = tour_nets_report(&toured, editor);- ok(&line, &hints);- }- "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()]); }- // a server left up by an earlier run answers health just as well, and the walkthrough then- // films that run's board (old models, missing parts): the solve served must be this run's- let served: 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);- if let Some(sb) = served["source"]["board"].as_str() {- let run_dir = std::fs::canonicalize(&dir).unwrap_or(dir.to_path_buf());- let board = std::fs::canonicalize(sb).unwrap_or_else(|_| PathBuf::from(sb));- if !board.starts_with(&run_dir) {- err(&format!("adom-fields on port {port} serves another run's solve ({sb}), not this run's board"), &[- format!("Stop that server (`pgrep -af \"adom-fields serve\"` names it; kill its pid), then solve this board and serve it: `adom-fields analyze --board <this run's board> --spec <spec> --out {}/fields` and `adom-fields serve --fields {}/fields --port {port} &`.", run_dir.display(), run_dir.display())]);- }- }- // issue #75: a switching converter's walkthrough (the step's proof) needs Fields to have modelled- // the converter: the spec's voltages/sources/switching and a solve whose review.converter is set- {- let spec = spec_soft(&r);- let calcs = converter::calcs_of_run(&r.data);- if let Some((msg, hints)) = converter::check(&spec, calcs.as_ref(), Some(&served), Board::load(&r.current_board()).ok().as_ref()) {- r.log("fields-refused", json!({"why": msg}));- r.save().unwrap();- note(&format!("the fields step cannot pass: {msg}"), &hints);- }- }- 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 mut sid = String::new();- let mut open_err = None;- let mut hwnd: Option<i64> = None;- // one more try in a fresh session when the first window never shows (a transient pup miss, 2026-10-20)- // issue #123: macOS pup keeps its window off-screen, where the browser stops painting it- let mut offscreen = false;- for attempt in 0..2 {- if attempt > 0 { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); }- sid = format!("aiflow-fields-{}{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>(), if attempt > 0 { "-retry" } else { "" });- 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"}));- open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().or(op["error"].as_str()).unwrap_or("?").chars().take(200).collect::<String>()) };- // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed- for _ in 0..40 {- std::thread::sleep(std::time::Duration::from_secs(2));- let lw = br.call("pup_list_windows", &json!({}));- let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);- let sessions = lw["sessions"].as_array().or_else(|| inner["sessions"].as_array());- // Only the session created by this invocation owns the recording target.- // A title match can select another user's or another run's Fields window.- let mine = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())));- hwnd = mine.and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))- .filter(|h| *h > 0);- // macOS pup lists its sessions without a window handle: this session's window, once it- // has its title, is found by the desktop's window list, and only when exactly one- // "Adom Fields" window exists (a second one could be another run's)- if hwnd.is_none() && mine.is_some_and(|w| w["title"].as_str() == Some("Adom Fields")) {- // pup keeps its window off-screen in the background, so off-screen windows are listed too- let fw = br.call("desktop_list_windows", &json!({"includeOffscreen": true}));- let inner: Value = fw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);- let rows = inner["data"]["windows"].as_array().or_else(|| inner["windows"].as_array()).cloned().unwrap_or_default();- let named: Vec<_> = rows.iter().filter(|w| w["title"].as_str().is_some_and(|t| t.starts_with("Adom Fields"))).collect();- let all_ours = sessions.map(|r| r.iter().filter(|w| w["title"].as_str() == Some("Adom Fields")).count()).unwrap_or(0) == 1;- if named.len() == 1 && all_ours {- hwnd = named[0]["hwnd"].as_i64().filter(|h| *h > 0);- offscreen = named[0]["isOnscreen"] == json!(false);- }- }- if hwnd.is_some() { break; }- }- 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 for this walkthrough session{})", 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(), match (preboard::proxy_is_local(&proxy), &host) {- (true, Some(h)) if br.target.eq_ignore_ascii_case(h) => format!("This is the right machine: {h} runs this container and is the only one that can open {proxy}. Retry `tour fields --port {port}` here (pup can take longer than this wait on a cold box); never pass --on another machine, which cannot open a localhost URL."),- (true, Some(h)) => format!("The URL {proxy} is a localhost proxy: only {h}, the machine running this container, can open it. `tour fields --port {port}` with no --on films there."),- (true, None) => format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`; ask the user which machine that is rather than guessing."),- _ => 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));- // issue #98: the walkthrough take is capped in length as well as in bit rate- if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); }- // an off-screen window records nothing (#123): put it on screen for the take, then tuck it away- let raised = offscreen && Bridge::ok(&br.call("pup_raise_os_window", &json!({"sessionId": sid, "reason": "aiflow fields walkthrough: an off-screen window is not painted, so it cannot be filmed; lowered again when the take ends"})));- if offscreen { eprintln!("{}", if raised { "the Fields window was off-screen (macOS): raised it on screen for the walkthrough take; it is lowered when the take ends" } else { "WARNING: the Fields window is off-screen (macOS) and pup could not raise it: the take will likely be empty" }); std::thread::sleep(std::time::Duration::from_secs(3)); }- let rid = br.record_window_start_capped(win, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS).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);- // the EMC beat after the app's own walkthrough: the hot loop, the return path under it and- // the radiated estimate against FCC Part 15 class B, held long enough to read- let ps = port.to_string();- let emc_ok = std::process::Command::new("adom-fields").args(["ui", "--port", &ps, "view", "--quantity", "emc", "--camera", "iso"]).output().map(|o| o.status.success()).unwrap_or(false);- if emc_ok {- let _ = std::process::Command::new("adom-fields").args(["ui", "--port", &ps, "toast", "EMC: the switching loop, the ground under its return path, and the radiated estimate against FCC Part 15 class B"]).output();- std::thread::sleep(std::time::Duration::from_secs(20));- r.log("fields-emc", json!({"step": step, "visit": visit}));- }- std::thread::sleep(std::time::Duration::from_secs(2));- let mut clip_file: Option<String> = None;- 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); clip_file = Some(f); }- }- if raised { let _ = br.call("pup_lower_os_window", &json!({"sessionId": sid, "reason": "aiflow fields walkthrough take ended"})); }- 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);- if tour_ok {- r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"], "clip": clip_file, "offscreen": offscreen, "raised": raised}));- }- 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();- // no silent pass (#123): the walkthrough ran, but there is no clip of it- if clip_file.is_none() {- eprintln!("WARNING: the fields walkthrough ran, but the recorder produced no clip of the app window{} (issue #123). The run's video will have no Fields segment: run `tour fields` again{}, or film the app another way and register it with `artifact`.", if offscreen { ", which was off-screen" } else { "" }, if offscreen && !raised { " once pup can raise its window (pup_raise_os_window)" } else { "" });- }- 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()]); }- let page_hint = results_hint(&r); // issue #147- ok(&format!("fields walkthrough {}: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", if clip_file.is_some() { "recorded" } else { "ran, but NO CLIP was recorded (see the WARNING above)" }, issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[page_hint, 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::Handson { action, args, profile, save_as, default, quote, summary, note, notify, screen, timeout, delete } => {- let at = now();- // the profiles live with the human, not the run: listing, the default and deleting need no run- if action == "profiles" {- if let Some(d) = delete { handson::delete_profile(d).unwrap_or_else(|e| err(&e, &[])); }- if *default { let p = profile.clone().or_else(|| args.first().cloned()).unwrap_or_else(|| err("--default needs the profile: `handson profiles --profile <name> --default`", &[]));- if handson::load_profile(&p).is_none() { err(&format!("no profile {p:?}"), &["`handson profiles` lists them.".into()]); }- handson::set_default(&p).unwrap_or_else(|e| err(&e, &[])); }- let list = handson::list_profiles();- let lines: Vec<String> = list.iter().map(|p| {- let st: Vec<String> = p["steps"].as_object().into_iter().flatten().filter(|(_, v)| v["mode"] != "auto").map(|(k, v)| format!("{k} {}", v["mode"].as_str().unwrap_or(""))).collect();- format!("{}{} ({}): {}; the rest {}; notify {}, screen {}", if p["default"] == true { "* " } else { " " }, p["name"].as_str().unwrap_or(""), p["slug"].as_str().unwrap_or(""),- if st.is_empty() { "every step auto".into() } else { st.join(", ") }, p["fallback"].as_str().unwrap_or("auto"), p["notify"].as_str().unwrap_or("hydrogen"), p["screen"].as_str().unwrap_or("hands-off"))- }).collect();- ok(&format!("{} flow profile(s) in {} (* = the default for a new run):\n{}", list.len(), handson::home().join("profiles").display(), lines.join("\n")), &[- "Switch this run: `handson use <slug>`. Make one the default: `handson profiles --profile <slug> --default`. A new one: `handson plan`, then the human saves it from the timeline (or `handson start --save-as \"<name>\"`).".into()]);- return;- }- let read_only = matches!(action.as_str(), "status" | "wait");- let t = if read_only { cli.ai_thread.clone().unwrap_or_default() } else { thread(&cli) };- let mut r = load_run(&cli);- let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&dir, &r.data)));- let tl = dir.join("timeline");- let mut lines = handson::ingest(&mut r.data, &tl);- let step_arg = |what: &str| -> String {- let s = args.first().cloned().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err(&format!("{what} needs the step"), &[]));- if r.data["steps"][&s].is_null() { err(&format!("{s} has not been visited in this run"), &[format!("`step {s}` first; {what} answers for a step's latest visit.")]); }- s- };- let profile_of = |name: &str| handson::load_profile(name).unwrap_or_else(|| err(&format!("no flow profile {name:?}"), &["`handson profiles` lists them (full-ai is built in).".into()]));- match action.as_str() {- "plan" => {- // start from the profile named, the run's own plan, the human's default, or every step auto- let (plan, prof) = if let Some(p) = profile { let v = profile_of(p); (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }- else if r.data["handsOn"]["steps"].is_object() { (handson::plan_of(&r.data), r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()))) }- else if let Some(v) = handson::default_profile() { (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }- else { (handson::normalise(&json!({})).unwrap(), None) };- handson::apply(&mut r.data, &plan, "planning", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "plan", &at);- r.log("handson-plan", json!({"state": "planning", "profile": prof.as_ref().map(|p| p.0.clone())}));- r.save().unwrap();- let note = handson::notify(&r.data, &t, "AI Flow: choose your flow", "Pick, for each step, whether the AI does it, checks in with you, leaves it to you, or follows your instructions.");- ok(&format!("hands-on plan open on the timeline, waiting for the human (starting from {}): {}", prof.as_ref().map(|p| format!("profile \"{}\"", p.1)).unwrap_or_else(|| "every step auto".into()), handson::summary(&r.data)), &[- format!("Tell the human: the AI Flow timeline shows every step with four choices (1 AI takes care of it, 2 AI checks in with you after it, 3 you do it, 4 custom instructions), plus how to notify them and whether you may bring windows to the front. They press Start there and can save it as a named flow (the first one becomes their default). {note}."),- "Then `handson wait` (it returns when they press Start). If they answer in chat instead: `handson set placement=checkin routing=human --note schematic=\"...\"`, then `handson start --save-as \"<their name for it>\"`.".into(),- "Wait for them to confirm before design work: `step` notes every step started while the plan is still open.".into()]);- }- "set" => {- let mut plan = handson::plan_of(&r.data);- if !plan["steps"].is_object() { plan["steps"] = json!({}); }- for a in args { let (k, v) = a.split_once('=').unwrap_or_else(|| err(&format!("{a:?}: write STEP=MODE (modes: {})", handson::MODES.join(", ")), &[]));- let keep = plan["steps"][k]["instructions"].clone();- plan["steps"][k] = json!({"mode": v}); if !keep.is_null() { plan["steps"][k]["instructions"] = keep; } }- for n in note { let (k, v) = n.split_once('=').unwrap_or_else(|| err(&format!("{n:?}: write --note STEP=\"text\""), &[]));- if plan["steps"][k].is_null() { plan["steps"][k] = json!({"mode": "custom"}); }- plan["steps"][k]["instructions"] = json!(v); }- if let Some(n) = notify { plan["notify"] = json!(n); }- // the human's latest word on the screen: the profile's choice replaces an earlier `capture screen`- if let Some(sc) = screen { plan["screen"] = json!(sc); if let Some(o) = r.data.as_object_mut() { o.remove("screen"); } }- let plan = handson::normalise(&plan).unwrap_or_else(|e| err(&e, &[]));- let state = if handson::active(&r.data) { "active" } else { "planning" };- let prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));- handson::apply(&mut r.data, &plan, state, prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);- r.save().unwrap();- ok(&format!("hands-on plan ({state}): {}", handson::summary(&r.data)), &[if state == "planning" { "When the human is happy: `handson start` (`--save-as \"<name>\"` to keep it as a flow profile).".into() } else { "Changed for this run only; `handson start --save-as \"<name>\"` keeps it as a profile.".into() }]);- }- "start" => {- if !r.data["handsOn"]["steps"].is_object() { err("no hands-on plan on this run", &["`handson plan` first.".into()]); }- let plan = handson::plan_of(&r.data);- let first = handson::default_slug().is_none();- // the first flow a human confirms is kept as their preference, so the next board starts from it- let name = save_as.clone().or_else(|| first.then(|| "My flow".to_string()));- let mut prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));- let mut saved = String::new();- if let Some(n) = &name { let s = handson::save_profile(n, &plan, &at).unwrap_or_else(|e| err(&e, &[])); saved = format!("; saved as flow profile \"{n}\" ({s}){}", if first { ", their default from now on" } else { "" }); prof = Some((s, n.clone())); }- if *default { if let Some((s, _)) = &prof { handson::set_default(s).unwrap_or_else(|e| err(&e, &[])); saved.push_str("; now their default"); } }- handson::apply(&mut r.data, &plan, "active", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);- r.log("handson-plan", json!({"state": "active", "profile": prof.as_ref().map(|p| p.0.clone())}));- r.save().unwrap();- ok(&format!("hands-on plan confirmed{saved}: {}", handson::summary(&r.data)), &["Every `step` now prints its mode: check-in steps end with `handson ask`, human steps are handed over and waited for, custom steps print the human's instructions.".into()]);- }- "use" => {- let name = args.first().cloned().or(profile.clone()).unwrap_or_else(|| err("use needs a profile: `handson use full-ai`", &["`handson profiles` lists them.".into()]));- let v = profile_of(&name);- let (s, n) = (v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string());- handson::apply(&mut r.data, &handson::profile_plan(&v), "active", Some((&s, &n)), "use", &at);- r.log("handson-plan", json!({"state": "active", "profile": s}));- r.save().unwrap();- ok(&format!("this run now follows \"{n}\": {}", handson::summary(&r.data)), &["Visits from now on follow it; work already done is not re-reviewed.".into()]);- }- "ask" => {- let s = step_arg("ask");- let sum = summary.clone().unwrap_or_else(|| err("ask needs --summary: what the human should look at, and where", &[]));- let n = handson::ask(&mut r.data, &s, &sum, &at).unwrap_or_else(|e| err(&e, &[]));- r.log("handson-ask", json!({"step": s, "visit": n, "summary": sum}));- r.save().unwrap();- let note = handson::notify(&r.data, &t, &format!("Review {s}"), &format!("AI Flow finished {s} and is waiting for your OK: {}", aiflow_run::clip_text(&sum, 180)));- ok(&format!("check-in on {s} (visit {n}) is open; {note}"), &["Now wait: `handson wait` returns when they press Approve or Request changes on the timeline. If they answer in chat: `handson approve <step> --quote \"<their words>\"` or `handson changes <step> --quote \"...\"`.".into(),- "While you wait, work that does not depend on this step is fine; the next step is refused until they approve.".into()]);- }- v @ ("approve" | "changes" | "done") => {- let s = step_arg(v);- let verdict = match v { "approve" => "approved", other => other };- let q = quote.clone().unwrap_or_default();- let n = handson::record_verdict(&mut r.data, &s, verdict, &q, "chat", &at).unwrap_or_else(|e| err(&e, &[]));- r.log("handson-verdict", json!({"step": s, "visit": n, "verdict": verdict, "quote": q, "by": "chat"}));- r.save().unwrap();- let next = match verdict { "changes" => format!("Make the changes they asked for, then `handson ask {s} --summary \"<what changed>\"` again."), "done" => format!("Now record {s}'s proof from their work: {}.", handson::human_proof(&s, &flow)), _ => "Go on to the next step.".into() };- ok(&format!("{verdict} recorded for {s} (visit {n}), in the human's words"), &[next]);- }- "wait" => {- let before = handson::waiting(&r.data);- let Some(w) = before.clone() else {- for l in &lines { println!("{l}"); }- if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); ok("the human had already answered on the timeline", &["Nothing else is waiting on them: go on (the step hints say what each mode needs).".into()]); }- else { ok("nothing to wait for: no plan, check-in or human step is open", &[]); }- return;- };- let started = std::time::Instant::now();- if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); }- while lines.is_empty() && started.elapsed().as_secs() < *timeout {- std::thread::sleep(std::time::Duration::from_secs(1));- let Some(mut r2) = Run::open(&dir) else { break };- let got = handson::ingest(&mut r2.data, &tl);- // an answer recorded from chat by another command also ends the wait- if !got.is_empty() || handson::waiting(&r2.data) != before { if !got.is_empty() { r2.save().unwrap(); refresh_dashboard(&dir); } lines = got; r = r2; if lines.is_empty() { lines.push("answered from chat".into()); } }- }- let secs = started.elapsed().as_secs();- r.log("handson-wait", json!({"for": w, "humanWaitSeconds": secs, "answered": !lines.is_empty()}));- if lines.is_empty() {- ok(&format!("still waiting after {secs} s for {}", w["what"].as_str().unwrap_or("the human")), &["Run `handson wait` again, or stop and tell the human what you need: finish-guard counts an open check-in as a pause, not an unfinished run.".into()]);- } else {- for l in &lines { println!("{l}"); }- ok(&format!("the human answered after {secs} s"), &[handson::waiting(&r.data).map(|w| format!("Still open: {}.", w["what"].as_str().unwrap_or(""))).unwrap_or_else(|| "Nothing else is waiting on them: go on (the step hints say what each mode needs).".into())]);- }- return;- }- "status" => {- if !lines.is_empty() { r.save().unwrap(); }- for l in &lines { println!("{l}"); }- let h = &r.data["handsOn"];- let st: Vec<String> = seq.iter().map(|s| { let m = handson::mode_of(&r.data, s); format!(" {s}: {m}{}", handson::instructions_of(&r.data, s).map(|t| format!(" (\"{}\")", aiflow_run::clip_text(t, 80))).unwrap_or_default()) }).collect();- ok(&format!("hands-on {} (flow \"{}\"); notify {}, screen {}\n{}\nwaiting: {}", h["state"].as_str().unwrap_or("off"), h["profileName"].as_str().unwrap_or("-"), h["notify"].as_str().unwrap_or("-"), h["screen"].as_str().unwrap_or("-"), st.join("\n"),- handson::waiting(&r.data).map(|w| w["what"].as_str().unwrap_or("").to_string()).unwrap_or_else(|| "nothing".into())), &[]);- return;- }- "off" => {- if r.data["handsOn"].is_object() { r.data["handsOn"]["state"] = json!("off"); r.data["handsOn"]["history"].as_array_mut().map(|a| a.push(json!({"at": at, "state": "off"}))); }- r.log("handson-plan", json!({"state": "off"}));- r.save().unwrap();- ok("hands-on is off for this run: every step is the AI's again (the screen and notification choices revert to the defaults)", &["Their saved profiles are kept; `handson use <profile>` turns it back on.".into()]);- }- _ => unreachable!(),- }- for l in &lines { println!("{l}"); }- refresh_dashboard(&dir);- }- Cmd::Step { name, back, why } => {- // never refused: an out-of-order step is noted and recorded (a return when it goes back)- let mut back_eff = *back;- thread(&cli);- let mut r = load_run(&cli);- for l in handson::ingest(&mut r.data, &dir.join("timeline")) { println!("{l}"); }- 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.iter().chain(reviews::STEPS.iter()) { 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);- // an Altium run's edits live in the open documents until they are saved: a 2026-10-20 run saved- // its board once, when it was built, and the project it handed over was that empty board. Every- // step change saves them (the schematic, then the board, which is left in front)- if let Some(pcb) = r.data["remoteBoard"].as_str().and_then(|b| b.strip_prefix("altium:")).filter(|_| prev.as_deref().map_or(false, |p| p != name.as_str())) {- let br = bridge_of(&r, &cli);- let sch = std::fs::read_to_string(dir.join("altium").join("project.json")).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["schematic"].as_str().map(str::to_string));- for doc in sch.iter().map(String::as_str).chain(std::iter::once(pcb)) {- let s = br.raw_call("altium_save", &json!({"filePath": doc}));- if !Bridge::ok(&s) { eprintln!("WARNING: could not save {doc} ({}): save it in Altium (File > Save) before going on", s["errorCode"].as_str().or(s["error"].as_str()).unwrap_or("?")); }- }- }- // the board's project page gets the step just left as its own sub-readme (evidence, screenshots,- // usage) and a line in the README's build section, in the background so the step is not slowed- if let (Some(pg), Some(pv)) = (r.data["projectPage"].as_str(), prev.as_deref()) {- if pv != name.as_str() {- let helper = dir.join(".aiflow-project-steps.py");- let _ = std::fs::write(&helper, include_str!("../../../tools/project-steps.py"));- let log = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("project-steps.log")).ok();- let mut c = std::process::Command::new("python3");- c.arg(&helper).arg(&dir).arg(pg).arg(pv).stdin(std::process::Stdio::null());- if let Some(l) = log { if let Ok(l2) = l.try_clone() { c.stdout(l).stderr(l2); } }- let _ = c.spawn();- }- }- // a Fusion project simulates the drawn schematic, in front of the human, before the board: placement- // waits until a simulation visit has started after the last schematic visit- if name == "placement" && !*back && aiflow_bridge::eda::project_backend(&dir).is_some() {- let last = |s: &str| r.data["steps"][s]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).last()).map(str::to_string);- if let Some(sch) = last("schematic") {- if last("simulation").map(|sim| sim < sch).unwrap_or(true) {- note("no simulation since the schematic was drawn: simulating before the board catches value mistakes early", &[- "`step simulation`, then run the circuit in the Adom SPICE dashboard and show it to the human: `adom-spice start`, `adom-spice study <study.json>`, `adom-spice run ngspice`, `adom-spice show` (a Hydrogen webview in the right pane, beside the chat).".into(),- "Check start-up, ripple and the load step against the requirements; change values in the schematic when a measurement misses, and say what changed. Record it: `evidence add --kind simulation --file <result.json>`. Then `step placement`.".into()]);- }- }- }- // the required steps, in order: a run that starts now carries its lane's sequence, and no step- // in it can be skipped (sequence.rs)- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- // the lane can still change while only intake is done (the run learns it is a Fusion board from the- // brief or the requirements written during intake)- {- let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data));- if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); }- for s in reviews::sync_sequence(&mut r.data) { eprintln!("{s}: declared in the spec or requirements, required before finish (`review {}`)", s.trim_end_matches("-review")); }- }- // the pattern lane is for boards with nothing to connect (issue #84): say so when this one needs routing- if boardkind::is_pattern(&r.data) && !r.board_pending() && sequence::sequence_key(&dir, &r.data) == boardkind::PATTERN {- let calcs = converter::calcs_of_run(&r.data);- if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), calcs.as_ref())) {- note(&format!("this run is a pattern board (`intake --kind pattern` or requirements boardKind), but {why}"), &["The pattern lane leaves out placement, routing, pours, current, thermal and fields. A board with nets to connect, loads or hot parts takes the electrical lane: start a new run without --kind pattern (and without requirements boardKind).".into()]);- }- }- let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();- // --back is a return: it may only go to an earlier step of the flow, never jump ahead past the checks- if !seq.is_empty() && *back {- if let (Some(pos), Some(cp)) = (sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))) {- if pos > cp { note(&format!("--back goes to an earlier step, but {name} comes after {} in the flow: recorded as a step forward", seq[cp]), &[]); back_eff = false; }- }- }- // hands-on (handson.rs): the human's plan, check-ins and their own steps; a return is never held- let earlier = matches!((sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))), (Some(p), Some(c)) if p < c);- if !*back && !earlier {- let b = handson::blockers(&r.data, &seq, name);- if !b.is_empty() {- r.save().ok();- let wait = handson::waiting(&r.data).map(|w| format!("The run is waiting for {}: `handson wait` (or the human answers in chat). Do other work that does not depend on it meanwhile, or stop and tell them; finish-guard counts this as a pause.", w["what"].as_str().unwrap_or("the human"))).unwrap_or_default();- // never refused (John, 2026-11-04): the human asked to be waited for, so say so plainly and log it- // when the AI goes on anyway; finish lists the missing OK as open and finish-guard reads it as a pause- r.log("handson-proceeded-without-ok", json!({"step": name, "waitingFor": b}));- r.save().ok();- note(&format!("the human asked to approve before {name} (hands-on flow \"{}\") and has not yet:\n {}\nWait for them unless they told you to go on; going on now is logged and listed at finish", r.data["handsOn"]["profileName"].as_str().unwrap_or("this run's plan"), b.join("\n ")), &[wait,- "To change it, the human changes the plan (`handson plan`, or `handson set <step>=auto` when they say so in chat).".into()]);- }- }- if !seq.is_empty() && !*back {- if let Some(pos) = sequence::position(&seq, name) {- let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c));- if let Some(cp) = cur { if pos < cp {- back_eff = true;- note(&format!("{name} comes before {} in the flow: recorded as a return (pass --why to say what sent you back; it shows on the timeline)", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it unless the board and spec come out of the return unchanged. To record a missing proof, run its command from here instead (no return needed).")]);- } }- let st = sequence::states(&dir, &r.data, &flow_full, &seq);- let (bs, ss) = run_hashes(&r);- let live = deviations::live(&r.data, &bs, &ss);- let b: Vec<String> = sequence::blockers(&st, name).into_iter().filter(|x| !live.iter().any(|d| d["item"] == format!("step {}", deviations::key(x)).as_str())).collect();- if !b.is_empty() {- r.data["lastRefusalItems"] = json!(b.iter().map(|x| format!("step {}", deviations::key(x))).collect::<Vec<_>>());- r.save().ok();- let first = b[0].split(' ').next().unwrap_or("").to_string();- r.log("steps-skipped", json!({"to": name, "skipped": b}));- note(&format!("moving to {name} with earlier steps not done yet (finish lists them as open):\n {}", b.join("\n ")), &[- format!("Suggested: come back to `step {first}` when it helps this board (a step counts as done when its own command ran during its latest visit, flows/board.json proofs)."),- format!("If a step does not apply to this board, say why for the record: `adom-aiflow deviate \"step {first}\" --why \"...\"`, and tell the human.")]);- }- }- }- record_usage(&mut r, &format!("step:{name}"));- // issue #152: the board and spec hashes as the step left closes and the new visit opens; a return- // that changes neither does not send later steps back to redo (sequence::unchanged_return)- let (vh_b, vh_s) = run_hashes(&r);- let vh = json!({"board": vh_b, "spec": vh_s});- if let Some(v) = prev.as_deref().and_then(|p| r.data["steps"][p]["visits"].as_array_mut()).and_then(|a| a.last_mut()) { if v["end"].is_null() { v["endHashes"] = vh.clone(); } }- r.step_set(name, back_eff, why.as_deref());- if let Some(v) = r.data["steps"][name.as_str()]["visits"].as_array_mut().and_then(|a| a.last_mut()) { v["hashes"] = vh; }- if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); }- // 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()));- if r.data["captures"][pos]["truncated"] == true { clip_note.push_str(&format!("WARNING: clip {} is truncated (no moov atom): re-record it with `step {} --back --why \"clip truncated\"` if it matters; ", st.as_str().unwrap_or(""), cs.as_str().unwrap_or(""))); }- clip_stopped = true;- }- // Recording can be delegated to another host without changing the EDA target.- // Always settle an existing take above, but never start/focus/capture here when off.- if std::env::var("AIFLOW_AUTO_CAPTURE").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {- clip_note.push_str("automatic step capture disabled; record reviewed clips on the chosen capture host");- r.log("clip-auto-disabled", json!({"step":name,"reason":"AIFLOW_AUTO_CAPTURE"}));- } else {- // the fields step films the app's window through `tour fields`: no editor clip for it- // a Fusion run films Fusion's own window, the board fitted to it first (a remembered- // hwnd can be a KiCad window from before the board, and Fusion keeps whatever zoom it had)- // the window the step works in, from the run's EDA backend: KiCad's PCB editor for this board,- // a one-window tool (Fusion, Altium) with the board fitted to it, and Hydrogen in front for the- // human-facing steps (the chat, the parts pages, the SPICE dashboard, the fields app). A project- // before its board exists (library, schematic) films its tool, never a KiCad window.- let eda = aiflow_bridge::eda::active();- let pre_board = aiflow_bridge::eda::project_backend(&dir).filter(|_| eda.id() == "kicad")- .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:"))));- let cam = pre_board.unwrap_or(eda);- let tx = |v: &str, a: &Value| br.raw_call(v, a);- // issue #132: on the human's own machine (screen hands-off or ask) nothing comes to the front;- // the clip records the window where it is- let raise = screen_mode(&mut r).may_raise();- if !raise { r.log("screen-hands-off", json!({"step": name})); }- if !raise { /* the human's screen is hands-off: film the windows where they are */ } else if let Some(h) = cam.hydrogen_for_step(name) {- let w = if h { br.hydrogen_hwnd() } else { cam.main_window(&tx) };- br.bring_front(w, if h { "Hydrogen" } else { cam.name() });- } else if cam.id() == "kicad" && r.data["sequence"].as_array().map_or(false, |s| s.iter().any(|x| x == "project-page")) {- // a KiCad board from scratch: Hydrogen is the stage for the steps without KiCad (the parts,- // the project page, the analysis apps) and KiCad for the rest; at every new phase the step- // dashboard comes up first, so the viewer sees where the run is- const HYDROGEN: [&str; 11] = ["intake", "rules", "design", "test-plan", "sourcing", "parts", "project-page", "simulation", "current", "thermal", "fields"];- let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();- let phase_of = |st: &str| flow_full["phases"].as_object().and_then(|ph| ph.iter().find(|(_, v)| v.as_array().map_or(false, |a| a.iter().any(|x| x == st))).map(|(k, _)| k.clone()));- let new_phase = prev.as_deref().map_or(true, |p| phase_of(p) != phase_of(name));- if new_phase || HYDROGEN.contains(&name.as_str()) {- br.bring_front(br.hydrogen_hwnd(), "Hydrogen");- if new_phase { ensure_dashboard(&dir, &thread(&cli), true); std::thread::sleep(std::time::Duration::from_secs(6)); }- }- // the board steps: KiCad's PCB editor in front (the schematic and library steps leave KiCad's own window as the AI opened it)- if !HYDROGEN.contains(&name.as_str()) && !matches!(name.as_str(), "library" | "schematic") { br.bring_front(br.pcb_editor_hwnd(), "KiCad"); }- }- let cached = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())));- // issue #59: a step before the board films Hydrogen's dashboard, the AI's window or the tool's own- // window, never an idle editor; nothing meaningful on screen is a recorded skip- let pre_cam = (name != "fields" && clipwatch::preboard_step(name)).then(|| clipwatch::choose_preboard(&br, &*cam, name, r.data["engine"].as_str().unwrap_or("")));- if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if what.starts_with("Hydrogen") { ensure_dashboard(&dir, &thread(&cli), true); } }- let hwnd = match &pre_cam { Some(clipwatch::Camera::Window { hwnd, .. }) => Some(*hwnd), Some(clipwatch::Camera::Skip(_)) => None, None => if name == "fields" { None } else { cam.camera_window(&tx, name, cached) } };- // issue #106: a box whose Adom Bridge has no recorder is said once, then each step keeps screenshots- let (can_record, rec_notice) = clipwatch::recorder(&mut r, &br);- if let Some(n) = &rec_notice { clip_note.push_str(&format!("{n}; ")); }- let disk = if can_record { disk_check(&dir, &br, name) } else { Ok(String::new()) };- 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 {- _ if !can_record => clip_note.push_str(&clipwatch::no_recorder_step(&mut r, &br, &dir, name, 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}));- if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(what); } clip_note.push_str(&format!("filming {what}; ")); }- // issue #155: a clip on a KiCad editor says which one, so a wrong window shows at once- if pre_cam.is_none() && cam.id() == "kicad" { if let Some(t) = aiflow_bridge::editor_title(&br.raw_call("kicad_window_info", &json!({})), h) { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(t); } clip_note.push_str(&format!("filming KiCad's \"{t}\"; ")); } }- 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 matches!(pre_cam, Some(clipwatch::Camera::Skip(_))) => {- let why_skip = match &pre_cam { Some(clipwatch::Camera::Skip(w)) => w.clone(), _ => String::new() };- let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);- if !r.data["clipSkips"].is_array() { r.data["clipSkips"] = json!([]); }- r.data["clipSkips"].as_array_mut().unwrap().push(json!({"step": name, "visit": visit, "at": now(), "why": why_skip}));- r.log("clip-skipped", json!({"step": name, "visit": visit, "why": why_skip}));- clip_note.push_str(&format!("no clip for {name}-{visit}: {why_skip}"));- }- None => clip_note.push_str(&format!("no clip for {name}: no PCB editor {}open on the test box (capture open first)", Some(aiflow_bridge::eda::run_kicad_stem()).filter(|s| !s.is_empty() && cam.id() == "kicad").map(|s| format!("of {s} (another board's editor is never filmed) ")).unwrap_or_default())),- }- }- }- 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 skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());- let board_hints = board_step_hints(&r, name);- let mut hands_hints = handson::step_hints(&r.data, name, &flow_full);- if handson::active(&r.data) && handson::mode_of(&r.data, name) == "human" {- hands_hints.push(handson::notify(&r.data, &thread(&cli), &format!("Your turn: {name}"), &format!("AI Flow handed {name} to you. Tell the AI when you are done (timeline button or chat).")));- }- 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}") }), &[vec![hint, record_hint], hands_hints, skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat());- print_next(&cli);- }- Cmd::ProjectPageCheck { project, live_json } => {- let mut command = std::process::Command::new(aiflow_python());- command.arg("-c").arg(include_str!("../../../tools/project-page-check.py")).arg("--project").arg(project);- if let Some(path) = live_json { command.arg("--live-json").arg(path); }- let output = command.output().unwrap_or_else(|e| err(&format!("project page check could not run: {e}"), &[]));- print!("{}", String::from_utf8_lossy(&output.stdout));- if !output.status.success() { err("project page preflight failed", &["Fix explicit board GLB and project PDF bindings, readable datasheet and 5–8 README screenshots. Read skills/aiflow-project-page/SKILL.md. PDF extraction requires pdftotext.".into()]); }- }- 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 {- note(&format!("{} clip(s) filmed nothing usable (delivering anyway; the video may have gaps):\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() {- note("delivering before `finish`: the board's checks have not been gathered into a finish report yet", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".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()]);- }- if let Some(open) = step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null)) {- err(&format!("not delivered: the board twin's STEP export: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);- }- let board_path = r.current_board();- let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));- let checked = verified::run_items(&r, facts.as_ref());- 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));- if let Some(a) = agy_report(&r) { r.data["summary"]["antigravity"] = a; }- 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"]["verifiedAtDelivery"] = verified::to_json(&checked);- 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"], "camera": c["camera"], "died": c["died"], "resumedAfterGap": c["resumedAfterGap"], "truncated": c["truncated"]})).collect::<Vec<_>>()).unwrap_or_default());- r.data["summary"]["clipSkips"] = r.data["clipSkips"].clone();- 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("; ")));- }- let verified_text = verified::message(&checked);- ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r), clipwatch::no_recorder_note(&r.data).map(|n| format!("\n{n}")).unwrap_or_default()), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "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, molecule_publish_hint(&r).unwrap_or_default(), 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 molecule_publish_tests {- #[test]- fn the_publish_counts_once_the_stage_has_ended() {- // issue #137: `stage end molecule-publish` records it; a started stage is not a publish- assert!(!super::molecule_published(&serde_json::json!({})));- assert!(!super::molecule_published(&serde_json::json!({"stages": {"molecule-publish": {"runs": [{"start": "t", "end": null}]}}})));- assert!(super::molecule_published(&serde_json::json!({"stages": {"molecule-publish": {"runs": [{"start": "t", "end": "u", "result": "adom/x"}]}}})));- assert!(super::MOLECULE_PAGE_HINT.contains("kind molecule") && super::MOLECULE_PAGE_HINT.contains("model_3d"));- }-}--#[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 netlist_freshness_tests {- use super::*;- #[test]- fn gate_rejects_child_only_edits_old_evidence_and_export_edits() {- let dir=std::env::temp_dir().join(format!("aiflow-net-freshness-{}",std::process::id()));- std::fs::create_dir_all(&dir).unwrap();- let sch=dir.join("root.kicad_sch"); let child=dir.join("child.kicad_sch");- let net=dir.join("export.net"); let board=dir.join("board.kicad_pcb");- std::fs::write(&sch,"(kicad_sch (sheet (property \"Sheetfile\" \"child.kicad_sch\")))").unwrap();- std::fs::write(&child,"(kicad_sch)").unwrap();- std::fs::write(&net,"(export (components) (nets))").unwrap();- std::fs::write(&board,"(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")))").unwrap();- let proof=json!({"ok":true,"sha256":sha256_file(sch.to_str().unwrap()),"schematicFiles":schematic_identity::capture(&sch).unwrap(),"netlist":net,"netlistSha256":sha256_file(net.to_str().unwrap())});- let mut run=Run{dir:dir.clone(),data:json!({"schematic":sch,"boards":[board],"outcomes":{"netlist":proof}})};- assert!(netlist_gate(&run).is_ok());- std::fs::write(&child,"(kicad_sch (changed yes))").unwrap();- assert!(netlist_gate(&run).unwrap_err().contains("hierarchy changed"));- std::fs::write(&child,"(kicad_sch)").unwrap();- std::fs::write(&net,"(export (components) (nets))\n").unwrap();- assert!(netlist_gate(&run).unwrap_err().contains("exported netlist changed"));- run.data["outcomes"]["netlist"].as_object_mut().unwrap().remove("schematicFiles");- assert!(netlist_gate(&run).unwrap_err().contains("older check"));- std::fs::remove_dir_all(dir).unwrap();- }-}--#[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_take_the_recorder_lost_is_flagged_pull_failed_with_the_reason() {- // issue #98: "No recording with id" ends the stop with a flag, not a wait- let dir = std::env::temp_dir().join(format!("aiflow-clipstop-c-{}", std::process::id()));- let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "fields", "visit": 1}));- let mut lost = stop(None, false);- lost.reply = json!({"aiflowStopError": "Adom Bridge no longer knows recording rec-3", "aiflowStopPending": false});- assert_eq!(settle_clip_stop(&mut r, 0, &lost, &dir), None);- let c = &r.data["captures"][0];- assert_eq!(c["pullFailed"], true);- assert!(c["stopError"].as_str().unwrap().contains("no longer knows") && c.get("stopPending").is_none());- let ev = clip_stop_event(json!({}), &lost);- assert_eq!(ev["pullFailed"], true);- lost.reply["aiflowStopPending"] = json!(true);- settle_clip_stop(&mut r, 0, &lost, &dir);- assert_eq!(r.data["captures"][0]["stopPending"], true);- 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);- }-}--#[cfg(test)]-mod media_tools_tests {- use super::*;-- #[test]- fn a_missing_ffprobe_is_an_error_naming_it_not_a_blank_clip() {- let e = media_tools_missing(&["aiflow-no-such-ffprobe"]).unwrap_err();- assert!(e.starts_with("aiflow-no-such-ffprobe is not installed"), "{e}");- let e = clip_verdict("/nonexistent/clip.mp4", &["aiflow-no-such-ffprobe", "ffmpeg"]).unwrap_err();- assert!(e.contains("aiflow-no-such-ffprobe") && !e.contains("no frames"), "{e}");- }-- #[test]- fn a_clip_is_left_unjudged_when_the_tools_are_missing() {- let dir = std::env::temp_dir().join(format!("aiflow-media-tools-{}", std::process::id()));- let _ = std::fs::remove_dir_all(&dir);- std::fs::create_dir_all(&dir).unwrap();- let file = dir.join("placement-1.mp4").display().to_string();- std::fs::write(&file, b"not really a video").unwrap();- let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": file, "step": "placement", "visit": 1}]}) };- clip_artifacts_with(&mut r, &json!("placement-1"), &file, 10, &["aiflow-no-such-ffprobe"]);- assert!(r.data["captures"][0].get("suspect").is_none(), "a missing tool must not flag the footage");- let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();- assert!(events.contains(&"clip-check-unavailable".to_string()) && !events.contains(&"clip-suspect".to_string()), "{events:?}");- assert!(blank_check_verdict("no frames") && !blank_check_verdict("rejected by the AI: covered"));- let _ = std::fs::remove_dir_all(dir);- }-}--#[cfg(test)]-mod compose_out_tests {- use super::*;-- /// Issue #146: the final video is 1080p, so nothing on the way to it may be smaller, the overlays- /// keep their proportions on the bigger frame, and only a file a page would refuse is squeezed.- #[test]- fn the_video_is_kept_at_1080p_and_its_overlays_keep_their_size() {- assert_eq!(CUT_SCALE, "scale='min(1920,iw)':-2", "a cut is never enlarged and never below the video's width");- assert!(VIDEO_CEILING_BYTES > 50_000_000 && VIDEO_CEILING_BYTES < 100_000_000, "under the wiki's limit, far over the old 10 MB");- let d = std::env::temp_dir().join(format!("aiflow-146-{}", std::process::id()));- std::fs::create_dir_all(&d).unwrap();- let f = overlay_filters(&d, "ROUTING", "claude · board", 0.0, 0.0, false);- assert!(f.starts_with("scale=1920:-2,"), "{}", &f[..40]);- assert_eq!(FRAME_W as f64 / 1280.0, OVERLAY_SCALE, "the overlays were laid out on a 1280 frame");- assert!(f.contains("fontsize=36:") && f.contains("fontsize=22.5:") && f.contains("boxborderw=15"), "title and label sizes scale with the frame: {f}");- assert!(!f.contains("fontsize=24:"));- let _ = std::fs::remove_dir_all(&d);- }-- #[test]- fn out_resolves_against_the_working_directory_and_defaults_into_the_run() {- let (run, cwd) = (Path::new("/p/aiflow/run-1mm"), Path::new("/p/aiflow"));- assert_eq!(compose_out_path(None, run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));- // issue #80: run-1mm/final.mp4 from /p/aiflow is the run's own final.mp4, not run-1mm/run-1mm/final.mp4- assert_eq!(compose_out_path(Some("run-1mm/final.mp4"), run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));- assert_eq!(compose_out_path(Some("cut.mp4"), run, cwd), PathBuf::from("/p/aiflow/cut.mp4"));- assert_eq!(compose_out_path(Some("/v/cut.mp4"), run, cwd), PathBuf::from("/v/cut.mp4"));- }-- #[test]- fn the_failure_names_the_output_and_ffmpeg_last_lines() {- let stderr = (1..=20).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n") + "\n/p/run-1mm/run-1mm/final.mp4: No such file or directory\n";- let e = compose_error("ffmpeg could not assemble the video and its narration", Path::new("/p/run-1mm/run-1mm/final.mp4"), &stderr);- assert!(e.contains("(output /p/run-1mm/run-1mm/final.mp4)"), "{e}");- assert!(e.contains("No such file or directory") && e.contains("line 20") && !e.contains("line 12\n"), "{e}");- let e = compose_error("x", Path::new("/o.mp4"), " \n");- assert!(e.contains("(ffmpeg printed nothing)"), "{e}");- // a long line keeps its head and tail- let long = format!("{}MIDDLE{}", "a".repeat(1000), "z".repeat(1000));- let e = compose_error("x", Path::new("/o.mp4"), &long);- assert!(e.contains(" ... ") && e.contains("aaaa") && e.ends_with("zzzz"), "{e}");- }-- #[test]- fn a_failed_ffmpeg_keeps_its_stderr() {- assert!(!ffmpeg_ok(std::process::Command::new("sh").args(["-c", "echo first >&2; echo 'Error opening output file' >&2; exit 1"])));- let kept = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());- assert!(kept.contains("Error opening output file"), "{kept}");- assert!(ffmpeg_ok(std::process::Command::new("sh").args(["-c", "exit 0"])));- }-}+ let off = export::off_board(&spec_soft(&r));+ match export::export_with(&board_path, &out_dir, &kicad, &off) {+ Ok(report) => {+ write_json(&out_dir.join("export-check.json"), &report);+ r.outcome("export", report.clone());+ r.save().unwrap();+ let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect();+ hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string));+ hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());+ if let Some(h) = molecule_publish_hint(&r) { hints.push(h); }+ // issue #160: the saved fills are older than the copper: a finding, the files are written+ if report["fillsCurrent"]["pass"] == false {+ let fc = &report["fillsCurrent"];+ let list: Vec<String> = fc["list"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect();+ hints.insert(0, export::STALE_FILLS_HINT.to_string());+ findings(&format!("manufacturing files written to {}, but the saved zone fills are stale: DRC of the saved board without refilling finds {} violation(s) involving zones ({}), and the Gerbers plot those fills\n {}", out_dir.display(), fc["zoneViolations"], fc["zoneViolationTypes"].as_object().map(|o| o.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ")).unwrap_or_default(), list.join("\n ")), &hints);+ }+ let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));+ let checked = verified::run_items(&r, facts.as_ref());+ ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}, pours present as regions on {} poured layer(s)), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)\n{}", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["fillsGerbers"]["layersWithPours"].as_object().map(|o| o.len()).unwrap_or(0), report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"], verified::message(&checked)), &hints);+ }+ Err((report, problems)) => {+ let _ = std::fs::create_dir_all(&out_dir);+ write_json(&out_dir.join("export-check.json"), &report);+ r.log("export-refused", json!({"board": board_path, "dir": out_dir, "problems": problems}));+ r.save().unwrap();+ let fill_hint = if problems.iter().any(|p| p.contains("filled polygon") || p.contains("G36")) { fills::HINT.to_string() } else { String::new() };+ err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &[fill_hint, "Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".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() { findings(&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, page_dir } => {+ if let Some(d) = page_dir { if let Err(e) = pagedir::validate(d) { err(&e, &[]); } }+ 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();+ // the plan board: rendered by the backend --remote-board names (a Fusion export, an Altium+ // read), or the KiCad board copied as it is (eda.rs import_board)+ if let Some(text) = aiflow_bridge::eda::import_board(remote_board.as_deref(), &src_text).unwrap_or_else(|e| err(&e, &[])) {+ std::fs::write(©, 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::eda::set_run_board(remote_board.as_deref().unwrap_or(""));+ } else {+ std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));+ aiflow_copper::copy_project_rules(board, ©.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(©.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 Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+ if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+ if r.data["target"].is_string() { screen_mode(&mut r); }+ 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();+ let mut attach_hints = vec![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()];+ // `rules use` in the intake run recorded fabRules before there was a spec: the gate+ // falls back to it when the spec names no fab.rules, and says when they disagree+ if let Some((p, layers, warning)) = checks::rules_target(&read_json(spec), &r.data["fabRules"]) {+ if let Some(w) = warning { attach_hints.push(format!("Fab rules: {w}.")); }+ if let Err((e, hint)) = checks::rules_gate(&p, ©.display().to_string(), layers) { attach_hints.push(format!("Fab rules: {e}. {hint}")); }+ }+ let rc_flags = rules_card::at_start(&dir, &read_json(spec), ©, &r.data["fabRules"]);+ for f in &rc_flags { eprintln!("WARNING: {f}"); }+ attach_hints.push(rules_card_hint(&rc_flags));+ attach_hints.extend(converter_warning(&r.data, &read_json(spec), None, Board::load(©.display().to_string()).ok().as_ref()));+ 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(", ") }), &attach_hints);+ print_next(&cli);+ return;+ }+ let pt = prompt_time.clone().unwrap_or_else(now);+ let mut r = Run::create(&dir, ©.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 Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+ let hands_line = handson::apply_default(&mut r.data, &now());+ if target.is_some() { screen_mode(&mut r); }+ 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, layers, _)) = checks::rules_target(&spec_v, &Value::Null) { if let Err((e, hint)) = checks::rules_gate(&p, board, layers) { start_hints.push(format!("Fab rules: {e}. {hint}")); } }+ let rc_flags = rules_card::at_start(&dir, &spec_v, ©, &r.data["fabRules"]);+ for f in &rc_flags { eprintln!("WARNING: {f}"); }+ start_hints.push(rules_card_hint(&rc_flags));+ start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(©.display().to_string()).ok().as_ref()));+ 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()]);+ start_hints.extend(hands_line);+ ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);+ print_next(&cli);+ }+ Cmd::Intake { prompt_time, brief, requirements, engine, target, page, page_dir, eda, kind } => {+ 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 mut probe = json!({}); if let Err(e) = pagedir::set(&mut probe, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); } }+ if kind.is_some() && eda.as_deref().is_some_and(|e| e != "kicad") { err("--kind pattern is a KiCad lane", &["Build the pattern board in KiCad (--eda kicad, or leave --eda out).".into()]); }+ 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 Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+ // the EDA tool, named: the step sequence is fixed at the first step after intake, before any+ // backend project exists, so a brief that never says "Altium" must not fall into kicad-new+ if let Some(e) = eda { r.data["eda"] = json!(e); }+ // a passive copper-pattern board: the pattern sequence (boardkind.rs)+ if let Some(k) = kind { r.data["kind"] = json!(k); }+ let hands_line = handson::apply_default(&mut r.data, &now()).unwrap_or_else(|| "Hands-on AI Flow: when the human asks for it (\"hands-on\", \"check in with me after placement\", \"I'll route it myself\"), run `handson plan` now, before any design work: they choose per step on the timeline, and the choice is saved as their flow profile for the next board (skill aiflow-hands-on).".into());+ if target.is_some() { screen_mode(&mut r); }+ 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 before the board films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else your own terminal window (nothing meaningful on screen is a recorded skip): work in a visible window.".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() },+ "Declare every step as you enter it, even when you do that step's work with your own scripts; the timeline, the clips and the video depend on it.".into(),+ hands_line,+ ]);+ print_next(&cli);+ }+ 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);+ design_evidence::exact_mpn_evidence(&text, design_evidence::require_exact(&spec, &req), &mut out);+ 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);+ let (suggestions, suggestion_note, lookup_counts) = part_suggestions(&r.dir, &prof, &out["consignedRows"]["list"]);+ out["suggestions"] = json!(suggestions);+ if !lookup_counts.is_null() { out["suggestionLookups"] = lookup_counts; }+ if let Some(n) = &suggestion_note { out["suggestionsNote"] = json!(n); }+ r.outcome("sourcing", out.clone());+ r.save().unwrap();+ let consigned_warning = out["consignedRows"]["warning"].as_str().unwrap_or("").to_string();+ let mut warnings: Vec<String> = Vec::new();+ for w in out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str()) {+ warnings.push(format!("warning: {w}"));+ if !consigned_warning.is_empty() && w == consigned_warning {+ for sg in &suggestions { if let Some(t) = sg["suggestion"].as_str() { warnings.push(format!(" suggestion: {t}")); } }+ if let Some(n) = &suggestion_note { warnings.push(format!(" {n}")); }+ }+ }+ let rule = if prof == "jlcpcb" { "jlcpcb prefers JLCPCB/LCSC parts with their LCSC number (basic parts first); any other source is fine as consigned parts you send JLCPCB" } else { "fab (the 3rd party fab) prefers Mouser or Adom stock; any other distributor (DigiKey, Arrow, ...) is fine as consigned parts you send the 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);+ findings(&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();+ findings(&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 review: Vec<String> = match (kind.as_str(), serde_json::from_str::<Value>(&text)) { ("calcs", Ok(v)) => design_evidence::calcs_warnings(&v), ("simulation", Ok(v)) => design_evidence::simulation_warnings(&requirements_of(&r), &v), _ => Vec::new() };+ 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, "warnings": review});+ 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 { "" }),+ };+ let mut hints = vec![hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()];+ hints.extend(review.iter().map(|w| format!("warning: {w}")));+ // issue #75: the calcs say what the converter is; a spec that exists already must carry Fields' block+ if kind == "calcs" {+ let spec = spec_soft(&r);+ if !spec.is_null() { hints.extend(converter_warning(&r.data, &spec, None, Board::load(&r.current_board()).ok().as_ref())); }+ }+ ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);+ }+ Cmd::Clips { page, page_dir, push } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if page.is_some() || page_dir.is_some() {+ // --page-dir alone sets the folder of the page the clips already go to+ if page.is_none() && r.data["clipsPage"].is_null() { if let Some(p) = r.data["page"].as_str().map(str::to_string) { r.data["clipsPage"] = json!(p); r.data["clipsPageDir"] = r.data["pageDir"].clone(); } }+ if let Err(e) = pagedir::set(&mut r.data, "clipsPage", "clipsPageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+ r.save().unwrap();+ }+ let (pg, pd) = 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, pd.as_deref(), *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::BindRemoteBoard { remote_board } => {+ thread(&cli);let mut r=load_run(&cli);let br=bridge_of(&r,&cli);+ let proof=checked_remote_binding(&r,&br,remote_board,&dir).unwrap_or_else(|e|err(&e,&["Transfer the current adopted board and unchanged project/rule sidecars to the intended desktop path, then bind again. This never edits the desktop file or accepts a gate.".into()]));+ let old=r.data["remoteBoard"].clone();r.data["remoteBoard"]=proof["remoteBoard"].clone();r.data["remoteBinding"]=proof.clone();r.log("bind-remote-board",json!({"previousRemoteBoard":old,"proof":proof}));r.save().unwrap();+ ok("native board and project rules match the adopted snapshot; desktop binding updated",&["Run models and verify the native 2D/3D views. Binding is not engineering acceptance.".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=run_drc_for(&r, &r.current_board()).unwrap_or_else(|e|err(&e,&[]));+ let candidate=run_drc_for(&r, board).unwrap_or_else(|e|err(&e,&[]));+ let summary=aiflow_copper::summarize_with(&candidate,Some(&base),silk_policy::blocking(&spec_soft(&r),&r.current_board()));+ 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,&[]));+ let (carried, carry_note) = copper_carry::adopt(&mut r, &dest.display().to_string());+ r.data["remoteBinding"]=Value::Null;+ r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary,"copperCarried":carried}));+ r.save().unwrap();+ if carried { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["Silkscreen changed: record the silkscreen proof again (`silkscreen-audit --record`) and export again; finish reruns the full native DRC. Bind the desktop board again (bind-remote-board) before live commands.".into()]); }+ else { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["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::Parts { bom, show, dwell, project_lib } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ let bom = bom.clone().or_else(|| r.data["outcomes"]["sourcing"]["bom"].as_str().map(str::to_string))+ .unwrap_or_else(|| err("no BOM: pass --bom <csv> or pass `sourcing check --bom <csv>` first", &[]));+ let text = std::fs::read_to_string(&bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));+ let mut rows = preboard::bom_parts(&text);+ if rows.is_empty() { err(&format!("{bom}: no rows with an MPN"), &[]); }+ let mut missing = Vec::new();+ for row in rows.iter_mut() {+ let mpn = row["mpn"].as_str().unwrap_or("").to_string();+ // the MPN, then the wiki's slug form of it (AP2112K-3.3TRG1 lives at ap2112k-3-3trg1, issue #76)+ let mut page = None;+ let mut queries = vec![mpn.clone()];+ if let Some(w) = preboard::part_slug_candidates(&mpn).into_iter().skip(1).next() { queries.push(w); }+ // MCP73831T-2ACI/OT's factory page is adom/mcp73831t-2aci (issue #161): the MPN without its "/" suffix last+ if let Some(b) = preboard::mpn_base(&mpn) { queries.push(b); }+ for q in &queries {+ let o = std::process::Command::new("adom-wiki").args(["search", q, "--json"]).stdin(std::process::Stdio::null()).output().ok();+ let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+ let hits = v["data"]["results"].as_array().or(v["data"].as_array()).or(v["results"].as_array()).cloned().unwrap_or_default();+ page = preboard::part_page(&hits, &mpn);+ if page.is_some() { break; }+ }+ if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }+ if let Some(how) = page.as_deref().and_then(|p| preboard::matched_by_base(p, &mpn)) { row["wikiMatch"] = json!(how); }+ row["wiki"] = json!(page);+ }+ // parts with no public page may come from the board's own project library (issue #66)+ let plib = project_lib.clone().or_else(|| spec_soft(&r)["projectLib"].as_str().or(requirements_of(&r)["projectLib"].as_str()).map(str::to_string));+ let mut why_missing: Vec<(String, String)> = Vec::new();+ let mut source_notes: Vec<String> = rows.iter().filter_map(|x| Some(format!("{}: {}", x["mpn"].as_str()?, x["wikiMatch"].as_str()?))).collect();+ if let Some(d) = &plib {+ let lib = project_lib::ProjectLib::load(Path::new(d)).unwrap_or_else(|e| err(&e, &["A project library is a folder with *.kicad_sym, *.pretty/*.kicad_mod, STEP models and provenance.json (see the aiflow-sourcing skill).".into()]));+ // a page and a library entry for one MPN: "prefer": true lets the library's verified entry win (issue #157)+ source_notes.extend(project_lib::prefer(&mut rows, &lib));+ why_missing = project_lib::resolve(&mut rows, &lib);+ missing.retain(|m| why_missing.iter().any(|(w, _)| w == m));+ }+ let from_lib: Vec<String> = rows.iter().filter(|x| x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect();+ let pass = missing.is_empty();+ r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing, "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>(), "sourceNotes": source_notes}));+ r.save().unwrap();+ if *show {+ // one named tab walks through the pages (never a tab per part)+ // ASCII only: Hydrogen's open-or-refresh never matches a name with a non-ASCII character+ // and opens a new tab per call (adom/hydrogen#137)+ let name = format!("Parts - {}", &r.data["id"].as_str().unwrap_or("run")[..8.min(r.data["id"].as_str().unwrap_or("run").len())]);+ for row in rows.iter().filter(|x| x["wiki"].is_string()) {+ let url = format!("https://wiki.adom.inc/{}", row["wiki"].as_str().unwrap());+ let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url, "--ai-thread", &t, "--reason", "adom-aiflow: show the chosen parts"]).stdin(std::process::Stdio::null()).output();+ std::thread::sleep(std::time::Duration::from_secs(*dwell));+ }+ }+ let list: Vec<String> = rows.iter().map(|x| format!(" {:<22} {:<10} {}", x["mpn"].as_str().unwrap_or(""), x["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default(), x["wiki"].as_str().map(str::to_string).or_else(|| x["projectLib"].is_object().then(|| x["wikiOverridden"].as_str().map(|w| format!("project library (preferred over {w})")).unwrap_or_else(|| "project library".to_string()))).unwrap_or_else(|| "MISSING".into()))).collect();+ let list: Vec<String> = if source_notes.is_empty() { list } else { list.into_iter().chain(std::iter::once(" which source each part comes from:".to_string())).chain(source_notes.iter().map(|n| format!(" {n}"))).collect() };+ if !pass {+ let gaps: Vec<String> = why_missing.iter().map(|(m, w)| format!(" {m}: project library: {w}")).collect();+ // AI Flow never refuses (0.2.48): the parts record is saved; the missing parts are findings with the ways on+ findings(&format!("{} of {} parts have no Adom wiki component page{} yet:\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.join("\n")) }), &["Make each missing part's page (the wiki-component skill: symbol, footprint and STEP from the datasheet), then run `parts` again. The project page and the KiCad library are built from these pages.".into(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);+ }+ ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);+ }+ Cmd::ProjectPage { page, results, out, sync, create, existing, private, title, brief, replace_readme, no_push, local_only, page_dir } => {+ let t = thread(&cli);+ let no_push = &(*no_push || *local_only);+ let mut r = load_run(&cli);+ if *results { page_results_cmd(&mut r, &dir, page, out.as_deref(), page_dir.as_deref()); return; }+ if page.is_empty() { err("project-page needs --page <owner>/<slug> (only --results finds the run's page by itself)", &[]); }+ // a folder of an existing page (issue #85): implies --existing, never creates a page+ let page_dir_arg = page_dir.clone();+ let page_dir: Option<String> = page_dir.as_deref().map(pagedir::validate).transpose().unwrap_or_else(|e| err(&e, &[])).flatten();+ if page_dir.is_some() && *create { err("--page-dir writes into a folder of an existing page and never creates one: drop --create (or drop --page-dir for a new page)", &[]); }+ let existing = &(*existing || page_dir.is_some());+ if *sync {+ if let Err(e) = pagedir::set(&mut r.data, "projectPage", "projectPageDir", Some(page.as_str()), page_dir_arg.as_deref()) { err(&e, &[]); }+ r.save().unwrap();+ let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-steps.py")).arg(&dir).arg(page).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));+ let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).to_string()}));+ if v["ok"] != true { err(&format!("step pages: {}", v["error"].as_str().unwrap_or("failed")), &[]); }+ r.data["projectPage"] = json!(page); r.save().unwrap();+ ok(&format!("{} step page(s) and the README's build section pushed to {page} (commit {})", v["steps"].as_array().map(|a| a.len()).unwrap_or(0), v["commit"].as_str().unwrap_or("?")), &[]);+ return;+ }+ let parts = r.data["outcomes"]["parts"].clone();+ if parts["pass"] != true { note("the parts are not all on the wiki yet: the page lists what is there; run `parts` until it passes to add the rest", &[]); }+ if !page.contains('/') { err("--page is <owner>/<slug>", &[]); }+ let bom_path = parts["bom"].as_str().unwrap_or("").to_string();+ let stale = project_lib::stale(&parts, &bom_path, std::fs::read_to_string(&bom_path).ok().as_deref());+ if !stale.is_empty() { note(&format!("the parts record is stale: {}", stale.join("; ")), &[format!("Run `parts` again (`parts --bom <current BOM>{}`), then `project-page` again.", parts["projectLib"].as_str().map(|d| format!(" --project-lib {d}")).unwrap_or_default())]); }+ let (spec, req) = (spec_soft(&r), requirements_of(&r));+ let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::label(&dir, &r.data));+ let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());+ let lane = sequence::lane(&dir, &r.data);+ let cfg = json!({"run": dir.display().to_string(), "eda": lane, "page": page, "pageDir": page_dir, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "localOnly": local_only, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",+ // issue #137: a molecule's page is kind molecule; molecule-publish puts its sources there+ "molecule": molecule_pins::wants_molecule(&spec, &req), // issue #158: or requirements formFactor.kind+ "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())});+ let cfgp = dir.join("project-page").join("config.json");+ let _ = std::fs::create_dir_all(cfgp.parent().unwrap());+ std::fs::write(&cfgp, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();+ let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-page.py")).arg(&cfgp).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));+ let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| json!({"ok": false, "error": aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 600)}));+ if v["ok"] != true {+ if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); r.save().unwrap(); }+ err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &["Nothing was published. Rate-limited adds are retried with backoff; rerun `project-page` after the wait and it reuses the same Cloud Library (never a second one).".into()]);+ }+ r.outcome("project-page", v.clone());+ r.data["projectPage"] = json!(page);+ r.data["projectPageDir"] = json!(page_dir);+ if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); }+ r.save().unwrap();+ if !no_push {+ // a webview tab has no wiki session: a private page would show "Page not found", so open the page+ // through a single-use browser-session link (adom-wiki auth login; never printed)+ // (every time, not only with --private: an --existing page may be private too)+ let url = signed_in_wiki_url(page).unwrap_or_else(|| v["url"].as_str().unwrap_or("").to_string());+ let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "Project page", "--url", &url, "--ai-thread", &t, "--reason", "adom-aiflow: the board's project page, revision 1"]).stdin(std::process::Stdio::null()).output();+ }+ // --no-push builds everything locally: never claim the page was created or updated (issue #82)+ let page_said = if *local_only { format!("project page built locally in {} (--local-only: no wiki call; {} was not created or updated)", dir.join("project-page").display(), v["url"].as_str().unwrap_or(page)) }+ else if *no_push { format!("project page built locally in {} (--no-push: nothing pushed to {}; it was not created or updated{})", dir.join("project-page").display(), v["url"].as_str().unwrap_or(page), v["cloudLibrary"].as_str().map(|l| format!("; the wiki parts were added to your private Cloud Library {l} to export their files, `--local-only` skips that")).unwrap_or_default()) }+ else { format!("project page {} ({})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }) };+ if lane == "altium" {+ let from_lib: Vec<&str> = v["projectLibParts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+ ok(&format!("{}: README revision 1 with {} parts and the component table (Altium Designer: the library step builds the SchLib and PcbLib from these same wiki pages{})", page_said, v["parts"], if from_lib.is_empty() { String::new() } else { format!(" and, for {}, from the board's project library", from_lib.join(", ")) }),+ &[if from_lib.is_empty() { String::new() } else { format!("from the board's project library (no public page yet): {}", from_lib.join(", ")) },+ if v["kind"] == "molecule" { MOLECULE_PAGE_HINT.into() } else { String::new() },+ "Next: `step library`, then `adom-aiflow altium library`: one SchLib and one PcbLib from the parts' wiki pages (project-library parts from their own KiCad files, through the same converters). Every step you leave is pushed to this page as steps/<nn>-<step>/README.md.".into()]);+ return;+ }+ let c = &v["counts"];+ let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();+ if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+ if v["kind"] == "molecule" { hints.push(MOLECULE_PAGE_HINT.into()); }+ if let Some(lo) = v["localOnlyLeftOut"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("--local-only left out the wiki parts (their files come from a Cloud Library export, a wiki call): {}; run without --local-only to package them", lo.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+ if v["zip"].is_null() && *local_only { ok(&format!("{page_said}: README with {} parts; no KiCad package (no project-library parts to package without a wiki call)", v["parts"]), &hints); return; }+ // a wiki part whose footprint named no 3D model took its own page's STEP (project-page.py bind_page_models)+ if let Some(b) = v["pageModels"]["bound"].as_array().filter(|a| !a.is_empty()) {+ hints.push(format!("3D models bound from the parts' own wiki pages (their footprints named none): {}; `models` checks them on the board like any other",+ b.iter().map(|x| format!("{} -> {}", x["mpn"].as_str().unwrap_or("?"), x["model"].as_str().unwrap_or("?"))).collect::<Vec<_>>().join(", ")));+ }+ ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),+ &[hints, if *no_push { vec![format!("Local build only: the README, PCM zip and repository files are in {}. The repository URL above works only after `project-page` runs without --no-push.", dir.join("project-page").display())] } else { vec![] }, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());+ }+ Cmd::Pcm { what: _ } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if sequence::lane(&dir, &r.data) != "kicad" { err("pcm check is KiCad's library check (the project page's PCM package installed in KiCad); this run builds in another tool", &["Altium: `adom-aiflow altium library` builds and records the library.".into()]); }+ let pp = r.data["outcomes"]["project-page"].clone();+ let Some(id) = pp["identifier"].as_str().map(str::to_string) else { err("no project page yet: `project-page --page <owner/slug> --create` first", &[]) };+ let br = bridge_of(&r, &cli);+ let list = br.raw_call("kicad_pcm_list", &json!({}));+ let text = list.to_string();+ let installed = text.contains(&id);+ r.outcome("library", json!({"pass": installed, "identifier": id, "libName": pp["libName"], "repositoryUrl": pp["repositoryUrl"], "pcmList": list}));+ r.save().unwrap();+ if !installed { err(&format!("{id} is not installed in the test box's KiCad"), &[format!("kicad_pcm_add_repository {{\"url\":\"{}\"}}, then kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{id}\"}}, restart KiCad, `pcm check` again.", pp["repositoryUrl"].as_str().unwrap_or(""), pp["repositoryUrl"].as_str().unwrap_or(""))]); }+ ok(&format!("{id} is installed in KiCad: the schematic uses PCM_{}:<part>", pp["libName"].as_str().unwrap_or("")), &["Next: `step schematic`: draw it in KiCad's schematic editor (kicad_sch_place_symbol, kicad_sch_wire, kicad_sch_label), ERC clean, every BOM reference on the sheet.".into()]);+ }+ Cmd::Schematic { what: _, sch } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if sequence::lane(&dir, &r.data) != "kicad" { err("schematic check reads a KiCad schematic (kicad-cli ERC); this run builds in another tool", &["Altium: `adom-aiflow altium schematic` draws, compiles and checks the schematic pin for pin.".into()]); }+ if !Path::new(sch).is_file() { err(&format!("no such schematic: {sch}"), &[]); }+ let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+ let sdir = dir.join("schematic"); let _ = std::fs::create_dir_all(&sdir);+ let erc = sdir.join("erc.json");+ let (_, erc_errors) = schematic_check::run_erc(&kc, sch, &erc).unwrap_or_else(|e| err(&format!("schematic check: {e}"), &["ERC must run and its report come back before the schematic can pass. In a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> (it runs `sch erc` on the desktop's KiCad).".into()]));+ let net = sdir.join("netlist.net");+ export_sch_netlist(sch, &net).unwrap_or_else(|e| err(&e, &[]));+ let on_sheet = schematic_check::sheet_symbols(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));+ let lib = r.data["outcomes"]["project-page"]["libName"].as_str().map(|l| format!("PCM_{l}"));+ let bom_refs: Vec<String> = r.data["outcomes"]["parts"]["parts"].as_array().cloned().unwrap_or_default().iter().flat_map(|p| p["refs"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect();+ let missing: Vec<String> = bom_refs.iter().filter(|rf| !on_sheet.contains_key(*rf)).cloned().collect();+ let foreign: Vec<String> = match &lib { Some(l) => on_sheet.iter().filter(|(rf, (sl, fp))| bom_refs.contains(rf) && (sl != l || !fp.starts_with(&format!("{l}:")))).map(|(rf, (sl, fp))| format!("{rf} ({sl} / {fp})")).collect(), None => Vec::new() };+ let extra: Vec<String> = on_sheet.keys().filter(|rf| !bom_refs.contains(rf)).cloned().collect();+ // issue #39: symbol polarity drawings, active-device pin types and readability (schematic_symbols.rs)+ let symbols = schematic_symbols::check(Path::new(sch), &spec_soft(&r)).unwrap_or_else(|e| json!({"ok": false, "errors": [format!("could not read the hierarchy: {e}")], "warnings": []}));+ schematic_symbols::record(&mut r, sch, &symbols);+ let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty() && symbols["ok"] == true;+ // issue #138: name machine pins and contacts the way the wiki's model registry counts them+ // while it is one edit in the schematic; a hint, never a refusal+ let pin_names = molecule_pins::names_from_footprints(&on_sheet.iter().map(|(rf, (_, fp))| (rf.clone(), fp.clone())).collect::<Vec<_>>());+ let pin_hints: Vec<String> = pin_names.iter().map(|(finding, suggestion)| format!("Molecule names: {finding}. {suggestion}")).collect();+ let canon = std::fs::canonicalize(sch).map(|p| p.display().to_string()).unwrap_or(sch.clone());+ r.outcome("erc", json!({"pass": erc_errors.is_empty(), "errors": erc_errors, "schematic": canon, "sha256": sha256_file(sch), "report": erc.display().to_string(), "source": "schematic check (kicad-cli sch erc)"}));+ r.outcome("schematic", json!({"pass": pass, "schematic": canon, "sha256": sha256_file(sch), "ercErrors": erc_errors, "symbols": on_sheet.len(), "missingRefs": missing, "notFromProjectLibrary": foreign, "notInBom": extra, "library": lib, "moleculeNames": pin_names.iter().map(|(a, b)| json!({"finding": a, "suggestion": b})).collect::<Vec<_>>()}));+ if pass { r.data["schematic"] = json!(canon); }+ r.save().unwrap();+ if !pass {+ let mut why = Vec::new();+ if !erc_errors.is_empty() { why.push(format!("ERC: {} error(s): {}", erc_errors.len(), erc_errors.iter().take(6).cloned().collect::<Vec<_>>().join("; "))); }+ if !missing.is_empty() { why.push(format!("not on the sheet: {}", missing.join(", "))); }+ if !foreign.is_empty() { why.push(format!("not from the project library {}: {}", lib.clone().unwrap_or_default(), foreign.join(", "))); }+ why.extend(symbols["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)));+ let mut hints = vec!["Fix it in KiCad's schematic editor on camera (kicad_sch_place_symbol / kicad_sch_wire / kicad_sch_label / kicad_sch_set_property), save, and check again.".to_string()];+ hints.extend(schematic_symbols::render(&symbols, true).1);+ hints.push(schematic_symbols::warning_hint(&symbols));+ hints.extend(pin_hints.iter().cloned());+ findings(&format!("schematic check failed:\n {}", why.join("\n ")), &hints);+ }+ ok(&format!("schematic {canon}: {} symbols, ERC clean, every BOM reference placed from {}{}", on_sheet.len(), lib.unwrap_or_else(|| "the project library".into()), if extra.is_empty() { String::new() } else { format!(" ({} not in the BOM: {})", extra.len(), extra.join(", ")) }),+ &[pin_hints, vec![schematic_symbols::warning_hint(&symbols), "Next: make the board from it (KiCad: Update PCB from Schematic), draw the outline, save, bind it (`bind-remote-board` / `start --board`), then `step components`.".into()]].concat());+ }+ 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": "the report card: what passed, what is still open, and suggestions; it never refuses"},+ });+ 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())]);+ print_next(&cli);+ }+ }+ Cmd::Place { what, wish, moves, out, pause_ms, allow_outside } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let b = board_of(&r);+ match what.as_str() {+ "verify" => {+ let board_path=r.current_board();+ let spec_path=r.data["spec"].as_str().unwrap_or("").to_string();+ let board_hash=placement_verify::digest(Path::new(&board_path)).unwrap_or_else(|e|err(&e,&[]));+ let spec_hash=placement_verify::digest(Path::new(&spec_path)).unwrap_or_else(|e|err(&e,&[]));+ if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {+ if !f.errors.is_empty() { note(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }+ }+ let spec_i=spec_of(&r);+ let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});+ for l in &intent_lines { eprintln!("{l}"); }+ let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();+ let br=bridge_of(&r,&cli);+ let before=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));+ let validation=br.call("kicad_placement_validate",&json!({"filePath":rb}));+ let after=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));+ let report=dir.join("placement-verify.json");+ write_json(&report,&json!({"before":before,"validation":validation,"after":after}));+ let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r));+ let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str());+ let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };+ let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()||!v.approved_contacts.is_empty()||!v.net_ties.is_empty()).map(|v|v.to_json());+ r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));+ r.save().unwrap_or_else(|e|err(&e,&[]));+ if let Some(e)=error { findings(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }+ let excused_line=excused.as_ref().map(|e|format!("; reviewed assembly contacts: {}; fixedRefs box exceptions: {}; padless courtyard exceptions: {}; net ties (KiCad's own rules): {} (original native findings retained)",e["approvedCourtyardContacts"].as_array().map_or(0,|a|a.len()),e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()),e["netTies"].as_array().map_or(0,|a|a.len()))).unwrap_or_default();+ ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);+ }+ "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 intent = placement_intent::of_spec(&spec);+ let (wishes, anchor_lines) = aiflow_place::intent::apply_anchors(&b, &wishes, &intent);+ let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);+ let (mv, mut lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);+ lines.extend(anchor_lines);+ lines.extend(placement_intent::pack_lines(&b, &mv, &intent));+ 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);+ // issue #140: name the parts left without a spot (they stay where they are, maybe off the board)+ let left: Vec<String> = wishes.iter().map(|(r, _)| r.clone()).filter(|r| !mv.contains_key(r)).collect();+ let mut msg = format!("legal spots for {} of {} parts{}:\n{}", mv.len(), wishes.len(), if left.is_empty() { String::new() } else { format!(" (no spot for {}; left where {} now)", left.join(", "), if left.len() == 1 { "it is" } else { "they are" }) }, lines.join("\n"));+ // and every part the packed board would still have outside the outline+ let packed = aiflow_place::move_in_text(&b, &mv).ok().and_then(|(t, _)| Board::from_text(&b.path, t).ok());+ let overhang_refs = edge_overhang::allowed(&spec, allow_outside).map(|o| edge_overhang::refs(&o)).unwrap_or_default();+ let outside = aiflow_place::parts_outside(packed.as_ref().unwrap_or(&b), &overhang_refs);+ if !outside.is_empty() {+ msg.push_str(&format!("\nFINDING: {} part(s) outside the board outline after this pack:", outside.len()));+ for (_, w) in &outside { msg.push_str(&format!("\n {w}")); }+ }+ 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() }, if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() }, 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();+ // issue #95: an edge connector may overhang the outline by design, never with copper+ let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));+ let overhang_lines = edge_overhang::check_board(&nb, &overhang).unwrap_or_else(|e| err(&e, &[format!("Move the part inward until every pad is inside; the run's current board is unchanged ({}).", outp.display())]));+ let problems = aiflow_place::check_allowing(&nb, &moved, 0.05, &edge_overhang::refs(&overhang));+ // issue #140: every part outside the outline, moved or not (a part pack found no spot for is not in the moves)+ let outside = aiflow_place::parts_outside(&nb, &edge_overhang::refs(&overhang));+ if !outside.is_empty() {+ note(&format!("{} part(s) outside the board outline on the moved board:\n {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n ")), &[aiflow_place::OUTSIDE_HINT.into()]);+ }+ 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 spec_i = spec_of(&r);+ if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }+ let drc = run_drc_for(&r, &outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));+ let baseline = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));+ let s = aiflow_copper::summarize_with(&drc, Some(&baseline), silk_policy::blocking(&spec_i, &r.current_board()));+ 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) {+ note(&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())]);+ }+ // issue #39: courtyards present and clear, part bodies clear, whatever the project severities say+ let mech_line = if mechanical::applies(&outp.display().to_string()) {+ let mech = mechanical::check_board(&nb, &outp.display().to_string(), &project_dir_of(&r), &spec_soft(&r));+ for n in &mech.severity_notes { eprintln!("Warning: {n}"); }+ if !mech.errors().is_empty() { note(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }+ if mech.errors().is_empty() { format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked) } else { format!("; {} courtyard/body finding(s) to look at (above)", mech.errors().len()) }+ } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };+ let old_board = r.current_board();+ if let Some(n) = reviews::void_on_service_change(&mut r, Path::new(&old_board), &outp) { eprintln!("{n}"); }+ r.add_board(&outp.display().to_string());+ r.save().unwrap();+ let lines: Vec<String> = lines.into_iter().chain(overhang_lines).chain(outside.iter().map(|(_, w)| format!("FINDING: {w}"))).collect();+ let outside_hint = if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() };+ ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line}{} and is now the run's current board", lines.join("\n"), outp.display(), if outside.is_empty() { String::new() } else { format!("; {} part(s) outside the outline (FINDING lines above)", outside.len()) }), &[outside_hint, if overhang.is_empty() { "Land the same moves live with `place land --moves ...`.".into() } else { "Land the same moves live with `place land --moves ...` (the edge overhang parts are sent with allowOutside, the others are not).".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();+ let mut mv = read_json(m);+ let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));+ for l in edge_overhang::check_moves(&b, &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }+ // issue #140: parts that will still be outside the outline once these moves land (named, then landed anyway)+ let landed_board = aiflow_place::moves_from_json(&mv).ok().and_then(|m| aiflow_place::move_in_text(&b, &m).ok()).and_then(|(t, _)| Board::from_text(&b.path, t).ok());+ if let Some(after) = landed_board.as_ref() {+ land_moves::fill_rotations(&mut mv, after);+ // issue #142: a Fusion move must keep each part on its side; read from the live board, not the run's copy+ if aiflow_bridge::fusion::doc_of(&rb).is_some() && mv.as_object().is_some_and(|o| !o.is_empty()) {+ if let Some((brd, kicad)) = live_board(&r, &cli, &dir) {+ let rots = std::fs::read_to_string(&brd).map_err(|e| e.to_string()).and_then(|t| aiflow_board::eagle::element_rotations(&t)).unwrap_or_else(|e| err(&e, &[]));+ let live = Board::load(&kicad).unwrap_or_else(|e| err(&e, &[]));+ if let Err(clash) = land_moves::fusion_sides(&mut mv, after, &live, &rots) {+ err(&format!("nothing landed: {} part(s) are on a different side in the run's board and on the live board:\n {}", clash.len(), clash.join("\n ")), &["To put a part back on the bottom, call fusion_move_parts through Adom Bridge with its x, y (Fusion's frame, y up) and a rotation such as \"MR0\". To keep the live side, make the run's board match it, then land again.".into()]);+ }+ }+ }+ }+ let outside = aiflow_place::parts_outside(landed_board.as_ref().unwrap_or(&b), &edge_overhang::refs(&overhang));+ if !outside.is_empty() {+ note(&format!("{} part(s) will be outside the board outline after this landing:\n {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n ")), &[aiflow_place::OUTSIDE_HINT.into()]);+ }+ r.stage_start("place-land", Some("binary"), None);+ 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_allowing(&rb, &mv, *pause_ms, &edge_overhang::refs(&overhang), |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,"edgeOverhang":edge_overhang::record(&overhang)}));+ 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, "edgeOverhang": edge_overhang::record(&overhang)}));+ 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(), KICAD_SOCKET_HINT.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("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "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.".to_string() }]);+ }+ let outside_line = if outside.is_empty() { String::new() } else { format!("\nFINDING: {} part(s) outside the board outline: {}", outside.len(), outside.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().join(", ")) };+ ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s{outside_line}", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into(), if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.into() }]);+ }+ other => err(&format!("place {other}: use pack, check, land or verify"), &[]),+ }+ }+ Cmd::Route { passes, rip_per_net, rip_global, variants, pick, why } => {+ let ai_thread = 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)) = live_board(&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);+ // issue #140: a part outside the outline cannot be routed; name it before routing (the router reports its nets as unrouted)+ let outside = aiflow_place::parts_outside(&b, &edge_overhang::allowed(&spec, &[]).map(|o| edge_overhang::refs(&o)).unwrap_or_default());+ if !outside.is_empty() {+ note(&format!("{} part(s) outside the board outline; their connections are left unrouted:\n {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n ")), &[aiflow_place::OUTSIDE_HINT.into()]);+ }+ 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 mut rules = aiflow_router::Rules::from_spec(&spec);+ // issue #52: impedance targets set their nets' width (and pair pitch) and hold them to one layer+ let imp_choice = impedance_cmd::for_route(&b, &spec, &r.current_board(), &mut rules).unwrap_or_else(|(m, h)| err(&m, &h));+ // issue #165: say the sizes the router actually uses, so a spec key it did not read shows at once+ eprintln!("route rules used: track {} mm, clearance {} mm, via {} mm; per-net widths: wide {}, mid {}{}", rules.track, rules.clearance, rules.via,+ if rules.wide.is_empty() { "none".to_string() } else { rules.wide.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") },+ if rules.mid.is_empty() { "none".to_string() } else { rules.mid.iter().map(|(n, w)| format!("{n} {w:?}")).collect::<Vec<_>>().join(", ") },+ if spec.get("track").is_none() && spec.get("trackWidth").is_some() { " (track from the spec's trackWidth)" } else if spec.get("track").is_none() { " (track is the router default: the spec names no track)" } else { "" });+ 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, ®ions);+ decoupling_check::refuse_bad_spec(&spec, &b);+ 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(&match (variants, pick) { (Some(n), _) => format!("variants={n} passes={passes}"), (_, Some(k)) => format!("pick=v{k}"), _ => format!("passes={passes}") }));+ let labels: Vec<String> = regions.iter().map(|k| k.label.clone()).collect();+ // issue #55: --variants routes several and the gates pick; --pick takes one of them; plain route is the baseline levers+ let (rt, variants_rec) = match (variants, pick) {+ (_, Some(k)) => route_variants::pick(&r, *k, why.as_deref(), &ai_thread),+ (Some(n), None) => route_variants::run(&r, &dir, &rules, &labels, *n, *passes, *rip_per_net, *rip_global),+ _ => (route_variants::route_once(b, &rules, &aiflow_router::variants::Levers::baseline(*passes), *rip_per_net, *rip_global, &labels, true), Value::Null),+ };+ let route_variants::Routed { plan_doc, mut report, failed, simp_line, log, board: routed_on, has_copper, bc } = rt;+ let plan: Vec<Value> = plan_doc["nets"].as_array().cloned().unwrap_or_default();+ std::fs::write(dir.join("router.log"), log.join("\n")).ok();+ let planp = dir.join("plan-route.json");+ for w in bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str()) { eprintln!("WARNING {w}"); }+ // issue #52: measure the chosen plan's impedance targets (coupling, skew) before it is written+ let imp_lines = impedance_cmd::after_route(&imp_choice, &routed_on, &spec, &r.current_board(), &plan_doc, &mut report);+ for l in imp_lines.iter().filter(|l| l.starts_with("WARNING")) { eprintln!("{l}"); }+ write_json(&planp, &plan_doc);+ write_json(&dir.join("plan-route-report.json"), &report);+ let dec = decoupling_check::review_route(&spec, &routed_on, &plan_doc, &report, r.outcomes().get("pour"));+ decoupling_check::record(&mut r, &dir, &dec);+ let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();+ r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);+ let mut route_o = 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()});+ if !variants_rec.is_null() { route_o["variants"] = variants_rec; }+ if !report["impedance"].is_null() { route_o["impedance"] = report["impedance"].clone(); }+ // issues #127/#128: route geometry and signal quality of the plan on this board, suggestions only+ let quality_lines = match route_quality::of_plan(&board_of(&r), &plan_doc, &spec) {+ Some((qv, lines)) => { let qp = dir.join("plan-route-quality.json"); write_json(&qp, &qv); route_o["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string()); lines }+ None => Vec::new(),+ };+ for l in &quality_lines { eprintln!("{l}"); }+ // issue #141: the route's numbers, kept across board changes until the next route+ let mut route_warnings: Vec<String> = bc["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str().map(str::to_string)).collect();+ route_warnings.extend(imp_lines.iter().filter(|l| l.starts_with("WARNING")).map(|l| l.trim_start_matches("WARNING").trim_start_matches(':').trim().to_string()));+ if let Some(d) = dec.as_ref().filter(|d| d.errors + d.warnings > 0) { route_warnings.push(format!("decoupling: {} error(s), {} warning(s) (decoupling-route.json)", d.errors, d.warnings)); }+ let plan_vias = aiflow_copper::apply(&board_of(&r), &plan_doc, false).ok().and_then(|(_, st)| st["vias"].as_u64());+ let revision = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+ r.data["lastRoute"] = attention::route_record(&report, plan_vias, route_warnings, attention::quality_headline(&quality_lines), &r.current_board(), revision, &now(), &planp.display().to_string(), *passes);+ r.outcome("route", route_o);+ // the same timestamp as the outcome: a carried outcome (copper_carry) is recognised by it+ r.data["lastRoute"]["at"] = r.data["outcomeAt"]["route"].clone();+ 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() };+ findings(&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()]);+ }+ decoupling_check::refuse_errors("route", &dec);+ if has_copper && plan.is_empty() {+ ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias, {} filled zone islands); empty plan {}", bc["trackPieces"], bc["vias"], bc["zoneIslands"], 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 (nets already complete on it, skipped: {}) and {} connection(s) it left open were planned", bc["netsComplete"].as_array().filter(|a| !a.is_empty()).map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_else(|| "none".into()), bc["openConnections"]) } else { String::new() };+ let imp_text = if imp_lines.is_empty() { String::new() } else { format!("\n{}", imp_lines.join("\n")) };+ ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}{imp_text}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint, if imp_lines.is_empty() { String::new() } else { "After landing: `adom-aiflow analyze impedance` judges the landed copper (and a same-input Fields Signals solve); finish requires it.".into() }]);+ }+ }+ Cmd::Pour { plan } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let spec = spec_of(&r);+ // a Fusion run pours around what is in Fusion now (the landed copper and heatsink vias)+ let b = live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| 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);+ // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of+ let tv_holes = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).filter(|p| std::path::Path::new(p).is_file()).map(|p| aiflow_pours::plan_holes(&read_json(p))).unwrap_or_default();+ r.stage_start("pour", Some("binary"), None);+ let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &aiflow_pours::with_project_rules(&spec, &r.current_board()), plan_v.as_ref(), &tv_holes).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}));+ pourverify::record_plan(&mut r.data, &zp, "pour").unwrap_or_else(|e| err(&e, &[]));+ 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();+ // issue #143: where `fusion library` finds the test point when its private wiki page cannot be read+ if args.first().is_some_and(|a| a == "library") { if let Err(e) = testplan::write_test_point_files(&dir) { eprintln!("WARNING the test point files could not be written: {e}"); } }+ let status = std::process::Command::new("python3").arg(lane_script("fusion", &format!("{}\n{}", include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/fusion-project.py")))).args(args)+ .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())+ .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));+ std::process::exit(status.code().unwrap_or(1));+ }+ Cmd::Altium { args, target } => {+ // the Altium steps: the shared project half (tools/project-common.py), the shared silkscreen planner,+ // the lane's pure helpers (tools/altium-lane.py: the symbol check, project-library parts), then the Altium half+ 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 script = format!("{}\n{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-lane.py"), include_str!("../../../tools/altium-project.py"));+ let status = std::process::Command::new("python3").arg(lane_script("altium", &script)).args(args)+ .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())+ .status().unwrap_or_else(|e| err(&format!("altium 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()+ });+ // the same normalisation `land route` applies, so the gate passes only what lands (issue #33)+ let (plan_v, plan_notes) = read_plan_normalised(&planp, &b);+ print_plan_notes(&plan_notes);+ let spec_g = spec_of(&r);+ if let Some((p, layers, warning)) = checks::rules_target(&spec_g, &r.data["fabRules"]) {+ if let Some(w) = warning { eprintln!("Warning: {w}"); }+ if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { findings(&format!("gate refused: {e}"), &[hint, "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }+ }+ // issue #148: molecule findings are reported and the gate still runs DRC (a suggestion, never a stop)+ let mut mol_errors: Vec<String> = Vec::new();+ if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {+ if !f.errors.is_empty() { note(&format!("the board is not yet a conforming molecule (DRC runs anyway):\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); mol_errors = f.errors.clone(); }+ }+ r.stage_start("gate", Some("binary"), None);+ // the checks themselves are gate_eval::evaluate, shared with `route --variants` (issue #55)+ let base = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&e, &[]));+ let routed = dir.join("board-routed.kicad_pcb");+ let gate_eval::GateEval { mut s, pu, pu_issues, sw_issues, dec, mech, stairs, quality, .. } = gate_eval::evaluate(&r, &b, &plan_v, &routed, &base, true).unwrap_or_else(|e| err(&e, &[]));+ // issues #127/#128: route geometry and signal quality, reported with suggestions, never a refusal+ let qp = dir.join("route-quality.json");+ write_json(&qp, &quality.0);+ s["routeQuality"] = route_quality::outcome(&quality.0, &qp.display().to_string());+ for l in &quality.1 { eprintln!("{l}"); }+ decoupling_check::record(&mut r, &dir, &dec);+ 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")); }+ if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }+ 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, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routeQuality": s["routeQuality"], "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));+ if !mol_errors.is_empty() { r.data["outcomes"]["gate"]["moleculeErrors"] = json!(mol_errors); }+ r.data["lastGate"] = json!({"at": r.data["outcomeAt"]["gate"], "board": r.current_board(), "boardRevision": r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1), "newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu, "plan": planp});+ r.save().unwrap();+ if !sw_issues.is_empty() {+ r.outcome("switch-node", json!({"issues": sw_issues}));+ r.save().unwrap();+ findings(&format!("gate failed: the switch node:\n {}", sw_issues.join("\n ")), &[switchnode::HINT.into()]);+ }+ if npu > 0 && newe == 0 && unc == 0 {+ findings(&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() };+ findings(&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, "Suggested: fix the plan (route again, or move parts) and gate again; landing it as it is lands these errors too.".into()]);+ }+ decoupling_check::refuse_errors("gate", &dec);+ if let Some(m) = &mech {+ for n in &m.severity_notes { eprintln!("Warning: {n}"); }+ if !m.errors().is_empty() { findings(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); }+ } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }+ let silk_n = s["silkAdvisories"].as_u64().unwrap_or(0) as usize;+ if silk_n > 0 { eprintln!("Warning: {}", silk_policy::advisory_line(silk_n, &s["silkAdvisoryList"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())); }+ if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); }+ let checked = match &mech { Some(m) => format!("; courtyards checked on {} footprint(s), {} explicitly reviewed contact(s), {} part bodies clear", m.with_courtyard, m.approved_contacts.len(), m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() };+ let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);+ if !mol_errors.is_empty() { findings(&format!("gate: 0 new DRC errors ({inh} inherited), 0 unconnected, but {} molecule finding(s) are open:\n {}", mol_errors.len(), mol_errors.join("\n ")), &["DRC is recorded (gate.json). Fix the molecule findings above (`adom-aiflow molecule check` lists them with suggestions), then gate again; finish lists whatever is still open.".into()]); }+ ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", 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, allow_outside } => {+ 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 && mechanical::gate_clear(&gate);+ let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));+ let empty_after_normalising = |p: &String| { let (v, _) = read_plan_normalised(p, &board_of(&r)); v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) };+ if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && empty_after_normalising(p)) {+ 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;+ }+ }+ if what == "pours" {+ // zones adopted with this exact plan that still match it: nothing to land, no bridge call (issue #83)+ if let Some(zp) = r.outcomes().get("pour").and_then(|p| p["pours"].as_str()).map(str::to_string) {+ let b = board_of(&r);+ if let Ok(n) = pourverify::adopted_unchanged(&r.data, Path::new(&zp), &b) {+ r.log("land-pours-skipped", json!({"plan": zp, "zones": n, "why": "adopted zones already match the plan"}));+ r.save().unwrap();+ ok(&format!("adopted zones already match the plan; nothing to land ({n} zone(s) adopted by `verify-pours --adopt` with this plan, unchanged on the saved board)"), &[if pourverify::is_existing(&r.data["outcomes"]["land-pours"]) { "The adopted-existing pours proof stands. Next: adom-aiflow measure, analyze current, analyze thermal, finish.".to_string() } else { format!("The pours proof was replaced by a later landing attempt: run `verify-pours --plan {zp}` again to record it (no copper changes), then measure, analyze current, analyze thermal, finish.") }]);+ 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", &[]));+ let mv = read_json(m);+ let overhang = edge_overhang::allowed(&spec_soft(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));+ for l in edge_overhang::check_moves(&board_of(&r), &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }+ r.stage_start("place-land", Some("binary"), None);+ 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_allowing(&rb, &mv, *pause_ms, &edge_overhang::refs(&overhang), |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, "edgeOverhang": edge_overhang::record(&overhang)}));+ 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("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "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.".to_string() }]);+ }+ 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 && r.outcomes().get("gate").map(mechanical::gate_clear).unwrap_or(false);+ if !gate_ok && plan.is_none() {+ note("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 {+ plan.clone().or_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", &[]))+ };+ // the gate's normalisation (issue #33): no zero-length trace reaches the bridge, and+ // trace numbers (`--from`) count the same traces the gate passed+ let b = board_of(&r);+ let (plan_main, plan_notes) = read_plan_normalised(&planp, &b);+ print_plan_notes(&plan_notes);+ let entries: Vec<Value> = aiflow_copper::plan_entries(&plan_main).unwrap_or_else(|e| err(&format!("{planp}: {e}"), &[]));+ // 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 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![plan_main.clone()];+ 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_plan_normalised(gp, &b).0); }+ }+ }+ let mut copper_cache: Option<Vec<Copper>> = None;+ // and keep the spec's hole-to-hole minimum and via-to-via clearance from every via and+ // hole, live or planned, of any net (issue #83): checked for every trace before any lands+ // the spec's numbers, else the project's .kicad_dru, else 0.2 mm (issue #90)+ let spec_v = aiflow_pours::with_project_rules(&r.data["spec"].as_str().filter(|p| Path::new(p).is_file()).map(read_json).unwrap_or(Value::Null), &r.current_board());+ let (h2h, via_clearance) = (aiflow_pours::hole_to_hole(&spec_v), aiflow_pours::via_clearance(&spec_v));+ let mut holes_cache: Option<Vec<landing::Hole>> = 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+ });+ if let Some(refusal) = 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)}))) { return Some(refusal); }+ let holes = holes_cache.get_or_insert_with(|| {+ let mut h = landing::live_holes(live);+ h.extend(landing::board_holes(&b));+ for p in &plans { h.extend(landing::plan_holes(p, &live.pads)); }+ h+ });+ let drill = e["viaDrill"].as_f64().unwrap_or(t.via_size / 2.0);+ t.vias.iter().find_map(|v| landing::via_hole_clash(&t.net, *v, t.via_size, drill, holes, h2h, via_clearance).map(|what| json!({"success": false, "errorCode": "via_hole_clash", "net": t.net, "at": [v.0, v.1], "clashes": what, "message": format!("the planned {} via at ({:.2}, {:.2}) is too close to {what}", 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 entries take \"size\" and \"drill\" in mm, 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" || rep["errorCode"] == "via_hole_clash" {+ 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; an entry's \"size\" and \"drill\" set its via, and spec viaClearance or the project's .kicad_dru its clearance), 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. To resume at this trace: `adom-aiflow land {what}{} --from {}`. The log has every reply.", plan.as_ref().map(|p| format!(" --plan {p}")).unwrap_or_default(), 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()));+ pourverify::landable(&zones).unwrap_or_else(|e| err(&e, &["Write that zone natively in KiCad, save and fill it, then accept it with `verify-pours --plan <plan>` (add --adopt --why to own it). Nothing was landed.".into()]));+ 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()]); }+ // issue #102: owned IDs gone from the live board and both saved boards (KiCad died after the commit)+ let run_board = r.current_board();+ for l in zone_ownership::reconcile_before_landing(&mut r.data, &before, &run_board, &br, &rb, &dir) { eprintln!("{l}"); }+ 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()); }+ }+ }+ }+ // issue #69 follow-up: a zone already on the board that is identical to a plan zone is adopted+ // (or, owned, kept) instead of refused; one that differs is refused before anything is landed+ let adoption = match pour_adopt::plan(&b, &zones, &before, &owned) {+ Ok(a) => a,+ Err(refusal) => {+ r.stage_end("land-pours", Some(json!({"groups": 0, "failed": 1})), None);+ r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": Value::Null, "failed": ["zone-adoption"],+ "groups": [json!({"net": "zone-adoption", "ok": false, "errorCode": refusal["errorCode"], "message": refusal["message"]})], "adoptionRefused": refusal}));+ write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "adoptionRefused": refusal}));+ r.save().unwrap();+ // AI Flow never refuses (0.2.48): the bridge would not land over a zone this run does not own, so+ // nothing was sent; this is reported as findings with the ways on+ findings(&format!("land pours: nothing landed yet, a zone already on the board stands where the plan puts one: {}. Nothing was landed or changed.", refusal["message"].as_str().unwrap_or("")),+ &[format!("Suggested: a zone already on the board is adopted when it is identical to its plan zone: the same name, net, layers, outline (every vertex within {} mm), priority, clearance, min thickness, pad connection and fill settings, not locked. Make the board zone match the plan (or the spec and `pour` match the board), or delete it in KiCad, then land again. Full readback: {}.", pourverify::ADOPT_TOLERANCE_MM, dir.join("land-pours-log.json").display())]);+ }+ };+ if let Some(n) = &adoption.note { eprintln!("{n}"); }+ let results = if adoption.send_zones.is_empty() && adoption.send_owned.is_empty() { Vec::new() } else { br.land_zones(&rb, &adoption.send_zones, *pause_ms, &adoption.send_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();+ if failed.is_empty() && !adoption.adopted.is_empty() {+ // ownership of an adopted zone is granted only with a landing that succeeded+ for a in &adoption.adopted { if let Some(id) = a["id"].as_str() { if !owned.iter().any(|x| x == id) { owned.push(id.to_string()); } } }+ r.data["ownedZoneIds"] = json!(owned);+ if !r.data["zoneAdoptionsByLanding"].is_array() { r.data["zoneAdoptionsByLanding"] = json!([]); }+ let entry = json!({"at": aiflow_run::now(), "pourPlan": zp, "planSha256": pourverify::hash(Path::new(&zp)).ok(), "liveRevision": before["revision"], "zones": adoption.adopted});+ r.data["zoneAdoptionsByLanding"].as_array_mut().unwrap().push(entry.clone());+ r.log("zone-adopted", entry);+ }+ r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len(), "adopted": adoption.adopted.len(), "kept": adoption.kept.len()})), None);+ r.outcome("land-pours", json!({"adopted": if failed.is_empty() { json!(adoption.adopted) } else { json!([]) }, "kept": adoption.kept, "leftAlone": adoption.left_alone, "adoptionNote": adoption.note, "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"], "message": rep["message"], "reply": if Bridge::ok(rep) { Value::Null } else { json!(aiflow_run::clip_text(&rep.to_string(), 1200)) }})).collect::<Vec<_>>()}));+ write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "adopted": adoption.adopted, "kept": adoption.kept, "leftAlone": adoption.left_alone, "sent": {"zones": adoption.send_zones.iter().map(|z| z["name"].clone()).collect::<Vec<_>>(), "ownedItemIds": adoption.send_owned}, "results": results, "after": after}));+ r.save().unwrap();+ if let Some((net, rep)) = failed.first() {+ err(&pourverify::refusal(net, rep, &zones), &[format!("Full replies: {}. A zone adopted with `verify-pours --adopt` and unchanged since is not landed again; this plan was sent because {}.", dir.join("land-pours-log.json").display(), pourverify::adopted_unchanged(&r.data, Path::new(&zp), &b).err().unwrap_or_default()),"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()]);+ }+ let names = |v: &Vec<Value>| v.iter().filter_map(|a| a["name"].as_str()).collect::<Vec<_>>().join(", ");+ let adopted_note = if adoption.adopted.is_empty() { String::new() } else { format!("; {} zone(s) already on the board adopted as identical to the plan, not landed again: {}", adoption.adopted.len(), names(&adoption.adopted)) };+ let kept_note = if adoption.kept.is_empty() { String::new() } else { format!("; {} owned zone(s) identical to the plan kept as they are: {}", adoption.kept.len(), names(&adoption.kept)) };+ let sent_note = format!("; {} zone(s) landed through the bridge", adoption.send_zones.len());+ let mut hints: Vec<String> = Vec::new();+ if !adoption.left_alone.is_empty() { hints.push(format!("{} board zone(s) this run does not own and the plan does not name were left alone: {}", adoption.left_alone.len(), names(&adoption.left_alone))); }+ if !adoption.adopted.is_empty() { hints.push(format!("The adoption evidence (each zone's compared fields, outline deviation and definition sha256) is in run.json zoneAdoptionsByLanding and {}.", dir.join("land-pours-log.json").display())); }+ hints.push("Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into());+ ok(&format!("pours landed: copper per layer before {} after {}{adopted_note}{kept_note}{sent_note}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &hints);+ }+ other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),+ }+ }+ Cmd::VerifyPours { plan, from_board, adopt, why, record_plan } => {+ if *adopt || *record_plan { thread(&cli); }+ let mut r = load_run(&cli);+ match pourverify::command(&mut r, &dir, plan.as_deref(), *from_board, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ }+ Cmd::Zones { action: _, id, missing, why } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ match zone_ownership::forget_command(&mut r, &br, &dir, id, *missing, why.as_deref()) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ }+ 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 ({}'s fills): {}; coverage {}", aiflow_bridge::eda::active().name(), zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);+ }+ Cmd::Analyze { what, plan, fields, ack, why } => {+ let t = thread(&cli);+ if !ack.is_empty() && what != "current" { err("--ack acknowledges a current-density peak: `analyze current --ack [email protected] --why ...`", &[]); }+ let mut r = load_run(&cli);+ if what == "impedance" {+ // issue #52: closed-form impedance of the landed copper, Fields Signals beside it+ let live = live_board(&r, &cli, &dir);+ let copper = live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board());+ let solved = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());+ let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o["plan"].as_str()).map(str::to_string));+ let spec = spec_of(&r);+ match impedance_cmd::analyze_run(&mut r, &dir, &spec, &copper, &solved, planp.as_deref(), fields.as_deref()) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ return;+ }+ 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 = live_board(&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 (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current, thermal or impedance"), &[]) };+ let mut fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));+ // provenance: a fields.json is evidence only when it is the output of a solve aiflow ran or watched;+ // any other file (copied, edited, or written to look like one) is solved again here, same inputs+ let mut unobserved: Option<String> = None;+ if aiflow_analyze::needs_field_screen(&spec,what) && fields_file.is_file() && !fields_observed(&r,&fields_file) {+ let out_dir = dir.join("fields");+ let args: Vec<String> = vec!["analyze".into(),"--board".into(),solved_board.clone(),"--spec".into(),r.data["spec"].as_str().unwrap_or("").to_string(),"--out".into(),out_dir.display().to_string()];+ eprintln!("{} was not produced by an Adom Fields solve this run watched; solving it now: adom-fields {}", fields_file.display(), args.join(" "));+ match std::process::Command::new("adom-fields").args(&args).status() {+ Ok(st) if st.success() => { fields_file = out_dir.join("fields.json"); let mut full=vec!["adom-fields".to_string()]; full.extend(args.clone()); record_fields_solve(&mut r, &fields_file, "analyze", &full); r.save().unwrap(); }+ Ok(st) => { unobserved = Some(format!("the field solve aiflow started failed ({st}); fix what adom-fields printed, then `adom-aiflow exec -- adom-fields {}` and analyze {what} again", args.join(" "))); }+ Err(e) => { unobserved = Some(format!("{} was not produced by an Adom Fields solve this run watched, and adom-fields could not start ({e}): install adom-fields, then `adom-aiflow exec -- adom-fields {}`", fields_file.display(), args.join(" "))); }+ }+ }+ if !ack.is_empty() && (unobserved.is_some() || !fields_file.is_file()) { err("--ack needs a same-input Fields solve this run watched to read the peak from; analyze current without --ack first", &[]); }+ let mut expired_acks: Vec<Value> = Vec::new();+ let mut sink_notes: Vec<String> = Vec::new();+ let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(Vec::new()) } else if fields_file.is_file() {+ let f=aiflow_analyze::fields_report::load(&fields_file).unwrap_or_else(|e| err(&e,&[]));+ if let Err(e)=std::fs::metadata(&solved_board) { 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(),&[]));+ // issue #70: record reviewed part-limited peaks, then screen with the live ones+ let live_acks = if what == "current" {+ let copper = ack_copper(live.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board));+ if !ack.is_empty() {+ let spec_sha = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(&spec_bytes)) };+ if f["inputIdentity"]["spec"].as_str() != Some(spec_sha.as_str()) || copper_carry::carried_from(&r.data, f["inputIdentity"]["board"].as_str().unwrap_or(""), Path::new(&solved_board)).is_err() { err("--ack: the Fields report is not of this saved board (or its carried-over copper) and spec; solve again, then acknowledge", &[]); }+ let copper = copper.clone().unwrap_or_else(|e| err(&format!("--ack: this board has no copper fingerprint to bind the acknowledgement to ({e})"), &[]));+ let w = why.clone().unwrap_or_else(|| err("--ack needs --why \"<what limits the peak and why the board cannot change it>\"", &[]));+ let limit = spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);+ for a in ack.iter() {+ let rec = aiflow_analyze::current_acks::record(a, &w, &t, &f, &board_now, &copper, limit, &now()).unwrap_or_else(|e| err(&e, &["The place is a pad of the part that limits the peak (REF.PAD on that net) or the Fields peak location x,y within 0.5 mm.".into()]));+ let mut list: Vec<Value> = r.data["currentAcks"].as_array().cloned().unwrap_or_default();+ list.retain(|x| x["target"] != rec["target"]);+ list.push(rec);+ r.data["currentAcks"] = json!(list);+ }+ r.save().unwrap();+ }+ let (live_a, expired) = aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &copper.unwrap_or_else(|_| "unavailable".into()), f["inputIdentity"]["spec"].as_str().unwrap_or(""));+ expired_acks = expired;+ live_a+ } else { Vec::new() };+ let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).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 });+ // issue #67: the same-input solve with a declared board-sink block stands in for the estimate+ if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); sink_notes.extend(aiflow_analyze::plane_backed::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+ // issue #107: the passed solve's rise at each net's narrowest neck judges the whole-net width screen+ if what == "current" && s.is_ok() { aiflow_analyze::solved_ampacity::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+ s+ } 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();+ // issue #75: the fields step refuses a converter Fields did not model; say so here already+ let converter_hints = converter_warning(&r.data, &spec, aiflow_analyze::fields_report::load(&fields_file).ok().as_ref(), Some(&board_now));+ let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default();+ let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res);+ 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, "acknowledgedPeaks": covered_acks, "expiredAcks": expired_acks, "boardSinkAssumptions": sink_assumptions, "boardSinkNotes": sink_notes}));+ r.save().unwrap();+ let mut 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();+ for c in &covered_acks { lines.push(format!("acknowledged peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} by --ack {}: \"{}\" ({}); Fields reports one maximum per net, so check its density map for a second spot", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }+ for a in &expired_acks { lines.push(format!("expired acknowledgement (copper or spec changed since the review; acknowledge again if it still holds): {}", aiflow_analyze::current_acks::describe(a, "expired"))); }+ for a in &sink_assumptions { lines.push(format!("board-sink assumption: {a}")); }+ for n in &sink_notes { lines.push(format!("board sink: {n}")); }+ // 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(&aiflow_analyze::fields_report::load(&fields_file).unwrap_or_default(), &bt, &spec) } else { Vec::new() };+ if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); }+ findings(&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, results_hint(&r)]);+ }+ let mut hints = vec![draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()];+ hints.extend(converter_hints);+ hints.push(results_hint(&r)); // issue #147+ ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &hints);+ }+ Cmd::Impedance { action, board, spec, plan, json: as_json } => {+ let (board_file, spec_v, plan_p) = match (board, spec) {+ (Some(b), Some(s)) => (b.clone(), read_json(s), plan.clone()),+ (None, None) => { let r = load_run(&cli); let p = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o["plan"].as_str()).map(str::to_string)); (r.current_board(), spec_of(&r), p) }+ _ => err("give both --board and --spec, or neither (the run's board and spec)", &[]),+ };+ match impedance_cmd::command(action, &board_file, &spec_v, plan_p.as_deref(), *as_json) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ }+ Cmd::Deviate { item, why, list, clear } => {+ let mut r = load_run(&cli);+ let (bs, ss) = run_hashes(&r);+ if *list {+ let (live, old) = (deviations::live(&r.data, &bs, &ss), deviations::expired(&r.data, &bs, &ss));+ let fmt = |d: &Value| format!("{}: \"{}\" ({}, {})", d["item"].as_str().unwrap_or(""), d["why"].as_str().unwrap_or(""), d["aiThread"].as_str().unwrap_or(""), d["at"].as_str().unwrap_or(""));+ ok(&format!("{} deviation(s) on this board and spec{}{}", live.len(), live.iter().map(|d| format!("\n {}", fmt(d))).collect::<String>(), if old.is_empty() { String::new() } else { format!("\n{} expired (the board or spec changed since):{}", old.len(), old.iter().map(|d| format!("\n {}", fmt(d))).collect::<String>()) }), &[]);+ return;+ }+ let t = thread(&cli);+ let item = item.clone().unwrap_or_else(|| err("deviate needs the item, as the refusal names it: `deviate \"gate\" --why \"...\"`", &["`deviate --list` shows the recorded ones.".into()]));+ if *clear {+ let had = deviations::clear(&mut r.data, &item);+ r.save().unwrap();+ ok(&format!("{}", if had { format!("removed the deviation for {}", deviations::key(&item)) } else { format!("no deviation recorded for {}", deviations::key(&item)) }), &[]);+ return;+ }+ let known: Vec<String> = r.data["lastRefusalItems"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();+ let w = why.clone().unwrap_or_default();+ let (rec, dnotes) = deviations::record(&mut r.data, &item, &w, &t, &bs, &ss, &now(), &known);+ for n in &dnotes { note(n, &[]); }+ r.log("deviate", rec.clone());+ r.save().unwrap();+ ok(&format!("deviation recorded for {}: \"{}\"", rec["item"].as_str().unwrap_or(""), rec["why"].as_str().unwrap_or("")), &["It holds for this saved board and spec only; any change to either expires it. Run the refused command again (finish, or the step). Tell the human about every deviation: finish lists them.".into()]);+ }+ Cmd::SpecAmend { why } => {+ let t = thread(&cli);+ let r = load_run(&cli);+ if why.trim().chars().count() < spec_freeze::MIN_WHY { note("the reason in --why is short: the datasheet page and what was misread make it useful later; recorded as given", &[]); }+ if r.data["spec"].is_null() { err("this run has no spec yet", &[]); }+ let spec = read_json(r.data["spec"].as_str().unwrap_or(""));+ let Some(mut rec) = spec_freeze::read(&r.dir) else { err("the budgets are not frozen yet (they freeze once placement begins): edit the spec freely until then", &[]) };+ let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(&spec));+ // a change already recorded without a reason (the spec was read after an edit) takes this reason+ if changed.is_empty() {+ let mut filled = 0;+ for a in rec["amendments"].as_array_mut().into_iter().flatten() { if a["why"].as_str().unwrap_or("").is_empty() { a["why"] = json!(why.trim()); a["aiThread"] = json!(t); filled += 1; } }+ if filled == 0 { ok("the spec's budgets match the recorded ones: nothing to amend", &[]); return; }+ spec_freeze::write(&r.dir, &rec);+ r.log("spec-amend-why", json!({"why": why.trim(), "amendments": filled}));+ ok(&format!("reason recorded for {filled} budget change(s) made earlier in the spec"), &["Listed in the finish summary.".into()]);+ return;+ }+ let a = json!({"at": now(), "step": r.data["currentStep"], "why": why.trim(), "changes": changed, "aiThread": t});+ rec["budgets"] = spec_freeze::budgets(&spec);+ rec["amendments"].as_array_mut().unwrap().push(a.clone());+ spec_freeze::write(&r.dir, &rec);+ r.log("spec-amend", a);+ ok(&format!("spec budgets amended on the record ({} change(s)): {}", changed.len(), changed.join("; ")), &["The amendment is listed in the finish summary; the gates now measure against the new values.".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();+ // a pattern board's run (boardkind.rs): the electrical steps are not applicable, the native DRC is the live one+ let pattern = verified::pattern_run(&r.data);+ let not_applicable = boardkind::finish_lines(&r.data);+ if pattern {+ if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref())) { missing.push(format!("pattern lane: {why} (start a run on the electrical lane)")); }+ if r.data["target"].is_null() { missing.push("native DRC: a pattern board's DRC is finish's live full DRC with the fab rules; bind the board on a test box (start --target <box> --remote-board <path>, bind-remote-board) and finish again".into()); }+ }+ if o.get("tour-fields").is_some_and(|f| f["notApplicable"] == true) {+ let why = boardkind::fields_applies(&spec_soft(&r), converter::calcs_of_run(&r.data).as_ref());+ if !why.is_empty() { missing.push(format!("fields: recorded as not applicable, but now {} (solve with adom-fields, then `tour fields`)", why.join("; "))); }+ }+ let placement=o.get("place-land").cloned().unwrap_or(Value::Null);+ if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {+ missing.push("place verify (saved board or spec changed; verify again)".into());+ }+ {+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&r.dir, &r.data)));+ missing.extend(handson::finish_missing(&r.data, &seq));+ }+ let gate = o.get("gate").cloned().unwrap_or(Value::Null);+ if !pattern && (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 let Some(m) = decoupling_check::blocks_finish(o.get("decoupling")) { missing.push(m); }+ if let Some(m) = placement_intent::blocks_finish(o.get("placement-intent")) { missing.push(m); }+ if !mechanical::gate_clear(&gate) {+ missing.push("gate: courtyards and part bodies (missing or overlapping; see gate.json \"mechanical\")".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 !pattern && !o.contains_key("land-route") {+ missing.push("land route".into());+ }+ if !pattern && o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {+ missing.push("land pours or verify-pours (every planned zone accepted; current inputs required)".into());+ }+ if !pattern && !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 = live_board(&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) };+ let mut acked_peaks: Vec<Value> = Vec::new();+ for k in ["analyze-current", "analyze-thermal"] {+ if pattern { continue; }+ 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().filter(|p| Path::new(p).is_file()).map(|p| aiflow_analyze::fields_report::load(Path::new(p)));+ let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();+ if let Some(Err(e))=&report { missing.push(format!("{k}: {e}")); }+ else if let Some(Ok(report))=report {+ let report_path=o[k]["fieldReport"].as_str().unwrap_or("").to_string();+ if !fields_observed(&r, Path::new(&report_path)) { missing.push(format!("{k}: the field report was not produced by an Adom Fields solve this run watched (analyze {} again; aiflow solves it)", k.trim_start_matches("analyze-"))); }+ // issue #70: only acknowledgements of this copper fingerprint and this Fields spec identity cover a peak+ let live_acks = if k == "analyze-current" { aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &ack_copper(live_board.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board)).unwrap_or_else(|_| "unavailable".into()), report["inputIdentity"]["spec"].as_str().unwrap_or("")).0 } else { Vec::new() };+ match copper_carry::field_screen_with_acks(&r.data,&report,&spec_of(&r),Path::new(&solved_board),&spec_bytes,k.trim_start_matches("analyze-"),&live_acks) { Err(reason) => missing.push(format!("{k}: {reason}")), Ok((n, c)) => { if let Some(n) = n { eprintln!("{n}"); } acked_peaks.extend(c); } }+ } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }+ }+ }+ // issue #75: a switching converter needs Fields' voltages/sources/switching and a solve that modelled it+ {+ let report = o.get("analyze-current").and_then(|a| a["fieldReport"].as_str()).map(PathBuf::from).filter(|p| p.is_file()).or_else(|| Some(dir.join("fields/fields.json")).filter(|p| p.is_file()))+ .and_then(|p| aiflow_analyze::fields_report::load(&p).ok());+ if let Some((msg, hints)) = converter::check(&spec_of(&r), converter::calcs_of_run(&r.data).as_ref(), report.as_ref(), Some(&board_now())) {+ missing.push(format!("fields: {msg}. {}", hints.join(" ")));+ }+ }+ // issue #52: impedance targets hold on the live board's copper+ if !pattern { if let Some(m) = impedance_cmd::finish_check(&board_now(), &spec_of(&r), &solved_board, o.get("analyze-impedance")) { missing.push(m); } }+ if let Err(m) = netlist_gate(&r) { missing.push(m); }+ if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); }+ let silk_warnings = silk_polarity::warnings(&board_now());+ for w in &silk_warnings { eprintln!("Warning: {w}"); }+ // issue #163: the Fab layer standard (a warning with the worst offenders, never an open item)+ { let b = board_now(); if mechanical::applies(&b.path) { let fab = fab_layer::check(&b); for f in fab["findings"].as_array().into_iter().flatten().filter_map(Value::as_str) { eprintln!("Warning (Fab layer, not blocking): {f}. {}", fab_layer::TIDY_HINT); } } }+ // issue #97: every label, LED and programming check says what it found, on a pass too+ let label_checks = silk_polarity::label_checks(&board_now());+ let mut check_lines = silk_polarity::label_check_lines(&label_checks);+ // 0.2.18: every LED's on/off state, and a programmable part's interface where the workcell reaches it+ let led_check = led_states::check(&solved_board, &board_now());+ check_lines.push(led_states::summary(&solved_board, &board_now(), &led_check));+ // the human's own preferences: listed, never blocking (they are taste, not the fab's limits)+ let pref = spec_soft(&r)["fab"]["preferences"].clone();+ if pref.is_object() {+ let found = prefs::findings(&board_now(), &pref);+ check_lines.push(if found.is_empty() { "preferences: all of the human's own preferences met".to_string() } else { format!("preferences (warnings, not blocking): {}", found.join("; ")) });+ }+ let led_file = match led_check { Ok(p) => p, Err(p) => { missing.push(led_states::gate_message(&p)); None } };+ let prog_check = programming::check(&solved_board, &board_now(), &spec_soft(&r));+ check_lines.push(programming::summary(&solved_board, &board_now(), &spec_soft(&r), &prog_check));+ if let Err(p) = prog_check { missing.push(programming::gate_message(&p)); }+ for l in &check_lines { eprintln!("{l}"); }++ let spec_f = spec_of(&r);+ if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) {+ if let Some(w) = warning { eprintln!("Warning: {w}"); }+ if let Err((e, hint)) = checks::rules_gate(&p, &r.current_board(), layers) { missing.push(format!("fab rules ({e}; {hint})")); }+ }+ 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("; "))); }+ }+ // issue #39: courtyards, part bodies and the pours in the saved board (the board that ships)+ // Altium: the live board is a fresh render of the board of record (its courtyards and fab+ // outlines from the parts' source footprints, Altium's own fills), so the facts hold for it+ let facts = ((live_board.is_none() || aiflow_bridge::eda::active().id() == "altium") && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r)));+ match &facts {+ Some(f) => {+ for n in &f.mech.severity_notes { eprintln!("Warning: {n}"); }+ for e in f.mech.errors() { missing.push(format!("mechanical: {e} ({})", f.mech.hint())); }+ for p in &f.fill_problems { missing.push(format!("pours in the saved board: {p} ({})", fills::HINT)); }+ }+ None => eprintln!("Warning: courtyards, bodies and saved pours not checked here (Fusion board); the summary says so"),+ }+ {+ let (labels, fps) = provenance::silk_labels(&board_now());+ // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,+ // which now carries its silkscreen text (issue #74); `fusion silkscreen`'s proof is the second source.+ if aiflow_bridge::eda::active().id() == "fusion" {+ let proof = std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+ let (m, w) = provenance::fusion_silk(labels, fps, proof.as_ref());+ missing.extend(m);+ if let Some(w) = w { eprintln!("Warning: {w}"); }+ }+ if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {+ missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT));+ }+ let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));+ if !proxies.is_empty() {+ missing.push(format!("3D models: {} footprint(s) use the laser-etch proxy box as their model ({}): fetch or build the real part's model (wiki component page, vendor STEP, adom-step)", proxies.len(), proxies.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>().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;+ let mut live_rec: Option<Value> = None;+ let mut silk_advisories: Vec<String> = Vec::new();+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let br = Bridge { ai_thread: t.clone(), target: target.clone() };+ live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));+ let eda = aiflow_bridge::eda::active();+ if eda.caps().file_backed_board {+ let st = br.status();+ let kv = st["kicadVersion"].as_str().or(st["data"]["kicadVersion"].as_str()).unwrap_or("").to_string();+ let text = std::fs::read_to_string(r.current_board()).unwrap_or_default();+ if !text.contains("(generator \"adom-aiflow-fusion\")") {+ if let Err(e) = provenance::saved_by_live_kicad(&text, &kv) { missing.push(format!("board file: {e}")); }+ }+ }+ let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+ // Owner-reviewed contacts are revalidated, by exact identity, at finish.+ // Never turn their number into a generic inherited-error allowance.+ let mut contacts_at_finish = Value::Null;+ let contact_declared = spec_f["placement"]["approvedCourtyardContacts"].as_array().is_some_and(|a| !a.is_empty());+ if contact_declared && !pattern {+ let rb = r.data["remoteBoard"].as_str().unwrap_or("");+ let before = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true}));+ let validation = br.call("kicad_placement_validate", &json!({"filePath": rb}));+ let after = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true}));+ let checked = placement_verify::check_with_spec(&board_of(&r), &before, &validation, &after, rb, &spec_f);+ write_json(&dir.join("finish-assembly-contacts.json"), &json!({"before":before,"validation":validation,"after":after}));+ match checked {+ Ok(v) if !v.approved_contacts.is_empty()+ && validation["drc"]["unconnected"].as_u64() == Some(0)+ && validation["drc"]["unconnectedReportMayBeTruncated"] == false+ && live["errors"] == validation["drc"]["errors"] => contacts_at_finish = v.to_json(),+ Ok(_) => missing.push("finish: native contact/connectivity reports do not reconcile".into()),+ Err(e) => missing.push(format!("finish: reviewed assembly contacts: {e}")),+ }+ }+ if live["unconnected"].as_u64() != Some(0) || (live["errors"].as_u64().unwrap_or(u64::MAX) > inherited && contacts_at_finish.is_null()) {+ missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));+ }+ // KiCad's full DRC on the live board, warnings included: the manufacturing findings, and the silkscreen+ // findings (ink over a mask opening, ink on ink, ink past the edge, text under the fab minimum), which are+ // advisories unless fab.silkBlocking opts in (issue #92); the routing validate above counts errors only+ if !eda.caps().full_drc {+ eprintln!("finish: {} has no full DRC through its bridge; the silkscreen findings below are not checked (recorded as a gap)", eda.name());+ r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));+ }+ let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null };+ // the live result is the run's DRC evidence (issue #86): the pattern lane's DRC and connectivity+ // items read it, bound to the board it ran on; recorded even when finish refuses+ let silk_blocking = silk_policy::blocking(&spec_f, &r.current_board());+ let mut rec = verified::live_drc_record_with(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited, silk_blocking);+ rec["reviewedAssemblyContacts"] = contacts_at_finish; // raw counts and clean=false stay honest+ if pattern && rec["clean"] != true {+ let f = &rec["fullDrc"];+ if f["ran"] != true { missing.push(format!("native DRC: the live full DRC returned no result ({}); finish again with the board open on {target}", f["error"].as_str().unwrap_or("no reply"))); }+ else if f["errors"] != 0 || f["unconnected"] != 0 || f["manufacturingWarnings"] != 0 { missing.push(format!("native DRC: the live full DRC has {} error(s), {} unconnected, {} manufacturing warning(s) {}", f["errors"], f["unconnected"], f["manufacturingWarnings"], f["manufacturingWarningTypes"])); }+ }+ live_rec = Some(rec);+ let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).or(drc["data"]["violations"].as_array()).cloned().unwrap_or_default();+ // silkscreen findings are advisories unless the spec or the rules profile opts in (issue #92)+ let (silk, refusal) = silk_policy::finish_findings(&found, silk_blocking);+ if let Some(m) = refusal { missing.push(m); } else if !silk.is_empty() { eprintln!("Warning: {}", silk_policy::advisory_line(silk.len(), &silk)); }+ silk_advisories = if silk_blocking { Vec::new() } else { silk };+ }+ // 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));+ // a pattern board with nothing in its BOM has nothing to source; any BOM footprint keeps the check+ let nothing_to_source = pattern && preboard::board_refs(&board_now()).0.is_empty();+ if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)).filter(|_| !nothing_to_source) {+ 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(", "))); }+ }+ {+ let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+ for (a, h, t) in thermalvias::short(&spec_f, &board_now(), &acked) { missing.push(format!("heatsink vias: {a} has {h} of {t} (thermal-vias plan, land)")); }+ }+ {+ // the outline as the board is now: the live snapshot when the tool has one, else the plan board+ let edges = outline::kicad_edges(&std::fs::read_to_string(live_board.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default());+ let sharp = outline::sharp_corners(&edges);+ if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }+ }+ if let Some(n) = switchnode::note(&spec_f, &board_now()) { eprintln!("{n}"); }+ for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); }+ // a recorded silkscreen proof is re-checked against the board's own pads and vias (the audit's geometry is supplied)+ if let Some(inp) = o.get("silkscreen").and_then(|p| p["input"].as_str()) {+ if let Ok(t) = std::fs::read_to_string(inp) {+ if let Ok(v) = serde_json::from_str::<Value>(&t) {+ for g in silkproof::geometry_gaps(&v, &board_now()) { missing.push(format!("silkscreen audit: {g} (silkscreen-audit --record again on real geometry)")); }+ }+ }+ }+ if copper_carry::silk_dropped(&r.data) { missing.push("silkscreen proof (adopt-board changed the board file; `silkscreen-audit --record` again)".into()); }+ // the test plan again, on the finished board: every measurement still has a landing+ if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {+ let list = testplan::tests_of(&read_json(&tp));+ let (_, gaps, _) = testplan::check_board(&list, &board_now());+ if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); }+ }+ let (qv, quality_lines) = route_quality::of_board(&board_now(), &spec_soft(&r));+ // every required step before the finish line, done in order and proven+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ // issue #60: declared mechanical and wiring reviews, bound to this board, spec and review file+ reviews::sync_sequence(&mut r.data);+ for g in reviews::finish_gaps(&r.data, Path::new(&r.current_board())) { missing.push(g); }+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if !seq.is_empty() {+ let st = sequence::states(&dir, &r.data, &flow_full, &seq);+ for b in sequence::blockers(&st, "finish") { missing.push(format!("step {b}")); }+ sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE);+ }+ // parts from the board's own project library stay visible as evidence (issue #66)+ let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);+ let lib_parts = project_lib::report(&parts_v);+ for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }+ if let Some(rec) = live_rec.take() { r.outcome(verified::LIVE_DRC, rec); r.save().ok(); }+ r.outcome("finish-checks", json!({"powerInputs": label_checks["powerInputs"], "testPoints": label_checks["testPoints"], "lines": check_lines})); r.save().ok();+ let (bs, ss) = run_hashes(&r);+ let mut items: Vec<String> = missing.iter().map(|m| deviations::key(m)).collect();+ items.dedup();+ let (missing, deviated) = deviations::split(missing, &deviations::live(&r.data, &bs, &ss));+ r.data["lastRefusalItems"] = json!(items);+ r.save().ok();+ // a report card, never a refusal (John, 2026-11-04): what is still open is listed with suggestions+ let open_items = missing.clone();+ if !open_items.is_empty() {+ eprintln!("Open items (suggestions, the board is not blocked):\n {}", open_items.join("\n "));+ }+ if !deviated.is_empty() {+ // finish-guard honours the steps these deviations left unproven, on this board and spec only+ let before_finish: Vec<String> = pending_steps(&r).into_iter().take_while(|s| s != "finish").collect();+ r.data["deviatedSteps"] = json!({"steps": before_finish, "boardSha256": bs, "specSha256": ss});+ }+ 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 sink_assumptions = o.get("analyze-thermal").map(|x| x["boardSinkAssumptions"].clone()).filter(|v| v.is_array()).unwrap_or(json!([]));+ let mut reviewed: Vec<String> = acked_peaks.iter().map(|c| format!("acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} ({}): \"{}\" by {}", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))).collect();+ reviewed.extend(sink_assumptions.as_array().into_iter().flatten().filter_map(|a| a.as_str()).map(|a| format!("board-sink assumption: {a}")));+ let checked = verified::run_items(&r, facts.as_ref());+ let summary = json!({"verified": verified::to_json(&checked), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "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, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions});+ r.data["summary"] = summary;+ if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }+ r.data["summary"]["projectLibraryParts"] = json!(lib_parts);+ r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);+ r.data["summary"]["silkAdvisories"] = json!({"count": silk_advisories.len(), "list": silk_advisories.iter().take(50).collect::<Vec<_>>()});+ if let Some(lf) = &led_file {+ let sha = sha256_file(&lf.display().to_string());+ r.data["summary"]["ledStates"] = json!({"path": lf.display().to_string(), "sha256": sha});+ if let Some(rel) = led_states::record_in_release(&solved_board, lf, &sha) { eprintln!("ledStates recorded in {}", rel.display()); }+ }+ r.data["summary"]["notApplicable"] = json!(not_applicable);+ r.data["summary"]["deviations"] = json!(deviated);+ r.data["summary"]["open"] = json!(open_items);+ // issues #127/#128: route geometry and signal quality of the saved board, report-card lines that never block+ let qp = dir.join("finish-route-quality.json");+ write_json(&qp, &qv);+ r.data["summary"]["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string());+ if nothing_to_source { r.data["summary"]["notApplicable"].as_array_mut().unwrap().push(json!("sourcing check: not applicable (no footprint in the BOM)")); }+ 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 {}, {:.1} min\npours: {}\n{}{}", r.data["engine"], match (open_items.len(), deviated.len()) { (0, 0) => "passing the configured screens".to_string(), (0, d) => format!("passing the configured screens except {d} recorded deviation(s), listed below"), (o, d) => format!("with {o} open item(s) still worth a look{} (listed above; finish again after any change)", if d > 0 { format!(" and {d} recorded deviation(s)") } else { String::new() }) }, r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &na_text(&r.data["summary"]["notApplicable"]) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n {}", reviewed.join("\n ")) } + &if deviated.is_empty() { String::new() } else { format!("\nfinished with {} recorded deviation(s) (checks this board did not meet, accepted on purpose):\n {}", deviated.len(), deviated.iter().map(|d| format!("{}: {} (why: {})", d["item"].as_str().unwrap_or(""), d["line"].as_str().unwrap_or(""), d["why"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("\n ")) } + &if quality_lines.is_empty() { String::new() } else { format!("\n{}", quality_lines.join("\n")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "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(), results_hint(&r), molecule_publish_hint(&r).unwrap_or_default(), give_back_hint()]);+ }+ Cmd::Capture { action, label, reason, off_page } => {+ let mut r = load_run(&cli);+ if action == "screen" {+ // the human's word, mid-run: "you can take my screen now" -> allowed, until they say otherwise+ let m = match label.as_deref() {+ None => None,+ Some(l) => match screen::Mode::parse(l) {+ Some(m) => Some(m.as_str()),+ None => { note(&format!("capture screen {l}: not a screen mode; the modes are hands-off, ask, allowed. The mode is unchanged."), &[]); None }+ },+ };+ if let Some(m) = m { r.data["screen"] = json!(m); }+ let mode = screen_mode(&mut r);+ r.save().unwrap();+ let src = r.data["screenMode"]["source"].as_str().unwrap_or("").to_string();+ ok(&screen::line(mode, &src), &[if m.is_some() && std::env::var("AIFLOW_SCREEN").is_ok() { "AIFLOW_SCREEN is set in this environment and wins over run.json.".into() } else { String::new() }]);+ return;+ }+ 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 raise = screen_mode(&mut r).may_raise();+ let (hwnd, shown) = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box")), raise).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, "window": shown}));+ 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(), KICAD_SOCKET_HINT.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, {shown} (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()))));+ if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&clipwatch::no_recorder_note(&r.data).unwrap_or_default(), &["Steps still keep their start and end screenshots; `deliver` works without clips.".into()]); }+ 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 let Some(why) = stop.failure().filter(|_| p.is_none()) {+ err(&format!("recording not collected: {why}"), &["The capture is flagged pullFailed. When the take was still finalising, `adom-aiflow recut` stops and pulls it later; a take Adom Bridge no longer knows cannot be pulled: re-record the step if the clip matters.".into()]);+ }+ 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);+ if r.data["captures"][pos]["truncated"] == true { err(&format!("recording stopped, but the clip {} is truncated (no moov atom: the take died before it was finalised)", p.unwrap_or_default()), &["Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.".into(), clips_note]); }+ 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)));+ // issue #155: a clip of another project (privacy) leaves the page and the run, not kept as evidence+ if *off_page {+ let gone = discard_clip_files(&mut r, pos, &format!("off the page by the AI: {why}"));+ r.log("clip-discarded", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai", "removed": gone}));+ r.save().unwrap();+ ok(&format!("clip {tag} taken off the page: {} local file(s) deleted, the capture kept only as a ledger line with your reason", gone.len()), &["The next page publish leaves it out (the page is rebuilt from the run). A page published before keeps it until then: publish again now if it was pushed. Re-take the step if its work should be on camera: `step <name> --back --why ...`.".into()]);+ }+ 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, reject or screen"), &[]),+ }+ }+ 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()).or_else(|| r.data["lastRoute"]["unrouted"].as_u64().map(|u| format!("{u} (previous board)"))).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) { pourverify::mode_label(&o["land-pours"]) } 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"]) + "\n" + &attention::status_lines(&r.data).join("\n")), &[step_table(&r)]);+ }+ Cmd::Report { page, page_dir, push, refresh, user_machine, surface } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if let Err(e) = pagedir::set(&mut r.data, "page", "pageDir", page.as_deref(), page_dir.as_deref()) { err(&e, &[]); }+ 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();+ if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }+ // no page (issue #85): the run README is written locally and nothing is pushed+ let Some(pg) = r.data["page"].as_str().map(str::to_string) else {+ let (_, _, readme) = build_report(&r, &dir, None);+ ok(&format!("report written locally: {} (no page for this run: nothing pushed{})", readme.display(), if *push || *refresh { "; --push and --refresh need a page" } else { "" }), &["Its media sit beside it and are referenced relatively. To publish it, name the page once: `report --page <owner/slug> [--page-dir <folder>] --push`.".into()]);+ return;+ };+ 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);+ if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then recut again. No clip was judged.".into()]); }+ 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);+ if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }+ let mut r = load_run(&cli);+ if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }+ let cwd = std::env::current_dir().unwrap_or_else(|e| err(&format!("cannot read the working directory: {e}"), &[]));+ let outp = compose_out_path(out.as_deref(), &dir, &cwd);+ if let Some(parent) = outp.parent() {+ if let Err(e) = std::fs::create_dir_all(parent) { err(&format!("compose cannot create the output folder {} for {}: {e}", parent.display(), outp.display()), &["Pass --out <file.mp4> in a folder you can write; a relative --out is resolved against the current working directory, and without --out the video is final.mp4 in the run directory.".into()]); }+ }+ 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);+ // Snapshot even in-run sources: later edits must not rewrite earlier evidence.+ let (dest, artifact_hash) = artifact_store::snapshot(&src, &dir)+ .unwrap_or_else(|e| err(&e, &["No artifact was registered. Resolve the storage error and retry; existing evidence is preserved.".into()]));+ 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, "sha256": artifact_hash, "sourceFile": src.display().to_string()}));+ 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: {}", aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 200)), &["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"), &["Continue the original requested scope after supporting tasks and status updates. Use `done --message \"...\"` only at completion, an explicit pause, or a genuine blocking handoff; it stops session accounting, not the engineering run.".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()]);+ print_next(&cli);+ }+ Cmd::FinishGuard { .. } => unreachable!("handled before side effects"),+ Cmd::Done { message, stop_evidence } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let verdict = continuation_report(&r, stop_evidence.as_deref());+ if verdict["mayStop"] != true {+ note("closing this session before the finish line (the run stays open for later prompts)", &[verdict["reason"].as_str().unwrap_or("").into(), verdict["nextAction"].as_str().unwrap_or("").into(), "A status update, a passing diagnostic, a fixable tool bug or a failed engineering gate is not a stopping point. Run finish-guard before any final answer.".into()]);+ }+ 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, "stopDecision": verdict}));+ 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()), &["Run finish-guard before a final answer. Intermediate progress stays in commentary; done requires completed delivery or explicit stop evidence. Unobserved time is not automatically 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 schematic_files = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy: {e}"), &[]));+ 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 netlist_hash = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(text.as_bytes())) };+ let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));+ let after_export = schematic_identity::capture(Path::new(&sch)).unwrap_or_else(|e| err(&format!("schematic hierarchy after export: {e}"), &[]));+ if schematic_files != after_export { err("schematic hierarchy changed during netlist export; rerun with stable inputs", &[]); }+ 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 sch_check = schematic_symbols::check(Path::new(&sch), &run.as_ref().map(spec_soft).unwrap_or(Value::Null));+ lines.push(schematic_symbols::summary_line(&sch_check));+ if let (Some(r), Ok(sc)) = (run.as_mut(), &sch_check) { schematic_symbols::record(r, &sch, sc); }+ // issue #150: KiCad's ERC on the same schematic, recorded so finish knows it (an adopted board+ // has no `schematic check`); a kicad-cli that cannot run ERC is a note, never a refusal+ if let Some(r) = run.as_mut() {+ let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+ let erc_report = dir.join("erc.json");+ match schematic_check::run_erc(&kc, &sch, &erc_report) {+ Ok((_, errs)) => {+ lines.push(if errs.is_empty() { "ERC (kicad-cli sch erc --severity-all): 0 errors, recorded for finish".to_string() } else { format!("ERC (kicad-cli sch erc --severity-all): {} error(s): {}", errs.len(), errs.iter().take(6).cloned().collect::<Vec<_>>().join("; ")) });+ r.outcome("erc", json!({"pass": errs.is_empty(), "errors": errs, "schematic": sch, "sha256": sha256_file(&sch), "report": erc_report.display().to_string(), "source": "netlist check (kicad-cli sch erc)"}));+ }+ Err(e) => note(&format!("ERC did not run, so it is not recorded: {e}"), &["Run `netlist check` again with a kicad-cli that can run `sch erc` (in a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>); finish shows ERC as not known until then.".into()]),+ }+ }+ 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), "schematicFiles": schematic_files, "netlistSha256": netlist_hash, "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("; ")) };+ // issue #138: the machine pin and contact names the wiki's model registry counts, from the schematic's references+ let names_hint = molecule_pins::names_from_footprints(&sn.refs.iter().map(|(a, b)| (a.clone(), b.clone())).collect::<Vec<_>>()).into_iter().map(|(finding, suggestion)| format!("Molecule names: {finding}. {suggestion}")).collect::<Vec<_>>().join(" ");+ if !passed {+ // AI Flow never refuses (0.2.48): a mismatch is findings with the ways on+ findings(&format!("the board does not match the schematic yet\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, names_hint]);+ }+ // issue #162: the edge rule as early as the board exists, while moving a fixed contact is one edit+ let edge_hint = run.as_ref().map(spec_soft).filter(|sp| sp.get("molecule").is_some()).map(|sp| checks::contact_edge(&b, &sp).1.into_iter().map(|(f, g)| format!("Molecule contacts: {f}. {g}")).collect::<Vec<_>>().join(" ")).unwrap_or_default();+ ok(&lines.join("\n"), &[names_hint, edge_hint, "This is electrical parity between schematic and board (plus the ERC line above, when it ran) only: not DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), 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, bom } => {+ 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 bom_path = bom.clone().or_else(|| run.as_ref().and_then(run_bom));+ let bom_text = bom_path.as_ref().map(|p| std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[])));+ let f = checks::molecule_check_bom(&b, &spec_v, before.as_ref(), bom_text.as_deref());+ 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, "pinClass": f.pin_class, "bom": bom_path}));+ 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() { findings(&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::ThermalVias { action, ack, why, pause_ms } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let spec = spec_of(&r);+ if let Some(refs) = ack {+ let w = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<why this part needs no more vias>\"", &[]));+ for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }+ }+ let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+ // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it+ let b = if action == "plan" { live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));+ let sw = switchnode::nets(&spec, &b);+ if action == "plan" {+ let (parts, plan) = thermalvias::plan_with_acknowledgements(&aiflow_pours::with_project_rules(&spec, &r.current_board()), &b, &sw, &acked);+ let pp = dir.join("plan-thermal-vias.json");+ write_json(&pp, &plan);+ // issue #40: spec.pours against the board's zones; a stale outline makes the counts wrong+ let pours_landed = r.outcomes().get("land-pours").map(|p| p["pass"] == true || p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false);+ let freshness = thermalvias::pour_freshness(&spec, &b, pours_landed);+ for f in freshness.iter().filter(|f| f.starts_with("WARNING")) { eprintln!("{f}"); }+ let mut lines: Vec<String> = parts.iter().map(|p| format!(" {:<5} {:<5} target {:>2} existing {:>2} planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!(" ({n})")).unwrap_or_default())).collect();+ // issue #136: say which copper each answer rests on, the board's own zones by name+ for p in parts.iter().filter(|p| !p.copper.is_empty()) { lines.push(format!(" {} copper read: {}", p.reference, p.copper.join("; "))); }+ let board_zones: Vec<String> = thermalvias::board_regions(&spec, &b).iter().filter_map(|z| z["label"].as_str().map(str::to_string)).fold(Vec::new(), |mut v, l| { if !v.contains(&l) { v.push(l); } v });+ if board_zones.is_empty() { lines.push(" board zones: none filled on the saved board; only spec.pours was read as copper".into()); }+ else { lines.push(format!(" board zones read as copper ({} filled on the saved board): {}", board_zones.len(), board_zones.join("; "))); }+ lines.extend(freshness.iter().map(|f| format!(" {f}")));+ r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note, "copper": p.copper})).collect::<Vec<_>>(), "pourFreshness": freshness, "boardZones": board_zones}));+ r.save().unwrap();+ if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }+ ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into(), "Copper here is spec.pours plus the saved board's filled zones (the human's zones count before the pours step adopts them); after the human redraws or refills a zone, save the board (adopt-board) and plan again.".into()]);+ } else {+ let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[]));+ // issue #90: an acknowledged part's planned vias stay off the board+ let (to_land, left_out) = thermalvias::without_acked(&read_json(&pp), &acked);+ let pp = if left_out.is_empty() { pp } else { let lp = dir.join("plan-thermal-vias-landing.json"); write_json(&lp, &to_land); for (a, k) in &left_out { eprintln!("{a}: {k} planned heatsink via(s) left out (acknowledged)"); } lp.display().to_string() };+ let n = to_land["nets"].as_array().map(|a| a.len()).unwrap_or(0);+ if n > 0 {+ let exe = std::env::current_exe().unwrap();+ let st = std::process::Command::new(exe).args(["--run", &dir.display().to_string(), "--ai-thread", &thread(&cli), "land", "vias", "--plan", &pp, "--pause-ms", &pause_ms.to_string()]).status().unwrap_or_else(|e| err(&e.to_string(), &[]));+ if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); }+ }+ let r2 = load_run(&cli);+ // issue #101: count on the live board (Fusion: its live snapshot; KiCad: kicad_routing_state)+ let (short, counted_on) = match live_board(&r2, &cli, &dir) {+ Some(l) => (thermalvias::short(&spec, &Board::load(&l.1).unwrap_or_else(|e| err(&e, &[])), &acked), "live board"),+ None => match r2.data["remoteBoard"].as_str().filter(|s| !s.is_empty()).and_then(|rb| bridge_of(&r2, &cli).live_copper(rb)) {+ Some(live) => (thermalvias::short_live(&spec, &board_of(&r2), &live, &acked), "live board (kicad_routing_state)"),+ None => { eprintln!("NOTE: the live board could not be read (kicad_routing_state); the heatsink vias are counted on the saved snapshot, which lacks vias landed since the last adopt-board"); (thermalvias::short(&spec, &board_of(&r2), &acked), "saved snapshot (live read unavailable)") }+ },+ };+ let mut r = r2;+ r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked, "leftOutAcked": left_out, "countedOn": counted_on}));+ r.save().unwrap();+ if !short.is_empty() { findings(&format!("{n} heatsink vias landed, but parts are still short (counted on the {counted_on}): {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }+ ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]);+ }+ }+ Cmd::Outline { action, radius } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let rad = radius.or_else(|| spec_soft(&r)["outline"]["cornerRadius"].as_f64()).unwrap_or(1.0);+ let eda = aiflow_bridge::eda::active();+ let read = |r: &Run| -> Vec<outline::Edge> {+ let board = live_board(r, &cli, &dir).map(|l| l.1).unwrap_or_else(|| r.current_board());+ outline::kicad_edges(&std::fs::read_to_string(board).unwrap_or_default())+ };+ let before = outline::sharp_corners(&read(&r));+ if action == "check" || before.is_empty() {+ // issue #152: `check` records the proof too when nothing is sharp (round would change nothing)+ if before.is_empty() { r.outcome("outline", json!({"radius": rad, "rounded": 0, "sharp": 0, "by": action})); r.save().unwrap(); }+ if before.is_empty() { ok("the board outline has no sharp corners (recorded: the outline step is proven)", &[]); return; }+ err(&format!("{} sharp outline corner(s): {}", before.len(), before.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["`outline round` turns each into an arc (spec outline.cornerRadius, default 1.0 mm).".into()]);+ }+ let br = bridge_of(&r, &cli);+ let rep = eda.round_corners(&|v: &str, a: &Value| br.raw_call(v, a), &before, rad);+ if rep["errorCode"].as_str().map(|c| c.starts_with("unsupported_in_")).unwrap_or(false) {+ err(&format!("outline round does not land live in {} yet", eda.name()), &["Round the board-outline corners in the tool (the corner radius), save, then `outline check` and `outline round` again to record it.".into()]);+ }+ let done = rep["rounded"].as_u64().unwrap_or(0);+ let after = outline::sharp_corners(&read(&r));+ r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()}));+ r.save().unwrap();+ if !after.is_empty() { findings(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }+ ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);+ }+ Cmd::Timeline { action, port, json: presentation, reuse_tab, new_tab, why, name, finished, stale } => {+ if action == "list" {+ let (head, lines) = dashboard::list().unwrap_or_else(|e| err(&e, &["`timeline list` reads Hydrogen's tabs; retry when Hydrogen is connected.".into()]));+ println!("OK: {head}");+ for l in &lines { println!("- {l}"); }+ println!("Hint: one tab per run keeps the human's Hydrogen tidy. Close finished runs' tabs with `adom-aiflow --ai-thread <you> timeline close --finished`; a thread reuses its own finished tab automatically.");+ return;+ }+ if action == "close" {+ thread(&cli);+ let picked = [cli.run.is_some(), name.is_some(), *finished, *stale].iter().filter(|x| **x).count();+ if picked != 1 { err("timeline close needs exactly one of --run <dir>, --name \"AI Flow · ...\", --finished, --stale", &["`timeline list` shows every AI Flow tab with its run, thread and whether it finished.".into()]); }+ let sel = if let Some(n) = name { dashboard::Close::Name(n.clone()) } else if *finished { dashboard::Close::Finished } else if *stale { dashboard::Close::Stale } else { dashboard::Close::Run(dir.clone()) };+ let (closed, stopped) = dashboard::close(&sel).unwrap_or_else(|e| err(&e, &["Only tabs named \"AI Flow · ...\" are ever closed; other tabs are the human's.".into()]));+ ok(&format!("closed {} AI Flow tab(s){}{}", closed.len(), if closed.is_empty() { String::new() } else { format!(": {}", closed.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ")) }, if stopped.is_empty() { String::new() } else { format!("; stopped timeline server(s) of {}", stopped.join(", ")) }),+ &["Board runs and their files are unchanged; `timeline open --run <dir>` shows a run again.".into()]);+ return;+ }+ let mut r = load_run(&cli);+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ {+ let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data));+ if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); r.save().unwrap(); }+ }+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));+ let tl = sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report());+ sequence::write_timeline(&dir, &tl, TIMELINE_PAGE);+ let tdir = dir.join("timeline");+ match action.as_str() {+ "write" => ok(&format!("timeline written: step {} of {}, {} done", tl["position"], tl["total"], tl["done"]), &[]),+ "serve" => {+ timeline_server::serve(&tdir,*port).unwrap_or_else(|e|err(&e,&["Use the existing run server; do not create another dashboard.".into()]));+ }+ "present" => {+ let caller=thread(&cli);+ let patch:Value=serde_json::from_str(presentation.as_deref().unwrap_or("{}")).unwrap_or_else(|e|err(&e.to_string(),&[]));+ let shown=dashboard::ensure(&dir,&caller,None,false,&dashboard::OpenOpts::default()).unwrap_or_else(|e|err(&e,&[]));+ if let Some(w)=&shown.warning { err(w,&["The presentation goes to this run's own dashboard tab; settle which tab shows this run first.".into()]); }+ let config=read_json(&dir.join("dashboard.json").to_string_lossy());+ let reply=timeline_server::update(config["port"].as_u64().unwrap() as u16,&caller,&patch).unwrap_or_else(|e|err(&e,&["The existing server must support the current live channel. Restart only this run server after an upgrade.".into()]));+ ok(&format!("presentation updated in the existing dashboard: {}",reply["presentation"]), &["SSE updates the open page in place; engineering gates are unchanged.".into()]);+ }+ _ => {+ let ai_thread=cli.ai_thread.clone().or_else(||r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(||"adom-aiflow".into());+ let opts=dashboard::OpenOpts{explicit:true,reuse_tab:reuse_tab.clone(),new_tab:*new_tab,why:why.clone()};+ let shown=dashboard::ensure(&dir,&ai_thread,Some(*port),true,&opts).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()]));+ if let Some(w)=&shown.warning { err(w,&["`adom-aiflow timeline list` shows every AI Flow tab, its run, thread and whether it finished.".into()]); }+ let mut hints: Vec<String> = shown.notes.clone();+ hints.push("Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into());+ ok(&format!("timeline open in Hydrogen as \"{}\": {} (step {} of {})", shown.name.unwrap_or_default(), shown.url.unwrap_or_default(), tl["position"], tl["total"]), &hints);+ }+ }+ }+ Cmd::Demo { action, at, port, title } => {+ let dir = run_dir(&cli);+ if action == "serve" {+ let d = dir.join("demo").join("timeline");+ if !d.join("timeline.json").exists() { err("nothing to serve yet", &["Run `demo at <step>` first; it writes <run>/demo/timeline/.".into()]); }+ timeline_server::serve(&d, *port).unwrap_or_else(|e| err(&e, &["Pick another --port; the run's live dashboard keeps its own.".into()]));+ return;+ }+ let r = load_run(&cli);+ let at = at.clone().unwrap_or_else(|| err("demo at needs a moment", &["`demo at placement`, `demo at routing@2+60`, or an ISO time like 2026-10-18T19:50:00Z.".into()]));+ let t = demo::resolve(&r.data, &at).unwrap_or_else(|e| err(&e, &["Step names are the run's (`status` lists them); @n picks the nth visit.".into()]));+ let state = demo::rewind(&dir, &t).unwrap_or_else(|e| err(&e, &[]));+ let rs = Run::open(&state).unwrap_or_else(|| err("the rewound state did not open", &[]));+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data)));+ let mut tl = sequence::timeline(&state, &rs.data, &flow_full, &seq, &rs.step_report());+ let d = demo::write(&dir, &t, &mut tl, TIMELINE_PAGE, title.as_deref()).unwrap_or_else(|e| err(&e, &[]));+ ok(&format!("demo at {t}: step {} of {}, {} done, {} loop back(s), current {}; written to {}", tl["position"], tl["total"], tl["done"], tl["loops"].as_array().map(|a| a.len()).unwrap_or(0), tl["current"], d.display()),+ &["`demo serve --port 8894` serves it (the run's live dashboard is untouched); an open page updates in place on the next `demo at`.".into()]);+ }+ Cmd::Review { kind, input, glb } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let res = reviews::run(&mut r, kind, input.as_deref(), glb.as_deref());+ r.save().unwrap();+ // a review that ran and found problems is findings; a review that could not run (no tool, bad input) is an error+ match res { Ok((m, h)) => ok(&m, &h), Err((m, h)) if m.contains("did not pass") => findings(&m, &h), Err((m, h)) => err(&m, &h) }+ }+ Cmd::Testplan { action: _, tests, netlist, add } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let tp = tests.clone().or_else(|| r.data["testPlan"].as_str().map(str::to_string)).unwrap_or_else(|| err("testplan check needs --tests tests.json the first time", &["Write tests.json: [{id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}], one per measurement you will take on the probing workcell (skill aiflow-test-plan).".into()]));+ let tv = read_json(&tp);+ let list = testplan::tests_of(&tv);+ if list.is_empty() { err(&format!("{tp} has no tests"), &["A board with nothing to measure has not been thought through: list the rails, the switch node, feedback, ripple, enable and every LED.".into()]); }+ let tp_abs = std::fs::canonicalize(&tp).map(|p| p.display().to_string()).unwrap_or(tp.clone());+ r.data["testPlan"] = json!(tp_abs);+ let has_board = r.data["boards"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+ let (rows, gaps, notes, added, mode) = if has_board {+ let (rows, gaps, warn) = testplan::check_board(&list, &board_of(&r));+ (rows, gaps, warn, Vec::new(), "board")+ } else {+ let np = netlist.clone().unwrap_or_else(|| err("before the board, testplan check needs --netlist <netlist.json>", &[]));+ let mut nl = read_json(&np);+ let (mut rows, mut gaps, unknown) = testplan::check_netlist(&list, &nl);+ if !unknown.is_empty() { err(&format!("tests name nets the netlist does not have: {}", unknown.join(", ")), &["Use the netlist's own net names (or rename the net in the design so the measurement has a name).".into()]); }+ let fixable = testplan::addable(&rows);+ let added = if *add && !fixable.is_empty() {+ let a = testplan::add_test_points(&mut nl, &fixable); write_json(Path::new(&np), &nl);+ // issue #143: the footprint, symbol and 3D model come with the binary; the wiki page is private+ match testplan::write_test_point_files(Path::new(&np).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."))) {+ Ok(to) => eprintln!("test point footprint, symbol, Fusion library and 3D model: {}", to.display()),+ Err(e) => eprintln!("WARNING the test point files could not be written: {e}"),+ }+ // the table and the gaps as the netlist now stands: only a ground test with no contact is left+ let again = testplan::check_netlist(&list, &nl); rows = again.0; gaps = again.1;+ a+ } else { vec![] };+ (rows, gaps, vec![], added, "netlist")+ };+ let table: Vec<String> = rows.iter().map(|x| format!(" {:<10} {:<12} {:<9} {}", x["id"].as_str().unwrap_or(""), x["net"].as_str().unwrap_or(""), x["kind"].as_str().unwrap_or(""), match x["status"].as_str().unwrap_or("") { "gap" => format!("GAP: {}", x["why"].as_str().unwrap_or("no test point")), s => format!("{s} {}", x["spot"].as_object().map(|o| format!("{} at ({}, {})", o["pad"].as_str().unwrap_or(""), o["x"], o["y"])).or_else(|| x["spot"].as_str().map(str::to_string)).unwrap_or_default()) })).collect();+ r.outcome("testplan", json!({"mode": mode, "tests": rows, "gaps": gaps, "added": added, "notes": notes, "file": tp_abs}));+ r.save().unwrap();+ let head = format!("test plan ({mode}): {} measurements, {} probe-able, {} gap(s){}", rows.len(), rows.iter().filter(|x| x["status"] != "gap").count(), gaps.len(), if added.is_empty() { String::new() } else { format!("; added {}", added.join(", ")) });+ let body = format!("{head}\n{}", table.join("\n"));+ if !gaps.is_empty() {+ let tether_hint = "A ground test needs a free MachineContactMedium on that net for the tether's medium pin: add one to the design (a test point or a large contact cannot take it).".to_string();+ if testplan::ground_gap(&rows) && testplan::addable(&rows).is_empty() { findings(&body, &[tether_hint]); }+ err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]);+ }+ let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];+ hints.extend(notes.iter().map(|n| format!("Note: {n}")));+ if mode == "netlist" { hints.push("Run it again once the board is placed (`testplan check`): a test point under a part or out of tether reach is a gap again. finish runs it too.".into()); }+ ok(&body, &hints);+ }+ Cmd::Rules { action, profile, board, force, layers, variant, prefs } if action == "use" || action == "list" => {+ let page = "adom/pcb-design-rules";+ let list = || -> Vec<String> {+ let o = std::process::Command::new("adom-wiki").args(["api", "/api/v1/pages/adom/pcb-design-rules/files?path=kicad"]).output().ok();+ let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+ let mut out: Vec<String> = v["files"].as_array().cloned().unwrap_or_default().iter().filter_map(|f| f["name"].as_str()).filter(|n| n.ends_with(".kicad_dru")).map(|n| n.trim_end_matches(".kicad_dru").to_string()).collect();+ out.sort(); out+ };+ if action == "list" {+ let l = list();+ if l.is_empty() { err(&format!("could not read the rule packs on {page}"), &["Check `adom-wiki whoami` and the wiki's health, then retry.".into()]); }+ // published packs end in -<n>L; anything after that is a variant someone published on top of one+ let is_variant = |n: &str| n.rsplit_once("L-").is_some_and(|(a, _)| a.rsplit('-').next().is_some_and(|d| d.chars().all(|c| c.is_ascii_digit()) && !d.is_empty()));+ let (vars, pubs): (Vec<String>, Vec<String>) = l.into_iter().partition(|n| is_variant(n));+ ok(&format!("{} published rule packs on {page} (<fab>[-<process>]-<n>L), the fab's own limits:\n {}{}", pubs.len(), pubs.join("\n "), if vars.is_empty() { String::new() } else { format!("\n{} variant(s) people published on top of a pack (<pack>-<variant>, opt-in):\n {}", vars.len(), vars.join("\n ")) }), &["Ask the human which fab and which set before choosing: the published pack (the default, the fab's own limits), a variant, or their own preferences (`rules use --prefs <file.json>`). `rules use --profile <fab> --layers <n> [--variant <name>]`.".into()]);+ return;+ }+ let mut r = load_run(&cli);+ let asked = profile.clone().unwrap_or_else(|| "3rdPartyFab".into());+ // `--profile jlcpcb-6L` names the layers too; --layers must agree with it+ let (asked_base, asked_layers) = checks::split_profile(&asked);+ if let (Some(a), Some(b)) = (asked_layers, *layers) { if a != b { err(&format!("--profile {asked} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }+ let cb = board.clone().unwrap_or_else(|| r.current_board());+ let layers: u32 = layers.or(asked_layers).or_else(|| checks::board_copper_layers(&cb)).unwrap_or(2);+ let slug = match asked_base.as_str() { "fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };+ let shown = if slug == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { slug.clone() };+ let variant = variant.clone().map(|v| v.trim().to_ascii_lowercase()).filter(|v| !v.is_empty());+ let file = match &variant { Some(v) => format!("{slug}-{layers}L-{v}.kicad_dru"), None => format!("{slug}-{layers}L.kicad_dru") };+ let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, &format!("kicad/{file}")]).output();+ let text = o.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.contains("(rule")).unwrap_or_else(|| {+ let l = list();+ let near: Vec<&String> = l.iter().filter(|n| n.starts_with(&format!("{slug}-{layers}L"))).collect();+ err(&format!("no {} for {shown} on {page} ({file})", match &variant { Some(v) => format!("{layers}-layer variant {v:?}"), None => format!("{layers}-layer rule pack") }), &[format!("For {shown} {layers}L there: {}. All packs: `rules list`.", if near.is_empty() { "nothing".into() } else { near.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ") })])+ });+ // the human's own preferences: kept in the spec and checked by AI Flow as warnings, never written into the+ // KiCad rules (KiCad applies one rule per constraint, so a preference rule would replace the fab's limit)+ let mut prefs_rec = Value::Null;+ if let Some(pf) = prefs {+ let pv: Value = serde_json::from_str(&std::fs::read_to_string(pf).unwrap_or_else(|e| err(&format!("{pf}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{pf} is not JSON: {e}"), &[]));+ prefs::validate(&pv).unwrap_or_else(|e| err(&format!("{pf}: {e}"), &["A stricter set others should share belongs on adom/pcb-design-rules as a profile (README, Published limits and profiles).".into()]));+ prefs_rec = pv;+ }+ let body = text.clone();+ let marked = format!("# adom-aiflow rules: {slug} {layers}L{} (from {page})\n{body}", variant.as_ref().map(|v| format!(" variant {v}")).unwrap_or_default());+ let rd = dir.join("rules"); let _ = std::fs::create_dir_all(&rd);+ std::fs::write(rd.join(&file), &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));+ let mut placed = vec![rd.join(&file).display().to_string()];+ let mut replaced_note = String::new();+ if cb.ends_with(".kicad_pcb") {+ let dru = Path::new(&cb).with_extension("kicad_dru");+ let cur = std::fs::read_to_string(&dru).ok();+ let cur_pack = cur.as_deref().and_then(checks::pack_of);+ let same_pack = cur_pack.as_ref().is_some_and(|(s, l)| *s == slug && l.is_none_or(|l| l == layers));+ if cur.is_some() && !*force && !same_pack {+ let what = cur_pack.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "other rules".into());+ note(&format!("{} carried {what}: replaced with {slug}-{layers}L, the old file kept as .kicad_dru.bak", dru.display()), &[]);+ }+ if cur.is_some() {+ let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak"));+ if !same_pack { replaced_note = format!(" (replaced {}; the old file is {})", cur_pack.map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into()), dru.with_extension("kicad_dru.bak").display()); }+ }+ std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));+ placed.push(dru.display().to_string());+ // a plan board rendered from another tool has no KiCad project: give it one carrying the same+ // fab floors, so the gate's KiCad DRC judges by the fab's rules, not KiCad's defaults+ let pro = Path::new(&cb).with_extension("kicad_pro");+ if !aiflow_bridge::eda::active().caps().file_backed_board && !pro.is_file() {+ let name = pro.file_name().and_then(|n| n.to_str()).unwrap_or("board.kicad_pro").to_string();+ std::fs::write(&pro, aiflow_bridge::eda::plan_project_json(&marked, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+ placed.push(pro.display().to_string());+ }+ }+ // Fusion: the bridge loads the same pack's Fusion rules into the open design+ let mut fusion_note = String::new();+ let mut fusion_loaded = Value::Null;+ let eda = aiflow_bridge::eda::active();+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let br = Bridge { ai_thread: thread(&cli), target };+ if let Some(rep) = eda.load_design_rules(&|v: &str, a: &Value| br.raw_call(v, a), &slug, layers, &text) {+ let okk = Bridge::ok(&rep);+ fusion_loaded = json!(okk);+ fusion_note = if okk { format!("; {}: {shown} {layers}-layer rules loaded into the open design", eda.name()) } else { format!("; {} did not load them ({}): open the design and run `rules use` again", eda.name(), rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };+ }+ }+ // the choice: in the spec when there is one, and in the run (an intake run has no spec yet)+ r.data["fabRules"] = json!({"profile": shown, "slug": slug, "layers": layers, "file": file, "variant": variant});+ if let Some(sp) = r.data["spec"].as_str().map(str::to_string) {+ if let Ok(mut v) = std::fs::read_to_string(&sp).map_err(|_| ()).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|_| ())) {+ if !v["fab"].is_object() { v["fab"] = json!({}); }+ v["fab"]["rules"] = json!(slug);+ match &variant { Some(x) => v["fab"]["rulesVariant"] = json!(x), None => { if let Some(o) = v["fab"].as_object_mut() { o.remove("rulesVariant"); } } }+ if !prefs_rec.is_null() { v["fab"]["preferences"] = prefs_rec.clone(); }+ write_json(Path::new(&sp), &v);+ }+ }+ let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();+ r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "variant": variant, "prefs": prefs_rec, "kicad": placed, "native": {"eda": eda.id(), "loaded": fusion_loaded}, "fusion": if eda.id() == "fusion" { fusion_loaded.clone() } else { Value::Null }}));+ r.save().unwrap();+ let set = match &variant { Some(v) => format!(" (variant {v}, opt-in, stricter or different from the fab's published limits)"), None => " (the fab's published limits)".into() };+ let pref_now = if prefs_rec.is_null() { spec_soft(&r)["fab"]["preferences"].clone() } else { prefs_rec.clone() };+ let pref_lines = if pref_now.is_object() && cb.ends_with(".kicad_pcb") { Board::load(&cb).map(|b| prefs::findings(&b, &pref_now)).unwrap_or_default() } else { Vec::new() };+ let pref_note = if pref_now.is_object() { format!(", the human's own preferences kept (checked as warnings: {})", if pref_lines.is_empty() { "all met now".to_string() } else { pref_lines.join("; ") }) } else { String::new() };+ ok(&format!("design rules: {shown}, {layers} layers{set}{pref_note}{fusion_note}. KiCad rules at {}{replaced_note}", placed.join(", ")), &[+ format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),+ "Every later step designs to these: placement checks, routing widths and clearances, the gate's DRC and finish all read them. Design with margin above the minimums.".into(),+ "The human can switch at any time: another variant (`rules use --variant <name> --force`), back to the published pack (`rules use --force`), or their own preferences (`--prefs <file.json>`); `rules list` shows what exists.".into()]);+ }+ Cmd::Rules { action, profile, board, force, layers, .. } => {+ 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", &[]));+ // the profile: --profile, else the spec's fab.rules, else the run's fabRules (`rules use`)+ let target = run.as_ref().and_then(|r| checks::rules_target(&spec_of(r), &r.data["fabRules"]));+ if profile.is_none() { if let Some(w) = target.as_ref().and_then(|t| t.2.clone()) { eprintln!("Warning: {w}"); } }+ if let (Some(r), Ok(b)) = (run.as_ref(), Board::load(&board_path)) {+ let pref = spec_soft(r)["fab"]["preferences"].clone();+ if pref.is_object() { for w in prefs::findings(&b, &pref) { eprintln!("Warning (the human's own preference, not blocking): {w}"); } }+ }+ let recorded_layers = if profile.is_none() { target.as_ref().and_then(|t| t.1) } else { None };+ let profile = profile.clone().or_else(|| target.as_ref().map(|t| t.0.clone())).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|jlcpcb-<n>L|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));+ match action.as_str() {+ "install" => {+ let (base, suffix) = checks::split_profile(&profile);+ if base != "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 (2 layers); `rules use --profile <fab> --layers <n>` loads any pack on adom/pcb-design-rules.".into()]);+ }+ if let (Some(a), Some(b)) = (suffix, *layers) { if a != b { err(&format!("--profile {profile} names {a} layers but --layers is {b}"), &["Pass one of them, or make them agree.".into()]); } }+ let board_layers = checks::board_copper_layers(&board_path);+ let n = layers.or(suffix).or(board_layers).or(recorded_layers).unwrap_or(2);+ if let Some(bl) = board_layers.filter(|bl| *bl != n) {+ err(&format!("--layers {n} but {board_path} has {bl} copper layers"), &[format!("Install the pack for the board's stackup: `adom-aiflow rules use --profile jlcpcb --layers {bl}`{}.", if bl == 2 { " or `rules install --profile jlcpcb --layers 2`" } else { "" })]);+ }+ let shipped = checks::shipped_pack(&base, n).unwrap_or_else(|| err(&format!("no shipped jlcpcb {n}-layer rules: adom-aiflow ships only jlcpcb-2L"), &[format!("Load the {n}-layer pack from adom/pcb-design-rules instead: `adom-aiflow rules use --profile jlcpcb --layers {n}` (`rules list` shows the packs).")]));+ let dru = Path::new(&board_path).with_extension("kicad_dru");+ let cur = std::fs::read_to_string(&dru).ok();+ let cur_pack = cur.as_deref().and_then(checks::pack_of);+ let describe = |p: &Option<(String, Option<u32>)>| p.as_ref().map(|(s, l)| format!("the {s}{} pack", l.map(|l| format!("-{l}L")).unwrap_or_default())).unwrap_or_else(|| "another rules file".into());+ let mut replaced = String::new();+ if let Some(c) = &cur {+ if cur_pack == Some(("jlcpcb".into(), Some(n))) { let _ = c; ok(&format!("{} already carries the jlcpcb-{n}L profile", dru.display()), &[]); return; }+ if !*force {+ note(&format!("{} already carried {}: replaced, the old file kept as .kicad_dru.bak beside it", dru.display(), describe(&cur_pack)), &[format!("If the old rules were the ones to keep (the 3rd party fab's, or a pack `rules use` loaded), copy the .kicad_dru.bak back over the jlcpcb-{n}L file.")]);+ }+ let bak = dru.with_extension("kicad_dru.bak");+ std::fs::copy(&dru, &bak).unwrap_or_else(|e| err(&format!("{}: {e}", bak.display()), &[]));+ replaced = format!("; replaced {} (kept as {})", describe(&cur_pack), bak.display());+ }+ std::fs::write(&dru, shipped).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()); }+ if let Some(fr) = run.as_ref().map(|r| r.data["fabRules"].clone()).filter(|f| f.is_object()) {+ let (fs, fl) = (fr["slug"].as_str().unwrap_or(""), fr["layers"].as_u64());+ if fs != "jlcpcb" || fl.is_some_and(|l| l as u32 != n) { hints.push(format!("Note: run.json fabRules still records {fs} {}L from `rules use`; the board now carries jlcpcb-{n}L.", fl.unwrap_or(0))); }+ }+ ok(&format!("installed the jlcpcb-{n}L rules (JLCPCB published {n}-layer capabilities, cited in the file) at {}{replaced}", dru.display()), &hints);+ }+ _ => {+ let fallback_layers = layers.or(recorded_layers);+ let result = checks::rules_check_layers(&profile, &board_path, fallback_layers);+ // A read-only check of this run's selected project rules is proof too.+ // Do not rewrite custom rules, or qualify a different board/profile.+ if let Some(mut r) = run {+ let same_board = std::fs::canonicalize(&board_path).ok()+ .zip(std::fs::canonicalize(r.current_board()).ok())+ .is_some_and(|(a, b)| a == b);+ let same_profile = checks::rules_target(&spec_of(&r), &r.data["fabRules"])+ .is_some_and(|t| t.0 == profile);+ let dru = Path::new(&board_path).with_extension("kicad_dru");+ let selected_dru = Path::new(&r.current_board()).with_extension("kicad_dru");+ let same_rules = dru == selected_dru || std::fs::canonicalize(&dru).ok()+ .zip(std::fs::canonicalize(&selected_dru).ok())+ .is_some_and(|(a, b)| a == b);+ if same_board && same_profile && same_rules {+ r.outcome("rules", json!({+ "pass": result.is_ok(), "profile": profile,+ "method": "existing-project-check", "board": board_path,+ "boardSha256": sha256_file(&board_path),+ "file": dru.display().to_string(),+ "rulesSha256": sha256_file(&dru.display().to_string()),+ "note": result.as_ref().map(|m| m.as_str())+ .unwrap_or_else(|e| e.as_str())+ }));+ r.save().unwrap_or_else(|e| err(&e.to_string(), &[]));+ }+ }+ match result {+ Ok(m) => ok(&m, &[]),+ Err(e) => findings(&e, &[checks::rules_hint_layers(&profile, checks::effective_layers(&profile, &board_path, fallback_layers))]),+ }+ }+ }+ }+ 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() { findings(&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;+ if aiflow_bridge::eda::active().caps().file_backed_board {+ let proof=checked_remote_binding(&r,&br,&rb,&dir).unwrap_or_else(|e|err(&e,&["Use bind-remote-board --remote-board <exact native path> after adopting a different board. Old desktop model results do not qualify the current snapshot.".into()]));+ r.data["remoteBinding"]=proof;+ }+ 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);+ // complete modelBindings + viewer classes (KiCad Bridge 1.0.22+); file resolution is not render proof+ let mc = aiflow_bridge::model_check_summary(&d);+ let missing = mc.missing.clone();+ let vis = if mc.hidden.is_empty() { String::new() } else { format!("; NOT visible proof, {}: read/set with kicad_3d_viewer_settings (KiCad closed) and confirm in the 3D frame, never by changing BOM/POS/DNP flags", mc.hidden.join("; ")) };+ r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);+ r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing, "viewerHidden": mc.hidden, "bindingsComplete": mc.bindings_complete, "evidenceSource": mc.evidence}));+ 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 {+ findings(&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()]);+ }+ let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));+ if !proxies.is_empty() {+ findings(&format!("{} footprint model(s) are the laser-etch proxy box, not the part:\n{}", proxies.len(), proxies.iter().map(|(r, m)| format!(" {r}: {m}")).collect::<Vec<_>>().join("\n")), &["A renamed or resized proxy box is a placeholder, not a 3D model. Use the part's real model: its wiki component page, the vendor STEP (adom-parts-search, chip-fetcher), or build one from the datasheet (adom-step); then `models` again.".into()]);+ }+ if !unacked.is_empty() {+ findings(&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{vis}); 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);+ // nothing for Adom Fields to solve (issue #84): the fields step is recorded as not applicable, never faked+ if what == "fields" && !r.board_pending() {+ let spec_path = r.data["spec"].as_str().unwrap_or("").to_string();+ let calcs = converter::calcs_of_run(&r.data);+ if Path::new(&spec_path).is_file() && boardkind::fields_applies(&spec_soft(&r), calcs.as_ref()).is_empty() {+ r.outcome("tour-fields", json!({"notApplicable": true, "reason": boardkind::FIELDS_NA, "spec": spec_path, "specSha256": sha256_file(&spec_path), "board": r.current_board()}));+ r.log("step-not-applicable", json!({"step": "fields", "reason": boardkind::FIELDS_NA}));+ r.save().unwrap();+ ok(&format!("fields: not applicable ({}); recorded as the fields step's proof (tour-fields), nothing solved or filmed", boardkind::FIELDS_NA), &["finish lists the fields step as not applicable, and refuses this record if the spec later names a load, hot part or signal check (then solve with adom-fields and `tour fields` again). Never add a load only to get a solve.".into()]);+ return;+ }+ }+ // 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 host = if local_proxy { container_host() } else { None };+ let br = match (on, &host) {+ // a box other than the host cannot open the localhost URL: it would film an error page+ // (2026-10-20: a run sent the walkthrough to the user's own machine this way)+ (Some(box_name), Some(h)) if what == "fields" && !box_name.eq_ignore_ascii_case(h) => err(+ &format!("`--on {box_name}` cannot film the Fields app: its URL ({}) is a localhost proxy, and only {h}, the machine running this container, can open it", std::env::var("VSCODE_PROXY_URI").unwrap_or_default().replace("{{port}}", &port.to_string())),+ &[format!("Run `tour fields --port {port}` with no --on: it films on {h}. Never move the walkthrough to another machine to get past a window that did not appear; retry it on {h}.")]),+ (Some(box_name), _) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },+ (_, Some(h)) => { on_note = format!("filmed on {h}, the machine running this container: the Fields URL is a localhost proxy only it can open"); Bridge { ai_thread: t.clone(), target: h.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" => {+ // the viewer paints its GL canvas only in front (below): on the human's own machine+ // (screen hands-off or ask) it is their call, so nothing is opened, raised or filmed+ let mode = screen_mode(&mut r);+ if !mode.may_raise() {+ r.log("tour-3d-skipped", json!({"step": r.data["currentStep"], "screen": mode.as_str(), "why": "the 3D viewer paints only in front"}));+ r.save().unwrap();+ ok(&format!("3D walkthrough not run: screen is {} on {}, and the 3D viewer only paints in front, so filming it would take the human's screen for about a minute", mode.as_str(), br.target), &[+ "Suggest to the human: run it themselves (KiCad: View > 3D Viewer, then watch it here), or let AI Flow take the screen for it: `capture screen allowed` (or AIFLOW_SCREEN=allowed for one command), then `tour 3d` again, then `capture screen hands-off`.".into(),+ "Nothing is missing: finish does not need the 3D walkthrough. Next: step nets, or step capture / finish.".into()]);+ return;+ }+ 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") || (e["kind"].is_null() && e["title"].as_str().is_some_and(|t| t.starts_with("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)) && (if s["canvas"].is_object() { s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8 } else { screenshot_distinct_colors(s["localPath"].as_str().or(s["savedTo"].as_str()).unwrap_or("")).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()]);+ }+ if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); }+ 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() {+ // the pour walkthrough: every poured net first, then the loaded, wide and Kelvin ones+ for z in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() { if let Some(n) = z["net"].as_str() { if !list.iter().any(|x| x == n) { list.push(n.to_string()); } } }+ 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();+ // PR #65 (Drew): frame with kicad_zoom_to where the bridge has it, the menu key otherwise, and keep the replies+ let callf = |v: &str, a: &Value| br.call(v, a);+ let mut zoom_to = true;+ let fit0 = clipwatch::frame_view(&callf, &mut zoom_to, editor, &rb, None);+ 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 framed = clipwatch::frame_view(&callf, &mut zoom_to, editor, &rb, Some(net));+ std::thread::sleep(std::time::Duration::from_millis(2600));+ let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();+ let shot = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}"));+ if let Some(pth) = &shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }+ let back = clipwatch::frame_view(&callf, &mut zoom_to, editor, &rb, None);+ std::thread::sleep(std::time::Duration::from_millis(1200));+ toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"], "framedBy": framed.how, "frameFailed": framed.failed, "screenshot": shot.is_some(), "fitBackFailed": if back.how.is_none() { json!(back.failed) } else { Value::Null }}));+ }+ 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, "fitFirst": {"framedBy": fit0.how, "failed": fit0.failed}}));+ r.save().unwrap();+ let (line, hints) = tour_nets_report(&toured, editor);+ ok(&line, &hints);+ }+ "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()]); }+ // a server left up by an earlier run answers health just as well, and the walkthrough then+ // films that run's board (old models, missing parts): the solve served must be this run's+ let served: 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);+ if let Some(sb) = served["source"]["board"].as_str() {+ let run_dir = std::fs::canonicalize(&dir).unwrap_or(dir.to_path_buf());+ let board = std::fs::canonicalize(sb).unwrap_or_else(|_| PathBuf::from(sb));+ if !board.starts_with(&run_dir) {+ err(&format!("adom-fields on port {port} serves another run's solve ({sb}), not this run's board"), &[+ format!("Stop that server (`pgrep -af \"adom-fields serve\"` names it; kill its pid), then solve this board and serve it: `adom-fields analyze --board <this run's board> --spec <spec> --out {}/fields` and `adom-fields serve --fields {}/fields --port {port} &`.", run_dir.display(), run_dir.display())]);+ }+ }+ // issue #75: a switching converter's walkthrough (the step's proof) needs Fields to have modelled+ // the converter: the spec's voltages/sources/switching and a solve whose review.converter is set+ {+ let spec = spec_soft(&r);+ let calcs = converter::calcs_of_run(&r.data);+ if let Some((msg, hints)) = converter::check(&spec, calcs.as_ref(), Some(&served), Board::load(&r.current_board()).ok().as_ref()) {+ r.log("fields-refused", json!({"why": msg}));+ r.save().unwrap();+ note(&format!("the fields step cannot pass: {msg}"), &hints);+ }+ }+ 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 mut sid = String::new();+ let mut open_err = None;+ let mut hwnd: Option<i64> = None;+ // one more try in a fresh session when the first window never shows (a transient pup miss, 2026-10-20)+ // issue #123: macOS pup keeps its window off-screen, where the browser stops painting it+ let mut offscreen = false;+ for attempt in 0..2 {+ if attempt > 0 { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); }+ sid = format!("aiflow-fields-{}{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>(), if attempt > 0 { "-retry" } else { "" });+ 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"}));+ open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().or(op["error"].as_str()).unwrap_or("?").chars().take(200).collect::<String>()) };+ // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed+ for _ in 0..40 {+ std::thread::sleep(std::time::Duration::from_secs(2));+ let lw = br.call("pup_list_windows", &json!({}));+ let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+ let sessions = lw["sessions"].as_array().or_else(|| inner["sessions"].as_array());+ // Only the session created by this invocation owns the recording target.+ // A title match can select another user's or another run's Fields window.+ let mine = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())));+ hwnd = mine.and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))+ .filter(|h| *h > 0);+ // macOS pup lists its sessions without a window handle: this session's window, once it+ // has its title, is found by the desktop's window list, and only when exactly one+ // "Adom Fields" window exists (a second one could be another run's)+ if hwnd.is_none() && mine.is_some_and(|w| w["title"].as_str() == Some("Adom Fields")) {+ // pup keeps its window off-screen in the background, so off-screen windows are listed too+ let fw = br.call("desktop_list_windows", &json!({"includeOffscreen": true}));+ let inner: Value = fw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+ let rows = inner["data"]["windows"].as_array().or_else(|| inner["windows"].as_array()).cloned().unwrap_or_default();+ let named: Vec<_> = rows.iter().filter(|w| w["title"].as_str().is_some_and(|t| t.starts_with("Adom Fields"))).collect();+ let all_ours = sessions.map(|r| r.iter().filter(|w| w["title"].as_str() == Some("Adom Fields")).count()).unwrap_or(0) == 1;+ if named.len() == 1 && all_ours {+ hwnd = named[0]["hwnd"].as_i64().filter(|h| *h > 0);+ offscreen = named[0]["isOnscreen"] == json!(false);+ }+ }+ if hwnd.is_some() { break; }+ }+ 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 for this walkthrough session{})", 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(), match (preboard::proxy_is_local(&proxy), &host) {+ (true, Some(h)) if br.target.eq_ignore_ascii_case(h) => format!("This is the right machine: {h} runs this container and is the only one that can open {proxy}. Retry `tour fields --port {port}` here (pup can take longer than this wait on a cold box); never pass --on another machine, which cannot open a localhost URL."),+ (true, Some(h)) => format!("The URL {proxy} is a localhost proxy: only {h}, the machine running this container, can open it. `tour fields --port {port}` with no --on films there."),+ (true, None) => format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`; ask the user which machine that is rather than guessing."),+ _ => 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));+ // issue #98: the walkthrough take is capped in length as well as in bit rate+ if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); }+ // an off-screen window records nothing (#123): put it on screen for the take, then tuck it away+ let raised = offscreen && Bridge::ok(&br.call("pup_raise_os_window", &json!({"sessionId": sid, "reason": "aiflow fields walkthrough: an off-screen window is not painted, so it cannot be filmed; lowered again when the take ends"})));+ if offscreen { eprintln!("{}", if raised { "the Fields window was off-screen (macOS): raised it on screen for the walkthrough take; it is lowered when the take ends" } else { "WARNING: the Fields window is off-screen (macOS) and pup could not raise it: the take will likely be empty" }); std::thread::sleep(std::time::Duration::from_secs(3)); }+ let rid = br.record_window_start_capped(win, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS).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);+ // the EMC beat after the app's own walkthrough: the hot loop, the return path under it and+ // the radiated estimate against FCC Part 15 class B, held long enough to read+ let ps = port.to_string();+ let emc_ok = std::process::Command::new("adom-fields").args(["ui", "--port", &ps, "view", "--quantity", "emc", "--camera", "iso"]).output().map(|o| o.status.success()).unwrap_or(false);+ if emc_ok {+ let _ = std::process::Command::new("adom-fields").args(["ui", "--port", &ps, "toast", "EMC: the switching loop, the ground under its return path, and the radiated estimate against FCC Part 15 class B"]).output();+ std::thread::sleep(std::time::Duration::from_secs(20));+ r.log("fields-emc", json!({"step": step, "visit": visit}));+ }+ std::thread::sleep(std::time::Duration::from_secs(2));+ let mut clip_file: Option<String> = None;+ 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); clip_file = Some(f); }+ }+ if raised { let _ = br.call("pup_lower_os_window", &json!({"sessionId": sid, "reason": "aiflow fields walkthrough take ended"})); }+ 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);+ if tour_ok {+ r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"], "clip": clip_file, "offscreen": offscreen, "raised": raised}));+ }+ 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();+ // no silent pass (#123): the walkthrough ran, but there is no clip of it+ if clip_file.is_none() {+ eprintln!("WARNING: the fields walkthrough ran, but the recorder produced no clip of the app window{} (issue #123). The run's video will have no Fields segment: run `tour fields` again{}, or film the app another way and register it with `artifact`.", if offscreen { ", which was off-screen" } else { "" }, if offscreen && !raised { " once pup can raise its window (pup_raise_os_window)" } else { "" });+ }+ 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()]); }+ let page_hint = results_hint(&r); // issue #147+ ok(&format!("fields walkthrough {}: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", if clip_file.is_some() { "recorded" } else { "ran, but NO CLIP was recorded (see the WARNING above)" }, issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[page_hint, 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::Handson { action, args, profile, save_as, default, quote, summary, note, notify, screen, timeout, delete } => {+ let at = now();+ // the profiles live with the human, not the run: listing, the default and deleting need no run+ if action == "profiles" {+ if let Some(d) = delete { handson::delete_profile(d).unwrap_or_else(|e| err(&e, &[])); }+ if *default { let p = profile.clone().or_else(|| args.first().cloned()).unwrap_or_else(|| err("--default needs the profile: `handson profiles --profile <name> --default`", &[]));+ if handson::load_profile(&p).is_none() { err(&format!("no profile {p:?}"), &["`handson profiles` lists them.".into()]); }+ handson::set_default(&p).unwrap_or_else(|e| err(&e, &[])); }+ let list = handson::list_profiles();+ let lines: Vec<String> = list.iter().map(|p| {+ let st: Vec<String> = p["steps"].as_object().into_iter().flatten().filter(|(_, v)| v["mode"] != "auto").map(|(k, v)| format!("{k} {}", v["mode"].as_str().unwrap_or(""))).collect();+ format!("{}{} ({}): {}; the rest {}; notify {}, screen {}", if p["default"] == true { "* " } else { " " }, p["name"].as_str().unwrap_or(""), p["slug"].as_str().unwrap_or(""),+ if st.is_empty() { "every step auto".into() } else { st.join(", ") }, p["fallback"].as_str().unwrap_or("auto"), p["notify"].as_str().unwrap_or("hydrogen"), p["screen"].as_str().unwrap_or("hands-off"))+ }).collect();+ ok(&format!("{} flow profile(s) in {} (* = the default for a new run):\n{}", list.len(), handson::home().join("profiles").display(), lines.join("\n")), &[+ "Switch this run: `handson use <slug>`. Make one the default: `handson profiles --profile <slug> --default`. A new one: `handson plan`, then the human saves it from the timeline (or `handson start --save-as \"<name>\"`).".into()]);+ return;+ }+ let read_only = matches!(action.as_str(), "status" | "wait");+ let t = if read_only { cli.ai_thread.clone().unwrap_or_default() } else { thread(&cli) };+ let mut r = load_run(&cli);+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, &sequence::sequence_key(&dir, &r.data)));+ let tl = dir.join("timeline");+ let mut lines = handson::ingest(&mut r.data, &tl);+ let step_arg = |what: &str| -> String {+ let s = args.first().cloned().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err(&format!("{what} needs the step"), &[]));+ if r.data["steps"][&s].is_null() { err(&format!("{s} has not been visited in this run"), &[format!("`step {s}` first; {what} answers for a step's latest visit.")]); }+ s+ };+ let profile_of = |name: &str| handson::load_profile(name).unwrap_or_else(|| err(&format!("no flow profile {name:?}"), &["`handson profiles` lists them (full-ai is built in).".into()]));+ match action.as_str() {+ "plan" => {+ // start from the profile named, the run's own plan, the human's default, or every step auto+ let (plan, prof) = if let Some(p) = profile { let v = profile_of(p); (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }+ else if r.data["handsOn"]["steps"].is_object() { (handson::plan_of(&r.data), r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()))) }+ else if let Some(v) = handson::default_profile() { (handson::profile_plan(&v), Some((v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string()))) }+ else { (handson::normalise(&json!({})).unwrap(), None) };+ handson::apply(&mut r.data, &plan, "planning", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "plan", &at);+ r.log("handson-plan", json!({"state": "planning", "profile": prof.as_ref().map(|p| p.0.clone())}));+ r.save().unwrap();+ let note = handson::notify(&r.data, &t, "AI Flow: choose your flow", "Pick, for each step, whether the AI does it, checks in with you, leaves it to you, or follows your instructions.");+ ok(&format!("hands-on plan open on the timeline, waiting for the human (starting from {}): {}", prof.as_ref().map(|p| format!("profile \"{}\"", p.1)).unwrap_or_else(|| "every step auto".into()), handson::summary(&r.data)), &[+ format!("Tell the human: the AI Flow timeline shows every step with four choices (1 AI takes care of it, 2 AI checks in with you after it, 3 you do it, 4 custom instructions), plus how to notify them and whether you may bring windows to the front. They press Start there and can save it as a named flow (the first one becomes their default). {note}."),+ "Then `handson wait` (it returns when they press Start). If they answer in chat instead: `handson set placement=checkin routing=human --note schematic=\"...\"`, then `handson start --save-as \"<their name for it>\"`.".into(),+ "Wait for them to confirm before design work: `step` notes every step started while the plan is still open.".into()]);+ }+ "set" => {+ let mut plan = handson::plan_of(&r.data);+ if !plan["steps"].is_object() { plan["steps"] = json!({}); }+ for a in args { let (k, v) = a.split_once('=').unwrap_or_else(|| err(&format!("{a:?}: write STEP=MODE (modes: {})", handson::MODES.join(", ")), &[]));+ let keep = plan["steps"][k]["instructions"].clone();+ plan["steps"][k] = json!({"mode": v}); if !keep.is_null() { plan["steps"][k]["instructions"] = keep; } }+ for n in note { let (k, v) = n.split_once('=').unwrap_or_else(|| err(&format!("{n:?}: write --note STEP=\"text\""), &[]));+ if plan["steps"][k].is_null() { plan["steps"][k] = json!({"mode": "custom"}); }+ plan["steps"][k]["instructions"] = json!(v); }+ if let Some(n) = notify { plan["notify"] = json!(n); }+ // the human's latest word on the screen: the profile's choice replaces an earlier `capture screen`+ if let Some(sc) = screen { plan["screen"] = json!(sc); if let Some(o) = r.data.as_object_mut() { o.remove("screen"); } }+ let plan = handson::normalise(&plan).unwrap_or_else(|e| err(&e, &[]));+ let state = if handson::active(&r.data) { "active" } else { "planning" };+ let prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));+ handson::apply(&mut r.data, &plan, state, prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);+ r.save().unwrap();+ ok(&format!("hands-on plan ({state}): {}", handson::summary(&r.data)), &[if state == "planning" { "When the human is happy: `handson start` (`--save-as \"<name>\"` to keep it as a flow profile).".into() } else { "Changed for this run only; `handson start --save-as \"<name>\"` keeps it as a profile.".into() }]);+ }+ "start" => {+ if !r.data["handsOn"]["steps"].is_object() { err("no hands-on plan on this run", &["`handson plan` first.".into()]); }+ let plan = handson::plan_of(&r.data);+ let first = handson::default_slug().is_none();+ // the first flow a human confirms is kept as their preference, so the next board starts from it+ let name = save_as.clone().or_else(|| first.then(|| "My flow".to_string()));+ let mut prof = r.data["handsOn"]["profile"].as_str().map(|s| (s.to_string(), r.data["handsOn"]["profileName"].as_str().unwrap_or(s).to_string()));+ let mut saved = String::new();+ if let Some(n) = &name { let s = handson::save_profile(n, &plan, &at).unwrap_or_else(|e| err(&e, &[])); saved = format!("; saved as flow profile \"{n}\" ({s}){}", if first { ", their default from now on" } else { "" }); prof = Some((s, n.clone())); }+ if *default { if let Some((s, _)) = &prof { handson::set_default(s).unwrap_or_else(|e| err(&e, &[])); saved.push_str("; now their default"); } }+ handson::apply(&mut r.data, &plan, "active", prof.as_ref().map(|(s, n)| (s.as_str(), n.as_str())), "chat", &at);+ r.log("handson-plan", json!({"state": "active", "profile": prof.as_ref().map(|p| p.0.clone())}));+ r.save().unwrap();+ ok(&format!("hands-on plan confirmed{saved}: {}", handson::summary(&r.data)), &["Every `step` now prints its mode: check-in steps end with `handson ask`, human steps are handed over and waited for, custom steps print the human's instructions.".into()]);+ }+ "use" => {+ let name = args.first().cloned().or(profile.clone()).unwrap_or_else(|| err("use needs a profile: `handson use full-ai`", &["`handson profiles` lists them.".into()]));+ let v = profile_of(&name);+ let (s, n) = (v["slug"].as_str().unwrap_or("").to_string(), v["name"].as_str().unwrap_or("").to_string());+ handson::apply(&mut r.data, &handson::profile_plan(&v), "active", Some((&s, &n)), "use", &at);+ r.log("handson-plan", json!({"state": "active", "profile": s}));+ r.save().unwrap();+ ok(&format!("this run now follows \"{n}\": {}", handson::summary(&r.data)), &["Visits from now on follow it; work already done is not re-reviewed.".into()]);+ }+ "ask" => {+ let s = step_arg("ask");+ let sum = summary.clone().unwrap_or_else(|| err("ask needs --summary: what the human should look at, and where", &[]));+ let n = handson::ask(&mut r.data, &s, &sum, &at).unwrap_or_else(|e| err(&e, &[]));+ r.log("handson-ask", json!({"step": s, "visit": n, "summary": sum}));+ r.save().unwrap();+ let note = handson::notify(&r.data, &t, &format!("Review {s}"), &format!("AI Flow finished {s} and is waiting for your OK: {}", aiflow_run::clip_text(&sum, 180)));+ ok(&format!("check-in on {s} (visit {n}) is open; {note}"), &["Now wait: `handson wait` returns when they press Approve or Request changes on the timeline. If they answer in chat: `handson approve <step> --quote \"<their words>\"` or `handson changes <step> --quote \"...\"`.".into(),+ "While you wait, work that does not depend on this step is fine; the next step is refused until they approve.".into()]);+ }+ v @ ("approve" | "changes" | "done") => {+ let s = step_arg(v);+ let verdict = match v { "approve" => "approved", other => other };+ let q = quote.clone().unwrap_or_default();+ let n = handson::record_verdict(&mut r.data, &s, verdict, &q, "chat", &at).unwrap_or_else(|e| err(&e, &[]));+ r.log("handson-verdict", json!({"step": s, "visit": n, "verdict": verdict, "quote": q, "by": "chat"}));+ r.save().unwrap();+ let next = match verdict { "changes" => format!("Make the changes they asked for, then `handson ask {s} --summary \"<what changed>\"` again."), "done" => format!("Now record {s}'s proof from their work: {}.", handson::human_proof(&s, &flow)), _ => "Go on to the next step.".into() };+ ok(&format!("{verdict} recorded for {s} (visit {n}), in the human's words"), &[next]);+ }+ "wait" => {+ let before = handson::waiting(&r.data);+ let Some(w) = before.clone() else {+ for l in &lines { println!("{l}"); }+ if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); ok("the human had already answered on the timeline", &["Nothing else is waiting on them: go on (the step hints say what each mode needs).".into()]); }+ else { ok("nothing to wait for: no plan, check-in or human step is open", &[]); }+ return;+ };+ let started = std::time::Instant::now();+ if !lines.is_empty() { r.save().unwrap(); refresh_dashboard(&dir); }+ while lines.is_empty() && started.elapsed().as_secs() < *timeout {+ std::thread::sleep(std::time::Duration::from_secs(1));+ let Some(mut r2) = Run::open(&dir) else { break };+ let got = handson::ingest(&mut r2.data, &tl);+ // an answer recorded from chat by another command also ends the wait+ if !got.is_empty() || handson::waiting(&r2.data) != before { if !got.is_empty() { r2.save().unwrap(); refresh_dashboard(&dir); } lines = got; r = r2; if lines.is_empty() { lines.push("answered from chat".into()); } }+ }+ let secs = started.elapsed().as_secs();+ r.log("handson-wait", json!({"for": w, "humanWaitSeconds": secs, "answered": !lines.is_empty()}));+ if lines.is_empty() {+ ok(&format!("still waiting after {secs} s for {}", w["what"].as_str().unwrap_or("the human")), &["Run `handson wait` again, or stop and tell the human what you need: finish-guard counts an open check-in as a pause, not an unfinished run.".into()]);+ } else {+ for l in &lines { println!("{l}"); }+ ok(&format!("the human answered after {secs} s"), &[handson::waiting(&r.data).map(|w| format!("Still open: {}.", w["what"].as_str().unwrap_or(""))).unwrap_or_else(|| "Nothing else is waiting on them: go on (the step hints say what each mode needs).".into())]);+ }+ return;+ }+ "status" => {+ if !lines.is_empty() { r.save().unwrap(); }+ for l in &lines { println!("{l}"); }+ let h = &r.data["handsOn"];+ let st: Vec<String> = seq.iter().map(|s| { let m = handson::mode_of(&r.data, s); format!(" {s}: {m}{}", handson::instructions_of(&r.data, s).map(|t| format!(" (\"{}\")", aiflow_run::clip_text(t, 80))).unwrap_or_default()) }).collect();+ ok(&format!("hands-on {} (flow \"{}\"); notify {}, screen {}\n{}\nwaiting: {}", h["state"].as_str().unwrap_or("off"), h["profileName"].as_str().unwrap_or("-"), h["notify"].as_str().unwrap_or("-"), h["screen"].as_str().unwrap_or("-"), st.join("\n"),+ handson::waiting(&r.data).map(|w| w["what"].as_str().unwrap_or("").to_string()).unwrap_or_else(|| "nothing".into())), &[]);+ return;+ }+ "off" => {+ if r.data["handsOn"].is_object() { r.data["handsOn"]["state"] = json!("off"); r.data["handsOn"]["history"].as_array_mut().map(|a| a.push(json!({"at": at, "state": "off"}))); }+ r.log("handson-plan", json!({"state": "off"}));+ r.save().unwrap();+ ok("hands-on is off for this run: every step is the AI's again (the screen and notification choices revert to the defaults)", &["Their saved profiles are kept; `handson use <profile>` turns it back on.".into()]);+ }+ _ => unreachable!(),+ }+ for l in &lines { println!("{l}"); }+ refresh_dashboard(&dir);+ }+ Cmd::Step { name, back, why } => {+ // never refused: an out-of-order step is noted and recorded (a return when it goes back)+ let mut back_eff = *back;+ thread(&cli);+ let mut r = load_run(&cli);+ for l in handson::ingest(&mut r.data, &dir.join("timeline")) { println!("{l}"); }+ 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.iter().chain(reviews::STEPS.iter()) { 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);+ // an Altium run's edits live in the open documents until they are saved: a 2026-10-20 run saved+ // its board once, when it was built, and the project it handed over was that empty board. Every+ // step change saves them (the schematic, then the board, which is left in front)+ if let Some(pcb) = r.data["remoteBoard"].as_str().and_then(|b| b.strip_prefix("altium:")).filter(|_| prev.as_deref().map_or(false, |p| p != name.as_str())) {+ let br = bridge_of(&r, &cli);+ let sch = std::fs::read_to_string(dir.join("altium").join("project.json")).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["schematic"].as_str().map(str::to_string));+ for doc in sch.iter().map(String::as_str).chain(std::iter::once(pcb)) {+ let s = br.raw_call("altium_save", &json!({"filePath": doc}));+ if !Bridge::ok(&s) { eprintln!("WARNING: could not save {doc} ({}): save it in Altium (File > Save) before going on", s["errorCode"].as_str().or(s["error"].as_str()).unwrap_or("?")); }+ }+ }+ // the board's project page gets the step just left as its own sub-readme (evidence, screenshots,+ // usage) and a line in the README's build section, in the background so the step is not slowed+ if let (Some(pg), Some(pv)) = (r.data["projectPage"].as_str(), prev.as_deref()) {+ if pv != name.as_str() {+ let helper = dir.join(".aiflow-project-steps.py");+ let _ = std::fs::write(&helper, include_str!("../../../tools/project-steps.py"));+ let log = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("project-steps.log")).ok();+ let mut c = std::process::Command::new("python3");+ c.arg(&helper).arg(&dir).arg(pg).arg(pv).stdin(std::process::Stdio::null());+ if let Some(l) = log { if let Ok(l2) = l.try_clone() { c.stdout(l).stderr(l2); } }+ let _ = c.spawn();+ }+ }+ // a Fusion project simulates the drawn schematic, in front of the human, before the board: placement+ // waits until a simulation visit has started after the last schematic visit+ if name == "placement" && !*back && aiflow_bridge::eda::project_backend(&dir).is_some() {+ let last = |s: &str| r.data["steps"][s]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).last()).map(str::to_string);+ if let Some(sch) = last("schematic") {+ if last("simulation").map(|sim| sim < sch).unwrap_or(true) {+ note("no simulation since the schematic was drawn: simulating before the board catches value mistakes early", &[+ "`step simulation`, then run the circuit in the Adom SPICE dashboard and show it to the human: `adom-spice start`, `adom-spice study <study.json>`, `adom-spice run ngspice`, `adom-spice show` (a Hydrogen webview in the right pane, beside the chat).".into(),+ "Check start-up, ripple and the load step against the requirements; change values in the schematic when a measurement misses, and say what changed. Record it: `evidence add --kind simulation --file <result.json>`. Then `step placement`.".into()]);+ }+ }+ }+ // the required steps, in order: a run that starts now carries its lane's sequence, and no step+ // in it can be skipped (sequence.rs)+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ // the lane can still change while only intake is done (the run learns it is a Fusion board from the+ // brief or the requirements written during intake)+ {+ let want = sequence::sequence_of(&flow_full, &sequence::sequence_key(&dir, &r.data));+ if sequence::may_resequence(&r.data, &want) && r.data["sequence"] != json!(want) { r.data["sequence"] = json!(want); }+ for s in reviews::sync_sequence(&mut r.data) { eprintln!("{s}: declared in the spec or requirements, required before finish (`review {}`)", s.trim_end_matches("-review")); }+ }+ // the pattern lane is for boards with nothing to connect (issue #84): say so when this one needs routing+ if boardkind::is_pattern(&r.data) && !r.board_pending() && sequence::sequence_key(&dir, &r.data) == boardkind::PATTERN {+ let calcs = converter::calcs_of_run(&r.data);+ if let Some(why) = boardkind::pattern_refusal(&board_of(&r)).or_else(|| boardkind::pattern_spec_refusal(&spec_soft(&r), calcs.as_ref())) {+ note(&format!("this run is a pattern board (`intake --kind pattern` or requirements boardKind), but {why}"), &["The pattern lane leaves out placement, routing, pours, current, thermal and fields. A board with nets to connect, loads or hot parts takes the electrical lane: start a new run without --kind pattern (and without requirements boardKind).".into()]);+ }+ }+ let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ // --back is a return: it may only go to an earlier step of the flow, never jump ahead past the checks+ if !seq.is_empty() && *back {+ if let (Some(pos), Some(cp)) = (sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))) {+ if pos > cp { note(&format!("--back goes to an earlier step, but {name} comes after {} in the flow: recorded as a step forward", seq[cp]), &[]); back_eff = false; }+ }+ }+ // hands-on (handson.rs): the human's plan, check-ins and their own steps; a return is never held+ let earlier = matches!((sequence::position(&seq, name), r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c))), (Some(p), Some(c)) if p < c);+ if !*back && !earlier {+ let b = handson::blockers(&r.data, &seq, name);+ if !b.is_empty() {+ r.save().ok();+ let wait = handson::waiting(&r.data).map(|w| format!("The run is waiting for {}: `handson wait` (or the human answers in chat). Do other work that does not depend on it meanwhile, or stop and tell them; finish-guard counts this as a pause.", w["what"].as_str().unwrap_or("the human"))).unwrap_or_default();+ // never refused (John, 2026-11-04): the human asked to be waited for, so say so plainly and log it+ // when the AI goes on anyway; finish lists the missing OK as open and finish-guard reads it as a pause+ r.log("handson-proceeded-without-ok", json!({"step": name, "waitingFor": b}));+ r.save().ok();+ note(&format!("the human asked to approve before {name} (hands-on flow \"{}\") and has not yet:\n {}\nWait for them unless they told you to go on; going on now is logged and listed at finish", r.data["handsOn"]["profileName"].as_str().unwrap_or("this run's plan"), b.join("\n ")), &[wait,+ "To change it, the human changes the plan (`handson plan`, or `handson set <step>=auto` when they say so in chat).".into()]);+ }+ }+ if !seq.is_empty() && !*back {+ if let Some(pos) = sequence::position(&seq, name) {+ let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c));+ if let Some(cp) = cur { if pos < cp {+ back_eff = true;+ note(&format!("{name} comes before {} in the flow: recorded as a return (pass --why to say what sent you back; it shows on the timeline)", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it unless the board and spec come out of the return unchanged. To record a missing proof, run its command from here instead (no return needed).")]);+ } }+ let st = sequence::states(&dir, &r.data, &flow_full, &seq);+ let (bs, ss) = run_hashes(&r);+ let live = deviations::live(&r.data, &bs, &ss);+ let b: Vec<String> = sequence::blockers(&st, name).into_iter().filter(|x| !live.iter().any(|d| d["item"] == format!("step {}", deviations::key(x)).as_str())).collect();+ if !b.is_empty() {+ r.data["lastRefusalItems"] = json!(b.iter().map(|x| format!("step {}", deviations::key(x))).collect::<Vec<_>>());+ r.save().ok();+ let first = b[0].split(' ').next().unwrap_or("").to_string();+ r.log("steps-skipped", json!({"to": name, "skipped": b}));+ note(&format!("moving to {name} with earlier steps not done yet (finish lists them as open):\n {}", b.join("\n ")), &[+ format!("Suggested: come back to `step {first}` when it helps this board (a step counts as done when its own command ran during its latest visit, flows/board.json proofs)."),+ format!("If a step does not apply to this board, say why for the record: `adom-aiflow deviate \"step {first}\" --why \"...\"`, and tell the human.")]);+ }+ }+ }+ record_usage(&mut r, &format!("step:{name}"));+ // issue #152: the board and spec hashes as the step left closes and the new visit opens; a return+ // that changes neither does not send later steps back to redo (sequence::unchanged_return)+ let (vh_b, vh_s) = run_hashes(&r);+ let vh = json!({"board": vh_b, "spec": vh_s});+ if let Some(v) = prev.as_deref().and_then(|p| r.data["steps"][p]["visits"].as_array_mut()).and_then(|a| a.last_mut()) { if v["end"].is_null() { v["endHashes"] = vh.clone(); } }+ r.step_set(name, back_eff, why.as_deref());+ if let Some(v) = r.data["steps"][name.as_str()]["visits"].as_array_mut().and_then(|a| a.last_mut()) { v["hashes"] = vh; }+ if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); }+ // 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()));+ if r.data["captures"][pos]["truncated"] == true { clip_note.push_str(&format!("WARNING: clip {} is truncated (no moov atom): re-record it with `step {} --back --why \"clip truncated\"` if it matters; ", st.as_str().unwrap_or(""), cs.as_str().unwrap_or(""))); }+ clip_stopped = true;+ }+ // Recording can be delegated to another host without changing the EDA target.+ // Always settle an existing take above, but never start/focus/capture here when off.+ if std::env::var("AIFLOW_AUTO_CAPTURE").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {+ clip_note.push_str("automatic step capture disabled; record reviewed clips on the chosen capture host");+ r.log("clip-auto-disabled", json!({"step":name,"reason":"AIFLOW_AUTO_CAPTURE"}));+ } else {+ // the fields step films the app's window through `tour fields`: no editor clip for it+ // a Fusion run films Fusion's own window, the board fitted to it first (a remembered+ // hwnd can be a KiCad window from before the board, and Fusion keeps whatever zoom it had)+ // the window the step works in, from the run's EDA backend: KiCad's PCB editor for this board,+ // a one-window tool (Fusion, Altium) with the board fitted to it, and Hydrogen in front for the+ // human-facing steps (the chat, the parts pages, the SPICE dashboard, the fields app). A project+ // before its board exists (library, schematic) films its tool, never a KiCad window.+ let eda = aiflow_bridge::eda::active();+ let pre_board = aiflow_bridge::eda::project_backend(&dir).filter(|_| eda.id() == "kicad")+ .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:"))));+ let cam = pre_board.unwrap_or(eda);+ let tx = |v: &str, a: &Value| br.raw_call(v, a);+ // issue #132: on the human's own machine (screen hands-off or ask) nothing comes to the front;+ // the clip records the window where it is+ let raise = screen_mode(&mut r).may_raise();+ if !raise { r.log("screen-hands-off", json!({"step": name})); }+ if !raise { /* the human's screen is hands-off: film the windows where they are */ } else if let Some(h) = cam.hydrogen_for_step(name) {+ let w = if h { br.hydrogen_hwnd() } else { cam.main_window(&tx) };+ br.bring_front(w, if h { "Hydrogen" } else { cam.name() });+ } else if cam.id() == "kicad" && r.data["sequence"].as_array().map_or(false, |s| s.iter().any(|x| x == "project-page")) {+ // a KiCad board from scratch: Hydrogen is the stage for the steps without KiCad (the parts,+ // the project page, the analysis apps) and KiCad for the rest; at every new phase the step+ // dashboard comes up first, so the viewer sees where the run is+ const HYDROGEN: [&str; 11] = ["intake", "rules", "design", "test-plan", "sourcing", "parts", "project-page", "simulation", "current", "thermal", "fields"];+ let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let phase_of = |st: &str| flow_full["phases"].as_object().and_then(|ph| ph.iter().find(|(_, v)| v.as_array().map_or(false, |a| a.iter().any(|x| x == st))).map(|(k, _)| k.clone()));+ let new_phase = prev.as_deref().map_or(true, |p| phase_of(p) != phase_of(name));+ if new_phase || HYDROGEN.contains(&name.as_str()) {+ br.bring_front(br.hydrogen_hwnd(), "Hydrogen");+ if new_phase { ensure_dashboard(&dir, &thread(&cli), true); std::thread::sleep(std::time::Duration::from_secs(6)); }+ }+ // the board steps: KiCad's PCB editor in front (the schematic and library steps leave KiCad's own window as the AI opened it)+ if !HYDROGEN.contains(&name.as_str()) && !matches!(name.as_str(), "library" | "schematic") { br.bring_front(br.pcb_editor_hwnd(), "KiCad"); }+ }+ let cached = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())));+ // issue #59: a step before the board films Hydrogen's dashboard, the AI's window or the tool's own+ // window, never an idle editor; nothing meaningful on screen is a recorded skip+ let pre_cam = (name != "fields" && clipwatch::preboard_step(name)).then(|| clipwatch::choose_preboard(&br, &*cam, name, r.data["engine"].as_str().unwrap_or("")));+ if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if what.starts_with("Hydrogen") { ensure_dashboard(&dir, &thread(&cli), true); } }+ let hwnd = match &pre_cam { Some(clipwatch::Camera::Window { hwnd, .. }) => Some(*hwnd), Some(clipwatch::Camera::Skip(_)) => None, None => if name == "fields" { None } else { cam.camera_window(&tx, name, cached) } };+ // issue #106: a box whose Adom Bridge has no recorder is said once, then each step keeps screenshots+ let (can_record, rec_notice) = clipwatch::recorder(&mut r, &br);+ if let Some(n) = &rec_notice { clip_note.push_str(&format!("{n}; ")); }+ let disk = if can_record { disk_check(&dir, &br, name) } else { Ok(String::new()) };+ 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 {+ _ if !can_record => clip_note.push_str(&clipwatch::no_recorder_step(&mut r, &br, &dir, name, 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}));+ if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(what); } clip_note.push_str(&format!("filming {what}; ")); }+ // issue #155: a clip on a KiCad editor says which one, so a wrong window shows at once+ if pre_cam.is_none() && cam.id() == "kicad" { if let Some(t) = aiflow_bridge::editor_title(&br.raw_call("kicad_window_info", &json!({})), h) { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(t); } clip_note.push_str(&format!("filming KiCad's \"{t}\"; ")); } }+ 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 matches!(pre_cam, Some(clipwatch::Camera::Skip(_))) => {+ let why_skip = match &pre_cam { Some(clipwatch::Camera::Skip(w)) => w.clone(), _ => String::new() };+ let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+ if !r.data["clipSkips"].is_array() { r.data["clipSkips"] = json!([]); }+ r.data["clipSkips"].as_array_mut().unwrap().push(json!({"step": name, "visit": visit, "at": now(), "why": why_skip}));+ r.log("clip-skipped", json!({"step": name, "visit": visit, "why": why_skip}));+ clip_note.push_str(&format!("no clip for {name}-{visit}: {why_skip}"));+ }+ None => clip_note.push_str(&format!("no clip for {name}: no PCB editor {}open on the test box (capture open first)", Some(aiflow_bridge::eda::run_kicad_stem()).filter(|s| !s.is_empty() && cam.id() == "kicad").map(|s| format!("of {s} (another board's editor is never filmed) ")).unwrap_or_default())),+ }+ }+ }+ 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 skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());+ let board_hints = board_step_hints(&r, name);+ let mut hands_hints = handson::step_hints(&r.data, name, &flow_full);+ if handson::active(&r.data) && handson::mode_of(&r.data, name) == "human" {+ hands_hints.push(handson::notify(&r.data, &thread(&cli), &format!("Your turn: {name}"), &format!("AI Flow handed {name} to you. Tell the AI when you are done (timeline button or chat).")));+ }+ 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}") }), &[vec![hint, record_hint], hands_hints, skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat());+ print_next(&cli);+ }+ Cmd::ProjectPageCheck { project, live_json } => {+ let mut command = std::process::Command::new(aiflow_python());+ command.arg("-c").arg(include_str!("../../../tools/project-page-check.py")).arg("--project").arg(project);+ if let Some(path) = live_json { command.arg("--live-json").arg(path); }+ let output = command.output().unwrap_or_else(|e| err(&format!("project page check could not run: {e}"), &[]));+ print!("{}", String::from_utf8_lossy(&output.stdout));+ if !output.status.success() { err("project page preflight failed", &["Fix explicit board GLB and project PDF bindings, readable datasheet and 5–8 README screenshots. Read skills/aiflow-project-page/SKILL.md. PDF extraction requires pdftotext.".into()]); }+ }+ 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 {+ note(&format!("{} clip(s) filmed nothing usable (delivering anyway; the video may have gaps):\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() {+ note("delivering before `finish`: the board's checks have not been gathered into a finish report yet", &["Deliver comes after finish: the checked board design first, then the video and your message. Physical qualification is a separate claim.".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()]);+ }+ if let Some(open) = step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null)) {+ err(&format!("not delivered: the board twin's STEP export: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);+ }+ let board_path = r.current_board();+ let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));+ let checked = verified::run_items(&r, facts.as_ref());+ 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));+ if let Some(a) = agy_report(&r) { r.data["summary"]["antigravity"] = a; }+ 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"]["verifiedAtDelivery"] = verified::to_json(&checked);+ 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"], "camera": c["camera"], "died": c["died"], "resumedAfterGap": c["resumedAfterGap"], "truncated": c["truncated"]})).collect::<Vec<_>>()).unwrap_or_default());+ r.data["summary"]["clipSkips"] = r.data["clipSkips"].clone();+ 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("; ")));+ }+ let verified_text = verified::message(&checked);+ ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r), clipwatch::no_recorder_note(&r.data).map(|n| format!("\n{n}")).unwrap_or_default()), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "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, molecule_publish_hint(&r).unwrap_or_default(), 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 molecule_publish_tests {+ #[test]+ fn the_publish_counts_once_the_stage_has_ended() {+ // issue #137: `stage end molecule-publish` records it; a started stage is not a publish+ assert!(!super::molecule_published(&serde_json::json!({})));+ assert!(!super::molecule_published(&serde_json::json!({"stages": {"molecule-publish": {"runs": [{"start": "t", "end": null}]}}})));+ assert!(super::molecule_published(&serde_json::json!({"stages": {"molecule-publish": {"runs": [{"start": "t", "end": "u", "result": "adom/x"}]}}})));+ assert!(super::MOLECULE_PAGE_HINT.contains("kind molecule") && super::MOLECULE_PAGE_HINT.contains("model_3d"));+ }+}++#[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 netlist_freshness_tests {+ use super::*;+ #[test]+ fn gate_rejects_child_only_edits_old_evidence_and_export_edits() {+ let dir=std::env::temp_dir().join(format!("aiflow-net-freshness-{}",std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let sch=dir.join("root.kicad_sch"); let child=dir.join("child.kicad_sch");+ let net=dir.join("export.net"); let board=dir.join("board.kicad_pcb");+ std::fs::write(&sch,"(kicad_sch (sheet (property \"Sheetfile\" \"child.kicad_sch\")))").unwrap();+ std::fs::write(&child,"(kicad_sch)").unwrap();+ std::fs::write(&net,"(export (components) (nets))").unwrap();+ std::fs::write(&board,"(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")))").unwrap();+ let proof=json!({"ok":true,"sha256":sha256_file(sch.to_str().unwrap()),"schematicFiles":schematic_identity::capture(&sch).unwrap(),"netlist":net,"netlistSha256":sha256_file(net.to_str().unwrap())});+ let mut run=Run{dir:dir.clone(),data:json!({"schematic":sch,"boards":[board],"outcomes":{"netlist":proof}})};+ assert!(netlist_gate(&run).is_ok());+ std::fs::write(&child,"(kicad_sch (changed yes))").unwrap();+ assert!(netlist_gate(&run).unwrap_err().contains("hierarchy changed"));+ std::fs::write(&child,"(kicad_sch)").unwrap();+ std::fs::write(&net,"(export (components) (nets))\n").unwrap();+ assert!(netlist_gate(&run).unwrap_err().contains("exported netlist changed"));+ run.data["outcomes"]["netlist"].as_object_mut().unwrap().remove("schematicFiles");+ assert!(netlist_gate(&run).unwrap_err().contains("older check"));+ std::fs::remove_dir_all(dir).unwrap();+ }+}++#[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_take_the_recorder_lost_is_flagged_pull_failed_with_the_reason() {+ // issue #98: "No recording with id" ends the stop with a flag, not a wait+ let dir = std::env::temp_dir().join(format!("aiflow-clipstop-c-{}", std::process::id()));+ let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "fields", "visit": 1}));+ let mut lost = stop(None, false);+ lost.reply = json!({"aiflowStopError": "Adom Bridge no longer knows recording rec-3", "aiflowStopPending": false});+ assert_eq!(settle_clip_stop(&mut r, 0, &lost, &dir), None);+ let c = &r.data["captures"][0];+ assert_eq!(c["pullFailed"], true);+ assert!(c["stopError"].as_str().unwrap().contains("no longer knows") && c.get("stopPending").is_none());+ let ev = clip_stop_event(json!({}), &lost);+ assert_eq!(ev["pullFailed"], true);+ lost.reply["aiflowStopPending"] = json!(true);+ settle_clip_stop(&mut r, 0, &lost, &dir);+ assert_eq!(r.data["captures"][0]["stopPending"], true);+ 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);+ }+}++#[cfg(test)]+mod media_tools_tests {+ use super::*;++ #[test]+ fn a_missing_ffprobe_is_an_error_naming_it_not_a_blank_clip() {+ let e = media_tools_missing(&["aiflow-no-such-ffprobe"]).unwrap_err();+ assert!(e.starts_with("aiflow-no-such-ffprobe is not installed"), "{e}");+ let e = clip_verdict("/nonexistent/clip.mp4", &["aiflow-no-such-ffprobe", "ffmpeg"]).unwrap_err();+ assert!(e.contains("aiflow-no-such-ffprobe") && !e.contains("no frames"), "{e}");+ }++ #[test]+ fn a_clip_is_left_unjudged_when_the_tools_are_missing() {+ let dir = std::env::temp_dir().join(format!("aiflow-media-tools-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(&dir).unwrap();+ let file = dir.join("placement-1.mp4").display().to_string();+ std::fs::write(&file, b"not really a video").unwrap();+ let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": file, "step": "placement", "visit": 1}]}) };+ clip_artifacts_with(&mut r, &json!("placement-1"), &file, 10, &["aiflow-no-such-ffprobe"]);+ assert!(r.data["captures"][0].get("suspect").is_none(), "a missing tool must not flag the footage");+ let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();+ assert!(events.contains(&"clip-check-unavailable".to_string()) && !events.contains(&"clip-suspect".to_string()), "{events:?}");+ assert!(blank_check_verdict("no frames") && !blank_check_verdict("rejected by the AI: covered"));+ let _ = std::fs::remove_dir_all(dir);+ }+}++#[cfg(test)]+mod compose_out_tests {+ use super::*;++ /// Issue #146: the final video is 1080p, so nothing on the way to it may be smaller, the overlays+ /// keep their proportions on the bigger frame, and only a file a page would refuse is squeezed.+ #[test]+ fn the_video_is_kept_at_1080p_and_its_overlays_keep_their_size() {+ assert_eq!(CUT_SCALE, "scale='min(1920,iw)':-2", "a cut is never enlarged and never below the video's width");+ assert!(VIDEO_CEILING_BYTES > 50_000_000 && VIDEO_CEILING_BYTES < 100_000_000, "under the wiki's limit, far over the old 10 MB");+ let d = std::env::temp_dir().join(format!("aiflow-146-{}", std::process::id()));+ std::fs::create_dir_all(&d).unwrap();+ let f = overlay_filters(&d, "ROUTING", "claude · board", 0.0, 0.0, false);+ assert!(f.starts_with("scale=1920:-2,"), "{}", &f[..40]);+ assert_eq!(FRAME_W as f64 / 1280.0, OVERLAY_SCALE, "the overlays were laid out on a 1280 frame");+ assert!(f.contains("fontsize=36:") && f.contains("fontsize=22.5:") && f.contains("boxborderw=15"), "title and label sizes scale with the frame: {f}");+ assert!(!f.contains("fontsize=24:"));+ let _ = std::fs::remove_dir_all(&d);+ }++ #[test]+ fn out_resolves_against_the_working_directory_and_defaults_into_the_run() {+ let (run, cwd) = (Path::new("/p/aiflow/run-1mm"), Path::new("/p/aiflow"));+ assert_eq!(compose_out_path(None, run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));+ // issue #80: run-1mm/final.mp4 from /p/aiflow is the run's own final.mp4, not run-1mm/run-1mm/final.mp4+ assert_eq!(compose_out_path(Some("run-1mm/final.mp4"), run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));+ assert_eq!(compose_out_path(Some("cut.mp4"), run, cwd), PathBuf::from("/p/aiflow/cut.mp4"));+ assert_eq!(compose_out_path(Some("/v/cut.mp4"), run, cwd), PathBuf::from("/v/cut.mp4"));+ }++ #[test]+ fn the_failure_names_the_output_and_ffmpeg_last_lines() {+ let stderr = (1..=20).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n") + "\n/p/run-1mm/run-1mm/final.mp4: No such file or directory\n";+ let e = compose_error("ffmpeg could not assemble the video and its narration", Path::new("/p/run-1mm/run-1mm/final.mp4"), &stderr);+ assert!(e.contains("(output /p/run-1mm/run-1mm/final.mp4)"), "{e}");+ assert!(e.contains("No such file or directory") && e.contains("line 20") && !e.contains("line 12\n"), "{e}");+ let e = compose_error("x", Path::new("/o.mp4"), " \n");+ assert!(e.contains("(ffmpeg printed nothing)"), "{e}");+ // a long line keeps its head and tail+ let long = format!("{}MIDDLE{}", "a".repeat(1000), "z".repeat(1000));+ let e = compose_error("x", Path::new("/o.mp4"), &long);+ assert!(e.contains(" ... ") && e.contains("aaaa") && e.ends_with("zzzz"), "{e}");+ }++ #[test]+ fn a_failed_ffmpeg_keeps_its_stderr() {+ assert!(!ffmpeg_ok(std::process::Command::new("sh").args(["-c", "echo first >&2; echo 'Error opening output file' >&2; exit 1"])));+ let kept = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());+ assert!(kept.contains("Error opening output file"), "{kept}");+ assert!(ffmpeg_ok(std::process::Command::new("sh").args(["-c", "exit 0"])));+ }+}+
Comments
Log in to comment.