← Commit history

Publish 0.2.56

John Lauer ·73fcf19013 · ·parent 6930540
44 files changed +3110−171
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+4−4bin/adom-aiflowcrates/adom-aiflow/src/checks.rs+111−2crates/adom-aiflow/src/clipwatch.rs+104−7crates/adom-aiflow/src/export.rs+75−2crates/adom-aiflow/src/fab_layer.rs+302crates/adom-aiflow/src/handson.rs+1−1crates/adom-aiflow/src/main.rs+394−58crates/adom-aiflow/src/mechanical.rs+68−4crates/adom-aiflow/src/molecule_pins.rs+22crates/adom-aiflow/src/page_results.rs+306crates/adom-aiflow/src/placement_verify.rs+44−3crates/adom-aiflow/src/pour_adopt.rs+259crates/adom-aiflow/src/pourverify.rs+250−9crates/adom-aiflow/src/preboard.rs+52−3crates/adom-aiflow/src/project_lib.rs+62−1crates/adom-aiflow/src/schematic_symbols.rs+97−10crates/adom-aiflow/src/sequence.rs+58−2crates/adom-aiflow/src/silk_audit_input.rs+177crates/adom-aiflow/src/verified.rs+12−2crates/aiflow-analyze/src/lib.rs+35−1crates/aiflow-analyze/src/pour_cover.rs+2−1crates/aiflow-board/src/silk.rs+57−1crates/aiflow-bridge/src/eda.rs+70−7crates/aiflow-bridge/src/lib.rs+90−9docs/altium-lane.md+2−1docs/isolated-kicad-read-socket.md+5−1package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+17−10skills/aiflow-hands-on/SKILL.md+1−1skills/aiflow-molecule/SKILL.md+5−3skills/aiflow-project-page/SKILL.md+5−1skills/aiflow-schematic-to-board/SKILL.md+3−3skills/aiflow-silkscreen/SKILL.md+13−1skills/aiflow-sourcing/SKILL.md+5−3tests/erc-outline-workflow.py+112tests/page-results-workflow.py+79tests/parts-sources-workflow.py+80tests/project-page-library.py+30tests/silk-fab-workflow.py+67tools/project-page.py+21−7
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.55"+version = "0.2.56" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.55"+version = "0.2.56" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.55"+version = "0.2.56" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.55"+version = "0.2.56" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+4−4
@@ -42,7 +42,7 @@ The guard is read-only and suitable for a host's pre-stop integration. It does n  ## The flow -The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.+The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with `kicad_zoom_to` (KiCad Bridge 1.0.80+; the Zoom to Selected Objects menu key on an older bridge), one net at a time on camera; it says "framed" only for the nets a zoom call framed and names the calls that failed for the others. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.  1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). `place pack` lays each `spec.chains` entry (a series chain in electrical order, such as a divider) on one line with the shared-net pads facing and says which chains were honoured; when a wish lists several rotations it picks the one whose pads land nearest their net partners. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's.@@ -311,7 +311,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo  - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.-- **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad (except net ties and footprints marked `allow_missing_courtyard`, KiCad's own exceptions) and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout. - **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.@@ -557,7 +557,7 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel  `verify-pours --plan <plan.json>` compares saved, filled zones with an explicit plan without landing them. Read `docs/existing-pours.md`. A failure names, per zone, the closest board zone and every field that differs: geometry, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge and island removal. `spec.pours` (and so the plan `pour` writes) can carry `connection` (thermal, solid or none), `thermalGap`, `thermalBridge`, `islandRemoval` (always, never or area) and `islandAreaMin`, so zones written outside the flow can be described exactly. `land pours` refuses pad connection none and island removal other than always before any bridge call, because the KiCad bridge cannot land them faithfully. -The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass.+The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass. The one exception is the human's own pours (hands-on human step): `verify-pours --from-board --adopt --why "poured by the human"` writes the plan from the saved zones itself (`plan-pours-from-board.json`) and records its provenance as `from-board`: the zones as the human drew them, not an independent design (a note says so when pours is not a human step).  `verify-pours --plan <plan> --adopt --why "<reason>"` takes ownership of the matched zones: their saved IDs join the run's owned zones (so a later `land pours` may replace them), the grant is logged in `zoneAdoptions`, and the outcome is `mode: adopted-existing`. `finish`, `status` and the summary report the pours as adopted, not landed. Native DRC, measurement and current/thermal gates remain required. @@ -621,7 +621,7 @@ Read `skills/aiflow-project-page/SKILL.md` and run `adom-aiflow project-page-che  ### Explicit native read endpoint -For multiple-editor IPC conflicts, read `docs/isolated-kicad-read-socket.md`. `ADOM_AIFLOW_KICAD_READ_SOCKET` selects a verified instance for read/validation and zone-measurement verbs; zone measurement includes its single refill request. It does not redirect route, placement, zone-edit or window-capture calls. Preserve unsaved editors, require live-editor identity, and keep every native DRC gate. A renamed PCB checkpoint must carry matching project/rule sidecars before it is opened.+For multiple-editor IPC conflicts, read `docs/isolated-kicad-read-socket.md`. `ADOM_AIFLOW_KICAD_READ_SOCKET` selects a verified instance for read/validation and zone-measurement verbs; zone measurement includes its single refill request. It does not redirect route, placement, zone-edit or window-capture calls. For live edits on a task-owned editor while another session's KiCad holds the default pipe, set `ADOM_AIFLOW_KICAD_SOCKET=ipc://<its pipe>` instead: it reaches every live read and edit verb (placement, routing, zones, net selection, zoom), never window or capture verbs. Preserve unsaved editors, require live-editor identity, and keep every native DRC gate. A renamed PCB checkpoint must carry matching project/rule sidecars before it is opened.  Copper measurement uses the same explicit `ADOM_AIFLOW_KICAD_READ_SOCKET` as placement/routing reads. This includes its one native refill request and delayed zone snapshots; it does not enable route or placement edits on isolated endpoints. See `docs/isolated-kicad-read-socket.md`. 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+111−2
@@ -282,6 +282,11 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom:         }     }     if contacts.is_empty() { f.warn("no MachineContact footprints: the molecule has no wired contacts".into(), "Contacts take the signals to the Control Panel; add MachineContactMedium footprints on the grid (skill aiflow-molecule)."); }+    // issue #162: contacts on the outer edge (a corner-pin row or column, or a wing outside them); a finding+    // with the nearest edge spot, never an error+    let (edge_ok, edge_warn) = contact_edge(b, spec);+    for l in edge_ok { f.ok(l); }+    for (finding, suggestion) in edge_warn { f.warn(finding, &suggestion); }     // issue #138: the names the wiki's model registry reads (adom.molecule-pins/2). A finding with     // the rename to make, never an error: the board is fine copper; only the names keep the registry     // from counting its contacts.@@ -332,6 +337,7 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom:             for r in fixed.keys() { if !refs.contains(r) { refs.push(r.clone()); } }         }         let mut moved = Vec::new();+        let mut moved_refs: Vec<&String> = Vec::new();         for r in &refs {             if let (Some(a), Some(c)) = (b0.footprints.get(r), b.footprints.get(r)) {                 // The explicit position was checked above. It may intentionally revise a@@ -341,18 +347,82 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom:                     && ((a.x - c.x).abs() > 0.005 || (a.y - c.y).abs() > 0.005);                 if moved_position || (a.rot - c.rot).abs() > 0.01 {                     moved.push(format!("{r} ({:.2}, {:.2}, {:.0} deg) -> ({:.2}, {:.2}, {:.0} deg)", a.x, a.y, a.rot, c.x, c.y, c.rot));+                    moved_refs.push(r);                 }             } else {                 f.error(format!("fixed reference {r} missing from current or starting board"), "Restore the reference and its reviewed interface binding; absence is not an unchanged position.");             }         }+        // A rename in place is not a move (the molecule rule itself asks for one when the start board+        // numbered its corner pins wrongly): every "moved" reference now sits exactly where another+        // moved reference sat on the start board, same footprint and rotation, one to one. The+        // physical interface is unchanged; the rest of this check judges the new names (MP1 front-left).+        let pose_eq = |a: &aiflow_board::Footprint, c: &aiflow_board::Footprint| a.lib == c.lib && (a.x - c.x).abs() <= 0.005 && (a.y - c.y).abs() <= 0.005 && (a.rot - c.rot).abs() <= 0.01;+        let mut taken = vec![false; moved_refs.len()];+        let mut renamed = Vec::new();+        let rename_only = !moved_refs.is_empty() && moved_refs.iter().all(|r| {+            let c = &b.footprints[*r];+            match moved_refs.iter().enumerate().find(|(i, s)| !taken[*i] && pose_eq(&b0.footprints[**s], c)) {+                Some((i, s)) => { taken[i] = true; renamed.push(format!("{r} is the part that was {s}")); true }+                None => false,+            }+        });         if moved.is_empty() { f.ok(format!("{} fixed references match explicit positions or starting-board locks; anchor and rotations retained", refs.len())); }+        else if rename_only { f.ok(format!("{} fixed references renamed in place, nothing moved (same footprints, positions and rotations as the start board): {}", renamed.len(), renamed.join("; "))); }         else { f.error(format!("fixed references moved since the run started: {}", moved.join("; ")), "Put them back where the start board had them (spec.fixedRefs); placement must not move the molecule interface."); }     }     pin_class_check(b, spec, &m, bom, &mut f);     f } +/// Issue #162 (Noah's rule): every machine contact sits on the molecule's outer edge, on a row or+/// column of the corner pins (x = 0 or span, y = 0 or span from MP1) or in a wing outside them, so a+/// wire reaches its contact without running over the parts and the middle stays free for the circuit.+/// Returns the OK lines and the findings (finding, suggestion with the nearest edge spot on the grid).+/// `spec.molecule.innerContacts: [{"ref": "MC4", "why": "<a sentence>"}]` records a contact kept inside+/// on purpose; `spec.molecule.contactEdgeBandMm` (default 0) widens the edge band. Never an error.+pub fn contact_edge(b: &Board, spec: &Value) -> (Vec<String>, Vec<(String, String)>) {+    let (mut oks, mut warns) = (Vec::new(), Vec::new());+    let m = spec.get("molecule").cloned().unwrap_or(json!({}));+    let grid = m["gridMm"].as_f64().filter(|g| *g > 0.0).unwrap_or(2.0);+    let band = m["contactEdgeBandMm"].as_f64().unwrap_or(0.0).max(0.0) + 0.05;+    let pins: Vec<(f64, f64)> = (1..=4).filter_map(|k| b.footprints.get(&format!("MP{k}")).map(|fp| (fp.x, fp.y))).collect();+    let Some(o) = b.footprints.get("MP1").map(|fp| (fp.x, fp.y)) else { return (oks, warns) };+    if pins.len() < 4 { return (oks, warns); }+    // the corner-pin rectangle in the design frame (mm from MP1, y up)+    let d: Vec<(f64, f64)> = pins.iter().map(|p| (p.0 - o.0, o.1 - p.1)).collect();+    let (x0, x1) = (d.iter().map(|p| p.0).fold(f64::MAX, f64::min), d.iter().map(|p| p.0).fold(f64::MIN, f64::max));+    let (y0, y1) = (d.iter().map(|p| p.1).fold(f64::MAX, f64::min), d.iter().map(|p| p.1).fold(f64::MIN, f64::max));+    if x1 - x0 < 0.1 || y1 - y0 < 0.1 { return (oks, warns); }+    let mut contacts: Vec<String> = m["contacts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+    for (r, fp) in &b.footprints {+        if fp.lib.to_ascii_lowercase().contains("machinecontact") && !contacts.contains(r) { contacts.push(r.clone()); }+    }+    contacts.sort();+    let inner: BTreeMap<String, String> = m["innerContacts"].as_array().map(|a| a.iter().filter_map(|x| Some((x["ref"].as_str()?.to_string(), x["why"].as_str().unwrap_or("").trim().to_string()))).collect()).unwrap_or_default();+    let snap = |v: f64, lo: f64, hi: f64| ((v / grid).round() * grid).clamp(lo, hi);+    let mut on_edge = 0;+    for r in &contacts {+        let Some(fp) = b.footprints.get(r) else { continue };+        let (dx, dy) = (fp.x - o.0, o.1 - fp.y);+        if dx <= x0 + band || dx >= x1 - band || dy <= y0 + band || dy >= y1 - band { on_edge += 1; continue; }+        let net = b.pads_of(r).first().map(|p| p.net.clone()).filter(|n| !n.is_empty()).map(|n| format!(" ({n})")).unwrap_or_default();+        if let Some(why) = inner.get(r) {+            if why.split_whitespace().count() >= 3 { oks.push(format!("{r}{net} sits inside the corner pins on purpose (spec.molecule.innerContacts): {why}")); continue; }+            warns.push((format!("{r}{net} is in spec.molecule.innerContacts without a reason"), format!("Say why {r} stays inside in a sentence (\"why\": \"...\"), so the next reader knows it is on purpose.")));+            continue;+        }+        let cands = [(x0, snap(dy, y0, y1)), (x1, snap(dy, y0, y1)), (snap(dx, x0, x1), y0), (snap(dx, x0, x1), y1)];+        let best = cands.iter().copied().fold((f64::MAX, (0.0, 0.0)), |acc, c| { let dd = (c.0 - dx).hypot(c.1 - dy); if dd < acc.0 { (dd, c) } else { acc } }).1;+        warns.push((+            format!("{r}{net} at ({dx:.1}, {dy:.1}) mm from MP1 sits inside the corner pins, not on the outer edge (pins span x {x0:.0} to {x1:.0}, y {y0:.0} to {y1:.0})"),+            format!("Suggested: put contacts on the outer edge (a corner-pin row or column, or a wing outside the pins) so a wire reaches {r} without running over the parts and the middle stays free for the circuit; the nearest edge spot on the {grid} mm grid is ({:.0}, {:.0}) mm from MP1. If {r} must stay inside, record why: spec.molecule.innerContacts: [{{\"ref\": \"{r}\", \"why\": \"...\"}}].", best.0, best.1),+        ));+    }+    if on_edge > 0 && on_edge == contacts.len() { oks.push(format!("{on_edge} contact(s) on the outer edge (corner-pin rows and columns or wings outside them)")); }+    (oks, warns)+}+ // ----------------------------------------------------------------------------- corner-pin class (#87)  /// Large corner pins when the board with its parts weighs more than this (4 medium contacts hold@@ -1153,9 +1223,9 @@ mod tests {             .replace("124", "132").replace(" 84", " 68").replace("(start 98 82)", "(start 96 64)").replace("(end 126 102)", "(end 136 104)")     } -    /// A MachineContactLarge MC2 on VMOT, 6 mm right of MC1 (on the 2 mm grid from MP1).+    /// A MachineContactLarge MC2 on VMOT on the front pin row, 6 mm right of MP1 (on the 2 mm grid; on the outer edge, issue #162).     fn with_large_contact(text: &str) -> String {-        text.replacen(" (footprint \"Resistor_SMD", " (footprint \"Molecule:MachineContactLarge\" (layer \"F.Cu\") (at 106 88) (property \"Reference\" \"MC2\") (pad \"1\" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers \"*.Cu\") (net \"VMOT\")))\n (footprint \"Resistor_SMD", 1)+        text.replacen(" (footprint \"Resistor_SMD", " (footprint \"Molecule:MachineContactLarge\" (layer \"F.Cu\") (at 106 100) (property \"Reference\" \"MC2\") (pad \"1\" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers \"*.Cu\") (net \"VMOT\")))\n (footprint \"Resistor_SMD", 1)     }      #[test]@@ -1319,6 +1389,45 @@ mod tests {         assert!(f.errors.iter().any(|e| e.contains("moved since the run started")), "{:?}", f.lines);     } +    #[test]+    fn contacts_inside_the_pins_are_a_finding_with_the_nearest_edge_spot() {+        // issue #162: MC1 sits on the MP1-MP3 column (an edge); MC2 in the middle is a finding, never an error+        let spec = json!({"molecule": {}});+        let f = molecule_check(&board(BOARD), &spec, None);+        assert!(f.lines.iter().any(|l| l.contains("on the outer edge")), "{:?}", f.lines);+        let mid = BOARD.replace("(footprint \"Resistor_SMD:R_0402_1005Metric\" (layer \"F.Cu\") (at 110 90) (property \"Reference\" \"R1\")",+            "(footprint \"Molecule:MachineContactMedium\" (layer \"F.Cu\") (at 106 98) (property \"Reference\" \"MC2\") (pad \"1\" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers \"*.Cu\") (net \"PHA\")))\n (footprint \"Resistor_SMD:R_0402_1005Metric\" (layer \"F.Cu\") (at 110 90) (property \"Reference\" \"R1\")");+        assert_ne!(mid, BOARD);+        let f = molecule_check(&board(&mid), &spec, None);+        assert!(f.errors.iter().all(|e| !e.contains("MC2")), "{:?}", f.lines);+        assert!(f.warnings.iter().any(|w| w.contains("MC2 (PHA)") && w.contains("not on the outer edge")), "{:?}", f.lines);+        // (6, 2) from MP1 in a 24 x 16 span: the nearest edge spot is the front row (6, 0)+        assert!(f.hints.iter().any(|h| h.contains("(6, 0) mm from MP1")), "{:?}", f.hints);+        // kept inside on purpose, with a sentence: an OK line+        let spec = json!({"molecule": {"innerContacts": [{"ref": "MC2", "why": "the phase wire leaves straight up through the heatsink slot"}]}});+        let f = molecule_check(&board(&mid), &spec, None);+        assert!(f.warnings.iter().all(|w| !w.contains("MC2")), "{:?}", f.lines);+        assert!(f.lines.iter().any(|l| l.contains("MC2 (PHA) sits inside the corner pins on purpose")), "{:?}", f.lines);+        // without a reason: a finding asking for one+        let spec = json!({"molecule": {"innerContacts": [{"ref": "MC2"}]}});+        let f = molecule_check(&board(&mid), &spec, None);+        assert!(f.warnings.iter().any(|w| w.contains("without a reason")), "{:?}", f.lines);+    }++    #[test]+    fn corner_pins_renamed_in_place_are_not_moved() {+        // the start board numbered MP1 and MP3 the other way round; the human renamed them to the rule+        let spec = json!({"molecule": {}});+        let start = BOARD.replace("\"MP1\"", "\"MPX\"").replace("\"MP3\"", "\"MP1\"").replace("\"MPX\"", "\"MP3\"");+        let f = molecule_check(&board(BOARD), &spec, Some(&board(&start)));+        assert!(f.errors.is_empty(), "{:?}", f.lines);+        assert!(f.lines.iter().any(|l| l.contains("renamed in place") && l.contains("MP1 is the part that was MP3")), "{:?}", f.lines);+        // a rename plus a real move is still a move+        let moved = BOARD.replace("(at 100 84) (property \"Reference\" \"MP3\")", "(at 100 86) (property \"Reference\" \"MP3\")");+        let f = molecule_check(&board(&moved), &spec, Some(&board(&start)));+        assert!(f.errors.iter().any(|e| e.contains("moved since the run started")), "{:?}", f.lines);+    }+     #[test]     fn explicit_interface_revision_retains_all_other_locks() {         let spec = json!({"molecule": {"fixed": {"MC1": [0, 10]}}, "fixedRefs": ["MC1"]});
crates/adom-aiflow/src/clipwatch.rs+104−7
@@ -63,13 +63,18 @@ pub fn preboard_camera(step: &str, windows: &[Value], engine: &str, tool: Option  /// The schematic or library window of the run's tool for a tool step: KiCad's schematic editor /// (any editor that is not the PCB editor) from `kicad_window_info`, else the tool's main window.-pub fn tool_window(step: &str, eda_id: &str, kicad_info: &Value, main: Option<i64>) -> Option<(i64, String)> {+/// With the run's board known (`stem`), only a schematic editor whose title names it (issue #155):+/// another project's schematic editor open on the same desktop is not this run's to film.+pub fn tool_window(step: &str, eda_id: &str, kicad_info: &Value, main: Option<i64>, stem: &str) -> Option<(i64, String)> {     if !tool_step(step) { return None; }     if eda_id == "kicad" {         let d = kicad_info.get("data").cloned().unwrap_or(kicad_info.clone());         let eds = d["editors"].as_array().cloned().unwrap_or_default();         let want = |w: &Value| { let k = w["kind"].as_str().unwrap_or("").to_ascii_lowercase(); if step == "schematic" { k.contains("sch") } else { k.contains("symbol") || k.contains("footprint") || k.contains("lib") } };-        return eds.iter().find(|w| want(w)).and_then(|w| w["hwnd"].as_i64().map(|h| (h, format!("KiCad's {}", w["kind"].as_str().unwrap_or("editor").replace('_', " ")))));+        // a schematic editor's title always names its project; a symbol or footprint editor names a library+        // (often a shared one), so only the schematic step is matched by the board's name+        let ours = |w: &Value| stem.is_empty() || step != "schematic" || aiflow_bridge::eda::names_board(w["title"].as_str().unwrap_or(""), stem);+        return eds.iter().find(|w| want(w) && ours(w)).and_then(|w| w["hwnd"].as_i64().map(|h| (h, format!("KiCad's {}", w["kind"].as_str().unwrap_or("editor").replace('_', " ")))));     }     main.map(|h| (h, format!("the {eda_id} window"))) }@@ -80,12 +85,60 @@ pub fn choose_preboard(br: &aiflow_bridge::Bridge, cam: &dyn aiflow_bridge::eda:     let tool = if tool_step(step) {         let info = if cam.id() == "kicad" { br.raw_call("kicad_window_info", &json!({})) } else { Value::Null };         let main = if cam.id() == "kicad" { None } else { cam.main_window(&tx) };-        tool_window(step, cam.id(), &info, main)+        tool_window(step, cam.id(), &info, main, &aiflow_bridge::eda::run_kicad_stem())     } else { None };     let windows = windows_of(&br.raw_call("desktop_list_windows", &json!({})));     preboard_camera(step, &windows, engine, tool) } +// -- framing the editor for a tour --------------------------------------------------------------++/// How one framing of the PCB editor went (`tour nets`): `how` names the call that framed it, None+/// when nothing did; `failed` says which calls failed and why.+#[derive(Debug, Clone, PartialEq, Default)]+pub struct Framing { pub how: Option<String>, pub failed: Vec<String> }++/// A reply that says the bridge has no such verb (a KiCad Bridge before 1.0.80, or the macOS bridge).+pub fn unknown_verb(reply: &Value) -> bool {+    let code = reply["errorCode"].as_str().unwrap_or("").to_ascii_lowercase();+    let text = format!("{} {}", reply["error"].as_str().unwrap_or(""), reply["message"].as_str().unwrap_or("")).to_ascii_lowercase();+    code.contains("unknown") || code.contains("unsupported") || code == "verb_not_found" || text.contains("unknown verb") || text.contains("unknown command") || text.contains("no such verb")+}++fn ok_reply(v: &Value) -> bool {+    v["success"].as_bool() == Some(true) || (v["status"].as_str() == Some("ok") && v.get("success").is_none())+}++fn why(v: &Value) -> String {+    v["errorCode"].as_str().or(v["actionStatus"].as_str()).or(v["data"]["actionStatus"].as_str()).or(v["error"].as_str()).or(v["status"].as_str()).unwrap_or("no answer").chars().take(80).collect()+}++/// Frame the PCB editor on a net (None: the whole board). Drew's note on PR #65 and kicad-bridge #121:+/// a menu command posted to a background editor is delivered and the view does not move, so the first+/// choice is `kicad_zoom_to` (KiCad's own Zoom to Selected Objects through the API, KiCad Bridge 1.0.80+,+/// Windows and Linux); without it (`zoom_to` false, or the bridge does not know the verb) the menu key+/// `kicad_send_key {menu}`. `zoom_to` is cleared when the bridge says it has no such verb, so the next+/// framing goes straight to the menu.+pub fn frame_view(call: &dyn Fn(&str, &Value) -> Value, zoom_to: &mut bool, editor: i64, board: &str, net: Option<&str>) -> Framing {+    let mut f = Framing::default();+    if *zoom_to {+        let mut a = json!({"filePath": board});+        if let Some(n) = net { a["net"] = json!(n); }+        let z = call("kicad_zoom_to", &a);+        let status = z["actionStatus"].as_str().or(z["data"]["actionStatus"].as_str());+        if ok_reply(&z) && status.map_or(true, |s| s == "Ok") {+            f.how = Some("kicad_zoom_to".into());+            return f;+        }+        if unknown_verb(&z) { *zoom_to = false; }+        f.failed.push(format!("kicad_zoom_to: {}", why(&z)));+    }+    let menu = if net.is_some() { "Zoom to Selected Objects" } else { "Zoom to Fit" };+    let k = call("kicad_send_key", &json!({"hwnd": editor, "menu": menu}));+    if ok_reply(&k) { f.how = Some(format!("the {menu} menu key")); } else { f.failed.push(format!("kicad_send_key {menu}: {}", why(&k))); }+    f+}+ // -- dead takes -------------------------------------------------------------------------------  /// Seconds between two health checks of the running take.@@ -232,13 +285,57 @@ mod tests {         assert!(matches!(preboard_camera("rules", &windows_of(&wrapped), "claude", None), Camera::Skip(_)));     } +    /// Drew's PR #65 note: "framed" only when a zoom call succeeded.+    #[test]+    fn framing_says_which_call_framed_the_view() {+        use std::cell::RefCell;+        let log = RefCell::new(Vec::<String>::new());+        let bridge = |zoom: Value, key: Value| { let log = &log; move |v: &str, a: &Value| { log.borrow_mut().push(format!("{v} {a}")); if v == "kicad_zoom_to" { zoom.clone() } else { key.clone() } } };+        // a bridge with kicad_zoom_to: framed by it, no menu key sent+        let mut zt = true;+        let f = frame_view(&bridge(json!({"success": true, "actionStatus": "Ok"}), json!({"success": true})), &mut zt, 5, "C:/b.kicad_pcb", Some("GND"));+        assert_eq!(f.how.as_deref(), Some("kicad_zoom_to"));+        assert!(f.failed.is_empty() && zt);+        assert_eq!(log.borrow().len(), 1);+        assert!(log.borrow()[0].contains("\"net\":\"GND\""));+        // an older bridge: the verb is unknown, the menu key frames, and the next framing skips zoom_to+        log.borrow_mut().clear();+        let f = frame_view(&bridge(json!({"success": false, "errorCode": "unknown_verb"}), json!({"success": true})), &mut zt, 5, "C:/b.kicad_pcb", None);+        assert_eq!(f.how.as_deref(), Some("the Zoom to Fit menu key"));+        assert_eq!(f.failed, vec!["kicad_zoom_to: unknown_verb".to_string()]);+        assert!(!zt);+        let f = frame_view(&bridge(json!({"success": true}), json!({"success": true})), &mut zt, 5, "C:/b.kicad_pcb", Some("VBUS"));+        assert_eq!(f.how.as_deref(), Some("the Zoom to Selected Objects menu key"));+        // both fail (a dead window): nothing framed, both failures named+        let mut zt = true;+        let f = frame_view(&bridge(json!({"success": true, "actionStatus": "Busy"}), json!({"success": false, "errorCode": "window_not_found"})), &mut zt, 1111, "C:/b.kicad_pcb", Some("GND"));+        assert_eq!(f.how, None);+        assert_eq!(f.failed, vec!["kicad_zoom_to: Busy".to_string(), "kicad_send_key Zoom to Selected Objects: window_not_found".to_string()]);+        assert!(zt, "a busy editor is not a missing verb");+    }+     #[test]     fn the_schematic_editor_is_the_tool_window_of_the_schematic_step() {         let info = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}, {"kind": "schematic_editor", "hwnd": 2}]}});-        assert_eq!(tool_window("schematic", "kicad", &info, None), Some((2, "KiCad's schematic editor".into())));-        assert_eq!(tool_window("schematic", "kicad", &json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}]}}), None), None);-        assert_eq!(tool_window("design", "kicad", &info, None), None);-        assert_eq!(tool_window("library", "fusion", &Value::Null, Some(9)), Some((9, "the fusion window".into())));+        assert_eq!(tool_window("schematic", "kicad", &info, None, ""), Some((2, "KiCad's schematic editor".into())));+        assert_eq!(tool_window("schematic", "kicad", &json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}]}}), None, ""), None);+        assert_eq!(tool_window("design", "kicad", &info, None, ""), None);+        assert_eq!(tool_window("library", "fusion", &Value::Null, Some(9), ""), Some((9, "the fusion window".into())));+    }++    /// Issue #155: another project's schematic editor is never this run's camera.+    #[test]+    fn another_projects_schematic_editor_is_never_filmed() {+        let info = json!({"data": {"editors": [+            {"kind": "schematic_editor", "hwnd": 397712, "title": "sense [other-esc/Sensing] — Schematic Editor"},+            {"kind": "pcb_editor", "hwnd": 461626, "title": "other-esc — PCB Editor"}]}});+        assert_eq!(tool_window("schematic", "kicad", &info, None, "esc-2s6s"), None);+        let mut both = info.clone();+        both["data"]["editors"].as_array_mut().unwrap().push(json!({"kind": "schematic_editor", "hwnd": 77, "title": "esc-2s6s [esc-2s6s/] — Schematic Editor"}));+        assert_eq!(tool_window("schematic", "kicad", &both, None, "esc-2s6s"), Some((77, "KiCad's schematic editor".into())));+        // a library editor names its library, not the project: the library step films it as before+        let lib = json!({"data": {"editors": [{"kind": "symbol_editor", "hwnd": 5, "title": "Adom:DRV8300DRGER — Symbol Editor"}]}});+        assert_eq!(tool_window("library", "kicad", &lib, None, "esc-2s6s"), Some((5, "KiCad's symbol editor".into())));     }      #[test]
crates/adom-aiflow/src/export.rs+75−2
@@ -328,6 +328,46 @@ fn kicad(cli: &str, args: &[String]) -> Result<(), String> {     Err(format!("{cli} {} failed: {} {}", args[..3.min(args.len())].join(" "), tail(&o.stdout), tail(&o.stderr))) } +/// A DRC finding names a zone: KiCad describes zone items as "Zone [NET] on F.Cu" (or "Filled zone").+fn names_zone(v: &Value) -> bool {+    v["items"].as_array().into_iter().flatten().any(|i| {+        let d = i["description"].as_str().or(i.as_str()).unwrap_or("").to_ascii_lowercase();+        d.starts_with("zone") || d.contains(" zone ") || d.contains("filled zone") || d.starts_with("copper zone")+    })+}++/// Read a `kicad-cli pcb drc` JSON report of the saved file WITHOUT `--refill-zones`: its fills as+/// saved, which are what the Gerbers and the STEP plot. Blocking findings that name a zone mean the+/// saved fills overlap copper added after the last fill (issue #160): DRC with refill passes, the+/// plotted copper shorts. Returns the `fillsCurrent` record.+pub fn stale_fills(drc: &Value) -> Value {+    let viol: Vec<&Value> = drc["violations"].as_array().map(|a| a.iter().collect()).unwrap_or_default();+    let blocking: Vec<&Value> = viol.into_iter().filter(|v| aiflow_copper::blocking_violation(v)).collect();+    let zone: Vec<&&Value> = blocking.iter().filter(|v| names_zone(v)).collect();+    let mut types: std::collections::BTreeMap<String, usize> = Default::default();+    for v in &zone { *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_default() += 1; }+    json!({"pass": zone.is_empty(), "checked": "kicad-cli pcb drc on the saved file without --refill-zones",+        "zoneViolations": zone.len(), "zoneViolationTypes": types, "otherErrors": blocking.len() - zone.len(),+        "list": zone.iter().take(12).map(|v| aiflow_copper::finding_line(v)).collect::<Vec<_>>()})+}++/// The hint for stale saved fills: refill, save, export again.+pub const STALE_FILLS_HINT: &str = "Refill the zones and save the board: in KiCad Edit > Fill All Zones (or `kicad_refill_zones` through the bridge) then save; in a pcbnew script `pcbnew.ZONE_FILLER(board).Fill(board.Zones())` before `pcbnew.SaveBoard(...)`. Then run `export` again: the Gerbers and STEP plot the saved fills as they are, and DRC with --refill-zones (the gate's) cannot see this.";++fn saved_drc(cli: &str, board_path: &str, out: &Path) -> Result<Value, String> {+    let report = out.join("drc-saved-fills.json");+    let _ = std::fs::remove_file(&report);+    let o = std::process::Command::new(cli).args(["pcb", "drc", "--format", "json", "--output"]).arg(&report).arg(board_path)+        .stdin(std::process::Stdio::null()).output().map_err(|e| format!("{cli} is not runnable: {e}"))?;+    let text = std::fs::read_to_string(&report).map_err(|_| {+        let tail = |b: &[u8]| aiflow_run::clip_text(&String::from_utf8_lossy(b), 300);+        format!("{cli} pcb drc wrote no report: {} {}", tail(&o.stdout), tail(&o.stderr))+    })?;+    let v: Value = serde_json::from_str(&text).map_err(|e| format!("DRC report: {e}"))?;+    if !v["violations"].is_array() { return Err("DRC report has no violations list".into()); }+    Ok(v)+}+ /// 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>)> {     let fail = |m: String| (json!({"board": board_path}), vec![m]);@@ -364,6 +404,14 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     problems.extend(p_fill);     let (gerber_fill, p_gfill) = crate::fills::gerbers(&gerbers, &filled_layers, inv.copper_layers.len());     problems.extend(p_gfill);+    // issue #160: fills that exist are not fills that are current. DRC the exact file without refilling;+    // stale fills are a finding (the set is still written and checked), never a refusal+    let fills_current = if filled_layers.is_empty() { json!({"pass": true, "checked": "no filled zones"}) } else {+        match saved_drc(cli, board_path, out) {+            Ok(drc) => stale_fills(&drc),+            Err(e) => { notes.push(format!("could not check that the saved zone fills are current (DRC of the saved file without refill did not run: {e}); run `kicad-cli pcb drc` on it without --refill-zones before ordering")); json!({"pass": null, "checked": "not run", "why": e}) }+        }+    };     let (d, p2, n2) = check_drill(&drills, &inv);     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())]) };@@ -378,7 +426,7 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     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, "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": "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)",         "files": files, "notes": notes, "problems": problems,     });     if problems.is_empty() { Ok(report) } else { Err((report, problems)) }@@ -487,6 +535,23 @@ mod tests {         assert!(p.contains("marks MP1 fitted"), "{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": [+            {"type": "clearance", "severity": "error", "description": "Clearance violation", "items": [{"description": "Zone [GND] on F.Cu", "pos": {"x": 1, "y": 1}}, {"description": "Track [VBUS] on F.Cu, length 3.0 mm", "pos": {"x": 1, "y": 1}}]},+            {"type": "hole_clearance", "severity": "error", "description": "Hole clearance", "items": [{"description": "Zone [GND] on B.Cu"}, {"description": "Via [VBUS] on F.Cu - B.Cu"}]},+            {"type": "silk_overlap", "severity": "warning", "description": "silk", "items": [{"description": "Reference field of R1"}]},+            {"type": "track_dangling", "severity": "warning", "description": "dangling", "items": [{"description": "Track [X] on F.Cu"}]}]});+        let r = stale_fills(&drc);+        assert_eq!(r["pass"], false); assert_eq!(r["zoneViolations"], 2);+        assert_eq!(r["zoneViolationTypes"]["clearance"], 1);+        assert!(r["list"][0].as_str().unwrap().contains("Zone [GND]"), "{r}");+        // current fills: nothing names a zone+        let r = stale_fills(&json!({"violations": [{"type": "clearance", "severity": "error", "items": [{"description": "Track [A] on F.Cu"}, {"description": "Pad 1 [B] of R1 on F.Cu"}]}]}));+        assert_eq!(r["pass"], true); assert_eq!(r["otherErrors"], 1);+    }+     #[test]     fn csv_reader_handles_quotes() {         let rows = read_csv("a,\"b,c\",\"d \"\"e\"\"\"\r\n\n1,2,3");@@ -506,7 +571,7 @@ mod tests {         std::fs::write(&board, BOARD).unwrap();         let fake = |skip: &str| {             let script = format!(r#"#!/bin/sh-out=""; kind="$3"; prev=""+out=""; kind="$3"; prev=""; [ "$2" = "drc" ] && kind=drc for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; done g() {{ printf '%%TF.FileFunction,%s*%%\nX0Y0D02*\nX1Y1D01*\nM02*\n' "$2" > "$out/$1"; }} case "$kind" in@@ -514,6 +579,7 @@ gerbers) for f in "F_Cu.gbr:Copper,L1,Top" "In1_Cu.gbr:Copper,L2,Inr" "In2_Cu.gb   n="${{f%%:*}}"; [ "$n" = "{skip}" ] || g "demo-$n" "${{f#*:}}"; done;; drill) printf 'M48\nT1C0.3\n%%\nT1\nX3Y-3\nX4Y-3\nX10Y-10\nX1Y-1\nX12Y-10G85X12Y-10.4\nM30\n' > "$out/demo-PTH.drl"; printf 'M48\nT1C3.2\n%%\nT1\nX18Y-18\nM30\n' > "$out/demo-NPTH.drl";; pos) printf 'Ref,Val,Package,PosX,PosY,Rot,Side\nJ1,HDR,H,10,-10,0,top\nR1,10k,R,5,-5,90,top\nR2,10k,R,8,-5,180,bottom\n' > "$out";;+drc) [ -f "$(dirname "$0")/drc.json" ] && cp "$(dirname "$0")/drc.json" "$out";; esac "#);             let p = dir.join("kicad-cli");@@ -541,6 +607,13 @@ esac         let (rep, problems) = export(zoned.to_str().unwrap(), &dir.join("mfg-z"), &fake_z("none")).unwrap_err();         assert_eq!(rep["fillsSaved"]["pass"], true, "{rep}");         assert!(problems.iter().any(|p| p.contains("B.Cu") && p.contains("G36")), "{problems:?}");+        // issue #160: no DRC report (a kicad-cli that cannot run it) is a note, and stale fills are recorded, never a problem+        assert!(rep["fillsCurrent"]["pass"].is_null(), "{rep}");+        std::fs::write(dir.join("drc.json"), r#"{"violations":[{"type":"clearance","severity":"error","items":[{"description":"Zone [GND] on B.Cu"},{"description":"Via [GND] on F.Cu - B.Cu"}]}]}"#).unwrap();+        let (rep, problems) = export(zoned.to_str().unwrap(), &dir.join("mfg-z"), &fake_z("none")).unwrap_err();+        assert_eq!(rep["fillsCurrent"]["pass"], false, "{rep}");+        assert!(!problems.iter().any(|p| p.contains("stale")), "{problems:?}");+        let _ = std::fs::remove_file(dir.join("drc.json"));         let cli = fake("In2_Cu.gbr");         let (_, problems) = export(board.to_str().unwrap(), &dir.join("mfg"), &cli).unwrap_err();         assert!(problems.iter().any(|p| p.contains("L3")), "a stale In2 file from the last export must not count: {problems:?}");
crates/adom-aiflow/src/fab_layer.rsadded+302
@@ -0,0 +1,302 @@+//! Issue #163: the Fab layer standard, its check and `silkscreen fab-tidy`.+//!+//! The standard: F.Fab (B.Fab) carries each part's reference only (`${REFERENCE}`), centred on the part+//! body, reading along the body's long axis and upright (0 or 90 degrees), one text size per board,+//! shrunk only where the body is too small to hold it (never below 0.3 mm), inside the courtyard.+//! Values are hidden on the Fab layers: they are in the BOM.+//!+//! The check reports findings (never a stop): Fab texts outside their own part's courtyard, values+//! shown on Fab, references off their body, parts with several Fab references or none, and reference+//! sizes that are not the board size (or its shrink to fit). `fab-tidy` applies the standard to the+//! board text: only Fab text nodes change; copper, zones and fills are untouched (refill-safe).++use aiflow_board::{geom, silk, sx::Node, Board, Pt};+use serde_json::{json, Value};++/// The board's Fab reference size when none is given (KiCad's own library convention).+pub const DEFAULT_SIZE: f64 = 1.0;+/// The smallest Fab reference a shrink may reach (mm).+pub const FLOOR: f64 = 0.3;+const THICK: f64 = 0.15;+const TOL: f64 = 0.05;++fn is_fab(layer: &str) -> bool { layer == "F.Fab" || layer == "B.Fab" }++fn layer_of(n: &Node) -> &str { n.find("layer").and_then(Node::value).unwrap_or("") }++fn hidden(n: &Node) -> bool {+    let h = |m: &Node| m.find("hide").map(|x| x.value() != Some("no")).unwrap_or(false) || m.items.iter().any(|it| matches!(it, aiflow_board::sx::Item::Atom { text, .. } if text == "hide"));+    h(n) || n.find("effects").map(h).unwrap_or(false)+}++fn font_h(n: &Node) -> f64 {+    n.find("effects").and_then(|e| e.find("font")).and_then(|f| f.find("size")).and_then(|s| s.number(1)).unwrap_or(1.0)+}++/// One text on a Fab layer of a footprint.+struct FabText<'a> { node: &'a Node, raw: String, shown: String, is_ref: bool, is_value: bool, layer: String }++struct Part<'a> {+    reference: String,+    node: &'a Node,+    x: f64, y: f64, rot: f64,+    back: bool,+    /// the body box in the footprint's own frame (F.Fab, else courtyard)+    body: Option<(f64, f64, f64, f64)>,+    courtyard: Option<(f64, f64, f64, f64)>,+    texts: Vec<FabText<'a>>,+}++fn parts(board: &Board) -> Vec<Part<'_>> {+    let mut out = Vec::new();+    for fp in board.root.find_all("footprint") {+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        if fp.find_all("pad").count() == 0 || crate::mechanical::is_net_tie(fp) { continue; }+        let at = fp.find("at");+        let back = fp.find("layer").and_then(Node::value) == Some("B.Cu");+        let value = fp.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();+        let (fab, crt) = if back { ("B.Fab", "B.CrtYd") } else { ("F.Fab", "F.CrtYd") };+        let mut texts = Vec::new();+        for t in fp.nodes().filter(|c| (c.tag == "property" || c.tag == "fp_text") && is_fab(layer_of(c))) {+            let raw = t.atom(2).unwrap_or("").to_string();+            let shown = raw.replace("${REFERENCE}", &reference).replace("${VALUE}", &value);+            let kind = t.atom(1).unwrap_or("").to_ascii_lowercase();+            let is_ref = kind == "reference" || raw == "${REFERENCE}";+            let is_value = !is_ref && (kind == "value" || raw == "${VALUE}");+            if t.tag == "property" && !is_ref && !is_value { continue; } // other properties (Footprint, Datasheet) are not drawn+            texts.push(FabText { node: t, raw, shown, is_ref, is_value, layer: layer_of(t).to_string() });+        }+        out.push(Part {+            reference,+            node: fp,+            x: at.and_then(|a| a.number(1)).unwrap_or(0.0), y: at.and_then(|a| a.number(2)).unwrap_or(0.0), rot: at.and_then(|a| a.number(3)).unwrap_or(0.0),+            back,+            body: crate::mechanical::local_box(fp, &[fab, crt]),+            courtyard: crate::mechanical::local_box(fp, &[crt]),+            texts,+        });+    }+    out+}++/// The reference height that fits a body box (long side, short side) at most `size`, never below FLOOR.+fn fit(text: &str, size: f64, body: Option<(f64, f64, f64, f64)>) -> f64 {+    let Some(b) = body else { return size };+    let (long, short) = { let (w, h) = (b.2 - b.0, b.3 - b.1); (w.max(h), w.min(h)) };+    let (w1, h1) = silk::text_extent(text, 1.0, 1.0, THICK, 0.0); // linear in the height+    let h = size.min(long / w1).min(short / h1);+    ((h * 100.0).floor() / 100.0).max(FLOOR).min(size)+}++fn centre(b: (f64, f64, f64, f64)) -> Pt { ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0) }++/// The text's absolute angle: along the body's long axis, upright (0 or 90).+fn upright(rot: f64, body: Option<(f64, f64, f64, f64)>) -> f64 {+    let local = match body { Some(b) if (b.3 - b.1) > (b.2 - b.0) + 1e-6 => 90.0, _ => 0.0 };+    let a = (rot + local).rem_euclid(180.0);+    if (a - 90.0).abs() <= 45.0 { 90.0 } else { 0.0 }+}++fn to_local(p: &Part, q: Pt) -> Pt { geom::rot_kicad(q.0 - p.x, q.1 - p.y, -p.rot) }++fn quad_of(p: &Part, t: &FabText) -> Vec<Pt> {+    let ta = t.node.find("at");+    let (dx, dy) = geom::rot_kicad(ta.and_then(|a| a.number(1)).unwrap_or(0.0), ta.and_then(|a| a.number(2)).unwrap_or(0.0), p.rot);+    silk::text_quad(t.node, &t.shown, (p.x + dx, p.y + dy), ta.and_then(|a| a.number(3)).unwrap_or(0.0))+}++fn inside(q: Pt, b: (f64, f64, f64, f64)) -> bool { q.0 >= b.0 - TOL && q.0 <= b.2 + TOL && q.1 >= b.1 - TOL && q.1 <= b.3 + TOL }++/// The board's reference size: of the visible Fab reference heights, the one the most references agree+/// with (at that size, or shrunk from it to fit their body); the larger wins a tie.+fn board_size(ps: &[Part]) -> f64 {+    let refs: Vec<(&Part, &FabText)> = ps.iter().flat_map(|p| p.texts.iter().filter(|t| t.is_ref && !hidden(t.node)).map(move |t| (p, t))).collect();+    // never below the floor: a board whose references are all 0.26 mm gets 0.3+    let mut sizes: Vec<i64> = refs.iter().map(|(_, t)| ((font_h(t.node).max(FLOOR)) * 100.0).round() as i64).collect();+    sizes.sort(); sizes.dedup();+    sizes.into_iter().map(|s| {+        let size = s as f64 / 100.0;+        (refs.iter().filter(|(p, t)| (font_h(t.node) - fit(&t.shown, size, p.body)).abs() <= 0.02).count(), s)+    }).max().map(|(_, s)| s as f64 / 100.0).unwrap_or(DEFAULT_SIZE)+}++/// The Fab check: counts, findings (each naming the worst offenders) and per-part detail.+pub fn check(board: &Board) -> Value {+    let ps = parts(board);+    let size = board_size(&ps);+    let (mut outside, mut values, mut off_body, mut dup, mut none, mut off_size) = (vec![], vec![], vec![], vec![], vec![], vec![]);+    let mut sizes: std::collections::BTreeSet<i64> = Default::default();+    let mut shown = 0;+    for p in &ps {+        let vis: Vec<&FabText> = p.texts.iter().filter(|t| !hidden(t.node)).collect();+        shown += vis.len();+        let refs: Vec<&&FabText> = vis.iter().filter(|t| t.is_ref).collect();+        if refs.is_empty() { none.push(p.reference.clone()); }+        if refs.len() > 1 { dup.push(json!({"ref": p.reference, "references": refs.len()})); }+        for t in &vis {+            let q: Vec<Pt> = quad_of(p, t).into_iter().map(|c| to_local(p, c)).collect();+            let (cx, cy) = q.iter().fold((0.0, 0.0), |a, c| (a.0 + c.0 / q.len() as f64, a.1 + c.1 / q.len() as f64));+            let from_body = p.body.map(|b| { let c = centre(b); (cx - c.0).hypot(cy - c.1) }).unwrap_or((cx).hypot(cy));+            if let Some(cy_box) = p.courtyard.or(p.body) {+                if q.iter().any(|c| !inside(*c, cy_box)) {+                    outside.push(json!({"ref": p.reference, "text": t.shown, "layer": t.layer, "fromBodyCentreMm": (from_body * 100.0).round() / 100.0}));+                }+            }+            if t.is_value { values.push(json!({"ref": p.reference, "text": t.shown})); }+            if t.is_ref {+                let h = font_h(t.node);+                sizes.insert((h * 100.0).round() as i64);+                if from_body > 0.3 { off_body.push(json!({"ref": p.reference, "fromBodyCentreMm": (from_body * 100.0).round() / 100.0})); }+                let want = fit(&t.shown, size, p.body);+                if (h - want).abs() > 0.02 { off_size.push(json!({"ref": p.reference, "heightMm": h, "standardMm": want})); }+            }+        }+    }+    let worst = |v: &mut Vec<Value>| { v.sort_by(|a, b| b["fromBodyCentreMm"].as_f64().unwrap_or(0.0).total_cmp(&a["fromBodyCentreMm"].as_f64().unwrap_or(0.0))); };+    worst(&mut outside); worst(&mut off_body);+    let name = |v: &[Value], f: &dyn Fn(&Value) -> String| { let s: Vec<String> = v.iter().take(6).map(f).collect(); format!("{}{}", s.join(", "), if v.len() > 6 { format!(" and {} more", v.len() - 6) } else { String::new() }) };+    let mut findings = Vec::new();+    if !outside.is_empty() { findings.push(format!("{} Fab text(s) reach outside their part's courtyard: {}", outside.len(), name(&outside, &|o| format!("\"{}\" of {} ({} mm from its body)", o["text"].as_str().unwrap_or(""), o["ref"].as_str().unwrap_or(""), o["fromBodyCentreMm"])))); }+    if !values.is_empty() { findings.push(format!("{} value(s) shown on the Fab layer (the standard hides them; they are in the BOM): {}", values.len(), name(&values, &|o| format!("{} \"{}\"", o["ref"].as_str().unwrap_or(""), o["text"].as_str().unwrap_or(""))))); }+    if !off_body.is_empty() { findings.push(format!("{} Fab reference(s) not centred on their body (more than 0.3 mm off): {}", off_body.len(), name(&off_body, &|o| format!("{} ({} mm)", o["ref"].as_str().unwrap_or(""), o["fromBodyCentreMm"])))); }+    if !off_size.is_empty() { findings.push(format!("Fab references come in {} size(s) ({} mm); {} differ from the board size {size} mm (or its shrink to fit the body, floor {FLOOR} mm): {}", sizes.len(), sizes.iter().map(|h| format!("{}", *h as f64 / 100.0)).collect::<Vec<_>>().join(", "), off_size.len(), name(&off_size, &|o| format!("{} {} mm", o["ref"].as_str().unwrap_or(""), o["heightMm"])))); }+    if !dup.is_empty() { findings.push(format!("{} part(s) show their reference on Fab more than once: {}", dup.len(), name(&dup, &|o| o["ref"].as_str().unwrap_or("").to_string()))); }+    if !none.is_empty() { findings.push(format!("{} part(s) have no reference on the Fab layer: {}", none.len(), none.iter().take(8).cloned().collect::<Vec<_>>().join(", "))); }+    json!({"parts": ps.len(), "fabTexts": shown, "boardSizeMm": size, "referenceSizesMm": sizes.iter().map(|h| *h as f64 / 100.0).collect::<Vec<_>>(),+        "outsideCourtyard": outside, "valuesShown": values, "offBody": off_body, "offSize": off_size, "duplicateReferences": dup, "noReference": none,+        "findings": findings, "clean": findings.is_empty(),+        "standard": "F.Fab carries the reference only, centred on the body, along its long axis and upright (0 or 90), one size per board shrunk only to fit the body (floor 0.3 mm), inside the courtyard; values hidden"})+}++/// The hint every Fab finding carries.+pub const TIDY_HINT: &str = "`adom-aiflow silkscreen fab-tidy --board <board>` applies the Fab standard (references centred on their bodies at one size, values hidden) to a copy of the board, then `adopt-board` it; Fab is not plotted for the fab, so this is tidiness, not a stop.";++fn effects(h: f64, back: bool) -> String {+    let t = ((h * THICK) * 1000.0).round() / 1000.0;+    format!("(effects (font (size {h} {h}) (thickness {t})){})", if back { " (justify mirror)" } else { "" })+}++fn uuid(seed: &str) -> String {+    use sha2::{Digest, Sha256};+    let d = format!("{:x}", Sha256::digest(seed.as_bytes()));+    format!("{}-{}-4{}-8{}-{}", &d[0..8], &d[8..12], &d[13..16], &d[17..20], &d[20..32])+}++/// `fab-tidy`: the board text with the Fab standard applied, and what changed (one entry per part).+/// `size` is the board's reference size (default: the most common one on the board).+pub fn tidy(board: &Board, size: Option<f64>) -> (String, Vec<Value>) {+    let ps = parts(board);+    let size = size.unwrap_or_else(|| board_size(&ps));+    let hide_new = board.text.contains("(hide yes)") || board.root.find("version").and_then(|v| v.number(1)).is_none_or(|v| v >= 20240108.0);+    let hide = if hide_new { " (hide yes)" } else { " hide" };+    let mut edits: Vec<(usize, usize, String)> = Vec::new();+    let mut changes = Vec::new();+    for p in &ps {+        let mut did: Vec<String> = Vec::new();+        let vis: Vec<&FabText> = p.texts.iter().filter(|t| !hidden(t.node)).collect();+        // the reference to keep: a visible ${REFERENCE} text first, else the Reference property on Fab+        let keep = vis.iter().find(|t| t.is_ref && t.raw == "${REFERENCE}").or_else(|| vis.iter().find(|t| t.is_ref)).map(|t| t.node.start);+        for t in &vis {+            if t.is_value || (t.is_ref && Some(t.node.start) != keep) {+                let l = t.node.find("layer").unwrap();+                edits.push((l.end, l.end, hide.to_string()));+                did.push(if t.is_value { format!("hid value \"{}\"", t.shown) } else { "hid a second reference".into() });+            }+        }+        let fab = if p.back { "B.Fab" } else { "F.Fab" };+        let (cx, cy) = p.body.map(centre).unwrap_or((0.0, 0.0));+        let angle = upright(p.rot, p.body);+        let h = fit(&p.reference, size, p.body);+        let at = format!("(at {} {} {})", (cx * 1e4).round() / 1e4, (cy * 1e4).round() / 1e4, angle);+        match keep.and_then(|s| vis.iter().find(|t| t.node.start == s)) {+            Some(t) => {+                let old_at = t.node.find("at");+                let old_h = font_h(t.node);+                if let Some(a) = old_at { edits.push((a.start, a.end, at.clone())); }+                match t.node.find("effects") {+                    Some(e) => edits.push((e.start, e.end, effects(h, t.layer.starts_with("B.")))),+                    None => edits.push((t.node.end - 1, t.node.end - 1, format!(" {}", effects(h, t.layer.starts_with("B."))))),+                }+                let moved = old_at.map(|a| ((a.number(1).unwrap_or(0.0) - cx).hypot(a.number(2).unwrap_or(0.0) - cy) > 0.01) || (a.number(3).unwrap_or(0.0) - angle).abs() > 0.01).unwrap_or(true);+                if moved { did.push("centred the reference on the body".into()); }+                if (old_h - h).abs() > 0.005 { did.push(format!("reference {old_h} -> {h} mm")); }+            }+            None => {+                let text = format!("\n\t\t(fp_text user \"${{REFERENCE}}\" {at} (layer \"{fab}\") (uuid \"{}\") {})", uuid(&format!("{}{}", p.reference, p.node.start)), effects(h, p.back));+                edits.push((p.node.end - 1, p.node.end - 1, text));+                did.push("added the reference on Fab".into());+            }+        }+        if !did.is_empty() { changes.push(json!({"ref": p.reference, "changes": did})); }+    }+    edits.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));+    let mut text = board.text.clone();+    for (s, e, r) in edits { text.replace_range(s..e, &r); }+    (text, changes)+}++#[cfg(test)]+mod tests {+    use super::*;+    /// A 1210 cap with its value hanging 6 mm off the body and a 0.26 mm reference; a 0402 resistor with+    /// a 0.6 mm reference at the KiCad library place; a back-side part.+    fn sprawl() -> Board {+        let text = r#"(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (gr_rect (start 0 0) (end 40 40) (layer "Edge.Cuts"))+ (footprint "C:C_1210" (layer "F.Cu") (at 10 10 90)+  (property "Reference" "C101" (at 0 -2.3 90) (layer "F.SilkS") (uuid "a1") (effects (font (size 1 1) (thickness 0.15))))+  (property "Value" "470uF 35V" (at 0 6.2 90) (layer "F.Fab") (uuid "a2") (effects (font (size 1 1) (thickness 0.15))))+  (fp_text user "${REFERENCE}" (at 0 0 90) (layer "F.Fab") (uuid "a3") (effects (font (size 0.26 0.26) (thickness 0.04))))+  (fp_rect (start -1.6 -1.25) (end 1.6 1.25) (stroke (width 0.1) (type solid)) (layer "F.Fab"))+  (fp_rect (start -2.3 -1.6) (end 2.3 1.6) (stroke (width 0.05) (type solid)) (layer "F.CrtYd"))+  (pad "1" smd roundrect (at -1.5 0 90) (size 1.2 2.7) (layers "F.Cu" "F.Mask")))+ (footprint "R:R_0402" (layer "F.Cu") (at 20 10 0)+  (property "Reference" "R5" (at 0 0 0) (layer "F.Fab") (uuid "b1") (effects (font (size 0.6 0.6) (thickness 0.09))))+  (property "Value" "10k" (at 0 1.2 0) (layer "F.Fab") (hide yes) (uuid "b2") (effects (font (size 1 1) (thickness 0.15))))+  (fp_rect (start -0.5 -0.25) (end 0.5 0.25) (stroke (width 0.1) (type solid)) (layer "F.Fab"))+  (fp_rect (start -0.93 -0.47) (end 0.93 0.47) (stroke (width 0.05) (type solid)) (layer "F.CrtYd"))+  (pad "1" smd roundrect (at -0.48 0) (size 0.56 0.62) (layers "F.Cu" "F.Mask")))+ (footprint "R:R_0805" (layer "B.Cu") (at 30 10 0)+  (property "Reference" "R6" (at 0 0 0) (layer "B.SilkS") (uuid "c1") (effects (font (size 1 1) (thickness 0.15)) (justify mirror)))+  (fp_text user "${REFERENCE}" (at 0 0 0) (layer "B.Fab") (uuid "c3") (effects (font (size 0.5 0.5) (thickness 0.08)) (justify mirror)))+  (fp_text user "${REFERENCE}" (at 0 0.5 0) (layer "B.Fab") (uuid "c4") (effects (font (size 0.5 0.5) (thickness 0.08)) (justify mirror)))+  (fp_rect (start -1 -0.625) (end 1 0.625) (stroke (width 0.1) (type solid)) (layer "B.Fab"))+  (fp_rect (start -1.68 -0.95) (end 1.68 0.95) (stroke (width 0.05) (type solid)) (layer "B.CrtYd"))+  (pad "1" smd roundrect (at -0.9 0) (size 1 1.45) (layers "B.Cu" "B.Mask"))))"#;+        Board::from_text("sprawl", text.into()).unwrap()+    }+    #[test]+    fn the_check_names_sprawling_values_sizes_and_duplicates() {+        let c = check(&sprawl());+        assert_eq!(c["clean"], false);+        let f = c["findings"].to_string();+        assert!(f.contains("470uF 35V") && f.contains("outside their part's courtyard"), "{f}");+        assert!(f.contains("value(s) shown on the Fab layer"), "{f}");+        assert!(f.contains("R6") && f.contains("more than once"), "{f}");+        assert_eq!(c["valuesShown"].as_array().unwrap().len(), 1, "the hidden 10k is not counted");+        assert!(c["referenceSizesMm"].as_array().unwrap().len() >= 3);+    }+    #[test]+    fn tidy_applies_the_standard_and_the_check_is_then_clean() {+        let b = sprawl();+        let (text, changes) = tidy(&b, Some(1.0));+        assert!(!changes.is_empty());+        let t = Board::from_text("tidy", text.clone()).unwrap();+        let c = check(&t);+        assert_eq!(c["clean"], true, "{}", c["findings"]);+        // only Fab texts changed: copper, the silk labels and the footprint count are as they were+        assert_eq!(t.footprints.len(), 3);+        assert_eq!(t.pads.len(), b.pads.len());+        assert!(text.contains("(property \"Reference\" \"C101\" (at 0 -2.3 90) (layer \"F.SilkS\")"));+        // the 1210 reads along its long axis: footprint at 90, body long along x, so 90; the 0402 shrinks to fit+        assert!(text.contains("(fp_text user \"${REFERENCE}\" (at 0 0 90) (layer \"F.Fab\") (uuid \"a3\") (effects (font (size 1 1)"), "{text}");+        let r5 = t.root.find_all("footprint").find(|f| aiflow_board::reference_of(f).as_deref() == Some("R5")).unwrap();+        let h = r5.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Reference")).map(font_h).unwrap();+        assert!(h < 0.6 && h >= FLOOR, "{h}");+        // the back part keeps mirrored text; idempotent second pass+        assert!(text.contains("(layer \"B.Fab\") (uuid \"c3\") (effects (font (size 1 1) (thickness 0.15)) (justify mirror))") || text.contains("(justify mirror)"));+        let (again, more) = tidy(&t, Some(1.0));+        assert!(more.is_empty(), "{more:?}");+        assert_eq!(again, text);+    }+}
crates/adom-aiflow/src/handson.rs+1−1
@@ -278,7 +278,7 @@ pub fn human_proof(step: &str, flow: &Value) -> String {     match step {         "placement" => "save it, `adopt-board --board <their saved board> --evidence <a note: placed by the human>`, then `place verify`: their placement becomes the placement proof".into(),         "routing" => "`adopt-board` their routed board, then `route` (it plans only what their copper leaves open; nothing when it is complete), `gate --plan <run>/plan-route.json` and `land route --plan <run>/plan-route.json`: an empty plan lands nothing and records the routing proof. Finish any open connections only if they ask you to".into(),-        "pours" => "`adopt-board`, then `verify-pours --adopt --why \"poured by the human\"`".into(),+        "pours" => "`adopt-board --board <their saved board> --evidence <a note: poured by the human>`, then `verify-pours --from-board --adopt --why \"poured by the human\"` (it writes the plan from their saved zones, records them as the human's, and adopts them)".into(),         "schematic" => "`schematic check --sch <their schematic>`".into(),         "silkscreen" => "`silkscreen-audit --input <audit of their silkscreen> --out <result> --record`".into(),         "simulation" => "`evidence add --kind simulation --file <their result>`".into(),
crates/adom-aiflow/src/main.rs+394−58
@@ -4,6 +4,8 @@ mod continuation; mod copper_carry; mod artifact_store; mod pourverify;+mod silk_audit_input;+mod fab_layer; mod dashboard; mod timeline_server; mod silkscreen_sections;@@ -52,6 +54,7 @@ mod demo; mod boardkind; mod pagedir; mod zone_ownership;+mod pour_adopt; mod gate_eval; mod route_quality; mod route_variants;@@ -63,6 +66,7 @@ mod prefs; mod handson; mod screen; mod attention;+mod page_results; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -106,9 +110,15 @@ enum Cmd {     SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },     SilkscreenDashboard { action: String, #[arg(long, default_value="auto")] port: String, #[arg(long, default_value="wv")] surface: String, #[arg(long)] url: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] json: Option<String>, #[arg(long)] reason: Option<String> },     /// Whole-board silkscreen obstacle audit (fail-closed). With --record, a pass whose inventory covers the board (every footprint reference, a via/hole item per via) and whose labels meet finish's per-footprint minimum records the `silkscreen` step proof; a failed audit records nothing.-    SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, /// record the silkscreen step proof when the audit passes and covers the board+    SilkscreenAudit { /// a hand-built inventory; without it the input is built from --board (or the run's current board) and written beside --out+    #[arg(long)] input: Option<PathBuf>, #[arg(long)] out: PathBuf, /// record the silkscreen step proof when the audit passes and covers the board     #[arg(long)] record: bool, /// the board the inventory covers (default: the run's current board)     #[arg(long)] board: Option<String> },+    /// `silkscreen fab-check|fab-tidy [--board B] [--out O] [--size MM]`: the Fab layer standard (issue #163). fab-check reports Fab texts off their parts, values shown and mixed reference sizes; fab-tidy writes a copy of the board with each reference centred on its body, upright along its long axis, one size (shrunk only to fit, floor 0.3 mm), values hidden. Only Fab texts change.+    Silkscreen { #[arg(value_parser = ["fab-check", "fab-tidy"])] action: String, /// default: the run's current board+    #[arg(long)] board: Option<String>, /// fab-tidy: where to write the tidied board (default: <board>-fab.kicad_pcb beside it)+    #[arg(long)] out: Option<String>, /// the board's Fab reference size in mm (default: the most common one on the board)+    #[arg(long)] size: Option<f64> },     SilkscreenSections { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },@@ -191,10 +201,14 @@ enum Cmd {     Parts { #[arg(long)] bom: Option<String>, #[arg(long)] show: bool, #[arg(long, default_value_t = 4)] dwell: u64, /// a project library folder for parts with no public component page: KiCad .kicad_sym, .pretty footprints, STEP models and a provenance.json naming each MPN's source (default: the spec's or requirements' "projectLib"). Each listed MPN needs a symbol, a footprint, a model and provenance; project-page packages them, finish reports them.     #[arg(long)] project_lib: Option<String> },     /// `project-page --page <owner/slug> [--create]`: revision 1 of the board's project page before any EDA work: a README that says what the board is with the component table, and a KiCad PCM repository of every part (an Adom Cloud Library, exported and repackaged for KiCad's Plugin and Content Manager). Opens the page in Hydrogen.-    ProjectPage { #[arg(long)] page: String, /// push every visited step's sub-readme and the README's "How this board was built" section now (each step's is also pushed in the background when the run leaves it)+    ProjectPage { #[arg(long, default_value = "")] page: String, /// write the design review (design-review/README.md: Fields current and thermal numbers, your analysis figures, a molecule's contact table, the parts table with prices) for the current board into the page's staging folder, or into --out (a molecule-publish package folder); prints the push (issues #147, #164)+    #[arg(long)] results: bool, /// with --results: the folder to write design-review/ into (a molecule-publish package folder) instead of <run>/page-results+    #[arg(long)] out: Option<String>, /// push every visited step's sub-readme and the README's "How this board was built" section now (each step's is also pushed in the background when the run leaves it)     #[arg(long)] sync: bool, #[arg(long)] create: bool, /// the page already exists and is this same board's own page (an earlier run of it); refused otherwise     #[arg(long)] existing: bool, #[arg(long)] private: bool, #[arg(long)] title: Option<String>, /// what the board is: text, or a path to a file (for example design/brief.md) whose contents are read-    #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, #[arg(long)] no_push: bool, /// a folder of an EXISTING page (relative, like rig/boards/rig-carrier): the board's README and its PCM repository go there, the repository URLs carry it; implies --existing and never creates a page+    #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, /// build locally and push nothing to the page (the wiki parts are still added to your private Cloud Library and exported from it)+    #[arg(long)] no_push: bool, /// no wiki call at all (implies --no-push): no Cloud Library create, add or export, so only the project-library parts are packaged and the wiki parts are named as left out (issue #157)+    #[arg(long)] local_only: bool, /// a folder of an EXISTING page (relative, like rig/boards/rig-carrier): the board's README and its PCM repository go there, the repository URLs carry it; implies --existing and never creates a page     #[arg(long)] page_dir: Option<String> },     /// `pcm check`: the project page's PCM package is installed in the test box's KiCad (kicad_pcm_list), so the schematic can use every part. Install it first with kicad_pcm_add_repository + kicad_pcm_install (the URLs project-page printed).     Pcm { #[arg(value_parser = ["check"])] what: String },@@ -248,7 +262,9 @@ enum Cmd {     /// Verify existing saved filled zones against an explicit reviewed plan, without landing or claiming ownership (--adopt --why takes ownership; --record-plan records a reviewed plan before the zones change)     VerifyPours {         /// the zone plan: the one `pour` wrote, or a reviewed plan recorded with --record-plan before the zones changed-        #[arg(long)] plan: String,+        #[arg(long)] plan: Option<String>,+        /// hands-on human pours: write the plan from the saved board's own zones (the human's), recorded as theirs, not as an independent design+        #[arg(long = "from-board")] from_board: bool,         /// take ownership of the matched zones (needs --why); recorded as adopted, not landed         #[arg(long)] adopt: bool,         /// reason for --adopt, or for accepting a plan whose provenance is not established; recorded with the proof@@ -314,7 +330,9 @@ enum Cmd {     /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.     Ledger { #[arg(long)] json: bool },     /// open, start, mark <label>, stop, reject <step>-<visit>, screen [hands-off|ask|allowed] (may AI Flow bring windows to the front on the run's desktop; no mode prints the current one)-    Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },+    Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String>,+    /// reject only: take the clip off the run page and out of the run (privacy: another project or a person was filmed), not kept as evidence+    #[arg(long)] off_page: bool },     /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/     Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },     /// Where the run stands@@ -402,6 +420,9 @@ fn ok(msg: &str, hints: &[String]) { /// section), set once in main and printed under every ERROR of the command. static INPUT_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); +/// Issue #159: when another session's KiCad owns the default IPC pipe (board_mismatch, openBoards not ours).+const KICAD_SOCKET_HINT: &str = "When the editor holding the default IPC pipe belongs to another session, never close it: launch a task-owned pcbnew with a private TEMP/TMP (docs/isolated-kicad-read-socket.md), open this board in it, and set ADOM_AIFLOW_KICAD_SOCKET=ipc://<its api.sock> so every live read and edit (place land, land route, land pours, tour nets) reaches it.";+ /// Every refusal says how to change AI Flow itself when the check, not the board, is what is wrong. const CONTRIBUTE: &str = "If this check is wrong for your board (AI Flow has not seen a design like it), improve AI Flow rather than stopping or working around it: `adom-wiki repo clone adom/adom-aiflow`, fix it, `cargo build --release`, finish this board with your build (that tests the change), then send it back with `adom-wiki pr create adom/adom-aiflow --title \"...\" --files <changed files> --body-file <why, base hash, how you tested>`. Every Adom user gets the fix."; @@ -603,7 +624,7 @@ fn wiki_component_page(mpn: &str) -> Result<Option<(String, Value)>, String> {         let (ok, body, errs) = command_once("adom-wiki", &["repo", "show", &page_ref, "page.json"], 30)?;         if ok {             let v: Value = serde_json::from_str(&body).map_err(|_| format!("{page_ref} page.json is not JSON"))?;-            if v["component"]["mpn"].as_str().map(|m| m.trim().eq_ignore_ascii_case(mpn.trim())).unwrap_or(false) { return Ok(Some((page_ref, v))); }+            if v["component"]["mpn"].as_str().map(|m| preboard::page_mpn_matches(m, mpn)).unwrap_or(false) { return Ok(Some((page_ref, v))); }             continue;         }         let said = format!("{body}\n{errs}");@@ -1445,29 +1466,73 @@ fn clip_verdict(file: &str, tools: &[&str]) -> Result<Option<String>, String> {     Ok(blank_check(file)) } +/// What `tour nets` says (PR #65, Drew): "framed" only for the nets a zoom call framed; the others name the calls+/// that failed, and a missing screenshot is said too.+fn tour_nets_report(toured: &[Value], editor: i64) -> (String, Vec<String>) {+    let item = |t: &Value| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"]);+    let framed: Vec<&Value> = toured.iter().filter(|t| t["framedBy"].is_string()).collect();+    let unframed: Vec<&Value> = toured.iter().filter(|t| !t["framedBy"].is_string()).collect();+    let no_shot: Vec<String> = toured.iter().filter(|t| t["screenshot"] != true).map(|t| t["net"].as_str().unwrap_or("").to_string()).collect();+    let mut by: Vec<String> = framed.iter().filter_map(|t| t["framedBy"].as_str().map(str::to_string)).collect();+    by.sort(); by.dedup();+    let mut hints = vec!["The clip of this step is the walkthrough; markers name each net at its moment.".to_string()];+    let mut line = if unframed.is_empty() {+        format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed by {}: {}", toured.len(), by.join(" / "), toured.iter().map(item).collect::<Vec<_>>().join(", "))+    } else {+        let failed = unframed.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["frameFailed"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("; ")).unwrap_or_default())).collect::<Vec<_>>().join(", ");+        hints.push(format!("Not framed means every zoom call to the editor (window {editor}) failed: check that it is this run's PCB editor (`kicad_window_info`), and update the KiCad Bridge to 1.0.80+ for kicad_zoom_to, which frames a background editor through the API. Then `tour nets` again if the clip matters."));+        format!("net walkthrough recorded: {} nets selected as a whole; framed {} of {}{}; NOT framed: {failed}", toured.len(), framed.len(), toured.len(), if framed.is_empty() { String::new() } else { format!(" by {} ({})", by.join(" / "), framed.iter().map(|t| item(t)).collect::<Vec<_>>().join(", ")) })+    };+    if by.iter().any(|b| b.contains("menu key")) { hints.push("Framed by the menu key: KiCad may not move the view of a background editor on a menu command (kicad-bridge #121). Look at the net screenshots; KiCad Bridge 1.0.80+ frames with kicad_zoom_to instead.".into()); }+    if !no_shot.is_empty() { line.push_str(&format!("; no screenshot for {} of {}: {}", no_shot.len(), toured.len(), no_shot.join(", "))); }+    (line, hints)+}++/// Issue #155: take a clip off the page and out of the run (it filmed another project): the clip, its cuts,+/// its contact sheet and its step screenshots are deleted, and the capture is marked `discarded` with the reason+/// (the page, the video and the suspect list skip a discarded capture). Returns the files deleted.+fn discard_clip_files(r: &mut Run, pos: usize, why: &str) -> Vec<String> {+    let c = r.data["captures"][pos].clone();+    let mut files: Vec<String> = Vec::new();+    if let Some(f) = c["file"].as_str() {+        let base = f.trim_end_matches(".mp4");+        files.extend([f.to_string(), format!("{base}-10x.mp4"), format!("{base}-action.mp4"), format!("{base}-sheet.png")]);+    }+    let (step, visit) = (c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));+    if !step.is_empty() {+        let prefix = format!("shot-{step}-{visit}-");+        for e in std::fs::read_dir(&r.dir).into_iter().flatten().flatten() {+            let n = e.file_name().to_string_lossy().to_string();+            if n.starts_with(&prefix) && n.ends_with(".png") { files.push(e.path().display().to_string()); }+        }+    }+    let gone: Vec<String> = files.into_iter().filter(|f| std::fs::remove_file(f).is_ok()).collect();+    r.data["captures"][pos]["discarded"] = json!(why);+    gone+}+ /// Verdicts that only the blank check gives; a later passing check (recut) clears them. An AI's own rejection stays. fn blank_check_verdict(why: &str) -> bool {     why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour") || why.starts_with("truncated") }  /// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a-/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey-/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than-/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.+/// painted canvas has content and an unpainted one is one flat colour) is read as 192 x 108 grey+/// pixels and judged by `frame_flat`. More than three quarters flat frames means the clip filmed+/// nothing worth keeping. Returns the reason. fn blank_check(file: &str) -> Option<String> {     let secs = media_seconds(file);     if secs <= 0.0 { return Some("no frames".into()); }     let n = 8usize;+    let (w, h) = (192usize, 108usize);     let mut flat = 0usize; let mut total = 0usize; let mut moved = 0usize; let mut prev: Option<Vec<u8>> = None;     for k in 0..n {         let t = secs * (k as f64 + 0.5) / n as f64;-        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;+        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", &format!("crop=iw*0.6:ih*0.6,scale={w}:{h}"), "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;         let px = out.stdout;-        if px.len() < 1000 { continue; }-        let mut bins = [0usize; 16];-        for b in &px { bins[(*b as usize) >> 4] += 1; }+        if px.len() < w * h { continue; }         total += 1;-        if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }+        if frame_flat(&px, w, h) { flat += 1; }         // a mostly dark canvas that changes between samples is being worked on (a Fusion board is black         // with the copper growing on it), not blank: count the samples that differ from the one before         if let Some(q) = &prev {@@ -1477,7 +1542,87 @@ fn blank_check(file: &str) -> Option<String> {         prev = Some(px);     }     if total == 0 { return Some("could not read frames".into()); }-    if flat * 4 > total * 3 && moved < 2 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }+    if flat * 4 > total * 3 && moved < 2 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (sampled: the centre 60 % of each frame, and no row or column of it had anything else on it: an unpainted or covered canvas)")) } else { None }+}++/// One sampled frame (grey, `w` x `h`) is flat when one 16-level bin holds over 80 % of its pixels+/// and fewer than two of its rows and fewer than two of its columns are a third or more something+/// else. Issue #156: a 500 x 12 mm strip at zoom-to-fit is a band a few rows high across a dark+/// canvas, over 80 % background in every frame, yet painted; its rows are copper and outline.+fn frame_flat(px: &[u8], w: usize, h: usize) -> bool {+    if w == 0 || h == 0 || px.len() < w * h { return false; }+    let mut bins = [0usize; 16];+    for b in &px[..w * h] { bins[(*b as usize) >> 4] += 1; }+    let (dom, most) = bins.iter().enumerate().max_by_key(|(_, c)| **c).map(|(i, c)| (i, *c)).unwrap_or((0, 0));+    if most * 100 <= w * h * 80 { return false; }+    let off = |x: usize, y: usize| (px[y * w + x] as usize >> 4) != dom;+    let rows = (0..h).filter(|&y| (0..w).filter(|&x| off(x, y)).count() * 3 >= w).count();+    let cols = (0..w).filter(|&x| (0..h).filter(|&y| off(x, y)).count() * 3 >= h).count();+    rows < 2 && cols < 2+}++#[cfg(test)]+mod blank_check_tests {+    use super::*;++    #[test]+    fn a_thin_board_band_is_painted_not_flat() {+        let (w, h) = (192, 108);+        let canvas = vec![20u8; w * h];+        assert!(frame_flat(&canvas, w, h), "one flat colour");+        // a 42:1 strip across the canvas: five rows of copper over 95 % background+        let mut strip = canvas.clone();+        for y in 52..57 { for x in 0..w { strip[y * w + x] = 140; } }+        assert!(!frame_flat(&strip, w, h));+        // a tall thin board, the same on its side+        let mut tall = canvas.clone();+        for y in 0..h { for x in 94..98 { tall[y * w + x] = 200; } }+        assert!(!frame_flat(&tall, w, h));+        // a cursor or a few specks are not a board+        let mut specks = canvas.clone();+        for i in 0..40 { specks[(i * 97) % (w * h)] = 255; }+        for x in 90..100 { specks[54 * w + x] = 255; }+        assert!(frame_flat(&specks, w, h));+        // a busy canvas is never flat+        let busy: Vec<u8> = (0..w * h).map(|i| (i % 256) as u8).collect();+        assert!(!frame_flat(&busy, w, h));+    }++    /// PR #65: `tour nets` says "framed" only when a zoom call framed the net.+    #[test]+    fn tour_nets_says_framed_only_when_a_zoom_call_framed() {+        let ok_net = json!({"net": "GND", "selected": 40, "framedBy": "kicad_zoom_to", "frameFailed": [], "screenshot": true});+        let (line, _) = tour_nets_report(&[ok_net.clone()], 5);+        assert!(line.contains("and framed by kicad_zoom_to: GND (40)"), "{line}");+        let bad = json!({"net": "VBUS", "selected": 9, "framedBy": null, "frameFailed": ["kicad_zoom_to: window_not_found", "kicad_send_key Zoom to Selected Objects: window_not_found"], "screenshot": false});+        let (line, hints) = tour_nets_report(&[ok_net, bad.clone()], 1111);+        assert!(!line.contains("and framed"), "{line}");+        assert!(line.contains("framed 1 of 2") && line.contains("NOT framed: VBUS (kicad_zoom_to: window_not_found; kicad_send_key Zoom to Selected Objects: window_not_found)") && line.contains("no screenshot for 1 of 2: VBUS"), "{line}");+        assert!(hints.iter().any(|h| h.contains("1.0.80")));+        let (line, _) = tour_nets_report(&[bad], 1111);+        assert!(line.contains("framed 0 of 1;"), "{line}");+        let menu = json!({"net": "GND", "selected": 3, "framedBy": "the Zoom to Selected Objects menu key", "screenshot": true});+        let (_, hints) = tour_nets_report(&[menu], 5);+        assert!(hints.iter().any(|h| h.contains("kicad-bridge #121")));+    }++    /// The whole check on real video: a dark canvas with a thin band stays unflagged, a flat one is flagged.+    #[test]+    fn a_long_thin_board_clip_is_not_blank() {+        if std::process::Command::new("ffmpeg").arg("-version").output().is_err() { return; }+        let dir = std::env::temp_dir().join(format!("aiflow-blank-{}", std::process::id()));+        let _ = std::fs::create_dir_all(&dir);+        let make = |name: &str, vf: &str| {+            let f = dir.join(name).display().to_string();+            let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "color=c=0x001023:s=1280x720:d=4:r=5", "-vf", vf, "-pix_fmt", "yuv420p", &f]).status().map(|s| s.success()).unwrap_or(false);+            ok.then_some(f)+        };+        let (Some(strip), Some(flat)) = (make("strip.mp4", "drawbox=x=20:y=350:w=1240:h=18:color=0xC83737:t=fill,drawbox=x=20:y=344:w=1240:h=2:color=0xD0D000:t=fill"), make("flat.mp4", "null")) else { return };+        assert_eq!(blank_check(&strip), None);+        let why = blank_check(&flat).expect("a flat canvas is blank");+        assert!(blank_check_verdict(&why) && why.contains("centre 60 %"), "{why}");+        let _ = std::fs::remove_dir_all(&dir);+    } }  /// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it@@ -2183,18 +2328,57 @@ fn latest_shot(dir: &Path, step: &str) -> Option<String> { /// The hint for export, finish and deliver until the run records the publish (`stage end /// molecule-publish --result <page>`); None for a plain board or once it is recorded. fn molecule_publish_hint(r: &Run) -> Option<String> {-    if spec_soft(r).get("molecule").is_none() || molecule_published(&r.data) { return None; }-    Some("NEXT (molecule): publish it with molecule-publish, a separate package (`adom-wiki pkg install adom/molecule-publish`; skill molecule-publish). Its bundle is the STEP, the board, every schematic sheet, `<slug>-fab.zip` and `export.json` (both made with molecule-publish's scripts/fab_bundle.py create from the same saved board as the Gerbers `adom-aiflow export` wrote); check names first with its scripts/pin_names.py. The page is kind molecule: never push a GLB or footprint/symbol JSON and never bind component.parts.model_3d there; the wiki's model registry builds the model, footprint and symbol, and the publish is proven by `adom-wiki model status` showing the model built by the pipeline with metadataComplete true. Then record it: `adom-aiflow stage end molecule-publish --result <owner/slug>` (skill aiflow-molecule, section 5).".into())+    if !molecule_pins::wants_molecule(&spec_soft(r), &requirements_of(r)) || molecule_published(&r.data) { return None; }+    Some("NEXT (molecule): publish it with molecule-publish, a separate package (`adom-wiki pkg install adom/molecule-publish`; skill molecule-publish). Its bundle is the STEP, the board, every schematic sheet, `<slug>-fab.zip` and `export.json` (both made with molecule-publish's scripts/fab_bundle.py create from the same saved board as the Gerbers `adom-aiflow export` wrote); check names first with its scripts/pin_names.py. Put the design review in the package too: `adom-aiflow project-page --results --out <package folder>` writes design-review/ (Fields numbers, analysis figures, contact table, parts table; issue #164) for it to carry and the README to link. The page is kind molecule: never push a GLB or footprint/symbol JSON and never bind component.parts.model_3d there; the wiki's model registry builds the model, footprint and symbol, and the publish is proven by `adom-wiki model status` showing the model built by the pipeline with metadataComplete true. Then record it: `adom-aiflow stage end molecule-publish --result <owner/slug>` (skill aiflow-molecule, section 5).".into()) }  /// Issue #137: what a molecule's project page is for (kind molecule) and what never goes on it.-const MOLECULE_PAGE_HINT: &str = "This page was created as kind molecule (the spec has \"molecule\"): molecule-publish puts the sources here after export (STEP, board, schematic, <slug>-fab.zip, export.json) and the wiki's model registry builds its GLB, footprint and symbol. Do not push a GLB or bind component.parts.model_3d on it; molecule-publish refuses them (REGISTRY_MODEL_FILE_REFUSED).";+const MOLECULE_PAGE_HINT: &str = "This page was created as kind molecule (the spec has \"molecule\", or the requirements' formFactor.kind is \"molecule\"): molecule-publish puts the sources here after export (STEP, board, schematic, <slug>-fab.zip, export.json) and the wiki's model registry builds its GLB, footprint and symbol. Do not push a GLB or bind component.parts.model_3d on it; molecule-publish refuses them (REGISTRY_MODEL_FILE_REFUSED).";  /// The run recorded the molecule publish (`stage end molecule-publish`). fn molecule_published(data: &Value) -> bool {     data["stages"]["molecule-publish"]["runs"].as_array().is_some_and(|a| a.iter().any(|x| !x["end"].is_null())) } +/// Issue #147: where the analysis results belong (the board page's design review), or "" when it is up to date.+fn results_hint(r: &Run) -> String {+    page_results::hint(&r.data, &sha256_file(&r.current_board()), molecule_pins::wants_molecule(&spec_soft(r), &requirements_of(r)))+}++/// `project-page --results` (issues #147, #164): write design-review/ for the current board and say how to push it.+fn page_results_cmd(r: &mut Run, dir: &Path, page: &str, out: Option<&str>, page_dir: Option<&str>) {+    let known = page_results::board_page(&r.data);+    let page = Some(page).filter(|p| !p.is_empty()).map(str::to_string).or_else(|| known.as_ref().map(|k| k.0.clone()));+    let page_dir = page_dir.map(str::to_string).or_else(|| known.as_ref().filter(|k| Some(&k.0) == page.as_ref()).and_then(|k| k.1.clone())).filter(|d| !d.is_empty() && d != ".");+    let molecule = molecule_pins::wants_molecule(&spec_soft(r), &requirements_of(r));+    let board_path = r.current_board();+    let board = Board::load(&board_path).ok();+    let sha = sha256_file(&board_path);+    let figures: Vec<(String, String, String)> = standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| {+        let f = e["file"].as_str()?;+        let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } };+        Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string()))+    }).collect();+    let bom = run_bom(r).and_then(|p| std::fs::read_to_string(p).ok());+    // with --out the package folder is the page root; otherwise the page's staging folder in the run+    let (root, in_package) = match out { Some(o) => (PathBuf::from(o), true), None => (dir.join("page-results"), false) };+    let pd = if in_package { None } else { page_dir.as_deref() };+    // the run report's folder, when it lives on this same page (page-root relative)+    let run_link = pagedir::run_page(&r.data).filter(|(p, _)| !in_package && Some(p) == page.as_ref()).map(|(_, d)| format!("{}/", pagedir::run_rel(d.as_deref(), &report_id(r))));+    let when = now();+    let built = page_results::build(&r.data, &figures, board.as_ref().map(|b| (board_path.as_str(), b)), &sha, bom.as_deref(), run_link.as_deref(), molecule, &root, pd, &when).unwrap_or_else(|e| err(&format!("design review: {e}"), &[]));+    r.outcome("page-results", page_results::outcome(&built, &board_path, &sha, &root, page.as_deref(), &when));+    r.save().unwrap_or_else(|e| err(&e, &[]));+    let mut hints: Vec<String> = Vec::new();+    match (&page, in_package) {+        (_, true) => hints.push(format!("Carry {}/ with the molecule-publish package (Stage 2 sources) and link it from the package README: [Design review]({}/).", page_results::FOLDER, page_results::FOLDER)),+        (Some(p), false) => hints.push(format!("Push it: `cd {} && adom-wiki repo push {p} --files {} -m \"design review for the current board\"`, then link it from the page README as a sub-README card: [Design review]({}/).", root.display(), built.files.join(" "), pagedir::join(pd, page_results::FOLDER))),+        (None, false) => hints.push("The run has no page yet: `project-page --page <owner/slug> --create` first (or `--out <folder>` for a molecule-publish package), then `project-page --results` again.".into()),+    }+    for o in &built.open { hints.push(format!("Still open on the page: {o}.")); }+    ok(&format!("design review written for {} ({}): {} file(s) under {}\n  {}", Path::new(&board_path).file_name().and_then(|n| n.to_str()).unwrap_or(""), if built.sections.is_empty() { "no sections with data yet".to_string() } else { built.sections.join(", ") }, built.files.len(), root.display(), built.files.join("\n  ")), &hints);+}+ /// The give-back hint: what the AI learned belongs to every Adom user. fn give_back_hint() -> String {     "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()@@ -2247,7 +2431,11 @@ fn build_report(r: &Run, dir: &Path, page: Option<(&str, Option<&str>)>) -> (Pat     let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);     let status = match (delivered, finished) {         (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),-        (None, Some(f)) => format!("**Finished** (board design checks) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),+        // issue #147: say what finish said, not a bare "Finished"+        (None, Some(f)) => match r.data["summary"]["open"].as_array().map(|a| a.len()).unwrap_or(0) {+            0 => format!("**Finished** (board design checks, no open items) at {f}, {:.1} min of AI time from the prompt; the video is being cut. A human review still comes before fabrication.", r.active_minutes_total()),+            n => format!("**finish ran with {n} open item(s); not cleared for fabrication** ({f}, {:.1} min of AI time from the prompt). The open items are listed under the run's summary; finish again after any change.", r.active_minutes_total()),+        },         _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),     };     md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (unobserved, not proven idle) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));@@ -2995,10 +3183,32 @@ fn main() {             ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);         }         Cmd::SilkscreenAudit { input, out, record, board } => {-            thread(&cli); let mut r = load_run(&cli);+            thread(&cli);+            // a plain audit of a named board needs no run; --record (and the default board) do+            let run = if *record || board.is_none() { Some(load_run(&cli)) } else { Run::open(&run_dir(&cli)) };+            // issue #153: with no --input, the input is built from the saved board (same geometry for every engine)+            let built = input.is_none();+            let input = input.clone().unwrap_or_else(|| {+                let bp = board.clone().unwrap_or_else(|| run.as_ref().map(|r| r.current_board()).unwrap_or_default());+                let b = Board::load(&bp).unwrap_or_else(|e| err(&e, &["Pass --board <the saved .kicad_pcb>, or --input <a hand-built inventory>.".into()]));+                let p = out.with_file_name("silkscreen-audit-input.json");+                std::fs::write(&p, serde_json::to_string_pretty(&silk_audit_input::build(&b)).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));+                p+            });+            let input = &input;+            let _ = std::fs::remove_file(out); // a stale result must not stand in for this run's             let result = std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));-            if !result.status.success() { err(&silkproof::checker_failure(&String::from_utf8_lossy(&result.stdout), &String::from_utf8_lossy(&result.stderr)), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }+            let rect = "The audit is a rectangle check: each label box against each obstacle box. Built from the board (no --input), labels are their turned text rectangles and pads and bodies their outlines in 0.1 mm strips, so a 45 degree label or a round pad is not a false collision; a hand-built input with one box per item reports collisions in the empty corners.".to_string();+            if !result.status.success() {+                let msg = silkproof::checker_failure(&String::from_utf8_lossy(&result.stdout), &String::from_utf8_lossy(&result.stderr));+                // collisions and gaps are findings (the audit ran); an unreadable input is an error+                if std::fs::read_to_string(out).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).is_some_and(|v| v["passed"] == false) {+                    findings(&format!("{msg} (no proof recorded; details in {})", out.display()), &[format!("Input: {}{}", input.display(), if built { " (built from the board)" } else { "" }), "Move the colliding labels (each collision names the mark and the obstacle), or decide with the human that a contact is fine, then run the audit again.".into(), rect]);+                }+                err(&msg, &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views. Or leave out --input and the audit builds its input from the board.".into()]);+            }             if *record {+                let mut r = run.unwrap_or_else(|| load_run(&cli));                 // the step proof: this audit passed, on an inventory that covers the board it claims to audit                 let board_path = board.clone().unwrap_or_else(|| r.current_board());                 let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));@@ -3019,14 +3229,39 @@ fn main() {                 proof["input"] = json!(input.display().to_string());                 proof["inputSha256"] = json!(sha256_file(&input.display().to_string()));                 for k in ["powerInputs", "testPoints"] { proof[k] = labels[k].clone(); }+                // issue #163: the Fab layer check rides with the proof (findings, not a condition)+                let fab = if mechanical::applies(&board_path) { fab_layer::check(&b) } else { Value::Null };+                if !fab.is_null() { proof["fab"] = json!({"clean": fab["clean"], "findings": fab["findings"], "fabTexts": fab["fabTexts"], "referenceSizesMm": fab["referenceSizesMm"]}); }                 r.outcome("silkscreen", proof.clone());                 r.save().unwrap();                 // 0.2.18: what the labels say, not only where they sit: power inputs without a voltage, test points without their net                 for w in silk_polarity::warnings(&b).iter().filter(|w| w.contains("power input") || w.contains("test point")) { eprintln!("Warning: {w}"); }+                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); }                 ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels\n{}", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"], silk_polarity::label_check_lines(&labels).join("\n")), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]);                 return;             }-            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into()]);+            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into(), rect]);+        }+        Cmd::Silkscreen { action, board, out, size } => {+            // issue #163: findings and an offline tidy the AI may run; never a gate+            let (r, bp) = match board.clone() { Some(b) => (Run::open(&run_dir(&cli)), b), None => { let r = load_run(&cli); let b = r.current_board(); (Some(r), b) } };+            let b = Board::load(&bp).unwrap_or_else(|e| err(&e, &["Pass --board <a saved .kicad_pcb>.".into()]));+            if action == "fab-check" {+                let c = fab_layer::check(&b);+                let lines: Vec<String> = c["findings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();+                if lines.is_empty() { ok(&format!("Fab layer meets the standard: {} parts, {} Fab texts, reference size {} mm", c["parts"], c["fabTexts"], c["boardSizeMm"]), &[]); return; }+                findings(&lines.join("; "), &[fab_layer::TIDY_HINT.into()]);+            }+            if size.is_some_and(|s| !(s.is_finite() && s >= fab_layer::FLOOR)) { err(&format!("--size is the Fab reference height in mm, at least {}", fab_layer::FLOOR), &[]); }+            let (text, changes) = fab_layer::tidy(&b, *size);+            let outp = out.clone().unwrap_or_else(|| { let p = Path::new(&bp); p.with_file_name(format!("{}-fab.kicad_pcb", p.file_stem().and_then(|s| s.to_str()).unwrap_or("board"))).display().to_string() });+            let tidied = Board::from_text(&outp, text.clone()).unwrap_or_else(|e| err(&format!("the tidied board does not parse ({e}); nothing written"), &[]));+            std::fs::write(&outp, &text).unwrap_or_else(|e| err(&format!("{outp}: {e}"), &[]));+            let after = fab_layer::check(&tidied);+            if let Some(mut r) = r { r.log("fab-tidy", json!({"board": bp, "out": outp, "parts": changes.len(), "changes": changes, "sizeMm": size, "clean": after["clean"]})); let _ = r.save(); }+            let left: Vec<String> = after["findings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();+            ok(&format!("Fab layer tidied on {} part(s); written to {outp} (copper, zones and silkscreen unchanged){}", changes.len(), if left.is_empty() { String::new() } else { format!("; still: {}", left.join("; ")) }),+                &[format!("Review it in KiCad (F.Fab and the outline), then `adom-aiflow adopt-board --board {outp} --evidence \"fab-tidy: Fab text only\"` to make it the run's board (Fab text is not in the copper fingerprint, so the copper evidence carries over; record the silkscreen proof again)."), "Fab is not plotted for the fab; it is the assembly drawing, the 3D and the board page. The silkscreen labels are untouched.".into()]);         }         Cmd::StepExport { board, out, log, ack, why } => {             thread(&cli); let mut r = load_run(&cli);@@ -3078,6 +3313,13 @@ fn main() {                     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);@@ -3409,6 +3651,8 @@ fn main() {                 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);@@ -3417,19 +3661,23 @@ fn main() {                     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<_>>()}));+            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)@@ -3442,16 +3690,21 @@ fn main() {                     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(|| "project library".to_string())).unwrap_or_else(|| "MISSING".into()))).collect();+            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();-                err(&format!("{} of {} parts have no Adom wiki component page{}:\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()]);+                // 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, sync, create, existing, private, title, brief, replace_readme, no_push, page_dir } => {+        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();@@ -3477,9 +3730,9 @@ fn main() {             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, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",+            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": spec.get("molecule").is_some(),+                "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());@@ -3503,7 +3756,8 @@ fn main() {                 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 *no_push { format!("project page built locally in {} (--no-push: nothing pushed; {} was not created or updated)", dir.join("project-page").display(), v["url"].as_str().unwrap_or(page)) }+            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();@@ -3517,6 +3771,8 @@ fn main() {             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",@@ -3566,6 +3822,7 @@ fn main() {             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();@@ -3715,11 +3972,11 @@ fn main() {                     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()).map(|v|v.to_json());+                    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: {} (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()))).unwrap_or_default();+                    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" => {@@ -3850,7 +4107,7 @@ fn main() {                     if let Some(i) = stopped {                         let rep = replies.last().cloned().unwrap_or(Value::Null);                         if rep["errorCode"] == "board_mismatch" {-                            err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]);+                            err(&format!("live placement refused 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() }]);                     }@@ -4053,8 +4310,10 @@ fn main() {                 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() { findings(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }+                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)@@ -4074,6 +4333,7 @@ fn main() {             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() {@@ -4118,6 +4378,7 @@ fn main() {             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 } => {@@ -4300,7 +4561,24 @@ fn main() {                             }                         }                     }-                    let results = br.land_zones(&rb, &zones, *pause_ms, &owned);+                    // 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); }@@ -4319,22 +4597,39 @@ fn main() {                     let mut results = results;                     if !Bridge::ok(&after) { results.push(("post-landing-readback".into(), after.clone())); }                     let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();-                    r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);-                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"], "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, "results": results, "after": after}));+                    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()]);                     }-                    ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".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, adopt, why, record_plan } => {+        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, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+            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);@@ -4465,10 +4760,11 @@ fn main() {             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]);+                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 } => {@@ -4607,6 +4903,8 @@ fn main() {             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);@@ -4848,9 +5146,9 @@ fn main() {             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(), molecule_publish_hint(&r).unwrap_or_default(), give_back_hint()]);+            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 } => {+        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@@ -4880,7 +5178,7 @@ fn main() {                     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()]);+                        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())) };@@ -4945,6 +5243,13 @@ fn main() {                     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()]);@@ -5177,6 +5482,19 @@ fn main() {             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");@@ -5189,9 +5507,12 @@ fn main() {             // 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 {-                err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint, names_hint]);+                // 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]);             }-            ok(&lines.join("\n"), &[names_hint, "This is electrical parity between schematic and board only: not ERC, 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]);+            // 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);@@ -5283,8 +5604,9 @@ fn main() {             };             let before = outline::sharp_corners(&read(&r));             if action == "check" || before.is_empty() {-                if action == "round" { r.outcome("outline", json!({"radius": rad, "rounded": 0, "sharp": 0})); r.save().unwrap(); }-                if before.is_empty() { ok("the board outline has no sharp corners", &[]); return; }+                // 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);@@ -5811,7 +6133,10 @@ fn main() {                     let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();                     let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));                     let mut toured: Vec<Value> = Vec::new();-                    let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+                    // 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}));@@ -5822,19 +6147,21 @@ fn main() {                             err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);                         }                         r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));-                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));+                        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();-                        if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }-                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+                        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"]}));+                        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}));+                    r.outcome("tour-nets", json!({"nets": toured, "fitFirst": {"framedBy": fit0.how, "failed": fit0.failed}}));                     r.save().unwrap();-                    ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);+                    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@@ -5981,7 +6308,8 @@ fn main() {                         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()]); }-                    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)), &[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]);+                    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"), &[]),             }@@ -6266,7 +6594,7 @@ fn main() {                     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.")]);+                        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);@@ -6284,7 +6612,13 @@ fn main() {                 }             }             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();@@ -6377,6 +6711,8 @@ fn main() {                             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")) {@@ -6407,7 +6743,7 @@ fn main() {                         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)")),+                    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())),                 }                 }             }
crates/adom-aiflow/src/mechanical.rs+68−4
@@ -9,7 +9,9 @@ //! The exemption rule is deliberately narrow: a footprint with no pads at all (a logo, board art, //! a text block) carries nothing to assemble or solder and needs no courtyard. Every footprint with //! a pad needs one, test pads, fiducials, mounting holes and machine pins included (KiCad's own-//! libraries draw courtyards for all of them). Footprints excluded from both the BOM and the+//! libraries draw courtyards for all of them). The two exceptions are KiCad's own (issue #151): a+//! net tie (`net_tie_pad_groups`) is copper artwork, and `(attr ... allow_missing_courtyard)` is the+//! footprint's own waiver, which KiCad's DRC honours too. Footprints excluded from both the BOM and the //! position files (bare test pads, mounting holes) are not bodies for the body-overlap check: the //! fab places nothing there, so a fab ring around a bare pad is not a part body. //!@@ -104,7 +106,7 @@ impl Report {             "bodySizeErrors": self.body_size_errors, "bodySizeNotVerified": self.body_size_unverified,             "projectSeverities": self.severity_notes, "notes": self.notes,             "courtyardsOk": self.courtyards_ok(), "bodiesOk": self.bodies_ok(),-            "exemptionRule": "only footprints with no pads (logos, board art) need no courtyard; footprints excluded from both BOM and position files are not bodies",+            "exemptionRule": "footprints with no pads (logos, board art), net ties (net_tie_pad_groups) and footprints KiCad marks allow_missing_courtyard need no courtyard; footprints excluded from both BOM and position files, and net ties, are not bodies",         })     } }@@ -120,6 +122,36 @@ fn flag(fp: &sx::Node, name: &str) -> bool {     in_attr || fp.find(name).map(|n| n.value() != Some("no")).unwrap_or(false) } +/// Issue #151: why KiCad itself lets this footprint go without a courtyard, or None. A net tie+/// (`net_tie_pad_groups`) is copper artwork joining two nets, and `(attr ... allow_missing_courtyard)`+/// is KiCad's own marker for a footprint its DRC does not ask a courtyard of.+pub fn courtyard_waiver(fp: &sx::Node) -> Option<&'static str> {+    if is_net_tie(fp) { return Some("net tie (net_tie_pad_groups): copper artwork, KiCad asks no courtyard of it"); }+    if flag(fp, "allow_missing_courtyard") { return Some("KiCad attribute allow_missing_courtyard"); }+    None+}++/// A KiCad net-tie footprint: it carries `net_tie_pad_groups`.+pub fn is_net_tie(fp: &sx::Node) -> bool { fp.find("net_tie_pad_groups").is_some() }++/// `courtyard_waiver` by reference on a board.+pub fn waiver_of(b: &Board, reference: &str) -> Option<&'static str> {+    b.root.find_all("footprint").find(|fp| aiflow_board::reference_of(fp).as_deref() == Some(reference)).and_then(courtyard_waiver)+}++/// Issue #151: a net tie sits on the pads of the part it ties (a Kelvin tie on a shunt), so a box+/// around its pads always meets that part. True when one of `a`, `b` is a net tie and the other+/// carries a net the tie joins: the overlap is the tie doing its job, not two parts colliding.+pub fn net_tie_on_its_part(b: &Board, a: &str, other: &str) -> bool {+    let tie_on = |tie: &str, part: &str| {+        let is_tie = b.root.find_all("footprint").any(|fp| aiflow_board::reference_of(fp).as_deref() == Some(tie) && is_net_tie(fp));+        if !is_tie { return false; }+        let nets: std::collections::BTreeSet<&str> = b.pads.iter().filter(|p| p.reference == tie && !p.net.is_empty()).map(|p| p.net.as_str()).collect();+        b.pads.iter().any(|p| p.reference == part && nets.contains(p.net.as_str()))+    };+    tie_on(a, other) || tie_on(other, a)+}+ fn xy(n: &sx::Node, tag: &str) -> Option<Pt> {     let p = n.find(tag)?;     Some((p.number(1)?, p.number(2)?))@@ -182,6 +214,37 @@ pub fn hull(pts: &[Pt]) -> Vec<Pt> {     lower } +/// Issue #153: each footprint's fitted body in board coordinates, as `check` judges bodies: the convex+/// hull of its F.Fab (B.Fab) graphics, else of its courtyard; net ties and padless art have none.+pub fn body_outlines(b: &Board) -> Vec<Shape> {+    let mut out = Vec::new();+    for fp in b.root.find_all("footprint") {+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        if fp.find_all("pad").count() == 0 || is_net_tie(fp) { continue; }+        let at = fp.find("at");+        let (x, y, rot) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));+        for (fab, crt, front) in [("F.Fab", "F.CrtYd", true), ("B.Fab", "B.CrtYd", false)] {+            let pts = |layer: &str| { let (c, o) = layer_geometry(fp, layer); c.into_iter().chain(o).flatten().collect::<Vec<Pt>>() };+            let mut p = pts(fab);+            if p.len() < 3 { p = pts(crt); }+            let h = hull(&p);+            if h.len() >= 3 { out.push(Shape { reference: reference.clone(), front, polys: place(vec![h], x, y, rot) }); }+        }+    }+    out+}++/// Issue #163: the box (x0, y0, x1, y1) of a footprint's graphics on the first of `layers` that has+/// any, in the footprint's own frame.+pub fn local_box(fp: &sx::Node, layers: &[&str]) -> Option<(f64, f64, f64, f64)> {+    layers.iter().find_map(|layer| {+        let (c, o) = layer_geometry(fp, layer);+        let pts: Vec<Pt> = c.into_iter().chain(o).flatten().collect();+        (pts.len() >= 2).then(|| pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))))+            .filter(|b| b.2 > b.0 && b.3 > b.1)+    })+}+ fn place(polys: Vec<Vec<Pt>>, x: f64, y: f64, rot: f64) -> Vec<Vec<Pt>> {     polys.into_iter().map(|poly| poly.into_iter().map(|(dx, dy)| { let (a, b) = geom::rot_kicad(dx, dy, rot); (x + a, y + b) }).collect()).collect() }@@ -313,9 +376,10 @@ pub fn check(b: &Board, board_path: &str, model_box: &dyn Fn(&str) -> Option<(f6         }         if found { rep.with_courtyard += 1; }         else if pads == 0 { rep.exempt.push((reference.clone(), "no pads (graphics only)".into())); }+        else if let Some(why) = courtyard_waiver(fp) { rep.exempt.push((reference.clone(), why.into())); }         else { rep.missing_courtyards.push(reference.clone()); }-        // bodies-        if pads == 0 || (flag(fp, "exclude_from_bom") && flag(fp, "exclude_from_pos_files")) { continue; }+        // bodies (a net tie is copper, not a part body)+        if pads == 0 || is_net_tie(fp) || (flag(fp, "exclude_from_bom") && flag(fp, "exclude_from_pos_files")) { continue; }         let mut got = false;         for (layer, front) in [("F.Fab", true), ("B.Fab", false)] {             let (closed, open) = layer_geometry(fp, layer);
crates/adom-aiflow/src/molecule_pins.rs+22
@@ -237,10 +237,32 @@ pub fn names_from_footprints(parts: &[(String, String)]) -> Vec<(String, String)     findings(&rows) } +/// Issue #158: is this run a molecule? The spec says so with a "molecule" object; before `start`+/// attaches a spec (the kicad-new lane runs project-page first) the requirements say so with+/// formFactor.kind "molecule", or a formFactor.pin naming a MachinePin.+pub fn wants_molecule(spec: &serde_json::Value, req: &serde_json::Value) -> bool {+    if spec.get("molecule").is_some_and(|m| !m.is_null()) { return true; }+    let ff = &req["formFactor"];+    ff["kind"].as_str().is_some_and(|k| k.trim().eq_ignore_ascii_case("molecule"))+        || ff["pin"].as_str().is_some_and(|p| p.to_ascii_lowercase().contains("machinepin"))+}+ #[cfg(test)] mod tests {     use super::*; +    #[test]+    fn molecule_from_spec_or_requirements() {+        use serde_json::json;+        // issue #158: the kicad-new lane runs project-page before start attaches the spec+        assert!(wants_molecule(&json!({"molecule": {}}), &json!(null)));+        assert!(wants_molecule(&json!(null), &json!({"formFactor": {"kind": "molecule"}})));+        assert!(wants_molecule(&json!({}), &json!({"formFactor": {"kind": "Molecule "}})));+        assert!(wants_molecule(&json!({}), &json!({"formFactor": {"pin": "MachinePinLargeShort"}})));+        assert!(!wants_molecule(&json!({}), &json!({"formFactor": {"kind": "board"}})));+        assert!(!wants_molecule(&json!(null), &json!(null)));+    }+     fn fp(lib: &str, des: &str, drill: f64, pads: usize, model: &str) -> String {         let pads: String = (1..=pads).map(|k| format!(r#"(pad "{k}" thru_hole circle (at {} 0) (size 1.3 1.3) (drill {drill}) (layers "*.Cu"))"#, k as f64 * 2.0)).collect();         let model = if model.is_empty() { String::new() } else { format!(r#"(model "{model}")"#) };
crates/adom-aiflow/src/page_results.rsadded+306
@@ -0,0 +1,306 @@+//! Issues #147 and #164: the run's analysis results and parts reach the board's page, not only the run+//! report. `project-page --results` writes a design review sub-README (design-review/README.md) with+//! the Fields current and thermal numbers, the AI's analysis figures, the contact table of a molecule+//! and the components table from the BOM, into the page's staging folder (or a molecule-publish+//! package folder with --out), and records which board it describes. `analyze current|thermal`,+//! `tour fields` and `finish` say when the page's design review is missing or older than the board.+//! Suggestions only: nothing here refuses.++use std::path::Path;++use aiflow_board::Board;+use serde_json::{json, Value};++/// The sub-README folder on the page (or in the molecule-publish package).+pub const FOLDER: &str = "design-review";++/// What `build` wrote.+pub struct Built {+    /// files written, relative to the output root (the page root), README first+    pub files: Vec<String>,+    pub sections: Vec<&'static str>,+    /// what the page still lacks: said in the hints, never a stop+    pub open: Vec<String>,+}++fn num(v: &Value) -> Option<f64> { v.as_f64().filter(|x| x.is_finite()) }+fn cell(v: Option<f64>, digits: usize) -> String { v.map(|x| format!("{x:.digits$}")).unwrap_or_else(|| "-".into()) }+fn esc(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") }++/// The Fields numbers the run holds: the `tour fields` outcome, else the field report an analysis read.+fn fields_of(data: &Value) -> Value {+    let o = &data["outcomes"];+    if o["tour-fields"]["nets"].is_array() || o["tour-fields"]["chips"].is_array() { return o["tour-fields"].clone(); }+    for k in ["analyze-current", "analyze-thermal"] {+        if let Some(p) = o[k]["fieldReport"].as_str() {+            if let Some(v) = std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { return v; }+        }+    }+    Value::Null+}++/// The design review's state against the current board: None when the page's review is this board's+/// and newer than every analysis; otherwise what is wrong with it, in words.+pub fn review_state(data: &Value, board_sha: &str) -> Option<String> {+    let o = &data["outcomes"];+    let analysed = ["analyze-current", "analyze-thermal", "tour-fields"].iter().any(|k| o.get(*k).is_some_and(|v| !v.is_null()));+    let pr = &o["page-results"];+    if pr.is_null() {+        return analysed.then(|| "has no design review from this run yet (no `project-page --results`)".to_string());+    }+    let at = pr["at"].as_str().unwrap_or("");+    if !board_sha.is_empty() && pr["boardSha256"].as_str() != Some(board_sha) {+        return Some(format!("still shows the design review of {at}, written for an earlier board"));+    }+    let newer = ["analyze-current", "analyze-thermal", "tour-fields"].iter().filter_map(|k| data["outcomeAt"][*k].as_str()).any(|t| t > at);+    newer.then(|| format!("shows the design review of {at}, written before the latest analysis"))+}++/// The board's page and folder: `project-page`'s (`projectPage`, `projectPageDir`), else the run's (`page`, `pageDir`).+pub fn board_page(data: &Value) -> Option<(String, Option<String>)> {+    let dir = |k: &str| data[k].as_str().filter(|d| !d.is_empty() && *d != ".").map(str::to_string);+    match data["projectPage"].as_str().filter(|p| !p.is_empty()) {+        Some(p) => Some((p.to_string(), dir("projectPageDir"))),+        None => data["page"].as_str().filter(|p| !p.is_empty()).map(|p| (p.to_string(), dir("pageDir"))),+    }+}++/// The hint after an analysis (and at finish): where the results belong. Empty when the page is up to+/// date, or when the run has no page and is not a molecule (nothing to put them on yet).+pub fn hint(data: &Value, board_sha: &str, molecule: bool) -> String {+    let Some(state) = review_state(data, board_sha) else { return String::new() };+    match board_page(data).map(|b| b.0) {+        Some(page) => format!("Board page: {page} {state}. Put these results where people read them (numbers, figures, what changed): `adom-aiflow project-page --results` writes {FOLDER}/README.md with the Fields current and thermal numbers, your analysis figures{} and the parts table, then prints the push.", if molecule { ", the contact table" } else { "" }),+        None if molecule => format!("Molecule page: the design review {state}. `adom-aiflow project-page --results --out <molecule-publish package folder>` puts {FOLDER}/ (Fields numbers, analysis figures, contact table, parts table) in the package before its Stage 3, so the molecule page carries the results."),+        None => String::new(),+    }+}++/// The BOM's optional columns for the components table: (unit price, quantity, buy link, role).+fn bom_extras(bom: &str) -> Vec<(Vec<String>, Option<f64>, Option<f64>, String, String)> {+    let rows = crate::preboard::parse_csv(bom);+    let Some(header) = rows.first() else { return Vec::new() };+    let norm = |s: &str| s.trim().to_ascii_lowercase().replace([' ', '-', '#', '(', ')', '$'], "_").trim_matches('_').to_string();+    let find = |names: &[&str]| header.iter().position(|h| names.iter().any(|n| norm(h) == *n));+    let c_ref = find(&["ref", "refs", "reference", "references", "designator", "designators", "refdes"]);+    let c_price = find(&["unit_price", "unitprice", "price", "unit_cost", "cost", "price_usd", "unit_price_usd"]);+    let c_qty = find(&["qty", "quantity", "count"]);+    let c_url = find(&["url", "link", "buy", "buy_link", "product_url", "mouser_url", "digikey_url"]);+    let c_role = find(&["role", "function", "description", "desc"]);+    let get = |row: &Vec<String>, c: Option<usize>| c.and_then(|i| row.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();+    rows.iter().skip(1).map(|row| {+        let refs = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();+        let price = get(row, c_price).trim_start_matches(['$', '€', '£']).replace(',', "").parse::<f64>().ok();+        (refs, price, get(row, c_qty).parse::<f64>().ok(), get(row, c_url), get(row, c_role))+    }).collect()+}++/// Write `<out_root>/<page_dir>/design-review/README.md` and its figures. `figures` are (step, local+/// file, caption) of the AI's standing analysis drawings; `run_link` is the run report's folder on the+/// page (page-root relative). Paths inside the README are page-root relative, as the wiki resolves them+/// in a sub-README.+#[allow(clippy::too_many_arguments)]+pub fn build(data: &Value, figures: &[(String, String, String)], board: Option<(&str, &Board)>, board_sha: &str, bom: Option<&str>, run_link: Option<&str>, molecule: bool, out_root: &Path, page_dir: Option<&str>, when: &str) -> Result<Built, String> {+    let rel_dir = crate::pagedir::join(page_dir, FOLDER);+    let dir = out_root.join(&rel_dir);+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;+    let mut files: Vec<String> = Vec::new();+    let mut sections: Vec<&'static str> = Vec::new();+    let mut open: Vec<String> = Vec::new();+    let o = &data["outcomes"];+    let mut md = String::from("# Design review\n\n");+    let board_name = board.map(|(p, _)| Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()).unwrap_or_else(|| "the board".into());+    md.push_str(&format!("Written by [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow) on {when} for `{board_name}` (sha256 `{}`).", board_sha.chars().take(12).collect::<String>()));+    if let Some(l) = run_link { md.push_str(&format!(" The run's own report, step by step: [{l}]({l})."), ); }+    md.push_str("\n\n");+    let open_items = data["summary"]["open"].as_array().map(|a| a.len());+    md.push_str(&match (data["clock"]["finishTime"].as_str(), open_items) {+        (Some(_), Some(n)) if n > 0 => format!("**Status:** finish ran with {n} open item(s); not cleared for fabrication.\n\n"),+        (Some(_), _) => "**Status:** finish ran with no open items. A human review still comes before fabrication.\n\n".to_string(),+        _ => "**Status:** the run has not finished; these are working results, not a release.\n\n".to_string(),+    });++    // the board and its contacts (a molecule)+    if let Some((_, b)) = board.filter(|_| molecule) {+        let origin = b.footprints.get("MP1").map(|f| (f.x, f.y));+        let mut rows: Vec<(String, String)> = b.footprints.iter().filter(|(r, fp)| { let l = fp.lib.to_ascii_lowercase(); l.contains("machinecontact") || l.contains("machinepin") || r.starts_with("MC") && r[2..].chars().all(|c| c.is_ascii_digit()) && !r[2..].is_empty() }).map(|(r, fp)| {+            let net = b.pads_of(r).first().map(|p| p.net.clone()).unwrap_or_default();+            let at = origin.map(|o| format!("({:.0}, {:.0})", fp.x - o.0, o.1 - fp.y)).unwrap_or_else(|| "-".into());+            (r.clone(), format!("| {r} | {} | {} | {at} |\n", esc(if net.is_empty() { "-" } else { &net }), esc(fp.lib.rsplit(':').next().unwrap_or(&fp.lib))))+        }).collect();+        rows.sort_by(|a, b| a.0.cmp(&b.0));+        if !rows.is_empty() {+            sections.push("contacts");+            md.push_str("## The board and its contacts\n\nPositions are millimetres from MP1 (front left), y toward the back.\n\n| Ref | Net | Footprint | From MP1 (mm) |\n|---|---|---|---|\n");+            for (_, l) in rows { md.push_str(&l); }+            md.push('\n');+        }+    }++    // simulation: the Fields numbers, the screens and the figures+    let f = fields_of(data);+    let mut sim = String::new();+    if let Some(nets) = f["nets"].as_array().filter(|a| !a.is_empty()) {+        sim.push_str("### Current\n\n| Net | Current (A) | Peak density (A/mm²) | Layers |\n|---|---|---|---|\n");+        for n in nets {+            let layers = n["layers"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default();+            sim.push_str(&format!("| {} | {} | {} | {} |\n", esc(n["net"].as_str().unwrap_or("?")), cell(num(&n["amps"]).or(num(&n["current"])), 2), cell(num(&n["peakJ"]), 1), esc(&layers)));+        }+        sim.push('\n');+    }+    if let Some(chips) = f["chips"].as_array().filter(|a| !a.is_empty()) {+        sim.push_str("### Heat\n\n| Part | Power (W) | Rise, still air (°C) | Rise, moving air (°C) |\n|---|---|---|---|\n");+        for c in chips {+            sim.push_str(&format!("| {} | {} | {} | {} |\n", esc(c["reference"].as_str().or(c["ref"].as_str()).unwrap_or("?")), cell(num(&c["watts"]), 2), cell(num(&c["riseC"]), 1), cell(num(&c["riseMovingC"]), 1)));+        }+        if let Some(p) = num(&f["heat"]["peakRiseC"]) { sim.push_str(&format!("\nPeak rise on the board: {p:.1} °C in still air.\n")); }+        sim.push('\n');+    }+    let mut screens = Vec::new();+    for (k, label) in [("analyze-current", "Current screen"), ("analyze-thermal", "Thermal screen")] {+        if o[k].is_null() { continue; }+        let pass = o[k]["pass"].as_bool() == Some(true);+        let not_pass: Vec<String> = o[k]["results"].as_array().map(|a| a.iter().filter(|x| x["status"].as_str() != Some("pass")).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()).unwrap_or_default();+        screens.push(format!("- **{label}: {}**{}", if pass { "passed" } else { "not passed" }, if not_pass.is_empty() { String::new() } else { format!("\n  - {}", not_pass.iter().map(|s| esc(s)).collect::<Vec<_>>().join("\n  - ")) }));+    }+    if !screens.is_empty() { sim.push_str(&format!("### Screens\n\n{}\n\nThe screens and the Fields solve are engineering screens with stated assumptions, not manufacturing or thermal qualification.\n\n", screens.join("\n"))); }+    if let Some(issues) = f["issues"].as_array().filter(|a| !a.is_empty()) {+        sim.push_str("### Open Fields issues\n\n");+        for i in issues { sim.push_str(&format!("- {}\n", esc(i["what"].as_str().unwrap_or(&i.to_string())))); }+        sim.push('\n');+    }+    let mut figs = String::new();+    for (k, (_step, file, caption)) in figures.iter().enumerate() {+        let src = Path::new(file);+        let Some(name) = src.file_name().and_then(|n| n.to_str()) else { continue };+        let name = format!("{:02}-{name}", k + 1);+        if std::fs::copy(src, dir.join(&name)).is_err() { continue; }+        let rel = format!("{rel_dir}/{name}");+        figs.push_str(&format!("![{}]({rel})\n\n*{}*\n\n", esc(caption), esc(caption)));+        files.push(rel);+    }+    if !figs.is_empty() { sim.push_str(&format!("### Figures\n\n{figs}")); }+    if sim.is_empty() {+        md.push_str("## Simulation\n\nNo current, thermal or Fields analysis has run on this board yet.\n\n");+        open.push("no analysis results yet: run `analyze current`, `analyze thermal` and the fields step, then `project-page --results` again".into());+    } else {+        sections.push("simulation");+        md.push_str(&format!("## Simulation\n\n{sim}"));+        if figs.is_empty() { open.push("no analysis figures: register your current-density and temperature drawings with `artifact --kind analysis-image`, then `project-page --results` again".into()); }+    }++    // components: the parts the run chose, with the BOM's prices and links when it has them+    let parts = o["parts"]["parts"].as_array().cloned().unwrap_or_default();+    if parts.is_empty() {+        open.push("no parts table: run `parts --bom <bom.csv>` first".into());+    } else {+        sections.push("components");+        let extras = bom.map(bom_extras).unwrap_or_default();+        let mut total = 0.0;+        let mut priced = 0usize;+        md.push_str("## Components\n\n| Part | Manufacturer | Role | Qty | References | Unit price | Line price | Links |\n|---|---|---|---|---|---|---|---|\n");+        for p in &parts {+            let refs: Vec<String> = p["refs"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+            let ex = extras.iter().find(|e| !refs.is_empty() && e.0 == refs);+            let qty = if refs.is_empty() { ex.and_then(|e| e.2).unwrap_or(1.0) } else { refs.len() as f64 };+            let unit = ex.and_then(|e| e.1);+            let line = unit.map(|u| u * qty);+            if let Some(l) = line { total += l; priced += 1; }+            let mut links = Vec::new();+            if let Some(w) = p["wiki"].as_str() { links.push(format!("[wiki](https://wiki.adom.inc/{w})")); }+            if let Some(u) = ex.map(|e| e.3.as_str()).filter(|u| u.starts_with("http")) { links.push(format!("[buy]({u})")); }+            let role = ex.map(|e| e.4.clone()).filter(|r| !r.is_empty()).unwrap_or_else(|| p["value"].as_str().unwrap_or("").to_string());+            md.push_str(&format!("| {} | {} | {} | {} | {} | {} | {} | {} |\n", esc(p["mpn"].as_str().unwrap_or("?")), esc(p["manufacturer"].as_str().unwrap_or("")), esc(&role), qty, esc(&refs.join(", ")), unit.map(|u| format!("${u:.3}")).unwrap_or_else(|| "-".into()), line.map(|l| format!("${l:.2}")).unwrap_or_else(|| "-".into()), links.join(" ")));+        }+        if priced > 0 { md.push_str(&format!("\n**Board total:** ${total:.2} for the {priced} priced line(s) of {}{}.\n", parts.len(), if priced < parts.len() { "; the others have no unit price in the BOM" } else { "" })); }+        else { open.push("the parts table has no prices: add a unit price (and a buy link) column to the BOM, then `project-page --results` again".into()); }+        md.push('\n');+        open.push("chip images on the components table are not added by the binary yet: each part's wiki page carries its hero".into());+    }+    open.push("the interactive schematic (circuit explorer, #131) is not generated by the binary yet: link the schematic PDF or a block diagram from this review by hand".into());+    let readme = format!("{rel_dir}/README.md");+    std::fs::write(out_root.join(&readme), md).map_err(|e| format!("{readme}: {e}"))?;+    files.insert(0, readme);+    Ok(Built { files, sections, open })+}++/// The outcome recorded for `page-results`.+pub fn outcome(built: &Built, board: &str, board_sha: &str, out_root: &Path, page: Option<&str>, when: &str) -> Value {+    json!({"at": when, "board": board, "boardSha256": board_sha, "root": out_root.display().to_string(), "files": built.files, "page": page, "sections": built.sections, "open": built.open})+}++#[cfg(test)]+mod tests {+    use super::*;++    const B: &str = r#"(kicad_pcb (version 20260206)+ (gr_rect (start 98 82) (end 126 102) (layer "Edge.Cuts"))+ (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 100 100) (property "Reference" "MP1") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 100 88) (property "Reference" "MC1") (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net "VIN"))))"#;++    #[test]+    fn hint_names_the_page_until_the_review_is_this_boards() {+        // issue #147: an analysis with a page and no review: the hint names the page and the command+        let mut d = json!({"projectPage": "noah/esc", "outcomes": {"analyze-current": {"pass": true}}, "outcomeAt": {"analyze-current": "2026-11-06T10:00:00Z"}});+        let h = hint(&d, "abc", false);+        assert!(h.contains("noah/esc") && h.contains("project-page --results") && h.contains("no design review"), "{h}");+        // written for this board after the analysis: nothing to say+        d["outcomes"]["page-results"] = json!({"at": "2026-11-06T11:00:00Z", "boardSha256": "abc"});+        assert_eq!(hint(&d, "abc", false), "");+        // the board changed since+        assert!(hint(&d, "def", false).contains("earlier board"));+        // a newer analysis on the same board+        d["outcomeAt"]["tour-fields"] = json!("2026-11-06T12:00:00Z");+        d["outcomes"]["tour-fields"] = json!({"nets": []});+        assert!(hint(&d, "abc", false).contains("before the latest analysis"));+        // a molecule with no page yet: the package folder+        let m = json!({"outcomes": {"analyze-thermal": {"pass": false}}});+        assert!(hint(&m, "abc", true).contains("--out <molecule-publish package folder>"));+        // no page, not a molecule, or nothing analysed: quiet+        assert_eq!(hint(&json!({"outcomes": {"analyze-thermal": {}}}), "abc", false), "");+        assert_eq!(hint(&json!({"projectPage": "a/b", "outcomes": {}}), "abc", false), "");+        // the run's own page (intake --page) counts when project-page has not run+        assert!(hint(&json!({"page": "noah/esc2", "outcomes": {"analyze-thermal": {}}}), "abc", false).contains("noah/esc2"));+    }++    #[test]+    fn build_writes_numbers_figures_contacts_and_parts() {+        let tmp = std::env::temp_dir().join(format!("aiflow-page-results-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&tmp);+        std::fs::create_dir_all(&tmp).unwrap();+        let fig = tmp.join("density.png");+        std::fs::write(&fig, b"png").unwrap();+        let data = json!({+            "clock": {"finishTime": "t"}, "summary": {"open": ["a", "b"]},+            "outcomes": {+                "tour-fields": {"nets": [{"net": "PHA", "amps": 10.0, "peakJ": 41.25, "layers": ["F.Cu", "B.Cu"]}], "chips": [{"reference": "U101", "watts": 1.2, "riseC": 38.0, "riseMovingC": 21.0}], "heat": {"peakRiseC": 38.0}, "issues": [{"what": "U101 near its still-air limit at 6S"}]},+                "analyze-current": {"pass": false, "results": [{"net": "PHA", "status": "fail", "how": "widen the neck"}, {"net": "GND", "status": "pass"}]},+                "parts": {"parts": [{"mpn": "STM32G071", "manufacturer": "ST", "refs": ["U1"], "wiki": "adom/stm32g071"}, {"mpn": "RC0402", "manufacturer": "Yageo", "refs": ["R1", "R2"], "value": "10k"}]}+            }+        });+        let bom = "Ref,MPN,Unit Price,URL,Role\nU1,STM32G071,$2.10,https://mouser.com/x,MCU\n\"R1,R2\",RC0402,0.01,,pull-up\n";+        let b = Board::from_text("t.kicad_pcb", B.to_string()).unwrap();+        let built = build(&data, &[("fields".into(), fig.display().to_string(), "Current density, PHA".into())], Some(("/x/esc.kicad_pcb", &b)), "0123456789abcdef", Some(bom), Some("docs/runs/r1/"), true, &tmp, None, "2026-11-06T12:00:00Z").unwrap();+        let md = std::fs::read_to_string(tmp.join("design-review/README.md")).unwrap();+        assert!(md.contains("finish ran with 2 open item(s); not cleared for fabrication"), "{md}");+        assert!(md.contains("| MC1 | VIN | MachineContactMedium | (0, 12) |"), "{md}");+        assert!(md.contains("| PHA | 10.00 | 41.2 | F.Cu, B.Cu |") || md.contains("| PHA | 10.00 | 41.3 | F.Cu, B.Cu |"), "{md}");+        assert!(md.contains("| U101 | 1.20 | 38.0 | 21.0 |"), "{md}");+        assert!(md.contains("Current screen: not passed") && md.contains("PHA fail: widen the neck"), "{md}");+        // figures are page-root relative (the wiki resolves sub-README paths from the page root)+        assert!(md.contains("](design-review/01-density.png)") && tmp.join("design-review/01-density.png").is_file(), "{md}");+        assert!(md.contains("| STM32G071 | ST | MCU | 1 | U1 | $2.100 | $2.10 | [wiki](https://wiki.adom.inc/adom/stm32g071) [buy](https://mouser.com/x) |"), "{md}");+        assert!(md.contains("| RC0402 | Yageo | pull-up | 2 | R1, R2 | $0.010 | $0.02 |"), "{md}");+        assert!(md.contains("**Board total:** $2.12"), "{md}");+        assert!(md.contains("[docs/runs/r1/](docs/runs/r1/)"), "{md}");+        assert_eq!(built.files[0], "design-review/README.md");+        assert_eq!(built.sections, vec!["contacts", "simulation", "components"]);+        assert!(built.open.iter().any(|o| o.contains("interactive schematic")));+        // in a page folder, every path carries it+        let built = build(&data, &[], None, "", None, None, false, &tmp, Some("rig/boards/esc"), "t").unwrap();+        assert_eq!(built.files[0], "rig/boards/esc/design-review/README.md");+        assert!(built.open.iter().any(|o| o.contains("no analysis figures")));+        let _ = std::fs::remove_dir_all(&tmp);+    }+}
crates/adom-aiflow/src/placement_verify.rs+44−3
@@ -27,12 +27,15 @@ pub struct Verified {     /// spec.fixedRefs footprints with no pads and no courtyard (the gate's own exemption).     pub excused_without_courtyard: Vec<String>,     pub approved_contacts: Vec<Value>,+    /// Issue #151: bridge box overlaps of a net tie with a part it ties, and footprints KiCad itself+    /// lets go without a courtyard (net ties, allow_missing_courtyard), each with the reason.+    pub net_ties: Vec<Value>, }  impl Verified {     pub fn to_json(&self) -> Value {-        serde_json::json!({"approvedCourtyardContacts": self.approved_contacts, "courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard,-            "rule": "fixedRefs box exceptions require clean native/exact courtyards; explicit assembly contacts require bound owner approval, checked bodies and exact native UUID pairs; padless fixedRefs may lack a courtyard"})+        serde_json::json!({"approvedCourtyardContacts": self.approved_contacts, "courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard, "netTies": self.net_ties,+            "rule": "fixedRefs box exceptions require clean native/exact courtyards; explicit assembly contacts require bound owner approval, checked bodies and exact native UUID pairs; padless fixedRefs may lack a courtyard; a net tie (net_tie_pad_groups) may meet the part whose net it ties, and net ties and allow_missing_courtyard footprints need no courtyard (KiCad's own rules, native DRC clean)"})     } } @@ -159,6 +162,8 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after:     for o in overlaps {         let (a,b)=(o["a"].as_str().unwrap_or(""),o["b"].as_str().unwrap_or(""));         if crate::assembly_contacts::covers(&contacts,a,b) { continue; }+        // issue #151: a net tie's pad box meets the part it ties by design; KiCad's exact DRC is clean (above)+        if crate::mechanical::net_tie_on_its_part(board,a,b) { verified.net_ties.push(serde_json::json!({"overlap": o, "why": "net tie on the pads of the part it ties"})); continue; }         if fixed.contains(a) && fixed.contains(b) && !exact_hit(a,b) { verified.excused_overlaps.push(o.clone()); } else { open.push(o); }     }     if !open.is_empty() {@@ -172,7 +177,9 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after:     let mut open_no_cy=Vec::new();     for r in no_cy {         let reference=r.as_str().unwrap_or("?");-        if fixed.contains(reference) && pad_count(board,reference)==Some(0) { verified.excused_without_courtyard.push(reference.to_string()); } else { open_no_cy.push(reference); }+        if fixed.contains(reference) && pad_count(board,reference)==Some(0) { verified.excused_without_courtyard.push(reference.to_string()); }+        else if let Some(why)=crate::mechanical::waiver_of(board,reference) { verified.net_ties.push(serde_json::json!({"withoutCourtyard": reference, "why": why})); }+        else { open_no_cy.push(reference); }     }     if !open_no_cy.is_empty() {         return Err(format!("native placement has unresolved withoutCourtyard: {}", open_no_cy.join(", ")));@@ -349,4 +356,38 @@ mod tests {         assert!(!bound(&serde_json::json!({"ok":true}),&b,&s));         std::fs::remove_dir_all(dir).unwrap();     }++    /// Issue #151: a 0.3 mOhm shunt R411 with two Kelvin net ties (no courtyard, allow_missing_courtyard) on its pads.+    fn shunt_with_ties() -> (Board,Value,Value) {+        let tie=|r:&str,x:f64,net:&str,sense:&str| format!("(footprint \"NetTie:NetTie-2_SMD_Pad0.5mm\" (layer \"F.Cu\") (at {x} 5 0) (property \"Reference\" \"{r}\") (attr exclude_from_bom allow_missing_courtyard) (net_tie_pad_groups \"1, 2\") (pad \"1\" smd circle (at -0.25 0) (size 0.5 0.5) (layers \"F.Cu\") (net 1 \"{net}\")) (pad \"2\" smd circle (at 0.25 0) (size 0.5 0.5) (layers \"F.Cu\") (net 3 \"{sense}\")))");+        let text=format!("(kicad_pcb (net 0 \"\") (net 1 \"VBAT\") (net 2 \"PGND\") (net 3 \"SNS_P\") (net 4 \"SNS_N\") (gr_rect (start 0 0) (end 20 10) (layer \"Edge.Cuts\")) (footprint \"R:R_2512\" (layer \"F.Cu\") (at 10 5 0) (property \"Reference\" \"R411\") (fp_rect (start -4 -2) (end 4 2) (layer \"F.CrtYd\")) (pad \"1\" smd rect (at -3 0) (size 1.5 3) (layers \"F.Cu\") (net 1 \"VBAT\")) (pad \"2\" smd rect (at 3 0) (size 1.5 3) (layers \"F.Cu\") (net 2 \"PGND\"))) {} {} (footprint \"R:R_0402\" (layer \"F.Cu\") (at 17 2 0) (property \"Reference\" \"R1\") (fp_rect (start -1 -0.5) (end 1 0.5) (layer \"F.CrtYd\")) (pad \"1\" smd rect (at -0.5 0) (size 0.5 0.5) (layers \"F.Cu\") (net 4 \"SNS_N\"))))",+            tie("NT401",7.0,"VBAT","SNS_P"), tie("NT402",13.0,"PGND","SNS_N"));+        let b=Board::from_text("shunt",text).unwrap();+        let fp=|r:&str,x:f64,y:f64,lib:&str| json!({"ref":r,"x":x,"y":y,"rotation":0,"side":"F.Cu","footprint":lib,"insideOutline":true});+        let s=json!({"success":true,"source":"live-editor","revision":"rev","boardPath":"board","footprintCount":4,"footprints":[fp("R411",10.0,5.0,"R:R_2512"),fp("NT401",7.0,5.0,"NetTie:NetTie-2_SMD_Pad0.5mm"),fp("NT402",13.0,5.0,"NetTie:NetTie-2_SMD_Pad0.5mm"),fp("R1",17.0,2.0,"R:R_0402")]});+        let v=json!({"success":true,"source":"live-editor","revision":"rev","currentRevision":"rev","stale":false,"placed":false,"outsideOutline":[],"parked":[],+            "courtyardOverlaps":[{"a":"NT401","b":"R411","side":"F.Cu"},{"a":"NT402","b":"R411","side":"F.Cu"}],"withoutCourtyard":["NT401","NT402"],+            "drc":{"available":true,"source":"live-editor-snapshot","placementClean":true,"placementViolationCount":0,"errors":0,"violationReportMayBeTruncated":false}});+        (b,s,v)+    }+    #[test]+    fn net_ties_on_the_shunt_they_tie_are_not_placement_overlaps() {+        let (b,s,v)=shunt_with_ties();+        let got=check_with_spec(&b,&s,&v,&s,"board",&Value::Null).unwrap();+        assert_eq!(got.accepted,4);+        assert_eq!(got.net_ties.len(),4,"{:?}",got.net_ties);+        assert!(got.to_json()["netTies"].to_string().contains("allow_missing_courtyard") || got.to_json()["netTies"].to_string().contains("net_tie_pad_groups"));+        // a net tie overlapping a part whose nets it does not tie is still named+        let mut other=v.clone();other["courtyardOverlaps"]=json!([{"a":"NT402","b":"R1","side":"F.Cu"}]);+        let e=check_with_spec(&b,&s,&other,&s,"board",&Value::Null).unwrap_err();+        assert!(e.contains("NT402 and R1"),"{e}");+        // the mechanical check exempts them from the courtyard rule and the body check+        let rep=crate::mechanical::check(&b,"",&|_|None);+        assert!(rep.missing_courtyards.is_empty(),"{:?}",rep.missing_courtyards);+        assert_eq!(rep.exempt.len(),2,"{:?}",rep.exempt);+        assert!(rep.courtyard_overlaps.is_empty());+        // a plain part without a courtyard is still named+        let bare=Board::from_text("shunt",b.text.replace("(fp_rect (start -1 -0.5) (end 1 0.5) (layer \"F.CrtYd\"))","")).unwrap();+        assert_eq!(crate::mechanical::check(&bare,"",&|_|None).missing_courtyards,vec!["R1"]);+    } }
crates/adom-aiflow/src/pour_adopt.rsadded+259
@@ -0,0 +1,259 @@+//! Issue #69 follow-up: `land pours` adopts a zone already on the board that is identical to a plan+//! zone, instead of refusing it.+//!+//! A board whose planes were staged as zones (so the routing gate sees the plane-connected pads+//! joined) carries the plan's zones before `land pours` runs. The bridge refuses a planned zone whose+//! name belongs to a zone the run does not own (KiCad bridge live_zones.py `prepare`), and+//! `verify-pours --adopt` needs saved fills and an entry for every board zone, rule areas included.+//!+//! Here, before anything is sent, every plan zone is looked up on the live board by its name:+//! - no board zone of that name: it is landed through `kicad_apply_zone_plan` as before;+//! - exactly one, identical to the plan zone (`pourverify::adoption_differences`: name, net, layers,+//!   outline within 0.01 mm, priority, clearance, min thickness, pad connection, fill settings, not+//!   locked, no field the comparison does not know): it is not sent. If the run did not own it, it is+//!   ADOPTED (ownership granted, recorded with the evidence); if the run owned it, it is KEPT as it is;+//! - exactly one that differs: if the run owns it, it is sent and the bridge replaces it, as before;+//!   if not, `land pours` lands nothing and reports every field that differs as findings, with the ways on;+//! - several board zones of that name: reported as ambiguous findings, nothing landed, as the bridge would not land it either.+//!+//! Board zones whose names match no plan zone and that the run does not own are left alone, exactly+//! as before (rule areas, someone else's zones). Nothing here edits the board.+use aiflow_board::{sx, Board};+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::collections::{BTreeMap, BTreeSet};++use crate::pourverify;++#[derive(Debug, Default)]+pub struct Adoption {+    /// The plan zones still to land through the bridge.+    pub send_zones: Vec<Value>,+    /// The owned IDs the bridge reconciles (owned zones kept as they are are left out).+    pub send_owned: Vec<String>,+    /// Unowned board zones identical to a plan zone: ownership is granted when the landing succeeds.+    pub adopted: Vec<Value>,+    /// Owned board zones identical to their plan zone: left as they are.+    pub kept: Vec<Value>,+    /// Unowned board zones whose names match no plan zone: untouched.+    pub left_alone: Vec<Value>,+    /// Why nothing could be adopted (an older bridge with no zone definitions), when that is so.+    pub note: Option<String>,+}++fn refuse(code: &str, zone: &str, message: String, extra: Value) -> Value {+    let mut v = json!({"success": false, "errorCode": code, "zone": zone, "message": message, "mutated": false});+    if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {+        for (k, x) in e {+            o.insert(k.clone(), x.clone());+        }+    }+    v+}++/// Decide, per plan zone, adopt / keep / send / refuse against a settled `kicad_zone_state` reply+/// (`live`, with its `zones` rows and `definitions`). Err is a refusal reply (nothing was landed).+pub fn plan(board: &Board, zones: &[Value], live: &Value, owned: &[String]) -> Result<Adoption, Value> {+    let rows: Vec<&Value> = live["zones"].as_array().map(|a| a.iter().filter(|r| r["type"] != "teardrop").collect()).unwrap_or_default();+    let Some(defs) = live["definitions"].as_array() else {+        return Ok(Adoption { send_zones: zones.to_vec(), send_owned: owned.to_vec(),+            note: Some("this KiCad bridge's kicad_zone_state carries no zone definitions, so no board zone could be compared with the plan; nothing was adopted".into()), ..Default::default() });+    };+    let defs: BTreeMap<&str, &str> = defs.iter().filter_map(|d| Some((d["id"].as_str()?, d["definitionSexpr"].as_str()?))).collect();+    let plan_names: BTreeSet<&str> = zones.iter().filter_map(|z| z["name"].as_str()).collect();+    let mut out = Adoption::default();+    let mut not_sent: BTreeSet<String> = BTreeSet::new();+    for z in zones {+        let name = z["name"].as_str().unwrap_or("");+        let same: Vec<&&Value> = if name.is_empty() { Vec::new() } else { rows.iter().filter(|r| r["name"].as_str() == Some(name)).collect() };+        if same.is_empty() {+            out.send_zones.push(z.clone());+            continue;+        }+        let ids: Vec<&str> = same.iter().filter_map(|r| r["id"].as_str()).collect();+        if same.len() > 1 {+            return Err(refuse("zone_adoption_ambiguous", name, format!("planned zone {name:?} matches {} board zones of that name ({}); adoption needs exactly one", same.len(), ids.join(", ")), json!({"boardZoneIds": ids})));+        }+        let Some(id) = ids.first().copied() else {+            return Err(refuse("zone_adoption_refused", name, format!("the board zone named {name:?} has no item ID in the live readback"), json!({})));+        };+        let is_owned = owned.iter().any(|o| o == id);+        let Some(def) = defs.get(id).copied() else {+            if is_owned {+                out.send_zones.push(z.clone());+                continue;+            }+            return Err(refuse("zone_adoption_refused", name, format!("planned zone {name:?} is already on the board as zone {id}, which this run does not own, and the live readback has no definition for it to compare"), json!({"boardZoneId": id})));+        };+        let node = sx::parse(def).map_err(|e| refuse("zone_adoption_refused", name, format!("the live definition of zone {id} does not parse: {e}"), json!({"boardZoneId": id})))?;+        let (diffs, outline) = pourverify::adoption_differences(board, z, &node).map_err(|e| refuse("zone_adoption_refused", name, e, json!({"boardZoneId": id})))?;+        if diffs.is_empty() {+            let rec = json!({"name": name, "id": id, "net": z["net"], "layers": z["layers"], "ownedBefore": is_owned,+                "fieldsCompared": pourverify::ADOPT_FIELDS, "outline": outline,+                "definitionSha256": format!("{:x}", Sha256::digest(def.as_bytes()))});+            if is_owned { out.kept.push(rec) } else { out.adopted.push(rec) }+            not_sent.insert(id.to_string());+            continue;+        }+        if is_owned {+            // a zone the run owns that no longer matches its plan zone: the bridge replaces it, as before+            out.send_zones.push(z.clone());+            continue;+        }+        return Err(refuse("zone_adoption_refused", name,+            format!("planned zone {name:?} is already on the board as zone {id}, which this run does not own, and it is not identical to the plan: {}", pourverify::adoption_line(&diffs)),+            json!({"boardZoneId": id, "differences": diffs, "outline": outline})));+    }+    out.send_owned = owned.iter().filter(|o| !not_sent.contains(*o)).cloned().collect();+    out.left_alone = rows.iter()+        .filter(|r| r["id"].as_str().is_some_and(|id| !owned.iter().any(|o| o == id)) && !r["name"].as_str().is_some_and(|n| plan_names.contains(n)))+        .map(|r| json!({"id": r["id"], "name": r["name"], "type": r["type"], "net": r["net"]}))+        .collect();+    Ok(out)+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A 2-layer KiCad 10 board: no net table, nets named on the items, as KiCad 10 writes it.+    fn board() -> Board {+        let text = r#"(kicad_pcb (version 20260206) (generator "test")+  (general (thickness 1.6))+  (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+  (gr_rect (start 0 0) (end 50 40) (stroke (width 0.1) (type default)) (fill no) (layer "Edge.Cuts"))+)"#;+        Board::from_text("test", text.to_string()).expect("test board")+    }++    fn plan_zone() -> Value {+        json!({"name": "VLED pour", "net": "VLED", "layers": ["F.Cu"], "priority": 1, "clearance": 0.25, "minThickness": 0.25,+               "connection": "thermal", "thermalGap": 0.3, "thermalBridge": 0.3,+               "polygon": [[1.0, 1.0], [24.0, 1.0], [24.0, 39.0], [1.0, 39.0]]})+    }++    fn gnd_zone() -> Value {+        json!({"name": "GND plane", "net": "GND", "layers": ["B.Cu"], "clearance": 0.25, "minThickness": 0.25, "connection": "solid",+               "thermalGap": 0.3, "thermalBridge": 0.3, "polygon": [[0.5, 0.5], [49.5, 0.5], [49.5, 39.5], [0.5, 39.5]]})+    }++    /// The zone as a board staged before routing carries it (a board generator writes this layout).+    fn staged_def(name: &str, body: &str, pts: &str) -> String {+        format!("(zone\n\t\t(net \"VLED\")\n\t\t(layer \"F.Cu\")\n\t\t(name \"{name}\")\n\t\t(hatch edge 0.5)\n{body}\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{pts}\n\t\t\t)\n\t\t)\n\t)")+    }++    const BODY: &str = "\t\t(priority 1)\n\t\t(connect_pads (clearance 0.25))\n\t\t(min_thickness 0.25)\n\t\t(fill (thermal_gap 0.3) (thermal_bridge_width 0.3) (island_removal_mode 0))\n";+    const PTS: &str = "(xy 1 1) (xy 24 1) (xy 24 39) (xy 1 39)";++    fn live(defs: &[(&str, &str, String)]) -> Value {+        json!({"success": true, "revision": "rev-1",+               "zones": defs.iter().map(|(id, name, d)| json!({"id": id, "name": name, "type": if d.contains("(keepout") { "keepout" } else { "copper" }})).collect::<Vec<_>>(),+               "definitions": defs.iter().map(|(id, _, d)| json!({"id": id, "definitionSexpr": d})).collect::<Vec<_>>()})+    }++    fn one(def: String) -> Result<Adoption, Value> {+        plan(&board(), &[plan_zone()], &live(&[("z-1", "VLED pour", def)]), &[])+    }++    fn refused_on(def: String, field: &str) {+        let r = one(def).expect_err("must be refused");+        assert_eq!(r["errorCode"], "zone_adoption_refused", "{r}");+        let fields: Vec<&str> = r["differences"].as_array().map(|a| a.iter().filter_map(|d| d["field"].as_str()).collect()).unwrap_or_default();+        assert!(fields.contains(&field), "{field} not named in {r}");+        assert!(r["message"].as_str().unwrap().contains("not identical to the plan"), "{r}");+    }++    #[test]+    fn an_identical_unowned_zone_is_adopted_and_not_sent() {+        let a = one(staged_def("VLED pour", BODY, PTS)).unwrap();+        assert_eq!(a.adopted.len(), 1);+        assert_eq!(a.adopted[0]["id"], "z-1");+        assert_eq!(a.adopted[0]["ownedBefore"], false);+        assert_eq!(a.adopted[0]["outline"]["maxVertexDeviationMm"], 0.0);+        assert!(a.send_zones.is_empty() && a.send_owned.is_empty() && a.kept.is_empty() && a.left_alone.is_empty());+    }++    #[test]+    fn within_the_tolerance_any_start_vertex_and_either_direction_is_the_same_outline() {+        let a = one(staged_def("VLED pour", BODY, "(xy 24.004 39) (xy 1 39) (xy 1 1.006) (xy 24 1)")).unwrap();+        assert_eq!(a.adopted.len(), 1);+        let o = &a.adopted[0]["outline"];+        assert!(o["maxVertexDeviationMm"].as_f64().unwrap() <= 0.01 && o["maxVertexDeviationMm"].as_f64().unwrap() > 0.0, "{o}");+        let rev = one(staged_def("VLED pour", BODY, "(xy 1 39) (xy 24 39) (xy 24 1) (xy 1 1)")).unwrap();+        assert_eq!((rev.adopted.len(), rev.adopted[0]["outline"]["reversed"].clone()), (1, json!(true)));+    }++    #[test]+    fn each_differing_field_is_refused_and_named() {+        refused_on(staged_def("VLED pour", BODY, "(xy 1 1) (xy 24.02 1) (xy 24 39) (xy 1 39)"), "geometry");+        refused_on(staged_def("VLED pour", BODY, "(xy 1 1) (xy 24 1) (xy 24 39) (xy 12 39) (xy 1 39)"), "geometry");+        refused_on(staged_def("VLED pour", BODY, PTS).replace("(net \"VLED\")", "(net \"SIG\")"), "net");+        refused_on(staged_def("VLED pour", BODY, PTS).replace("(layer \"F.Cu\")", "(layer \"B.Cu\")"), "layer");+        refused_on(staged_def("VLED pour", &BODY.replace("(priority 1)", "(priority 2)"), PTS), "priority");+        refused_on(staged_def("VLED pour", &BODY.replace("(clearance 0.25)", "(clearance 0.3)"), PTS), "clearance");+        refused_on(staged_def("VLED pour", &BODY.replace("(min_thickness 0.25)", "(min_thickness 0.2)"), PTS), "min thickness");+        refused_on(staged_def("VLED pour", &BODY.replace("(connect_pads (clearance 0.25))", "(connect_pads yes (clearance 0.25))"), PTS), "pad connection");+        refused_on(staged_def("VLED pour", &BODY.replace("(thermal_gap 0.3)", "(thermal_gap 0.5)"), PTS), "thermal gap");+        refused_on(staged_def("VLED pour", &BODY.replace("(thermal_bridge_width 0.3)", "(thermal_bridge_width 0.4)"), PTS), "thermal bridge");+        refused_on(staged_def("VLED pour", &BODY.replace("(island_removal_mode 0)", "(island_removal_mode 1)"), PTS), "island removal");+        refused_on(staged_def("VLED pour", &BODY.replace("(island_removal_mode 0)", "(island_removal_mode 0) (mode hatch)"), PTS), "fill mode");+        refused_on(staged_def("VLED pour", &BODY.replace("(island_removal_mode 0)", "(island_removal_mode 0) (smoothing fillet) (radius 1)"), PTS), "native fill field the plan does not set");+        refused_on(staged_def("VLED pour", &format!("{BODY}\t\t(filled_areas_thickness yes)\n"), PTS), "filled areas thickness");+        refused_on(staged_def("VLED pour", &format!("{BODY}\t\t(locked yes)\n"), PTS), "locked");+        refused_on(staged_def("VLED pour", &format!("{BODY}\t\t(attr (teardrop (type padvia)))\n"), PTS), "native field the plan does not set");+    }++    #[test]+    fn an_otherwise_identical_zone_with_another_name_is_not_adopted() {+        let a = plan(&board(), &[plan_zone()], &live(&[("z-1", "VLED copper", staged_def("VLED copper", BODY, PTS))]), &[]).unwrap();+        assert!(a.adopted.is_empty());+        assert_eq!(a.send_zones.len(), 1, "the plan zone is landed as its own zone");+        assert_eq!(a.left_alone.len(), 1, "the other zone is left alone");+    }++    #[test]+    fn an_unrelated_zone_is_never_adopted_and_is_left_alone() {+        let keepout = "(zone\n\t\t(layers \"F.Cu\" \"B.Cu\")\n\t\t(name \"RF window\")\n\t\t(hatch edge 0.5)\n\t\t(connect_pads (clearance 0))\n\t\t(min_thickness 0.25)\n\t\t(keepout (tracks allowed) (vias allowed) (pads allowed) (copperpour not_allowed) (footprints allowed))\n\t\t(fill (thermal_gap 0.5) (thermal_bridge_width 0.5))\n\t\t(polygon (pts (xy 30 5) (xy 40 5) (xy 40 15) (xy 30 15)))\n\t)".to_string();+        let a = plan(&board(), &[plan_zone()], &live(&[("z-1", "VLED pour", staged_def("VLED pour", BODY, PTS)), ("k-1", "RF window", keepout)]), &[]).unwrap();+        assert_eq!(a.adopted.len(), 1);+        assert_eq!(a.left_alone, vec![json!({"id": "k-1", "name": "RF window", "type": "keepout", "net": Value::Null})]);+        assert!(a.send_zones.is_empty() && a.send_owned.is_empty());+    }++    #[test]+    fn two_board_zones_of_the_plan_name_are_refused() {+        let d = staged_def("VLED pour", BODY, PTS);+        let r = plan(&board(), &[plan_zone()], &live(&[("z-1", "VLED pour", d.clone()), ("z-2", "VLED pour", d)]), &[]).unwrap_err();+        assert_eq!(r["errorCode"], "zone_adoption_ambiguous");+    }++    #[test]+    fn owned_zones_are_kept_when_identical_and_sent_when_they_differ() {+        let same = plan(&board(), &[plan_zone()], &live(&[("z-1", "VLED pour", staged_def("VLED pour", BODY, PTS))]), &["z-1".into(), "z-9".into()]).unwrap();+        assert_eq!((same.kept.len(), same.adopted.len()), (1, 0));+        assert!(same.send_zones.is_empty());+        assert_eq!(same.send_owned, vec!["z-9".to_string()], "an owned zone the plan dropped is still reconciled by the bridge");+        let diff = plan(&board(), &[plan_zone()], &live(&[("z-1", "VLED pour", staged_def("VLED pour", &BODY.replace("(clearance 0.25)", "(clearance 0.3)"), PTS))]), &["z-1".into()]).unwrap();+        assert_eq!((diff.kept.len(), diff.send_zones.len()), (0, 1));+        assert_eq!(diff.send_owned, vec!["z-1".to_string()], "the bridge replaces the owned zone, as before");+    }++    #[test]+    fn a_solid_plane_and_a_new_zone_in_one_plan() {+        let gnd = "(zone\n\t\t(net \"GND\")\n\t\t(layer \"B.Cu\")\n\t\t(name \"GND plane\")\n\t\t(hatch edge 0.5)\n\t\t(connect_pads yes (clearance 0.25))\n\t\t(min_thickness 0.25)\n\t\t(fill (thermal_gap 0.3) (thermal_bridge_width 0.3) (island_removal_mode 0))\n\t\t(polygon (pts (xy 0.5 0.5) (xy 49.5 0.5) (xy 49.5 39.5) (xy 0.5 39.5)))\n\t)".to_string();+        let a = plan(&board(), &[plan_zone(), gnd_zone()], &live(&[("g-1", "GND plane", gnd)]), &[]).unwrap();+        assert_eq!(a.adopted.len(), 1);+        assert_eq!(a.adopted[0]["name"], "GND plane");+        assert_eq!(a.send_zones.len(), 1);+        assert_eq!(a.send_zones[0]["name"], "VLED pour");+    }++    #[test]+    fn a_bridge_without_zone_definitions_adopts_nothing() {+        let mut l = live(&[("z-1", "VLED pour", staged_def("VLED pour", BODY, PTS))]);+        l.as_object_mut().unwrap().remove("definitions");+        let a = plan(&board(), &[plan_zone()], &l, &[]).unwrap();+        assert!(a.adopted.is_empty() && a.note.is_some());+        assert_eq!(a.send_zones.len(), 1, "sent as before: the bridge's own ownership refusal still applies");+    }+}
crates/adom-aiflow/src/pourverify.rs+250−9
@@ -447,6 +447,127 @@ pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result<     ) } +// -- issue #69 follow-up: `land pours` adopts a zone already on the board that is identical -------+// to a plan zone (pour_adopt.rs decides; this is the comparison). verify-pours above is unchanged.++/// How far a board vertex may sit from the plan's and still be the same outline (mm, Euclidean).+pub const ADOPT_TOLERANCE_MM: f64 = 0.01;++/// What adoption compares, in the plan's words (keepouts: their keepout and placement rules instead+/// of the copper settings).+pub const ADOPT_FIELDS: [&str; 15] = ["name", "net", "layer", "geometry", "priority", "clearance", "min thickness",+    "pad connection", "thermal gap", "thermal bridge", "island removal", "island area min", "fill mode",+    "filled areas thickness", "locked"];++/// The best vertex-for-vertex alignment of the board loop with the plan loop, starting anywhere and+/// in either direction: (start vertex, reversed, max deviation in mm, the vertex at that deviation).+/// None when the vertex counts differ or a loop has fewer than three vertices.+fn outline_best(plan: &[(f64, f64)], board: &[(f64, f64)]) -> Option<(usize, bool, f64, usize)> {+    let n = plan.len();+    if n < 3 || board.len() != n {+        return None;+    }+    let mut best: Option<(usize, bool, f64, usize)> = None;+    for reversed in [false, true] {+        for start in 0..n {+            let (mut worst, mut at) = (0.0f64, 0usize);+            for (i, p) in plan.iter().enumerate() {+                let j = if reversed { (start + n - i) % n } else { (start + i) % n };+                let d = (p.0 - board[j].0).hypot(p.1 - board[j].1);+                if d > worst {+                    worst = d;+                    at = i;+                }+                if best.is_some_and(|b| worst >= b.2) {+                    break;+                }+            }+            if best.is_none_or(|b| worst < b.2) {+                best = Some((start, reversed, worst, at));+            }+        }+    }+    best+}++/// Every field where a live board zone (`live`: its definition, as kicad_zone_state returns it)+/// differs from a plan zone, empty when the two are identical, and the outline evidence.+/// Identical means: the same name, net, layers, outline (every vertex within ADOPT_TOLERANCE_MM, one+/// outline, any start vertex, either direction), priority, clearance, min thickness, pad connection+/// and fill settings (thermal gap and bridge, island removal and its area, a solid fill, filled+/// areas thickness), not locked; a keepout's keepout and placement rules instead of the copper+/// settings. A field this comparison does not know (smoothing, hatched fill settings, a teardrop+/// attribute, anything newer) is a difference, never ignored.+pub fn adoption_differences(board: &Board, plan_zone: &Value, live: &Node) -> Result<(Vec<Value>, Value), String> {+    let label = plan_zone["name"].as_str().unwrap_or("unnamed zone");+    let loops = planned_loops(plan_zone).map_err(|e| format!("{label}: {e}"))?;+    if loops.len() != 1 {+        return Err(format!("{label}: native polygon loops are verification-only and cannot be adopted by land pours"));+    }+    let expected = expected_zone(board, plan_zone, &loops)?;+    let (mut want, mut have) = (signature(board, &expected), signature(board, live));+    for v in [&mut want, &mut have] {+        if let Some(o) = v.as_object_mut() {+            o.remove("polygons");+        }+    }+    let mut diffs = differences(&want, &have, &expected, live);+    let (wn, hn) = (zone_name(&expected), zone_name(live));+    if wn != hn {+        diffs.push(json!({"field":"name","plan":wn,"board":hn}));+    }+    let polygons = live.find_all("polygon").count();+    let (pw, ph) = (points(&expected), points(live));+    let best = outline_best(&pw, &ph);+    let evidence = json!({"vertices": pw.len(), "boardVertices": ph.len(), "boardPolygons": polygons, "toleranceMm": ADOPT_TOLERANCE_MM,+        "maxVertexDeviationMm": best.map(|b| (b.2 * 1e6).round() / 1e6), "startVertex": best.map(|b| b.0), "reversed": best.map(|b| b.1)});+    if polygons != 1 {+        diffs.push(json!({"field":"geometry","plan":"one outline","board":format!("{polygons} polygons"),"detail":format!("the board zone has {polygons} polygons; the plan has one outline")}));+    } else {+        match best {+            None => diffs.push(json!({"field":"geometry","plan":format!("{} vertices", pw.len()),"board":format!("{} vertices", ph.len()),"detail":format!("plan {} vertices, board {}", pw.len(), ph.len())})),+            Some((_, _, dev, at)) if dev > ADOPT_TOLERANCE_MM => diffs.push(json!({"field":"geometry","plan":format!("{} vertices", pw.len()),"board":format!("{} vertices", ph.len()),+                "detail":format!("plan vertex {at} ({}, {}) is {:.4} mm from the board's at the best alignment; the tolerance is {ADOPT_TOLERANCE_MM} mm", pw[at].0, pw[at].1, dev)})),+            Some(_) => {}+        }+    }+    let fat = |n: &Node| n.find("filled_areas_thickness").and_then(Node::value).unwrap_or("no").to_string();+    if fat(&expected) != fat(live) {+        diffs.push(json!({"field":"filled areas thickness","plan":fat(&expected),"board":fat(live)}));+    }+    // signature() reads the fill mode as a number: a hatched fill, (mode hatch), must not read as solid+    let mode = |n: &Node| n.find("fill").and_then(|f| f.find("mode")).and_then(Node::value).unwrap_or("solid").to_string();+    if mode(&expected) != mode(live) {+        diffs.push(json!({"field":"fill mode","plan":mode(&expected),"board":mode(live)}));+    }+    let locked = live.find("locked").and_then(Node::value).is_some_and(|v| v != "no");+    if locked {+        diffs.push(json!({"field":"locked","plan":"no","board":"yes"}));+    }+    let keepout = live.find("keepout").is_some();+    let known = ["net", "net_name", "layer", "layers", "uuid", "tstamp", "name", "hatch", "priority", "connect_pads",+        "min_thickness", "filled_areas_thickness", "fill", "polygon", "filled_polygon", "fill_segments", "keepout", "locked"];+    for child in live.nodes().filter(|x| !known.contains(&x.tag.as_str()) && !(keepout && x.tag == "placement")) {+        diffs.push(json!({"field":"native field the plan does not set","plan":Value::Null,"board":child.tag}));+    }+    if let Some(fill) = live.find("fill") {+        for child in fill.nodes().filter(|x| !["thermal_gap", "thermal_bridge_width", "island_removal_mode", "island_area_min", "mode"].contains(&x.tag.as_str())) {+            diffs.push(json!({"field":"native fill field the plan does not set","plan":Value::Null,"board":child.tag}));+        }+    }+    if let Some(cp) = live.find("connect_pads") {+        for child in cp.nodes().filter(|x| x.tag != "clearance") {+            diffs.push(json!({"field":"native pad-connection field the plan does not set","plan":Value::Null,"board":child.tag}));+        }+    }+    Ok((diffs, evidence))+}++/// One line naming each difference, for the refusal.+pub fn adoption_line(d: &[Value]) -> String {+    diff_line(d)+}+ /// True for a `land-pours` outcome that accepted zones already on the saved board. pub fn is_existing(outcome: &Value) -> bool {     outcome["mode"].as_str().is_some_and(|m| EXISTING_MODES.contains(&m))@@ -699,11 +820,75 @@ pub fn refusal(step: &str, reply: &Value, zones: &[Value]) -> String {  type Reply = Result<(String, Vec<String>), (String, Vec<String>)>; +/// Issue #154: the zones already on a saved board, written as a pour plan in `verify-pours`' own+/// words (net, layers, every outline loop, saved ID, priority, pad connection, clearance, minimum+/// width, thermal relief, island removal; keepouts with their rules). It describes the board, so+/// it always matches it: it records whose zones they are (a human's, adopted), never an+/// independent design.+pub fn plan_from_board(board: &Board) -> Value {+    let mut zones = Vec::new();+    for (i, n) in board.root.find_all("zone").enumerate() {+        let sig = signature(board, n);+        let mut layers: Vec<String> = n.find("layer").or_else(|| n.find("layers"))+            .map(|x| (1..).map_while(|j| x.atom(j)).map(str::to_string).collect()).unwrap_or_default();+        layers.dedup();+        let loops: Vec<Value> = n.find_all("polygon").filter_map(|p| p.find("pts"))+            .map(|pts| json!(pts.find_all("xy").filter_map(|xy| Some(json!([xy.number(1)?, xy.number(2)?]))).collect::<Vec<_>>()))+            .collect();+        let name = n.find("name").and_then(Node::value).map(str::to_string).unwrap_or_else(|| format!("board zone {i}"));+        let mut z = json!({"name": name, "layers": layers});+        if loops.len() == 1 { z["polygon"] = loops[0].clone(); } else { z["polygons"] = json!(loops); }+        if let Some(id) = zone_id(n) { z["boardZoneUuid"] = json!(id); }+        if let Some(k) = n.find("keepout") {+            let rule = |tag: &str| k.find(tag).and_then(Node::value) == Some("not_allowed");+            z["type"] = json!("keepout");+            z["keepout"] = json!({"tracks": rule("tracks"), "vias": rule("vias"), "pads": rule("pads"), "copperPour": rule("copperpour"), "footprints": rule("footprints")});+        } else {+            z["net"] = sig["net"].clone();+            if let Some(p) = sig["priority"].as_f64().filter(|p| *p > 0.0) { z["priority"] = json!(p as u64); }+            z["connection"] = sig["connection"].clone();+            z["clearance"] = sig["clearance"].clone();+            z["minThickness"] = sig["minThickness"].clone();+            z["thermalGap"] = sig["thermal_gap"].clone();+            z["thermalBridge"] = sig["thermal_bridge_width"].clone();+            z["islandRemoval"] = sig["island_removal_mode"].clone();+            if sig["island_removal_mode"] == "area" { z["islandAreaMin"] = sig["island_area_min"].clone(); }+        }+        zones.push(z);+    }+    json!({"zones": zones, "source": "from-board", "note": "written by `verify-pours --from-board` from the saved board's own zones: it records them as they are (a human's pours, adopted), not as an independent design"})+}+ /// `verify-pours`: compare, bind the plan's provenance, and optionally record a reviewed plan or /// adopt the matched zones. Writes the `land-pours` outcome only for a comparison.-pub fn command(r: &mut Run, dir: &Path, plan: &str, adopt_zones: bool, why: Option<&str>, record: bool) -> Reply {+pub fn command(r: &mut Run, dir: &Path, plan: Option<&str>, from_board: bool, adopt_zones: bool, why: Option<&str>, record: bool) -> Reply {     let e = |m: String| (m, Vec::<String>::new());     let why = why.map(str::trim).filter(|w| !w.is_empty());+    // issue #154: --from-board writes the plan from the saved board's zones (a human's pours)+    let mut notes: Vec<String> = Vec::new();+    let written;+    let plan: &str = match (plan, from_board) {+        (Some(_), true) => return Err(e("pass --plan <file> or --from-board, not both".into())),+        (None, false) => return Err(("verify-pours needs the zones to compare with: --plan <the plan `pour` wrote, or a reviewed plan>, or --from-board when the human drew the zones (hands-on human pours)".into(),+            vec!["Human pours: `verify-pours --from-board --adopt --why \"poured by the human\"` writes the plan from their saved zones and adopts them.".into()])),+        (Some(p), false) => p,+        (None, true) => {+            if record { return Err(e("--record-plan records a plan written before the zones change; --from-board reads the zones as they are now".into())); }+            let board_path = r.current_board();+            let board = Board::load(&board_path).map_err(e)?;+            let v = plan_from_board(&board);+            if v["zones"].as_array().is_none_or(Vec::is_empty) {+                return Err((format!("{board_path} has no zones to read a plan from"), vec!["Ask the human to draw and fill their pours in the editor and save, then `adopt-board` and run this again.".into()]));+            }+            let out = dir.join("plan-pours-from-board.json");+            std::fs::write(&out, serde_json::to_string_pretty(&v).unwrap()).map_err(|x| e(format!("{}: {x}", out.display())))?;+            if crate::handson::mode_of(&r.data, "pours") != "human" {+                notes.push("pours is not a hands-on human step on this run: a plan read from the board always matches the board, so this records the zones as they are, not that they follow an independent plan. If the AI drew them, `pour` + `land pours` (or a plan recorded with --record-plan before the zones changed) is the stronger proof.".into());+            }+            written = out.display().to_string();+            &written+        }+    };     let plan_path = Path::new(plan);     if record {         if adopt_zones {@@ -733,6 +918,7 @@ pub fn command(r: &mut Run, dir: &Path, plan: &str, adopt_zones: bool, why: Opti     }     let provenance = match provenance(&r.data, &plan_hash, plan_path) {         Ok(p) => p,+        Err(_) if from_board => json!({"kind": "from-board", "zonesBy": if crate::handson::mode_of(&r.data, "pours") == "human" { "the human (hands-on human step)" } else { "unknown" }, "why": why, "note": "plan read from the saved board's zones: they are recorded as drawn, not checked against an independent design"}),         Err(reason) => match why {             Some(w) => json!({"kind": "accepted-with-why", "why": w, "refusal": reason}),             None => {@@ -778,10 +964,14 @@ pub fn command(r: &mut Run, dir: &Path, plan: &str, adopt_zones: bool, why: Opti         let carry = &proof["zonesOnlyCarry"];         if carry["carried"] == true { hints.insert(0, carry["note"].as_str().unwrap_or("").to_string()); }         if carry["carried"] == false { hints.insert(0, format!("the zones-only adoption did not carry placement and route evidence: {}; verify placement and land route and vias again", carry["why"].as_str().unwrap_or("?"))); }+        if from_board { hints.insert(0, format!("plan written from the saved board's zones: {plan}")); }+        hints.extend(notes);         return Ok((format!("existing saved pours match the reviewed plan ({kind}); {} zone(s) adopted: owned by this run, recorded as adopted, not landed", proof["adoptedZoneIds"].as_array().map(|a| a.len()).unwrap_or(0)), hints));     }-    Ok((format!("existing saved pours match the reviewed plan ({kind}); no copper or ownership changed"),-        vec!["Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof. Add --adopt --why <reason> to take ownership of these zones.".into()]))+    let mut hints = vec!["Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof. Add --adopt --why <reason> to take ownership of these zones.".to_string()];+    if from_board { hints.insert(0, format!("plan written from the saved board's zones: {plan}")); }+    hints.extend(notes);+    Ok((format!("existing saved pours match the reviewed plan ({kind}); no copper or ownership changed"), hints)) }  #[cfg(test)]@@ -1145,11 +1335,11 @@ mod tests {         let mut r = prov_run(&d, now - 200, now - 100);         let plan = write_plan(&d, "plan-actual.json");         let ps = plan.display().to_string();-        let (m, _) = command(&mut r, &d, &ps, false, None, false).unwrap_err();+        let (m, _) = command(&mut r, &d, Some(&ps), false, false, None, false).unwrap_err();         assert!(m.contains("provenance"), "{m}");         assert!(r.data["outcomes"].get("land-pours").is_none(), "a refusal records no proof");-        assert!(command(&mut r, &d, &ps, true, None, false).unwrap_err().0.contains("--why"));-        let (m, _) = command(&mut r, &d, &ps, false, Some("plan typed from the native review, not the board"), false).unwrap();+        assert!(command(&mut r, &d, Some(&ps), false, true, None, false).unwrap_err().0.contains("--why"));+        let (m, _) = command(&mut r, &d, Some(&ps), false, false, Some("plan typed from the native review, not the board"), false).unwrap();         assert!(m.contains("accepted-with-why"), "{m}");         let o = r.data["outcomes"]["land-pours"].clone();         assert_eq!(o["planProvenance"]["why"], "plan typed from the native review, not the board");@@ -1157,7 +1347,7 @@ mod tests {         let spec = r.data["spec"].as_str().unwrap().to_string();         assert!(bound(&o, Path::new(&r.current_board()), Path::new(&spec)));         // adoption: owned, recorded, reported as adopted rather than landed-        let (m, _) = command(&mut r, &d, &ps, true, Some("zones written offline (#69)"), false).unwrap();+        let (m, _) = command(&mut r, &d, Some(&ps), false, true, Some("zones written offline (#69)"), false).unwrap();         assert!(m.contains("adopted"), "{m}");         let o = r.data["outcomes"]["land-pours"].clone();         assert_eq!(o["mode"], "adopted-existing");@@ -1169,12 +1359,30 @@ mod tests {         let d2 = tmpdir("cmd2");         let mut r2 = prov_run(&d2, now - 200, now + 100);         let plan2 = write_plan(&d2, "reviewed.json");-        command(&mut r2, &d2, &plan2.display().to_string(), false, None, true).unwrap();-        let (m, _) = command(&mut r2, &d2, &plan2.display().to_string(), false, None, false).unwrap();+        command(&mut r2, &d2, Some(&plan2.display().to_string()), false, false, None, true).unwrap();+        let (m, _) = command(&mut r2, &d2, Some(&plan2.display().to_string()), false, false, None, false).unwrap();         assert!(m.contains("reviewed-before-zone-change"), "{m}");         let _ = std::fs::remove_dir_all(d);         let _ = std::fs::remove_dir_all(d2);     }+    #[test]+    fn from_board_writes_the_humans_plan_and_adopts_it() {+        // issue #154: the hands-on human pours recipe runs as printed, with no --plan+        let d = tmpdir("fromboard");+        let now = aiflow_run::parse(&aiflow_run::now()).unwrap();+        let mut r = prov_run(&d, now - 200, now - 100);+        let (m, h) = command(&mut r, &d, None, false, true, Some("poured by the human"), false).unwrap_err();+        assert!(m.contains("--from-board") && h.join(" ").contains("--from-board"), "{m}");+        let (m, h) = command(&mut r, &d, None, true, true, Some("poured by the human"), false).unwrap();+        assert!(m.contains("from-board") && m.contains("adopted"), "{m}");+        assert!(d.join("plan-pours-from-board.json").is_file());+        assert!(h.iter().any(|x| x.contains("not a hands-on human step")), "{h:?}");+        let o = r.data["outcomes"]["land-pours"].clone();+        assert_eq!(o["mode"], "adopted-existing");+        assert_eq!(o["planProvenance"]["kind"], "from-board");+        assert_eq!(r.data["ownedZoneIds"], json!(["aaaa-1"]));+        let _ = std::fs::remove_dir_all(d);+    }     // -- issue #83: land pours after adoption, and refusal detail ----------------------------------     #[test]     fn adopted_zones_that_still_match_need_no_landing() {@@ -1209,4 +1417,37 @@ mod tests {         let m = refusal("native-zone-plan", &json!({"errorCode":"drc","message":"clearance at (1, 2)","violations":[{"type":"clearance"},{"type":"clearance"}]}), &zones);         assert!(m.contains("drc: clearance at (1, 2); violations {\"clearance\": 2}"), "{m}");     }++    #[test]+    fn plan_from_board_describes_a_humans_zones_exactly() {+        // issue #154: zones drawn in KiCad by the human (priority, thermal relief, island area, a keepout)+        let text = r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (net 0 "") (net 1 "GND") (net 2 "VBAT")+ (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts"))+ (zone (net 1) (net_name "GND") (layers "F.Cu" "B.Cu") (uuid "aaaa-1") (name "ground") (hatch edge 0.5) (priority 2)+  (connect_pads (clearance 0.3)) (min_thickness 0.2) (filled_areas_thickness no)+  (fill yes (thermal_gap 0.4) (thermal_bridge_width 0.35) (island_removal_mode 2) (island_area_min 5))+  (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))+  (filled_polygon (layer "F.Cu") (pts (xy 0 0) (xy 10 0) (xy 10 10)))+  (filled_polygon (layer "B.Cu") (pts (xy 0 0) (xy 10 0) (xy 10 10))))+ (zone (net 2) (net_name "VBAT") (layer "F.Cu") (uuid "bbbb-2") (hatch edge 0.5)+  (connect_pads yes (clearance 0.2)) (min_thickness 0.25) (filled_areas_thickness no)+  (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5))+  (polygon (pts (xy 11 11) (xy 19 11) (xy 19 19)))+  (filled_polygon (layer "F.Cu") (pts (xy 11 11) (xy 19 11) (xy 19 19))))+ (zone (net 0) (net_name "") (layer "B.Cu") (uuid "cccc-3") (name "no pour") (hatch edge 0.5)+  (keepout (tracks allowed) (vias allowed) (pads allowed) (copperpour not_allowed) (footprints allowed))+  (fill (thermal_gap 0.5) (thermal_bridge_width 0.5))+  (polygon (pts (xy 12 1) (xy 18 1) (xy 18 6)))))"#;+        let b = Board::from_text("test", text.to_string()).unwrap();+        let plan = plan_from_board(&b);+        let zones = plan["zones"].as_array().unwrap();+        assert_eq!(zones.len(), 3);+        assert_eq!(zones[0]["priority"], 2);+        assert_eq!(zones[0]["islandRemoval"], "area");+        assert_eq!(zones[1]["connection"], "solid");+        assert_eq!(zones[2]["keepout"]["copperPour"], true);+        let proof = check_with(&b, &plan, true).unwrap();+        assert_eq!(proof["pass"], true, "{proof}");+        assert_eq!(proof["groups"].as_array().unwrap().len(), 3);+    } }
crates/adom-aiflow/src/preboard.rs+52−3
@@ -861,14 +861,42 @@ pub fn wiki_slug(s: &str) -> String {     out.trim_end_matches('-').to_string() } -/// The slugs a part's component page may have: the lowercase MPN, then the wiki's slug form (issue #76).+/// The MPN without its packaging suffix after a "/" (MCP73831T-2ACI/OT -> MCP73831T-2ACI, LP5907MFX-3.3/NOPB ->+/// LP5907MFX-3.3), or None when it has none. The Adom part factory names a page by this base (issue #161:+/// adom/mcp73831t-2aci for MCP73831T-2ACI/OT); other pages keep the suffix in their slug (adom/sp485een-l-tr+/// for SP485EEN-L/TR), so the full forms are always tried first.+pub fn mpn_base(mpn: &str) -> Option<String> {+    let (b, rest) = mpn.trim().split_once('/')?;+    let b = b.trim();+    (!b.is_empty() && !rest.trim().is_empty()).then(|| b.to_string())+}++/// The slugs a part's component page may have: the lowercase MPN, then the wiki's slug form (issue #76),+/// then both forms of the MPN without its "/" packaging suffix (issue #161). pub fn part_slug_candidates(mpn: &str) -> Vec<String> {     let mut v = vec![mpn.trim().to_lowercase()];-    let w = wiki_slug(mpn);-    if !w.is_empty() && !v.contains(&w) { v.push(w); }+    let mut add = |s: String| if !s.is_empty() && !v.contains(&s) { v.push(s); };+    add(wiki_slug(mpn));+    if let Some(b) = mpn_base(mpn) { add(b.to_lowercase()); add(wiki_slug(&b)); }     v } +/// Whether a component page's own MPN (page.json component.mpn) names this BOM MPN: the same MPN, or the+/// BOM MPN is that page's MPN plus a "/" packaging suffix (issue #161). Case and spaces are ignored.+pub fn page_mpn_matches(page_mpn: &str, mpn: &str) -> bool {+    let k = |s: &str| s.trim().to_ascii_uppercase();+    k(page_mpn) == k(mpn) || mpn_base(mpn).map(|b| k(&b) == k(page_mpn)).unwrap_or(false)+}++/// How a page slug was reached from the MPN, when it was only through the base MPN (the "/" suffix+/// dropped): a short note for the parts list, else None.+pub fn matched_by_base(page: &str, mpn: &str) -> Option<String> {+    let slug = page.rsplit('/').next().unwrap_or(page).to_lowercase();+    let full = [mpn.trim().to_lowercase(), wiki_slug(mpn)];+    let b = mpn_base(mpn)?;+    (!full.contains(&slug) && (slug == b.to_lowercase() || slug == wiki_slug(&b))).then(|| format!("{page} is the page for {b}, the MPN without its /{} packaging suffix", mpn.trim().split_once('/').map(|x| x.1.trim()).unwrap_or("")))+}+ /// The component page for an MPN among `adom-wiki search` hits: a hardware page the search matched by /// exact slug or exact title, else a hardware page whose slug is one of the MPN's slug candidates /// (the search ranks such a page by its title or triggers, not as an exact slug). "owner/slug".@@ -894,6 +922,27 @@ mod bom_parts_tests {         assert_eq!(wiki_slug("LM317T/NOPB"), "lm317t-nopb");         assert_eq!(part_slug_candidates("AP2112K-3.3TRG1"), vec!["ap2112k-3.3trg1".to_string(), "ap2112k-3-3trg1".to_string()]);         assert_eq!(part_slug_candidates("TPS54202DDCR"), vec!["tps54202ddcr".to_string()]);+        // issue #161: the part factory names MCP73831T-2ACI/OT's page adom/mcp73831t-2aci; SP485EEN-L/TR keeps its suffix+        assert_eq!(part_slug_candidates("MCP73831T-2ACI/OT"), vec!["mcp73831t-2aci/ot", "mcp73831t-2aci-ot", "mcp73831t-2aci"].iter().map(|s| s.to_string()).collect::<Vec<_>>());+        assert_eq!(part_slug_candidates("LP5907MFX-3.3/NOPB")[2..], ["lp5907mfx-3.3".to_string(), "lp5907mfx-3-3".to_string()]);+        assert_eq!(mpn_base("SP485EEN-L/TR").as_deref(), Some("SP485EEN-L"));+        assert_eq!(mpn_base("TPS54202DDCR"), None);+        assert_eq!(mpn_base("ABC/"), None);+        assert!(page_mpn_matches("MCP73831T-2ACI", "MCP73831T-2ACI/OT"));+        assert!(page_mpn_matches("SP485EEN-L/TR", "sp485een-l/tr"));+        assert!(!page_mpn_matches("MCP73831T-2ACI/OT", "MCP73831T-2ACI"));+        assert!(!page_mpn_matches("MCP73831T-2ATI", "MCP73831T-2ACI/OT"));+        // what `adom-wiki search MCP73831T-2ACI/OT` returns (2026-11-06): the base pages ranked by phrase only+        let hits = vec![+            json!({"type": "hardware", "owner": "adom", "slug": "mcp73831", "match": {"tier": "phrase"}}),+            json!({"type": "hardware", "owner": "adom", "slug": "mcp73831t-2aci", "title": "MCP73831T-2ACI", "match": {"tier": "phrase"}}),+        ];+        assert_eq!(part_page(&hits, "MCP73831T-2ACI/OT").as_deref(), Some("adom/mcp73831t-2aci"));+        assert!(matched_by_base("adom/mcp73831t-2aci", "MCP73831T-2ACI/OT").unwrap().contains("without its /OT packaging suffix"));+        assert_eq!(matched_by_base("adom/sp485een-l-tr", "SP485EEN-L/TR"), None);+        // the page that keeps the suffix wins over the base page+        let both = vec![json!({"type": "hardware", "owner": "adom", "slug": "sp485een-l", "match": {"tier": "phrase"}}), json!({"type": "hardware", "owner": "adom", "slug": "sp485een-l-tr", "match": {"tier": "phrase"}})];+        assert_eq!(part_page(&both, "SP485EEN-L/TR").as_deref(), Some("adom/sp485een-l-tr"));         // what `adom-wiki search AP2112K-3.3TRG1` returns: the page is ranked by its triggers, not as an exact slug         let hits = vec![             json!({"type": "hardware", "owner": "adom", "slug": "amfm-receiver-molecule", "match": {"tier": "phrase"}}),
crates/adom-aiflow/src/project_lib.rs+62−1
@@ -8,6 +8,7 @@ //! provenance.json: {"parts": [{"mpn": "ABC-123", "source": "datasheet rev C, p. 12", "symbol": "ABC-123", //! "footprint": "QFN-16_3x3", "model": "3dmodels/ABC-123.step"}]} (or {"parts": {"ABC-123": {...}}}). //! symbol defaults to the MPN, footprint to the symbol's Footprint property, model to the footprint's model.+//! A wiki page for the same MPN wins unless the entry says "prefer": true (issue #157, `prefer`).  use aiflow_board::sx; use serde_json::{json, Value};@@ -121,11 +122,41 @@ impl ProjectLib {     } } +/// Whether a provenance.json entry asks to win over a public wiki page for its MPN (issue #157):+/// `"prefer": true` (or `"prefer": "project-lib"`, or `"precedence": "project-lib"`).+fn prefers_lib(e: &Value) -> bool {+    e["prefer"].as_bool().unwrap_or(false) || [&e["prefer"], &e["precedence"]].iter().any(|v| v.as_str().map(|s| matches!(s.trim().to_ascii_lowercase().as_str(), "project-lib" | "projectlib" | "project library")).unwrap_or(false))+}++/// Issue #157: a BOM row that has a wiki page AND a project-library entry for the same MPN. An entry marked+/// `"prefer": true` that is complete takes the row (row["wiki"] becomes null, row["wikiOverridden"] keeps the+/// page), so `project-page` packages the library's own symbol, footprint and model, never the page's. Every+/// other overlap is named, never silently dropped: the page is used and the note says how to switch.+pub fn prefer(rows: &mut [Value], lib: &ProjectLib) -> Vec<String> {+    let mut notes = Vec::new();+    for row in rows.iter_mut().filter(|r| r["wiki"].is_string()) {+        let mpn = row["mpn"].as_str().unwrap_or("").to_string();+        let page = row["wiki"].as_str().unwrap_or("").to_string();+        let Some(e) = lib.entries.get(&key(&mpn)) else { continue };+        let wants = prefers_lib(e);+        match lib.part(&mpn) {+            Ok(p) if wants => {+                row["projectLib"] = p; row["source"] = json!("project-lib"); row["wikiOverridden"] = json!(page.clone()); row["wiki"] = Value::Null;+                notes.push(format!("{mpn}: the project library's entry is used, not the wiki page {page} (provenance.json \"prefer\": true)"));+            }+            Ok(_) => notes.push(format!("{mpn}: both the wiki page {page} and a complete project-library entry; the wiki page is used. Compare them (footprint, pads, 3D height, manufacturer) and, if the library's verified entry should win, add \"prefer\": true to its provenance.json entry and run `parts` again")),+            Err(why) if wants => notes.push(format!("{mpn}: provenance.json prefers the project library, but its entry is incomplete ({}), so the wiki page {page} is used until it is complete", why.join("; "))),+            Err(_) => {}+        }+    }+    notes+}+ /// Every BOM row with no wiki page is looked up in the project library: a complete part gets /// row["projectLib"]; the rest come back as (mpn, why) for the parts report. pub fn resolve(rows: &mut [Value], lib: &ProjectLib) -> Vec<(String, String)> {     let mut missing = Vec::new();-    for row in rows.iter_mut().filter(|r| r["wiki"].is_null()) {+    for row in rows.iter_mut().filter(|r| r["wiki"].is_null() && !r["projectLib"].is_object()) {         let mpn = row["mpn"].as_str().unwrap_or("").to_string();         match lib.part(&mpn) {             Ok(p) => { row["projectLib"] = p; row["source"] = json!("project-lib"); }@@ -226,6 +257,36 @@ mod tests {         let _ = std::fs::remove_dir_all(&d);     } +    #[test]+    fn a_preferred_project_library_entry_wins_over_the_wiki_page() {+        // issue #157: a verified private entry for an MPN that also has a public page+        let d = tmp("prefer");+        fixture(&d, true);+        let lib = ProjectLib::load(&d).unwrap();+        let mut rows = vec![json!({"mpn": "ABC-123", "refs": ["U1"], "wiki": "adom/abc-123"}), json!({"mpn": "XYZ-9", "refs": ["U2"], "wiki": "adom/xyz-9"})];+        // not marked: the page is used, the overlap is named with the way to switch+        let notes = prefer(&mut rows, &lib);+        assert_eq!(rows[0]["wiki"], "adom/abc-123");+        assert!(rows[0]["projectLib"].is_null());+        assert_eq!(notes.len(), 1, "{notes:?}");+        assert!(notes[0].contains("both the wiki page adom/abc-123") && notes[0].contains("\"prefer\": true"), "{notes:?}");+        // marked: the library entry takes the row; an incomplete marked entry keeps the page and says why+        std::fs::write(d.join("provenance.json"), r#"{"parts": [{"mpn": "abc-123", "source": "datasheet rev C, p. 12", "prefer": true}, {"mpn": "XYZ-9", "precedence": "project-lib"}]}"#).unwrap();+        let lib = ProjectLib::load(&d).unwrap();+        let notes = prefer(&mut rows, &lib);+        assert!(rows[0]["wiki"].is_null());+        assert_eq!(rows[0]["wikiOverridden"], "adom/abc-123");+        assert_eq!(rows[0]["source"], "project-lib");+        assert!(rows[0]["projectLib"]["footprintFile"].as_str().unwrap().ends_with("QFN-16.kicad_mod"));+        assert_eq!(rows[1]["wiki"], "adom/xyz-9");+        assert!(notes[0].contains("project library's entry is used, not the wiki page adom/abc-123"), "{notes:?}");+        assert!(notes[1].contains("XYZ-9: provenance.json prefers the project library, but its entry is incomplete"), "{notes:?}");+        // resolve leaves the preferred row as it is and reports nothing missing+        assert!(resolve(&mut rows, &lib).is_empty());+        assert_eq!(report(&json!(rows)).len(), 1);+        let _ = std::fs::remove_dir_all(&d);+    }+     #[test]     fn a_missing_model_fails_the_part() {         let d = tmp("nomodel");
crates/adom-aiflow/src/schematic_symbols.rs+97−10
@@ -14,7 +14,9 @@ //!    power pin is an error on op-amps and comparators and a warning on other ICs (vendor symbols often //!    do it). Stacked pins (KiCad's convention: one typed pin, the rest passive at the same place) and //!    crystal pins are skipped. Transistor (Q) pins are left alone: KiCad's own transistor symbols-//!    declare them passive.+//!    declare them passive. A pin is read as an amplifier input only on a part whose context says it+//!    is an amplifier (keywords, description, value, library, a + and - input pair, and not an MCU by+//!    its words or its PA0/PB7 port pins); on any other part the symbol's own pin type stands. //! 3. Readability per sheet: many local labels and almost no wires, the same local label used for a //!    short hop inside one block, free text over pins or symbol bodies, and parts or text outside the //!    drawing border.@@ -154,16 +156,26 @@ fn norm(name: &str) -> String {     name.trim().to_ascii_uppercase().replace(['{', '}', '~', ' ', '_'], "").replace(['\u{2212}', '\u{2013}'], "-") } -/// `+` or `-` for a pin named as an amplifier input.+/// `+` or `-` for a pin named as an amplifier input. The sign must belong to the input itself:+/// `IN+`, `IN1-`, `1IN+`, `-IN`, `+INA`, `INP`, `INN`. A `-` that only separates a port from its+/// function, as in an MCU's `PC14-OSCX_IN` or `PB2-INT0`, is not a sign. Whether the part has+/// amplifier inputs at all is `amp_input_on`'s call, from the part's context. fn amp_input(name: &str) -> Option<char> {     let n = norm(name);     if n == "+" { return Some('+'); }     if n == "-" { return Some('-'); }-    if n.contains("IN") && !n.starts_with('V') {-        if n.contains('+') { return Some('+'); }-        if n.contains('-') { return Some('-'); }-        if n.ends_with("INP") || n == "NONINV" { return Some('+'); }-        if n.ends_with("INN") { return Some('-'); }+    if n.starts_with('V') { return None; }+    if n == "NONINV" || n.ends_with("INP") { return Some('+'); }+    if n.ends_with("INN") { return Some('-'); }+    let b = n.as_bytes();+    let sign = |c: u8| (c == b'+' || c == b'-').then_some(c as char);+    for (i, _) in n.match_indices("IN") {+        let after = &b[i + 2..];+        // IN+ / IN-, or one channel digit or letter between: IN1-, INA++        if let Some(s) = after.first().and_then(|c| sign(*c)) { return Some(s); }+        if after.len() >= 2 && after[0].is_ascii_alphanumeric() { if let Some(s) = sign(after[1]) { return Some(s); } }+        // +IN / -IN, followed by at most a channel digit or letter: -IN, +INA (not PB2-INT0)+        if i > 0 && after.len() <= 1 && after.iter().all(u8::is_ascii_alphanumeric) { if let Some(s) = sign(b[i - 1]) { return Some(s); } }     }     None }@@ -190,18 +202,37 @@ fn two_terminal(s: &LibSym) -> bool { /// A pin named bare `+` or `-`, which says nothing about amplifiers on its own, unlike `IN+`. fn bare_sign(name: &str) -> bool { matches!(norm(name).as_str(), "+" | "-") } +/// Keywords, description, value or library name that say a part is an amplifier.+const AMP_WORDS: &[&str] = &["opamp", "op amp", "op-amp", "operational", "comparator", "amplifier", "instrumentation"];+/// Context that says a part's pins are GPIO, whatever their names spell: an MCU, an FPGA. These+/// win over AMP_WORDS (an MCU's description may list its built-in op-amps).+const NOT_AMP_WORDS: &[&str] = &["microcontroller", "mcu", "cortex", "risc-v", "fpga", "cpld", "gpio"];++/// Four or more pins named as GPIO port bits (PA0, PB7, PC14-OSCX_IN): a microcontroller, even when+/// its symbol carries no keywords or description.+fn port_pins(s: &LibSym) -> bool {+    let port = |n: &str| { let b = n.as_bytes(); b.len() >= 3 && b[0] == b'P' && (b'A'..=b'K').contains(&b[1]) && b[2].is_ascii_digit() };+    s.pins.iter().filter(|p| port(&norm(&p.name))).map(|p| p.number.as_str()).collect::<BTreeSet<_>>().len() >= 4+}++/// Whether a part is an amplifier (op-amp, comparator, instrumentation or current-sense amp), read+/// from its context first (keywords, description, value, library and symbol name, its pins as a+/// whole) and only then from pins named as a + and - input pair. The pin-type rule trusts the+/// symbol's own types on everything else. fn amp_like(s: &LibSym) -> bool {     if two_terminal(s) { return false; }     let i = &s.info;-    if ["opamp", "op amp", "op-amp", "operational", "comparator"].iter().any(|k| i.contains(k)) { return true; }+    if NOT_AMP_WORDS.iter().any(|k| i.contains(k)) || port_pins(s) { return false; }+    if AMP_WORDS.iter().any(|k| i.contains(k)) { return true; }     let both = s.pins.iter().any(|p| amp_input(&p.name) == Some('+')) && s.pins.iter().any(|p| amp_input(&p.name) == Some('-'));     // bare +/- make an amplifier only with an output beside them: a `+ - NTC` battery connector is not one     both && s.pins.iter().any(|p| (amp_input(&p.name).is_some() && !bare_sign(&p.name)) || is_output(&p.name) || ["output", "open_collector", "open_emitter", "tri_state"].contains(&p.etype.as_str())) } -/// The sign of a pin that really is an amplifier input on this symbol.+/// The sign of a pin that really is an amplifier input on this symbol: only an amplifier has them,+/// so a GPIO named `PC14-OSCX_IN` on an MCU keeps the type its symbol gives it. fn amp_input_on(s: &LibSym, name: &str) -> Option<char> {-    if two_terminal(s) || (bare_sign(name) && !amp_like(s)) { return None; }+    if !amp_like(s) { return None; }     amp_input(name) } @@ -836,6 +867,62 @@ mod tests {         let errs = errors_of(&plain_lib("Trial:SENSE", &[("IN+", "1"), ("IN-", "2"), ("REF", "3")]), "Trial:SENSE", "U2");         assert!(errs.iter().any(|e| e.contains("amplifier input")), "{errs:?}");     }+    /// A symbol of typed pins, as (name, number, electrical type), with no keywords or description.+    fn typed_lib(name: &str, pins: &[(&str, &str, &str)]) -> String {+        let rows: Vec<String> = pins.iter().enumerate().map(|(i, (n, num, et))| format!(r#"(pin {et} line (at -7.62 {} 0) (length 2.54) (name "{n}" (effects (font (size 1.27 1.27)))) (number "{num}" (effects (font (size 1.27 1.27)))))"#, i as f64 * -2.54)).collect();+        format!(r#"(symbol "{name}" (property "Reference" "U" (at 0 5 0) (effects (font (size 1.27 1.27)))) (property "Value" "{v}" (at 0 -5 0) (effects (font (size 1.27 1.27)))) (symbol "T_1_1" (rectangle (start -5.08 2.54) (end 5.08 -40) (stroke (width 0.254)) (fill (type background))) {}))"#, rows.join(" "), v = name.rsplit(':').next().unwrap())+    }+    fn findings_of(lib: &str, sym: &str, r: &str) -> Vec<String> {+        let f = Fixture::new();+        let p = f.write("s.kicad_sch", &sheet(&[lib.to_string()], &placed(sym, r, 120.0, 90.0)));+        let rep = check(&p, &Value::Null).unwrap();+        ["errors", "warnings"].iter().flat_map(|k| rep[*k].as_array().unwrap().iter().map(|e| e.as_str().unwrap().to_string())).collect()+    }+    #[test]+    fn amplifier_input_signs_belong_to_the_input_name() {+        for (n, s) in [("IN+", '+'), ("IN1-", '-'), ("1IN+", '+'), ("2IN\u{2013}", '-'), ("-IN", '-'), ("+IN A", '+'), ("INA-", '-'), ("INP", '+'), ("INN", '-'), ("+", '+')] {+            assert_eq!(amp_input(n), Some(s), "{n}");+        }+        for n in ["PC14-OSCX_IN", "PC14-OSC32_IN", "PB2-INT0", "PF2-NRST", "VIN+", "IN", "EN"] { assert_eq!(amp_input(n), None, "{n}"); }+    }+    #[test]+    fn an_mcu_gpio_named_like_an_input_keeps_its_symbol_type() {+        // AF27: the Rev K strip's STM32C011F4P6 (U4), a vendor symbol with no keywords or description.+        // DS13866 Table 12 lists PC14-OSCX_IN as an FT I/O; with no crystal fitted it is a GPIO.+        let pins = [("PB7", "1", "bidirectional"), ("PC14-OSCX_IN", "2", "bidirectional"), ("PC15-OSCX_OUT", "3", "bidirectional"), ("VDD/VDDA", "4", "power_in"),+            ("VSS/VSSA", "5", "power_in"), ("PF2-NRST", "6", "bidirectional"), ("PA0", "7", "bidirectional"), ("PA1", "8", "bidirectional"), ("PA14-BOOT0", "19", "bidirectional")];+        let found = findings_of(&typed_lib("Trial:STM32C011F4P6", &pins), "Trial:STM32C011F4P6", "U4");+        assert!(found.iter().all(|e| !e.contains("amplifier input")), "{found:?}");+        // a larger MCU with a comparator's INP/INN pins (its port pins say MCU), and one whose description says so+        let mut more = pins.to_vec();+        more.extend([("PA2-COMP1_INP", "9", "bidirectional"), ("PA3-COMP1_INN", "10", "bidirectional")]);+        let found = findings_of(&typed_lib("Trial:STM32G4", &more), "Trial:STM32G4", "U1");+        assert!(found.iter().all(|e| !e.contains("amplifier input")), "{found:?}");+        let lib = typed_lib("Trial:MCU", &[("RA0-AN0_IN+", "1", "bidirectional"), ("RA1-AN1_IN-", "2", "bidirectional"), ("RA2", "3", "bidirectional")])+            .replacen("(property \"Value\"", "(property \"ki_description\" \"8-bit microcontroller with op-amp\" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes))) (property \"Value\"", 1);+        let found = findings_of(&lib, "Trial:MCU", "U2");+        assert!(found.iter().all(|e| !e.contains("amplifier input")), "{found:?}");+    }+    #[test]+    fn an_op_amp_input_typed_wrong_is_still_flagged() {+        // the strip's TLV9004 symbol, also without keywords, with section 1's inputs typed wrong+        let pins = [("OUT1", "1", "output"), ("IN1-", "2", "passive"), ("IN1+", "3", "bidirectional"), ("V+", "4", "power_in"), ("IN2+", "5", "input"),+            ("IN2-", "6", "input"), ("OUT2", "7", "output"), ("V-", "11", "power_in")];+        let f = Fixture::new();+        let p = f.write("s.kicad_sch", &sheet(&[typed_lib("Trial:TLV9004IPWR", &pins)], &placed("Trial:TLV9004IPWR", "U3", 120.0, 90.0)));+        let rep = check(&p, &Value::Null).unwrap();+        let errs: Vec<&str> = rep["errors"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect();+        let warns: Vec<&str> = rep["warnings"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect();+        assert_eq!(errs, ["pin type: U3 pin 2 (Trial:TLV9004IPWR): amplifier input `IN1-` (-) is passive; declare it input"], "{rep}");+        assert!(warns.iter().any(|w| w.contains("pin 3") && w.contains("amplifier input `IN1+` is bidirectional")), "{rep}");+        assert!(warns.iter().chain(errs.iter()).all(|w| !w.contains("IN2")), "{rep}");+        // and named the way ADI does, with a description that says so+        let lib = typed_lib("Trial:AD8221", &[("-IN", "1", "bidirectional"), ("+IN", "4", "input"), ("RG", "2", "passive"), ("VOUT", "7", "output")])+            .replacen("(property \"Value\"", "(property \"ki_description\" \"instrumentation amplifier\" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes))) (property \"Value\"", 1);+        let found = findings_of(&lib, "Trial:AD8221", "U5");+        assert!(found.iter().any(|w| w.contains("pin 1") && w.contains("amplifier input `-IN` is bidirectional")), "{found:?}");+    }+     #[test]     fn passive_amplifier_and_power_pins_are_errors_unless_justified() {         let f = Fixture::new();
crates/adom-aiflow/src/sequence.rs+58−2
@@ -158,6 +158,30 @@ fn last_visit(data: &Value, step: &str) -> Option<String> {     data["steps"][step]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).max().map(str::to_string)) } +/// Issue #152: the step's latest visit was a finished return that changed neither the board nor the+/// spec (the hashes `step` records when the visit starts and when it ends are equal): it re-proves the+/// step but reworks nothing, so it does not send the later steps back to redo.+fn unchanged_return(data: &Value, step: &str) -> bool {+    let Some(v) = data["steps"][step]["visits"].as_array().and_then(|a| a.iter().max_by(|x, y| x["at"].as_str().cmp(&y["at"].as_str()))) else { return false };+    v["back"] == true && v["hashes"].is_object() && v["hashes"] == v["endHashes"]+}++/// The command that records a step's proof (flows/board.json proofs). A proof recorded after the+/// step's last visit counts, so it can be run from any later step: no return needed.+pub fn proof_command(key: &str) -> Option<&'static str> {+    Some(match key {+        "outline" => "adom-aiflow outline check",+        "analyze-current" => "adom-aiflow analyze current",+        "analyze-thermal" => "adom-aiflow analyze thermal",+        "tour-fields" => "adom-aiflow tour fields",+        "tour-nets" => "adom-aiflow tour nets",+        "tour-3d" => "adom-aiflow tour 3d",+        "testplan" => "adom-aiflow testplan check",+        "export" => "adom-aiflow export",+        _ => return None,+    })+}+ fn visits(data: &Value, step: &str) -> usize {     data["steps"][step]["visits"].as_array().map(|a| a.len()).unwrap_or(0) }@@ -189,7 +213,7 @@ pub fn states(dir: &Path, data: &Value, flow: &Value, seq: &[String]) -> Vec<Ste             Some(_) if name == current => Status::Current,             Some(_) => Status::Done,         };-        if let Some(l) = &last { if *l > floor { floor = l.clone(); } }+        if let Some(l) = &last { if *l > floor && !unchanged_return(data, name) { floor = l.clone(); } }         out.push(StepState { name: name.clone(), status, visits: visits(data, name), last, unproven: if proven { vec![] } else { proofs } });     }     out@@ -204,7 +228,12 @@ pub fn blockers(st: &[StepState], target: &str) -> Vec<String> {         match s.status {             Status::Todo => out.push(format!("{} (never done)", s.name)),             Status::Redo => out.push(format!("{} (redo: an earlier step was reworked after it)", s.name)),-            _ if !s.unproven.is_empty() => out.push(format!("{} (no {} recorded during its last visit)", s.name, s.unproven.join(" or "))),+            _ if !s.unproven.is_empty() => {+                // issue #152: name the command that records it; run from here, it counts without a return+                let cmds: Vec<&str> = s.unproven.iter().filter_map(|k| proof_command(k)).collect();+                let how = if cmds.is_empty() { String::new() } else { format!("; `{}` records it from any step, no return needed", cmds.join("` or `")) };+                out.push(format!("{} (no {} recorded during its last visit{how})", s.name, s.unproven.join(" or ")))+            }             _ => {}         }     }@@ -397,6 +426,33 @@ mod tests {         assert_eq!(tl["loops"][0]["from"], "c"); assert_eq!(tl["loops"][0]["to"], "a");     } +    #[test]+    fn a_return_that_changed_nothing_keeps_later_steps_done() {+        // issue #152: back to a, nothing changed (same board and spec hashes in and out), forward to c again+        let mut d = run(&[("a", "2026-01-01T00:00:01Z", false), ("b", "2026-01-01T00:00:02Z", false), ("c", "2026-01-01T00:00:03Z", false), ("a", "2026-01-01T00:00:04Z", true), ("c", "2026-01-01T00:00:05Z", false)]);+        let h = json!({"board": "b1", "spec": "s1"});+        d["steps"]["a"]["visits"][1]["hashes"] = h.clone();+        d["steps"]["a"]["visits"][1]["endHashes"] = h.clone();+        let st = states(Path::new("/nonexistent"), &d, &json!({}), &seq());+        assert_eq!(st[1].status, Status::Done, "b stays done");+        // the board changed during the return: b is redone+        d["steps"]["a"]["visits"][1]["endHashes"] = json!({"board": "b2", "spec": "s1"});+        let st = states(Path::new("/nonexistent"), &d, &json!({}), &seq());+        assert_eq!(st[1].status, Status::Redo);+        // a return still in progress (no end hashes yet) marks later steps as before+        d["steps"]["a"]["visits"][1].as_object_mut().unwrap().remove("endHashes");+        assert_eq!(states(Path::new("/nonexistent"), &d, &json!({}), &seq())[1].status, Status::Redo);+    }++    #[test]+    fn an_unproven_step_names_the_command_that_records_it() {+        let d = run(&[("a", "2026-01-01T00:00:01Z", false), ("b", "2026-01-01T00:00:05Z", false)]);+        let st = states(Path::new("/nonexistent"), &d, &json!({"proofs": {"a": ["outline"]}}), &seq());+        let b = blockers(&st, "c");+        assert!(b[0].contains("no outline recorded during its last visit; `adom-aiflow outline check` records it from any step, no return needed"), "{b:?}");+        assert_eq!(crate::deviations::key(&format!("step {}", b[0])), "step a");+    }+     #[test]     fn a_step_needs_its_proof_from_its_last_visit() {         let mut d = run(&[("a", "2026-01-01T00:00:01Z", false), ("b", "2026-01-01T00:00:05Z", false)]);
crates/adom-aiflow/src/silk_audit_input.rsadded+177
@@ -0,0 +1,177 @@+//! Issue #153: the `silkscreen-audit` input built from the saved board itself, so every engine audits+//! the same geometry. The audit is a rectangle check (each mark box against each obstacle box), so the+//! shapes are given as their own outlines cut into thin horizontal strips, not as one box each: a+//! 45 degree label is its turned rectangle, a round pad its circle, never the empty corners of the+//! square around them (which turned one real collision into 54 on AM32 ESC v1).+//!+//! - marks: every visible text on F.SilkS / B.SilkS, its turned text rectangle (size, length, rotation,+//!   stroke; KiCad's stroke font estimated as `aiflow_board::silk::text_extent`, not per stroke);+//! - obstacles per footprint and silk side: every pad's copper (+0.05 mm for the mask opening) on that+//!   side and the part's fitted body (F.Fab hull, else courtyard); the footprints' and board's silk+//!   artwork (lines, circles, polygons); every via as its full square on both sides;+//! - inventory: every footprint and via, so `--record` can tell the audit covers the board.++use aiflow_board::{silk, Board, Pt};+use serde_json::{json, Value};++/// Strip height (mm): a strip's box reaches at most this far past a sloped edge's ink.+const STEP: f64 = 0.1;+/// The mask opening around a pad (mm), as a margin on its copper.+const PAD_MARGIN: f64 = 0.05;++fn r4(v: f64) -> f64 { (v * 1e4).round() / 1e4 }++/// Thin horizontal strips whose boxes hold every point of a convex polygon, grown by `grow` on every+/// side. A non-convex outline is covered too (each strip spans its widest crossing).+pub fn strips(poly: &[Pt], grow: f64) -> Vec<[f64; 4]> {+    if poly.len() < 3 { return Vec::new(); }+    let (y0, y1) = poly.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let n = (((y1 - y0) / STEP).ceil() as usize).max(1);+    let h = (y1 - y0) / n as f64;+    let mut out = Vec::with_capacity(n);+    for i in 0..n {+        let (a, b) = (y0 + h * i as f64, if i + 1 == n { y1 } else { y0 + h * (i + 1) as f64 });+        // x extent of the polygon within a <= y <= b: vertices inside the strip and edge crossings at a and b+        let (mut lo, mut hi) = (f64::MAX, f64::MIN);+        for k in 0..poly.len() {+            let (p, q) = (poly[k], poly[(k + 1) % poly.len()]);+            if p.1 >= a - 1e-12 && p.1 <= b + 1e-12 { lo = lo.min(p.0); hi = hi.max(p.0); }+            for y in [a, b] {+                if (p.1 - y) * (q.1 - y) < 0.0 {+                    let x = p.0 + (q.0 - p.0) * (y - p.1) / (q.1 - p.1);+                    lo = lo.min(x); hi = hi.max(x);+                }+            }+        }+        // rounded outward, so a box never falls short of the shape+        let (dn, up) = (|v: f64| (v * 1e4).floor() / 1e4, |v: f64| (v * 1e4).ceil() / 1e4);+        if lo <= hi { out.push([dn(lo - grow), dn(a - grow), up(hi + grow), up(b + grow)]); }+    }+    out+}++/// A stroke from `a` to `b` of half width `half` as its rectangle.+fn stroke_quad(a: Pt, b: Pt, half: f64) -> Vec<Pt> {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l = dx.hypot(dy);+    if l < 1e-9 { return vec![(a.0 - half, a.1 - half), (a.0 + half, a.1 - half), (a.0 + half, a.1 + half), (a.0 - half, a.1 + half)]; }+    let (ux, uy) = (dx / l * half, dy / l * half);+    let (nx, ny) = (-uy, ux);+    vec![(a.0 - ux + nx, a.1 - uy + ny), (b.0 + ux + nx, b.1 + uy + ny), (b.0 + ux - nx, b.1 + uy - ny), (a.0 - ux - nx, a.1 - uy - ny)]+}++/// A circle approximated from outside: the regular polygon whose edges touch it.+fn round_out(poly: &[Pt]) -> Vec<Pt> {+    // pad outlines are inscribed polygons (circle_poly); push each vertex out by 1/cos(pi/n)+    let n = poly.len().max(3) as f64;+    let (cx, cy) = poly.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n));+    let k = 1.0 / (std::f64::consts::PI / n).cos();+    poly.iter().map(|p| (cx + (p.0 - cx) * k, cy + (p.1 - cy) * k)).collect()+}++/// The audit input for `board`, in the schema `silkscreen-obstacle-check.py` reads.+pub fn build(board: &Board) -> Value {+    let sides = [("F.SilkS", true), ("B.SilkS", false)];+    let bodies = crate::mechanical::body_outlines(board);+    let mut obstacles: Vec<Value> = Vec::new();+    let mut inventory: Vec<Value> = Vec::new();+    for (reference, fp) in &board.footprints {+        let own = if fp.layer == "B.Cu" { "B.SilkS" } else { "F.SilkS" };+        let mut layers = Vec::new();+        for (layer, front) in sides {+            let mut boxes: Vec<[f64; 4]> = Vec::new();+            for p in board.pads.iter().filter(|p| &p.reference == reference && if front { p.on_f } else { p.on_b }) {+                for poly in &p.polys {+                    let poly = if p.shape == "circle" || p.shape == "oval" { round_out(poly) } else { poly.clone() };+                    boxes.extend(strips(&poly, PAD_MARGIN));+                }+            }+            for b in bodies.iter().filter(|b| &b.reference == reference && b.front == front) {+                for poly in &b.polys { boxes.extend(strips(poly, 0.0)); }+            }+            if boxes.is_empty() && layer != own { continue; }+            layers.push(layer);+            obstacles.push(json!({"id": reference, "kind": "footprint", "layer": layer, "boxes": boxes,+                "source": "every pad's copper + 0.05 mm and the fitted body (F.Fab hull, else courtyard), each as its outline in 0.1 mm strips"}));+        }+        inventory.push(json!({"id": reference, "kind": "footprint", "layers": layers}));+    }+    // silk artwork (not text): footprint and board lines, circles, arcs, polygons; one obstacle per+    // footprint and side (a label crossing a silk ring is one collision, not one per segment)+    let mut art: Vec<(String, String, Vec<[f64; 4]>)> = Vec::new();+    for (i, s) in silk::silk_shapes(board).iter().enumerate() {+        let (poly, layer, what): (Vec<Pt>, &str, &str) = match s {+            silk::Shape::Stroke { a, b, half, layer, what } => (stroke_quad(*a, *b, *half), layer, what),+            silk::Shape::Poly { pts, layer, what } => (pts.clone(), layer, what),+            silk::Shape::Box { .. } => continue,+        };+        let owner = what.split_whitespace().next().filter(|w| *w != "board" && board.footprints.contains_key(*w));+        let id = match owner { Some(r) => format!("{r}:silk"), None => format!("board:silk:{i}") };+        let boxes = strips(&poly, 0.0);+        match art.iter_mut().find(|a| a.0 == id && a.1 == layer) { Some(a) => a.2.extend(boxes), None => art.push((id, layer.to_string(), boxes)) }+    }+    for (id, layer, boxes) in art { obstacles.push(json!({"id": id, "kind": "silk", "layer": layer, "boxes": boxes})); }+    for (i, v) in board.vias().iter().enumerate() {+        let r = v.size / 2.0;+        let b = [r4(v.x - r), r4(v.y - r), r4(v.x + r), r4(v.y + r)];+        for (layer, _) in sides { obstacles.push(json!({"id": format!("via{i}"), "kind": "via", "layer": layer, "box": b})); }+        inventory.push(json!({"id": format!("via{i}"), "kind": "via", "layers": ["F.SilkS", "B.SilkS"]}));+    }+    let mut marks = Vec::new();+    let mut seen: std::collections::BTreeMap<String, usize> = Default::default();+    for t in silk::text_quads(board, &|l| l == "F.SilkS" || l == "B.SilkS") {+        let base = match &t.owner { Some(r) => format!("{r}-{}", t.kind), None => format!("text:{}", t.text.replace('\n', " ")) };+        let n = seen.entry(base.clone()).or_insert(0);+        *n += 1;+        let id = if *n == 1 { base } else { format!("{base}:{n}") };+        marks.push(json!({"id": id, "text": t.text, "layer": t.layer, "boxes": strips(&t.quad, 0.0)}));+    }+    json!({"obstacles": obstacles, "inventory": inventory, "marks": marks, "source": board.path,+        "builtBy": "adom-aiflow silkscreen-audit --board (issue #153)",+        "note": "built from the saved board: labels as their turned text rectangles, pads and bodies as their outlines, each in 0.1 mm strips (the audit compares boxes); vias as their full square"})+}++#[cfg(test)]+mod tests {+    use super::*;+    fn collide(a: &[[f64; 4]], b: &[[f64; 4]]) -> bool {+        a.iter().any(|a| b.iter().any(|b| a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]))+    }+    #[test]+    fn strips_hold_the_polygon_and_little_else() {+        // a 45 degree 4 x 1 rectangle: its axis-aligned box is 3.54 x 3.54, its strips hug the diagonal+        let q = super::stroke_quad((0.0, 0.0), (2.83, 2.83), 0.5);+        let s = strips(&q, 0.0);+        let area: f64 = s.iter().map(|b| (b[2] - b[0]) * (b[3] - b[1])).sum();+        assert!(area < 5.5, "{area}");+        // every vertex is inside a strip+        for p in &q { assert!(s.iter().any(|b| p.0 >= b[0] - 1e-6 && p.0 <= b[2] + 1e-6 && p.1 >= b[1] - 1e-6 && p.1 <= b[3] + 1e-6)); }+        // the far corner of the bounding square is clear+        assert!(!collide(&s, &[[2.8, -0.7, 3.2, -0.3]]));+    }+    #[test]+    fn a_45_degree_label_beside_a_round_pad_is_not_a_collision() {+        // a test point (round pad) and a 45 degree label whose bounding box takes in the pad's empty corner+        let text = r#"(kicad_pcb (net 0 "") (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+ (footprint "TestPoint:TP" (layer "F.Cu") (at 10 10 0)+   (property "Reference" "TP1" (at 1.5 -1.5 45) (layer "F.SilkS") (effects (font (size 0.8 0.8) (thickness 0.12))))+   (pad "1" smd circle (at 0 0) (size 2 2) (layers "F.Cu" "F.Mask")))+ (via (at 25 25) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 0)))"#;+        let b = Board::from_text("t", text.into()).unwrap();+        let input = build(&b);+        let res = input["marks"].as_array().unwrap();+        assert_eq!(res.len(), 1);+        assert_eq!(res[0]["id"], "TP1-reference");+        let boxes = |v: &Value| -> Vec<[f64; 4]> { v.as_array().unwrap().iter().map(|b| { let a: Vec<f64> = b.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); [a[0], a[1], a[2], a[3]] }).collect() };+        let mark = boxes(&res[0]["boxes"]);+        let pad = boxes(&input["obstacles"][0]["boxes"]);+        // the plain boxes collide; the shapes do not+        let u = |v: &[[f64; 4]]| [v.iter().map(|b| b[0]).fold(f64::MAX, f64::min), v.iter().map(|b| b[1]).fold(f64::MAX, f64::min), v.iter().map(|b| b[2]).fold(f64::MIN, f64::max), v.iter().map(|b| b[3]).fold(f64::MIN, f64::max)];+        assert!(collide(&[u(&mark)], &[u(&pad)]), "the bounding boxes overlap: the old false collision");+        assert!(!collide(&mark, &pad), "the label's rectangle and the pad's circle are apart");+        // inventory covers the board and the geometry passes the proof's own checks+        assert_eq!(input["inventory"].as_array().unwrap().len(), 2);+        assert!(crate::silkproof::geometry_gaps(&input, &b).is_empty(), "{:?}", crate::silkproof::geometry_gaps(&input, &b));+    }+}
crates/adom-aiflow/src/verified.rs+12−2
@@ -120,10 +120,10 @@ pub fn from_run(outcomes: &Map<String, Value>, data: &Value, mech: Option<(bool,     let erc_rec = outcomes.get("erc").map(|e| e["pass"].as_bool().or(e["ok"].as_bool()))         .or_else(|| outcomes.get("schematic").filter(|s| s["ercErrors"].is_array()).map(|s| Some(s["ercErrors"].as_array().map(|a| a.is_empty()).unwrap_or(false))))         .or_else(|| data["evidence"].as_array().and_then(|a| a.iter().rev().find(|e| e["kind"] == "erc")).map(|e| e["pass"].as_bool()));-    let erc = match erc_rec { Some(p) => yes_no(p, "ERC failed", "ERC result states no pass"), None if no_sch => State::NotApplicable("pattern board, no schematic".into()), None => State::Unknown("no ERC result recorded in the run".into()) };+    let erc = match erc_rec { Some(p) => yes_no(p, "ERC failed", "ERC result states no pass"), None if no_sch => State::NotApplicable("pattern board, no schematic".into()), None => State::Unknown("no ERC result recorded in the run; `adom-aiflow netlist check` runs KiCad's ERC on the run's schematic and records it".into()) };     // the pattern lane skips the gate: its DRC is finish's recorded live full DRC, never assumed     let drc = if pattern { live_drc } else { gate.map(|g| yes_no(g["newErrors"].as_u64().map(|n| n == 0), "DRC errors remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into())) };-    let gerbers = outcomes.get("export").map(|e| yes_no(e["fillsGerbers"]["pass"].as_bool(), "a poured layer has no Gerber regions", "export predates this check; run export again")).unwrap_or(State::Unknown("not exported yet (run export)".into()));+    let gerbers = outcomes.get("export").map(|e| if e["fillsCurrent"]["pass"] == false { State::No("the saved zone fills are stale: DRC without refill finds zone violations; refill, save and export again".into()) } else { yes_no(e["fillsGerbers"]["pass"].as_bool(), "a poured layer has no Gerber regions", "export predates this check; run export again") }).unwrap_or(State::Unknown("not exported yet (run export)".into()));     vec![         Item { name: "schematic parity", state: parity },         Item { name: "connectivity", state: connectivity },@@ -276,6 +276,16 @@ mod tests {         assert!(v.starts_with("Not cleared for fabrication") && v.contains("ERC") && v.contains("body overlap"), "{v}");     } +    #[test]+    fn stale_saved_fills_say_no_on_the_gerbers() {+        // issue #160: fills present in the Gerbers but older than the copper+        let o: Map<String, Value> = serde_json::from_value(json!({"export": {"fillsGerbers": {"pass": true}, "fillsCurrent": {"pass": false}}, "erc": {"pass": true}})).unwrap();+        let l = line(&from_run(&o, &json!({"schematic": "a.kicad_sch"}), None, Some(true)));+        assert!(l.contains("fills in Gerbers NO (the saved zone fills are stale") && l.contains("ERC yes"), "{l}");+        let o: Map<String, Value> = serde_json::from_value(json!({"export": {"fillsGerbers": {"pass": true}, "fillsCurrent": {"pass": true}}})).unwrap();+        assert!(line(&from_run(&o, &json!({}), None, Some(true))).contains("fills in Gerbers yes"));+    }+     /// Issue #86: a pattern board's DRC and connectivity come from finish's recorded live DRC only;     /// a clean one with a passing export reads cleared, and nothing is assumed without the record.     #[test]
crates/aiflow-analyze/src/lib.rs+35−1
@@ -88,7 +88,7 @@ fn layers_of(zs: &[Value]) -> Vec<String> { /// layer carries the current between them; vias on the net then only spread heat. /// A pour pass here is provisional (`pourCarries`): `analyze_current_on_board` keeps it only when the /// pour covers the narrow copper or joins every pad (pour_cover, issue #107). A failure names its-/// kind (`failure`: no-copper, bottleneck, via-stitching), so a same-input Fields solve can judge the+/// kind (`failure`: no-copper, bottleneck, via-stitching; a poured net with no tracks is pour-carried, issue #149), so a same-input Fields solve can judge the /// width approximation (solved_ampacity). pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, pad_layers: Option<&BTreeMap<String, Vec<String>>>) -> (bool, Vec<Value>) {     let t_um = spec.get("copperUm").and_then(|v| v.as_f64()).unwrap_or(35.0);@@ -101,6 +101,16 @@ pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, p         let amps = ld.get("amps").and_then(|v| v.as_f64()).unwrap_or(0.0);         let max_rise = ld.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(max_rise_default);         let Some(w) = widths.get(&net) else {+            // issue #149: no tracks, but the net is poured: the pour carries it (judged by pour_cover+            // on the saved board, then by a same-input Fields solve), not "no copper"+            let zs = pours.get(&net).cloned().unwrap_or_default();+            let pour_area = (fill_area(&zs) * 10.0).round() / 10.0;+            if !zs.is_empty() && pour_area > 0.0 {+                let pour_layers = layers_of(&zs);+                results.push(json!({"net": net, "amps": amps, "maxRiseC": max_rise, "status": "pass", "pourCarries": true, "pourOnly": true, "vias": 0, "pourLayers": pour_layers, "pourFilledMm2": pour_area,+                    "how": format!("{net} is carried only by its pour ({pour_area:.0} mm2 on {}), no tracks", pour_layers.join("+"))}));+                continue;+            }             results.push(json!({"net": net, "amps": amps, "status": "no-copper", "failure": "no-copper", "hint": format!("net {net} carries {amps:.1} A but the routing plan has no copper for it")}));             ok = false;             continue;@@ -310,6 +320,30 @@ mod regression_tests {         let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(4.0,26.0,8.0,12.0));         assert!(pass,"{r:?}"); assert!(r[0]["how"].as_str().unwrap().contains("covers every piece"));     }+    #[test] fn a_net_carried_only_by_its_pour_is_not_no_copper() {+        // issue #149: +BATT poured on F.Cu with no tracks read "the routing plan has no copper for it"+        let plan=json!({"nets":[]});+        let spec=json!({"loads":{"+BATT":{"amps":20.0,"maxRiseC":10.0}},"copperUm":35.0});+        let zs=json!({"zones":[{"type":"copper","net":"+BATT","layers":["F.Cu"],"fills":[{"areaMm2":498.0}]}]});+        let (pass,r)=analyze_current(&plan,&spec,Some(&zs),None);+        assert!(pass,"{r:?}"); assert_eq!(r[0]["pourOnly"],true);+        assert!(r[0]["how"].as_str().unwrap().contains("carried only by its pour (498 mm2 on F.Cu)"),"{r:?}");+        // still no copper at all: no-copper as before+        let (pass,r)=analyze_current(&plan,&spec,None,None);+        assert!(!pass); assert_eq!(r[0]["failure"],"no-copper");+        let board=|x1:f64| aiflow_board::Board::from_text("t.kicad_pcb",format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "+BATT") (net 2 "GND")+  (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))+  (footprint "A" (layer "F.Cu") (at 5 10) (property "Reference" "J1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "+BATT")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))+  (footprint "A" (layer "F.Cu") (at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "+BATT")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))+  (zone (net 1) (net_name "+BATT") (layer "F.Cu") (polygon (pts (xy 4 8) (xy {x1} 8) (xy {x1} 12) (xy 4 12))) (filled_polygon (layer "F.Cu") (pts (xy 4 8) (xy {x1} 8) (xy {x1} 12) (xy 4 12)))))"#)).unwrap();+        // the pour reaches both pads: it carries the load+        let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(26.0));+        assert!(pass,"{r:?}"); assert!(r[0]["how"].as_str().unwrap().contains("reaches all 2"),"{r:?}");+        // the pour misses U1: a pour-coverage finding a Fields solve can judge, never no-copper+        let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(15.0));+        assert!(!pass); assert_eq!(r[0]["failure"],"pour-coverage");+        assert!(r[0]["hint"].as_str().unwrap().contains("carried only by its pour"),"{r:?}");+    }     #[test] fn wide_series_segment_cannot_hide_a_narrow_bottleneck() {         let plan=json!({"nets":[{"net":"POWER","width":0.25},{"net":"POWER","width":40.0}]});         let spec=json!({"loads":{"POWER":{"amps":20.0,"maxRiseC":10.0}},"copperUm":35.0});
crates/aiflow-analyze/src/pour_cover.rs+2−1
@@ -153,7 +153,8 @@ pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_     let gap = match first_gap {         Some((piece, gap)) => format!("it leaves the {:.2} mm piece on {} from ({:.2}, {:.2}) to ({:.2}, {:.2}) uncovered at ({:.2}, {:.2}) ({:.0} C at {amps:.1} A, limit {max_rise:.0} C)",             piece.width, piece.layer, rd(piece.a.0), rd(piece.a.1), rd(piece.b.0), rd(piece.b.1), rd(gap.0), rd(gap.1), crate::rise_for(piece.width, t_um, amps, true)),-        None => format!("the saved board has no {net} tracks to check it against"),+        // reached only with no tracks at all (issue #149): the pour is the whole conductor+        None => format!("{net} is carried only by its pour (no tracks on the saved board)"),     };     Err(format!("the {net} pour does not carry the load: {gap}, and {unreached}. Extend the pour over the narrow copper, widen it, declare the pads that carry no load (`loads.{net}.terminals`, zero amps), or solve it with Adom Fields (analyze current then judges the net by the solved rise at its narrowest neck)")) }
crates/aiflow-board/src/silk.rs+57−1
@@ -68,6 +68,20 @@ fn filled(n: &Node) -> bool { /// The visible text box of a text node (a footprint's property or fp_text, or a gr_text), its anchor at /// `at` (already in board coordinates), honouring left/right/top/bottom justification. fn text_box(n: &Node, text: &str, at: Pt, angle: f64, layer: &str, what: String) -> Shape {+    let (cx, cy, ew, eh) = text_frame(n, text, at, angle).0;+    Shape::Box { x0: cx - ew / 2.0, y0: cy - eh / 2.0, x1: cx + ew / 2.0, y1: cy + eh / 2.0, layer: layer.to_string(), what }+}++/// Issue #153: the text's own (turned) rectangle, four corners in board coordinates: a 45 degree label+/// is this quad, not the axis-aligned box around it (about twice its area).+pub fn text_quad(n: &Node, text: &str, at: Pt, angle: f64) -> Vec<Pt> {+    let ((cx, cy, _, _), (uw, uh)) = text_frame(n, text, at, angle);+    [(-uw / 2.0, -uh / 2.0), (uw / 2.0, -uh / 2.0), (uw / 2.0, uh / 2.0), (-uw / 2.0, uh / 2.0)].iter()+        .map(|&(x, y)| { let (dx, dy) = geom::rot_kicad(x, y, angle); (cx + dx, cy + dy) }).collect()+}++/// ((centre x, centre y, turned width, turned height), (unturned width, height)) of a text node.+fn text_frame(n: &Node, text: &str, at: Pt, angle: f64) -> ((f64, f64, f64, f64), (f64, f64)) {     let f = n.find("effects").and_then(|e| e.find("font"));     let size = f.and_then(|f| f.find("size"));     let h = size.and_then(|s| s.number(1)).unwrap_or(1.0);@@ -84,7 +98,49 @@ fn text_box(n: &Node, text: &str, at: Pt, angle: f64, layer: &str, what: String)     let oy = if has("top") { uh / 2.0 } else if has("bottom") { -uh / 2.0 } else { 0.0 };     let (dx, dy) = geom::rot_kicad(ox, oy, angle);     let (cx, cy) = (at.0 + dx, at.1 + dy);-    Shape::Box { x0: cx - ew / 2.0, y0: cy - eh / 2.0, x1: cx + ew / 2.0, y1: cy + eh / 2.0, layer: layer.to_string(), what }+    ((cx, cy, ew, eh), (uw, uh))+}++/// One visible silkscreen text: its owner footprint (None for board text), kind (reference, value,+/// user...), the printed text, layer and turned rectangle (`text_quad`).+#[derive(Clone, Debug)]+pub struct TextQuad { pub owner: Option<String>, pub kind: String, pub text: String, pub layer: String, pub quad: Vec<Pt> }++/// Every visible text on the layers `keep` accepts, as its turned rectangle (issue #153).+pub fn text_quads(board: &Board, keep: &dyn Fn(&str) -> bool) -> Vec<TextQuad> {+    let mut out = Vec::new();+    for n in board.root.nodes() {+        match n.tag.as_str() {+            "footprint" => {+                let reference = reference_of(n).unwrap_or_default();+                let value = n.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();+                let at = n.find("at");+                let (fx, fy, fr) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));+                for c in n.nodes().filter(|c| c.tag == "property" || c.tag == "fp_text") {+                    let Some(layer) = layer_of(c) else { continue };+                    if !keep(layer) || hidden(c) { continue; }+                    let text = c.atom(2).unwrap_or("").replace("${REFERENCE}", &reference).replace("${VALUE}", &value);+                    if text.trim().is_empty() { continue; }+                    let ta = c.find("at");+                    let (dx, dy) = geom::rot_kicad(ta.and_then(|a| a.number(1)).unwrap_or(0.0), ta.and_then(|a| a.number(2)).unwrap_or(0.0), fr);+                    let kind = if c.tag == "property" { c.atom(1).unwrap_or("text").to_ascii_lowercase() } else { c.atom(1).unwrap_or("text").to_string() };+                    let quad = text_quad(c, &text, (fx + dx, fy + dy), ta.and_then(|a| a.number(3)).unwrap_or(0.0));+                    out.push(TextQuad { owner: Some(reference.clone()), kind, text, layer: layer.to_string(), quad });+                }+            }+            "gr_text" => {+                let Some(layer) = layer_of(n) else { continue };+                if !keep(layer) || hidden(n) { continue; }+                let text = n.atom(1).unwrap_or("").to_string();+                if text.trim().is_empty() { continue; }+                let a = n.find("at");+                let quad = text_quad(n, &text, (a.and_then(|a| a.number(1)).unwrap_or(0.0), a.and_then(|a| a.number(2)).unwrap_or(0.0)), a.and_then(|a| a.number(3)).unwrap_or(0.0));+                out.push(TextQuad { owner: None, kind: "text".into(), text, layer: layer.to_string(), quad });+            }+            _ => {}+        }+    }+    out }  /// The strokes and fills of one graphic item (fp_* in a footprint's frame through `to_board`, or gr_*).
crates/aiflow-bridge/src/eda.rs+70−7
@@ -206,22 +206,25 @@ impl Eda for KicadEda {     fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_3d_view", a) }     fn screenshot_all(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_screenshot_all", a) }     fn passthrough(&self, t: &dyn Transport, verb: &str, a: &Value) -> Value { t.send(verb, a) }-    /// 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.+    /// The PCB editor showing this board (its title names the board's file stem as a whole name); a+    /// cached window that is a PCB editor of another board is replaced by it. Other cached windows (3D+    /// viewer, Fields) are kept. Issue #155: with the run's board known and not open, another project's+    /// PCB editor is never the camera (None: the step says no editor of this board is open).     fn camera_window(&self, t: &dyn Transport, _step: &str, cached: Option<i64>) -> Option<i64> {         let r = t.send("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(&self.board).file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();+        let stem = file_stem(&self.board);         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()) };+        let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| names_board(&title_of(w), &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) }+                let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !names_board(&title_of(w), &stem));+                if other_board { matching } else { Some(h) }             }-            None => matching.or_else(|| pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())),+            None if stem.is_empty() => pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()),+            None => matching,         }     }     /// KiCad keeps its own windows in front; the flow never swaps to Hydrogen for it.@@ -416,6 +419,29 @@ pub fn active() -> Arc<dyn Eda> {     RUN.lock().unwrap().clone().unwrap_or_else(|| Arc::new(KicadEda { board: String::new() })) } +/// The file stem of the run's KiCad board ("" when the run has none yet). KiCad's editor titles carry+/// the board's name, so the flow can tell this run's windows from another project open on the same+/// desktop (issue #155).+pub fn run_kicad_stem() -> String {+    match active().board() { BoardRef::Kicad(p) => file_stem(&p), _ => String::new() }+}++/// The file name without its extension, for a Windows or a Unix path.+pub fn file_stem(path: &str) -> String {+    let name = path.rsplit(['/', '\\']).next().unwrap_or("");+    name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name).to_string()+}++/// Whether a window title names this board: the stem as a whole name ("esc" is not named by+/// "esc-v1 — PCB Editor", nor "esc" by "esc_v2"), so a sibling project's editor is never taken for+/// the run's. A prefix such as KiCad's `PCM_` or an unsaved board's `*` still counts.+pub fn names_board(title: &str, stem: &str) -> bool {+    if stem.is_empty() { return false; }+    let before = |c: Option<char>| c.map_or(false, |c| c.is_alphanumeric() || c == '-');+    let after = |c: Option<char>| c.map_or(false, |c| c.is_alphanumeric() || c == '-' || c == '_');+    title.match_indices(stem).any(|(i, _)| !before(title[..i].chars().next_back()) && !after(title[i + stem.len()..].chars().next()))+}+ /// The backend a single call is addressed to: a `filePath` naming another tool's board wins (a /// verb may name its board explicitly), else the run's. pub fn for_call(args: &Value) -> Arc<dyn Eda> {@@ -430,6 +456,43 @@ mod tests {     use super::*;     use std::cell::RefCell; +    #[test]+    fn a_title_names_a_board_only_as_a_whole_name() {+        assert_eq!(file_stem("C:/Users/me/KiCad/esc/esc-2s6s.kicad_pcb"), "esc-2s6s");+        assert_eq!(file_stem("C:\\work\\esc\\esc-2s6s.kicad_pcb"), "esc-2s6s");+        assert_eq!(file_stem(""), "");+        assert!(names_board("esc-2s6s — PCB Editor", "esc-2s6s"));+        assert!(names_board("*esc-2s6s — PCB Editor", "esc-2s6s"));+        assert!(names_board("top [esc-2s6s/] — Schematic Editor", "esc-2s6s"));+        assert!(names_board("PCM_esc-2s6s:R — Symbol Editor", "esc-2s6s"));+        assert!(!names_board("esc-2s6s-v2 — PCB Editor", "esc-2s6s"));+        assert!(!names_board("esc-2s6s_v2 — PCB Editor", "esc-2s6s"));+        assert!(!names_board("other-esc-v1 — PCB Editor", "esc"));+        assert!(!names_board("anything", ""));+    }++    /// Issue #155: led-strip-500's run started its components clip on another project's editor.+    #[test]+    fn the_camera_never_falls_back_to_another_boards_editor() {+        let t = |reply: Value| move |_v: &str, _a: &Value| reply.clone();+        let eds = json!({"data": {"editors": [+            {"kind": "pcb_editor", "hwnd": 1, "title": "other-board — PCB Editor"},+            {"kind": "pcb_editor", "hwnd": 2, "title": "led-strip-500 — PCB Editor"}]}});+        let k = KicadEda { board: "C:/p/led-strip-500/led-strip-500.kicad_pcb".into() };+        assert_eq!(k.camera_window(&t(eds.clone()), "components", None), Some(2));+        assert_eq!(k.camera_window(&t(eds.clone()), "components", Some(1)), Some(2));+        let only_other = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 7472288, "title": "other-board — PCB Editor"}]}});+        assert_eq!(k.camera_window(&t(only_other.clone()), "components", None), None);+        assert_eq!(k.camera_window(&t(only_other.clone()), "models", Some(7472288)), None);+        // a sibling board whose name only starts with the run's is another board+        let sibling = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 3, "title": "led-strip-500-v2 — PCB Editor"}]}});+        assert_eq!(k.camera_window(&t(sibling), "components", None), None);+        // a cached window that is not a PCB editor (the 3D viewer) is kept+        assert_eq!(k.camera_window(&t(only_other.clone()), "3d", Some(99)), Some(99));+        // no board known yet: the first editor, as before+        assert_eq!(KicadEda { board: String::new() }.camera_window(&t(only_other), "placement", None), Some(7472288));+    }+     #[test]     fn board_refs_parse_and_round_trip() {         for s in ["C:/b/buck.kicad_pcb", "fusion:Buck-brd", "altium:C:/b/buck.PcbDoc"] {
crates/aiflow-bridge/src/lib.rs+90−9
@@ -295,24 +295,67 @@ fn read_socket_args(verb: &str, args: &Value, socket: Option<&str>) -> Value {     result } +/// The live (IPC) verbs a task-owned, isolated KiCad instance answers, reads and edits alike.+const LIVE_IPC_VERBS: &[&str] = &["kicad_routing_state", "kicad_routing_validate", "kicad_placement_state",+    "kicad_placement_validate", "kicad_zone_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route",+    "kicad_apply_tracks", "kicad_apply_zone_plan", "kicad_add_zone", "kicad_select_net", "kicad_select_clear", "kicad_zoom_to"];++/// Issue #159: opt-in endpoint for EVERY live IPC verb, reads and edits (`ADOM_AIFLOW_KICAD_SOCKET`). For a+/// task-owned KiCad launched with its own API pipe (a private TEMP/TMP) while another session's KiCad owns+/// the default pipe; window, capture and file verbs are never redirected. An explicit socketPath in the call+/// wins. `ADOM_AIFLOW_KICAD_READ_SOCKET` keeps its read-only meaning.+fn live_socket_args(verb: &str, args: &Value, socket: Option<&str>) -> Value {+    let mut result = args.clone();+    if LIVE_IPC_VERBS.contains(&verb) {+        if let (Some(object), Some(socket)) = (result.as_object_mut(), socket) {+            if !socket.trim().is_empty() && !object.contains_key("socketPath") {+                object.insert("socketPath".into(), Value::String(socket.into()));+            }+        }+    }+    result+}++fn socket_args(verb: &str, args: &Value) -> Value {+    let live = std::env::var("ADOM_AIFLOW_KICAD_SOCKET").ok();+    let args = live_socket_args(verb, args, live.as_deref());+    let read = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();+    read_socket_args(verb, &args, read.as_deref())+}+ /// The PCB editor's window in a `kicad_window_info` reply: the editor whose `kind` is `pcb_editor`. /// The macOS KiCad bridge (0.11.x) names no kind at all (`{app, hwnd, pid, rect, title}`), so there, /// and only when no editor carries a kind, it is the first window titled `<board> — PCB Editor`. /// Before this, a Mac run fell back to the last window it had filmed, which /// after `tour fields` or `tour 3d` is the closed Fields or 3D Viewer window: the net walkthrough /// then framed and screenshotted nothing, and the next step's clip did not start.-pub fn pcb_editor_of(reply: &Value) -> Option<i64> {+pub fn pcb_editor_of(reply: &Value) -> Option<i64> { pcb_editor_named(reply, "") }++/// The PCB editor of the board `stem` (its title names the stem as a whole name, `eda::names_board`);+/// with no stem, the first PCB editor (`pcb_editor_of`). Issue #155: another project's PCB editor open+/// on the same desktop is never returned for the run's board.+pub fn pcb_editor_named(reply: &Value, stem: &str) -> Option<i64> {     let d = reply.get("data").cloned().unwrap_or(reply.clone());     let eds = d.get("editors").and_then(|e| e.as_array())?;     let hwnd = |w: &Value| w.get("hwnd").and_then(|h| h.as_i64());-    if let Some(w) = eds.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")) {-        return hwnd(w);+    let title = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();+    let ours = |w: &Value| stem.is_empty() || eda::names_board(&title(w), stem);+    if eds.iter().any(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")) {+        return eds.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor") && ours(w)).and_then(hwnd);     }     // a bridge that names kinds and lists no PCB editor has none open: unchanged     if eds.iter().any(|w| w.get("kind").map_or(false, |k| !k.is_null())) {         return None;     }-    eds.iter().find(|w| w.get("title").and_then(|t| t.as_str()).map_or(false, titled_pcb_editor)).and_then(hwnd)+    eds.iter().find(|w| titled_pcb_editor(&title(w)) && ours(w)).and_then(hwnd)+}++/// The title of window `hwnd` in a `kicad_window_info` reply (issue #155: a step's clip names the+/// editor it films, so a wrong window shows at once).+pub fn editor_title(reply: &Value, hwnd: i64) -> Option<String> {+    let d = reply.get("data").cloned().unwrap_or(reply.clone());+    d.get("editors")?.as_array()?.iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd))+        .and_then(|w| w.get("title").and_then(|t| t.as_str())).map(str::to_string) }  /// "scratch_strip — PCB Editor" or "*board — PCB Editor" (unsaved), never the bare@@ -344,8 +387,7 @@ impl Bridge {     /// process is killed and the reply is `errorCode: aiflow_timeout`.     pub fn raw_call_bounded(&self, verb: &str, args: &Value, limit: std::time::Duration) -> Value {         use std::io::Read;-        let socket = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();-        let args = read_socket_args(verb, args, socket.as_deref());+        let args = socket_args(verb, args);         let child = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()])             .stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn();         let mut child = match child { Ok(c) => c, Err(e) => return json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}) };@@ -365,8 +407,7 @@ impl Bridge {      /// One adom-bridge verb, as is.     pub fn raw_call(&self, verb: &str, args: &Value) -> Value {-        let socket = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();-        let args = read_socket_args(verb, args, socket.as_deref());+        let args = socket_args(verb, args);         let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();         match out {             Ok(o) => {@@ -498,8 +539,10 @@ impl Bridge {         match hwnd { Some(h) => self.raw_call("desktop_bring_to_front", &serde_json::json!({"hwnd": h, "reason": format!("show {what} for this step")}))["success"].as_bool().unwrap_or(true), None => false }     } +    /// The PCB editor of the run's board (its title names the board's file stem); with no board set,+    /// the first PCB editor. Another project's PCB editor on the same desktop is never returned (#155).     pub fn pcb_editor_hwnd(&self) -> Option<i64> {-        pcb_editor_of(&self.call("kicad_window_info", &json!({})))+        pcb_editor_named(&self.call("kicad_window_info", &json!({})), &eda::run_kicad_stem())     }      pub fn record_start(&self, reason: &str) -> Result<String, Value> {@@ -1136,4 +1179,42 @@ mod pcb_editor_tests {         assert_eq!(pcb_editor_of(&json!({"status": "error"})), None);         assert_eq!(pcb_editor_of(&json!({"data": {"editors": []}})), None);     }++    /// Issue #155: the run's own editor by its board's name, never another project's.+    #[test]+    fn the_runs_editor_is_found_by_its_board_name() {+        let win = json!({"data": {"editors": [+            {"kind": "pcb_editor", "hwnd": 461626, "title": "other-esc — PCB Editor"},+            {"kind": "pcb_editor", "hwnd": 1905110, "title": "esc-2s6s — PCB Editor"}]}});+        assert_eq!(pcb_editor_named(&win, "esc-2s6s"), Some(1905110));+        assert_eq!(pcb_editor_named(&win, "esc"), None);+        assert_eq!(pcb_editor_named(&win, ""), Some(461626));+        assert_eq!(pcb_editor_named(&mac_reply(), "edge_board"), Some(2846));+        assert_eq!(pcb_editor_named(&mac_reply(), "led-strip-500"), None);+        assert_eq!(editor_title(&win, 1905110).as_deref(), Some("esc-2s6s — PCB Editor"));+        assert_eq!(editor_title(&win, 5), None);+    }+}++#[cfg(test)]+mod live_socket_tests {+    use super::*;+    /// Issue #159 (patch by Noah): the isolated editor's pipe reaches every live verb, never a window verb.+    #[test]+    fn live_socket_reaches_edits_and_reads_but_never_windows() {+        let args = json!({"filePath": "C:/b.kicad_pcb"});+        for verb in ["kicad_move_footprint", "kicad_route_net", "kicad_apply_zone_plan", "kicad_routing_state", "kicad_zone_state", "kicad_select_net", "kicad_zoom_to"] {+            assert_eq!(live_socket_args(verb, &args, Some("ipc://iso/api.sock"))["socketPath"], "ipc://iso/api.sock", "{verb}");+        }+        for verb in ["kicad_open_board", "kicad_send_key", "kicad_window_info", "kicad_screenshot_all", "kicad_run_drc", "desktop_screenshot_window"] {+            assert!(live_socket_args(verb, &args, Some("ipc://iso/api.sock")).get("socketPath").is_none(), "{verb}");+        }+        let explicit = json!({"filePath": "C:/b.kicad_pcb", "socketPath": "mine"});+        assert_eq!(live_socket_args("kicad_move_footprint", &explicit, Some("other")), explicit);+        assert_eq!(live_socket_args("kicad_move_footprint", &args, None), args);+        assert_eq!(live_socket_args("kicad_move_footprint", &args, Some("  ")), args);+        // the read socket still answers only reads+        assert!(read_socket_args("kicad_move_footprint", &args, Some("r")).get("socketPath").is_none());+        assert_eq!(read_socket_args("kicad_routing_state", &args, Some("r"))["socketPath"], "r");+    } }
docs/altium-lane.md+2−1
@@ -57,7 +57,8 @@ the STEP are staged as `<run>/altium/pages/<MPN>/` (where a wiki page is cloned, its courtyard from that footprint too) and go through the same `altium-kicad2altium` and `altium-symbol-modes` conversions and the same `altium_import_part`. Manufacturer, package, description and any `mouser`/`digikey`/`lcsc` numbers come from its provenance.json entry (then the BOM row); the-distributor lookups run as for any part. A page named in the netlist, or one `parts` matched, still wins.+distributor lookups run as for any part. A page named in the netlist, or one `parts` matched, still wins,+except over a complete project-library entry marked `"prefer": true` in provenance.json (issue #157). `finish` re-checks that the project-library files are still there.  ## Symbols and pin types on the Altium sheet
docs/isolated-kicad-read-socket.md+5−1
@@ -6,7 +6,11 @@ For an independently verified native instance, `ADOM_AIFLOW_KICAD_READ_SOCKET=ip  Verify the returned board path, revision, `source: live-editor`, native validation and window identity. Existing gates still compare saved and live footprints, exact revision, complete DRC and reviewed edge-connector geometry. An invalid endpoint or file fallback must fail those gates. -A task-owned instance can be launched with a process-local TEMP/TMP directory to give its API pipe a separate name. This workaround requires native verification on the selected desktop; it is not a promise for every KiCad build. Do not change global environment/configuration or replace a shared runtime. Native edits needing endpoint selection remain a bridge-owned capability gap until their schemas explicitly support it.+A task-owned instance can be launched with a process-local TEMP/TMP directory to give its API pipe a separate name. This workaround requires native verification on the selected desktop; it is not a promise for every KiCad build. Do not change global environment/configuration or replace a shared runtime.++## Live edits on a task-owned editor: ADOM_AIFLOW_KICAD_SOCKET (issue #159)++When another session's KiCad owns the default pipe (`capture open` answers `board_mismatch` with `openBoards` naming a board that is not yours), never close that editor. Launch your own `pcbnew.exe` with a private `TEMP`/`TMP`, open your board in it, and set `ADOM_AIFLOW_KICAD_SOCKET=ipc://<that instance's kicad\api.sock>`. AI Flow then adds that `socketPath` to every live IPC verb it calls, reads and edits alike: `kicad_routing_state`, `kicad_routing_validate`, `kicad_placement_state`, `kicad_placement_validate`, `kicad_zone_state`, `kicad_move_footprint`, `kicad_route_net`, `kicad_remove_route`, `kicad_apply_tracks`, `kicad_apply_zone_plan`, `kicad_add_zone`, `kicad_select_net`, `kicad_select_clear` and `kicad_zoom_to`. Window, capture, screenshot and file verbs are never redirected. An explicit `socketPath` in a call wins, and `ADOM_AIFLOW_KICAD_READ_SOCKET` keeps its read-only meaning. KiCad Bridge 1.0.104 accepts `socketPath` on the edit verbs (verified with `kicad_routing_state` and a `kicad_move_footprint` dry run answering `source: live-editor` for the exact board); an older bridge may ignore it, so check that the first live read names your board.  When preparing renamed PCB checkpoints, copy matching `.kicad_pro` and `.kicad_dru` sidecars to the output basename before opening it. Copying only the input basename can cause KiCad to create a default project for the output file, changing the DRC rules. Close only the saved task-owned editor before replacing its sidecars, then reopen and verify the actual rules. Never adjust acceptance thresholds to match an accidental default project. 
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.55",+  "version": "0.2.56",   "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.2.55",+  "version": "0.2.56",   "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.",
skills/adom-aiflow/SKILL.md+17−10
@@ -32,7 +32,7 @@ The guard is read-only and suitable for a host's pre-stop integration. It does n  ## The flow -The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.+The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with `kicad_zoom_to` (KiCad Bridge 1.0.80+; the Zoom to Selected Objects menu key on an older bridge), one net at a time on camera; it says "framed" only for the nets a zoom call framed and names the calls that failed for the others. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.  1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). `place pack` lays each `spec.chains` entry (a series chain in electrical order, such as a divider) on one line with the shared-net pads facing and says which chains were honoured; when a wish lists several rotations it picks the one whose pads land nearest their net partners. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's.@@ -47,7 +47,7 @@ The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-    **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched.-7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.+7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. A loaded net with filled zones and no tracks at all is "carried only by its pour" (judged on the saved board by whether the pour reaches its terminals, then by the Fields solve), never no-copper. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` reports whether the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.  ### High-speed boards: your own router, inside the flow@@ -77,10 +77,13 @@ design, test-plan, sourcing, library, schematic, simulation, placement, routing, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` notes (and logs) any required step before x is not done, and a step is done only when its own command ran during its latest visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land-moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record`+moves` for placement, `thermal-vias land`, `outline round` (or `outline check` when no corner is sharp), `tour nets`, `silkscreen-audit --record` (Fusion: `fusion silkscreen`; Altium: `altium silkscreen`), `export` (Fusion: `fusion export`; Altium: `altium export`), `fusion|altium library` and `fusion|altium schematic` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it-draws an arc on the timeline, and every later step becomes a redo that must be done again in order.+draws an arc on the timeline, and every later step becomes a redo that must be done again in order,+unless the board and spec come out of the return unchanged (same hashes in and out: nothing reworked, no+redo). A proof recorded after a step's last visit counts, so for a missing proof run its command from where+you are (finish names it) instead of returning. `finish` lists what is still open in the chain as a report card, with suggestions; it never refuses.  **When a check is wrong for this board, deviate on purpose instead of stopping.** AI Flow has not seen@@ -121,7 +124,11 @@ labels per footprint. `export` writes the manufacturing set with kicad-cli (ADOM CPL and a BOM built from the board's footprints beside them. It reads every file back (copper layers from the Gerber file functions, drill hits against vias and drilled pads, CPL and BOM references, sides and top rotations against the placed parts, DNP and exclude attributes) and records the export proof only when all-of it matches. Bottom-side CPL rotations are counted, not compared; check them in the fab's preview.+of it matches. It also runs `pcb drc` on the saved file without `--refill-zones` (issue #160): blocking+findings that name a zone mean the saved fills are older than the copper (an offline pcbnew script that+saved without `pcbnew.ZONE_FILLER(board).Fill(board.Zones())`); the Gerbers plot those fills, so export+answers FINDINGS with the refill hint and finish shows "fills in Gerbers NO" until you refill, save and+export again. Any pcbnew script that edits copper must refill before `SaveBoard`. Bottom-side CPL rotations are counted, not compared; check them in the fab's preview.  ## The timeline (show it, keep it up) @@ -366,7 +373,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo  - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.-- **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad (except KiCad's own exceptions: net ties, `net_tie_pad_groups`, and footprints marked `allow_missing_courtyard`; net ties are copper, not part bodies) and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout. - **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.@@ -414,7 +421,7 @@ adom-aiflow is the token cache of every Adom user's flow. When the binary was wr  ## The code checks what you filmed -Every clip stop runs a blank check (nine sampled frames; a clip whose frames are one flat colour filmed an unpainted or covered window) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` refuses while one is unresolved (`--accept-suspect` overrides, and the message must say why). Every stop checks that the clip reached the run (a non-empty local file) and pulls up to three times; a clip that still is not there is flagged `pullFailed` with its desktop path and a WARNING, and `recut`, `finish` and `deliver` pull it again first. Run `recut` promptly: the desktop cleans its recordings folder. `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.+Every clip stop runs a blank check (nine sampled frames; a clip whose frames are one flat colour filmed an unpainted or covered window) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. The blank check samples the centre 60 % of each frame; a frame with two or more rows or columns of board content (a long thin strip at zoom-to-fit) is painted, not flat. A step's clip on a KiCad editor prints the editor's title ("filming KiCad's ..."), and the camera only ever films the PCB editor whose title names this run's board, never another project's. A clip that filmed another project or anything private: `capture reject <step>-<visit> --reason "..." --off-page` deletes it, its cuts, sheet and screenshots, and leaves it off the page. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` refuses while one is unresolved (`--accept-suspect` overrides, and the message must say why). Every stop checks that the clip reached the run (a non-empty local file) and pulls up to three times; a clip that still is not there is flagged `pullFailed` with its desktop path and a WARNING, and `recut`, `finish` and `deliver` pull it again first. Run `recut` promptly: the desktop cleans its recordings folder. `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.  ## Honesty rules @@ -527,7 +534,7 @@ Component library footage: keep the full `library-tour` separate. A reviewed fiv  ## Native editor readiness -`capture open` must confirm native IPC for the exact board, not only a visible window. KiCad may give its single IPC server to the first editor. On `board_mismatch`, read `capture-open-ipc.json` or `place-land-log.json`, save and gracefully close only your task-owned conflicting editors, and reopen the intended board first. Never close another user's work or restart a shared bridge. Permanent identity refusals are not retryable. Reject a static failed take and record the reason before re-taking it.+`capture open` must confirm native IPC for the exact board, not only a visible window. KiCad may give its single IPC server to the first editor. On `board_mismatch`, read `capture-open-ipc.json` or `place-land-log.json`, save and gracefully close only your task-owned conflicting editors, and reopen the intended board first. Never close another user's work or restart a shared bridge. When the pipe's owner is another session, launch your own pcbnew with a private TEMP/TMP and set `ADOM_AIFLOW_KICAD_SOCKET=ipc://<its pipe>`: every live read and edit then reaches your editor (docs/isolated-kicad-read-socket.md). Permanent identity refusals are not retryable. Reject a static failed take and record the reason before re-taking it.  Tours use the complete pinned canonical viewer release, served locally with the tour. On a viewer load error, rebuild with `tools/library-tour.py build --manifest <selection.json> --out <tour-dir>` and inspect the explicit error before recording. Native variants and tour selection must agree, including pin-one markers and the chosen LED/MPN state. @@ -575,7 +582,7 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel  ## Verify existing placement without replaying moves -After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. The bridge judges courtyards as boxes, so nested generated copper (concentric rings) overlaps there: a box overlap is excused only when both references are in `spec.fixedRefs`, KiCad's placement DRC is clean and aiflow's exact courtyard check finds no overlap for the pair; a missing courtyard is excused only for a `fixedRefs` footprint with no pads (the gate's own exemption). Parts that touch on purpose (a gang of connectors butted body to body, approved by the board's owner) take `placement.approvedCourtyardContacts`: the exact pair, side, footprint fingerprints, the reviewed overlap bound and the owner's approval. `place verify`, `gate` and `finish` then accept that courtyard overlap and nothing else; body intersections still refuse (docs/approved-courtyard-contacts.md). Excused findings are recorded in the outcome (`excused`); every other pair or reference is named in the error. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step.+After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. The bridge judges courtyards as boxes, so nested generated copper (concentric rings) overlaps there: a box overlap is excused only when both references are in `spec.fixedRefs`, KiCad's placement DRC is clean and aiflow's exact courtyard check finds no overlap for the pair; a missing courtyard is excused only for a `fixedRefs` footprint with no pads (the gate's own exemption). KiCad's own exceptions hold too (issue #151): a net tie (`net_tie_pad_groups`) or a footprint marked `allow_missing_courtyard` needs no courtyard, and a net tie's box overlapping a part whose net it ties (a Kelvin tie on a shunt's pad) is not a placement overlap; both are recorded under `excused.netTies`. Parts that touch on purpose (a gang of connectors butted body to body, approved by the board's owner) take `placement.approvedCourtyardContacts`: the exact pair, side, footprint fingerprints, the reviewed overlap bound and the owner's approval. `place verify`, `gate` and `finish` then accept that courtyard overlap and nothing else; body intersections still refuse (docs/approved-courtyard-contacts.md). Excused findings are recorded in the outcome (`excused`); every other pair or reference is named in the error. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step.  ## One live dashboard per run @@ -625,7 +632,7 @@ A USB or other edge connector may intentionally project its mechanical courtyard  `verify-pours --plan <plan.json>` compares saved, filled zones with an explicit plan without landing them. Read `docs/existing-pours.md`. A failure names, per zone, the closest board zone and every field that differs: geometry, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge and island removal. `spec.pours` (and so the plan `pour` writes) can carry `connection` (thermal, solid or none), `thermalGap`, `thermalBridge`, `islandRemoval` (always, never or area) and `islandAreaMin`, so zones written outside the flow can be described exactly. `land pours` refuses pad connection none and island removal other than always before any bridge call, because the KiCad bridge cannot land them faithfully. -The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass.+The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass. The one exception is the human's own pours (hands-on human step): `verify-pours --from-board --adopt --why "poured by the human"` writes the plan from the saved zones itself (`plan-pours-from-board.json`) and records its provenance as `from-board`: the zones as the human drew them, not an independent design (a note says so when pours is not a human step).  `verify-pours --plan <plan> --adopt --why "<reason>"` takes ownership of the matched zones: their saved IDs join the run's owned zones (so a later `land pours` may replace them), the grant is logged in `zoneAdoptions`, and the outcome is `mode: adopted-existing`. `finish`, `status` and the summary report the pours as adopted, not landed. Native DRC, measurement and current/thermal gates remain required. 
skills/aiflow-hands-on/SKILL.md+1−1
@@ -74,7 +74,7 @@ When `step <name>` prints HANDS-ON HUMAN STEP: |---|---| | placement | `adopt-board --board <their saved board> --evidence <note>`, then `place verify` | | routing | `adopt-board`, then `route` (plans only what their copper leaves open), `gate --plan <run>/plan-route.json`, `land route --plan <run>/plan-route.json` (an empty plan records the proof) |-| pours | `adopt-board`, then `verify-pours --adopt --why "poured by the human"` |+| pours | `adopt-board`, then `verify-pours --from-board --adopt --why "poured by the human"` (writes the plan from their saved zones, `plan-pours-from-board.json`, recorded as theirs: provenance `from-board`) | | schematic | `schematic check --sch <their file>` | | silkscreen | `silkscreen-audit ... --record` | | simulation | `evidence add --kind simulation --file <their result>` |
skills/aiflow-molecule/SKILL.md+5−3
@@ -74,7 +74,7 @@ Put the interface in netlist.json as `fixed` positions (aiflow-schematic-to-boar  ## 2. Designing the interface -- Size the pin span to the circuit, then round up to 8 mm multiples. Place contacts on the two short edges or wherever wiring is cleanest, on the grid, in an order that reads left to right on the silkscreen.+- Size the pin span to the circuit, then round up to 8 mm multiples. Place contacts on the outer edge: on a row or column of the corner pins (x = 0 or span, y = 0 or span from MP1) or in a wing outside them, so a wire reaches its contact without running over the parts and the middle stays free for the circuit; on the grid, in an order that reads left to right on the silkscreen. - Group power contacts together (input and its return side by side, output and its return side by side). Give each rail its own ground contact near it; corner pins are GND too, but they are mechanical first. - A monitor contact that feeds a Control Panel ADC must be scaled to the ADC's range (VMON = VOUT/2 through 10k/10k here for a 3.3 V ADC). - Test pads for probing sit on the top face, clear of the pins, with room for a probe tip (aiflow-scaffold-probe plans them).@@ -100,7 +100,9 @@ The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in i - Every contact is `MachineContactMedium` or `MachineContactLarge` with its pad and drill, on the 2 mm grid from MP1. A large contact requires large corner pins unless `largeContactMediumPins` gives the reason. - The names the registry reads (its rule `adom.molecule-pins/2`, ported): a contact footprint not named `MC<n>`, a machine pin outside `MP1` to `MP4` (name extra ones `MS<n>`), a designator in the wrong case, a pin name on a contact hole, a multi-pad part on contact holes, and an off-table drill come back as findings with the rename to make. `schematic check` and `netlist check` give the same hint from the footprint names, so rename there, before layout: after layout a rename also moves silkscreen labels, pinout tables and the copper fingerprint. - Pin span on 8 mm multiples with medium pins (LrgMed), 32 mm multiples with large pins (base scaffold); outline = span + margin with rounded corners (radius = margin, centred on the corner pins); board origin on MP1.-- All interface parts locked and in `fixedRefs`.+- Every contact on the outer edge (a corner-pin row or column, or a wing outside them). A contact inside the pins is a finding with the nearest edge spot on the grid, never an error; keep one inside on purpose with `spec.molecule.innerContacts: [{"ref": "MC4", "why": "<a sentence>"}]` (`contactEdgeBandMm` widens the edge band). `netlist check` gives the same hint once the board exists, while moving a fixed contact is one edit.+- All interface parts locked and in `fixedRefs`. Renaming fixed parts in place (MP1 to MP4 renumbered to the rule, same footprints, positions and rotations) is reported as a rename, not a move; a rename plus a real move is still a move.+- `gate` reports molecule findings and still runs DRC, so DRC is always recorded; the gate's last line then lists the open molecule findings. - Every pin and contact has its STEP model bound (`adom-aiflow models`).  ## 5. Export, convert, publish@@ -131,7 +133,7 @@ Gate on the printed stats before publishing:  The published board GLB is always the `--out` of `board-glb copper`, never the raw `step2glb convert` output. If `board-glb copper` refuses (quantized GLBs from step2glb are read as of 0.1.92), fix the cause and run it again; never publish the raw GLB to get past the gate. -The GLB above is for your own review and the run's pictures; it never goes on the molecule's page. Publish with molecule-publish 3.x, a separate package (`adom-wiki pkg install adom/molecule-publish`; it is a dependency of adom-aiflow). Its bundle is what the wiki's model registry builds from: the STEP, the board (`.kicad_pcb`), every schematic sheet (with the `.kicad_pro` and any project `.kicad_sym` / `.pretty` and `3d/` models, carried for APM), `<slug>-fab.zip` and `export.json`. Make the last two with its `scripts/fab_bundle.py create <folder> --slug <slug> --fab <a folder with only the Gerber and drill files> --tool kicad --tool-version <v> --exporter adom-aiflow --exporter-version <v> --source step=<slug>.step --source board_kicad_pcb=<design>.kicad_pcb`, from the same saved board as the Gerbers (`adom-aiflow export`), then run its Stage 1 checks (`bundle_check.py`, `pin_names.py check`, `fab_bundle.py check`). Never put a GLB, `*_footprint.json` or `*_symbol.json` in the bundle or bind `component.parts.model_3d`: the registry builds the model, footprint and symbol and refuses a pushed one (`REGISTRY_MODEL_FILE_REFUSED`). The page is kind `molecule` (`project-page` creates it so when the spec has `molecule`). The publish is proven by `adom-wiki model status` showing the model built by the pipeline with `metadataComplete: true`; record it with `adom-aiflow stage end molecule-publish --result <owner/slug>` (`export`, `finish`, `deliver` and `finish-guard` suggest it until then). Publishing is its own reviewed step: check the payload for anything confidential first, and say "the 3rd party fab" wherever the fab is named.+The GLB above is for your own review and the run's pictures; it never goes on the molecule's page. Publish with molecule-publish 3.x, a separate package (`adom-wiki pkg install adom/molecule-publish`; it is a dependency of adom-aiflow). Its bundle is what the wiki's model registry builds from: the STEP, the board (`.kicad_pcb`), every schematic sheet (with the `.kicad_pro` and any project `.kicad_sym` / `.pretty` and `3d/` models, carried for APM), `<slug>-fab.zip` and `export.json`. Make the last two with its `scripts/fab_bundle.py create <folder> --slug <slug> --fab <a folder with only the Gerber and drill files> --tool kicad --tool-version <v> --exporter adom-aiflow --exporter-version <v> --source step=<slug>.step --source board_kicad_pcb=<design>.kicad_pcb`, from the same saved board as the Gerbers (`adom-aiflow export`), then run its Stage 1 checks (`bundle_check.py`, `pin_names.py check`, `fab_bundle.py check`). Never put a GLB, `*_footprint.json` or `*_symbol.json` in the bundle or bind `component.parts.model_3d`: the registry builds the model, footprint and symbol and refuses a pushed one (`REGISTRY_MODEL_FILE_REFUSED`). The page is kind `molecule` (`project-page` creates it so when the spec has `molecule`, or before `start` when requirements.json says `formFactor.kind: "molecule"` or names a MachinePin in `formFactor.pin`). Before Stage 3, put the design review in the package: `adom-aiflow project-page --results --out <package folder>` writes `design-review/` (Fields current and thermal numbers, your analysis figures, the contact table, the parts table with prices) for the package to carry; link it from the package README. The publish is proven by `adom-wiki model status` showing the model built by the pipeline with `metadataComplete: true`; record it with `adom-aiflow stage end molecule-publish --result <owner/slug>` (`export`, `finish`, `deliver` and `finish-guard` suggest it until then). Publishing is its own reviewed step: check the payload for anything confidential first, and say "the 3rd party fab" wherever the fab is named.  ## Pads are real copper (John's rule) 
skills/aiflow-project-page/SKILL.md+5−1
@@ -5,9 +5,13 @@ user-invocable: true --- # Deliver the project page +## Design review: `project-page --results`++Analysis results belong on the page people read, not only on the run report. After `analyze current`, `analyze thermal` or `tour fields` (they say so when the page's review is missing or older than the board, and `finish` says it again), run `adom-aiflow project-page --results`: it writes `design-review/README.md` for the current board (Fields current and thermal tables, the screens' verdicts, your `artifact --kind analysis-image` figures, a molecule's contact table, and the parts table with unit and line prices and buy links when the BOM has those columns) under `<run>/page-results/`, records which board it describes, and prints the `adom-wiki repo push`. Link it from the README as a sub-README card. For a molecule, `--out <molecule-publish package folder>` writes it into the package instead. Still by hand: chip images on the parts table and the interactive schematic (circuit explorer); the command lists them as open.+ ## Revision 1: `project-page` -`adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (per part: its symbol is in the library, the symbol's Footprint names a footprint file in it, and that footprint's model file is in it; parts may share a footprint or a model, and a part marked `noModelExpected` needs none; the error names each part by MPN and references) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts.+`adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (per part: its symbol is in the library, the symbol's Footprint names a footprint file in it, and that footprint's model file is in it; parts may share a footprint or a model, and a part marked `noModelExpected` needs none; the error names each part by MPN and references) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts; an entry marked `"prefer": true` replaces its MPN's wiki page (aiflow-sourcing, "Which source wins"). `--no-push` pushes nothing to the page but still adds the wiki parts to your private Cloud Library and exports it (that is where their files come from); `--local-only` makes no wiki call at all, packages only the project-library parts and names the wiki parts it left out.  A board in a folder of an existing project page (one page per project): `adom-aiflow project-page --page <owner/slug> --page-dir <folder>` writes the board's README to `<folder>/README.md` (only when the folder has none yet, or with `--replace-readme`) and its PCM repository to `<folder>/pcm/`, with the repository, packages and download URLs under `.../files/<folder>/pcm/`. It never creates a page: the page must exist, `--existing` is implied and `--create` is refused. The library nickname (`PCM_<slug>-<last folder name>`), the PCM identifier and the Cloud Library name carry the folder, so two boards on one page never share a library. The step pages then go to `<folder>/steps/` and the build section to `<folder>/README.md`. 
skills/aiflow-schematic-to-board/SKILL.md+3−3
@@ -64,7 +64,7 @@ Run with the Python that ships with KiCad on the desktop, not the container's: "C:\Users\<user>\AppData\Local\Programs\KiCad\10.0\bin\python.exe" build_board.py ``` -It creates the nets, loads each footprint (`pcbnew.FootprintLoad(dir, name)`, project `lib/<lib>.pretty` before KiCad's), sets reference, value and fields, assigns every pad's net, places and locks the `fixed` parts (KiCad is y down: `KiCad = (X0 + x, Y0 - y)`), stages the rest in a row beside the outline, draws the outline, sets the aux and grid origin on MP1, copies the fab-profile `.kicad_dru`, saves, and writes `build_board.json` (placed parts, net count, warnings). A pad with no net is a warning: read them all.+It creates the nets, loads each footprint (`pcbnew.FootprintLoad(dir, name)`, project `lib/<lib>.pretty` before KiCad's), sets reference, value and fields, assigns every pad's net, places and locks the `fixed` parts (KiCad is y down: `KiCad = (X0 + x, Y0 - y)`), stages the rest in a row beside the outline, draws the outline, sets the aux and grid origin on MP1, copies the fab-profile `.kicad_dru`, saves, and writes `build_board.json` (placed parts, net count, warnings). A pad with no net is a warning: read them all. Any later pcbnew script that edits copper on a board with zones must refill before saving (`pcbnew.ZONE_FILLER(board).Fill(board.Zones())`, then `pcbnew.SaveBoard`): the Gerbers plot the saved fills, and `export` flags stale ones (issue #160).  ### KiCad 10 traps @@ -108,7 +108,7 @@ kicad-cli sch erc --format json --severity-all --output erc.json <design>.kicad_ kicad-cli sch export netlist --output sch.net <design>.kicad_sch ``` -- **ERC: 0 violations**, warnings included. Library warnings mean the tables were missing when ERC ran; save the clean report, not the first one.+- **ERC: 0 violations**, warnings included. In a run, `netlist check` also runs `kicad-cli sch erc --severity-all` on the same schematic and records the ERC result finish reads (an adopted board has no `schematic check`); if kicad-cli cannot run ERC it says so and finish shows ERC as not known. Library warnings mean the tables were missing when ERC ran; save the clean report, not the first one. - **Netlist equivalence**: `adom-aiflow netlist check --sch <design>.kicad_sch --board <design>.kicad_pcb` exports the netlist and compares every `REF.pad` (root-sheet `/VIN` equals the board's `VIN`; unlabelled `Net-(...)` nets compare by their pins), reports missing and extra references and every pin on a different net, and answers OK only when equal (58/58 pins here). This is a gate: no `start` until it passes, then `start --sch <design>.kicad_sch` so the run records the schematic and `finish` re-checks it on the finished board.  ## 7. Hand off@@ -135,7 +135,7 @@ Then `step components` / `components`, `models`, and placement. `schematic check --sch <root>.kicad_sch` (and `netlist check`, which runs the same part) reads the root sheet and every child sheet with the symbols they embed, beyond ERC:  1. **Polarity drawings agree with the pin names.** A diode's cathode bar and triangle point at the pin named K, a transistor's emitter arrow sits on the E lead, an op-amp's drawn `+`/`-` sit at the inputs named that way. A custom symbol that contradicts its pins is an error (a standard KiCad library symbol only warns). A drawing you reviewed against the datasheet goes in the spec: `"schematic": {"polarityReviewed": [{"symbol": "<lib id or reference>", "why": "..."}]}`.-2. **Pin electrical types are real.** Amplifier inputs are `input`, outputs `output`, power pins `power_in`; `passive` on an op-amp or comparator input, output or power pin is an error (a warning on other ICs). When the datasheet truly says otherwise: `"schematic": {"allowPassive": [{"ref": "U1", "pin": "3", "why": "datasheet ..."}]}`. Prefer library symbols (one triangle per op-amp section, a conventional transistor) over custom rectangles.+2. **Pin electrical types are real.** Amplifier inputs are `input`, outputs `output`, power pins `power_in`; `passive` on an op-amp or comparator input, output or power pin is an error (a warning on other ICs). A pin counts as an amplifier input only on a part that is an amplifier by its keywords, description, value, library or a + and - input pair; an MCU's GPIO named `PC14-OSCX_IN` keeps the type its symbol gives it. When the datasheet truly says otherwise: `"schematic": {"allowPassive": [{"ref": "U1", "pin": "3", "why": "datasheet ..."}]}`. Prefer library symbols (one triangle per op-amp section, a conventional transistor) over custom rectangles. 3. **Readable sheets** (warnings): many local labels and almost no wires, the same local label used for a short hop inside one block, free text over pins or symbol bodies, and parts or text outside the drawing border. Draw the signal path with wires inside each functional block; keep labels for long or cross-sheet nets, so feedback loops can be traced by eye.  The result is `<run>/schematic-check.json` and the run outcome `schematic-check`, bound to every sheet's hash; `finish` requires a pass on the hierarchy as it is then. Limits: heuristic geometry (polylines, rectangles, circles, arcs), one diode per unit with pins named K/A, transistors with pins named E/C, op-amp inputs named `+`/`-`, `IN+`/`IN-`, `INP`/`INN`.
skills/aiflow-silkscreen/SKILL.md+13−1
@@ -138,7 +138,8 @@ When using native SVG as a 3D silkscreen texture, export with `--drill-shape-opt  Every silkscreen command reads a JSON file you write; lengths are mm in board coordinates, boxes are `[x0, y0, x1, y1]`. -- `silkscreen-audit --input audit.json --out result.json [--record]`: `{"obstacles": [{"id", "kind", "layer", "box" | "boxes": [[...]]}], "inventory": [{"id", "kind", "layers": ["F.SilkS", ...]}], "marks": [{"id", "layer", "box" | "boxes"}]}`. The inventory is independent of the obstacles and covers the whole board: every footprint by its reference (`R1`, `R1:body`, `R1:pad:1`) and a via or hole item per via. Every inventory item needs an obstacle with the same `id`, `kind` and `layer`; every mark is checked against every obstacle on its layer. `--record` also checks the boxes against the saved board (each pad inside one of its footprint's boxes, each via inside a via box as large as the via). Source: `tools/silkscreen-obstacle-check.py` in the package.+- `silkscreen-audit --board <board> --out result.json [--record]` (issue #153): leave out `--input` and the audit builds its input from the saved board (default the run's current board) and writes it beside `--out` as `silkscreen-audit-input.json`, so every engine audits the same geometry. The audit is a rectangle check (each label box against each obstacle box), so the builder gives shapes, not bounding boxes: each label is its turned text rectangle (KiCad stroke font estimate), each pad its outline (round pads as circles) plus 0.05 mm, each body its F.Fab hull (else courtyard), silk artwork its strokes, all cut into 0.1 mm strips; vias are their full square. A 45 degree label or a round test point is then not a false collision. Collisions are findings (exit 0, no proof recorded); an unreadable input is an error.+- `silkscreen-audit --input audit.json --out result.json [--record]` (a hand-built input; one box per item reports collisions in the empty corners of turned labels and round pads): `{"obstacles": [{"id", "kind", "layer", "box" | "boxes": [[...]]}], "inventory": [{"id", "kind", "layers": ["F.SilkS", ...]}], "marks": [{"id", "layer", "box" | "boxes"}]}`. The inventory is independent of the obstacles and covers the whole board: every footprint by its reference (`R1`, `R1:body`, `R1:pad:1`) and a via or hole item per via. Every inventory item needs an obstacle with the same `id`, `kind` and `layer`; every mark is checked against every obstacle on its layer. `--record` also checks the boxes against the saved board (each pad inside one of its footprint's boxes, each via inside a via box as large as the via). Source: `tools/silkscreen-obstacle-check.py` in the package. - `silkscreen-layout --input candidates.json --out layout.json [--events ...]`: `{"bounds": [x0, y0, x1, y1], "obstacles": [{"box"}], "clearance": 0.12, "labels": [{"id", "anchor": [x, y], "maxDistance", "orderGroup", "order", "requiredRoles", "candidates": [{"position": [x, y], "box", "boxes", "font", "children": [{"role", "size"}], "qualityPenalty"}]}]}`. Source: `tools/silkscreen-layout.py`. - `silkscreen-sections`, `silkscreen-preflight` (`{"placements": [{"index", "box", "polygon"}], "obstacles": [...], "requiredIndices": [...]}`), `silkscreen-contact-policy` (`--geometry` carries the board `outline`) and `silkscreen-mask --board <file.kicad_pcb>`: see the scripts of the same names under `tools/` in the package for every key. @@ -153,3 +154,14 @@ Every power input (a pin, machine contact, connector or pad on VIN, VBUS, VCC_IN ## Every test point is labelled (0.2.18)  Each test point (reference TP*, or a TestPoint/test-pad footprint) gets a small silkscreen label beside it saying what it is for: its net or function (`3V3`, `PH1`, `USB D+`, `RX A OUT`), on the same face, clear of pads and other silk. Use the fab profile's minimum silkscreen text height: 0.3 mm on the 3rd party fab rules, otherwise the profile's own minimum (JLCPCB 0.8 mm). Place the labels with the same layout and collision tools as every other label. `step silkscreen` lists the unlabelled test points in a Hint; `silkscreen-audit --record` and `finish` warn when a test point has no label within 2 mm of its pad edge, or when its label does not name its net (case, spaces, `_` and `+`/`-` are ignored, so `USB D+` matches `USB_D+`). A test pad that carries a programming pin must be labelled, or the programming gate refuses it.++## Fab layer standard (issue #163)++F.Fab (B.Fab) is not plotted for the fab, but it is the assembly drawing, the 3D view and the board page, so keep it tidy:++- the reference only (`${REFERENCE}`), once per part, centred on the part body;+- reading along the body's long axis, upright (0 or 90 degrees);+- one text size per board (default the most common one, KiCad's libraries use 1.0 mm), shrunk only where the body is too small to hold it, never below 0.3 mm, inside the courtyard;+- values hidden on Fab (they are in the BOM).++`adom-aiflow silkscreen fab-check [--board B]` reports Fab texts outside their part's courtyard, values shown, references off their body, duplicates and mixed sizes, naming the worst offenders. `silkscreen-audit --record` records the same check with the proof (`fab`) and `finish` prints it as a warning; neither is a condition. `adom-aiflow silkscreen fab-tidy [--board B] [--out O] [--size MM]` applies the standard to a copy of the board (default `<board>-fab.kicad_pcb`): only Fab texts change, copper, zones, fills and the silkscreen stay as they are, and the change is logged in the run (`fab-tidy`). Review it in KiCad, then `adopt-board` it (the copper evidence carries over; record the silkscreen proof again). You may run it without asking: it is tidiness, not a design change.
skills/aiflow-sourcing/SKILL.md+5−3
@@ -64,7 +64,7 @@ It reads the profile from the spec or requirements.json (`fab` by default), refu  Under that warning it makes one suggestion per consigned row, using the data Adom already has before spending vendor quota. Per distinct MPN, in this order: -1. **The wiki component page** (`adom-wiki repo show adom/<mpn-lowercase> page.json`, then the wiki slug form, e.g. adom/ap2112k-3-3trg1 for AP2112K-3.3TRG1). It reads `component.distributor_links`: `mouser` on `fab`, `lcsc` (or `jlcpcb`) on `jlcpcb`. A link is a part number string, `{part, url, stock, checked}` or `false` (that distributor does not stock it). One read per MPN.+1. **The wiki component page** (`adom-wiki repo show adom/<mpn-lowercase> page.json`, then the wiki slug form, e.g. adom/ap2112k-3-3trg1 for AP2112K-3.3TRG1, then the MPN without a `/` packaging suffix: the part factory names MCP73831T-2ACI/OT's page adom/mcp73831t-2aci, while adom/sp485een-l-tr keeps it; the page's `component.mpn` must be the MPN or the MPN without that suffix). It reads `component.distributor_links`: `mouser` on `fab`, `lcsc` (or `jlcpcb`) on `jlcpcb`. A link is a part number string, `{part, url, stock, checked}` or `false` (that distributor does not stock it). One read per MPN. 2. **The pnp-inventory vendor stock cache** (`pnp-inventory vendor-stock --mpn <MPN>`, the service's hourly Mouser / DigiKey / LCSC cache), when the page gave no number or no dated stock and the CLI is installed. Skipped quietly otherwise. 3. **Only then a live `adom-parts-search search <MPN>`**, for an MPN still without a number or a dated stock count (at most 25 live searches per check, no retries, shared quota). Page and cache reads do not count toward the 25. @@ -87,7 +87,7 @@ Every footprint needs its model (`adom-aiflow models` refuses otherwise). Take t  1. **The manufacturer's own site.** Symbol, footprint and STEP. Search by the **series** as well as the full MPN: ordering codes often do not match CAD file names, and many families publish one STEP per case size, not per value. 2. **Distributor CAD links.** Mouser and DigiKey part pages link SamacSys (Component Search Engine) and Ultra Librarian downloads. Treat them as reference CAD: check them like any other, and do not republish them on a global wiki page without permission.-3. **The Adom wiki component page.** `adom-wiki repo show adom/<mpn slug> page.json` (vendor numbers under `component.distributor_links`; the slug is the MPN lowercased, or with each run of other characters turned into `-`). Read its provenance: a file recorded as manufacturer-supplied is the manufacturer's file and can be reused as such.+3. **The Adom wiki component page.** `adom-wiki repo show adom/<mpn slug> page.json` (vendor numbers under `component.distributor_links`; the slug is the MPN lowercased, or with each run of other characters turned into `-`, or either of those for the MPN without a `/` packaging suffix, e.g. adom/mcp73831t-2aci for MCP73831T-2ACI/OT; `parts` lists a page reached that way so you can check its package is the one you ordered). Read its provenance: a file recorded as manufacturer-supplied is the manufacturer's file and can be reused as such. 4. **KiCad.** KiCad's library 3D model, when every printed dimension is inside the datasheet limits (or within 0.05 mm of a typical-only value) and its leads are realistic (gull-wing feet bent like the real part, not square folds or straight blocks). Otherwise regenerate the package with KiCad's own package generator (kicad-packages3D-generator and kicad-footprint-generator, the CadQuery scripts) at the datasheet's values. Either way, record the measurement you compared (for a gull-wing lead, the JEDEC gauge-plane foot length) and both numbers, the datasheet's and the model's. Never replace a realistic model with a square-fold one over a micron-level miss: regenerate it with the generator instead. The reference implementation is `provenance/tools/kicad-generator/` on adom/stm32g431cbt6 (`kgen.py` runs the generator, `measure.py` measures the result against the datasheet, `materials.py` and the LOD script finish the model). 5. **Only then, draw your own.** Label it AI-generated in the file name and a provenance JSON beside it: datasheet URL, revision, page, figure, retrieval date, PDF sha256, every dimension with its tolerance, simplifications, the generator script, and which OCCT path made it. Create and check the geometry on the shared step2glb OCCT service; install OCCT locally only when `step2glb health` says the service is unreachable, and record that in the provenance. @@ -169,7 +169,9 @@ When a part's page must stay private, waits for an owner's sign-off, or the foot {"parts": [{"mpn": "ABC-123", "source": "datasheet rev C, fig. 12 (AI-created footprint)", "symbol": "ABC-123", "footprint": "QFN-16_3x3", "model": "3dmodels/ABC-123.step"}]} ``` -`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source) and still prefers a wiki page when one exists. `project-page` packages these parts into the board's PCM library beside the wiki parts (on Altium, `altium library` builds them into the SchLib and PcbLib from these same files), and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.+`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source).++Which source wins for one MPN (issue #157), in this order: a page named in the netlist (`page`); a complete project-library entry marked `"prefer": true` in provenance.json (the run records the page it replaced as `wikiOverridden`, and `project-page` and `altium library` package only the library's files for it); the wiki page `parts` matched; an unmarked project-library entry. When an MPN has both a page and a complete unmarked entry, `parts` says so and uses the page: compare the two (pads, thermal pad, 3D height, manufacturer) against the datasheet and mark the entry `"prefer": true` if it is the verified one. A marked entry that is incomplete keeps the page and says what is missing. `parts` does not compare a page's geometry with the datasheet itself. `project-page` packages these parts into the board's PCM library beside the wiki parts (on Altium, `altium library` builds them into the SchLib and PcbLib from these same files), and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.  ## Required component pages include off-board hardware 
tests/erc-outline-workflow.pyadded+112
@@ -0,0 +1,112 @@+"""Issues #150 and #152 end to end, with a stand-in kicad-cli: `netlist check` runs ERC on the same+schematic and records it (finish then says ERC yes); `outline check` records the outline proof when+no corner is sharp, from any step, so no return is needed; finish names that command; a return that+changes neither board nor spec leaves the later steps done. No CAD, bridge or dashboard."""+import json, os, pathlib, stat, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())+EDGE = " ".join([+    '(gr_line (start 1 0) (end 29 0) (layer "Edge.Cuts"))', '(gr_line (start 30 1) (end 30 19) (layer "Edge.Cuts"))',+    '(gr_line (start 29 20) (end 1 20) (layer "Edge.Cuts"))', '(gr_line (start 0 19) (end 0 1) (layer "Edge.Cuts"))',+    '(gr_arc (start 29 0) (mid 29.7071 0.2929) (end 30 1) (layer "Edge.Cuts"))', '(gr_arc (start 30 19) (mid 29.7071 19.7071) (end 29 20) (layer "Edge.Cuts"))',+    '(gr_arc (start 1 20) (mid 0.2929 19.7071) (end 0 19) (layer "Edge.Cuts"))', '(gr_arc (start 0 1) (mid 0.2929 0.2929) (end 1 0) (layer "Edge.Cuts"))'])+BOARD = """(kicad_pcb (version 20241229) (generator "test") (general (thickness 1.6))+ (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user) (37 "F.SilkS" user))+ (net 0 "") (net 1 "A") (net 2 "B")+ EDGE+ (footprint "R:R0603" (layer "F.Cu") (at 10 10) (property "Reference" "R1" (at 0 -1.5) (layer "F.SilkS"))+  (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "A"))+  (pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "B")))+ (footprint "R:R0603" (layer "F.Cu") (at 20 10) (property "Reference" "R2" (at 0 -1.5) (layer "F.SilkS"))+  (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "A"))+  (pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "B"))))+""".replace("EDGE", EDGE)+NETLIST = """(export (version "E") (design (source "x.kicad_sch") (tool "Eeschema 10.0.6"))+ (components (comp (ref "R1") (value "10k") (footprint "R:R0603")) (comp (ref "R2") (value "10k") (footprint "R:R0603")))+ (nets (net (code "1") (name "/A") (node (ref "R1") (pin "1")) (node (ref "R2") (pin "1")))+       (net (code "2") (name "/B") (node (ref "R1") (pin "2")) (node (ref "R2") (pin "2")))))"""+FAKE = """#!/bin/sh+out=""; prev=""+for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; done+here="$(dirname "$0")"+case "$1 $2" in+"sch export") cp "$here/netlist.net" "$out";;+"sch erc") cp "$here/erc.json" "$out";;+*) exit 1;;+esac+"""++with tempfile.TemporaryDirectory(prefix="aiflow-erc-outline-") as td:+    p = pathlib.Path(td)+    tools = p / "tools"; tools.mkdir()+    (tools / "netlist.net").write_text(NETLIST)+    (tools / "erc.json").write_text(json.dumps({"sheets": [{"path": "/", "violations": [{"severity": "warning", "description": "lib warning"}]}]}))+    cli = tools / "kicad-cli"; cli.write_text(FAKE); cli.chmod(cli.stat().st_mode | stat.S_IEXEC)+    env = dict(os.environ, AIFLOW_DASHBOARD="off", AIFLOW_NO_PART_SEARCH="1", AIFLOW_NO_PART_LOOKUP="1", ADOM_AIFLOW_KICAD_CLI=str(cli))+    (p / "board.kicad_pcb").write_text(BOARD)+    (p / "board.kicad_sch").write_text('(kicad_sch (version 20250114) (generator "eeschema"))\n')+    (p / "spec.json").write_text("{}")++    def call(*args):+        return subprocess.run([binary, "--run", "run", "--ai-thread", "erc-outline-test", *args], cwd=p, env=env, capture_output=True, text=True, timeout=120)++    def run_json():+        return json.loads((p / "run" / "run.json").read_text())++    r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test")+    assert r.returncode == 0, r.stdout + r.stderr++    # issue #150: finish's ERC hint names the command; netlist check runs ERC and records it+    r = call("finish")+    assert r.returncode == 0 and "ERC not known" in r.stdout and "netlist check` runs KiCad's ERC" in r.stdout, r.stdout + r.stderr+    r = call("netlist", "check", "--sch", "board.kicad_sch")+    assert r.returncode == 0 and "ERC (kicad-cli sch erc --severity-all): 0 errors" in r.stdout, r.stdout + r.stderr+    erc = run_json()["outcomes"]["erc"]+    assert erc["pass"] is True and erc["source"].startswith("netlist check"), erc+    r = call("finish")+    assert "ERC yes" in r.stdout, r.stdout+    # an ERC error is recorded as a failed ERC, and the parity check still answers+    (tools / "erc.json").write_text(json.dumps({"sheets": [{"path": "/", "violations": [{"severity": "error", "description": "Pin not driven"}]}]}))+    r = call("netlist", "check", "--sch", "board.kicad_sch")+    assert r.returncode == 0 and "1 error(s): Pin not driven" in r.stdout, r.stdout + r.stderr+    assert run_json()["outcomes"]["erc"]["pass"] is False+    # a kicad-cli that cannot run ERC: a note, nothing recorded, the command completes+    (tools / "erc.json").unlink()+    r = call("netlist", "check", "--sch", "board.kicad_sch")+    assert r.returncode == 0 and "ERC did not run" in r.stderr, r.stdout + r.stderr+    (tools / "erc.json").write_text(json.dumps({"sheets": []}))+    assert call("netlist", "check", "--sch", "board.kicad_sch").returncode == 0++    # issue #152: the outline step visited, then left without its proof+    for s in ["rules", "outline", "current"]:+        assert call("step", s).returncode == 0+    r = call("finish")+    assert "no outline recorded during its last visit; `adom-aiflow outline check` records it from any step, no return needed" in r.stderr, r.stderr+    # outline check from a later step records it (no sharp corner): the step is proven without a return+    r = call("outline", "check")+    assert r.returncode == 0 and "recorded" in r.stdout, r.stdout + r.stderr+    assert run_json()["outcomes"]["outline"]["by"] == "check"+    r = call("finish")+    assert "step outline" not in r.stderr, r.stderr++    # a return that changes nothing: the later step stays done+    assert call("step", "thermal").returncode == 0+    r = call("step", "outline", "--back", "--why", "look again")+    assert r.returncode == 0, r.stdout + r.stderr+    assert call("outline", "check").returncode == 0+    assert call("step", "thermal").returncode == 0+    r = call("finish")+    assert "current (redo" not in r.stderr, r.stderr+    visits = run_json()["steps"]["outline"]["visits"]+    assert visits[-1]["back"] is True and visits[-1]["hashes"] == visits[-1]["endHashes"], visits[-1]++    # a return that changes the board: the later step is a redo again+    r = call("step", "outline", "--back", "--why", "move a part")+    assert r.returncode == 0+    board = pathlib.Path(run_json()["boards"][-1])+    board = board if board.is_absolute() else p / board+    board.write_text(board.read_text().replace("(at 20 10)", "(at 21 10)"))+    assert call("step", "thermal").returncode == 0+    r = call("finish")+    assert "current (redo" in r.stderr, r.stderr+print("erc-outline-workflow: OK")
tests/page-results-workflow.pyadded+79
@@ -0,0 +1,79 @@+"""Issues #147, #164 and #158 through the CLI: `project-page --results` writes design-review/README.md for+the current board (Fields numbers, the AI's figures, a molecule's contact table, the parts table with+prices) into the page's staging folder or a molecule-publish package folder (--out), records which board+it describes, and prints the push; the run report's header says what finish said (open items, not+cleared for fabrication) instead of a bare "Finished". No CAD, desktop, wiki or dashboard: nothing is pushed.++  python3 tests/page-results-workflow.py <adom-aiflow binary>+"""+import json, os, pathlib, subprocess, sys, tempfile+binary = str(pathlib.Path(sys.argv[1]).resolve())++BOARD = '''(kicad_pcb (version 20260206)+ (gr_rect (start 98 82) (end 126 102) (layer "Edge.Cuts"))+ (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 100 100) (property "Reference" "MP1") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND")))+ (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 124 100) (property "Reference" "MP2") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND")))+ (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 100 84) (property "Reference" "MP3") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND")))+ (footprint "Molecule:MachinePinMediumShort" (layer "F.Cu") (at 124 84) (property "Reference" "MP4") (pad "1" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.2) (layers "*.Cu") (net "GND")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 100 88) (property "Reference" "MC1") (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net "VIN")))+)+'''++with tempfile.TemporaryDirectory(prefix='aiflow-page-results-') as td:+    p = pathlib.Path(td)+    env = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1')+    def call(run, *args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'page-results-test', *args], env=env, capture_output=True, text=True, timeout=60)+    def data(run): return json.loads((run / 'run.json').read_text())+    def put(run, d): (run / 'run.json').write_text(json.dumps(d))++    board = p / 'esc.kicad_pcb'; board.write_text(BOARD)+    fig = p / 'density.png'; fig.write_bytes(b'\x89PNG fake')+    bom = p / 'bom.csv'; bom.write_text('Ref,MPN,Manufacturer,Unit Price,URL\nMC1,MCM-1,Adom,0.50,https://example.com/mcm\n')+    req = p / 'requirements.json'; req.write_text(json.dumps({"title": "ESC molecule", "formFactor": {"kind": "molecule", "pin": "MachinePinMediumShort"}}))++    # a run on a page, analysed, with one AI figure+    run = p / 'run'+    r = call(run, 'intake', '--prompt-time', '2026-11-06T10:00:00Z', '--engine', 'test', '--page', 'noah/esc', '--requirements', str(req))+    assert r.returncode == 0, r.stdout + r.stderr+    d = data(run)+    d['boards'] = [str(board)]+    d.setdefault('outcomes', {})+    d['outcomes']['tour-fields'] = {"nets": [{"net": "VIN", "amps": 3.0, "peakJ": 22.5, "layers": ["F.Cu"]}], "chips": [{"reference": "U1", "watts": 0.8, "riseC": 12.0, "riseMovingC": 7.0}], "heat": {"peakRiseC": 12.0}, "issues": []}+    d['outcomes']['analyze-current'] = {"pass": True, "results": [{"net": "VIN", "status": "pass"}]}+    d['outcomes']['parts'] = {"pass": True, "bom": str(bom), "parts": [{"mpn": "MCM-1", "manufacturer": "Adom", "refs": ["MC1"], "wiki": "adom/mcm-1"}]}+    d.setdefault('outcomeAt', {})['tour-fields'] = '2026-11-06T10:30:00Z'+    put(run, d)+    with open(run / 'run.jsonl', 'a') as f:+        f.write(json.dumps({"event": "artifact", "byAi": True, "kind": "analysis-image", "file": str(fig), "caption": "Current density on VIN", "step": "fields", "t": "2026-11-06T10:31:00Z"}) + "\n")++    r = call(run, 'project-page', '--results'); out = r.stdout + r.stderr+    assert r.returncode == 0 and 'design review written' in out, out+    readme = run / 'page-results' / 'design-review' / 'README.md'+    assert readme.is_file(), out+    md = readme.read_text()+    assert '| VIN | 3.00 | 22.5 | F.Cu |' in md and '| U1 | 0.80 | 12.0 | 7.0 |' in md, md+    assert '](design-review/01-density.png)' in md and (run / 'page-results' / 'design-review' / '01-density.png').is_file(), md+    # issue #158: the requirements' formFactor makes it a molecule before any spec: the contact table+    assert '| MC1 | VIN | MachineContactMedium | (0, 12) |' in md, md+    assert '| MCM-1 | Adom |' in md and '[buy](https://example.com/mcm)' in md and '**Board total:** $0.50' in md, md+    assert 'adom-wiki repo push noah/esc --files design-review/README.md design-review/01-density.png' in out, out+    assert 'interactive schematic' in out, out+    o = data(run)['outcomes']['page-results']+    assert o['page'] == 'noah/esc' and len(o['boardSha256']) == 64 and o['files'][0] == 'design-review/README.md', o++    # into a molecule-publish package folder: the package root is the page root, no push printed+    pkg = p / 'pkg'+    r = call(run, 'project-page', '--results', '--out', str(pkg)); out = r.stdout + r.stderr+    assert r.returncode == 0 and (pkg / 'design-review' / 'README.md').is_file(), out+    assert 'molecule-publish package' in out and 'repo push' not in out, out++    # issue #147: the run report's header says what finish said+    d = data(run); d['clock']['finishTime'] = '2026-11-06T11:00:00Z'; d['summary'] = {"open": ["silkscreen check", "fields issue"]}+    d.pop('page', None); put(run, d)+    r = call(run, 'report'); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    rep = (run / 'report' / 'README.md').read_text()+    assert 'finish ran with 2 open item(s); not cleared for fabrication' in rep and '**Finished** (board design checks)' not in rep, rep++print('page-results workflow: OK')
tests/parts-sources-workflow.pyadded+80
@@ -0,0 +1,80 @@+"""`parts` through the CLI against a fake adom-wiki on PATH (no network):+issue #161: an ordering code with a "/" packaging suffix (MCP73831T-2ACI/OT) finds the part factory's page+for the MPN without it (adom/mcp73831t-2aci), says how it matched, and a page that keeps the suffix+(adom/sp485een-l-tr) is still found by its own slug first;+issue #157: a project-library entry for an MPN that also has a wiki page is named (page used), and with+"prefer": true in provenance.json the library entry wins (wiki null, wikiOverridden kept).++  python3 tests/parts-sources-workflow.py <abs path to target/release/adom-aiflow>+"""+import json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())++# what `adom-wiki search <q> --json` answered on 2026-11-06: the factory page is ranked by phrase only+FAKE_WIKI = r'''#!/usr/bin/env python3+import json, sys+a = [x for x in sys.argv[1:] if x != "--json"]+H = lambda slug, title, tier: {"type": "hardware", "owner": "adom", "slug": slug, "title": title, "match": {"tier": tier}}+if a[:1] == ["search"]:+    q = a[1].upper()+    hits = []+    if q.startswith("MCP73831"):+        hits = [H("mcp73831", "MCP73831", "phrase"), H("mcp73831t-2aci", "MCP73831T-2ACI", "phrase")]+    elif q.startswith("SP485EEN"):+        hits = [H("sp485een-l", "SP485EEN-L", "phrase"), H("sp485een-l-tr", "SP485EEN-L/TR", "exact_title" if q == "SP485EEN-L/TR" else "phrase")]+    elif q == "ABC-123":+        hits = [H("abc-123", "ABC-123", "exact_slug")]+    print(json.dumps({"status": "ok", "data": {"results": hits}})); sys.exit(0)+print(json.dumps({"status": "error", "error": "fake adom-wiki: " + " ".join(a)})); sys.exit(2)+'''++with tempfile.TemporaryDirectory(prefix='aiflow-parts-sources-') as td:+    p = pathlib.Path(td)+    b = p / 'bin'; b.mkdir()+    (b / 'adom-wiki').write_text(FAKE_WIKI); (b / 'adom-wiki').chmod(0o755)+    (b / 'adom-cli').write_text('#!/bin/sh\nexit 0\n'); (b / 'adom-cli').chmod(0o755)+    env = dict(os.environ, AIFLOW_DASHBOARD='off', PATH=f"{b}:{os.environ['PATH']}")+    def call(run, *args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'parts-sources-test', *args], env=env, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)+    def parts(run): return json.loads((run / 'run.json').read_text())['outcomes']['parts']++    bom = p / 'bom.csv'+    bom.write_text('ref,value,mpn,manufacturer\nU1,charger,MCP73831T-2ACI/OT,Microchip\nU2,RS-485,SP485EEN-L/TR,MaxLinear\nU3,thing,ABC-123,Acme\n')++    # --- #161: the "/OT" ordering code finds the factory page for its base MPN ---------------------------+    run = p / 'run'+    assert call(run, 'intake', '--prompt-time', '2026-11-06T00:00:00Z', '--engine', 'test').returncode == 0+    r = call(run, 'parts', '--bom', str(bom)); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    rows = {x['mpn']: x for x in parts(run)['parts']}+    assert rows['MCP73831T-2ACI/OT']['wiki'] == 'adom/mcp73831t-2aci', rows+    assert 'without its /OT packaging suffix' in rows['MCP73831T-2ACI/OT']['wikiMatch'], rows+    assert rows['SP485EEN-L/TR']['wiki'] == 'adom/sp485een-l-tr' and 'wikiMatch' not in rows['SP485EEN-L/TR'], rows+    assert 'MCP73831T-2ACI/OT: adom/mcp73831t-2aci is the page for MCP73831T-2ACI' in out, out+    assert parts(run)['missing'] == [], parts(run)++    # --- #157: a project-library entry for an MPN that has a page --------------------------------------+    lib = p / 'lib'; (lib / 'lib.pretty').mkdir(parents=True); (lib / '3dmodels').mkdir()+    (lib / 'lib.kicad_sym').write_text('(kicad_symbol_lib (version 20241209) (generator "x")\n  (symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16"))\n)\n')+    (lib / 'lib.pretty' / 'QFN-16.kicad_mod').write_text('(footprint "QFN-16" (layer "F.Cu") (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')+    (lib / '3dmodels' / 'ABC-123.step').write_text('ISO-10303-21;\nHEADER;\nENDSEC;\n')+    prov = lib / 'provenance.json'+    prov.write_text(json.dumps({'parts': [{'mpn': 'ABC-123', 'source': 'datasheet rev C, fig. 12'}]}))+    r = call(run, 'parts', '--bom', str(bom), '--project-lib', str(lib)); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    rows = {x['mpn']: x for x in parts(run)['parts']}+    assert rows['ABC-123']['wiki'] == 'adom/abc-123' and 'projectLib' not in rows['ABC-123'], rows+    assert 'both the wiki page adom/abc-123 and a complete project-library entry' in out and '"prefer": true' in out, out++    prov.write_text(json.dumps({'parts': [{'mpn': 'ABC-123', 'source': 'datasheet rev C, fig. 12', 'prefer': True}]}))+    r = call(run, 'parts', '--bom', str(bom), '--project-lib', str(lib)); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    rec = parts(run); rows = {x['mpn']: x for x in rec['parts']}+    assert rows['ABC-123']['wiki'] is None and rows['ABC-123']['wikiOverridden'] == 'adom/abc-123', rows+    assert rows['ABC-123']['projectLib']['footprint'] == 'QFN-16', rows+    assert rec['projectLibParts'] == ['ABC-123'], rec+    assert 'project library (preferred over adom/abc-123)' in out, out+    assert any('project library\'s entry is used' in n for n in rec['sourceNotes']), rec++print('parts-sources-workflow: OK')
tests/project-page-library.py+30
@@ -264,6 +264,36 @@ class ProjectPageLibrary(unittest.TestCase):         self.assertIsNone(v["cloudLibrary"])         self.assertEqual(self.read_state().get("calls", []), []) +    def test_local_only_makes_no_wiki_call(self):+        # issue #157: --no-push still adds the wiki parts to the Cloud Library and exports it; --local-only+        # (localOnly) makes no adom-wiki call at all, packages the project-library parts, names the wiki parts left out+        v = self.run_page(parts=self.parts() + [self.project_lib()], localOnly=True, push=True)+        self.assertTrue(v["ok"], v)+        self.assertTrue(v["localOnly"])+        self.assertIsNone(v["cloudLibrary"])+        self.assertEqual(self.read_state().get("calls", []), [])+        self.assertEqual(len(v["localOnlyLeftOut"]), 3)+        self.assertIn("TPS54202DDCR (adom/u1-page)", v["localOnlyLeftOut"])+        self.assertEqual(v["counts"]["distinctParts"], 1)+        with zipfile.ZipFile(v["zip"]) as z:+            self.assertIn("footprints/test-board.pretty/QFN-16.kicad_mod", z.namelist())+        # wiki parts only: a README and no package, still no call+        v = self.run_page(localOnly=True)+        self.assertTrue(v["ok"], v)+        self.assertIsNone(v["zip"])+        self.assertEqual(self.read_state().get("calls", []), [])+        # a plain --no-push dry run does use the Cloud Library (what the run now says)+        self.assertTrue(self.run_page()["cloudLibrary"].startswith("lib_"))++    def test_a_preferred_project_library_part_replaces_its_wiki_page(self):+        # issue #157: `parts` gave the row wiki=null, wikiOverridden=<page>: the page is never added or packaged+        pl = self.project_lib(); pl["wikiOverridden"] = "adom/abc-123"+        v = self.run_page(parts=self.parts() + [pl])+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["projectLibParts"], ["ABC-123"])+        lib = self.read_state()["libraries"][0]+        self.assertNotIn("adom/abc-123", lib["items"])+     def test_altium_page_lists_project_library_parts(self):         # the Altium lane: README revision 1 only (no Cloud Library, no PCM package); the project-library         # part is in the component table, in projectLibParts and named in the Altium section
tests/silk-fab-workflow.pyadded+67
@@ -0,0 +1,67 @@+"""Issues #153 and #163 through the CLI: `silkscreen-audit --board` builds its own input from the saved board+(a 45 degree label beside a round test point is not a collision; a label on a pad is a finding, exit 0), and+`silkscreen fab-check` / `fab-tidy` report and apply the Fab layer standard without a run.++  python3 tests/silk-fab-workflow.py <abs path to target/release/adom-aiflow>+"""+import json, os, subprocess, sys, tempfile+from pathlib import Path++BIN = sys.argv[1]+ENV = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1')++BOARD = '''(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+ (footprint "TestPoint:TP" (layer "F.Cu") (at 10 10 0)+  (property "Reference" "TP1" (at 1.5 -1.5 45) (layer "F.SilkS") (uuid "t1") (effects (font (size 0.8 0.8) (thickness 0.12))))+  (property "Value" "TP 3V3" (at 0 4 0) (layer "F.Fab") (uuid "t2") (effects (font (size 1 1) (thickness 0.15))))+  (fp_text user "${REFERENCE}" (at 0 2.5 0) (layer "F.Fab") (uuid "t3") (effects (font (size 0.26 0.26) (thickness 0.04))))+  (fp_circle (center 0 0) (end 1 0) (stroke (width 0.1) (type solid)) (fill no) (layer "F.Fab"))+  (fp_circle (center 0 0) (end 1.5 0) (stroke (width 0.05) (type solid)) (fill no) (layer "F.CrtYd"))+  (pad "1" smd circle (at 0 0) (size 2 2) (layers "F.Cu" "F.Mask")))+ (footprint "R:R_0805" (layer "F.Cu") (at 20 20 0)+  (property "Reference" "R1" (at 0 0 0) (layer "F.SilkS") (uuid "r1") (effects (font (size 0.8 0.8) (thickness 0.12))))+  (property "Value" "10k" (at 0 1.6 0) (layer "F.Fab") (uuid "r2") (effects (font (size 1 1) (thickness 0.15))))+  (fp_rect (start -1 -0.625) (end 1 0.625) (stroke (width 0.1) (type solid)) (layer "F.Fab"))+  (fp_rect (start -1.68 -0.95) (end 1.68 0.95) (stroke (width 0.05) (type solid)) (layer "F.CrtYd"))+  (pad "1" smd roundrect (at -0.9 0) (size 1 1.45) (layers "F.Cu" "F.Mask"))+  (pad "2" smd roundrect (at 0.9 0) (size 1 1.45) (layers "F.Cu" "F.Mask")))+ (via (at 25 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 0)))+'''+++def call(*args, cwd=None):+    return subprocess.run([BIN, '--ai-thread', 'silk-fab-test', *args], capture_output=True, text=True, env=ENV, cwd=cwd)+++with tempfile.TemporaryDirectory() as tmp:+    p = Path(tmp)+    board = p / 'b.kicad_pcb'+    board.write_text(BOARD)+    # --- #153: the input is built from the board; R1's label sits on its own pads (a real collision), TP1's 45 degree label does not+    r = call('--run', str(p / 'norun'), 'silkscreen-audit', '--board', str(board), '--out', str(p / 'res.json'))+    out = r.stdout + r.stderr+    assert r.returncode == 0 and 'FINDINGS' in out and 'rectangle check' in out, out+    inp = json.loads((p / 'silkscreen-audit-input.json').read_text())+    assert {i['id'] for i in inp['inventory']} == {'TP1', 'R1', 'via0'}, inp['inventory']+    res = json.loads((p / 'res.json').read_text())+    hits = {(c['mark'], c['obstacle']) for c in res['collisions']}+    assert ('R1-reference', 'R1') in hits, hits+    assert not any(m.startswith('TP1') and o == 'TP1' for m, o in hits), hits+    # move R1's label off the part: the audit passes+    board.write_text(BOARD.replace('(property "Reference" "R1" (at 0 0 0)', '(property "Reference" "R1" (at 0 -2.2 0)'))+    r = call('--run', str(p / 'norun'), 'silkscreen-audit', '--board', str(board), '--out', str(p / 'res.json'))+    assert r.returncode == 0 and r.stdout.startswith('OK'), r.stdout + r.stderr+    # --- #163: fab-check names the sprawl; fab-tidy writes a copy that meets the standard+    r = call('--run', str(p / 'norun'), 'silkscreen', 'fab-check', '--board', str(board))+    out = r.stdout + r.stderr+    assert r.returncode == 0 and 'FINDINGS' in out and 'value(s) shown on the Fab layer' in out and 'fab-tidy' in out, out+    r = call('--run', str(p / 'norun'), 'silkscreen', 'fab-tidy', '--board', str(board), '--size', '1')+    out = r.stdout + r.stderr+    tidied = p / 'b-fab.kicad_pcb'+    assert r.returncode == 0 and tidied.is_file() and 'adopt-board' in out, out+    assert board.read_text() != tidied.read_text() and '(at 0 -2.2 0) (layer "F.SilkS")' in tidied.read_text()+    r = call('--run', str(p / 'norun'), 'silkscreen', 'fab-check', '--board', str(tidied))+    assert r.returncode == 0 and r.stdout.startswith('OK: Fab layer meets the standard'), r.stdout + r.stderr++print('PASS: silkscreen-audit builds its input from the board (45 degree label by a round pad clear, a label on its pads a finding); fab-check names the Fab sprawl and fab-tidy fixes it')
tools/project-page.py+21−7
@@ -6,10 +6,10 @@ Content Manager (PCM) repository on the page so KiCad installs the whole parts l   python3 -c "$(cat project-page.py)" <config.json>  config: {"run": dir, "page": "owner/slug", "title", "brief", "summary", "parts": [{refs, mpn,-manufacturer, value, wiki}], "create": bool, "push": bool, "private": bool, "version": "1.0.0",+manufacturer, value, wiki}], "create": bool, "push": bool, "localOnly": bool (no wiki call at all: project-library parts only), "private": bool, "version": "1.0.0", "pageDir": "rig/boards/<board>" or null, "molecule": bool} -molecule (issue #137): the spec declares a molecule, so a page this makes is of kind molecule, not board.+molecule (issues #137, #158): the spec declares a molecule (or the requirements say formFactor.kind "molecule"), so a page this makes is of kind molecule, not board. Its model, footprint and symbol are built by the wiki's model registry from the sources molecule-publish puts there after export; nothing here pushes or binds a GLB. @@ -915,6 +915,8 @@ The library appears as `PCM_{lib}` (package `{pkgid}`): every symbol, footprint  def main():     cfg = json.load(open(sys.argv[1]))+    if cfg.get("localOnly"):+        cfg["push"] = False   # local only implies no push (issue #157)     owner, slug = cfg["page"].split("/", 1)     work = os.path.join(cfg["run"], "project-page"); os.makedirs(work, exist_ok=True)     folder = check_dir(cfg.get("pageDir"))@@ -975,12 +977,24 @@ def main():                           "adds": {"addedPages": 0, "rateLimitWaits": [], "notes": []}, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],                           "readme": os.path.join(work, "README.md"), "commit": pushed}))         return-    if any(p.get("wiki") for p in cfg["parts"]):-        lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}/{folder}" if folder else f"{owner}/{slug}", cfg["parts"], work, cfg.get("libRef"))+    # issue #157: --local-only makes no wiki call at all. The wiki parts' files come only from a Cloud Library+    # export (library create/add/export are remote calls), so a local-only build packages the project-library+    # parts and names the wiki parts it left out.+    local = bool(cfg.get("localOnly"))+    pkg_parts = [p for p in cfg["parts"] if not (local and p.get("wiki"))]+    left_out = [f"{p['mpn']} ({p['wiki']})" for p in cfg["parts"] if local and p.get("wiki")]+    if any(p.get("wiki") for p in pkg_parts):+        lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}/{folder}" if folder else f"{owner}/{slug}", pkg_parts, work, cfg.get("libRef"))     else:   # every part from the project library: no Cloud Library needed         lib_ref, bundle, report, adds = None, None, {}, {"addedPages": 0, "rateLimitWaits": [], "notes": []}-    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, cfg["parts"], report)-    counts, gaps = completeness(cfg["parts"], report, check["paths"]["symbols"], check["paths"]["footprints"], check["paths"]["models"],+    if local and not any(p.get("projectLib") for p in pkg_parts):+        open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, None))+        print(json.dumps({"ok": True, "kind": kind, "localOnly": True, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": False, "cloudLibrary": None, "libName": lib,+                          "identifier": None, "repositoryUrl": None, "zip": None, "parts": len(cfg["parts"]), "counts": {}, "libraryCheck": {}, "localOnlyLeftOut": left_out,+                          "adds": adds, "projectLibParts": [], "readme": os.path.join(work, "README.md"), "commit": None}))+        return+    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, pkg_parts, report)+    counts, gaps = completeness(pkg_parts, report, check["paths"]["symbols"], check["paths"]["footprints"], check["paths"]["models"],                                 (check.get("pageModels") or {}).get("notBound"))     if gaps:         # never publish a library that is short of the board's parts@@ -1005,7 +1019,7 @@ def main():         if readme_wanted(cfg, folder, created): files.insert(0, "README.md")         pushed = push_files(cfg, work, folder, files, "Revision 1: the chosen parts, the component table and the KiCad PCM repository (adom-aiflow)")     page_finished()-    print(json.dumps({"ok": True, "kind": kind, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,+    print(json.dumps({"ok": True, "kind": kind, "localOnly": local, "localOnlyLeftOut": left_out, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,                       "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check,                       "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],                       "pageModels": check.get("pageModels"),