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.
← Commit history
Publish 0.1.93
9 files changed
+307−17
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.",