← Commit history

Publish 1.0.145

John Lauer ·a1ca5a043b ·1mo ago ·parent 134c93f
4 files changed +311−23
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "kicad-bridge",-  "version": "1.0.144",+  "version": "1.0.145",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the KiCad bridge, and how to contribute a fix back (kicad-bridge-contributing). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+36−5
@@ -4,7 +4,7 @@   "slug": "kicad-bridge",   "title": "KiCad - the KiCad Bridge",   "brief": "Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",-  "version": "1.0.111",+  "version": "1.0.145",   "tags": [     "kicad",     "pcb",@@ -53,7 +53,7 @@     "install_hint": "",     "version_cmd": ""   },-  "readme": "# Adom Desktop \u2014 KiCad Bridge\n\nA **reverse bridge** that lets Adom Desktop (AD) \u2014 and the AI driving it \u2014 control the user's own\nKiCad on Windows: open and drive every editor, install symbol/footprint libraries, place parts, run\nDRC/ERC, export manufacturing files, and screenshot any window back to the AI.\n\nKiCad is the user's **host app**. The bridge *detects* an existing KiCad and, if none is present,\n*installs* one for them \u2014 it never asks the user to download or click through anything by hand.\n\n- **Page / docs:** https://wiki.adom.inc/adom/adom-desktop-kicad-bridge\n- **Bridge SDK:** https://wiki.adom.inc/adom/adom-desktop-bridges\n- **Verb namespace:** `kicad_*` &nbsp;\u00b7&nbsp; **Status verb:** `kicad_bridge_status` &nbsp;\u00b7&nbsp; **Health:** `GET /status`\n\n> Every screenshot in this README was captured on **ADOMBASELINE**, a stock Hyper-V VM with **no\n> GPU**, driven end-to-end through the bridge \u2014 install \u2192 libraries \u2192 editors \u2192 2D \u2192 3D. If it\n> renders there, it renders on a real laptop.\n\n---\n\n## Demo \u2014 narrated live editor tour (real screen recording)\n\n[![KiCad bridge live demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo-poster.png)](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)\n\n**\u25b6 [Watch the demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)** (48s, \ud83d\udd0a narrated) \u2014 the bridge driving real KiCad on a Windows VM: **select** a footprint and **zoom** the copper in the PCB editor, **rotate** the assembled board in 3D, then tour the schematic, symbol, and footprint editors. Genuine screen recording of the live windows (zoom / select / 3D rotation), narrated section by section \u2014 not animated stills.\n\n\n---\n\n## The demo verb \u2014 `kicad_demo`\n\nOne verb that shows the whole bridge off, built for the AI that demos Adom Desktop\nduring Hydrogen Desktop's install. Six beats, in the order a hardware person thinks:\n\n| Beat | Shows |\n|---|---|\n| 1 | the **schematic symbol** in the user's own Symbol Editor |\n| 2 | the **footprint** for that same part |\n| 3 | that part in **3D** |\n| 4 | a **schematic** using it (U1 + two 10k resistors) |\n| 5 | the **2D board layout** |\n| 6 | the finished **board in 3D** |\n\n```bash\nadom-desktop kicad_demo '{}'                      # start: prepares, warms KiCad, opens beat 1\nadom-desktop kicad_demo '{\"step\":\"footprint\"}'    # ...then follow data.nextCall each time\nadom-desktop kicad_demo '{\"all\":true}'            # bulk (non-interactive; slower than one request budget)\nadom-desktop kicad_close '{\"force\":true}'         # tear down\n```\n\n**It narrates itself.** Every beat returns `say` (a line to speak), `pointOut`\n(what's actually on screen), `window` (match it in `kicad_screenshot_all`) and\n`nextCall`. One beat per call is the default precisely so the AI can talk, screenshot,\nthen move on.\n\n**It ships nothing.** The two demo parts, the schematic and the board are all\ngenerated in code, and the 3D bodies come from KiCad's *own* bundled\n`3dmodels/*.3dshapes` \u2014 so the runtime zip stays small and the 3D views are real.\nParts install into the user's real `Adom` library; the project lands in\n`Documents/adom-kicad-demo`.\n\n**No KiCad? It offers to install it.** `kicad_demo` returns an offer instead of an\nerror; `kicad_demo '{\"installKiCad\": true}'` silently installs the official build\n(per-user, no UAC) and then runs the tour. Watching the AI install your EDA tool is\nitself a good demo beat.\n\n**It never takes your screen.** Every window opens in the background and all input is\nwindow-targeted (UIA / `PostMessage`), so you can keep working while it runs.\n\n---\n\n## How it fits together\n\nThe bridge is a `spawn.kind: python` process AD launches on the user's machine (`entrypoint:\nserver.py`, `port: 0` \u2014 AD picks the port and passes `ADOM_BIND_HOST`). It speaks HTTP\n(`POST /command`, `GET /status`) and reaches KiCad through **four control surfaces**, picking the\nlightest one that can do the job.\n\n![Control surfaces](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/control-surfaces.svg)\n\n| # | Surface | Used for | Where |\n|---|---------|----------|-------|\n| 1 | **kicad-cli** (subprocess) | headless DRC/ERC, gerber/pdf/svg/step/bom export, format upgrade | `handlers/export.py`, `run_drc`, `run_erc`, `lint_*` |\n| 2 | **KiCad IPC (kipy)** | board/schematic introspection, confirmed footprint placement (KiCad 9+) | `handlers/place_footprint.py` |\n| 3 | **Embedded Python** | JSON-RPC *inside* each KiCad process, dispatched on its wx UI thread | `plugin_payload/adom_bridge.py`, `handlers/bridge_client.py` |\n| 4 | **Win32 / UIA / SendKeys** | open editors, screenshots, clicks/keys, dialog handling, window management | `handlers/kicad_ui.py`, `handlers/close_windows.py` |\n\n---\n\n## The window tour\n\nEverything below was opened and captured on the GPU-less VM. Sample data is the **RP2040 breakout**\ngenerated by `tour-pack-rp2040/` (an `AdomRP2040` symbol + `QFN-56_AdomRP2040` footprint + a board).\n\n### Schematic editor \u2014 `kicad_open_schematic`\n\nThe RP2040 breakout: the MCU (U1), USB-C, a 12 MHz crystal, decoupling, and mounting pins.\n\n![Schematic editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/schematic-editor.png)\n\n### Symbol editor \u2014 `kicad_open_symbol_editor`\n\nOpens straight to a symbol (`kicad_install_symbol` puts it in a user library first).\n\n![Symbol editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/symbol-editor.png)\n\n### Footprint editor \u2014 `kicad_open_footprint_editor`\n\n`{\"footprintName\":\"QFN-56_AdomRP2040\",\"library\":\"AdomRP2040\"}` loads the part directly \u2014 56 pins +\nthermal pad, 7\u00d77 mm, 0.4 mm pitch, courtyard and silkscreen.\n\n![Footprint editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/footprint-editor.png)\n\n### PCB editor (2D) \u2014 `kicad_open_board`\n\nThe routed breakout \u2014 copper, silkscreen, the QFN-56 land pattern, mounting holes.\n\n![PCB editor 2D](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/pcb-editor-2d.png)\n\n### 3D viewer \u2014 `kicad_open_3d_viewer {\"editor\":\"pcb\"}`\n\nThe full board in 3D \u2014 board body, the USB-C connector's 3D model, the QFN chip body, SMD parts,\nplated through-holes. **Rendered on the CPU via the software-OpenGL fallback (below); reload 1.7 s.**\n\n![3D viewer](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/3d-viewer.png)\n\n---\n\n## Install & upgrade \u2014 zero manual steps\n\nThe bridge never tells the user to go download KiCad. `kicad_upgrade` fetches the official installer\nand runs it silently, picking the scope automatically:\n\n- **Elevated AD** \u2192 `/allusers /S` (system-wide, `%ProgramFiles%\\KiCad`).\n- **Non-elevated AD** \u2192 `/currentuser /S` (`%LocalAppData%\\Programs\\KiCad`, **no UAC prompt**).\n\n`kicad_readiness` reports whether KiCad is installed and ready without side effects; the AI routes on\nit before offering to install. Hard-won install details (all handled for you):\n\n- KiCad 10's NsisMultiUser installer **requires** a scope flag \u2014 bare `/S` errors `rc=666660`.\n- AD's portable Python has **no CA bundle**; the bridge ships `certs/cacert.pem` and uses it for TLS.\n- Downloads are size-checked against `Content-Length` (a truncated installer otherwise fails at NSIS).\n- `%APPDATA%/kicad/<ver>/` lib tables don't exist until first launch \u2014 the bridge **bootstraps** them\n  so `install_library`/`install_footprint` work on a never-opened KiCad.\n- `kicad_upgrade {\"diagnoseOnly\":true}` reports token-elevation type + `EnableLUA` without installing.\n\n---\n\n## Dialogs & error handling \u2014 the bridge clears the pointless ones\n\nKiCad throws modal dialogs that stall automation. The bridge **scans every window owned by a running\nKiCad process** (by PID \u2014 reliable, unlike title matching), auto-expires the benign ones, screenshots\nwhat it dismissed, and returns a hint so the AI can decide what (if anything) to tell the user.\n\nAuto-expired benign dialogs include:\n\n- **\"Could not use OpenGL / falling back to software rendering\"** (GPU-less hosts).\n- **\"Welcome to KiCad \u2014 starting for the first time\"** first-run wizard (dismissing accepts defaults).\n- **\"This file was created by an older version of KiCad\"** conversion notice.\n\n![First-run wizard, auto-dismissed](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/dialog-firstrun-wizard.png)\n\nVerbs:\n\n- `kicad_window_info` \u2014 lists windows and **self-heals** (auto-expires benign dialogs) by default.\n- `kicad_dismiss_dialogs {\"all\":true}` \u2014 clear everything blocking; `{\"screenshot\":true}` returns\n  images of each; `{\"forceSoftwareCanvas\":true}` persists Cairo canvas; `{\"debug\":true}` dumps the\n  raw PID-based scan.\n- `kicad_screenshot_all` \u2014 one call returns **every** open KiCad window, so the AI can spot an error\n  dialog it didn't expect and read the message.\n\n---\n\n## Software-OpenGL fallback (last resort)\n\n`kicad_enable_software_opengl` deploys Mesa's `llvmpipe` (a CPU OpenGL rasterizer) into KiCad's `bin`\nso a box with **no usable GPU** \u2014 Hyper-V, RDP, headless CI \u2014 can still render the editors and the 3D\nviewer. This is how every 3D shot above exists.\n\n> \u26a0\ufe0f It renders on the CPU and is **slow**. It is a worst-case fallback only \u2014 a real GPU (or GPU-P /\n> DDA passthrough) is vastly better. The bridge keeps it so a GPU-less box isn't a dead end; it is\n> never suggested proactively.\n\n---\n\n## Verb reference\n\nAll 41 verbs (prefix `kicad_`). Call `kicad_describe` for the live catalog with per-verb hints,\nrelated verbs, and pitfalls.\n\n**Windows & UI** \u2014 `open_board`, `open_schematic`, `open_symbol_editor`, `open_footprint_editor`,\n`open_3d_viewer`, `open_editors`, `close_symbol_editor`, `close_footprint_editor`, `close_3d_viewer`,\n`close`, `window_info`, `dismiss_dialogs`, `screenshot_all`, `send_key`, `click`, `fix_keyboard`\n\n**Libraries & parts** \u2014 `install_library`, `install_symbol`, `install_footprint`, `install_plugin`,\n`place_footprint`, `adom_library_status`\n\n**Checks & export** \u2014 `run_drc`, `run_erc`, `lint_board`, `lint_schematic`, `lint_library`,\n`format_upgrade`, `export_gerber`, `export_pdf`, `export_svg`, `export_step`, `export_bom_csv`\n\n**Detect, install & meta** \u2014 `list_versions`, `readiness`, `describe`, `enable_software_opengl`,\n`check_for_updates`, `upgrade`, `bridge_status`, `bridge_call`\n\n---\n\n## Running / dependencies\n\nAD provisions everything; there is nothing to install by hand.\n\n- **Python** \u2265 3.11 (AD provisions it) \u2014 stdlib only, plus the bundled `certs/cacert.pem`.\n- **KiCad** \u2265 7.0 (host app; `kicad_upgrade` installs it if absent).\n- **OS:** Windows (macOS/Linux detection stubs exist; the GUI surfaces are Windows-first).\n\nThe bridge binds `ADOM_BIND_HOST` on an AD-assigned port \u2014 never `0.0.0.0`. Auto-updates via\n`updateManifestUrl` in `bridge.json`.\n\n---\n\n*Developer & publish notes live in `dev-skills/` and `publish-skills/` (source-only, never shipped to\na user install).*\n",+  "readme": "# Adom Desktop — KiCad Bridge\n\nA **reverse bridge** that lets Adom Desktop (AD) — and the AI driving it — control the user's own\nKiCad on Windows: open and drive every editor, install symbol/footprint libraries, place parts, run\nDRC/ERC, export manufacturing files, and screenshot any window back to the AI.\n\nKiCad is the user's **host app**. The bridge *detects* an existing KiCad and, if none is present,\n*installs* one for them — it never asks the user to download or click through anything by hand.\n\n- **Page / docs:** https://wiki.adom.inc/adom/adom-desktop-kicad-bridge\n- **Bridge SDK:** https://wiki.adom.inc/adom/adom-desktop-bridges\n- **Verb namespace:** `kicad_*` &nbsp;·&nbsp; **Status verb:** `kicad_bridge_status` &nbsp;·&nbsp; **Health:** `GET /status`\n\n> Every screenshot in this README was captured on **ADOMBASELINE**, a stock Hyper-V VM with **no\n> GPU**, driven end-to-end through the bridge — install → libraries → editors → 2D → 3D. If it\n> renders there, it renders on a real laptop.\n\n---\n\n## Demo — narrated live editor tour (real screen recording)\n\n[![KiCad bridge live demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo-poster.png)](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)\n\n**▶ [Watch the demo](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-bridge-demo.mp4)** (48s, 🔊 narrated) — the bridge driving real KiCad on a Windows VM: **select** a footprint and **zoom** the copper in the PCB editor, **rotate** the assembled board in 3D, then tour the schematic, symbol, and footprint editors. Genuine screen recording of the live windows (zoom / select / 3D rotation), narrated section by section — not animated stills.\n\n\n---\n\n## The demo verb — `kicad_demo`\n\nOne verb that shows the whole bridge off, built for the AI that demos Adom Desktop\nduring Hydrogen Desktop's install. Six beats, in the order a hardware person thinks:\n\n| Beat | Shows |\n|---|---|\n| 1 | the **schematic symbol** in the user's own Symbol Editor |\n| 2 | the **footprint** for that same part |\n| 3 | that part in **3D** |\n| 4 | a **schematic** using it (U1 + two 10k resistors) |\n| 5 | the **2D board layout** |\n| 6 | the finished **board in 3D** |\n\n```bash\nadom-desktop kicad_demo '{}'                      # start: prepares, warms KiCad, opens beat 1\nadom-desktop kicad_demo '{\"step\":\"footprint\"}'    # ...then follow data.nextCall each time\nadom-desktop kicad_demo '{\"all\":true}'            # bulk (non-interactive; slower than one request budget)\nadom-desktop kicad_close '{\"force\":true}'         # tear down\n```\n\n**It narrates itself.** Every beat returns `say` (a line to speak), `pointOut`\n(what's actually on screen), `window` (match it in `kicad_screenshot_all`) and\n`nextCall`. One beat per call is the default precisely so the AI can talk, screenshot,\nthen move on.\n\n**It ships nothing.** The two demo parts, the schematic and the board are all\ngenerated in code, and the 3D bodies come from KiCad's *own* bundled\n`3dmodels/*.3dshapes` — so the runtime zip stays small and the 3D views are real.\nParts install into the user's real `Adom` library; the project lands in\n`Documents/adom-kicad-demo`.\n\n**No KiCad? It offers to install it.** `kicad_demo` returns an offer instead of an\nerror; `kicad_demo '{\"installKiCad\": true}'` silently installs the official build\n(per-user, no UAC) and then runs the tour. Watching the AI install your EDA tool is\nitself a good demo beat.\n\n**It never takes your screen.** Every window opens in the background and all input is\nwindow-targeted (UIA / `PostMessage`), so you can keep working while it runs.\n\n---\n\n## How it fits together\n\nThe bridge is a `spawn.kind: python` process AD launches on the user's machine (`entrypoint:\nserver.py`, `port: 0` — AD picks the port and passes `ADOM_BIND_HOST`). It speaks HTTP\n(`POST /command`, `GET /status`) and reaches KiCad through **four control surfaces**, picking the\nlightest one that can do the job.\n\n![Control surfaces](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/control-surfaces.svg)\n\n| # | Surface | Used for | Where |\n|---|---------|----------|-------|\n| 1 | **kicad-cli** (subprocess) | headless DRC/ERC, gerber/pdf/svg/step/bom export, format upgrade | `handlers/export.py`, `run_drc`, `run_erc`, `lint_*` |\n| 2 | **KiCad IPC (kipy)** | board/schematic introspection, confirmed footprint placement (KiCad 9+) | `handlers/place_footprint.py` |\n| 3 | **Embedded Python** | JSON-RPC *inside* each KiCad process, dispatched on its wx UI thread | `plugin_payload/adom_bridge.py`, `handlers/bridge_client.py` |\n| 4 | **Win32 / UIA / SendKeys** | open editors, screenshots, clicks/keys, dialog handling, window management | `handlers/kicad_ui.py`, `handlers/close_windows.py` |\n\n---\n\n## The window tour\n\nEverything below was opened and captured on the GPU-less VM. Sample data is the **RP2040 breakout**\ngenerated by `tour-pack-rp2040/` (an `AdomRP2040` symbol + `QFN-56_AdomRP2040` footprint + a board).\n\n### Schematic editor — `kicad_open_schematic`\n\nThe RP2040 breakout: the MCU (U1), USB-C, a 12 MHz crystal, decoupling, and mounting pins.\n\n![Schematic editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/schematic-editor.png)\n\n### Symbol editor — `kicad_open_symbol_editor`\n\nOpens straight to a symbol (`kicad_install_symbol` puts it in a user library first).\n\n![Symbol editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/symbol-editor.png)\n\n### Footprint editor — `kicad_open_footprint_editor`\n\n`{\"footprintName\":\"QFN-56_AdomRP2040\",\"library\":\"AdomRP2040\"}` loads the part directly — 56 pins +\nthermal pad, 7×7 mm, 0.4 mm pitch, courtyard and silkscreen.\n\n![Footprint editor](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/footprint-editor.png)\n\n### PCB editor (2D) — `kicad_open_board`\n\nThe routed breakout — copper, silkscreen, the QFN-56 land pattern, mounting holes.\n\n![PCB editor 2D](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/pcb-editor-2d.png)\n\n### 3D viewer — `kicad_open_3d_viewer {\"editor\":\"pcb\"}`\n\nThe full board in 3D — board body, the USB-C connector's 3D model, the QFN chip body, SMD parts,\nplated through-holes. **Rendered on the CPU via the software-OpenGL fallback (below); reload 1.7 s.**\n\n![3D viewer](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/3d-viewer.png)\n\n---\n\n## Install & upgrade — zero manual steps\n\nThe bridge never tells the user to go download KiCad. `kicad_upgrade` fetches the official installer\nand runs it silently, picking the scope automatically:\n\n- **Elevated AD** → `/allusers /S` (system-wide, `%ProgramFiles%\\KiCad`).\n- **Non-elevated AD** → `/currentuser /S` (`%LocalAppData%\\Programs\\KiCad`, **no UAC prompt**).\n\n`kicad_readiness` reports whether KiCad is installed and ready without side effects; the AI routes on\nit before offering to install. Hard-won install details (all handled for you):\n\n- KiCad 10's NsisMultiUser installer **requires** a scope flag — bare `/S` errors `rc=666660`.\n- AD's portable Python has **no CA bundle**; the bridge ships `certs/cacert.pem` and uses it for TLS.\n- Downloads are size-checked against `Content-Length` (a truncated installer otherwise fails at NSIS).\n- `%APPDATA%/kicad/<ver>/` lib tables don't exist until first launch — the bridge **bootstraps** them\n  so `install_library`/`install_footprint` work on a never-opened KiCad.\n- `kicad_upgrade {\"diagnoseOnly\":true}` reports token-elevation type + `EnableLUA` without installing.\n\n---\n\n## Dialogs & error handling — the bridge clears the pointless ones\n\nKiCad throws modal dialogs that stall automation. The bridge **scans every window owned by a running\nKiCad process** (by PID — reliable, unlike title matching), auto-expires the benign ones, screenshots\nwhat it dismissed, and returns a hint so the AI can decide what (if anything) to tell the user.\n\nAuto-expired benign dialogs include:\n\n- **\"Could not use OpenGL / falling back to software rendering\"** (GPU-less hosts).\n- **\"Welcome to KiCad — starting for the first time\"** first-run wizard (dismissing accepts defaults).\n- **\"This file was created by an older version of KiCad\"** conversion notice.\n\n![First-run wizard, auto-dismissed](https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/dialog-firstrun-wizard.png)\n\nVerbs:\n\n- `kicad_window_info` — lists windows and **self-heals** (auto-expires benign dialogs) by default.\n- `kicad_dismiss_dialogs {\"all\":true}` — clear everything blocking; `{\"screenshot\":true}` returns\n  images of each; `{\"forceSoftwareCanvas\":true}` persists Cairo canvas; `{\"debug\":true}` dumps the\n  raw PID-based scan.\n- `kicad_screenshot_all` — one call returns **every** open KiCad window, so the AI can spot an error\n  dialog it didn't expect and read the message.\n\n---\n\n## Software-OpenGL fallback (last resort)\n\n`kicad_enable_software_opengl` deploys Mesa's `llvmpipe` (a CPU OpenGL rasterizer) into KiCad's `bin`\nso a box with **no usable GPU** — Hyper-V, RDP, headless CI — can still render the editors and the 3D\nviewer. This is how every 3D shot above exists.\n\n> ⚠️ It renders on the CPU and is **slow**. It is a worst-case fallback only — a real GPU (or GPU-P /\n> DDA passthrough) is vastly better. The bridge keeps it so a GPU-less box isn't a dead end; it is\n> never suggested proactively.\n\n---\n\n## Verb reference\n\nAll 41 verbs (prefix `kicad_`). Call `kicad_describe` for the live catalog with per-verb hints,\nrelated verbs, and pitfalls.\n\n**Windows & UI** — `open_board`, `open_schematic`, `open_symbol_editor`, `open_footprint_editor`,\n`open_3d_viewer`, `open_editors`, `close_symbol_editor`, `close_footprint_editor`, `close_3d_viewer`,\n`close`, `window_info`, `dismiss_dialogs`, `screenshot_all`, `send_key`, `click`, `fix_keyboard`\n\n**Libraries & parts** — `install_library`, `install_symbol`, `install_footprint`, `install_plugin`,\n`place_footprint`, `adom_library_status`\n\n**Checks & export** — `run_drc`, `run_erc`, `lint_board`, `lint_schematic`, `lint_library`,\n`format_upgrade`, `export_gerber`, `export_pdf`, `export_svg`, `export_step`, `export_bom_csv`\n\n**Detect, install & meta** — `list_versions`, `readiness`, `describe`, `enable_software_opengl`,\n`check_for_updates`, `upgrade`, `bridge_status`, `bridge_call`\n\n---\n\n## Running / dependencies\n\nAD provisions everything; there is nothing to install by hand.\n\n- **Python** ≥ 3.11 (AD provisions it) — stdlib only, plus the bundled `certs/cacert.pem`.\n- **KiCad** ≥ 7.0 (host app; `kicad_upgrade` installs it if absent).\n- **OS:** Windows (macOS/Linux detection stubs exist; the GUI surfaces are Windows-first).\n\nThe bridge binds `ADOM_BIND_HOST` on an AD-assigned port — never `0.0.0.0`. Auto-updates via\n`updateManifestUrl` in `bridge.json`.\n\n---\n\n*Developer & publish notes live in `dev-skills/` and `publish-skills/` (source-only, never shipped to\na user install).*\n",   "author": {     "name": "John Lauer",     "email": "[email protected]"@@ -71,7 +71,7 @@   "skills": [],   "author_name": "John Lauer",   "source_visibility": "public",-  "description": "Your real KiCad, driven by AI in the background: a native Rust bridge (Windows) with 110+ kicad_* verbs for editors, libraries, placement, routing, pours, DRC, exports and screenshots. This page's repo is the full source; fixes come back as wiki PRs (skill kicad-bridge-contributing).",+  "description": "Skills for your container so your AI knows how to drive the KiCad bridge, and how to contribute a fix back (kicad-bridge-contributing). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "dependencies": {     "adom/adom-bridge": "^2.0.3"   },@@ -87,9 +87,15 @@     "uninstall.sh",     "skills/kicad-interaction/SKILL.md",     "skills/kicad-3d-models/SKILL.md",+    "skills/kicad-autorouting/SKILL.md",+    "skills/kicad-place-route-loop/SKILL.md",+    "skills/kicad-copper-pours/SKILL.md",     "skills/kicad-tour/SKILL.md",     "skills/kicad-tour/tour_runner.py",     "skills/kicad-web-control/SKILL.md",+    "skills/kicad-uia/catalog.json",+    "skills/kicad-uia/SKILL.md",+    "skills/kicad-bridge-contributing/SKILL.md",     "skills/kicad-bridge-dev/SKILL.md",     "skills/kicad-bridge-publish/SKILL.md",     "skills/kicad-bridge-hero/SKILL.md",@@ -127,5 +133,30 @@   "binary_name": "adom-bridge-kicad",   "authors": null,   "author_handle": "john",-  "authors_json": null-}+  "authors_json": null,+  "agent_permissions": {+    "allow_bash": [+      "adom-desktop-kicad-bridge"+    ],+    "allow": [+      "adom-desktop-kicad-bridge status, readiness, describe, diagnostics, window_info, state and open_editors are read-only queries against KiCad and the bridge; they change nothing.",+      "adom-desktop-kicad-bridge list_versions, list_footprints, list_symbols, list_design_rules and adom_library_status read library tables and files; they change nothing.",+      "adom-desktop-kicad-bridge plugin_diagnose, verb_times, progress, errors, log_tail, get_settings and check_for_updates read bridge logs and settings; they change nothing.",+      "adom-desktop-kicad-bridge screenshot_all captures KiCad windows to images; it changes nothing in KiCad.",+      "adom-desktop-kicad-bridge extract_netlist, trace_net, find_connections and analyze_connections read the open design; they change nothing.",+      "adom-desktop-kicad-bridge lint_board, lint_schematic, lint_library, run_drc and run_erc run KiCad's own checks and produce reports; they change nothing."+    ],+    "soft_deny": [+      "adom-desktop-kicad-bridge install_library, install_library_bundle, install_symbol, install_footprint, pcm_install and pcm_uninstall write to the user's KiCad libraries.",+      "adom-desktop-kicad-bridge adom_library_heal, set_design_rules, format_upgrade and set_settings rewrite library files, rules or settings.",+      "adom-desktop-kicad-bridge launch, close, close_symbol_editor, close_footprint_editor, close_3d_viewer and dismiss_dialogs start or stop KiCad windows.",+      "adom-desktop-kicad-bridge uninstall, install_plugin, upgrade and enable_software_opengl reconfigure the user's KiCad or the bridge.",+      "adom-desktop-kicad-bridge export_gerber, export_gerbers, export_pdf, export_svg, export_step, export_molecule, export_bom_csv and export_part write files.",+      "adom-desktop-kicad-bridge make_part_project and place_footprint edit or create designs; demo, send_key and click drive KiCad's windows and inject input."+    ],+    "environment": [+      "adom-desktop-kicad-bridge is the KiCad Bridge; every kicad verb also passes Adom Bridge's human-approval gate on the user's desktop (caller identity, per-call reason, Activity Log).",+      "adom-desktop-kicad-bridge container approval is the first of two gates; the desktop gate is the second."+    ]+  }+}\ No newline at end of file
skills/kicad-bridge-test/SKILL.md+27−1
@@ -1,7 +1,7 @@ --- name: kicad-bridge-test user-invocable: false-description: DEVELOPER-only skill (user-invocable:false). The standard end-to-end verb test for the KiCad bridge - one runner that exercises ALL 48 kicad_* verbs against a real Windows box with real sample files (the rp2040 tour pack), the way an actual user's AI drives them. Coverage-enforced: a new verb without a test turns the run red. Run it daily pre-release, after every runtime publish, and after any Adom Bridge (ab) core update. Trigger words - test the kicad bridge, verb test, daily test, test all verbs, regression test kicad, verb coverage, run_verb_tests, pre-release check, are any verbs failing.+description: DEVELOPER-only skill (user-invocable:false). The standard end-to-end verb test for the KiCad bridge - one runner that exercises EVERY kicad_* verb in the live catalog (117 as of 1.0.34) against a real Windows box with real sample files (the rp2040 tour pack), the way an actual user's AI drives them. Coverage-enforced: a new verb without a test turns the run red. Run it daily pre-release, after every runtime publish, and after any Adom Bridge (ab) core update. Trigger words - test the kicad bridge, verb test, daily test, test all verbs, regression test kicad, verb coverage, run_verb_tests, pre-release check, are any verbs failing. ---  # kicad-bridge-test@@ -116,6 +116,32 @@ Manual driving unchanged: run the one command, read `report.md`.   matrix entry or a justified skip; that is the contract.  +## Runner changes, 2026-09-20 (full battery before the 1.0.34 promotion)++- **All 117 live verbs have a real test.** Two stated skips remain: `enable_software_opengl` (writes into KiCad's+  `bin/`) and `install_library_bundle` (the bridge has no verb that unregisters a whole library, so a test+  install would stay on the box).+- **Phase 6.5, the live board.** KiCad is closed, a scratch copy of `demo/placement/esc-g431-unrouted.kicad_pcb`+  is opened alone, and the IPC verbs run in a fixed order with the revision chained from reply to reply:+  routing_state, board_pads, routing_validate, route_net then remove_route, add_zone, zone_state, remove_zone,+  apply_zone_plan (create, exact no-op with `mutated:false`, remove), select_net, select_clear, text_bounds, the+  three silk batch verbs, menu_items, 3d_view, refresh_board_views. The copy is closed unsaved.+- **Edits find their own clean spot.** route_net dry-runs the shortest same-side pad pairs until one passes DRC.+  The silk verbs dry-run a list of spots, because KiCad refuses silk over the edge, the mask or other silk and+  enforces the board's minimum text height (0.8 mm on this fixture). A refusal there is the bridge being right.+- **silk_fields_batch passes on a classified refusal.** KiCad 10.0.x will not write fields over IPC, so+  `native_field_update_unsupported` or `drc_rejected` with nothing changed is the contract.+- **Phase 6.6, offline copper** (add_track, add_via, route) runs on a second copy no editor has open. A route that+  changes layer must come back to the pad's layer before it lands on an SMD pad.+- **Phase 6.7, PCM.** A real install of this page's sample package, then its removal. Skipped, and left alone,+  when the user already has the package.+- set_settings writes `foregroundToastMinutes` back at the value it just read. set_design_rules stages on the+  board copy. rescan_libraries runs without force: a tree refresh that proves a footprint loads.+- `--only` with any live verb runs the whole live phase, and skips the cleanup phase: finish with+  `kicad_uninstall {"testResidue": true}`.+- Results on 1.0.34: ConfRoomROG (KiCad 10.0.5) and arav-rog (KiCad 10.0.3, two monitors), every verb passing,+  zero foreground steals. The only failures on the way were mistakes in the new tests, listed above as rules.+ ## Runner changes, 2026-09-07 (#78, #81, #85)  - **Coverage is complete against the live catalog:** every verb has a matrix entry or a stated
skills/kicad-bridge-test/run_verb_tests.py+247−16
@@ -89,6 +89,8 @@ CHECKS = {                                  and len(r["footprints"]) > 0 and "totalMm" in (r.get("ratsnest") or {}),     "move_dry": lambda r: ok(r) and r.get("dryRun") is True and r.get("mutated") is False                           and "checks" in r and r.get("undoSteps") == 0,+    "taskbar_report": lambda r: ok(r) and r.get("errors") == 0 and ("already" in r or "written" in r),+    "viewer_flags": lambda r: ok(r) and r.get("mutated") is False and "flags" in (r.get("viewer") or {}),     "placement_validated": lambda r: ok(r) and isinstance(r.get("courtyardOverlaps"), list)                                      and "placed" in r and "drc" in r, }@@ -126,6 +128,11 @@ def matrix(w: dict) -> dict:         "pcm_list":          dict(phase=1, args={}, check="pcm_listed", timeout=90,                                   note="checkUpdates defaults on: fetches each registered repository (1.0.2 fields "                                        "updatesAvailable, latestVersion, updateCheck)"),+        "taskbar_repair":    dict(phase=1, args={}, check="taskbar_report",+                                  note="idempotent: writes KiCad's own AUMID onto its standalone shortcuts only when blank"),+        "3d_viewer_settings": dict(phase=1, args={}, check="viewer_flags", note="READ ONLY here: no toggle passed"),+        "set_settings":      dict(phase=1, args={"__special": "set_settings_same"},+                                  note="SAFETY: writes foregroundToastMinutes back at the value get_settings just read"),         "ipc_api":           dict(phase=1, args={}, note="READ ONLY here: no enable arg, so nothing is written"),         "adom_library_heal": dict(phase=4.5, args={"mode": "prune", "dryRun": True},                                   note="SAFETY: dryRun only; after the installs, because a fresh box has no Adom "@@ -169,13 +176,13 @@ def matrix(w: dict) -> dict:         "lint_library":      dict(phase=2, args={"filePath": w["symlib"]}, timeout=120,                                   note="Adom.kicad_sym installed in phase 4a; runs after it"),         "export_gerber":     dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers"}, timeout=220),-        "export_gerbers":    dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers2"}, timeout=220,-                                  note="legacy alias of export_gerber — kept until callers migrate"),         "export_pdf":        dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.pdf"}, timeout=220),         "export_svg":        dict(phase=2, args={"filePath": B, "outputDir": OUT + "/svg"}, timeout=220),         "export_step":       dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.step"}, timeout=320),         "export_bom_csv":    dict(phase=2, args={"filePath": S, "outputPath": OUT + "/bom.csv"}, timeout=200),         "export_molecule":   dict(phase=2, args={"filePath": B, "outputDir": OUT + "/molecule"}, timeout=320),+        "set_design_rules":  dict(phase=2.7, args={"fab": "adom-instapcb", "boardPath": w["board_copy_src"], "runDrc": False},+                                  timeout=200, note="on the COPY of the board (format_upgrade's), staged only, never the fixture"),         "format_upgrade":    dict(phase=2, args={"filePath": w["board_copy"]}, timeout=200,                                   note="runs on a COPY of the board, never the fixture itself"),         # ---- phase 3: GUI lifecycle (background windows on the real box)@@ -240,20 +247,24 @@ def matrix(w: dict) -> dict:                                        note="cleanup pair of pcm_add_repository, after close (KiCad closed, so the "                                             "kicad.json edit sticks): removes the row ONLY when the add wrote it "                                             "(added:true); alreadyRegistered means the user had it and it stays"),-        # Routing verbs need KiCad IPC and a dedicated unrouted board: covered by tests/ and demo/routing/run_demo.py.-        **{verb: dict(skip="Covered by tests/test_routing_ipc.py and demo/routing/run_demo.py; requires a dedicated unrouted IPC board")-           for verb in ("routing_state", "route_net", "remove_route", "routing_validate",-                        "board_pads", "add_track", "add_via", "route")},+        # ---- phase 6.5: the live board (KiCad IPC). One scratch copy of the unrouted ESC board, opened alone,+        # revisions chained from reply to reply, every edit undone or discarded. Run by live_phase() below.+        **{verb: dict(phase=6.5, live=True) for verb in (+            "routing_state", "board_pads", "routing_validate", "route_net", "remove_route",+            "add_zone", "zone_state", "remove_zone", "apply_zone_plan", "select_net", "select_clear",+            "text_bounds", "silk_text_batch", "silk_graphics_batch", "silk_fields_batch",+            "menu_items", "3d_view", "refresh_board_views")},+        # ---- phase 6.6: offline copper editing on a second copy that no editor has open+        **{verb: dict(phase=6.6, live=True) for verb in ("add_track", "add_via", "route")},         "install_library_bundle": dict(skip="needs a Cloud Library bundle zip staged on the box; covered by the "-                                            "install_library/install_symbol/install_footprint trio it wraps"),-        "pcm_install":       dict(skip="writes into %KICAD_3RD_PARTY% and installed_packages.json (a real PCM package "-                                       "download); run by hand per the proof in docs/pcm.md when touching the PCM code"),-        "pcm_uninstall":     dict(skip="pair of pcm_install; same reason"),-        "rescan_libraries":  dict(skip="a full KiCad restart (~150 s, #51); exercised implicitly by the install verbs' "-                                       "staleEditorOpen path"),-        "set_design_rules":  dict(skip="rewrites the board's design rules from the wiki; run by hand on a copy when "-                                       "touching handlers/design_rules.py"),-        "set_settings":      dict(skip="mutates the user's bridge settings (overlay badges); get_settings covers the read side"),+                                            "install_library/install_symbol/install_footprint trio it wraps; "+                                            "and the bridge has no verb that unregisters a whole library, so a test install would stay on the box"),+        # ---- phase 6.7: a REAL install of this page's tiny sample package, then its removal (KiCad closed)+        "pcm_install":       dict(phase=6.7, live=True),+        "pcm_uninstall":     dict(phase=6.7, live=True),+        "rescan_libraries":  dict(phase=3, args={"footprints": ["Resistor_SMD:R_0603_1608Metric"]}, timeout=200,+                                  note="no force: refreshes the open Footprint Editor's tree in place and proves the load; "+                                       "the restart path is proven by hand (wiki #48, #107)"),         # ---- explicit skips (reason required — counted as covered)         "enable_software_opengl": dict(skip="mutates the user's KiCad bin/ (mesa deploy) — only for GPU-less "                                             "VMs; run manually on ADOMBASELINE when testing that path"),@@ -342,6 +353,9 @@ def main():         "symlib": f"C:/Users/{user}/Documents/KiCad/10.0/symbols/Adom.kicad_sym",         "out": base + "/out",         "templib": base + "/AdomVerbTest.kicad_sym",+        "board_copy_src": base + "/copy/rp2040-breakout.kicad_pcb",+        "live": base + "/live/esc-g431-unrouted.kicad_pcb",+        "offline": base + "/offline/esc-g431-unrouted.kicad_pcb",     }     M = matrix(w) @@ -394,6 +408,9 @@ def main():     # board copy for format_upgrade (server-side copy via read+write is overkill; re-send under new name)     ab(a.target, "send_files", {"filePaths": [files[0]], "dest": base + "/copy"}, timeout=120)     w_copy_src = base + "/copy/rp2040-breakout.kicad_pcb"+    live_src = str(REPO / "demo" / "placement" / "esc-g431-unrouted.kicad_pcb")+    ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/live"}, timeout=200)+    ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/offline"}, timeout=200)     # scratch symbol library for install_library (written locally, pushed over)     import tempfile as _tf     tl = Path(_tf.gettempdir()) / "AdomVerbTest.kicad_sym"@@ -491,6 +508,8 @@ def main():         if spec.get("skip"):             results[verb] = {"result": "SKIP", "reason": spec["skip"]}             return+        if spec.get("live"):+            return   # run by live_phase(), in a fixed order with chained revisions         if a.skip_gui and spec.get("phase", 9) >= 3:             results[verb] = {"result": "SKIP", "reason": "--skip-gui"}             return@@ -529,6 +548,13 @@ def main():             first = sorted(fps, key=lambda f: f.get("ref") or "")[0]             args = {"filePath": w["board"], "expectedRevision": st["revision"], "ref": first["ref"],                     "x": first["x"], "y": first["y"], "dryRun": True}+        elif special == "set_settings_same":+            cur = ab(a.target, "kicad_get_settings", {}, timeout=60)+            val = (cur.get("settings") or cur).get("foregroundToastMinutes")+            if val is None:+                results[verb] = {"result": "FAIL", "reason": "get_settings carries no foregroundToastMinutes to write back"}+                return+            args = {"foregroundToastMinutes": val}         elif special == "pcm_remove_repository":             added = (results.get("pcm_add_repository") or {}).get("response") or {}             if added.get("added") is not True:@@ -588,7 +614,7 @@ def main():     PHASE3_ORDER = ["launch", "show_project", "open_board", "open_schematic", "open_symbol_editor",                     "show_schematic", "show_2d_board", "show_symbol", "show_footprint",                     "show_3d_chip", "show_3d_board", "show_library", "state",-                    "open_footprint_editor", "open_3d_viewer", "open_editors",+                    "open_footprint_editor", "rescan_libraries", "open_3d_viewer", "open_editors",                     "window_info", "screenshot_all", "dismiss_dialogs", "send_key",                     "click", "bridge_call"]     for phase in (1, 2, 3, 4, 5, 6):@@ -611,6 +637,211 @@ def main():         if phase == 3 and not a.skip_gui:             time.sleep(2) +    # ------------------------------------------------------------ phase 6.5-6.7: the live board+    def record(verb, r, passed, reason=None):+        results[verb] = {"result": "PASS" if passed else "FAIL", "elapsed": None,+                         "response_head": json.dumps(r)[:400], "response": r}+        if reason:+            results[verb]["reason"] = reason+        print(f"  kicad_{verb:24} {'PASS' if passed else 'FAIL'}{('  (' + reason + ')') if reason else ''}")++    def live_phase():+        import math, re as _re+        L, WHY = w["live"], "daily verb test: live board phase"+        wanted = [v for v, sp in M.items() if sp.get("live")]+        if only and not (only & set(wanted)):+            return+        if a.skip_gui:+            for v in wanted:+                results[v] = {"result": "SKIP", "reason": "--skip-gui"}+            return+        print(f"[6.5] live board: {len(wanted)} verbs")+        ab(a.target, "kicad_close", {"force": True}, timeout=120)+        time.sleep(4)+        o = ab(a.target, "kicad_open_board", {"filePath": L, "reason": WHY}, timeout=200)+        if not ok(o):+            for v in wanted:+                if v not in ("add_track", "add_via", "route", "pcm_install", "pcm_uninstall"):+                    record(v, o, False, "the live board did not open")+        else:+            pcb_hwnd = o.get("hwnd")+            time.sleep(8)+            st = {}+            for _ in range(6):   # the IPC server comes up a few seconds after the window+                st = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)+                if ok(st) and st.get("revision"):+                    break+                time.sleep(5)+            record("routing_state", st, ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)))+            rev = [st.get("revision")]+            pads = st.get("pads") or []+            if isinstance(pads, str):+                pads = json.loads(pads)++            def step(verb, args, check=ok, chain=True, timeout=200):+                r = ab(a.target, f"kicad_{verb}", args, timeout=timeout)+                if chain and r.get("revision"):+                    rev[0] = r["revision"]+                return r++            r = step("board_pads", {"filePath": L}, chain=False)+            record("board_pads", r, ok(r) and len(r.get("pads") or []) > 0)+            r = step("routing_validate", {"filePath": L}, chain=False, timeout=300)+            record("routing_validate", r, ok(r) and "unconnected" in r)++            # route_net: the shortest same-side two-pad hop among the unrouted nets, tried as a dry run first+            remaining = set(st.get("netsRemaining") or [])+            by_net = {}+            for p in pads:+                if p.get("netName") in remaining and p.get("type") == "smd":+                    by_net.setdefault(p["netName"], []).append(p)+            cands = []+            for net, ps in by_net.items():+                for i in range(len(ps)):+                    for k in range(i + 1, len(ps)):+                        if ps[i].get("side") == ps[k].get("side"):+                            cands.append((math.hypot(ps[i]["x"] - ps[k]["x"], ps[i]["y"] - ps[k]["y"]), net, ps[i], ps[k]))+            cands.sort(key=lambda c: c[0])+            routed, last = None, {}+            for dist, net, p1, p2 in cands[:12]:+                args = {"filePath": L, "expectedRevision": rev[0], "net": net, "points": [p1["name"], p2["name"]],+                        "layer": p1["side"], "reason": WHY}+                last = step("route_net", dict(args, dryRun=True), chain=False)+                if ok(last):+                    routed = step("route_net", args)+                    break+            if routed is not None:+                record("route_net", routed, ok(routed) and bool(routed.get("itemIds")))+                ids = routed.get("itemIds") or []+                r = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "reason": WHY})+                record("remove_route", r, ok(r))+            else:+                well_formed = bool(last.get("errorCode")) and last.get("mutated") is not True+                record("route_net", last, well_formed, "no straight pad-to-pad hop passed DRC; the refusal path was exercised")+                results["remove_route"] = {"result": "SKIP", "reason": "route_net committed nothing to remove"}++            # zones: a small GND pour in the corner of the pad field, measured, then removed; then the plan verb+            xs, ys = [p["x"] for p in pads], [p["y"] for p in pads]+            x0, y0 = min(xs), min(ys)+            poly = [[x0, y0], [x0 + 6, y0], [x0 + 6, y0 + 6], [x0, y0 + 6]]+            nets_all = {p.get("netName") for p in pads}+            znet = "GND" if "GND" in nets_all else sorted(n for n in nets_all if n)[0]+            r = step("add_zone", {"filePath": L, "expectedRevision": rev[0], "net": znet, "layers": ["B.Cu"],+                                  "polygon": poly, "priority": 40, "reason": WHY}, timeout=300)+            zids = r.get("itemIds") or r.get("zoneIds") or [z.get("id") for z in (r.get("zones") or []) if isinstance(z, dict)]+            record("add_zone", r, ok(r) and bool(zids))+            z = step("zone_state", {"filePath": L}, timeout=200)+            record("zone_state", z, ok(z) and "filledAreaByLayerMm2" in z)+            if zids:+                r = step("remove_zone", {"filePath": L, "expectedRevision": rev[0], "itemIds": zids, "reason": WHY}, timeout=300)+                record("remove_zone", r, ok(r))+            else:+                results["remove_zone"] = {"result": "SKIP", "reason": "add_zone returned no zone id to remove"}+            plan = {"net": znet, "name": "verbtest-plan", "layers": ["B.Cu"], "polygon": poly}+            r1 = step("apply_zone_plan", {"filePath": L, "expectedRevision": rev[0], "ownedItemIds": [], "zones": [plan], "reason": WHY}, timeout=300)+            owned = r1.get("ownedItemIds") or []+            r2 = step("apply_zone_plan", {"filePath": L, "expectedRevision": rev[0], "ownedItemIds": owned, "zones": [plan], "reason": WHY}, timeout=300)+            r3 = step("apply_zone_plan", {"filePath": L, "expectedRevision": rev[0], "ownedItemIds": owned, "zones": [], "reason": WHY}, timeout=300)+            good = ok(r1) and bool(owned) and ok(r2) and r2.get("mutated") is False and ok(r3)+            record("apply_zone_plan", {"create": r1, "unchanged": r2, "remove": r3}, good,+                   None if good else "create, exact no-op, then remove: one of the three did not hold")++            r = step("select_net", {"filePath": L, "net": znet}, chain=False)+            record("select_net", r, ok(r))+            r = step("select_clear", {"filePath": L}, chain=False)+            record("select_clear", r, ok(r))++            r = step("text_bounds", {"filePath": L, "texts": [{"text": "VERBTEST", "height": 0.8, "align": "left"}]}, chain=False)+            record("text_bounds", r, ok(r) and len(r.get("texts") or []) == 1 and r.get("mutated") is False)+            # A clear spot INSIDE the outline. KiCad's DRC refuses silk over the edge, a pad, the mask or other+            # silk, and enforces the board's own minimum text height, so each candidate is a dry run first.+            x1, y1 = max(xs), max(ys)+            spots = [(100.0, 44.0)] + [(x0 + (x1 - x0) * fx, y0 + (y1 - y0) * fy)+                                       for fy in (0.5, 0.3, 0.7, 0.15, 0.85) for fx in (0.5, 0.3, 0.7, 0.15, 0.85)]++            def silk_round_trip(verb, make):+                first = {}+                for cx, cy in spots:+                    t = step(verb, {"filePath": L, "expectedRevision": rev[0], "dryRun": True, "reason": WHY, "create": [make(cx, cy)]},+                             chain=False, timeout=300)+                    first = first or t+                    if not ok(t):+                        continue+                    c = step(verb, {"filePath": L, "expectedRevision": rev[0], "reason": WHY, "create": [make(cx, cy)]}, timeout=300)+                    cid = (c.get("createdIds") or [None])[0]+                    d = step(verb, {"filePath": L, "expectedRevision": rev[0], "remove": [cid], "reason": WHY}, timeout=300) if cid else {}+                    good = ok(c) and bool(cid) and ok(d) and d.get("deletionVerified") is not False+                    record(verb, {"create": c, "remove": d, "at": [cx, cy]}, good)+                    return+                record(verb, first, False, f"no clean spot among {len(spots)} dry runs on this board")++            silk_round_trip("silk_text_batch", lambda cx, cy: {"text": "VT", "layer": "F.SilkS", "x": cx, "y": cy,+                                                                "height": 0.8, "stroke": 0.12, "align": "left"})+            silk_round_trip("silk_graphics_batch", lambda cx, cy: {"kind": "line", "layer": "F.SilkS",+                                                                    "points": [[cx, cy], [cx + 1.5, cy]], "stroke": 0.15})+            # fields: KiCad 10.0.x refuses the write over IPC; the documented refusal is the contract there+            text = open(live_src, encoding="utf-8", errors="replace").read()+            m = _re.search(r'\(property "Reference" "[^"]*"\s*\(at ([-\d.]+) ([-\d.]+)[^)]*\)\s*(?:\(unlocked \w+\)\s*)?\(layer "F\.SilkS"\)(?:\s*\(hide \w+\))?\s*\(uuid "([^"]+)"\)', text)+            if m:+                r = step("silk_fields_batch", {"filePath": L, "expectedRevision": rev[0], "dryRun": True, "reason": WHY,+                         "update": [{"id": m.group(3), "x": float(m.group(1)), "y": float(m.group(2)), "layer": "F.SilkS", "height": 0.8, "stroke": 0.12}]}, timeout=300)+                fine = ok(r) or r.get("errorCode") in ("native_field_update_unsupported", "drc_rejected")+                record("silk_fields_batch", r, fine, None if ok(r) else f"answered {r.get('errorCode')}: a classified refusal, nothing changed")+            else:+                results["silk_fields_batch"] = {"result": "SKIP", "reason": "no F.SilkS Reference field with a uuid in the fixture"}++            v = ab(a.target, "kicad_open_3d_viewer", {"reason": WHY}, timeout=220)+            time.sleep(4)+            r = step("menu_items", {"hwnd": pcb_hwnd, "contains": "zoom"}, chain=False)+            record("menu_items", r, ok(r) and len(r.get("items") or []) > 0)+            r = step("3d_view", {"action": "rotate_z_cw", "count": 1, "pauseMs": 500, "reason": WHY}, chain=False)+            record("3d_view", r, ok(r))+            r = step("refresh_board_views", {"pcbHwnd": pcb_hwnd, "reason": WHY}, chain=False)+            record("refresh_board_views", r, ok(r) and r.get("refreshRequested") is not False)+            ab(a.target, "kicad_close", {"force": True}, timeout=120)   # discard: the scratch copy is never saved+            time.sleep(4)++        # 6.6 offline copper editing: a copy nobody has open+        print("[6.6] offline copper editing")+        F = w["offline"]+        bp = ab(a.target, "kicad_board_pads", {"filePath": F}, timeout=150)+        ops = [p for p in (bp.get("pads") or []) if p.get("net") or p.get("netName")]+        groups = {}+        for p in ops:+            groups.setdefault(p.get("netName") or p.get("net"), []).append(p)+        pair = next(((n, g[0], g[1]) for n, g in sorted(groups.items()) if len(g) >= 2), None)+        if not pair:+            for v in ("add_track", "add_via", "route"):+                record(v, bp, False, "board_pads listed no net with two pads on the offline copy")+        else:+            net, p1, p2 = pair+            n1, n2 = p1.get("name") or f"{p1.get('ref')}.{p1.get('pad')}", p2.get("name") or f"{p2.get('ref')}.{p2.get('pad')}"+            r = ab(a.target, "kicad_add_track", {"filePath": F, "start": n1, "end": n2, "net": net, "layer": "F.Cu"}, timeout=200)+            record("add_track", r, ok(r))+            r = ab(a.target, "kicad_add_via", {"filePath": F, "at": [p1["x"], p1["y"]], "net": net, "layers": ["F.Cu", "B.Cu"]}, timeout=200)+            record("add_via", r, ok(r))+            # down to B.Cu a third of the way along and back up at two thirds: both layer changes drop a via+            m1 = {"x": p1["x"] + (p2["x"] - p1["x"]) / 3, "y": p1["y"] + (p2["y"] - p1["y"]) / 3, "layer": "B.Cu"}+            m2 = {"x": p1["x"] + (p2["x"] - p1["x"]) * 2 / 3, "y": p1["y"] + (p2["y"] - p1["y"]) * 2 / 3, "layer": "F.Cu"}+            r = ab(a.target, "kicad_route", {"filePath": F, "net": net, "points": [n1, m1, m2, n2]}, timeout=200)+            record("route", r, ok(r))++        # 6.7 PCM: install this page's sample package for real, then take it out again+        print("[6.7] PCM install and uninstall")+        ident = "com.adom.kicad-bridge.sample-lib"+        before = ab(a.target, "kicad_pcm_list", {"checkUpdates": False}, timeout=90)+        had = any((pk.get("identifier") == ident) for pk in (before.get("packages") or []) if isinstance(pk, dict))+        if had:+            for v in ("pcm_install", "pcm_uninstall"):+                results[v] = {"result": "SKIP", "reason": "the user already has the sample package installed; left alone"}+        else:+            r = ab(a.target, "kicad_pcm_install", {"repositoryUrl": PCM_REPO_URL, "identifier": ident}, timeout=200)+            record("pcm_install", r, ok(r))+            r = ab(a.target, "kicad_pcm_uninstall", {"identifier": ident}, timeout=120)+            record("pcm_uninstall", r, ok(r))++    live_phase()+     # phase 7: clean up EVERYTHING the suite created on the box (John 2026-08-14)     if not only and not a.skip_gui:         cr = ab(a.target, "kicad_uninstall", {"testResidue": True}, timeout=120)