← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.57"+version = "0.2.58" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.57"+version = "0.2.58" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.57"+version = "0.2.58" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.57"+version = "0.2.58" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+11−11
@@ -36,7 +36,7 @@ A failed engineering gate, an inaccurate heuristic, missing automation, a source  `done` closes a session at any time and notes when the finish line has not been reached yet. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents. -Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change.+Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`; a stop `done` accepts is kept in run.json (`stopEvidence`), so later `finish-guard` calls (a host stop hook passes no path) honour it, re-reading the file, until the next `prompt`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change.  The guard is read-only and suitable for a host's pre-stop integration. It does not install a watcher, inject user messages, or restart a terminated AI turn. Never promise universal nonstop execution: host/runtime interruption, user control, limits and real external dependencies still apply. When a host has no stop integration, following this protocol remains the calling AI's responsibility. @@ -45,17 +45,17 @@ The guard is read-only and suitable for a host's pre-stop integration. It does n The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with `kicad_zoom_to` (KiCad Bridge 1.0.80+; the Zoom to Selected Objects menu key on an older bridge), one net at a time on camera; it says "framed" only for the nets a zoom call framed and names the calls that failed for the others. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.  1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly.-2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). `place pack` lays each `spec.chains` entry (a series chain in electrical order, such as a divider) on one line with the shared-net pads facing and says which chains were honoured; when a wish lists several rotations it picks the one whose pads land nearest their net partners. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's.+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. It packs per side: a bottom part may sit under a top part (a courtyard meets only courtyards on its own side; through-hole parts and drilled pads count on both), and `place check`'s bounding-box advisory is per side too. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--sch design.kicad_sch]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. `--sch` records the schematic the board came from, so `finish` requires a passing `netlist check`. 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--page <owner/slug> [--page-dir <folder>]]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it.-4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. Every open connection is counted, also a net the router never got back to after a rip-up (reason "never routed"), so the summary and the variant ranking see it. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and so is a via within the spec's hole-to-hole minimum (`holeToHole`, default 0.2 mm) of any via or hole, or within `clearance` of another via's copper, whatever its net (the ERROR names the clash), and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.-4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. Every open connection is counted, also a net the router never got back to after a rip-up (reason "never routed"), so the summary and the variant ranking see it.    **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged.-5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes.+5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Pours (and their vias) that `pour` planned and that are not landed yet go into the gate copy, so a net only the planned pours join is not read as unconnected (`plannedPours` in gate.json). Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` reports whether the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.@@ -90,7 +90,7 @@ visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` fo moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record` (Fusion: `fusion silkscreen`), `export` (Fusion: `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` lists what is still open in the chain as a report card, with suggestions; it never refuses.+`finish` lists what is still open in the chain as a report card, with suggestions; it never refuses. When open items belong to a step the run is past (a switch-node finding to placement, DRC to routing), it also prints `Suggested: earliest step these open items go back to: <step>` with the `step <step> --back --why` command, and `finish-guard` repeats it until that step is visited again. Layout work within the spec is yours to do without asking; ask the human only to change a frozen budget or requirement.  **When a check is wrong for this board, deviate on purpose instead of stopping.** AI Flow has not seen every kind of board. When a step does not apply, or `finish` lists something as open that is right for this@@ -312,7 +312,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate. - **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad (except net ties and footprints marked `allow_missing_courtyard`, KiCad's own exceptions) and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.-- **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule.+- **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` exports from the source project's folder (so project-library and PCM 3D models resolve) and keeps the native STEP export's warnings apart from its exit status; geometry warnings and missing 3D models are open issues that `board-glb copper` and `deliver` report until fixed or reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`. - **Clips.** Pre-board steps film Hydrogen's dashboard, the AI's window or the tool's own window, or record a skip with the reason; a take that died is restarted on the step's current window with the gap recorded; a truncated MP4 (no moov atom) is flagged when it stops. Skill aiflow-live-clips.@@ -359,7 +359,7 @@ adom-aiflow is the token cache of every Adom user's flow. When the binary was wr  ## The code checks what you filmed -Every clip stop runs a blank check (nine sampled frames; a clip whose frames are one flat colour filmed an unpainted or covered window) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` refuses while one is unresolved (`--accept-suspect` overrides, and the message must say why). Every stop checks that the clip reached the run (a non-empty local file) and pulls up to three times; a clip that still is not there is flagged `pullFailed` with its desktop path and a WARNING, and `recut`, `finish` and `deliver` pull it again first. Run `recut` promptly: the desktop cleans its recordings folder. `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.+Every clip stop runs a blank check (eight frames sampled in one decode pass; a clip whose frames are one flat colour filmed an unpainted or covered window; a flagged clip gets no cuts, and a clip over 1 GB is checked and cut later by `recut` or `compose`, never inside the step) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` names every unresolved one in a note and delivers anyway (`--accept-suspect` says you looked). Every stop checks that the clip reached the run (a non-empty local file) and pulls up to three times; a clip that still is not there is flagged `pullFailed` with its desktop path and a WARNING, and `recut`, `finish` and `deliver` pull it again first. Run `recut` promptly: the desktop cleans its recordings folder. `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.  ## Honesty rules @@ -531,7 +531,7 @@ Do not present a saved capture as live state or a workflow completion as hardwar  ## Explicit branch currents -When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification. Since 0.2.23 (issue #107) the branch model runs on multilayer boards (outer-layer nets as on two layers; inner-layer tracks need the board's saved physical stackup). A pour passes the whole-net screen only when it covers the narrow tracks on their layer or one pour reaches every terminal of the net (declare zero-current pads in `loads.<net>.terminals`). With a same-input Fields solve that passes the field screen, a net that failed only the width approximation is judged by the solved rise at its narrowest neck (`review.checks.ampacity`), recorded as `judgedBy: fields-solve`.+When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification. Since 0.2.23 (issue #107) the branch model runs on multilayer boards (outer-layer nets as on two layers; inner-layer tracks need the board's saved physical stackup). A pour passes the whole-net screen only when it covers the narrow tracks on their layer or one pour reaches every terminal of the net (declare zero-current pads in `loads.<net>.terminals`). With a same-input Fields solve that passes the field screen, a net that failed only the width approximation is judged by the solved rise at its narrowest neck (`review.checks.ampacity`), recorded as `judgedBy: fields-solve`. A load that declares terminals with non-zero amps names its terminals, a Kelvin sense pad (`spec.kelvin`) is never one, and a narrow dead-end stub to pads that draw nothing (a Kelvin tap, an LDO input declared at 0 A) is not judged at the net's load.  ### Unclipped Fields current maxima @@ -570,7 +570,7 @@ The plan needs provenance: the plan `pour` wrote (its hash is recorded in the ru A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a USB-C shell peg) is read as a board hole, not a pad. The router, the stitching and heatsink via planners and the pour masks keep clearance from it; pad, net, BOM and netlist consumers never see it.  **Vias, edge connectors and repeated pad numbers (0.2.19).**-- `thermal-vias land --ack U3 --why "..."` leaves U3's planned vias out of the landing (the plan entries carry `ref`; the outcome records `leftOutAcked`). Acknowledged parts are never landed, whenever the ack was given.+- `thermal-vias land --ack U3 --why "..."` (or on `plan`; `--ack Q1,Q2` or `--ack Q1 --ack Q2`; acks are saved at once and kept across calls, and finish honours them) leaves U3's planned vias out of the landing (the plan entries carry `ref`; the outcome records `leftOutAcked`). Acknowledged parts are never landed, whenever the ack was given. - The heatsink via planner (`thermal-vias plan`) and the small-pin ring of `thermalVias` keep every via clear of visible silkscreen on both sides: board and footprint text (the printed text's extent) and silk graphics, by `silkClearance` (default 0.1 mm). A part short of its target says how many spots the silk took; move the label to free them. - A `stitchVias` entry takes `size` and `drill` in mm. Without them it gets the spec's `viaSignal` (and `drillSignal`), else the board's most common via, else 0.6 / 0.3 mm (it was 0.8 / 0.4). - Via-to-via copper clearance and the hole-to-hole minimum come from the spec (`viaClearance`, else `clearance`; `holeToHole`); when the spec names neither, from the project's `.kicad_dru` beside the board (its unconditioned or via-only clearance and hole_to_hole rules, the last one winning as in KiCad); else 0.2 mm. This applies to `land vias`, `pour` and `thermal-vias plan`; zones keep `clearance`.@@ -653,7 +653,7 @@ Every LED on the board is described in `led-states.json` beside the board, and l   "evidence": "<datasheet page / calc>"}]} ``` -`on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` refuses the board while any LED has no complete entry.+`on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` lists every LED with no complete entry as an open item. An LED is a reference `LED*`, a `D*`/`CR*` whose footprint name (not its library nickname) or value has a word starting with LED, or any reference `led-states.json` declares; a BAT54, TVS or clamp diode needs no entry.  `silkscreen-audit --record` and `finish` print one line per label check on every run, a pass included (`power inputs: 0 found`, `power inputs: 1 (J1 VIN), all labelled with a voltage`, `test points: ...`), and record `powerInputs` and `testPoints` (`found`, `labelled`, `unlabelled`, `mismatched`, `items`) in the audit's result JSON, the `silkscreen` outcome and the `finish-checks` outcome. `finish` also prints `LED states: no LEDs on the board: led-states.json not required` (or what it found) and `programming: no programmable part: programming not required` (or the parts and interface). 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/export.rs+69−7
@@ -133,7 +133,29 @@ pub fn read_csv(text: &str) -> Vec<Vec<String>> {  /// The BOM from the board's own footprints: one line per value, footprint, MPN and DNP state; /// `exclude_from_bom` footprints are left out, DNP parts stay as DNP lines.-pub fn write_bom(inv: &Inventory) -> String {+pub fn write_bom(inv: &Inventory) -> String { write_bom_with(inv, &[]) }++/// A part that ships with the board but is not on it (a battery on a cable, a case part): spec+/// `offBoard`, `[{"ref": "BT1", "value": "...", "mpn": "...", "manufacturer": "...", "connectsTo": "J2"}]`.+/// It goes in the fab BOM as a do-not-place line, so the order lists it and the fab never fits it.+#[derive(Debug, Clone, Default, PartialEq)]+pub struct OffBoard { pub reference: String, pub qty: usize, pub value: String, pub mpn: String, pub manufacturer: String, pub connects_to: String }++/// The spec's `offBoard` list (missing or malformed entries are left out; a reference is required).+pub fn off_board(spec: &Value) -> Vec<OffBoard> {+    spec["offBoard"].as_array().into_iter().flatten().filter_map(|x| {+        let reference = x["ref"].as_str().or(x["reference"].as_str())?.trim().to_string();+        if reference.is_empty() { return None; }+        let s = |k: &str| x[k].as_str().unwrap_or("").trim().to_string();+        Some(OffBoard { reference, qty: x["qty"].as_u64().unwrap_or(1).max(1) as usize, value: s("value"), mpn: s("mpn"), manufacturer: s("manufacturer"), connects_to: s("connectsTo") })+    }).collect()+}++/// The DNP column of an off-board line: never empty, so a fab reads it as do not place.+pub const OFF_BOARD_DNP: &str = "off-board (not assembled)";++/// write_bom, plus one do-not-place line per off-board part after the board's own lines.+pub fn write_bom_with(inv: &Inventory, off: &[OffBoard]) -> String {     let mut groups: BTreeMap<(String, String, String, String, bool), Vec<String>> = BTreeMap::new();     for (r, p) in inv.parts.iter().filter(|(_, p)| p.in_bom) {         groups.entry((p.value.clone(), p.footprint.clone(), p.manufacturer.clone(), p.mpn.clone(), p.dnp)).or_default().push(r.clone());@@ -146,6 +168,11 @@ pub fn write_bom(inv: &Inventory) -> String {     lines.sort_by_key(|(refs, _)| natural(&refs[0]));     let mut out = String::from("Reference,Qty,Value,Footprint,Manufacturer,MPN,DNP\n");     for (_, l) in lines { out.push_str(&l); out.push('\n'); }+    for o in off.iter().filter(|o| !inv.parts.contains_key(&o.reference)) {+        let fp = if o.connects_to.is_empty() { "off-board".to_string() } else { format!("off-board, plugs into {}", o.connects_to) };+        let line = [o.reference.clone(), o.qty.to_string(), o.value.clone(), fp, o.manufacturer.clone(), o.mpn.clone(), OFF_BOARD_DNP.to_string()].iter().map(|s| csv_field(s)).collect::<Vec<_>>().join(",");+        out.push_str(&line); out.push('\n');+    }     out } @@ -165,7 +192,12 @@ fn list(v: &[String]) -> String { }  /// Re-read the BOM against the board: every BOM footprint exactly once, its value and DNP state as placed.-pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) {+pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) { check_bom_with(text, inv, &[]) }++/// check_bom, where the spec's off-board parts may appear as do-not-place lines (and must, once each).+pub fn check_bom_with(text: &str, inv: &Inventory, off: &[OffBoard]) -> (Value, Vec<String>) {+    let off_refs: BTreeSet<String> = off.iter().filter(|o| !inv.parts.contains_key(&o.reference)).map(|o| o.reference.clone()).collect();+    let mut off_seen = BTreeSet::new();     let rows = read_csv(text);     let mut problems = Vec::new();     let Some(h) = rows.first() else { return (json!({"lines": 0}), vec!["BOM is empty".into()]) };@@ -182,6 +214,11 @@ pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) {         }         let is_dnp = dc.and_then(|i| row.get(i)).map(|s| !s.trim().is_empty()).unwrap_or(false);         for r in refs {+            if off_refs.contains(&r) {+                if !is_dnp { problems.push(format!("BOM fits {r}, which the spec lists as off-board: mark it do not place ({OFF_BOARD_DNP})")); }+                if !off_seen.insert(r.clone()) { dup.push(r); }+                continue;+            }             if let Some(p) = inv.parts.get(&r) {                 if let Some(v) = vc.and_then(|i| row.get(i)) { if *v != p.value { problems.push(format!("BOM gives {r} the value {v}, the board has {}", p.value)); } }                 if is_dnp != p.dnp { problems.push(format!("BOM marks {r} {}, the board {}", if is_dnp { "DNP" } else { "fitted" }, if p.dnp { "does not fit it (DNP)" } else { "fits it" })); }@@ -195,7 +232,9 @@ pub fn check_bom(text: &str, inv: &Inventory) -> (Value, Vec<String>) {     if !missing.is_empty() { problems.push(format!("BOM is missing {}", list(&missing))); }     if !extra.is_empty() { problems.push(format!("BOM lists parts the board does not put in a BOM: {}", list(&extra))); }     if !dup.is_empty() { problems.push(format!("BOM lists more than once: {}", list(&dup))); }-    (json!({"lines": rows.len() - 1, "refs": seen.len(), "fitted": placed, "dnp": dnp}), problems)+    let off_missing: Vec<String> = off_refs.difference(&off_seen).cloned().collect();+    if !off_missing.is_empty() { problems.push(format!("BOM is missing the off-board part(s) the spec lists: {}", list(&off_missing))); }+    (json!({"lines": rows.len() - 1, "refs": seen.len(), "fitted": placed, "dnp": dnp, "offBoard": off_seen.iter().collect::<Vec<_>>()}), problems) }  /// Re-read the pick and place: exactly the fitted, placeable parts, each on its own side, the top-side@@ -369,7 +408,10 @@ fn saved_drc(cli: &str, board_path: &str, out: &Path) -> Result<Value, String> { }  /// Write the set into `out` and check it. Ok: the proof (what was checked); Err: the report and the problems.-pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value, Vec<String>)> {+pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value, Vec<String>)> { export_with(board_path, out, cli, &[]) }++/// export, with the spec's off-board parts in the BOM as do-not-place lines.+pub fn export_with(board_path: &str, out: &Path, cli: &str, off: &[OffBoard]) -> Result<Value, (Value, Vec<String>)> {     let fail = |m: String| (json!({"board": board_path}), vec![m]);     // kicad-cli gets absolute outputs (#72): a relative run folder means nothing on a desktop's KiCad     let out_abs = std::path::absolute(out).unwrap_or_else(|_| out.to_path_buf());@@ -393,7 +435,7 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     let _ = std::fs::remove_file(&cpl);     kicad(cli, &[s(&["pcb", "export", "pos", "--output", &cpl.display().to_string(), "--format", "csv", "--units", "mm", "--side", "both", "--exclude-dnp"]), vec![board_path.to_string()]].concat()).map_err(fail)?;     let bom = out.join(format!("{stem}-bom.csv"));-    std::fs::write(&bom, write_bom(&inv)).map_err(|e| fail(format!("{}: {e}", bom.display())))?;+    std::fs::write(&bom, write_bom_with(&inv, off)).map_err(|e| fail(format!("{}: {e}", bom.display())))?;      // read every file back, as the fab will     let gerbers = read_dir(&gdir, &["gbr"]);@@ -416,7 +458,7 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     problems.extend(p2); notes.extend(n2);     let (c, p3) = match std::fs::read_to_string(&cpl) { Ok(t) => check_cpl(&t, &inv), Err(e) => (Value::Null, vec![format!("pick and place not written ({}: {e})", cpl.display())]) };     problems.extend(p3);-    let (b, p4) = check_bom(&std::fs::read_to_string(&bom).unwrap_or_default(), &inv);+    let (b, p4) = check_bom_with(&std::fs::read_to_string(&bom).unwrap_or_default(), &inv, off);     problems.extend(p4);     notes.extend(fab_role_notes(&inv));     let no_mpn: Vec<String> = inv.parts.iter().filter(|(_, p)| p.in_bom && !p.dnp && p.mpn.is_empty()).map(|(r, _)| r.clone()).collect();@@ -426,7 +468,7 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     let report = json!({         "pass": problems.is_empty(), "dir": out.display().to_string(), "board": board_path,         "boardSha256": crate::sha256_file(board_path), "kicadCli": cli,-        "gerbers": g, "drill": d, "cpl": c, "bom": b, "fillsSaved": saved_fill, "fillsGerbers": gerber_fill, "fillsCurrent": fills_current, "bomSource": "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)",+        "gerbers": g, "drill": d, "cpl": c, "bom": b, "fillsSaved": saved_fill, "fillsGerbers": gerber_fill, "fillsCurrent": fills_current, "bomSource": if off.is_empty() { "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)".to_string() } else { format!("the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes), plus {} off-board part(s) from the spec as do-not-place lines", off.len()) },         "files": files, "notes": notes, "problems": problems,     });     if problems.is_empty() { Ok(report) } else { Err((report, problems)) }@@ -535,6 +577,26 @@ mod tests {         assert!(p.contains("marks MP1 fitted"), "{p}");     } +    #[test]+    fn off_board_parts_go_in_the_bom_as_do_not_place_lines() {+        let i = inv();+        let spec = json!({"offBoard": [{"ref": "BT1", "value": "500mAh 1S LiPo", "mpn": "1578", "manufacturer": "Adafruit", "connectsTo": "J2"}, {"value": "no reference"}]});+        let off = off_board(&spec);+        assert_eq!(off.len(), 1);+        let text = write_bom_with(&i, &off);+        assert!(text.contains("BT1,1,500mAh 1S LiPo,\"off-board, plugs into J2\",Adafruit,1578,off-board (not assembled)"), "{text}");+        let (b, p) = check_bom_with(&text, &i, &off);+        assert!(p.is_empty(), "{p:?}\n{text}");+        assert_eq!(b["offBoard"][0], "BT1");+        // fitted, missing, or not in the spec+        let p = check_bom_with(&text.replace(",off-board (not assembled)", ","), &i, &off).1.join("; ");+        assert!(p.contains("BOM fits BT1, which the spec lists as off-board"), "{p}");+        let p = check_bom_with(&write_bom(&i), &i, &off).1.join("; ");+        assert!(p.contains("missing the off-board part(s) the spec lists: BT1"), "{p}");+        let p = check_bom(&text, &i).1.join("; ");+        assert!(p.contains("does not put in a BOM: BT1"), "{p}");+    }+     #[test]     fn stale_saved_fills_are_found_in_a_drc_without_refill() {         // issue #160: the saved GND fill overlaps a track added after it; DRC with refill was clean
crates/adom-aiflow/src/gate_eval.rs+88−1
@@ -22,6 +22,38 @@ pub struct GateEval {     pub quality: (Value, Vec<String>), } +/// Issue #168: the run's planned pours (plan-pours.json) and their vias (plan-vias.json) that the board+/// does not carry yet, added to a copy of the board and the plan. A planned zone is skipped when the+/// board already has a zone of its net on one of its layers (landed); a planned via when the board+/// already has a via of its net there. Returns (board with the zones, plan with the vias, a summary+/// for gate.json, null when nothing was added).+pub fn with_planned_pours(b: &Board, plan_v: &Value, pours: Option<&Value>, vias: Option<&Value>) -> Result<(Option<Board>, Option<Value>, Value), String> {+    let have = b.zones();+    let landed = |z: &Value| {+        let net = z["net"].as_str().unwrap_or("");+        let layers: Vec<&str> = z["layers"].as_array().map(|a| a.iter().filter_map(|l| l.as_str()).collect()).unwrap_or_default();+        have.iter().any(|h| h.net == net && h.layers.iter().any(|&l| layers.contains(&aiflow_board::LAYER_NAMES[l])))+    };+    let zones: Vec<Value> = pours.and_then(|p| p["zones"].as_array()).map(|a| aiflow_pours::resolve_outline(b, a)).unwrap_or_default()+        .into_iter().filter(|z| z.get("keepout").is_none() && z["type"].as_str() != Some("keepout") && !landed(z)).collect();+    let board_vias = b.vias();+    let new_vias: Vec<Value> = vias.and_then(|v| v["nets"].as_array()).cloned().unwrap_or_default().into_iter().filter(|e| {+        let net = e["net"].as_str().unwrap_or("");+        let at = e["paths"][0][0].as_array().and_then(|p| Some((p.first()?.as_f64()?, p.get(1)?.as_f64()?)));+        at.is_some_and(|(x, y)| !board_vias.iter().any(|v| v.net == net && (v.x - x).abs() < 1e-3 && (v.y - y).abs() < 1e-3)) && b.nets.contains_key(net)+    }).collect();+    if zones.is_empty() && new_vias.is_empty() { return Ok((None, None, Value::Null)); }+    let blocks: Vec<String> = zones.iter().map(|z| aiflow_pours::zone_sexpr(b, z)).collect::<Result<_, _>>().map_err(|e| format!("planned pour: {e}"))?;+    let board = if blocks.is_empty() { None } else { Some(Board::from_text(&b.path, aiflow_copper::splice(&b.text, &blocks))?) };+    let plan = if new_vias.is_empty() { None } else {+        let mut p = plan_v.clone();+        if let Some(a) = p["nets"].as_array_mut() { a.extend(new_vias.iter().cloned()); }+        Some(p)+    };+    let nets: std::collections::BTreeSet<&str> = zones.iter().filter_map(|z| z["net"].as_str()).collect();+    Ok((board, plan, json!({"zones": zones.len(), "vias": new_vias.len(), "nets": nets, "note": "the pours and vias planned by `pour` and not yet landed are in the gate copy (filled by the DRC), so the pads they join count as connected"})))+}+ /// Apply `plan_v` (already normalised) to the run's board `b`, write the routed board to `routed` /// with the project rules, run KiCad DRC on it against `base` (the DRC of the run's current board) /// and the layer, switch-node, decoupling, courtyard and staircase checks. `notes` prints the@@ -29,7 +61,13 @@ pub struct GateEval { pub fn evaluate(r: &Run, b: &Board, plan_v: &Value, routed: &Path, base: &Value, notes: bool) -> Result<GateEval, String> {     let spec_g = crate::spec_of(r);     // an empty plan adds no copper: the board's own zone fills stand, a raster refill would only invent errors-    let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true);+    // issue #168: the pours (and their vias) planned by `pour` but not yet landed go into the gate copy,+    // so a net that only the planned pours join is not read as hundreds of unconnected pads+    let pour_plan = r.outcomes().get("pour").and_then(|o| o["pours"].as_str()).filter(|p| Path::new(p).is_file()).and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());+    let via_plan = r.outcomes().get("pour").and_then(|o| o["vias"].as_str()).filter(|p| Path::new(p).is_file()).and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());+    let (planned_b, planned_plan, planned) = with_planned_pours(b, plan_v, pour_plan.as_ref(), via_plan.as_ref())?;+    let (b, plan_v) = (planned_b.as_ref().unwrap_or(b), planned_plan.as_ref().unwrap_or(plan_v));+    let adds_copper = plan_v["nets"].as_array().map(|a| !a.is_empty()).unwrap_or(true) || planned["zones"].as_u64().unwrap_or(0) > 0;     let (text, stats) = aiflow_copper::apply(b, plan_v, adds_copper && !aiflow_copper::native_drc_available()).map_err(|e| format!("plan does not apply to the board: {e}"))?;     std::fs::write(routed, &text).map_err(|e| e.to_string())?;     let routed_s = routed.display().to_string();@@ -37,6 +75,7 @@ pub fn evaluate(r: &Run, b: &Board, plan_v: &Value, routed: &Path, base: &Value,     let data = crate::run_drc_for(r, &routed_s)?;     let mut s = aiflow_copper::summarize_with(&data, Some(base), silk_policy::blocking(&spec_g, &r.current_board()));     s["copper"] = stats;+    if !planned.is_null() { s["plannedPours"] = planned; }     s["file"] = json!(routed_s);     let rb = || Board::from_text(&routed_s, text.clone());     // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error@@ -60,3 +99,51 @@ pub fn evaluate(r: &Run, b: &Board, plan_v: &Value, routed: &Path, base: &Value,     s["routeQuality"] = crate::route_quality::outcome(&quality.0, "route-quality.json");     Ok(GateEval { s, pu, pu_issues, sw_issues, dec, mech, stairs, quality }) }++#[cfg(test)]+mod tests {+    use super::*;++    /// One copper layer, two GND pads and two +5V pads joined by nothing but the planned pours.+    fn board() -> Board {+        Board::from_text("one.kicad_pcb", r#"(kicad_pcb (version 20240108) (generator "pcbnew")+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (net 0 "") (net 1 "GND") (net 2 "+5V")+  (gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))+  (footprint "R" (layer "F.Cu") (at 5 5) (property "Reference" "R1")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))+    (pad "2" smd rect (at 0 10) (size 1 1) (layers "F.Cu") (net 2 "+5V")))+  (footprint "R" (layer "F.Cu") (at 35 5) (property "Reference" "R2")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))+    (pad "2" smd rect (at 0 10) (size 1 1) (layers "F.Cu") (net 2 "+5V"))))"#.into()).unwrap()+    }++    /// Issue #168: the gate copy carries the planned pours that the board does not, and not twice.+    #[test]+    fn planned_pours_go_into_the_gate_copy_once() {+        let b = board();+        let pours = json!({"zones": [+            {"net": "GND", "layers": ["F.Cu"], "polygon": [[1, 1], [39, 1], [39, 9], [1, 9]], "name": "GND top"},+            {"net": "+5V", "layers": ["F.Cu"], "outline": true, "inset": 0.5, "priority": 0, "name": "+5V top"},+            {"type": "keepout", "layers": ["F.Cu"], "polygon": [[2, 2], [3, 2], [3, 3]], "keepout": {"copperPour": true}}]});+        let plan = json!({"nets": []});+        let (nb, np, sum) = with_planned_pours(&b, &plan, Some(&pours), None).unwrap();+        let nb = nb.expect("a board with the planned zones");+        assert!(np.is_none());+        assert_eq!(sum["zones"], 2, "{sum}");+        let zones = nb.zones();+        assert_eq!(zones.len(), 2);+        assert!(zones.iter().any(|z| z.net == "GND") && zones.iter().any(|z| z.net == "+5V" && z.outline.len() >= 4));+        // the board already carries them (landed): nothing is added again+        let (again, _, sum2) = with_planned_pours(&nb, &plan, Some(&pours), None).unwrap();+        assert!(again.is_none() && sum2.is_null(), "{sum2}");+        // the planned vias are added to the plan unless the board has them+        let vias = json!({"nets": [{"net": "GND", "width": 0.5, "viaSize": 0.6, "viaDrill": 0.3, "paths": [[[20.0, 5.0], {"x": 20.0, "y": 5.0, "layer": "B.Cu"}]]}]});+        let (_, np, sum3) = with_planned_pours(&nb, &plan, None, Some(&vias)).unwrap();+        assert_eq!(np.unwrap()["nets"].as_array().unwrap().len(), 1);+        assert_eq!(sum3["vias"], 1);+        // no pour plan: the gate is unchanged+        let (n0, p0, s0) = with_planned_pours(&b, &plan, None, None).unwrap();+        assert!(n0.is_none() && p0.is_none() && s0.is_null());+    }+}
crates/adom-aiflow/src/led_states.rs+34−6
@@ -1,8 +1,8 @@ //! LED on/off states (0.2.18): every LED on the board gets an entry in `led-states.json` next to the //! board (schema `adom/led-states@1`), saying what colour it is, which nets it sits on, the current it //! draws and when it is lit. The probing stage reads it (through board-release.json `ledStates`) to-//! show each LED turning on when the probed state would light it. `finish` refuses a board whose LEDs-//! are not all described.+//! show each LED turning on when the probed state would light it. `finish` lists an LED with no+//! complete entry as an open item. //! //! ```json //! {"$schema": "adom/led-states@1", "leds": [{"ref": "D1", "mpn": "LTST-C191KGKT",@@ -24,12 +24,27 @@ pub const FILE: &str = "led-states.json"; fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() } fn bare(net: &str) -> String { net.trim().trim_start_matches('/').to_string() } -/// The LEDs on a board: references LED*, or D*/CR* whose footprint library names an LED.+/// Whether a name says LED: a word of it starts with "led" (`LED_0603`, `LED-RGB`, `LEDs`), so the+/// "led" inside `soldered` or `sealed` does not count.+fn says_led(s: &str) -> bool {+    s.split(|c: char| !c.is_ascii_alphanumeric()).any(|w| w.len() >= 3 && w[..3].eq_ignore_ascii_case("led"))+}++/// The footprint's Value property (empty when it has none).+fn value_of(b: &Board, fp: &aiflow_board::Footprint) -> String {+    let block = b.text.get(fp.start..fp.end).unwrap_or("");+    let Some(i) = block.find("(property \"Value\" \"") else { return String::new() };+    let rest = &block[i + 19..];+    rest[..rest.find('"').unwrap_or(0)].to_string()+}++/// The LEDs on a board: references LED*, or D*/CR* whose footprint name (not its library nickname:+/// issue #172, a project library called `led-driver` made every diode an LED) or value says LED. pub fn leds(b: &Board) -> Vec<String> {     let mut v: Vec<String> = b.footprints.iter().filter(|(r, fp)| {         let p = prefix(r);-        let lib = fp.lib.to_ascii_lowercase();-        !r.starts_with('#') && (p == "LED" || (matches!(p.as_str(), "D" | "CR") && lib.contains("led")))+        let name = fp.lib.rsplit(':').next().unwrap_or(&fp.lib);+        !r.starts_with('#') && (p == "LED" || (matches!(p.as_str(), "D" | "CR") && (says_led(name) || says_led(&value_of(b, fp)))))     }).map(|(r, _)| r.clone()).collect();     v.sort();     v@@ -145,7 +160,7 @@ pub fn hint(board_path: &str, b: &Board) -> Option<String> {     let want = leds_on(board_path, b);     if want.is_empty() { return None; }     let state = match check(board_path, b) { Ok(_) => "complete".to_string(), Err(p) => format!("open: {}", p.join("; ")) };-    Some(format!("LEDs on this board: {}. Describe each one's on/off state in {} beside the board ({state}): {{\"$schema\":\"{SCHEMA}\",\"leds\":[{{\"ref\",\"mpn\",\"colour\":{{\"name\",\"dominant_nm\",\"hex\"}},\"anode_net\",\"cathode_net\",\"series\":[\"R1\"],\"current_mA\" (from the rail, Vf and the series resistor),\"on_when\":{{\"kind\":\"rail\",\"net\",\"min_v\"}} | {{\"kind\":\"gpio\",\"mcu\",\"pin\",\"port\",\"active\":\"high|low\"}} | {{\"kind\":\"signal\",\"net\",\"active\"}},\"evidence\"}}]}} and list it in board-release.json under ledStates. `finish` refuses the board until every LED has an entry. For each LED part, add led.json ({{\"$schema\":\"{PART_SCHEMA}\",\"colour\":{{name,dominant_nm,hex}},\"vf_V\":{{typ,max,at_mA}},\"if_max_mA\",\"viewing_deg\",\"glb_lens_material\"}}) to its wiki component page when you may edit that page; otherwise open an issue on the page carrying that record. Never edit someone else's page.", want.join(", "), FILE))+    Some(format!("LEDs on this board: {}. Describe each one's on/off state in {} beside the board ({state}): {{\"$schema\":\"{SCHEMA}\",\"leds\":[{{\"ref\",\"mpn\",\"colour\":{{\"name\",\"dominant_nm\",\"hex\"}},\"anode_net\",\"cathode_net\",\"series\":[\"R1\"],\"current_mA\" (from the rail, Vf and the series resistor),\"on_when\":{{\"kind\":\"rail\",\"net\",\"min_v\"}} | {{\"kind\":\"gpio\",\"mcu\",\"pin\",\"port\",\"active\":\"high|low\"}} | {{\"kind\":\"signal\",\"net\",\"active\"}},\"evidence\"}}]}} and list it in board-release.json under ledStates. `finish` lists every LED without an entry as an open item (a diode that is not an LED needs none). For each LED part, add led.json ({{\"$schema\":\"{PART_SCHEMA}\",\"colour\":{{name,dominant_nm,hex}},\"vf_V\":{{typ,max,at_mA}},\"if_max_mA\",\"viewing_deg\",\"glb_lens_material\"}}) to its wiki component page when you may edit that page; otherwise open an issue on the page carrying that record. Never edit someone else's page.", want.join(", "), FILE)) }  /// After a passing finish: name the LED states in board-release.json beside the board, when one exists.@@ -208,6 +223,19 @@ mod tests {         std::fs::remove_dir_all(&dir).ok();     } +    #[test]+    fn only_leds_need_an_entry_not_every_diode() {+        // issue #172: a project library nicknamed after the board ("led-driver") made BAT54, TVS and clamp diodes LEDs+        let fp = |lib: &str, r: &str, v: &str| format!("  (footprint \"{lib}\" (layer \"F.Cu\") (at 10 10)\n    (property \"Reference\" \"{r}\" (at 0 -2) (layer \"F.SilkS\")) (property \"Value\" \"{v}\" (at 0 2) (layer \"F.Fab\"))\n    (pad \"1\" smd rect (at 0 0) (size 0.8 0.8) (layers \"F.Cu\") (net 0 \"\")))\n");+        let text = format!("(kicad_pcb (version 20241229)\n  (net 0 \"\")\n  (gr_rect (start 0 0) (end 30 20) (layer \"Edge.Cuts\") (width 0.1))\n{}{}{}{}{}{}{})",+            fp("led-driver:SOT-323_SC-70", "D1", "BAT54CW"), fp("led-driver:smbj28a", "D2", "SMBJ28A"), fp("led-driver:IN-S63BTG", "D3", "IN-S63BTG"),+            fp("led-driver:D_SOD-323F", "D7", "PMEG6010CEJ"), fp("LED_SMD:LED_0603_1608Metric", "D20", "green"), fp("Diode_SMD:D_SOD-123", "D21", "LED_Red"),+            fp("Project:SOLDERED_JUMPER", "D22", "SEALED"));+        let b = Board::from_text("/nonexistent/b.kicad_pcb", text).unwrap();+        // the LED footprint and the LED value count; the nickname, "soldered" and "sealed" do not+        assert_eq!(leds(&b), vec!["D20", "D21"]);+    }+     #[test]     fn a_declared_rgb_part_is_checked_die_by_die() {         // issue #122: D1 on a project footprint named by its MPN, common anode, three cathodes
crates/adom-aiflow/src/main.rs+342−55
@@ -143,8 +143,8 @@ enum Cmd {     #[arg(long)] bom: Option<String> },     /// `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 --layers 2`: put the shipped JLCPCB 2-layer .kicad_dru beside the board (the only shipped pack; other layer counts come from `rules use`); `rules check`: does the board's project carry the rules profile spec.fab.rules names, else run.json fabRules ("fab" = the project's own .kicad_dru, "jlcpcb" = the JLCPCB pack for the board's copper layer count, "jlcpcb-6L" = that count exactly).     /// `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 },+    /// `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` (on plan or land; a comma list or repeated, and kept across calls) accepts a part short of its target. finish lists a hot part below target as an open item.+    ThermalVias { #[arg(value_parser = ["plan", "land"])] action: String, #[arg(long, value_delimiter = ',', help = "a hot part to accept below its via target: Q1,Q2 or repeat --ack; needs --why; kept for later plan, land and finish")] ack: Vec<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).@@ -197,7 +197,7 @@ enum Cmd {     BindRemoteBoard { #[arg(long)] remote_board: String },     /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.     AdoptBoard { #[arg(long)] board: String, #[arg(long)] evidence: String },-    /// `parts [--bom <csv>] [--show] [--dwell 4]`: the from-scratch board's parts on the wiki. Every BOM row with an MPN (default: the BOM the sourcing check passed) is matched to its Adom wiki component page; --show walks a Hydrogen tab through each page so the viewer sees the parts being chosen. A part with no page fails: make it (wiki-component skill) and run again, or give it from the board's own project library (--project-lib).+    /// `parts [--bom <csv>] [--show] [--dwell 4]`: the from-scratch board's parts on the wiki. Every BOM row with an MPN (default: the BOM the sourcing check passed) is matched to its Adom wiki component page (a `wiki` column, owner/slug, names it directly; else the MPN's slug forms, then a page whose component.mpn is the MPN); --show walks a Hydrogen tab through each page so the viewer sees the parts being chosen. A part with no page fails: make it (wiki-component skill) and run again, or give it from the board's own project library (--project-lib).     Parts { #[arg(long)] bom: Option<String>, #[arg(long)] show: bool, #[arg(long, default_value_t = 4)] dwell: u64, /// a project library folder for parts with no public component page: KiCad .kicad_sym, .pretty footprints, STEP models and a provenance.json naming each MPN's source (default: the spec's or requirements' "projectLib"). Each listed MPN needs a symbol, a footprint, a model and provenance; project-page packages them, finish reports them.     #[arg(long)] project_lib: Option<String> },     /// `project-page --page <owner/slug> [--create]`: revision 1 of the board's project page before any EDA work: a README that says what the board is with the component table, and a KiCad PCM repository of every part (an Adom Cloud Library, exported and repackaged for KiCad's Plugin and Content Manager). Opens the page in Hydrogen.@@ -245,11 +245,11 @@ enum Cmd {     Export { /// the board (default: the run's current board)     #[arg(long)] board: Option<String>, /// where the set goes (default: <run>/manufacturing)     #[arg(long)] out: Option<PathBuf> },-    /// The board twin's native STEP export (kicad-cli pcb export step with models, tracks, pads, zones, mask and silkscreen; never fewer layers). Its output is kept and classified (geometry, model, other) apart from the exit status; geometry warnings block `board-glb copper` and `deliver` until `step-export --ack --why` after a review of the STEP. --log classifies the output of an export run elsewhere (the desktop).+    /// The board twin's native STEP export (kicad-cli pcb export step with models, tracks, pads, zones, mask and silkscreen; never fewer layers). Its output is kept and classified (geometry, model, other) apart from the exit status; the export runs from the source project's folder so ${KIPRJMOD} and the PCM 3rdparty models resolve; geometry warnings and missing 3D models are open issues that `board-glb copper` and `deliver` report until fixed or `step-export --ack --why` after a review of the STEP. --log classifies the output of an export run elsewhere (the desktop).     StepExport { /// the board (default: the run's current board)     #[arg(long)] board: Option<String>, /// the STEP to write (default: <run>/twin/<board>.step), or with --log the STEP that export wrote     #[arg(long)] out: Option<PathBuf>, /// classify this saved export output instead of running kicad-cli-    #[arg(long)] log: Option<PathBuf>, /// record that the geometry warnings of the last export were reviewed (needs --why)+    #[arg(long)] log: Option<PathBuf>, /// record that the geometry and model warnings of the last export were reviewed (needs --why)     #[arg(long)] ack: bool, #[arg(long)] why: Option<String> },     /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay     Unroute { #[arg(long, required = true)] net: Vec<String> },@@ -858,6 +858,12 @@ fn auto_clips(r: &Run, dir: &Path) -> String {     match publish_clips(r, dir, &pg, pd.as_deref(), true) { Ok((s, _)) => s, Err(e) => format!("clips page: {e}") } } +/// AIFLOW_AUTO_CAPTURE=off (or false, 0): AI Flow starts no recording by itself, in `step` or in a+/// tour (issue #180); the tours still drive their views and take their screenshots.+fn auto_capture_off() -> bool {+    std::env::var("AIFLOW_AUTO_CAPTURE").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false)+}+ /// The user's primary machine (`adom-bridge targets` names it). fn primary_machine() -> Option<String> {     let o = std::process::Command::new("adom-bridge").arg("targets").output().ok()?;@@ -1299,6 +1305,45 @@ fn recover_capture_files(r: &mut Run, dir: &Path) -> usize {  /// The fields walkthrough's take stops itself after 15 minutes (issue #98; the tour runs about ten). const FIELDS_CLIP_MAX_MS: u64 = 15 * 60 * 1000;+/// The app's own tour is stopped after 13 minutes, so its take always ends at the tour's end screenshot,+/// never at the recorder's cap (issue #180).+const FIELDS_TOUR_MAX_MS: u64 = 13 * 60 * 1000;+/// A tour take's size cap (issue #180: a 4K Fields window made 3.6 GB in 12 minutes): the take is recorded+/// at a constant rate that fits this many MB in its length cap.+const TOUR_CLIP_MAX_MB: u64 = 1024;++/// Start a tour's window take, or None with a note: AIFLOW_AUTO_CAPTURE=off, no recorder, or the recorder+/// refused. Never a stop: the tour runs and keeps its screenshots without a clip.+fn tour_record_start(r: &mut Run, br: &Bridge, hwnd: i64, why: &str, max_ms: u64, max_mb: u64) -> Option<String> {+    if auto_capture_off() {+        r.log("clip-auto-disabled", json!({"step": r.data["currentStep"], "tour": why, "reason": "AIFLOW_AUTO_CAPTURE"}));+        eprintln!("AIFLOW_AUTO_CAPTURE is off: {why} is not recorded (its views and screenshots still run)");+        return None;+    }+    if !clipwatch::recorder(r, br).0 {+        note(&format!("no clip of {why}: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &["The tour still runs and keeps its start and end screenshots.".into()]);+        return None;+    }+    match br.record_window_start_sized(hwnd, why, max_ms, max_mb) {+        Ok(rid) => Some(rid),+        Err(rep) => { note(&format!("no clip of {why}: the recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?")), &["The tour still runs and keeps its start and end screenshots.".into()]); None }+    }+}++/// Run a child to completion or kill it at `limit`: Ok(success) when it ended, Err(why) when it could not+/// start or ran out of time (issue #180: every take a tour films has a stop condition of its own).+fn run_bounded(cmd: &mut std::process::Command, limit: std::time::Duration) -> Result<bool, String> {+    let mut child = cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn().map_err(|e| format!("could not start: {e}"))?;+    let t0 = std::time::Instant::now();+    loop {+        match child.try_wait() {+            Ok(Some(st)) => return Ok(st.success()),+            Ok(None) if t0.elapsed() >= limit => { let _ = child.kill(); let _ = child.wait(); return Err(format!("ran past {} s and was stopped", limit.as_secs())); }+            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(250)),+            Err(e) => return Err(format!("could not be watched: {e}")),+        }+    }+}  /// Put one clip stop on its capture. The remote path and the local file are only ever filled in, /// never blanked: a second stop of the same recording (the recorder answers "nothing running")@@ -1525,21 +1570,33 @@ fn blank_check_verdict(why: &str) -> bool {     why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour") || why.starts_with("truncated") } -/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a-/// painted canvas has content and an unpainted one is one flat colour) is read as 192 x 108 grey-/// pixels and judged by `frame_flat`. More than three quarters flat frames means the clip filmed-/// nothing worth keeping. Returns the reason.+/// Eight frames spread through a clip, each the centre 60 % of the frame as `w` x `h` grey pixels,+/// from ONE decode pass (issues #180, #181): the `fps` filter holds the last decoded frame until the+/// next, so a clip of an idle window (the recorder writes frames only on change, all of them at the+/// head) still gives its painted frames, and a multi-GB clip is decoded once, not once per seek.+fn sample_frames(file: &str, secs: f64, n: usize, w: usize, h: usize) -> Vec<Vec<u8>> {+    let vf = format!("fps={:.6}:start_time={:.6}:round=down,crop=iw*0.6:ih*0.6,scale={w}:{h}", n as f64 / secs, secs / (2.0 * n as f64));+    let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-i", file, "-an", "-vf", &vf, "-frames:v", &n.to_string(), "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output();+    let px = out.map(|o| o.stdout).unwrap_or_default();+    let mut frames: Vec<Vec<u8>> = px.chunks_exact(w * h).map(|c| c.to_vec()).collect();+    // a clip shorter than the first sample point (its only frames at the head): decode it from its start+    if frames.is_empty() {+        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-i", file, "-an", "-vf", &format!("crop=iw*0.6:ih*0.6,scale={w}:{h}"), "-frames:v", &n.to_string(), "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output();+        frames = out.map(|o| o.stdout).unwrap_or_default().chunks_exact(w * h).map(|c| c.to_vec()).collect();+    }+    frames+}++/// Sample eight frames spread through a clip (`sample_frames`); each is judged by `frame_flat`.+/// More than three quarters flat frames means the clip filmed nothing worth keeping. Returns the+/// reason. "could not read frames" means a full decode gave no frame at all. fn blank_check(file: &str) -> Option<String> {     let secs = media_seconds(file);     if secs <= 0.0 { return Some("no frames".into()); }     let n = 8usize;     let (w, h) = (192usize, 108usize);     let mut flat = 0usize; let mut total = 0usize; let mut moved = 0usize; let mut prev: Option<Vec<u8>> = None;-    for k in 0..n {-        let t = secs * (k as f64 + 0.5) / n as f64;-        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", &format!("crop=iw*0.6:ih*0.6,scale={w}:{h}"), "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;-        let px = out.stdout;-        if px.len() < w * h { continue; }+    for px in sample_frames(file, secs, n, w, h) {         total += 1;         if frame_flat(&px, w, h) { flat += 1; }         // a mostly dark canvas that changes between samples is being worked on (a Fusion board is black@@ -1671,7 +1728,32 @@ fn media_seconds(path: &str) -> f64 { /// of the step. The window recorder only emits frames on change, so the cut is by timestamp. fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) { clip_artifacts_with(r, step, file, speed, MEDIA_TOOLS) } +/// Issue #170: set by `recut` and `compose`, the commands whose job is the media; every other command+/// (a step, a tour, finish) leaves a clip over the deferral size for them.+static MEDIA_NOW: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);++/// Clips over this many MB (AIFLOW_CLIP_DEFER_MB, default 1024; 0 never defers) are not checked or cut+/// inside an engineering command: decoding a multi-GB take held a step for many minutes (#170, #180).+fn clip_defer_mb() -> u64 {+    std::env::var("AIFLOW_CLIP_DEFER_MB").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(1024)+}++/// The clip's size in MB when its check and cuts wait for `recut` / `compose`, else None.+fn media_deferred(file: &str, media_now: bool, limit_mb: u64) -> Option<u64> {+    let mb = std::fs::metadata(file).map(|m| m.len() / 1_000_000).unwrap_or(0);+    (!media_now && limit_mb > 0 && mb > limit_mb).then_some(mb)+}+ fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools: &[&str]) {+    let name = Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();+    fn cap_of<'a>(r: &'a mut Run, file: &str) -> Option<&'a mut Value> { r.data["captures"].as_array_mut()?.iter_mut().find(|c| c["file"].as_str() == Some(file)) }+    if let Some(mb) = media_deferred(file, MEDIA_NOW.load(std::sync::atomic::Ordering::Relaxed), clip_defer_mb()) {+        if let Some(c) = cap_of(r, file) { c["mediaDeferred"] = json!({"mb": mb, "at": now()}); }+        r.log("clip-media-deferred", json!({"step": step, "file": file, "mb": mb, "limitMb": clip_defer_mb()}));+        eprintln!("NOTE (a suggestion, not a stop): clip {name} is {mb} MB: its blank check, contact sheet and cuts wait for `adom-aiflow recut` (or `compose`, which does them first), so this command is not held by the video. The raw clip is kept.");+        return;+    }+    if let Some(o) = cap_of(r, file).and_then(|c| c.as_object_mut()) { o.remove("mediaDeferred"); }     // no ffprobe/ffmpeg: say so and keep the clip as it is, unjudged (never flag the footage blank)     let verdict = match clip_verdict(file, tools) {         Ok(v) => v,@@ -1721,6 +1803,13 @@ fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools:         r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));         eprintln!("contact sheet (look at it before you go on): {sheet}");     }+    // issue #170 (Josh Aberdeen's patch): footage flagged blank or truncated is never cut; the raw clip,+    // its sheet and the suspect flag stay, and a recut that passes the check makes the cuts+    if let Some(why) = verdict.as_deref() {+        r.log("clip-encode-skipped", json!({"step": step, "file": file, "reason": why, "policy": "invalid-video-no-encode", "originalPreserved": true}));+        eprintln!("clip {name}: no 10x or action cut made (flagged: it stays out of the video); the raw clip is kept, and `adom-aiflow recut` cuts it if a new check passes");+        return;+    }     let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));     // the recorder's first frames are black before the window paints: they are cut off the head     // of both cuts (never more than five seconds; longer black is a real clip problem, kept)@@ -2922,6 +3011,35 @@ fn unacked_orientation(r: &Run, warnings: &[Value]) -> Vec<String> {     refs } +/// A wiki page's page.json through `adom-wiki repo show` (None when it cannot be read).+fn wiki_page_json(page: &str) -> Option<Value> {+    let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, "page.json"]).stdin(std::process::Stdio::null()).output().ok().filter(|o| o.status.success())?;+    let t = String::from_utf8_lossy(&o.stdout);+    serde_json::from_str(t.get(t.find('{')?..=t.rfind('}')?)?).ok()+}++/// The installed version of package `id` in a kicad_pcm_list answer (any object whose identifier is `id`).+fn pcm_installed_version(list: &Value, id: &str) -> Option<String> {+    match list {+        Value::Object(o) => {+            if o.get("identifier").and_then(Value::as_str) == Some(id) { if let Some(v) = o.get("version").and_then(Value::as_str) { return Some(v.to_string()); } }+            o.values().find_map(|v| pcm_installed_version(v, id))+        }+        Value::Array(a) => a.iter().find_map(|v| pcm_installed_version(v, id)),+        _ => None,+    }+}++#[cfg(test)]+mod pcm_installed_version_tests {+    #[test]+    fn finds_the_package_row_anywhere_in_the_answer() {+        let list = serde_json::json!({"count": 2, "packages": [{"identifier": "a.b", "version": "2.0.0"}, {"identifier": "inc.adom.wiki.x.y", "version": "1.0.0", "latestVersion": "1.0.1"}]});+        assert_eq!(super::pcm_installed_version(&list, "inc.adom.wiki.x.y").as_deref(), Some("1.0.0"));+        assert_eq!(super::pcm_installed_version(&list, "nope"), None);+    }+}+ fn project_dir_of(r: &Run) -> PathBuf {     let src = r.data["sourceBoard"].as_str().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r.current_board()));     src.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))@@ -2987,8 +3105,14 @@ fn continuation_report(r: &Run, evidence: Option<&Path>) -> Value {         let finish_accepted = accepted["boardSha256"] == bs.as_str() && accepted["specSha256"] == ss.as_str() && accepted["steps"].as_array().is_some_and(|a| a.iter().any(|s| s == p.as_str()));         !finish_accepted && !live.iter().any(|d| d["item"] == format!("step {p}").as_str())     }).collect();-    let stop = evidence.map(|p| std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str::<Value>(&s).map_err(|e| e.to_string())));-    continuation::assess(&r.data, &pending, stop)+    // issue #171: a stop `done --stop-evidence` accepted is read again by every later guard call (a host+    // stop hook passes no path) until a new prompt clears it; the file is re-read and re-checked each time+    let recorded = r.data["stopEvidence"]["path"].as_str().map(PathBuf::from);+    let evidence = evidence.map(Path::to_path_buf).or(recorded);+    let stop = evidence.as_ref().map(|p| std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str::<Value>(&s).map_err(|e| e.to_string())));+    let mut v = continuation::assess(&r.data, &pending, stop);+    if let (Some(p), Some(o)) = (&evidence, v.as_object_mut()) { o.insert("stopEvidencePath".into(), json!(p.display().to_string())); }+    v }  /// A routing plan read and normalised the one way both `gate` and `land` see it (issue #33):@@ -3041,7 +3165,12 @@ fn main() {         let warning = overdue_warning(&r);         if let (Some(w), Some(o)) = (&warning, report.as_object_mut()) { o.insert("warning".into(), json!(w)); }         // issue #137: an open item worth a look, never a requirement (the decision is unchanged)-        let suggestions: Vec<String> = molecule_publish_hint(&r).map(|h| format!("molecule not published yet: {}", h.trim_start_matches("NEXT (molecule): "))).into_iter().collect();+        let mut suggestions: Vec<String> = molecule_publish_hint(&r).map(|h| format!("molecule not published yet: {}", h.trim_start_matches("NEXT (molecule): "))).into_iter().collect();+        // issue #171: finish's suggested return, until that step is visited again+        if let (Some(s), Some(i), Some(at)) = (r.data["suggestedReturn"]["step"].as_str(), r.data["suggestedReturn"]["item"].as_str(), r.data["suggestedReturn"]["at"].as_str()) {+            let revisited = r.data["steps"][s]["visits"].as_array().is_some_and(|a| a.iter().any(|v| v["at"].as_str().is_some_and(|t| t >= at)));+            if !revisited { suggestions.push(sequence::return_suggestion(s, i)); }+        }         if let Some(o) = report.as_object_mut() { o.insert("suggestions".into(), json!(suggestions)); }         if *as_json { println!("{}", serde_json::to_string_pretty(&report).unwrap()); }         else { println!("{}: {}", report["decision"].as_str().unwrap_or("continue"), report["reason"].as_str().unwrap_or(""));@@ -3154,7 +3283,7 @@ fn main() {             if run.is_some() { thread(&cli); }             if action == "copper" {                 if let Some(open) = run.as_ref().and_then(|r| step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null))) {-                    err(&format!("board twin not delivered: {open}"), &["The STEP behind this GLB carries exporter geometry warnings nobody has reviewed. Review the STEP at those spots, then `adom-aiflow step-export --ack --why \"...\"`; never drop a layer to silence the exporter.".into()]);+                    note(&format!("the board twin's STEP has open issues: {open}"), &["The STEP behind this GLB carries exporter warnings nobody has reviewed (geometry, or parts with no 3D model). Review the STEP at those spots, then `adom-aiflow step-export --ack --why \"...\"`, or fix and export again; never drop a layer to silence the exporter.".into()]);                 }             }             let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));@@ -3277,12 +3406,12 @@ fn main() {             if *ack {                 let why = why.as_deref().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("step-export --ack needs --why \"what you reviewed in the STEP and why it is complete\"", &[]));                 let mut rec = r.outcomes().get("step-export").cloned().unwrap_or_else(|| err("no STEP export recorded in this run", &["Run `adom-aiflow step-export` first.".into()]));-                if rec["geometryWarnings"].as_u64().unwrap_or(0) == 0 { ok("nothing to acknowledge: the last STEP export has no geometry warnings", &[]); return; }+                if rec["geometryWarnings"].as_u64().unwrap_or(0) + rec["modelWarnings"].as_u64().unwrap_or(0) == 0 { ok("nothing to acknowledge: the last STEP export has no geometry or model warnings", &[]); return; }                 rec["ack"] = json!({"why": why, "at": now(), "stepSha256": rec["stepSha256"].clone()});                 r.outcome("step-export", rec.clone());                 r.log("step-export-ack", json!({"why": why, "step": rec["step"], "geometryWarnings": rec["geometryWarnings"]}));                 r.save().unwrap();-                ok(&format!("acknowledged {} STEP geometry warning(s) for {} (bound to this STEP file; a new export needs a new review)", rec["geometryWarnings"], rec["step"].as_str().unwrap_or("")), &[]);+                ok(&format!("acknowledged {} STEP geometry and {} model warning(s) for {} (bound to this STEP file; a new export needs a new review)", rec["geometryWarnings"], rec["modelWarnings"], rec["step"].as_str().unwrap_or("")), &[]);                 return;             }             let board_path = board.clone().unwrap_or_else(|| r.current_board());@@ -3293,7 +3422,7 @@ fn main() {                 None => {                     let _ = std::fs::create_dir_all(step.parent().unwrap_or(Path::new(".")));                     let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());-                    step_export::run(&board_path, &step, &kicad)+                    step_export::run(&board_path, &step, &kicad, &project_dir_of(&r))   // issues #175, #177: ${KIPRJMOD} is the project                 }             };             write_json(&step.with_extension("export.json"), &rec);@@ -3302,7 +3431,8 @@ fn main() {             let (g, m, o) = (rec["geometryWarnings"].as_u64().unwrap_or(0), rec["modelWarnings"].as_u64().unwrap_or(0), rec["otherWarnings"].as_u64().unwrap_or(0));             if let Some(open) = step_export::open_issue(&rec) {                 let spots: Vec<String> = rec["diagnostics"].as_array().into_iter().flatten().map(|d| format!("{}: {}{}", d["kind"].as_str().unwrap_or(""), d["message"].as_str().unwrap_or(""), d["location"].as_object().map(|l| format!(" at z {} box ({}, {})-({}, {}) near {}", l["z"], l["x0"], l["y0"], l["x1"], l["y1"], d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).unwrap_or_default())).take(12).collect();-                err(&format!("STEP export recorded with open issues: {open}\n  {}", spots.join("\n  ")), &[format!("Exit status {} and the file are recorded separately from these diagnostics ({}).", rec["exitCode"], step.with_extension("export.json").display()), "Do not re-export without silkscreen or mask to hide them. Review the STEP at each spot; if it is complete, `step-export --ack --why \"...\"`; if not, fix the board geometry and export again.".into()]);+                let ctx: Vec<String> = rec["exportContext"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(|n| format!("Export context: {n}"))).collect();+                findings(&format!("STEP export recorded with open issues: {open}\n  {}", spots.join("\n  ")), &[vec![format!("Exit status {} and the file are recorded separately from these diagnostics ({}).", rec["exitCode"], step.with_extension("export.json").display()), "Do not re-export without silkscreen or mask to hide them. Review the STEP at each spot; if it is complete, `step-export --ack --why \"...\"`; if not, fix the board geometry and export again.".into()], ctx].concat());             }             ok(&format!("STEP export {}: exit {}, {g} geometry, {m} model, {o} other warning(s){}", step.display(), rec["exitCode"], if g > 0 { " (acknowledged)" } else { "" }), &[if m > 0 { "Model warnings: a part may be missing from the twin; check the models step.".into() } else { String::new() }, "Next: step2glb convert, then `board-glb copper` (skill aiflow-molecule).".into()]);         }@@ -3313,7 +3443,8 @@ fn main() {             let board_path = board.clone().unwrap_or_else(|| r.current_board());             let out_dir = out.clone().unwrap_or_else(|| dir.join("manufacturing"));             let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());-            match export::export(&board_path, &out_dir, &kicad) {+            let off = export::off_board(&spec_soft(&r));+            match export::export_with(&board_path, &out_dir, &kicad, &off) {                 Ok(report) => {                     write_json(&out_dir.join("export-check.json"), &report);                     r.outcome("export", report.clone());@@ -3658,6 +3789,12 @@ fn main() {                 let mpn = row["mpn"].as_str().unwrap_or("").to_string();                 // the MPN, then the wiki's slug form of it (AP2112K-3.3TRG1 lives at ap2112k-3-3trg1, issue #76)                 let mut page = None;+                // issue #178: a page the BOM row names (`wiki` column) is used when it exists, like the netlist `page`+                if let Some(named) = row["page"].as_str().map(str::to_string) {+                    if wiki_page_json(&named).is_some() { row["wiki"] = json!(named); row["wikiMatch"] = json!(format!("{named} is named in the BOM's wiki column")); row["pageNamed"] = json!(true); continue; }+                    row["wikiMatch"] = json!(format!("the BOM names {named}, which could not be read (adom-wiki repo show {named} page.json); searched by MPN instead"));+                }+                let mut seen_hits: Vec<Value> = Vec::new();                 let mut queries = vec![mpn.clone()];                 if let Some(w) = preboard::part_slug_candidates(&mpn).into_iter().skip(1).next() { queries.push(w); }                 // MCP73831T-2ACI/OT's factory page is adom/mcp73831t-2aci (issue #161): the MPN without its "/" suffix last@@ -3668,6 +3805,12 @@ fn main() {                     let hits = v["data"]["results"].as_array().or(v["data"].as_array()).or(v["results"].as_array()).cloned().unwrap_or_default();                     page = preboard::part_page(&hits, &mpn);                     if page.is_some() { break; }+                    seen_hits.extend(hits);+                }+                // issue #178: a page under another slug (adom/adom-1317) that declares this MPN in its component.mpn+                if page.is_none() {+                    page = preboard::mpn_page_candidates(&seen_hits, &mpn).into_iter().find(|p| wiki_page_json(p).and_then(|j| j["component"]["mpn"].as_str().map(|m| preboard::page_mpn_matches(m, &mpn))).unwrap_or(false));+                    if let Some(p) = &page { row["wikiMatch"] = json!(format!("{p} declares MPN {mpn} (component.mpn)")); }                 }                 if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }                 if let Some(how) = page.as_deref().and_then(|p| preboard::matched_by_base(p, &mpn)) { row["wikiMatch"] = json!(how); }@@ -3787,7 +3930,9 @@ fn main() {                 hints.push(format!("3D models bound from the parts' own wiki pages (their footprints named none): {}; `models` checks them on the board like any other",                     b.iter().map(|x| format!("{} -> {}", x["mpn"].as_str().unwrap_or("?"), x["model"].as_str().unwrap_or("?"))).collect::<Vec<_>>().join(", ")));             }-            ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),+            // issue #169: the package version follows its content; KiCad's PCM updates only to a higher one+            if v["pcmVersionWhy"].as_str().is_some_and(|w| w.starts_with("content changed")) { hints.push(format!("PCM package {} ({}): a KiCad that has an earlier version installed updates with kicad_pcm_install {{\"identifier\":\"{}\",\"update\":true}}, or Update in KiCad's Plugin and Content Manager after a restart", v["pcmVersion"].as_str().unwrap_or("?"), v["pcmVersionWhy"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))); }+            ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} (package {}; {} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), v["pcmVersion"].as_str().unwrap_or("?"), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),                &[hints, if *no_push { vec![format!("Local build only: the README, PCM zip and repository files are in {}. The repository URL above works only after `project-page` runs without --no-push.", dir.join("project-page").display())] } else { vec![] }, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());         }         Cmd::Pcm { what: _ } => {@@ -3800,9 +3945,14 @@ fn main() {             let list = br.raw_call("kicad_pcm_list", &json!({}));             let text = list.to_string();             let installed = text.contains(&id);-            r.outcome("library", json!({"pass": installed, "identifier": id, "libName": pp["libName"], "repositoryUrl": pp["repositoryUrl"], "pcmList": list}));+            // issue #169: an installed package older than the page's build still has the old parts+            let built = pp["pcmVersion"].as_str().unwrap_or("").to_string();+            let have = pcm_installed_version(&list, &id);+            let stale = installed && !built.is_empty() && have.as_deref().is_some_and(|h| h != built);+            r.outcome("library", json!({"pass": installed && !stale, "identifier": id, "libName": pp["libName"], "repositoryUrl": pp["repositoryUrl"], "installedVersion": have, "builtVersion": pp["pcmVersion"], "pcmList": list}));             r.save().unwrap();-            if !installed { err(&format!("{id} is not installed in the test box's KiCad"), &[format!("kicad_pcm_add_repository {{\"url\":\"{}\"}}, then kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{id}\"}}, restart KiCad, `pcm check` again.", pp["repositoryUrl"].as_str().unwrap_or(""), pp["repositoryUrl"].as_str().unwrap_or(""))]); }+            if stale { findings(&format!("{id} {} is installed in KiCad, but the project page built {built}: KiCad still has the old parts", have.unwrap_or_default()), &[format!("kicad_pcm_install {{\"identifier\":\"{id}\",\"update\":true}} (or Update in KiCad's Plugin and Content Manager after a restart), then `pcm check` again.")]); }+            if !installed { findings(&format!("{id} is not installed in the test box's KiCad"), &[format!("kicad_pcm_add_repository {{\"url\":\"{}\"}}, then kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{id}\"}}, restart KiCad, `pcm check` again.", pp["repositoryUrl"].as_str().unwrap_or(""), pp["repositoryUrl"].as_str().unwrap_or(""))]); }             ok(&format!("{id} is installed in KiCad: the schematic uses PCM_{}:<part>", pp["libName"].as_str().unwrap_or("")), &["Next: `step schematic`: draw it in KiCad's schematic editor (kicad_sch_place_symbol, kicad_sch_wire, kicad_sch_label), ERC clean, every BOM reference on the sheet.".into()]);         }         Cmd::Schematic { what: _, sch } => {@@ -3969,7 +4119,7 @@ fn main() {                         if !f.errors.is_empty() { note(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }                     }                     let spec_i=spec_of(&r);-                    let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});+                    let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();note(&e,&[placement_intent::HINT.into()]);Vec::new()});                     for l in &intent_lines { eprintln!("{l}"); }                     let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();                     let br=bridge_of(&r,&cli);@@ -4035,7 +4185,7 @@ fn main() {                     let moved: Vec<String> = mv.keys().cloned().collect();                     // issue #95: an edge connector may overhang the outline by design, never with copper                     let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));-                    let overhang_lines = edge_overhang::check_board(&nb, &overhang).unwrap_or_else(|e| err(&e, &[format!("Move the part inward until every pad is inside; the run's current board is unchanged ({}).", outp.display())]));+                    let overhang_lines = edge_overhang::check_board(&nb, &overhang).unwrap_or_else(|e| { note(&e, &[format!("Suggested: move the part inward until every pad is inside; the run's current board is unchanged ({}).", outp.display())]); Vec::new() });                     let problems = aiflow_place::check_allowing(&nb, &moved, 0.05, &edge_overhang::refs(&overhang));                     // issue #140: every part outside the outline, moved or not (a part pack found no spot for is not in the moves)                     let outside = aiflow_place::parts_outside(&nb, &edge_overhang::refs(&overhang));@@ -4045,12 +4195,13 @@ fn main() {                     if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) {                         if !f.errors.is_empty() {                             let mut h = f.hints.clone();-                            h.push(format!("The moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));-                            err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);+                            h.push(format!("Suggested: the moves break the molecule; the run's current board is unchanged. Fix the wishes (never move the interface parts) and check again; see {}.", outp.display()));+                            // AI Flow never refuses (0.2.48): the molecule findings are said and the check goes on to DRC+                            note(&format!("the moved board is not a conforming molecule yet:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);                         }                     }                     let spec_i = spec_of(&r);-                    if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }+                    if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); note(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }                     let drc = run_drc_for(&r, &outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));                     let baseline = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));                     let s = aiflow_copper::summarize_with(&drc, Some(&baseline), silk_policy::blocking(&spec_i, &r.current_board()));@@ -5079,7 +5230,7 @@ fn main() {             }             {                 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)")); }+                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; or `thermal-vias land --ack {a} --why \"...\"` when it needs no more)")); }             }             {                 // the outline as the board is now: the live snapshot when the tool has one, else the plan board@@ -5133,6 +5284,10 @@ fn main() {             if !open_items.is_empty() {                 eprintln!("Open items (suggestions, the board is not blocked):\n  {}", open_items.join("\n  "));             }+            // issue #171: name the earliest step the open items go back to (a suggestion; the AI declares the return)+            let back_to = sequence::earliest_return(&seq, r.data["currentStep"].as_str().unwrap_or(""), &open_items);+            if let Some((s, i)) = &back_to { eprintln!("Suggested: {}", sequence::return_suggestion(s, i)); }+            r.data["suggestedReturn"] = back_to.map(|(s, i)| json!({"step": s, "item": i, "at": now()})).unwrap_or(Value::Null);             if !deviated.is_empty() {                 // finish-guard honours the steps these deviations left unproven, on this board and spec only                 let before_finish: Vec<String> = pending_steps(&r).into_iter().take_while(|s| s != "finish").collect();@@ -5320,6 +5475,7 @@ fn main() {         Cmd::Recut => {             let t = thread(&cli);             if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then recut again. No clip was judged.".into()]); }+            MEDIA_NOW.store(true, std::sync::atomic::Ordering::Relaxed);             let mut r = load_run(&cli);             // a clip whose pull failed at its stop comes first: the desktop cleans its recordings folder             if let Some(target) = r.data["target"].as_str().map(str::to_string) {@@ -5346,6 +5502,11 @@ fn main() {             if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }             let mut r = load_run(&cli);             if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }+            // issue #170: clips a step left for later (too big to check inside it) are checked and cut now+            MEDIA_NOW.store(true, std::sync::atomic::Ordering::Relaxed);+            let deferred: Vec<(String, String)> = r.data["captures"].as_array().into_iter().flatten().filter(|c| c.get("mediaDeferred").is_some()).filter_map(|c| Some((c["file"].as_str()?.to_string(), format!("{}-{}", c["step"].as_str()?, c["visit"].as_u64().unwrap_or(1))))).collect();+            for (f, tag) in &deferred { if Path::new(f).is_file() { clip_artifacts(&mut r, &json!(tag), f, 10); } }+            if !deferred.is_empty() { r.save().unwrap(); }             let cwd = std::env::current_dir().unwrap_or_else(|e| err(&format!("cannot read the working directory: {e}"), &[]));             let outp = compose_out_path(out.as_deref(), &dir, &cwd);             if let Some(parent) = outp.parent() {@@ -5427,6 +5588,8 @@ fn main() {             let n = r.sessions().len() + 1;             r.log("prompt", json!({"n": n, "text": text, "at": at}));             r.data["clock"]["lastPromptTime"] = json!(at);+            // issue #171: a new prompt resumes the run: a recorded stop no longer holds+            r.data["stopEvidence"] = Value::Null; // null, not removed: Run::save keeps keys missing from memory             r.save().unwrap();             ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Continue the original requested scope after supporting tasks and status updates. Use `done --message \"...\"` only at completion, an explicit pause, or a genuine blocking handoff; it stops session accounting, not the engineering run.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into(), "Measure external work with adom-aiflow exec -- <program> <args>. Unwrapped long gaps are unobserved and may undercount active work, not proof of idle time.".into()]);             print_next(&cli);@@ -5443,6 +5606,10 @@ fn main() {             let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };             if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }             let t = now();+            // issue #171: an accepted stop is kept, so the finish guard honours it on later calls without its path+            if let (Some(p), true) = (stop_evidence, verdict["mayStop"] == true && verdict["stopEvidence"].is_object()) {+                r.data["stopEvidence"] = json!({"path": std::fs::canonicalize(p).unwrap_or(p.clone()).display().to_string(), "at": t, "decision": verdict["decision"]});+            }             r.log("done", json!({"n": cur.n, "message": message, "stopDecision": verdict}));             r.save().unwrap();             let ss = r.sessions();@@ -5562,9 +5729,11 @@ fn main() {             thread(&cli);             let mut r = load_run(&cli);             let spec = spec_of(&r);-            if let Some(refs) = ack {+            if !ack.is_empty() {                 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()); }+                for x in ack.iter().map(|x| x.trim()).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }+                // issue #174: saved now, so `land` (which reloads the run after landing) and finish keep it+                r.save().unwrap();             }             let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();             // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it@@ -6110,10 +6279,12 @@ fn main() {                         r.save().unwrap();                         err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);                     }-                    if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); }-                    let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));-                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));+                    // issue #180: no recording with AIFLOW_AUTO_CAPTURE=off, and a recorder problem is a note: the tour still runs+                    let rid = tour_record_start(&mut r, &br, viewer, "aiflow 3D walkthrough", aiflow_bridge::WINDOW_MAX_MS, TOUR_CLIP_MAX_MB);+                    if let Some(rid) = &rid {+                        r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));+                        r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));+                    }                     r.save().unwrap();                     let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));                     if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }@@ -6123,8 +6294,8 @@ fn main() {                     let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));                     if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }                     let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let stop = br.record_stop(&rid, &save_to);+                    if let Some((rid, pos)) = rid.as_deref().and_then(|rid| Some((rid, caps.iter().rposition(|c| c["recordingId"].as_str() == Some(rid))?))) {+                        let stop = br.record_stop(rid, &save_to);                         let local = settle_clip_stop(&mut r, pos, &stop, &dir);                         r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));                         if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }@@ -6136,7 +6307,7 @@ fn main() {                     r.outcome("tour-3d", json!({"commands": cmds}));                     r.save().unwrap();                     if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }-                    ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);+                    ok(&format!("3D walkthrough {}: {cmds} view commands on the viewer, no mouse, {} in the run", if rid.is_some() { "recorded" } else { "ran" }, if rid.is_some() { "clip and screenshots" } else { "screenshots (no clip)" }), &["Next: step nets, or step capture / finish.".into()]);                 }                 "nets" => {                     let spec = spec_of(&r);@@ -6281,19 +6452,24 @@ fn main() {                     }                     std::thread::sleep(std::time::Duration::from_secs(4));                     // issue #98: the walkthrough take is capped in length as well as in bit rate-                    if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); }+                    // issue #180: AIFLOW_AUTO_CAPTURE=off records nothing (the tour still drives the views and takes+                    // its screenshots); a recorder problem is a note, never a stop; a take is capped in length AND size+                    let record = !auto_capture_off();                     // an off-screen window records nothing (#123): put it on screen for the take, then tuck it away+                    let offscreen = offscreen && record;                     let raised = offscreen && Bridge::ok(&br.call("pup_raise_os_window", &json!({"sessionId": sid, "reason": "aiflow fields walkthrough: an off-screen window is not painted, so it cannot be filmed; lowered again when the take ends"})));                     if offscreen { eprintln!("{}", if raised { "the Fields window was off-screen (macOS): raised it on screen for the walkthrough take; it is lowered when the take ends" } else { "WARNING: the Fields window is off-screen (macOS) and pup could not raise it: the take will likely be empty" }); std::thread::sleep(std::time::Duration::from_secs(3)); }-                    let rid = br.record_window_start_capped(win, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS).unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));-                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));+                    let rid = tour_record_start(&mut r, &br, win, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS, TOUR_CLIP_MAX_MB);+                    if let Some(rid) = &rid {+                        r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields", "maxMs": FIELDS_CLIP_MAX_MS, "maxMb": TOUR_CLIP_MAX_MB}));+                        r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));+                    }                     r.save().unwrap();                     if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }                     // no pace of our own: the app holds each beat and drifts the board gently, because                     // this clip is played back at ten times speed-                    let tour = std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]).output();-                    let tour_ok = tour.as_ref().map(|o| o.status.success()).unwrap_or(false);+                    // the app's tour runs about ten minutes; it is stopped before the take's own cap (issue #180)+                    let tour_ok = run_bounded(std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]), std::time::Duration::from_millis(FIELDS_TOUR_MAX_MS)).unwrap_or_else(|why| { note(&format!("the app's walkthrough {why}"), &[]); false });                     // the EMC beat after the app's own walkthrough: the hot loop, the return path under it and                     // the radiated estimate against FCC Part 15 class B, held long enough to read                     let ps = port.to_string();@@ -6307,8 +6483,8 @@ fn main() {                     let mut clip_file: Option<String> = None;                     if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }                     let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let stop = br.record_stop(&rid, &save_to);+                    if let Some((rid, pos)) = rid.as_deref().and_then(|rid| Some((rid, caps.iter().rposition(|c| c["recordingId"].as_str() == Some(rid))?))) {+                        let stop = br.record_stop(rid, &save_to);                         let local = settle_clip_stop(&mut r, pos, &stop, &dir);                         r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));                         if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); clip_file = Some(f); }@@ -6327,12 +6503,14 @@ fn main() {                     r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));                     r.save().unwrap();                     // no silent pass (#123): the walkthrough ran, but there is no clip of it-                    if clip_file.is_none() {+                    if clip_file.is_none() && !record {+                        eprintln!("NOTE: no clip of the fields walkthrough: AIFLOW_AUTO_CAPTURE is off, so nothing was recorded; the start and end screenshots are in the run");+                    } else if clip_file.is_none() {                         eprintln!("WARNING: the fields walkthrough ran, but the recorder produced no clip of the app window{} (issue #123). The run's video will have no Fields segment: run `tour fields` again{}, or film the app another way and register it with `artifact`.", if offscreen { ", which was off-screen" } else { "" }, if offscreen && !raised { " once pup can raise its window (pup_raise_os_window)" } else { "" });                     }                     if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }                     let page_hint = results_hint(&r);   // issue #147-                    ok(&format!("fields walkthrough {}: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", if clip_file.is_some() { "recorded" } else { "ran, but NO CLIP was recorded (see the WARNING above)" }, issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[page_hint, if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);+                    ok(&format!("fields walkthrough {}: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", if clip_file.is_some() { "recorded" } else if !record { "ran (not recorded: AIFLOW_AUTO_CAPTURE=off)" } else { "ran, but NO CLIP was recorded (see the WARNING above)" }, issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[page_hint, if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into(), on_note]);                 }                 other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),             }@@ -6673,7 +6851,7 @@ fn main() {                 }                 // Recording can be delegated to another host without changing the EDA target.                 // Always settle an existing take above, but never start/focus/capture here when off.-                if std::env::var("AIFLOW_AUTO_CAPTURE").ok().map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "false" | "0")).unwrap_or(false) {+                if auto_capture_off() {                     clip_note.push_str("automatic step capture disabled; record reviewed clips on the chosen capture host");                     r.log("clip-auto-disabled", json!({"step":name,"reason":"AIFLOW_AUTO_CAPTURE"}));                 } else {@@ -6721,13 +6899,14 @@ fn main() {                 let (can_record, rec_notice) = clipwatch::recorder(&mut r, &br);                 if let Some(n) = &rec_notice { clip_note.push_str(&format!("{n}; ")); }                 let disk = if can_record { disk_check(&dir, &br, name) } else { Ok(String::new()) };+                // a clip problem is a note, never a stop: the step is recorded and charged without its clip                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));-                    r.save().unwrap();-                    err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);+                    note(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `capture start`.".into()]);                 }                 match hwnd {                     _ if !can_record => clip_note.push_str(&clipwatch::no_recorder_step(&mut r, &br, &dir, name, hwnd)),+                    _ if disk.is_err() => clip_note.push_str(&format!("no clip for {name}: not enough disk (see the NOTE above)")),                     Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {                         Ok(rid) => {                             // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back@@ -6816,7 +6995,7 @@ fn main() {                 err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);             }             if let Some(open) = step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null)) {-                err(&format!("not delivered: the board twin's STEP export: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);+                note(&format!("the board twin's STEP export has open issues: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);             }             let board_path = r.current_board();             let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));@@ -7018,6 +7197,114 @@ mod media_tools_tests {         assert!(blank_check_verdict("no frames") && !blank_check_verdict("rejected by the AI: covered"));         let _ = std::fs::remove_dir_all(dir);     }++    fn media_fixture(tag: &str, name: &str, args: &[&str]) -> Option<(PathBuf, String)> {+        for t in MEDIA_TOOLS { if std::process::Command::new(t).arg("-version").output().is_err() { return None; } }+        let dir = std::env::temp_dir().join(format!("aiflow-{tag}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        let f = dir.join(name);+        let mut a: Vec<&str> = vec!["-v", "error", "-y"];+        a.extend_from_slice(args);+        let fs = f.display().to_string();+        a.push(&fs);+        assert!(std::process::Command::new("ffmpeg").args(&a).status().unwrap().success(), "fixture {name}");+        Some((dir, fs))+    }++    /// Issue #170 (Josh Aberdeen's patch): a clip flagged blank is kept with its sheet and flag, and never cut.+    #[test]+    fn invalid_blank_video_is_preserved_and_never_encoded() {+        let Some((dir, source)) = media_fixture("blank-noencode", "routing-blank.mp4", &["-f", "lavfi", "-i", "color=c=black:s=96x96:r=2", "-t", "4", "-pix_fmt", "yuv420p"]) else { return };+        let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": source, "step": "routing", "visit": 1}]}) };+        clip_artifacts_with(&mut r, &json!("routing-1"), &source, 10, MEDIA_TOOLS);+        assert!(Path::new(&source).is_file(), "raw evidence destroyed");+        assert!(r.data["captures"][0]["suspect"].as_str().unwrap_or("").contains("flat colour"));+        assert!(!dir.join("routing-blank-10x.mp4").exists() && !dir.join("routing-blank-action.mp4").exists(), "an invalid clip was cut");+        let ledger = r.ledger();+        assert!(ledger.iter().any(|e| e["event"] == "clip-encode-skipped") && ledger.iter().any(|e| e["event"] == "clip-suspect"));+        assert!(ledger.iter().any(|e| e["event"] == "artifact" && e["kind"] == "clip") && !ledger.iter().any(|e| e["event"] == "artifact" && e["kind"] == "clip10x"));+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn invalid_truncated_video_has_no_encoded_derivatives() {+        let Some((dir, _)) = media_fixture("truncated-noencode", "x.mp4", &["-f", "lavfi", "-i", "color=c=black:s=16x16:r=1", "-t", "1"]) else { return };+        let f = dir.join("routing-truncated.mp4");+        std::fs::write(&f, b"not a valid MP4").unwrap();+        let source = f.display().to_string();+        let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": source, "step": "routing", "visit": 1}]}) };+        clip_artifacts_with(&mut r, &json!("routing-1"), &source, 10, MEDIA_TOOLS);+        assert!(f.is_file() && !dir.join("routing-truncated-10x.mp4").exists());+        assert!(r.ledger().iter().any(|e| e["event"] == "clip-encode-skipped"));+        let _ = std::fs::remove_dir_all(dir);+    }++    /// Changing footage still gets its cuts.+    #[test]+    fn healthy_motion_clip_still_generates_video_derivatives() {+        let Some((dir, source)) = media_fixture("motion-kept", "routing-motion.mp4", &["-f", "lavfi", "-i", "testsrc2=size=128x128:rate=10", "-t", "6", "-pix_fmt", "yuv420p"]) else { return };+        let mut r = Run { dir: dir.clone(), data: json!({"captures": [{"file": source, "step": "routing", "visit": 1}]}) };+        clip_artifacts_with(&mut r, &json!("routing-1"), &source, 10, MEDIA_TOOLS);+        let ledger = r.ledger();+        assert!(!ledger.iter().any(|e| e["event"] == "clip-encode-skipped"));+        assert!(dir.join("routing-motion-10x.mp4").is_file() && ledger.iter().any(|e| e["event"] == "artifact" && e["kind"] == "clip10x"));+        let _ = std::fs::remove_dir_all(dir);+    }++    /// Issue #181 (Drew Owens): an idle window's clip has its only frames at the head and a longer container;+    /// a seek per sample read nothing ("could not read frames"). One decode pass reads its painted frames.+    #[test]+    fn an_idle_window_clip_with_its_frames_at_the_head_is_judged() {+        // two painted frames, then 1.3 s of container (a longer silent track) with no video frame in it+        let Some((dir, source)) = media_fixture("idle-head", "silkscreen-1.mp4", &["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=0.066", "-f", "lavfi", "-i", "anullsrc=r=8000:cl=mono", "-t", "1.3", "-c:a", "aac", "-pix_fmt", "yuv420p"]) else { return };+        assert!(media_seconds(&source) > 1.0);+        let frames = sample_frames(&source, media_seconds(&source), 8, 192, 108);+        assert!(!frames.is_empty(), "the head frames are read");+        assert_eq!(blank_check(&source), None, "a painted idle window is not blank");+        let _ = std::fs::remove_dir_all(dir);+    }++    /// Issue #170/#180: a clip over the deferral size is not checked or cut inside an engineering command.+    #[test]+    fn a_big_clip_waits_for_recut_or_compose() {+        let dir = std::env::temp_dir().join(format!("aiflow-defer-{}", std::process::id()));+        let _ = std::fs::create_dir_all(&dir);+        let f = dir.join("big.mp4");+        std::fs::write(&f, vec![0u8; 2_500_000]).unwrap();+        let fs = f.display().to_string();+        assert_eq!(media_deferred(&fs, false, 1), Some(2));+        assert_eq!(media_deferred(&fs, true, 1), None, "recut and compose do the media");+        assert_eq!(media_deferred(&fs, false, 0), None, "0 never defers");+        assert_eq!(media_deferred(&fs, false, 1024), None);+        let _ = std::fs::remove_dir_all(dir);+    }++    /// Issue #180: AIFLOW_AUTO_CAPTURE=off starts no tour recording (and asks no bridge anything).+    #[test]+    fn a_tour_records_nothing_when_auto_capture_is_off() {+        let dir = std::env::temp_dir().join(format!("aiflow-tour-off-{}", std::process::id()));+        let _ = std::fs::create_dir_all(&dir);+        let mut r = Run { dir: dir.clone(), data: json!({"captures": [], "currentStep": "fields"}) };+        let br = Bridge { ai_thread: "test".into(), target: "aiflow-no-such-box".into() };+        std::env::set_var("AIFLOW_AUTO_CAPTURE", "off");+        assert!(auto_capture_off());+        assert_eq!(tour_record_start(&mut r, &br, 1, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS, TOUR_CLIP_MAX_MB), None);+        std::env::remove_var("AIFLOW_AUTO_CAPTURE");+        assert!(r.ledger().iter().any(|e| e["event"] == "clip-auto-disabled"));+        assert!(FIELDS_TOUR_MAX_MS < FIELDS_CLIP_MAX_MS, "the tour ends before the take's own cap");+        let _ = std::fs::remove_dir_all(dir);+    }++    /// Issue #180: every tour's child process has a stop condition.+    #[test]+    fn a_tour_child_is_stopped_at_its_limit() {+        assert_eq!(run_bounded(&mut std::process::Command::new("true"), std::time::Duration::from_secs(5)), Ok(true));+        let t0 = std::time::Instant::now();+        let e = run_bounded(std::process::Command::new("sleep").arg("20"), std::time::Duration::from_millis(400)).unwrap_err();+        assert!(e.contains("stopped") && t0.elapsed().as_secs() < 5, "{e}");+        assert!(run_bounded(&mut std::process::Command::new("aiflow-no-such-tool"), std::time::Duration::from_secs(1)).is_err());+    } }  #[cfg(test)]
crates/adom-aiflow/src/preboard.rs+77−4
@@ -110,6 +110,9 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri     let c_stock_dated = header.iter().position(|h| h.to_ascii_lowercase().contains("stock") && date_in(h).is_some());     let c_stock = c_stock_dated.or_else(|| col(header, &["stock", "stock_qty", "in_stock", "stock_checked"]));     let c_stock_date = col(header, &["stock_date", "stock_checked_on", "checked", "checked_on", "stock_checked_date"]);+    // a part that ships with the board but is not on it (a battery on a cable, a case part): placement `off-board`.+    // It is sourced like any row (MPN, source, dated stock) and is left out of the board diff.+    let c_place = col(header, &["placement", "fitted", "mount", "mounting", "location"]);     let header_date = c_stock_dated.and_then(|i| date_in(&header[i]));     for (name, c) in [("ref", c_ref), ("mpn", c_mpn), ("source", c_src)] {         if c.is_none() { errors.push(format!("the BOM has no {name} column (header: {})", header.join(","))); }@@ -127,6 +130,7 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri     // that fab prefers. Anything else is a consigned (customer-supplied) part the user sends the fab,     // which every fab accepts. Never an error.     let mut consigned: Vec<Value> = Vec::new();+    let mut off_board: BTreeSet<String> = BTreeSet::new();     for (i, row) in rows.iter().enumerate().skip(1) {         let line = i + 1;         let refs: Vec<String> = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();@@ -135,6 +139,7 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri         for rf in &refs {             if bom_refs.insert(rf.clone(), line).is_some() { errors.push(format!("{rf}: listed twice in the BOM")); }         }+        if is_off_board(&get(row, c_place)) { off_board.extend(refs.iter().cloned()); }         let mpn = get(row, c_mpn);         let src = get(row, c_src);         let src_l = src.to_ascii_lowercase();@@ -189,7 +194,9 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri     let (mut missing, mut extra) = (Vec::new(), Vec::new());     if let Some((need, excluded)) = board {         for rf in need { if !bom_refs.contains_key(rf) { missing.push(rf.clone()); } }-        for rf in bom_refs.keys() { if !need.contains(rf) && !excluded.contains(rf) { extra.push(rf.clone()); } }+        for rf in bom_refs.keys() { if !need.contains(rf) && !excluded.contains(rf) && !off_board.contains(rf) { extra.push(rf.clone()); } }+        let on_board: Vec<String> = off_board.iter().filter(|rf| need.contains(*rf) || excluded.contains(*rf)).cloned().collect();+        if !on_board.is_empty() { errors.push(format!("marked off-board in the BOM but a footprint on the board: {}", on_board.join(", "))); }         if !missing.is_empty() { errors.push(format!("on the board but not in the BOM: {}", missing.join(", "))); }         if !extra.is_empty() { errors.push(format!("in the BOM but not an electrical part on the board: {}", extra.join(", "))); }     }@@ -198,11 +205,17 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri         "noPartRows": no_part_rows, "adomStockRows": adom_rows, "distributorRows": distributor_rows, "lcscRows": lcsc_rows,         "stockCheckDates": dates, "boardChecked": board.is_some(),         "boardRefs": board.map(|(n, _)| n.len()), "excludedByBoard": board.map(|(_, x)| x.iter().cloned().collect::<Vec<_>>()),-        "missing": missing, "extra": extra, "errors": errors, "warnings": warnings,+        "missing": missing, "extra": extra, "offBoard": off_board, "errors": errors, "warnings": warnings,         "consignedRows": {"count": consigned.len(), "list": consigned, "warning": consigned_warning},     }) } +/// A BOM placement value that says the part ships with the board but is not on it.+fn is_off_board(v: &str) -> bool {+    let v = v.trim().to_ascii_lowercase().replace(['_', ' '], "-");+    matches!(v.as_str(), "off-board" | "offboard" | "off-pcb" | "loose" | "cable" | "enclosure")+}+ /// The source a profile prefers, by name: where the part suggestions look. pub fn preferred_source(profile: &str) -> &'static str {     if profile == "jlcpcb" { "LCSC" } else { "Mouser" }@@ -494,7 +507,8 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str, page:         let mut b = vec![format!("### Clip {n}: {title}"), String::new(), format!("<a id=\"clip-{n}\"></a>"), String::new()];         let mut why = c["why"].as_str().map(|w| format!(" Back because: {w}.")).unwrap_or_default();         if let Some(g) = c["resumedAfterGap"].as_object() { why.push_str(&format!(" Resumed after a gap from {} to {}: {}.", when(g.get("from").and_then(|v| v.as_str()).unwrap_or("")), when(g.get("to").and_then(|v| v.as_str()).unwrap_or("")), g.get("why").and_then(|v| v.as_str()).unwrap_or("the take died"))); }-        if let Some(cam) = c["camera"].as_str() { why.push_str(&format!(" Filmed: {cam}.")); }+        // issue #167: the camera names a window by its title, and KiCad writes its titles with an em-dash+        if let Some(cam) = c["camera"].as_str() { why.push_str(&format!(" Filmed: {}.", cam.replace('\u{2014}', "-"))); }         b.push(format!("Step `{step}`, visit {visit}. Recorded {}{offset}{secs}.{why}", when(start)));         b.push(String::new());         if let Some(s) = note["shows"].as_str().filter(|s| !s.is_empty()) { b.push(s.to_string()); b.push(String::new()); }@@ -595,6 +609,23 @@ mod tests {         assert_eq!(rows, vec![vec!["a", "b"], vec!["C6 C7", "x, \"y\""]]);     } +    #[test]+    fn off_board_rows_are_sourced_but_left_out_of_the_board_diff() {+        let bom = "ref,mpn,source,vendor_pn,stock_checked_2026-11-07,placement\nU1,TPS,Mouser,595-TPS,5000,\nBT1,1578,Mouser,485-1578,435,off-board\n";+        let out = sourcing_check(bom, Some(&refs(&["U1"], &[])), "fab", 10);+        assert_eq!(out["pass"], true, "{out}");+        assert_eq!(out["offBoard"][0], "BT1");+        // without the placement it is an extra reference+        let out = sourcing_check(&bom.replace(",off-board", ","), Some(&refs(&["U1"], &[])), "fab", 10);+        assert!(out["errors"].to_string().contains("not an electrical part on the board: BT1"), "{out}");+        // marked off-board but on the board+        let out = sourcing_check(bom, Some(&refs(&["U1", "BT1"], &[])), "fab", 10);+        assert!(out["errors"].to_string().contains("marked off-board in the BOM but a footprint on the board: BT1"), "{out}");+        // an off-board row still needs its stock check+        let out = sourcing_check(&bom.replace("485-1578,435", "485-1578,"), Some(&refs(&["U1"], &[])), "fab", 10);+        assert!(out["errors"].to_string().contains("BT1 (1578): no stock check recorded"), "{out}");+    }+     #[test]     fn fab_profile_passes_mouser_and_adom_and_diffs_the_board() {         let bom = format!("{HEAD}U1,x,TPS54202DDCR,TI,Mouser,595-TPS54202DDCR,160 (lead 140 d),\n\"R1 R2\",10k,CR0402,Bourns,Adom stock,,,\n");@@ -841,15 +872,44 @@ pub fn bom_parts(text: &str) -> Vec<Value> {     let c_mpn = col(header, &["mpn", "mfr_part", "mfr_pn", "manufacturer_part_number", "part_number", "mfr_part_number"]);     let c_mfr = col(header, &["manufacturer", "mfr", "maker", "brand"]);     let c_val = col(header, &["value", "val", "description", "desc"]);+    // issue #178: a row may name its component page (`wiki`/`page` column), as the netlist `page` field does+    let c_page = col(header, &["wiki", "page", "wiki_page", "component_page"]);     let get = |row: &Vec<String>, c: Option<usize>| c.and_then(|i| row.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();     rows.iter().skip(1).filter_map(|row| {         let mpn = get(row, c_mpn);         if mpn.is_empty() || mpn.eq_ignore_ascii_case("no part") { return None; }         let refs: Vec<String> = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();-        Some(json!({"refs": refs, "mpn": mpn, "manufacturer": get(row, c_mfr), "value": get(row, c_val)}))+        let mut v = json!({"refs": refs, "mpn": mpn, "manufacturer": get(row, c_mfr), "value": get(row, c_val)});+        if let Some(p) = page_ref(&get(row, c_page)) { v["page"] = json!(p); }+        Some(v)     }).collect() } +/// A component page named in a BOM cell: "owner/slug" or a wiki.adom.inc URL of it; None for anything else.+pub fn page_ref(cell: &str) -> Option<String> {+    let s = cell.trim().trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("wiki.adom.inc/").trim_matches('/');+    let mut it = s.split('/');+    let (o, sl) = (it.next()?, it.next()?);+    let ok = |x: &str| !x.is_empty() && x.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));+    (it.next().is_none() && ok(o) && ok(sl)).then(|| format!("{}/{}", o.to_ascii_lowercase(), sl.to_ascii_lowercase()))+}++/// Issue #178: hardware hits that may be the MPN's page under another slug (adom/adom-1317 for MPN 1317):+/// a slug with the MPN's slug form as a whole dash-separated part. Their page.json `component.mpn` decides.+pub fn mpn_page_candidates(hits: &[Value], mpn: &str) -> Vec<String> {+    let w = wiki_slug(mpn);+    if w.is_empty() { return Vec::new(); }+    let mut out: Vec<String> = Vec::new();+    for h in hits.iter().filter(|h| h["type"] == "hardware") {+        let Some(slug) = h["slug"].as_str().map(str::to_lowercase).or_else(|| h["ref"].as_str().and_then(|r| r.split('/').nth(1)).map(str::to_lowercase)) else { continue };+        let whole = slug == w || slug.starts_with(&format!("{w}-")) || slug.ends_with(&format!("-{w}")) || slug.contains(&format!("-{w}-"));+        let r = h["ref"].as_str().map(str::to_string).or_else(|| Some(format!("{}/{}", h["owner"].as_str()?, slug)));+        if let Some(r) = r.filter(|r| whole && !out.contains(r)) { out.push(r); }+    }+    out.truncate(4);+    out+}+ /// The wiki's slug for a title or MPN: lowercase, every run of characters outside [a-z0-9] one "-", /// no "-" at either end (adom/ap2112k-3-3trg1 for AP2112K-3.3TRG1, adom/ap2112k-1-2trg1 for /// AP2112K-1.2TRG1). Some older pages keep a "." (adom/ap2112k-3.3), so the lowercase MPN stays a candidate.@@ -964,6 +1024,19 @@ mod bom_parts_tests {         assert_eq!(p[0]["refs"], json!(["C1", "C2"]));         assert_eq!(p[1]["mpn"], "TPS54202DDCR");         assert_eq!(p[1]["manufacturer"], "TI");+        assert!(p[0].get("page").is_none());+    }++    #[test]+    fn a_bom_row_can_name_its_component_page() {+        // issue #178: the off-board battery, MPN 1317, lives at adom/adom-1317+        let p = bom_parts("ref,mpn,placement,wiki\nBT1,1317,off-board,adom/adom-1317\nU1,TPS,,https://wiki.adom.inc/adom/TPS54202DDCR/\nU2,X,,not a page\n");+        assert_eq!(p[0]["page"], "adom/adom-1317");+        assert_eq!(p[1]["page"], "adom/tps54202ddcr");+        assert!(p[2].get("page").is_none());+        assert_eq!(page_ref("adom/a/b"), None);+        let hits = vec![json!({"type": "hardware", "owner": "adom", "slug": "adom-1317"}), json!({"type": "hardware", "owner": "x", "slug": "a13170"}), json!({"type": "app", "owner": "adom", "slug": "1317-app"})];+        assert_eq!(mpn_page_candidates(&hits, "1317"), vec!["adom/adom-1317".to_string()]);     }      #[test]
crates/adom-aiflow/src/programming.rs+89−15
@@ -24,6 +24,8 @@ pub fn required(interface: &str) -> Option<&'static [&'static str]> {         "swd" => Some(&["SWDIO", "SWCLK", "NRST", "GND"]),         "uart-boot" => Some(&["TX", "RX", "GND"]),         "usb-dfu" => Some(&["D+|DP|USB_DP", "D-|DM|USB_DM", "GND"]),+        // AVR 0/1/2-series (ATtiny, AVR-DA/DB/DD) and megaAVR 0: one-wire UPDI plus GND.+        "updi" => Some(&["UPDI", "GND"]),         _ => None,     } }@@ -111,7 +113,7 @@ fn cable_usb_check(b: &Board, rec: &Value) -> Result<(), Vec<String>> {     if problems.is_empty() { Ok(()) } else { Err(problems) } } -/// The finish gate: Ok when the board has no programmable part or its record is complete and every pin+/// The finish check (an open item, never a refusal): Ok when the board has no programmable part or its record is complete and every pin /// is reachable; Err(problems) otherwise. pub fn check(board_path: &str, b: &Board, spec: &Value) -> Result<(), Vec<String>> {     let t = targets(b);@@ -155,13 +157,26 @@ fn check_list(b: &Board, t: &[(String, String)], list: &[Value]) -> Result<(), V     if p.is_empty() { Ok(()) } else { Err(p) } } -/// A Tag-Connect footprint (TC2030 / TC2050) is a pad set made for a spring-pin cable, as reachable as a USB-/// connector for a cable: the declared connector pads must carry the SWD nets of the part it programs.+/// The part a cable record programs: `targetRef`, else (issue #173) the programmable part whose number is+/// `target`, else the board's only programmable part.+fn target_ref_of(b: &Board, rec: &Value) -> String {+    if let Some(r) = rec["targetRef"].as_str() { return r.to_string(); }+    let t = targets(b);+    t.iter().find(|(_, m)| Some(m.as_str()) == rec["target"].as_str()).or(if t.len() == 1 { t.first() } else { None }).map(|(r, _)| r.clone()).unwrap_or_default()+}++/// A cable landing: a Tag-Connect footprint (TC2030 / TC2050, `"access": "tag-connect"`) or a cable header+/// (issue #173: an STDC14 / ARM-10 SWD header, a UART or UPDI header, `"access": "cable"` or `"header"`) is+/// as reachable as a USB connector for a cable: the declared connector pads must carry the interface's nets+/// and those nets must reach the part it programs. fn tag_connect_check(b: &Board, rec: &Value) -> Result<(), Vec<String>> {     let mut problems = Vec::new();     let connector = rec["connector"].as_str().unwrap_or("");-    let target_ref = rec["targetRef"].as_str().unwrap_or("");-    // the evidence is the declared pads carrying the SWD nets into the part (below), not the footprint's+    let target_ref = target_ref_of(b, rec);+    let target_ref = target_ref.as_str();+    let iface = rec["interface"].as_str().unwrap_or("");+    let Some(req) = required(iface) else { return Err(vec![format!("interface {iface:?} is not swd, uart-boot, usb-dfu or updi")]) };+    // the evidence is the declared pads carrying the nets into the part (below), not the footprint's     // name: a project-library pogo-pin pad set is as valid a cable landing as a TC2030 (as for usb-cable)     if !b.footprints.contains_key(connector) || connector == target_ref {         problems.push(format!("connector {connector} must be the board's cable footprint (not the target chip)"));@@ -169,36 +184,65 @@ fn tag_connect_check(b: &Board, rec: &Value) -> Result<(), Vec<String>> {     if !targets(b).iter().any(|(r, m)| r == target_ref && Some(m.as_str()) == rec["target"].as_str()) {         problems.push("targetRef/target must match a programmable part on the board".into());     }+    let cp = rec["connectorPins"].as_object().cloned().unwrap_or_default();     let mut nets = Vec::new();-    for signal in ["SWDIO", "SWCLK", "NRST", "GND"] {-        let pin = &rec["connectorPins"][signal];+    for need in req {+        let alias: Vec<&str> = need.split('|').collect();+        let signal = alias[0];+        let pin = cp.iter().find(|(k, _)| alias.iter().any(|a| a.eq_ignore_ascii_case(k))).map(|(_, v)| v.clone()).unwrap_or(Value::Null);         // NRST is optional: plenty of SWD hookups leave reset unwired; when declared it is checked         if signal == "NRST" && pin.is_null() { continue; }-        let net = pin["net"].as_str().unwrap_or("");+        let net = pin["net"].as_str().unwrap_or("").to_string();         let names = pin["pads"].as_array();         if net.is_empty() || names.map_or(true, |a| a.is_empty()) { problems.push(format!("connectorPins.{signal} needs nonempty pads and net")); continue; }         for name in names.unwrap() {             let pad = name.as_str().unwrap_or("");             if !b.pads.iter().any(|p| p.reference == connector && p.name == pad && p.net == net) { problems.push(format!("{signal}: {connector}.{pad} does not carry declared net {net}")); }         }-        // the SWD nets must reach the part it programs (GND aside)+        // the interface's nets must reach the part it programs (GND aside)         if signal != "GND" && !b.pads.iter().any(|p| p.reference == target_ref && p.net == net) { problems.push(format!("{signal}: net {net} does not reach {target_ref}")); }         nets.push(net);     }     let declared = nets.len();     nets.sort(); nets.dedup();-    if nets.len() != declared { problems.push("SWDIO, SWCLK, GND (and NRST when declared) must be distinct nets".into()); }+    if nets.len() != declared { problems.push(format!("{} must be distinct nets", req.iter().map(|n| n.split('|').next().unwrap_or(n)).collect::<Vec<_>>().join(", "))); }     if problems.is_empty() { Ok(()) } else { Err(problems) } } +/// Issue #173: `pins` written as connector pads (`"SWDIO": "J7.4"`) all on one footprint are a cable+/// header: the record in the `connector` / `connectorPins` form, each pad's net read from the board.+fn pads_as_header(b: &Board, rec: &Value) -> Option<Value> {+    let pins = rec["pins"].as_object()?;+    let mut connector: Option<&str> = None;+    let mut cp = serde_json::Map::new();+    for (signal, at) in pins {+        let (r, pad) = at.as_str()?.split_once('.')?;+        if b.footprints.contains_key(at.as_str()?) || connector.map_or(false, |c| c != r) { return None; }+        let net = b.pads.iter().find(|p| p.reference == r && p.name == pad).map(|p| p.net.clone())?;+        connector = Some(r);+        let e = cp.entry(signal.clone()).or_insert_with(|| serde_json::json!({"pads": [], "net": net}));+        e["pads"].as_array_mut()?.push(Value::String(pad.to_string()));+    }+    let mut out = rec.clone();+    let o = out.as_object_mut()?;+    o.remove("pins");+    o.insert("connector".into(), Value::String(connector?.to_string()));+    o.insert("connectorPins".into(), Value::Object(cp));+    Some(out)+}+ fn check_one(b: &Board, rec: &Value) -> Result<(), Vec<String>> {     if rec["interface"] == "usb-serial-jtag" && rec["access"] == "usb-cable" { return cable_usb_check(b, rec); }-    if rec["interface"] == "swd" && rec["access"] == "tag-connect" { return tag_connect_check(b, rec); }+    if rec["interface"] != "usb-serial-jtag" && matches!(rec["access"].as_str(), Some("tag-connect" | "cable" | "header")) {+        if rec["connectorPins"].is_null() { if let Some(h) = pads_as_header(b, rec) { return tag_connect_check(b, &h); } }+        return tag_connect_check(b, rec);+    }+    if let Some(h) = pads_as_header(b, rec) { return tag_connect_check(b, &h); }     let mut p = Vec::new();     let iface = rec["interface"].as_str().unwrap_or("");     let pins = rec["pins"].as_object().cloned().unwrap_or_default();     match required(iface) {-        None => p.push(format!("interface {iface:?} is not swd, uart-boot or usb-dfu")),+        None => p.push(format!("interface {iface:?} is not swd, uart-boot, usb-dfu or updi")),         Some(req) => for need in req {             let alias: Vec<&str> = need.split('|').collect();             if !pins.keys().any(|k| alias.iter().any(|a| a.eq_ignore_ascii_case(k))) { p.push(format!("pins.{} missing", alias[0])); }@@ -226,7 +270,7 @@ pub fn summary(board_path: &str, b: &Board, spec: &Value, result: &Result<(), Ve }  pub fn gate_message(problems: &[String]) -> String {-    format!("programming: {} (bring the programming interface out to machine contacts or labelled test pads and record `programming` in board-release.json; skill aiflow-molecule, Programmable boards)", problems.join("; "))+    format!("programming: {} (bring the programming interface out to machine contacts, labelled test pads or a cable header, and record `programming` in board-release.json; a cable header (STDC14, ARM-10, Tag-Connect, a UART or UPDI header) is `\"access\": \"cable\", \"connector\": \"J7\", \"connectorPins\": {{\"SWDIO\": {{\"pads\": [\"4\"], \"net\": \"SWDIO\"}}, ...}}`; skill aiflow-molecule, Programmable boards)", problems.join("; ")) }  /// The schematic/placement hint for a board with a programmable part.@@ -235,7 +279,7 @@ pub fn hint(b: &Board, molecule: bool) -> Option<String> {     if t.is_empty() { return None; }     let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", ");     let reach = if molecule { "the board's machine contacts (the workcell's pins)" } else { "pads or test points with silkscreen labels (this board is not a molecule on machine pins)" };-    Some(format!("Programmable part(s): {names}. Bring the programming interface out to {reach} so the workcell's debug probe can flash it with no hand wiring: SWD = SWDIO, SWCLK, NRST and GND (plus a VTref/3V3 sense if the target needs one); or UART boot (TX, RX, GND, with BOOT0 set); or USB DFU (D+, D-, GND). Record it as `programming` in board-release.json: {{\"interface\":\"swd|uart-boot|usb-dfu\",\"pins\":{{\"SWDIO\":\"J8\",\"SWCLK\":\"J9\",\"NRST\":\"J10\",\"GND\":\"J7\"}},\"target\":\"<MCU MPN>\",\"boot\":\"<e.g. BOOT0 low via R2; JP1 bridges for DFU>\"}}. `finish` refuses the board without it, or when a pin is not on a machine contact or a labelled test pad."))+    Some(format!("Programmable part(s): {names}. Bring the programming interface out to {reach} so the workcell's debug probe can flash it with no hand wiring: SWD = SWDIO, SWCLK, NRST and GND (plus a VTref/3V3 sense if the target needs one); or UART boot (TX, RX, GND, with BOOT0 set); or USB DFU (D+, D-, GND). Record it as `programming` in board-release.json: {{\"interface\":\"swd|uart-boot|usb-dfu\",\"pins\":{{\"SWDIO\":\"J8\",\"SWCLK\":\"J9\",\"NRST\":\"J10\",\"GND\":\"J7\"}},\"target\":\"<MCU MPN>\",\"boot\":\"<e.g. BOOT0 low via R2; JP1 bridges for DFU>\"}}. A cable header (STDC14 / ARM-10 SWD, Tag-Connect, a UART or UPDI header) is `\"access\":\"cable\",\"connector\":\"J7\",\"connectorPins\":{{\"SWDIO\":{{\"pads\":[\"4\"],\"net\":\"SWDIO\"}},...}}` (or `\"pins\":{{\"SWDIO\":\"J7.4\",...}}`). `finish` lists it as an open item when the record is missing, a required pin is missing, or a pin is not reachable.")) }  #[cfg(test)]@@ -308,6 +352,36 @@ mod tests {         assert!(check("t.kicad_pcb", &b, &json!({"programming": [no_rst]})).unwrap_err().iter().all(|e| e.contains("U2")));     } +    #[test]+    fn an_swd_cable_header_is_a_programming_landing() {+        // issue #173: an STDC14 header (Samtec FTSH-107) for the ST-LINK V3 cable on a board, not a molecule+        let txt = TC_BOARD.replace("Connector:Tag-Connect_TC2030-IDC-NL_2x03_P1.27mm_Vertical", "Connector_Samtec:Samtec_FTSH-107-01-L-DV-K_2x07_P1.27mm")+            .replace("(pad \"2\" connect circle (at 0 1.27) (size 0.8 0.8) (layers \"F.Cu\") (net 1 \"SWDIO1\"))", "(pad \"4\" smd rect (at 0 1.27) (size 0.8 0.8) (layers \"F.Cu\") (net 1 \"SWDIO1\"))")+            .replace("(pad \"4\" connect circle (at 1.27 1.27) (size 0.8 0.8) (layers \"F.Cu\") (net 2 \"SWCLK1\"))", "(pad \"6\" smd rect (at 1.27 1.27) (size 0.8 0.8) (layers \"F.Cu\") (net 2 \"SWCLK1\"))")+            .replace("(pad \"3\" connect circle (at 1.27 0) (size 0.8 0.8) (layers \"F.Cu\") (net 3 \"NRST1\"))", "(pad \"12\" smd rect (at 1.27 0) (size 0.8 0.8) (layers \"F.Cu\") (net 3 \"NRST1\"))")+            .replace("(pad \"5\" connect circle (at 2.54 0) (size 0.8 0.8) (layers \"F.Cu\") (net 4 \"GND\"))", "(pad \"5\" smd rect (at 2.54 0) (size 0.8 0.8) (layers \"F.Cu\") (net 4 \"GND\")) (pad \"7\" smd rect (at 2.54 1) (size 0.8 0.8) (layers \"F.Cu\") (net 4 \"GND\"))")+            // one programmable part, so the record need not name targetRef+            .replace("(property \"Value\" \"STM32G474CBT6\" (at 0 5) (layer \"F.Fab\"))\n    (pad \"34\" smd rect (at -3 0) (size 1 0.3) (layers \"F.Cu\") (net 6 \"SWDIO2\"))", "(property \"Value\" \"AP2112K-3.3\" (at 0 5) (layer \"F.Fab\"))");+        let b = Board::from_text("/nonexistent/b.kicad_pcb", txt).unwrap();+        assert_eq!(targets(&b).len(), 1);+        let mut rec = json!({"interface": "swd", "access": "cable", "connector": "J7", "target": "STM32G474CBT6",+            "connectorPins": {"SWDIO": {"pads": ["4"], "net": "SWDIO1"}, "SWCLK": {"pads": ["6"], "net": "SWCLK1"}, "NRST": {"pads": ["12"], "net": "NRST1"}, "GND": {"pads": ["5", "7"], "net": "GND"}}});+        assert_eq!(check("/nonexistent/b.kicad_pcb", &b, &json!({"programming": rec.clone()})), Ok(()));+        rec["access"] = json!("header");+        assert_eq!(check("/nonexistent/b.kicad_pcb", &b, &json!({"programming": rec.clone()})), Ok(()));+        // a pad that does not carry its net is named+        rec["connectorPins"]["SWCLK"]["pads"] = json!(["12"]);+        assert!(check("/nonexistent/b.kicad_pcb", &b, &json!({"programming": rec})).unwrap_err().iter().any(|e| e.contains("J7.12 does not carry declared net SWCLK1")));+        // the pins form written as connector pads reads the same header+        let pins = json!({"programming": {"interface": "swd", "target": "STM32G474CBT6", "pins": {"SWDIO": "J7.4", "SWCLK": "J7.6", "NRST": "J7.12", "GND": "J7.5"}}});+        assert_eq!(check("/nonexistent/b.kicad_pcb", &b, &pins), Ok(()));+        let mut wrong = pins.clone(); wrong["programming"]["pins"]["SWCLK"] = json!("J7.5");+        assert!(check("/nonexistent/b.kicad_pcb", &b, &wrong).unwrap_err().iter().any(|e| e.contains("SWCLK: net GND does not reach U1")));+        // a UART boot header needs TX, RX and GND+        let uart = json!({"programming": {"interface": "uart-boot", "access": "cable", "connector": "J7", "target": "STM32G474CBT6", "connectorPins": {"TX": {"pads": ["4"], "net": "SWDIO1"}, "GND": {"pads": ["5"], "net": "GND"}}}});+        assert!(check("/nonexistent/b.kicad_pcb", &b, &uart).unwrap_err().iter().any(|e| e.contains("connectorPins.RX")));+    }+     #[test]     fn programmable_boards_bring_their_interface_to_the_workcell() {         let b = Board::from_text("/nonexistent/b.kicad_pcb", BOARD.to_string()).unwrap();@@ -332,7 +406,7 @@ mod tests {         spec["programming"]["pins"].as_object_mut().unwrap().remove("GND");         assert!(check("/nonexistent/b.kicad_pcb", &b2, &spec).unwrap_err().contains(&"pins.GND missing".to_string()));         spec["programming"]["interface"] = json!("jtag");-        assert!(gate_message(&check("/nonexistent/b.kicad_pcb", &b2, &spec).unwrap_err()).starts_with("programming: interface \"jtag\" is not swd, uart-boot or usb-dfu"));+        assert!(gate_message(&check("/nonexistent/b.kicad_pcb", &b2, &spec).unwrap_err()).starts_with("programming: interface \"jtag\" is not swd, uart-boot, usb-dfu or updi"));     }      #[test]
crates/adom-aiflow/src/project_lib.rs+2−1
@@ -134,7 +134,8 @@ fn prefers_lib(e: &Value) -> bool { /// other overlap is named, never silently dropped: the page is used and the note says how to switch. pub fn prefer(rows: &mut [Value], lib: &ProjectLib) -> Vec<String> {     let mut notes = Vec::new();-    for row in rows.iter_mut().filter(|r| r["wiki"].is_string()) {+    // a page the BOM names (issue #178) wins, as the netlist `page` does+    for row in rows.iter_mut().filter(|r| r["wiki"].is_string() && r["pageNamed"] != true) {         let mpn = row["mpn"].as_str().unwrap_or("").to_string();         let page = row["wiki"].as_str().unwrap_or("").to_string();         let Some(e) = lib.entries.get(&key(&mpn)) else { continue };
crates/adom-aiflow/src/sequence.rs+49
@@ -242,6 +242,55 @@ pub fn blockers(st: &[StepState], target: &str) -> Vec<String> {  pub fn position(seq: &[String], name: &str) -> Option<usize> { seq.iter().position(|s| s == name) } +/// Issue #171: the step whose work fixes an open finish item (a line as finish prints it), when that is+/// clear from the item: placement for a placement or switch-node finding, routing for DRC and unconnected,+/// and so on. None when the item is not a board step's work (evidence, sourcing, a publish).+pub fn owning_step(item: &str) -> Option<String> {+    let l = item.trim().to_ascii_lowercase();+    if let Some(rest) = l.strip_prefix("step ") { return rest.split([' ', ':', '(']).next().filter(|s| !s.is_empty()).map(str::to_string); }+    const MAP: &[(&str, &str)] = &[+        ("place verify", "placement"), ("placement intent", "placement"), ("decoupling", "placement"), ("switch node", "placement"),+        ("gate: courtyards", "placement"), ("mechanical", "placement"),+        ("gate: ", "routing"), ("gate", "routing"), ("land route", "routing"), ("live validate", "routing"), ("native drc: the live full drc has", "routing"),+        ("heatsink vias", "thermal-vias"), ("land pours", "pours"), ("pours in the saved board", "pours"), ("outline", "outline"),+        ("analyze-current", "current"), ("analyze-thermal", "thermal"), ("fields", "fields"), ("silkscreen", "silkscreen"),+    ];+    MAP.iter().find(|(p, _)| l.starts_with(p)).map(|(_, s)| s.to_string())+}++/// Issue #171: the earliest step in the flow that the open items send the run back to, as (step, item),+/// when it comes before `current` (the run is past it). A suggestion only: the AI declares the return.+pub fn earliest_return(seq: &[String], current: &str, items: &[String]) -> Option<(String, String)> {+    let cur = position(seq, current).unwrap_or(seq.len());+    items.iter().filter_map(|i| owning_step(i).and_then(|s| position(seq, &s).map(|p| (p, s, i.clone()))))+        .filter(|(p, _, _)| *p < cur).min_by_key(|(p, _, _)| *p).map(|(_, s, i)| (s, i))+}++#[cfg(test)]+mod return_tests {+    #[test]+    fn open_items_name_the_earliest_step_to_return_to() {+        let seq: Vec<String> = ["intake", "placement", "routing", "thermal-vias", "pours", "outline", "current", "finish"].iter().map(|s| s.to_string()).collect();+        let items = vec!["outline: 2 sharp corner(s) (outline round)".to_string(), "switch node: SW loop 40 mm2 (back to placement)".into(), "gate: 0 new DRC errors and 0 unconnected (run gate)".into(), "sourcing check against the board's references".into()];+        assert_eq!(super::earliest_return(&seq, "outline", &items), Some(("placement".to_string(), items[1].clone())));+        // only steps the run is past: at routing, placement is behind it; at intake nothing is+        assert_eq!(super::earliest_return(&seq, "routing", &items).unwrap().0, "placement");+        assert_eq!(super::earliest_return(&seq, "intake", &items), None);+        assert_eq!(super::owning_step("gate: courtyards and part bodies (missing)"), Some("placement".into()));+        assert_eq!(super::owning_step("step pours (redo: an earlier step was reworked after it)"), Some("pours".into()));+        assert_eq!(super::owning_step("heatsink vias: Q1 has 0 of 6"), Some("thermal-vias".into()));+        assert_eq!(super::owning_step("sourcing check on the jlc profile"), None);+        assert!(super::return_suggestion("placement", "switch node: \"x\"").contains("`step placement --back --why \"switch node: 'x'\"`"));+    }+}++/// The suggestion line for `earliest_return`.+pub fn return_suggestion(step: &str, item: &str) -> String {+    let why: String = item.chars().take(120).collect::<String>().replace('"', "'");+    format!("earliest step these open items go back to: {step} ({why}). `step {step} --back --why \"{why}\"`, rework there, then walk the later steps again: they show as redo until revisited (a return that changes neither the board nor the spec clears nothing), and the earlier visits and their time stay on the timeline. Layout work within the spec (placement, rerouting, copper) is yours to do; ask the human only to change a frozen budget or requirement.")+}++ fn title_of(flow: &Value, name: &str) -> String {     let t = flow["titles"][name].as_str().map(str::to_string);     t.unwrap_or_else(|| {
crates/adom-aiflow/src/step_export.rs+104−14
@@ -7,14 +7,15 @@ //! - geometry: the exporter could not build a shape cleanly (self-interference, failed outlines); //! - model: a 3D model was not found or not loaded; //! - other: any other warning or error line.-//! Geometry warnings block the final twin delivery (`board-glb copper` in a run and `deliver`)-//! until someone reviews the exported STEP and records `step-export --ack --why "..."`, which binds-//! to that STEP file's hash. The export always includes every layer; it never drops silkscreen,+//! Geometry warnings and missing 3D models are open issues that `step-export`, `board-glb copper` and+//! `deliver` report (findings, never refusals) until someone reviews the exported STEP and records+//! `step-export --ack --why "..."`, which binds to that STEP file's hash, or fixes them and exports+//! again. The export runs from the source project's folder (issues #175, #177; see `context`). The export always includes every layer; it never drops silkscreen, //! mask or zones to make a warning go away.  use aiflow_board::Board; use serde_json::{json, Value};-use std::path::Path;+use std::path::{Path, PathBuf};  /// The full twin: models, tracks, pads, zones, mask and silkscreen. Never trimmed. pub const LAYER_FLAGS: &[&str] = &["--subst-models", "--include-tracks", "--include-pads", "--include-zones", "--include-soldermask", "--include-silkscreen"];@@ -87,27 +88,77 @@ pub fn record(board_path: &str, step: Option<&Path>, exit_code: Option<i32>, out     }) } -/// Why the twin may not be delivered yet, if it may not.+/// What the twin still lacks, if anything: a failed export, unreviewed geometry warnings, or 3D models+/// the export could not add (issues #175, #177: a STEP with no parts is not a twin). A finding with a+/// suggested next action, never a refusal. pub fn open_issue(rec: &Value) -> Option<String> {     if rec.is_null() { return None; }     if rec["exitCode"].as_i64().map(|c| c != 0).unwrap_or(false) || rec["stepWritten"] == false {         return Some(format!("the native STEP export failed (exit {}, file written {})", rec["exitCode"], rec["stepWritten"]));     }     let g = rec["geometryWarnings"].as_u64().unwrap_or(0);-    if g == 0 { return None; }+    let m = rec["modelWarnings"].as_u64().unwrap_or(0);+    if g == 0 && m == 0 { return None; }     let acked = rec["ack"]["stepSha256"].as_str().is_some() && rec["ack"]["stepSha256"] == rec["stepSha256"];     if acked { return None; }-    Some(format!("{g} STEP geometry warning(s) not reviewed ({}): inspect the exported STEP at those locations, then `step-export --ack --why \"...\"`", rec["diagnostics"].as_array().map(|a| a.iter().filter(|d| d["kind"] == "geometry").take(3).map(|d| format!("{} near {}", d["message"].as_str().unwrap_or(""), d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).collect::<Vec<_>>().join("; ")).unwrap_or_default()))+    let models = if m == 0 { None } else { Some(format!("{m} 3D model(s) missing from the STEP ({}): export from the project folder so ${{KIPRJMOD}} resolves, set KICAD<N>_3RD_PARTY to the folder holding the PCM 3dmodels, then `step-export` again (or `step-export --ack --why \"...\"` if those parts have no model on purpose)", rec["diagnostics"].as_array().map(|a| a.iter().filter(|d| d["kind"] == "model").take(3).filter_map(|d| d["message"].as_str()).collect::<Vec<_>>().join("; ")).unwrap_or_default())) };+    if g == 0 { return models; }+    Some(format!("{g} STEP geometry warning(s) not reviewed ({}): inspect the exported STEP at those locations, then `step-export --ack --why \"...\"`", rec["diagnostics"].as_array().map(|a| a.iter().filter(|d| d["kind"] == "geometry").take(3).map(|d| format!("{} near {}", d["message"].as_str().unwrap_or(""), d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).collect::<Vec<_>>().join("; ")).unwrap_or_default()) + &models.map(|m| format!("; {m}")).unwrap_or_default()) } -/// Run the native export into `out` and record it.-pub fn run(board_path: &str, out: &Path, cli: &str) -> Value {+/// Where kicad-cli exports from (issues #175, #177). KiCad sets ${KIPRJMOD} to the folder of the board it+/// opens, so the run's board-N copy is exported from a temporary copy in the source project's folder,+/// where the project library lives; a `${KICAD<N>_3RD_PARTY}` the environment leaves unset points at a+/// 3rdparty folder with 3dmodels beside the project (kicad/3rdparty or 3rdparty, in the project folder or+/// its parent), else KiCad's own Linux user folder. Each choice is recorded.+pub struct Context { pub board: PathBuf, pub temp: Option<PathBuf>, pub env: Vec<(String, String)>, pub notes: Vec<String> }++pub fn context(board_path: &str, project_dir: &Path) -> Context {+    let mut c = Context { board: PathBuf::from(board_path), temp: None, env: Vec::new(), notes: Vec::new() };+    let text = std::fs::read_to_string(board_path).unwrap_or_default();+    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());+    let here = Path::new(board_path).parent().map(canon).unwrap_or_default();+    if project_dir.is_dir() && canon(project_dir) != here {+        let stem = Path::new(board_path).file_stem().and_then(|s| s.to_str()).unwrap_or("board");+        let tmp = project_dir.join(format!(".aiflow-step-{stem}-{}.kicad_pcb", std::process::id()));+        match std::fs::write(&tmp, &text) {+            Ok(()) => { c.notes.push(format!("exported from the project folder {} (a temporary copy of {board_path}) so ${{KIPRJMOD}} resolves to it", project_dir.display())); c.board = tmp.clone(); c.temp = Some(tmp); }+            Err(e) => c.notes.push(format!("could not place a copy in the project folder {} ({e}): exported from {}, where ${{KIPRJMOD}} paths may not resolve", project_dir.display(), here.display())),+        }+    }+    let mut vars: Vec<String> = Vec::new();+    let mut rest = text.as_str();+    while let Some(i) = rest.find("${KICAD") {+        let tail = &rest[i + 2..];+        if let Some(j) = tail.find('}') { let v = &tail[..j]; if v.ends_with("_3RD_PARTY") && !vars.iter().any(|x| x == v) { vars.push(v.to_string()); } }+        rest = &rest[i + 2..];+    }+    for v in vars {+        if std::env::var(&v).is_ok_and(|x| !x.is_empty()) { continue; }+        let ver = v.trim_start_matches("KICAD").trim_end_matches("_3RD_PARTY");+        let mut cands: Vec<PathBuf> = Vec::new();+        for base in [Some(project_dir), project_dir.parent()].into_iter().flatten() { cands.push(base.join("kicad").join("3rdparty")); cands.push(base.join("3rdparty")); }+        if let Ok(h) = std::env::var("HOME") { cands.push(Path::new(&h).join(".local/share/kicad").join(format!("{ver}.0")).join("3rdparty")); }+        match cands.into_iter().find(|d| d.join("3dmodels").is_dir()) {+            Some(d) => { c.notes.push(format!("{v} was unset: {}", d.display())); c.env.push((v, d.display().to_string())); }+            None => c.notes.push(format!("{v} is unset and no 3rdparty folder with 3dmodels was found beside the project: set it to the folder KiCad's PCM installs into")),+        }+    }+    c+}++/// Run the native export into `out` and record it. `project_dir` is the source board's folder (the+/// project), which ${KIPRJMOD} must resolve to.+pub fn run(board_path: &str, out: &Path, cli: &str, project_dir: &Path) -> Value {     let _ = std::fs::remove_file(out);+    let ctx = context(board_path, project_dir);     let mut args: Vec<String> = vec!["pcb".into(), "export".into(), "step".into()];     args.extend(LAYER_FLAGS.iter().map(|s| s.to_string()));-    args.extend(["--output".to_string(), out.display().to_string(), board_path.to_string()]);-    let command = format!("{cli} {}", args.join(" "));-    match std::process::Command::new(cli).args(&args).output() {+    args.extend(["--output".to_string(), out.display().to_string(), ctx.board.display().to_string()]);+    let command = format!("{}{cli} {}", ctx.env.iter().map(|(k, v)| format!("{k}={v} ")).collect::<String>(), args.join(" "));+    let result = std::process::Command::new(cli).args(&args).envs(ctx.env.iter().map(|(k, v)| (k, v))).output();+    if let Some(t) = &ctx.temp { let _ = std::fs::remove_file(t); }+    let mut rec = match result {         Ok(o) => {             let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));             let mut rec = record(board_path, Some(out), o.status.code(), &text, &command);@@ -115,7 +166,10 @@ pub fn run(board_path: &str, out: &Path, cli: &str) -> Value {             rec         }         Err(e) => { let mut rec = record(board_path, Some(out), None, "", &command); rec["error"] = json!(format!("{cli} is not runnable: {e}")); rec }-    }+    };+    rec["projectDir"] = json!(project_dir.display().to_string());+    rec["exportContext"] = json!(ctx.notes);+    rec }  #[cfg(test)]@@ -171,7 +225,7 @@ mod tests {         std::fs::write(&cli, "#!/bin/sh\nout=\"\"; prev=\"\"; for a in \"$@\"; do [ \"$prev\" = \"--output\" ] && out=\"$a\"; prev=\"$a\"; done\necho \"$@\" > \"$out.args\"\necho 'Wire self-interference check failed'\necho 'z: 1.585; bounding box: x0: 1; y0: 2; x1: 3; y1: 4' >&2\necho STEP > \"$out\"\n").unwrap();         std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();         let out = dir.join("b.step");-        let rec = run(board.to_str().unwrap(), &out, cli.to_str().unwrap());+        let rec = run(board.to_str().unwrap(), &out, cli.to_str().unwrap(), &dir);         assert_eq!(rec["exitCode"], 0);         assert_eq!(rec["stepWritten"], true);         assert_eq!(rec["geometryWarnings"], 1);@@ -179,4 +233,40 @@ mod tests {         for f in LAYER_FLAGS { assert!(args.contains(f), "{args}"); }         let _ = std::fs::remove_dir_all(&dir);     }++    /// Issues #175, #177: the run's board copy is exported from the project folder (${KIPRJMOD}), with an+    /// unset KICAD10_3RD_PARTY pointed at the project's 3rdparty mirror; the temporary copy is removed.+    #[cfg(unix)]+    #[test]+    fn the_run_copy_is_exported_from_the_project_folder() {+        use std::os::unix::fs::PermissionsExt;+        let dir = std::env::temp_dir().join(format!("aiflow-step-proj-{}", std::process::id()));+        let (proj, run_dir) = (dir.join("proj"), dir.join("proj/aiflow/run"));+        std::fs::create_dir_all(&run_dir).unwrap();+        std::fs::create_dir_all(proj.join("kicad/3rdparty/3dmodels")).unwrap();+        let board = run_dir.join("board-29.kicad_pcb");+        std::fs::write(&board, "(kicad_pcb (version 20240108) (footprint \"A\" (model \"${KIPRJMOD}/lib/a.step\")) (footprint \"B\" (model \"${KICAD10_3RD_PARTY}/3dmodels/x/b.step\")))").unwrap();+        let cli = dir.join("kicad-cli");+        std::fs::write(&cli, "#!/bin/sh\nout=\"\"; prev=\"\"; for a in \"$@\"; do [ \"$prev\" = \"--output\" ] && out=\"$a\"; prev=\"$a\"; done\nb=\"$a\"; echo \"board=$b dir=$(dirname \"$b\") tp=$KICAD10_3RD_PARTY\" > \"$out.ctx\"\necho 'Could not add 3D model for A. File not found: x' >&2\necho STEP > \"$out\"\n").unwrap();+        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();+        std::env::remove_var("KICAD10_3RD_PARTY");+        let out = run_dir.join("twin.step");+        let rec = run(board.to_str().unwrap(), &out, cli.to_str().unwrap(), &proj);+        let ctx = std::fs::read_to_string(run_dir.join("twin.step.ctx")).unwrap();+        assert!(ctx.contains(&format!("dir={} ", proj.display())), "{ctx}");+        assert!(ctx.contains(&format!("tp={}", proj.join("kicad/3rdparty").display())), "{ctx}");+        assert!(std::fs::read_dir(&proj).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().starts_with(".aiflow-step")), "temporary copy left behind");+        assert_eq!(rec["board"], board.to_str().unwrap());+        assert_eq!(rec["modelWarnings"], 1);+        // a missing model is an open issue (a finding), until fixed or acknowledged for this STEP+        let open = open_issue(&rec).unwrap();+        assert!(open.contains("1 3D model(s) missing") && open.contains("KIPRJMOD"), "{open}");+        let mut acked = rec.clone();+        acked["ack"] = json!({"why": "A is a label with no body", "stepSha256": rec["stepSha256"].clone()});+        assert!(open_issue(&acked).is_none());+        // a board already in the project folder is exported as it is+        let c = context(board.to_str().unwrap(), &run_dir);+        assert!(c.temp.is_none() && c.board == board);+        let _ = std::fs::remove_dir_all(&dir);+    } }
crates/aiflow-analyze/src/pour_cover.rs+126−3
@@ -11,7 +11,10 @@ //! pads are all on this net, on no net, or on nets the spec gives no load and that are not ground (a //! test point, a feedback divider, a bootstrap or feed-forward capacitor). Anything else is not "the //! pour carries the load"; a same-input Fields solve can still judge the net by its solved neck rise-//! (solved_ampacity).+//! (solved_ampacity). Issue #176: a load that declares terminals with non-zero amps names its terminals+//! (the heuristic is for the rest), a Kelvin sense pad (spec.kelvin) is never a terminal, and (c) a narrow+//! dead-end stub, copper that meets the load path at one place and otherwise reaches only pads that+//! draw nothing (a Kelvin tap, an LDO input declared at zero amps), is not judged at the net's load. use aiflow_board::{geom, sx::Node, Board, Pt};  /// A point within this distance of one of the net's own pads on that layer counts as covered: the@@ -85,6 +88,59 @@ pub fn ground_like(net: &str) -> bool {     n.contains("GND") || n.starts_with("VSS") || n.starts_with("VEE") || n == "0" || n.contains("EARTH") || n.contains("CHASSIS") || n.contains("RTN") || n.contains("RETURN") } +/// Issue #176: the indices (into `all`) of the narrow pieces on dead-end stubs. The narrow pieces not+/// wholly inside a pour (`open[i]`) join where their ends meet on one layer, or at one via or through+/// pad. A connected run of them is a stub when it meets the load path at one place only (within+/// `PLACE_MM`) and reaches a pad that draws nothing. It meets the load path where an end is on a load+/// terminal pad, on a piece wide enough for the load, or on a via, and where it runs into a pour+/// (`in_fill`: the first point of each run inside a fill). Pads that are not+/// terminals (zero amps, side parts) and `sense` pads (a Kelvin tap) draw nothing.+#[allow(clippy::too_many_arguments)]+fn dead_end_stubs(all: &[Piece], open: &[bool], wide_enough: &[bool], in_fill: impl Fn(Pt, &str) -> bool, terminals: &[&&aiflow_board::Pad], vias: &[aiflow_board::Via], pads: &[&aiflow_board::Pad], sense: impl Fn(&aiflow_board::Pad) -> bool) -> Vec<usize> {+    const PLACE_MM: f64 = 1.5;+    let n = all.len();+    let mut comp: Vec<usize> = (0..n).collect();+    fn find(g: &mut [usize], mut i: usize) -> usize { while g[i] != i { g[i] = g[g[i]]; i = g[i]; } i }+    let near = |a: Pt, b: Pt| (a.0 - b.0).hypot(a.1 - b.1) <= TOUCH_MM;+    let in_pad = |p: Pt, q: &aiflow_board::Pad| q.polys.iter().any(|poly| geom::point_poly_distance(p.0, p.1, poly) <= TOUCH_MM);+    let joint = |a: Pt, b: Pt| vias.iter().any(|v| (a.0 - v.x).hypot(a.1 - v.y) <= v.size / 2. && (b.0 - v.x).hypot(b.1 - v.y) <= v.size / 2.)+        || pads.iter().any(|q| q.thru && in_pad(a, q) && in_pad(b, q));+    for i in (0..n).filter(|&i| open[i]) {+        for j in (i + 1..n).filter(|&j| open[j]) {+            let (p, q) = (&all[i], &all[j]);+            let meet = [p.a, p.b].iter().any(|&x| [q.a, q.b].iter().any(|&y| (p.layer == q.layer && near(x, y)) || joint(x, y)));+            if meet { let (a, b) = (find(&mut comp, i), find(&mut comp, j)); comp[a.max(b)] = a.min(b); }+        }+    }+    let mut places: std::collections::BTreeMap<usize, Vec<Pt>> = std::collections::BTreeMap::new();+    let mut draws_nothing: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();+    for i in (0..n).filter(|&i| open[i]) {+        let c = find(&mut comp, i);+        let piece = &all[i];+        let len = (piece.b.0 - piece.a.0).hypot(piece.b.1 - piece.a.1);+        let steps = (len / STEP_MM).ceil().max(1.0) as usize;+        let at = places.entry(c).or_default();+        let mut inside = false;+        for s in 0..=steps {+            let t = s as f64 / steps as f64;+            let p = (piece.a.0 + t * (piece.b.0 - piece.a.0), piece.a.1 + t * (piece.b.1 - piece.a.1));+            let now = in_fill(p, &piece.layer);+            if now && !inside { at.push(p); }+            inside = now;+        }+        for p in [piece.a, piece.b] {+            let on_load = terminals.iter().any(|q| pad_on(q, &piece.layer) && in_pad(p, q))+                || all.iter().enumerate().any(|(w, o)| wide_enough[w] && o.layer == piece.layer && geom::seg_distance(p.0, p.1, o.a.0, o.a.1, o.b.0, o.b.1) <= o.width / 2. + TOUCH_MM)+                || vias.iter().any(|v| (p.0 - v.x).hypot(p.1 - v.y) <= v.size / 2.);+            if on_load { at.push(p); }+            let idle = |q: &&aiflow_board::Pad| pad_on(q, &piece.layer) && in_pad(p, q) && (sense(q) || !terminals.iter().any(|t| std::ptr::eq(**t, *q)));+            if pads.iter().any(idle) { draws_nothing.insert(c); }+        }+    }+    let one_place = |pts: &Vec<Pt>| pts.iter().all(|p| (p.0 - pts[0].0).hypot(p.1 - pts[0].1) <= PLACE_MM);+    (0..n).filter(|&i| open[i] && { let c = find(&mut comp, i); places.get(&c).is_some_and(one_place) && draws_nothing.contains(&c) }).collect()+}+ /// Whether the net's pour carries its load. Ok(how) when it does; Err(why not and what to do). pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_rise: f64, t_um: f64) -> Result<String, String> {     let load = &spec["loads"][net];@@ -135,7 +191,16 @@ pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_     let loaded = |n: &str| spec["loads"][n]["amps"].as_f64().is_some_and(|a| a > 0.0);     let side_part = |q: &aiflow_board::Pad| board.pads.iter().filter(|o| o.reference == q.reference && o.net != net)         .all(|o| o.net.is_empty() || (!loaded(&o.net) && !ground_like(&o.net)));-    let terminals: Vec<_> = pads.iter().filter(|q| !zero(q) && !side_part(q)).collect();+    // issue #176: a load that declares its terminals with current names them; the heuristic is for the rest+    let declared = |q: &aiflow_board::Pad| load["terminals"].as_array().into_iter().flatten().any(|t| {+        t["reference"].as_str() == Some(q.reference.as_str()) && t["pad"].as_str() == Some(q.name.as_str()) && t["amps"].as_f64().is_some_and(|a| a != 0.0)+            && (t["at"].is_null() || (t["at"][0].as_f64() == Some(q.x) && t["at"][1].as_f64() == Some(q.y)))+    });+    let explicit = pads.iter().any(|q| declared(q));+    // a Kelvin sense pad (spec.kelvin "REF.PAD": shunt pad) carries no load by definition+    let kelvin: Vec<&str> = spec["kelvin"].as_object().map(|o| o.keys().map(String::as_str).collect()).unwrap_or_default();+    let sense = |q: &aiflow_board::Pad| kelvin.contains(&format!("{}.{}", q.reference, q.name).as_str());+    let terminals: Vec<_> = pads.iter().filter(|q| !sense(q) && if explicit { declared(q) } else { !zero(q) && !side_part(q) }).collect();     let roots: std::collections::BTreeSet<usize> = (0..fills.len()).map(|i| find(&mut group, i)).collect();     let mut best: Option<(String, Vec<String>)> = None;     for root in roots {@@ -149,7 +214,32 @@ pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_         }         if best.as_ref().is_none_or(|b| missed.len() < b.1.len()) { best = Some((layers.join("+"), missed)); }     }-    let unreached = best.map(|(l, m)| format!("no one pour reaches every terminal (the best, on {l}, misses {}{})", m.iter().take(4).cloned().collect::<Vec<_>>().join(", "), if m.len() > 4 { ", ..." } else { "" })).unwrap_or_default();+    // issue #176: (c) a narrow piece on a dead-end stub, copper that meets the load path at one place only and+    // otherwise reaches only pads that carry no load (declared zero amps, a Kelvin sense pad, a side part),+    // carries no current: the pour carries the load when it covers every other narrow piece+    let wide_enough: Vec<bool> = all.iter().map(|p| crate::rise_for(p.width, t_um, amps, true) <= max_rise).collect();+    let fully = |p: &Piece| { let len = (p.b.0 - p.a.0).hypot(p.b.1 - p.a.1); let n = (len / STEP_MM).ceil().max(1.0) as usize; (0..=n).all(|i| { let t = i as f64 / n as f64; covered((p.a.0 + t * (p.b.0 - p.a.0), p.a.1 + t * (p.b.1 - p.a.1)), &p.layer) }) };+    let open: Vec<bool> = all.iter().enumerate().map(|(i, p)| !wide_enough[i] && !fully(p)).collect();+    let in_fill = |p: Pt, layer: &str| fills.iter().any(|f| f.layer == layer && in_box(p, f.bbox, TOUCH_MM) && geom::point_poly_distance(p.0, p.1, &f.poly) <= TOUCH_MM);+    let stubs = dead_end_stubs(&all, &open, &wide_enough, in_fill, &terminals, &vias, &pads, sense);+    // with fewer than two load terminals there is no telling where the load runs: no stub is exempt+    if terminals.len() >= 2 && !stubs.is_empty() {+        let stub_set: std::collections::HashSet<*const Piece> = stubs.iter().map(|&k| &all[k] as *const Piece).collect();+        let rest_gap = judged.iter().filter(|p| !stub_set.contains(&(**p as *const Piece))).find_map(|piece| {+            let len = (piece.b.0 - piece.a.0).hypot(piece.b.1 - piece.a.1);+            let n = (len / STEP_MM).ceil().max(1.0) as usize;+            (0..=n).map(|i| { let t = i as f64 / n as f64; (piece.a.0 + t * (piece.b.0 - piece.a.0), piece.a.1 + t * (piece.b.1 - piece.a.1)) }).find(|p| !covered(*p, &piece.layer))+        });+        if rest_gap.is_none() {+            let n_judged = judged.iter().filter(|p| stub_set.contains(&(**p as *const Piece))).count();+            return Ok(format!("the {net} pour covers every piece too narrow for {amps:.1} A that carries load; {n_judged} narrow piece(s) on dead-end stubs to pads that carry no load (zero-amp terminals, Kelvin sense pads) are not judged at the net's load{outline_note}"));+        }+    }+    let unreached = if terminals.len() < 2 {+        format!("fewer than two load terminals on {net} ({}): declare where the load enters and leaves (`loads.{net}.terminals` with non-zero amps)", if terminals.is_empty() { "none".to_string() } else { terminals.iter().map(|q| format!("{}.{}", q.reference, q.name)).collect::<Vec<_>>().join(", ") })+    } else {+        best.map(|(l, m)| format!("no one pour reaches every terminal (the best, on {l}, misses {}{})", m.iter().take(4).cloned().collect::<Vec<_>>().join(", "), if m.len() > 4 { ", ..." } else { "" })).unwrap_or_default()+    };     let gap = match first_gap {         Some((piece, gap)) => format!("it leaves the {:.2} mm piece on {} from ({:.2}, {:.2}) to ({:.2}, {:.2}) uncovered at ({:.2}, {:.2}) ({:.0} C at {amps:.1} A, limit {max_rise:.0} C)",             piece.width, piece.layer, rd(piece.a.0), rd(piece.a.1), rd(piece.b.0), rd(piece.b.1), rd(gap.0), rd(gap.1), crate::rise_for(piece.width, t_um, amps, true)),@@ -223,6 +313,39 @@ mod tests {         // unless the spec loads FB         assert!(check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}, "FB": {"amps": 1.0}}}), 1.5, 10.0, 35.0).is_err());     }+    /// Issue #176: J1 to U1 on a 2 mm track (wide enough), a token V patch, and a 0.25 mm sense trace from+    /// U1.1 to S1.1 (S1's other pad on GND, so the heuristic takes S1.1 for a load terminal).+    fn sense_board() -> Board {+        let mut t = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0)).text+            .replace("(segment (start 5 10) (end 25 10) (width 0.3)", "(segment (start 5 10) (end 25 10) (width 2.0)");+        t = t.replacen("(segment", r#"(footprint "A" (layer "F.Cu") (at 25 18) (property "Reference" "S1") (pad "1" smd rect (at 0 0) (size 0.6 0.6) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND"))) (segment (start 25 10) (end 25 18) (width 0.25) (layer "F.Cu") (net 1)) (segment"#, 1);+        Board::from_text("t.kicad_pcb", t).unwrap()+    }+    #[test]+    fn a_dead_end_sense_trace_to_zero_amp_pads_is_not_judged_at_the_load() {+        let b = sense_board();+        // undeclared, S1.1 may carry the load: the narrow trace is judged and fails+        let e = check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}}}), 1.5, 10.0, 35.0).unwrap_err();+        assert!(e.contains("0.25 mm piece"), "{e}");+        // declared at zero amps (explicit terminals): a dead end to a pad that draws nothing+        let spec = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}, {"reference": "U1", "pad": "1", "amps": -1.5}, {"reference": "S1", "pad": "1", "amps": 0}]}}});+        let how = check(&b, "V", &spec, 1.5, 10.0, 35.0).unwrap();+        assert!(how.contains("dead-end stubs") && how.contains("1 narrow piece"), "{how}");+        // a Kelvin sense pad (spec.kelvin) draws nothing either, with no zero-amp declaration+        let kelvin = serde_json::json!({"kelvin": {"S1.1": "U1.1"}, "loads": {"V": {"amps": 1.5}}});+        assert!(check(&b, "V", &kelvin, 1.5, 10.0, 35.0).unwrap().contains("dead-end stubs"));+        // a narrow trace that joins two load terminals is not a stub: it is judged+        let t = b.text.replace("(segment (start 5 10) (end 25 10) (width 2.0)", "(segment (start 5 10) (end 25 10) (width 0.25)");+        let e = check(&Board::from_text("t.kicad_pcb", t).unwrap(), "V", &spec, 1.5, 10.0, 35.0).unwrap_err();+        assert!(e.contains("does not carry the load"), "{e}");+    }+    #[test]+    fn explicit_terminals_name_the_load_and_too_few_are_said_plainly() {+        let one = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}]}}});+        let b = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0));+        let e = check(&b, "V", &one, 1.5, 10.0, 35.0).unwrap_err();+        assert!(e.contains("fewer than two load terminals on V (J1.1)") && !e.contains("misses )"), "{e}");+    }     #[test]     fn islands_on_both_layers_joined_by_a_via_are_one_pour() {         // J1 poured on F.Cu, U1 on B.Cu (a through pad), the islands joined by a via; the track runs outside both
crates/aiflow-bridge/src/lib.rs+29
@@ -186,6 +186,25 @@ pub fn took_foreground(editor: i64, before: Option<&Value>, fg: &Value) -> bool     kicad(fg) && !before.is_some_and(|b| kicad(b)) } +/// The constant bit rate that keeps a take of `max_ms` under `max_mb` (95 % of it, for the container),+/// never above `peak` and never under 1 Mbit/s.+pub fn sized_bitrate(max_ms: u64, max_mb: u64, peak: u64) -> u64 {+    let secs = (max_ms / 1000).max(1);+    (max_mb * 8_000_000 * 95 / 100 / secs).min(peak).max(1_000_000)+}++#[cfg(test)]+mod sized_bitrate_tests {+    #[test]+    fn a_fields_take_fits_its_size_cap() {+        let b = super::sized_bitrate(15 * 60 * 1000, 1024, super::WINDOW_MAX_BITRATE);+        assert!(b < super::WINDOW_MAX_BITRATE && b > 8_000_000, "{b}");+        assert!(b * 900 / 8 <= 1024 * 1_000_000);+        assert_eq!(super::sized_bitrate(60_000, 100_000, 12_000_000), 12_000_000);+        assert_eq!(super::sized_bitrate(3_600_000, 10, 12_000_000), 1_000_000);+    }+}+ fn window_max_bitrate() -> u64 {     std::env::var("AIFLOW_CLIP_MAX_BITRATE").ok().and_then(|v| v.trim().parse().ok()).filter(|v| *v >= 1_000_000).unwrap_or(WINDOW_MAX_BITRATE) }@@ -519,6 +538,16 @@ impl Bridge {         r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)     } +    /// `record_window_start_capped` with a hard size cap as well (issue #180): the take is recorded at a+    /// constant rate (`bitrate`, CBR) that fits `max_mb` in `max_ms`, at most the usual peak, so its file+    /// can never pass `max_mb` whatever the window shows.+    pub fn record_window_start_sized(&self, hwnd: i64, reason: &str, max_ms: u64, max_mb: u64) -> Result<String, Value> {+        let max_ms = max_ms.min(WINDOW_MAX_MS);+        let bitrate = sized_bitrate(max_ms, max_mb, window_max_bitrate());+        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": max_ms, "bitrate": bitrate, "maxBitrate": bitrate}));+        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+    }+     /// A background screenshot of one window (owned popups included), copied into `save_dir`     /// under `name`; returns the local path.     pub fn screenshot_window(&self, hwnd: i64, save_dir: &str, name: &str) -> Option<String> {
crates/aiflow-copper/src/lib.rs+17−1
@@ -568,11 +568,16 @@ pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value),             *widths.entry(num(s.width)).or_insert(0) += 1;         }         for v in &vias {+            // issue #167: two paths of one net that change layer at one point are one via, not two+            // co-located holes+            if vias_by_net.get(&net).is_some_and(|vs| vs.iter().any(|w| (w.x - v.x).abs() < 1e-3 && (w.y - v.y).abs() < 1e-3)) {+                continue;+            }             blocks.push(via_sexpr(board, v, &net)?);             vias_by_net.entry(net.clone()).or_default().push(v.clone());+            n_via += 1;         }         n_seg += segs.len();-        n_via += vias.len();     }     let mut text = splice(&board.text, &blocks);     let mut stats = json!({"entries": entries.len(), "nets": nets.len(), "segments": n_seg, "vias": n_via, "copperLengthMm": (length * 100.0).round() / 100.0, "segmentsByWidth": widths});@@ -882,6 +887,17 @@ mod normalise_tests {         assert!(normalise_plan(&b, &mut via_only).is_empty());         assert_eq!(via_only["nets"][0]["points"].as_array().unwrap().len(), 2);     }+    #[test]+    fn two_paths_of_one_net_changing_layer_at_one_point_are_one_via() {+        // issue #167: the gate copy had two same-net vias at one point (holes_co_located)+        let b = board();+        let plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [+            ["U1.1", [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 104.0]],+            [[88.0, 100.0], [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 96.0]]]}]});+        let (text, stats) = apply(&b, &plan, false).unwrap();+        assert_eq!(stats["vias"], 1, "{stats}");+        assert_eq!(text.matches("(via").count(), 1);+    } }  #[cfg(test)]
crates/aiflow-place/src/lib.rs+68−1
@@ -101,6 +101,28 @@ fn clear(b: &Box4, others: &[Box4], gap: f64) -> bool {     !others.iter().any(|o| b.0 - gap < o.2 && o.0 - gap < b.2 && b.1 - gap < o.3 && o.1 - gap < b.3) } +/// The sides a part occupies (issue #179): (top, bottom). An SMD part on F.Cu is top only, one on B.Cu+/// bottom only (KiCad checks F.CrtYd and B.CrtYd separately); a part with a drilled pad or hole is on both.+pub fn sides(board: &Board, reference: &str) -> (bool, bool) {+    let drilled = board.pads.iter().any(|p| p.reference == reference && (p.thru || p.drill.is_some())) || board.holes.iter().any(|h| h.reference == reference);+    if drilled { return (true, true); }+    let bottom = board.footprints.get(reference).is_some_and(|f| f.layer == "B.Cu");+    (!bottom, bottom)+}++fn share_side(a: (bool, bool), b: (bool, bool)) -> bool {+    (a.0 && b.0) || (a.1 && b.1)+}++/// The copper of every drilled pad and the outline of every bare hole, grown by 0.25 mm: on both sides,+/// and it can stick out past its part's courtyard (a USB-C shell tab). Parts in `skip` are left out.+fn drilled_boxes(board: &Board, skip: &[&str]) -> Vec<Box4> {+    let grow = |b: Box4| (b.0 - 0.25, b.1 - 0.25, b.2 + 0.25, b.3 + 0.25);+    let pads = board.pads.iter().filter(|p| (p.thru || p.drill.is_some()) && !p.polys.iter().all(|q| q.is_empty()) && !skip.contains(&p.reference.as_str())).map(|p| grow(geom::bbox(&p.polys.iter().flatten().copied().collect::<Vec<_>>())));+    let holes = board.holes.iter().filter(|h| !skip.contains(&h.reference.as_str()) && !h.poly.is_empty()).map(|h| grow(geom::bbox(&h.poly)));+    pads.chain(holes).collect()+}+ /// Local outline points of a part for placement: its courtyard, else its pad box grown by 0.25. fn local_pts(board: &Board, reference: &str) -> Vec<Pt> {     if let Some(p) = courtyard_local(board, reference) {@@ -173,13 +195,18 @@ pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (B     let mut out = BTreeMap::new();     let mut lines = Vec::new();     let mut pads = PadMap::new(board);+    // issue #179: a candidate meets only the courtyards on its own side, plus every drilled pad and hole+    let side: BTreeMap<String, (bool, bool)> = board.footprints.keys().map(|r| (r.clone(), sides(board, r))).collect();+    let wished: Vec<&str> = wishes.iter().map(|(r, _)| r.as_str()).collect();+    let drilled = drilled_boxes(board, &wished);     for (reference, w) in wishes {         if !board.footprints.contains_key(reference) {             lines.push(format!("{reference}: not on the board"));             continue;         }         let pts = local_pts(board, reference);-        let others: Vec<Box4> = boxes.iter().filter(|(r, _)| *r != reference).map(|(_, b)| *b).collect();+        let mine = side[reference];+        let others: Vec<Box4> = boxes.iter().filter(|(r, _)| *r != reference && side.get(*r).is_none_or(|s| share_side(*s, mine))).map(|(_, b)| *b).chain(drilled.iter().copied()).collect();         let g = w.gap.unwrap_or(gap);         let n = (w.radius / 0.1) as i64;         let mut cands: Vec<(f64, f64, f64)> = Vec::new();@@ -562,6 +589,10 @@ pub fn check_allowing(board: &Board, moved: &[String], gap: f64, overhang: &[Str             if !moved.contains(a) && !moved.contains(b) {                 continue;             }+            // issue #179: a top part and a bottom part never share a courtyard layer+            if !share_side(sides(board, a), sides(board, b)) {+                continue;+            }             let (ba, bb) = (boxes[*a], boxes[*b]);             if ba.0 - gap < bb.2 && bb.0 - gap < ba.2 && ba.1 - gap < bb.3 && bb.1 - gap < ba.3 {                 problems.push(format!("courtyards overlap: {a} {b}"));@@ -623,6 +654,42 @@ mod tests {         Wish { x, y, rot: rot.to_vec(), radius: 3.0, keepout: Vec::new(), gap: None }     } +    /// Issue #179: a QFN on F.Cu at (10, 10), a 0402 on B.Cu and a 0402 on F.Cu parked aside, and a+    /// through-hole header at (20, 10).+    fn two_sided() -> Board {+        let crt = |layer: &str, h: f64, w: f64| format!("\t\t(fp_rect (start {} {}) (end {w} {h}) (layer \"{layer}\"))\n", -w, -h);+        let smd = |r: &str, layer: &str, x: f64, y: f64, h: f64, w: f64| format!("\t(footprint \"T:{r}\"\n\t\t(layer \"{layer}\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n{}\t\t(pad \"1\" smd rect (at 0 0) (size 0.5 0.5) (layers \"{}\") (net \"N{r}\"))\n\t)\n", crt(&layer.replace(".Cu", ".CrtYd"), h, w), layer);+        let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");+        t.push_str(&smd("U1", "F.Cu", 10.0, 10.0, 2.0, 2.0));+        t.push_str(&smd("C1", "B.Cu", 3.0, 3.0, 0.5, 0.9));+        t.push_str(&smd("C2", "F.Cu", 3.0, 17.0, 0.5, 0.9));+        t.push_str(&format!("\t(footprint \"T:J1\"\n\t\t(layer \"F.Cu\")\n\t\t(at 20 10)\n\t\t(property \"Reference\" \"J1\")\n{}\t\t(pad \"1\" thru_hole circle (at 0 0) (size 1.7 1.7) (drill 1) (layers \"*.Cu\") (net \"NJ\"))\n\t)\n", crt("F.CrtYd", 1.0, 1.0)));+        t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 30 20)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");+        Board::from_text("two.kicad_pcb", t).unwrap()+    }++    #[test]+    fn pack_puts_a_bottom_part_under_a_top_part_and_keeps_sides_apart() {+        let b = two_sided();+        assert_eq!((sides(&b, "U1"), sides(&b, "C1"), sides(&b, "J1")), ((true, false), (false, true), (true, true)));+        // the bottom 0402 wished at the QFN's centre gets its spot 0.00 mm from the wish+        let (m, lines) = pack(&b, &[("C1".into(), wish(10.0, 10.0, &[0.0]))], 0.05, 0.3);+        assert_eq!((m["C1"].x, m["C1"].y), (10.0, 10.0), "{lines:?}");+        assert!(lines[0].contains("0.00 mm from the wish"), "{lines:?}");+        // a top 0402 wished there has to leave the QFN's courtyard+        let (m, _) = pack(&b, &[("C2".into(), wish(10.0, 10.0, &[0.0]))], 0.05, 0.3);+        assert!((m["C2"].x - 10.0).abs() > 1.0 || (m["C2"].y - 10.0).abs() > 1.0, "{:?}", m["C2"]);+        // a through-hole part is on both sides: the bottom 0402 cannot sit on the header+        let (m, _) = pack(&b, &[("C1".into(), wish(20.0, 10.0, &[0.0]))], 0.05, 0.3);+        assert!((m["C1"].x - 20.0).abs() >= 1.0 || (m["C1"].y - 10.0).abs() >= 1.0, "{:?}", m["C1"]);+        // the overlap advisory is per side too: C1 under U1 is fine, C2 on U1 is not+        let (text, _) = move_in_text(&b, &BTreeMap::from([("C1".to_string(), Move { x: 10.0, y: 10.0, rotation: None }), ("C2".to_string(), Move { x: 10.0, y: 10.5, rotation: None })])).unwrap();+        let moved = Board::from_text("moved.kicad_pcb", text).unwrap();+        let problems = check(&moved, &["C1".into(), "C2".into()], 0.05);+        assert!(problems.iter().any(|p| p.contains("C2 U1")), "{problems:?}");+        assert!(!problems.iter().any(|p| p.contains("C1") && p.contains("U1")), "{problems:?}");+    }+     /// Issue #140: a part parked off the board is a finding that names it; one straddling the edge     /// names its pad; parts inside, and an allowed overhang, are not listed.     #[test]
crates/aiflow-router/src/drive.rs+72−5
@@ -920,7 +920,7 @@ impl Router {         queue.append(&mut rest);         self.log(format!("routing {} signal nets, {}{}", queue.len(), self.order.describe(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) }));         let mut order_no = 0;-        let mut best_state: Option<(usize, Snapshot)> = None;+        let mut best_state: Option<(usize, Snapshot, Vec<usize>)> = None;         while !queue.is_empty() {             let net = queue.remove(0);             if self.nets[net].routes.iter().any(|r| r.pair.is_none()) {@@ -938,7 +938,8 @@ impl Router {             }             let score = self.nets.iter().filter(|n| !queue.contains(&n.idx)).map(|n| n.failed.len()).sum::<usize>() + queue.iter().map(|&q| self.nets[q].clusters.len().saturating_sub(1)).sum::<usize>();             if best_state.as_ref().map(|b| score < b.0).unwrap_or(true) {-                best_state = Some((score, self.snapshot()));+                // issue #167: the nets still queued here are not routed in this snapshot; keep them to finish after a restore+                best_state = Some((score, self.snapshot(), queue.clone()));             }             if !self.nets[net].failed.is_empty() && !blockers.is_empty() && self.nets[net].rips < rip_per_net && self.rip_total < rip_global {                 let mut cands: Vec<usize> = blockers.iter().copied().filter(|b| !self.kelvin_owner.contains_key(b)).filter_map(|b| self.nets.iter().position(|n| n.idx == b)).filter(|&n| self.nets[n].routes.iter().any(|r| r.pair.is_none()) && self.nets[n].plane.is_none()).collect();@@ -1002,10 +1003,20 @@ impl Router {             }         }         let mut final_failed: usize = self.nets.iter().map(|n| n.failed.len()).sum();-        if let Some((score, snap)) = best_state {+        if let Some((score, snap, pending)) = best_state {             if score < final_failed {-                self.log(format!("  restoring the best intermediate state ({score} failed connections instead of {final_failed})"));+                // issue #167: the best intermediate state still had nets in the queue (never routed, never+                // failed); route them now, without rip-up, and keep that state only when it really is better+                let end = self.snapshot();                 self.restore(&snap);+                for &net in &pending {+                    if self.nets[net].routes.iter().any(|r| r.pair.is_none()) {+                        self.rip(net);+                    }+                    self.route_net(net);+                    order_no += 1;+                    self.nets[net].routed_order = Some(order_no);+                }                 if planes {                     for &pn in &plane_nets {                         if !self.nets[pn].failed.is_empty() {@@ -1013,14 +1024,48 @@ impl Router {                         }                     }                 }-                final_failed = self.nets.iter().map(|n| n.failed.len()).sum();+                let restored: usize = self.nets.iter().map(|n| n.failed.len()).sum();+                if restored < final_failed {+                    self.log(format!("  restoring the best intermediate state ({restored} failed connections instead of {final_failed}, {} queued net(s) routed after it)", pending.len()));+                    final_failed = restored;+                } else {+                    self.restore(&end);+                }             }         }+        final_failed += self.flag_never_routed();         let (exp, rips) = (self.stats.get("expanded").copied().unwrap_or(0), self.stats.get("rips").copied().unwrap_or(0));         self.log(format!("routing done in {:.1}s, {exp} node expansions, {rips} rip-ups, {final_failed} failed connections", t0.elapsed().as_secs_f64()));         final_failed     } +    /// Issue #167: a signal net that ends a run with open clusters, no routes, no failures and no+    /// route order was dropped, never tried. Each open cluster becomes a failed connection with+    /// its reason, so the report, the summary and the variant ranking count it. Returns how many.+    pub fn flag_never_routed(&mut self) -> usize {+        let mut added = 0;+        for n in 0..self.nets.len() {+            let net = &self.nets[n];+            if net.plane.is_some() || net.done_by_board || net.clusters.len() < 2 || !net.failed.is_empty() || !net.routes.is_empty() {+                continue;+            }+            let groups: HashSet<usize> = (0..net.clusters.len()).map(|c| self.comp_of(n, c)).collect();+            if groups.len() < 2 {+                continue;+            }+            let mut seen: HashSet<usize> = HashSet::from([self.comp_of(n, 0)]);+            let open: Vec<String> = (1..net.clusters.len()).filter(|&c| seen.insert(self.comp_of(n, c))).map(|c| net.clusters[c].key(&self.board).to_string()).collect();+            for key in open {+                self.nets[n].failed.push((key, "never routed: the router dropped this net (ripped up and not retried before the rip budget or the pass ended); route again with a higher --rip-global or more --passes".into()));+                added += 1;+            }+        }+        if added > 0 {+            self.log(format!("  {added} connection(s) of nets that were never routed, now reported as failed"));+        }+        added+    }+     pub fn reset(&mut self) {         let n = self.nets.len();         for net in 0..n {@@ -1398,6 +1443,28 @@ mod path_via_tests {         assert!(text.contains("B.Cu"), "{text}");     } +    /// Issue #167: a net left with open clusters, no routes and no failure (dropped from the queue by a+    /// best-state restore) is reported unrouted with its reason and counted, never left silent.+    #[test]+    fn a_dropped_net_is_reported_unrouted_and_counted() {+        let mut r = Router::new(aiflow_board::Board::from_text("bottom.kicad_pcb", BOTTOM.to_string()).unwrap(), crate::Rules::default(), false);+        let (_, _, failed) = route_board(&mut r, 2, 4, 40);+        assert_eq!(failed, 0);+        assert_eq!(r.flag_never_routed(), 0, "a fully routed board flags nothing");+        let net = r.nets.iter().position(|n| n.plane.is_none() && n.clusters.len() >= 2 && !n.done_by_board && !n.routes.is_empty()).unwrap();+        r.rip_keeping(net, false);+        r.nets[net].failed.clear();+        r.nets[net].routed_order = None;+        let report = r.report();+        assert_eq!(report["nets"][&r.nets[net].name]["routes"], 0);+        assert!(report["unrouted"].as_array().unwrap().is_empty(), "the silent state the issue saw");+        let added = r.flag_never_routed();+        assert_eq!(added, r.nets[net].clusters.len() - 1);+        let un = r.report()["unrouted"].as_array().unwrap().clone();+        assert_eq!(un.len(), added);+        assert!(un[0][1][1].as_str().unwrap().starts_with("never routed"), "{un:?}");+    }+     /// Issue #140: a part parked outside the outline (pads off the routing grid) no longer panics in     /// obstacle_maps; its connections are reported unrouted with the part named, the rest route.     #[test]
docs/branch-current-screen.md+2−1
@@ -26,8 +26,9 @@ Without the branch model, `analyze current` judges the narrowest track over 0.2  1. every track the screen would fail at the full load lies inside a filled zone of the same net on its own layer (a point within 0.5 mm of one of the net's own pads or vias counts, for the thermal-relief gap), or 2. one pour (fill islands joined by the net's own vias and through pads) reaches every terminal of the net, so the narrow tracks run beside it.+3. every narrow track it leaves uncovered is on a dead-end stub: copper that meets the load path (a pour, a load terminal pad, a track wide enough for the load, a via) at one place only and otherwise reaches only pads that draw nothing, such as a Kelvin tap or an LDO input declared at 0 A. A stub carries no current, so it is not judged at the net's load (issue #176). This needs two or more load terminals. -The terminals are every pad of the net except the pads `loads.<net>.terminals` declares at zero amps and the pads of parts that cannot pass load current by the spec's own declaration: a part whose other pads are all on this net, on no net, or on nets with no load in `spec.loads` that are not ground by name (a test point, a feedback divider, a bootstrap or feed-forward capacitor). A part bridging to a ground net, or to a net the spec loads, stays a terminal. A zone saved unfilled is counted by its outline, and the result says so. Otherwise the net fails with the uncovered piece, the terminals no pour reaches, and the ways out: extend the pour, widen the copper, declare the zero-current pads, or solve with Fields.+When `loads.<net>.terminals` lists pads with non-zero amps, those are the terminals. Otherwise the terminals are every pad of the net except the pads `loads.<net>.terminals` declares at zero amps and the pads of parts that cannot pass load current by the spec's own declaration: a part whose other pads are all on this net, on no net, or on nets with no load in `spec.loads` that are not ground by name (a test point, a feedback divider, a bootstrap or feed-forward capacitor). A part bridging to a ground net, or to a net the spec loads, stays a terminal. A Kelvin sense pad (a key of `spec.kelvin`) is never a terminal. A zone saved unfilled is counted by its outline, and the result says so. Otherwise the net fails with the uncovered piece, the terminals no pour reaches, and the ways out: extend the pour, widen the copper, declare the zero-current pads, or solve with Fields.  **With a same-input Fields solve, its solved rise judges the width approximation.** When the Fields report passes the same-input field screen (exact board and spec hashes, converged at the full declared current, density and barrel limits), each net that failed only the width approximation (`failure: bottleneck` or `pour-coverage`) is judged by `review.checks.ampacity` (adom-fields 0.9 or later): the solved temperature rise at the net's narrowest neck, at the full current. The net passes when that rise plus a 0.05 C rounding allowance is within its `maxRiseC`. The result says `judgedBy: fields-solve`, keeps the original screen as `ipcWholeNetScreen` and the Fields row as `fieldsAmpacity`. A solved rise over the limit keeps the failure and adds the number. Missing copper and too few stitching vias are not width questions and are never judged this way. 
docs/continuation.md+1−1
@@ -20,7 +20,7 @@ Write actual evidence, never invent a request: {"kind":"user-pause","reason":"User requested a pause","userInstruction":"pause until I tell you to resume","instructionAt":"2026-10-10T00:00:00Z"} ``` -Pass its file to `finish-guard --stop-evidence pause.json`, then to `done --stop-evidence pause.json --message "Paused at the user's request"`. This closes accounting without passing or completing engineering. On resume, do not reuse it.+Pass its file to `finish-guard --stop-evidence pause.json`, then to `done --stop-evidence pause.json --message "Paused at the user's request"`. This closes accounting without passing or completing engineering. `done` keeps an accepted stop in run.json (`stopEvidence`), so a later `finish-guard` with no `--stop-evidence` (a host stop hook) re-reads and re-checks that file and answers the same stop, with `stopEvidencePath` in its JSON, instead of asking again. The next `prompt` clears it. On resume, do not reuse it.  ## External dependency 
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.57",+  "version": "0.2.58",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.57",+  "version": "0.2.58",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+11−11
@@ -26,7 +26,7 @@ A failed engineering gate, an inaccurate heuristic, missing automation, a source  `done` closes a session at any time and notes when the finish line has not been reached yet. The guard checks recorded gates and delivery artifacts; the AI must also verify the user's full scope, including firmware, molecule, wiki, dashboard and edited video when requested. Track additional deliverables in the project's completion contract; optional `run.json` `continuation.requiredDeliverables` rows use `name`, `complete` and an existing `evidence` file. Evidence existence is not independent validation of its contents. -Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change.+Only the user's explicit pause/cancellation, or an unavoidable external dependency after exhausting useful authorized work, permits an incomplete handoff. Use `finish-guard --stop-evidence <json>` and `done --stop-evidence <json> --message ...`; a stop `done` accepts is kept in run.json (`stopEvidence`), so later `finish-guard` calls (a host stop hook passes no path) honour it, re-reading the file, until the next `prompt`. See `docs/continuation.md` for the evidence schemas. Preserve the exact user instruction when pausing. Stop evidence never completes a gate and must not be reused after a resume or an external-state change.  The guard is read-only and suitable for a host's pre-stop integration. It does not install a watcher, inject user messages, or restart a terminated AI turn. Never promise universal nonstop execution: host/runtime interruption, user control, limits and real external dependencies still apply. When a host has no stop integration, following this protocol remains the calling AI's responsibility. @@ -35,17 +35,17 @@ The guard is read-only and suitable for a host's pre-stop integration. It does n The flow is a file, `flows/board.json`: the steps in order (intake, rules, test-plan, components, libraries, models, placement, routing, thermal-vias, pours, outline, current, thermal, fields, nets, silkscreen, 3d, finish, export; the `sequence` per lane is enforced, see No step is skipped), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with `kicad_zoom_to` (KiCad Bridge 1.0.80+; the Zoom to Selected Objects menu key on an older bridge), one net at a time on camera; it says "framed" only for the nets a zoom call framed and names the calls that failed for the others. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.  1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly.-2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). `place pack` lays each `spec.chains` entry (a series chain in electrical order, such as a divider) on one line with the shared-net pads facing and says which chains were honoured; when a wish lists several rotations it picks the one whose pads land nearest their net partners. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's.+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. It packs per side: a bottom part may sit under a top part (a courtyard meets only courtyards on its own side; through-hole parts and drilled pads count on both), and `place check`'s bounding-box advisory is per side too. Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--sch design.kicad_sch]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. `--sch` records the schematic the board came from, so `finish` requires a passing `netlist check`. 1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>] [--page <owner/slug> [--page-dir <folder>]]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly. When the work begins before any board exists, open the run with `intake` instead (next section); `start` on that run attaches the board and keeps its clock. 2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself (high-speed boards: see High-speed boards below). Pours, gate, landing, measurement, both analyses and the finish are the binary's. 3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it.-4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. Every open connection is counted, also a net the router never got back to after a rip-up (reason "never routed"), so the summary and the variant ranking see it. 5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via or trace already on the live board (same net, same points) is not landed twice, so a rerun after a stop resumes where it stopped (`--from <n>` starts at trace n), a reply lost to the transport is answered by reading the board and sending that one trace once more only if it did not land, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. A via whose copper would touch another net's track, via or pad (planned or on the board) is refused before anything lands, and so is a via within the spec's hole-to-hole minimum (`holeToHole`, default 0.2 mm) of any via or hole, or within `clearance` of another via's copper, whatever its net (the ERROR names the clash), and every landed via is read back: one that came back on another net than planned is taken back and the command errors with both nets.-4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps, `planeUnder` regions that no other net may cross on their layer). Copper already on the board (an `adopt-board` of a partly or fully routed board) is kept: its tracks and vias are obstacles, and only the connections it leaves open are planned; with nothing open it writes an empty plan with a note, which gate, `land route` and `finish` accept. Filled zones are that copper too: a pad inside a fill of its net on its layer (a through-hole pad on either layer) is joined, so a net its pours already join is skipped and named in the note. A net with a zone that has no fill in the board file is still tracked, with a WARNING to refill the zones and route again. With `planes`, a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane) sits in the pour and gets no stub or via; through-hole pads reach any plane. If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself. Every open connection is counted, also a net the router never got back to after a rip-up (reason "never routed"), so the summary and the variant ranking see it.    **`route --variants N`** (issue #55, at most 8): route the same board N times with different levers and let the gates pick. The levers are the ones kicad-place-route-loop lists, in a fixed order so a rerun gives the same variants: v1 is a plain `route` (shortest nets first, the spec's vias, `--passes`), then longest first, signal vias at the spec's power via size (left out when the spec's two vias are the same), most pads first, seeded orders (seed 1, 2), shortest first with 4 more passes, longest first with power-size vias and 4 more passes; more than that are more seeds. Each variant writes `plan-route-v<k>.json`, `plan-route-v<k>-report.json` and `router-v<k>.log`, and is gated with the checks `gate` runs (the same function, routed board `board-routed-v<k>.kicad_pcb`). The ranking uses the gates alone, in order: unrouted connections; gate issues (new DRC errors, unconnected, planeUnder, switch node, courtyards and bodies, decoupling errors); copper length of the plan; via count; then the failed current and thermal screens, only when a Fields solve of that exact routed board is on the run (a Fields solve per variant is not run: it takes minutes per board, so in practice this column is empty and `analyze` runs on the winner as usual); the lower variant number breaks a full tie. Without kicad-cli the variants are ranked without the gate column and the table says so. The winner becomes `plan-route.json` exactly as a plain route would, and every variant (levers, scores, plan paths, rank) is on the route outcome under `variants`, in the ledger as `route-variants`, and in the run README as a table. **`route --pick K --why "..."`** makes variant K the route plan instead (the board must be unchanged); the reason, the thread and the gates' own pick stay on the record. Then `gate` as usual. A plain `route` is unchanged.-5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Nothing lands until it passes.+5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated, plus any other-net copper inside a `planeUnder` region. Pours (and their vias) that `pour` planned and that are not landed yet go into the gate copy, so a net only the planned pours join is not read as unconnected (`plannedPours` in gate.json). Nothing lands until it passes. 6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched. 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. A loaded net with filled zones and no tracks at all is "carried only by its pour" (judged on the saved board by whether the pour reaches its terminals, then by the Fields solve), never no-copper. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` reports whether the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.@@ -84,7 +84,7 @@ draws an arc on the timeline, and every later step becomes a redo that must be d unless the board and spec come out of the return unchanged (same hashes in and out: nothing reworked, no redo). A proof recorded after a step's last visit counts, so for a missing proof run its command from where you are (finish names it) instead of returning.-`finish` lists what is still open in the chain as a report card, with suggestions; it never refuses.+`finish` lists what is still open in the chain as a report card, with suggestions; it never refuses. When open items belong to a step the run is past (a switch-node finding to placement, DRC to routing), it also prints `Suggested: earliest step these open items go back to: <step>` with the `step <step> --back --why` command, and `finish-guard` repeats it until that step is visited again. Layout work within the spec is yours to do without asking; ask the human only to change a frozen budget or requirement.  **When a check is wrong for this board, deviate on purpose instead of stopping.** AI Flow has not seen every kind of board. When a step does not apply, or `finish` lists something as open that is right for this@@ -374,7 +374,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate. - **Courtyards and bodies are reported prominently.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad (except KiCad's own exceptions: net ties, `net_tie_pad_groups`, and footprints marked `allow_missing_courtyard`; net ties are copper, not part bodies) and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.-- **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule.+- **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` exports from the source project's folder (so project-library and PCM 3D models resolve) and keeps the native STEP export's warnings apart from its exit status; geometry warnings and missing 3D models are open issues that `board-glb copper` and `deliver` report until fixed or reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`. - **Clips.** Pre-board steps film Hydrogen's dashboard, the AI's window or the tool's own window, or record a skip with the reason; a take that died is restarted on the step's current window with the gap recorded; a truncated MP4 (no moov atom) is flagged when it stops. Skill aiflow-live-clips.@@ -421,7 +421,7 @@ adom-aiflow is the token cache of every Adom user's flow. When the binary was wr  ## The code checks what you filmed -Every clip stop runs a blank check (nine sampled frames; a clip whose frames are one flat colour filmed an unpainted or covered window) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. The blank check samples the centre 60 % of each frame; a frame with two or more rows or columns of board content (a long thin strip at zoom-to-fit) is painted, not flat. A step's clip on a KiCad editor prints the editor's title ("filming KiCad's ..."), and the camera only ever films the PCB editor whose title names this run's board, never another project's. A clip that filmed another project or anything private: `capture reject <step>-<visit> --reason "..." --off-page` deletes it, its cuts, sheet and screenshots, and leaves it off the page. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` refuses while one is unresolved (`--accept-suspect` overrides, and the message must say why). Every stop checks that the clip reached the run (a non-empty local file) and pulls up to three times; a clip that still is not there is flagged `pullFailed` with its desktop path and a WARNING, and `recut`, `finish` and `deliver` pull it again first. Run `recut` promptly: the desktop cleans its recordings folder. `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.+Every clip stop runs a blank check (eight frames sampled in one decode pass; a clip whose frames are one flat colour filmed an unpainted or covered window; a flagged clip gets no cuts, and a clip over 1 GB is checked and cut later by `recut` or `compose`, never inside the step) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. The blank check samples the centre 60 % of each frame; a frame with two or more rows or columns of board content (a long thin strip at zoom-to-fit) is painted, not flat. A step's clip on a KiCad editor prints the editor's title ("filming KiCad's ..."), and the camera only ever films the PCB editor whose title names this run's board, never another project's. A clip that filmed another project or anything private: `capture reject <step>-<visit> --reason "..." --off-page` deletes it, its cuts, sheet and screenshots, and leaves it off the page. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` names every unresolved one in a note and delivers anyway (`--accept-suspect` says you looked). Every stop checks that the clip reached the run (a non-empty local file) and pulls up to three times; a clip that still is not there is flagged `pullFailed` with its desktop path and a WARNING, and `recut`, `finish` and `deliver` pull it again first. Run `recut` promptly: the desktop cleans its recordings folder. `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.  ## Honesty rules @@ -554,7 +554,7 @@ Refill acknowledgement is asynchronous. Request it once, then read without refil  ## Explicit branch currents -When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification. Since 0.2.23 (issue #107) the branch model runs on multilayer boards (outer-layer nets as on two layers; inner-layer tracks need the board's saved physical stackup). A pour passes the whole-net screen only when it covers the narrow tracks on their layer or one pour reaches every terminal of the net (declare zero-current pads in `loads.<net>.terminals`). With a same-input Fields solve that passes the field screen, a net that failed only the width approximation is judged by the solved rise at its narrowest neck (`review.checks.ampacity`), recorded as `judgedBy: fields-solve`.+When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification. Since 0.2.23 (issue #107) the branch model runs on multilayer boards (outer-layer nets as on two layers; inner-layer tracks need the board's saved physical stackup). A pour passes the whole-net screen only when it covers the narrow tracks on their layer or one pour reaches every terminal of the net (declare zero-current pads in `loads.<net>.terminals`). With a same-input Fields solve that passes the field screen, a net that failed only the width approximation is judged by the solved rise at its narrowest neck (`review.checks.ampacity`), recorded as `judgedBy: fields-solve`. A load that declares terminals with non-zero amps names its terminals, a Kelvin sense pad (`spec.kelvin`) is never one, and a narrow dead-end stub to pads that draw nothing (a Kelvin tap, an LDO input declared at 0 A) is not judged at the net's load.  ### Unclipped Fields current maxima @@ -645,7 +645,7 @@ The plan needs provenance: the plan `pour` wrote (its hash is recorded in the ru A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a USB-C shell peg) is read as a board hole, not a pad. The router, the stitching and heatsink via planners and the pour masks keep clearance from it; pad, net, BOM and netlist consumers never see it.  **Vias, edge connectors and repeated pad numbers (0.2.19).**-- `thermal-vias land --ack U3 --why "..."` leaves U3's planned vias out of the landing (the plan entries carry `ref`; the outcome records `leftOutAcked`). Acknowledged parts are never landed, whenever the ack was given.+- `thermal-vias land --ack U3 --why "..."` (or on `plan`; `--ack Q1,Q2` or `--ack Q1 --ack Q2`; acks are saved at once and kept across calls, and finish honours them) leaves U3's planned vias out of the landing (the plan entries carry `ref`; the outcome records `leftOutAcked`). Acknowledged parts are never landed, whenever the ack was given. - The heatsink via planner (`thermal-vias plan`) and the small-pin ring of `thermalVias` keep every via clear of visible silkscreen on both sides: board and footprint text (the printed text's extent) and silk graphics, by `silkClearance` (default 0.1 mm). A part short of its target says how many spots the silk took; move the label to free them. - A `stitchVias` entry takes `size` and `drill` in mm. Without them it gets the spec's `viaSignal` (and `drillSignal`), else the board's most common via, else 0.6 / 0.3 mm (it was 0.8 / 0.4). - Via-to-via copper clearance and the hole-to-hole minimum come from the spec (`viaClearance`, else `clearance`; `holeToHole`); when the spec names neither, from the project's `.kicad_dru` beside the board (its unconditioned or via-only clearance and hole_to_hole rules, the last one winning as in KiCad); else 0.2 mm. This applies to `land vias`, `pour` and `thermal-vias plan`; zones keep `clearance`.@@ -682,7 +682,7 @@ Every LED on the board is described in `led-states.json` beside the board, and l  An LED is any reference AI Flow recognises (LED*, or D*/CR* on an LED footprint) and any reference this file declares, so an RGB part on a project footprint named by its MPN is covered by listing it. A multi-die part (RGB) takes one entry per die with the same `ref` and a distinct `channel` (`red`, `green`, `blue`); each die's anode and cathode nets are checked against that part's own pads. A declared reference that is not on the board fails. -`on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` refuses the board while any LED has no complete entry.+`on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` lists every LED with no complete entry as an open item. An LED is a reference `LED*`, a `D*`/`CR*` whose footprint name (not its library nickname) or value has a word starting with LED, or any reference `led-states.json` declares; a BAT54, TVS or clamp diode needs no entry.  `silkscreen-audit --record` and `finish` print one line per label check on every run, a pass included (`power inputs: 0 found`, `power inputs: 1 (J1 VIN), all labelled with a voltage`, `test points: ...`), and record `powerInputs` and `testPoints` (`found`, `labelled`, `unlabelled`, `mismatched`, `items`) in the audit's result JSON, the `silkscreen` outcome and the `finish-checks` outcome. `finish` also prints `LED states: no LEDs on the board: led-states.json not required` (or what it found) and `programming: no programmable part: programming not required` (or the parts and interface). 
skills/aiflow-live-clips/SKILL.md+8−1
@@ -62,10 +62,17 @@ When a clip stops (`step <next>`, `capture stop`, `finish`, `deliver`, or the cl  ## Separate recording storage from the EDA host -Honor the user's recording/storage target. An EDA bound to a low-disk laptop does not authorize automatic capture there. Set `AIFLOW_AUTO_CAPTURE=off` to prevent `step` from starting a new window/monitor recording or bringing a recording window forward. A running take is still settled before the next step. This setting does not redirect capture to another host: use the named remote Bridge target explicitly and retain/render raw media there. Explicit `capture start` and tours still require choosing the correct host.+Honor the user's recording/storage target. An EDA bound to a low-disk laptop does not authorize automatic capture there. Set `AIFLOW_AUTO_CAPTURE=off` to prevent `step`, `tour 3d` and `tour fields` from starting any recording or bringing a recording window forward (the tours still drive their views and take their start and end screenshots). A running take is still settled before the next step. Every tour take has a stop condition: the fields tour's child is stopped after 13 minutes, its take is capped at 15 minutes and 1 GB (a constant rate that fits), and a recorder that is missing or refuses is a note, never a stop. This setting does not redirect capture to another host: use the named remote Bridge target explicitly and retain/render raw media there. Explicit `capture start` and tours still require choosing the correct host.  Set `AIFLOW_AUTO_PUBLISH_CLIPS=off` when raw clips require editorial/narration review before wiki publication. This disables automatic raw-clip publication,not explicit reviewed publication or engineering gates. For the electronic-load workflow,use remote footage with Adom TTS,no dead air,and separate player captions;never burn captions into reusable footage. Verify the rendered wiki player and caption control. If automatic capture ignored the storage preference,stop it,mark the take rejected,withdraw its unreviewed player from the clip index,and archive it on the requested host before removing local raw copies. +## Clip checks never hold the run (0.2.58)++- The blank check reads its eight sampled frames in one decode pass (the `fps` filter holds the last frame), so an idle window whose frames all sit at the head of its clip is judged on them; "could not read frames" means a full decode found none (#181).+- A clip flagged blank or truncated keeps its raw file, contact sheet and flag, and gets no 10x or action cut (`clip-encode-skipped` in the ledger); a `recut` that passes the check cuts it (#170).+- A clip over `AIFLOW_CLIP_DEFER_MB` (default 1024 MB, 0 never defers) is not checked or cut inside a step, tour or finish: the capture is marked `mediaDeferred` and `recut` or `compose` (which does deferred clips first) does it (#170, #180).+- Not enough disk for a step's clip is a NOTE: the step is recorded without its clip.+ ## Clips before the board, and clips that die (0.2.3)  - **Steps before the board film something.** intake, rules, sourcing, design, simulation, test-plan, parts, project-page, library and schematic do not film an idle editor. Each films the tool's own window when the step works in it (KiCad's schematic editor for `schematic`), else Hydrogen with the run's dashboard (the timeline moves at every command), else the AI's own terminal or IDE window. With none of them on the test box the clip is skipped and the skip is recorded (`clipSkips` in run.json, `clip-skipped` in the ledger). Work in a visible window when you can: a script run off screen films nothing.
skills/aiflow-molecule/SKILL.md+3−3
@@ -185,7 +185,7 @@ Do not populate this map merely to silence a moved-pin error. First retain a bef  ## The STEP export's own warnings (0.2.3) -`adom-aiflow step-export` runs the native `kicad-cli pcb export step` with every layer (models, tracks, pads, zones, mask, silkscreen) and keeps its output, classified apart from the exit status: geometry warnings (`Wire self-interference check failed` with its location and the nearest footprints), model warnings and others. An export that exits 0 with geometry warnings is not a complete twin: `board-glb copper` in a run and `deliver` refuse until you review the STEP at those spots and record `step-export --ack --why "..."` (bound to that STEP file's hash). Never drop silkscreen, mask or zones to make a warning go away. `--log <saved output> --out <the STEP>` classifies an export run elsewhere.+`adom-aiflow step-export` runs the native `kicad-cli pcb export step` with every layer (models, tracks, pads, zones, mask, silkscreen) and keeps its output, classified apart from the exit status: geometry warnings (`Wire self-interference check failed` with its location and the nearest footprints), model warnings and others. It exports from the source project's folder (a temporary copy of the run's board there), so `${KIPRJMOD}` is the project and its library resolves; a `${KICAD<N>_3RD_PARTY}` the environment leaves unset points at a `kicad/3rdparty` or `3rdparty` folder with `3dmodels` in the project folder or its parent (issues #175, #177; `exportContext` in the record says what it chose). An export that exits 0 with geometry warnings or missing 3D models is not a complete twin: `step-export`, `board-glb copper` and `deliver` report it as an open issue (a finding, never a refusal) until you fix it and export again, or review the STEP and record `step-export --ack --why "..."` (bound to that STEP file's hash). A remote kicad-cli (the container shim) does not carry the project library: export on the desktop from the project folder and pass `--log <its output> --out <its STEP>`. Never drop silkscreen, mask or zones to make a warning go away. `--log <saved output> --out <the STEP>` classifies an export run elsewhere.  ## Programmable boards reach the workcell (0.2.18) @@ -201,6 +201,6 @@ Record it as `programming` in board-release.json beside the board (or `programmi {"interface": "swd", "pins": {"SWDIO": "J8", "SWCLK": "J9", "NRST": "J10", "GND": "J7"}, "target": "STM32G431CBT6", "boot": "BOOT0 low via R2; JP1 bridges for DFU"} ``` -`interface` is `swd`, `uart-boot` or `usb-dfu`; `pins` maps each interface signal to the reference that carries it; `target` is the MPN being programmed. A board programmed over its own USB connector instead (an ESP32-S3 or C3 with native USB Serial/JTAG) declares `"interface": "usb-serial-jtag", "access": "usb-cable"` with `connector` (the USB connector's reference), `targetRef` and `target` (the ESP32), `boot` (how to force download mode) and `connectorPins` mapping `D+`, `D-`, `GND` and `VBUS` to `{"pads": [...], "net": "..."}`; the check confirms those pads carry those four distinct nets, and no robot contacts are needed. `step schematic` and `step placement` print the Hint; `finish` refuses the board when a programmable part has no `programming` record, a required pin is missing, or a pin is neither a machine contact nor a labelled test pad.+`interface` is `swd`, `uart-boot` or `usb-dfu`; `pins` maps each interface signal to the reference that carries it; `target` is the MPN being programmed. A board programmed over its own USB connector instead (an ESP32-S3 or C3 with native USB Serial/JTAG) declares `"interface": "usb-serial-jtag", "access": "usb-cable"` with `connector` (the USB connector's reference), `targetRef` and `target` (the ESP32), `boot` (how to force download mode) and `connectorPins` mapping `D+`, `D-`, `GND` and `VBUS` to `{"pads": [...], "net": "..."}`; the check confirms those pads carry those four distinct nets, and no robot contacts are needed. `step schematic` and `step placement` print the Hint; `finish` lists it as an open item when a programmable part has no `programming` record, a required pin is missing, or a pin is not reachable (a machine contact, a labelled test pad or a cable header). -A board with several programmable parts (four MCUs, or an MCU beside an ESP32 on its own USB) records `programming` as a list, one record per interface, each naming the part(s) it programs with `targetRef` or `targetRefs` (their part number must be its `target`); `finish` refuses a programmable part no record names. SWD over a Tag-Connect footprint (TC2030 / TC2050, or any project-library pad set made for a spring-pin cable: the pads and nets are the evidence, not the footprint's name) declares `"access": "tag-connect"` with `connector` (the Tag-Connect footprint), `targetRef`, `target` and `connectorPins` mapping `SWDIO`, `SWCLK`, `GND` and (when reset is wired) `NRST` to `{"pads": [...], "net": "..."}`; the check confirms the pads carry those nets and that the SWD nets reach the part, like the USB-cable record.+A board with several programmable parts (four MCUs, or an MCU beside an ESP32 on its own USB) records `programming` as a list, one record per interface, each naming the part(s) it programs with `targetRef` or `targetRefs` (their part number must be its `target`); `finish` lists a programmable part no record names as an open item. SWD over a Tag-Connect footprint (TC2030 / TC2050, or any project-library pad set made for a spring-pin cable: the pads and nets are the evidence, not the footprint's name) declares `"access": "tag-connect"` with `connector` (the Tag-Connect footprint), `targetRef`, `target` and `connectorPins` mapping `SWDIO`, `SWCLK`, `GND` and (when reset is wired) `NRST` to `{"pads": [...], "net": "..."}`; the check confirms the pads carry those nets and that the SWD nets reach the part, like the USB-cable record. A cable header (an STDC14 or ARM-10 SWD header for an ST-LINK or J-Link cable, a UART-boot or UPDI header) is the same record with `"access": "cable"` (or `"header"`): `connectorPins` maps that interface's signals (SWDIO, SWCLK, GND and optional NRST; TX, RX, GND; UPDI, GND), `targetRef` may be left out when the board has one programmable part, and `"pins": {"SWDIO": "J7.4", ...}` written as pads of one connector reads as the same header (each pad's net from the board).
skills/aiflow-project-page/SKILL.md+2
@@ -13,6 +13,8 @@ Analysis results belong on the page people read, not only on the run report. Aft  `adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (per part: its symbol is in the library, the symbol's Footprint names a footprint file in it, and that footprint's model file is in it; parts may share a footprint or a model, and a part marked `noModelExpected` needs none; the error names each part by MPN and references) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts; an entry marked `"prefer": true` replaces its MPN's wiki page (aiflow-sourcing, "Which source wins"). `--no-push` pushes nothing to the page but still adds the wiki parts to your private Cloud Library and exports it (that is where their files come from); `--local-only` makes no wiki call at all, packages only the project-library parts and names the wiki parts it left out. +The PCM package version follows its content (issue #169): the first build is 1.0.0; a rebuild whose files differ from the last build (the run's `pcm/build.json`, else the page's published `pcm/build.json` or `pcm/packages.json`) gets the next patch version, and the same content keeps its version, so KiCad's PCM sees an update. `pcm/build.json` (version and content hash) is pushed with the repository. `pcm check` reports an installed version older than the build, with the `kicad_pcm_install {"identifier": ..., "update": true}` call.+ A board in a folder of an existing project page (one page per project): `adom-aiflow project-page --page <owner/slug> --page-dir <folder>` writes the board's README to `<folder>/README.md` (only when the folder has none yet, or with `--replace-readme`) and its PCM repository to `<folder>/pcm/`, with the repository, packages and download URLs under `.../files/<folder>/pcm/`. It never creates a page: the page must exist, `--existing` is implied and `--create` is refused. The library nickname (`PCM_<slug>-<last folder name>`), the PCM identifier and the Cloud Library name carry the folder, so two boards on one page never share a library. The step pages then go to `<folder>/steps/` and the build section to `<folder>/README.md`.  **A molecule is different.** When the spec has `molecule`, `project-page` creates the page with `--kind molecule`, not `board`. Do not push a GLB to it or bind `component.parts.model_3d` (steps 2 and 6 below do not apply to the model): the wiki's model registry builds a molecule page's GLB, footprint and symbol from the sources molecule-publish publishes after `export` (STEP, board, schematic, `<slug>-fab.zip`, `export.json`; skill aiflow-molecule, section 5), and molecule-publish refuses a pushed GLB (`REGISTRY_MODEL_FILE_REFUSED`). `project-page-check` skips the model on a page of kind molecule and suggests dropping a bound one.
skills/aiflow-sourcing/SKILL.md+1−1
@@ -171,7 +171,7 @@ When a part's page must stay private, waits for an owner's sign-off, or the foot  `symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source). -Which source wins for one MPN (issue #157), in this order: a page named in the netlist (`page`); a complete project-library entry marked `"prefer": true` in provenance.json (the run records the page it replaced as `wikiOverridden`, and `project-page` and `altium library` package only the library's files for it); the wiki page `parts` matched; an unmarked project-library entry. When an MPN has both a page and a complete unmarked entry, `parts` says so and uses the page: compare the two (pads, thermal pad, 3D height, manufacturer) against the datasheet and mark the entry `"prefer": true` if it is the verified one. A marked entry that is incomplete keeps the page and says what is missing. `parts` does not compare a page's geometry with the datasheet itself. `project-page` packages these parts into the board's PCM library beside the wiki parts (on Altium, `altium library` builds them into the SchLib and PcbLib from these same files), and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.+Which source wins for one MPN (issue #157), in this order: a page named in the netlist (`page`) or in the BOM row's `wiki` (or `page`) column, `owner/slug` (issue #178: the way an off-board part such as a battery, which is not in the netlist, names a page whose slug is not its MPN; used when its page.json reads); a complete project-library entry marked `"prefer": true` in provenance.json (the run records the page it replaced as `wikiOverridden`, and `project-page` and `altium library` package only the library's files for it); the wiki page `parts` matched (the MPN's slug forms, then a search hit whose slug holds the MPN as a whole part, like adom/adom-1317 for 1317, whose page.json `component.mpn` is that MPN); an unmarked project-library entry. When an MPN has both a page and a complete unmarked entry, `parts` says so and uses the page: compare the two (pads, thermal pad, 3D height, manufacturer) against the datasheet and mark the entry `"prefer": true` if it is the verified one. A marked entry that is incomplete keeps the page and says what is missing. `parts` does not compare a page's geometry with the datasheet itself. `project-page` packages these parts into the board's PCM library beside the wiki parts (on Altium, `altium library` builds them into the SchLib and PcbLib from these same files), and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.  ## Required component pages include off-board hardware 
tests/parts-sources-workflow.py+24−1
@@ -3,7 +3,9 @@ issue #161: an ordering code with a "/" packaging suffix (MCP73831T-2ACI/OT) fin for the MPN without it (adom/mcp73831t-2aci), says how it matched, and a page that keeps the suffix (adom/sp485een-l-tr) is still found by its own slug first; issue #157: a project-library entry for an MPN that also has a wiki page is named (page used), and with-"prefer": true in provenance.json the library entry wins (wiki null, wikiOverridden kept).+"prefer": true in provenance.json the library entry wins (wiki null, wikiOverridden kept);+issue #178: a BOM `wiki` column names a row's page (adom/adom-1578), and a page under another slug that+declares the MPN in component.mpn (adom/adom-1317 for MPN 1317) is found when the slug forms miss.    python3 tests/parts-sources-workflow.py <abs path to target/release/adom-aiflow> """@@ -25,7 +27,14 @@ if a[:1] == ["search"]:         hits = [H("sp485een-l", "SP485EEN-L", "phrase"), H("sp485een-l-tr", "SP485EEN-L/TR", "exact_title" if q == "SP485EEN-L/TR" else "phrase")]     elif q == "ABC-123":         hits = [H("abc-123", "ABC-123", "exact_slug")]+    elif q == "1317":+        # issue #178: the battery's page is adom/adom-1317 (a bare numeric slug is a poor page name)+        hits = [H("adom-1317", "Adafruit 1317 LiPo battery", "phrase"), H("adom-13170", "Other", "phrase")]     print(json.dumps({"status": "ok", "data": {"results": hits}})); sys.exit(0)+if a[:2] == ["repo", "show"] and a[3] == "page.json":+    pages = {"adom/adom-1317": {"component": {"mpn": "1317"}}, "adom/adom-1578": {"component": {"mpn": "1578"}}}+    if a[2] in pages: print(json.dumps(pages[a[2]])); sys.exit(0)+    print(json.dumps({"status": "error", "error": "not found"})); sys.exit(1) print(json.dumps({"status": "error", "error": "fake adom-wiki: " + " ".join(a)})); sys.exit(2) ''' @@ -77,4 +86,18 @@ with tempfile.TemporaryDirectory(prefix='aiflow-parts-sources-') as td:     assert 'project library (preferred over adom/abc-123)' in out, out     assert any('project library\'s entry is used' in n for n in rec['sourceNotes']), rec +    # --- #178: a BOM row names its page; else a page declaring the MPN in component.mpn is found ---------+    bom2 = p / 'bom2.csv'+    bom2.write_text('ref,mpn,placement,wiki\nBT1,1578,off-board,adom/adom-1578\nBT2,1317,off-board,\nBT3,9999,off-board,adom/no-such-page\n')+    run2 = p / 'run2'+    assert call(run2, 'intake', '--prompt-time', '2026-11-06T00:00:00Z', '--engine', 'test').returncode == 0+    r = call(run2, 'parts', '--bom', str(bom2)); out = r.stdout + r.stderr+    assert r.returncode == 0, out+    rec = parts(run2); rows = {x['mpn']: x for x in rec['parts']}+    assert rows['1578']['wiki'] == 'adom/adom-1578' and 'named in the BOM' in rows['1578']['wikiMatch'], rows+    assert rows['1317']['wiki'] == 'adom/adom-1317' and 'component.mpn' in rows['1317']['wikiMatch'], rows+    assert rows['9999']['wiki'] is None and 'could not be read' in rows['9999']['wikiMatch'], rows+    assert rec['missing'] == ['9999'], rec+    assert 'adom/adom-1578 is named in the BOM' in out, out+ print('parts-sources-workflow: OK')
tests/project-page-library.py+52
@@ -258,6 +258,15 @@ class ProjectPageLibrary(unittest.TestCase):         readme = (self.run_dir / "project-page" / "README.md").read_text()         self.assertIn("project library (no public page yet)", readme) +    def test_a_project_library_part_on_several_references_is_packaged_once(self):+        a = self.project_lib(); b = dict(a)+        a["refs"], b["refs"] = ["C2"], ["C28", "C29"]+        v = self.run_page(parts=self.parts() + [a, b])+        self.assertTrue(v["ok"], v)+        with zipfile.ZipFile(v["zip"]) as z:+            sym = z.read("symbols/test-board.kicad_sym").decode()+        self.assertEqual(sym.count('(symbol "ABC-123"'), 1)+     def test_only_project_library_parts_need_no_cloud_library(self):         v = self.run_page(parts=[self.project_lib()])         self.assertTrue(v["ok"], v)@@ -337,6 +346,34 @@ class ProjectPageLibrary(unittest.TestCase):         self.assertTrue(self.run_page(push=True, create=False, pageDir="rig/boards/rig-carrier")["ok"])         self.assertNotIn("rig/boards/rig-carrier/README.md", self.read_state()["pushes"][-1]["files"]) +    def test_a_rebuild_with_new_content_gets_a_new_pcm_version(self):+        # issue #169: KiCad's PCM updates only to a higher version, so changed content bumps it+        v1 = self.run_page()+        self.assertEqual(v1["pcmVersion"], "1.0.0", v1)+        again = self.run_page()+        self.assertEqual(again["pcmVersion"], "1.0.0", "the same content keeps its version")+        pages = dict(PAGES); pages.pop("adom/c1-page")+        v2 = self.run_page(parts=self.parts(pages))+        self.assertEqual(v2["pcmVersion"], "1.0.1", v2)+        self.assertTrue(v2["zip"].endswith("-pcm-1.0.1.zip"))+        pkgs = json.loads((self.run_dir / "project-page" / "pcm" / "packages.json").read_text())+        self.assertEqual(pkgs["packages"][0]["versions"][0]["version"], "1.0.1")+        with zipfile.ZipFile(v2["zip"]) as z:+            self.assertEqual(json.loads(z.read("metadata.json"))["versions"][0]["version"], "1.0.1")+        build = json.loads((self.run_dir / "project-page" / "pcm" / "build.json").read_text())+        self.assertEqual((build["version"], build["identifier"]), ("1.0.1", v2["identifier"]))++    def test_a_page_published_before_versions_followed_content_is_bumped(self):+        # the page serves 1.0.0 (no build.json): a new run's build is 1.0.1, and build.json is pushed+        pkgid = "inc.adom.wiki.adom.test-board"+        s = self.read_state(); s["repo"] = {"page.json": "{}", "pcm/packages.json": json.dumps({"packages": [{"identifier": pkgid, "versions": [{"version": "1.0.0"}]}, {"identifier": "other", "versions": [{"version": "7.0.0"}]}]})}+        self.write_state(s)+        v = self.run_page(push=True, create=False, existing=True)+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["identifier"], pkgid)+        self.assertEqual(v["pcmVersion"], "1.0.1", v)+        self.assertIn("pcm/build.json", self.read_state()["pushes"][-1]["files"])+     def test_page_dir_never_creates_a_page(self):         v = self.run_page(push=True, create=False, pageDir="rig/boards/a")         self.assertFalse(v["ok"]); self.assertIn("never creates one", v["error"])@@ -525,6 +562,21 @@ def load_script():     return mod  +class PcmVersion(unittest.TestCase):+    """pcm_version: the PCM package version follows its content (issue #169)."""+    pp = load_script()++    def test_versions(self):+        v = self.pp.pcm_version+        self.assertEqual(v([], "a")[0], "1.0.0")+        self.assertEqual(v([{"version": "1.0.0", "contentSha256": "a"}], "a")[0], "1.0.0")+        self.assertEqual(v([{"version": "1.0.0", "contentSha256": "a"}], "b")[0], "1.0.1")+        self.assertEqual(v([{"version": "1.0.9"}], "a")[0], "1.0.10", "no recorded hash counts as changed")+        self.assertEqual(v([{"version": "1.0.2"}, {"version": "1.0.2", "contentSha256": "a"}], "a")[0], "1.0.2")+        self.assertEqual(v([{"version": "1.0.3", "contentSha256": "x"}, {"version": "1.0.1", "contentSha256": "a"}], "a")[0], "1.0.4", "never back to a lower version")+        self.assertEqual(v([{"version": "0.1"}], "a", "1.0.0")[0], "1.0.0")++ class PageStepName(unittest.TestCase):     """page_step_name on the page.json shapes of the five basic part pages (read 2026-11-04)."""     pp = load_script()
tests/sides-pours-workflow.pyadded+121
@@ -0,0 +1,121 @@+"""Issues #168 and #179 end to end, with no CAD and no desktop.++#168: on a one-copper-layer board whose GND and +5V pads only the planned pours join, `gate` before+`land pours` puts the pours `pour` planned into the gate copy, so the pads they join are not read as+unconnected. A fake kicad-cli reports every pad unconnected unless the board it checks carries zones.++#179: `place pack` on a two-sided board puts a bottom part straight under a top QFN (courtyards meet+only on their own side), keeps a top part out of the QFN, and `place check`'s bounding-box advisory+does not name a top/bottom pair.++Usage: python3 tests/sides-pours-workflow.py <adom-aiflow binary>"""+import json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())++FAKE_BRIDGE = r'''#!/usr/bin/env python3+import json+print(json.dumps({"success": True, "status": "ok", "revision": "r1"}))+'''+# DRC: every pad on GND or +5V is unconnected unless the board carries a zone of that net+FAKE_CLI = r'''#!/usr/bin/env python3+import sys, json, re+a = sys.argv[1:]+if "--help" in a: print("--refill-zones"); sys.exit(0)+out = a[a.index("--output") + 1]+text = open(a[-1]).read()+unc = []+for net in ("GND", "+5V"):+    if not re.search(r'\(zone\s*\(net \d+\)\s*\(net_name "' + re.escape(net) + '"', text):+        unc.append({"type": "unconnected_items", "severity": "error", "description": "Missing connection between items",+                    "items": [{"description": f"Pad 1 [{net}] of R1"}, {"description": f"Pad 1 [{net}] of R2"}]})+json.dump({"violations": [], "unconnected_items": unc}, open(out, "w"))+'''++ONE_LAYER = '''(kicad_pcb (version 20240108) (generator "pcbnew")+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+  (net 0 "") (net 1 "GND") (net 2 "+5V")+  (gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))+  (footprint "R" (layer "F.Cu") (at 5 5) (property "Reference" "R1") (fp_rect (start -1 -1) (end 1 11) (layer "F.CrtYd"))+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))+    (pad "2" smd rect (at 0 10) (size 1 1) (layers "F.Cu") (net 2 "+5V")))+  (footprint "R" (layer "F.Cu") (at 35 5) (property "Reference" "R2") (fp_rect (start -1 -1) (end 1 11) (layer "F.CrtYd"))+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))+    (pad "2" smd rect (at 0 10) (size 1 1) (layers "F.Cu") (net 2 "+5V"))))+'''+++def smd(ref, layer, x, y, h, w):+    crt = layer.replace('.Cu', '.CrtYd')+    return (f'\t(footprint "T:{ref}"\n\t\t(layer "{layer}")\n\t\t(at {x} {y})\n\t\t(property "Reference" "{ref}")\n'+            f'\t\t(fp_rect (start {-w} {-h}) (end {w} {h}) (layer "{crt}"))\n'+            f'\t\t(pad "1" smd rect (at 0 0) (size 0.5 0.5) (layers "{layer}") (net "N{ref}"))\n\t)\n')+++TWO_SIDED = ('(kicad_pcb\n\t(version 20260206)\n\t(generator "test")\n\t(layers\n\t\t(0 "F.Cu" signal)\n\t\t(2 "B.Cu" signal)\n\t\t(25 "Edge.Cuts" user)\n\t)\n'+             + smd('U1', 'F.Cu', 10, 10, 2.0, 2.0) + smd('C1', 'B.Cu', 3, 3, 0.5, 0.9) + smd('C2', 'F.Cu', 3, 17, 0.5, 0.9)+             + '\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 30 20)\n\t\t(layer "Edge.Cuts")\n\t)\n)\n')++passed = 0+++def check(cond, what, out=''):+    global passed+    if not cond:+        sys.exit(f'FAIL: {what}\n{out}')+    passed += 1+++with tempfile.TemporaryDirectory(prefix='aiflow-sides-pours-') as td:+    p = pathlib.Path(td)+    bindir = p / 'bin'; bindir.mkdir()+    (bindir / 'adom-bridge').write_text(FAKE_BRIDGE); (bindir / 'adom-bridge').chmod(0o755)+    (bindir / 'kicad-cli').write_text(FAKE_CLI); (bindir / 'kicad-cli').chmod(0o755)+    env = dict(os.environ, AIFLOW_DASHBOARD='off', AIFLOW_NO_PART_SEARCH='1', AIFLOW_NO_PART_LOOKUP='1',+               ADOM_AIFLOW_KICAD_CLI=str(bindir / 'kicad-cli'), PATH=f"{bindir}:{os.environ['PATH']}")+    env.pop('KICAD_REMOTE_TARGET', None)++    def call(run, *args):+        return subprocess.run([binary, '--run', str(run), '--ai-thread', 'sides-pours-test', *args], env=env, capture_output=True, text=True, timeout=300)++    # ---- issue #168: pour-only power nets on one copper layer+    (p / 'one.kicad_pcb').write_text(ONE_LAYER)+    (p / 'spec.json').write_text(json.dumps({"board": "one-layer pours", "clearance": 0.2, "pours": [+        {"net": "GND", "layers": ["F.Cu"], "polygon": [[1, 1], [39, 1], [39, 9], [1, 9]]},+        {"net": "+5V", "layers": ["F.Cu"], "polygon": [[1, 11], [39, 11], [39, 19], [1, 19]]}]}))+    (p / 'plan-ai.json').write_text(json.dumps({"engine": "ai", "nets": []}))+    run = p / 'run'+    r = call(run, 'start', '--board', str(p / 'one.kicad_pcb'), '--spec', str(p / 'spec.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/one.kicad_pcb')+    check(r.returncode == 0, 'start', r.stdout + r.stderr)+    r = call(run, 'gate', '--plan', str(p / 'plan-ai.json'))+    out = r.stdout + r.stderr+    check(r.returncode == 0 and '2 unconnected' in out, 'before pour: the gate reads the pour-only pads as unconnected (a finding)', out)+    r = call(run, 'pour')+    check(r.returncode == 0 and '2 zones' in r.stdout + r.stderr, 'pour plans the two zones', r.stdout + r.stderr)+    r = call(run, 'gate', '--plan', str(p / 'plan-ai.json'))+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'gate passed' in out and '0 unconnected' in out, 'after pour: the planned pours join the pads in the gate copy', out)+    g = json.loads((run / 'gate.json').read_text())+    check(g.get('plannedPours', {}).get('zones') == 2 and g['plannedPours']['nets'] == ['+5V', 'GND'], 'gate.json names the planned pours', json.dumps(g.get('plannedPours')))+    routed = (run / 'board-routed.kicad_pcb').read_text()+    check(routed.count('(zone') == 2, 'the gate copy carries the two zones', routed[-800:])+    check((p / 'one.kicad_pcb').read_text() == ONE_LAYER, 'the source board is untouched')++    # ---- issue #179: a bottom part under a top part+    (p / 'two.kicad_pcb').write_text(TWO_SIDED)+    (p / 'spec2.json').write_text(json.dumps({"board": "two-sided", "clearance": 0.2}))+    run2 = p / 'run2'+    r = call(run2, 'start', '--board', str(p / 'two.kicad_pcb'), '--spec', str(p / 'spec2.json'), '--engine', 'test', '--target', 'fakebox', '--remote-board', 'C:/boards/two.kicad_pcb')+    check(r.returncode == 0, 'start two-sided', r.stdout + r.stderr)+    (p / 'wish.json').write_text(json.dumps([{"ref": "C1", "x": 10, "y": 10, "rot": [0], "radius": 2.0}, {"ref": "C2", "x": 10, "y": 10, "rot": [0], "radius": 4.0}]))+    r = call(run2, 'place', 'pack', '--wish', str(p / 'wish.json'), '--out', str(p / 'moves.json'))+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'legal spots for 2 of 2' in out, 'pack places both parts', out)+    moves = json.loads((p / 'moves.json').read_text())+    check((moves['C1']['x'], moves['C1']['y']) == (10, 10), 'the bottom part sits at its wish under the QFN', json.dumps(moves))+    check(abs(moves['C2']['x'] - 10) > 1.0 or abs(moves['C2']['y'] - 10) > 1.0, 'the top part leaves the QFN courtyard', json.dumps(moves))+    r = call(run2, 'place', 'check', '--moves', str(p / 'moves.json'))+    out = r.stdout + r.stderr+    check(r.returncode == 0 and 'C1 U1' not in out, 'the advisory does not name the top/bottom pair', out)++print(f'OK sides-pours-workflow: {passed} checks passed')
tests/stage-return-workflow.pyadded+101
@@ -0,0 +1,101 @@+"""Issues #171-#174 end to end, no CAD, bridge or dashboard:+#174 an ack given on `thermal-vias land` is kept and honoured by finish, and --ack repeats;+#172 a plain diode (BAT54) on a project library nicknamed after an LED driver is not an LED;+#173 an SWD cable header (STDC14) passes the programming check;+#171 finish names the earliest step its open items go back to, finish-guard repeats it, and a stop+`done --stop-evidence` accepted is honoured by later guard calls without the path until a new prompt."""+import json, os, pathlib, subprocess, sys, tempfile++binary = str(pathlib.Path(sys.argv[1]).resolve())+BOARD = """(kicad_pcb (version 20241229) (generator "test") (general (thickness 1.6))+ (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user) (37 "F.SilkS" user))+ (net 0 "") (net 1 "DRAIN") (net 2 "GND") (net 3 "SWDIO") (net 4 "SWCLK") (net 5 "NRST") (net 6 "K")+ (gr_rect (start 0 0) (end 40 30) (layer "Edge.Cuts"))+ (footprint "led-driver:PowerPAK_SO-8" (layer "F.Cu") (at 10 10) (property "Reference" "Q1" (at 0 -3) (layer "F.SilkS")) (property "Value" "BSC0902" (at 0 3) (layer "F.Fab"))+  (pad "5" smd rect (at 0 0) (size 3 3) (layers "F.Cu") (net 1 "DRAIN"))+  (pad "1" smd rect (at -3 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND")))+ (footprint "led-driver:SOT-23" (layer "F.Cu") (at 20 10) (property "Reference" "D1" (at 0 -2) (layer "F.SilkS")) (property "Value" "BAT54" (at 0 2) (layer "F.Fab"))+  (pad "1" smd rect (at -1 0) (size 0.6 0.6) (layers "F.Cu") (net 1 "DRAIN"))+  (pad "2" smd rect (at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 6 "K")))+ (footprint "Package_QFP:LQFP-48_7x7mm_P0.5mm" (layer "F.Cu") (at 30 20) (property "Reference" "U1" (at 0 -5) (layer "F.SilkS")) (property "Value" "STM32G0B1CBT6" (at 0 5) (layer "F.Fab"))+  (pad "1" smd rect (at -3 0) (size 1 0.3) (layers "F.Cu") (net 3 "SWDIO"))+  (pad "2" smd rect (at -3 1) (size 1 0.3) (layers "F.Cu") (net 4 "SWCLK"))+  (pad "3" smd rect (at -3 2) (size 1 0.3) (layers "F.Cu") (net 5 "NRST")))+ (footprint "Connector_Samtec:FTSH-107-01-L-DV-K" (layer "F.Cu") (at 10 22) (property "Reference" "J7" (at 0 -3) (layer "F.SilkS"))+  (pad "4" smd rect (at 0 0) (size 0.7 0.7) (layers "F.Cu") (net 3 "SWDIO"))+  (pad "5" smd rect (at 1.27 0) (size 0.7 0.7) (layers "F.Cu") (net 2 "GND"))+  (pad "6" smd rect (at 2.54 0) (size 0.7 0.7) (layers "F.Cu") (net 4 "SWCLK"))+  (pad "12" smd rect (at 3.81 0) (size 0.7 0.7) (layers "F.Cu") (net 5 "NRST"))))+"""+SPEC = {"hot": {"Q1": {"watts": 1.0, "tabNet": "DRAIN"}},+        "programming": {"interface": "swd", "access": "cable", "connector": "J7", "targetRef": "U1", "target": "STM32G0B1CBT6",+                        "connectorPins": {"SWDIO": {"pads": ["4"], "net": "SWDIO"}, "SWCLK": {"pads": ["6"], "net": "SWCLK"},+                                          "NRST": {"pads": ["12"], "net": "NRST"}, "GND": {"pads": ["5"], "net": "GND"}}}}++with tempfile.TemporaryDirectory(prefix="aiflow-stage-return-") as td:+    p = pathlib.Path(td)+    env = dict(os.environ, AIFLOW_DASHBOARD="off", AIFLOW_NO_PART_SEARCH="1", AIFLOW_NO_PART_LOOKUP="1")+    (p / "board.kicad_pcb").write_text(BOARD)+    (p / "spec.json").write_text(json.dumps(SPEC))++    def call(*args):+        return subprocess.run([binary, "--run", "run", "--ai-thread", "stage-return-test", *args], cwd=p, env=env, capture_output=True, text=True, timeout=120)++    def data():+        return json.loads((p / "run" / "run.json").read_text())++    r = call("start", "--board", "board.kicad_pcb", "--spec", "spec.json", "--engine", "test")+    assert r.returncode == 0, r.stdout + r.stderr++    # #174: Q1's drain has no copper on B.Cu: the plan says so; finish lists it until acknowledged+    r = call("thermal-vias", "plan")+    assert r.returncode == 0 and "Q1" in r.stdout, r.stdout + r.stderr+    r = call("finish")+    assert "heatsink vias: Q1 has 0 of" in r.stderr and "thermal-vias land --ack Q1" in r.stderr, r.stderr+    # #172 / #173: the BAT54 is not an LED, and the STDC14 header programs U1+    assert "LED states: no LEDs on the board" in r.stdout + r.stderr, r.stdout + r.stderr+    assert "programming: U1 STM32G0B1CBT6, interface swd with declared access checked" in r.stdout + r.stderr, r.stdout + r.stderr+    # an ack given on land (as the plan's note suggests) is saved and honoured by finish, no plan --ack needed+    r = call("thermal-vias", "land", "--ack", "Q1", "--why", "drain net has no B.Cu copper; heat leaves through the F.Cu pour")+    assert r.returncode == 0, r.stdout + r.stderr+    assert "Q1" in data()["thermalViaAcks"], data().get("thermalViaAcks")+    # --ack repeats and takes a comma list; earlier acks are kept+    r = call("thermal-vias", "land", "--ack", "Q2", "--ack", "Q3,Q4", "--why", "fixture")+    assert r.returncode == 0, r.stdout + r.stderr+    assert sorted(data()["thermalViaAcks"]) == ["Q1", "Q2", "Q3", "Q4"], data()["thermalViaAcks"]++    # #171: at outline, finish names the earliest step its open items go back to+    assert call("step", "outline").returncode == 0+    r = call("finish")+    assert r.returncode == 0, r.stdout + r.stderr+    assert "heatsink vias: Q1" not in r.stderr, r.stderr+    assert "Suggested: earliest step these open items go back to:" in r.stderr, r.stderr+    sr = data()["suggestedReturn"]+    assert sr and sr["step"], sr+    g = json.loads(call("finish-guard", "--json").stdout)+    assert any(s.startswith("earliest step these open items go back to: " + sr["step"]) for s in g["suggestions"]), g["suggestions"]+    # visiting that step again retires the suggestion+    call("step", sr["step"], "--back", "--why", "rework")+    g = json.loads(call("finish-guard", "--json").stdout)+    assert not any(s.startswith("earliest step") for s in g["suggestions"]), g["suggestions"]++    # #171: an accepted stop is honoured by later guard calls (a host hook passes no path) until a new prompt+    assert call("prompt", "--text", "Finish the board").returncode == 0+    ev = p / "owner.json"+    ev.write_text(json.dumps(dict(kind="user-pause", reason="owner asked to pause for review", userInstruction="pause until I say go", instructionAt="2026-11-09T00:00:00Z")))+    assert call("finish-guard", "--json").returncode == 2+    r = call("done", "--message", "paused for the owner", "--stop-evidence", str(ev))+    assert r.returncode == 0, r.stdout + r.stderr+    for _ in range(2):+        r = call("finish-guard", "--json")+        g = json.loads(r.stdout)+        assert r.returncode == 0 and g["decision"] == "user-paused" and g["stopEvidencePath"].endswith("owner.json"), r.stdout+        assert not g["engineeringComplete"]+    assert call("prompt", "--text", "go on").returncode == 0+    r = call("finish-guard", "--json"); assert r.returncode == 2, (r.stdout, data().get("stopEvidence"))+    # a recorded stop whose file is gone fails closed+    call("done", "--message", "paused", "--stop-evidence", str(ev))+    ev.unlink()+    assert call("finish-guard", "--json").returncode == 2++print("PASS: land --ack kept and honoured by finish, --ack repeats; a BAT54 is not an LED; an STDC14 header programs the MCU; finish suggests the earliest return; an accepted stop holds until the next prompt")
tools/project-page.py+87−8
@@ -6,7 +6,7 @@ Content Manager (PCM) repository on the page so KiCad installs the whole parts l   python3 -c "$(cat project-page.py)" <config.json>  config: {"run": dir, "page": "owner/slug", "title", "brief", "summary", "parts": [{refs, mpn,-manufacturer, value, wiki}], "create": bool, "push": bool, "localOnly": bool (no wiki call at all: project-library parts only), "private": bool, "version": "1.0.0",+manufacturer, value, wiki}], "create": bool, "push": bool, "localOnly": bool (no wiki call at all: project-library parts only), "private": bool, "version": "1.0.0" (the first build's; a later build whose package content changed gets the next patch version, issue #169), "pageDir": "rig/boards/<board>" or null, "molecule": bool}  molecule (issues #137, #158): the spec declares a molecule (or the requirements say formFactor.kind "molecule"), so a page this makes is of kind molecule, not board.@@ -46,6 +46,15 @@ KICAD10_SYM_VERSION = 20251024 ADD_CHUNK = 10  ++def mpn_candidates(p):+    """The MPN, then the MPN without a packaging suffix (MCP73831T-2ACI/OT -> MCP73831T-2ACI) when the+    parts record matched the page that way (wikiMatch), so the page's symbol is found by its own name."""+    out = [p["mpn"]]+    if p.get("wiki") and "/" in p["mpn"]:+        out.append(p["mpn"].split("/")[0])+    return out+ def clip(text, n=600):     """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""     text = text or ""@@ -565,7 +574,7 @@ def symbol_of(p, syms, by_norm, rep_names):     """The part's symbol in the packaged library, or None."""     if p.get("projectLib") and not p.get("wiki"):         return p["projectLib"]["symbol"] if p["projectLib"]["symbol"] in syms else None-    return next((by_norm[norm(c)] for c in [p["mpn"]] + rep_names.get(norm(p["mpn"]), []) if norm(c) in by_norm), None)+    return next((by_norm[norm(c)] for c in mpn_candidates(p) + rep_names.get(norm(p["mpn"]), []) if norm(c) in by_norm), None)   # --- Route (b) for a footprint with no 3D model ---@@ -749,7 +758,7 @@ def completeness(cfg_parts, report, sym_path, fp_dir, m_dir, not_bound=None):         if p.get("projectLib") and not p.get("wiki"):             name = p["projectLib"]["symbol"] if p["projectLib"]["symbol"] in syms else None         else:-            name = next((by_norm[norm(c)] for c in [p["mpn"]] + rep_names.get(norm(p["mpn"]), []) if norm(c) in by_norm), None)+            name = next((by_norm[norm(c)] for c in mpn_candidates(p) + rep_names.get(norm(p["mpn"]), []) if norm(c) in by_norm), None)         if not name:             missing.append(label)             continue@@ -779,6 +788,67 @@ def completeness(cfg_parts, report, sym_path, fp_dir, m_dir, not_bound=None):     return counts, problems  +def pcm_content_sha(stage):+    """The package's content: every file but metadata.json, by path and bytes (never the zip, whose+    timestamps change on every build)."""+    h = hashlib.sha256()+    for d, dirs, fs in os.walk(stage):+        dirs.sort()+        for f in sorted(fs):+            p = os.path.join(d, f); rel = os.path.relpath(p, stage).replace(os.sep, "/")+            if rel == "metadata.json":+                continue+            h.update(rel.encode() + b"\0"); h.update(open(p, "rb").read()); h.update(b"\0")+    return h.hexdigest()+++def vtuple(v):+    return tuple(int(x) if x.isdigit() else 0 for x in re.split(r"[.\-+]", str(v or "0")))+++def pcm_version(previous, content, base="1.0.0"):+    """The package version for this content (issue #169). KiCad's PCM installs a version once and updates+    only to a higher one, so a rebuild whose content differs from the last build gets the next patch+    version; the same content keeps its version. previous: [{"version", "contentSha256"?}] from the run's+    own record and the page's published pcm files; an entry without a hash counts as different content."""+    prev = [p for p in previous or [] if p and p.get("version")]+    if not prev:+        return base, "first build"+    top = max(prev, key=lambda p: (vtuple(p["version"]), bool(p.get("contentSha256"))))+    if top.get("contentSha256") == content:+        return top["version"], f"unchanged since {top['version']}"+    parts = [int(x) if x.isdigit() else 0 for x in str(top["version"]).split("+")[0].split("-")[0].split(".")]+    parts = (parts + [0, 0, 0])[:max(3, len(parts))]; parts[-1] += 1+    bumped = ".".join(map(str, parts))+    return max(bumped, base, key=vtuple), f"content changed since {top['version']}"+++def pcm_previous(cfg, folder, work, pkgid):+    """The package versions built before: the run's own pcm/build.json, then (unless local only) the+    page's published pcm/build.json and pcm/packages.json. A failed read is no previous build."""+    out = []+    try:+        b = json.load(open(os.path.join(work, "pcm", "build.json")))+        if b.get("identifier") == pkgid: out.append(b)+    except (OSError, ValueError):+        pass+    if cfg.get("localOnly") or not (cfg.get("push") or cfg.get("existing") or folder):+        return out+    for name in ("build.json", "packages.json"):+        p = subprocess.run(["adom-wiki", "repo", "show", cfg["page"], under(folder, f"pcm/{name}")], capture_output=True, text=True, stdin=subprocess.DEVNULL)+        if p.returncode != 0 or "{" not in p.stdout:+            continue+        try:+            v = json.loads(p.stdout[p.stdout.index("{"):p.stdout.rindex("}") + 1])+        except ValueError:+            continue+        if name == "build.json":+            if v.get("identifier") == pkgid: out.append(v)+        else:+            out += [{"version": x.get("version")} for pk in v.get("packages") or [] if pk.get("identifier") == pkgid for x in pk.get("versions") or []]+    return out++ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts=(), report=None):     """Repackage the Cloud Library bundle (and the project-library parts) in the PCM layout, bind a wiki part's     own page STEP where its footprint names no model (bind_page_models), normalise and parse-check the symbol@@ -803,7 +873,9 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts                 open(os.path.join(fp_dir, m), "w").write(t)     sp = os.path.join(sym_dir, f"{lib}.kicad_sym")     t = open(sp).read() if os.path.isfile(sp) else ""-    proj = [p for p in parts if p.get("projectLib") and not p.get("wiki")]+    # one entry per MPN: the parts record lists a part once per reference group, so a value on several+    # references (four 1 uF caps) arrives more than once and must be merged only once+    proj = list({p["mpn"]: p for p in reversed([p for p in parts if p.get("projectLib") and not p.get("wiki")])}.values())[::-1]     versions = [sym_header_version(t) or 0] + [sym_header_version(open(p["projectLib"]["symbolFile"]).read()) or 0 for p in proj]     t = merge_project_parts(proj, t, fp_dir, m_dir, lib, pkgid)     if t:@@ -837,7 +909,12 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts                                 f": symbol, footprint and STEP model. Project page: {page_url}",             "identifier": pkgid, "type": "library", "author": {"name": "Adom wiki", "contact": {"web": page_url}},             "license": "MIT", "resources": {"homepage": page_url},-            "versions": [{"version": version, "status": "stable", "kicad_version": "10.0" if header >= KICAD10_SYM_VERSION else "9.0"}]}+            "versions": [{"version": None, "status": "stable", "kicad_version": "10.0" if header >= KICAD10_SYM_VERSION else "9.0"}]}+    # issue #169: the version follows the content, so KiCad's PCM sees a changed library as an update+    content = pcm_content_sha(stage)+    version, why = version(content) if callable(version) else (version, "given")+    meta["versions"][0]["version"] = version+    check["pcm"] = {"version": version, "contentSha256": content, "why": why}     json.dump(meta, open(os.path.join(stage, "metadata.json"), "w"), indent=1)     out = os.path.join(work, "pcm", f"{lib}-pcm-{version}.zip"); os.makedirs(os.path.dirname(out), exist_ok=True)     size = 0@@ -993,7 +1070,8 @@ def main():                           "identifier": None, "repositoryUrl": None, "zip": None, "parts": len(cfg["parts"]), "counts": {}, "libraryCheck": {}, "localOnlyLeftOut": left_out,                           "adds": adds, "projectLibParts": [], "readme": os.path.join(work, "README.md"), "commit": None}))         return-    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, pkg_parts, report)+    previous = pcm_previous(cfg, folder, work, pkgid)+    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, lambda c: pcm_version(previous, c, version), cfg["title"], cfg["brief"], page_url, work, pkg_parts, report)     counts, gaps = completeness(pkg_parts, report, check["paths"]["symbols"], check["paths"]["footprints"], check["paths"]["models"],                                 (check.get("pageModels") or {}).get("notBound"))     if gaps:@@ -1010,19 +1088,20 @@ def main():             "maintainer": {"name": owner, "contact": {"web": page_url}},             "packages": {"url": f"{files_url}/pcm/packages.json", "sha256": sha(pj), "update_timestamp": now, "update_time_utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(now))}}     rp = os.path.join(work, "pcm", "repository.json"); json.dump(repo, open(rp, "w"), indent=1)+    json.dump(dict(check["pcm"], identifier=pkgid, zip=zname), open(os.path.join(work, "pcm", "build.json"), "w"), indent=1)     repo_url = f"{files_url}/pcm/repository.json"     open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, repo_url))     pushed = None     make_page()     if cfg.get("push"):-        files = [f"pcm/{zname}", "pcm/packages.json", "pcm/repository.json"]+        files = [f"pcm/{zname}", "pcm/packages.json", "pcm/repository.json", "pcm/build.json"]         if readme_wanted(cfg, folder, created): files.insert(0, "README.md")         pushed = push_files(cfg, work, folder, files, "Revision 1: the chosen parts, the component table and the KiCad PCM repository (adom-aiflow)")     page_finished()     print(json.dumps({"ok": True, "kind": kind, "localOnly": local, "localOnlyLeftOut": left_out, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,                       "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check,                       "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],-                      "pageModels": check.get("pageModels"),+                      "pageModels": check.get("pageModels"), "pcmVersion": check["pcm"]["version"], "pcmVersionWhy": check["pcm"]["why"],                       "exportReport": payload(report, "report"),                       "readme": os.path.join(work, "README.md"), "commit": pushed}))