← Commit history

0.1.37: every open gap from the buck molecule run closed in the binary: intake, sourcing check, evidence, netlist check, molecule check, fab rules, model orientation, resumable landing, via net check, planeUnder, chains, adopted-board routing, live clips page, clip pull checks, Fields tour on the user's machine

John Lauer ·d49513ee32 ·6d ago ·parent c781714
36 files changed +4105−219
Cargo.lock+18−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.36"+version = "0.1.37" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -16,6 +16,7 @@ dependencies = [  "aiflow-router",  "aiflow-run",  "clap",+ "regex-lite",  "serde",  "serde_json",  "sha2",@@ -23,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.36"+version = "0.1.37" dependencies = [  "serde",  "serde_json",@@ -32,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.36"+version = "0.1.37" dependencies = [  "serde",  "serde_json",@@ -40,15 +41,16 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.36"+version = "0.1.37" dependencies = [+ "aiflow-board",  "serde",  "serde_json", ]  [[package]] name = "aiflow-copper"-version = "0.1.36"+version = "0.1.37" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -58,7 +60,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.36"+version = "0.1.37" dependencies = [  "aiflow-board",  "serde",@@ -67,7 +69,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.36"+version = "0.1.37" dependencies = [  "aiflow-board",  "serde",@@ -76,7 +78,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.36"+version = "0.1.37" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -86,7 +88,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.36"+version = "0.1.37" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -96,7 +98,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.36"+version = "0.1.37" dependencies = [  "serde",  "serde_json",@@ -306,6 +308,12 @@ dependencies = [  "proc-macro2", ] +[[package]]+name = "regex-lite"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"+ [[package]] name = "serde" version = "1.0.229"
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.36"+version = "0.1.37" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+30−4
@@ -17,17 +17,32 @@ Every state-changing command takes `--ai-thread "<your thread name>"`. Every run The flow is a file, `flows/board.json`: the steps in order (intake, models, placement, routing, pours, current, thermal, nets, 3d, capture, finish), 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.  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. Pours, gate, landing, measurement, both analyses and the finish are the binary's.+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>] [--sch design.kicad_sch]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. `--sch` records the schematic the board came from, so `finish` requires a passing `netlist check`.+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>] [--page <owner/slug>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock. 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`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself. Pours, gate, landing, measurement, both analyses and the finish are the binary's. 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. 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 or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, 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. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+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.-8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors. 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`.+8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.++## Before the board: intake, sourcing, evidence++The clock starts at the prompt even when there is no board yet.++- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each records a clip of the desktop until a PCB editor is open. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff.+- **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result.+- **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.  ## The fields step (adom-fields) -After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.+After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.  ## Time: sessions, not the wall clock @@ -53,6 +68,15 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and `  `docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable. +## Design gates (schematic, molecule, fab rules, 3D models)++Deterministic checks that refuse, each an OK/ERROR with hints:++- **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed.+- **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional).+- **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not.+- **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith.+ ## The rules behind the stages  The judgement is in these skills, which this tool executes: kicad-place-route-loop (place for routability, the levers when routing cannot close, go back to placement), kicad-copper-pours (which nets get a pour and which never do, priorities, the Kelvin pair, the preflight lessons), and the tool-neutral eda-engineering skillpack (eda-end-to-end-layout, eda-kelvin-current-sense, eda-copper-ablation, eda-pour-planning-measurement, eda-thermal-bottlenecks, eda-multilayer-current-review).@@ -85,6 +109,8 @@ The number is the human's prompt to your "done, here is your video" (`deliver`),  `report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown. +The live clips page is the binary's too: `clips --page <owner/slug>` rebuilds `docs/clips.md` in `<run>/clips-page` (a table of every clip with its step, visit, time, links and suspect state, then one block per clip: the 10x cut as a video, the action cut, the contact sheet, and the note from `<run>/clip-notes.json`) and says what it would push; `--push` pushes it and the changed clip media with `adom-wiki repo push --files`. Once the run has a page (`start --page`, `intake --page`, `report --page` or `clips --page`), every clip stop rebuilds and pushes it by itself, best effort (a failed push is said, never a failed step). It refuses to push a page with an em-dash or a match of the regex in `$AIFLOW_BANNED_RE`. Open the rendered page for the human once and link it from the README (aiflow-live-clips).+ ## Give it back  adom-aiflow is the token cache of every Adom user's flow. When the binary was wrong, rigid or missing a step (an RF board that needs length-matched differential pairs and impedance checks, say), or your own code did a step better, file it: `adom-wiki issue create adom/adom-aiflow ...` or a PR against the repo, then `adom-aiflow giveback <url>` so the run counts it. Adom processes PRs within minutes during business hours (a non-stop PR intake), and `deliver` reports how many issues and PRs the run's AI gave back.@@ -92,11 +118,11 @@ 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). `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. 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 -- The clock starts at the prompt. A late `start` takes `--prompt-time`.+- The clock starts at the prompt. A late `start` takes `--prompt-time`; work before the board starts with `intake --prompt-time`. - A stage you take yourself is stamped by you and named in the manifest as `ai`; the binary's stages are `binary`. Both go on the chart. - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not).
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/Cargo.toml+1
@@ -24,3 +24,4 @@ aiflow-run = { workspace = true } aiflow-bridge = { workspace = true }  sha2 = "0.10"+regex-lite = "0.1"
crates/adom-aiflow/src/checks.rsadded+623
@@ -0,0 +1,623 @@+//! The deterministic gates that sit between the design and the board: schematic to board netlist+//! equivalence, Adom molecule conformance, the fab rules profile, and 3D model orientation.+//! Every check here is offline and pure (files in, findings out); main.rs records the outcome in+//! the run and turns the findings into OK/ERROR lines with hints.+use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};++use aiflow_board::{geom, sx, Board};+use serde_json::{json, Value};++// ----------------------------------------------------------------------------- netlist (#8)++/// The schematic's view of the design, from a KiCad netlist exported as `kicadsexpr`.+#[derive(Debug, Default, Clone)]+pub struct SchNetlist {+    /// reference -> footprint lib id (may be empty)+    pub refs: BTreeMap<String, String>,+    /// (reference, pin) -> net name as the schematic names it+    pub pins: BTreeMap<(String, String), String>,+}++/// A net name the way both sides can agree on it: root-sheet labels come out of the schematic as+/// `/VIN` and out of a board built from the same netlist as `VIN`. Only the leading `/` goes; a+/// sub-sheet path (`/power/VIN`) keeps its hierarchy.+pub fn norm_net(n: &str) -> String {+    let t = n.trim();+    let t = t.strip_prefix('/').unwrap_or(t);+    t.to_string()+}++/// KiCad's names for a pin connected to nothing.+fn unconnected_name(n: &str) -> bool {+    let t = norm_net(n);+    t.is_empty() || t.starts_with("unconnected-(") || t.starts_with("Net-(unconnected")+}++/// KiCad's auto names (`Net-(R1-Pad1)`): a net nobody labelled, named after one of its pins.+fn auto_name(n: &str) -> bool {+    norm_net(n).starts_with("Net-(")+}++pub fn parse_sch_netlist(text: &str) -> Result<SchNetlist, String> {+    let root = sx::parse(text)?;+    if root.tag != "export" {+        return Err(format!("not a KiCad netlist (root is ({}), expected (export ...)); export it with `kicad-cli sch export netlist --format kicadsexpr`", root.tag));+    }+    let mut out = SchNetlist::default();+    if let Some(comps) = root.find("components") {+        for c in comps.find_all("comp") {+            let Some(r) = c.find("ref").and_then(|x| x.value()) else { continue };+            if r.starts_with('#') { continue; }+            let excluded = c.find_all("property").any(|p| p.find("name").and_then(|n| n.value()) == Some("exclude_from_board"));+            if excluded { continue; }+            out.refs.insert(r.to_string(), c.find("footprint").and_then(|f| f.value()).unwrap_or("").to_string());+        }+    }+    let nets = root.find("nets").ok_or("the netlist has no (nets ...) section")?;+    for n in nets.find_all("net") {+        let name = n.find("name").and_then(|x| x.value()).unwrap_or("").to_string();+        for node in n.find_all("node") {+            let (Some(r), Some(p)) = (node.find("ref").and_then(|x| x.value()), node.find("pin").and_then(|x| x.value())) else { continue };+            if r.starts_with('#') || !out.refs.contains_key(r) { continue; }+            out.pins.insert((r.to_string(), p.to_string()), name.clone());+        }+    }+    Ok(out)+}++/// The board's view: every footprint (not board-only) and the net of each copper pad.+pub fn board_netlist(b: &Board) -> (BTreeMap<String, String>, BTreeMap<(String, String), BTreeSet<String>>) {+    let mut refs = BTreeMap::new();+    for fp in b.root.find_all("footprint") {+        let Some(r) = aiflow_board::reference_of(fp) else { continue };+        if r.starts_with('#') || r.contains("**") { continue; }+        let board_only = fp.find_all("attr").any(|a| a.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == "board_only")));+        if board_only { continue; }+        refs.insert(r, fp.value().unwrap_or("").to_string());+    }+    let mut pins: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();+    for p in &b.pads {+        if !refs.contains_key(&p.reference) { continue; }+        let e = pins.entry((p.reference.clone(), p.name.clone())).or_default();+        if !unconnected_name(&p.net) { e.insert(p.net.clone()); }+    }+    (refs, pins)+}++/// Pin-by-pin comparison of the schematic netlist with the board. `ok` only when every connected+/// schematic pin sits on the same net on the board, no reference is missing or extra, and no board+/// pad carries a net the schematic does not give it.+pub fn compare_netlists(sch: &SchNetlist, b: &Board) -> Value {+    let (brefs, bpins) = board_netlist(b);+    let missing_refs: Vec<String> = sch.refs.keys().filter(|r| !brefs.contains_key(*r)).cloned().collect();+    let extra_refs: Vec<String> = brefs.keys().filter(|r| !sch.refs.contains_key(*r)).cloned().collect();+    let mut footprint_diffs = Vec::new();+    for (r, f) in &sch.refs {+        if let Some(bf) = brefs.get(r) {+            if !f.is_empty() && !bf.is_empty() && f != bf { footprint_diffs.push(format!("{r}: schematic {f}, board {bf}")); }+        }+    }+    // pin sets per net, both sides, for nets the schematic named automatically+    let mut sch_sets: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();+    for (k, n) in &sch.pins { if !unconnected_name(n) { sch_sets.entry(n.clone()).or_default().insert(k.clone()); } }+    let mut board_sets: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();+    for (k, ns) in &bpins { for n in ns { board_sets.entry(n.clone()).or_default().insert(k.clone()); } }+    let same = |sn: &str, bn: &str| -> bool {+        norm_net(sn) == norm_net(bn) || (auto_name(sn) && sch_sets.get(sn).is_some() && sch_sets.get(sn) == board_sets.get(bn))+    };+    let mut mismatches: Vec<Value> = Vec::new();+    let (mut connected, mut matched) = (0usize, 0usize);+    let mut keys: BTreeSet<(String, String)> = sch.pins.keys().cloned().collect();+    keys.extend(bpins.keys().cloned());+    for k in keys {+        let (r, pin) = (&k.0, &k.1);+        if !sch.refs.contains_key(r) || !brefs.contains_key(r) { continue; }+        let sn = sch.pins.get(&k).filter(|n| !unconnected_name(n)).cloned();+        let bset = bpins.get(&k);+        if sn.is_some() { connected += 1; }+        match (&sn, bset) {+            (Some(s), Some(set)) if set.len() == 1 && same(s, set.iter().next().unwrap()) => matched += 1,+            (Some(s), Some(set)) if set.is_empty() => mismatches.push(json!({"pin": format!("{r}.{pin}"), "schematic": norm_net(s), "board": null, "why": "unconnected on the board"})),+            (Some(s), Some(set)) => mismatches.push(json!({"pin": format!("{r}.{pin}"), "schematic": norm_net(s), "board": set.iter().map(|x| norm_net(x)).collect::<Vec<_>>().join(" + "), "why": "different net"})),+            (Some(s), None) => mismatches.push(json!({"pin": format!("{r}.{pin}"), "schematic": norm_net(s), "board": null, "why": "the board footprint has no such pad"})),+            (None, Some(set)) if !set.is_empty() => mismatches.push(json!({"pin": format!("{r}.{pin}"), "schematic": null, "board": set.iter().map(|x| norm_net(x)).collect::<Vec<_>>().join(" + "), "why": if sch.pins.contains_key(&k) { "unconnected in the schematic" } else { "the schematic symbol has no such pin" }})),+            _ => {}+        }+    }+    let ok = missing_refs.is_empty() && extra_refs.is_empty() && mismatches.is_empty();+    json!({"ok": ok, "schematicRefs": sch.refs.len(), "boardRefs": brefs.len(), "connectedPins": connected, "matchedPins": matched,+           "missingRefs": missing_refs, "extraRefs": extra_refs, "mismatches": mismatches, "footprintDifferences": footprint_diffs})+}++// ----------------------------------------------------------------------------- molecule (#9)++/// Pad and drill of each Adom molecule footprint (Adom KiCad Library 1.2.3), by footprint name.+pub fn molecule_part(lib: &str) -> Option<(&'static str, f64, f64)> {+    let name = lib.rsplit(':').next().unwrap_or(lib);+    match name {+        "MachinePinMediumShort" | "MachinePinMediumStandard" => Some(("pin", 1.6, 1.2)),+        "MachinePinLargeShort" | "MachinePinLargeStandard" => Some(("pin", 5.2, 3.45)),+        "MachineContactMedium" => Some(("contact", 1.3, 0.78)),+        "MachineContactLarge" => Some(("contact", 4.4, 2.62)),+        _ => None,+    }+}++#[derive(Debug, Default)]+pub struct Findings {+    pub lines: Vec<String>,+    pub errors: Vec<String>,+    pub warnings: Vec<String>,+    pub hints: Vec<String>,+}++impl Findings {+    fn ok(&mut self, s: String) { self.lines.push(format!("OK    {s}")); }+    fn error(&mut self, s: String, hint: &str) {+        self.lines.push(format!("ERROR {s}"));+        self.errors.push(s);+        if !hint.is_empty() && !self.hints.iter().any(|h| h == hint) { self.hints.push(hint.to_string()); }+    }+    fn warn(&mut self, s: String, hint: &str) {+        self.lines.push(format!("WARN  {s}"));+        self.warnings.push(s);+        if !hint.is_empty() && !self.hints.iter().any(|h| h == hint) { self.hints.push(hint.to_string()); }+    }+}++fn near_multiple(v: f64, m: f64) -> bool {+    if m <= 0.0 { return true; }+    let k = (v / m).round();+    (v - k * m).abs() < 0.01+}++/// Conformance of the board to the Adom molecule format. `spec_mol` is spec.molecule; `before` is+/// the board the run started from (for the fixed references), when there is a run.+pub fn molecule_check(b: &Board, spec: &Value, before: Option<&Board>) -> Findings {+    let mut f = Findings::default();+    let m = spec.get("molecule").cloned().unwrap_or(json!({}));+    let grid = m["gridMm"].as_f64().unwrap_or(2.0);+    let margin = m["edgeMarginMm"].as_f64().unwrap_or(2.0);+    let pin_net = m["cornerPinsNet"].as_str().unwrap_or("GND").to_string();+    let hint_fp = "Use the Adom KiCad Library 1.2.3 footprints (lib/Molecule.pretty): MachinePinMediumShort 1.6 mm pad / 1.2 mm drill for MP1 to MP4, MachineContactMedium 1.3 mm pad / 0.78 mm drill for contacts; spec.molecule.pinPadMm / pinDrillMm / contactPadMm / contactDrillMm override the expected sizes (skill aiflow-molecule).";+    let pad_of = |r: &str| b.pads_of(r).into_iter().next().cloned();+    let mut pins: BTreeMap<String, (f64, f64)> = BTreeMap::new();+    for k in 1..=4 {+        let r = format!("MP{k}");+        let Some(fp) = b.footprints.get(&r) else { f.error(format!("{r} is missing: a molecule has four machine pins MP1 (front left), MP2 (front right), MP3 (back left), MP4 (back right)"), hint_fp); continue };+        let Some((_, pad, drill)) = molecule_part(&fp.lib).filter(|p| p.0 == "pin") else { f.error(format!("{r} is {}, not a MachinePin footprint", fp.lib), hint_fp); continue };+        let (pad, drill) = (m["pinPadMm"].as_f64().unwrap_or(pad), m["pinDrillMm"].as_f64().unwrap_or(drill));+        match pad_of(&r) {+            Some(p) => {+                let d = p.drill.unwrap_or(0.0);+                if (p.size.0 - pad).abs() > 0.01 || (p.size.1 - pad).abs() > 0.01 || (d - drill).abs() > 0.01 {+                    f.error(format!("{r} ({}) pad {:.2} x {:.2} mm, drill {d:.2} mm; expected {pad} mm pad, {drill} mm drill", fp.lib, p.size.0, p.size.1), hint_fp);+                } else { f.ok(format!("{r} {} at ({:.2}, {:.2}): {pad} mm pad, {drill} mm drill", fp.lib, fp.x, fp.y)); }+                if !p.net.is_empty() && norm_net(&p.net) != norm_net(&pin_net) { f.warn(format!("{r} is on net {}; corner pins are {pin_net}", p.net), "Corner machine pins are mechanical first and tie to GND (spec.molecule.cornerPinsNet names another net)."); }+            }+            None => f.error(format!("{r} ({}) has no copper pad", fp.lib), hint_fp),+        }+        pins.insert(r, (fp.x, fp.y));+    }+    let mut origin = None;+    if pins.len() == 4 {+        let (p1, p2, p3, p4) = (pins["MP1"], pins["MP2"], pins["MP3"], pins["MP4"]);+        let minx = pins.values().map(|p| p.0).fold(f64::MAX, f64::min);+        let maxy = pins.values().map(|p| p.1).fold(f64::MIN, f64::max);+        let hint_pos = "MP1 is the front-left pin: the smallest x and the largest KiCad y (y grows toward the front). MP2 is front right, MP3 back left, MP4 back right, on one rectangle.";+        if (p1.0 - minx).abs() > 0.01 || (p1.1 - maxy).abs() > 0.01 {+            f.error(format!("MP1 at ({:.2}, {:.2}) is not the front-left pin (front-left corner of the pins is ({minx:.2}, {maxy:.2}))", p1.0, p1.1), hint_pos);+        }+        let (w, h) = (p2.0 - p1.0, p1.1 - p3.1);+        let rect = (p2.1 - p1.1).abs() < 0.01 && (p3.0 - p1.0).abs() < 0.01 && (p4.0 - p2.0).abs() < 0.01 && (p4.1 - p3.1).abs() < 0.01 && w > 0.0 && h > 0.0;+        if !rect {+            f.error(format!("MP1 to MP4 are not on a rectangle: MP1 ({:.2}, {:.2}), MP2 ({:.2}, {:.2}), MP3 ({:.2}, {:.2}), MP4 ({:.2}, {:.2})", p1.0, p1.1, p2.0, p2.1, p3.0, p3.1, p4.0, p4.1), hint_pos);+        } else {+            let hint_grid = format!("The pin span must be a multiple of the {grid} mm grid; move MP2 to MP4 (and the outline) so the sides are whole grid steps.");+            if !near_multiple(w, grid) || !near_multiple(h, grid) {+                f.error(format!("pin span {w:.2} x {h:.2} mm is not a multiple of the {grid} mm grid"), &hint_grid);+            } else {+                f.ok(format!("pin span {w:.2} x {h:.2} mm on the {grid} mm grid"));+                if !near_multiple(w, 4.0) || !near_multiple(h, 4.0) {+                    f.warn(format!("pin span {w:.2} x {h:.2} mm is not a multiple of 4 mm: a medium-pin molecule on a LrgMed scaffold needs 4 mm multiples to land every pin on a scaffold hole"), "Round the span up to 4 mm multiples unless this molecule never mounts on a LrgMed scaffold.");+                }+            }+        }+        origin = Some(p1);+    } else if let Some(p1) = pins.get("MP1") {+        origin = Some(*p1);+    }+    // the aux/grid origin on MP1 helps every later export (step2glb molecule frame, fab files)+    if let Some(o) = origin {+        let aux = b.root.find("setup").and_then(|s| s.find("aux_axis_origin")).and_then(|a| Some((a.number(1)?, a.number(2)?)));+        match aux {+            Some(a) if (a.0 - o.0).abs() < 0.01 && (a.1 - o.1).abs() < 0.01 => f.ok(format!("aux origin on MP1 ({:.2}, {:.2})", o.0, o.1)),+            _ => f.warn(format!("the aux axis origin is {}, not MP1 ({:.2}, {:.2})", aux.map(|a| format!("({:.2}, {:.2})", a.0, a.1)).unwrap_or_else(|| "not set".into()), o.0, o.1), "Set the drill/place (aux) and grid origin on MP1 so exports and the molecule frame share the origin."),+        }+    }+    // contacts+    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.contains("MachineContact") && !contacts.contains(r) { contacts.push(r.clone()); }+    }+    contacts.sort();+    for r in &contacts {+        let Some(fp) = b.footprints.get(r) else { f.error(format!("contact {r} (spec.molecule.contacts) is not on the board"), ""); continue };+        let Some((_, pad, drill)) = molecule_part(&fp.lib).filter(|p| p.0 == "contact") else { f.error(format!("contact {r} is {}, not a MachineContact footprint", fp.lib), hint_fp); continue };+        let (pad, drill) = (m["contactPadMm"].as_f64().unwrap_or(pad), m["contactDrillMm"].as_f64().unwrap_or(drill));+        let mut good = true;+        if let Some(p) = pad_of(r) {+            let d = p.drill.unwrap_or(0.0);+            if (p.size.0 - pad).abs() > 0.01 || (p.size.1 - pad).abs() > 0.01 || (d - drill).abs() > 0.01 {+                good = false;+                f.error(format!("{r} ({}) pad {:.2} x {:.2} mm, drill {d:.2} mm; expected {pad} mm pad, {drill} mm drill", fp.lib, p.size.0, p.size.1), hint_fp);+            }+        }+        if let Some(o) = origin {+            let (dx, dy) = (fp.x - o.0, o.1 - fp.y);+            if !near_multiple(dx, grid) || !near_multiple(dy, grid) {+                f.error(format!("{r} at ({dx:.2}, {dy:.2}) mm from MP1 is off the {grid} mm grid"), &format!("Snap {r} to the nearest {grid} mm grid point from MP1 (design frame y up): ({:.0}, {:.0}).", (dx / grid).round() * grid, (dy / grid).round() * grid));+            } else if good { f.ok(format!("{r} {} at ({dx:.0}, {dy:.0}) mm from MP1, on the {grid} mm grid", fp.lib.rsplit(':').next().unwrap_or(""))); }+        }+    }+    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)."); }+    // edge margin: every pin and contact centre at least edgeMarginMm inside the outline+    if b.outline.len() >= 3 {+        for r in pins.keys().chain(contacts.iter()) {+            let Some(fp) = b.footprints.get(r) else { continue };+            let inside = geom::point_in_poly(fp.x, fp.y, &b.outline);+            let n = b.outline.len();+            let d = (0..n).map(|k| { let (a, c) = (b.outline[k], b.outline[(k + 1) % n]); geom::seg_distance(fp.x, fp.y, a.0, a.1, c.0, c.1) }).fold(f64::INFINITY, f64::min);+            if !inside || d < margin - 0.01 {+                f.error(format!("{r} centre is {}{d:.2} mm from the board edge; the molecule edge margin is {margin} mm", if inside { "" } else { "OUTSIDE the outline, " }), &format!("Keep every machine pin and contact centre {margin} mm inside the outline (spec.molecule.edgeMarginMm): grow the outline or move the part inward on the grid."));+            }+        }+    }+    // fixed references: where the spec says, and where the run's first board had them+    if let Some(fixed) = m["fixed"].as_object() {+        if let Some(o) = origin {+            for (r, pos) in fixed {+                let (x, y) = (pos.get(0).and_then(|v| v.as_f64()).or(pos["x"].as_f64()), pos.get(1).and_then(|v| v.as_f64()).or(pos["y"].as_f64()));+                let (Some(x), Some(y)) = (x, y) else { continue };+                match b.footprints.get(r) {+                    Some(fp) if (fp.x - o.0 - x).abs() < 0.01 && (o.1 - fp.y - y).abs() < 0.01 => {}+                    Some(fp) => f.error(format!("{r} is at ({:.2}, {:.2}) mm from MP1; spec.molecule.fixed puts it at ({x}, {y})", fp.x - o.0, o.1 - fp.y), "The interface is fixed: move it back (the scaffold and the Control Panel wiring depend on it), or change the spec on purpose."),+                    None => f.error(format!("{r} (spec.molecule.fixed) is not on the board"), ""),+                }+            }+        }+    }+    if let Some(b0) = before {+        let mut refs: Vec<String> = spec["fixedRefs"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+        for r in pins.keys() { if !refs.contains(r) { refs.push(r.clone()); } }+        let mut moved = Vec::new();+        for r in &refs {+            if let (Some(a), Some(c)) = (b0.footprints.get(r), b.footprints.get(r)) {+                if (a.x - c.x).abs() > 0.005 || (a.y - c.y).abs() > 0.005 || (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));+                }+            }+        }+        if moved.is_empty() { f.ok(format!("{} fixed references where the run started", refs.len())); }+        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."); }+    }+    f+}++// ----------------------------------------------------------------------------- fab rules (#10)++/// JLCPCB's published 2-layer standard capabilities as KiCad custom rules (shipped with the binary).+pub const JLCPCB_2L: &str = include_str!("../../../rules/jlcpcb-2L.kicad_dru");+pub const JLCPCB_MARKER: &str = "adom-aiflow rules profile: jlcpcb-2L";++/// The rules profile the spec names: `"fab": {"rules": "fab" | "jlcpcb"}`. A plain-text `fab`+/// (older specs) names no profile.+pub fn rules_profile(spec: &Value) -> Option<String> {+    spec.get("fab").and_then(|f| f.get("rules")).and_then(|r| r.as_str()).map(str::to_string)+}++/// Whether the board's project carries rules matching the profile. Ok(text) says what matched.+pub fn rules_check(profile: &str, board: &str) -> Result<String, String> {+    let dru = Path::new(board).with_extension("kicad_dru");+    let text = std::fs::read_to_string(&dru).ok();+    match (profile, text) {+        ("jlcpcb", Some(t)) if t.contains(JLCPCB_MARKER) => Ok(format!("{} carries the jlcpcb-2L profile", dru.display())),+        ("jlcpcb", Some(_)) => Err(format!("the spec names the jlcpcb rules profile but {} is another rules file", dru.display())),+        ("jlcpcb", None) => Err(format!("the spec names the jlcpcb rules profile but the board has no {}", dru.display())),+        ("fab", Some(t)) if t.contains(JLCPCB_MARKER) => Err(format!("the spec names the 3rd party fab rules profile but {} is the shipped jlcpcb-2L file", dru.display())),+        ("fab", Some(t)) if t.contains("(rule") => Ok(format!("{} carries the project's own fab rules", dru.display())),+        ("fab", _) => Err(format!("the spec names the 3rd party fab rules profile but the board has no {} with rules in it", dru.display())),+        (other, _) => Err(format!("unknown rules profile {other:?}; use \"fab\" (the project's own .kicad_dru) or \"jlcpcb\"")),+    }+}++pub fn rules_hint(profile: &str) -> String {+    if profile == "jlcpcb" {+        "Install the shipped profile beside the board: `adom-aiflow rules install --profile jlcpcb` (--force replaces another rules file; then adopt-board or start again so the run's snapshot carries it).".into()+    } else {+        "The 3rd party fab's rules are private and are not shipped: copy the project's own <board>.kicad_dru beside the board (the project keeps it under rules/), then run the gate again.".into()+    }+}++// ----------------------------------------------------------------------------- model orientation (#20)++pub type BBox = [[f64; 2]; 3];++/// The bbox of a STEP file's CARTESIAN_POINTs (control points included, which is fine for an+/// orientation check). Inch files are scaled to mm.+pub fn step_points_bbox(text: &str) -> Option<BBox> {+    let scale = if text.contains("INCH") && text.contains("CONVERSION_BASED_UNIT") { 25.4 } else { 1.0 };+    let mut bb = [[f64::MAX, f64::MIN]; 3];+    let mut n = 0;+    let mut rest = text;+    while let Some(i) = rest.find("CARTESIAN_POINT") {+        rest = &rest[i + 15..];+        // CARTESIAN_POINT ( 'name', ( x, y, z ) ): the coordinates are the second list+        let Some(a) = rest.find('(') else { break };+        let Some(c) = rest[a + 1..].find('(').map(|c| a + 1 + c) else { break };+        if c > 400 { continue; }+        let body = &rest[c + 1..];+        let Some(close) = body.find(')') else { break };+        let nums: Vec<f64> = body[..close].split(',').filter_map(|s| s.trim().parse::<f64>().ok()).collect();+        // stray far points (construction geometry, a point at 1e5) are not the body+        if nums.len() == 3 && nums.iter().all(|v| v.abs() * scale < 1000.0) {+            for k in 0..3 { bb[k][0] = bb[k][0].min(nums[k] * scale); bb[k][1] = bb[k][1].max(nums[k] * scale); }+            n += 1;+        }+    }+    (n > 0).then_some(bb)+}++/// The model's bbox after KiCad's model transform: scale, then rotate about X, Y, Z by the NEGATED+/// file angles (KiCad's 3D renderer convention: a Y-up model is stood up with rotate -90 about X),+/// then the offset in mm.+pub fn transform_bbox(bb: &BBox, offset: [f64; 3], scale: [f64; 3], rot: [f64; 3]) -> BBox {+    let mut out = [[f64::MAX, f64::MIN]; 3];+    for i in 0..8 {+        let mut p = [bb[0][i & 1], bb[1][(i >> 1) & 1], bb[2][(i >> 2) & 1]];+        for k in 0..3 { p[k] *= scale[k]; }+        let rx = (-rot[0]).to_radians();+        p = [p[0], p[1] * rx.cos() - p[2] * rx.sin(), p[1] * rx.sin() + p[2] * rx.cos()];+        let ry = (-rot[1]).to_radians();+        p = [p[0] * ry.cos() + p[2] * ry.sin(), p[1], -p[0] * ry.sin() + p[2] * ry.cos()];+        let rz = (-rot[2]).to_radians();+        p = [p[0] * rz.cos() - p[1] * rz.sin(), p[0] * rz.sin() + p[1] * rz.cos(), p[2]];+        for k in 0..3 { let v = p[k] + offset[k]; out[k][0] = out[k][0].min(v); out[k][1] = out[k][1].max(v); }+    }+    out+}++/// A flat SMD chip by its footprint name: chip passives by metric size code and the common flat+/// IC packages. Tall cans (CP_Elec), inductors and connectors are not in the list.+pub fn flat_chip(lib: &str) -> bool {+    let n = lib.rsplit(':').next().unwrap_or(lib).to_ascii_lowercase();+    let chip = ["r_", "c_", "l_", "led_", "d_", "f_", "fb_"].iter().any(|p| n.starts_with(p)) && n.contains("metric");+    let ic = ["sot-", "tsot-", "sod-", "soic-", "ssop-", "tssop-", "msop-", "qfn-", "vqfn-", "wqfn-", "dfn-", "wson-", "son-", "sc-70", "sc-88"].iter().any(|p| n.starts_with(p));+    chip || ic+}++#[derive(Debug, Clone)]+pub struct ModelRef {+    pub reference: String,+    pub lib: String,+    pub path: String,+    pub offset: [f64; 3],+    pub scale: [f64; 3],+    pub rotate: [f64; 3],+    pub smd_only: bool,+}++pub fn model_refs(b: &Board) -> Vec<ModelRef> {+    let xyz = |m: &sx::Node, tag: &str, d: f64| -> [f64; 3] {+        let n = m.find(tag).and_then(|t| t.find("xyz"));+        [n.and_then(|x| x.number(1)).unwrap_or(d), n.and_then(|x| x.number(2)).unwrap_or(d), n.and_then(|x| x.number(3)).unwrap_or(d)]+    };+    let mut out = Vec::new();+    for fp in b.root.find_all("footprint") {+        let Some(r) = aiflow_board::reference_of(fp) else { continue };+        let pads = b.pads_of(&r);+        let smd_only = !pads.is_empty() && pads.iter().all(|p| !p.thru);+        for m in fp.find_all("model") {+            let hidden = m.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || m.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == "hide"));+            if hidden { continue; }+            let Some(path) = m.value() else { continue };+            out.push(ModelRef { reference: r.clone(), lib: fp.value().unwrap_or("").to_string(), path: path.to_string(), offset: xyz(m, "offset", 0.0), scale: xyz(m, "scale", 1.0), rotate: xyz(m, "rotate", 0.0), smd_only });+        }+    }+    out+}++/// A model path on this machine: ${KIPRJMOD} is the project directory, other ${VARS} come from the+/// environment; a .wrl falls back to a .step/.stp beside it. None when it cannot be found here.+pub fn resolve_model(path: &str, project_dir: &Path) -> Option<PathBuf> {+    let mut p = path.replace("${KIPRJMOD}", &project_dir.display().to_string()).replace("$(KIPRJMOD)", &project_dir.display().to_string());+    while let Some(i) = p.find("${") {+        let j = p[i..].find('}')? + i;+        let v = std::env::var(&p[i + 2..j]).ok()?;+        p = format!("{}{}{}", &p[..i], v, &p[j + 1..]);+    }+    let mut pb = PathBuf::from(&p);+    if pb.is_relative() { pb = project_dir.join(pb); }+    let low = p.to_ascii_lowercase();+    let cands: Vec<PathBuf> = if low.ends_with(".wrl") || low.ends_with(".vrml") { vec![pb.with_extension("step"), pb.with_extension("stp"), pb.clone()] } else { vec![pb.clone()] };+    cands.into_iter().find(|c| c.is_file() && { let e = c.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); e == "step" || e == "stp" })+}++/// The bbox of a STEP: step2glb's `features` on the shared service first (ADOM_AIFLOW_STEP2GLB+/// names the CLI; "off" skips it), the file's own CARTESIAN_POINTs when that gives nothing.+/// Returns (bbox, source).+pub fn model_bbox(file: &Path, service_calls: &mut usize) -> Option<(BBox, String)> {+    let cli = std::env::var("ADOM_AIFLOW_STEP2GLB").unwrap_or_else(|_| "step2glb".into());+    if cli != "off" {+        // pace the shared service: one call at a time, a short gap between them+        if *service_calls > 0 { std::thread::sleep(std::time::Duration::from_millis(400)); }+        *service_calls += 1;+        if let Ok(o) = std::process::Command::new(&cli).args(["features", "-o", "-"]).arg(file).stderr(std::process::Stdio::null()).output() {+            if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {+                let axis = |k: &str| -> Option<[f64; 2]> { let a = v["bbox_mm"][k].as_array()?; Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) };+                if let (Some(x), Some(y), Some(z)) = (axis("x"), axis("y"), axis("z")) {+                    return Some(([x, y, z], "step2glb features".into()));+                }+            }+        }+    }+    let text = std::fs::read(file).ok()?;+    step_points_bbox(&String::from_utf8_lossy(&text)).map(|b| (b, "STEP CARTESIAN_POINTs".into()))+}++/// Orientation findings for one placed model (already transformed bbox, in the footprint frame).+pub fn orientation_findings(m: &ModelRef, bb: &BBox) -> Vec<String> {+    let ext = [bb[0][1] - bb[0][0], bb[1][1] - bb[1][0], bb[2][1] - bb[2][0]];+    let mut out = Vec::new();+    if m.smd_only && flat_chip(&m.lib) {+        let min_xy = ext[0].min(ext[1]);+        // a chip LED can stand a little taller than it is wide; a chip lying on its side is far taller+        if ext[2] > min_xy + 0.1 && ext[2] > 1.5 * min_xy {+            out.push(format!("thinnest axis is not Z on a flat SMD chip (x {:.2}, y {:.2}, z {:.2} mm): the model looks Y-up or on its side; bind it with rotate -90 about X (or fix the rotation) and check it sits on its pads in the native 3D viewer", ext[0], ext[1], ext[2]));+        }+    }+    // a bottom off the board by more than a tenth of a mm and a fifth of the body height: centred or floating+    if m.smd_only && bb[2][0].abs() > 0.1_f64.max(0.2 * ext[2]) {+        out.push(format!("bottom at z {:.2} mm, {} the board (an SMD body sits on the board, z 0): {}", bb[2][0], if bb[2][0] < 0.0 { "below" } else { "above" }, if bb[2][0] < 0.0 { "a Y-up or centred model; rotate -90 about X or offset it up" } else { "check the model offset" }));+    }+    if m.smd_only && bb[2][1] < 0.05 {+        out.push(format!("the whole body is at or below the board top (top z {:.2} mm): upside down or on the wrong side", bb[2][1]));+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    const BOARD: &str = r#"(kicad_pcb (version 20260206)+ (setup (aux_axis_origin 100 100))+ (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" "J1") (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net "VIN")))+ (footprint "Resistor_SMD:R_0402_1005Metric" (layer "F.Cu") (at 110 90) (property "Reference" "R1")+   (pad "1" smd roundrect (at -0.5 0) (size 0.5 0.6) (layers "F.Cu") (net "VIN"))+   (pad "2" smd roundrect (at 0.5 0) (size 0.5 0.6) (layers "F.Cu") (net "Net-(R1-Pad2)"))+   (model "${KIPRJMOD}/3d/r.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz -90 0 0))))+ (footprint "Resistor_SMD:R_0402_1005Metric" (layer "F.Cu") (at 112 90) (property "Reference" "R2")+   (pad "1" smd roundrect (at -0.5 0) (size 0.5 0.6) (layers "F.Cu") (net "Net-(R1-Pad2)"))+   (pad "2" smd roundrect (at 0.5 0) (size 0.5 0.6) (layers "F.Cu") (net "GND")))+)"#;++    fn board(text: &str) -> Board { Board::from_text("t.kicad_pcb", text.to_string()).unwrap() }++    fn netlist(r2_pin2: &str, extra: &str) -> String {+        format!(r#"(export (version "E")+ (components (comp (ref "MP1")) (comp (ref "MP2")) (comp (ref "MP3")) (comp (ref "MP4")) (comp (ref "J1")) (comp (ref "R1") (footprint "Resistor_SMD:R_0402_1005Metric")) (comp (ref "R2")) {extra})+ (nets+  (net (code "1") (name "/VIN") (node (ref "J1") (pin "1")) (node (ref "R1") (pin "1")))+  (net (code "2") (name "Net-(R1-Pad2)") (node (ref "R1") (pin "2")) (node (ref "R2") (pin "1")))+  (net (code "3") (name "GND") (node (ref "MP1") (pin "1")) (node (ref "MP2") (pin "1")) (node (ref "MP3") (pin "1")) (node (ref "MP4") (pin "1")) (node (ref "R2") (pin "{r2_pin2}")))))"#)+    }++    #[test]+    fn netlist_equal_with_slash_and_auto_names() {+        let sch = parse_sch_netlist(&netlist("2", "")).unwrap();+        let rep = compare_netlists(&sch, &board(BOARD));+        assert_eq!(rep["ok"], true, "{rep}");+        assert_eq!(rep["matchedPins"], 9);+    }++    #[test]+    fn netlist_reports_moved_pin_and_missing_ref() {+        let sch = parse_sch_netlist(&netlist("3", r#"(comp (ref "C9"))"#)).unwrap();+        let rep = compare_netlists(&sch, &board(BOARD));+        assert_eq!(rep["ok"], false);+        assert_eq!(rep["missingRefs"], json!(["C9"]));+        let pins: Vec<&str> = rep["mismatches"].as_array().unwrap().iter().map(|m| m["pin"].as_str().unwrap()).collect();+        assert!(pins.contains(&"R2.3") && pins.contains(&"R2.2"), "{rep}");+    }++    #[test]+    fn molecule_conforms_and_catches_off_grid_contact() {+        let spec = json!({"molecule": {"gridMm": 2, "edgeMarginMm": 2, "fixed": {"J1": [0, 12]}}});+        let f = molecule_check(&board(BOARD), &spec, None);+        assert!(f.errors.is_empty(), "{:?}", f.lines);+        let off = BOARD.replace("(at 100 88) (property \"Reference\" \"J1\")", "(at 100.5 88) (property \"Reference\" \"J1\")");+        let f = molecule_check(&board(&off), &spec, None);+        assert!(f.errors.iter().any(|e| e.contains("off the 2 mm grid")), "{:?}", f.lines);+        assert!(f.errors.iter().any(|e| e.contains("spec.molecule.fixed")), "{:?}", f.lines);+        // MP1 swapped with MP3: not front left+        let swapped = BOARD.replace("(at 100 100) (property \"Reference\" \"MP1\")", "(at 100 84) (property \"Reference\" \"MP1\")").replace("(at 100 84) (property \"Reference\" \"MP3\")", "(at 100 100) (property \"Reference\" \"MP3\")");+        let f = molecule_check(&board(&swapped), &spec, None);+        assert!(f.errors.iter().any(|e| e.contains("front-left")), "{:?}", f.lines);+    }++    #[test]+    fn molecule_moved_fixed_ref_against_start_board() {+        let spec = json!({"molecule": {}, "fixedRefs": ["J1"]});+        let moved = BOARD.replace("(at 100 88) (property \"Reference\" \"J1\")", "(at 100 90) (property \"Reference\" \"J1\")");+        let f = molecule_check(&board(&moved), &spec, Some(&board(BOARD)));+        assert!(f.errors.iter().any(|e| e.contains("moved since the run started")), "{:?}", f.lines);+    }++    #[test]+    fn rules_profiles() {+        let dir = std::env::temp_dir().join(format!("aiflow-rules-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let b = dir.join("b.kicad_pcb");+        let bs = b.to_str().unwrap();+        assert!(rules_check("jlcpcb", bs).is_err());+        assert!(rules_check("fab", bs).is_err());+        std::fs::write(b.with_extension("kicad_dru"), JLCPCB_2L).unwrap();+        assert!(rules_check("jlcpcb", bs).is_ok());+        assert!(rules_check("fab", bs).is_err());+        std::fs::write(b.with_extension("kicad_dru"), "(version 1)\n(rule \"x\" (constraint clearance (min 0.1mm)))\n").unwrap();+        assert!(rules_check("fab", bs).is_ok());+        assert!(rules_check("jlcpcb", bs).is_err());+        assert_eq!(rules_profile(&json!({"fab": "3rd party fab"})), None);+        assert_eq!(rules_profile(&json!({"fab": {"rules": "jlcpcb"}})).as_deref(), Some("jlcpcb"));+        assert!(JLCPCB_2L.contains(JLCPCB_MARKER));+        std::fs::remove_dir_all(dir).unwrap();+    }++    #[test]+    fn y_up_chip_is_flagged_and_rotated_one_is_not() {+        // a 1.0 x 0.5 x 0.35 chip modelled Y-up and centred: height on Y, centred on Z+        let yup: BBox = [[-0.5, 0.5], [0.0, 0.35], [-0.25, 0.25]];+        let mut m = ModelRef { reference: "R1".into(), lib: "Resistor_SMD:R_0402_1005Metric".into(), path: String::new(), offset: [0.0; 3], scale: [1.0; 3], rotate: [0.0; 3], smd_only: true };+        let raw = transform_bbox(&yup, m.offset, m.scale, m.rotate);+        assert!(orientation_findings(&m, &raw).iter().any(|s| s.contains("below the board")));+        m.rotate = [-90.0, 0.0, 0.0];+        let stood = transform_bbox(&yup, m.offset, m.scale, m.rotate);+        assert!((stood[2][0]).abs() < 1e-9 && (stood[2][1] - 0.35).abs() < 1e-9, "{stood:?}");+        assert!(orientation_findings(&m, &stood).is_empty());+        // an 0805 lying on its side, bottom on the board: the width stands on Z+        m.rotate = [0.0; 3];+        let side: BBox = [[-1.0, 1.0], [0.0, 0.5], [0.0, 1.25]];+        assert!(orientation_findings(&m, &side).iter().any(|s| s.contains("thinnest axis")));+        // a chip LED a little taller than wide is not flagged+        let led: BBox = [[-0.8, 0.8], [-0.4, 0.4], [0.0, 1.1]];+        assert!(orientation_findings(&m, &led).is_empty());+        // through-hole contacts sitting in the board are not judged by the SMD rules+        m.smd_only = false;+        assert!(orientation_findings(&m, &[[-0.44, 0.44], [-0.44, 0.44], [-1.65, 0.0]]).is_empty());+    }++    #[test]+    fn step_points_give_a_bbox() {+        let t = "#1=CARTESIAN_POINT('',(-1.,0.,2.5));\n#2 = CARTESIAN_POINT ( 'NONE',  ( 3.0, -4.0, 0.0 ) ) ;\n#3=DIRECTION('',(0.,0.,1.));";+        let bb = step_points_bbox(t).unwrap();+        assert_eq!(bb, [[-1.0, 3.0], [-4.0, 0.0], [0.0, 2.5]]);+    }++    #[test]+    fn model_refs_read_transform() {+        let b = board(BOARD);+        let ms = model_refs(&b);+        assert_eq!(ms.len(), 1);+        assert_eq!(ms[0].rotate, [-90.0, 0.0, 0.0]);+        assert!(ms[0].smd_only);+        assert!(flat_chip(&ms[0].lib));+        assert!(!flat_chip("Capacitor_SMD:CP_Elec_6.3x5.8"));+    }+}
crates/adom-aiflow/src/main.rs+898−92
@@ -2,11 +2,14 @@ //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run. mod silkscreen_sections; mod library_review;+mod checks;+mod preboard; use std::collections::BTreeMap; use std::path::{Path, PathBuf};  use aiflow_board::Board;-use aiflow_bridge::Bridge;+use aiflow_bridge::landing::{self, Copper};+use aiflow_bridge::{Bridge, ClipStop}; use aiflow_run::{now, Run, STAGES}; use clap::{Parser, Subcommand}; use serde_json::{json, Value};@@ -47,8 +50,32 @@ enum Cmd {     SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenContactPolicy { #[arg(long)] input: PathBuf, #[arg(long)] geometry: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] no_contact_borders: bool }, -    /// Start a run: copies the board, stamps the prompt time-    Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },+    /// Start a run: copies the board, stamps the prompt time. On a run made by `intake` it attaches the board and keeps the same clock and ledger.+    Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool, /// the run's wiki page (owner/slug): the run README and the clips page go there as the run goes+    #[arg(long)] page: Option<String>, /// the schematic the board was made from: recorded so `finish` requires a passing `netlist check`+    #[arg(long)] sch: Option<String> },+    /// `netlist check`: export the schematic's netlist with kicad-cli (ADOM_AIFLOW_KICAD_CLI) and compare it pin by pin with the board's pads; OK only when equal. In a run it defaults to the run's schematic and current board and records the outcome `finish` requires.+    Netlist { #[arg(value_parser = ["check"])] action: String, #[arg(long)] sch: Option<String>, #[arg(long)] board: Option<String>, /// an already exported netlist (kicadsexpr) instead of exporting one+    #[arg(long)] net: Option<String> },+    /// `molecule check`: Adom molecule conformance (MP1 to MP4 machine pins, contacts on the grid from MP1, edge margin, fixed references) from spec.molecule; `place check`, `gate` and `finish` run it too when the spec has "molecule".+    Molecule { #[arg(value_parser = ["check"])] action: String, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },+    /// `rules install --profile jlcpcb`: put the shipped JLCPCB 2-layer .kicad_dru beside the board; `rules check`: does the board's project carry the rules profile spec.fab.rules names ("fab" = the project's own .kicad_dru, "jlcpcb" = the shipped profile).+    Rules { #[arg(value_parser = ["install", "check"])] action: String, #[arg(long)] profile: Option<String>, #[arg(long)] board: Option<String>, /// replace an existing .kicad_dru+    #[arg(long)] force: bool },+    /// Start the clock at the prompt before any board exists: the run opens with the `intake` step and no board; `start --board` attaches it later on the same clock.+    Intake { #[arg(long)] prompt_time: String, /// the brief (a file: the human's message, a transcript, a spec); a requirements JSON is recorded as the requirements too+    #[arg(long)] brief: Option<String>, /// requirements.json (aiflow-intake): fab.target picks the sourcing profile+    #[arg(long)] requirements: Option<String>, #[arg(long)] engine: Option<String>, /// the desktop for pre-board clips (a monitor recording while no editor is open)+    #[arg(long)] target: Option<String>, /// the run's wiki page (owner/slug)+    #[arg(long)] page: Option<String> },+    /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, sources that fit the profile (fab: Mouser or Adom stock, no LCSC/JLCPCB parts; jlcpcb: LCSC numbers), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile.+    Sourcing { what: String, #[arg(long)] bom: String, #[arg(long, value_parser = ["fab", "jlcpcb"])] profile: Option<String>, /// boards to build (default: requirements build.quantity, else 10)+    #[arg(long)] qty: Option<u64>, /// the board to diff against (default: the run's current board)+    #[arg(long)] board: Option<String>, #[arg(long)] requirements: Option<String> },+    /// `evidence add --kind calcs|simulation|vendor-model --file <json|md>`: the design record in the run and the ledger, with its sha256; a failing simulation is refused. finish requires the kinds the spec lists in requireEvidence.+    Evidence { what: String, #[arg(long, value_parser = ["calcs", "simulation", "vendor-model"])] kind: String, #[arg(long)] file: String, #[arg(long)] note: Option<String> },+    /// The live clips page: rebuild docs/clips.md (every clip: step, visit, time, the 10x cut, the action cut, the contact sheet, the suspect state, the note from clip-notes.json) and with --push put it and the clip media on the page. Once a page is set, every clip stop does this by itself.+    Clips { #[arg(long)] page: Option<String>, #[arg(long)] push: bool },     /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.     /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.     AdoptBoard { #[arg(long)] board: String, #[arg(long)] evidence: String },@@ -71,7 +98,8 @@ enum Cmd {     /// KiCad DRC offline on the routing plan     Gate { #[arg(long)] plan: Option<String> },     /// Replay live through the bridge: route, vias, pours, moves-    Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64 },+    Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway+    #[arg(long)] from: Option<usize> },     /// Filled copper per layer from the live board     Measure,     /// current or thermal@@ -101,8 +129,11 @@ enum Cmd {     #[arg(long)] replaces: Option<String>, #[arg(long)] review: Option<PathBuf> },     /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.     Usage,-    /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement.-    Models,+    /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement. Also checks every bound model is right way up (step2glb bbox after the footprint's rotate/offset); warnings must be fixed or acknowledged (--ack/--why) before finish.+    Models { /// only the offline 3D orientation check (no live kicad_model_check through the bridge)+    #[arg(long)] offline: bool, /// acknowledge orientation warnings for these references (comma separated) after looking at them in the native 3D viewer+    #[arg(long)] ack: Option<String>, /// why the acknowledged models are right as they are+    #[arg(long)] why: Option<String> },     /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.     Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container     #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)@@ -174,9 +205,94 @@ fn read_json(p: &str) -> Value { }  fn spec_of(r: &Run) -> Value {+    if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }     read_json(r.data["spec"].as_str().unwrap_or("")) } +/// The spec when there is one, Null before the board (an intake run) or when it does not read.+fn spec_soft(r: &Run) -> Value {+    r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++/// requirements.json (aiflow-intake) when the run knows one, else Null.+fn requirements_of(r: &Run) -> Value {+    r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+}++/// The page the clips go to: `clips --page`, else the run's page (`start --page`, `report --page`).+fn clips_page_of(r: &Run) -> Option<String> {+    r.data["clipsPage"].as_str().or(r.data["page"].as_str()).map(str::to_string)+}++/// Rebuild docs/clips.md in <run>/clips-page and, with `push`, put it and the changed clip media+/// on the page (`adom-wiki repo push --files`, batches of about 20 MB). Refuses to push a page+/// with an em-dash or a match of $AIFLOW_BANNED_RE. Returns (what happened, the files).+fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String, Vec<String>), String> {+    let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    let (md, media) = preboard::clips_page(&r.data, dir, &notes, &now());+    let stage = dir.join("clips-page");+    std::fs::create_dir_all(stage.join("docs").join("clips")).map_err(|e| format!("{}: {e}", stage.display()))?;+    std::fs::write(stage.join("docs").join("clips.md"), &md).map_err(|e| e.to_string())?;+    for (src, rel) in &media {+        let dest = stage.join(rel);+        let same = std::fs::metadata(&dest).ok().zip(std::fs::metadata(src).ok()).map(|(a, b)| a.len() == b.len()).unwrap_or(false);+        if !same { std::fs::copy(src, &dest).map_err(|e| format!("copy {}: {e}", src.display()))?; }+    }+    let clips = md.matches("\n### Clip ").count();+    let mut files: Vec<String> = vec!["docs/clips.md".into()];+    files.extend(media.iter().map(|(_, rel)| rel.clone()));+    let banned = std::env::var("AIFLOW_BANNED_RE").ok();+    let scrubbed = preboard::scrub(&md, banned.as_deref());+    let url = format!("https://wiki.adom.inc/{page}/files/docs/clips.md");+    if !push {+        return Ok((format!("docs/clips.md rebuilt with {clips} clip(s) and {} media file(s) in {} (dry run, nothing pushed; {}); {url} after --push", media.len(), stage.display(), match &scrubbed { Ok(()) => "the scrub passes".to_string(), Err(e) => format!("a push would be refused: {e}") }), files));+    }+    scrubbed.map_err(|e| format!("not pushed: {e}"))?;+    // only what changed since the last push goes up (docs/clips.md always), in batches the gateway takes+    let cache_path = stage.join(".pushed.json");+    let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+    let mut todo: Vec<(String, u64, String)> = Vec::new();+    for f in &files {+        let p = stage.join(f);+        let sum = library_review::hash(&p).unwrap_or_default();+        if f == "docs/clips.md" || cache.get(f).and_then(|v| v.as_str()) != Some(sum.as_str()) { todo.push((f.clone(), std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0), sum)); }+    }+    let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];+    let mut acc = 0u64;+    for f in &todo {+        if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }+        batches.last_mut().unwrap().push(f); acc += f.1;+    }+    let (mut pushed, mut failed) = (0usize, Vec::new());+    for batch in batches.iter().filter(|b| !b.is_empty()) {+        let mut cmd = std::process::Command::new("adom-wiki");+        cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("clips: {clips} clip(s), {}", r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);+        for f in batch { cmd.arg(&f.0); }+        match cmd.output() {+            Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }+            Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())),+            Err(e) => failed.push(format!("{} file(s): {e}", batch.len())),+        }+    }+    let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());+    r.log("clips-page", json!({"page": page, "clips": clips, "pushed": pushed, "unchanged": files.len() - todo.len(), "failed": failed}));+    if !failed.is_empty() { return Err(format!("push failed for {}; the next clip stop (or `clips --push`) sends it again", failed.join("; "))); }+    Ok((format!("clips page pushed: {clips} clip(s), {pushed} changed file(s) ({} unchanged skipped): {url}", files.len() - todo.len()), files))+}++/// After a clip stops: when the run has a page, rebuild and push the clips page. Best effort: a+/// failure is said, never a failed step.+fn auto_clips(r: &Run, dir: &Path) -> String {+    let Some(pg) = clips_page_of(r) else { return String::new() };+    match publish_clips(r, dir, &pg, true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") }+}++/// The user's primary machine (`adom-bridge targets` names it).+fn primary_machine() -> Option<String> {+    let o = std::process::Command::new("adom-bridge").arg("targets").output().ok()?;+    preboard::primary_from_targets(&serde_json::from_slice::<Value>(&o.stdout).ok()?)+}+ /// The board's own copper as plan-shaped entries (one per net and track width, one per via), so the /// analyses see a board whose copper is already on it (an adopted, hand-reworked or natively routed /// board) and not only what a routing plan adds.@@ -507,6 +623,85 @@ fn recover_capture_files(r: &mut Run, dir: &Path) -> usize {     n } +/// Put one clip stop on its capture. The remote path and the local file are only ever filled in,+/// never blanked: a second stop of the same recording (the recorder answers "nothing running")+/// keeps what the first stop recorded. A clip whose pull did not land after the retries is+/// flagged `pullFailed` with its desktop path, its `file` stays null, and a WARNING names the+/// remote path; `recut`, `finish` and `deliver` pull it again. Returns the local file.+fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Option<String> {+    let cap = &mut r.data["captures"][pos];+    let had_file = cap["file"].as_str().map(str::to_string);+    if stop.remote.is_some() || cap.get("stop").is_none() { cap["stop"] = json!(now()); }+    match &stop.remote {+        Some(remote) => {+            cap["remoteFile"] = json!(remote);+            let kept = had_file.as_deref().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false);+            if stop.pulled {+                cap["file"] = json!(dir.join(Path::new(remote).file_name().unwrap_or_default()).display().to_string());+                if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }+            } else if !kept {+                cap["file"] = Value::Null;+                cap["pullFailed"] = json!(true);+                cap["pullAttempts"] = json!(stop.attempts);+                eprintln!("WARNING: clip {}-{} did NOT reach the run after {} pull(s): it is still on the desktop at {remote}. Run `adom-aiflow recut` to pull it again before the desktop cleans its recordings folder.", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1), stop.attempts);+            }+        }+        None => {+            if had_file.is_none() { cap["file"] = Value::Null; }+            if cap.get("remoteFile").is_none() { cap["remoteFile"] = Value::Null; }+        }+    }+    cap["file"].as_str().map(str::to_string)+}++/// The ledger line of a clip stop: `file` is the desktop path (what recover_capture_files reads),+/// `pullFailed` when the pull did not land.+fn clip_stop_event(mut ev: Value, stop: &ClipStop) -> Value {+    ev["file"] = json!(stop.remote);+    if stop.remote.is_some() && !stop.pulled { ev["pullFailed"] = json!(true); }+    ev+}++/// Pull again every stopped clip that is not in the run: flagged `pullFailed`, or recorded with a+/// local file that never arrived. The desktop path comes from the capture or, for older runs,+/// from the ledger's clip-stop lines. Returns (pulled now, still missing).+fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) {+    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+    let mut by_rid: std::collections::HashMap<String, String> = Default::default();+    for e in &r.ledger() {+        if e["event"] == "clip-stop" {+            if let (Some(rid), Some(f)) = (e["recordingId"].as_str(), e["file"].as_str()) { by_rid.insert(rid.to_string(), f.to_string()); }+        }+    }+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    let (mut pulled, mut missing) = (0, 0);+    for (i, c) in caps.iter().enumerate() {+        // running clips have no file key yet; discarded ones are never shown+        if c.get("file").is_none() || c.get("discarded").is_some() { continue; }+        if c["file"].as_str().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false) { continue; }+        let Some(remote) = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned()) else { continue };+        let local = dir.join(Path::new(&remote).file_name().unwrap_or_default()).display().to_string();+        let tag = format!("{}-{}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1));+        let landed = aiflow_bridge::nonempty(Path::new(&local)) || br.pull_verified(&remote, &save_to, aiflow_bridge::PULL_ATTEMPTS).0;+        let cap = &mut r.data["captures"][i];+        cap["remoteFile"] = json!(remote);+        if landed {+            cap["file"] = json!(local);+            cap["pulledLater"] = json!(now());+            if let Some(o) = cap.as_object_mut() { o.remove("pullFailed"); }+            r.log("clip-pulled", json!({"step": tag, "recordingId": c["recordingId"], "file": remote}));+            clip_artifacts(r, &json!(tag), &local, 10);+            pulled += 1;+        } else {+            cap["pullFailed"] = json!(true);+            eprintln!("WARNING: clip {tag} is still not in the run: pulling {remote} from the desktop failed again (the desktop may have cleaned it). Re-record the step if it matters.");+            missing += 1;+        }+    }+    if pulled > 0 { r.mark(&format!("pulled {pulled} clip(s) that had not reached the run")); }+    (pulled, missing)+}+ /// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so /// no recorder outlives the run (a clip left running once recorded nine hours of a static editor). fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {@@ -514,16 +709,15 @@ fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<     let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;     let rid = caps[pos]["recordingId"].as_str()?.to_string();     let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();-    let (p, _) = br.record_stop(&rid, &save_to);+    let stop = br.record_stop(&rid, &save_to);+    let local = settle_clip_stop(r, pos, &stop, dir);     let cap = &mut r.data["captures"][pos];-    cap["stop"] = json!(now());-    cap["remoteFile"] = json!(p);-    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));     cap["stoppedBy"] = json!(why);     let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-    let local = cap["file"].as_str().map(str::to_string);-    r.log("clip-stop", json!({"step": st, "recordingId": rid, "file": p, "by": why}));+    r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": why}), &stop));     if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }+    let note = auto_clips(r, dir);+    if !note.is_empty() { eprintln!("{note}"); }     local } @@ -1476,6 +1670,7 @@ fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, th }  fn board_of(r: &Run) -> Board {+    if r.board_pending() { err("this run has no board yet (an intake run)", &["Attach it when it exists: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (same run, same clock). Pre-board steps (sourcing, design, simulation, schematic) need no board.".into()]); }     Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[])) } @@ -1502,11 +1697,101 @@ fn command_line() -> String {     out.join(" ") } +// ----------------------------------------------------------------------------- design gates (checks.rs)++fn sha256_file(p: &str) -> String {+    use sha2::{Digest, Sha256};+    std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()+}++/// Export a schematic's netlist (kicadsexpr) with the native kicad-cli: ADOM_AIFLOW_KICAD_CLI, else+/// `kicad-cli`. In a container with no native KiCad, adom-aiflow-kicad-cli-remote runs a desktop's.+fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> {+    let cli = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+    let o = std::process::Command::new(&cli).args(["sch", "export", "netlist", "--format", "kicadsexpr", "--output"]).arg(out).arg(sch).output()+        .map_err(|e| format!("{cli} is not runnable: {e}"))?;+    if !o.status.success() || !out.is_file() {+        let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(500).collect::<String>().chars().rev().collect::<String>() };+        return Err(format!("{cli} sch export netlist failed: {} {}", tail(&o.stdout), tail(&o.stderr)));+    }+    Ok(cli)+}++/// The run's first board (what `start` copied): the reference for fixed parts.+fn first_board(r: &Run) -> Option<Board> {+    r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()).and_then(|p| Board::load(p).ok())+}++/// Molecule conformance of a board when the spec declares a molecule; None otherwise.+fn molecule_findings(spec: &Value, board: &Board, r: Option<&Run>) -> Option<checks::Findings> {+    if spec.get("molecule").is_none() { return None; }+    let before = r.and_then(first_board);+    Some(checks::molecule_check(board, spec, before.as_ref()))+}++/// Why the run's schematic and current board are not proven equal, or Ok when they are: the+/// recorded `netlist check` passed on this schematic (same sha256), and its exported netlist still+/// matches the current board (placement, routing or an adopted board cannot change a pin's net+/// unnoticed).+fn netlist_gate(r: &Run) -> Result<String, String> {+    let Some(sch) = r.data["schematic"].as_str() else { return Ok("no schematic recorded".into()) };+    let o = r.outcomes().get("netlist").cloned().ok_or("netlist check (the run has a schematic; run `adom-aiflow netlist check`)")?;+    if o["sha256"].as_str() != Some(sha256_file(sch).as_str()) { return Err("netlist check (the schematic changed since the last check; run it again)".into()); }+    let net = o["netlist"].as_str().unwrap_or("");+    let sn = checks::parse_sch_netlist(&std::fs::read_to_string(net).map_err(|e| format!("netlist check ({net}: {e}; run it again)"))?).map_err(|e| format!("netlist check ({e})"))?;+    let rep = checks::compare_netlists(&sn, &Board::load(&r.current_board()).map_err(|e| format!("netlist check ({e})"))?);+    if rep["ok"] != true { return Err(format!("netlist check (the current board differs from the schematic: {} mismatched pin(s), missing {}, extra {})", rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), rep["missingRefs"], rep["extraRefs"])); }+    Ok(format!("{}/{} pins", rep["matchedPins"], rep["connectedPins"]))+}++/// The 3D orientation pass of the models step: every model bound on the board, its bbox from+/// step2glb (or its STEP points), transformed as KiCad places it, judged. Returns+/// (warnings as [{ref, model, finding}], notes on what could not be checked). Bboxes are cached per+/// file (path, size, mtime) in the run directory so reruns do not call the shared service again.+fn model_orientation(board: &Board, project_dir: &Path, cache_path: Option<&Path>) -> (Vec<Value>, Vec<String>) {+    let mut cache: serde_json::Map<String, Value> = cache_path.and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();+    let (mut warnings, mut notes, mut calls) = (Vec::new(), Vec::new(), 0usize);+    let mut unresolved: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for m in checks::model_refs(board) {+        let Some(file) = checks::resolve_model(&m.path, project_dir) else { unresolved.entry(m.path.clone()).or_default().push(m.reference.clone()); continue };+        let meta = std::fs::metadata(&file).ok();+        let key = format!("{}|{}|{}", file.display(), meta.as_ref().map(|x| x.len()).unwrap_or(0), meta.and_then(|x| x.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0));+        let got = match cache.get(&key) {+            Some(v) => serde_json::from_value::<checks::BBox>(v["bbox"].clone()).ok().map(|b| (b, v["source"].as_str().unwrap_or("").to_string())),+            None => {+                let g = checks::model_bbox(&file, &mut calls);+                if let Some((b, src)) = &g { cache.insert(key, json!({"bbox": b, "source": src})); }+                g+            }+        };+        let Some((bb, src)) = got else { notes.push(format!("{} ({}): no bbox from step2glb or the STEP points", m.reference, file.display())); continue };+        let placed = checks::transform_bbox(&bb, m.offset, m.scale, m.rotate);+        for finding in checks::orientation_findings(&m, &placed) {+            warnings.push(json!({"ref": m.reference, "model": file.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), "rotate": m.rotate, "offset": m.offset, "bboxSource": src, "finding": finding}));+        }+    }+    for (p, refs) in unresolved { notes.push(format!("not checked here (path does not resolve in this container): {p} ({})", refs.join(","))); }+    if let Some(p) = cache_path { let _ = std::fs::write(p, serde_json::to_string_pretty(&Value::Object(cache)).unwrap_or_default()); }+    (warnings, notes)+}++/// Orientation warnings the AI has not acknowledged (run.data.modelAcks: {ref: why}).+fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> {+    let mut refs: Vec<String> = warnings.iter().filter_map(|w| w["ref"].as_str()).filter(|x| r.data["modelAcks"].get(*x).is_none()).map(str::to_string).collect();+    refs.dedup();+    refs+}++fn project_dir_of(r: &Run) -> PathBuf {+    let src = r.data["sourceBoard"].as_str().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r.current_board()));+    src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))+}+ fn main() {     let cli = Cli::parse();     let dir = run_dir(&cli);     // every command is a turn in the run's ledger; the gap before it is the AI's thinking time-    if !matches!(cli.cmd, Cmd::Start { .. }) {+    if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. }) {         if let Some(mut r) = Run::open(&dir) {             let idx = r.turn_begin(&command_line());             let _ = r.save();@@ -1520,7 +1805,7 @@ fn main() {                     let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);                     let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);                     let step = caps[pos]["step"].as_str().unwrap_or("").to_string();-                    let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, _ => 10 } as u64;+                    let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, "sourcing" => 20, "design" | "simulation" | "schematic" => 30, _ => 10 } as u64;                     if age > budget_min * 60 * 2 || age > 3600 {                         let br = Bridge { ai_thread: t, target };                         if let Some(mut r2) = Run::open(&dir) {@@ -1608,7 +1893,7 @@ fn main() {             if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }             ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);         }-        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => {+        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force, sch, page } => {             let t = thread(&cli);             if !Path::new(board).is_file() {                 err(&format!("board not found: {board}"), &[]);@@ -1616,22 +1901,166 @@ fn main() {             if !Path::new(spec).is_file() {                 err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);             }-            if let (Some(r), false) = (Run::open(&dir), *force) {+            if let Some(s) = sch { if !Path::new(s).is_file() { err(&format!("schematic not found: {s}"), &["--sch names the .kicad_sch the board was made from.".into()]); } }+            // a run made by `intake` has no board yet: start attaches it and keeps the clock and the ledger+            let intake_run = Run::open(&dir).filter(|r| r.board_pending() && !*force);+            if let (Some(r), false, true) = (Run::open(&dir), *force, intake_run.is_none()) {                 err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);             }             std::fs::create_dir_all(&dir).unwrap();             let copy = dir.join("board-0.kicad_pcb");             std::fs::copy(board, &copy).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));             aiflow_copper::copy_project_rules(board, &copy.display().to_string()).unwrap_or_else(|e|err(&e,&[]));-            let pt = prompt_time.clone().unwrap_or_else(now);             let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());+            if let Some(mut r) = intake_run {+                let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("").to_string();+                let differs = prompt_time.as_ref().filter(|p| **p != pt).map(|p| format!("--prompt-time {p} ignored: the intake run's clock started at {pt} and stays there. "));+                r.attach_board(&copy.display().to_string(), &specp, engine, target.as_deref(), remote_board.as_deref());+                r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+                if let Some(p) = page { r.data["page"] = json!(p); }+                if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+                let idx = r.turn_begin(&command_line());+                record_usage(&mut r, "start");+                r.save().unwrap();+                let _ = TURN.set((dir.clone(), idx));+                let steps: Vec<String> = r.data["steps"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+                ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &[differs.unwrap_or_default() + &format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Re-run `sourcing check --bom <csv>` now: the check before the board could not diff the BOM against the board's references.".into()]);+                return;+            }+            let pt = prompt_time.clone().unwrap_or_else(now);             let mut r = Run::create(&dir, &copy.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));             r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+            if let Some(s) = sch { r.data["schematic"] = json!(std::fs::canonicalize(s).map(|p| p.display().to_string()).unwrap_or(s.clone())); }+            if let Some(p) = page { r.data["page"] = json!(p); }             let idx = r.turn_begin(&command_line());             record_usage(&mut r, "start");             r.save().unwrap();             let _ = TURN.set((dir.clone(), idx));-            ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+            let mut start_hints = vec![format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display())];+            if sch.is_some() { start_hints.push("The run has a schematic: `adom-aiflow netlist check` proves the board equals it pin by pin; finish requires it.".into()); }+            else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }+            let spec_v = read_json(spec);+            if let Some(p) = checks::rules_profile(&spec_v) { if let Err(e) = checks::rules_check(&p, board) { start_hints.push(format!("Fab rules: {e}. {}", checks::rules_hint(&p))); } }+            if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }+            start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+            ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);+        }+        Cmd::Intake { prompt_time, brief, requirements, engine, target, page } => {+            let t = thread(&cli);+            if aiflow_run::parse(prompt_time).is_none() { err(&format!("--prompt-time {prompt_time} is not a time (RFC 3339 UTC, e.g. 2026-09-29T00:39:00Z)"), &["Use the human message's own timestamp, not the moment you got to it.".into()]); }+            if let Some(r) = Run::open(&dir) {+                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory: intake opens a new run. A follow-up prompt on this run is `prompt --text ...`.".into()]);+            }+            for f in brief.iter().chain(requirements.iter()) { if !Path::new(f).is_file() { err(&format!("not found: {f}"), &[]); } }+            let canon = |p: &String| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or(p.clone());+            // a brief that is already a requirements file (aiflow-intake's shape) is recorded as both+            let req = requirements.as_ref().map(canon).or_else(|| brief.as_ref().filter(|b| std::fs::read_to_string(b).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v.get("fab").is_some() || v.get("sourcing").is_some() || v.get("outputs").is_some()).unwrap_or(false)).map(canon));+            let mut r = Run::create_intake(&dir, prompt_time, &t, engine.as_deref(), brief.as_ref().map(canon).as_deref(), req.as_deref(), target.as_deref()).unwrap_or_else(|e| err(&e, &[]));+            if let Some(p) = page { r.data["page"] = json!(p); }+            let idx = r.turn_begin(&command_line());+            record_usage(&mut r, "start");+            r.save().unwrap();+            let _ = TURN.set((dir.clone(), idx));+            let profile = preboard::named_profile(&requirements_of(&r));+            ok(&format!("intake run started at {}; the clock started at the prompt, {prompt_time}; step intake is open and there is no board yet", dir.display()), &[+                "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step records a clip of the desktop until a PCB editor is open.".into(),+                match profile { Some(p) => format!("Sourcing profile {p} (from the requirements): `sourcing check --bom design/bom.csv` gates the BOM; finish requires it."), None => "No sourcing profile named yet: write requirements.json with fab.target `fab` (default) or `jlcpcb` (aiflow-intake), then `sourcing check --bom <csv> --requirements requirements.json`.".into() },+                "Record the design record as it lands: `evidence add --kind calcs --file design/calcs.json`, `evidence add --kind simulation --file sim/<results>.json` (a failing simulation is refused), `evidence add --kind vendor-model --file <result>` for a vendor PSpice/LTspice run (adom/adom-spice-skillpack).".into(),+                "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),+                if r.data["page"].is_null() { "Name the run's page once (`clips --page <owner/slug>` or `report --page ...`) so every clip lands on docs/clips.md as it stops.".into() } else { String::new() },+            ]);+        }+        Cmd::Sourcing { what, bom, profile, qty, board, requirements } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if what != "check" { err(&format!("sourcing {what}: use `sourcing check --bom <csv>`"), &[]); }+            if let Some(q) = requirements {+                if !Path::new(q).is_file() { err(&format!("not found: {q}"), &[]); }+                r.data["requirements"] = json!(std::fs::canonicalize(q).map(|p| p.display().to_string()).unwrap_or(q.clone()));+            }+            let (spec, req) = (spec_soft(&r), requirements_of(&r));+            let named = preboard::named_profile(&spec).map(|p| (p, "the spec")).or_else(|| preboard::named_profile(&req).map(|p| (p, "the requirements")));+            let prof = profile.clone().or_else(|| named.as_ref().map(|n| n.0.clone())).unwrap_or_else(|| "fab".into());+            let override_note = match (profile, &named) { (Some(p), Some((n, by))) if p != n => format!("--profile {p} overrides {by}, which name {n}; finish checks against the recorded outcome, so re-run without --profile if that was not meant."), _ => String::new() };+            let (qty, qty_src) = match qty { Some(q) => (*q, "--qty"), None => match req["build"]["quantity"].as_u64().or(req["buildQuantity"].as_u64()).map(|q| (q, "the requirements")).or_else(|| spec["buildQuantity"].as_u64().map(|q| (q, "the spec"))) { Some(x) => x, None => (10, "the default; say --qty or requirements build.quantity") } };+            let text = std::fs::read_to_string(bom).unwrap_or_else(|e| err(&format!("{bom}: {e}"), &[]));+            let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));+            let refs = board_path.as_ref().map(|p| Board::load(p).map(|b| preboard::board_refs(&b)).unwrap_or_else(|e| err(&format!("board {p}: {e}"), &[])));+            let mut out = preboard::sourcing_check(&text, refs.as_ref(), &prof, qty);+            let bomp = std::fs::canonicalize(bom).map(|p| p.display().to_string()).unwrap_or(bom.clone());+            out["bom"] = json!(bomp);+            out["bomSha256"] = json!(library_review::hash(Path::new(bom)).unwrap_or_default());+            out["board"] = json!(board_path);+            out["checkedAt"] = json!(now());+            out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") });+            out["qtySource"] = json!(qty_src);+            r.outcome("sourcing", out.clone());+            r.save().unwrap();+            let warnings: Vec<String> = out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("warning: {s}"))).collect();+            let rule = if prof == "jlcpcb" { "jlcpcb: every row carries its LCSC number (JLCPCB/LCSC basic parts first)" } else { "fab: every row from Mouser or Adom stock, no LCSC/JLCPCB parts (the 3rd party fab)" };+            if out["pass"] != true {+                let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("  {s}"))).collect();+                let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note];+                hints.extend(warnings);+                err(&format!("sourcing check failed on the {prof} profile ({} problem(s)):\n{}", errs.len(), errs.join("\n")), &hints);+            }+            let diff = if out["boardChecked"] == true { format!("every one of the board's {} BOM references is in the BOM ({} excluded by their footprints)", out["boardRefs"], out["excludedByBoard"].as_array().map(|a| a.len()).unwrap_or(0)) } else { "no board yet, so no reference diff: check again after `start --board` (finish requires the diff)".into() };+            let mut hints = vec![format!("Profile {rule}; build quantity {qty} ({qty_src})."), override_note];+            hints.extend(warnings);+            hints.push("Next: `evidence add --kind calcs --file design/calcs.json` once the values are derived (aiflow-circuit-design).".into());+            ok(&format!("sourcing check passed on the {prof} profile: {} rows, {} references ({} Adom stock, {} distributor, {} no part), stock checked {}; {diff}", out["rows"], out["refs"], out["adomStockRows"], out["distributorRows"], out["noPartRows"], out["stockCheckDates"].as_array().map(|a| a.iter().filter_map(|d| d.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()), &hints);+        }+        Cmd::Evidence { what, kind, file, note } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if what != "add" { err(&format!("evidence {what}: use `evidence add --kind calcs|simulation|vendor-model --file <json|md>`"), &[]); }+            let src = PathBuf::from(file);+            if !src.is_file() { err(&format!("no such file: {file}"), &[]); }+            let text = std::fs::read_to_string(&src).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["Evidence is a JSON or Markdown file.".into()]));+            let is_json = src.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("json")).unwrap_or(false);+            if is_json && serde_json::from_str::<Value>(&text).is_err() { err(&format!("{file}: not JSON"), &[]); }+            let sha = library_review::hash(&src).unwrap_or_else(|e| err(&e, &[]));+            let (top, checks, failing) = preboard::evidence_checks(&text, is_json);+            let pass = if top == Some(false) || !failing.is_empty() { Some(false) } else if top == Some(true) || !checks.is_empty() { Some(true) } else { None };+            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());+            let step = r.data["currentStep"].clone();+            if kind == "simulation" && pass == Some(false) {+                r.log("evidence-refused", json!({"kind": kind, "file": canon.display().to_string(), "sha256": sha, "failing": failing, "pass": top}));+                r.save().unwrap();+                err(&format!("simulation evidence refused: {} says it fails{}", file, if failing.is_empty() { " (top-level pass: false)".to_string() } else { format!(": {}", failing.iter().map(|c| format!("{} {} against {}", c["name"].as_str().unwrap_or("?"), c["value"], c["limit"])).collect::<Vec<_>>().join("; ")) }), &["A failing simulation is a design result, not evidence of a working design: change the values (aiflow-circuit-design), simulate again, and add the passing result. Obey a datasheet limit over a fitted model (aiflow-simulate).".into()]);+            }+            // a copy in the run keeps the record when the design folder moves on+            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());+            let copy = if canon.starts_with(&rdir) { canon.clone() } else {+                let d = dir.join("evidence");+                let _ = std::fs::create_dir_all(&d);+                let dest = d.join(format!("{kind}-{}", src.file_name().and_then(|n| n.to_str()).unwrap_or("file")));+                std::fs::copy(&src, &dest).unwrap_or_else(|e| err(&format!("copy into the run: {e}"), &[]));+                std::fs::canonicalize(&dest).unwrap_or(dest)+            };+            let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks});+            if !r.data["evidence"].is_array() { r.data["evidence"] = json!([]); }+            r.data["evidence"].as_array_mut().unwrap().push(entry.clone());+            r.log("evidence", entry);+            r.save().unwrap();+            let n = r.data["evidence"].as_array().map(|a| a.iter().filter(|e| e["kind"] == json!(kind)).count()).unwrap_or(0);+            let state = match pass { Some(true) => format!("pass ({} check(s))", checks.len()), Some(false) => format!("FAIL ({} failing check(s))", failing.len()), None => "no pass/fail fields".into() };+            let hint = match kind.as_str() {+                "calcs" => "Next: simulate the values the equations cannot settle (loop, startup, load step; aiflow-simulate), then `evidence add --kind simulation --file sim/<results>.json` with a top-level \"pass\" or \"checks\": [{\"name\",\"value\",\"limit\",\"pass\"}] so the gate can read it.".to_string(),+                "simulation" => if pass.is_none() { "This result states no pass or fail: add a top-level \"pass\" or a \"checks\" list against the requirements so the gate can read it (the file is recorded either way).".to_string() } else { "Next: the vendor model if the brief asks for one (`evidence add --kind vendor-model`), then `step schematic`.".to_string() },+                _ => format!("Vendor PSpice/LTspice runs belong to the adom/adom-spice-skillpack (`adom-wiki pkg install adom/adom-spice-skillpack`: PSpice for TI and LTspice on a Windows desktop through Adom Bridge); this records its result{}. Publish results, never solver speed numbers.", if pass == Some(false) { ", which says it fails: write the design conclusion in sim/<vendor>-validation.md" } else { "" }),+            };+            ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &[hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()]);+        }+        Cmd::Clips { page, push } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if let Some(p) = page { r.data["clipsPage"] = json!(p); r.save().unwrap(); }+            let pg = clips_page_of(&r).unwrap_or_else(|| err("no page for the clips: `clips --page <owner/slug>` once (the project's wiki page)", &[]));+            match publish_clips(&r, &dir, &pg, *push) {+                Ok((said, files)) => ok(&said, &[if *push { "Look at the rendered page once (a pup tab or a Hydrogen webview on the human's machine): a successful push is not a working page. Link docs/clips.md from the README once (wiki-sub-readme).".to_string() } else { format!("Would push {} file(s): {}", files.len(), files.iter().take(8).cloned().collect::<Vec<_>>().join(", ")) + if files.len() > 8 { ", ..." } else { "" } }, "From now on every clip stop rebuilds and pushes this page by itself; write each clip's note in <run>/clip-notes.json (step, shows, state) after you look at its contact sheet (aiflow-live-clips).".into()]),+                Err(e) => err(&e, &["Fix the page text (clip-notes.json is where notes come from), then `clips --push` again.".into()]),+            }         }         Cmd::Widget { action, file, label, step, progress } => {             let t = thread(&cli);@@ -1661,7 +2090,7 @@ fn main() {             r.add_board(&dest.display().to_string());             r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary}));             r.save().unwrap();-            ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route or gate against the adopted board. Re-solve Fields before finish.".into()]);+            ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]);         }         Cmd::Components { etch } => {             thread(&cli);@@ -1775,11 +2204,23 @@ fn main() {                 "pack" => {                     let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));                     let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));-                    let courtyard_edge=spec_of(&r)["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);+                    let spec = spec_of(&r);+                    let courtyard_edge=spec["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);+                    let chains = aiflow_place::chains_from_spec(&spec).unwrap_or_else(|e| err(&format!("spec.chains: {e}"), &[]));+                    let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);                     let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);                     write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());                     let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();-                    ok(&format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n")), &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }]);+                    let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);+                    let mut msg = format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n"));+                    if !chains.is_empty() {+                        let honoured = verdicts.iter().filter(|v| v.1).count();+                        msg.push_str(&format!("\nchains: {honoured} of {} honoured", chains.len()));+                        for l in &chain_lines { msg.push_str(&format!("\n  {l}")); }+                        for (name, _, v) in &verdicts { msg.push_str(&format!("\n  chain {name}: {v}")); }+                    }+                    let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() };+                    ok(&msg, &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }, chain_hint]);                 }                 "check" => {                     let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));@@ -1792,6 +2233,13 @@ fn main() {                     let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));                     let moved: Vec<String> = mv.keys().cloned().collect();                     let problems = aiflow_place::check(&nb, &moved, 0.05);+                    if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {+                        if !f.errors.is_empty() {+                            let mut h = f.hints.clone();+                            h.push(format!("The moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));+                            err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);+                        }+                    }                     let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));                     let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));                     let s = aiflow_copper::summarize(&drc, Some(&baseline));@@ -1845,18 +2293,43 @@ fn main() {             let missing=aiflow_pours::missing_planes(&b,&spec);             if !missing.is_empty(){err(&format!("declared reference planes do not exist: {}",missing.join(", ")), &["Create the listed net/layer zones through the EDA bridge, fill and save them, then `adom-aiflow adopt-board --board <saved.kicad_pcb> --evidence <native review notes>` before route. A spec.planes declaration alone is not copper.".into()]);}             let rules = aiflow_router::Rules::from_spec(&spec);+            let regions = aiflow_router::plane_under::regions(&b, &rules.plane_under).unwrap_or_else(|e| err(&format!("spec.planeUnder: {e}"), &["Each entry needs a layer (F.Cu or B.Cu), the net that owns the region, and \"around\" (parts or pads on this board, grown by \"margin\", default 1.0 mm) or a \"polygon\".".into()]));+            if spec.get("planeUnder").is_some() && regions.len() != spec["planeUnder"].as_array().map(|a| a.len()).unwrap_or(1) {+                err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);+            }+            let preexisting = aiflow_router::plane_under::issues(&b, &regions);             if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}             r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));             let mut router = aiflow_router::Router::new(b, rules, true);             let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);             std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();             let planp = dir.join("plan-route.json");-            write_json(&planp, &json!({"engine": "adom-aiflow router", "nets": plan}));+            let bc = report["boardCopper"].clone();+            let has_copper = bc["trackPieces"].as_u64().unwrap_or(0) + bc["vias"].as_u64().unwrap_or(0) > 0;+            let mut plan_doc = json!({"engine": "adom-aiflow router", "nets": plan});+            if has_copper {+                let complete = bc["netsComplete"].as_array().map(|a| a.len()).unwrap_or(0);+                plan_doc["boardCopper"] = bc.clone();+                plan_doc["note"] = json!(if plan.is_empty() && failed == 0 {+                    format!("nothing to route: the board's own copper ({} track pieces, {} vias, filled zones) already joins every net ({complete} complete); this empty plan is the route result, and gate, land route and finish accept it", bc["trackPieces"], bc["vias"])+                } else {+                    format!("the board's own copper is kept as fixed copper ({} track pieces, {} vias); {complete} nets are complete on it and the plan carries only the {} connection(s) it left open", bc["trackPieces"], bc["vias"], bc["openConnections"])+                });+            }+            if !regions.is_empty() {+                plan_doc["planeUnder"] = json!(regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>());+            }+            write_json(&planp, &plan_doc);             write_json(&dir.join("plan-route-report.json"), &report);             let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();             r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);-            r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>()}));+            r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));             r.save().unwrap();+            let policy_hint = if regions.is_empty() { String::new() } else if preexisting.is_empty() {+                format!("planeUnder: {} kept free of every other net's copper.", regions.iter().map(|k| k.label.clone()).collect::<Vec<_>>().join("; "))+            } else {+                format!("planeUnder: the board's own copper already sits inside a region ({}); the gate reports it until that copper is moved (rework it in KiCad, then adopt-board).", preexisting.iter().take(4).cloned().collect::<Vec<_>>().join("; "))+            };             if failed > 0 {                 let mut blockers: Vec<String> = Vec::new();                 for u in &unrouted {@@ -1872,9 +2345,15 @@ fn main() {                     }                 }                 let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();-                err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+                let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };+                err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+            }+            if has_copper && plan.is_empty() {+                ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);+            } else {+                let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };+                ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);             }-            ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into()]);         }         Cmd::Pour { plan } => {             thread(&cli);@@ -1898,11 +2377,29 @@ fn main() {         Cmd::Gate { plan } => {             thread(&cli);             let mut r = load_run(&cli);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]));             let b = board_of(&r);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| {+                // an adopted board that already carries its copper: gate the board as it is+                if b.tracks().is_empty() && b.vias().is_empty() {+                    err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]);+                }+                let p = dir.join("plan-route.json");+                write_json(&p, &json!({"engine": "none (the board's own copper)", "nets": [], "note": "gate with no routing plan on a board that carries copper: the board is gated as it is"}));+                eprintln!("Hint: no routing plan; the board carries copper, so it is gated as it is with an empty plan ({})", p.display());+                p.display().to_string()+            });             let plan_v = read_json(&planp);+            let spec_g = spec_of(&r);+            if let Some(p) = checks::rules_profile(&spec_g) {+                if let Err(e) = checks::rules_check(&p, &r.current_board()) { err(&format!("gate refused: {e}"), &[checks::rules_hint(&p), "DRC without the target fab's rules proves nothing about the fab; the gate runs only on the board's project rules.".into()]); }+            }+            if let Some(f) = molecule_findings(&spec_g, &b, Some(&r)) {+                if !f.errors.is_empty() { err(&format!("gate refused: the board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &f.hints); }+            }             r.stage_start("gate", Some("binary"), None);-            let (text, stats) = aiflow_copper::apply(&b, &plan_v, !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));+            // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors+            let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);+            let (text, stats) = aiflow_copper::apply(&b, &plan_v, adds_copper && !aiflow_copper::native_drc_available()).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));             let routed = dir.join("board-routed.kicad_pcb");             std::fs::write(&routed, &text).unwrap();             aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));@@ -1911,11 +2408,24 @@ fn main() {             let mut s = aiflow_copper::summarize(&data, Some(&base));             s["copper"] = stats;             s["file"] = json!(routed.display().to_string());+            // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error+            let spec = spec_of(&r);+            let pu = aiflow_router::rules::PlaneUnder::from_spec(&spec);+            let pu_issues: Vec<String> = if pu.is_empty() { Vec::new() } else {+                let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[]));+                match aiflow_router::plane_under::regions(&rb, &pu) { Ok(regs) => aiflow_router::plane_under::issues(&rb, &regs), Err(e) => vec![format!("spec.planeUnder: {e}")] }+            };+            s["planeUnderIssues"] = json!(pu_issues);+            if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }             write_json(&dir.join("gate.json"), &s);             let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));-            r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh})), None);-            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "routedBoard": routed.display().to_string(), "plan": planp}));+            let npu = pu_issues.len() as u64;+            r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);+            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "routedBoard": routed.display().to_string(), "plan": planp}));             r.save().unwrap();+            if npu > 0 && newe == 0 && unc == 0 {+                err(&format!("gate failed: {npu} planeUnder issue(s), other-net copper inside a plane region:\n{}", pu_issues.join("\n")), &["spec.planeUnder keeps these regions for one net's plane. Route again (the router keeps other nets out of them), or move the copper by hand and adopt the board; if the region is wrong, change spec.planeUnder.".into()]);+            }             if newe > 0 || unc > 0 {                 // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever                 let mut hair: Vec<String> = Vec::new();@@ -1937,12 +2447,29 @@ fn main() {                 }                 hair.sort(); hair.dedup();                 let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };-                err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);+                let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };+                err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);             }-            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected; copper {}", s["copper"]), &["Next: adom-aiflow land route (then pour, land vias, land pours).".into()]);+            let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);+            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}; copper {}", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);         }-        Cmd::Land { what, plan, moves, pause_ms } => {+        Cmd::Land { what, plan, moves, pause_ms, from } => {             let mut r = load_run(&cli);+            if what == "route" {+                // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call+                let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);+                let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0;+                let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));+                if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && read_json(p)["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false)) {+                    let note = read_json(&planp).get("note").cloned().unwrap_or(json!("empty plan"));+                    r.stage_start("land-route", Some("binary"), None);+                    r.stage_end("land-route", Some(json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0})), Some("empty plan: nothing to land"));+                    r.outcome("land-route", json!({"accepted": 0, "entries": 0, "traces": 0, "wallSeconds": 0.0, "stoppedAt": Value::Null, "emptyPlan": true, "note": note}));+                    r.save().unwrap();+                    ok(&format!("nothing to land: the gated plan {planp} is empty (the board already carries its copper); recorded as landed"), &["Next: adom-aiflow pour, land vias, land pours.".into()]);+                    return;+                }+            }             let br = bridge_of(&r, &cli);             let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();             match what.as_str() {@@ -1967,7 +2494,7 @@ fn main() {                 }                 "route" | "vias" => {                     let planp = if what == "route" {-                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0);+                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0;                         if !gate_ok && plan.is_none() {                             err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);                         }@@ -1975,39 +2502,83 @@ fn main() {                     } else {                         r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))                     };-                    let mut entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();-                    let mut already = 0usize;+                    let entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();+                    // a via must not touch another net's copper, planned or already on the board: KiCad+                    // hands a touching via the other net and the bridge's DRC then sees nothing wrong+                    let b = board_of(&r);+                    let pad_copper: Vec<Copper> = b.pads.iter().flat_map(|p| p.polys.iter().map(move |poly| Copper::Poly { poly: poly.clone(), net: p.net.clone(), what: format!("pad {} ({})", p.key, if p.net.is_empty() { "no net" } else { p.net.as_str() }) })).collect();+                    let mut plans: Vec<Value> = vec![read_json(&planp)];                     if what == "vias" {-                        // resumable: a via already on the live board at the same place on the same net is not landed twice-                        let live = br.live_vias(&rb);-                        entries.retain(|e| {-                            let net = e["net"].as_str().unwrap_or("");-                            let p = &e["paths"][0][0];-                            let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));-                            let dup = live.iter().any(|(n, lx, ly)| n == net && (lx - x).abs() < 0.05 && (ly - y).abs() < 0.05);-                            if dup { already += 1; }-                            !dup-                        });-                        if already > 0 {-                            eprintln!("{already} vias already on the live board; landing the other {}", entries.len());+                        if let Some(gp) = r.outcomes().get("gate").and_then(|g| g.get("plan")).or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan"))).and_then(|p| p.as_str()) {+                            if Path::new(gp).is_file() { plans.push(read_json(gp)); }                         }                     }+                    let mut copper_cache: Option<Vec<Copper>> = None;+                    let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> {+                        let t = landing::resolve(e, &live.pads)?;+                        if t.vias.is_empty() { return None; }+                        let copper = copper_cache.get_or_insert_with(|| {+                            let mut c = pad_copper.clone();+                            c.extend(landing::live_copper(live));+                            for p in &plans { c.extend(landing::plan_copper(p, &live.pads)); }+                            c+                        });+                        t.vias.iter().find_map(|v| landing::via_touch(&t.net, *v, t.via_size / 2.0, copper).map(|what| json!({"success": false, "errorCode": "via_touches_other_net", "net": t.net, "at": [v.0, v.1], "touches": what, "message": format!("the planned {} via at ({:.2}, {:.2}) would touch {what}; KiCad would give it that net", t.net, v.0, v.1)})))+                    };                     let stage = format!("land-{what}");                     r.stage_start(&stage, Some("binary"), None);                     let t0 = std::time::Instant::now();                     // one trace at a time: a net with several paths lands as several undo steps-                    let (replies, accepted, stopped, traces) = br.land_plan(&rb, &entries, *pause_ms, |i, n, entry, rep| {+                    let res = br.land_plan(&rb, &entries, from.unwrap_or(1), *pause_ms, precheck, |i, n, entry, rep| {+                        if rep["alreadyPresent"] == true { return; }                         eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);                     });-                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "accepted": accepted, "replies": replies, "wallSeconds": t0.elapsed().as_secs_f64()}));-                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "stoppedAt": stopped}));+                    let (accepted, stopped, traces) = (res.accepted, res.stopped, res.traces);+                    if res.skipped > 0 {+                        eprintln!("{} {} already on the live board (same net, same points): not landed again", res.skipped, if what == "vias" { "vias" } else { "traces" });+                    }+                    if res.recovered + res.retried > 0 {+                        eprintln!("transport: {} lost repl(ies) whose trace had landed, {} trace(s) landed on the one retry", res.recovered, res.retried);+                    }+                    // read back every landed via: the planned net must be the net KiCad gave it+                    let mut mismatches: Vec<Value> = Vec::new();+                    let with_vias: Vec<&(usize, Value, Value)> = res.landed.iter().filter(|(_, e, _)| e["paths"].to_string().contains("\"layer\"") || e["points"].to_string().contains("\"layer\"")).collect();+                    if !with_vias.is_empty() {+                        let Some(live) = br.live_copper(&rb) else {+                            r.stage_end(&stage, Some(json!({"accepted": accepted, "viaReadback": "unavailable"})), None);+                            r.save().unwrap();+                            err(&format!("{accepted} trace(s) landed, but the live board could not be read back to check the vias' nets"), &[format!("Read kicad_routing_state for {rb} and compare every via's net with {planp} before going on.")]);+                        };+                        let landed: Vec<(usize, landing::Trace)> = with_vias.iter().filter_map(|(n, e, _)| landing::resolve(e, &live.pads).map(|t| (*n, t))).collect();+                        for m in landing::via_net_mismatches(&landed, &live) {+                            let reply = with_vias.iter().find(|(n, _, _)| *n == m.trace).map(|(_, _, rep)| rep.clone()).unwrap_or(Value::Null);+                            let mut ids: Vec<String> = reply["itemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();+                            if ids.is_empty() && !m.uuid.is_empty() { ids.push(m.uuid.clone()); }+                            let undo = if ids.is_empty() { json!({"success": false, "errorCode": "no_item_ids"}) } else { br.remove_items(&rb, &ids) };+                            eprintln!("trace {}: the {} via at ({:.2}, {:.2}) landed as {}; {}", m.trace, m.planned, m.at.0, m.at.1, m.actual, if Bridge::ok(&undo) { "taken back" } else { "NOT taken back" });+                            mismatches.push(json!({"trace": m.trace, "planned": m.planned, "actual": m.actual, "at": [m.at.0, m.at.1], "viaId": m.uuid, "removedIds": ids, "undone": Bridge::ok(&undo), "undo": undo}));+                        }+                    }+                    let undone = mismatches.iter().filter(|m| m["undone"] == true).count();+                    let accepted = accepted - undone.min(accepted);+                    let wall = (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0;+                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "from": from, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "accepted": accepted, "viaNetMismatches": mismatches, "replies": res.replies, "wallSeconds": t0.elapsed().as_secs_f64()}));+                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "wallSeconds": wall})), None);+                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "skippedPresent": res.skipped, "skippedBeforeFrom": res.before_from, "recoveredFromReadback": res.recovered, "retried": res.retried, "viaNetMismatches": mismatches.len(), "wallSeconds": wall, "stoppedAt": stopped}));                     r.save().unwrap();+                    if !mismatches.is_empty() {+                        let lines: Vec<String> = mismatches.iter().map(|m| format!("trace {}: planned {} at {}, landed as {}{}", m["trace"], m["planned"], m["at"], m["actual"], if m["undone"] == true { " (taken back)" } else { " (NOT taken back: remove it by hand)" })).collect();+                        err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]);+                    }                     if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Resume from entry {} once fixed: the log has every reply.", i + 1)]);+                        let rep = res.replies.last().cloned().unwrap_or(Value::Null);+                        if rep["errorCode"] == "via_touches_other_net" {+                            err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);+                        }+                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped (or start at `--from {}`). The log has every reply.", i + 1)]);                     }-                    ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);+                    let skipped_note = if res.skipped > 0 { format!(", {} already on the board skipped", res.skipped) } else { String::new() };+                    ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries{skipped_note}, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);                 }                 "pours" => {                     let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));@@ -2122,12 +2693,19 @@ fn main() {         Cmd::Finish => {             let t = thread(&cli);             let mut r = load_run(&cli);+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t.clone(), target }, &dir);+                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+            }             let o = r.outcomes().clone();             let mut missing = Vec::new();             let gate = o.get("gate").cloned().unwrap_or(Value::Null);             if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {                 missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());             }+            if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {+                missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));+            }             if !o.contains_key("land-route") {                 missing.push("land route".into());             }@@ -2150,6 +2728,19 @@ fn main() {                     } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }                 }             }+            if let Err(m) = netlist_gate(&r) { missing.push(m); }+            let spec_f = spec_of(&r);+            if let Some(p) = checks::rules_profile(&spec_f) {+                if let Err(e) = checks::rules_check(&p, &r.current_board()) { missing.push(format!("fab rules ({e})")); }+            }+            if let Some(f) = molecule_findings(&spec_f, &board_of(&r), Some(&r)) {+                if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }+            }+            if r.outcomes().contains_key("models-orientation") {+                let (w, _) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));+                let open = unacked_orientation(&r, &w);+                if !open.is_empty() { missing.push(format!("3D models right way up (fix or `models --ack {} --why ...`)", open.join(","))); }+            }             let mut live = Value::Null;             if let Some(target) = r.data["target"].as_str() {                 let br = Bridge { ai_thread: t.clone(), target: target.to_string() };@@ -2159,6 +2750,20 @@ fn main() {                     missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));                 }             }+            // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for+            let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));+            if let Some(p) = preboard::named_profile(&spec_v).or_else(|| preboard::named_profile(&req_v)) {+                let sc = o.get("sourcing").cloned().unwrap_or(Value::Null);+                if sc["pass"] != true { missing.push(format!("sourcing check on the {p} profile (sourcing check --bom <csv>)")); }+                else if sc["boardChecked"] != true { missing.push("sourcing check against the board's references (run it again now the board exists)".into()); }+                else if sc["profile"].as_str() != Some(p.as_str()) { missing.push(format!("sourcing check on the {p} profile the spec or requirements name (the last check used {})", sc["profile"].as_str().unwrap_or("?"))); }+            }+            let mut required: Vec<String> = Vec::new();+            for v in [&spec_v, &req_v] { for k in v["requireEvidence"].as_array().cloned().unwrap_or_default() { if let Some(k) = k.as_str() { if !required.iter().any(|x| x == k) { required.push(k.to_string()); } } } }+            for k in &required {+                let have = r.data["evidence"].as_array().map(|a| a.iter().any(|e| e["kind"].as_str() == Some(k.as_str()) && e["pass"] != false)).unwrap_or(false);+                if !have { missing.push(format!("{k} evidence (evidence add --kind {k} --file <json|md>)")); }+            }             if !missing.is_empty() {                 err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);             }@@ -2220,19 +2825,22 @@ fn main() {                     let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };                     let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();                     let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let (p, _) = br.record_stop(&rid, &save_to);-                    let cap = &mut r.data["captures"][pos];-                    cap["stop"] = json!(now());-                    cap["remoteFile"] = json!(p);-                    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                    let stop = br.record_stop(&rid, &save_to);+                    let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                    let cap = &r.data["captures"][pos];                     let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-                    let local = cap["file"].as_str().map(str::to_string);+                    let p = stop.remote.clone();+                    r.log("clip-stop", clip_stop_event(json!({"step": st, "recordingId": rid, "by": "capture stop"}), &stop));                     r.mark("capture-stop");                     if let Some(f) = local.filter(|f| Path::new(f).is_file()) {                         clip_artifacts(&mut r, &st, &f, 10);                     }                     r.save().unwrap();-                    ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into()]);+                    if p.is_some() && !stop.pulled {+                        err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]);+                    }+                    let clips_note = auto_clips(&r, &dir);+                    ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into(), clips_note]);                 }                 "reject" => {                     // the AI looked at a contact sheet and saw a clip the blank check accepted but a@@ -2283,8 +2891,13 @@ fn main() {         }          Cmd::Recut => {-            thread(&cli);+            let t = thread(&cli);             let mut r = load_run(&cli);+            // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: t, target }, &dir);+                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+            }             let recovered = recover_capture_files(&mut r, &dir);             if recovered > 0 { eprintln!("recovered {recovered} clip file(s) the manifest had lost"); }             let caps = r.data["captures"].as_array().cloned().unwrap_or_default();@@ -2418,9 +3031,118 @@ fn main() {             ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);         } -        Cmd::Models => {+        Cmd::Netlist { action: _, sch, board, net } => {+            let mut run = Run::open(&dir);+            if run.is_some() { thread(&cli); }+            let sch = sch.clone().or_else(|| run.as_ref().and_then(|r| r.data["schematic"].as_str().map(str::to_string)))+                .unwrap_or_else(|| err("netlist check needs --sch <file.kicad_sch> (or a run started with --sch)", &["The schematic the board was made from; its netlist is exported with kicad-cli and compared pin by pin.".into()]));+            if !Path::new(&sch).is_file() { err(&format!("schematic not found: {sch}"), &[]); }+            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("netlist check needs --board <file.kicad_pcb> outside a run", &[]));+            let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+            let out_dir = if run.is_some() { dir.clone() } else { std::env::temp_dir() };+            let (netfile, via) = match net {+                Some(n) => (PathBuf::from(n), "given netlist".to_string()),+                None => {+                    let out = out_dir.join(if run.is_some() { "netlist-sch.net".to_string() } else { format!("aiflow-netlist-{}.net", std::process::id()) });+                    let _ = std::fs::remove_file(&out);+                    let cli_used = export_sch_netlist(&sch, &out).unwrap_or_else(|e| err(&e, &["Export needs KiCad's own kicad-cli for the schematic's version (a KiCad 7 kicad-cli cannot read a KiCad 10 schematic). In a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> KICAD_REMOTE_THREAD=<your thread>. Or export it yourself (`kicad-cli sch export netlist --format kicadsexpr`) and pass --net <file>.".into()]));+                    (out, format!("exported by {cli_used}"))+                }+            };+            let text = std::fs::read_to_string(&netfile).unwrap_or_else(|e| err(&format!("{}: {e}", netfile.display()), &[]));+            let sn = checks::parse_sch_netlist(&text).unwrap_or_else(|e| err(&e, &[]));+            let rep = checks::compare_netlists(&sn, &b);+            let passed = rep["ok"] == true;+            let mut lines = vec![format!("schematic {} ({} refs) vs board {} ({} refs): {}/{} connected pins on the same net ({via})", sch, rep["schematicRefs"], board_path, rep["boardRefs"], rep["matchedPins"], rep["connectedPins"])];+            for (k, label) in [("missingRefs", "missing on the board"), ("extraRefs", "on the board, not in the schematic")] {+                if let Some(a) = rep[k].as_array().filter(|a| !a.is_empty()) { lines.push(format!("  {label}: {}", a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+            }+            for m in rep["mismatches"].as_array().cloned().unwrap_or_default() {+                lines.push(format!("  {:<10} schematic {:<12} board {:<12} {}", m["pin"].as_str().unwrap_or(""), m["schematic"].as_str().unwrap_or("-"), m["board"].as_str().unwrap_or("-"), m["why"].as_str().unwrap_or("")));+            }+            let fpd: Vec<String> = rep["footprintDifferences"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+            if let Some(r) = run.as_mut() {+                let report = dir.join("netlist.json");+                write_json(&report, &rep);+                if r.data["schematic"].is_null() { r.data["schematic"] = json!(std::fs::canonicalize(&sch).map(|p| p.display().to_string()).unwrap_or(sch.clone())); }+                r.outcome("netlist", json!({"ok": passed, "schematic": sch, "sha256": sha256_file(&sch), "netlist": std::fs::canonicalize(&netfile).map(|p| p.display().to_string()).unwrap_or_default(), "board": board_path, "report": report.display().to_string(), "matchedPins": rep["matchedPins"], "connectedPins": rep["connectedPins"], "mismatches": rep["mismatches"].as_array().map(|a| a.len()).unwrap_or(0), "missingRefs": rep["missingRefs"], "extraRefs": rep["extraRefs"]}));+                r.save().unwrap_or_else(|e| err(&e, &[]));+            }+            let fp_hint = if fpd.is_empty() { String::new() } else { format!("Footprints differ (not blocking, but check they are the parts you meant): {}", fpd.join("; ")) };+            if !passed {+                err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);+            }+            ok(&lines.join("\n"), &[if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);+        }+        Cmd::Molecule { action: _, board, spec } => {+            let mut run = Run::open(&dir);+            if run.is_some() && board.is_none() { thread(&cli); }+            let spec_v = match spec { Some(p) => read_json(p), None => run.as_ref().map(spec_of).unwrap_or_else(|| err("molecule check needs --spec <spec.json> outside a run", &[])) };+            if spec_v.get("molecule").is_none() { eprintln!("Hint: the spec has no \"molecule\" object; checking with the Adom defaults (2 mm grid, 2 mm edge margin, medium pins). Add \"molecule\": {{...}} so place check, gate and finish run it too."); }+            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("molecule check needs --board <file.kicad_pcb> outside a run", &[]));+            let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));+            let before = if board.is_none() { run.as_ref().and_then(first_board) } else { None };+            let f = checks::molecule_check(&b, &spec_v, before.as_ref());+            if let (Some(r), None) = (run.as_mut(), board) {+                r.outcome("molecule", json!({"ok": f.errors.is_empty(), "board": board_path, "errors": f.errors, "warnings": f.warnings}));+                r.save().unwrap_or_else(|e| err(&e, &[]));+            }+            let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n"));+            if !f.errors.is_empty() { err(&text, &f.hints); }+            let mut hints = f.hints.clone();+            hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish (skill aiflow-molecule).".into());+            ok(&text, &hints);+        }+        Cmd::Rules { action, profile, board, force } => {+            let run = Run::open(&dir);+            if run.is_some() { thread(&cli); }+            let board_path = board.clone().or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("rules needs --board <file.kicad_pcb> outside a run", &[]));+            let profile = profile.clone().or_else(|| run.as_ref().and_then(|r| checks::rules_profile(&spec_of(r)))).unwrap_or_else(|| err("no rules profile: pass --profile jlcpcb|fab or set \"fab\": {\"rules\": ...} in the spec", &[]));+            match action.as_str() {+                "install" => {+                    if profile != "jlcpcb" {+                        err(&format!("no shipped rules for profile {profile:?}"), &["The 3rd party fab's rules are private and are not shipped with adom-aiflow: keep the project's own .kicad_dru beside the board (spec \"fab\": {\"rules\": \"fab\"}). `rules install` ships only --profile jlcpcb.".into()]);+                    }+                    let dru = Path::new(&board_path).with_extension("kicad_dru");+                    if dru.is_file() && !*force {+                        let cur = std::fs::read_to_string(&dru).unwrap_or_default();+                        if cur.contains(checks::JLCPCB_MARKER) { ok(&format!("{} already carries the jlcpcb-2L profile", dru.display()), &[]); return; }+                        err(&format!("{} already exists and is another rules file", dru.display()), &["Keep it (it may be the 3rd party fab's rules) or pass --force to replace it with the jlcpcb-2L profile.".into()]);+                    }+                    std::fs::write(&dru, checks::JLCPCB_2L).unwrap_or_else(|e| err(&format!("{}: {e}", dru.display()), &[]));+                    let mut hints = vec!["DRC loads it with the board: `gate` and `place check` need a native kicad-cli for project rules (ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote in a container).".into(), "Set \"fab\": {\"rules\": \"jlcpcb\"} in the spec so the gate refuses a board without it, and source parts on the jlcpcb profile (skill aiflow-sourcing).".into()];+                    if run.is_some() && board.is_none() { hints.push("Installed beside the run's current board; later snapshots carry it. Put the same file beside the source board too so the project keeps it.".into()); }+                    ok(&format!("installed the jlcpcb-2L rules (JLCPCB published 2-layer capabilities, cited in the file) at {}", dru.display()), &hints);+                }+                _ => {+                    match checks::rules_check(&profile, &board_path) {+                        Ok(m) => ok(&m, &[]),+                        Err(e) => err(&e, &[checks::rules_hint(&profile)]),+                    }+                }+            }+        }+        Cmd::Models { offline, ack, why } => {             let t = thread(&cli);             let mut r = load_run(&cli);+            if let Some(refs) = ack {+                let why = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<what you checked in the native 3D viewer>\"", &[]));+                for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["modelAcks"][x] = json!(why.clone()); }+                r.log("models-ack", json!({"refs": refs, "why": why}));+            }+            // the orientation pass: offline, every model bound on the board, right way up+            let (orient, orient_notes) = model_orientation(&board_of(&r), &project_dir_of(&r), Some(&dir.join("model-bbox-cache.json")));+            let unacked = unacked_orientation(&r, &orient);+            r.outcome("models-orientation", json!({"warnings": orient, "notChecked": orient_notes, "unacknowledged": unacked}));+            let orient_lines: Vec<String> = orient.iter().map(|w| format!("  {} {}: {}{}", w["ref"].as_str().unwrap_or(""), w["model"].as_str().unwrap_or(""), w["finding"].as_str().unwrap_or(""), if r.data["modelAcks"].get(w["ref"].as_str().unwrap_or("")).is_some() { " (acknowledged)" } else { "" })).collect();+            let orient_hint = if unacked.is_empty() { String::new() } else { format!("3D orientation warnings to fix or acknowledge ({}): correct the model's rotate/offset in the footprint and look at it in the native 3D viewer, then `models` again; when the model is right as it is, `models --ack {} --why \"<what you saw>\"`. finish refuses while any stays open.", unacked.join(","), unacked.join(",")) };+            if *offline {+                r.save().unwrap();+                let text = format!("3D orientation (offline): {} warning(s), {} not checked here{}{}", orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }, if orient_notes.is_empty() { String::new() } else { format!("\n  {}", orient_notes.join("\n  ")) });+                if !unacked.is_empty() { err(&text, &[orient_hint]); }+                ok(&text, &["The live resolution check is `models` without --offline (kicad_model_check on the test box).".into()]);+                return;+            }             let br = bridge_of(&r, &cli);             let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();             let _ = t;@@ -2446,12 +3168,26 @@ fn main() {             if unresolved > 0 {                 err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);             }-            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none)", counts["withModel"], counts["noModelExpected"]), &["Next: step placement.".into()]);+            if !unacked.is_empty() {+                err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);+            }+            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);         }         Cmd::Tour { what, nets, port, on } => {             let t = thread(&cli);             let mut r = load_run(&cli);-            let br = match on { Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() }, _ => bridge_of(&r, &cli) };+            // a localhost Fields URL ({{port}}.localhost, 127.0.0.1) is reachable only on the machine+            // hosting this container: film it there (the user's primary machine), not on the test box+            let local_proxy = what == "fields" && std::env::var("VSCODE_PROXY_URI").map(|u| preboard::proxy_is_local(&u)).unwrap_or(false);+            let mut on_note = String::new();+            let br = match on {+                Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() },+                _ if local_proxy => match primary_machine() {+                    Some(m) => { on_note = format!("filmed on {m}, the user's primary machine: the Fields URL is a localhost proxy only the container's host can reach (--on <box> overrides)"); Bridge { ai_thread: t.clone(), target: m } }+                    None => { on_note = "the Fields URL is a localhost proxy only the container's host can reach, and `adom-bridge targets` named no primary machine: filmed on the test box; say --on <the machine hosting this container> if the window does not appear".into(); bridge_of(&r, &cli) }+                },+                _ => bridge_of(&r, &cli),+            };             let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();             let editor = if what == "fields" { 0 } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };             match what.as_str() {@@ -2487,9 +3223,8 @@ fn main() {                     let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));                     if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {                         let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                        let stop = br.record_stop(&rid, &save_to);+                        settle_clip_stop(&mut r, pos, &stop, &dir);                         r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");                     }                     if !painted {@@ -2511,14 +3246,14 @@ fn main() {                     if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }                     let caps = r.data["captures"].as_array().cloned().unwrap_or_default();                     if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                        let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);-                        r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));+                        let stop = br.record_stop(&rid, &save_to);+                        let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                        r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));                         if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }                     }                     let _ = br.call("kicad_close_3d_viewer", &json!({}));+                    let clips_note = auto_clips(&r, &dir);+                    if !clips_note.is_empty() { eprintln!("{clips_note}"); }                     r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);                     r.outcome("tour-3d", json!({"commands": cmds}));                     r.save().unwrap();@@ -2597,16 +3332,15 @@ fn main() {                         hwnd = wins.iter().find(|w| w["title"].as_str().map(|t| (t == "Adom Fields" || t.starts_with("Adom Fields - ")) && !t.contains("Wiki") && !t.contains("wiki")).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())));                         if hwnd.is_some() { break; }                     }-                    let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on the test box (no window titled Adom Fields{})", open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into()]); };+                    let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on {} (no window titled Adom Fields{})", br.target, open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into(), if preboard::proxy_is_local(&proxy) { format!("The URL {proxy} is a localhost proxy: only the machine hosting this container can open it. `tour fields --on <that machine>`.") } else { String::new() }, on_note.clone()]); };                     let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();                     let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));                     // the editor clip of this visit is not the walkthrough: the app window is the camera                     let caps = r.data["captures"].as_array().cloned().unwrap_or_default();                     if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {                         let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                        let stop = br.record_stop(&rid, &save_to);+                        settle_clip_stop(&mut r, pos, &stop, &dir);                         r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");                     }                     std::thread::sleep(std::time::Duration::from_secs(4));@@ -2623,14 +3357,14 @@ fn main() {                     if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }                     let caps = r.data["captures"].as_array().cloned().unwrap_or_default();                     if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                        let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);-                        r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));+                        let stop = br.record_stop(&rid, &save_to);+                        let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                        r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));                         if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }                     }                     let _ = br.call("pup_close_window", &json!({"sessionId": sid}));+                    let clips_note = auto_clips(&r, &dir);+                    if !clips_note.is_empty() { eprintln!("{clips_note}"); }                     // the solved numbers ride the run: the issues are what the AI answers for                     let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);                     let issues = fields["issues"].as_array().cloned().unwrap_or_default();@@ -2639,7 +3373,7 @@ fn main() {                     r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));                     r.save().unwrap();                     if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }-                    ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into()]);+                    ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);                 }                 other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),             }@@ -2666,6 +3400,9 @@ fn main() {                         "marker" => println!("{t}  marker     {}", e["label"].as_str().unwrap_or("")),                         "finish" | "deliver" => println!("{t}  {}    {}", e["event"].as_str().unwrap_or(""), e["minutes"]),                         "prompt" => println!("{t}  prompt {:>2}  {}", e["n"], e["text"].as_str().unwrap_or("")),+                        "board-attached" => println!("{t}  board      {} attached to the intake run (clock from {})", e["board"].as_str().unwrap_or(""), e["promptTime"].as_str().unwrap_or("")),+                        "evidence" => println!("{t}  evidence   {} {} pass {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or(""), e["pass"]),+                        "evidence-refused" => println!("{t}  refused    {} {}", e["kind"].as_str().unwrap_or(""), e["file"].as_str().unwrap_or("")),                         "done" => println!("{t}  done   {:>2}  {}", e["n"], e["message"].as_str().unwrap_or("")),                         _ => {}                     }@@ -2676,8 +3413,10 @@ fn main() {         Cmd::Step { name, back, why } => {             thread(&cli);             let mut r = load_run(&cli);-            let known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());+            let mut known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());+            for s in preboard::PREBOARD_STEPS { if !known.iter().any(|k| k == s) { known.push(s.to_string()); } }             let known: Vec<&str> = known.iter().map(String::as_str).collect();+            let mut clip_stopped = false;             let prev = r.data["currentStep"].as_str().map(str::to_string);             record_usage(&mut r, &format!("step:{name}"));             r.step_set(name, *back, why.as_deref());@@ -2696,19 +3435,17 @@ fn main() {                             r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));                         }                     }-                    let (p, _) = br.record_stop(&rid, &save_to);-                    let cap = &mut r.data["captures"][pos];-                    cap["stop"] = json!(now());-                    cap["remoteFile"] = json!(p);-                    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                    let stop = br.record_stop(&rid, &save_to);+                    let local = settle_clip_stop(&mut r, pos, &stop, &dir);+                    let cap = &r.data["captures"][pos];                     let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-                    let local = cap["file"].as_str().map(str::to_string);                     let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());-                    r.log("clip-stop", json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid, "file": p}));+                    r.log("clip-stop", clip_stop_event(json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid}), &stop));                     if let Some(f) = local.filter(|f| Path::new(f).is_file()) {                         clip_artifacts(&mut r, &st, &f, 10);                     }                     clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));+                    clip_stopped = true;                 }                 // the fields step films the app's window through `tour fields`: no editor clip for it                 let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };@@ -2745,10 +3482,22 @@ fn main() {                         Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),                     },                     None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),+                    // before the board there is no editor to film: the desktop is the camera+                    None if preboard::PREBOARD_STEPS.contains(&name.as_str()) => match br.record_start(&format!("aiflow step {name} (before the board)")) {+                        Ok(rid) => {+                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "monitor", "step": name, "visit": visit, "back": *back, "why": why}));+                            r.mark(&format!("clip-start:{name}-{visit}"));+                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid, "window": "monitor"}));+                            clip_note.push_str(&format!("clip {name}-{visit} recording the desktop (no PCB editor before the board)"));+                        }+                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),+                    },                     None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),                 }             }             r.save().unwrap();+            let clips_note = if clip_stopped { auto_clips(&r, &dir) } else { String::new() };             let mut report_note = String::new();             if let Some(pg) = r.data["page"].as_str().map(str::to_string) {                 let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));@@ -2757,11 +3506,15 @@ fn main() {             let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();             let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };             let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };-            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note]);+            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note, clips_note]);         }         Cmd::Deliver { video, message, accept_suspect } => {             thread(&cli);             let mut r = load_run(&cli);+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let (pulled, missing) = repull_failed_clips(&mut r, &Bridge { ai_thread: thread(&cli), target }, &dir);+                if pulled + missing > 0 { eprintln!("pulled {pulled} clip(s) that had not reached the run; {missing} still missing"); r.save().unwrap(); }+            }             let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();             if !suspects.is_empty() && !*accept_suspect {                 err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!("  {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);@@ -2816,3 +3569,56 @@ mod measurement_gate_tests {         assert!(measurement_usable(Some(&json!({"filledAreaByLayerMm2":{"F.Cu":12,"B.Cu":0}}))));     } }++#[cfg(test)]+mod clip_stop_tests {+    use super::*;++    fn run_with_capture(dir: &Path, cap: Value) -> Run {+        let _ = std::fs::remove_dir_all(dir);+        std::fs::create_dir_all(dir).unwrap();+        Run { dir: dir.to_path_buf(), data: json!({"captures": [cap]}) }+    }+    fn stop(remote: Option<&str>, pulled: bool) -> ClipStop {+        ClipStop { remote: remote.map(str::to_string), local: None, pulled, attempts: if remote.is_some() { 3 } else { 0 }, reply: Value::Null }+    }++    #[test]+    fn a_failed_pull_is_flagged_with_its_remote_path_not_a_local_file() {+        let dir = std::env::temp_dir().join(format!("aiflow-clipstop-a-{}", std::process::id()));+        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-1", "step": "components", "visit": 1}));+        let remote = "C:/Users/j/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4";+        assert_eq!(settle_clip_stop(&mut r, 0, &stop(Some(remote), false), &dir), None);+        let c = &r.data["captures"][0];+        assert!(c["file"].is_null() && c.get("file").is_some(), "stopped, with no local file");+        assert_eq!((c["pullFailed"].clone(), c["remoteFile"].clone()), (json!(true), json!(remote)));+        assert_eq!(clip_stop_event(json!({}), &stop(Some(remote), false))["pullFailed"], true);+        // the pull that lands clears the flag and names the local file+        std::fs::write(dir.join("window-1.mp4"), b"mp4").unwrap();+        let f = settle_clip_stop(&mut r, 0, &stop(Some(remote), true), &dir).unwrap();+        assert!(f.ends_with("window-1.mp4"));+        assert!(r.data["captures"][0].get("pullFailed").is_none());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn a_second_stop_never_blanks_the_first_stops_file() {+        let dir = std::env::temp_dir().join(format!("aiflow-clipstop-b-{}", std::process::id()));+        let local = dir.join("window-2.mp4").display().to_string();+        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-2", "step": "placement", "visit": 1, "stop": "2026-09-29T02:43:48Z", "file": local, "remoteFile": "C:/rec/window-2.mp4"}));+        std::fs::write(&local, b"mp4").unwrap();+        // the recorder answers "nothing running": no path+        assert_eq!(settle_clip_stop(&mut r, 0, &stop(None, false), &dir), Some(local.clone()));+        let c = &r.data["captures"][0];+        assert_eq!((c["file"].as_str(), c["remoteFile"].as_str(), c["stop"].as_str()), (Some(local.as_str()), Some("C:/rec/window-2.mp4"), Some("2026-09-29T02:43:48Z")));+        // a failed re-pull of the same file keeps the good local copy+        settle_clip_stop(&mut r, 0, &stop(Some("C:/rec/window-2.mp4"), false), &dir);+        assert_eq!(r.data["captures"][0]["file"].as_str(), Some(local.as_str()));+        assert!(r.data["captures"][0].get("pullFailed").is_none());+        // a first stop with no path still marks the capture stopped+        let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "nets", "visit": 1}));+        settle_clip_stop(&mut r, 0, &stop(None, false), &dir);+        assert!(r.data["captures"][0].get("file").is_some());+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/preboard.rsadded+450
@@ -0,0 +1,450 @@+//! The work around the board that the binary measures and gates: the sourcing check (the BOM+//! against the board and the fab profile), design and simulation evidence, the live clips page and+//! the Fields URL's reachability. Pure functions here; main.rs wires them to the run.+use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};++use aiflow_board::Board;+use serde_json::{json, Value};++/// Steps before a board exists: they are steps like any other (turns, clips, the page).+pub const PREBOARD_STEPS: [&str; 5] = ["intake", "sourcing", "design", "simulation", "schematic"];++/// The profile a spec or requirements file names, if any: `sourcing.profile`, `sourcingProfile`+/// or `fab.target` (an object's `target`; a free-text `fab` string names no profile).+pub fn named_profile(v: &Value) -> Option<String> {+    let raw = v["sourcing"]["profile"].as_str().or(v["sourcingProfile"].as_str()).or(v["fab"]["target"].as_str())?;+    Some(normalise_profile(raw))+}++pub fn normalise_profile(raw: &str) -> String {+    if raw.to_ascii_lowercase().contains("jlc") { "jlcpcb".into() } else { "fab".into() }+}++/// A CSV file as rows of fields (quoted fields, doubled quotes and quoted newlines).+pub fn parse_csv(text: &str) -> Vec<Vec<String>> {+    let mut rows = Vec::new();+    let mut row = Vec::new();+    let mut field = String::new();+    let mut quoted = false;+    let mut chars = text.chars().peekable();+    while let Some(c) = chars.next() {+        if quoted {+            if c == '"' {+                if chars.peek() == Some(&'"') { field.push('"'); chars.next(); } else { quoted = false; }+            } else { field.push(c); }+            continue;+        }+        match c {+            '"' => quoted = true,+            ',' => row.push(std::mem::take(&mut field)),+            '\r' => {}+            '\n' => { row.push(std::mem::take(&mut field)); if row.iter().any(|f| !f.trim().is_empty()) { rows.push(std::mem::take(&mut row)); } else { row.clear(); } }+            _ => field.push(c),+        }+    }+    if !field.is_empty() || !row.is_empty() { row.push(field); if row.iter().any(|f| !f.trim().is_empty()) { rows.push(row); } }+    rows+}++/// The references a board needs in its BOM: every footprint with a pad on a net (electrical),+/// minus the ones the footprint itself excludes from the BOM (`attr exclude_from_bom` or+/// `board_only`: contacts, test pads, machine pins drawn as pads). Returns (needed, excluded).+pub fn board_refs(b: &Board) -> (BTreeSet<String>, BTreeSet<String>) {+    let (mut need, mut excluded) = (BTreeSet::new(), BTreeSet::new());+    for fp in b.root.find_all("footprint") {+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        if reference.is_empty() || reference.starts_with('#') { continue; }+        let electrical = b.pads_of(&reference).iter().any(|p| !p.net.trim().is_empty());+        if !electrical { continue; }+        let text = fp.find("attr").map(|a| (1..8).filter_map(|i| a.atom(i).map(str::to_string)).collect::<Vec<_>>().join(" ")).unwrap_or_default();+        if text.contains("exclude_from_bom") || text.contains("board_only") { excluded.insert(reference); } else { need.insert(reference); }+    }+    (need, excluded)+}++fn col(header: &[String], names: &[&str]) -> Option<usize> {+    let norm = |s: &str| s.trim().to_ascii_lowercase().replace([' ', '-', '#'], "_");+    header.iter().position(|h| names.iter().any(|n| norm(h) == *n))+}++fn date_in(s: &str) -> Option<String> {+    if s.len() < 10 { return None; }+    (0..=s.len() - 10).find_map(|i| {+        let w = s.get(i..i + 10)?;+        let ok = w.bytes().enumerate().all(|(k, c)| if k == 4 || k == 7 { c == b'-' } else { c.is_ascii_digit() });+        ok.then(|| w.to_string())+    })+}++fn leading_count(s: &str) -> Option<u64> {+    let digits: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || *c == ',' || *c == '_').filter(|c| c.is_ascii_digit()).collect();+    digits.parse().ok()+}++fn no_part(mpn: &str) -> bool {+    matches!(mpn.trim().to_ascii_lowercase().as_str(), "no part" | "none" | "n/a" | "na" | "dnp" | "-" | "no_part")+}++fn is_lcsc_number(s: &str) -> bool {+    let t = s.trim();+    t.len() >= 4 && (t.starts_with('C') || t.starts_with('c')) && t[1..].chars().all(|c| c.is_ascii_digit())+}++/// The sourcing check. `board` is (needed refs, excluded refs) when a board exists. `qty` is the+/// build quantity (boards). Returns the outcome: pass, errors, warnings and the counts.+pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<String>)>, profile: &str, qty: u64) -> Value {+    let rows = parse_csv(bom);+    let mut errors: Vec<String> = Vec::new();+    let mut warnings: Vec<String> = Vec::new();+    let Some(header) = rows.first() else { return json!({"pass": false, "errors": ["the BOM is empty"], "warnings": [], "rows": 0}) };+    let c_ref = col(header, &["ref", "refs", "reference", "references", "designator", "designators", "refdes"]);+    let c_mpn = col(header, &["mpn", "mfr_part", "mfr_pn", "manufacturer_part_number", "part_number", "mfr_part_number"]);+    let c_src = col(header, &["source", "supplier", "distributor", "vendor"]);+    let c_vpn = col(header, &["vendor_pn", "supplier_pn", "distributor_pn", "mouser_pn", "vendor_part_number", "supplier_part_number"]);+    let c_lcsc = col(header, &["lcsc", "lcsc_pn", "lcsc_part", "lcsc_part_number", "jlcpcb_part", "jlc_pn"]);+    // the dated stock column: a header carrying the date (stock_checked_2026-09-29), or a stock+    // column beside a stock_date / checked column that carries the date per row+    let c_stock_dated = header.iter().position(|h| h.to_ascii_lowercase().contains("stock") && date_in(h).is_some());+    let c_stock = c_stock_dated.or_else(|| col(header, &["stock", "stock_qty", "in_stock", "stock_checked"]));+    let c_stock_date = col(header, &["stock_date", "stock_checked_on", "checked", "checked_on", "stock_checked_date"]);+    let header_date = c_stock_dated.and_then(|i| date_in(&header[i]));+    for (name, c) in [("ref", c_ref), ("mpn", c_mpn), ("source", c_src)] {+        if c.is_none() { errors.push(format!("the BOM has no {name} column (header: {})", header.join(","))); }+    }+    if c_stock.is_none() { errors.push("the BOM has no stock column: add one named with the check date, e.g. stock_checked_2026-09-29".into()); }+    else if header_date.is_none() && c_stock_date.is_none() { errors.push(format!("the stock column `{}` carries no date: name it stock_checked_<YYYY-MM-DD> or add a stock_date column", header[c_stock.unwrap()])); }+    if !errors.is_empty() { return json!({"pass": false, "errors": errors, "warnings": warnings, "rows": rows.len().saturating_sub(1)}); }+    let get = |row: &Vec<String>, c: Option<usize>| c.and_then(|i| row.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();+    let mut bom_refs: BTreeMap<String, usize> = BTreeMap::new();+    let mut per_mpn: BTreeMap<String, (u64, Option<u64>, String)> = BTreeMap::new(); // mpn -> (refs, stock, first ref)+    let (mut no_part_rows, mut adom_rows, mut distributor_rows, mut lcsc_rows) = (0usize, 0usize, 0usize, 0usize);+    let mut dates: BTreeSet<String> = header_date.iter().cloned().collect();+    let mut adom_no_qty: Vec<String> = Vec::new();+    for (i, row) in rows.iter().enumerate().skip(1) {+        let line = i + 1;+        let refs: Vec<String> = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();+        if refs.is_empty() { warnings.push(format!("line {line}: a row with no reference")); continue; }+        let label = refs.join(" ");+        for rf in &refs {+            if bom_refs.insert(rf.clone(), line).is_some() { errors.push(format!("{rf}: listed twice in the BOM")); }+        }+        let mpn = get(row, c_mpn);+        let src = get(row, c_src);+        let src_l = src.to_ascii_lowercase();+        let lcsc = get(row, c_lcsc);+        let vpn = get(row, c_vpn);+        if no_part(&mpn) { no_part_rows += 1; continue; }+        if mpn.is_empty() { errors.push(format!("{label}: no MPN")); continue; }+        let carries_lcsc = !lcsc.is_empty() || is_lcsc_number(&vpn) || src_l.contains("lcsc") || src_l.contains("jlc");+        if carries_lcsc { lcsc_rows += 1; }+        let adom = src_l.contains("adom");+        if adom { adom_rows += 1; } else { distributor_rows += 1; }+        match profile {+            "jlcpcb" => {+                if lcsc.is_empty() && !is_lcsc_number(&vpn) { errors.push(format!("{label} ({mpn}): no LCSC number on the jlcpcb profile (add an lcsc column, e.g. C12345)")); }+            }+            _ => {+                if carries_lcsc { errors.push(format!("{label} ({mpn}): an LCSC/JLCPCB part on the fab profile; source it from Mouser or Adom stock")); }+                else if !(src_l.contains("mouser") || adom) { errors.push(format!("{label} ({mpn}): source `{src}` is not Mouser or Adom stock on the fab profile")); }+            }+        }+        let stock_text = get(row, c_stock);+        if let Some(d) = c_stock_date.map(|c| get(row, Some(c))).filter(|d| !d.is_empty()) { if let Some(d) = date_in(&d) { dates.insert(d); } }+        let stock = leading_count(&stock_text);+        if stock_text.is_empty() {+            if adom { adom_no_qty.push(label.clone()); }+            else { errors.push(format!("{label} ({mpn}): no stock check recorded")); }+        } else if stock.is_none() {+            warnings.push(format!("{label} ({mpn}): stock `{stock_text}` is not a count"));+        }+        if header_date.is_none() && c_stock_date.map(|c| get(row, Some(c)).is_empty()).unwrap_or(true) && !stock_text.is_empty() {+            errors.push(format!("{label} ({mpn}): the stock check has no date"));+        }+        let e = per_mpn.entry(mpn.clone()).or_insert((0, None, label.clone()));+        e.0 += refs.len() as u64;+        if e.1.is_none() { e.1 = stock; }+    }+    if !adom_no_qty.is_empty() { warnings.push(format!("{} Adom stock row(s) with no quantity recorded ({}): `pnp-inventory bulk-check --bom <csv>` fills it", adom_no_qty.len(), adom_no_qty.join(", "))); }+    for (mpn, (n, stock, label)) in &per_mpn {+        if let Some(s) = stock {+            let need = n * qty.max(1);+            if *s < 2 * need { warnings.push(format!("{label} ({mpn}): thin stock, {s} on hand for {need} needed ({n} per board x {qty}); under 2x the build: order early or pick a second source")); }+        }+    }+    let (mut missing, mut extra) = (Vec::new(), Vec::new());+    if let Some((need, excluded)) = board {+        for rf in need { if !bom_refs.contains_key(rf) { missing.push(rf.clone()); } }+        for rf in bom_refs.keys() { if !need.contains(rf) && !excluded.contains(rf) { extra.push(rf.clone()); } }+        if !missing.is_empty() { errors.push(format!("on the board but not in the BOM: {}", missing.join(", "))); }+        if !extra.is_empty() { errors.push(format!("in the BOM but not an electrical part on the board: {}", extra.join(", "))); }+    }+    json!({+        "pass": errors.is_empty(), "profile": profile, "qty": qty, "rows": rows.len() - 1, "refs": bom_refs.len(),+        "noPartRows": no_part_rows, "adomStockRows": adom_rows, "distributorRows": distributor_rows, "lcscRows": lcsc_rows,+        "stockCheckDates": dates, "boardChecked": board.is_some(),+        "boardRefs": board.map(|(n, _)| n.len()), "excludedByBoard": board.map(|(_, x)| x.iter().cloned().collect::<Vec<_>>()),+        "missing": missing, "extra": extra, "errors": errors, "warnings": warnings,+    })+}++/// Pass/fail fields of an evidence file: a top-level `pass`, and `checks` rows with+/// `name`, `value`, `limit`, `pass` (a row without `pass` is judged by value against limit:+/// a number is a maximum, {"min","max"} a range). Markdown: a line `pass: true|false`.+/// Returns (overall pass if stated, every check row, the failing ones).+pub fn evidence_checks(text: &str, is_json: bool) -> (Option<bool>, Vec<Value>, Vec<Value>) {+    if !is_json {+        let stated = text.lines().find_map(|l| {+            let l = l.trim().trim_start_matches(['-', '*', ' ']).to_ascii_lowercase();+            let v = l.strip_prefix("pass:")?.trim().trim_matches('*').to_string();+            match v.as_str() { "true" | "yes" | "pass" => Some(true), "false" | "no" | "fail" => Some(false), _ => None }+        });+        return (stated, Vec::new(), Vec::new());+    }+    let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);+    let top = v["pass"].as_bool();+    let mut rows = Vec::new();+    let mut failing = Vec::new();+    for c in v["checks"].as_array().cloned().unwrap_or_default() {+        let judged = c["pass"].as_bool().or_else(|| {+            let val = c["value"].as_f64()?;+            if let Some(l) = c["limit"].as_f64() { return Some(val <= l); }+            let (lo, hi) = (c["limit"]["min"].as_f64(), c["limit"]["max"].as_f64());+            if lo.is_none() && hi.is_none() { return None; }+            Some(lo.map(|x| val >= x).unwrap_or(true) && hi.map(|x| val <= x).unwrap_or(true))+        });+        let row = json!({"name": c["name"], "value": c["value"], "limit": c["limit"], "pass": judged});+        if judged == Some(false) { failing.push(row.clone()); }+        rows.push(row);+    }+    (top, rows, failing)+}++// -- the live clips page ------------------------------------------------------------------------++/// A clip file's local path: the manifest keeps the path as the stop wrote it (possibly relative+/// to another working directory); the run folder is the fallback.+pub fn clip_local(dir: &Path, file: &str) -> Option<PathBuf> {+    let p = PathBuf::from(file);+    if p.is_file() { return Some(p); }+    let q = dir.join(p.file_name()?);+    q.is_file().then_some(q)+}++fn when(iso: &str) -> String {+    if iso.len() >= 16 { format!("{} {} UTC", &iso[..10], &iso[11..16]) } else { iso.to_string() }+}++/// docs/clips.md and the media it references: (markdown, [(local source, repo path)]).+/// Media refs (video src, image) are repo-root-relative (docs/clips/x-10x.mp4); plain links are+/// relative to docs/ (clips/x-action.mp4), which is how the wiki's files viewer resolves them.+pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str) -> (String, Vec<(PathBuf, String)>) {+    let t0 = data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse);+    let mut caps: Vec<Value> = data["captures"].as_array().cloned().unwrap_or_default().into_iter().filter(|c| c["file"].is_string() && c.get("discarded").map(|d| d.is_null()).unwrap_or(true)).collect();+    caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));+    let known: BTreeSet<String> = data["captures"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["file"].as_str().and_then(|f| Path::new(f).file_stem()).map(|s| s.to_string_lossy().to_string())).collect();+    // clips on disk the manifest never recorded (a stop whose manifest write was lost) still go on the page+    let mut orphans: Vec<String> = std::fs::read_dir(dir).map(|d| d.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).filter(|n| n.starts_with("window-") && n.ends_with(".mp4") && !n.ends_with("-10x.mp4") && !n.ends_with("-action.mp4")).map(|n| n.trim_end_matches(".mp4").to_string()).filter(|s| !known.contains(s)).collect()).unwrap_or_default();+    orphans.sort();+    for s in orphans { caps.push(json!({"file": format!("{s}.mp4"), "step": null, "orphan": true})); }+    let (mut rows, mut blocks, mut media) = (Vec::new(), Vec::new(), Vec::new());+    for (i, c) in caps.iter().enumerate() {+        let n = i + 1;+        let file = c["file"].as_str().unwrap_or("");+        let stem = Path::new(file).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+        let note = &notes[&stem];+        let step = c["step"].as_str().unwrap_or("(not in the manifest)").to_string();+        let visit = c["visit"].as_u64().map(|v| v.to_string()).unwrap_or_else(|| "-".into());+        let title = note["step"].as_str().map(str::to_string).unwrap_or_else(|| step.clone());+        let start = c["start"].as_str().unwrap_or("");+        let offset = match (t0, aiflow_run::parse(start)) { (Some(a), Some(b)) => format!(", {} min after the prompt", b.saturating_sub(a) / 60), _ => String::new() };+        let secs = match (aiflow_run::parse(start), c["stop"].as_str().and_then(aiflow_run::parse)) { (Some(a), Some(b)) => format!(", {} min long", ((b.saturating_sub(a)) as f64 / 6.0).round() / 10.0), _ => String::new() };+        let mut have = BTreeMap::new();+        for suffix in ["-10x.mp4", "-action.mp4", "-sheet.png"] {+            let src = dir.join(format!("{stem}{suffix}"));+            if src.is_file() { let rel = format!("docs/clips/{stem}{suffix}"); media.push((src, rel.clone())); have.insert(suffix, rel); }+        }+        let mut state: Vec<String> = Vec::new();+        if let Some(s) = c["suspect"].as_str() { state.push(format!("suspect: {s}")); }+        if let Some(s) = note["state"].as_str().filter(|s| *s != "ok") { state.push(format!("note: {s}")); }+        let raw_here = clip_local(dir, file).is_some();+        if !raw_here && have.is_empty() { state.push("the recording never reached the run (the pull from the desktop did not land); nothing to show".into()); }+        else if !have.contains_key("-10x.mp4") { state.push("no 10x cut".into()); }+        let state_cell = if state.is_empty() { "ok".to_string() } else if c["suspect"].is_string() { "suspect".into() } else if !raw_here && have.is_empty() { "missing".into() } else { "see note".into() };+        let mut links = vec![format!("[clip](#clip-{n})")];+        for (suffix, label) in [("-10x.mp4", "10x"), ("-action.mp4", "action"), ("-sheet.png", "sheet")] {+            if have.contains_key(suffix) { links.push(format!("[{label}](clips/{stem}{suffix})")); }+        }+        rows.push(format!("| {n} | {step} | {visit}{} | {} | {} | {state_cell} |", if c["back"].as_bool() == Some(true) { " (back)" } else { "" }, when(start), links.join(", ")));+        let mut b = vec![format!("### Clip {n}: {title}"), String::new(), format!("<a id=\"clip-{n}\"></a>"), String::new()];+        let why = c["why"].as_str().map(|w| format!(" Back because: {w}.")).unwrap_or_default();+        b.push(format!("Step `{step}`, visit {visit}. Recorded {}{offset}{secs}.{why}", when(start)));+        b.push(String::new());+        if let Some(s) = note["shows"].as_str().filter(|s| !s.is_empty()) { b.push(s.to_string()); b.push(String::new()); }+        for s in &state { b.push(format!("> {s}")); b.push(String::new()); }+        if let Some(rel) = have.get("-10x.mp4") { b.push(format!("<video src=\"{rel}\" controls muted playsinline></video>")); b.push(String::new()); }+        if have.contains_key("-action.mp4") { b.push(format!("Motion-only action cut: [docs/clips/{stem}-action.mp4](clips/{stem}-action.mp4)")); b.push(String::new()); }+        if let Some(rel) = have.get("-sheet.png") { b.push(format!("Contact sheet (nine frames): ![clip {n} contact sheet]({rel})")); b.push(String::new()); }+        if let Some(raw) = clip_local(dir, file) {+            let mb = std::fs::metadata(&raw).map(|m| m.len()).unwrap_or(0) as f64 / 1e6;+            b.push(format!("Raw recording: `{stem}.mp4`, {mb:.0} MB, kept with the run (not uploaded)."));+            b.push(String::new());+        }+        blocks.push(b.join("\n"));+    }+    let mut md = vec![+        "# Build clips".to_string(), String::new(),+        "Every step of the adom-aiflow run records its own clip. This page is rebuilt and pushed as each clip stops, so it can stay open in a browser tab while the build runs.".to_string(), String::new(),+        format!("Last updated {}. {} clip(s).", when(updated), caps.len()), String::new(),+        "| # | step | visit | recorded | links | state |".to_string(), "|---|---|---|---|---|---|".to_string(),+    ];+    md.extend(rows);+    md.push(String::new());+    md.extend(blocks);+    (md.join("\n") + "\n", media)+}++/// Refuse a payload with an em-dash or a match of $AIFLOW_BANNED_RE (case-insensitive).+pub fn scrub(text: &str, banned: Option<&str>) -> Result<(), String> {+    if let Some(line) = text.lines().position(|l| l.contains('\u{2014}')) { return Err(format!("an em-dash on line {} of the page; rewrite it (a comma, a colon or two sentences)", line + 1)); }+    if let Some(re) = banned.filter(|r| !r.trim().is_empty()) {+        let rx = regex_lite::Regex::new(&format!("(?i){re}")).map_err(|e| format!("AIFLOW_BANNED_RE is not a regex: {e}"))?;+        if let Some(m) = rx.find(text) { let line = text[..m.start()].lines().count().max(1); return Err(format!("a banned word (AIFLOW_BANNED_RE) on line {line} of the page")); }+    }+    Ok(())+}++// -- the Fields URL -----------------------------------------------------------------------------++/// True when a URL is reachable only on the machine hosting the container: localhost,+/// 127.0.0.1, or a `{{port}}.localhost` proxy.+pub fn proxy_is_local(url: &str) -> bool {+    let rest = url.split("://").nth(1).unwrap_or(url);+    let host = rest.split(['/', '?', '#']).next().unwrap_or("");+    let host = if host.starts_with('[') { host.split(']').next().unwrap_or("").trim_start_matches('[') } else { host.rsplit_once(':').map(|(h, p)| if p.chars().all(|c| c.is_ascii_digit()) { h } else { host }).unwrap_or(host) };+    let h = host.to_ascii_lowercase();+    h == "localhost" || h.ends_with(".localhost") || h.starts_with("127.") || h == "::1" || h == "0.0.0.0"+}++/// The user's primary machine from `adom-bridge targets`.+pub fn primary_from_targets(v: &Value) -> Option<String> {+    v["primaryMachine"].as_str().filter(|s| !s.is_empty()).map(str::to_string).or_else(|| v["targets"].as_array()?.iter().find(|t| t["primary"].as_bool() == Some(true)).and_then(|t| t["name"].as_str()).map(str::to_string))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn refs(need: &[&str], ex: &[&str]) -> (BTreeSet<String>, BTreeSet<String>) {+        (need.iter().map(|s| s.to_string()).collect(), ex.iter().map(|s| s.to_string()).collect())+    }++    const HEAD: &str = "ref,value,mpn,manufacturer,source,vendor_pn,stock_checked_2026-09-29,note\n";++    #[test]+    fn csv_quotes_and_multi_refs() {+        let rows = parse_csv("a,b\n\"C6 C7\",\"x, \"\"y\"\"\"\n\n");+        assert_eq!(rows, vec![vec!["a", "b"], vec!["C6 C7", "x, \"y\""]]);+    }++    #[test]+    fn fab_profile_passes_mouser_and_adom_and_diffs_the_board() {+        let bom = format!("{HEAD}U1,x,TPS54202DDCR,TI,Mouser,595-TPS54202DDCR,160 (lead 140 d),\n\"R1 R2\",10k,CR0402,Bourns,Adom stock,,,\n");+        let out = sourcing_check(&bom, Some(&refs(&["U1", "R1", "R2"], &["J1"])), "fab", 10);+        assert_eq!(out["pass"], true, "{out}");+        assert_eq!(out["warnings"].as_array().unwrap().len(), 1); // Adom stock row with no quantity+        let out = sourcing_check(&bom, Some(&refs(&["U1", "R1", "R2", "C1"], &[])), "fab", 10);+        assert_eq!(out["pass"], false);+        assert_eq!(out["missing"], json!(["C1"]));+        let out = sourcing_check(&bom, Some(&refs(&["U1", "R1"], &[])), "fab", 10);+        assert_eq!(out["extra"], json!(["R2"]));+    }++    #[test]+    fn fab_profile_refuses_lcsc_and_other_sources_and_missing_mpn() {+        let bom = format!("{HEAD}U1,x,TPS,TI,JLCPCB,C123456,1000,\nU2,x,ABC,TI,DigiKey,1-2,1000,\nU3,x,,TI,Mouser,1-2,1000,\n");+        let out = sourcing_check(&bom, None, "fab", 1);+        let e = out["errors"].to_string();+        assert!(e.contains("U1") && e.contains("LCSC"), "{e}");+        assert!(e.contains("U2") && e.contains("DigiKey"), "{e}");+        assert!(e.contains("U3: no MPN"), "{e}");+        assert_eq!(out["boardChecked"], false);+    }++    #[test]+    fn jlcpcb_profile_needs_lcsc_numbers() {+        let bom = "ref,mpn,source,lcsc,stock_checked_2026-09-29\nU1,TPS,JLCPCB,C123456,5000\nU2,ABC,JLCPCB,,5000\n";+        let out = sourcing_check(bom, None, "jlcpcb", 1);+        assert_eq!(out["pass"], false);+        assert!(out["errors"].to_string().contains("U2 (ABC): no LCSC number"));+    }++    #[test]+    fn stock_needs_a_date_and_thin_stock_warns() {+        let out = sourcing_check("ref,mpn,source,stock\nU1,TPS,Mouser,160\n", None, "fab", 1);+        assert_eq!(out["pass"], false);+        assert!(out["errors"].to_string().contains("carries no date"));+        let out = sourcing_check("ref,mpn,source,stock,stock_date\nU1,TPS,Mouser,15,2026-09-29\n", None, "fab", 10);+        assert_eq!(out["pass"], true, "{out}");+        assert!(out["warnings"].to_string().contains("thin stock"));+    }++    #[test]+    fn profiles_are_named_by_spec_or_requirements() {+        assert_eq!(named_profile(&json!({"fab": {"target": "jlcpcb"}})).as_deref(), Some("jlcpcb"));+        assert_eq!(named_profile(&json!({"sourcing": {"profile": "fab"}})).as_deref(), Some("fab"));+        assert_eq!(named_profile(&json!({"fab": "3rd party fab, 2-layer"})), None);+    }++    #[test]+    fn simulation_checks_pass_and_fail() {+        let (top, rows, failing) = evidence_checks(r#"{"pass": true, "checks": [{"name":"ripple","value":3.2,"limit":10},{"name":"pm","value":40,"limit":{"min":45}},{"name":"x","pass":true}]}"#, true);+        assert_eq!(top, Some(true));+        assert_eq!(rows.len(), 3);+        assert_eq!(failing.len(), 1);+        assert_eq!(failing[0]["name"], "pm");+        assert_eq!(evidence_checks("# Result\n- pass: false\n", false).0, Some(false));+        assert_eq!(evidence_checks("{\"vin\": 12}", true), (None, vec![], vec![]));+    }++    #[test]+    fn scrub_refuses_em_dash_and_banned_words() {+        assert!(scrub("fine - text", None).is_ok());+        assert!(scrub("bad \u{2014} text", None).is_err());+        assert!(scrub("the Secret fab", Some("secret|hidden")).is_err());+        assert!(scrub("the open fab", Some("secret|hidden")).is_ok());+    }++    #[test]+    fn local_proxies_are_detected() {+        assert!(proxy_is_local("http://8874.localhost:3000/"));+        assert!(proxy_is_local("http://{{port}}.localhost:8080/"));+        assert!(proxy_is_local("http://127.0.0.1:8874/"));+        assert!(proxy_is_local("http://localhost/x"));+        assert!(!proxy_is_local("https://slug.adom.cloud/proxy/8874/"));+        assert_eq!(primary_from_targets(&json!({"primaryMachine": "AdomLapper"})).as_deref(), Some("AdomLapper"));+        assert_eq!(primary_from_targets(&json!({"targets": [{"name": "a"}, {"name": "b", "primary": true}]})).as_deref(), Some("b"));+    }++    #[test]+    fn clips_page_paths_and_states() {+        let dir = std::env::temp_dir().join(format!("aiflow-clips-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        for f in ["window-1-20260929-010000.mp4", "window-1-20260929-010000-10x.mp4", "window-1-20260929-010000-sheet.png", "window-1-20260929-010000-action.mp4", "window-9-20260929-020000.mp4"] { std::fs::write(dir.join(f), b"x").unwrap(); }+        let data = json!({"clock": {"promptTime": "2026-09-29T00:00:00Z"}, "captures": [+            {"file": "./run/window-1-20260929-010000.mp4", "step": "placement", "visit": 1, "start": "2026-09-29T01:00:00Z", "stop": "2026-09-29T01:10:00Z", "suspect": "8 of 8 flat"},+            {"file": "./run/window-2.mp4", "step": "3d", "visit": 1, "start": "2026-09-29T01:30:00Z", "discarded": "editor clip"}]});+        let notes = json!({"window-1-20260929-010000": {"step": "Placement", "shows": "parts land", "state": "ok"}});+        let (md, media) = clips_page(&data, &dir, &notes, "2026-09-29T03:00:00Z");+        assert!(md.contains("<video src=\"docs/clips/window-1-20260929-010000-10x.mp4\""), "{md}");+        assert!(md.contains("](clips/window-1-20260929-010000-action.mp4)"));+        assert!(md.contains("![clip 1 contact sheet](docs/clips/window-1-20260929-010000-sheet.png)"));+        assert!(md.contains("| 1 | placement | 1 | 2026-09-29 01:00 UTC |"));+        assert!(md.contains("suspect: 8 of 8 flat"));+        assert!(md.contains("(not in the manifest)"), "orphan window-9 listed");+        assert!(!md.contains("window-2"), "discarded clip left out");+        assert_eq!(media.len(), 3);+        assert!(scrub(&md, None).is_ok());+        std::fs::remove_dir_all(dir).unwrap();+    }+}
crates/aiflow-board/src/lib.rs+104
@@ -60,6 +60,34 @@ pub struct Footprint {     pub end: usize, } +/// One straight piece of an existing track (a `segment`, or a piece of an `arc`).+#[derive(Debug, Clone)]+pub struct Track {+    pub net: String,+    pub layer: usize,+    pub a: Pt,+    pub b: Pt,+    pub width: f64,+}++#[derive(Debug, Clone)]+pub struct Via {+    pub net: String,+    pub x: f64,+    pub y: f64,+    pub size: f64,+    pub drill: f64,+}++/// A copper zone: its outline and its filled islands per layer (empty when never filled).+#[derive(Debug, Clone)]+pub struct Zone {+    pub net: String,+    pub layers: Vec<usize>,+    pub outline: Vec<Pt>,+    pub fills: Vec<(usize, Vec<Pt>)>,+}+ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetFormat {     Number,@@ -330,6 +358,82 @@ impl Board {         self.pads.iter().filter(|p| p.reference == reference).collect()     } +    /// The net name of a board item's `(net ...)`: a number through the net table, or the name itself.+    pub fn item_net(&self, n: &Node) -> String {+        let Some(x) = n.find("net") else { return String::new() };+        if self.net_format == NetFormat::Number {+            if let Some(k) = x.atom(1).and_then(|a| a.parse::<i64>().ok()) {+                return self.net_table.get(&k).cloned().unwrap_or_default();+            }+        }+        x.atom(1).unwrap_or("").to_string()+    }++    /// The copper tracks already on the board: every `segment`, and every `arc` as short straight+    /// pieces, on F.Cu or B.Cu.+    pub fn tracks(&self) -> Vec<Track> {+        let mut out = Vec::new();+        let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };+        for node in self.root.nodes() {+            if node.tag != "segment" && node.tag != "arc" {+                continue;+            }+            let layer = match node.find("layer").and_then(|l| l.value()) { Some("F.Cu") => F_CU, Some("B.Cu") => B_CU, _ => continue };+            let width = node.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);+            let net = self.item_net(node);+            let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) else { continue };+            let pts = match (node.tag.as_str(), p2(node.find("mid"))) { ("arc", Some(m)) => geom::arc_points(s, m, e, 0.1), _ => vec![s, e] };+            for w in pts.windows(2) {+                out.push(Track { net: net.clone(), layer, a: w[0], b: w[1], width });+            }+        }+        out+    }++    /// The vias already on the board (through vias: both outer layers).+    pub fn vias(&self) -> Vec<Via> {+        let mut out = Vec::new();+        for v in self.root.find_all("via") {+            let at = v.find("at");+            let (Some(x), Some(y)) = (at.and_then(|a| a.number(1)), at.and_then(|a| a.number(2))) else { continue };+            let size = v.find("size").and_then(|s| s.number(1)).unwrap_or(0.6);+            let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);+            out.push(Via { net: self.item_net(v), x, y, size, drill });+        }+        out+    }++    /// Copper zones with their net, layers, outline and filled islands (one polygon per island).+    pub fn zones(&self) -> Vec<Zone> {+        let mut out = Vec::new();+        let pts_of = |n: &Node| -> Vec<Pt> { n.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default() };+        for z in self.root.find_all("zone") {+            if z.find("keepout").is_some() {+                continue;+            }+            let mut layers = Vec::new();+            let mut names: Vec<String> = Vec::new();+            if let Some(l) = z.find("layer").and_then(|l| l.value()) { names.push(l.to_string()); }+            if let Some(ls) = z.find("layers") {+                names.extend(ls.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { Some(text.clone()) } else { None }).filter(|t| t != "layers"));+            }+            for n in &names {+                for (k, l) in LAYER_NAMES.iter().enumerate() {+                    if (n == l || n == "*.Cu" || n == "F&B.Cu") && !layers.contains(&k) { layers.push(k); }+                }+            }+            let outline = z.find("polygon").map(pts_of).unwrap_or_default();+            let mut fills = Vec::new();+            for f in z.find_all("filled_polygon") {+                let layer = match f.find("layer").and_then(|l| l.value()) { Some("F.Cu") => F_CU, Some("B.Cu") => B_CU, _ => continue };+                let poly = pts_of(f);+                if poly.len() >= 3 { fills.push((layer, poly)); }+            }+            out.push(Zone { net: self.item_net(z), layers, outline, fills });+        }+        out+    }+     /// Pad-box (copper extent) of every pad of a footprint: (x0, y0, x1, y1).     pub fn pad_box(&self, reference: &str) -> Option<(f64, f64, f64, f64)> {         let pts: Vec<Pt> = self.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).collect();
crates/aiflow-bridge/Cargo.toml+1
@@ -8,3 +8,4 @@ description = "adom-aiflow: bridge" [dependencies] serde = { workspace = true } serde_json = { workspace = true }+aiflow-board = { workspace = true }
crates/aiflow-bridge/src/landing.rsadded+463
@@ -0,0 +1,463 @@+//! Landing bookkeeping that needs no desktop: which traces of a plan are already on the live+//! board (so a rerun after a dropped reply never lands a trace twice), which planned vias would+//! touch another net's copper, which landed vias came back on another net, and which failed+//! replies are the transport rather than KiCad.+use std::collections::BTreeMap;++use aiflow_board::geom;+use serde_json::{json, Value};++pub type Pt = (f64, f64);++/// Two plan points closer than this are the same point on the live board.+pub const SAME_POINT_MM: f64 = 0.05;++#[derive(Clone, Debug)]+pub struct LiveSeg { pub net: String, pub a: Pt, pub b: Pt, pub width: f64, pub uuid: String }++#[derive(Clone, Debug)]+pub struct LiveVia { pub net: String, pub at: Pt, pub size: f64, pub uuid: String }++/// The copper a kicad_routing_state reply describes, plus the pads by name (J5.1) with net and centre.+#[derive(Clone, Debug, Default)]+pub struct LiveCopper {+    pub segments: Vec<LiveSeg>,+    pub vias: Vec<LiveVia>,+    pub pads: BTreeMap<String, (String, Pt)>,+}++fn xy(v: &Value) -> Option<Pt> {+    Some((v["x"].as_f64()?, v["y"].as_f64()?))+}++impl LiveCopper {+    pub fn from_state(state: &Value) -> LiveCopper {+        let mut live = LiveCopper::default();+        for s in state["segments"].as_array().into_iter().flatten() {+            let (Some(a), Some(b)) = (xy(&s["start"]), xy(&s["end"])) else { continue };+            live.segments.push(LiveSeg { net: s["net_name"].as_str().unwrap_or("").to_string(), a, b, width: s["width"].as_f64().unwrap_or(0.0), uuid: s["uuid"].as_str().unwrap_or("").to_string() });+        }+        for v in state["vias"].as_array().into_iter().flatten() {+            let Some(at) = xy(&v["position"]) else { continue };+            live.vias.push(LiveVia { net: v["net_name"].as_str().unwrap_or("").to_string(), at, size: v["size"].as_f64().unwrap_or(0.0), uuid: v["uuid"].as_str().unwrap_or("").to_string() });+        }+        for p in state["pads"].as_array().into_iter().flatten() {+            let (Some(name), Some(at)) = (p["name"].as_str(), xy(p)) else { continue };+            let net = p["netName"].as_str().or(p["netKey"].as_str()).unwrap_or("").to_string();+            live.pads.insert(name.to_string(), (net, at));+        }+        live+    }+}++/// One plan trace as copper: its net, the segments it draws and the vias its layer markers make.+#[derive(Clone, Debug, PartialEq)]+pub struct Trace { pub net: String, pub segs: Vec<(Pt, Pt)>, pub vias: Vec<Pt>, pub width: f64, pub via_size: f64 }++/// An entry with several paths becomes one entry per path: one kicad_route_net per trace.+pub fn expand_traces(entries: &[Value]) -> Vec<Value> {+    entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {+        Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<Value>>(),+        _ => vec![e.clone()],+    }).collect()+}++/// A trace entry (one path) resolved against the live pads, in the bridge's waypoint grammar:+/// "J1.1", [x, y], {x, y, layer} or {pad, layer}; a layer marker that changes the layer after the+/// first waypoint is a through via at that point. None when a pad is not on the board.+pub fn resolve(entry: &Value, pads: &BTreeMap<String, (String, Pt)>) -> Option<Trace> {+    let path = entry.get("paths").and_then(|p| p.as_array()).and_then(|p| p.first()).or_else(|| entry.get("points"))?.as_array()?;+    let mut layer = entry["layer"].as_str().unwrap_or("F.Cu").to_string();+    let mut pts: Vec<Pt> = Vec::new();+    let mut vias: Vec<Pt> = Vec::new();+    for (i, wp) in path.iter().enumerate() {+        let (p, marker) = if let Some(name) = wp.as_str() {+            (pads.get(name)?.1, None)+        } else if let Some(a) = wp.as_array() {+            ((a.first()?.as_f64()?, a.get(1)?.as_f64()?), None)+        } else if let Some(pad) = wp["pad"].as_str() {+            (pads.get(pad)?.1, wp["layer"].as_str())+        } else {+            (xy(wp)?, wp["layer"].as_str())+        };+        if let Some(l) = marker {+            if i > 0 && l != layer { vias.push(p); }+            layer = l.to_string();+        }+        pts.push(p);+    }+    let segs = pts.windows(2).filter(|w| (w[0].0 - w[1].0).hypot(w[0].1 - w[1].1) > 1e-6).map(|w| (w[0], w[1])).collect();+    Some(Trace { net: entry["net"].as_str().unwrap_or("").to_string(), segs, vias, width: entry["width"].as_f64().unwrap_or(0.25), via_size: entry["viaSize"].as_f64().unwrap_or(0.6) })+}++fn same(a: Pt, b: Pt) -> bool {+    (a.0 - b.0).abs() < SAME_POINT_MM && (a.1 - b.1).abs() < SAME_POINT_MM+}++/// Every segment and via of the trace is already on the live board on the same net, at the same+/// points within SAME_POINT_MM (a segment may run either way). A trace with no copper is never present.+pub fn present(t: &Trace, live: &LiveCopper) -> bool {+    if t.segs.is_empty() && t.vias.is_empty() { return false; }+    t.segs.iter().all(|(a, b)| live.segments.iter().any(|s| s.net == t.net && ((same(s.a, *a) && same(s.b, *b)) || (same(s.a, *b) && same(s.b, *a)))))+        && t.vias.iter().all(|v| live.vias.iter().any(|lv| lv.net == t.net && same(lv.at, *v)))+}++/// Copper a planned via must not touch unless it is on the same net.+#[derive(Clone, Debug)]+pub enum Copper {+    Seg { a: Pt, b: Pt, half: f64, net: String, what: String },+    Disc { at: Pt, r: f64, net: String, what: String },+    Poly { poly: Vec<Pt>, net: String, what: String },+}++impl Copper {+    fn net(&self) -> &str {+        match self { Copper::Seg { net, .. } | Copper::Disc { net, .. } | Copper::Poly { net, .. } => net }+    }+    fn what(&self) -> &str {+        match self { Copper::Seg { what, .. } | Copper::Disc { what, .. } | Copper::Poly { what, .. } => what }+    }+    fn touches(&self, at: Pt, r: f64) -> bool {+        let eps = 1e-4;+        match self {+            Copper::Seg { a, b, half, .. } => geom::seg_distance(at.0, at.1, a.0, a.1, b.0, b.1) < r + half - eps,+            Copper::Disc { at: c, r: rc, .. } => (at.0 - c.0).hypot(at.1 - c.1) < r + rc - eps,+            Copper::Poly { poly, .. } => poly.len() > 2 && (geom::point_in_poly(at.0, at.1, poly) || geom::point_poly_distance(at.0, at.1, poly) < r - eps),+        }+    }+}++/// The live board's tracks and vias as copper.+pub fn live_copper(live: &LiveCopper) -> Vec<Copper> {+    let mut out: Vec<Copper> = live.segments.iter().map(|s| Copper::Seg { a: s.a, b: s.b, half: s.width / 2.0, net: s.net.clone(), what: format!("live {} track", s.net) }).collect();+    out.extend(live.vias.iter().map(|v| Copper::Disc { at: v.at, r: v.size / 2.0, net: v.net.clone(), what: format!("live {} via", v.net) }));+    out+}++/// A routing plan's copper (tracks and the vias of its layer markers), pads resolved on the live board.+pub fn plan_copper(plan: &Value, pads: &BTreeMap<String, (String, Pt)>) -> Vec<Copper> {+    let entries = plan["nets"].as_array().cloned().unwrap_or_default();+    let mut out = Vec::new();+    for e in expand_traces(&entries) {+        let Some(t) = resolve(&e, pads) else { continue };+        out.extend(t.segs.iter().map(|(a, b)| Copper::Seg { a: *a, b: *b, half: t.width / 2.0, net: t.net.clone(), what: format!("planned {} track", t.net) }));+        out.extend(t.vias.iter().map(|v| Copper::Disc { at: *v, r: t.via_size / 2.0, net: t.net.clone(), what: format!("planned {} via", t.net) }));+    }+    out+}++/// The first copper of another net that a via of `net` at `at` with radius `r` would touch.+/// Touching copper is what makes KiCad hand the via that other net.+pub fn via_touch(net: &str, at: Pt, r: f64, copper: &[Copper]) -> Option<String> {+    copper.iter().find(|c| c.net() != net && c.touches(at, r)).map(|c| c.what().to_string())+}++/// A landed via that came back on a net other than the planned one.+#[derive(Clone, Debug, PartialEq)]+pub struct ViaMismatch { pub trace: usize, pub planned: String, pub actual: String, pub at: Pt, pub uuid: String }++/// Read back each landed via by position: (trace number, trace) pairs against the live copper.+/// A planned via with no live via at its place is not reported here (the bridge's DRC and the+/// validate step own missing copper).+pub fn via_net_mismatches(landed: &[(usize, Trace)], live: &LiveCopper) -> Vec<ViaMismatch> {+    let mut out = Vec::new();+    for (n, t) in landed {+        for v in &t.vias {+            let near = live.vias.iter().filter(|lv| same(lv.at, *v)).min_by(|a, b| {+                let da = (a.at.0 - v.0).hypot(a.at.1 - v.1);+                let db = (b.at.0 - v.0).hypot(b.at.1 - v.1);+                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)+            });+            if let Some(lv) = near {+                if lv.net != t.net && !live.vias.iter().any(|o| o.net == t.net && same(o.at, *v)) {+                    out.push(ViaMismatch { trace: *n, planned: t.net.clone(), actual: lv.net.clone(), at: *v, uuid: lv.uuid.clone() });+                }+            }+        }+    }+    out+}++/// A failed reply that says nothing about KiCad: the CLI output did not parse, the bridge+/// answered with a generic error and no code, or the code names the transport (HTTP, relay,+/// connection, timeout). The mutation may or may not have landed; read the board before acting.+pub fn transport_error(reply: &Value) -> bool {+    if reply.get("success").and_then(|s| s.as_bool()) == Some(true) { return false; }+    match reply["errorCode"].as_str() {+        Some(code) => {+            let c = code.to_ascii_lowercase();+            if matches!(c.as_str(), "invalid_socket" | "approval_timeout" | "permission_denied") { return false; }+            ["http", "transport", "relay", "unreachable", "connection", "econn", "network", "disconnected", "timeout"].iter().any(|k| c.contains(k))+        }+        None => matches!(reply["status"].as_str(), Some("error" | "parse_error")) || (reply.get("success").is_none() && reply.get("status").is_none()),+    }+}++/// What a trace landing did.+#[derive(Clone, Debug, Default)]+pub struct Landing {+    pub replies: Vec<Value>,+    pub accepted: usize,+    /// index (0-based, in the flattened traces) of the trace that stopped the landing+    pub stopped: Option<usize>,+    pub traces: usize,+    /// already on the live board: not landed again+    pub skipped: usize,+    /// before --from+    pub before_from: usize,+    /// the reply was lost but the read-back shows the trace landed+    pub recovered: usize,+    /// landed on the one retry after a transport error+    pub retried: usize,+    /// (trace number 1-based, trace entry, reply) for every trace this landing put on the board+    pub landed: Vec<(usize, Value, Value)>,+}++/// The landing loop with the bridge calls passed in, so it runs offline in tests.+/// `read` returns the live revision and the kicad_routing_state reply; `precheck` may refuse a+/// trace before anything lands (checked for every trace first); `from` is 1-based.+#[allow(clippy::too_many_arguments)]+pub fn land_traces_with(+    board: &str,+    traces: &[Value],+    from: usize,+    mut call: impl FnMut(&str, &Value) -> Value,+    mut read: impl FnMut() -> (Option<String>, Value),+    mut precheck: impl FnMut(usize, &Value, &LiveCopper) -> Option<Value>,+    mut wait: impl FnMut(),+    mut progress: impl FnMut(usize, usize, &Value, &Value),+) -> Landing {+    let mut out = Landing { traces: traces.len(), ..Default::default() };+    let (revision, state) = read();+    let Some(mut rev) = revision else { out.replies.push(state); out.stopped = Some(0); return out };+    let live = LiveCopper::from_state(&state);+    let mut todo: Vec<usize> = Vec::new();+    for (i, e) in traces.iter().enumerate() {+        if i + 1 < from.max(1) { out.before_from += 1; continue; }+        if resolve(e, &live.pads).map(|t| present(&t, &live)).unwrap_or(false) {+            out.skipped += 1;+            progress(i + 1, traces.len(), e, &json!({"success": true, "alreadyPresent": true}));+            continue;+        }+        todo.push(i);+    }+    for &i in &todo {+        if let Some(refusal) = precheck(i, &traces[i], &live) {+            out.replies.push(refusal);+            out.stopped = Some(i);+            return out;+        }+    }+    let args_of = |e: &Value, rev: &str| {+        let mut args = json!({"filePath": board, "expectedRevision": rev, "net": e["net"], "width": e["width"]});+        for k in ["viaSize", "viaDrill", "layer"] {+            if let Some(v) = e.get(k) { args[k] = v.clone(); }+        }+        if let Some(p) = e.get("paths") { args["paths"] = p.clone(); } else if let Some(p) = e.get("points") { args["points"] = p.clone(); }+        args+    };+    let ok = |r: &Value| crate::Bridge::ok(r);+    for &i in &todo {+        let e = &traces[i];+        let mut r = call("kicad_route_net", &args_of(e, &rev));+        progress(i + 1, traces.len(), e, &r);+        if !ok(&r) && transport_error(&r) {+            // the reply was lost, not refused: the board says whether the trace landed+            let (nrev, st) = read();+            let Some(nrev) = nrev else { out.replies.push(r); out.replies.push(st); out.stopped = Some(i); return out };+            let now = LiveCopper::from_state(&st);+            if resolve(e, &now.pads).map(|t| present(&t, &now)).unwrap_or(false) {+                out.recovered += 1;+                r = json!({"success": true, "recoveredFromReadback": true, "revision": nrev, "lostReply": r});+            } else {+                let first = r;+                r = call("kicad_route_net", &args_of(e, &nrev));+                progress(i + 1, traces.len(), e, &r);+                if ok(&r) { out.retried += 1; r["retriedAfter"] = first; } else { out.replies.push(first); }+            }+        }+        if !ok(&r) {+            out.replies.push(r);+            out.stopped = Some(i);+            return out;+        }+        out.accepted += 1;+        if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) { rev = nr.to_string(); }+        out.landed.push((i + 1, e.clone(), r.clone()));+        out.replies.push(r);+        wait();+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A kicad_routing_state reply in the bridge's shape: two pads, one GND trace (pad to point to+    /// point), one SW track and one GND via.+    fn state(rev: &str, extra_segments: &[Value], extra_vias: &[Value]) -> Value {+        let mut segs = vec![+            json!({"start": {"x": 115.4, "y": 83.9}, "end": {"x": 114.6, "y": 83.9}, "layer": "F.Cu", "net_name": "GND", "width": 0.8, "uuid": "g1"}),+            json!({"start": {"x": 114.6, "y": 83.9}, "end": {"x": 114.6, "y": 86.4}, "layer": "F.Cu", "net_name": "GND", "width": 0.8, "uuid": "g2"}),+            json!({"start": {"x": 110.0, "y": 92.5}, "end": {"x": 118.0, "y": 92.5}, "layer": "F.Cu", "net_name": "/SW", "width": 0.6, "uuid": "sw1"}),+        ];+        segs.extend(extra_segments.iter().cloned());+        let mut vias = vec![json!({"position": {"x": 103.1, "y": 92.2}, "net_name": "GND", "size": 0.8, "drill": 0.4, "uuid": "v1"})];+        vias.extend(extra_vias.iter().cloned());+        json!({"success": true, "revision": rev, "segments": segs, "vias": vias, "pads": [+            {"name": "C3.2", "netName": "GND", "x": 115.4, "y": 83.9},+            {"name": "J5.1", "netName": "GND", "x": 124.0, "y": 92.0},+        ]})+    }++    fn gnd_trace() -> Value {+        json!({"net": "GND", "width": 0.8, "paths": [["C3.2", [115.4, 83.9], [114.6, 83.9], [114.6, 86.4]]]})+    }+    fn via_entry(net: &str, x: f64, y: f64) -> Value {+        json!({"net": net, "width": 0.5, "viaSize": 0.6, "viaDrill": 0.3, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]})+    }++    #[test]+    fn a_trace_on_the_board_is_present_within_tolerance_and_only_on_its_net() {+        let live = LiveCopper::from_state(&state("r", &[], &[]));+        let t = resolve(&gnd_trace(), &live.pads).unwrap();+        assert_eq!(t.segs.len(), 2, "the pad-to-first-point hop has no length and is not a segment");+        assert!(present(&t, &live));+        // drawn the other way, and 0.03 mm off: still the same trace+        let back = json!({"net": "GND", "width": 0.8, "points": [[114.62, 86.4], [114.6, 83.93], "C3.2"]});+        assert!(present(&resolve(&back, &live.pads).unwrap(), &live));+        let moved = json!({"net": "GND", "width": 0.8, "points": [[114.6, 86.4], [114.6, 88.0]]});+        assert!(!present(&resolve(&moved, &live.pads).unwrap(), &live));+        let other = json!({"net": "+5V", "width": 0.8, "paths": [["C3.2", [115.4, 83.9], [114.6, 83.9]]]});+        assert!(!present(&resolve(&other, &live.pads).unwrap(), &live));+        assert!(resolve(&json!({"net": "GND", "points": ["U9.1", [1.0, 1.0]]}), &live.pads).is_none(), "an unknown pad is never assumed present");+    }++    #[test]+    fn a_layer_marker_is_a_via_and_a_via_counts_only_on_its_net() {+        let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 113.3, "y": 92.55}, "net_name": "/SW", "size": 0.6, "uuid": "vsw"})]));+        let t = resolve(&via_entry("GND", 103.1, 92.2), &live.pads).unwrap();+        assert_eq!((t.segs.len(), t.vias.clone()), (0, vec![(103.1, 92.2)]));+        assert!(present(&t, &live));+        // the via that KiCad handed to SW does not count as the planned GND via+        assert!(!present(&resolve(&via_entry("GND", 113.3, 92.55), &live.pads).unwrap(), &live));+        // a starting {pad, layer} sets the layer; it is not a via+        let start = json!({"net": "GND", "points": [{"pad": "J5.1", "layer": "B.Cu"}, [124.0, 95.0]]});+        assert!(resolve(&start, &live.pads).unwrap().vias.is_empty());+    }++    #[test]+    fn a_via_touching_another_nets_copper_is_refused_before_it_lands() {+        let live = LiveCopper::from_state(&state("r", &[], &[]));+        let copper = live_copper(&live);+        // the buck run: a GND thermal via whose copper overlaps the SW track by 0.05 mm+        assert_eq!(via_touch("GND", (113.3, 92.5 + 0.3 + 0.3 - 0.05), 0.3, &copper).as_deref(), Some("live /SW track"));+        assert!(via_touch("GND", (113.3, 93.5), 0.3, &copper).is_none(), "0.4 mm clear is not touching");+        assert!(via_touch("/SW", (113.3, 92.55), 0.3, &copper).is_none(), "its own net may touch");+        let pad = vec![Copper::Poly { poly: vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], net: "/SW".into(), what: "pad L1.1 (/SW)".into() }];+        assert!(via_touch("GND", (1.2, 0.5), 0.3, &pad).is_some());+        assert!(via_touch("GND", (0.5, 0.5), 0.1, &pad).is_some(), "inside the pad");+        assert!(via_touch("GND", (1.5, 0.5), 0.3, &pad).is_none());+        let plan = json!({"nets": [{"net": "/SW", "width": 0.6, "paths": [[[110.0, 80.0], [118.0, 80.0]]]}]});+        assert_eq!(via_touch("GND", (112.0, 80.4), 0.3, &plan_copper(&plan, &live.pads)).as_deref(), Some("planned /SW track"));+    }++    #[test]+    fn a_landed_via_on_another_net_is_named_with_both_nets() {+        let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 113.3, "y": 92.55}, "net_name": "/SW", "size": 0.6, "uuid": "vsw"})]));+        let landed = vec![+            (1, resolve(&via_entry("GND", 103.1, 92.2), &live.pads).unwrap()),+            (2, resolve(&via_entry("GND", 113.3, 92.55), &live.pads).unwrap()),+            (3, resolve(&via_entry("GND", 50.0, 50.0), &live.pads).unwrap()),+        ];+        let m = via_net_mismatches(&landed, &live);+        assert_eq!(m, vec![ViaMismatch { trace: 2, planned: "GND".into(), actual: "/SW".into(), at: (113.3, 92.55), uuid: "vsw".into() }]);+    }++    #[test]+    fn transport_errors_are_told_from_kicad_refusals() {+        for r in [json!({"status": "parse_error", "raw": ""}), json!({"status": "error", "error": "adom-bridge not runnable"}), json!({}),+                  json!({"success": false, "errorCode": "http_error"}), json!({"success": false, "errorCode": "ipc_timeout"}), json!({"success": false, "errorCode": "relay_disconnected"})] {+            assert!(transport_error(&r), "{r}");+        }+        for r in [json!({"success": false, "errorCode": "drc_rejected"}), json!({"success": false, "errorCode": "stale_revision"}), json!({"success": false, "errorCode": "approval_timeout"}),+                  json!({"success": false, "errorCode": "invalid_socket"}), json!({"success": true, "revision": "x"})] {+            assert!(!transport_error(&r), "{r}");+        }+    }++    /// Run the loop with scripted replies; returns the landing and the kicad_route_net calls made.+    fn run(traces: &[Value], from: usize, replies: Vec<Value>, states: Vec<Value>, refuse: bool) -> (Landing, Vec<Value>) {+        let mut replies = replies.into_iter();+        let mut states = states.into_iter();+        let mut calls = Vec::new();+        let l = land_traces_with("B", traces, from,+            |verb, args| { assert_eq!(verb, "kicad_route_net"); calls.push(args.clone()); replies.next().expect("an unexpected kicad_route_net call") },+            || { let s = states.next().expect("an unexpected read"); (s["revision"].as_str().map(str::to_string), s) },+            |_, _, _| if refuse { Some(json!({"success": false, "errorCode": "via_touches_other_net"})) } else { None },+            || {}, |_, _, _, _| {});+        (l, calls)+    }++    fn trace(x: f64) -> Value {+        json!({"net": "+5V", "width": 0.4, "points": [[x, 10.0], [x, 20.0]]})+    }+    fn seg(x: f64) -> Value {+        json!({"start": {"x": x, "y": 10.0}, "end": {"x": x, "y": 20.0}, "layer": "F.Cu", "net_name": "+5V", "width": 0.4, "uuid": format!("s{x}")})+    }+    fn good(rev: &str) -> Value {+        json!({"success": true, "revision": rev, "itemIds": ["i"]})+    }++    #[test]+    fn a_rerun_skips_the_traces_already_landed() {+        let traces: Vec<Value> = (1..=4).map(|x| trace(x as f64)).collect();+        let (l, calls) = run(&traces, 1, vec![good("r3"), good("r4")], vec![state("r2", &[seg(1.0), seg(2.0)], &[])], false);+        assert_eq!((l.skipped, l.accepted, l.stopped, calls.len()), (2, 2, None, 2));+        assert_eq!(calls[0]["points"][0][0], 3.0);+        assert_eq!(calls[0]["expectedRevision"], "r2");+        assert_eq!(calls[1]["expectedRevision"], "r3");+        assert_eq!(l.landed.iter().map(|x| x.0).collect::<Vec<_>>(), vec![3, 4]);+    }++    #[test]+    fn from_starts_at_the_given_trace() {+        let traces: Vec<Value> = (1..=4).map(|x| trace(x as f64)).collect();+        let (l, calls) = run(&traces, 4, vec![good("r1")], vec![state("r0", &[], &[])], false);+        assert_eq!((l.before_from, l.accepted, calls.len()), (3, 1, 1));+        assert_eq!(calls[0]["points"][0][0], 4.0);+    }++    #[test]+    fn a_lost_reply_is_retried_once_only_when_the_trace_did_not_land() {+        let traces = vec![trace(1.0), trace(2.0)];+        let lost = json!({"status": "error", "error": "HTTP error from the local port"});+        // not landed: one retry at the re-read revision, then on+        let (l, calls) = run(&traces, 1, vec![lost.clone(), good("r2"), good("r3")], vec![state("r0", &[], &[]), state("r1", &[], &[])], false);+        assert_eq!((l.accepted, l.retried, l.recovered, l.stopped, calls.len()), (2, 1, 0, None, 3));+        assert_eq!(calls[1]["expectedRevision"], "r1");+        // landed although the reply was lost: counted, never sent again+        let (l, calls) = run(&traces, 1, vec![lost.clone(), good("r3")], vec![state("r0", &[], &[]), state("r1", &[seg(1.0)], &[])], false);+        assert_eq!((l.accepted, l.retried, l.recovered, l.stopped, calls.len()), (2, 0, 1, None, 2));+        assert_eq!(calls[1]["expectedRevision"], "r1");+        // lost twice: stop at that trace, the second trace is not attempted+        let (l, calls) = run(&traces, 1, vec![lost.clone(), lost.clone()], vec![state("r0", &[], &[]), state("r1", &[], &[])], false);+        assert_eq!((l.accepted, l.stopped, calls.len()), (0, Some(0), 2));+    }++    #[test]+    fn a_kicad_refusal_stops_without_a_retry() {+        let traces = vec![trace(1.0), trace(2.0)];+        let (l, calls) = run(&traces, 1, vec![good("r1"), json!({"success": false, "errorCode": "drc_rejected"})], vec![state("r0", &[], &[])], false);+        assert_eq!((l.accepted, l.stopped, calls.len()), (1, Some(1), 2));+    }++    #[test]+    fn a_precheck_refusal_lands_nothing() {+        let traces = vec![trace(1.0), trace(2.0)];+        let (l, calls) = run(&traces, 1, vec![], vec![state("r0", &[], &[])], true);+        assert_eq!((l.accepted, l.stopped, calls.len()), (0, Some(0), 0));+        assert_eq!(l.replies[0]["errorCode"], "via_touches_other_net");+    }+}
crates/aiflow-bridge/src/lib.rs+113−38
@@ -2,10 +2,17 @@ //! (`adom-bridge --ai-thread ... --target ... <verb> <json>`), so the same etiquette, approval //! gates and activity log apply as for any AI thread. Capture recipe, landing, measurement, //! validation.+use std::path::{Path, PathBuf}; use std::process::Command;  use serde_json::{json, Value}; +pub mod landing;+pub use landing::Landing;++/// Pulls of one clip before the stop gives up and flags it `pullFailed`: the first and two retries.+pub const PULL_ATTEMPTS: usize = 3;+ // Waiting cannot repair an explicit identity/configuration refusal. In particular, // never conceal the requested/open board identities behind a generic no_revision. fn retryable_read(reply: &Value) -> bool {@@ -56,6 +63,36 @@ fn read_zone_state_with(     json!({"success":false,"errorCode":"zone_readback_unsettled","lastReply":reply,"message":"No usable stable zone snapshot; inspect native state. Refill was not replayed."}) } +/// What a clip stop brought back: the desktop path, where the pull puts it, and whether it landed.+#[derive(Clone, Debug)]+pub struct ClipStop {+    pub remote: Option<String>,+    pub local: Option<PathBuf>,+    pub pulled: bool,+    pub attempts: usize,+    pub reply: Value,+}++/// Where pull_file puts a desktop file: `save_to` plus the file's own name.+pub fn landed_path(remote: &str, save_to: &str) -> PathBuf {+    let name = remote.rsplit(['/', '\\']).next().unwrap_or(remote);+    Path::new(save_to).join(name)+}++/// A file that exists and holds at least one byte.+pub fn nonempty(p: &Path) -> bool {+    std::fs::metadata(p).map(|m| m.is_file() && m.len() > 0).unwrap_or(false)+}++fn pull_until_landed(local: &Path, attempts: usize, mut pull: impl FnMut(), mut wait: impl FnMut()) -> (bool, usize) {+    for k in 1..=attempts.max(1) {+        pull();+        if nonempty(local) { return (true, k); }+        if k < attempts { wait(); }+    }+    (false, attempts.max(1))+}+ #[derive(Clone)] pub struct Bridge {     pub ai_thread: String,@@ -149,7 +186,10 @@ impl Bridge {         r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)     } -    pub fn record_stop(&self, recording_id: &str, save_to: &str) -> (Option<String>, Value) {+    /// Stop a recording and pull its file into `save_to`, verified: the local copy must exist and+    /// be non-empty, or the pull is tried again (PULL_ATTEMPTS in all). The desktop cleans its+    /// recordings folder, so a clip not pulled here may be gone later; `pulled` says which it is.+    pub fn record_stop(&self, recording_id: &str, save_to: &str) -> ClipStop {         let r = self.call("desktop_record_stop", &json!({"recordingId": recording_id}));         let mut p = r.get("filePath").or_else(|| r.get("path")).and_then(|v| v.as_str()).map(str::to_string);         if p.is_none() {@@ -160,10 +200,22 @@ impl Bridge {             }         }         let p = p.map(|s| s.replace('\\', "/"));-        if let Some(path) = &p {-            self.call("pull_file", &json!({"filePaths": [path], "saveTo": save_to}));+        match p {+            Some(path) => {+                let (pulled, attempts) = self.pull_verified(&path, save_to, PULL_ATTEMPTS);+                ClipStop { local: Some(landed_path(&path, save_to)), remote: Some(path), pulled, attempts, reply: r }+            }+            None => ClipStop { remote: None, local: None, pulled: false, attempts: 0, reply: r },         }-        (p, r)+    }++    /// pull_file one desktop file into `save_to` until its local copy exists and is non-empty,+    /// at most `attempts` pulls. Returns (landed, pulls made).+    pub fn pull_verified(&self, remote: &str, save_to: &str, attempts: usize) -> (bool, usize) {+        let local = landed_path(remote, save_to);+        pull_until_landed(&local, attempts,+            || { self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to})); },+            || std::thread::sleep(std::time::Duration::from_secs(3)))     }      // -- landing ------------------------------------------------------------------------------@@ -197,40 +249,33 @@ impl Bridge {      /// Replay a plan ONE TRACE AT A TIME: an entry with several paths becomes one kicad_route_net     /// per path, so the camera sees each trace drawn instead of a whole net appearing at once.-    /// Returns (replies, accepted, stopped-at, traces).-    pub fn land_plan(&self, board: &str, entries: &[Value], pause_ms: u64, mut progress: impl FnMut(usize, usize, &Value, &Value)) -> (Vec<Value>, usize, Option<usize>, usize) {-        let traces: Vec<Value> = entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {-            Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<Value>>(),-            _ => vec![e.clone()],-        }).collect();-        let entries: &[Value] = &traces;-        let (revision, reply) = self.revision_with_reply(board);-        let mut rev = match revision { Some(r) => r, None => return (vec![reply], 0, Some(0), traces.len()) };-        let mut replies = Vec::new();-        let mut ok = 0;-        for (i, e) in entries.iter().enumerate() {-            let mut args = json!({"filePath": board, "expectedRevision": rev, "net": e["net"], "width": e["width"]});-            for k in ["viaSize", "viaDrill", "layer"] {-                if let Some(v) = e.get(k) {-                    args[k] = v.clone();-                }-            }-            if let Some(p) = e.get("paths") { args["paths"] = p.clone(); } else if let Some(p) = e.get("points") { args["points"] = p.clone(); }-            let r = self.call("kicad_route_net", &args);-            let good = Bridge::ok(&r);-            progress(i + 1, entries.len(), e, &r);-            if !good {-                replies.push(r);-                return (replies, ok, Some(i), traces.len());-            }-            ok += 1;-            if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) {-                rev = nr.to_string();-            }-            replies.push(r);-            std::thread::sleep(std::time::Duration::from_millis(pause_ms));-        }-        (replies, ok, None, traces.len())+    /// Resumable: a trace already on the live board (same net, same points) is skipped, `from`+    /// (1-based) skips the traces before it, and a reply lost to the transport is answered by+    /// reading the board: a trace that landed counts, one that did not is sent once more.+    /// `precheck` may refuse a trace before anything lands.+    pub fn land_plan(&self, board: &str, entries: &[Value], from: usize, pause_ms: u64,+        precheck: impl FnMut(usize, &Value, &landing::LiveCopper) -> Option<Value>,+        progress: impl FnMut(usize, usize, &Value, &Value)) -> Landing {+        let traces = landing::expand_traces(entries);+        landing::land_traces_with(board, &traces, from,+            |verb, args| self.call(verb, args),+            || self.revision_with_reply(board),+            precheck,+            || std::thread::sleep(std::time::Duration::from_millis(pause_ms)),+            progress)+    }++    /// The live board's copper and pads (one kicad_routing_state read, retried like `revision`).+    pub fn live_copper(&self, board: &str) -> Option<landing::LiveCopper> {+        let (rev, state) = self.revision_with_reply(board);+        rev.map(|_| landing::LiveCopper::from_state(&state))+    }++    /// Take copper items back as one native undo step (kicad_remove_route at the live revision).+    pub fn remove_items(&self, board: &str, item_ids: &[String]) -> Value {+        let (rev, reply) = self.revision_with_reply(board);+        let Some(rev) = rev else { return reply };+        self.call("kicad_remove_route", &json!({"filePath": board, "expectedRevision": rev, "itemIds": item_ids}))     }      /// One native transaction owns reconciliation, DRC, equality and Undo.@@ -362,3 +407,33 @@ mod tests {         assert_eq!(traces[1]["width"], 1.0, "the net's width rides every one of its traces");     } }++#[cfg(test)]+mod pull_tests {+    use super::*;++    fn scratch(name: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("aiflow-pull-{name}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    #[test]+    fn a_pull_counts_only_when_a_nonempty_file_landed() {+        let d = scratch("retry");+        let local = landed_path("C:/Users/x/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4", &d.display().to_string());+        assert_eq!(local, d.join("window-1.mp4"));+        assert_eq!(landed_path("C:\\rec\\w.mp4", "/run"), Path::new("/run/w.mp4"));+        // the first pull leaves an empty file, the second lands it+        let mut n = 0;+        let (ok, tries) = pull_until_landed(&local, PULL_ATTEMPTS, || { n += 1; std::fs::write(&local, if n == 1 { &b""[..] } else { &b"mp4"[..] }).unwrap(); }, || {});+        assert_eq!((ok, tries), (true, 2));+        // nothing ever arrives: three pulls, then give up+        let never = d.join("never.mp4");+        let mut pulls = 0;+        let (ok, tries) = pull_until_landed(&never, PULL_ATTEMPTS, || pulls += 1, || {});+        assert_eq!((ok, tries, pulls), (false, 3, 3));+        let _ = std::fs::remove_dir_all(&d);+    }+}
crates/aiflow-place/src/lib.rs+358−2
@@ -110,13 +110,67 @@ fn local_pts(board: &Board, reference: &str) -> Vec<Pt> {     vec![(x0 - 0.25, y0 - 0.25), (x1 + 0.25, y1 + 0.25)] } +/// Pads of a part in its own frame (unrotated, anchor-relative), with their nets.+fn local_pads(board: &Board, reference: &str) -> Vec<(String, String, Pt)> {+    let Some(fp) = board.footprints.get(reference) else { return Vec::new() };+    board.pads.iter().filter(|p| p.reference == reference).map(|p| (p.name.clone(), p.net.clone(), geom::rot_kicad(p.x - fp.x, p.y - fp.y, -fp.rot))).collect()+}++/// Where the pads of every part sit now: moved parts at their move, the rest where the board has+/// them (parts parked outside the outline are left out: their position means nothing yet).+struct PadMap {+    at: BTreeMap<String, Vec<(String, Pt)>>,+    small_nets: std::collections::BTreeSet<String>,+}++impl PadMap {+    fn new(board: &Board) -> PadMap {+        let (x0, y0, x1, y1) = board.bbox;+        let mut at: BTreeMap<String, Vec<(String, Pt)>> = BTreeMap::new();+        for p in &board.pads {+            if p.net.is_empty() || p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 {+                continue;+            }+            at.entry(p.reference.clone()).or_default().push((p.net.clone(), (p.x, p.y)));+        }+        // nets with a handful of pads: a ground or rail with dozens of pads says nothing about adjacency+        let small_nets = board.nets.iter().filter(|(_, v)| v.len() <= 8).map(|(k, _)| k.clone()).collect();+        PadMap { at, small_nets }+    }++    fn place(&mut self, board: &Board, reference: &str, x: f64, y: f64, rot: f64) {+        let pads = local_pads(board, reference).into_iter().filter(|(_, n, _)| !n.is_empty()).map(|(_, n, (lx, ly))| { let (a, b) = geom::rot_kicad(lx, ly, rot); (n, (x + a, y + b)) }).collect();+        self.at.insert(reference.to_string(), pads);+    }++    /// Sum over the part's pads on small nets of the distance to the nearest same-net pad of another part.+    fn adjacency(&self, board: &Board, reference: &str, x: f64, y: f64, rot: f64) -> f64 {+        let mut total = 0.0;+        for (_, net, (lx, ly)) in local_pads(board, reference) {+            if !self.small_nets.contains(&net) {+                continue;+            }+            let (a, b) = geom::rot_kicad(lx, ly, rot);+            let (px, py) = (x + a, y + b);+            let best = self.at.iter().filter(|(r, _)| r.as_str() != reference).flat_map(|(_, v)| v.iter()).filter(|(n, _)| *n == net).map(|(_, (qx, qy))| (qx - px).hypot(qy - py)).fold(f64::INFINITY, f64::min);+            if best.is_finite() {+                total += best;+            }+        }+        total+    }+}+ /// For each wish, in order, the nearest legal spot: courtyard clears every other courtyard by /// `gap`, stays `edge` inside the outline box, avoids the keepout rectangles (grown by their margin).+/// When a wish lists several rotations and more than one is legal at the nearest spot, the one that+/// puts the part's pads nearest their net partners wins (ties keep the list order). pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (BTreeMap<String, Move>, Vec<String>) {     let mut boxes = boxes(board);     let (x0, y0, x1, y1) = board.bbox;     let mut out = BTreeMap::new();     let mut lines = Vec::new();+    let mut pads = PadMap::new(board);     for (reference, w) in wishes {         if !board.footprints.contains_key(reference) {             lines.push(format!("{reference}: not on the board"));@@ -138,6 +192,7 @@ pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (B         cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());         let mut best: Option<(f64, f64, f64, Box4, f64)> = None;         'outer: for (d, x, y) in cands {+            let mut legal: Vec<(f64, Box4)> = Vec::new();             for &rot in &w.rot {                 let b = placed_box(&pts, x, y, rot);                 if b.0 < x0 + edge || b.1 < y0 + edge || b.2 > x1 - edge || b.3 > y1 - edge {@@ -149,14 +204,30 @@ pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (B                 if w.keepout.iter().any(|(kx0, ky0, kx1, ky1, m)| b.0 - m < *kx1 && kx0 - m < b.2 && b.1 - m < *ky1 && ky0 - m < b.3) {                     continue;                 }-                best = Some((x, y, rot, b, d));-                break 'outer;+                legal.push((rot, b));+            }+            if legal.is_empty() {+                continue;             }+            let mut pick = legal[0];+            if legal.len() > 1 {+                let mut score = pads.adjacency(board, reference, x, y, pick.0);+                for &(rot, b) in &legal[1..] {+                    let s = pads.adjacency(board, reference, x, y, rot);+                    if s < score - 0.05 {+                        score = s;+                        pick = (rot, b);+                    }+                }+            }+            best = Some((x, y, pick.0, pick.1, d));+            break 'outer;         }         match best {             None => lines.push(format!("{reference}: NO SPOT within {:.1} mm of ({:.1}, {:.1})", w.radius, w.x, w.y)),             Some((x, y, rot, b, d)) => {                 boxes.insert(reference.clone(), b);+                pads.place(board, reference, x, y, rot);                 out.insert(reference.clone(), Move { x, y, rotation: Some(rot) });                 lines.push(format!("{reference}: ({x:.2}, {y:.2}, {rot}) {d:.2} mm from the wish, box x {:.2}..{:.2} y {:.2}..{:.2}", b.0, b.2, b.1, b.3));             }@@ -165,6 +236,194 @@ pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (B     (out, lines) } +/// A series chain from the spec (`"chains": [["R1", "R2", "C9"]]`, or an entry+/// `{"refs": [...], "direction": "down", "gap": 0.25}`): parts in electrical order, each sharing a+/// net with the next. `place pack` lays them on one line in that order, on one axis, each turned+/// so the pads on the shared net face each other.+#[derive(Clone, Debug)]+pub struct Chain {+    pub refs: Vec<String>,+    pub direction: Option<String>,+    pub gap: Option<f64>,+}++/// `spec.chains` or `spec.placement.chains`.+pub fn chains_from_spec(spec: &Value) -> Result<Vec<Chain>, String> {+    let v = spec.get("chains").or_else(|| spec.get("placement").and_then(|p| p.get("chains")));+    let Some(a) = v.and_then(|v| v.as_array()) else { return Ok(Vec::new()) };+    let refs_of = |x: &Value| -> Option<Vec<String>> { x.as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(str::to_string)).collect()) };+    let mut out = Vec::new();+    for (k, e) in a.iter().enumerate() {+        let chain = if let Some(refs) = refs_of(e) {+            Chain { refs, direction: None, gap: None }+        } else if let Some(refs) = e.get("refs").and_then(refs_of) {+            Chain { refs, direction: e.get("direction").and_then(|d| d.as_str()).map(str::to_string), gap: e.get("gap").and_then(|g| g.as_f64()) }+        } else {+            return Err(format!("chains[{k}]: use [\"R1\", \"R2\", ...] or {{\"refs\": [...], \"direction\": \"down\"}}"));+        };+        if chain.refs.len() < 2 {+            return Err(format!("chains[{k}]: a chain needs two or more parts"));+        }+        if let Some(d) = &chain.direction {+            if !matches!(d.as_str(), "down" | "up" | "left" | "right") {+                return Err(format!("chains[{k}]: direction {d:?} is not down, up, left or right"));+            }+        }+        out.push(chain);+    }+    Ok(out)+}++fn shared_net(board: &Board, a: &str, b: &str) -> Option<String> {+    let na: std::collections::BTreeSet<String> = board.pads.iter().filter(|p| p.reference == a && !p.net.is_empty()).map(|p| p.net.clone()).collect();+    board.pads.iter().filter(|p| p.reference == b).map(|p| p.net.clone()).find(|n| na.contains(n))+}++fn centroid(pts: &[Pt]) -> Option<Pt> {+    if pts.is_empty() { None } else { Some((pts.iter().map(|p| p.0).sum::<f64>() / pts.len() as f64, pts.iter().map(|p| p.1).sum::<f64>() / pts.len() as f64)) }+}++/// A chain as laid out for the packer: its members' rotations and the shared nets between them.+#[derive(Clone, Debug)]+pub struct ChainPlan {+    pub refs: Vec<String>,+    pub rots: Vec<f64>,+    pub nets: Vec<String>,+    pub direction: String,+}++/// Rewrite the wishes of every chain: members on one line from the first member's wish, in chain+/// order, each with the one rotation (0/90/180/270) that points its pad on the previous link's net+/// back up the chain and its pad on the next link's net down it. A chain that cannot be laid out+/// (a member without a wish or not on the board, two neighbours with no common net) is left alone+/// and says why.+pub fn apply_chains(board: &Board, wishes: &[(String, Wish)], chains: &[Chain], gap: f64) -> (Vec<(String, Wish)>, Vec<ChainPlan>, Vec<String>) {+    let mut out: Vec<(String, Wish)> = wishes.to_vec();+    let mut plans = Vec::new();+    let mut lines = Vec::new();+    'chain: for ch in chains {+        let name = ch.refs.join("-");+        for r in &ch.refs {+            if !board.footprints.contains_key(r) {+                lines.push(format!("chain {name}: not applied, {r} is not on the board"));+                continue 'chain;+            }+            if !out.iter().any(|(k, _)| k == r) {+                lines.push(format!("chain {name}: not applied, {r} has no wish (list every member in the wishes)"));+                continue 'chain;+            }+        }+        let mut nets = Vec::new();+        for w in ch.refs.windows(2) {+            match shared_net(board, &w[0], &w[1]) {+                Some(n) => nets.push(n),+                None => {+                    lines.push(format!("chain {name}: not applied, {} and {} share no net", w[0], w[1]));+                    continue 'chain;+                }+            }+        }+        let wish_of = |r: &str| out.iter().find(|(k, _)| k == r).map(|(_, w)| w.clone()).unwrap();+        let first = wish_of(&ch.refs[0]);+        let last = wish_of(ch.refs.last().unwrap());+        let direction = ch.direction.clone().unwrap_or_else(|| {+            let (dx, dy) = (last.x - first.x, last.y - first.y);+            if dx.abs() > dy.abs() { if dx > 0.0 { "right" } else { "left" } } else if dy < 0.0 { "up" } else { "down" }.to_string()+        });+        let d: Pt = match direction.as_str() { "up" => (0.0, -1.0), "left" => (-1.0, 0.0), "right" => (1.0, 0.0), _ => (0.0, 1.0) };+        let n = ch.refs.len();+        let mut rots = Vec::new();+        for (k, r) in ch.refs.iter().enumerate() {+            let pads = local_pads(board, r);+            let prev = if k > 0 { Some(&nets[k - 1]) } else { None };+            let next = if k + 1 < n { Some(&nets[k]) } else { None };+            let on = |net: Option<&String>| -> Vec<Pt> { pads.iter().filter(|(_, pn, _)| Some(pn) == net).map(|(_, _, p)| *p).collect() };+            let off = |net: Option<&String>| -> Vec<Pt> { pads.iter().filter(|(_, pn, _)| Some(pn) != net).map(|(_, _, p)| *p).collect() };+            let pin = centroid(&if prev.is_some() { on(prev) } else { off(next) }).unwrap_or((0.0, 0.0));+            let pout = centroid(&if next.is_some() { on(next) } else { off(prev) }).unwrap_or((0.0, 0.0));+            let v = (pout.0 - pin.0, pout.1 - pin.1);+            let rot = [0.0, 90.0, 180.0, 270.0].into_iter().max_by(|a, b| {+                let da = { let (x, y) = geom::rot_kicad(v.0, v.1, *a); x * d.0 + y * d.1 };+                let db = { let (x, y) = geom::rot_kicad(v.0, v.1, *b); x * d.0 + y * d.1 };+                da.partial_cmp(&db).unwrap().then(b.partial_cmp(a).unwrap())+            }).unwrap();+            rots.push(rot);+        }+        // centres along the line: each part's courtyard extent forward, the gap, the next part's extent back+        let g = ch.gap.unwrap_or(gap.max(0.25));+        let (mut cx, mut cy) = (first.x, first.y);+        for (k, r) in ch.refs.iter().enumerate() {+            let pts = local_pts(board, r);+            let b = placed_box(&pts, 0.0, 0.0, rots[k]);+            let (back, fwd) = match direction.as_str() { "up" => (b.3, -b.1), "left" => (b.2, -b.0), "right" => (-b.0, b.2), _ => (-b.1, b.3) };+            if k > 0 {+                cx += d.0 * (back + g);+                cy += d.1 * (back + g);+            }+            let e = out.iter_mut().find(|(key, _)| key == r).unwrap();+            e.1.x = (cx * 100.0).round() / 100.0;+            e.1.y = (cy * 100.0).round() / 100.0;+            e.1.rot = vec![rots[k]];+            cx += d.0 * fwd;+            cy += d.1 * fwd;+        }+        let same = rots.iter().all(|r| *r == rots[0]);+        lines.push(format!("chain {name}: laid out {direction} from ({:.2}, {:.2}), rotation{} {}", first.x, first.y, if same { "" } else { "s" }, if same { format!("{} for every member", rots[0]) } else { format!("{} (pad numbering differs, so a member turns 180 to face its neighbour)", rots.iter().map(|r| r.to_string()).collect::<Vec<_>>().join("/")) }));+        plans.push(ChainPlan { refs: ch.refs.clone(), rots, nets, direction });+    }+    (out, plans, lines)+}++/// After packing: was each chain honoured? Every member got its chain rotation, and for each link+/// the closest pair of pads between the two parts is a pair on their shared net.+pub fn chain_report(board: &Board, moves: &BTreeMap<String, Move>, plans: &[ChainPlan]) -> Vec<(String, bool, String)> {+    let mut out = Vec::new();+    for p in plans {+        let name = p.refs.join("-");+        let world = |r: &str| -> Option<Vec<(String, String, Pt)>> {+            let m = moves.get(r)?;+            let rot = m.rotation.unwrap_or(0.0);+            Some(local_pads(board, r).into_iter().map(|(pn, net, (lx, ly))| { let (a, b) = geom::rot_kicad(lx, ly, rot); (pn, net, (m.x + a, m.y + b)) }).collect())+        };+        let mut why = Vec::new();+        let mut links = Vec::new();+        for (k, r) in p.refs.iter().enumerate() {+            match moves.get(r) {+                None => why.push(format!("{r} found no spot")),+                Some(m) if m.rotation != Some(p.rots[k]) => why.push(format!("{r} at rotation {:?}, not {}", m.rotation, p.rots[k])),+                _ => {}+            }+        }+        if why.is_empty() {+            for (k, w) in p.refs.windows(2).enumerate() {+                let (Some(a), Some(b)) = (world(&w[0]), world(&w[1])) else { continue };+                let mut best: Option<(f64, &(String, String, Pt), &(String, String, Pt))> = None;+                for pa in &a {+                    for pb in &b {+                        let dd = (pa.2 .0 - pb.2 .0).hypot(pa.2 .1 - pb.2 .1);+                        if best.map(|x| dd < x.0 - 1e-6).unwrap_or(true) {+                            best = Some((dd, pa, pb));+                        }+                    }+                }+                if let Some((dd, pa, pb)) = best {+                    if pa.1 == p.nets[k] && pb.1 == p.nets[k] {+                        links.push(format!("{}.{}-{}.{} {} {:.2} mm", w[0], pa.0, w[1], pb.0, p.nets[k], dd));+                    } else {+                        why.push(format!("{} and {} face each other on {}.{}/{}.{}, not on {}", w[0], w[1], w[0], pa.0, w[1], pb.0, p.nets[k]));+                    }+                }+            }+        }+        if why.is_empty() {+            out.push((name, true, format!("honoured: {}", links.join(", "))));+        } else {+            out.push((name, false, format!("not honoured: {}", why.join("; "))));+        }+    }+    out+}+ /// Move footprints in the file text: the anchor's `(at x y rot)`, and every child's own angle by /// the rotation change (pads, properties, graphics carry the footprint rotation in the file). pub fn move_in_text(board: &Board, moves: &BTreeMap<String, Move>) -> Result<(String, Vec<String>), String> {@@ -266,3 +525,100 @@ pub fn wishes_from_json(v: &Value) -> Result<Vec<(String, Wish)>, String> { pub fn moves_from_json(v: &Value) -> Result<BTreeMap<String, Move>, String> {     serde_json::from_value(v.clone()).map_err(|e| e.to_string()) }++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;++    /// A 30 x 20 mm board with two-pad parts: (reference, pad 1 net, pad 2 net, x, y); pads 1 mm apart on x.+    fn board(parts: &[(&str, &str, &str, f64, f64)]) -> Board {+        let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");+        for (r, n1, n2, x, y) in parts {+            let pad = |name: &str, dx: f64, net: &str| format!("\t\t(pad \"{name}\" smd rect\n\t\t\t(at {dx} 0)\n\t\t\t(size 0.6 0.6)\n\t\t\t(layers \"F.Cu\")\n\t\t\t(net \"{net}\")\n\t\t)\n");+            t.push_str(&format!("\t(footprint \"T:R\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n{}{}\t)\n", pad("1", -0.5, n1), pad("2", 0.5, n2)));+        }+        t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 30 20)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");+        Board::from_text("test.kicad_pcb", t).unwrap()+    }++    fn wish(x: f64, y: f64, rot: &[f64]) -> Wish {+        Wish { x, y, rot: rot.to_vec(), radius: 3.0, keepout: Vec::new(), gap: None }+    }++    #[test]+    fn chains_parse_from_the_spec() {+        let c = chains_from_spec(&json!({"chains": [["R1", "R2"], {"refs": ["R3", "R4", "R5"], "direction": "left", "gap": 0.3}]})).unwrap();+        assert_eq!(c.len(), 2);+        assert_eq!(c[1].direction.as_deref(), Some("left"));+        assert_eq!(chains_from_spec(&json!({"placement": {"chains": [["A", "B"]]}})).unwrap().len(), 1);+        assert!(chains_from_spec(&json!({"chains": [["R1"]]})).is_err());+        assert!(chains_from_spec(&json!({"chains": [{"refs": ["R1", "R2"], "direction": "sideways"}]})).is_err());+        assert!(chains_from_spec(&json!({})).unwrap().is_empty());+    }++    #[test]+    fn a_chain_is_laid_on_one_line_with_shared_pads_facing() {+        // R1: A-B; R2 numbered the other way round: C-B; parked outside the outline+        let b = board(&[("R1", "A", "B", 40.0, 5.0), ("R2", "C", "B", 44.0, 5.0)]);+        let wishes = vec![("R1".to_string(), wish(10.0, 8.0, &[0.0, 90.0, 180.0, 270.0])), ("R2".to_string(), wish(10.0, 12.0, &[0.0, 90.0, 180.0, 270.0]))];+        let chains = chains_from_spec(&json!({"chains": [["R1", "R2"]]})).unwrap();+        let (w, plans, lines) = apply_chains(&b, &wishes, &chains, 0.05);+        assert_eq!(plans.len(), 1, "{lines:?}");+        assert_eq!(plans[0].direction, "down");+        // R1's B pad (pad 2, local +x) points down at 270; R2's B pad (pad 2) must point up: 90+        assert_eq!(plans[0].rots, vec![270.0, 90.0]);+        assert_eq!(w[0].1.x, w[1].1.x, "one line");+        assert!(w[1].1.y > w[0].1.y);+        let (mv, _) = pack(&b, &w, 0.05, 0.0);+        let verdict = chain_report(&b, &mv, &plans);+        assert!(verdict[0].1, "{verdict:?}");+        assert!(verdict[0].2.contains("R1.2-R2.2 B"), "{verdict:?}");+    }++    #[test]+    fn a_chain_with_a_missing_member_or_no_common_net_is_left_alone() {+        let b = board(&[("R1", "A", "B", 40.0, 5.0), ("R2", "C", "D", 44.0, 5.0)]);+        let wishes = vec![("R1".to_string(), wish(10.0, 8.0, &[0.0])), ("R2".to_string(), wish(10.0, 12.0, &[0.0]))];+        let (w, plans, lines) = apply_chains(&b, &wishes, &chains_from_spec(&json!({"chains": [["R1", "R2"]]})).unwrap(), 0.05);+        assert!(plans.is_empty() && lines[0].contains("share no net"), "{lines:?}");+        assert_eq!(w[1].1.y, 12.0);+        let (_, plans, lines) = apply_chains(&b, &wishes[..1], &chains_from_spec(&json!({"chains": [["R1", "R2"]]})).unwrap(), 0.05);+        assert!(plans.is_empty() && lines[0].contains("R2 has no wish"), "{lines:?}");+    }++    #[test]+    fn pack_turns_a_part_so_its_pad_faces_its_net_partner() {+        // P is already placed at the left; R1's pad 2 is on P's net, so R1 turns 180 to face it+        let b = board(&[("P", "X", "Y", 5.0, 10.0), ("R1", "Z", "Y", 40.0, 5.0)]);+        let (mv, _) = pack(&b, &[("R1".to_string(), wish(9.0, 10.0, &[0.0, 180.0]))], 0.05, 0.0);+        assert_eq!(mv["R1"].rotation, Some(180.0));+        // with the partner on the right, the list order (0) already faces it and stays+        let b = board(&[("P", "X", "Y", 13.0, 10.0), ("R1", "Z", "Y", 40.0, 5.0)]);+        let (mv, _) = pack(&b, &[("R1".to_string(), wish(9.0, 10.0, &[0.0, 180.0]))], 0.05, 0.0);+        assert_eq!(mv["R1"].rotation, Some(0.0));+    }++    /// The buck run's own placement: the VOUT-R1-FBM-R2-FB-R3-GND divider as a chain.+    #[test]+    fn the_buck_divider_chain_is_honoured_on_the_real_board() {+        let src = "/home/adom/project/buck-12v5v-molecule/aiflow";+        if !std::path::Path::new(&format!("{src}/run/board-0.kicad_pcb")).is_file() {+            return;+        }+        let tmp = std::env::temp_dir().join(format!("aiflow-place-chain-{}", std::process::id()));+        std::fs::create_dir_all(&tmp).unwrap();+        let bp = tmp.join("board-0.kicad_pcb");+        std::fs::copy(format!("{src}/run/board-0.kicad_pcb"), &bp).unwrap();+        let b = Board::load(bp.to_str().unwrap()).unwrap();+        let wishes = wishes_from_json(&serde_json::from_str(&std::fs::read_to_string(format!("{src}/wishes.json")).unwrap()).unwrap()).unwrap();+        let chains = chains_from_spec(&json!({"chains": [{"refs": ["R1", "R2", "R3"], "direction": "down"}]})).unwrap();+        let (w, plans, lines) = apply_chains(&b, &wishes, &chains, 0.05);+        assert_eq!(plans.len(), 1, "{lines:?}");+        assert!(plans[0].rots.iter().all(|r| *r == plans[0].rots[0]), "{:?}", plans[0].rots);+        let (mv, _) = pack(&b, &w, 0.05, 0.0);+        let verdict = chain_report(&b, &mv, &plans);+        std::fs::remove_dir_all(&tmp).ok();+        assert!(verdict[0].1, "{verdict:?}");+    }+}
crates/aiflow-router/src/board_copper.rsadded+290
@@ -0,0 +1,290 @@+//! Copper already on the board (an adopted, hand-reworked or partly routed board): its tracks and+//! vias are fixed obstacles for every other net, and its connectivity (tracks, vias and filled zone+//! islands joining pads) decides which connections are already made, so the router plans only the+//! ones still open. A board with no copper takes none of these paths.+use std::collections::HashMap;++use aiflow_board::{geom, Track, Via, B_CU, F_CU};++use crate::{Router, Term};++struct Dsu(Vec<usize>);++impl Dsu {+    fn find(&mut self, mut a: usize) -> usize {+        while self.0[a] != a {+            self.0[a] = self.0[self.0[a]];+            a = self.0[a];+        }+        a+    }+    fn join(&mut self, a: usize, b: usize) {+        let (ra, rb) = (self.find(a), self.find(b));+        if ra != rb {+            self.0[ra] = rb;+        }+    }+}++/// What a connected group of pads carries: any fixed copper at all, and a via or zone island+/// (what joins a pad to a plane).+#[derive(Clone, Copy, Debug, Default)]+pub struct CompCopper {+    pub copper: bool,+    pub via_or_zone: bool,+}++const TOUCH: f64 = 1e-3;++impl Router {+    /// Stamp the board's own tracks and vias as fixed copper and work out which pads they already join.+    pub(crate) fn stamp_board_copper(&mut self) {+        let np = self.board.pads.len();+        self.pad_comp = (0..np).collect();+        let tracks: Vec<Track> = self.board.tracks().into_iter().filter(|t| t.width > 0.0).collect();+        let vias: Vec<Via> = self.board.vias();+        let zones = self.board.zones();+        if tracks.is_empty() && vias.is_empty() {+            return;+        }+        let g = self.grid.clone();+        let nid_of = |r: &Router, net: &str| -> i16 { r.net_ids.get(net).map(|&k| k as i16).unwrap_or(-2) };+        for t in &tracks {+            let nid = nid_of(self, &t.net);+            for c in g.segment_cells(t.a, t.b, t.width / 2.0, Some(1e-6)) {+                self.fixed_raw[t.layer][c] = nid;+            }+        }+        for v in &vias {+            let nid = nid_of(self, &v.net);+            for c in g.disk_cells((v.x, v.y), v.size / 2.0, Some(1e-6)) {+                self.fixed_raw[F_CU][c] = nid;+                self.fixed_raw[B_CU][c] = nid;+            }+            if nid >= 0 {+                let (i, j) = g.to_cell(v.x, v.y);+                self.vias.push((i as i32, j as i32, nid as usize, v.size, v.drill));+            }+        }+        // connectivity: pads, then tracks, then vias, then filled zone islands, one node each+        let islands: Vec<(String, usize, Vec<aiflow_board::Pt>)> = zones.iter().flat_map(|z| z.fills.iter().map(move |(l, p)| (z.net.clone(), *l, p.clone()))).collect();+        let (nt, nv) = (tracks.len(), vias.len());+        let mut dsu = Dsu((0..np + nt + nv + islands.len()).collect());+        let mut kind = vec![CompCopper::default(); np + nt + nv + islands.len()];+        for k in 0..nt { kind[np + k] = CompCopper { copper: true, via_or_zone: false }; }+        for k in 0..nv + islands.len() { kind[np + nt + k] = CompCopper { copper: true, via_or_zone: true }; }+        let pads = self.board.pads.clone();+        let pad_touch = |p: &aiflow_board::Pad, x: f64, y: f64, r: f64| p.polys.iter().any(|poly| geom::point_poly_distance(x, y, poly) <= r + TOUCH);+        for (k, t) in tracks.iter().enumerate() {+            if t.net.is_empty() { continue; }+            for (pi, p) in pads.iter().enumerate() {+                if p.net == t.net && p.on_layer(t.layer) && (pad_touch(p, t.a.0, t.a.1, t.width / 2.0) || pad_touch(p, t.b.0, t.b.1, t.width / 2.0)) {+                    dsu.join(pi, np + k);+                }+            }+            for (m, u) in tracks.iter().enumerate().skip(k + 1) {+                if u.net != t.net || u.layer != t.layer { continue; }+                let near = |p: aiflow_board::Pt, s: &Track| geom::seg_distance(p.0, p.1, s.a.0, s.a.1, s.b.0, s.b.1) <= s.width / 2.0 + TOUCH;+                if near(t.a, u) || near(t.b, u) || near(u.a, t) || near(u.b, t) {+                    dsu.join(np + k, np + m);+                }+            }+            for (m, v) in vias.iter().enumerate() {+                if v.net == t.net && geom::seg_distance(v.x, v.y, t.a.0, t.a.1, t.b.0, t.b.1) <= v.size / 2.0 + t.width / 2.0 {+                    dsu.join(np + k, np + nt + m);+                }+            }+        }+        for (m, v) in vias.iter().enumerate() {+            if v.net.is_empty() { continue; }+            for (pi, p) in pads.iter().enumerate() {+                if p.net == v.net && pad_touch(p, v.x, v.y, v.size / 2.0) {+                    dsu.join(pi, np + nt + m);+                }+            }+        }+        for (z, (net, l, poly)) in islands.iter().enumerate() {+            if net.is_empty() { continue; }+            let node = np + nt + nv + z;+            let inside = |x: f64, y: f64| geom::point_in_poly(x, y, poly);+            for (pi, p) in pads.iter().enumerate() {+                if &p.net != net || !p.on_layer(*l) { continue; }+                // a thermal relief leaves the pad centre bare: its spokes reach into the pad+                let hit = p.polys.iter().any(|pp| pp.iter().any(|&(x, y)| inside(x, y)) || poly.iter().any(|&(x, y)| geom::point_in_poly(x, y, pp)));+                if hit { dsu.join(pi, node); }+            }+            for (k, t) in tracks.iter().enumerate() {+                if &t.net == net && t.layer == *l && (inside(t.a.0, t.a.1) || inside(t.b.0, t.b.1)) { dsu.join(np + k, node); }+            }+            for (m, v) in vias.iter().enumerate() {+                if &v.net == net && geom::point_poly_distance(v.x, v.y, poly) <= v.size / 2.0 { dsu.join(np + nt + m, node); }+            }+        }+        let mut comp_copper: HashMap<usize, CompCopper> = HashMap::new();+        for (n, kd) in kind.iter().enumerate() {+            let root = dsu.find(n);+            let e = comp_copper.entry(root).or_default();+            e.copper |= kd.copper;+            e.via_or_zone |= kd.via_or_zone;+        }+        for pi in 0..np {+            self.pad_comp[pi] = dsu.find(pi);+        }+        // landing cells on the fixed copper, per net and group, for groups that reach a pad+        let with_pad: std::collections::HashSet<usize> = self.pad_comp.iter().copied().collect();+        for (k, t) in tracks.iter().enumerate() {+            let root = dsu.find(np + k);+            let Some(&net) = self.net_ids.get(&t.net) else { continue };+            if !with_pad.contains(&root) { continue; }+            let cells = g.segment_cells(t.a, t.b, (t.width / 2.0 - 0.03).max(0.0), Some(0.0));+            let e = self.fixed_targets.entry(net).or_default();+            for c in cells {+                e.push((root, Term::new(t.layer, (c % g.w) as i64, (c / g.w) as i64)));+            }+        }+        for (m, v) in vias.iter().enumerate() {+            let root = dsu.find(np + nt + m);+            let Some(&net) = self.net_ids.get(&v.net) else { continue };+            if !with_pad.contains(&root) { continue; }+            let (i, j) = g.to_cell(v.x, v.y);+            let e = self.fixed_targets.entry(net).or_default();+            e.push((root, Term::new(F_CU, i, j)));+            e.push((root, Term::new(B_CU, i, j)));+        }+        self.comp_copper = comp_copper;+        self.log(format!("board copper: {} track pieces and {} vias kept as fixed copper, {} filled zone islands read for connectivity", nt, nv, islands.len()));+        self.fixed_tracks = tracks;+        self.fixed_vias = vias;+    }++    /// The fixed-copper group a cluster's pad belongs to (its own pad index when nothing joins it).+    pub fn comp_of(&self, net: usize, c: usize) -> usize {+        let p = self.nets[net].clusters[c].primary;+        self.pad_comp.get(p).copied().unwrap_or(p)+    }++    /// Does the board's own copper already reach this cluster (for a plane net: through a via or a zone)?+    pub fn joined_by_board(&self, net: usize, c: usize) -> bool {+        let cc = self.comp_copper.get(&self.comp_of(net, c)).copied().unwrap_or_default();+        if self.nets[net].plane.is_some() { cc.via_or_zone } else { cc.copper }+    }++    /// Mark every cluster of the net in the same fixed-copper group as `c` connected; returns them.+    pub fn connect_group(&mut self, net: usize, c: usize) -> Vec<usize> {+        let comp = self.comp_of(net, c);+        let n = self.nets[net].clusters.len();+        let same: Vec<usize> = (0..n).filter(|&k| self.comp_of(net, k) == comp).collect();+        for &k in &same {+            self.nets[net].clusters[k].connected = true;+        }+        same+    }++    /// Other-net fixed copper within `need` of a point on a layer (None: either layer, as for a via).+    pub fn fixed_blocks(&self, net_name: &str, layer: Option<usize>, x: f64, y: f64, need: f64) -> bool {+        for t in &self.fixed_tracks {+            if t.net == net_name || layer.map(|l| l != t.layer).unwrap_or(false) { continue; }+            if (t.a.0 - x).abs() > 3.0 + t.width && (t.b.0 - x).abs() > 3.0 + t.width { continue; }+            if geom::seg_distance(x, y, t.a.0, t.a.1, t.b.0, t.b.1) < need + t.width / 2.0 { return true; }+        }+        self.fixed_vias.iter().any(|v| v.net != net_name && (v.x - x).hypot(v.y - y) < need + v.size / 2.0)+    }+}++#[cfg(test)]+pub(crate) mod tests {+    use super::*;+    use crate::Rules;+    use aiflow_board::Board;++    /// A 20 x 10 mm two-layer board: `pads` as (reference, net, x, y, thru), plus raw `extra` items.+    pub(crate) fn board(pads: &[(&str, &str, f64, f64, bool)], extra: &str) -> Board {+        let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");+        for (r, net, x, y, thru) in pads {+            let pad = if *thru { format!("(pad \"1\" thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 1.2 1.2)\n\t\t\t(drill 0.6)\n\t\t\t(layers \"*.Cu\")\n\t\t\t(net \"{net}\")\n\t\t)") } else { format!("(pad \"1\" smd rect\n\t\t\t(at 0 0)\n\t\t\t(size 1 1)\n\t\t\t(layers \"F.Cu\")\n\t\t\t(net \"{net}\")\n\t\t)") };+            t.push_str(&format!("\t(footprint \"T:{r}\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n\t\t{pad}\n\t)\n"));+        }+        t.push_str(extra);+        t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 20 10)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");+        Board::from_text("test.kicad_pcb", t).unwrap()+    }++    pub(crate) fn seg(net: &str, layer: &str, a: (f64, f64), b: (f64, f64)) -> String {+        format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width 0.25)\n\t\t(layer \"{layer}\")\n\t\t(net \"{net}\")\n\t)\n", a.0, a.1, b.0, b.1)+    }++    const PADS: [(&str, &str, f64, f64, bool); 4] = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J3", "B", 10.0, 2.0, true), ("J4", "B", 10.0, 8.0, true)];++    fn route(b: Board) -> (Router, Vec<serde_json::Value>, serde_json::Value, usize) {+        let mut r = Router::new(b, Rules::default(), false);+        let (plan, report, failed) = crate::drive::route_board(&mut r, 2, 4, 50);+        (r, plan, report, failed)+    }++    fn nets_in(plan: &[serde_json::Value]) -> Vec<String> {+        let mut n: Vec<String> = plan.iter().filter_map(|e| e["net"].as_str().map(str::to_string)).collect();+        n.dedup();+        n+    }++    #[test]+    fn a_board_without_copper_routes_every_net() {+        let (_, plan, report, failed) = route(board(&PADS, ""));+        assert_eq!(failed, 0);+        let nets = nets_in(&plan);+        assert!(nets.contains(&"A".to_string()) && nets.contains(&"B".to_string()), "{nets:?}");+        assert_eq!(report["boardCopper"]["trackPieces"], 0);+    }++    #[test]+    fn a_fully_routed_board_gets_an_empty_plan() {+        let extra = seg("A", "F.Cu", (2.0, 5.0), (18.0, 5.0)) + &seg("B", "B.Cu", (10.0, 2.0), (10.0, 8.0));+        let (r, plan, report, failed) = route(board(&PADS, &extra));+        assert_eq!(failed, 0);+        assert!(plan.is_empty(), "{plan:?}");+        assert!(r.nets.iter().all(|n| n.done_by_board));+        assert_eq!(report["boardCopper"]["openConnections"], 0);+        assert_eq!(report["boardCopper"]["netsComplete"], serde_json::json!(["A", "B"]));+    }++    #[test]+    fn existing_copper_is_kept_and_only_open_nets_are_planned() {+        // A is done on F.Cu straight through the middle; B must cross it, so not on F.Cu+        let (r, plan, _, failed) = route(board(&PADS, &seg("A", "F.Cu", (2.0, 5.0), (18.0, 5.0))));+        assert_eq!(failed, 0);+        assert_eq!(nets_in(&plan), vec!["B".to_string()]);+        let b = r.net_ids["B"];+        let g = &r.grid;+        for route in &r.nets[b].routes {+            for c in &route.cells {+                let (x, y) = g.to_xy(c.i as i64, c.j as i64);+                if c.layer() == F_CU {+                    assert!((y - 5.0).abs() > 0.125 + 0.2 + 0.125 - 1e-6 || x < 1.0, "B crosses A's fixed track at ({x}, {y})");+                }+            }+        }+    }++    #[test]+    fn a_partly_routed_net_is_finished_onto_its_own_copper() {+        let (r, plan, report, failed) = route(board(&PADS, &seg("A", "F.Cu", (2.0, 5.0), (8.0, 5.0))));+        assert_eq!(failed, 0);+        assert!(nets_in(&plan).contains(&"A".to_string()));+        assert_eq!(report["boardCopper"]["netsPartial"], serde_json::json!(["A"]));+        let a = r.net_ids["A"];+        // one new connection for A, and it is shorter than the whole 16 mm span: it lands on the stub+        assert_eq!(r.nets[a].routes.len(), 1);+        assert!(r.nets[a].routes[0].cells.len() < 150, "{}", r.nets[a].routes[0].cells.len());+    }++    #[test]+    fn a_filled_zone_island_joins_its_pads() {+        let zone = "\t(zone\n\t\t(net \"A\")\n\t\t(layer \"F.Cu\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 1 4) (xy 19 4) (xy 19 6) (xy 1 6)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon\n\t\t\t(layer \"F.Cu\")\n\t\t\t(pts\n\t\t\t\t(xy 1 4) (xy 19 4) (xy 19 6) (xy 1 6)\n\t\t\t)\n\t\t)\n\t)\n";+        // a via somewhere else on the board so the fixed-copper pass runs+        let via = "\t(via\n\t\t(at 5 8.5)\n\t\t(size 0.6)\n\t\t(drill 0.3)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n\t\t(net \"B\")\n\t)\n";+        let (r, plan, _, failed) = route(board(&PADS, &format!("{zone}{via}")));+        assert_eq!(failed, 0);+        assert!(r.nets[r.net_ids["A"]].done_by_board);+        assert_eq!(nets_in(&plan), vec!["B".to_string()]);+    }+}
crates/aiflow-router/src/drive.rs+72−14
@@ -260,6 +260,11 @@ impl Router {             }             t.extend(r.cells.iter().copied());         }+        // the board's own copper of every group already joined to the tree+        if let Some(cells) = self.fixed_targets.get(&net) {+            let joined: HashSet<usize> = (0..self.nets[net].clusters.len()).filter(|&c| self.nets[net].clusters[c].connected).map(|c| self.comp_of(net, c)).collect();+            t.extend(cells.iter().filter(|(g, _)| joined.contains(g)).map(|(_, c)| *c));+        }         t     } @@ -285,7 +290,12 @@ impl Router {     pub fn connect(&mut self, net: usize, cluster: usize, anchor: &[(i64, i64)], soft: bool, vip: bool) -> (Option<Route>, HashSet<usize>) {         let mut targets = self.copper_targets(net);         let cl = self.nets[net].clusters[cluster].clone();-        let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();+        let mut sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();+        // a cluster the board's own copper already reaches may leave from anywhere on that copper+        if let Some(cells) = self.fixed_targets.get(&net) {+            let group = self.comp_of(net, cluster);+            sources.extend(cells.iter().filter(|(g, t)| *g == group && !targets.contains(t)).map(|(_, t)| (*t, 0)));+        }         let allow = cl.allow;         let mut widths: Vec<f64> = self.nets[net].widths.iter().copied().filter(|w| *w <= allow).collect();         if widths.is_empty() {@@ -309,7 +319,8 @@ impl Router {                 if let Some(cells) = cells {                     let start_term = cells[0];                     let end_cell = *cells.last().unwrap();-                    let start = if cl.escapes.contains_key(&start_term) { End::Escape(cluster, start_term) } else { End::Pad(cluster, start_term) };+                    let from_pad = cl.terminals.contains(&start_term);+                    let start = if cl.escapes.contains_key(&start_term) { End::Escape(cluster, start_term) } else if from_pad { End::Pad(cluster, start_term) } else { End::Copper };                     let mut end = End::Copper;                     for c in &self.nets[net].clusters {                         if c.connected && (c.terminals.contains(&end_cell) || (end_cell.layer() == B_CU && c.terminals.contains(&Term::new(F_CU, end_cell.i as i64, end_cell.j as i64)) && targets.contains(&end_cell))) {@@ -326,7 +337,7 @@ impl Router {                             self.nets[net].via_in_pad.push(key);                         }                     }-                    if cells.len() > 1 && (cells[0].i, cells[0].j) == (cells[1].i, cells[1].j) && cells[0].layer() != cells[1].layer() && !self.board.pads[cl.primary].thru {+                    if from_pad && cells.len() > 1 && (cells[0].i, cells[0].j) == (cells[1].i, cells[1].j) && cells[0].layer() != cells[1].layer() && !self.board.pads[cl.primary].thru {                         self.stat("viaInPad", 1);                         let key = cl.key(&self.board).to_string();                         self.nets[net].via_in_pad.push(key);@@ -354,14 +365,17 @@ impl Router {             let pb = &self.board.pads[cb.primary];             (ca.allow, pa.half_long * pa.half_short).partial_cmp(&(cb.allow, pb.half_long * pb.half_short)).unwrap()         }).unwrap();-        self.nets[net].clusters[start].connected = true;-        let mut connected = vec![start];-        let mut remaining: Vec<usize> = (0..n).filter(|&c| c != start).collect();+        // the start and every cluster the board's own copper already joins to it+        let mut connected = self.connect_group(net, start);+        let mut remaining: Vec<usize> = (0..n).filter(|c| !connected.contains(c)).collect();         let mut blockers_all = HashSet::new();         // Kelvin taps first: sense pad to shunt pad as a dedicated trace         let kelvin = self.rules.kelvin.clone();         for c in remaining.clone() {             let key = self.nets[net].clusters[c].key(&self.board).to_string();+            if self.joined_by_board(net, c) {+                continue;   // a sense pad the board's copper already reaches keeps that copper+            }             if let Some(dst_key) = kelvin.get(&key) {                 let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dst_key);                 let Some(dst) = dst else { continue };@@ -413,8 +427,12 @@ impl Router {                 }                 Some(r) => {                     self.commit(net, r);-                    self.nets[net].clusters[c].connected = true;-                    connected.push(c);+                    for k in self.connect_group(net, c) {+                        if !connected.contains(&k) {+                            connected.push(k);+                        }+                        remaining.retain(|&x| x != k);+                    }                 }             }         }@@ -611,7 +629,7 @@ impl Router {             }         }         // a synthetic net state just for the search-        let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill };+        let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill, done_by_board: false };         self.nets.push(fake);         let fidx = self.nets.len() - 1;         let sources: Vec<(Term, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (*t, 0)).collect();@@ -673,7 +691,7 @@ impl Router {         for c in 0..n {             let cl = self.nets[net].clusters[c].clone();             let key = cl.key(&self.board).to_string();-            if self.board.pads[cl.primary].thru {+            if self.board.pads[cl.primary].thru || self.joined_by_board(net, c) {                 self.nets[net].clusters[c].connected = true;                 continue;             }@@ -744,6 +762,12 @@ impl Router {                 }             }         }+        if self.fixed_blocks(&name, None, x, y, rv + self.rules.clearance) {+            return false;+        }+        if g.in_bounds(i, j) && (0..2).any(|l| self.kept_from(&name, l, j as usize * g.w + i as usize)) {+            return false;+        }         let nid = self.nets[net].idx;         for (oi, other) in self.nets.iter().enumerate() {             if other.idx == nid || oi == net {@@ -805,10 +829,16 @@ impl Router {         let kelvin = self.rules.kelvin.clone();         if planes {             for &net in &plane_nets {+                if self.nets[net].done_by_board {+                    continue;+                }                 let t1 = std::time::Instant::now();                 let n = self.nets[net].clusters.len();                 for c in 0..n {                     let key = self.nets[net].clusters[c].key(&self.board).to_string();+                    if self.joined_by_board(net, c) {+                        continue;+                    }                     if let Some(dk) = kelvin.get(&key) {                         let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dk);                         if dst.map(|d| !self.route_kelvin(net, c, d)).unwrap_or(true) {@@ -821,7 +851,7 @@ impl Router {                 self.log(format!("  {name:<28} plane {plane}: {} smd stubs, {nv} via-in-pad, {nf} failed, {:.1}s", nr - nv, t1.elapsed().as_secs_f64()));             }         }-        let mut signal: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_none() && n.clusters.len() >= 2).map(|n| n.idx).collect();+        let mut signal: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_none() && n.clusters.len() >= 2 && !n.done_by_board).map(|n| n.idx).collect();         for &n in &signal {             let l = self.mst_length(n);             self.nets[n].mst_len = l;@@ -945,8 +975,9 @@ impl Router {             self.nets[net].via_in_pad.clear();             self.nets[net].routed_order = None;             self.nets[net].used_escapes.clear();+            let done = self.nets[net].done_by_board;             for c in self.nets[net].clusters.iter_mut() {-                c.connected = false;+                c.connected = done;             }         }         self.rip_total = 0;@@ -974,7 +1005,8 @@ impl Router {             for c in 0..n {                 let plane_thru = self.nets[net].plane.is_some() && self.board.pads[self.nets[net].clusters[c].primary].thru;                 let touched = self.nets[net].routes.iter().any(|r| r.start.cluster() == Some(c) || (matches!(r.end, End::Pad(..) | End::Escape(..)) && r.end.cluster() == Some(c)));-                self.nets[net].clusters[c].connected = touched || plane_thru;+                let done = self.nets[net].done_by_board || self.joined_by_board(net, c);+                self.nets[net].clusters[c].connected = touched || plane_thru || done;             }         }     }@@ -1111,12 +1143,38 @@ impl Router {             nets.insert(net.name.clone(), json!({                 "clusters": net.clusters.len(), "pads": net.pads.len(), "routes": net.routes.len(), "widths": widths, "plane": net.plane,                 "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order, "mstMm": (net.mst_len * 100.0).round() / 100.0,+                "doneByBoard": net.done_by_board,             }));             for (k, why) in &net.failed {                 unrouted.push(json!([net.name, [k, why]]));             }         }-        json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted})+        json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted, "boardCopper": self.board_copper_summary(),+            "planeUnder": self.keep.iter().map(|k| json!({"label": k.label, "net": k.net, "layer": aiflow_board::LAYER_NAMES[k.layer], "polygon": k.poly})).collect::<Vec<_>>()})+    }++    /// What the board's own copper already did: its size, the nets it completes, the nets it+    /// only partly joins (the plan finishes those), and how many connections the router still had to plan.+    pub fn board_copper_summary(&self) -> Value {+        let mut complete = Vec::new();+        let mut partial = Vec::new();+        let mut open = 0usize;+        for (n, net) in self.nets.iter().enumerate() {+            if net.clusters.len() < 2 && net.plane.is_none() {+                continue;+            }+            if net.done_by_board {+                complete.push(net.name.clone());+                continue;+            }+            let groups: HashSet<usize> = (0..net.clusters.len()).map(|c| self.comp_of(n, c)).collect();+            let joined = (0..net.clusters.len()).any(|c| self.joined_by_board(n, c));+            if joined {+                partial.push(net.name.clone());+            }+            open += if net.plane.is_some() { (0..net.clusters.len()).filter(|&c| !self.joined_by_board(n, c) && !self.board.pads[net.clusters[c].primary].thru).count() } else { groups.len() - 1 };+        }+        json!({"trackPieces": self.fixed_tracks.len(), "vias": self.fixed_vias.len(), "netsComplete": complete, "netsPartial": partial, "openConnections": open})     } } 
crates/aiflow-router/src/lib.rs+90−5
@@ -10,8 +10,15 @@ //! connection fails, multi-pass with the failed nets first, and Kelvin sense taps routed first as //! dedicated traces that the rest of their net treats as foreign copper. //!+//!+//! Copper already on the board (an adopted or partly routed board) is fixed: its tracks and vias are+//! obstacles for every other net, and connections it already makes are not planned again. A spec's+//! planeUnder regions are keepouts for every net but their own on their layer.+//! //! Output: a plan, a list of `kicad_route_net` calls ({net, width, viaSize, viaDrill, paths}).+pub mod board_copper; pub mod drive;+pub mod plane_under; pub mod rules;  use std::collections::{HashMap, HashSet};@@ -138,6 +145,8 @@ pub struct NetState {     pub last_soft: Vec<Term>,     pub via: f64,     pub drill: f64,+    /// Every cluster of the net is already joined by the board's own copper: nothing to route.+    pub done_by_board: bool, }  #[derive(Clone)]@@ -176,6 +185,18 @@ pub struct Router {     pub ic_zone: Mask,     pub ic_nets: HashMap<usize, HashSet<usize>>,     pub log_lines: Vec<String>,+    /// The board's own tracks and vias, per layer: net id, -2 for copper on no known net, -1 empty.+    pub fixed_raw: [Vec<i16>; 2],+    pub fixed_tracks: Vec<aiflow_board::Track>,+    pub fixed_vias: Vec<aiflow_board::Via>,+    /// Per pad: the group of pads the board's own copper already joins (its own index when none).+    pub pad_comp: Vec<usize>,+    pub comp_copper: HashMap<usize, board_copper::CompCopper>,+    /// Per net: landing cells on its fixed copper, with the group each belongs to.+    pub fixed_targets: HashMap<usize, Vec<(usize, Term)>>,+    /// planeUnder regions and, per layer, the region index owning each cell (-1 none).+    pub keep: Vec<plane_under::Region>,+    pub keep_raw: [Vec<i16>; 2], }  impl Router {@@ -202,7 +223,7 @@ impl Router {             nets.push(NetState {                 idx: k, name: (*name).clone(), pads: board.nets[*name].clone(), clusters: Vec::new(), routes: Vec::new(),                 widths: vec![rules.track], plane: rules.planes.get(*name).cloned(), mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(),-                rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: rules.via_small, drill: rules.drill_small,+                rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: rules.via_small, drill: rules.drill_small, done_by_board: false,             });         }         let mut refs: Vec<&String> = board.footprints.keys().collect();@@ -215,14 +236,18 @@ impl Router {             big_clr: [grid.mask(), grid.mask()], big_clr_val: 0.0, smd_pad_cells: HashMap::new(), thru_cells: grid.mask(),             history: [vec![0; n], vec![0; n]], chan_count: vec![0; n], chan_lines: HashMap::new(), vias: Vec::new(), fp_index,             pad_cells: HashMap::new(), inside, edge_dist, inside_w: HashMap::new(), rip_total: 0, stats: HashMap::new(), kelvin_owner: HashMap::new(), kelvin_clusters: HashSet::new(),-            body_owner: vec![-1; n], ic_zone: grid.mask(), ic_nets: HashMap::new(), log_lines: Vec::new(), board,+            body_owner: vec![-1; n], ic_zone: grid.mask(), ic_nets: HashMap::new(), log_lines: Vec::new(),+            fixed_raw: [vec![-1; n], vec![-1; n]], fixed_tracks: Vec::new(), fixed_vias: Vec::new(), pad_comp: Vec::new(), comp_copper: HashMap::new(), fixed_targets: HashMap::new(),+            keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], board,         };         r.big_clr_val = r.rules.clearance;         let t0 = std::time::Instant::now();         r.stamp_pads();+        r.stamp_board_copper();+        r.stamp_plane_under();         r.build_clusters();         r.build_ic_zones();-        let count = r.nets.iter().filter(|n| n.clusters.len() >= 2 || n.plane.is_some()).count();+        let count = r.nets.iter().filter(|n| (n.clusters.len() >= 2 || n.plane.is_some()) && !n.done_by_board).count();         r.log(format!("router: {} x {} cells, {} pads stamped, {} nets to route, init {:.1}s", r.grid.w, r.grid.h, r.board.pads.len(), count, t0.elapsed().as_secs_f64()));         r     }@@ -272,6 +297,38 @@ impl Router {         }     } +    /// Resolve the planeUnder regions and mark their cells. A region the board cannot resolve is+    /// logged and skipped here; `route` refuses such a spec before it builds a router.+    fn stamp_plane_under(&mut self) {+        let regions = match plane_under::regions(&self.board, &self.rules.plane_under) {+            Ok(r) => r,+            Err(e) => {+                self.log(format!("planeUnder: {e}; no region applied"));+                return;+            }+        };+        let g = self.grid.clone();+        for (k, reg) in regions.iter().enumerate() {+            let mut m = g.mask();+            g.poly_mask(&reg.poly, &mut m);+            let mut cells = 0;+            for (c, b) in m.bits.iter().enumerate() {+                if *b {+                    self.keep_raw[reg.layer][c] = k as i16;+                    cells += 1;+                }+            }+            self.log(format!("  planeUnder {}: {:.1} mm2 of {} kept for {} (a keepout for every other net)", reg.label, cells as f64 * RES * RES, aiflow_board::LAYER_NAMES[reg.layer], reg.net));+        }+        self.keep = regions;+    }++    /// Is this cell of the layer inside a planeUnder region that belongs to another net?+    pub fn kept_from(&self, net_name: &str, l: usize, c: usize) -> bool {+        let k = self.keep_raw[l][c];+        k >= 0 && self.keep[k as usize].net != net_name+    }+     pub fn inside_for(&mut self, w: f64) -> &Mask {         let key = (w * 1000.0).round() as i64;         if !self.inside_w.contains_key(&key) {@@ -472,6 +529,18 @@ impl Router {                 self.nets[net].widths = w.clone();             }             let count = self.nets[net].clusters.len();+            if !self.comp_copper.is_empty() {+                let plane = self.nets[net].plane.is_some();+                let first = self.comp_of(net, 0);+                let done = if plane { (0..count).all(|c| self.joined_by_board(net, c) || self.board.pads[self.nets[net].clusters[c].primary].thru) } else { count >= 2 && (0..count).all(|c| self.comp_of(net, c) == first) };+                if done {+                    self.nets[net].done_by_board = true;+                    for c in 0..count {+                        self.nets[net].clusters[c].connected = true;+                    }+                    continue;+                }+            }             for c in 0..count {                 self.terminals_for(net, c);             }@@ -558,7 +627,8 @@ impl Router {         }         let l = if p.on_f { F_CU } else { B_CU };         let narrow = md < self.rules.narrow && p.half_long > p.half_short * 1.4;-        if !narrow {+        // a pad the board's own copper already reaches is joined where it is: no escape stub of its own+        if !narrow || self.joined_by_board(net, c) {             let (i, j) = g.to_cell(p.x, p.y);             self.nets[net].clusters[c].terminals = vec![Term::new(l, i, j)];             return;@@ -635,6 +705,17 @@ impl Router {         samples.push((b.0, b.1, track / 2.0));         let reach = 2.5;         let net_name = self.nets[net].name.clone();+        if !self.fixed_tracks.is_empty() || !self.fixed_vias.is_empty() || !self.keep.is_empty() {+            for &(x, y, hw) in &samples {+                if self.fixed_blocks(&net_name, Some(l), x, y, hw + self.rules.clearance) {+                    return false;+                }+                let (ci, cj) = g.to_cell(x, y);+                if g.in_bounds(ci, cj) && self.kept_from(&net_name, l, cj as usize * g.w + ci as usize) {+                    return false;+                }+            }+        }         for q in &self.board.pads {             if q.reference == p.reference || q.net == net_name {                 continue;@@ -680,6 +761,8 @@ impl Router {         let mut softm: [Option<Mask>; 2] = [None, None];         let inside_w = self.inside_for(width).clone();         let inside_via = self.inside_for(via).clone();+        let net_name = self.nets[net].name.clone();+        let keep_foreign: Vec<bool> = self.keep.iter().map(|k| k.net != net_name).collect();         for l in 0..2 {             let mut other_pads = Mask::new(ww, hh);             let mut other_routes = Mask::new(ww, hh);@@ -690,7 +773,9 @@ impl Router {                     let c = base + ii;                     let pr = self.pad_raw[l][c];                     let rr = self.route_raw[l][c];-                    let op = pr != -1 && pr != nid;+                    let fr = self.fixed_raw[l][c];+                    let kr = self.keep_raw[l][c];+                    let op = (pr != -1 && pr != nid) || (fr != -1 && fr != nid) || (kr >= 0 && keep_foreign[kr as usize]);                     let orr = rr != -1 && rr != nid;                     other_pads.bits[jj * ww + ii] = op;                     other_routes.bits[jj * ww + ii] = orr;
crates/aiflow-router/src/plane_under.rsadded+173
@@ -0,0 +1,173 @@+//! The layer policy for 2-layer power boards (spec "planeUnder"): a region of one copper layer that+//! belongs to one net's plane, typically B.Cu under the hot loop kept as unbroken ground. The router+//! treats every region as a keepout for every other net on that layer (and for other nets' vias,+//! which pierce both layers); `issues` finds other-net copper already inside a region, for the gate.+use aiflow_board::{geom, Board, Pt, B_CU, F_CU, LAYER_NAMES};++use crate::rules::PlaneUnder;++/// A resolved region: layer index, the net that owns it, the polygon, and a label for messages.+#[derive(Clone, Debug)]+pub struct Region {+    pub layer: usize,+    pub net: String,+    pub poly: Vec<Pt>,+    pub label: String,+}++fn pad_box(board: &Board, keys: &[String]) -> Result<(f64, f64, f64, f64), String> {+    let mut pts = Vec::new();+    for k in keys {+        let found: Vec<Pt> = match k.split_once('.') {+            Some((r, n)) if board.pad_by_key(k).is_some() => board.pads.iter().filter(|p| p.reference == r && p.name == n).flat_map(|p| p.polys.iter().flatten().copied()).collect(),+            _ => board.pads.iter().filter(|p| p.reference == *k).flat_map(|p| p.polys.iter().flatten().copied()).collect(),+        };+        if found.is_empty() {+            return Err(format!("{k} is not a part or pad on the board"));+        }+        pts.extend(found);+    }+    Ok(geom::bbox(&pts))+}++/// Every planeUnder entry as a polygon on its layer. An entry naming an unknown layer, part or+/// shape is an error, so a typo never silently disables the policy.+pub fn regions(board: &Board, entries: &[PlaneUnder]) -> Result<Vec<Region>, String> {+    let mut out = Vec::new();+    for (k, e) in entries.iter().enumerate() {+        let layer = match e.layer.as_str() { "F.Cu" => F_CU, "B.Cu" => B_CU, other => return Err(format!("planeUnder[{k}]: layer {other:?} is not F.Cu or B.Cu")) };+        if e.net.is_empty() {+            return Err(format!("planeUnder[{k}]: needs the net that owns the region (\"net\": \"GND\")"));+        }+        let poly: Vec<Pt> = if e.polygon.len() >= 3 {+            e.polygon.clone()+        } else if !e.around.is_empty() {+            let (x0, y0, x1, y1) = pad_box(board, &e.around).map_err(|m| format!("planeUnder[{k}]: {m}"))?;+            let m = e.margin.unwrap_or(1.0);+            let (bx0, by0, bx1, by1) = board.bbox;+            let r4 = |v: f64| (v * 10000.0).round() / 10000.0;+            let (a, b, c, d) = (r4((x0 - m).max(bx0)), r4((y0 - m).max(by0)), r4((x1 + m).min(bx1)), r4((y1 + m).min(by1)));+            vec![(a, b), (c, b), (c, d), (a, d)]+        } else {+            return Err(format!("planeUnder[{k}]: needs \"around\" (parts or pads) or a \"polygon\" of 3 or more points"));+        };+        let label = e.name.clone().unwrap_or_else(|| if e.around.is_empty() { format!("{} {} polygon", e.net, e.layer) } else { format!("{} {} under {}", e.net, e.layer, e.around.join(",")) });+        out.push(Region { layer, net: e.net.clone(), poly, label });+    }+    Ok(out)+}++/// How much of a copper segment of half width hw touches the region (0 when it stays out), sampled+/// every 0.05 mm; a touch that is shorter than one sample still counts as one sample.+fn segment_inside(poly: &[Pt], a: Pt, b: Pt, hw: f64) -> Option<f64> {+    let l = (b.0 - a.0).hypot(b.1 - a.1);+    let n = ((l / 0.05).ceil() as usize).max(1);+    let hits = (0..=n).filter(|&k| {+        let t = k as f64 / n as f64;+        geom::point_poly_distance(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, poly) < hw+    }).count();+    if hits == 0 { None } else { Some((l * hits as f64 / (n + 1) as f64).max(0.05)) }+}++fn segment_touches(poly: &[Pt], a: Pt, b: Pt, hw: f64) -> bool {+    segment_inside(poly, a, b, hw).is_some()+}++/// Other-net copper inside a region: tracks and zone outlines on its layer, and vias. One line each,+/// with the length or position, for the gate's report.+pub fn issues(board: &Board, regions: &[Region]) -> Vec<String> {+    let mut out = Vec::new();+    if regions.is_empty() {+        return out;+    }+    let tracks = board.tracks();+    let vias = board.vias();+    let zones = board.zones();+    for r in regions {+        let mut by_net: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();+        for t in tracks.iter().filter(|t| t.layer == r.layer && t.net != r.net) {+            if let Some(mm) = segment_inside(&r.poly, t.a, t.b, t.width / 2.0) {+                *by_net.entry(t.net.clone()).or_insert(0.0) += mm;+            }+        }+        for (net, mm) in by_net {+            out.push(format!("{}: {net} track on {} inside the region (about {mm:.1} mm)", r.label, LAYER_NAMES[r.layer]));+        }+        for v in vias.iter().filter(|v| v.net != r.net) {+            if geom::point_poly_distance(v.x, v.y, &r.poly) < v.size / 2.0 {+                out.push(format!("{}: {} via at ({:.2}, {:.2}) inside the region", r.label, v.net, v.x, v.y));+            }+        }+        for z in zones.iter().filter(|z| z.net != r.net && z.layers.contains(&r.layer)) {+            let islands: Vec<&Vec<Pt>> = z.fills.iter().filter(|(l, _)| *l == r.layer).map(|(_, p)| p).collect();+            let shapes: Vec<&Vec<Pt>> = if islands.is_empty() { vec![&z.outline] } else { islands };+            let hit = shapes.iter().any(|s| s.len() >= 3 && ((0..s.len()).any(|k| segment_touches(&r.poly, s[k], s[(k + 1) % s.len()], 1e-6)) ||r.poly.iter().any(|&(x, y)| geom::point_in_poly(x, y, s))));+            if hit {+                out.push(format!("{}: {} zone on {} reaches into the region", r.label, if z.net.is_empty() { "an unassigned" } else { z.net.as_str() }, LAYER_NAMES[r.layer]));+            }+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::board_copper::tests::{board, seg};+    use crate::{Router, Rules};++    fn entry(json: serde_json::Value) -> Vec<PlaneUnder> {+        crate::rules::PlaneUnder::from_spec(&serde_json::json!({ "planeUnder": json }))+    }++    #[test]+    fn around_resolves_to_the_pad_box_grown_by_the_margin() {+        let b = board(&[("U1", "GND", 10.0, 5.0, false), ("J1", "A", 2.0, 5.0, true)], "");+        let r = regions(&b, &entry(serde_json::json!([{"layer": "B.Cu", "net": "GND", "around": ["U1"], "margin": 1.0}]))).unwrap();+        assert_eq!(r.len(), 1);+        assert_eq!(r[0].layer, B_CU);+        let (x0, y0, x1, y1) = geom::bbox(&r[0].poly);+        assert!((x0 - 8.5).abs() < 1e-6 && (x1 - 11.5).abs() < 1e-6 && (y0 - 3.5).abs() < 1e-6 && (y1 - 6.5).abs() < 1e-6);+        assert!(regions(&b, &entry(serde_json::json!([{"layer": "B.Cu", "net": "GND", "around": ["U9"]}]))).is_err());+        assert!(regions(&b, &entry(serde_json::json!([{"layer": "In1.Cu", "net": "GND", "around": ["U1"]}]))).is_err());+    }++    #[test]+    fn issues_name_other_net_copper_inside_a_region() {+        let via = "\t(via\n\t\t(at 10 6)\n\t\t(size 0.6)\n\t\t(drill 0.3)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n\t\t(net \"A\")\n\t)\n";+        let extra = seg("A", "B.Cu", (2.0, 5.0), (18.0, 5.0)) + &seg("GND", "B.Cu", (9.0, 5.0), (11.0, 5.0)) + &seg("A", "F.Cu", (2.0, 5.0), (18.0, 5.0)) + via;+        let b = board(&[("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J3", "GND", 10.0, 1.0, true), ("J4", "GND", 10.0, 9.0, true)], &extra);+        let r = regions(&b, &entry(serde_json::json!({"layer": "B.Cu", "net": "GND", "polygon": [[8, 3], [12, 3], [12, 7], [8, 7]]}))).unwrap();+        let found = issues(&b, &r);+        assert_eq!(found.len(), 2, "{found:?}");+        assert!(found[0].contains("A track on B.Cu") && found[0].contains("about 4.2 mm"), "{found:?}");+        assert!(found[1].contains("A via at (10.00, 6.00)"), "{found:?}");+    }++    #[test]+    fn the_router_keeps_other_nets_out_of_a_region() {+        // A runs left to right; a GND region fills the middle of F.Cu top to bottom, so A must dive to B.Cu+        let pads = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J3", "GND", 10.0, 1.0, true), ("J4", "GND", 10.0, 9.0, true)];+        let mut rules = Rules::default();+        rules.plane_under = entry(serde_json::json!([{"layer": "F.Cu", "net": "GND", "polygon": [[8, 0], [12, 0], [12, 10], [8, 10]]}]));+        let mut r = Router::new(board(&pads, ""), rules, false);+        let (_, _, failed) = crate::drive::route_board(&mut r, 2, 4, 50);+        assert_eq!(failed, 0);+        let a = r.net_ids["A"];+        let g = r.grid.clone();+        let mut on_b = false;+        for route in &r.nets[a].routes {+            for c in &route.cells {+                let (x, _) = g.to_xy(c.i as i64, c.j as i64);+                if c.layer() == F_CU {+                    assert!(!(8.0..=12.0).contains(&x), "A on F.Cu inside the GND region at x {x}");+                } else if (8.0..=12.0).contains(&x) {+                    on_b = true;+                }+            }+        }+        assert!(on_b, "A should cross the region on B.Cu");+        // GND itself may use its own region+        assert!(r.nets[r.net_ids["GND"]].failed.is_empty());+    }+}
crates/aiflow-router/src/rules.rs+35−1
@@ -28,6 +28,22 @@ pub struct Rules {     pub hole_to_hole: f64,     pub via_search_mm: f64,     pub kelvin: BTreeMap<String, String>,+    /// Layer policy (spec "planeUnder"): regions of a layer that belong to one net's plane; every+    /// other net treats them as a keepout on that layer (vias included, they pierce both layers).+    pub plane_under: Vec<PlaneUnder>,+}++/// One `planeUnder` entry: `{"layer": "B.Cu", "net": "GND", "around": ["U1", "C2.1"], "margin": 1.0}`+/// (the pad box of the listed parts or pads grown by the margin) or an explicit `"polygon"`.+#[derive(Clone, Debug, Default, Serialize, Deserialize)]+#[serde(default)]+pub struct PlaneUnder {+    pub layer: String,+    pub net: String,+    pub around: Vec<String>,+    pub margin: Option<f64>,+    pub polygon: Vec<(f64, f64)>,+    pub name: Option<String>, }  impl Default for Rules {@@ -35,7 +51,7 @@ impl Default for Rules {         Rules {             track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5,             stub: 0.25, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5,-            planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(),+            planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(),         }     } }@@ -54,6 +70,7 @@ impl Rules {         r.kelvin = get_map("kelvin");         r.wide = get_widths("wideNets");         r.mid = get_widths("midNets");+        r.plane_under = PlaneUnder::from_spec(spec);         let num = |k: &str| spec.get(k).and_then(|v| v.as_f64());         if let Some(v) = num("clearance") { r.clearance = v; }         if let Some(m) = spec.get("netClearance").and_then(|v| v.as_object()) {@@ -73,6 +90,15 @@ impl Rules {     } } +impl PlaneUnder {+    /// Every `planeUnder` entry of a spec (an array, or one object); an entry that does not parse is skipped.+    pub fn from_spec(spec: &serde_json::Value) -> Vec<PlaneUnder> {+        let v = spec.get("planeUnder").cloned().unwrap_or(serde_json::Value::Null);+        let items = match v { serde_json::Value::Array(a) => a, serde_json::Value::Object(_) => vec![v], _ => Vec::new() };+        items.into_iter().filter_map(|e| serde_json::from_value(e).ok()).collect()+    }+}+ #[cfg(test)] mod tests {     use super::*;@@ -82,4 +108,12 @@ mod tests {         assert_eq!(Rules::from_spec(&json!({"track":0.4,"stub":0.1})).stub,0.4);         assert_eq!(Rules::from_spec(&json!({"track":0.4,"stub":0.2,"minTrackWidth":0.15})).stub,0.2);     }+    #[test] fn plane_under_reads_an_array_or_one_object() {+        let r = Rules::from_spec(&json!({"planeUnder":[{"layer":"B.Cu","net":"GND","around":["U1","C2"],"margin":1.0},{"layer":"B.Cu","net":"GND","polygon":[[0,0],[1,0],[1,1]]}]}));+        assert_eq!(r.plane_under.len(), 2);+        assert_eq!(r.plane_under[0].around, vec!["U1".to_string(), "C2".to_string()]);+        assert_eq!(r.plane_under[1].polygon.len(), 3);+        assert_eq!(Rules::from_spec(&json!({"planeUnder":{"layer":"F.Cu","net":"GND","around":["U1"]}})).plane_under.len(), 1);+        assert!(Rules::from_spec(&json!({})).plane_under.is_empty());+    } }
crates/aiflow-run/src/lib.rs+66−1
@@ -94,6 +94,43 @@ impl Run {         Ok(r)     } +    /// A run that starts at the prompt, before any board exists: the clock runs, the `intake` step+    /// is open, and `start --board` attaches the board later on the same clock and ledger.+    pub fn create_intake(dir: &Path, prompt_time: &str, ai_thread: &str, engine: Option<&str>, brief: Option<&str>, requirements: Option<&str>, target: Option<&str>) -> Result<Run, String> {+        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;+        let mut r = Run { dir: dir.to_path_buf(), data: json!({+            "version": 1, "tool": "adom-aiflow", "engine": engine, "aiThread": ai_thread, "board": null, "spec": null,+            "target": target, "remoteBoard": null, "intake": {"at": now(), "brief": brief, "requirements": requirements},+            "requirements": requirements,+            "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": null, "deliveredTime": null},+            "plan": null, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [],+            "outcomes": {}, "summary": null,+            "currentStep": null, "steps": {}, "turns": [], "delivery": null,+        }) };+        r.save()?;+        r.log("start", json!({"engine": engine, "aiThread": ai_thread, "board": null, "spec": null, "promptTime": prompt_time, "target": target, "intake": true, "brief": brief, "requirements": requirements}));+        r.step_set("intake", false, None);+        r.save()?;+        Ok(r)+    }++    /// True for a run made by `intake` that has no board yet.+    pub fn board_pending(&self) -> bool {+        self.data["boards"].as_array().map(|a| a.is_empty()).unwrap_or(true)+    }++    /// Attach the first board (and its spec) to an intake run: the clock, the steps and the+    /// ledger are kept; only the board, the spec and the desktop target are filled in.+    pub fn attach_board(&mut self, board: &str, spec: &str, engine: &str, target: Option<&str>, remote_board: Option<&str>) {+        self.data["board"] = json!(board);+        self.data["boards"] = json!([board]);+        self.data["spec"] = json!(spec);+        self.data["engine"] = json!(engine);+        if target.is_some() { self.data["target"] = json!(target); }+        self.data["remoteBoard"] = json!(remote_board);+        self.log("board-attached", json!({"engine": engine, "board": board, "spec": spec, "target": self.data["target"], "remoteBoard": remote_board, "promptTime": self.data["clock"]["promptTime"]}));+    }+     /// Append one event to run.jsonl, the append-only ledger: one line per event, never rewritten.     /// run.json is the derived state; when the two disagree, the ledger is the record.     pub fn log(&self, event: &str, mut fields: Value) {@@ -230,7 +267,10 @@ impl Run {         let first = c.split_whitespace().next().unwrap_or("");         let second = c.split_whitespace().nth(1).unwrap_or("");         match (first, second) {-            ("start", _) | ("plan", _) | ("take", _) => "intake",+            ("start", _) | ("plan", _) | ("take", _) | ("netlist", _) | ("rules", _) | ("intake", _) => "intake",+            ("molecule", _) => "placement",+            ("sourcing", _) => "sourcing",+            ("evidence", _) => if c.contains("simulation") || c.contains("vendor-model") { "simulation" } else { "design" },             ("place", _) => "placement",             ("route", _) | ("gate", _) => "routing",             ("land", "moves") => "placement",@@ -591,6 +631,31 @@ mod tests {         std::fs::remove_dir_all(dir).unwrap();     }     #[test]+    fn intake_run_keeps_its_clock_and_ledger_when_the_board_arrives() {+        let dir = std::env::temp_dir().join(format!("aiflow-intake-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        let mut r = Run::create_intake(&dir, "2026-09-29T00:39:00Z", "t", Some("claude"), None, None, None).unwrap();+        assert!(r.board_pending());+        assert_eq!(r.data["currentStep"], "intake");+        r.step_set("sourcing", false, None);+        r.save().unwrap();+        let mut r = Run::open(&dir).unwrap();+        r.attach_board("/x/board-0.kicad_pcb", "/x/spec.json", "claude", Some("box"), Some("C:/b.kicad_pcb"));+        r.save().unwrap();+        let r = Run::open(&dir).unwrap();+        assert!(!r.board_pending());+        assert_eq!(r.current_board(), "/x/board-0.kicad_pcb");+        assert_eq!(r.data["clock"]["promptTime"], "2026-09-29T00:39:00Z");+        assert_eq!(r.data["steps"]["intake"]["visits"].as_array().unwrap().len(), 1);+        assert_eq!(r.data["steps"]["sourcing"]["visits"].as_array().unwrap().len(), 1);+        let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();+        assert_eq!(events.iter().filter(|e| *e == "start").count(), 1);+        assert!(events.contains(&"board-attached".to_string()));+        assert_eq!(Run::infer_step("sourcing check --bom b.csv"), "sourcing");+        assert_eq!(Run::infer_step("evidence add --kind simulation --file s.json"), "simulation");+        std::fs::remove_dir_all(dir).unwrap();+    }+    #[test]     fn iso_round_trips() {         assert_eq!(iso(0), "1970-01-01T00:00:00Z");         let t = 1_789_299_600; // some 2026 second
docs/release-0.1.37.mdadded+25
@@ -0,0 +1,25 @@+# AI Flow 0.1.37++Every open gap from the buck molecule run, closed in the binary. Seven new commands (`intake`, `sourcing`, `evidence`, `clips`, `netlist`, `molecule`, `rules`); no existing command changed its interface.++| gap | what changed |+|---|---|+| 1, the clock needs a board | `intake --prompt-time` opens a run before any board exists; `start --board` on it attaches the board and keeps the same clock and ledger. Pre-board steps (intake, sourcing, design, simulation, schematic) are ordinary steps |+| 3, 4, 5, sourcing | `sourcing check --bom <csv>`: every board reference in the BOM with an MPN, sources that fit the profile (`fab`: Mouser or Adom stock, no LCSC/JLCPCB parts; `jlcpcb`: LCSC numbers), a dated stock check, thin stock warned. finish requires it when the spec names a profile |+| 6, 7, 12, design and simulation evidence | `evidence add --kind calcs|simulation|vendor-model`: recorded with its sha256; a failing simulation is refused; finish requires the kinds in `requireEvidence`. Vendor PSpice/LTspice runs come from adom/adom-spice-skillpack |+| 8, netlist equivalence | `netlist check`: the schematic's netlist (kicad-cli, local or through `adom-aiflow-kicad-cli-remote`, which now exports netlists too) against the board pin by pin; `start --sch` makes finish require it |+| 9, molecule format | `molecule check` (also inside place check, gate and finish when the spec has `molecule`): machine pins, MP1 front-left, grid, contacts, edge margin, fixed references |+| 10, fab rules | `fab.rules` in the spec; `rules install --profile jlcpcb` ships JLCPCB's 2-layer rules; the 3rd party fab's rules stay private (the project's own .kicad_dru); gate and finish refuse a mismatch |+| 20, models right way up | `models` checks each model's bounding box after its rotate/offset (step2glb features, or the STEP's own points) and flags a flat chip standing on edge or a body off the board; finish refuses open warnings (`models --ack`) |+| 23, land route resume | already-landed traces are skipped (same net, same points); a lost reply re-reads the board and retries that one trace once; `--from <n>` |+| 25, layer policy | `planeUnder`: a region of one layer kept for one net's plane; the router keeps other nets and their vias out, and the gate reports any other-net copper inside |+| 26, series chains | `chains`: pack lays a chain on one line with shared-net pads facing; rotation choice scores pad adjacency |+| 29, via on the wrong net | vias are checked against other nets' copper before landing and read back after; a via that landed on another net is taken back and reported |+| 30, live clips page | `clips --page <owner/slug> --push` builds and pushes docs/clips.md; once a page is set, every clip stop does it |+| 31, tour fields on localhost | a localhost Fields URL is filmed on the user's own machine by default; `--on` overrides |+| 33, adopted boards | `route` reads the board's own tracks, vias and zone fills, plans only what is still unconnected, and writes a valid empty plan when nothing is; gate, land route and finish accept it |+| 35, clip pulls | every clip stop checks the file arrived, retries the pull, and flags `pullFailed` with the desktop path; recut, finish and deliver pull those again |++Gaps 16, 17 and 32 are bridge bugs, filed on adom/pup-bridge (#127), adom/adom-bridge (#220) and adom/kicad-bridge (#104).++Acceptance: workspace tests pass (70). On the buck molecule: `sourcing check` passes (30 references), `molecule check` passes, `netlist check` passes (58/58 pins, KiCad 10 through the remote wrapper), `route` on the finished board says nothing to route, and the gate's new `planeUnder` check finds an EN via and a VOUT sense trace on B.Cu under the input hot loop that the old flow let through.
docs/spec-example.json+46−3
@@ -1,6 +1,5 @@ {   "board": "ESC G431 (Adom molecule: STM32G431, DRV8300, six BSC016N06NS, TPSM365 buck, INA181 current sense), 64 x 74 mm, 4 layers",-  "fab": "3rd party fab (copper ablation: keep as much copper as the electrical rules allow)",   "copperUm": 35,   "clearance": 0.2,   "edgeClearance": 0.5,@@ -81,6 +80,29 @@     "U6.4": "R32.1"   },   "kelvinNote": "INA181 IN- (pad 3) taps the shunt's GND pad, IN+ (pad 4) taps its GND_OUT pad; dedicated traces, no plane stub, pour keepouts along them",+  "planeUnder": [+    {+      "layer": "B.Cu",+      "net": "GND",+      "around": [+        "U6",+        "R32"+      ],+      "margin": 1.0,+      "name": "GND under the current sense"+    }+  ],+  "planeUnderNote": "layer policy: B.Cu under the sense amplifier and its shunt belongs to GND. The router treats each region as a keepout for every other net on that layer (and for other nets' vias); the gate reports any other-net track, via or zone inside it. \"around\" takes parts or pads (the pad box grown by margin, default 1.0 mm); \"polygon\": [[x, y], ...] gives the region exactly. On a 2-layer power board list the hot loop: the IC and its input caps.",+  "chains": [+    {+      "refs": [+        "R1",+        "R2"+      ],+      "direction": "down"+    }+  ],+  "chainsNote": "series chains for place pack, in electrical order (a divider: VOUT-R1-FBM-R2-FB). pack lays the members on one line from the first member's wish, on one axis, each turned so the pads on the shared net face each other, and reports which chains were honoured. A plain [\"R1\", \"R2\"] takes the direction from the first to the last wish. Shape only here: list this board's own divider parts.",   "loads": {     "+VBAT": {       "amps": 20,@@ -549,5 +571,26 @@   "poursNote": "Phase current flows on F.Cu (source pads, low-side tab and connector are all F.Cu); the B.Cu phase zones are heat spreaders under the low-side tabs, fed by thermal vias. The +VBAT B.Cu block keeps a column under the high-side tabs and a bypass strip right of the FET column so Q1's tab copper stays connected past the Q4 tab bridge.",   "hotNote": "BSC016N06NS at 20 A phase current: I2R at 3 mOhm hot (Rds(on) 1.6 mOhm typ at 25 C) over one third of the electrical cycle (six-step) is 0.4 W, plus about 0.2 W of switching at 24 kHz: 0.6 W per FET. U1 is a linear regulator on the +VBAT tab: 0.5 W.",   "hotMaxRiseC": 60,-  "hotMaxRiseNote": "the tab may sit 60 C above ambient at the stated dissipation (theta from the 40 C/W per square inch outer-copper heuristic of 0.1)"-}+  "hotMaxRiseNote": "the tab may sit 60 C above ambient at the stated dissipation (theta from the 40 C/W per square inch outer-copper heuristic of 0.1)",+  "fabNote": "3rd party fab (copper ablation: keep as much copper as the electrical rules allow)",+  "fab": {+    "rules": "fab",+    "target": "fab"+  },+  "fabRulesNote": "fab.rules picks the DRC rules profile: \"fab\" = the project's own .kicad_dru for the 3rd party fab (private, never shipped), \"jlcpcb\" = `rules install --profile jlcpcb` copies the shipped JLCPCB 2-layer rules; gate and finish refuse a mismatch. fab.target (or sourcing.profile) picks the sourcing profile for `sourcing check`.",+  "sourcing": {+    "profile": "fab"+  },+  "molecule": {+    "pin": "medium",+    "gridMm": 2,+    "edgeMarginMm": 2,+    "cornerPinsNet": "GND"+  },+  "moleculeNote": "present when the board is an Adom molecule: `molecule check` (and place check, gate, finish) require MP1 to MP4 machine pins with MP1 front-left, the pin rectangle and every contact on gridMm from MP1 (4 mm multiples warned for LrgMed scaffolds), edgeMarginMm to the outline, and fixedRefs where the start board had them; pinPadMm/pinDrillMm override the pin size.",+  "requireEvidence": [+    "calcs",+    "simulation"+  ],+  "requireEvidenceNote": "kinds `evidence add` must have recorded before finish: calcs (the design equations), simulation (a failing result is refused), vendor-model (a PSpice/LTspice run from adom/adom-spice-skillpack)."+}\ No newline at end of file
flows/board.json+1
@@ -59,6 +59,7 @@       "record": "Native board 3D inspection and model-check evidence; the separate library-tour step reviews each selected component.",       "workflow": [         "Use the components register and its reviewed plain or explicitly selected marked variants; fix portable model paths, run kicad_model_check, then inspect the native board render. Do not substitute a model merely to make the missing-file gate pass.",+        "`models` also checks every bound model is right way up (step2glb bbox after the footprint's rotate/offset): a flat SMD chip whose thinnest axis is not Z, or an SMD body whose bottom is off the board, is a warning to fix in the binding and verify in the native 3D viewer, or to acknowledge with `models --ack <refs> --why ...`; finish refuses open ones. Never adom-chipsmith.",         "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",         "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."       ]
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.36",+  "version": "0.1.37",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.36",+  "version": "0.1.37",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
rules/jlcpcb-2L.kicad_druadded+65
@@ -0,0 +1,65 @@+(version 1)++# adom-aiflow rules profile: jlcpcb-2L+# JLCPCB standard 2-layer (1 oz outer copper, 1.6 mm FR4) as KiCad custom rules, installed with+#   adom-aiflow rules install --profile jlcpcb+# Source: JLCPCB PCB capabilities, https://jlcpcb.com/capabilities/pcb-capabilities (read 2026-09-29).+# The page's absolute minimums are quoted beside each rule; this profile uses JLCPCB's long-standing+# standard values above them (5/5 mil tracks, 0.3 mm drills, 0.6 mm vias), so a board passing it+# needs no small-feature upgrade. Tighten a rule only when the order pays for the upgrade.++# Tracks and spacing. Page: "Min. track width and spacing (1 oz): 0.10 / 0.10 mm (4 / 4 mil)"+# for 1-2 layers; 2 oz is 0.16 / 0.16 mm.+(rule "jlcpcb track width"+  (layer outer)+  (condition "A.Type == 'Track'")+  (constraint track_width (min 0.127mm)))++(rule "jlcpcb clearance"+  (constraint clearance (min 0.127mm)))++# Drills. Page: "Min. Via hole size/diameter: 0.15/0.25mm"; 0.1 and 0.15 mm holes "cost more".+(rule "jlcpcb drill"+  (constraint hole_size (min 0.3mm)))++# Non-plated holes. Page: "Min NPTH size: 0.50mm".+(rule "jlcpcb npth"+  (condition "A.Type == 'Pad' && !A.isPlated()")+  (constraint hole_size (min 0.5mm)))++# Vias. Page: via diameter "0.1mm (0.15mm preferred) larger than Via hole size".+(rule "jlcpcb via diameter"+  (condition "A.Type == 'Via'")+  (constraint via_diameter (min 0.6mm))+  (constraint annular_width (min 0.15mm)))++# Plated through-hole pads. Page: "PTH annular ring (1 oz): recommended 0.25 mm or above;+# absolute minimum 0.18 mm".+(rule "jlcpcb pth annular ring"+  (condition "A.Type == 'Pad' && A.isPlated()")+  (constraint annular_width (min 0.18mm)))++# Hole to hole. Page: "Via Hole-to-Hole Spacing: 0.2mm", "Pad Hole-to-Hole Spacing: 0.45mm".+(rule "jlcpcb via hole to hole"+  (constraint hole_to_hole (min 0.2mm)))++(rule "jlcpcb pad hole to hole"+  (condition "A.Type == 'Pad' && B.Type == 'Pad'")+  (constraint hole_to_hole (min 0.45mm)))++# Copper to board edge. Page: copper clearance from routed edges ">= 0.2 mm", from V-cut edges+# ">= 0.4 mm". 0.3 mm keeps margin on routed edges. The page gives no separate hole-to-edge+# figure; plated holes carry copper, so this rule covers them.+(rule "jlcpcb copper to edge"+  (constraint edge_clearance (min 0.3mm)))++# Silkscreen. Page: "Min line width >= 0.15mm", "Min text height 40 mil (1.0mm)".+(rule "jlcpcb silk text"+  (layer "F.SilkS")+  (constraint text_thickness (min 0.153mm))+  (constraint text_height (min 1.0mm)))++(rule "jlcpcb silk text back"+  (layer "B.SilkS")+  (constraint text_thickness (min 0.153mm))+  (constraint text_height (min 1.0mm)))
skills/adom-aiflow/SKILL.md+30−4
@@ -17,17 +17,32 @@ Every state-changing command takes `--ai-thread "<your thread name>"`. Every run The flow is a file, `flows/board.json`: the steps in order (intake, models, placement, routing, pours, current, thermal, nets, 3d, capture, finish), 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.  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. Pours, gate, landing, measurement, both analyses and the finish are the binary's.+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>] [--sch design.kicad_sch]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. `--sch` records the schematic the board came from, so `finish` requires a passing `netlist check`.+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>] [--page <owner/slug>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock. 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`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself. Pours, gate, landing, measurement, both analyses and the finish are the binary's. 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it. 4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. 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 or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, 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. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+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.-8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors. 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`.+8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.++## Before the board: intake, sourcing, evidence++The clock starts at the prompt even when there is no board yet.++- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each records a clip of the desktop until a PCB editor is open. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff.+- **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result.+- **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.  ## The fields step (adom-fields) -After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.+After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.  ## Time: sessions, not the wall clock @@ -53,6 +68,15 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and `  `docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable. +## Design gates (schematic, molecule, fab rules, 3D models)++Deterministic checks that refuse, each an OK/ERROR with hints:++- **`netlist check [--sch design.kicad_sch] [--board B] [--net exported.net]`**: exports the schematic's netlist with kicad-cli (`sch export netlist --format kicadsexpr`, through `ADOM_AIFLOW_KICAD_CLI`; `adom-aiflow-kicad-cli-remote` does it on a desktop's KiCad 10 and carries the sub-sheets, lib tables and `${KIPRJMOD}` symbol libraries) and compares it pin by pin with the board's pads: missing and extra references, and every pin whose net differs (`/VIN` equals `VIN`; unlabelled `Net-(...)` nets compare by their pins). In a run it is recorded; `finish` re-checks the stored netlist against the board it finishes on and asks again when the schematic changed.+- **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional).+- **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not.+- **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith.+ ## The rules behind the stages  The judgement is in these skills, which this tool executes: kicad-place-route-loop (place for routability, the levers when routing cannot close, go back to placement), kicad-copper-pours (which nets get a pour and which never do, priorities, the Kelvin pair, the preflight lessons), and the tool-neutral eda-engineering skillpack (eda-end-to-end-layout, eda-kelvin-current-sense, eda-copper-ablation, eda-pour-planning-measurement, eda-thermal-bottlenecks, eda-multilayer-current-review).@@ -85,6 +109,8 @@ The number is the human's prompt to your "done, here is your video" (`deliver`),  `report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown. +The live clips page is the binary's too: `clips --page <owner/slug>` rebuilds `docs/clips.md` in `<run>/clips-page` (a table of every clip with its step, visit, time, links and suspect state, then one block per clip: the 10x cut as a video, the action cut, the contact sheet, and the note from `<run>/clip-notes.json`) and says what it would push; `--push` pushes it and the changed clip media with `adom-wiki repo push --files`. Once the run has a page (`start --page`, `intake --page`, `report --page` or `clips --page`), every clip stop rebuilds and pushes it by itself, best effort (a failed push is said, never a failed step). It refuses to push a page with an em-dash or a match of the regex in `$AIFLOW_BANNED_RE`. Open the rendered page for the human once and link it from the README (aiflow-live-clips).+ ## Give it back  adom-aiflow is the token cache of every Adom user's flow. When the binary was wrong, rigid or missing a step (an RF board that needs length-matched differential pairs and impedance checks, say), or your own code did a step better, file it: `adom-wiki issue create adom/adom-aiflow ...` or a PR against the repo, then `adom-aiflow giveback <url>` so the run counts it. Adom processes PRs within minutes during business hours (a non-stop PR intake), and `deliver` reports how many issues and PRs the run's AI gave back.@@ -92,11 +118,11 @@ 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). `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. 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 -- The clock starts at the prompt. A late `start` takes `--prompt-time`.+- The clock starts at the prompt. A late `start` takes `--prompt-time`; work before the board starts with `intake --prompt-time`. - A stage you take yourself is stamped by you and named in the manifest as `ai`; the binary's stages are `binary`. Both go on the chart. - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not).
skills/aiflow-circuit-design/SKILL.md+1−1
@@ -80,7 +80,7 @@ Each `note` in calcs.json says what the value does, why this one, and what would  ## 6. Hand off -`design/calcs.json`, `design/bom.csv` with notes, and a list of values the simulation must confirm (feed-forward cap, output capacitance, anything chosen against the equation). Next: aiflow-simulate.+`design/calcs.json`, `design/bom.csv` with notes, and a list of values the simulation must confirm (feed-forward cap, output capacitance, anything chosen against the equation). Record the calcs in the run: `adom-aiflow evidence add --kind calcs --file design/calcs.json` (sha256 in the ledger; `finish` wants it when the spec says `"requireEvidence": ["calcs", ...]`). Next: aiflow-simulate.  ## Worked example: TPS54202, 12 V to 5 V / 1 A (datasheet SLVSD26C) 
skills/aiflow-intake/SKILL.md+9−5
@@ -16,12 +16,16 @@ Write the UTC of the human's message down before anything else. Use the message' date -u +%Y-%m-%dT%H:%M:%SZ   # only when the prompt is this instant ``` -Today `adom-aiflow start` needs `--board` and `--spec`, so there is no run until a `.kicad_pcb` exists. Until the binary accepts a board-less start:+Open the run at the prompt, before any board exists: -- Keep the time in `requirements.json` as `prompt.utc`.-- Keep a `preBoard` list (phase, start UTC, end UTC, what it produced) as you go: intake, sourcing, circuit, simulate, schematic. The run page uses it to show where the pre-board hour went.-- When the board exists, start with that time, never the current one:-  `adom-aiflow --ai-thread "<thread>" start --board <b>.kicad_pcb --spec aiflow/spec.json --engine <you> --prompt-time <prompt.utc> --target <box> --remote-board <path on the box>`+```bash+adom-aiflow --ai-thread "<thread>" --run aiflow/run intake --prompt-time <prompt.utc> --brief <brief file> --requirements requirements.json --page <owner/slug> [--target <box>]+```++- The run opens with the `intake` step and no board. Declare each pre-board step as you move: `step sourcing`, `step design`, `step simulation`, `step schematic`. They are steps like any other: turns charged to them, clips (of the desktop, with `--target`) and the clips page.+- Keep the time in `requirements.json` as `prompt.utc` too; `--requirements` records the file, and its `fab.target` picks the sourcing profile for `sourcing check`.+- When the board exists, attach it on the same run: `adom-aiflow --ai-thread "<thread>" --run aiflow/run start --board <b>.kicad_pcb --spec aiflow/spec.json --engine <you> --target <box> --remote-board <path on the box>`. The clock stays at the prompt; `--prompt-time` is ignored there.+- A run that started without `intake` still takes `start --prompt-time <prompt.utc>`, never the current time. - Never subtract pre-board time and never start late to look faster (aiflow-measurement). - Follow-up prompts on the same run are `prompt --text "..." [--at <paste time>]`; close each answer with `done --message "..."`. 
skills/aiflow-live-clips/SKILL.md+6−12
@@ -12,9 +12,9 @@ This is your job after every step, not at the end. Never batch it.  ## 1. Once, at the start -- Pick the page: the project's own wiki page (`<owner>/<project-slug>`), the same one `report --page` pushes to.-- Clone it to a staging folder in your scratchpad (`adom-wiki repo clone <owner>/<slug>`).-- Make the first push (an empty `docs/clips.md` with the header and a link from the README's feature-guides table; wiki-sub-readme).+- Pick the page: the project's own wiki page (`<owner>/<project-slug>`), the same one `report --page` pushes to. Give it to the run once (`intake --page`, `start --page` or `adom-aiflow clips --page <owner>/<slug> --push`). From then on every clip stop rebuilds and pushes `docs/clips.md` by itself.+- `clips --page ...` without `--push` is the dry run: it builds the page in `<run>/clips-page` and lists what it would push.+- Link `docs/clips.md` from the README's feature-guides table once (wiki-sub-readme); the binary pushes only the clips page and its media, never the README. - Open the rendered page for the human ONCE, in the surface they use (a pup window on their machine, or a Hydrogen webview tab), and tell them the URL in one line: `https://wiki.adom.inc/<owner>/<slug>/files/docs/clips.md`. Do not reopen it at every push; the page reloads.  ## 2. After every step@@ -31,28 +31,22 @@ When a clip stops (`step <next>`, `capture stop`, `finish`, `deliver`, or the cl    }    ```    `state` is `ok` or a sentence: a false blank flag and why it is false, a guard stop and what was lost, a missing cut.-3. **Rebuild and push** the page:-   ```bash-   python3 tools/clips_subreadme.py <run dir> <owner/slug> <staging dir>-   ```-   The script (in `buck-12v5v-molecule/tools/clips_subreadme.py`) rebuilds `docs/clips.md` from the run folder and the notes: a table (number, step, time, link, state), then one block per clip with its note, the 10x cut as a `<video>`, the action cut, the contact sheet and the raw file's size (raw recordings stay with the run, not uploaded). It copies the media into `docs/clips/`, adds the README link once, scrubs the payload and pushes with `adom-wiki repo push --files`.+3. **Push the note**: the clip's own stop already pushed the page; after you write the note, `adom-aiflow clips --push` pushes it again with the note in. The page is rebuilt from the run: a table (number, step, visit, time, links, state), then one block per clip with its note, the 10x cut as a `<video>`, the action cut, the contact sheet and the raw file's size (raw recordings stay with the run, not uploaded). A clip whose recording never reached the run is listed as `missing`. Only changed media go up. 4. **Verify by looking**: reload the rendered page and check the new clip plays and the sheet shows. A successful push is not a working page. -Wrap the push in `adom-aiflow exec -- python3 tools/clips_subreadme.py ...` so its time is measured.- ## 3. Mechanics that bite  - **Publish with `adom-wiki repo push --files`**, not the files blob API (it does not update the git repo the Files tab reads). - **Media paths are repo-root-relative**, even inside `docs/clips.md`: `docs/clips/<stem>-10x.mp4`, `docs/clips/<stem>-sheet.png`. The files viewer prepends `files/` to the ref as written; a path relative to the doc's own folder (`clips/x.mp4`) points at the wrong place. Use the same form for plain links to the action cuts. - **Doc links are relative** (`docs/clips.md` from the README), never absolute URLs. A plain link (not an image or a video `src`) resolves from the doc's own folder, so from `docs/clips.md` the action cut is `[...](clips/<stem>-action.mp4)`; checked on the rendered page, it lands on `files/docs/clips/...`. - **Push only what changed** plus `docs/clips.md`; pushing the whole run folder is slow and bloats the page.-- **Scrub before every push**: the script refuses a payload that names the 3rd party fab's product or carries an em-dash. Say "the 3rd party fab".+- **Scrub before every push**: `clips` refuses a page with an em-dash, or with a match of the regex in `$AIFLOW_BANNED_RE` (set it to the confidential names). Say "the 3rd party fab". - **Rate limits**: one push per step is fine; do not push in a loop.  ## 4. Overnight runs  - More steps, more clips: keep the per-step rhythm; the guard stops any clip past twice its budget, and a guard stop still gets a note and a push.-- If a push fails, the next step's push carries both clips (the script rebuilds from the whole run folder). Say so in the note.+- If a push fails, the next clip stop's push carries both clips (the page is rebuilt from the whole run). Say so in the note. - At `deliver`, add the composed video's link at the top of `docs/clips.md` and push once more. - In the morning the page should read as a story: step, what happened, why it went back, and the fix, one clip at a time. 
skills/aiflow-molecule/SKILL.md+3−3
@@ -33,18 +33,18 @@ Put the interface in netlist.json as `fixed` positions (aiflow-schematic-to-boar  ## 3. Fab-rules profile -The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in it. The profile comes from `fab.target` in requirements.json:+The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in it. The profile comes from `fab.target` in requirements.json; write it into the aiflow spec as `"fab": {"rules": "fab"}` or `{"rules": "jlcpcb"}` and the gate and `finish` refuse a board whose project does not carry matching rules (`adom-aiflow rules check`):  | Profile | Rules file | |---|---| | `fab` (default) | the 3rd party fab 2-layer rules file shipped for the 3rd party fab (`rules/fab-2L.kicad_dru` in the project); design with margin above its minimums. Do not restate its process limits on public pages |-| `jlcpcb` | JLCPCB's published 2-layer capabilities as a `.kicad_dru` |+| `jlcpcb` | JLCPCB's published 2-layer capabilities, shipped with adom-aiflow: `adom-aiflow rules install --profile jlcpcb --board <design>.kicad_pcb` |  `build_board.py` copies the file beside the board; `start` carries `.kicad_pro` and `.kicad_dru` into the run; the gate needs a native kicad-cli for project rules (`ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote`). Put the copper weight you designed for in the spec (`copperUm`, with a note).  ## 4. Conformance check before placement -Run it yourself until the binary has a gate for it:+`adom-aiflow molecule check --board <design>.kicad_pcb --spec aiflow/spec.json` (in a run: `molecule check`; `place check`, `gate` and `finish` run it by themselves when the spec has `"molecule": {...}`). Put the interface in the spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "fixed": {"J1": [0, 12], ...}}` (mm from MP1, y up). It checks:  - MP1 to MP4 present, references exact, `MachinePinMediumShort`, at the four corners of the span, all on GND. - Every contact is `MachineContactMedium`, on the 2 mm grid from MP1.
skills/aiflow-power-layout/SKILL.md+10−6
@@ -1,7 +1,7 @@ --- name: aiflow-power-layout description: >--  Layout judgement for small 2-layer power boards in an adom-aiflow run (bucks, LDOs, load switches on molecules): write the spec as the electrical judgement, keep B.Cu an unbroken ground under the hot loop (the router has no layer policy yet, so check and hand-rework FB and VOUT), give series chains one rotation, route Kelvin taps on their own layer, make SW a small solid patch, put thermal vias beside small pins not in the pad, iterate the current-density necks with Fields (215 to 50 A/mm2 here), reason about a still-air thermal budget on a small board, use the silkscreen text verb correctly, and resume `land route` by revision after a dropped reply. Trigger words: power layout, buck layout, hot loop, ground plane under the hot loop, layer policy, FB routing, Kelvin sense, SW node, switch node copper, thermal vias SOT-23, current density neck, Fields, still air, thermal budget, silkscreen text batch, kicad_silk_text_batch, land route resume, dropped reply, route_live.+  Layout judgement for small 2-layer power boards in an adom-aiflow run (bucks, LDOs, load switches on molecules): write the spec as the electrical judgement, keep B.Cu an unbroken ground under the hot loop (spec planeUnder, which the router keeps and the gate checks), give series chains one rotation (spec chains), route Kelvin taps on their own layer, make SW a small solid patch, put thermal vias beside small pins not in the pad, iterate the current-density necks with Fields (215 to 50 A/mm2 here), reason about a still-air thermal budget on a small board, use the silkscreen text verb correctly, and resume `land route` by revision after a dropped reply. Trigger words: power layout, buck layout, hot loop, ground plane under the hot loop, layer policy, FB routing, Kelvin sense, SW node, switch node copper, thermal vias SOT-23, current density neck, Fields, still air, thermal budget, silkscreen text batch, kicad_silk_text_batch, land route resume, dropped reply, route_live. ---  # aiflow-power-layout: small power boards, two layers@@ -23,15 +23,19 @@ Write `aiflow/spec.json` from the schematic before `start`, with a note on every  - Input caps, IC VIN and IC GND form the hot loop: smallest possible loop on F.Cu, the high-frequency cap closest to the pins. - Inductor right after SW; output caps right after the inductor; the feedback network on the quiet side, away from SW.-- **Series chains get ONE rotation.** `place pack` picks 90/270 per part independently and can knot a chain: R1/R2/C9 came out VOUT-bottom, FBM-top, forcing FBM to loop round and a via into an 0402 pad. Turned 180 together, the column read VOUT, R1, FBM, R2, FB top to bottom and FBM became a 1 mm straight link. Give chains a rotation in the wish, then `place check --moves` and `place land --moves`.+- **Series chains get ONE rotation.** Before `spec.chains`, `place pack` picked 90/270 per part independently and knotted a chain: R1/R2/C9 came out VOUT-bottom, FBM-top, forcing FBM to loop round and a via into an 0402 pad. Turned 180 together, the column read VOUT, R1, FBM, R2, FB top to bottom and FBM became a 1 mm straight link. Now declare the chain in electrical order, `"chains": [{"refs": ["R1", "R2", "R3"], "direction": "down"}]` (VOUT-R1-FBM-R2-FB-R3-GND), give every member a wish, and `place pack` lays them on one line from the first member's wish, each turned so the shared-net pads face (a part numbered the other way round turns 180), and reports `chains: N of M honoured` with the facing pads. A chain not honoured says why (a member with no spot, no wish, or no common net). Then `place check --moves` and `place land --moves`.  ## 3. Routing: keep B.Cu a ground plane under the hot loop -The grid router treats B.Cu as free space. On the buck it put 17.5 mm of VOUT and 2.8 mm of FB under the hot loop, and the VOUT Kelvin sense sliced the bottom plane under the input caps. There is no spec field yet to forbid it, so after `route`:+Without a layer policy the grid router treats B.Cu as free space: on the buck it put 17.5 mm of VOUT and 2.8 mm of FB under the hot loop, and the VOUT Kelvin sense sliced the bottom plane under the input caps. Declare the plane: -1. Look at B.Cu under the hot loop and under SW. Any non-GND copper there is a defect.-2. Take those nets yourself (`take route=ai`, `step routing --back --why "..."`): FB on F.Cu inside the quiet side; the VOUT sense along an edge, not across the plane.-3. Land hand routes one at a time with a dry run first: `tools/route_live.py routes.json` calls `kicad_route_net` with `dryRun`, commits only on 0 DRC errors, and chains `expectedRevision` from each reply.+```json+"planeUnder": [{"layer": "B.Cu", "net": "GND", "around": ["U1", "C2", "C3", "C4"], "margin": 1.0}]+```++`around` is the pad box of the listed parts or pads grown by `margin` (default 1.0 mm); `polygon: [[x, y], ...]` gives the region exactly. The router treats each region as a keepout for every other net on that layer and for other nets' vias, and says so in its hints; the gate fails on any other-net track, via or zone inside a region (`planeUnderIssues` in gate.json) and `finish` refuses until it is clear.++A region is a hard wall: a pin of another net inside it (the IC's EN or FB pin under U1) can only leave on the other layer, and a Kelvin sense may find no path. When `route` closes short with a region in place, shrink the margin, list fewer parts, or draw a polygon that leaves those pins a way out, before taking the nets by hand. If you do route by hand (`take route=ai`, `step routing --back --why "..."`): FB on F.Cu inside the quiet side, the VOUT sense along an edge, not across the plane, and land hand routes one at a time with a dry run first (`tools/route_live.py routes.json` calls `kicad_route_net` with `dryRun`, commits only on 0 DRC errors, and chains `expectedRevision` from each reply). After `adopt-board` of the reworked board, `route` keeps that copper and plans only what is still open (an empty plan when nothing is), and the gate checks the regions on it.  Kelvin keepouts follow the tap's own layer (both layers only at its vias) and stop short of the power pin, so the IC GND pin keeps its pour (fixed in adom-aiflow 0.1.33; check older runs). 
skills/aiflow-scaffold-probe/SKILL.md+7
@@ -80,6 +80,13 @@ Write `probe/plan.json` and a readable table in the README. Each test point in t  Each test gets: the setup (PS1 voltage and current limit, load, what GPIO does), the probe points, the expected value with its source (calcs.json, the ngspice result, the vendor model) and the pass band. Include the checks the design left open for the bench (the load step the model disagreed on, the UVLO thresholds). Probe ripple with the shortest ground you can; a long ground lead measures itself. +## Getting the molecules into Hydrogen++- Import the published molecule: `ADOM_HYDROGEN_URL=https://hydrogen.adom.inc adom-molecule import --owner <you> <owner>/<slug>`. From an Adom container, set that variable: the container's local proxy (127.0.0.1:63548) answers 404 for every molecule route.+- The layout's `sku` is `<owner>/<molecule name>/v1` exactly as Hydrogen lists it (`adom-molecule list --owner <you>`).+- A LrgMed 64 x 64 scaffold exists as `john/Scaffold LrgMed 64x64` (made from the wiki's adom/scaffold-lrgmed-64x64 model, re-anchored at its front-left large pin). For another size, do the same: shift the wiki GLB so the FL large pin is the origin, and `adom-molecule create` it with a minimal KiCad project (outline plus MP1 to MP4); Hydrogen's create refuses a KiCad molecule without .kicad_pcb, .kicad_sch and .kicad_pro.+- Loading a layout into the 3D Editor has no API yet: it is one click for the human.+ ## 4. Hand off  The layout JSON, the contact mapping table, the probe plan, and a line in the run page's deliverables. If the human wants it live, the layout opens in Hydrogen and the probe plan drives the workcell.
skills/aiflow-schematic-to-board/SKILL.md+2−2
@@ -82,7 +82,7 @@ export ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote   # ships with adom-ai export KICAD_REMOTE_TARGET=<desktop> ``` -The remote wrapper ships the board with its `.kicad_pro` and `.kicad_dru` to the desktop's KiCad 10 through Adom Bridge, runs `kicad-cli pcb drc`, and pulls the JSON back. For ERC and netlist export, `send_files` the schematic, its lib tables and `lib/` to the desktop and run the same kicad-cli there with `shell_execute` (reason inside the JSON args; after a timeout check the process list before re-sending).+The remote wrapper ships the board with its `.kicad_pro` and `.kicad_dru` to the desktop's KiCad 10 through Adom Bridge, runs `kicad-cli pcb drc`, and pulls the JSON back. It also runs `sch export netlist` (the schematic, its sub-sheets, `.kicad_pro`, lib tables and the `${KIPRJMOD}` symbol libraries travel with it; about 2 s a round trip). For ERC, `send_files` the same set and run kicad-cli there with `shell_execute` (reason inside the JSON args; after a timeout check the process list before re-sending).  Pushing a footprint library: `send_files` to a destination ending in `.pretty` wrote a FILE named `x.pretty`. Send to a plain folder, then `move` it into place. @@ -94,7 +94,7 @@ 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.-- **Netlist equivalence**: every `REF.pad` in the same net on both sides (`sch.net` vs the board's pad nets), same net names, nothing extra or missing. Report it as a count (58/58 pins) and list any mismatch. This is a gate: no `start` until it passes.+- **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 
skills/aiflow-simulate/SKILL.md+6−1
@@ -70,7 +70,12 @@ Then write the design conclusion in engineering terms: what matched, what the ve  ## 8. Hand off -`sim/loop.json`, `sim/transient_*.json`, the PNGs, the validation note with the conclusion, and any value changes pushed back into calcs.py. Next: aiflow-schematic-to-board.+`sim/loop.json`, `sim/transient_*.json`, the PNGs, the validation note with the conclusion, and any value changes pushed back into calcs.py. Record them in the run:++- `adom-aiflow evidence add --kind simulation --file sim/<results>.json`. Write the verdict into the JSON so the gate can read it: a top-level `"pass": true|false`, or `"checks": [{"name": "phase_margin_deg", "value": 76, "limit": {"min": 45}, "pass": true}]` (a numeric `limit` is a maximum). A failing result is refused: change the design and simulate again.+- `adom-aiflow evidence add --kind vendor-model --file sim/<vendor>-validation.md` for the vendor PSpice/LTspice run, which belongs to the adom/adom-spice-skillpack.++`finish` wants simulation evidence when the spec says `"requireEvidence": ["calcs", "simulation"]`. Next: aiflow-schematic-to-board.  ## Worked example: TPS54202 (2026-09-29) 
skills/aiflow-sourcing/SKILL.md+10−1
@@ -1,7 +1,7 @@ --- name: aiflow-sourcing description: >--  Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile picks where parts come from (default "3rd party fab" = the 3rd party fab, Mouser plus Adom stocked basic parts, no JLCPCB parts; user-selectable "jlcpcb" = JLCPCB/LCSC basic parts), search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source.+  Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile picks where parts come from (default `fab` = the 3rd party fab, Mouser plus Adom stocked basic parts, no JLCPCB parts; user-selectable "jlcpcb" = JLCPCB/LCSC basic parts), search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source. ---  # aiflow-sourcing: parts that exist, CAD that is true@@ -54,6 +54,14 @@ U1,TPS54202DDCR,sync buck,TPS54202DDCR,Texas Instruments,SOT-23-6 (DDC),Mouser,5  Every reference in the netlist must have a BOM row, including contacts, machine pins and test pads (as "no part" rows). Diff the two before the schematic is frozen. +The binary gates this:++```bash+adom-aiflow --ai-thread "<thread>" sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty <boards>]+```++It reads the profile from the spec or requirements.json (`fab` by default), refuses a row without an MPN, a row from anywhere but Mouser or Adom stock on `fab` (and any LCSC/JLCPCB part there), a row without an LCSC number on `jlcpcb`, and a stock column without its check date; it warns on stock under twice the build quantity. With a board it diffs the BOM against every electrical reference (missing and extra); run it again after `start --board`, because `finish` requires the diffed pass when a profile is named. On the TPS54202 molecule the check found the contacts and machine pins (J1 to J6, MP1 to MP4) missing from the BOM.+ ## 4. Never write a package dimension without its source  Case sizes, heights and pad spans come from the manufacturer's page or drawing, quoted with where you read them, and nominal separated from maximum. Not from memory, not from a similar part.@@ -84,6 +92,7 @@ step2glb thumbnail part.step       # look at it - The bbox must match the drawing's body and the footprint. - Height on Z. When the height sits on Y (Y-up), the footprint's model binding needs a -90 degree X rotation. - Then look in KiCad's native 3D viewer. A rotation is not done until the part sits on its pads there.+- `adom-aiflow models` (or `models --offline`) repeats this for every model bound on the board: step2glb bbox, the footprint's rotate/offset applied, a warning when a flat chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding, or `models --ack <refs> --why "<what the 3D viewer showed>"`; `finish` refuses open warnings.  ## 7. Hand off 
tools/kicad-cli-remote+96−11
@@ -3,7 +3,10 @@ # through Adom Bridge. Implements what adom-aiflow needs (ADOM_AIFLOW_KICAD_CLI): #   kicad-cli-remote --version #   kicad-cli-remote pcb drc --format json --output <report.json> <board.kicad_pcb>-# The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept.+#   kicad-cli-remote sch export netlist --format kicadsexpr --output <out.net> <design.kicad_sch>+# The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The+# schematic travels with its sub-sheets, .kicad_pro, sym-lib-table and fp-lib-table, and every+# ${KIPRJMOD} library the sym-lib-table names, in the same folders relative to the project. # Env: KICAD_REMOTE_TARGET (default ConfRoomROG), KICAD_REMOTE_CLI (native kicad-cli path on it), #      KICAD_REMOTE_THREAD (ai-thread name for the bridge; default kicad-cli-remote). set -euo pipefail@@ -13,7 +16,97 @@ TH="${KICAD_REMOTE_THREAD:-kicad-cli-remote}" CLI="${KICAD_REMOTE_CLI:-}"  if [[ "${1:-}" == "--version" ]]; then echo "kicad-cli-remote -> $T (native KiCad via Adom Bridge)"; exit 0; fi-[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc' and '--version' are supported" >&2; exit 2; }++find_cli() {+  if [[ -z "$CLI" ]]; then+    CLI=$(adom-bridge --target "$T" kicad_status '{"reason":"find the native kicad-cli for adom-aiflow"}' --ai-thread "$TH" | python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""))')+    [[ -n "$CLI" ]] || { echo "kicad-cli-remote: no KiCad found on $T (set KICAD_REMOTE_CLI)" >&2; exit 2; }+  fi+}+find_tmp() {+  # A temp folder on the desktop: KICAD_REMOTE_TMP, else the desktop's %TEMP%.+  RTMP="${KICAD_REMOTE_TMP:-}"+  if [[ -z "$RTMP" ]]; then+    RTMP=$(adom-bridge --target "$T" shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an adom-aiflow kicad-cli job"}' --ai-thread "$TH" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("stdout","").strip().replace(chr(92),"/"))')+  fi+}+cleanup_remote() {+  adom-bridge --target "$T" shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the kicad-cli temp folder"}))' "$1")" --ai-thread "$TH" >/dev/null || true+}++if [[ "${1:-}" == "sch" && "${2:-}" == "export" && "${3:-}" == "netlist" ]]; then+  shift 3+  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then+    echo "Usage: kicad-cli-remote sch export netlist [--format kicadsexpr] --output FILE INPUT_FILE"+    echo "  --format  kicadsexpr (default), kicadxml, orcadpcb2, ... (passed to the desktop's native KiCad)"+    exit 0+  fi+  fmt=kicadsexpr; out=""; sch=""+  while [[ $# -gt 0 ]]; do+    case "$1" in+      --format) fmt="$2"; shift 2;;+      --output|-o) out="$2"; shift 2;;+      --*) shift;;+      *) sch="$1"; shift;;+    esac+  done+  [[ -f "$sch" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing schematic" >&2; exit 2; }+  find_cli; find_tmp+  job="aiflow-net-$(date +%s)-$$"+  dest="$RTMP/$job"+  base="$(basename "$sch" .kicad_sch)"+  # groups of files by folder relative to the project: {"": [...], "lib": [...]}+  groups=$(python3 - "$(readlink -f "$sch")" <<'PY'+import json, os, re, sys+root_sch = sys.argv[1]; proj = os.path.dirname(root_sch)+files, todo = set(), [root_sch]+while todo:+    s = todo.pop()+    if s in files or not os.path.isfile(s): continue+    files.add(s)+    text = open(s, encoding="utf-8", errors="replace").read()+    for m in re.finditer(r'\(property\s+"Sheet ?[Ff]ile(?:name)?"\s+"([^"]+)"', text):+        todo.append(os.path.normpath(os.path.join(os.path.dirname(s), m.group(1))))+stem = os.path.splitext(root_sch)[0]+for extra in (stem + ".kicad_pro", os.path.join(proj, "sym-lib-table"), os.path.join(proj, "fp-lib-table")):+    if os.path.isfile(extra): files.add(extra)+table = os.path.join(proj, "sym-lib-table")+if os.path.isfile(table):+    for uri in re.findall(r'\(uri\s+"([^"]+)"\)', open(table, encoding="utf-8").read()):+        if "${KIPRJMOD}" in uri:+            p = os.path.normpath(uri.replace("${KIPRJMOD}", proj))+            if os.path.isfile(p): files.add(p)+groups = {}+for f in sorted(files):+    rel = os.path.relpath(os.path.dirname(f), proj)+    if rel.startswith(".."): rel = ""+    groups.setdefault("" if rel == "." else rel.replace(os.sep, "/"), []).append(f)+print(json.dumps(groups))+PY+)+  while IFS= read -r line; do+    sub="${line%%$'\t'*}"; list="${line#*$'\t'}"+    d="$dest"; [[ -n "$sub" ]] && d="$dest/$sub"+    args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":json.loads(sys.argv[2]),"dest":sys.argv[1],"reason":"schematic netlist export for adom-aiflow (native kicad-cli on the desktop)"}))' "$d" "$list")+    adom-bridge --target "$T" send_files "$args" --ai-thread "$TH" >/dev/null+  done < <(python3 -c 'import json,sys;[print(k+"\t"+json.dumps(v)) for k,v in json.loads(sys.argv[1]).items()]' "$groups")+  winsch="$dest/$base.kicad_sch"; winnet="$dest/$base.aiflow.net"+  cmd=$(python3 -c 'import json,sys;cli,s,o,f=sys.argv[1:5];c=f"\"{cli}\" sch export netlist --format {f} --output \"{o}\" \"{s}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":180,"reason":"schematic netlist export for adom-aiflow"}))' "$CLI" "$winsch" "$winnet" "$fmt")+  res=$(adom-bridge --target "$T" shell_execute "$cmd" --ai-thread "$TH")+  tmp="$(mktemp -d)"+  adom-bridge --target "$T" pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the netlist back"}))' "$winnet" "$tmp")" --ai-thread "$TH" >/dev/null+  if [[ -f "$tmp/$base.aiflow.net" ]]; then+    mv "$tmp/$base.aiflow.net" "$out"+    cleanup_remote "$dest"+    rmdir "$tmp" 2>/dev/null || true+    exit 0+  fi+  echo "kicad-cli-remote: no netlist came back" >&2+  echo "$res" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true+  exit 1+fi++[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'sch export netlist' and '--version' are supported" >&2; exit 2; } shift 2 if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then   echo "Usage: kicad-cli-remote pcb drc [--format json] [--output FILE] [--refill-zones] [--severity-all] INPUT_FILE"@@ -31,15 +124,7 @@ while [[ $# -gt 0 ]]; do   esac done [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }-if [[ -z "$CLI" ]]; then-  CLI=$(adom-bridge --target "$T" kicad_status '{"reason":"find the native kicad-cli for an aiflow DRC"}' --ai-thread "$TH" | python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""))')-  [[ -n "$CLI" ]] || { echo "kicad-cli-remote: no KiCad found on $T (set KICAD_REMOTE_CLI)" >&2; exit 2; }-fi-# A temp folder on the desktop: KICAD_REMOTE_TMP, else the desktop's %TEMP%.-RTMP="${KICAD_REMOTE_TMP:-}"-if [[ -z "$RTMP" ]]; then-  RTMP=$(adom-bridge --target "$T" shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an aiflow DRC"}' --ai-thread "$TH" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("stdout","").strip().replace(chr(92),"/"))')-fi+find_cli; find_tmp job="aiflow-drc-$(date +%s)-$$" dest="$RTMP/$job" base="$(basename "$board" .kicad_pcb)"