← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.59"+version = "0.1.60" dependencies = [  "serde",  "serde_json",@@ -33,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.59"+version = "0.1.60" dependencies = [  "roxmltree",  "serde",@@ -42,7 +42,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-board",  "serde",@@ -51,7 +51,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-board",  "serde",@@ -70,7 +70,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-board",  "serde",@@ -79,7 +79,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.59"+version = "0.1.60" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.59"+version = "0.1.60" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.59"+version = "0.1.60" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+76−54
@@ -14,7 +14,7 @@ Every state-changing command takes `--ai-thread "<your thread name>"`. Every run  ## The flow -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.+The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.  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.@@ -31,67 +31,88 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac 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; 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`. +## No step is skipped++A run carries its lane's required steps in order (`flows/board.json` `sequence`; Fusion: intake, rules,+design, test-plan, sourcing, library, schematic, simulation, placement, routing, thermal-vias, pours,+outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` refuses while any+required step before x is not done, and a step is done only when its own command ran during its latest+visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land+moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `fusion silkscreen`, `fusion+export` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it+draws an arc on the timeline, and every later step becomes a redo that must be done again in order.+`finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never+jump past it.++## The timeline (show it, keep it up)++`adom-aiflow timeline open` serves the run's timeline and opens it as a Hydrogen webview in the right+pane of the workspace the run is driving (for a demo, the filmed Hydrogen, never your own): every+required step grouped by phase, a teal check on each done step, the current one lit, "Step 9 of 21, 12 to+go", and a purple arc for every return with its reason, ending at the finish line (Gerbers, BOM and CPL+exported). Every `step` refreshes it within a second. Open it at intake and leave it up.+ ## A board in Autodesk Fusion  When the human asks for a board in Fusion ("a 12 V to 5 V buck converter using the TPS54202, in Fusion"), the whole flow below is yours: they should never have to spell out a step. Read `docs/fusion-lane.md` once; the Fusion bridge must be 1.11.33 or later. Hydrogen stays full screen behind Fusion; keep Fusion windowed (about 90 % of the screen), never maximized, so the human sees both and a screen recording-catches Fusion's 3D view. Inside Hydrogen, the VS Code tab (the chat) always stays in the left pane and-every webview (the parts pages, the SPICE dashboard, the fields app) opens in the right pane; never widen-or collapse a pane to make a webview bigger. `step` brings the window each step works in to the front: Hydrogen for intake,-design, simulation, sourcing, the library, current, thermal and fields (the chat and its webviews), Fusion-for the schematic, placement, routing, pours, 3D and finish; the board is framed by its outline.--1. **The parts, from their wiki component pages.** Design the circuit (aiflow-circuit-design), then find-   each part's component page on the Adom wiki (a reference design on the wiki, such as a molecule page,-   is fair to start from). Show the human the important ones as you go: open the regulator's, the-   inductor's and the bulk capacitor's pages in a Hydrogen webview, a moment each-   (`adom-cli hydrogen webview open-or-refresh --name "Parts" --url <page url> --panel-id <id>`).-2. **One project library in Fusion.** `adom-aiflow fusion library --parts <netlist.json> --footprints <kicad lib folder> --name <Project>`-   builds ONE library for the project: every part from its page, the Mouser, DigiKey and LCSC numbers on-   the part, the chip outline as the symbol for the parts a reader recognises (regulators, inductors, big-   capacitors) and standard symbols for passives, the laser-etched or coloured 3D model bound. A part-   added later goes into the same library: run `fusion library` again with it. Never a second library.-   Every 3D model is checked for which way is up before it is bound (below).-3. **The schematic, live.** `adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> [--fixed <fixed.json>] --name <Design>`-   starts the design with the fab's rules, draws the schematic in labelled groups, checks it pin by pin,-   puts back any 3D model Fusion dropped when a part was added, and saves the design.-4. **Simulate before the board (required: `step placement` waits for a `step simulation` after the-   schematic).** `step simulation`, then run the circuit in the Adom SPICE dashboard (adom/adom-spice-skillpack;-   ngspice in the container is the default): `adom-spice start`, `adom-spice study <study.json>`,-   `adom-spice run ngspice`, `adom-spice show` (it opens in a Hydrogen webview in the right pane). Check start-up, ripple and the load step against the-   requirements. When a measurement misses, change the part values in the schematic-   (`fusion_schematic_run` with `CHANGE VALUE`, and the netlist), run the study again, and say what you-   changed and why. Record it: `evidence add --kind simulation --file <result.json>`.-5. **The 2D board.** `start --board board.brd --remote-board fusion:<Design>`, then placement, routing,-   pours and `measure` as for any board.-6. **The board in 3D.** `adom-aiflow fusion view3d` shows the 3D PCB, saves a still from Fusion's own-   renderer and orbits the live view. Read the still: every part must sit flat on its pads.-7. **Current and heat, in Hydrogen.** `analyze current` and `analyze thermal` with adom-fields on the live-   board. Show it to the human in a Hydrogen webview: `adom-fields serve --fields <run>/fields &`, open-   `http://localhost:8874/` as a webview tab named "Adom Fields", then-   `adom-fields ui view --quantity current --parts ghost --camera iso` (every part's 3D model ghosted over-   the copper, so the fields read first and the hot part is plain) and `adom-fields tour`. The models come-   from `<run>/3d/<MPN>.step`, which `fusion library` writes (the checked Z-up STEPs); adom-fields converts-   them with `step2glb`, so `adom-wiki pkg install adom/adom-step2glb` if it says the converter is missing.-   If the solve came before the library wrote them, `adom-fields parts --board <run>/board-live.brd --fields <run>/fields`. A failing screen prints `why:` lines naming the cause and the spec-   change that fixes it (a via ring: `viaPower`; another net crossing a patch: `planeUnder`). Change the-   spec, `adom-aiflow fusion reset` (the parts stay, the copper goes), then route, gate, land, pour and-   analyze again. Repeat until both screens pass, then `finish`.-8. **Export when the human asks.** Do not export on your own: say the board is done and that the-   Gerbers, BOM and pick and place are one request away. When they ask, `adom-aiflow fusion export`-   writes the Gerbers (the JLCPCB CAM job), the pick and place and the BOM with every distributor number-   to `<run>/manufacturing/`; answer with each file's path and say they are ready for the fab.+catches Fusion's 3D view. Inside Hydrogen, the VS Code tab (the chat) always stays in the left pane with+its file explorer closed (`adom-vscode sidebar close`), and every webview (the timeline, the parts pages,+the SPICE dashboard, the fields app) opens in the right pane; never widen or collapse a pane. `step`+brings the window each step works in to the front: Hydrogen for intake, design, the test plan, sourcing,+the library, simulation, current, thermal, fields and export, Fusion for everything drawn on the design.++1. **Intake and the timeline.** `intake`, `timeline open`. Offer the Claude usage widget in Hydrogen's+   title bar (Widgets, Claude usage) so the human sees the percent used as the run goes.+2. **Design rules, before anything is drawn.** Ask which fab builds the board. Default 3rdPartyFab (our+   own process, say only "3rdPartyFab"), JLCPCB or any pack `rules list` shows. `rules use --profile+   3rdPartyFab [--layers 2]` installs the KiCad rules, loads the same pack into the open Fusion design and+   writes the choice into the spec. Tell the human the rules in plain words.+3. **The circuit** (aiflow-circuit-design): datasheet math and values.+4. **The test plan** (skill aiflow-test-plan). Think through how the built board will be tested on the+   probing workcell and write tests.json; `testplan check --tests tests.json --netlist <netlist.json>+   --add` gives every measurement a probe spot, adding 0.5 mm round test points+   (adom/testpoint-pad-0-5mm) for the gaps, before the schematic is drawn.+5. **The parts, from their wiki component pages.** Find each part's page; show the important ones (the+   regulator, the inductor, the bulk capacitor) in a Hydrogen webview, a moment each.+6. **One project library in Fusion.** `fusion library --parts <netlist.json> --footprints <lib> --name+   <Project>`: one library, part numbers on every part, the chip outline as the symbol for the parts a+   reader recognises, the 3D model bound. Every model is checked for which way is up (below), and read+   through the Adom STEP service as CAD tools read it; a part that is not Z-up and seated is refused.+7. **The schematic, live.** `fusion schematic --netlist <netlist.json> --spec <spec.json> --name <Design>`.+8. **SPICE, on the drawn schematic.** `step simulation` (it must come after the schematic; placement+   refuses otherwise): the Adom SPICE dashboard in the right pane, start-up, ripple and the load step+   against the requirements; change values in the schematic when a measurement misses.+   `evidence add --kind simulation --file <result.json>`.+9. **Placement, then routing.** `start --board board.brd --remote-board fusion:<Design>`, place for the+   hot loop first: the inductor at the regulator's switch pin, the input caps where the switch node+   cannot pass under them. `gate` refuses switch copper under another part or a switch node longer than+   `switchNodeMaxLengthMm` (12 mm default) and sends you back to placement: fix it there, not with a wider+   trace.+10. **Heatsink vias.** `thermal-vias plan` (a target per hot part from spec.hot watts and the rise+    budget, at least 6) and `thermal-vias land`.+11. **Pours**, then **rounded corners**: `outline round` (1.0 mm default, spec outline.cornerRadius).+12. **Current, heat and fields, in Hydrogen.** `analyze current`, `analyze thermal`, adom-fields in the+    right pane with every part ghosted (`ui view --parts ghost --camera iso`, `tour`). A problem here is+    a return to the step that causes it (usually placement), on camera.+13. **The pour walkthrough.** `step nets`, `tour nets`: every poured net lit on its own in turn, the+    board framed; name each one in the chat.+14. **Silkscreen.** `fusion silkscreen --title "<board>"`: every part name readable (R0, else R90), clear+    of pads, bodies, vias, the edge and each other; each machine contact's net on its edge side; the+    board identity on the bottom. Names it cannot fit are listed, never dropped silently.+15. **3D, finish, export.** `fusion view3d` (read the still: every part flat on its pads), `finish`,+    then `fusion export` writes and checks the Gerbers, BOM and CPL; answer with each file's path and+    say they are good to go. That is the finish line.  **Which way is up: every 3D model is checked.** A STEP carries no "up" axis, and a model authored Y-up-lies on its side on a Z-up board (an electrolytic can on its flank, an inductor on edge). Adom component-pages publish Z-up, seated, centred STEPs with an identity footprint binding, and `fusion library`-checks every model against its footprint anyway: it applies a footprint's own model transform when there-is one, turns a model with no transform upright when its height is clearly not on Z, and names every part-it touched. You do not get this check for free from your own instincts, so do it on purpose: read the-`fusion view3d` still before you call the board done, and when a part is wrong, fix its component page-(the source), not just this board.+lies on its side on a Z-up board. Raw coordinates are not enough: a STEP's frames and assembly+placements are applied by every reader (KiCad, step2glb, adom-fields), so a rotation that turns a+representation's frame with the geometry turns the part twice (the 2026-10-07 inductor). Adom component+pages publish Z-up, seated, centred STEPs, checked with `step2glb features`, and `fusion library` checks+every model again the same way. Read the `fusion view3d` still before you call the board done, and fix a+wrong part at its component page, not just on this board.  ## Before the board: intake, sourcing, evidence @@ -154,6 +175,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | choosing parts, the fab profile (3rd party fab by default, JLCPCB on request), symbols, footprints and 3D models | `aiflow-sourcing` | | datasheet equations, stocked values, margins | `aiflow-circuit-design` | | loop and transient simulation, vendor PSpice models | `aiflow-simulate` |+| what to measure on the probing workcell, test points and probe spots | `aiflow-test-plan` | | schematic and board from a netlist, KiCad 10 traps | `aiflow-schematic-to-board` | | an Adom molecule: pins, contacts, grid, export and publish | `aiflow-molecule` | | a vendor login, account or verification code | `aiflow-credentials` |
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+3−1
@@ -328,8 +328,10 @@ pub fn rules_check(profile: &str, board: &str) -> Result<String, String> {         ("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())),+        ("adom-3rdpartyfab", Some(t)) if t.contains("3rd party fab") && t.contains("(rule") => Ok(format!("{} carries the 3rdPartyFab 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\"")),+        (other, Some(t)) if t.contains(&format!("adom-aiflow rules: {other} ")) => Ok(format!("{} carries the {other} rule pack", dru.display())),+        (other, _) => Err(format!("the spec names the {other:?} rules but {} does not carry that pack; `adom-aiflow rules use --profile {other}` installs it", dru.display())),     } } 
crates/adom-aiflow/src/main.rs+311−8
@@ -1,6 +1,14 @@ //! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line. //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run. mod silkscreen_sections;+mod sequence;+mod testplan;+mod switchnode;+mod thermalvias;+mod outline;++/// The timeline dashboard (tools/timeline/index.html): it polls timeline.json beside it.+const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html"); mod library_review; mod checks; mod preboard;@@ -65,9 +73,18 @@ enum Cmd {     #[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 },+    /// `rules use --profile 3rdPartyFab|<slug>`: the design-rules step. Loads a fab's rule pack from the wiki page adom/pcb-design-rules (3rdPartyFab is the default; `rules list` shows them all): the KiCad .kicad_dru beside the board and in the run, the Fusion DRC rules into the open design, the choice into the spec and the run. `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).+    /// `testplan check --tests tests.json`: the test-plan step. Every measurement the AI plans for the probing workcell gets a probe spot: before the board, a test point on its net in the netlist (`--netlist <netlist.json> --add` adds TP<n> for each gap, 0.5 mm round pads from adom/testpoint-pad-0-5mm); with a board, a real 0.5 mm landing (test point or exposed pad) clear of part bodies and other nets, within 50 mm of a ground contact. finish runs the board check again.+    /// `thermal-vias plan`: the heatsink-via step. For every part in spec.hot ({REF: {watts, tabNet}}) a via target from its dissipation and the rise budget (at least 6), and the spots beside its heat pads, clear of other nets, the edge and every pad; existing vias count. `thermal-vias land` lands the plan one via at a time; `--ack REF --why` accepts a part short of its target. thermal and finish refuse a hot part below target.+    ThermalVias { #[arg(value_parser = ["plan", "land"])] action: String, #[arg(long)] ack: Option<String>, #[arg(long)] why: Option<String>, #[arg(long, default_value_t = 400)] pause_ms: u64 },+    /// `outline round`: the rounded-corners step. Every sharp outside corner of the board outline becomes an arc of spec.outline.cornerRadius (default 1.0 mm): live in Fusion (MITER at each corner of the dimension layer), checked on the board after; `outline check` only reports. finish refuses a sharp corner.+    Outline { #[arg(value_parser = ["round", "check"])] action: String, #[arg(long)] radius: Option<f64> },+    /// `timeline open`: the flow's timeline dashboard in a Hydrogen webview (the right pane): every required step in order, the current one, how many to go, a check on each done step and an arc for every return. `timeline serve` serves <run>/timeline/ (open starts it when needed); `timeline write` refreshes timeline.json (every `step` does).+    Timeline { #[arg(value_parser = ["open", "serve", "write"])] action: String, #[arg(long, default_value_t = 8893)] port: u16 },+    Testplan { #[arg(value_parser = ["check"])] action: String, #[arg(long)] tests: Option<String>, #[arg(long)] netlist: Option<String>, #[arg(long)] add: bool },+    Rules { #[arg(value_parser = ["use", "install", "check", "list"])] action: String, #[arg(long)] profile: Option<String>, #[arg(long)] board: Option<String>, /// replace an existing .kicad_dru+    #[arg(long)] force: bool, /// copper layers (default 2)+    #[arg(long, default_value_t = 2)] layers: u32 },     /// 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@@ -2561,7 +2578,7 @@ fn main() {             let run = Run::open(&dir);             let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();             let status = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/fusion-project.py")).args(args)-                .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli))+                .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())                 .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));             std::process::exit(status.code().unwrap_or(1));         }@@ -2622,6 +2639,9 @@ fn main() {                 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);+            // the switch node: short, and never under another part (switchnode.rs)+            let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); switchnode::findings(&spec_g, &rb) };+            s["switchNodeIssues"] = json!(sw_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));@@ -2629,6 +2649,11 @@ fn main() {             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 !sw_issues.is_empty() {+                r.outcome("switch-node", json!({"issues": sw_issues}));+                r.save().unwrap();+                err(&format!("gate failed: the switch node:\n  {}", sw_issues.join("\n  ")), &[switchnode::HINT.into()]);+            }             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()]);             }@@ -2706,7 +2731,7 @@ fn main() {                         }                         plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))                     } else {-                        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", &[]))+                        plan.clone().or_else(|| r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))                     };                     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@@ -2946,7 +2971,7 @@ fn main() {             }             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 Some(p) = checks::rules_profile(&spec_f).or_else(|| r.data["fabRules"]["slug"].as_str().map(str::to_string)) {                 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_now(), Some(&r)) {@@ -2988,6 +3013,33 @@ fn main() {                 let unmarked = unmarked_ics(&r);                 if !unmarked.is_empty() { missing.push(format!("MPN laser etch on the ICs ({}): `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model-etched.step> --footprint <part.kicad_mod>` (gate must PASS), or `components --etch off` to decline", unmarked.join(", "))); }             }+            {+                let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+                for (a, h, t) in thermalvias::short(&spec_f, &board_now(), &acked) { missing.push(format!("heatsink vias: {a} has {h} of {t} (thermal-vias plan, land)")); }+            }+            {+                let edges = match r.data["remoteBoard"].as_str().and_then(aiflow_bridge::fusion::doc_of) {+                    Some(doc) => { let br = bridge_of(&r, &cli); let src = br.call("fusion_board_source", &json!({"expectDocument": doc})); outline::eagle_edges(src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("")) }+                    None => outline::kicad_edges(&std::fs::read_to_string(r.current_board()).unwrap_or_default()),+                };+                let sharp = outline::sharp_corners(&edges);+                if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }+            }+            for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); }+            // the test plan again, on the finished board: every measurement still has a landing+            if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {+                let list = testplan::tests_of(&read_json(&tp));+                let (_, gaps, _) = testplan::check_board(&list, &board_now());+                if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); }+            }+            // every required step before the finish line, done in order and proven+            let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+            if !seq.is_empty() {+                let st = sequence::states(&dir, &r.data, &flow_full, &seq);+                for b in sequence::blockers(&st, "finish") { missing.push(format!("step {b}")); }+                sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE);+            }             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()]);             }@@ -3318,7 +3370,232 @@ fn main() {             hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into());             ok(&text, &hints);         }-        Cmd::Rules { action, profile, board, force } => {+        Cmd::ThermalVias { action, ack, why, pause_ms } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let spec = spec_of(&r);+            if let Some(refs) = ack {+                let w = why.clone().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("--ack needs --why \"<why this part needs no more vias>\"", &[]));+                for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }+            }+            let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+            let b = board_of(&r);+            let sw = switchnode::nets(&spec, &b);+            if action == "plan" {+                let (parts, plan) = thermalvias::plan(&spec, &b, &sw);+                let pp = dir.join("plan-thermal-vias.json");+                write_json(&pp, &plan);+                let lines: Vec<String> = parts.iter().map(|p| format!("  {:<5} {:<5} target {:>2}  existing {:>2}  planned {:>2}{}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note.as_ref().map(|n| format!("  ({n})")).unwrap_or_default())).collect();+                r.outcome("thermal-vias-plan", json!({"plan": pp, "parts": parts.iter().map(|p| json!({"ref": p.reference, "net": p.net, "target": p.target, "existing": p.existing, "planned": p.planned.len(), "note": p.note})).collect::<Vec<_>>()}));+                r.save().unwrap();+                if spec["hot"].as_object().map(|o| o.is_empty()).unwrap_or(true) { err("spec.hot names no hot parts", &["Add every part that dissipates real power: \"hot\": {\"U1\": {\"watts\": 0.25, \"tabNet\": \"GND\"}} (watts from the datasheet's efficiency or Rds(on), the inductor's DCR times I squared).".into()]); }+                ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into()]);+            } else {+                let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[]));+                let n = read_json(&pp)["nets"].as_array().map(|a| a.len()).unwrap_or(0);+                if n > 0 {+                    let exe = std::env::current_exe().unwrap();+                    let st = std::process::Command::new(exe).args(["--run", &dir.display().to_string(), "--ai-thread", &thread(&cli), "land", "vias", "--plan", &pp, "--pause-ms", &pause_ms.to_string()]).status().unwrap_or_else(|e| err(&e.to_string(), &[]));+                    if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); }+                }+                let r2 = load_run(&cli);+                let live_board = fusion_live(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2));+                let short = thermalvias::short(&spec, &live_board, &acked);+                let mut r = r2;+                r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked}));+                r.save().unwrap();+                if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short: {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }+                ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]);+            }+        }+        Cmd::Outline { action, radius } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let rad = radius.or_else(|| spec_soft(&r)["outline"]["cornerRadius"].as_f64()).unwrap_or(1.0);+            let fusion_doc = r.data["remoteBoard"].as_str().and_then(aiflow_bridge::fusion::doc_of);+            let read = |r: &Run| -> Vec<outline::Edge> {+                if let Some(doc) = &fusion_doc {+                    let br = bridge_of(r, &cli);+                    let src = br.call("fusion_board_source", &json!({"expectDocument": doc}));+                    let text = src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("").to_string();+                    outline::eagle_edges(&text)+                } else { outline::kicad_edges(&std::fs::read_to_string(r.current_board()).unwrap_or_default()) }+            };+            let before = outline::sharp_corners(&read(&r));+            if action == "check" || before.is_empty() {+                if action == "round" { r.outcome("outline", json!({"radius": rad, "rounded": 0, "sharp": 0})); r.save().unwrap(); }+                if before.is_empty() { ok("the board outline has no sharp corners", &[]); return; }+                err(&format!("{} sharp outline corner(s): {}", before.len(), before.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["`outline round` turns each into an arc (spec outline.cornerRadius, default 1.0 mm).".into()]);+            }+            let Some(doc) = fusion_doc.clone() else {+                err("outline round lands live in Fusion only for now; this is a KiCad board", &["Round the Edge.Cuts corners in KiCad (Edit > Fillet, the corner radius), save, then `outline check` and `outline round` again to record it. File the KiCad landing on adom/adom-aiflow.".into()]);+            };+            let br = bridge_of(&r, &cli);+            let _ = br.call("fusion_show_2d_board", &json!({"documentName": doc}));+            let mut done = 0;+            for p in &before {+                let cmd = format!("GRID MM; MITER {rad} ({:.4} {:.4});", p.0, p.1);+                let rep = br.call("fusion_electron_run", &json!({"command": cmd}));+                if Bridge::ok(&rep) { done += 1; }+                std::thread::sleep(std::time::Duration::from_millis(500));+            }+            let after = outline::sharp_corners(&read(&r));+            r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()}));+            r.save().unwrap();+            if !after.is_empty() { err(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }+            ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);+        }+        Cmd::Timeline { action, port } => {+            let r = load_run(&cli);+            let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));+            let tl = sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report());+            sequence::write_timeline(&dir, &tl, TIMELINE_PAGE);+            let tdir = dir.join("timeline");+            match action.as_str() {+                "write" => ok(&format!("timeline written: step {} of {}, {} done", tl["position"], tl["total"], tl["done"]), &[]),+                "serve" => {+                    let l = std::net::TcpListener::bind(("0.0.0.0", *port)).unwrap_or_else(|e| err(&format!("port {port}: {e}"), &["Another timeline server may already be running: `timeline open` reuses it.".into()]));+                    eprintln!("serving {} on port {port}", tdir.display());+                    for st in l.incoming().flatten() {+                        let tdir = tdir.clone();+                        std::thread::spawn(move || {+                            use std::io::{Read, Write};+                            let mut st = st; let mut buf = [0u8; 4096];+                            let n = st.read(&mut buf).unwrap_or(0);+                            let req = String::from_utf8_lossy(&buf[..n]).to_string();+                            let path = req.split_whitespace().nth(1).unwrap_or("/").split('?').next().unwrap_or("/").trim_start_matches('/').to_string();+                            let mut name = if path.is_empty() || path.ends_with('/') { "index.html".to_string() } else { path.rsplit('/').next().unwrap_or("index.html").to_string() };+                            if name == "favicon.ico" { name = "favicon.svg".into(); }+                            let file = tdir.join(&name);+                            let (code, body, ctype) = if !name.contains("..") && file.is_file() {+                                (200, std::fs::read(&file).unwrap_or_default(), if name.ends_with(".json") { "application/json" } else if name.ends_with(".html") { "text/html; charset=utf-8" } else if name.ends_with(".svg") { "image/svg+xml" } else { "application/octet-stream" })+                            } else { (404, b"not found".to_vec(), "text/plain") };+                            let _ = write!(st, "HTTP/1.1 {code} OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", body.len());+                            let _ = st.write_all(&body);+                        });+                    }+                }+                _ => {+                    // the server: reuse a running one, else start one that outlives this command+                    if std::net::TcpStream::connect(("127.0.0.1", *port)).is_err() {+                        let exe = std::env::current_exe().unwrap();+                        let log = std::fs::File::create(tdir.join("serve.log")).ok();+                        let mut c = std::process::Command::new("setsid");+                        c.arg(exe).args(["--run", &dir.display().to_string(), "timeline", "serve", "--port", &port.to_string()]).stdin(std::process::Stdio::null());+                        if let Some(f) = log { c.stdout(f.try_clone().unwrap()).stderr(f); }+                        c.spawn().unwrap_or_else(|e| err(&format!("could not start the timeline server: {e}"), &[]));+                        std::thread::sleep(std::time::Duration::from_millis(600));+                    }+                    let url = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| format!("http://127.0.0.1:{port}/"));+                    let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow timeline", "--url", &url]).output();+                    let opened = o.as_ref().map(|o| o.status.success()).unwrap_or(false);+                    if !opened { err(&format!("the timeline is served at {url}, but Hydrogen did not open it"), &["Open it yourself: `adom-cli hydrogen webview open-or-refresh --name \"AI Flow timeline\" --url <that url>`.".into()]); }+                    ok(&format!("timeline open in Hydrogen: {url} (step {} of {})", tl["position"], tl["total"]), &["Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into()]);+                }+            }+        }+        Cmd::Testplan { action: _, tests, netlist, add } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let tp = tests.clone().or_else(|| r.data["testPlan"].as_str().map(str::to_string)).unwrap_or_else(|| err("testplan check needs --tests tests.json the first time", &["Write tests.json: [{id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}], one per measurement you will take on the probing workcell (skill aiflow-test-plan).".into()]));+            let tv = read_json(&tp);+            let list = testplan::tests_of(&tv);+            if list.is_empty() { err(&format!("{tp} has no tests"), &["A board with nothing to measure has not been thought through: list the rails, the switch node, feedback, ripple, enable and every LED.".into()]); }+            let tp_abs = std::fs::canonicalize(&tp).map(|p| p.display().to_string()).unwrap_or(tp.clone());+            r.data["testPlan"] = json!(tp_abs);+            let has_board = r.data["boards"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+            let (rows, gaps, notes, added, mode) = if has_board {+                let (rows, gaps, warn) = testplan::check_board(&list, &board_of(&r));+                (rows, gaps, warn, Vec::new(), "board")+            } else {+                let np = netlist.clone().unwrap_or_else(|| err("before the board, testplan check needs --netlist <netlist.json>", &[]));+                let mut nl = read_json(&np);+                let (rows, gaps, unknown) = testplan::check_netlist(&list, &nl);+                if !unknown.is_empty() { err(&format!("tests name nets the netlist does not have: {}", unknown.join(", ")), &["Use the netlist's own net names (or rename the net in the design so the measurement has a name).".into()]); }+                let added = if *add && !gaps.is_empty() { let a = testplan::add_test_points(&mut nl, &gaps); write_json(Path::new(&np), &nl); a } else { vec![] };+                (rows, if *add { vec![] } else { gaps }, vec![], added, "netlist")+            };+            let table: Vec<String> = rows.iter().map(|x| format!("  {:<10} {:<12} {:<9} {}", x["id"].as_str().unwrap_or(""), x["net"].as_str().unwrap_or(""), x["kind"].as_str().unwrap_or(""), match x["status"].as_str().unwrap_or("") { "gap" => format!("GAP: {}", x["why"].as_str().unwrap_or("no test point")), s => format!("{s} {}", x["spot"].as_object().map(|o| format!("{} at ({}, {})", o["pad"].as_str().unwrap_or(""), o["x"], o["y"])).or_else(|| x["spot"].as_str().map(str::to_string)).unwrap_or_default()) })).collect();+            r.outcome("testplan", json!({"mode": mode, "tests": rows, "gaps": gaps, "added": added, "notes": notes, "file": tp_abs}));+            r.save().unwrap();+            let head = format!("test plan ({mode}): {} measurements, {} probe-able, {} gap(s){}", rows.len(), rows.iter().filter(|x| x["status"] != "gap").count(), gaps.len(), if added.is_empty() { String::new() } else { format!("; added {}", added.join(", ")) });+            let body = format!("{head}\n{}", table.join("\n"));+            if !gaps.is_empty() {+                err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]);+            }+            let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];+            hints.extend(notes.iter().map(|n| format!("Note: {n}")));+            if mode == "netlist" { hints.push("Run it again once the board is placed (`testplan check`): a test point under a part or out of tether reach is a gap again. finish runs it too.".into()); }+            ok(&body, &hints);+        }+        Cmd::Rules { action, profile, board, force, layers } if action == "use" || action == "list" => {+            let page = "adom/pcb-design-rules";+            let list = || -> Vec<String> {+                let o = std::process::Command::new("adom-wiki").args(["api", "/api/v1/pages/adom/pcb-design-rules/files?path=kicad"]).output().ok();+                let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+                let mut out: Vec<String> = v["files"].as_array().cloned().unwrap_or_default().iter().filter_map(|f| f["name"].as_str()).filter(|n| n.ends_with(".kicad_dru")).map(|n| n.trim_end_matches(".kicad_dru").to_string()).collect();+                out.sort(); out+            };+            if action == "list" {+                let l = list();+                if l.is_empty() { err(&format!("could not read the rule packs on {page}"), &["Check `adom-wiki whoami` and the wiki's health, then retry.".into()]); }+                ok(&format!("{} rule packs on {page} (<fab>[-<process>]-<n>L):\n  {}", l.len(), l.join("\n  ")), &["`rules use --profile 3rdPartyFab` (the default) or `--profile <fab>[-<process>]`; --layers picks the copper count.".into()]);+                return;+            }+            let mut r = load_run(&cli);+            let asked = profile.clone().unwrap_or_else(|| "3rdPartyFab".into());+            let slug = match asked.to_ascii_lowercase().as_str() { "3rdpartyfab" | "fab" | "3rd-party-fab" | "adom-3rdpartyfab" => "adom-3rdpartyfab".to_string(), o => o.to_string() };+            let shown = if slug == "adom-3rdpartyfab" { "3rdPartyFab".to_string() } else { slug.clone() };+            let file = format!("{slug}-{layers}L.kicad_dru");+            let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, &format!("kicad/{file}")]).output();+            let text = o.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string()).filter(|t| t.contains("(rule")).unwrap_or_else(|| {+                let l = list();+                err(&format!("no {layers}-layer rule pack for {shown} on {page} ({file})"), &[format!("Packs there: {}. Pick one with --profile and --layers.", if l.is_empty() { "(could not list)".into() } else { l.join(", ") })])+            });+            let marked = format!("# adom-aiflow rules: {slug} {layers}L (from {page})\n{text}");+            let rd = dir.join("rules"); let _ = std::fs::create_dir_all(&rd);+            std::fs::write(rd.join(&file), &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));+            let mut placed = vec![rd.join(&file).display().to_string()];+            let cb = board.clone().unwrap_or_else(|| r.current_board());+            if cb.ends_with(".kicad_pcb") {+                let dru = Path::new(&cb).with_extension("kicad_dru");+                if dru.is_file() && !*force && std::fs::read_to_string(&dru).map(|t| !t.contains(&format!("adom-aiflow rules: {slug} "))).unwrap_or(false) {+                    err(&format!("{} already carries other rules", dru.display()), &["Pass --force to replace them with this pack (the old file is kept as .kicad_dru.bak).".into()]);+                }+                if dru.is_file() { let _ = std::fs::copy(&dru, dru.with_extension("kicad_dru.bak")); }+                std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));+                placed.push(dru.display().to_string());+            }+            // Fusion: the bridge loads the same pack's Fusion rules into the open design+            let mut fusion_note = String::new();+            let mut fusion_loaded = Value::Null;+            if sequence::lane(&dir, &r.data) == "fusion" {+                if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                    let br = Bridge { ai_thread: thread(&cli), target };+                    let rep = br.call("fusion_set_design_rules", &json!({"fab": slug, "layers": layers}));+                    let okk = Bridge::ok(&rep);+                    fusion_loaded = json!(okk);+                    fusion_note = if okk { format!("; Fusion: {shown} {layers}-layer rules loaded into the open design") } else { format!("; Fusion did not load them ({}): open the design and run `rules use` again", rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };+                }+            }+            // the choice: in the spec when there is one, and in the run (an intake run has no spec yet)+            r.data["fabRules"] = json!({"profile": shown, "slug": slug, "layers": layers, "file": file});+            if let Some(sp) = r.data["spec"].as_str().map(str::to_string) {+                if let Ok(mut v) = std::fs::read_to_string(&sp).map_err(|_| ()).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|_| ())) {+                    if !v["fab"].is_object() { v["fab"] = json!({}); }+                    v["fab"]["rules"] = json!(slug);+                    write_json(Path::new(&sp), &v);+                }+            }+            let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();+            r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "kicad": placed, "fusion": fusion_loaded}));+            r.save().unwrap();+            ok(&format!("design rules: {shown}, {layers} layers{fusion_note}. KiCad rules at {}", placed.join(", ")), &[+                format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),+                "Every later step designs to these: placement checks, routing widths and clearances, the gate's DRC and finish all read them. Design with margin above the minimums.".into()]);+        }+        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", &[]));@@ -3489,6 +3766,8 @@ fn main() {                     let spec = spec_of(&r);                     let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();                     if list.is_empty() {+                        // the pour walkthrough: every poured net first, then the loaded, wide and Kelvin ones+                        for z in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() { if let Some(n) = z["net"].as_str() { if !list.iter().any(|x| x == n) { list.push(n.to_string()); } } }                         for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }                         for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }                         for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {@@ -3655,8 +3934,32 @@ fn main() {                     }                 }             }+            // the required steps, in order: a run that starts now carries its lane's sequence, and no step+            // in it can be skipped (sequence.rs)+            let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            if r.data["sequence"].is_null() && r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {+                r.data["sequence"] = json!(sequence::sequence_of(&flow_full, sequence::lane(&dir, &r.data)));+            }+            let seq: Vec<String> = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+            if !seq.is_empty() && !*back {+                if let Some(pos) = sequence::position(&seq, name) {+                    let cur = r.data["currentStep"].as_str().and_then(|c| sequence::position(&seq, c));+                    if let Some(cp) = cur { if pos < cp {+                        err(&format!("{name} comes before {} in the flow: going back is a return", seq[cp]), &[format!("`step {name} --back --why \"<what has to change>\"`: the return is filmed and shown on the timeline as a loop, and every later step is redone after it.")]);+                    } }+                    let st = sequence::states(&dir, &r.data, &flow_full, &seq);+                    let b = sequence::blockers(&st, name);+                    if !b.is_empty() {+                        let first = b[0].split(' ').next().unwrap_or("").to_string();+                        err(&format!("not yet: {name} needs every required step before it done, and these are not:\n  {}", b.join("\n  ")), &[+                            format!("`step {first}` and do it: a step is done when its own command has run during its latest visit (flows/board.json proofs). No step of the flow can be skipped."),+                            "If a step truly does not apply to this board, say so to the human and ask; never jump past it.".into()]);+                    }+                }+            }             record_usage(&mut r, &format!("step:{name}"));             r.step_set(name, *back, why.as_deref());+            if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); }             // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)             let mut clip_note = String::new();             if let Some(target) = r.data["target"].as_str().map(str::to_string) {@@ -3693,7 +3996,7 @@ fn main() {                 // the window the step works in comes to the front: Hydrogen for the human-facing steps (the                 // chat, the parts pages, the SPICE dashboard, the fields app), Fusion for the design itself                 if fusion_run || fusion_project {-                    const HYDROGEN_STEPS: [&str; 9] = ["intake", "design", "simulation", "sourcing", "library", "current", "thermal", "fields", "export"];+                    const HYDROGEN_STEPS: [&str; 10] = ["intake", "design", "simulation", "test-plan", "sourcing", "library", "current", "thermal", "fields", "export"];                     br.front(if HYDROGEN_STEPS.contains(&name.as_str()) { "hydrogen" } else { "fusion" });                 }                 let hwnd = if name == "fields" { None }
crates/adom-aiflow/src/outline.rsadded+77
@@ -0,0 +1,77 @@+//! Rounded board corners. Engineers do not like sharp outside corners (they cut hands, chip in a+//! panel and stress the fab's router), so the outline step turns every sharp corner into an arc of+//! spec.outline.cornerRadius (1.0 mm by default) and finish refuses a sharp one.++/// One outline edge: a straight line (`arc == false`) or an arc, by its endpoints.+#[derive(Debug, Clone)]+pub struct Edge { pub a: (f64, f64), pub b: (f64, f64), pub arc: bool }++fn near(p: (f64, f64), q: (f64, f64)) -> bool { (p.0 - q.0).hypot(p.1 - q.1) < 0.01 }++/// The sharp corners: a point where two straight edges meet at an angle (more than 10 degrees of turn).+pub fn sharp_corners(edges: &[Edge]) -> Vec<(f64, f64)> {+    let mut out: Vec<(f64, f64)> = Vec::new();+    for (i, e) in edges.iter().enumerate() {+        if e.arc { continue; }+        for p in [e.a, e.b] {+            if out.iter().any(|q| near(*q, p)) { continue; }+            let others: Vec<&Edge> = edges.iter().enumerate().filter(|(j, f)| *j != i && (near(f.a, p) || near(f.b, p))).map(|(_, f)| f).collect();+            let Some(f) = others.first() else { continue };+            if f.arc { continue; }+            let d1 = if near(e.a, p) { (e.b.0 - p.0, e.b.1 - p.1) } else { (e.a.0 - p.0, e.a.1 - p.1) };+            let d2 = if near(f.a, p) { (f.b.0 - p.0, f.b.1 - p.1) } else { (f.a.0 - p.0, f.a.1 - p.1) };+            let cos = (d1.0 * d2.0 + d1.1 * d2.1) / (d1.0.hypot(d1.1) * d2.0.hypot(d2.1)).max(1e-9);+            // straight through is cos = -1; any real turn is a corner+            if cos > -0.985 { out.push(p); }+        }+    }+    out+}++/// The outline of an EAGLE/Fusion board: the wires on the dimension layer (20); a wire with a+/// `curve` attribute is an arc.+pub fn eagle_edges(src: &str) -> Vec<Edge> {+    let mut out = Vec::new();+    for w in src.split("<wire ").skip(1) {+        let w = &w[..w.find('>').unwrap_or(w.len())];+        if !w.contains("layer=\"20\"") { continue; }+        let num = |k: &str| w.split(&format!(" {k}=\"")).nth(1).or_else(|| if w.starts_with(&format!("{k}=\"")) { w.split(&format!("{k}=\"")).nth(1) } else { None }).and_then(|r| r.split('"').next()).and_then(|v| v.parse::<f64>().ok());+        if let (Some(x1), Some(y1), Some(x2), Some(y2)) = (num("x1"), num("y1"), num("x2"), num("y2")) {+            let arc = num("curve").map(|c| c.abs() > 1e-6).unwrap_or(false);+            out.push(Edge { a: (x1, y1), b: (x2, y2), arc });+        }+    }+    out+}++/// The outline of a KiCad board: Edge.Cuts gr_line / gr_arc / gr_rect (a rect is four sharp corners).+pub fn kicad_edges(text: &str) -> Vec<Edge> {+    let mut out = Vec::new();+    for (tag, arc) in [("(gr_line", false), ("(gr_arc", true), ("(gr_rect", false)] {+        for blk in text.split(tag).skip(1) {+            let end = blk.find("\n  (").unwrap_or(blk.len().min(600));+            let blk = &blk[..end];+            if !blk.contains("Edge.Cuts") { continue; }+            let xy = |k: &str| blk.split(&format!("({k} ")).nth(1).and_then(|r| { let v: Vec<f64> = r.split(')').next()?.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some((v[0], v[1])) } else { None } });+            if let (Some(a), Some(b)) = (xy("start"), xy("end")) {+                if tag == "(gr_rect" {+                    let (c, d) = ((b.0, a.1), (a.0, b.1));+                    for (p, q) in [(a, c), (c, b), (b, d), (d, a)] { out.push(Edge { a: p, b: q, arc: false }); }+                } else { out.push(Edge { a, b, arc }); }+            }+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn rectangle_has_four_corners_and_rounded_has_none() {+        let src = r#"<wire x1="0" y1="0" x2="30" y2="0" width="0" layer="20"/><wire x1="30" y1="0" x2="30" y2="20" width="0" layer="20"/><wire x1="30" y1="20" x2="0" y2="20" width="0" layer="20"/><wire x1="0" y1="20" x2="0" y2="0" width="0" layer="20"/>"#;+        assert_eq!(sharp_corners(&eagle_edges(src)).len(), 4);+        let r = r#"<wire x1="1" y1="0" x2="29" y2="0" width="0" layer="20"/><wire x1="29" y1="0" x2="30" y2="1" width="0" layer="20" curve="90"/><wire x1="30" y1="1" x2="30" y2="19" width="0" layer="20"/>"#;+        assert!(sharp_corners(&eagle_edges(r)).is_empty());+    }+}
crates/adom-aiflow/src/preboard.rs+1−1
@@ -8,7 +8,7 @@ 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"];+pub const PREBOARD_STEPS: [&str; 7] = ["intake", "rules", "sourcing", "design", "simulation", "test-plan", "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).
crates/adom-aiflow/src/sequence.rsadded+227
@@ -0,0 +1,227 @@+//! The flow's required steps, in order, and whether each one really happened.+//!+//! A run that starts under this rule carries `sequence` (the lane's ordered required steps) in+//! run.json. A step is DONE when its latest visit started no earlier than the latest visit of every+//! required step before it, and its proof (an outcome, a proof file or evidence the step must record)+//! was recorded during that visit. A return (`step k --back`) makes every later visited step a REDO;+//! a step never visited is TODO. `step x` refuses to go past a step that is not done, and finish+//! refuses until the whole chain up to it is done. This is what stops a step from being skipped.++use serde_json::{json, Value};+use std::path::Path;++pub const FAVICON: &str = include_str!("../../../tools/timeline/favicon.svg");++#[derive(Debug, Clone, PartialEq)]+pub enum Status { Done, Current, Redo, Todo }++impl Status {+    pub fn as_str(&self) -> &'static str {+        match self { Status::Done => "done", Status::Current => "current", Status::Redo => "redo", Status::Todo => "todo" }+    }+}++#[derive(Debug, Clone)]+pub struct StepState {+    pub name: String,+    pub status: Status,+    pub visits: usize,+    pub last: Option<String>,+    /// the proof keys this step needs and none was recorded during its latest visit+    pub unproven: Vec<String>,+}++/// Which lane a run is on: a Fusion project or run, else KiCad.+pub fn lane(dir: &Path, data: &Value) -> &'static str {+    if dir.join("fusion").join("project.json").is_file() || data["engine"].as_str().map(|e| e.to_ascii_lowercase().contains("fusion")).unwrap_or(false)+        || data["remoteBoard"].as_str().map(|b| b.starts_with("fusion:")).unwrap_or(false) { "fusion" } else { "kicad" }+}++pub fn sequence_of(flow: &Value, lane: &str) -> Vec<String> {+    flow["sequence"][lane].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()+}++fn last_visit(data: &Value, step: &str) -> Option<String> {+    data["steps"][step]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).max().map(str::to_string))+}++fn visits(data: &Value, step: &str) -> usize {+    data["steps"][step]["visits"].as_array().map(|a| a.len()).unwrap_or(0)+}++/// When a proof key was last recorded: an outcome, a proof file (`<run>/proofs/<key>.json` with "at",+/// written by tools outside the binary, the Fusion lane's Python), or `evidence-<kind>`.+pub fn proof_at(dir: &Path, data: &Value, key: &str) -> Option<String> {+    if let Some(kind) = key.strip_prefix("evidence-") {+        return data["evidence"].as_array().and_then(|a| a.iter().filter(|e| e["kind"].as_str() == Some(kind) && e["pass"] != false).filter_map(|e| e["at"].as_str()).max().map(str::to_string));+    }+    let a = data["outcomeAt"][key].as_str().map(str::to_string);+    let b = std::fs::read_to_string(dir.join("proofs").join(format!("{key}.json"))).ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v["at"].as_str().map(str::to_string));+    match (a, b) { (Some(x), Some(y)) => Some(x.max(y)), (x, y) => x.or(y) }+}++/// The state of every required step. `current` is the step the run is in now.+pub fn states(dir: &Path, data: &Value, flow: &Value, seq: &[String]) -> Vec<StepState> {+    let current = data["currentStep"].as_str().unwrap_or("");+    let mut floor = String::new();+    let mut out = Vec::new();+    for name in seq {+        let last = last_visit(data, name);+        let proofs: Vec<String> = flow["proofs"][name.as_str()].as_array().map(|a| a.iter().filter_map(|k| k.as_str().map(str::to_string)).collect()).unwrap_or_default();+        let proven = proofs.is_empty() || last.as_ref().map(|l| proofs.iter().any(|k| proof_at(dir, data, k).map(|t| t >= *l).unwrap_or(false))).unwrap_or(false);+        let status = match &last {+            None => Status::Todo,+            Some(l) if *l < floor => Status::Redo,+            Some(_) if name == current => Status::Current,+            Some(_) => Status::Done,+        };+        if let Some(l) = &last { if *l > floor { floor = l.clone(); } }+        out.push(StepState { name: name.clone(), status, visits: visits(data, name), last, unproven: if proven { vec![] } else { proofs } });+    }+    out+}++/// What blocks going to `target` (forward): every required step before it must be done and proven.+/// The step being left (the current one) counts as done once its proof is in.+pub fn blockers(st: &[StepState], target: &str) -> Vec<String> {+    let Some(pos) = st.iter().position(|s| s.name == target) else { return vec![] };+    let mut out = Vec::new();+    for s in &st[..pos] {+        match s.status {+            Status::Todo => out.push(format!("{} (never done)", s.name)),+            Status::Redo => out.push(format!("{} (redo: an earlier step was reworked after it)", s.name)),+            _ if !s.unproven.is_empty() => out.push(format!("{} (no {} recorded during its last visit)", s.name, s.unproven.join(" or "))),+            _ => {}+        }+    }+    out+}++pub fn position(seq: &[String], name: &str) -> Option<usize> { seq.iter().position(|s| s == name) }++fn title_of(flow: &Value, name: &str) -> String {+    let t = flow["titles"][name].as_str().map(str::to_string);+    t.unwrap_or_else(|| {+        let s = name.replace('-', " ");+        let mut c = s.chars();+        c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()+    })+}++fn does_of(flow: &Value, name: &str) -> String {+    flow["short"][name].as_str().map(str::to_string)+        .or_else(|| flow["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["does"].as_str()).map(|d| d.split([':', ';', '.']).next().unwrap_or(d).trim().to_string()))+        .unwrap_or_default()+}++/// The timeline the dashboard draws: every required step with its status, and every return as a loop.+pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes: &serde_json::Map<String, Value>) -> Value {+    let st = states(dir, data, flow, seq);+    let phase = |n: &str| flow["phases"].as_object().and_then(|o| o.iter().find(|(_, v)| v.as_array().map(|a| a.iter().any(|x| x.as_str() == Some(n))).unwrap_or(false)).map(|(k, _)| k.clone())).unwrap_or_default();+    let steps: Vec<Value> = st.iter().map(|s| json!({+        "name": s.name, "title": title_of(flow, &s.name), "does": does_of(flow, &s.name), "phase": phase(&s.name),+        "status": s.status.as_str(), "visits": s.visits,+        "minutes": minutes.get(&s.name).and_then(|m| m["wallSeconds"].as_f64()).map(|x| (x / 6.0).round() / 10.0).unwrap_or(0.0),+        "unproven": s.unproven,+    })).collect();+    // the loops: every return, from the step the run was in to the step it went back to+    let mut events: Vec<(String, String, bool, Option<String>)> = Vec::new();+    if let Some(o) = data["steps"].as_object() {+        for (name, v) in o { for vis in v["visits"].as_array().cloned().unwrap_or_default() {+            if let Some(at) = vis["at"].as_str() { events.push((at.to_string(), name.clone(), vis["back"].as_bool().unwrap_or(false), vis["why"].as_str().map(str::to_string))); }+        } }+    }+    events.sort();+    let mut loops = Vec::new();+    for i in 0..events.len() {+        let (at, to, back, why) = &events[i];+        if !*back || i == 0 { continue; }+        let from = &events[i - 1].1;+        if from == to { continue; }+        loops.push(json!({"from": from, "to": to, "why": why, "at": at}));+    }+    let mut current = data["currentStep"].as_str().unwrap_or("").to_string();+    let mut steps = steps;+    // the finish line: the last step, proven, with everything before it done, is done, and the flow is finished+    if let Some(last) = st.last() {+        if last.name == current && last.unproven.is_empty() && st[..st.len() - 1].iter().all(|s| s.status == Status::Done && s.unproven.is_empty()) {+            if let Some(v) = steps.last_mut() { v["status"] = json!("done"); }+            current = String::new();+        }+    }+    let idx = st.iter().position(|s| s.name == current).map(|i| i + 1).unwrap_or(st.iter().filter(|s| s.status == Status::Done).count() + if current.is_empty() && !st.is_empty() { 1 } else { 0 }).min(st.len());+    json!({+        "title": data["title"].as_str().or(data["requirements"]["title"].as_str()).unwrap_or("adom-aiflow"),+        "engine": if lane(dir, data) == "fusion" { "Fusion" } else { "KiCad" },+        "finish": flow["finishLine"][lane(dir, data)].as_str().unwrap_or("Gerbers, BOM and CPL exported"),+        "updated": aiflow_run::now(),+        "current": if current.is_empty() { Value::Null } else { json!(current) },+        "position": idx, "total": st.len(),+        "done": steps.iter().filter(|s| s["status"] == "done").count(),+        "steps": steps, "loops": loops,+    })+}++/// Write `<run>/timeline/timeline.json` (and the page beside it), for the dashboard to poll.+pub fn write_timeline(dir: &Path, tl: &Value, page: &str) {+    let d = dir.join("timeline");+    let _ = std::fs::create_dir_all(&d);+    let tmp = d.join("timeline.json.tmp");+    if std::fs::write(&tmp, serde_json::to_string_pretty(tl).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, d.join("timeline.json")); }+    let idx = d.join("index.html");+    if std::fs::read_to_string(&idx).map(|t| t != page).unwrap_or(true) { let _ = std::fs::write(&idx, page); }+    // the one icon: the page's <link rel="icon">, the webview tab and the header mark draw the same lines+    let fav = d.join("favicon.svg");+    if std::fs::read_to_string(&fav).map(|t| t != FAVICON).unwrap_or(true) { let _ = std::fs::write(&fav, FAVICON); }+}++#[cfg(test)]+mod tests {+    use super::*;+    fn run(visits: &[(&str, &str, bool)]) -> Value {+        let mut d = json!({"steps": {}});+        for (s, at, back) in visits {+            let e = d["steps"].as_object_mut().unwrap().entry(s.to_string()).or_insert(json!({"visits": []}));+            e["visits"].as_array_mut().unwrap().push(json!({"at": at, "back": back}));+            d["currentStep"] = json!(s);+        }+        d+    }+    fn seq() -> Vec<String> { ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect() }++    #[test]+    fn skipping_a_step_is_blocked() {+        let d = run(&[("a", "2026-01-01T00:00:01Z", false), ("b", "2026-01-01T00:00:02Z", false)]);+        let st = states(Path::new("/nonexistent"), &d, &json!({}), &seq());+        assert!(blockers(&st, "c").is_empty());+        let b = blockers(&st, "d");+        assert_eq!(b.len(), 1); assert!(b[0].starts_with("c"));+    }++    #[test]+    fn a_return_makes_later_steps_redo() {+        let d = run(&[("a", "2026-01-01T00:00:01Z", false), ("b", "2026-01-01T00:00:02Z", false), ("c", "2026-01-01T00:00:03Z", false), ("a", "2026-01-01T00:00:04Z", true)]);+        let st = states(Path::new("/nonexistent"), &d, &json!({}), &seq());+        assert_eq!(st[0].status, Status::Current);+        assert_eq!(st[1].status, Status::Redo);+        assert_eq!(st[2].status, Status::Redo);+        assert_eq!(st[3].status, Status::Todo);+        assert!(blockers(&st, "c")[0].starts_with("b (redo"));+        let tl = timeline(Path::new("/nonexistent"), &d, &json!({}), &seq(), &serde_json::Map::new());+        assert_eq!(tl["loops"][0]["from"], "c"); assert_eq!(tl["loops"][0]["to"], "a");+    }++    #[test]+    fn a_step_needs_its_proof_from_its_last_visit() {+        let mut d = run(&[("a", "2026-01-01T00:00:01Z", false), ("b", "2026-01-01T00:00:05Z", false)]);+        let flow = json!({"proofs": {"a": ["thing"]}});+        d["outcomeAt"]["thing"] = json!("2026-01-01T00:00:00Z");+        let st = states(Path::new("/nonexistent"), &d, &flow, &seq());+        assert_eq!(st[0].unproven, vec!["thing".to_string()]);+        assert!(!blockers(&st, "b").is_empty());+        d["outcomeAt"]["thing"] = json!("2026-01-01T00:00:02Z");+        let st = states(Path::new("/nonexistent"), &d, &flow, &seq());+        assert!(blockers(&st, "b").is_empty());+    }+}
crates/adom-aiflow/src/switchnode.rsadded+70
@@ -0,0 +1,70 @@+//! The switch node: the one net in a switching regulator that must stay short and must not run under+//! other parts. Its copper carries the full switching edge; under an input cap or a feedback divider it+//! couples noise and heat into them, and a long switch trace is an antenna and a hot spot. adom-fields+//! found exactly this on a buck (a long switch trace under both input caps); the fix is placement, not+//! a wider trace, so the gate and finish send the AI back to placement.++use aiflow_board::Board;+use serde_json::Value;++/// The switch nets: spec.switchNodes, else any net named SW / *_SW / SW_*.+pub fn nets(spec: &Value, b: &Board) -> Vec<String> {+    if let Some(a) = spec["switchNodes"].as_array() { return a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(); }+    b.nets.keys().filter(|n| { let u = n.to_ascii_uppercase(); u == "SW" || u.ends_with("_SW") || u.starts_with("SW_") || u.ends_with("/SW") }).cloned().collect()+}++fn in_box(p: (f64, f64), bb: &(f64, f64, f64, f64), grow: f64) -> bool { p.0 > bb.0 - grow && p.0 < bb.2 + grow && p.1 > bb.1 - grow && p.1 < bb.3 + grow }++/// What is wrong with the switch node's copper, in words the AI can act on.+pub fn findings(spec: &Value, b: &Board) -> Vec<String> {+    let max_len = spec["switchNodeMaxLengthMm"].as_f64().unwrap_or(12.0);+    let mut out = Vec::new();+    let bodies = crate::testplan::bodies(b);+    let layer_name = |l: usize| b.copper_layers.get(l).cloned().unwrap_or_else(|| format!("layer {l}"));+    for net in nets(spec, b) {+        // the parts that belong to the switch node may sit over it+        let own: Vec<String> = b.pads.iter().filter(|p| p.net == net).map(|p| p.reference.clone()).collect();+        let mut under: Vec<String> = Vec::new();+        let mut length = 0.0;+        for t in b.tracks().iter().filter(|t| t.net == net) {+            let d = (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1);+            length += d;+            let n = (d / 0.2).ceil().max(1.0) as usize;+            for i in 0..=n {+                let f = i as f64 / n as f64;+                let p = (t.a.0 + (t.b.0 - t.a.0) * f, t.a.1 + (t.b.1 - t.a.1) * f);+                for (r, bb) in &bodies {+                    if own.contains(r) { continue; }+                    let tag = format!("{r} ({})", layer_name(t.layer));+                    if in_box(p, bb, t.width / 2.0) && !under.contains(&tag) { under.push(tag); }+                }+            }+        }+        for z in b.zones().iter().filter(|z| z.net == net) {+            for (l, poly) in &z.fills {+                for p in poly {+                    for (r, bb) in &bodies {+                        if own.contains(r) { continue; }+                        let tag = format!("{r} ({} pour)", layer_name(*l));+                        if in_box(*p, bb, 0.0) && !under.contains(&tag) { under.push(tag); }+                    }+                }+            }+        }+        if !under.is_empty() { out.push(format!("{net} copper runs under {}", under.join(", "))); }+        if length > max_len { out.push(format!("{net} traces total {length:.1} mm (budget {max_len} mm, spec switchNodeMaxLengthMm): the regulator's switch pin and the inductor are too far apart")); }+    }+    out+}++pub const HINT: &str = "Fix it in placement, not with copper: put the inductor right at the regulator's switch pin, and the input caps and the feedback divider where the switch node cannot pass under them. `step placement --back --why \"switch node under <parts>\"`, move the parts (`place pack`/`land moves`), then route, pours and the analyses again.";++#[cfg(test)]+mod tests {+    #[test]+    fn real_board_if_present() {+        let Ok(p) = std::env::var("AIFLOW_SW_BOARD") else { return };+        let b = aiflow_board::Board::load(&p).unwrap();+        for f in super::findings(&serde_json::json!({}), &b) { println!("FINDING {f}"); }+    }+}
crates/adom-aiflow/src/testplan.rsadded+179
@@ -0,0 +1,179 @@+//! The test plan: what the AI will measure when the built board is on the probing workcell, and a+//! probe spot for every measurement.+//!+//! tests.json (the AI writes it): [{id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}].+//! Before the board exists the check reads the netlist: every measured net must exist, and a net with+//! no test point gets one (`--add` writes TP<n> into the netlist). Once the board exists it looks for a+//! real landing on each net: a test point or an exposed pad where a 0.5 mm pogo landing fits clear of+//! every part body and of other nets' copper, within the ground tether's reach of a ground contact.+//! Workcell numbers are Stephen's (adom/probing-workcell): smallest pad 200 um, under 300 um needs a+//! human crosshair check, 50 mm tether reach from a MachineContactMedium, tips about 3 mm apart.++use aiflow_board::{geom, Board};+use serde_json::{json, Value};++/// The wiki component page the added test points come from.+pub const TP_PAGE: &str = "adom/testpoint-pad-0-5mm";+pub const TP_VALUE: &str = "TP_0.5mm";+/// The library name the page publishes under (a test point is copper, not a bought part).+pub const TP_MPN: &str = "Adom_TestPoint_D0.5mm";+/// A 0.5 mm landing: the pogo tip's working circle plus placement error.+pub const LANDING_R: f64 = 0.25;+/// Copper of another net must stay this far from the landing circle (short risk under the tip).+pub const SHORT_GAP: f64 = 0.3;+/// Ground tether reach from a ground contact (mm).+pub const REACH: f64 = 50.0;++pub fn needs_spot(kind: &str) -> bool { !matches!(kind, "camera") }++pub fn tests_of(v: &Value) -> Vec<Value> {+    v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default()+}++/// Netlist mode: (rows, gaps). A gap is a measured net with no test point on it.+pub fn check_netlist(tests: &[Value], netlist: &Value) -> (Vec<Value>, Vec<String>, Vec<String>) {+    let nets: Vec<(String, Vec<String>)> = netlist["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| {+        let a = n.as_array()?; Some((a.first()?.as_str()?.to_string(), a.get(1)?.as_array()?.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()))+    }).collect();+    let tp_refs: Vec<String> = netlist["components"].as_array().cloned().unwrap_or_default().iter().filter(|c| is_tp(c)).filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();+    let (mut rows, mut gaps, mut unknown) = (Vec::new(), Vec::new(), Vec::new());+    for t in tests {+        let net = t["net"].as_str().unwrap_or("");+        let kind = t["kind"].as_str().unwrap_or("dc");+        let Some((_, pins)) = nets.iter().find(|(n, _)| n == net) else {+            if needs_spot(kind) { unknown.push(net.to_string()); }+            rows.push(json!({"id": t["id"], "net": net, "kind": kind, "spot": null, "status": "unknown net"})); continue;+        };+        let tp = pins.iter().find(|p| tp_refs.iter().any(|r| p.split('.').next() == Some(r.as_str())));+        let status = if !needs_spot(kind) { "camera" } else if tp.is_some() { "test point" } else { "gap" };+        if status == "gap" && !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); }+        rows.push(json!({"id": t["id"], "net": net, "kind": kind, "spot": tp, "status": status}));+    }+    (rows, gaps, unknown)+}++fn is_tp(c: &Value) -> bool {+    let r = c["ref"].as_str().unwrap_or("");+    let txt = format!("{} {} {} {}", c["fp"].as_str().unwrap_or(""), c["value"].as_str().unwrap_or(""), c["page"].as_str().unwrap_or(""), c["mpn"].as_str().unwrap_or("")).to_ascii_lowercase();+    r.starts_with("TP") || txt.contains("testpoint") || txt.contains("test_point") || txt.contains("test point")+}++/// Add one test point per gap net to the netlist (TP<n>, from the wiki test point page).+pub fn add_test_points(netlist: &mut Value, gaps: &[String]) -> Vec<String> {+    let mut used: Vec<u32> = netlist["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str()?.strip_prefix("TP")?.parse().ok()).collect();+    let mut added = Vec::new();+    for net in gaps {+        let n = (1..).find(|k| !used.contains(k)).unwrap(); used.push(n);+        let r = format!("TP{n}");+        netlist["components"].as_array_mut().map(|a| a.push(json!({"ref": r, "value": TP_VALUE, "fp": "adom:Adom_TestPoint_D0.5mm", "page": TP_PAGE, "mpn": TP_MPN, "block": "test", "why": format!("probe spot for {net} (test plan)")})));+        if let Some(a) = netlist["nets"].as_array_mut() {+            if let Some(entry) = a.iter_mut().find(|e| e[0].as_str() == Some(net.as_str())) {+                entry[1].as_array_mut().map(|p| p.push(json!(format!("{r}.1"))));+            }+        }+        added.push(format!("{r} on {net}"));+    }+    added+}++/// Every footprint's body box (its fab outline, else its courtyard shrunk a little), placed.+pub fn bodies(b: &Board) -> Vec<(String, (f64, f64, f64, f64))> {+    let mut out = Vec::new();+    for (r, fp) in &b.footprints {+        let block = &b.text[fp.start..fp.end];+        let Ok(node) = aiflow_board::sx::parse(block) else { continue };+        let mut pts = Vec::new();+        for n in node.nodes() {+            if !matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc") { continue; }+            let layer = n.find("layer").and_then(|l| l.value()).unwrap_or("");+            if layer != "F.Fab" && layer != "B.Fab" { continue; }+            for tag in ["start", "end", "mid"] { if let Some(p) = n.find(tag) { if let (Some(x), Some(y)) = (p.number(1), p.number(2)) { pts.push((x, y)); } } }+            if let Some(p) = n.find("pts") { for xy in p.find_all("xy") { if let (Some(x), Some(y)) = (xy.number(1), xy.number(2)) { pts.push((x, y)); } } }+            if n.tag == "fp_circle" {+                if let (Some(c), Some(e)) = (n.find("center"), n.find("end")) {+                    if let (Some(cx), Some(cy), Some(ex), Some(ey)) = (c.number(1), c.number(2), e.number(1), e.number(2)) {+                        let rr = (ex - cx).hypot(ey - cy); pts.push((cx - rr, cy - rr)); pts.push((cx + rr, cy + rr));+                    }+                }+            }+        }+        if pts.len() < 2 {+            // no fab outline (a board converted from Fusion): the pads' box is the body's footprint+            if r.starts_with("TP") { continue; }+            if let Some(bb) = b.pad_box(r) { out.push((r.clone(), bb)); }+            continue;+        }+        let placed: Vec<(f64, f64)> = pts.iter().map(|&(dx, dy)| { let (a, c) = geom::rot_kicad(dx, dy, fp.rot); (fp.x + a, fp.y + c) }).collect();+        let bb = geom::bbox(&placed);+        // a test point's own fab ring is not a body+        if r.starts_with("TP") { continue; }+        out.push((r.clone(), bb));+    }+    out+}++fn in_box(p: (f64, f64), b: &(f64, f64, f64, f64), grow: f64) -> bool { p.0 > b.0 - grow && p.0 < b.2 + grow && p.1 > b.1 - grow && p.1 < b.3 + grow }++/// Board mode: for each measured net, the best landing (x, y, on which pad) or why there is none.+pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec<String>) {+    let bodies = bodies(b);+    let grounds: Vec<(f64, f64, String)> = b.footprints.iter().filter(|(_, fp)| fp.lib.to_ascii_lowercase().contains("machinecontact"))+        .flat_map(|(r, _)| b.pads_of(r).into_iter().filter(|p| p.net.eq_ignore_ascii_case("GND")).map(move |p| (p.x, p.y, r.clone()))).collect();+    let (mut rows, mut gaps, mut warn) = (Vec::new(), Vec::new(), Vec::new());+    if grounds.is_empty() { warn.push("no MachineContact on GND: the ground tether has nowhere to plug in (add one near each corner, adom/probing-workcell)".into()); }+    for t in tests {+        let net = t["net"].as_str().unwrap_or("");+        let kind = t["kind"].as_str().unwrap_or("dc");+        if !needs_spot(kind) { rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "camera"})); continue; }+        let mut best: Option<(f64, f64, String, f64)> = None;+        let mut why = "no exposed copper on this net".to_string();+        for p in b.pads.iter().filter(|p| p.net == net && p.on_f) {+            // sample landing centres inside the pad, shrunk by the landing radius+            let (hx, hy) = (p.size.0 / 2.0 - LANDING_R, p.size.1 / 2.0 - LANDING_R);+            if hx < -1e-9 || hy < -1e-9 { why = format!("pads too small for a 0.5 mm landing (e.g. {} {:.2}x{:.2} mm)", p.key, p.size.0, p.size.1); continue; }+            let steps = 5;+            for i in 0..=steps { for j in 0..=steps {+                let lx = if steps > 0 { -hx + 2.0 * hx * i as f64 / steps as f64 } else { 0.0 };+                let ly = if steps > 0 { -hy + 2.0 * hy * j as f64 / steps as f64 } else { 0.0 };+                let (dx, dy) = geom::rot_kicad(lx, ly, p.angle);+                let c = (p.x + dx, p.y + dy);+                if let Some((r, _)) = bodies.iter().find(|(_, bb)| in_box(c, bb, LANDING_R)) {+                    why = format!("every landing on {net} is under a part body (e.g. {} under {r})", p.key); continue;+                }+                if b.pads.iter().any(|q| q.net != net && (q.x - c.0).hypot(q.y - c.1) < q.half_long + LANDING_R + SHORT_GAP) {+                    why = format!("every landing on {net} is within {SHORT_GAP} mm of another net's pad (short risk)"); continue;+                }+                let reach = grounds.iter().map(|g| (g.0 - c.0).hypot(g.1 - c.1)).fold(f64::MAX, f64::min);+                if !grounds.is_empty() && reach > REACH { why = format!("{net}'s landings are {reach:.0} mm from the nearest ground contact (tether reach {REACH} mm)"); continue; }+                // prefer test points, then the landing nearest a ground contact+                let score = if p.reference.starts_with("TP") { -1000.0 } else { 0.0 } + if reach.is_finite() { reach } else { 0.0 };+                if best.as_ref().map(|b| score < b.3).unwrap_or(true) { best = Some((c.0, c.1, p.key.clone(), score)); }+            } }+        }+        match best {+            Some((x, y, pad, _)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else { "exposed pad" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})),+            None => { if !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); } rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "gap", "why": why})); }+        }+    }+    (rows, gaps, warn)+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn netlist_gaps_and_added_points() {+        let mut nl = json!({"components": [{"ref": "U1"}, {"ref": "TP1", "fp": "TestPoint"}],+            "nets": [["VIN", ["U1.3", "TP1.1"]], ["SW", ["U1.2"]], ["GND", ["U1.1"]]]});+        let tests = vec![json!({"id": "vin", "net": "VIN", "kind": "dc"}), json!({"id": "sw", "net": "SW", "kind": "waveform"}), json!({"id": "led", "net": "LED", "kind": "camera"}), json!({"id": "x", "net": "NOPE", "kind": "dc"})];+        let (rows, gaps, unknown) = check_netlist(&tests, &nl);+        assert_eq!(gaps, vec!["SW".to_string()]);+        assert_eq!(unknown, vec!["NOPE".to_string()]);+        assert_eq!(rows[0]["status"], "test point");+        let added = add_test_points(&mut nl, &gaps);+        assert_eq!(added, vec!["TP2 on SW".to_string()]);+        assert!(nl["nets"][1][1].as_array().unwrap().contains(&json!("TP2.1")));+        let (_, gaps2, _) = check_netlist(&tests, &nl);+        assert!(gaps2.is_empty());+    }+}
crates/adom-aiflow/src/thermalvias.rsadded+137
@@ -0,0 +1,137 @@+//! Heatsink vias: for every part that dissipates real power (spec.hot: {REF: {watts, tabNet}}), an+//! array of vias beside the pads it dumps heat through, on that net, so the heat reaches the copper on+//! the other side. The count comes from the dissipation and the rise budget; existing vias of the net+//! near the part count toward it. Never on the switch node. The plan is the same shape the pour planner+//! writes, so `land vias --plan` lands it.++use aiflow_board::Board;+use serde_json::{json, Value};++/// Thermal resistance of one plated 0.3 mm via through 1.6 mm FR4, about 100 C/W; the count keeps+/// the vias' share of the rise under half the budget.+pub fn target(watts: f64, max_rise: f64) -> usize {+    ((watts * 100.0 / (max_rise.max(1.0) * 0.5)).ceil() as usize).clamp(6, 16)+}++fn point_in(poly: &[(f64, f64)], p: (f64, f64)) -> bool {+    let mut inside = false;+    let n = poly.len();+    if n < 3 { return true; }+    let mut j = n - 1;+    for i in 0..n {+        let (xi, yi) = poly[i]; let (xj, yj) = poly[j];+        if (yi > p.1) != (yj > p.1) && p.0 < (xj - xi) * (p.1 - yi) / (yj - yi + 1e-12) + xi { inside = !inside; }+        j = i;+    }+    inside+}++fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 < 1e-12 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+    (p.0 - a.0 - t * dx).hypot(p.1 - a.1 - t * dy)+}++pub struct Part { pub reference: String, pub net: String, pub target: usize, pub existing: usize, pub planned: Vec<(f64, f64)>, pub note: Option<String> }++/// Plan the vias. `poured_both` says whether a net has copper on both outer layers (a via only moves+/// heat where the net has copper on the other side).+pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) {+    let size = spec["viaPower"].as_array().and_then(|a| a.first()).and_then(|v| v.as_f64()).or(spec["viaPower"].as_f64()).unwrap_or(0.6);+    let drill = spec["viaPower"].as_array().and_then(|a| a.get(1)).and_then(|v| v.as_f64()).or(spec["drillPower"].as_f64()).unwrap_or(0.3);+    let clearance = spec["clearance"].as_f64().unwrap_or(0.2);+    let edge = spec["edgeClearance"].as_f64().unwrap_or(0.3);+    let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);+    let pours = spec["pours"].as_array().cloned().unwrap_or_default();+    let poured_both = |net: &str| -> bool {+        let mut f = false; let mut bk = false;+        for p in pours.iter().filter(|p| p["net"].as_str() == Some(net)) {+            for l in p["layers"].as_array().cloned().unwrap_or_default() { match l.as_str() { Some("F.Cu") => f = true, Some("B.Cu") => bk = true, _ => {} } }+        }+        f && bk+    };+    let tracks = b.tracks(); let vias = b.vias();+    let mut placed: Vec<(f64, f64)> = Vec::new();+    let mut parts = Vec::new();+    let mut entries = Vec::new();+    let hot = spec["hot"].as_object().cloned().unwrap_or_default();+    for (reference, h) in hot {+        let watts = h["watts"].as_f64().unwrap_or(0.0);+        let net = h["tabNet"].as_str().unwrap_or("GND").to_string();+        let tgt = target(watts, h["maxRiseC"].as_f64().unwrap_or(max_rise));+        let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+        let mut part = Part { reference: reference.clone(), net: net.clone(), target: tgt, existing: 0, planned: vec![], note: None };+        if sw_nets.contains(&net) { part.note = Some(format!("{net} is a switch node: no heatsink vias on it")); parts.push(part); continue; }+        if pads.is_empty() { part.note = Some(format!("{reference} has no pad on {net}")); parts.push(part); continue; }+        if !poured_both(&net) { part.note = Some(format!("{net} has no pour on both layers: a via would carry the heat nowhere (pour {net} on B.Cu under {reference}, or ack)")); parts.push(part); continue; }+        // vias of the net already within 3 mm of the part's heat pads+        let near = |x: f64, y: f64| pads.iter().any(|p| (p.x - x).hypot(p.y - y) < 3.0);+        part.existing = vias.iter().filter(|v| v.net == net && near(v.x, v.y)).count();+        let need = tgt.saturating_sub(part.existing);+        // candidate spots on rings around each heat pad, nearest first+        let mut cands: Vec<(f64, (f64, f64))> = Vec::new();+        for p in &pads {+            for ring in 0..6 {+                let r = p.half_long + size / 2.0 + clearance + 0.1 + ring as f64 * (size + 0.25);+                let n = (2.0 * std::f64::consts::PI * r / (size + 0.3)).floor().max(6.0) as usize;+                for k in 0..n {+                    let a = 2.0 * std::f64::consts::PI * k as f64 / n as f64;+                    cands.push((r, (p.x + r * a.cos(), p.y + r * a.sin())));+                }+            }+        }+        cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+        let rr = size / 2.0;+        for (_, c) in cands {+            if part.planned.len() >= need { break; }+            if !b.outline.is_empty() && (!point_in(&b.outline, c) || b.outline.windows(2).any(|w| seg_dist(c, w[0], w[1]) < rr + edge)) { continue; }+            // clear of other nets' pads and tracks, of every via, and of every part's pads (no via in a pad)+            if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long + rr + clearance) { continue; }+            if tracks.iter().any(|t| t.net != net && seg_dist(c, t.a, t.b) < t.width / 2.0 + rr + clearance) { continue; }+            if vias.iter().chain([].iter()).any(|v| (v.x - c.0).hypot(v.y - c.1) < v.size / 2.0 + rr + clearance) { continue; }+            if placed.iter().any(|q| (q.0 - c.0).hypot(q.1 - c.1) < size + 0.25) { continue; }+            placed.push(c); part.planned.push(c);+            entries.push(json!({"net": net, "paths": [[[c.0, c.1], {"layer": "B.Cu", "x": c.0, "y": c.1}]], "viaSize": size, "viaDrill": drill, "width": size, "why": format!("heatsink via for {reference}")}));+        }+        if part.existing + part.planned.len() < tgt { part.note = Some(format!("room for {} of {} vias near {reference}: move neighbours or widen its {net} copper", part.existing + part.planned.len(), tgt)); }+        parts.push(part);+    }+    (parts, json!({"engine": "adom-aiflow thermal-vias plan", "nets": entries}))+}++/// After landing: (reference, have, target) for every hot part short of its target.+pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, usize)> {+    let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);+    let vias = b.vias();+    let mut out = Vec::new();+    for (reference, h) in spec["hot"].as_object().cloned().unwrap_or_default() {+        if acked.contains(&reference) { continue; }+        let net = h["tabNet"].as_str().unwrap_or("GND");+        let tgt = target(h["watts"].as_f64().unwrap_or(0.0), h["maxRiseC"].as_f64().unwrap_or(max_rise));+        let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+        if pads.is_empty() { continue; }+        let have = vias.iter().filter(|v| v.net == net && pads.iter().any(|p| (p.x - v.x).hypot(p.y - v.y) < 3.0)).count();+        if have < tgt { out.push((reference, have, tgt)); }+    }+    out+}++#[cfg(test)]+mod tests {+    #[test]+    fn targets_scale_with_power() {+        assert_eq!(super::target(0.25, 10.0), 6);+        assert_eq!(super::target(0.05, 10.0), 6);+        assert_eq!(super::target(2.0, 10.0), 16);+    }+    #[test]+    fn real_board_if_present() {+        let (Ok(bp), Ok(sp)) = (std::env::var("AIFLOW_TV_BOARD"), std::env::var("AIFLOW_TV_SPEC")) else { return };+        let b = aiflow_board::Board::load(&bp).unwrap();+        let spec: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(sp).unwrap()).unwrap();+        let (parts, plan) = super::plan(&spec, &b, &["SW".into()]);+        for p in parts { println!("PART {} {} target {} existing {} planned {} {:?}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note); }+        println!("ENTRIES {}", plan["nets"].as_array().unwrap().len());+    }+}
crates/aiflow-bridge/src/fusion.rs+18−1
@@ -160,8 +160,25 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value             if verb == "kicad_open_board" { json!({"success": hwnd.is_some(), "hwnd": hwnd}) }             else { json!({"success": true, "data": {"editors": hwnd.map(|h| vec![json!({"kind": "pcb_editor", "hwnd": h}), json!({"kind": "3d_viewer", "hwnd": h})]).unwrap_or_default()}}) }         }+        // "Zoom to Selected Objects" after a net select: the select already framed the board, and a WINDOW+        // now would end the open SHOW and drop the highlight+        "kicad_send_key" if args["menu"].as_str() == Some("Zoom to Selected Objects") => json!({"success": true, "note": "framed by the select (Fusion keeps the highlight only while SHOW is open)"}),         "kicad_send_key" => { fit_board(doc, call); json!({"success": true}) }-        "kicad_select_net" => call("fusion_electron_run", &json!({"command": format!("SHOW '{}'; WINDOW FIT;", args["net"].as_str().unwrap_or(""))})),+        "kicad_select_net" => {+            // Fusion highlights a net with SHOW; what was lit is counted from the board source, the way+            // KiCad's select answers, so the walkthrough knows a net with no copper from a lit one+            let net = args["net"].as_str().unwrap_or("");+            let src = call("fusion_board_source", &json!({"expectDocument": doc}));+            let text = src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("").to_string();+            let sig = text.split(&format!("<signal name=\"{net}\"")).nth(1).map(|t| t[..t.find("</signal>").unwrap_or(t.len())].to_string()).unwrap_or_default();+            let (wires, polys, vias, pads) = (sig.matches("<wire ").count(), sig.matches("<polygon ").count(), sig.matches("<via ").count(), sig.matches("<contactref ").count());+            // no closing semicolon: Fusion keeps a SHOW highlight only while the command stays open (checked+            // live 2026-10-07; `SHOW 'X';` lit nothing). The next SHOW replaces it, `SHOW;` clears it.+            let arg = if net.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+' || c == '.') { net.to_string() } else { format!("'{net}'") };+            fit_board(doc, call);  // frame first: any later command would end the SHOW and its highlight+            let r = call("fusion_electron_run", &json!({"command": format!("SHOW {arg}")}));+            json!({"success": r["success"], "selected": wires + polys + vias + pads, "byKind": {"tracks": wires, "zones": polys, "vias": vias, "pads": pads}, "error": r["error"]})+        }         "kicad_select_clear" => call("fusion_electron_run", &json!({"command": "SHOW;"})),         "kicad_open_3d_viewer" => { let r = call("fusion_show_3d_board", &json!({"documentName": doc})); json!({"success": r["success"], "hwnd": main_hwnd(call)}) }         "kicad_close_3d_viewer" => call("fusion_show_2d_board", &json!({"documentName": doc})),
crates/aiflow-run/src/lib.rs+2
@@ -253,6 +253,8 @@ impl Run {     pub fn outcome(&mut self, key: &str, v: Value) {         self.log("outcome", json!({"stage": key, "outcome": v}));         self.data["outcomes"][key] = v;+        // when, so a step's proof can be checked against its latest visit+        self.data["outcomeAt"][key] = json!(now());     }      pub fn outcomes(&self) -> &Map<String, Value> {
docs/fusion-lane.md+17
@@ -92,6 +92,23 @@ Spec coordinates (pour polygons, `planeUnder` regions, stitch vias) are in the r written for a KiCad board elsewhere on the sheet must be moved into it, or every one of them lands off the board without an error. +## Required steps, the timeline and the new checks (0.1.60)++The Fusion lane's required steps, in order: intake, rules, design, test-plan, sourcing, library,+schematic, simulation, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets,+silkscreen, 3d, finish, export. None can be skipped (`step` and `finish` refuse), and `timeline open`+shows where the run is. New Fusion verbs and how they land, all checked live on 2026-10-07:++- `rules use --profile 3rdPartyFab` loads the pack with `fusion_set_design_rules {fab, layers}`.+- `outline round` runs `MITER <r> (x y)` at each sharp corner of the dimension layer; Fusion writes a+  `curve="90"` arc.+- `tour nets` uses `SHOW <net>` with no closing semicolon (the highlight lives only while SHOW is open;+  `SHOW 'X';` lights nothing), framing first because any later command ends it; `SHOW;` clears.+- `fusion silkscreen` lands one `SCRIPT` file: the bridge refuses a bare `MOVE` (interactive without+  coordinates), and inside a script `SMASH`, `ROTATE =R0 'R1>NAME'`, `MOVE 'R1>NAME' (x y)` and `TEXT`+  all work. A smashed name keeps its package's alignment (bottom-center from adom-lbr), so the move+  target is that anchor of the planned box. `CHANGE SIZE ... 'R1>NAME'` does not land; names stay 0.8 mm.+ ## The fields step on a Fusion board  adom-fields 0.6.0 reads a Fusion `.brd` directly and uses Fusion's computed pour fill, so solve the
docs/release-0.1.60.mdadded+16
@@ -0,0 +1,16 @@+# AI Flow 0.1.60++From a review of the Fusion buck video (2026-10-07): steps were skipped, test points were square pads with a black placeholder box, the switch node ran under both input caps, there were too few heatsink vias, the corners were sharp, and nothing showed where the run was. Every one of those is now code, not advice.++- **No step can be skipped.** A run carries its lane's required steps in order (`flows/board.json` `sequence`). `step <x>` refuses while a required step before x is not done, and a step is done only when its own command ran during its latest visit (`proofs`). Going back needs `--back --why`; every later step then shows as a redo and must be done again. `finish` refuses until the chain is complete. The Fusion run that prompted this had skipped `silkscreen`, `nets` and `models` with nothing to stop it.+- **The timeline.** `timeline open` serves a live dashboard (in Adom's colours: teal checks, purple loop-back arcs) in the right pane of Hydrogen: every step by phase, the current one, how many to go, every return with its reason, to the finish line. Every `step` updates it.+- **Design rules first.** `rules use --profile 3rdPartyFab|<pack> [--layers N]` loads a fab's pack from adom/pcb-design-rules into KiCad and the open Fusion design and records it in the spec; `rules list` shows the 53 packs. A required step right after intake.+- **The test plan.** `testplan check --tests tests.json` (skill aiflow-test-plan): every measurement planned for the probing workcell gets a probe spot; before the schematic `--netlist ... --add` adds 0.5 mm round test points (adom/testpoint-pad-0-5mm), on a board it finds a real 0.5 mm landing clear of part bodies and other nets within tether reach of a ground contact. finish runs it again.+- **The switch node.** `gate` and `finish` refuse switch copper under another part (fab outline, else its pads' box) or a switch node longer than `switchNodeMaxLengthMm` (12 mm), and send the AI back to placement.+- **Heatsink vias.** `thermal-vias plan` / `land`: a via target per hot part (spec.hot watts and the rise budget, at least 6), placed beside its heat pads clear of other nets; finish refuses a hot part short of its target unless acked. `land vias --plan` takes any via plan.+- **Rounded corners.** `outline round` turns every sharp outline corner into an arc (1.0 mm default, `outline.cornerRadius`), live in Fusion with MITER; finish refuses a sharp corner.+- **The pour walkthrough in Fusion.** `tour nets` lights every poured net on its own (Fusion keeps a SHOW highlight only while the command is open; checked live), framed, one after another.+- **Silkscreen in Fusion.** `fusion silkscreen`: every part name placed readable (R0, else R90) clear of pads, bodies, vias, the edge and other text, each machine contact's net on its edge side, the board identity on the bottom, landed as one SCRIPT and read back. Names that cannot fit are listed.+- **Export is checked.** `fusion export` checks the Gerber zip has copper, mask, silkscreen, outline and drill, and that the BOM and pick and place name every part on the board, before it says they are good to go.+- **3D models read as CAD tools read them.** The STEP rotation keeps a representation's own frame and moves an assembly's top placement instead of turning the geometry twice; extents are measured in world coordinates; and `fusion library` checks every model with the Adom STEP service (`step2glb features`) and refuses one that is not Z-up and seated. ORIENT_RULES 2 re-checks every part.+- **Simulation after the schematic** in the Fusion sequence (the SPICE study runs on the drawn schematic).
flows/board.json+277−7
@@ -12,6 +12,35 @@         "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."       ]     },+    {+      "name": "rules",+      "who": "both",+      "does": "Choose the fab and load its design rules before anything is drawn: the AI asks the human which fab builds the board (3rdPartyFab by default, JLCPCB on request) and loads that fab's rule profile into the EDA (KiCad .kicad_dru, Fusion DRC rules), so every later step designs to the process that will build it.",+      "binary": [+        "rules use",+        "rules check"+      ],+      "record": "the AI asking which fab and which design rules, the human's answer, then the rules loading in the EDA (Fusion's DRC dialog or KiCad's board setup) with the key numbers on screen",+      "workflow": [+        "Ask the human which fab will build the board and name the choices: 3rdPartyFab (default, Adom's own process; its rules are private, fetched with an Adom login and never restated on a public page) or JLCPCB (published 2-layer capabilities). Never guess: a board drawn to the wrong process is a respin.",+        "`adom-aiflow rules use --profile 3rdPartyFab` (or jlcpcb) writes the profile into the spec (fab.rules), installs the KiCad rules beside the board and, on a Fusion run, loads the Fusion DRC rules into the open design. `rules check` must pass before placement.",+        "Say the rules out loud in the chat in plain words (minimum track, clearance, via, edge clearance) only for public profiles; for 3rdPartyFab say 'the 3rdPartyFab 2-layer rules are loaded' and nothing more."+      ]+    },+    {+      "name": "test-plan",+      "who": "ai",+      "does": "Think through how this board will be tested once it is built and on the probing workcell: the measurements a careful engineer would take (rails, switch node, feedback, ripple, enable, LEDs), the net each one needs, and a probe spot for every one. The binary checks each planned measurement has a probe-able spot (a 0.5 mm round test point or an exposed pad the pogo tip can land on, within 50 mm of a ground machine contact, not under a part body) and adds a test point to the netlist for every gap.",+      "binary": [+        "testplan check"+      ],+      "record": "the AI writing its test plan in the chat (what it will measure and why), then the binary's coverage table: each measurement, its net, its probe spot, and the test points it added",+      "workflow": [+        "Write tests.json: one entry per measurement {id, what, net, why, kind: dc|ripple|waveform|camera|ground, expect}. Think like the engineer at the bench: the input rail at the board, the output rail, the switch node, both inductor ends, feedback, enable, power-good, the bulk caps' ripple, every LED (camera), the ground pin. Use the circuit and the simulation, not a template.",+        "`adom-aiflow testplan check --tests tests.json` maps every measurement to a probe spot on the netlist (and on the board once it exists), and adds `TP<n>` test points (adom/testpoint-pad-0-5mm, 0.5 mm round exposed copper, no paste) for each gap. Rerun it after placement and before finish: a spot under a part body or out of tether reach is a gap again.",+        "Stephen's workcell numbers: smallest probing pad 200 um (pads under 300 um need a human crosshair check, so 0.5 mm is the default); two probes can work about 3 mm apart; every probed point within 50 mm of a MachineContactMedium ground contact; parts under a probed point under 18 mm tall."+      ]+    },     {       "name": "components",       "who": "ai",@@ -80,7 +109,7 @@     {       "name": "placement",       "who": "ai",-      "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves; reserve visible space for connector labels and the two-sided service silkscreen",+      "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves; reserve visible space for connector labels and the two-sided service silkscreen; the switch-node check refuses switch copper under another part or a switch loop longer than the spec allows, and sends the AI back here",       "binary": [         "place pack",         "place check",@@ -100,6 +129,20 @@       ],       "record": "the traces drawn ONE AT A TIME (land route), then the vias: a net with several paths lands as several undo steps, so every trace is seen being drawn; the longest clip, the one viewers speed up"     },+    {+      "name": "thermal-vias",+      "who": "binary",+      "does": "Heatsink vias where the heat is: for every part that dissipates real power (the regulator, the inductor, the catch diode, the FETs), an array of vias in its thermal pad or the copper it dumps heat into, stitched to the ground or the plane on the other side, sized and counted from its dissipation; the AI reviews the plan and the binary lands it.",+      "binary": [+        "thermal-vias plan",+        "thermal-vias land"+      ],+      "record": "the via arrays landing one part at a time beside the hot parts, then the count per part against its target",+      "workflow": [+        "`adom-aiflow thermal-vias plan` estimates each hot part's dissipation (spec loads, the datasheet's efficiency or Rds(on), the inductor's DCR) and proposes an array: count, drill, pitch, the net it stitches (usually GND), in the part's thermal pad or the copper beside its hottest pin. Never on the switch node.",+        "Review it: add vias where a part's heat has nowhere to go; then `thermal-vias land`. The thermal step and finish refuse while a hot part is below its target."+      ]+    },     {       "name": "pours",       "who": "binary",@@ -111,6 +154,18 @@       ],       "record": "the pours filling in (land pours) and the copper readback; keep the DRC refusals in the clip, they are the story"     },+    {+      "name": "outline",+      "who": "binary",+      "does": "Round the board's corners: every sharp outline corner becomes a radius (1.0 mm by default, spec outline.cornerRadius), so the board is kind to hands, panels and the fab's router.",+      "binary": [+        "outline round"+      ],+      "record": "the board corners changing from sharp to round in the editor",+      "workflow": [+        "`adom-aiflow outline round` replaces each sharp outline corner with an arc of the spec's radius (default 1.0 mm), keeps copper clear of the new edge, and lands it. finish refuses a board with a sharp outside corner."+      ]+    },     {       "name": "current",       "who": "binary",@@ -147,11 +202,11 @@     {       "name": "nets",       "who": "binary",-      "does": "walk the key nets on camera: each loaded, wide or Kelvin net selected as a whole so the editor highlights its pours, tracks, vias and pads together, framed with Zoom to Selection",+      "does": "the copper pour walkthrough: every poured net (and every loaded, wide or Kelvin net) highlighted on its own in turn, the board framed, so the viewer sees where each net's copper is; the AI names each one",       "binary": [         "tour nets"       ],-      "record": "the editor with one net lit at a time, framed; the markers say which net"+      "record": "the editor with one net lit at a time, all of the pour nets in sequence, the board framed; the chat names the net"     },     {       "name": "silkscreen",@@ -165,13 +220,13 @@         "Keep each reference unmistakably associated with its own component. Prefer reducing reference font size locally over moving a label farther away. Aim for complete reference coverage; use a short clear leader only when proximity alone is ambiguous. Treat approximately 0.8 mm as an initial reference size, not a minimum. For dense 3rd party fab artwork Adam explicitly permits secondary values at 0.3 mm or even 0.2 mm (2026-09-16); try 0.5, 0.3 then 0.2 mm while preserving the ref/value pairing and required stroke/spacing. These tiny sizes are user-requested artwork options, not independently verified laser-process capability. Keep the actual sizes and any unresolved physical legibility/DFM limits in the review; do not silently omit labels or globally weaken fab rules. Inspect the result at actual size and close-up.",         "Avoid mask openings, contact surfaces, holes, fiducials and bodies that hide labels. Inspect bottom mirroring, actual-size legibility and both native 2D/3D faces. Use EDA bridge text/plot/DRC operations; give missing primitives back to the bridge.",         "Run native DRC against the chosen fab profile and compare with the baseline. Preserve connectivity, placement, copper, outline and model transforms for silk-only edits. Register the label manifest and top/bottom evidence; return here when placements or pinouts change.",-        "Record BEFORE the first silkscreen mutation: show labels appearing individually or in small meaningful groups, pairing references with smaller values. Film actual moves, resizing, rotations and overlap corrections in order; preserve a timestamped operation/reason sidecar and raw uncaptioned footage. Use native bridge edits and refresh, never invented verbs. If native incremental editing is missing, file the bridge gap; identify any checkpoint reconstruction as a replay, never as original live placement. Keep a detailed action cut and use 3\u20135 seconds of accelerated population/rework in the final 120-second film. Read docs/silkscreen.md for recording and evidence rules.",+        "Record BEFORE the first silkscreen mutation: show labels appearing individually or in small meaningful groups, pairing references with smaller values. Film actual moves, resizing, rotations and overlap corrections in order; preserve a timestamped operation/reason sidecar and raw uncaptioned footage. Use native bridge edits and refresh, never invented verbs. If native incremental editing is missing, file the bridge gap; identify any checkpoint reconstruction as a replay, never as original live placement. Keep a detailed action cut and use 3–5 seconds of accelerated population/rework in the final 120-second film. Read docs/silkscreen.md for recording and evidence rules.",         "Require positional contact labels on BOTH faces: a pinout table is supplementary, never a substitute for text beside each actual machine contact, machine pin or edge connection. Separate primary reference (MC10) and smaller secondary function (DSHOT) as independently sized paired text. Prefer consistent reading directions. Where association remains ambiguous, add a short gentle curved silkscreen leader ending outside the intended pad mask opening; avoid crossings and obstacles. Verify one-to-one pad association, bottom mirroring, label/leader clearance and legibility in close-up native views and DRC. Film real additions and rework; retain mapping and unresolved constraints. See docs/silkscreen.md.",         "For the requested 3rd party fab profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.",         "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.",         "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md."       ],-      "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3\u20135 second final-video excerpt. Clearly label reconstructed replay.",+      "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3–5 second final-video excerpt. Clearly label reconstructed replay.",       "hints": [         "Final documentation pass: prefer open interior regions for a verified board description and ordered left-aligned service blocks. Physical pin/contact labels remain local. Apply docs/silkscreen-priority-pass.md; inspect interior alternatives at readable fonts and report explicit edge fallbacks. Native Rust silkscreen-sections performs the documentation solve; preflight, native DRC and both EDA views still gate acceptance."       ]@@ -209,6 +264,18 @@         "deliver"       ],       "record": "the finish line passing (0 unconnected, 0 new errors), then the cut video and the delivery message. Before delivering, record the run's cost for the process video: model calls and tokens (input, cache reads, cache writes, output) from the AI's own session logs inside the run's clock window, sub-agents included; plan-limit percentages are not a cost. Then build the process video per skill aiflow-process-video: every scene loaded into video-post's storyboard for per-clip review before assembly."+    },+    {+      "name": "export",+      "who": "both",+      "does": "The finish line for the human: Gerbers, drill, BOM and pick-and-place (CPL) written from the finished board, checked, and announced in the chat with where every file is.",+      "binary": [+        "export"+      ],+      "record": "the AI's prompt asking for the files, the export running, then the chat listing every file with its path and saying they are good to go",+      "workflow": [+        "`adom-aiflow export` (Fusion: `fusion export`) writes the manufacturing set, re-reads every file (layer count, drill hits, BOM refs against the board, CPL refs and rotations) and refuses a set that does not match the board."+      ]     }   ],   "returns": "any step may send the AI back to any earlier step: step <name> --back --why \"...\"",@@ -244,5 +311,208 @@     }   ],   "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop",-  "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one"-}+  "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one",+  "sequence": {+    "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit.",+    "fusion": [+      "intake",+      "rules",+      "design",+      "test-plan",+      "sourcing",+      "library",+      "schematic",+      "simulation",+      "placement",+      "routing",+      "thermal-vias",+      "pours",+      "outline",+      "current",+      "thermal",+      "fields",+      "nets",+      "silkscreen",+      "3d",+      "finish",+      "export"+    ],+    "kicad": [+      "intake",+      "rules",+      "components",+      "libraries",+      "models",+      "test-plan",+      "placement",+      "routing",+      "thermal-vias",+      "pours",+      "outline",+      "current",+      "thermal",+      "fields",+      "nets",+      "silkscreen",+      "3d",+      "finish",+      "export"+    ]+  },+  "proofs": {+    "_": "outcomes a step must record during its latest visit (any one of the list)",+    "rules": [+      "rules"+    ],+    "test-plan": [+      "testplan"+    ],+    "placement": [+      "place-land"+    ],+    "routing": [+      "land-route"+    ],+    "thermal-vias": [+      "thermal-vias"+    ],+    "pours": [+      "land-pours"+    ],+    "outline": [+      "outline"+    ],+    "current": [+      "analyze-current"+    ],+    "thermal": [+      "analyze-thermal"+    ],+    "fields": [+      "tour-fields"+    ],+    "nets": [+      "tour-nets"+    ],+    "silkscreen": [+      "silkscreen"+    ],+    "3d": [+      "tour-3d"+    ],+    "simulation": [+      "evidence-simulation"+    ],+    "export": [+      "export"+    ],+    "sourcing": [+      "sourcing"+    ],+    "library": [+      "library"+    ],+    "schematic": [+      "schematic"+    ],+    "models": [+      "models"+    ]+  },+  "titles": {+    "intake": "Intake",+    "rules": "Design rules",+    "design": "Circuit design",+    "simulation": "SPICE simulation",+    "test-plan": "Test plan",+    "sourcing": "Sourcing",+    "library": "Library",+    "components": "Components",+    "libraries": "Libraries",+    "models": "3D models",+    "schematic": "Schematic",+    "placement": "Placement",+    "routing": "Routing",+    "thermal-vias": "Heatsink vias",+    "pours": "Copper pours",+    "outline": "Rounded corners",+    "current": "Current",+    "thermal": "Thermal",+    "fields": "Fields",+    "nets": "Pour walkthrough",+    "silkscreen": "Silkscreen",+    "3d": "3D board",+    "finish": "Finish line",+    "export": "Gerbers, BOM, CPL"+  },+  "short": {+    "intake": "read the prompt and the requirements",+    "rules": "choose the fab, load its design rules",+    "design": "datasheet math and part values",+    "simulation": "the drawn schematic in SPICE: start-up, ripple, load step",+    "test-plan": "what to measure on the workcell, a probe spot for each",+    "sourcing": "every part in stock, with an MPN",+    "library": "symbols, footprints and 3D from the wiki",+    "components": "wiki component identity and CAD audit",+    "libraries": "the project library in the EDA",+    "models": "every 3D model bound and upright",+    "schematic": "drawn live, checked against the netlist",+    "placement": "parts placed for current and heat",+    "routing": "every trace routed, DRC clean",+    "thermal-vias": "via arrays under the hot parts",+    "pours": "copper pours and stitching",+    "outline": "sharp board corners rounded",+    "current": "current density on every loaded net",+    "thermal": "temperature rise against the budget",+    "fields": "current and heat solved in 3D",+    "nets": "each pour net lit in turn",+    "silkscreen": "labels, polarity, pinouts, both faces",+    "3d": "the finished board in 3D",+    "finish": "0 unconnected, 0 errors, every check passed",+    "export": "manufacturing files written and checked"+  },+  "phases": {+    "Plan": [+      "intake",+      "rules"+    ],+    "Circuit": [+      "design",+      "test-plan"+    ],+    "Parts": [+      "sourcing",+      "library",+      "components",+      "libraries",+      "models"+    ],+    "Schematic": [+      "schematic",+      "simulation"+    ],+    "Board": [+      "placement",+      "routing",+      "thermal-vias",+      "pours",+      "outline"+    ],+    "Analysis": [+      "current",+      "thermal",+      "fields",+      "nets"+    ],+    "Finish": [+      "silkscreen",+      "3d",+      "finish",+      "export"+    ]+  },+  "finishLine": {+    "fusion": "Gerbers, BOM and CPL exported",+    "kicad": "Gerbers, BOM and CPL exported"+  }+}\ No newline at end of file
package.json+4−3
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.59",+  "version": "0.1.60",   "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.",@@ -83,6 +83,7 @@     }   ],   "confirmed_user_skills": [-    "aiflow-simulate"+    "aiflow-simulate",+    "aiflow-test-plan"   ]-}\ No newline at end of file+}
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.59",+  "version": "0.1.60",   "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.",
skills/adom-aiflow/SKILL.md+76−54
@@ -14,7 +14,7 @@ Every state-changing command takes `--ai-thread "<your thread name>"`. Every run  ## The flow -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.+The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.  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.@@ -31,67 +31,88 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac 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; 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`. +## No step is skipped++A run carries its lane's required steps in order (`flows/board.json` `sequence`; Fusion: intake, rules,+design, test-plan, sourcing, library, schematic, simulation, placement, routing, thermal-vias, pours,+outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step <x>` refuses while any+required step before x is not done, and a step is done only when its own command ran during its latest+visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land+moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `fusion silkscreen`, `fusion+export` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it+draws an arc on the timeline, and every later step becomes a redo that must be done again in order.+`finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never+jump past it.++## The timeline (show it, keep it up)++`adom-aiflow timeline open` serves the run's timeline and opens it as a Hydrogen webview in the right+pane of the workspace the run is driving (for a demo, the filmed Hydrogen, never your own): every+required step grouped by phase, a teal check on each done step, the current one lit, "Step 9 of 21, 12 to+go", and a purple arc for every return with its reason, ending at the finish line (Gerbers, BOM and CPL+exported). Every `step` refreshes it within a second. Open it at intake and leave it up.+ ## A board in Autodesk Fusion  When the human asks for a board in Fusion ("a 12 V to 5 V buck converter using the TPS54202, in Fusion"), the whole flow below is yours: they should never have to spell out a step. Read `docs/fusion-lane.md` once; the Fusion bridge must be 1.11.33 or later. Hydrogen stays full screen behind Fusion; keep Fusion windowed (about 90 % of the screen), never maximized, so the human sees both and a screen recording-catches Fusion's 3D view. Inside Hydrogen, the VS Code tab (the chat) always stays in the left pane and-every webview (the parts pages, the SPICE dashboard, the fields app) opens in the right pane; never widen-or collapse a pane to make a webview bigger. `step` brings the window each step works in to the front: Hydrogen for intake,-design, simulation, sourcing, the library, current, thermal and fields (the chat and its webviews), Fusion-for the schematic, placement, routing, pours, 3D and finish; the board is framed by its outline.--1. **The parts, from their wiki component pages.** Design the circuit (aiflow-circuit-design), then find-   each part's component page on the Adom wiki (a reference design on the wiki, such as a molecule page,-   is fair to start from). Show the human the important ones as you go: open the regulator's, the-   inductor's and the bulk capacitor's pages in a Hydrogen webview, a moment each-   (`adom-cli hydrogen webview open-or-refresh --name "Parts" --url <page url> --panel-id <id>`).-2. **One project library in Fusion.** `adom-aiflow fusion library --parts <netlist.json> --footprints <kicad lib folder> --name <Project>`-   builds ONE library for the project: every part from its page, the Mouser, DigiKey and LCSC numbers on-   the part, the chip outline as the symbol for the parts a reader recognises (regulators, inductors, big-   capacitors) and standard symbols for passives, the laser-etched or coloured 3D model bound. A part-   added later goes into the same library: run `fusion library` again with it. Never a second library.-   Every 3D model is checked for which way is up before it is bound (below).-3. **The schematic, live.** `adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> [--fixed <fixed.json>] --name <Design>`-   starts the design with the fab's rules, draws the schematic in labelled groups, checks it pin by pin,-   puts back any 3D model Fusion dropped when a part was added, and saves the design.-4. **Simulate before the board (required: `step placement` waits for a `step simulation` after the-   schematic).** `step simulation`, then run the circuit in the Adom SPICE dashboard (adom/adom-spice-skillpack;-   ngspice in the container is the default): `adom-spice start`, `adom-spice study <study.json>`,-   `adom-spice run ngspice`, `adom-spice show` (it opens in a Hydrogen webview in the right pane). Check start-up, ripple and the load step against the-   requirements. When a measurement misses, change the part values in the schematic-   (`fusion_schematic_run` with `CHANGE VALUE`, and the netlist), run the study again, and say what you-   changed and why. Record it: `evidence add --kind simulation --file <result.json>`.-5. **The 2D board.** `start --board board.brd --remote-board fusion:<Design>`, then placement, routing,-   pours and `measure` as for any board.-6. **The board in 3D.** `adom-aiflow fusion view3d` shows the 3D PCB, saves a still from Fusion's own-   renderer and orbits the live view. Read the still: every part must sit flat on its pads.-7. **Current and heat, in Hydrogen.** `analyze current` and `analyze thermal` with adom-fields on the live-   board. Show it to the human in a Hydrogen webview: `adom-fields serve --fields <run>/fields &`, open-   `http://localhost:8874/` as a webview tab named "Adom Fields", then-   `adom-fields ui view --quantity current --parts ghost --camera iso` (every part's 3D model ghosted over-   the copper, so the fields read first and the hot part is plain) and `adom-fields tour`. The models come-   from `<run>/3d/<MPN>.step`, which `fusion library` writes (the checked Z-up STEPs); adom-fields converts-   them with `step2glb`, so `adom-wiki pkg install adom/adom-step2glb` if it says the converter is missing.-   If the solve came before the library wrote them, `adom-fields parts --board <run>/board-live.brd --fields <run>/fields`. A failing screen prints `why:` lines naming the cause and the spec-   change that fixes it (a via ring: `viaPower`; another net crossing a patch: `planeUnder`). Change the-   spec, `adom-aiflow fusion reset` (the parts stay, the copper goes), then route, gate, land, pour and-   analyze again. Repeat until both screens pass, then `finish`.-8. **Export when the human asks.** Do not export on your own: say the board is done and that the-   Gerbers, BOM and pick and place are one request away. When they ask, `adom-aiflow fusion export`-   writes the Gerbers (the JLCPCB CAM job), the pick and place and the BOM with every distributor number-   to `<run>/manufacturing/`; answer with each file's path and say they are ready for the fab.+catches Fusion's 3D view. Inside Hydrogen, the VS Code tab (the chat) always stays in the left pane with+its file explorer closed (`adom-vscode sidebar close`), and every webview (the timeline, the parts pages,+the SPICE dashboard, the fields app) opens in the right pane; never widen or collapse a pane. `step`+brings the window each step works in to the front: Hydrogen for intake, design, the test plan, sourcing,+the library, simulation, current, thermal, fields and export, Fusion for everything drawn on the design.++1. **Intake and the timeline.** `intake`, `timeline open`. Offer the Claude usage widget in Hydrogen's+   title bar (Widgets, Claude usage) so the human sees the percent used as the run goes.+2. **Design rules, before anything is drawn.** Ask which fab builds the board. Default 3rdPartyFab (our+   own process, say only "3rdPartyFab"), JLCPCB or any pack `rules list` shows. `rules use --profile+   3rdPartyFab [--layers 2]` installs the KiCad rules, loads the same pack into the open Fusion design and+   writes the choice into the spec. Tell the human the rules in plain words.+3. **The circuit** (aiflow-circuit-design): datasheet math and values.+4. **The test plan** (skill aiflow-test-plan). Think through how the built board will be tested on the+   probing workcell and write tests.json; `testplan check --tests tests.json --netlist <netlist.json>+   --add` gives every measurement a probe spot, adding 0.5 mm round test points+   (adom/testpoint-pad-0-5mm) for the gaps, before the schematic is drawn.+5. **The parts, from their wiki component pages.** Find each part's page; show the important ones (the+   regulator, the inductor, the bulk capacitor) in a Hydrogen webview, a moment each.+6. **One project library in Fusion.** `fusion library --parts <netlist.json> --footprints <lib> --name+   <Project>`: one library, part numbers on every part, the chip outline as the symbol for the parts a+   reader recognises, the 3D model bound. Every model is checked for which way is up (below), and read+   through the Adom STEP service as CAD tools read it; a part that is not Z-up and seated is refused.+7. **The schematic, live.** `fusion schematic --netlist <netlist.json> --spec <spec.json> --name <Design>`.+8. **SPICE, on the drawn schematic.** `step simulation` (it must come after the schematic; placement+   refuses otherwise): the Adom SPICE dashboard in the right pane, start-up, ripple and the load step+   against the requirements; change values in the schematic when a measurement misses.+   `evidence add --kind simulation --file <result.json>`.+9. **Placement, then routing.** `start --board board.brd --remote-board fusion:<Design>`, place for the+   hot loop first: the inductor at the regulator's switch pin, the input caps where the switch node+   cannot pass under them. `gate` refuses switch copper under another part or a switch node longer than+   `switchNodeMaxLengthMm` (12 mm default) and sends you back to placement: fix it there, not with a wider+   trace.+10. **Heatsink vias.** `thermal-vias plan` (a target per hot part from spec.hot watts and the rise+    budget, at least 6) and `thermal-vias land`.+11. **Pours**, then **rounded corners**: `outline round` (1.0 mm default, spec outline.cornerRadius).+12. **Current, heat and fields, in Hydrogen.** `analyze current`, `analyze thermal`, adom-fields in the+    right pane with every part ghosted (`ui view --parts ghost --camera iso`, `tour`). A problem here is+    a return to the step that causes it (usually placement), on camera.+13. **The pour walkthrough.** `step nets`, `tour nets`: every poured net lit on its own in turn, the+    board framed; name each one in the chat.+14. **Silkscreen.** `fusion silkscreen --title "<board>"`: every part name readable (R0, else R90), clear+    of pads, bodies, vias, the edge and each other; each machine contact's net on its edge side; the+    board identity on the bottom. Names it cannot fit are listed, never dropped silently.+15. **3D, finish, export.** `fusion view3d` (read the still: every part flat on its pads), `finish`,+    then `fusion export` writes and checks the Gerbers, BOM and CPL; answer with each file's path and+    say they are good to go. That is the finish line.  **Which way is up: every 3D model is checked.** A STEP carries no "up" axis, and a model authored Y-up-lies on its side on a Z-up board (an electrolytic can on its flank, an inductor on edge). Adom component-pages publish Z-up, seated, centred STEPs with an identity footprint binding, and `fusion library`-checks every model against its footprint anyway: it applies a footprint's own model transform when there-is one, turns a model with no transform upright when its height is clearly not on Z, and names every part-it touched. You do not get this check for free from your own instincts, so do it on purpose: read the-`fusion view3d` still before you call the board done, and when a part is wrong, fix its component page-(the source), not just this board.+lies on its side on a Z-up board. Raw coordinates are not enough: a STEP's frames and assembly+placements are applied by every reader (KiCad, step2glb, adom-fields), so a rotation that turns a+representation's frame with the geometry turns the part twice (the 2026-10-07 inductor). Adom component+pages publish Z-up, seated, centred STEPs, checked with `step2glb features`, and `fusion library` checks+every model again the same way. Read the `fusion view3d` still before you call the board done, and fix a+wrong part at its component page, not just on this board.  ## Before the board: intake, sourcing, evidence @@ -154,6 +175,7 @@ AI Flow's binary is the instrument, the gates and the hands; the judgement for e | choosing parts, the fab profile (3rd party fab by default, JLCPCB on request), symbols, footprints and 3D models | `aiflow-sourcing` | | datasheet equations, stocked values, margins | `aiflow-circuit-design` | | loop and transient simulation, vendor PSpice models | `aiflow-simulate` |+| what to measure on the probing workcell, test points and probe spots | `aiflow-test-plan` | | schematic and board from a netlist, KiCad 10 traps | `aiflow-schematic-to-board` | | an Adom molecule: pins, contacts, grid, export and publish | `aiflow-molecule` | | a vendor login, account or verification code | `aiflow-credentials` |
skills/aiflow-test-plan/SKILL.mdadded+56
@@ -0,0 +1,56 @@+---+name: aiflow-test-plan+description: >-+  The test-plan step of an adom-aiflow run: before the schematic is drawn, think through how the built board will be tested on the Adom probing workcell (the rails, the switch node, feedback, ripple, enable, power-good, every LED, the ground pin), write tests.json, and let `adom-aiflow testplan check` give every measurement a real probe spot, adding 0.5 mm round test points (adom/testpoint-pad-0-5mm) for the gaps; then check the landings again on the placed and finished board. Trigger words: test plan, test points, probe points, probing workcell, design for test, DFT, where will we probe, TP, testpoint, probe spot, bring-up measurements, what should we measure.+---++# aiflow-test-plan: decide what you will measure, then make sure you can++Every Adom board goes to the probing workcell after it is built: a gantry lands a pogo-tipped scope+probe on the board while a ground tether is plugged into a ground machine contact. A board can only be+tested where there is copper the probe can land on. This step makes the AI decide, before the schematic+is drawn, what a careful engineer would measure at the bench, and makes the binary prove each+measurement has a spot. It is a required step: `step` and `finish` refuse to go past it.++## 1. Think like the engineer at the bench++Write `tests.json`, one entry per measurement, from THIS circuit and its simulation, not a template:++```json+[+  {"id": "vin",  "what": "12 V arriving on the board",       "net": "VIN",  "why": "input path and the input cap", "kind": "dc",       "expect": "11.4 to 12.6 V"},+  {"id": "sw",   "what": "switch node: frequency, ringing", "net": "SW",   "why": "500 kHz, edges, dead time",    "kind": "waveform", "expect": "0 to 12 V square, 500 kHz"},+  {"id": "vout", "what": "5 V out, ripple at 1 A",           "net": "VOUT", "why": "regulation and ripple",       "kind": "ripple",   "expect": "5.0 V, under 20 mV p-p"},+  {"id": "fb",   "what": "feedback node",                    "net": "FB",   "why": "loop at 0.596 V",              "kind": "dc",       "expect": "0.596 V"},+  {"id": "led",  "what": "power LED lit",                    "net": "LED",  "why": "camera check",                "kind": "camera",   "expect": "on above 4.5 V in"}+]+```++Kinds: `dc`, `ripple`, `waveform` need a probe landing; `camera` (an LED, a display) needs none;+`ground` is the tether's machine contact. Typical for a regulator: the input at the board, the output,+the switch node, both inductor ends, feedback, enable, power-good, the bulk caps' ripple, every LED.++## 2. Give every measurement a spot (before the schematic)++`adom-aiflow testplan check --tests tests.json --netlist <netlist.json> --add` maps each measurement to+its net and adds `TP<n>` (adom/testpoint-pad-0-5mm: a 0.5 mm round exposed-copper pad, no paste, a 1.1 mm+courtyard) on every net that has none. The test points go into the library and the schematic like any+part. Read the table to the human: what you will measure and where the probe lands.++## 3. Check again on the real board++Run `testplan check` again after placement and before finish (finish runs it itself). On a board it+looks for a real landing on each net: a test point, or an exposed pad where a 0.5 mm landing fits clear+of every part body (an inductor's pads under its body, a 1206's end caps, an IC's pins do not count) and+0.3 mm from other nets' copper, within 50 mm of a ground machine contact. A gap names why; fix it by+moving a part, adding a test point (back to the schematic), or widening copper.++## The workcell's numbers (Stephen's spec, adom/probing-workcell)++- Smallest probing pad 200 um; pads under 300 um may need a human crosshair check, so 0.5 mm is the+  default test point. Use 1.0 mm for a signal you know you will need often.+- Two probe tips work about 3 mm apart (the 2.5 mm pogo mount).+- Every probed point within 50 mm of a MachineContactMedium ground contact; put one near each corner.+- Parts directly under a probed point under 18 mm tall; within 50 mm of it, under 40 mm.+- adom-aiflow-probing's planner still assumes a 0.3 mm tip and 0.15 mm XY error (unconfirmed); with+  those, a 0.5 mm pad shows negative landing margin there until the tip and error are measured.
tools/fusion-project.py+522−35
@@ -146,6 +146,12 @@ def save_state(st):     (STATE / 'project.json').write_text(json.dumps(st, indent=1))  +def prove(key, data=None):+    """Record that a step's own work ran (the flow's proof, read by `step` and `finish`): <run>/proofs/<key>.json."""+    d = RUN / 'proofs'; d.mkdir(parents=True, exist_ok=True)+    (d / (key + '.json')).write_text(json.dumps({'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'data': data or {}}, indent=1))++ # ---------------------------------------------------------------- library  def lookup_numbers(mpn, page_links, cache):@@ -222,11 +228,6 @@ def symbol_choice(comp, fp_text, sym_path):  _NUM = r'[-+0-9.Ee]+' -def step_extents(text):-    pts = [tuple(map(float, m)) for m in re.findall(r"CARTESIAN_POINT\s*\(\s*'[^']*'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)" % (_NUM, _NUM, _NUM), text)]-    if not pts: return None-    return [min(p[i] for p in pts) for i in range(3)], [max(p[i] for p in pts) for i in range(3)]- def _blocks(text, head):     """Every balanced (head ...) s-expression in text."""     out, i = [], 0@@ -285,22 +286,209 @@ def _rot(up):     if up == 0: return lambda x, y, z: (-z, y, x)    # -90 about Y: +X -> +Z     return lambda x, y, z: (x, y, z) +_ENT = re.compile(r'#(\d+)\s*=\s*([A-Z0-9_]+)\s*\((.*?)\)\s*;', re.S)+_CPLX = re.compile(r'#(\d+)\s*=\s*\(\s*(REPRESENTATION_RELATIONSHIP.*?)\)\s*;', re.S)+_PT = lambda kind: re.compile(r"%s\s*\(\s*'([^']*)'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)\s*\)" % (kind, _NUM, _NUM, _NUM))++def _vec(body):+    return [float(v) for v in re.findall(_NUM, body.split('(', 1)[1])[:3]]++def _structure(text):+    """The file as CAD readers see it: entities, the coordinate FRAMES (a representation's own placement+    among its items, both ends of an ITEM_DEFINED_TRANSFORMATION, the frames of a REPRESENTATION_MAP /+    MAPPED_ITEM), and for every representation its transform to the world. OCCT (KiCad, step2glb,+    adom-fields) applies frames as transforms: rotating them with the geometry turns a part twice, and an+    assembly's geometry lives in child representations placed by those transforms."""+    ents = {m.group(1): (m.group(2), m.group(3)) for m in _ENT.finditer(text)}+    refs = lambda body: re.findall(r'#(\d+)', body)+    kind = lambda r: ents.get(r, ('',))[0]+    frames, reps = set(), {}+    for k, (kd, body) in ents.items():+        if kd.endswith('SHAPE_REPRESENTATION') and '(' in body:  # not the styling (presentation) ones+            items = refs(body[body.find('(') + 1:body.find(')')])+            reps[k] = items+            frames.update(r for r in items if kind(r) == 'AXIS2_PLACEMENT_3D')+        elif kd in ('ITEM_DEFINED_TRANSFORMATION', 'REPRESENTATION_MAP', 'MAPPED_ITEM'):+            frames.update(r for r in refs(body) if kind(r) == 'AXIS2_PLACEMENT_3D')+    # links: (child, parent, idt or None). SHAPE_REPRESENTATION_RELATIONSHIP without a transform is identity+    # which representation is the parent: the assembly's own product structure says so. A shape+    # representation belongs to a product definition (SHAPE_DEFINITION_REPRESENTATION -> its+    # PRODUCT_DEFINITION_SHAPE), and a placement's CONTEXT_DEPENDENT_SHAPE_REPRESENTATION names the+    # NEXT_ASSEMBLY_USAGE_OCCURRENCE (relating = parent, related = child). Exporters write the+    # relationship's two representations in either order.+    pd_of_rep = {}+    for k, (kd, body) in ents.items():+        if kd == 'SHAPE_DEFINITION_REPRESENTATION':+            r = refs(body)+            if len(r) >= 2 and kind(r[0]) == 'PRODUCT_DEFINITION_SHAPE':+                d = refs(ents[r[0]][1])+                if d: pd_of_rep[r[1]] = d[-1]+    parent_pd = {}+    for k, (kd, body) in ents.items():+        if kd == 'CONTEXT_DEPENDENT_SHAPE_REPRESENTATION':+            r = refs(body)+            if len(r) >= 2 and kind(r[1]) == 'PRODUCT_DEFINITION_SHAPE':+                d = refs(ents[r[1]][1])+                if d and kind(d[-1]) == 'NEXT_ASSEMBLY_USAGE_OCCURRENCE':+                    n = refs(ents[d[-1]][1])+                    if len(n) >= 2: parent_pd[r[0]] = n[0]+    links = []+    for m in _CPLX.finditer(text):+        body = m.group(2); rr = re.search(r"REPRESENTATION_RELATIONSHIP\s*\(\s*'[^']*'\s*,\s*'[^']*'\s*,\s*#(\d+)\s*,\s*#(\d+)", body)+        idt = re.search(r'WITH_TRANSFORMATION\s*\(\s*#(\d+)', body)+        if not rr: continue+        a, b = rr.group(1), rr.group(2)+        if idt:+            want = parent_pd.get(m.group(1))+            if want and pd_of_rep.get(a) == want: a, b = b, a      # (child, parent)+            elif not want and len(reps.get(a, [])) > len(reps.get(b, [])): a, b = b, a+        links.append((a, b, idt.group(1) if idt else None))+    for k, (kd, body) in ents.items():+        if kd == 'SHAPE_REPRESENTATION_RELATIONSHIP':+            r = refs(body)+            # an identity link joins a part's wrapper SHAPE_REPRESENTATION to the rep holding its B-rep:+            # the B-rep side is the child, so it goes wherever the wrapper is placed+            if len(r) >= 2: links.append((r[1], r[0], None) if kind(r[0]) != 'SHAPE_REPRESENTATION' and kind(r[1]) == 'SHAPE_REPRESENTATION' else (r[0], r[1], None) if kind(r[0]) != 'SHAPE_REPRESENTATION' else (r[1], r[0], None))+    return ents, frames, reps, links++def _frame(ents, ref):+    """An AXIS2_PLACEMENT_3D as (origin, 3x3 columns x,y,z)."""+    r = re.findall(r'#(\d+)', ents[ref][1]); o = _vec(ents[r[0]][1])+    z = _vec(ents[r[1]][1]) if len(r) > 1 and r[1] in ents else [0, 0, 1]+    x = _vec(ents[r[2]][1]) if len(r) > 2 and r[2] in ents else [1, 0, 0]+    n = lambda v: [c / (math.sqrt(sum(d * d for d in v)) or 1) for c in v]+    z = n(z); x = n([x[i] - sum(x[j] * z[j] for j in range(3)) * z[i] for i in range(3)])+    y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]]+    return o, [[x[i], y[i], z[i]] for i in range(3)]++def _compose(A, B):  # A then... A∘B as (o, M): p -> A(B(p))+    (oa, Ma), (ob, Mb) = A, B+    M = [[sum(Ma[i][k] * Mb[k][j] for k in range(3)) for j in range(3)] for i in range(3)]+    return [sum(Ma[i][k] * ob[k] for k in range(3)) + oa[i] for i in range(3)], M++def _inverse(A):+    o, M = A; Mt = [[M[j][i] for j in range(3)] for i in range(3)]+    return [-sum(Mt[i][k] * o[k] for k in range(3)) for i in range(3)], Mt++_I = ([0, 0, 0], [[1, 0, 0], [0, 1, 0], [0, 0, 1]])++def _placed(text):+    """[(transform to world, [point ids])] for every placed instance of every shape representation."""+    ents, frames, reps, links = _structure(text)+    ups = {}+    for c, p, idt in links: ups.setdefault(c, []).append((p, idt))+    def worlds(rep, seen=()):+        if rep not in ups or rep in seen: return [_I]+        out = []+        for p, idt in ups[rep]:+            T = _I+            if idt:+                a, b = re.findall(r'#(\d+)', ents[idt][1])[:2]+                if b in reps.get(rep, []) and a not in reps.get(rep, []): a, b = b, a   # a: the child's frame+                T = _compose(_inverse(_frame(ents, a)), _frame(ents, b))  # as OCCT reads it, checked against step2glb+            out += [_compose(W, T) for W in worlds(p, seen + (rep,))]+        return out+    # the body's extent is its vertices: surface placements (a plane's or cylinder's origin) lie off the body+    verts = {r for kd, body in ents.values() if kd == 'VERTEX_POINT' for r in re.findall(r'#(\d+)', body)}+    out = []+    for rep, items in reps.items():+        pts, stack, seen = [], [i for i in items if i not in frames], set()+        while stack:+            r = stack.pop()+            if r in seen or r not in ents: continue+            seen.add(r); kd, body = ents[r]+            if kd == 'CARTESIAN_POINT':+                if len(_vec(body)) == 3 and (r in verts or not verts): pts.append(r)  # 2D points are curve parameters+            elif not kd.endswith('REPRESENTATION'): stack.extend(re.findall(r'#(\d+)', body))+        for W in worlds(rep): out.append((W, pts))+    return ents, frames, links, out++def step_extents(text):+    """World extents, as a CAD reader places the geometry (assembly transforms applied, frames not+    counted as geometry)."""+    try:+        ents, frames, links, placed = _placed(text)+    except Exception:+        placed = None+    if placed and any(p for _, p in placed):+        pts = []+        for (o, M), ids in placed:+            for i in ids:+                v = _vec(ents[i][1]); pts.append([sum(M[r][k] * v[k] for k in range(3)) + o[r] for r in range(3)])+    else:+        pts = [tuple(map(float, m)) for m in re.findall(r"CARTESIAN_POINT\s*\(\s*'[^']*'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)" % (_NUM, _NUM, _NUM), text)]+    if not pts: return None+    return [min(p[i] for p in pts) for i in range(3)], [max(p[i] for p in pts) for i in range(3)]++def rigid_step(text, M, off=(0, 0, 0)):+    """Move the whole model by p -> M p + off, the way every reader will see it. Geometry of the root+    representation (and of ones linked to it without a transform) is moved point by point; a child placed+    by an ITEM_DEFINED_TRANSFORMATION is moved by moving its placement in the parent; frames that are not+    such placements keep the values the file had."""+    ents, frames, reps, links = _structure(text)+    moved_links = [(c, p, idt) for c, p, idt in links if idt]+    children = {c for c, _, _ in moved_links}+    # representations reached from a root through identity links only: their geometry moves raw+    parents = {c: p for c, p, idt in links if not idt}+    def root_side(rep, seen=()):+        if rep in children: return False+        if rep in parents and rep not in seen: return root_side(parents[rep], seen + (rep,))+        return True+    raw_pts, raw_dirs = set(), set()+    for rep, items in reps.items():+        if not root_side(rep): continue+        stack, seen = [i for i in items if i not in frames], set()+        while stack:+            r = stack.pop()+            if r in seen or r not in ents: continue+            seen.add(r); kd, body = ents[r]+            if kd == 'CARTESIAN_POINT':+                if len(_vec(body)) == 3: raw_pts.add(r)+            elif kd == 'DIRECTION':+                if len(_vec(body)) == 3: raw_dirs.add(r)+            elif not kd.endswith('REPRESENTATION'): stack.extend(re.findall(r'#(\d+)', body))+    # the placements in the parent of the top-level children (their parent is root-side)+    targets = set()+    for c, p, idt in moved_links:+        if root_side(p):+            a, b = re.findall(r'#(\d+)', ents[idt][1])[:2]+            # move the end that lives in the parent (exporters write the two ends in either order)+            targets.add(a if a in reps.get(p, []) and b not in reps.get(p, []) else b)+    ap = lambda v, t: [sum(M[i][k] * v[k] for k in range(3)) + t[i] for i in range(3)]+    nxt = [max(int(k) for k in ents) + 1]; add = []+    def new(kind, v):+        add.append("#%d = %s('',(%.9g,%.9g,%.9g)) ;" % (nxt[0], kind, *v)); nxt[0] += 1; return '#%d' % (nxt[0] - 1)+    def sub_ent(t, ref, kind, v):+        return re.sub(r'#%s\s*=\s*%s\s*\(.*?\)\s*;' % (ref, kind), lambda m: "#%s = %s('',(%.9g,%.9g,%.9g)) ;" % (ref, kind, *v), t, count=1, flags=re.S)+    out = text+    for r in raw_pts: out = sub_ent(out, r, 'CARTESIAN_POINT', ap(_vec(ents[r][1]), off))+    for r in raw_dirs: out = sub_ent(out, r, 'DIRECTION', ap(_vec(ents[r][1]), (0, 0, 0)))+    for f in targets:  # fresh entities: a placement's point and directions are often shared+        rr = re.findall(r'#(\d+)', ents[f][1])+        o = new('CARTESIAN_POINT', ap(_vec(ents[rr[0]][1]), off))+        rest = [new('DIRECTION', ap(_vec(ents[d][1]), (0, 0, 0))) if d in ents and ents[d][0] == 'DIRECTION' else '#' + d for d in rr[1:]]+        out = re.sub(r'#%s\s*=\s*AXIS2_PLACEMENT_3D\s*\(.*?\)\s*;' % f, lambda m: "#%s = AXIS2_PLACEMENT_3D('',%s) ;" % (f, ','.join([o] + rest)), out, count=1, flags=re.S)+    # frames that are root-side items share points/directions with geometry: give them their originals back+    keep = [f for f in frames if f not in targets]+    for f in keep:+        rr = re.findall(r'#(\d+)', ents[f][1])+        if not any(r in raw_pts or r in raw_dirs for r in rr): continue+        vals = ['#' + r if r not in ents or ents[r][0] not in ('CARTESIAN_POINT', 'DIRECTION') else new(ents[r][0], _vec(ents[r][1])) for r in rr]+        out = re.sub(r'#%s\s*=\s*AXIS2_PLACEMENT_3D\s*\(.*?\)\s*;' % f, lambda m: "#%s = AXIS2_PLACEMENT_3D('',%s) ;" % (f, ','.join(vals)), out, count=1, flags=re.S)+    if add:+        i = out.rfind('ENDSEC;'); out = out[:i] + '\n'.join(add) + '\n' + out[i:]+    return out+ def make_upright(text, up):-    """Rotate every point and direction so `up` becomes Z, then seat the model on z=0. A rigid motion of-    all CARTESIAN_POINTs and DIRECTIONs moves STEP B-rep geometry consistently (radii and lengths keep)."""-    R = _rot(up)-    def sub(kind, shift):-        def f(m):-            x, y, z = R(float(m.group(2)), float(m.group(3)), float(m.group(4)))-            if shift: z -= shift-            return "%s('%s',(%.9g,%.9g,%.9g))" % (kind, m.group(1), x, y, z)-        return f-    pat = lambda kind: r"%s\s*\(\s*'([^']*)'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)\s*\)" % (kind, _NUM, _NUM, _NUM)-    t = re.sub(pat('DIRECTION'), sub('DIRECTION', 0), text)-    t = re.sub(pat('CARTESIAN_POINT'), sub('CARTESIAN_POINT', 0), t)+    """Turn the model so `up` becomes Z, then seat it on z=0 (a rigid motion as every reader sees it)."""+    R = {1: [[1, 0, 0], [0, 0, -1], [0, 1, 0]], 0: [[0, 0, -1], [0, 1, 0], [1, 0, 0]]}.get(up, [[1, 0, 0], [0, 1, 0], [0, 0, 1]])+    t = rigid_step(text, R)     zmin = step_extents(t)[0][2]-    t = re.sub(pat('CARTESIAN_POINT'), sub('CARTESIAN_POINT', 0) if abs(zmin) < 1e-6 else (lambda m: "CARTESIAN_POINT('%s',(%.9g,%.9g,%.9g))" % (m.group(1), float(m.group(2)), float(m.group(3)), float(m.group(4)) - zmin)), t)-    return t+    return rigid_step(t, _I[1], (0, 0, -zmin)) if abs(zmin) >= 1e-6 else t++def transform_step(text, M, off=(0, 0, 0)):+    """Apply a rigid motion (KiCad's model transform) as every reader will see it."""+    return rigid_step(text, M, off)  def kicad_model_transform(fp_text):     """The footprint's own (model ...) entry: rotate (deg) and offset (mm), or None. KiCad applies these@@ -320,18 +508,6 @@ def _matrix(rx, ry, rz):     mul = lambda A, B: [[sum(A[i][k] * B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]     return mul(R('z', rz), mul(R('y', ry), R('x', rx))) -def transform_step(text, M, off=(0, 0, 0)):-    """Apply a rigid motion to every CARTESIAN_POINT and DIRECTION (B-rep geometry moves with them)."""-    ap = lambda v: [sum(M[i][k] * v[k] for k in range(3)) for i in range(3)]-    pat = lambda kind: r"%s\s*\(\s*'([^']*)'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)\s*\)" % (kind, _NUM, _NUM, _NUM)-    def sub(kind, add):-        def f(m):-            v = ap([float(m.group(2)), float(m.group(3)), float(m.group(4))])-            return "%s('%s',(%.9g,%.9g,%.9g))" % (kind, m.group(1), v[0] + add[0], v[1] + add[1], v[2] + add[2])-        return f-    t = re.sub(pat('DIRECTION'), sub('DIRECTION', (0, 0, 0)), text)-    return re.sub(pat('CARTESIAN_POINT'), sub('CARTESIAN_POINT', off), t)- def upright_model(step, fp, out_dir):     """The model as it must sit on the board: the footprint's own transform applied, then checked against     the footprint (the two board-plane extents must span it). Returns (path, note); note is None when the@@ -358,7 +534,29 @@ def upright_model(step, fp, out_dir):     return out, '; '.join(notes)  -ORIENT_RULES = 1  # bump when the orientation rules change: parts bound under older rules are bound again+def service_pose(step, fp):+    """The model as every CAD reader sees it (the Adom STEP service, OpenCascade, the reader KiCad,+    step2glb and adom-fields use): None when it is Z-up and seated, else what is wrong. Skipped (None)+    when step2glb is not installed."""+    if not shutil.which('step2glb'): return None+    r = run(['step2glb', 'features', str(step)], timeout=300)+    m = re.search(r'bbox x=([-\d.]+)\.\.([-\d.]+) y=([-\d.]+)\.\.([-\d.]+) z=([-\d.]+)\.\.([-\d.]+)', r.stdout + r.stderr)+    feat = Path(str(step).rsplit('.', 1)[0] + '-features.json')+    try: feat.unlink()+    except OSError: pass+    if not m: return None+    v = [float(x) for x in m.groups()]+    ext = ([v[0], v[2], v[4]], [v[1], v[3], v[5]])+    span = footprint_span(fp.read_text(errors='ignore')) if fp else None+    if span:+        up = up_axis(ext, span)[0]+        if up != 2: return 'its height is on %s (service bbox %s)' % ('XYZ'[up], m.group(0))+    if abs(v[4]) > 0.06 and v[4] > -0.06 - 3.0 and not (fp and 'thru_hole' in fp.read_text(errors='ignore')):+        return 'not seated: z starts at %.2f mm (service bbox %s)' % (v[4], m.group(0))+    return None+++ORIENT_RULES = 2  # bump when the orientation rules change: parts bound under older rules are bound again   def silk_text(t):@@ -403,7 +601,9 @@ def cmd_library(a):         refs = st.setdefault('refs', {})         for c in src['components']:             if c.get('mpn'):-                want.append({'mpn': c['mpn'], 'manufacturer': c.get('mfr')}); refs[c['ref']] = c['mpn']+                w_ = {'mpn': c['mpn'], 'manufacturer': c.get('mfr')}+                if c.get('page'): w_['page'] = c['page']   # a part from a named wiki page (the test plan's test points)+                want.append(w_); refs[c['ref']] = c['mpn']             else:                 fpname = (c.get('fp') or '').split(':')[-1]                 hit = next(iter(sorted(Path(a.footprints).rglob(fpname + '.kicad_mod'))), None) if a.footprints and fpname else None@@ -470,6 +670,8 @@ def cmd_library(a):         if step:             step, onote = upright_model(step, fp, STATE / 'models')             if onote: turned[mpn] = onote+            bad = service_pose(step, fp)+            if bad: err('%s: its 3D model is not right side up as CAD tools read it: %s' % (mpn, bad), ['Fix the component page (the STEP must be Z-up, seated on z=0, centred, as `step2glb features` reads it, not as raw coordinates say), then `fusion library` again. The 2026-10-07 inductor bug: a frame turned with the geometry.'])         prev = st['parts'].get(mpn, {})         if prev.get('urn') and prev.get('orient') != ORIENT_RULES: prev = {k: v for k, v in prev.items() if k != 'urn'}  # bound before the check: bind again         st['parts'][mpn] = {**prev, 'lbr': str(out_lbr), 'step': str(step) if step else None, 'numbers': numbers, 'symbol': choice, 'orient': ORIENT_RULES, 'orientNote': onote, 'lbrVersion': lbr_ver}@@ -524,6 +726,7 @@ def cmd_library(a):     combined.write_text(t); send([combined], bdir)     st['libraryPath'] = bdir + '/' + combined.name; save_state(st)     unbound = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]+    prove('library', {'name': name, 'parts': len(st['parts'])})     ok('library %s: %d part(s) (%d added, %d kept), %d with real 3D, one library at %s' % (name, len(st['parts']), len(added), len(kept), len(urns), st['libraryPath']),        ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `fusion library` again when the services are back, or add the numbers to the part\'s wiki page (component.distributor_links).' if unverified else '',         '3D not bound yet: %s; run this again to retry them' % ', '.join(unbound) if unbound else '',@@ -678,6 +881,7 @@ def cmd_schematic(a):     dru = Path(a.fixed).parent / 'board.kicad_dru' if a.fixed else None     if dru and dru.is_file(): shutil.copy(dru, out.with_suffix('.kicad_dru'))     st['design'] = name; save_state(st)+    prove('schematic', {'name': name, 'groups': len(plan['groups'])})     ok('schematic drawn live in %d group(s), every pin on its net; the board followed with %d parts; saved as %s; board written to %s' % (len(plan['groups']), n, name, out),        [('3D: Fusion dropped the 3D models of %d part(s) when they were added; they were put back (the design was imported again with every binding on it): %s.' % (len(restored), ', '.join(restored))) if restored else '',         ('3D: %d part(s) still show as flat placeholder boxes: %s. Read <run>/fusion/restore3d/ and fusion_check_dialogs.' % (len(lost), ', '.join(lost))) if lost else '',@@ -778,12 +982,260 @@ def cmd_view3d(a):     for _ in range(a.steps):         ab('fusion_view', {'action': 'orbit', 'degrees': 360.0 / a.steps}); time.sleep(a.pause)     ab('fusion_view', {'action': 'iso'}); ab('fusion_view', {'action': 'fit'})+    prove('tour-3d', {'still': str(still) if still else None})     ok('%s shown in 3D and orbited once%s' % (name, '; still: %s' % still if still else ''),        ['Read the still before going on: every part sits flat on its pads, the way its datasheet drawing shows (no part on its side or on edge, no flat placeholder box). The AI does not catch a sideways model by itself; this look is the check.' if still else 'No still came back (fusion_take_screenshot); look at Fusion yourself.',         'A part that is wrong: say which; fix its wiki component page (the STEP must be Z-up, seated, centred, with an identity footprint binding) and run `fusion library` again.',         'Back to 2D: fusion_show_2d_board {"documentName": "%s"}.' % name])  +# ---------------------------------------------------------------- silkscreen++def _num(tag, k, d=None):+    m = re.search(r'\s%s="([^"]*)"' % k, ' ' + tag)+    if not m: return d+    try: return float(m.group(1))+    except ValueError: return m.group(1)++def _rot_of(r):+    r = r or 'R0'; mir = r.startswith('M') or 'M' in r[:2]+    try: ang = float(re.sub(r'[^0-9.\-]', '', r) or 0)+    except ValueError: ang = 0.0+    return ang, mir++def _xf(x, y, ex, ey, ang, mir):+    if mir: x = -x+    c, s_ = math.cos(math.radians(ang)), math.sin(math.radians(ang))+    return ex + x * c - y * s_, ey + x * s_ + y * c++def board_model(text):+    """The board as the label search sees it: copper and mask boxes, part bodies, the outline, and each+    element's NAME text. EAGLE coordinates (mm, y up)."""+    pk = {}+    for m in re.finditer(r'<package name="([^"]+)"[^>]*>(.*?)</package>', text, re.S):+        body, pads = [], []+        for t in re.findall(r'<smd [^>]*>', m.group(2)):+            x, y, dx, dy = _num(t, 'x', 0), _num(t, 'y', 0), _num(t, 'dx', 0), _num(t, 'dy', 0)+            rr = _rot_of(_num(t, 'rot', 'R0') if isinstance(_num(t, 'rot', 'R0'), str) else 'R0')[0]+            if int(round(rr)) % 180 == 90: dx, dy = dy, dx+            pads.append((x, y, dx / 2, dy / 2, _num(t, 'layer', 1)))+        for t in re.findall(r'<pad [^>]*>', m.group(2)):+            d = _num(t, 'diameter', 0) or (_num(t, 'drill', 0.8) * 1.6)+            pads.append((_num(t, 'x', 0), _num(t, 'y', 0), d / 2, d / 2, 0))+        for t in re.findall(r'<wire [^>]*layer="(?:51|39|21)"[^>]*>', m.group(2)):+            body += [(_num(t, 'x1', 0), _num(t, 'y1', 0)), (_num(t, 'x2', 0), _num(t, 'y2', 0))]+        for t in re.findall(r'<circle [^>]*layer="(?:51|21)"[^>]*>', m.group(2)):+            cx, cy, r = _num(t, 'x', 0), _num(t, 'y', 0), _num(t, 'radius', 0)+            body += [(cx - r, cy - r), (cx + r, cy + r)]+        nt = re.search(r'<text [^>]*>&gt;NAME</text>', m.group(2))+        pk[m.group(1)] = {'pads': pads, 'body': body, 'nameAlign': (re.search(r'align="([^"]+)"', nt.group(0)) or [None, 'bottom-left'])[1] if nt else 'bottom-left'}+    els = []+    for m in re.finditer(r'<element ([^>]*)>(.*?)</element>|<element ([^>]*)/>', text, re.S):+        tag = m.group(1) or m.group(3); inner = m.group(2) or ''+        name, pkg = _num(tag, 'name'), _num(tag, 'package')+        ex, ey = _num(tag, 'x', 0), _num(tag, 'y', 0)+        ang, mir = _rot_of(_num(tag, 'rot', 'R0') if isinstance(_num(tag, 'rot', 'R0'), str) else 'R0')+        p = pk.get(pkg, {'pads': [], 'body': []})+        pads = []+        for (x, y, hx, hy, layer) in p['pads']:+            cx, cy = _xf(x, y, ex, ey, ang, mir)+            if int(round(ang)) % 180 == 90: hx, hy = hy, hx+            pads.append((cx - hx, cy - hy, cx + hx, cy + hy))+        pts = [_xf(x, y, ex, ey, ang, mir) for (x, y) in p['body']] or [(b[0], b[1]) for b in pads] + [(b[2], b[3]) for b in pads]+        body = (min(q[0] for q in pts), min(q[1] for q in pts), max(q[0] for q in pts), max(q[1] for q in pts)) if pts else (ex, ey, ex, ey)+        na = re.search(r'<attribute name="NAME"[^>]*>', inner)+        align = (re.search(r'align="([^"]+)"', na.group(0)) or [None, None])[1] if na else None+        els.append({'name': name, 'package': pkg, 'value': _num(tag, 'value', ''), 'x': ex, 'y': ey, 'bottom': mir, 'pads': pads, 'body': body,+                    'nameAttr': na.group(0) if na else None, 'nameAlign': align or p.get('nameAlign', 'bottom-left')})+    xs, ys = [], []+    for t in re.findall(r'<wire [^>]*layer="20"[^>]*>', text):+        xs += [_num(t, 'x1', 0), _num(t, 'x2', 0)]; ys += [_num(t, 'y1', 0), _num(t, 'y2', 0)]+    outline = (min(xs), min(ys), max(xs), max(ys)) if xs else None+    vias = [(_num(t, 'x', 0), _num(t, 'y', 0), (_num(t, 'diameter', 0) or _num(t, 'drill', 0.3) * 2) / 2) for t in re.findall(r'<via [^>]*>', text)]+    # board text already printed (labels from an earlier pass, the board identity): kept, and in the way+    plain = text[text.find('<plain>'):text.find('</plain>')] if '<plain>' in text else ''+    texts = []+    for m in re.finditer(r'<text ([^>]*)>([^<]*)</text>', plain):+        tag, body = m.group(1), m.group(2)+        layer = int(_num(tag, 'layer', 0) or 0)+        if layer not in (21, 22): continue+        sz = _num(tag, 'size', 0.8) or 0.8; x, y = _num(tag, 'x', 0), _num(tag, 'y', 0)+        r_ = str(_num(tag, 'rot', 'R0')); w, h = 0.95 * sz * len(body) + 0.1, sz * 1.1+        box = (x, y, x + w, y + h) if '90' not in r_ else (x - h, y, x, y + w)+        texts.append({'text': body, 'layer': layer, 'box': box})+    for e in els:+        e['nameBox'] = None+        if e['nameAttr']:+            a_ = e['nameAttr']; x, y = _num(a_, 'x', None), _num(a_, 'y', None)+            if x is not None:+                w = 0.95 * 0.8 * len(e['name']) + 0.1; h = 0.88+                al = e['nameAlign']; x0 = x - w / 2 if 'center' in al else (x - w if al.endswith('right') else x)+                y0 = y if al.startswith('bottom') else (y - h if al.startswith('top') else y - h / 2)+                e['nameBox'] = (x0, y0, x0 + w, y0 + h) if '90' not in str(_num(a_, 'rot', 'R0')) else (x - h, y0, x, y0 + w)+    return els, outline, vias, texts++def _ov(a, b, g=0.0): return not (a[2] + g <= b[0] or b[2] + g <= a[0] or a[3] + g <= b[1] or b[3] + g <= a[1])++def text_box(x, y, s, size, align='bottom-left'):+    """EAGLE vector text (ratio 15): about 0.85 x size per character, size tall."""+    w, h = 0.85 * size * max(1, len(s)), size+    x0 = x - w / 2 if 'center' in align and not align.endswith('left') and not align.endswith('right') or align == 'center' else (x - w if align.endswith('right') else x)+    if align.startswith('top'): y0 = y - h+    elif align.startswith('center'): y0 = y - h / 2+    else: y0 = y+    if align == 'center': x0 = x - w / 2+    return (x0, y0, x0 + w, y0 + h)++def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=()):+    """Every part's name next to its body, readable (R0), clear of copper, mask, bodies, the edge and+    the other labels; machine contacts also get their net, on the board-edge side. Biggest parts first;+    a smaller size before giving up; whatever cannot fit is named, never dropped silently."""+    pads = [b for e in els for b in e['pads']]+    bodies = [e['body'] for e in els]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None+    taken, out, unresolved = list(fixed), [], []+    def free(bx, own=None):+        if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]): return False+        if any(_ov(bx, p, 0.1) for p in pads): return False+        if any(_ov(bx, b, 0.05) for b in bodies if b is not own): return False+        if any(_ov(bx, v, 0.05) for v in viab): return False+        if any(_ov(bx, t, gap) for t in taken): return False+        return True+    order = sorted([e for e in els if not e['bottom']], key=lambda e: -((e['body'][2] - e['body'][0]) * (e['body'][3] - e['body'][1])))+    for e in order:+        b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2+        texts = [(e['name'], 'name')]+        if e['package'] and 'MACHINECONTACT' in e['package'].upper() and e['value']: texts.append((e['value'], 'net'))+        for txt, kind in texts:+            done = False+            if kind == 'net' and any(t['text'] == txt and t['layer'] == 21 and abs((t['box'][0] + t['box'][2]) / 2 - cx) < 4 and abs((t['box'][1] + t['box'][3]) / 2 - cy) < 4 for t in existing):+                continue  # printed by an earlier pass: it stays, and it is already an obstacle+            # part names keep the library's 0.8 mm (a size change by name does not land in Fusion); the new+            # net labels may step down. EAGLE's vector font measured at about 0.95 x size per character.+            for size in ((0.8,) if kind == 'name' else (0.8, 0.6, 0.5)):+                for rot in ('R0', 'R90'):+                    # R0 first (reads left to right); R90 (reads bottom to top) where a part has no room beside it+                    tw = 0.95 * size * len(txt) + 0.1; th = size * 1.1+                    w, h = (tw, th) if rot == 'R0' else (th, tw)+                    cands = []+                    for d in (gap, gap + 0.4, gap + 0.9):+                        for f in (0.0, -0.5, 0.5, -1.0, 1.0):+                            sx, sy = f * w, f * h+                            cands += [(cx - w / 2 + sx, b[3] + d), (cx - w / 2 + sx, b[1] - d - h), (b[2] + d, cy - h / 2 + sy), (b[0] - d - w, cy - h / 2 + sy)]+                        cands += [(b[2] + d, b[3] + d), (b[0] - d - w, b[3] + d), (b[2] + d, b[1] - d - h), (b[0] - d - w, b[1] - d - h)]+                    if kind == 'net' and outline:+                        # the edge side first: the side of the contact nearest the outline+                        dists = {'L': cx - outline[0], 'R': outline[2] - cx, 'B': cy - outline[1], 'T': outline[3] - cy}+                        side = min(dists, key=dists.get)+                        cands.sort(key=lambda p: {'L': p[0], 'R': -p[0], 'B': p[1], 'T': -p[1]}[side])+                    for (x0, y0) in cands:+                        bx = (x0, y0, x0 + w, y0 + h)+                        if free(bx, own=b):+                            taken.append(bx)+                            out.append({'ref': e['name'], 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})+                            done = True; break+                    if done: break+                if done: break+            if not done: unresolved.append('%s %s' % (e['name'], kind))+    return out, unresolved, taken++def identity_spot(els, outline, vias, lines):+    """A clear place on the bottom face for the board's identity (bottom pads, through-holes and vias are+    the obstacles there): (x, y, size), the largest size that fits nearest the centre."""+    if not outline: return None+    obs = [b for e in els for b in e['pads'] if e['bottom']] + [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    obs += [b for e in els for b in e['pads'] if (b[2] - b[0]) > 0.9 and abs((b[2] - b[0]) - (b[3] - b[1])) < 0.05]  # round through-hole lands+    cx0, cy0 = (outline[0] + outline[2]) / 2, (outline[1] + outline[3]) / 2+    for size in (1.0, 0.8, 0.7, 0.6):+        w = max(0.95 * size * len(l) for l in lines); h = len(lines) * size * 1.5+        best = None+        for i in range(-30, 31):+            for j in range(-30, 31):+                x, y = cx0 + i * 0.4, cy0 + j * 0.4+                bx = (x - w / 2, y - h / 2, x + w / 2, y + h / 2)+                if bx[0] < outline[0] + 0.5 or bx[2] > outline[2] - 0.5 or bx[1] < outline[1] + 0.5 or bx[3] > outline[3] - 0.5: continue+                if any(_ov(bx, o, 0.1) for o in obs): continue+                d = abs(i) + abs(j)+                if best is None or d < best[0]: best = (d, x, y)+        if best: return (best[1], best[2], size)+    return None++def cmd_silkscreen(a):+    st = load_state(); name = a.design or st.get('design')+    if not name: err('which design? pass --design <name>')+    src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''+    if '<element ' not in text: err('could not read the board %s (%s)' % (name, src.get('error')), ['Show the 2D board (fusion_show_2d_board) and run this again.'])+    els, outline, vias, texts = board_model(text)+    fixed = [t['box'] for t in texts if t['layer'] == 21]+    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts)+    if unresolved:+        # a name that cannot move stays where it is: plan the others around it+        stay = [e['nameBox'] for e in els if e['nameBox'] and ('%s name' % e['name']) in unresolved]+        labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed + stay, existing=texts)+    title = a.title or st.get('title') or name+    lines = [title, 'adom-aiflow %s' % time.strftime('%Y-%m-%d')]+    spot = None if any(t['layer'] == 22 and t['text'] == title for t in texts) else identity_spot(els, outline, vias, lines)+    aligns = {e['name']: e['nameAlign'] for e in els}+    def anchor(box, align, rot='R0'):+        # where EAGLE puts a text's origin for its alignment (a smashed NAME keeps its library alignment);+        # R90 turns the text's own frame: its 'bottom' is the box's right side, its 'left' the box's bottom+        hor = 'left' if align.endswith('left') else 'right' if align.endswith('right') else 'center'+        ver = 'bottom' if align.startswith('bottom') else 'top' if align.startswith('top') else 'center'+        if rot == 'R0':+            x = {'left': box[0], 'right': box[2], 'center': (box[0] + box[2]) / 2}[hor]+            y = {'bottom': box[1], 'top': box[3], 'center': (box[1] + box[3]) / 2}[ver]+        else:+            x = {'bottom': box[2], 'top': box[0], 'center': (box[0] + box[2]) / 2}[ver]+            y = {'left': box[1], 'right': box[3], 'center': (box[1] + box[3]) / 2}[hor]+        return x, y+    cmds = ['GRID MM;']+    for L in labels:+        x0, y0 = L['origin']+        if L['kind'] == 'name':+            ax, ay = anchor(L['box'], aligns.get(L['ref'], 'bottom-left'), L.get('rot', 'R0'))+            L['anchor'] = [round(ax, 3), round(ay, 3)]+            cmds += ["SMASH %s;" % L['ref'], "ROTATE =%s '%s>NAME';" % (L.get('rot', 'R0'), L['ref']), "MOVE '%s>NAME' (%.3f %.3f);" % (L['ref'], ax, ay)]+        else:+            tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])+            cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]+    if spot:+        cmds += ['CHANGE LAYER 22;', 'CHANGE SIZE %s;' % spot[2], 'CHANGE RATIO 15;', 'CHANGE ALIGN center;']+        for k, l in enumerate(lines):+            cmds.append("TEXT '%s' MR0 (%.3f %.3f);" % (l.replace("'", ''), spot[0], spot[1] + (len(lines) / 2 - k - 0.5) * spot[2] * 1.5))+    plan = {'labels': labels, 'unresolved': unresolved, 'identity': {'lines': lines, 'at': spot}, 'commands': cmds}+    STATE.mkdir(parents=True, exist_ok=True); (STATE / 'silkscreen-plan.json').write_text(json.dumps(plan, indent=1))+    if a.dry:+        ok('silkscreen plan (not landed): %d labels, %d unresolved%s; %s' % (len(labels), len(unresolved), (' (' + ', '.join(unresolved) + ')') if unresolved else '', STATE / 'silkscreen-plan.json'))+    ab('fusion_show_2d_board', {'documentName': name})+    # one script file run by Fusion's SCRIPT command: the bridge refuses a bare MOVE (it is interactive+    # without coordinates), and a script lands every label as the editor's own commands, undoable+    failed = []+    scr = STATE / 'silkscreen.scr'; scr.write_text('\n'.join(cmds) + '\n')+    dest = box_dir() + '/silkscreen'+    send([scr], dest)+    r = ab('fusion_electron_run', {'command': "SCRIPT '%s/silkscreen.scr';" % dest})+    if r.get('success') is False: failed.append('SCRIPT -> %s' % str(r.get('error'))[:160])+    time.sleep(1.0)+    # read the board back: every name where it was put, nothing on copper+    src2 = ab('fusion_board_source', {'expectDocument': name}); t2 = (src2.get('data') or src2).get('source') or src2.get('source') or ''+    moved, off = 0, []+    for L in labels:+        if L['kind'] != 'name': continue+        m = re.search(r'<element name="%s"[^>]*>.*?<attribute name="NAME"([^>]*)>' % re.escape(L['ref']), t2, re.S)+        if m and abs((_num(m.group(1), 'x', 0) or 0) - L['anchor'][0]) < 0.05 and abs((_num(m.group(1), 'y', 0) or 0) - L['anchor'][1]) < 0.05: moved += 1+        else: off.append(L['ref'])+    nets_written = sum(1 for L in labels if L['kind'] == 'net')+    nets_kept = sum(1 for t in texts if t['layer'] == 21)+    identity_kept = any(t['layer'] == 22 and t['text'] == title for t in texts)+    prove('silkscreen', {'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot)})+    if failed or off:+        err('silkscreen landed in part: %d of %d names where planned%s%s' % (moved, len([l for l in labels if l['kind'] == 'name']), ('; not where planned: ' + ', '.join(off)) if off else '', ('; refused: ' + '; '.join(failed[:4])) if failed else ''),+            ['Look at the board in Fusion; move the named labels by hand or fix the command, then `fusion silkscreen` again.'])+    ok('silkscreen: %d part names placed readable and clear of copper, %d contact net labels on the edge side%s%s, board identity on the bottom%s' % (moved, nets_written, (' (%d kept from before)' % nets_kept) if nets_kept else '', (', unresolved: ' + ', '.join(unresolved)) if unresolved else '', ' (kept from before)' if identity_kept else '' if spot else ' (no clear spot found)'),+       ['Show the human both faces: the top labels next to their parts, the bottom identity. Then `step 3d`.'])++ # ---------------------------------------------------------------- export  def cmd_export(a):@@ -800,7 +1252,41 @@ def cmd_export(a):     files = [gd['zipPath'], bdir + '/' + name + '-cpl.csv', bdir + '/bom.csv', bdir + '/bom-jlcpcb.csv']     r = ab('pull_file', {'filePaths': files, 'saveTo': str(out.resolve())}, timeout=600)     missing = {k: v for k, v in (b.get('missingPartNumbers') or {}).items() if v}-    ok('manufacturing files in %s: Gerbers (%d files, %d layers), pick and place, BOM (%d lines) and the JLCPCB BOM' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(b.get('lines') or [])),+    # check what was written before calling it good: every layer a fab needs, and the BOM and the pick and+    # place naming exactly the board's parts+    import zipfile+    problems = []+    zp = out / Path(gd['zipPath'].replace('\\', '/')).name+    names = []+    try: names = [n.lower() for n in zipfile.ZipFile(zp).namelist()]+    except Exception as e: problems.append('Gerber zip unreadable (%s)' % e)+    need = {'top copper': ('gtl', 'copper_top', 'top_copper', 'f_cu', 'copper_l1'), 'bottom copper': ('gbl', 'copper_bottom', 'bottom_copper', 'b_cu', 'copper_l2'),+            'top mask': ('gts', 'soldermask_top', 'mask_top', 'f_mask'), 'top silkscreen': ('gto', 'silkscreen_top', 'silk_top', 'f_silks', 'legend_top'),+            'outline': ('gko', 'gm1', 'profile', 'outline', 'edge_cuts', 'dimension'), 'drill': ('drl', 'xln', '.drd', 'drill', '.txt')}+    if names:+        for what, keys in need.items():+            if not any(any(k in n for k in keys) for n in names): problems.append('no %s layer in the Gerbers' % what)+    src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''+    board_refs = set(re.findall(r'<element name="([^"]+)"', text))+    def refs_in(path, cols):+        try:+            rows = list(csv.reader(open(path, newline='', encoding='utf-8-sig')))+            h = [c.strip().lower() for c in rows[0]]; i = next((h.index(c) for c in cols if c in h), None)+            out_refs = set()+            for row in rows[1:]:+                if i is not None and i < len(row): out_refs |= {x.strip() for x in re.split(r'[ ,;]+', row[i]) if x.strip()}+            return out_refs+        except Exception as e:+            problems.append('%s unreadable (%s)' % (Path(path).name, e)); return set()+    bom_refs = refs_in(out / 'bom.csv', ('designator', 'designators', 'parts', 'part', 'references', 'reference', 'refdes'))+    cpl_refs = refs_in(out / (name + '-cpl.csv'), ('designator', 'refdes', 'part', 'reference', 'name'))+    board_only = set(b.get('boardOnly') or [])+    if board_refs and bom_refs and (board_refs - board_only) - bom_refs: problems.append('BOM is missing %s' % ', '.join(sorted((board_refs - board_only) - bom_refs)))+    if board_refs and cpl_refs and (board_refs - board_only) - cpl_refs: problems.append('pick and place is missing %s' % ', '.join(sorted((board_refs - board_only) - cpl_refs)))+    if problems: err('manufacturing files written to %s, but they do not check out: %s' % (out, '; '.join(problems)), ['Fix the export (the CAM job, the parts\' attributes) and run `fusion export` again; never hand the human files that do not match the board.'])+    prove('export', {'dir': str(out.resolve()), 'gerberFiles': len(names), 'bomRefs': len(bom_refs), 'cplRefs': len(cpl_refs)})+    if os.environ.get('AIFLOW_EXE'): run([os.environ['AIFLOW_EXE'], '--run', str(RUN), 'timeline', 'write'], timeout=60)+    ok('manufacturing files in %s, checked: Gerbers (%d files, %d layers: copper, mask, silkscreen, outline, drill), pick and place (%d parts), BOM (%d lines) and the JLCPCB BOM; every part on the board is in both' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(cpl_refs), len(b.get('lines') or [])),        ['Parts without a distributor number: ' + '; '.join('%s: %s' % (k, ', '.join(v)) for k, v in missing.items()) if missing else '',         'Board-only parts (nothing to buy): ' + ', '.join(b.get('boardOnly') or []) if b.get('boardOnly') else '']) @@ -834,8 +1320,9 @@ def main():     p = sub.add_parser('reset'); p.add_argument('--design')     p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing'))     p = sub.add_parser('view3d', help='the board in 3D: a still to check and a live orbit'); p.add_argument('--design'); p.add_argument('--steps', type=int, default=24); p.add_argument('--pause', type=float, default=0.6); p.add_argument('--settle', type=float, default=8)+    p = sub.add_parser('silkscreen', help='every part name readable and clear of copper, contact nets on the edge side, the board identity on the bottom'); p.add_argument('--design'); p.add_argument('--title'); p.add_argument('--dry', action='store_true')     a = ap.parse_args()-    {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'reset': cmd_reset, 'export': cmd_export, 'view3d': cmd_view3d}[a.cmd](a)+    {'silkscreen': cmd_silkscreen, 'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'reset': cmd_reset, 'export': cmd_export, 'view3d': cmd_view3d}[a.cmd](a)   if __name__ == '__main__':
tools/timeline/README.mdadded+48
@@ -0,0 +1,48 @@+# Flow timeline++A live, single-file dashboard for an adom-aiflow run. It shows every step of the flow on one path, where the AI is right now, how many steps are left, a check mark on every finished step, and a purple arc for every time a later step sent the AI back to rework an earlier one. Built for a Hydrogen webview beside the AI chat (about 700 to 900 px wide), and it switches to a horizontal layout above 1300 px.++## Run it++Serve this folder and have the flow write `timeline.json` next to `index.html`:++```bash+cp sample-timeline.json timeline.json      # or let adom-aiflow write it+python3 -m http.server 8790+```++Open `index.html` in a Hydrogen webview. The page fetches `timeline.json` (a relative URL, so it works behind a proxy path) every second and animates changes: a step turning done, the current-step marker gliding, a new loop arc drawing itself. `?src=other.json` reads a different relative file.++If the file is missing or invalid the page shows "Waiting for the flow to start". After the first good read, a missing or half-written file keeps the last picture and the header says "Reconnecting". Write the file atomically (write a temp file, then rename) to avoid half-written reads.++## timeline.json++```json+{+  "title": "12 V to 5 V, 1 A buck converter",+  "engine": "Fusion",+  "finish": "Gerbers, BOM and CPL exported",+  "updated": "2026-10-07T14:02:11Z",+  "current": "routing",+  "steps": [+    {"name": "routing", "title": "Routing", "does": "what the AI is doing in this step",+     "phase": "Board", "status": "current", "visits": 2, "minutes": 6.0}+  ],+  "loops": [+    {"from": "fields", "to": "placement", "why": "why the AI went back", "at": "2026-10-07T13:40:00Z"}+  ]+}+```++- `steps` is the ordered path. `status` is `todo`, `current`, `done` or `redo` (was done, a loop back reopened it). `current` at the top level wins over a step's own status.+- `phase` groups consecutive steps (Plan, Circuit, Library, Board, Analysis, Deliver).+- `visits` above 1 shows an "x2" style badge. `minutes` is AI time on that step, summed in the header.+- `loops` are in time order; the last one is the "latest" loop shown in the callout. `from` is the later step that found the problem, `to` is the earlier step the AI went back to. When writing a loop, also set the steps from `to` onward to `redo`.+- "Step N of M" is the current step's position; "to go" counts `todo` plus `redo` steps. When every step is `done` (and `current` is null) the finish line lights up.+- Unknown fields are ignored, so the schema can grow.++## Design notes++Adom brand palette only: teal `#00b8b1` for done marks, progress and the current step, purple `#8c6bf7` for loop backs and rework, the signature gradient on the finish line once reached. Familjen Grotesk for display, DM Sans for body (Satoshi if installed), JetBrains Mono for numbers. Icons are white line drawings. Tooltips (600 ms hover, tap on touch) clamp to the viewport. Motion is turned off under `prefers-reduced-motion`.++`window.flowTimeline.state()` returns what is on screen, for scripted checks.
tools/timeline/favicon.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="5" r="2.2"/><circle cx="5" cy="19" r="2.2"/><path d="M5 7.5v9M8 5h6a5 5 0 0 1 0 10h-3"/><path d="M13 12.5 10.5 15l2.5 2.5"/></svg>
tools/timeline/index.htmladded+1131
@@ -0,0 +1,1131 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>AI Flow Timeline</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<link rel="preconnect" href="https://fonts.googleapis.com">+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>+<link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&family=DM+Sans:opsz,[email protected],400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">+<style>+:root {+  color-scheme: dark;+  --bg: #0d1117;+  --surface: #161b22;+  --elevated: #1c2128;+  --overlay: #21262d;+  --border: #30363d;+  --border-soft: #21262d;+  --text: #e6edf3;+  --text-body: #c9d1d9;+  --text-2: #8b949e;+  --text-3: #484f58;+  --accent: #00b8b1;+  --accent-bright: #00e6dc;+  --accent-glow: rgba(0, 184, 177, .38);+  --accent-wash: rgba(0, 184, 177, .10);+  --on-accent: #05221f;+  --purple: #8c6bf7;+  --purple-wash: rgba(140, 107, 247, .13);+  --blue: #64abff;+  --sig: linear-gradient(100deg, #00e6dc, #39b8ff 60%, #8c6bf7);++  --f-display: 'Familjen Grotesk', 'DM Sans', system-ui, sans-serif;+  --f-body: 'Satoshi', 'DM Sans', system-ui, sans-serif;+  --f-mono: 'JetBrains Mono', ui-monospace, monospace;++  --pad: 20px;+  --row: 32px;+  --row-cur: 86px;+  --gut: 64px;+  --node-col: 40px;+  --phase-col: 112px;+  --ease: cubic-bezier(.65, 0, .35, 1);+  --ease-out: cubic-bezier(.22, 1, .36, 1);+}+* { box-sizing: border-box; }+html, body { margin: 0; background: var(--bg); color: var(--text-body); }+body {+  font-family: var(--f-body);+  font-size: 15px;+  line-height: 1.4;+  overflow-x: hidden;+  -webkit-font-smoothing: antialiased;+  font-variant-numeric: tabular-nums;+  min-height: 100vh;+}+body::before {+  /* very soft teal light from the top, gives the ground some depth on camera */+  content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;+  background:+    radial-gradient(1200px 420px at 15% -120px, rgba(0, 184, 177, .07), transparent 70%);+}+.mono { font-family: var(--f-mono); font-variant-numeric: tabular-nums; }+svg.i { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }++/* ---------- app bar (44px, three zones) ---------- */+.appbar {+  position: relative; z-index: 2;+  height: 44px; display: flex; align-items: center; gap: 12px;+  padding: 0 var(--pad);+  border-bottom: 1px solid var(--border-soft);+  background: rgba(13, 17, 23, .7);+}+.brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-family: var(--f-display); font-weight: 600; font-size: 15px; letter-spacing: .1px; white-space: nowrap; cursor: default; }+.brand svg.i { width: 16px; height: 16px; color: var(--text); }+.brand .sep { color: var(--text-2); }+.vsep { width: 1px; height: 20px; background: var(--border); flex: none; }+.subject { display: flex; align-items: baseline; gap: 10px; min-width: 0; }+.subject b { color: var(--text); font-family: var(--f-display); font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.subject .meta2 { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; }+.appbar .spacer { flex: 1; }+.chip {+  display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 11px;+  border: 1px solid var(--border); border-radius: 999px; color: var(--text-body);+  font-size: 13px; font-weight: 500; white-space: nowrap; background: var(--surface);+}+.chip svg.i { width: 15px; height: 15px; color: var(--text); }+.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(0, 184, 177, .6); animation: livepulse 2.4s ease-out infinite; align-self: center; }+.live.stale .dot { background: var(--text-2); animation: none; }+.live.idle .dot { background: var(--text-2); animation: none; }+.live .when { color: var(--text-2); font-family: var(--f-mono); font-size: 12px; }+@keyframes livepulse { 0% { box-shadow: 0 0 0 0 rgba(0, 184, 177, .55); } 70%, 100% { box-shadow: 0 0 0 7px rgba(0, 184, 177, 0); } }++/* ---------- hero header ---------- */+.hero { position: relative; z-index: 2; padding: 14px var(--pad) 0; display: grid; grid-template-columns: 1fr auto; gap: 6px 20px; align-items: center; }+.hero h1 {+  margin: 0; font-family: var(--f-display); font-weight: 700; color: var(--text);+  font-size: 26px; line-height: 1.12; letter-spacing: -.2px;+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;+}+.finishline { display: flex; align-items: center; gap: 8px; margin-top: 5px; color: var(--text-2); font-size: 14px; min-width: 0; }+.finishline svg.i { color: var(--text); width: 17px; height: 17px; }+.finishline b { color: var(--text-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.ring { position: relative; width: 84px; height: 84px; grid-row: span 2; }+.ring svg { width: 84px; height: 84px; transform: rotate(-90deg); }+.ring circle { fill: none; stroke-width: 7; }+.ring .track { stroke: var(--overlay); }+.ring .redo { stroke: var(--purple); opacity: .85; transition: stroke-dasharray .9s var(--ease), stroke-dashoffset .9s var(--ease); }+.ring .done { stroke: url(#ringgrad); stroke-linecap: round; transition: stroke-dasharray .9s var(--ease); }+.ring .pct { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }+.ring .pct b { font-family: var(--f-mono); font-weight: 600; font-size: 21px; color: var(--text); line-height: 1; }+.ring .pct b small { font-size: 12px; color: var(--text-2); font-weight: 500; margin-left: 1px; }+.ring .pct span { font-size: 11px; color: var(--text-2); margin-top: 3px; letter-spacing: .4px; }++.stats { grid-column: 1 / 2; display: flex; flex-wrap: wrap; gap: 6px 26px; align-items: baseline; margin-top: 4px; }+.stat { display: flex; align-items: baseline; gap: 7px; white-space: nowrap; font-size: 16px; color: var(--text-2); }+.stat b { font-family: var(--f-mono); font-weight: 600; color: var(--text); font-size: 26px; line-height: 1; letter-spacing: -.5px; transition: color .4s; }+.stat.stepno { color: var(--text-body); font-family: var(--f-display); font-weight: 600; font-size: 19px; }+.stat.stepno b { color: var(--accent-bright); font-size: 30px; }+.stat.loops b { color: var(--purple); }+.stat.loops.none b { color: var(--text-2); }+.stat .unit { font-size: 14px; }++/* ---------- latest loop callout ---------- */+.callout-wrap { position: relative; z-index: 2; padding: 0 var(--pad); display: grid; grid-template-rows: 0fr; transition: grid-template-rows .6s var(--ease-out), margin .6s var(--ease-out); margin-top: 0; }+.callout-wrap.on { grid-template-rows: 1fr; margin-top: 12px; }+.callout-wrap > div { overflow: hidden; min-height: 0; }+.callout {+  display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: start;+  padding: 8px 14px 9px 12px; border-radius: 12px; cursor: default;+  background: linear-gradient(90deg, var(--purple-wash), rgba(140, 107, 247, .04));+  border: 1px solid rgba(140, 107, 247, .35);+  transition: border-color .2s ease, background .2s ease;+}+.callout:hover { border-color: rgba(140, 107, 247, .7); }+.callout .ic { grid-row: span 2; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(140, 107, 247, .18); color: var(--text); margin-top: 1px; }+.callout .ic svg.i { width: 17px; height: 17px; }+.callout .head { font-size: 14px; color: var(--purple); font-weight: 600; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }+.callout .head em { font-style: normal; color: var(--text); }+.callout .head .t { margin-left: auto; color: var(--text-2); font-family: var(--f-mono); font-size: 12px; font-weight: 400; }+.callout .why { color: var(--text-body); font-size: 15px; line-height: 1.38; }+.callout.fresh { animation: calloutin 1.4s var(--ease-out); }+@keyframes calloutin { 0% { box-shadow: 0 0 0 0 rgba(140, 107, 247, .55); } 100% { box-shadow: 0 0 0 14px rgba(140, 107, 247, 0); } }++/* ---------- stage: the timeline ---------- */+.stage { position: relative; z-index: 1; margin: 10px var(--pad) 0; padding: 0 0 8px; }+.overlay { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 4; }+.spine, .spine-fill { position: absolute; z-index: 0; border-radius: 2px; }+.spine { background: var(--border); }+.spine-fill { background: linear-gradient(180deg, rgba(0, 184, 177, .7), var(--accent-bright)); transition: height .8s var(--ease), width .8s var(--ease); box-shadow: 0 0 10px rgba(0, 184, 177, .25); }+.halo { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; pointer-events: none; transition: transform .85s var(--ease); }+.halo::before, .halo::after {+  content: ""; position: absolute; left: -22px; top: -22px; width: 44px; height: 44px; border-radius: 50%;+  border: 2px solid var(--accent); opacity: 0;+  animation: ping 2.2s var(--ease-out) infinite;+}+.halo::after { animation-delay: 1.1s; }+.halo .glow { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; background: radial-gradient(circle, var(--accent-glow), transparent 68%); }+.halo.hide { opacity: 0; }+@keyframes ping { 0% { transform: scale(.55); opacity: .85; } 100% { transform: scale(1.45); opacity: 0; } }++ol.steps { list-style: none; margin: 0; padding: 0; position: relative; z-index: 3; }+li.step {+  position: relative;+  display: grid; grid-template-columns: var(--gut) var(--node-col) minmax(0, 1fr) auto var(--phase-col);+  align-items: center; height: var(--row);+  transition: height .6s var(--ease);+}+li.step.cur { height: var(--row-cur); }+li.step .rowbg {+  position: absolute; left: calc(var(--gut) - 8px); right: calc(var(--phase-col) - 10px); top: 3px; bottom: 3px;+  border-radius: 12px; opacity: 0; transition: opacity .5s ease;+  background: linear-gradient(90deg, rgba(0, 184, 177, .14), rgba(0, 184, 177, .03) 70%);+  border: 1px solid rgba(0, 184, 177, .34);+  box-shadow: 0 8px 30px rgba(0, 0, 0, .25);+}+li.step.cur .rowbg { opacity: 1; }+li.step:not(.cur):hover .rowbg { opacity: .45; background: var(--surface); border-color: var(--border); box-shadow: none; }++.node {+  grid-column: 2; justify-self: center; position: relative; z-index: 2;+  width: 22px; height: 22px; border-radius: 50%;+  display: grid; place-items: center;+  background: var(--bg); border: 2px solid var(--text-3);+  transition: background .45s ease, border-color .45s ease, transform .45s var(--ease-out), width .45s var(--ease-out), height .45s var(--ease-out), box-shadow .45s ease;+}+.node > svg { grid-area: 1 / 1; }+.node svg { width: 14px; height: 14px; fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; opacity: 0; transition: opacity .3s ease; }+.node .ck { stroke: var(--on-accent); stroke-dasharray: 20; stroke-dashoffset: 20; }+.node .rd { stroke: var(--text); stroke-width: 2.4; width: 13px; height: 13px; }+.node .cu { stroke: none; fill: var(--accent-bright); width: 10px; height: 10px; }++li.done .node { background: var(--accent); border-color: var(--accent); }+li.done .node .ck { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset .5s var(--ease-out) .1s, opacity .1s; }+li.redo .node { background: #17142b; border-color: var(--purple); }+li.redo .node .rd { opacity: 1; }+li.cur .node { width: 30px; height: 30px; background: var(--bg); border: 3px solid var(--accent-bright); box-shadow: 0 0 0 5px rgba(0, 230, 220, .14), 0 0 24px rgba(0, 230, 220, .55); }+li.cur .node svg.cu { opacity: 1; }+li.just-done .node { animation: donepop .9s var(--ease-out); }+@keyframes donepop { 0% { transform: scale(.6); box-shadow: 0 0 0 0 rgba(0, 184, 177, .7); } 45% { transform: scale(1.18); } 100% { transform: scale(1); box-shadow: 0 0 0 16px rgba(0, 184, 177, 0); } }+li.just-redo .node { animation: redopop 1s var(--ease-out); }+@keyframes redopop { 0% { box-shadow: 0 0 0 0 rgba(140, 107, 247, .7); } 100% { box-shadow: 0 0 0 16px rgba(140, 107, 247, 0); } }++.label { grid-column: 3; min-width: 0; padding-left: 8px; position: relative; z-index: 2; }+.label .t { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 16px; font-weight: 500; color: var(--text-body); transition: color .4s ease, font-size .45s var(--ease); }+.label .t .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.label .n { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); width: 20px; flex: none; text-align: right; margin-right: 2px; transition: color .4s; }+li.todo .label .t { color: var(--text-2); }+li.redo .label .t { color: var(--text-body); }+li.cur .label .t { font-family: var(--f-display); font-size: 23px; font-weight: 700; color: var(--text); letter-spacing: -.1px; }+li.cur .label .n { color: var(--accent-bright); }+.badge {+  flex: none; font-family: var(--f-mono); font-size: 12px; font-weight: 600; line-height: 1;+  padding: 3px 6px 3px 5px; border-radius: 999px; display: inline-flex; align-items: center; gap: 3px;+  color: var(--purple); background: var(--purple-wash); border: 1px solid rgba(140, 107, 247, .4);+  transition: opacity .3s ease, transform .3s var(--ease-out);+}+.badge svg { width: 11px; height: 11px; fill: none; stroke: var(--text); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }+.badge.off { display: none; }+li.cur .badge { font-size: 13px; }+.label .does {+  font-size: 15px; color: var(--text-body); line-height: 1.35; margin-top: 3px; padding-left: 30px;+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;+  max-height: 0; opacity: 0; transition: opacity .4s ease .15s, max-height .5s var(--ease);+}+li.cur .label .does { max-height: 42px; opacity: 1; }+.meta { grid-column: 4; position: relative; z-index: 2; padding: 0 14px 0 10px; font-family: var(--f-mono); font-size: 13px; color: var(--text-2); white-space: nowrap; text-align: right; }+li.cur .meta { color: var(--text-body); }+.meta .redo-tag { color: var(--purple); font-family: var(--f-body); font-size: 13px; font-weight: 600; }++/* finish row */+li.finish .node { width: 30px; height: 30px; border-radius: 9px; background: var(--surface); border: 2px solid var(--border); color: var(--text); }+li.finish .node svg.fl { width: 16px; height: 16px; opacity: 1; stroke: currentColor; stroke-width: 2; }+li.finish .label .t { font-family: var(--f-display); font-weight: 600; color: var(--text-body); font-size: 16px; }+li.finish .label .t .nm { white-space: nowrap; }+li.finish .label .t .k { color: var(--text-2); font-weight: 500; }+li.finish.won .node { background: var(--sig); border-color: transparent; color: var(--bg); box-shadow: 0 0 26px rgba(57, 184, 255, .45); }+li.finish.won .label .t { color: var(--text); font-size: 23px; font-weight: 700; }+li.finish.won { height: var(--row-cur); }+li.finish.won .rowbg { opacity: 1; background: linear-gradient(100deg, rgba(0, 230, 220, .14), rgba(57, 184, 255, .08) 60%, rgba(140, 107, 247, .10)); border-color: rgba(57, 184, 255, .45); }+body.wide li.finish.won { height: auto; }+body.wide li.finish.won .label .t { font-size: 15px; }+.stat.stepno.won b { background: var(--sig); -webkit-background-clip: text; background-clip: text; color: transparent; }++/* phase brackets */+.phase { position: absolute; z-index: 2; pointer-events: auto; transition: top .6s var(--ease), height .6s var(--ease), left .6s var(--ease), width .6s var(--ease); }+.phase .bar { position: absolute; background: var(--border); border-radius: 2px; transition: background .5s ease; }+.phase .nm { position: absolute; font-family: var(--f-display); font-weight: 600; font-size: 14px; color: var(--text-2); white-space: nowrap; transition: color .5s; }+.phase .ct { position: absolute; font-family: var(--f-mono); font-size: 12px; color: var(--text-2); white-space: nowrap; }+.phase.active .bar { background: var(--accent); box-shadow: 0 0 8px rgba(0, 184, 177, .45); }+.phase.active .nm { color: var(--text); }+.phase.complete .bar { background: rgba(0, 184, 177, .75); }+.phase.complete .nm { color: var(--text-body); }+.phase.rework .bar { background: var(--purple); }+.phase.rework .nm { color: var(--text-body); }++/* loop arcs */+.arc { fill: none; stroke: var(--purple); stroke-width: 2.4; stroke-linecap: round; opacity: .5; transition: opacity .3s ease, stroke-width .3s ease; }+.arc.latest { opacity: 1; stroke: var(--purple); filter: drop-shadow(0 0 5px rgba(140, 107, 247, .55)); }+.arc.new { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.3s var(--ease) forwards; }+.arrow { fill: var(--purple); opacity: .5; transition: opacity .3s ease; }+.arrow.latest { fill: var(--purple); opacity: 1; }+.arrow.new { animation: fadein .3s ease 1.15s both; }+.arc-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: help; }+.loopbadge { pointer-events: auto; cursor: help; }+.loopbadge circle { fill: #1b1735; stroke: var(--purple); stroke-width: 1.6; }+.loopbadge.latest circle { fill: var(--purple); stroke: var(--purple); }+.loopbadge text { font-family: var(--f-mono); font-size: 11.5px; font-weight: 600; fill: var(--purple); text-anchor: middle; dominant-baseline: central; }+.loopbadge.latest text { fill: #0d1117; }+.loopbadge.new { animation: fadein .35s ease 1.1s both; }+.arcgrp.hl .arc { opacity: 1; stroke-width: 3.4; }+.arcgrp.hl .arrow { opacity: 1; }+.arcgrp.dim .arc, .arcgrp.dim .arrow { opacity: .18; }+@keyframes draw { to { stroke-dashoffset: 0; } }+@keyframes fadein { from { opacity: 0; } }++body.notrans *, body.notrans *::before, body.notrans *::after { transition: none !important; }++.arcpill {+  position: absolute; z-index: 4; left: 0; top: 0; display: none; align-items: center; gap: 9px;+  max-width: min(900px, calc(100% - 16px)); padding: 6px 18px 6px 6px; border-radius: 18px; cursor: help;+  background: #1a1631; border: 1.5px solid var(--purple); color: var(--text-body);+  font-size: 14.5px; line-height: 1.25; box-shadow: 0 6px 24px rgba(0, 0, 0, .45), 0 0 18px rgba(140, 107, 247, .25);+  transition: left .6s var(--ease), top .6s var(--ease), opacity .4s ease;+}+.arcpill .num { flex: none; width: 22px; height: 22px; border-radius: 50%; background: var(--purple); color: #0d1117; font-family: var(--f-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }+.arcpill .tx { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }+.arcpill b { color: var(--purple); font-weight: 600; }+body.wide .arcpill.on { display: flex; }+body.wide .loopbadge.latest { display: none; }+.arcpill.new { animation: fadein .4s ease 1.2s both; }++/* ---------- wide layout: horizontal timeline ---------- */+.lower { display: none; }+body.wide { --pad: 32px; }+body.wide .hero { grid-template-columns: minmax(0, 1fr) auto auto; padding-top: 22px; }+body.wide .hero h1 { font-size: 32px; }+body.wide .ring { grid-row: 1 / 3; grid-column: 3; }+body.wide .stats { grid-column: 2; grid-row: 1 / 3; gap: 10px 34px; margin: 0 6px 0 0; flex-wrap: nowrap; }+body.wide .stat b { font-size: 30px; }+body.wide .stat.stepno b { font-size: 36px; }+body.wide .callout-wrap { display: none; }+body.wide .stage { margin-top: 22px; background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 18px 16px; }+body.wide ol.steps { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }+body.wide li.step, body.wide li.step.cur {+  height: auto; display: flex; flex-direction: column; align-items: center; text-align: center;+  padding-top: var(--htop); grid-template-columns: none;+}+body.wide .node { flex: none; }+body.wide li.step .rowbg { left: 3px; right: 3px; top: calc(var(--htop) - 18px); bottom: -6px; }+body.wide .label { padding: 10px 2px 0; width: 100%; }+body.wide .label .t { flex-direction: column; gap: 5px; font-size: 14px; line-height: 1.2; padding: 0 2px; hyphens: auto; font-size: 13.5px; }+body.wide .label .t .nm { white-space: normal; overflow: visible; text-wrap: balance; }+body.wide li.cur .label .t { font-size: 16px; color: var(--accent-bright); }+body.wide .label .n { width: auto; margin: 0; text-align: center; }+body.wide .label .does { display: none; }+body.wide .meta { padding: 4px 0 0; text-align: center; font-size: 12px; }+body.wide li.finish .label .t .k { display: block; }+body.wide .lower { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; margin: 18px var(--pad) 24px; position: relative; z-index: 2; }+.card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 22px; min-width: 0; }+.card .eyebrow { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); display: flex; align-items: center; gap: 8px; }+.card .eyebrow svg.i { width: 15px; height: 15px; color: var(--text); }+.now h2 { font-family: var(--f-display); font-size: 34px; font-weight: 700; color: var(--text); margin: 10px 0 6px; line-height: 1.1; display: flex; align-items: center; gap: 12px; }+.now h2 .n:empty { display: none; }+.now h2 .n { font-family: var(--f-mono); color: var(--accent-bright); font-size: 22px; font-weight: 600; }+.now p { margin: 0; font-size: 19px; line-height: 1.45; color: var(--text-body); }+.now .facts { display: flex; gap: 22px; margin-top: 14px; color: var(--text-2); font-size: 14px; flex-wrap: wrap; }+.now .facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }+.looplist { margin: 10px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }+.looplist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 3px 12px; align-items: baseline; padding: 9px 12px; border-radius: 10px; border: 1px solid transparent; cursor: default; transition: background .2s ease, border-color .2s ease; }+.looplist li:hover { background: var(--elevated); }+.looplist li.latest { background: var(--purple-wash); border-color: rgba(140, 107, 247, .35); }+.looplist .num { grid-row: span 2; align-self: start; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-family: var(--f-mono); font-size: 12px; font-weight: 600; color: var(--purple); border: 1.5px solid var(--purple); }+.looplist li.latest .num { background: var(--purple); color: #0d1117; }+.looplist .path { font-weight: 600; color: var(--text); font-size: 15px; display: flex; gap: 7px; align-items: center; }+.looplist .path svg.i { width: 15px; height: 15px; color: var(--text); }+.looplist .tm { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); }+.looplist .why { grid-column: 2 / 4; color: var(--text-body); font-size: 15px; }+.looplist .empty { color: var(--text-2); font-size: 15px; display: block; padding: 6px 0; }++/* ---------- waiting state ---------- */+.waiting { display: none; position: fixed; inset: 0; z-index: 5; place-items: center; text-align: center; padding: 24px; }+body.waiting-on .waiting { display: grid; }+body.waiting-on .appbar ~ *:not(.waiting) { visibility: hidden; }+.waiting .orb { width: 74px; height: 74px; margin: 0 auto 22px; border-radius: 50%; position: relative; display: grid; place-items: center; color: var(--text); }+.waiting .orb::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: spin 2.4s linear infinite; }+.waiting .orb svg.i { width: 28px; height: 28px; }+.waiting h2 { font-family: var(--f-display); font-weight: 700; font-size: 26px; color: var(--text); margin: 0 0 8px; }+.waiting p { margin: 0 auto; max-width: 420px; color: var(--text-2); font-size: 15px; line-height: 1.5; }+.waiting code { font-family: var(--f-mono); font-size: 13px; color: var(--text-body); background: var(--surface); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }+@keyframes spin { to { transform: rotate(360deg); } }++/* ---------- tooltip ---------- */+#tip {+  position: fixed; z-index: 99999; left: 0; top: 0; display: none; opacity: 0; pointer-events: none;+  max-width: min(340px, calc(100vw - 24px));+  background: rgba(33, 38, 45, .97); color: var(--text-body);+  border: 1px solid rgba(0, 184, 177, .28); border-radius: 10px;+  padding: 10px 13px; font: 400 14px/1.45 var(--f-body);+  box-shadow: 0 14px 40px rgba(0, 0, 0, .55);+  transition: opacity .18s ease, transform .18s ease;+}+#tip.loopy { border-color: rgba(140, 107, 247, .5); }+#tip.on { display: block; }+#tip.shown { opacity: 1; }+#tip .tt { font-family: var(--f-display); font-weight: 700; font-size: 15px; color: var(--text); margin-bottom: 3px; display: flex; gap: 8px; align-items: baseline; }+#tip .tt .s { margin-left: auto; font-family: var(--f-body); font-weight: 600; font-size: 12px; }+#tip .s.done { color: var(--accent); } #tip .s.current { color: var(--accent-bright); } #tip .s.redo { color: var(--purple); } #tip .s.todo { color: var(--text-2); }+#tip .ft { margin-top: 6px; color: var(--text-2); font-size: 12.5px; font-family: var(--f-mono); }++/* phones and very narrow panes */+@media (max-width: 620px) {+  :root { --pad: 14px; --phase-col: 0px; --row-cur: 96px; }+  .phase { display: none; }+  li.step .rowbg { right: 0; }+  .hero h1 { font-size: 22px; white-space: normal; }+  .ring { width: 70px; height: 70px; } .ring svg { width: 70px; height: 70px; }+  .stat b { font-size: 22px; } .stat.stepno b { font-size: 26px; }+}++@media (prefers-reduced-motion: reduce) {+  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; animation-delay: 0s !important; transition-duration: .001ms !important; transition-delay: 0s !important; }+  .halo::before, .halo::after { display: none; }+  .arc.new { stroke-dashoffset: 0; }+}+</style>+</head>+<body class="waiting-on">++<svg width="0" height="0" style="position:absolute" aria-hidden="true">+  <defs>+    <linearGradient id="ringgrad" x1="0" y1="0" x2="1" y2="1">+      <stop offset="0" stop-color="#00b8b1"/><stop offset="1" stop-color="#00e6dc"/>+    </linearGradient>+    <symbol id="i-flow" viewBox="0 0 24 24"><circle cx="5" cy="5" r="2.2"/><circle cx="5" cy="19" r="2.2"/><path d="M5 7.5v9M8 5h6a5 5 0 0 1 0 10h-3"/><path d="M13 12.5 10.5 15l2.5 2.5"/></symbol>+    <symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 21V4"/><path d="M5 4.5c4.5-2 7.5 2 12 0v9c-4.5 2-7.5-2-12 0"/><path d="M9 4.2v9M13 5.4v9"/></symbol>+    <symbol id="i-loop" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></symbol>+    <symbol id="i-back" viewBox="0 0 24 24"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></symbol>+    <symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2.5v3.5M15 2.5v3.5M9 18v3.5M15 18v3.5M2.5 9H6M2.5 15H6M18 9h3.5M18 15h3.5"/></symbol>+    <symbol id="i-now" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>+  </defs>+</svg>++<header class="appbar">+  <div class="brand" data-tipkey="brand"><svg class="i"><use href="#i-flow"/></svg>AI Flow <span class="sep">&middot;</span> Timeline</div>+  <div class="vsep"></div>+  <div class="subject"><b id="engine" data-tipkey="engine" style="display:none"><span></span></b><span class="meta2 live idle" id="live" data-tipkey="live"><span class="dot"></span><span class="lbl">Live</span><span class="when" id="when"></span></span></div>+</header>++<section class="hero">+  <div style="min-width:0">+    <h1 id="title">Flow</h1>+    <div class="finishline" data-tipkey="finish"><svg class="i"><use href="#i-flag"/></svg><span>Finish line:</span><b id="finish"></b></div>+  </div>+  <div class="stats" id="stats">+    <div class="stat stepno" data-tipkey="stepno">Step <b id="s-step">0</b> of <span id="s-total">0</span></div>+    <div class="stat" data-tipkey="togo"><b id="s-togo">0</b> to go</div>+    <div class="stat" data-tipkey="minutes"><b id="s-min">0.0</b> <span class="unit">AI min</span></div>+    <div class="stat loops none" id="s-loops-w" data-tipkey="loops"><b id="s-loops">0</b> <span id="s-loops-l">loop backs</span></div>+  </div>+  <div class="ring" data-tipkey="ring">+    <svg viewBox="0 0 84 84"><circle class="track" cx="42" cy="42" r="36"/><circle class="redo" id="ring-redo" cx="42" cy="42" r="36"/><circle class="done" id="ring-done" cx="42" cy="42" r="36"/></svg>+    <div class="pct"><b id="pct">0<small>%</small></b><span>done</span></div>+  </div>+</section>++<div class="callout-wrap" id="callout-wrap"><div><div class="callout" id="callout" data-tipkey="callout">+  <div class="ic"><svg class="i"><use href="#i-loop"/></svg></div>+  <div class="head"><span id="co-n">Loop back</span><em id="co-path"></em><span class="t" id="co-t"></span></div>+  <div class="why" id="co-why"></div>+</div></div></div>++<main class="stage" id="stage">+  <div class="spine" id="spine"></div>+  <div class="spine-fill" id="spine-fill"></div>+  <svg class="overlay" id="overlay"></svg>+  <div class="halo hide" id="halo"><div class="glow"></div></div>+  <div id="phases"></div>+  <div class="arcpill" id="arcpill"><span class="num"></span><span class="tx"></span></div>+  <ol class="steps" id="steps"></ol>+</main>++<section class="lower" id="lower">+  <div class="card now">+    <div class="eyebrow"><svg class="i"><use href="#i-now"/></svg>Now working on</div>+    <h2><span class="n" id="now-n"></span><span id="now-title"></span></h2>+    <p id="now-does"></p>+    <div class="facts" id="now-facts"></div>+  </div>+  <div class="card">+    <div class="eyebrow"><svg class="i"><use href="#i-loop"/></svg>Loop backs: where the AI went back to rework</div>+    <ul class="looplist" id="looplist"></ul>+  </div>+</section>++<div class="waiting" id="waiting">+  <div>+    <div class="orb"><svg class="i"><use href="#i-flow"/></svg></div>+    <h2>Waiting for the flow to start</h2>+    <p>The timeline appears here as soon as adom-aiflow writes <code>timeline.json</code>. It checks again every second.</p>+  </div>+</div>++<div id="tip" role="tooltip"></div>++<script>+(() => {+  'use strict';+  const $ = id => document.getElementById(id);+  const SVGNS = 'http://www.w3.org/2000/svg';+  const params = new URLSearchParams(location.search);+  // Relative on purpose: the page is served behind a proxy path. Never add a leading slash.+  const SRC = (params.get('src') || 'timeline.json').replace(/^\/+/, '');+  const WIDE_AT = 1300;+  const reduced = matchMedia('(prefers-reduced-motion: reduce)');+  const STATUSES = { todo: 1, current: 1, done: 1, redo: 1 };+  const STATUS_LABEL = { done: 'Done', current: 'Working now', redo: 'Needs rework', todo: 'Not started' };++  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));+  const fmtMin = m => (Math.round((+m || 0) * 10) / 10).toFixed(1);+  const fmtClock = iso => {+    const d = new Date(iso);+    if (isNaN(d)) return '';+    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });+  };+  const fmtClockS = iso => {+    const d = new Date(iso);+    if (isNaN(d)) return '';+    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });+  };++  const svgEl = (tag, attrs) => {+    const el = document.createElementNS(SVGNS, tag);+    for (const k in attrs) el.setAttribute(k, attrs[k]);+    return el;+  };++  // ---------------- state ----------------+  let model = null;          // normalized data+  let lastRaw = '';+  let stepKey = '';           // names joined, decides rebuild vs in-place update+  const rows = new Map();     // name -> {li, ...}+  let finishLi = null;+  const phaseEls = new Map();+  const seenLoops = new Set();+  let firstRender = true;+  let lastOkAt = 0, everOk = false;+  let lastCurrent = null;++  // ---------------- normalize ----------------+  function normalize(d) {+    if (!d || typeof d !== 'object' || !Array.isArray(d.steps) || !d.steps.length) return null;+    const steps = [];+    const seen = new Set();+    d.steps.forEach((s, i) => {+      if (!s || typeof s !== 'object') return;+      let name = String(s.name || s.title || ('step-' + i));+      while (seen.has(name)) name += '_';+      seen.add(name);+      steps.push({+        name,+        title: String(s.title || s.name || 'Step ' + (i + 1)),+        does: String(s.does || ''),+        phase: String(s.phase || ''),+        status: STATUSES[s.status] ? s.status : 'todo',+        visits: Math.max(0, parseInt(s.visits, 10) || 0),+        minutes: Math.max(0, +s.minutes || 0),+      });+    });+    if (!steps.length) return null;+    let current = d.current && seen.has(String(d.current)) ? String(d.current) : null;+    if (!current) {+      const c = steps.find(s => s.status === 'current');+      if (c) current = c.name;+    }+    steps.forEach(s => {+      if (s.name === current) s.status = 'current';+      else if (s.status === 'current') s.status = 'todo';+    });+    const idx = Object.fromEntries(steps.map((s, i) => [s.name, i]));+    const loops = (Array.isArray(d.loops) ? d.loops : [])+      .filter(l => l && idx[l.from] != null && idx[l.to] != null && idx[l.from] !== idx[l.to])+      .map((l, i) => ({ from: String(l.from), to: String(l.to), why: String(l.why || ''), at: l.at || '', n: i + 1 }));+    loops.forEach(l => { l.key = l.from + '>' + l.to + '@' + l.at + '#' + l.n; });+    return {+      title: String(d.title || 'adom-aiflow'),+      engine: String(d.engine || ''),+      finish: String(d.finish || 'Finished'),+      updated: d.updated || '',+      current, steps, loops, idx,+    };+  }++  // ---------------- build ----------------+  const ICON_CK = '<svg class="ck" viewBox="0 0 24 24"><path d="M5.5 12.5 10 17l8.5-9.5"/></svg>';+  const ICON_RD = '<svg class="rd" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>';+  const ICON_CU = '<svg class="cu" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11"/></svg>';+  const ICON_FL = '<svg class="fl" viewBox="0 0 24 24"><path d="M5 21V4"/><path d="M5 4.5c4.5-2 7.5 2 12 0v9c-4.5 2-7.5-2-12 0"/><path d="M9 4.2v9M13 5.4v9"/></svg>';+  const ICON_BADGE = '<svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>';++  function build(m) {+    const ol = $('steps');+    ol.textContent = '';+    rows.clear();+    m.steps.forEach((s, i) => {+      const li = document.createElement('li');+      li.className = 'step todo';+      li.dataset.name = s.name;+      li.dataset.tipkey = 'step:' + s.name;+      li.innerHTML =+        '<div class="rowbg"></div>' ++        '<div class="node">' + ICON_CK + ICON_RD + ICON_CU + '</div>' ++        '<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span></div><div class="does"></div></div>' ++        '<div class="meta"></div>';+      ol.appendChild(li);+      rows.set(s.name, {+        li, node: li.querySelector('.node'), nm: li.querySelector('.nm'), badge: li.querySelector('.badge'),+        v: li.querySelector('.badge .v'), does: li.querySelector('.does'), meta: li.querySelector('.meta'), status: null,+      });+    });+    finishLi = document.createElement('li');+    finishLi.className = 'step finish';+    finishLi.dataset.tipkey = 'finish';+    finishLi.innerHTML = '<div class="rowbg"></div><div class="node">' + ICON_FL + '</div><div class="label"><div class="t"><span class="n"></span><span class="nm"><span class="k">Finish line: </span><span class="fx"></span></span></div></div><div class="meta"></div>';+    ol.appendChild(finishLi);++    const ph = $('phases');+    ph.textContent = '';+    phaseEls.clear();+    groupsOf(m).forEach(g => {+      const el = document.createElement('div');+      el.className = 'phase';+      el.dataset.tipkey = 'phase:' + g.key;+      el.innerHTML = '<div class="bar"></div><div class="nm"></div><div class="ct"></div>';+      el.querySelector('.nm').textContent = g.phase || 'Steps';+      ph.appendChild(el);+      phaseEls.set(g.key, el);+    });+  }++  function groupsOf(m) {+    const out = [];+    m.steps.forEach((s, i) => {+      const last = out[out.length - 1];+      if (last && last.phase === s.phase) last.end = i;+      else out.push({ phase: s.phase, start: i, end: i, key: s.phase + '#' + i });+    });+    return out;+  }++  // ---------------- render ----------------+  function render(m) {+    const key = m.steps.map(s => s.name + '|' + s.phase).join(',');+    if (key !== stepKey) { build(m); stepKey = key; firstRender = true; seenLoops.clear(); }++    const total = m.steps.length;+    const done = m.steps.filter(s => s.status === 'done').length;+    const redo = m.steps.filter(s => s.status === 'redo').length;+    const allDone = done === total;+    const curIdx = m.current ? m.idx[m.current] : -1;+    const posIdx = curIdx >= 0 ? curIdx : (allDone ? total - 1 : Math.max(0, m.steps.findIndex(s => s.status !== 'done')));+    const toGo = m.steps.filter(s => s.status === 'todo' || s.status === 'redo').length;+    const minutes = m.steps.reduce((a, s) => a + s.minutes, 0);++    // header+    document.title = m.title + ' : Flow Timeline';+    $('title').textContent = m.title;+    $('engine').style.display = m.engine ? '' : 'none';+    $('engine').querySelector('span').textContent = m.engine;+    $('finish').textContent = m.finish;+    setText('s-step', allDone && curIdx < 0 ? total : posIdx + 1);+    document.querySelector('.stat.stepno').classList.toggle('won', allDone && curIdx < 0);+    setText('s-total', total);+    setText('s-togo', toGo);+    setText('s-min', fmtMin(minutes));+    setText('s-loops', m.loops.length);+    $('s-loops-l').textContent = m.loops.length === 1 ? 'loop back' : 'loop backs';+    $('s-loops-w').classList.toggle('none', !m.loops.length);+    const C = 2 * Math.PI * 36;+    const fDone = done / total, fRedo = redo / total;+    $('ring-done').style.strokeDasharray = (C * fDone) + ' ' + C;+    $('ring-redo').style.strokeDasharray = (C * fRedo) + ' ' + C;+    $('ring-redo').style.strokeDashoffset = -(C * fDone);+    $('pct').firstChild.nodeValue = Math.round(fDone * 100);+    $('when').textContent = fmtClockS(m.updated);++    // rows+    m.steps.forEach((s, i) => {+      const r = rows.get(s.name);+      const prev = r.status;+      const cls = s.status === 'current' ? 'cur' : s.status;+      if (prev !== s.status) {+        r.li.className = 'step ' + cls;+        if (!firstRender && s.status === 'done' && prev && prev !== 'done') flash(r.li, 'just-done', 1000);+        if (!firstRender && s.status === 'redo' && prev && prev !== 'redo') flash(r.li, 'just-redo', 1100);+        r.status = s.status;+      }+      if (r.nm.textContent !== s.title) r.nm.textContent = s.title;+      if (r.does.textContent !== s.does) r.does.textContent = s.does;+      const showBadge = s.visits > 1;+      r.badge.classList.toggle('off', !showBadge);+      r.v.textContent = 'x' + s.visits;+      r.badge.setAttribute('aria-label', 'Visited ' + s.visits + ' times');+      let meta = '';+      if (s.status === 'redo') meta = '<span class="redo-tag">rework</span>';+      else if (s.minutes > 0) meta = fmtMin(s.minutes) + ' min';+      if (r.meta._v !== meta) { r.meta.innerHTML = meta; r.meta._v = meta; }+    });+    finishLi.querySelector('.fx').textContent = m.finish;+    finishLi.classList.toggle('won', allDone);++    // phases+    groupsOf(m).forEach(g => {+      const el = phaseEls.get(g.key);+      if (!el) return;+      const ss = m.steps.slice(g.start, g.end + 1);+      const nd = ss.filter(s => s.status === 'done').length;+      el.classList.toggle('active', ss.some(s => s.status === 'current'));+      el.classList.toggle('complete', nd === ss.length);+      el.classList.toggle('rework', !ss.some(s => s.status === 'current') && ss.some(s => s.status === 'redo'));+      el.querySelector('.ct').textContent = nd + ' of ' + ss.length;+    });++    // latest loop callout (narrow) and loop list (wide)+    const latest = m.loops[m.loops.length - 1];+    const wrap = $('callout-wrap');+    if (latest) {+      const fresh = !seenLoops.has(latest.key) && !firstRender;+      $('co-n').textContent = 'Loop back ' + latest.n + ':';+      $('co-path').textContent = titleOf(m, latest.from) + ' sent the AI back to ' + titleOf(m, latest.to);+      $('co-t').textContent = fmtClock(latest.at);+      $('co-why').textContent = latest.why;+      wrap.classList.add('on');+      if (fresh) flash($('callout'), 'fresh', 1500);+    } else wrap.classList.remove('on');++    const ll = $('looplist');+    ll.innerHTML = m.loops.length ? m.loops.slice().reverse().slice(0, 4).map(l =>+      '<li class="' + (l === latest ? 'latest' : '') + '" data-tipkey="loop:' + esc(l.key) + '" data-loop="' + esc(l.key) + '">' ++      '<span class="num">' + l.n + '</span>' ++      '<span class="path">' + esc(titleOf(m, l.from)) + ' <svg class="i"><use href="#i-back"/></svg> back to ' + esc(titleOf(m, l.to)) + '</span>' ++      '<span class="tm">' + esc(fmtClock(l.at)) + '</span>' ++      '<span class="why">' + esc(l.why) + '</span></li>').join('')+      : '<li><span class="empty">No loop backs yet. When a later step finds a problem, the AI goes back to an earlier step and it shows up here.</span></li>';++    // now card (wide)+    const cs = m.steps[posIdx];+    $('now-n').textContent = allDone && curIdx < 0 ? '' : String(posIdx + 1).padStart(2, '0');+    $('now-title').textContent = allDone && curIdx < 0 ? 'Finished: ' + m.finish : cs.title;+    $('now-does').textContent = allDone && curIdx < 0 ? 'Every step is done.' : cs.does;+    $('now-facts').innerHTML = allDone && curIdx < 0 ? '' :+      '<span>Phase <b>' + esc(cs.phase || '-') + '</b></span>' ++      '<span>Visit <b>' + Math.max(1, cs.visits) + '</b></span>' ++      '<span>Time so far <b>' + fmtMin(cs.minutes) + ' min</b></span>';++    if (m.current !== lastCurrent) {+      const changed = lastCurrent !== null || !firstRender;+      lastCurrent = m.current;+      if (changed || firstRender) setTimeout(() => scrollCurrentIntoView(!firstRender), 650);+    }++    if (firstRender) document.body.classList.add('notrans');+    layout();+    if (firstRender) { drawOverlay(); requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('notrans'))); }+    animateOverlay(firstRender ? 400 : 1100);+    m.loops.forEach(l => seenLoops.add(l.key));+    firstRender = false;+  }++  function titleOf(m, name) { const i = m.idx[name]; return i == null ? name : m.steps[i].title; }+  function setText(id, v) { const el = $(id); const s = String(v); if (el.textContent !== s) el.textContent = s; }+  function flash(el, cls, ms) { el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls); setTimeout(() => el.classList.remove(cls), ms); }++  // ---------------- layout ----------------+  let wide = false, laneH = 42, laneW = 26;+  function lanesFor(m) {+    // interval colouring: shortest loops hug the spine, longer ones go outside them+    const items = m.loops.map(l => {+      const a = m.idx[l.to], b = m.idx[l.from];+      return { l, lo: Math.min(a, b), hi: Math.max(a, b) };+    }).sort((x, y) => (x.hi - x.lo) - (y.hi - y.lo) || x.lo - y.lo);+    const placed = [];+    items.forEach(it => {+      let lane = 0;+      const taken = new Set(placed.filter(p => !(p.hi < it.lo || p.lo > it.hi)).map(p => p.lane));+      while (taken.has(lane)) lane++;+      it.lane = lane;+      placed.push(it);+    });+    const map = new Map(placed.map(p => [p.l.key, p.lane]));+    const count = placed.length ? Math.max(...placed.map(p => p.lane)) + 1 : 0;+    return { map, count };+  }++  function layout() {+    if (!model) return;+    wide = innerWidth > WIDE_AT;+    document.body.classList.toggle('wide', wide);+    const lanes = lanesFor(model);+    const root = document.documentElement.style;+    if (wide) {+      const phaseBand = 40;+      const nl = Math.max(2, lanes.count);+      const setZone = lh => { laneH = lh; root.setProperty('--htop', (phaseBand + 40 + nl * lh) + 'px'); };+      setZone(42);+      // spend spare height on taller loop arcs so the page is balanced on a big screen+      const spare = innerHeight - ($('lower').getBoundingClientRect().bottom + scrollY) - 28;+      if (spare > 0) setZone(Math.min(84, 42 + spare / nl));+      const arcZone = 40 + nl * laneH;+      root.setProperty('--arcZone', arcZone + 'px');+      $('steps').style.gridTemplateColumns = model.steps.map(s => s.name === model.current ? '1.5fr' : '1fr').join(' ') + ' 1.3fr';+    } else {+      $('steps').style.gridTemplateColumns = '';+      // fixed gutter so a new loop never shifts the list sideways; lanes tighten instead+      laneW = Math.min(26, 66 / Math.max(1, lanes.count));+      root.setProperty('--gut', '96px');+      fitRows();+    }+  }++  function fitRows() {+    // make every step fit the pane height when possible, without going below a readable row+    const stage = $('stage');+    const top = stage.getBoundingClientRect().top + scrollY;+    const n = model.steps.length + 1;+    const curH = innerWidth <= 620 ? 96 : 86;+    const avail = innerHeight - top - 14;+    let row = Math.floor((avail - curH) / (n - 1));+    row = Math.max(26, Math.min(44, row));+    const rs = document.documentElement.style;+    if (rs.getPropertyValue('--row') !== row + 'px' || rs.getPropertyValue('--row-cur') !== curH + 'px') {+      rs.setProperty('--row', row + 'px');+      rs.setProperty('--row-cur', curH + 'px');+      animUntil = Math.max(animUntil, performance.now() + 750);+    }+  }++  // positions of every node centre, relative to the stage box+  function measure() {+    const st = $('stage').getBoundingClientRect();+    const pts = new Map();+    rows.forEach((r, name) => {+      const b = r.node.getBoundingClientRect();+      pts.set(name, { x: b.left + b.width / 2 - st.left, y: b.top + b.height / 2 - st.top, r: b.width / 2, li: r.li.getBoundingClientRect() });+    });+    const fb = finishLi.querySelector('.node').getBoundingClientRect();+    pts.set('__finish', { x: fb.left + fb.width / 2 - st.left, y: fb.top + fb.height / 2 - st.top, r: fb.width / 2, li: finishLi.getBoundingClientRect() });+    return { st, pts };+  }++  let animUntil = 0, rafOn = false;+  function animateOverlay(ms) {+    animUntil = Math.max(animUntil, performance.now() + ms);+    if (!rafOn) { rafOn = true; requestAnimationFrame(tick); }+  }+  function tick() {+    if (model && !wide) fitRows();+    drawOverlay();+    if (performance.now() < animUntil) requestAnimationFrame(tick);+    else rafOn = false;+  }++  function drawOverlay() {+    if (!model) return;+    const { st, pts } = measure();+    const m = model;+    const first = pts.get(m.steps[0].name), fin = pts.get('__finish');+    const spine = $('spine'), fill = $('spine-fill');+    const curName = m.current || (m.steps.every(s => s.status === 'done') ? '__finish' : null);+    const cur = curName ? pts.get(curName) : null;+    // progress reaches the last contiguous done step, or the current step+    let reachName = curName;+    if (!reachName) {+      let k = -1; m.steps.forEach((s, i) => { if (s.status === 'done' && k === i - 1) k = i; });+      reachName = k >= 0 ? m.steps[k].name : m.steps[0].name;+    }+    const reach = pts.get(reachName);++    if (wide) {+      setBox(spine, { left: first.x, top: first.y - 1.5, width: fin.x - first.x, height: 3 });+      setBox(fill, { left: first.x, top: first.y - 1.5, width: Math.max(0, reach.x - first.x), height: 3 });+      fill.style.background = 'linear-gradient(90deg, rgba(0, 184, 177,.85), var(--accent))';+    } else {+      setBox(spine, { left: first.x - 1.5, top: first.y, width: 3, height: fin.y - first.y });+      setBox(fill, { left: first.x - 1.5, top: first.y, width: 3, height: Math.max(0, reach.y - first.y) });+      fill.style.background = '';+    }++    const halo = $('halo');+    if (cur && curName !== '__finish') {+      halo.classList.remove('hide');+      const t = 'translate(' + cur.x.toFixed(1) + 'px,' + cur.y.toFixed(1) + 'px)';+      if (halo._t !== t) { halo.style.transform = t; halo._t = t; }+    } else halo.classList.add('hide');++    drawPhases(pts, st);+    drawArcs(pts);+  }++  function setBox(el, b) {+    for (const k in b) {+      const v = b[k].toFixed(1) + 'px';+      if (el.style[k] !== v) el.style[k] = v;+    }+  }++  function drawPhases(pts, st) {+    groupsOf(model).forEach(g => {+      const el = phaseEls.get(g.key);+      if (!el) return;+      const a = pts.get(model.steps[g.start].name), b = pts.get(model.steps[g.end].name);+      const bar = el.querySelector('.bar'), nm = el.querySelector('.nm'), ct = el.querySelector('.ct');+      if (wide) {+        const half = (a.li.width) / 2;+        const left = a.x - half + 10, right = b.x + half - 10;+        setBox(el, { left, top: 0, width: right - left, height: 30 });+        Object.assign(bar.style, { left: '0px', right: '0px', top: '24px', height: '3px', width: '', bottom: '' });+        Object.assign(nm.style, { left: '2px', top: '0px', right: '', fontSize: '' });+        Object.assign(ct.style, { right: '2px', top: '2px', left: '' });+        ct.style.display = '';+        ct.style.display = (nm.offsetWidth + ct.offsetWidth + 18) < (right - left) ? '' : 'none';+      } else {+        const top = a.li.top - st.top + 5, bottom = b.li.bottom - st.top - 5;+        const left = a.li.right - st.left - parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phase-col')) + 8;+        setBox(el, { left, top, width: 100, height: Math.max(10, bottom - top) });+        Object.assign(bar.style, { left: '0px', top: '0px', bottom: '0px', width: '3px', height: '', right: '' });+        Object.assign(nm.style, { left: '13px', top: '-2px', right: '' });+        Object.assign(ct.style, { left: '13px', top: '17px', right: '' });+        ct.style.display = (bottom - top) > 44 ? '' : 'none';+      }+    });+  }++  function placePill(l, bx, by, isNew) {+    const pill = $('arcpill');+    if (!wide) return;+    if (pill._k !== l.key) {+      pill._k = l.key;+      pill.dataset.tipkey = 'loop:' + l.key;+      pill.querySelector('.num').textContent = l.n;+      pill.querySelector('.tx').innerHTML = '<b>' + esc(titleOf(model, l.from)) + ' sent the AI back to ' + esc(titleOf(model, l.to)) + ':</b> ' + esc(l.why);+      if (isNew && !reduced.matches) flash(pill, 'new', 1700);+    }+    const W = $('stage').clientWidth, w = pill.offsetWidth, h = pill.offsetHeight;+    const x = Math.max(8, Math.min(W - w - 8, bx - w / 2));+    setBox(pill, { left: x, top: by - h / 2 });+  }++  function drawArcs(pts) {+    const svg = $('overlay');+    $('arcpill').classList.toggle('on', wide && model.loops.length > 0);+    const m = model;+    const lanes = lanesFor(m);+    const latest = m.loops[m.loops.length - 1];+    const want = new Set(m.loops.map(l => l.key));+    svg.querySelectorAll('g.arcgrp').forEach(g => { if (!want.has(g.dataset.key)) g.remove(); });+    m.loops.forEach(l => {+      let g = svg.querySelector('g.arcgrp[data-key="' + CSS.escape(l.key) + '"]');+      const isNew = !g && !firstRender && !seenLoops.has(l.key);+      if (!g) {+        g = svgEl('g', { class: 'arcgrp', 'data-key': l.key, 'data-tipkey': 'loop:' + l.key });+        g.appendChild(svgEl('path', { class: 'arc' + (isNew && !reduced.matches ? ' new' : ''), pathLength: '1' }));+        g.appendChild(svgEl('path', { class: 'arrow' + (isNew && !reduced.matches ? ' new' : '') }));+        g.appendChild(svgEl('path', { class: 'arc-hit', 'data-tipkey': 'loop:' + l.key }));+        const b = svgEl('g', { class: 'loopbadge' + (isNew && !reduced.matches ? ' new' : ''), 'data-tipkey': 'loop:' + l.key });+        b.appendChild(svgEl('circle', { r: '10' }));+        const t = svgEl('text', {}); t.textContent = l.n; b.appendChild(t);+        g.appendChild(b);+        svg.appendChild(g);+        if (isNew) setTimeout(() => g.querySelectorAll('.new').forEach(e => e.classList.remove('new')), 1700);+      }+      const isLatest = l === latest;+      g.querySelector('.arc').classList.toggle('latest', isLatest);+      g.querySelector('.arrow').classList.toggle('latest', isLatest);+      g.querySelector('.loopbadge').classList.toggle('latest', isLatest);+      if (isLatest) svg.appendChild(g); // latest on top++      const A = pts.get(l.from), B = pts.get(l.to); // A later step, B earlier step it sent the AI to+      const lane = lanes.map.get(l.key) || 0;+      let d, arrow, bx, by;+      if (wide) {+        const y0 = A.y - A.r - 4, y1 = B.y - B.r - 9;+        const peak = 26 + (lane + 1) * laneH;+        const k = peak / 0.75;+        d = 'M' + A.x + ' ' + y0 + ' C' + A.x + ' ' + (y0 - k) + ' ' + B.x + ' ' + (y1 - k) + ' ' + B.x + ' ' + y1;+        arrow = 'M' + (B.x - 6) + ' ' + (y1 - 8) + ' L' + B.x + ' ' + (y1 + 1) + ' L' + (B.x + 6) + ' ' + (y1 - 8) + 'Z';+        bx = (A.x + B.x) / 2; by = (y0 + y1) / 2 - peak;+      } else {+        const x0 = A.x - A.r - 5, x1 = B.x - B.r - 10;+        const depth = 20 + (lane + 1) * laneW;+        const k = depth / 0.75;+        d = 'M' + x0 + ' ' + A.y + ' C' + (x0 - k) + ' ' + A.y + ' ' + (x1 - k) + ' ' + B.y + ' ' + x1 + ' ' + B.y;+        arrow = 'M' + (x1 - 8) + ' ' + (B.y - 6) + ' L' + (x1 + 1) + ' ' + B.y + ' L' + (x1 - 8) + ' ' + (B.y + 6) + 'Z';+        bx = (x0 + x1) / 2 - depth; by = (A.y + B.y) / 2;+      }+      const arc = g.querySelector('.arc');+      if (arc.getAttribute('d') !== d) { arc.setAttribute('d', d); g.querySelector('.arc-hit').setAttribute('d', d); g.querySelector('.arrow').setAttribute('d', arrow); }+      g.querySelector('.loopbadge').setAttribute('transform', 'translate(' + bx.toFixed(1) + ' ' + by.toFixed(1) + ')');+      if (isLatest) placePill(l, bx, by, isNew);+    });+  }++  function scrollCurrentIntoView(smooth) {+    if (!model || wide) return;+    const name = model.current;+    if (!name || !rows.has(name)) return;+    const r = rows.get(name).li.getBoundingClientRect();+    if (r.top < 0 || r.bottom > innerHeight) {+      window.scrollTo({ top: Math.max(0, scrollY + r.top - innerHeight / 2 + r.height / 2), behavior: smooth && !reduced.matches ? 'smooth' : 'auto' });+    }+  }++  // ---------------- tooltips ----------------+  const tip = $('tip');+  let tipTimer = 0, tipFor = null, cx = 0, cy = 0;+  function tipHtml(key) {+    const m = model;+    if (!m) return null;+    if (key.startsWith('step:')) {+      const s = m.steps[m.idx[key.slice(5)]];+      if (!s) return null;+      const i = m.idx[s.name];+      const sentBack = m.loops.filter(l => l.to === s.name);+      const sentFrom = m.loops.filter(l => l.from === s.name);+      let h = '<div class="tt">' + (i + 1) + '. ' + esc(s.title) + '<span class="s ' + s.status + '">' + STATUS_LABEL[s.status] + '</span></div>';+      if (s.does) h += '<div>' + esc(s.does) + '</div>';+      sentFrom.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Sent the AI back to ' + esc(titleOf(m, l.to)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });+      sentBack.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Reworked after ' + esc(titleOf(m, l.from)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });+      h += '<div class="ft">' + esc(s.phase || 'No phase') + ' · visited ' + s.visits + (s.visits === 1 ? ' time' : ' times') + ' · ' + fmtMin(s.minutes) + ' AI min</div>';+      return { h };+    }+    if (key.startsWith('loop:')) {+      const l = m.loops.find(x => x.key === key.slice(5));+      if (!l) return null;+      return { loopy: true, h: '<div class="tt">Loop back ' + l.n + '<span class="s redo">' + esc(fmtClock(l.at)) + '</span></div>' ++        '<div><b style="color:var(--text)">' + esc(titleOf(m, l.from)) + '</b> found a problem and sent the AI back to <b style="color:var(--text)">' + esc(titleOf(m, l.to)) + '</b>.</div>' ++        '<div style="margin-top:6px">' + esc(l.why) + '</div>' ++        '<div class="ft">Every step from ' + esc(titleOf(m, l.to)) + ' on gets redone.</div>' };+    }+    if (key.startsWith('phase:')) {+      const g = groupsOf(m).find(x => x.key === key.slice(6));+      if (!g) return null;+      const ss = m.steps.slice(g.start, g.end + 1);+      return { h: '<div class="tt">' + esc(g.phase || 'Steps') + ' phase</div><div>' + ss.map(s => esc(s.title)).join(', ') + '</div><div class="ft">' + ss.filter(s => s.status === 'done').length + ' of ' + ss.length + ' done</div>' };+    }+    const fixed = {+      brand: '<div class="tt">AI Flow Timeline</div><div>Live map of the adom-aiflow run: every step, where the AI is now, and every time it looped back to rework.</div><div class="ft">Engine ' + esc(m.engine || 'unknown') + ' \u00b7 reads ' + esc(SRC) + ' every 1 s</div>',+      engine: '<div class="tt">CAD engine</div><div>The design tool the AI is driving for this board.</div>',+      live: '<div class="tt">Live</div><div>This page reads timeline.json every second. The time is the last update the flow wrote.</div>',+      finish: '<div class="tt">Finish line</div><div>The flow is done when this is true: ' + esc(m.finish) + '.</div>',+      stepno: '<div class="tt">Where we are</div><div>The step the AI is working on, counted along the timeline.</div>',+      togo: '<div class="tt">Steps to go</div><div>Steps not done yet, including steps a loop back sent us to redo.</div>',+      minutes: '<div class="tt">AI minutes</div><div>Total time the AI has spent across every step and every visit.</div>',+      loops: '<div class="tt">Loop backs</div><div>Times a later step found a problem and sent the AI back to rework an earlier step. This is normal: it is how the flow converges on a good board.</div>',+      ring: '<div class="tt">Progress</div><div>Share of steps done. The purple part is work a loop back reopened.</div>',+      callout: '<div class="tt">Latest loop back</div><div>The most recent time the AI went back to rework an earlier step, and why.</div>',+    };+    return fixed[key] ? { h: fixed[key], loopy: key === 'callout' || key === 'loops' } : null;+  }+  function placeTip() {+    const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight, M = 8, O = 16;+    const w = tip.offsetWidth, h = tip.offsetHeight;+    let x = cx + O, y = cy + O;+    if (x + w > W - M) x = cx - w - O;   // flip left+    if (y + h > H - M) y = cy - h - O;   // flip above+    x = Math.max(M, Math.min(x, W - w - M));+    y = Math.max(M, Math.min(y, H - h - M));+    tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';+  }+  function showTip(key) {+    const t = tipHtml(key);+    if (!t) return hideTip();+    tip.innerHTML = t.h;+    tip.classList.toggle('loopy', !!t.loopy);+    tip.classList.add('on');+    placeTip();+    requestAnimationFrame(() => tip.classList.add('shown'));+  }+  function hideTip() { clearTimeout(tipTimer); tipFor = null; tip.classList.remove('on', 'shown'); highlightLoop(null); }+  function highlightLoop(k) {+    document.querySelectorAll('g.arcgrp').forEach(g => {+      g.classList.toggle('hl', !!k && g.dataset.key === k);+      g.classList.toggle('dim', !!k && g.dataset.key !== k);+    });+  }+  function keyFrom(target) {+    const el = target && target.closest ? target.closest('[data-tipkey]') : null;+    return el ? el.getAttribute('data-tipkey') : null;+  }+  function loopKeyFor(k) {+    if (!k || !model) return null;+    if (k.startsWith('loop:')) return k.slice(5);+    if (k === 'callout' && model.loops.length) return model.loops[model.loops.length - 1].key;+    return null;+  }+  document.addEventListener('pointermove', e => {+    if (e.pointerType === 'touch') return;+    cx = e.clientX; cy = e.clientY;+    const k = keyFrom(e.target);+    if (k !== tipFor) {+      hideTip();+      tipFor = k;+      if (k) {+        highlightLoop(loopKeyFor(k));+        tipTimer = setTimeout(() => showTip(k), 600);+      }+    } else if (tip.classList.contains('on')) placeTip();+  }, { passive: true });+  document.addEventListener('pointerdown', e => {+    if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;+    cx = e.clientX; cy = e.clientY;+    const k = keyFrom(e.target);+    if (k && k !== tipFor) { hideTip(); tipFor = k; highlightLoop(loopKeyFor(k)); showTip(k); }+    else hideTip();+  });+  document.addEventListener('pointerleave', hideTip);+  addEventListener('scroll', () => { if (tipFor) hideTip(); }, { passive: true });++  // ---------------- polling ----------------+  function setLive(state) {+    const el = $('live');+    el.classList.toggle('stale', state === 'stale');+    el.classList.toggle('idle', state === 'idle');+    el.querySelector('.lbl').textContent = state === 'stale' ? 'Reconnecting' : (state === 'idle' ? 'Waiting' : 'Live');+  }+  async function poll() {+    try {+      const sep = SRC.includes('?') ? '&' : '?';+      const res = await fetch(SRC + sep + '_=' + Date.now(), { cache: 'no-store' });+      if (!res.ok) throw new Error('HTTP ' + res.status);+      const raw = await res.text();+      lastOkAt = Date.now();+      if (raw !== lastRaw) {+        const m = normalize(JSON.parse(raw));+        if (!m) throw new Error('no steps');+        lastRaw = raw;+        model = m;+        everOk = true;+        document.body.classList.remove('waiting-on');+        render(m);+      }+      setLive('live');+    } catch (err) {+      // a half-written file or a missing file: keep the last good picture, never a broken page+      if (!everOk) { document.body.classList.add('waiting-on'); setLive('idle'); }+      else if (Date.now() - lastOkAt > 4000) setLive('stale');+    } finally {+      setTimeout(poll, 1000);+    }+  }++  let rz = 0;+  addEventListener('resize', () => {+    cancelAnimationFrame(rz);+    rz = requestAnimationFrame(() => { layout(); animateOverlay(700); });+  });+  if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { if (model) { layout(); animateOverlay(300); } });++  // test and driving hook: window.flowTimeline.state() returns what is on screen+  window.flowTimeline = {+    state: () => model && ({ current: model.current, wide, steps: model.steps.length, loops: model.loops.length }),+    tipCheck: () => { const r = tip.getBoundingClientRect(); return { on: tip.classList.contains('on'), left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight }; },+    showTip: (key, x, y) => { cx = x; cy = y; tipFor = key; showTip(key); },+  };++  poll();+})();+</script>+</body>+</html>
tools/timeline/sample-timeline.jsonadded+33
@@ -0,0 +1,33 @@+{+  "title": "12 V to 5 V, 1 A buck converter",+  "engine": "Fusion",+  "finish": "Gerbers, BOM and CPL exported",+  "updated": "2026-10-07T14:02:11Z",+  "current": "routing",+  "steps": [+    {"name": "intake", "title": "Intake", "does": "Read the prompt, picked the fab and its design rules", "phase": "Plan", "status": "done", "visits": 1, "minutes": 3.2},+    {"name": "rules", "title": "Design rules", "does": "3rdPartyFab 2-layer rules loaded: 6 mil trace, 6 mil space, 0.3 mm drill", "phase": "Plan", "status": "done", "visits": 1, "minutes": 0.5},+    {"name": "design", "title": "Circuit design", "does": "Picked a 1.4 MHz synchronous buck, sized L1 at 4.7 uH and Cout at 2 x 22 uF", "phase": "Circuit", "status": "done", "visits": 1, "minutes": 5.8},+    {"name": "simulation", "title": "Simulation", "does": "SPICE transient: 5.02 V out, 38 mV ripple, stable into a 1 A load step", "phase": "Circuit", "status": "done", "visits": 1, "minutes": 4.1},+    {"name": "test-plan", "title": "Test plan", "does": "Wrote 7 bench checks: ripple, load step, efficiency, thermal soak", "phase": "Circuit", "status": "done", "visits": 1, "minutes": 1.6},+    {"name": "sourcing", "title": "Sourcing", "does": "All 14 parts in stock at the fab, 3rdPartyFab basic parts where possible", "phase": "Library", "status": "done", "visits": 1, "minutes": 2.9},+    {"name": "library", "title": "Library parts", "does": "Built symbols, footprints and 3D models for U1, L1 and J1", "phase": "Library", "status": "done", "visits": 1, "minutes": 6.4},+    {"name": "schematic", "title": "Schematic", "does": "Captured the schematic in Fusion, ERC clean", "phase": "Board", "status": "done", "visits": 1, "minutes": 3.7},+    {"name": "placement", "title": "Placement", "does": "Moved C3 and C4 tight to VIN, kept the switch node loop small", "phase": "Board", "status": "done", "visits": 2, "minutes": 5.3},+    {"name": "routing", "title": "Routing", "does": "Rerouting the switch node on top, keeping it clear of the input caps", "phase": "Board", "status": "current", "visits": 2, "minutes": 6.0},+    {"name": "thermal-vias", "title": "Thermal vias", "does": "3 x 3 via array under the U1 exposed pad", "phase": "Board", "status": "redo", "visits": 2, "minutes": 1.8},+    {"name": "pours", "title": "Copper pours", "does": "GND pour on both layers, VIN and VOUT islands", "phase": "Board", "status": "redo", "visits": 2, "minutes": 1.4},+    {"name": "outline", "title": "Board outline", "does": "30 x 22 mm outline, 2 M3 mounting holes", "phase": "Board", "status": "redo", "visits": 2, "minutes": 0.6},+    {"name": "current", "title": "Current capacity", "does": "IPC-2152 check on every power trace at 1.5 A", "phase": "Analysis", "status": "redo", "visits": 2, "minutes": 1.2},+    {"name": "thermal", "title": "Thermal", "does": "U1 junction estimate at 1 A, 25 C ambient", "phase": "Analysis", "status": "redo", "visits": 2, "minutes": 2.6},+    {"name": "fields", "title": "Field analysis", "does": "Switch node E-field and loop area review", "phase": "Analysis", "status": "redo", "visits": 1, "minutes": 3.1},+    {"name": "nets", "title": "Net check", "does": "Compare board nets to the schematic, no opens or shorts", "phase": "Analysis", "status": "todo", "visits": 0, "minutes": 0},+    {"name": "silkscreen", "title": "Silkscreen", "does": "Readable refdes, polarity marks, board name and revision", "phase": "Deliver", "status": "todo", "visits": 0, "minutes": 0},+    {"name": "3d", "title": "3D review", "does": "Render the assembled board and check clearances", "phase": "Deliver", "status": "todo", "visits": 0, "minutes": 0},+    {"name": "export", "title": "Export", "does": "Gerbers, BOM and CPL for 3rdPartyFab assembly", "phase": "Deliver", "status": "todo", "visits": 0, "minutes": 0}+  ],+  "loops": [+    {"from": "thermal", "to": "thermal-vias", "why": "U1 junction estimate hit 96 C at 1 A: added a 3 x 3 via array under the exposed pad", "at": "2026-10-07T13:21:40Z"},+    {"from": "fields", "to": "placement", "why": "Switch node trace ran under C3 and C4: moved the input caps", "at": "2026-10-07T13:40:00Z"}+  ]+}
tools/timeline/screenshots/timeline-1600x900.pngadded
⋯ 1 unchanged line ⋯
tools/timeline/screenshots/timeline-800x950.pngadded
⋯ 1 unchanged line ⋯