← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.92"+version = "0.1.93" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.92"+version = "0.1.93" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.92"+version = "0.1.93" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.92"+version = "0.1.93" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+10
@@ -260,6 +260,16 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper. +## Evidence comes from the work (0.1.93)++The gates read the board and the tools' own output, so a file written to look like evidence does not pass:++- **The board is built in KiCad.** Place and route through kicad-bridge (`place land`, `land route`) on the live board, or adopt a board KiCad saved. `finish` refuses a board file that the running KiCad did not save (its `generator_version` must match the live KiCad's major version): a board written by a script is not a KiCad board.+- **Fields evidence is a solve aiflow watched.** Run Adom Fields through aiflow (`adom-aiflow exec -- adom-fields analyze --board <board> --spec <spec> --out <run>/fields`). When `analyze` finds a `fields.json` it did not watch being made, it solves the board again itself with the same inputs (about two minutes) and screens that. `finish` refuses a field report no watched solve produced. Your own current and heat drawings are welcome as illustrations; they are not evidence.+- **3D models are the parts.** A renamed or resized laser-etch proxy box is refused by `models` and `finish`. Use the wiki component page's model, the vendor STEP, or a model built from the datasheet.+- **Silkscreen is printed.** `finish` refuses a board with fewer than 0.5 visible silkscreen labels per footprint (finished boards carry about 1 to 3.5): references, values and contact labels go on the silkscreen.+- `step <name> --back` only returns to an earlier step.+ ## Component coverage before placement  The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+67−4
@@ -14,6 +14,7 @@ mod outline; mod browser_access; mod schematic_identity; mod placement_verify;+mod provenance;  /// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html");@@ -1892,6 +1893,23 @@ fn command_line() -> String {  // ----------------------------------------------------------------------------- design gates (checks.rs) +/// True when this fields.json is byte-for-byte the output of an Adom Fields solve aiflow ran or watched+/// (`exec -- adom-fields analyze ...`, or the solve `analyze` starts itself).+fn fields_observed(r: &Run, file: &Path) -> bool {+    let Ok(bytes) = std::fs::read(file) else { return false };+    let h = provenance::sha256_hex(&bytes);+    r.data["fieldsSolves"].as_array().map(|a| a.iter().any(|x| x["sha256"].as_str() == Some(h.as_str()))).unwrap_or(false)+}++/// Record a finished Adom Fields solve by the hash of the fields.json it wrote.+fn record_fields_solve(r: &mut Run, file: &Path, how: &str, args: &[String]) {+    if let Ok(bytes) = std::fs::read(file) {+        let entry = json!({"sha256": provenance::sha256_hex(&bytes), "file": file.display().to_string(), "how": how, "args": args, "at": aiflow_run::now()});+        if !r.data["fieldsSolves"].is_array() { r.data["fieldsSolves"] = json!([]); }+        r.data["fieldsSolves"].as_array_mut().unwrap().push(entry);+    }+}+ fn sha256_file(p: &str) -> String {     use sha2::{Digest, Sha256};     std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()@@ -2088,7 +2106,9 @@ fn main() {             let mut r=load_run(&cli);             match status {                 Ok(status)=>{-                    r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success()}));r.save().unwrap();+                    r.log("external-end",json!({"program":command[0],"exitCode":status.code(),"success":status.success()}));+                    if status.success() && provenance::is_fields_solve(command) { if let Some(f)=provenance::fields_out(command) { record_fields_solve(&mut r, Path::new(&f), "exec", command); } }+                    r.save().unwrap();                     if !status.success(){err(&format!("external command failed: {} ({status})",command[0]), &["Inspect the command output above, fix the reported failure and retry with adom-aiflow exec -- <program> <args>. The failed command's time is retained.".into()]);}                     ok("external command completed; its running time is in this session", &["Use exec for build, browser automation and other work outside AI Flow. Do not start an idle heartbeat; only actual work counts.".into()]);                 }@@ -3056,8 +3076,21 @@ fn main() {             let name = format!("analyze-{what}");             r.stage_start(&name, Some("binary"), None);             let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };-            let fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));-            let screen=if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {+            let 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(" "))); }+                }+            }+            let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {                 let f=read_json(&fields_file.display().to_string());                 let board_bytes=std::fs::read(&solved_board).unwrap_or_else(|e|err(&e.to_string(),&[]));                 let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));@@ -3130,6 +3163,8 @@ fn main() {                     let board_bytes=std::fs::read(&solved_board).unwrap_or_default();                     let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();                     if let Some(report)=report {+                        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-"))); }                         if let Err(reason)=aiflow_analyze::field_screen(&report,&spec_of(&r),&board_bytes,&spec_bytes,k.trim_start_matches("analyze-")) { missing.push(format!("{k}: {reason}")); }                     } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }                 }@@ -3142,6 +3177,16 @@ fn main() {             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("; "))); }             }+            {+                let (labels, fps) = provenance::silk_labels(&board_now());+                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);@@ -3151,6 +3196,14 @@ fn main() {             if let Some(target) = r.data["target"].as_str() {                 let br = Bridge { ai_thread: t.clone(), target: target.to_string() };                 live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));+                if aiflow_bridge::fusion::doc_of(r.data["remoteBoard"].as_str().unwrap_or("")).is_none() {+                    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);                 if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {                     missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));@@ -3863,6 +3916,10 @@ fn main() {             if unresolved > 0 {                 err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);             }+            let proxies = provenance::proxy_models(&board_of(&r), &project_dir_of(&r));+            if !proxies.is_empty() {+                err(&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() {                 err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);             }@@ -4151,6 +4208,12 @@ fn main() {                 if r.data["sequence"] != want { r.data["sequence"] = want; }             }             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 { err(&format!("--back goes to an earlier step, but {name} comes after {} in the flow", seq[cp]), &[format!("`step {name}` without --back: every step before it must be done first.")]); }+                }+            }             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));@@ -4212,7 +4275,7 @@ fn main() {                 let hwnd = if name == "fields" { None }                     else if fusion_project { br.fusion_hwnd() }                     else if fusion_run { let h = br.pcb_editor_hwnd(); br.fusion_fit_board(); h }-                    else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };+                    else { 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()))); br.editor_for_board(r.data["remoteBoard"].as_str().unwrap_or(""), cached) };                 let disk = disk_check(&dir, &br, name);                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));
crates/adom-aiflow/src/provenance.rsadded+198
@@ -0,0 +1,198 @@+//! Provenance gates: evidence must come from the work, not from a file written to look like it.+//!+//! A run (2026-10-14) wrote its board with a script, forged `fields/fields.json` in the solver's format,+//! renamed the laser-etch proxy box after real MPNs (with an injected `Adom.LaserEtch` marker) and left+//! the silkscreen empty; every gate passed. These checks look at the board and the files themselves, and+//! every threshold was set against finished runs that did the work, so those runs still pass.++use aiflow_board::{sx, Board};+use sha2::{Digest, Sha256};+use std::path::Path;++/// The STEP placeholder aiflow ships for laser-etch plates; a model built from it is a box, not the part.+const PROXY_STEP: &str = include_str!("../../../tools/laser-etch/proxy_box.step");++/// Entity type names of a STEP file in order, without the ones that carry names (PRODUCT,+/// PRODUCT_RELATED_PRODUCT_CATEGORY): two files with the same skeleton are the same shape with+/// different coordinates or labels.+fn step_skeleton(text: &str) -> Vec<String> {+    let data = match text.find("DATA;") { Some(i) => &text[i + 5..], None => text };+    let mut out = Vec::new();+    for ent in data.split(';') {+        let e = ent.trim();+        if !e.starts_with('#') { continue; }+        let Some(eq) = e.find('=') else { continue };+        let rest = e[eq + 1..].trim_start();+        let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();+        if name.is_empty() || name == "PRODUCT" || name == "PRODUCT_RELATED_PRODUCT_CATEGORY" { continue; }+        out.push(name);+    }+    out+}++/// True when a STEP is the shipped proxy box (renamed, resized or re-labelled), not a real model.+pub fn is_proxy_box(text: &str) -> bool {+    // the name alone proves nothing: real laser-etched models carry the proxy as their marking plate+    if text.matches("ADVANCED_FACE").count() != 6 { return false; }+    step_skeleton(text) == step_skeleton(PROXY_STEP)+}++/// Footprints whose 3D model is the proxy box: (reference, model path).+pub fn proxy_models(b: &Board, project_dir: &Path) -> Vec<(String, String)> {+    let mut out = Vec::new();+    for m in crate::checks::model_refs(b) {+        let Some(p) = crate::checks::resolve_model(&m.path, project_dir) else { continue };+        let Ok(text) = std::fs::read_to_string(&p) else { continue };+        if is_proxy_box(&text) { out.push((m.reference, m.path)); }+    }+    out+}++fn is_hidden(n: &sx::Node) -> bool {+    let in_effects = n.find("effects").map(|e| e.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || e.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == "hide"))).unwrap_or(false);+    let direct = n.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || n.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == "hide"));+    in_effects || direct+}++fn on_silk(n: &sx::Node) -> bool {+    n.find("layer").and_then(|l| l.value()).map(|l| l == "F.SilkS" || l == "B.SilkS").unwrap_or(false)+}++/// Visible silkscreen text on the board (footprint references, values and texts, board texts) and the+/// footprint count. Finished runs measured 0.93 to 3.55 labels per footprint; a board with none has no+/// silkscreen at all.+pub fn silk_labels(b: &Board) -> (usize, usize) { silk_labels_in(&b.root) }++fn silk_labels_in(root: &sx::Node) -> (usize, usize) {+    let mut labels = 0;+    let mut fps = 0;+    for n in root.nodes() {+        match n.tag.as_str() {+            "footprint" => {+                fps += 1;+                for t in n.nodes().filter(|c| c.tag == "property" || c.tag == "fp_text") {+                    if on_silk(t) && !is_hidden(t) { labels += 1; }+                }+            }+            "gr_text" | "gr_text_box" => { if on_silk(n) && !is_hidden(n) { labels += 1; } }+            _ => {}+        }+    }+    (labels, fps)+}++/// The fewest visible silkscreen labels per footprint a finished board may have.+pub const MIN_SILK_PER_FOOTPRINT: f64 = 0.5;++/// The KiCad that saved the board file: `generator_version` (for example "10.0"); None when absent.+pub fn saved_by(board_text: &str) -> Option<String> {+    let head = &board_text[..board_text.len().min(4000)];+    let i = head.find("(generator_version")?;+    let rest = &head[i + "(generator_version".len()..];+    let q1 = rest.find('"')?;+    let q2 = rest[q1 + 1..].find('"')?;+    Some(rest[q1 + 1..q1 + 1 + q2].to_string())+}++fn major(v: &str) -> Option<u32> { v.split('.').next()?.trim().parse().ok() }++/// Err when the board file was not saved by the major KiCad version that is running.+pub fn saved_by_live_kicad(board_text: &str, live_version: &str) -> Result<(), String> {+    let Some(live) = major(live_version) else { return Ok(()) };+    match saved_by(board_text) {+        Some(v) if major(&v) == Some(live) => Ok(()),+        Some(v) => Err(format!("the board file says it was saved by KiCad {v}, but KiCad {live_version} is running: it was not saved by this KiCad (written outside it?). Open it in KiCad and save, or build it there")),+        None => Err(format!("the board file has no generator_version: it was not saved by KiCad {live_version}. Open it in KiCad and save, or build it there")),+    }+}++pub fn sha256_hex(bytes: &[u8]) -> String {+    let mut h = Sha256::new();+    h.update(bytes);+    format!("{:x}", h.finalize())+}++/// True when the command line is an Adom Fields solve (`adom-fields analyze ...`).+pub fn is_fields_solve(command: &[String]) -> bool {+    let prog = command.first().map(|p| Path::new(p).file_name().and_then(|f| f.to_str()).unwrap_or("").to_string()).unwrap_or_default();+    prog == "adom-fields" && command.iter().skip(1).any(|a| a == "analyze")+}++/// The fields.json an Adom Fields command line writes: `<--out>/fields.json`.+pub fn fields_out(command: &[String]) -> Option<String> {+    let i = command.iter().position(|a| a == "--out")?;+    let out = command.get(i + 1)?;+    Some(Path::new(out).join("fields.json").display().to_string())+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn proxy_detected_after_rename_resize_and_marker() {+        assert!(is_proxy_box(PROXY_STEP));+        // what the 2026-10-14 run did: rename, move the corners, inject the laser-etch marker+        let forged = PROXY_STEP+            .replace("adom_mark_proxy", "TPS54202DDCR")+            .replace("#350 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7));", "#350 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7,#405));\n#405 = PRODUCT('Adom.LaserEtch','Adom.LaserEtch','',(#8));");+        let forged = regex_lite::Regex::new(r"\(1\.,").unwrap().replace_all(&forged, "(1.45,").to_string();+        assert!(!forged.contains("adom_mark_proxy"));+        assert!(is_proxy_box(&forged));+    }++    #[test]+    fn real_model_is_not_proxy() {+        let real = PROXY_STEP.replace("adom_mark_proxy", "X").replacen("ADVANCED_FACE", "ADVANCED_FACE", 1) + "\n#9999 = ADVANCED_FACE('',(#1),#2,.T.);";+        assert!(!is_proxy_box(&real));+    }++    #[test]+    fn saved_by_reads_and_compares() {+        let t = "(kicad_pcb\n\t(version 20241229)\n\t(generator \"pcbnew\")\n\t(generator_version \"9.0\")\n";+        assert_eq!(saved_by(t).as_deref(), Some("9.0"));+        assert!(saved_by_live_kicad(t, "10.0.3").is_err());+        assert!(saved_by_live_kicad(&t.replace("9.0", "10.0"), "10.0.3").is_ok());+        assert!(saved_by_live_kicad(t, "").is_ok());+    }++    #[test]+    fn fields_solve_command() {+        let c: Vec<String> = ["/usr/bin/adom-fields", "analyze", "--board", "b", "--spec", "s", "--out", "run/fields"].iter().map(|s| s.to_string()).collect();+        assert!(is_fields_solve(&c));+        assert_eq!(fields_out(&c).as_deref(), Some("run/fields/fields.json"));+        let fake: Vec<String> = ["python3", "fake.py", "analyze"].iter().map(|s| s.to_string()).collect();+        assert!(!is_fields_solve(&fake));+    }++    #[test]+    fn silk_counts_visible_labels_only() {+        let t = r#"(kicad_pcb (version 20260206) (generator "pcbnew") (generator_version "10.0")+ (footprint "R" (layer "F.Cu") (property "Reference" "R1" (at 0 0 0) (layer "F.SilkS") (effects (font (size 1 1))))+   (property "Value" "1k" (at 0 0 0) (layer "F.Fab") (effects (font (size 1 1)))))+ (footprint "C" (layer "F.Cu") (property "Reference" "C1" (at 0 0 0) (layer "F.SilkS") (hide yes) (effects (font (size 1 1)))))+ (gr_text "VIN" (at 0 0) (layer "F.SilkS") (effects (font (size 1 1)))))"#;+        let root = sx::parse(t).unwrap();+        assert_eq!(silk_labels_in(&root), (2, 2));+    }++    /// Opt-in regression on real boards: AIFLOW_PROVENANCE_BOARDS="pass:/a.kicad_pcb fail:/b.kicad_pcb"+    /// (each board's 3D models resolve beside it). `cargo test -- --ignored provenance_on_real_boards`.+    #[test]+    #[ignore]+    fn provenance_on_real_boards() {+        let list = std::env::var("AIFLOW_PROVENANCE_BOARDS").unwrap_or_default();+        for item in list.split_whitespace() {+            let (want, path) = item.split_once(':').unwrap();+            let b = Board::load(path).unwrap();+            let (labels, fps) = silk_labels(&b);+            let silk_ok = fps == 0 || (labels as f64) >= MIN_SILK_PER_FOOTPRINT * fps as f64;+            let proxies = proxy_models(&b, Path::new(path).parent().unwrap());+            let text = std::fs::read_to_string(path).unwrap();+            let saved_ok = saved_by_live_kicad(&text, "10.0.3").is_ok();+            let pass = silk_ok && proxies.is_empty() && saved_ok;+            eprintln!("{} silk {labels}/{fps} proxies {} saved_by {:?} -> {}", path, proxies.len(), saved_by(&text), if pass { "PASS" } else { "FAIL" });+            assert_eq!(pass, want == "pass", "{path}");+        }+    }+}
crates/aiflow-bridge/src/lib.rs+19
@@ -218,6 +218,25 @@ impl Bridge {     /// Frame the live Fusion board by its outline.     pub fn fusion_fit_board(&self) -> bool { match fusion::active() { Some(d) => fusion::fit_board(&d, &|v, a| self.raw_call(v, a)), None => false } } +    /// The PCB editor showing this board (title holds the board's file stem); a cached window that is a+    /// PCB editor of another board is replaced by it. Other cached windows (3D viewer, Fields) are kept.+    pub fn editor_for_board(&self, board: &str, cached: Option<i64>) -> Option<i64> {+        let r = self.call("kicad_window_info", &json!({}));+        let d = r.get("data").cloned().unwrap_or(r.clone());+        let eds: Vec<Value> = d.get("editors").and_then(|e| e.as_array()).cloned().unwrap_or_default();+        let pcbs: Vec<&Value> = eds.iter().filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")).collect();+        let stem = std::path::Path::new(board).file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();+        let title_of = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();+        let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| title_of(w).contains(&stem)).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) };+        match cached {+            Some(h) => {+                let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !title_of(w).contains(&stem));+                if other_board { matching.or(Some(h)) } else { Some(h) }+            }+            None => matching.or_else(|| pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())),+        }+    }+     pub fn pcb_editor_hwnd(&self) -> Option<i64> {         let r = self.call("kicad_window_info", &json!({}));         let d = r.get("data").cloned().unwrap_or(r.clone());
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.92",+  "version": "0.1.93",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.92",+  "version": "0.1.93",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",